Tests are hard not because the tool is weak but because the code is not fit for them. A function fused tight to the database, the clock and the network demands a mountain of mocks, and the suite turns into a brittle copy of the implementation. Professional patterns solve this from the other end: they change the code's structure so it is easy to check honestly. A good test often starts not in the test but in the design of what you test.
The base technique is ports and adapters, separating the domain from the outside world through interfaces. The domain does not call the system clock, the repository or the payment gateway directly - it accepts Clock, Repository, Gateway as dependencies behind interfaces. In production you supply real implementations, in the test simple fakes. Then a business rule is checked without any module mocking: the needed boundary is passed through the constructor rather than intercepted at the import level.
Assembling complex objects for a test is helped by the Test Data Builder. Instead of hand-filling an order with two dozen fields in each test, you set up a builder with sensible defaults and in the test specify only the fields that matter for the behavior under check. This removes noise - looking at the test, you see at once which field decides the outcome - and does not fall apart when a new required field is added to the object.
Another pattern is the humble object: minimize logic in the parts that are hard to test. A component, a controller, a queue handler are made thin - they only take input and delegate - and all the substantive logic is moved into pure functions and domain objects checked by a unit test without an environment. The thin shell then needs only a few integration or E2E checks, rather than trying to test everything through it.
A special role is played by contract tests. When an interface has several implementations - an in-memory repository for speed and a PostgreSQL one for production - the very same set of checks is run against both. A function repositoryContract(name, createRepo) describes the expectations of the interface once, and is called for both memory and the database. This way the fake is guaranteed to behave like the real one, and tests on it do not lie about the production storage's behavior.
Contract tests close the fake's main danger - divergence from reality. A fast fake repository is valuable exactly as long as it behaves like the real one; let them diverge and green unit tests start to lie. A shared contract keeps them in sync: if the fake stops matching the interface, what goes red is the contract test, not a bug in production. That is the price of trusting fast tests over a fake.
All these patterns serve one thing - removing the temptation to mock everything. When dependencies are behind interfaces, objects are assembled by a builder, logic is moved into pure functions, and fakes are bound by a contract, an honest test is written naturally and without a heap of substitutions. The antipattern they lead away from is the same one: mock everything, a suite that checks its own mocks and falls apart at any refactoring.
The typical failure is treating a hard test with new mocks instead of fixing the design: dependency injection would have removed the mock entirely. The second is a fake without a contract test: it quietly diverges from the real implementation, and one day production fails where the unit was green. Start with the shape of the code: boundaries behind interfaces, thin shells, builders for data, a contract for fakes - and you will need far fewer mocks.
// Port: the domain depends on an interface, not an implementation
interface Clock { now(): Date }
class Subscription {
constructor(private clock: Clock, private repo: Repository) {}
isActive(id: string) { /* uses this.clock.now() */ }
}
// In the test - a simple fake instead of a module mock
const clock: Clock = { now: () => new Date('2026-07-26T10:00:00Z') }// Contract: one set of checks against both implementations
function repositoryContract(name: string, createRepo: () => UserRepo) {
describe(name, () => {
test('finds a saved user', async () => {
const repo = createRepo()
const saved = await repo.save({ email: 'a@shop.io' })
expect(await repo.byId(saved.id)).toEqual(saved)
})
})
}
repositoryContract('in-memory', () => inMemoryUsers())
repositoryContract('postgres', () => postgresUsers(testDb))