After 96 days of building production systems, I've learned that tests fail not because the code is broken, but because the tests themselves are unreadable. Testing patterns are the quiet infrastructure that keeps your test suite maintainable as it grows from hundreds to thousands of cases. Today I'm sharing four patterns that have made the difference between a test suite that feels like a chore and one that actually guides your design.
Arrange, act, assert — One clear structure per test
Every test should tell a story in three acts. The arrange phase sets up the world. The act phase performs a single action. The assert phase verifies the outcome. When you follow this structure religiously, you train your brain to read tests the same way every time.
I learned this the hard way building a payment processing system. Without this pattern, tests would sprawl across 50 lines with setup hidden in the middle and assertions scattered at the end. A teammate would spend 10 minutes just figuring out what the test was actually checking.
Here's a clean example from a ride-hailing service I worked on:
describe('RideService', () => {
it('should calculate fare correctly for standard ride', () => {
// Arrange
const rideService = new RideService(/* dependencies */);
const pickup = new Location(6.9271, 80.7789);
const dropoff = new Location(6.9369, 80.6355);
const distanceKm = 10;
// Act
const fare = rideService.calculateFare(pickup, dropoff, distanceKm);
// Assert
expect(fare).toBe(450); // Base + distance rate
});
});The structure is obvious. When someone reads this six months later, they immediately understand what's being tested and why. No cognitive overhead.
Test data builders — Readable creation of test objects with sensible defaults
Creating test objects shouldn't require knowing every constructor parameter. Test data builders solve this by providing fluent, readable object creation with sensible defaults.
In an e-commerce platform I built, we had an Order class that required 15+ parameters. Without builders, every test would duplicate the same boilerplate:
// Without builders - painful to read
const order = new Order(
'ORD-123',
new Date('2024-01-15'),
userId,
250.00,
'PENDING',
customerEmail,
null,
shippingAddress,
billingAddress,
/* ... 6 more parameters */
);
// With a builder - clear intent
const order = new OrderBuilder()
.withOrderId('ORD-123')
.withAmount(250.00)
.withStatus('PENDING')
.build();Here's how I implement builders in TypeScript:
class OrderBuilder {
private orderId = 'ORD-DEFAULT';
private amount = 100.00;
private status = 'PENDING';
private customerId = 'CUST-1';
private createdAt = new Date();
private items: OrderItem[] = [];
withOrderId(id: string): OrderBuilder {
this.orderId = id;
return this;
}
withAmount(amt: number): OrderBuilder {
this.amount = amt;
return this;
}
withStatus(status: string): OrderBuilder {
this.status = status;
return this;
}
withItems(items: OrderItem[]): OrderBuilder {
this.items = items;
return this;
}
build(): Order {
return new Order(
this.orderId,
this.customerId,
this.amount,
this.status,
this.createdAt,
this.items
);
}
}
// In tests
const paidOrder = new OrderBuilder()
.withStatus('PAID')
.withAmount(500)
.build();The builder pattern makes test code read like documentation. A new developer sees the test and immediately understands what state the order is in and why it matters for the scenario being tested.
Fakes over mocks — In-memory implementations for repositories and clocks
Mocks are powerful but can make tests brittle. When you mock a repository method, you're testing the mock's behavior, not the real interaction. Fakes—actual in-memory implementations—keep tests readable and resilient.
I discovered this building a trading platform. Tests that relied on mocked database calls would pass locally but fail in CI because the mock and real implementation had drifted. Fakes forced alignment.
Here's an example with an order repository:
// Real interface
interface IOrderRepository {
save(order: Order): Promise;
findById(id: string): Promise;
findByCustomerId(customerId: string): Promise;
}
// Fake implementation - actual logic, no network
class FakeOrderRepository implements IOrderRepository {
private orders = new Map();
async save(order: Order): Promise {
this.orders.set(order.id, order);
}
async findById(id: string): Promise {
return this.orders.get(id) || null;
}
async findByCustomerId(customerId: string): Promise {
return Array.from(this.orders.values())
.filter(o => o.customerId === customerId);
}
}
// Test reads clearly
it('should retrieve all orders for customer', async () => {
// Arrange
const repository = new FakeOrderRepository();
const order1 = new OrderBuilder().withCustomerId('CUST-1').build();
const order2 = new OrderBuilder().withCustomerId('CUST-1').build();
await repository.save(order1);
await repository.save(order2);
// Act
const orders = await repository.findByCustomerId('CUST-1');
// Assert
expect(orders).toHaveLength(2);
});Fakes are especially valuable for time-dependent logic. A FakeClock that you can advance is far clearer than mocking Date:
class FakeClock implements Clock {
private now: Date;
constructor(initialDate: Date) {
this.now = new Date(initialDate);
}
getCurrentTime(): Date {
return this.now;
}
advanceBy(ms: number): void {
this.now = new Date(this.now.getTime() + ms);
}
}
// In tests
it('should mark ride as expired after 24 hours', () => {
const clock = new FakeClock(new Date('2024-01-15T10:00:00Z'));
const rideService = new RideService(clock);
const ride = rideService.createRide(pickup, dropoff);
expect(ride.isExpired()).toBe(false);
clock.advanceBy(24 * 60 * 60 * 1000);
expect(ride.isExpired()).toBe(true);
});Object mother — Named factories like aPaidOrder() for common scenarios
Object mother extends the builder pattern by creating named factory functions for common test scenarios. Instead of new OrderBuilder().withStatus('PAID').build(), you write aPaidOrder(). It's more readable and centralizes knowledge about what makes a valid "paid order".
Working on a SaaS CRM system, we had dozens of subscription states: trial, active, paused, cancelled. Without object mothers, tests would duplicate the logic for setting up each state:
// Object mothers - read like natural language
export function aSubscription(): Subscription {
return new SubscriptionBuilder().build();
}
export function anActiveSubscription(): Subscription {
return new SubscriptionBuilder()
.withStatus('ACTIVE')
.withStartDate(new Date('2024-01-01'))
.build();
}
export function aTrialSubscription(): Subscription {
return new SubscriptionBuilder()
.withStatus('TRIAL')
.withTrialEndsAt(addDays(new Date(), 14))
.build();
}
export function aCancelledSubscription(): Subscription {
return new SubscriptionBuilder()
.withStatus('CANCELLED')
.withCancelledAt(new Date())
.build();
}
// Tests become declarative
it('should allow upgrade from trial to paid', () => {
const subscription = aTrialSubscription();
subscription.upgradeToPaid();
expect(subscription.status).toBe('ACTIVE');
});
it('should prevent operations on cancelled subscriptions', () => {
const subscription = aCancelledSubscription();
expect(() => subscription.extendTrial()).toThrow();
});The beauty here is that all the knowledge about what constitutes a valid trial subscription lives in one place. If requirements change—say, trial now defaults to 30 days instead of 14—you fix it once and all tests reflect the change immediately.
Common mistakes
- Testing implementation details instead of behavior: Don't assert on internal state or mock calls. Assert on outcomes. If a test breaks when you refactor internals, it's too tightly coupled.
- Multiple arrange phases: If you need multiple setup blocks, your test is testing too many things. Split it or simplify the scenario.
- Over-parameterizing builders: Builders with 20 methods are as bad as constructors with 20 parameters. Keep defaults sensible; add methods for variations that matter to tests.
- Fakes that don't behave like the real thing: A fake repository that always succeeds when the real one has validation is worse than a mock. Fakes should enforce the same invariants as production code.
- Object mothers for everything: Don't create
aUserWithOneOrderAndNoPaymentMethod()for a single test. That's premature optimization. Add object mothers when you see the same setup repeated.
Key takeaways
- Structure every test as arrange, act, assert. It trains both you and future readers to navigate tests predictably.
- Use test data builders to create objects with readable, chainable methods and sensible defaults. Constructor parameters fade into the background.
- Prefer fakes over mocks for dependencies like repositories and clocks. They're closer to real behavior and less brittle.
- Extract common test scenarios into object mothers. Function names like
aPaidOrder()document intent better than builder chains. - Remember that test code is read far more often than it's written. Invest in clarity and the investment pays dividends in maintenance burden.






Comments
Loading comments…