Integration Tests vs Unit Tests: Finding the Right Balance
Understand the tradeoffs between unit tests and integration tests. Learn the testing pyramid, trophy, and practical strategies for effective test suites.
What you'll learn
- ✓The difference between unit, integration, and end-to-end tests
- ✓The testing pyramid vs the testing trophy
- ✓When unit tests provide the most value
- ✓When integration tests are more valuable
- ✓Building a practical test strategy for your project
Prerequisites
- •Basic testing experience (any framework)
- •Understanding of software architecture (modules, APIs)
- •Familiarity with mocking concepts
The unit-vs-integration debate is one of the oldest arguments in software testing. Some teams write thousands of unit tests with extensive mocking. Others skip unit tests entirely and write integration tests that exercise real databases and APIs. Both extremes have problems. The answer depends on your codebase, your team, and what kind of bugs you are trying to prevent.
Definitions
Unit tests test a single function, method, or class in isolation. Dependencies are mocked or stubbed. They run fast and tell you exactly which piece of code is broken.
Integration tests test how multiple components work together. They use real databases, real file systems, or real HTTP calls between services. They are slower but catch bugs that unit tests miss: configuration errors, serialization problems, query bugs, and contract violations.
End-to-end tests test the entire application from the user’s perspective, typically through a browser or API client. They are the slowest and most fragile but provide the highest confidence that the system works.
The testing pyramid
The traditional testing pyramid says: write many unit tests, fewer integration tests, and even fewer E2E tests.
/ E2E \ Few, slow, high confidence
/----------\
/ Integration\ Some, medium speed
/----------------\
/ Unit Tests \ Many, fast, low confidence per test
/____________________\
The rationale: unit tests are fast and cheap. Run thousands in seconds. When one fails, you know exactly what broke. Integration and E2E tests are slower, more expensive to maintain, and harder to debug when they fail.
The testing trophy
Kent C. Dodds proposed the “testing trophy” as an alternative, arguing that integration tests provide the best return on investment:
/ E2E \
/--------\
/Integration\ <-- Most tests here
/--------------\
/ Unit Tests \
/ Static Types \
/____________________\
The argument: unit tests with heavy mocking test the implementation rather than the behavior. They pass even when the system is broken because the mocks hide real problems. Integration tests catch more real bugs per test written.
When unit tests shine
Unit tests are most valuable when:
1. Complex business logic
# This function has many edge cases worth testing individually
def calculate_shipping(weight, destination, membership_level, promo_code=None):
base_rate = get_base_rate(destination)
weight_surcharge = max(0, (weight - 5) * 0.50)
total = base_rate + weight_surcharge
if membership_level == "premium":
total *= 0.8
elif membership_level == "gold":
total *= 0.9
if promo_code == "FREESHIP":
return 0
if promo_code == "HALFSHIP":
total *= 0.5
return max(total, 2.99) # Minimum shipping fee
This has many code paths. Each combination of weight, destination, membership, and promo code should be tested. Unit tests with parameterization are perfect here.
2. Algorithmic code
def test_binary_search():
assert binary_search([1, 3, 5, 7, 9], 5) == 2
assert binary_search([1, 3, 5, 7, 9], 1) == 0
assert binary_search([1, 3, 5, 7, 9], 9) == 4
assert binary_search([1, 3, 5, 7, 9], 4) == -1
assert binary_search([], 1) == -1
assert binary_search([1], 1) == 0
3. Utility functions and libraries
Pure functions without side effects are ideal for unit tests. No mocking needed.
4. Edge cases and error handling
test('handles empty input', () => {
expect(parseCSV('')).toEqual([]);
});
test('handles malformed rows', () => {
expect(parseCSV('a,b\n1')).toEqual([{ a: '1', b: undefined }]);
});
When integration tests shine
Integration tests are most valuable when:
1. Database interactions
def test_create_and_retrieve_user(db_session):
# Uses a real (test) database
user_service = UserService(db_session)
created = user_service.create(name="Alice", email="alice@test.com")
retrieved = user_service.get_by_id(created.id)
assert retrieved.name == "Alice"
assert retrieved.email == "alice@test.com"
A unit test would mock the database and miss bugs in SQL queries, ORM mappings, and constraint violations.
2. API endpoints
def test_create_user_endpoint(client, db):
response = client.post("/api/users", json={
"name": "Alice",
"email": "alice@test.com"
})
assert response.status_code == 201
assert response.json()["name"] == "Alice"
# Verify it actually persisted
user = db.query(User).filter_by(email="alice@test.com").first()
assert user is not None
3. Multi-component workflows
test('order workflow: create, pay, fulfill', async () => {
const order = await orderService.create({ items: [{ productId: 1, qty: 2 }] });
expect(order.status).toBe('pending');
await paymentService.processPayment(order.id, { method: 'card' });
const updated = await orderService.getById(order.id);
expect(updated.status).toBe('paid');
await fulfillmentService.ship(order.id);
const shipped = await orderService.getById(order.id);
expect(shipped.status).toBe('shipped');
});
4. External service contracts
Testing that your code correctly calls and handles responses from external services (using test instances or contract tests).
Practical strategy: the right mix
Rather than choosing one philosophy, match test types to code characteristics:
| Code type | Best test type | Why |
|---|---|---|
| Pure functions, algorithms | Unit | Fast, many edge cases, no dependencies |
| Business rules with branching | Unit | Many code paths to cover |
| Database queries | Integration | Mock hides real SQL bugs |
| API endpoints | Integration | Tests serialization, validation, auth |
| UI components | Integration (RTL) | Tests user-facing behavior |
| Multi-service workflows | Integration/E2E | Tests system behavior |
| Config and wiring | Integration | ”Does it all connect?” |
Making integration tests fast enough
The main objection to integration tests is speed. These techniques help:
Parallel test execution
# pytest: run tests in parallel
pip install pytest-xdist
pytest -n auto
# Jest: parallel by default
# Vitest: parallel by default
Transaction rollback
Instead of creating and destroying a database for each test, use transactions:
@pytest.fixture
def db_session(database):
connection = database.engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback() # Undo all changes
connection.close()
Each test runs inside a transaction that is rolled back. No data leaks between tests, and no costly database recreation.
In-memory databases
# Use SQLite in-memory for testing instead of PostgreSQL
@pytest.fixture(scope="session")
def engine():
return create_engine("sqlite:///:memory:")
Shared expensive resources
Use session-scoped fixtures for things that are expensive to create but safe to share:
@pytest.fixture(scope="session")
def docker_services():
# Start PostgreSQL and Redis containers once
compose = DockerCompose("./docker-compose.test.yml")
compose.start()
yield compose
compose.stop()
Anti-patterns
1. Testing implementation, not behavior
// Bad: testing which internal methods are called
test('creates user', () => {
const service = new UserService(mockDb);
service.create({ name: 'Alice' });
expect(mockDb.insert).toHaveBeenCalledWith('users', { name: 'Alice' });
});
// Good: testing the observable result
test('creates user', async () => {
const service = new UserService(db);
const user = await service.create({ name: 'Alice' });
expect(user.id).toBeDefined();
expect(user.name).toBe('Alice');
});
2. 100% unit test coverage with everything mocked
High coverage with mocks gives false confidence. The mocks pass, but the real system fails on first use.
3. No tests at the integration level
All unit tests, no integration tests means you never test that the pieces fit together. The pieces might be individually correct but incompatible.
4. Slow, flaky E2E tests as the primary test suite
E2E tests are important but should not be your first line of defense. They are too slow and too flaky for rapid feedback during development.
A balanced test suite in practice
For a typical web API:
- Unit tests (~40%): Business logic, validators, calculators, formatters, utility functions.
- Integration tests (~40%): API endpoints, database operations, service interactions, authentication flows.
- E2E tests (~10%): Critical user journeys (signup, purchase, core workflow).
- Static analysis (~10%): TypeScript, ESLint, type checking.
The exact percentages do not matter. What matters is that each type of test covers what it does best.
Summary
Unit tests and integration tests serve different purposes. Unit tests are best for algorithmic code, complex business rules, and edge cases. Integration tests are best for database interactions, API endpoints, and multi-component workflows. The testing trophy suggests that integration tests offer the best ROI for many applications. In practice, use both: unit test the logic, integration test the wiring, and E2E test the critical paths. Invest in making integration tests fast (parallel execution, transaction rollback, in-memory databases) so speed is not a barrier.
Related articles
- Testing End-to-End Testing with Cypress
Write reliable E2E tests with Cypress — selectors, assertions, network stubbing, custom commands, and CI integration.
- Testing Mocking Strategies for Unit Tests
Learn when and how to mock dependencies in unit tests. Covers stubs, spies, fakes, mock patterns, and how to avoid over-mocking.
- Testing pytest Fixtures: A Complete Guide
Master pytest fixtures for reusable test setup and teardown. Covers scopes, parameterization, factories, autouse, and fixture composition.
- Testing Testing React Components with Testing Library
Learn to test React components the right way with React Testing Library. Covers rendering, queries, user events, async testing, and best practices.