Why Your Unit Tests Are Hurting You
A practical guide to moving beyond traditional unit testing toward more effective testing strategies.
Unit tests are considered a best practice. But when taken too far, they can actually make your codebase more brittle and harder to maintain. Here's why, and what to do instead.
The Common Pattern
// The code
class OrderProcessor {
constructor(
private paymentService: PaymentService,
private inventoryService: InventoryService,
private emailService: EmailService
) {}
async processOrder(order: Order): Promise<void> {
await this.paymentService.charge(order.total);
await this.inventoryService.reserve(order.items);
await this.emailService.sendConfirmation(order);
}
}
// The tests
describe('OrderProcessor', () => {
let processor: OrderProcessor;
let mockPayment: jest.Mocked<PaymentService>;
let mockInventory: jest.Mocked<InventoryService>;
let mockEmail: jest.Mocked<EmailService>;
beforeEach(() => {
mockPayment = createMock<PaymentService>();
mockInventory = createMock<InventoryService>();
mockEmail = createMock<EmailService>();
processor = new OrderProcessor(
mockPayment,
mockInventory,
mockEmail
);
});
it('should process order successfully', async () => {
const order = createTestOrder();
await processor.processOrder(order);
expect(mockPayment.charge)
.toHaveBeenCalledWith(order.total);
expect(mockInventory.reserve)
.toHaveBeenCalledWith(order.items);
expect(mockEmail.sendConfirmation)
.toHaveBeenCalledWith(order);
});
});
The Problems
1. Implementation Details Leakage
// ❌ BAD: Tests know too much about internals
it('should update user status', () => {
const user = new User();
user.upgradeSubscription();
// Why do we care about these internal details?
expect(user.status).toBe('premium');
expect(user.lastUpdated).toBeDefined();
expect(user.auditLog.length).toBe(1);
});
2. Mock Explosion
// ❌ BAD: Too many mocks make tests brittle
describe('UserService', () => {
let service: UserService;
let mockRepo: jest.Mocked<UserRepository>;
let mockAuth: jest.Mocked<AuthService>;
let mockEmail: jest.Mocked<EmailService>;
let mockAudit: jest.Mocked<AuditService>;
let mockCache: jest.Mocked<CacheService>;
let mockQueue: jest.Mocked<QueueService>;
beforeEach(() => {
// Setup all mocks...
// One change to constructor = update all tests
});
});
Better Approaches
1. Behavior-Driven Tests
// ✅ BETTER: Test behavior, not implementation
describe('Order Processing', () => {
it('should complete purchase when everything is available', async () => {
const order = createOrder({
items: [{ id: 'item-1', quantity: 1 }],
payment: { type: 'credit', amount: 100 }
});
const result = await orderSystem.placePurchase(order);
expect(result.status).toBe('completed');
expect(result.confirmationNumber).toBeDefined();
});
it('should fail when item is out of stock', async () => {
const order = createOrder({
items: [{ id: 'out-of-stock', quantity: 1 }]
});
await expect(orderSystem.placePurchase(order))
.rejects
.toThrow('Item out of stock');
});
});
2. Sociable Unit Tests
// ✅ BETTER: Test related units together
class OrderSystem {
constructor(
private readonly inventory: Inventory,
private readonly payments: Payments
) {}
// Main public interface
async placePurchase(order: Order): Promise<PurchaseResult> {
const items = await this.inventory.checkAvailability(order.items);
if (!items.allAvailable) {
throw new OutOfStockError(items.unavailable);
}
const payment = await this.payments.process(order.payment);
if (!payment.successful) {
throw new PaymentError(payment.reason);
}
return this.completePurchase(order, payment);
}
}
// Test the whole flow
describe('Order System', () => {
let system: OrderSystem;
let inventory: Inventory;
let payments: Payments;
beforeEach(() => {
// Use real implementations with test database
inventory = new Inventory(testDb);
payments = new Payments(testPaymentProvider);
system = new OrderSystem(inventory, payments);
});
it('completes valid purchase', async () => {
// Setup
await inventory.addStock({ id: 'item-1', quantity: 10 });
// Act
const result = await system.placePurchase({
items: [{ id: 'item-1', quantity: 1 }],
payment: { amount: 100 }
});
// Assert
expect(result.status).toBe('completed');
const stock = await inventory.getStock('item-1');
expect(stock.quantity).toBe(9);
});
});
3. Contract Tests
// ✅ BETTER: Test contracts, not implementations
interface PaymentProvider {
processPayment(amount: number): Promise<PaymentResult>;
}
// Test the contract
function testPaymentProvider(provider: PaymentProvider) {
describe('Payment Provider Contract', () => {
it('processes valid payment', async () => {
const result = await provider.processPayment(100);
expect(result.successful).toBe(true);
expect(result.transactionId).toBeDefined();
});
it('handles invalid amount', async () => {
await expect(provider.processPayment(-100))
.rejects
.toThrow('Invalid amount');
});
});
}
// Test each implementation
describe('Stripe Provider', () => {
testPaymentProvider(new StripeProvider(testConfig));
});
describe('PayPal Provider', () => {
testPaymentProvider(new PayPalProvider(testConfig));
});
Best Practices
1. Test Organization
// ✅ BETTER: Organize by feature
describe('Order Management', () => {
describe('Purchase Flow', () => {
it('completes valid purchase');
it('handles out of stock items');
it('handles payment failures');
});
describe('Refund Flow', () => {
it('processes valid refund');
it('handles partial refunds');
});
});
2. Test Data Builders
// ✅ BETTER: Flexible test data creation
class OrderBuilder {
private order: Partial<Order> = {
items: [],
payment: { type: 'credit', amount: 0 }
};
withItem(item: OrderItem) {
this.order.items.push(item);
this.order.payment.amount += item.price;
return this;
}
withPaymentType(type: PaymentType) {
this.order.payment.type = type;
return this;
}
build(): Order {
return this.order as Order;
}
}
// Usage in tests
const order = new OrderBuilder()
.withItem({ id: 'item-1', price: 100 })
.withPaymentType('paypal')
.build();
What to Test Instead
- Business Rules
describe('Discount Rules', () => {
it('applies bulk discount over 10 items', () => {
const order = new OrderBuilder()
.withItems(11, standardItem)
.build();
const price = priceCalculator.calculate(order);
expect(price.discount).toBe(0.1); // 10% off
});
});
- Edge Cases
describe('Payment Processing', () => {
it('handles currency conversion edge cases', async () => {
const order = new OrderBuilder()
.withAmount(0.1) // Very small amount
.withCurrency('JPY') // No decimal places
.build();
const result = await payments.process(order);
expect(result.successful).toBe(true);
expect(result.convertedAmount).toBe(1); // Minimum JPY
});
});
Conclusion
Instead of traditional unit tests:
- Focus on behavior, not implementation
- Use sociable unit tests for related components
- Write contract tests for interfaces
- Test business rules and edge cases
- Keep tests maintainable and meaningful
Remember: The goal is confident deployments, not 100% coverage.