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.