Anatomy of a Clean Test
I once debugged a test failure for an hour. The test had 12 assertions. When it failed, the message said "expected false to be true." Which of the 12? I had to add console.log and re-run to find out.
A clean test fails in a way that tells you exactly what broke.
Arrange-Act-Assert
Every test has three phases. Name them explicitly.
Arrange — Set up the world. Create test data, mocks, dependencies.
Act — Do the thing. Call the function, trigger the behavior.
Assert — Check the outcome. Verify the result.
it('applies discount when cart exceeds threshold', () => {
// Arrange
const cart = { total: 150, itemCount: 3 };
const discountService = new DiscountService();
// Act
const result = discountService.calculateDiscount(cart);
// Assert
expect(result).toBe(15);
});
When the test fails, you know which phase to inspect. The structure is predictable. Any developer can read it.
When Arrange Gets Long
If your Arrange section is 20 lines, something's wrong. Either:
- The function has too many dependencies (design smell)
- You need a test helper or factory
// Before: noisy arrange
it('creates order with correct totals', () => {
const user = { id: 'u1', email: 'test@example.com', address: { ... } };
const items = [
{ sku: 'A', price: 10, quantity: 2 },
{ sku: 'B', price: 25, quantity: 1 },
];
const payment = { method: 'card', last4: '4242' };
const shipping = { method: 'standard', region: 'US' };
// ... 10 more lines
const order = createOrder(user, items, payment, shipping);
expect(order.total).toBe(45);
});
// After: extract a factory
it('creates order with correct totals', () => {
const order = createTestOrder({ items: [
{ price: 10, quantity: 2 },
{ price: 25, quantity: 1 },
]});
const result = createOrder(order.user, order.items, order.payment, order.shipping);
expect(result.total).toBe(45);
});
One Assertion Per Test (Mostly)
One assertion per test means: one behavior per test. When the test fails, you know which behavior broke.
// Hard to diagnose
it('validates user registration', () => {
const result = validateUser({ email: 'bad', password: 'short' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid email');
expect(result.errors).toContain('Password too short');
});
// Better: one behavior each
it('rejects invalid email format', () => {
const result = validateUser({ email: 'bad', password: 'ValidPass123!' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid email');
});
it('rejects password shorter than 8 characters', () => {
const result = validateUser({ email: 'good@example.com', password: 'short' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Password too short');
});
When to Break the Rule
Multiple assertions on the same logical outcome. If you're verifying one behavior that has multiple observable effects, multiple assertions are fine.
it('returns user with hashed password and no plaintext', () => {
const user = createUser({ email: 'a@b.com', password: 'secret123' });
expect(user.passwordHash).toBeDefined();
expect(user.passwordHash).not.toBe('secret123');
expect(user.password).toBeUndefined();
});
These three assertions verify one thing: "password was hashed and not stored in plaintext."
Setup is expensive. If creating the test context takes 2 seconds, and you're asserting five properties of the same result, one test with five assertions may be pragmatic. Document why.
Test Isolation
Each test must be independent. Run in any order. Run alone. Same result.
The Shared Mutable State Trap
// BAD: shared state
describe('ShoppingCart', () => {
let cart: ShoppingCart;
beforeEach(() => {
cart = new ShoppingCart();
});
it('adds item', () => {
cart.addItem({ id: '1', price: 10 });
expect(cart.itemCount).toBe(1);
});
it('calculates total', () => {
// cart might have state from previous test if order changes!
cart.addItem({ id: '1', price: 10 });
expect(cart.total).toBe(10);
});
});
If tests run in parallel or order changes, cart might have leftover state. Each test should build its own world.
Avoid Test Interdependencies
// BAD: test 2 depends on test 1
it('creates user', () => {
user = await userService.create({ email: 'test@example.com' });
expect(user.id).toBeDefined();
});
it('updates user', () => {
user.name = 'Updated';
await userService.update(user); // Depends on previous test!
expect(user.name).toBe('Updated');
});
// GOOD: each test is self-contained
it('creates user', async () => {
const user = await userService.create({ email: 'test@example.com' });
expect(user.id).toBeDefined();
});
it('updates user', async () => {
const user = await userService.create({ email: 'update@example.com' });
user.name = 'Updated';
await userService.update(user);
const updated = await userService.findById(user.id);
expect(updated.name).toBe('Updated');
});
Setup and Teardown Best Practices
beforeEach vs beforeAll
beforeEach — Runs before every test. Use when each test needs a fresh instance. Default choice.
beforeAll — Runs once for the whole describe. Use only for expensive setup that never changes (e.g., starting a test database). Be careful: mutable state in beforeAll will leak between tests.
Clean Up After Yourself
If you create files, open connections, or modify global state, clean up in afterEach or afterAll.
describe('FileProcessor', () => {
const tempDir = path.join(os.tmpdir(), `test-${Date.now()}`);
beforeAll(() => {
fs.mkdirSync(tempDir, { recursive: true });
});
afterAll(() => {
fs.rmSync(tempDir, { recursive: true });
});
it('processes file', () => {
const filePath = path.join(tempDir, 'input.txt');
fs.writeFileSync(filePath, 'content');
const result = processFile(filePath);
expect(result.lineCount).toBe(1);
});
});
Avoid Setup That Hides What the Test Needs
// BAD: setup obscures what this test cares about
beforeEach(() => {
user = createUser({ email: 'a@b.com', role: 'admin', plan: 'enterprise' });
order = createOrder({ total: 100, status: 'pending' });
payment = createPayment({ method: 'card' });
});
it('allows admin to refund', () => {
// Which of these mattered? Unclear.
refundService.processRefund(user, order, payment);
expect(order.status).toBe('refunded');
});
// GOOD: test shows its requirements
it('allows admin to refund', () => {
const admin = createUser({ role: 'admin' });
const order = createOrder({ status: 'pending' });
refundService.processRefund(admin, order, createPayment());
expect(order.status).toBe('refunded');
});
When the setup is in the test, the test documents what it needs.
Key insight: A clean test has clear Arrange-Act-Assert structure, one logical behavior per test, and complete isolation. Extract factories when Arrange gets long. Use multiple assertions only when they verify one outcome. Never let tests depend on each other.