Good tests care about what your code does, not how it does it. When you refactor, your tests should still pass. If they don't, you were testing the wrong thing.
// 😱 BAD: Testing implementation details class ShoppingCart { constructor() { this.items = []; this.discountRate = 0; } addItem(item) { this.items.push(item); this._recalculateTotals(); } _recalculateTotals() { // Private method this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0); this.total = this.subtotal * (1 - this.discountRate); } } // BAD TEST - Testing internals test('cart recalculates on add', () => { const cart = new ShoppingCart(); const spy = jest.spyOn(cart, '_recalculateTotals'); cart.addItem({ price: 10 }); expect(spy).toHaveBeenCalledTimes(1); expect(cart.items.length).toBe(1); // Accessing private state expect(cart.subtotal).toBe(10); // Testing internal property }); // Now refactor to use a different internal structure... class ShoppingCart { constructor() { this.itemMap = new Map(); // Changed internal structure this.priceCache = null; // Added caching } addItem(item) { this.itemMap.set(item.id, item); this.priceCache = null; // Invalidate cache } getTotal() { if (!this.priceCache) { // Calculate on demand instead const items = Array.from(this.itemMap.values()); this.priceCache = items.reduce((sum, item) => sum + item.price, 0); } return this.priceCache; } } // ❌ TEST BREAKS! Even though behavior is identical
// ✅ GOOD: Testing behavior/contract test('cart calculates total correctly', () => { const cart = new ShoppingCart(); cart.addItem({ id: 1, name: 'Widget', price: 10 }); cart.addItem({ id: 2, name: 'Gadget', price: 20 }); expect(cart.getTotal()).toBe(30); }); test('cart handles discounts', () => { const cart = new ShoppingCart(); cart.addItem({ id: 1, price: 100 }); cart.applyDiscount(0.2); // 20% off expect(cart.getTotal()).toBe(80); }); test('cart prevents duplicate items', () => { const cart = new ShoppingCart(); const item = { id: 1, name: 'Widget', price: 10 }; cart.addItem(item); cart.addItem(item); // Add same item again expect(cart.getItemCount()).toBe(1); expect(cart.getTotal()).toBe(10); }); // ✅ These tests still pass after refactoring! // They test WHAT the cart does, not HOW it does it
// 😱 BAD: Testing exact call sequences test('process order calls services in order', () => { const emailMock = jest.fn(); const inventoryMock = jest.fn(); const paymentMock = jest.fn(); processOrder(order, { emailMock, inventoryMock, paymentMock }); // Testing specific call order expect(paymentMock).toHaveBeenCalledBefore(inventoryMock); expect(inventoryMock).toHaveBeenCalledBefore(emailMock); // Testing exact call arguments expect(emailMock).toHaveBeenCalledWith( 'order@example.com', 'Order Confirmation', expect.stringContaining('Thank you for your order') ); }); // ✅ GOOD: Test outcomes, not orchestration test('process order completes successfully', async () => { const result = await processOrder(order); expect(result.status).toBe('completed'); expect(result.paymentId).toBeDefined(); expect(result.trackingNumber).toBeDefined(); // Verify side effects happened (not how) const emailSent = await checkEmailQueue(order.customerEmail); expect(emailSent).toBe(true); });
// 😱 BAD: Testing internal state machine test('order state machine transitions', () => { const order = new Order(); expect(order.state).toBe('pending'); order.validate(); expect(order.state).toBe('validated'); order.authorize(); expect(order.state).toBe('authorized'); order.capture(); expect(order.state).toBe('captured'); }); // ✅ GOOD: Test business rules test('cannot ship unvalidated order', () => { const order = new Order({ items: [] }); expect(() => order.ship()).toThrow('Cannot ship invalid order'); }); test('can ship validated paid order', () => { const order = new Order({ items: [{ id: 1, price: 10 }], payment: { status: 'completed' } }); const result = order.ship(); expect(result.trackingNumber).toBeDefined(); expect(result.estimatedDelivery).toBeDefined(); });
// Only test what consumers can access class UserService { async createUser(email, password) { // Public API - test this } _validateEmail(email) { // Private - don't test directly } _hashPassword(password) { // Private - don't test directly } }
// Instead of testing each step... test('complete checkout flow', async () => { const { customerId } = await createCustomer(userData); const { cartId } = await createCart(customerId); await addItemsToCart(cartId, items); const { orderId } = await checkout(cartId, paymentInfo); const order = await getOrder(orderId); expect(order.status).toBe('completed'); expect(order.items).toHaveLength(items.length); });
// Test like a user would use it test('calculator performs operations', () => { const calc = new Calculator(); calc.enter(5); calc.add(3); expect(calc.getResult()).toBe(8); calc.multiply(2); expect(calc.getResult()).toBe(16); calc.clear(); expect(calc.getResult()).toBe(0); });
// Focus on observable outcomes test('user registration', async () => { await registerUser({ email, password }); // Can the user log in? const session = await login(email, password); expect(session).toBeDefined(); // Did they get a welcome email? const emails = await getEmailsFor(email); expect(emails).toContainEqual( expect.objectContaining({ subject: 'Welcome!' }) ); });
Try this experiment:
If your tests fail despite behavior being identical, they're testing implementation, not contract. Good tests should pass as long as the contract is honored.