Unit vs Integration vs E2E Tests: Choosing the Right API Test Level
In this tutorial, you will learn about Unit vs Integration vs E2E Tests: Choosing the Right API Test Level. We cover key concepts, practical examples, and best practices to help you master this topic.
API testing operates at three levels: unit tests validate isolated functions, integration tests verify component interactions, and end-to-end tests confirm complete system behavior across all layers.
What You'll Learn
The differences between unit, integration, and E2E tests, when to use each level, pros and cons, code examples for each, test doubles (mocks, stubs, fakes), and how to balance the testing pyramid.
Why It Matters
Using the wrong test level wastes time and money. Too many E2E tests make CI slow; too few integration tests miss real bugs. DodaTech maintains 60% unit, 30% integration, and 10% E2E ratio for optimal feedback speed.
Real-World Use
A developer adds a discount feature. Unit tests validate the calculation logic (2ms each). Integration tests verify the discount API with database (50ms each). One E2E test confirms the full checkout with discount applied (5 seconds).
flowchart LR
subgraph Unit
A["Function\nInput"] --> B["Business\nLogic"]
B --> C["Function\nOutput"]
end
subgraph Integration
D["API\nRequest"] --> E["Route\nHandler"]
E --> F["Database\nQuery"]
F --> G["API\nResponse"]
end
subgraph E2E
H["Browser/\nClient"] --> I["Full App\nStack"]
I --> J["External\nServices"]
J --> I
I --> K["End\nResult"]
end
style A fill:#bbf7d0,stroke:#16a34a
style D fill:#dbeafe,stroke:#2563eb
style H fill:#fef3c7,stroke:#d97706
Unit Test Example
import pytest
# Pure function (no dependencies) - ideal for unit testing
def calculate_discount(price, discount_percent, max_discount=50):
if not isinstance(price, (int, float)) or price < 0:
raise ValueError("Price must be a positive number")
if not 0 <= discount_percent <= 100:
raise ValueError("Discount must be between 0 and 100")
actual_discount = min(discount_percent, max_discount)
return round(price * (1 - actual_discount / 100), 2)
class TestCalculateDiscount:
def test_basic_discount(self):
assert calculate_discount(100, 20) == 80.0
def test_zero_discount(self):
assert calculate_discount(100, 0) == 100.0
def test_max_discount_applied(self):
assert calculate_discount(100, 70) == 50.0 # capped at 50
def test_invalid_price_negative(self):
with pytest.raises(ValueError):
calculate_discount(-10, 20)
def test_invalid_discount_range(self):
with pytest.raises(ValueError):
calculate_discount(100, 150)
# Expected output when running: pytest test_unit.py -v
# test_basic_discount PASSED
# test_zero_discount PASSED
# test_max_discount_applied PASSED
# test_invalid_price_negative PASSED
# test_invalid_discount_range PASSED
Integration Test Example
import pytest
from fastapi.testclient import TestClient
# Integration test with real database
class TestUserAPI:
def test_create_user(self, test_client, test_db):
response = test_client.post("/api/users", json={
"email": "test@example.com",
"name": "Test User",
"password": "SecurePass123!"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert "id" in data
assert "password" not in data # Password not exposed
# Verify user exists in database
user = test_db.execute(
"SELECT * FROM users WHERE email = ?",
("test@example.com",)
).fetchone()
assert user is not None
def test_get_user_not_found(self, test_client):
response = test_client.get("/api/users/99999")
assert response.status_code == 404
assert response.json()["detail"] == "User not found"
# Expected output:
# test_create_user PASSED
# test_get_user_not_found PASSED
E2E Test Example
import requests
# Full end-to-end test across multiple services
class TestCheckoutFlow:
BASE_URL = "https://api.staging.dodatech.com"
def test_complete_checkout_flow(self):
session = requests.Session()
# 1. Register user
user_resp = session.post(f"{self.BASE_URL}/api/auth/register", json={
"email": "e2e-test@example.com",
"password": "TestPass123!"
})
assert user_resp.status_code == 201
token = user_resp.json()["token"]
# 2. Add product to cart
headers = {"Authorization": f"Bearer {token}"}
cart_resp = session.post(f"{self.BASE_URL}/api/cart", json={
"product_id": "prod-123",
"quantity": 1
}, headers=headers)
assert cart_resp.status_code == 200
# 3. Create order
order_resp = session.post(f"{self.BASE_URL}/api/orders", headers=headers)
assert order_resp.status_code == 201
order_id = order_resp.json()["order_id"]
# 4. Process payment
payment_resp = session.post(f"{self.BASE_URL}/api/payments", json={
"order_id": order_id,
"card_token": "tok_visa"
}, headers=headers)
assert payment_resp.status_code == 200
# 5. Verify order status
verify_resp = session.get(
f"{self.BASE_URL}/api/orders/{order_id}", headers=headers
)
assert verify_resp.json()["status"] == "confirmed"
print(f"Complete checkout flow passed for order {order_id}")
# Expected output:
# Complete checkout flow passed for order ORD-12345
Common Mistakes
1. Too Many E2E Tests
E2E tests are slow (5-30 seconds each) and brittle. Only test critical user journeys as E2E. Cover most logic with unit and integration tests.
2. No Integration Tests
Unit tests mock all dependencies, but real bugs occur at integration boundaries (database queries, HTTP calls, Serialization). Always test real component interaction.
3. Using Mocks for Everything in Unit Tests
If you mock the database, HTTP client, and file system, you're testing the mock framework, not your code. Keep unit tests for pure logic; test I/O at integration level.
4. Sharing State Between Tests
Tests that depend on the same database records, file system state, or global variables interfere with each other. Use fresh setup/teardown per test or per module.
5. Ignoring Test Performance
If the test suite takes 30 minutes to run, developers stop running it. Keep unit tests under 10ms each, integration tests under 100ms, and E2E tests under 10 seconds.
Practice Questions
- What is the difference between a unit test and an integration test?
- When would you write an E2E test instead of an integration test?
- How do you decide the test level ratio?
- What is a test double and when should you use it?
Answers:
- A unit test tests a single function/class in isolation with mocked dependencies. An integration test tests real component interaction (API handler with database, service with HTTP client).
- Write E2E when the feature spans multiple services (checkout involves cart, orders, payments, inventory) and you need to verify they work together in a production-like environment.
- Follow the testing pyramid: 60-70% unit (fast, many), 20-30% integration (medium, moderate), 5-10% E2E (slow, few). Adjust based on system complexity and risk areas.
- Test doubles replace real dependencies: mocks (verify interactions), stubs (return fixed values), fakes (lightweight implementations). Use them in unit tests to isolate the code under test.
Challenge: Take a feature (user registration) and write tests at all three levels: a unit test for password validation logic, an integration test for the registration API endpoint with database, and an E2E test that registers via the full stack. Compare execution times.
FAQ
Mini Project
Take a simple CRUD API (users, products) and write a complete test suite: 5 unit tests for validation logic, 3 integration tests for API endpoints with database, and 1 E2E test for a complete user interaction flow. Measure and compare execution times. Document the test level decisions.
What's Next
Postman Testing — write API tests with Postman.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro