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

  1. 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
  });
});
  1. 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.