Let us start with terms, because they are easy to mix up here. A test double is a stand-in for a real dependency for the duration of a test. A mock is a kind of double that records how it was called so you can assert on that later. A fake is also a double, but a working one: a simplified yet real implementation - for example, a repository that keeps records in memory instead of a database. A boundary is the point where your code meets the outside world: the database, the network, the file system. The whole argument about backend tests is about which boundaries to replace and which to leave real.
The naive move is tempting for its speed: mock everything past the boundary. Mock the ORM, mock the database driver, mock every outbound call. The test runs in milliseconds, no database is needed, CI does not drag in Docker. It feels like the perfect unit test - isolated and fast. And for part of the logic that is true.
It breaks where the mock starts impersonating the database itself. A mock of the ORM asserts not the behavior of PostgreSQL but your beliefs about it. A unique constraint, a transaction rollback, a cascading delete, index behavior, a migration applied to a real schema - none of that lives in the mock. When you write mockRepo.save.mockResolvedValue(...), you check that your code handles what the mock returned. Whether the real database returns exactly that is a question the mock cannot answer by definition. Asserting the behavior of a real DB from a mock object of the ORM means testing your faith in the database, not the database.
Hence a split that dissolves the argument. Logic comes in two kinds. A business rule - the shape of the public DTO, the ban on leaking a password outward, input validation - does not depend on SQL. A fake is enough for it: an in-memory repository that behaves like storage but lives in memory. The test brings up the application with such a repository, sends a request, and checks the response - fast, honest, no database. Here mocks are in fact few, and that is right.
SQL, transactions, indexes, constraints, and migrations, on the other hand, are checked only by a real test PostgreSQL. A tool like testcontainers brings up a throwaway Postgres instance in Docker for the duration of the test suite; the alternative is a dedicated test database. This is already an integration test: it goes through the real driver to a real schema with migrations applied. Only this way do you learn that the unique index actually catches a duplicate and the transaction actually rolls back as a whole.
The mechanism works for a simple reason: the only authority on a database's behavior is the database. Isolation levels, constraint enforcement, the order migrations apply in, a column data type - these are properties of the DBMS, not of your code. They cannot be reproduced in a mock, because a mock is precisely the absence of the DBMS. A real database in the test moves the conversation about its behavior out of the realm of assumptions and into the realm of facts.
The cost is honest and clear. A real database is slower, needs Docker, and needs careful isolation between tests - a transaction rollback or table truncation, or the tests pollute one another. So divide the work: pure business rules on fakes, the query layer and migrations on a real database. The classic failure is a suite of one-hundred-percent green mock tests that breaks in production on a unique constraint violation, because that constraint was never checked against a real database. There were plenty of mocks and not a single boundary.
test('POST /users returns the public DTO', async () => {
const app = buildApp({ users: new InMemoryUserRepository() })
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { email: 'anna@example.com', password: 'secret123' },
})
expect(response.statusCode).toBe(201)
expect(response.json()).toEqual({
id: expect.any(String),
email: 'anna@example.com',
})
expect(response.json()).not.toHaveProperty('passwordHash')
})