TDD Anti-Patterns: Common Mistakes That Undermine Test-Driven Development
TDD promises better design, faster feedback, and confident refactoring. Many teams try it and find the opposite: slow development, brittle tests, and refactoring that breaks dozens of tests at once. Usually the problem isn't TDD itself — it's specific anti-patterns that undermine the practice.
Anti-Pattern 1: Testing Implementation Details
The most common TDD mistake is writing tests that verify how code works rather than what it does.
// Bad: tests internal state
test('stores items in an array', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', price: 9.99 });
expect(cart._items).toHaveLength(1); // accesses private state
expect(cart._items[0].id).toBe('1'); // tests internal structure
});When you refactor the internal storage from an array to a Map for better lookup performance, this test breaks — even though the cart works perfectly.
// Good: tests observable behavior
test('reports count and can retrieve added items', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', price: 9.99 });
expect(cart.itemCount()).toBe(1); // tests public interface
expect(cart.hasItem('1')).toBe(true); // tests behavior
});The rule: if your test would break during a refactor that doesn't change external behavior, the test is testing implementation details.
Anti-Pattern 2: The Liar Test
Tests that always pass, regardless of whether the code is correct.
# Bad: always passes
def test_user_creation():
user = User(name='Alice', email='alice@example.com')
user.save()
# Missing assertion! Test "passes" but verifies nothing
# Bad: assertion that can't fail
def test_price_format():
price = format_price(9.99)
assert isinstance(price, str) # any string passes, '$9.99' or 'broken' both pass# Good: assertion that can actually fail
def test_user_creation():
user = User(name='Alice', email='alice@example.com')
user.save()
saved = User.find_by_email('alice@example.com')
assert saved is not None
assert saved.name == 'Alice'
def test_price_format():
assert format_price(9.99) == '$9.99'
assert format_price(1000.00) == '$1,000.00'
assert format_price(0.01) == '$0.01'A useful check: change the implementation to return wrong values. If the test still passes, it's a liar.
Anti-Pattern 3: Over-Mocking
Mocking every dependency creates tests that pass even when the integration between components is broken.
# Bad: mocks everything
def test_checkout():
with patch('orders.PaymentService') as mock_payment, \
patch('orders.InventoryService') as mock_inventory, \
patch('orders.EmailService') as mock_email, \
patch('orders.OrderRepository') as mock_repo:
mock_payment.charge.return_value = {'status': 'success'}
mock_inventory.reserve.return_value = True
mock_repo.create.return_value = Order(id='123')
result = checkout(user_id='abc', items=[...])
assert result.order_id == '123'
# This test verifies almost nothing about real behaviorIf you change PaymentService.charge() to return {'charged': True} instead of {'status': 'success'}, this test still passes. But the checkout code that checks result['status'] == 'success' is now broken.
# Good: mock at boundaries, use real components internally
def test_checkout(real_db, mock_stripe_api):
# Only mock external HTTP calls
mock_stripe_api.post('/v1/charges').return_value = {
'id': 'ch_test', 'status': 'succeeded', 'amount': 999
}
# Use real repositories, real business logic
result = checkout(user_id='abc', items=[Item(product_id='123', quantity=1)])
assert result.status == 'confirmed'
order = Order.find(result.order_id)
assert order.payment_status == 'paid'Mock at the boundary of your system (external APIs, email servers, payment processors). Don't mock internal components.
Anti-Pattern 4: The Test That Does Too Much
Tests that cover multiple behaviors fail for ambiguous reasons and are hard to debug.
# Bad: tests too many things
def test_order_lifecycle():
order = create_order(user_id='abc', items=[...])
assert order.status == 'pending'
order.pay(card='tok_visa')
assert order.status == 'paid'
assert notification_service.email_sent_to('abc@example.com')
order.ship(tracking_number='1Z123')
assert order.status == 'shipped'
assert notification_service.email_sent_count() == 2
order.deliver()
assert order.status == 'delivered'
# 15 more assertions...When this test fails, which part broke? You have to read the whole test to figure it out.
# Good: one behavior per test
def test_new_order_is_pending():
order = create_order(user_id='abc', items=[...])
assert order.status == 'pending'
def test_payment_confirms_order():
order = create_order(user_id='abc', items=[...])
order.pay(card='tok_visa')
assert order.status == 'paid'
def test_payment_sends_confirmation_email():
order = create_order(user_id='abc', items=[...])
order.pay(card='tok_visa')
assert notification_service.email_sent_to('abc@example.com')When any of these fails, you know immediately what broke.
Anti-Pattern 5: Skipping the Refactor Step
The TDD cycle is red-green-refactor. Many developers do red-green and skip refactor. This creates a codebase that's test-covered but messy.
// After green: works but messy
function validateEmail(email) {
if (email === null || email === undefined || email === '') {
return false;
}
if (email.indexOf('@') === -1) {
return false;
}
if (email.indexOf('.') === -1) {
return false;
}
if (email.split('@').length !== 2) {
return false;
}
return true;
}All tests pass. Now refactor while keeping tests green:
// After refactor: same behavior, cleaner code
function validateEmail(email) {
if (!email) return false;
const [localPart, domain] = email.split('@');
return Boolean(localPart && domain?.includes('.'));
}Tests still pass. The behavior is preserved. The code is better. This is the value of the refactor step.
Anti-Pattern 6: Tests Without Assertions (Test-After)
Writing tests after implementation often results in tests that don't actually drive design — they just document whatever the implementation happens to do.
# Bad: written to match existing implementation, not to specify behavior
def test_user_serialization():
user = User(id=1, name='Alice', created_at=datetime(2024, 1, 1))
result = user.to_dict()
# Just checks that to_dict() returns what it returns
assert result == {
'id': 1,
'name': 'Alice',
'created_at': '2024-01-01T00:00:00',
'updated_at': None, # included because the implementation includes it
'_version': 3 # internal field that leaked into the API
}The test passes, but it's validating implementation accidents: _version is internal, updated_at might not belong in the API. Writing the test first would have caught these.
# Good: written before implementation, specifies intent
def test_user_serialization():
user = User(id=1, name='Alice')
result = user.to_dict()
assert result['id'] == 1
assert result['name'] == 'Alice'
assert 'created_at' in result # ISO 8601 date
assert '_version' not in result # internal field must not leak
assert 'password_hash' not in result # security requirementAnti-Pattern 7: Inverted Test Pyramid
Integration and E2E tests are slow and brittle. Unit tests are fast and reliable. The test pyramid puts many unit tests at the base, some integration tests in the middle, and few E2E tests at the top.
TDD that ignores unit tests inverts this: lots of integration tests, few unit tests.
Signs of inverted pyramid:
- Test suite takes 30+ minutes to run
- Most tests require a real database or server
- You can't run a single component's tests in isolation
- Failures are hard to diagnose because tests are too broad
Fix: For each integration test, ask: "What's the unit test version of this?" Often, the business logic can be extracted and tested in isolation, with one integration test verifying the wiring.
# Unit test: fast, isolated
def test_discount_calculation():
assert apply_discount(100.00, 'SAVE10') == 90.00
# Integration test: verifies the wiring, not the logic
def test_checkout_applies_discount_code(db):
response = client.post('/checkout', json={
'userId': 'abc', 'discountCode': 'SAVE10'
})
assert response.json()['total'] == 90.00One integration test that verifies discounts work end-to-end, backed by many unit tests that cover all discount logic edge cases.
Anti-Pattern 8: No Test for Failure Cases
Tests written after implementation often only cover the happy path — because the developer wrote the happy path first and then wrote tests to match.
TDD naturally generates failure case tests because you write tests before you know exactly how the implementation will handle errors:
// Before implementation, you must consider all cases
test('rejects negative prices', () => {
expect(() => new Product({ price: -5 })).toThrow('Price must be positive');
});
test('rejects prices over 1 million', () => {
expect(() => new Product({ price: 1_500_000 })).toThrow('Price exceeds maximum');
});
test('handles missing price', () => {
expect(() => new Product({})).toThrow('Price is required');
});These tests force you to implement proper validation, not just the happy path.
TDD works when you follow the discipline: test first, minimum green implementation, then refactor. Shortcuts that feel like they save time — writing tests after, mocking everything, skipping refactor — accumulate into test suites that are painful to maintain and don't actually protect you from regressions.
The anti-patterns here are symptoms of the same disease: tests written to verify existing code rather than to specify desired behavior. The cure is the same in every case: write the test first, and let it be uncomfortable until the implementation is right.