Backend tests are most often spoiled by one habit - mocking everything in sight. The ORM is stubbed, the HTTP client is stubbed, the file system is stubbed, and in the end the test checks not the application but its own mocks: it stays green even when a real query to the database has long been broken. The rule is simple: the closer to a real boundary a check runs, the more it is worth. A mock is for cheap isolation of rules, a real boundary for what cannot be checked honestly without it.
The main value on the server comes from an integration test of the HTTP layer. The app is assembled by a buildApp factory and a request is sent in memory - via Fastify's app.inject or supertest for Express - without raising a real port. The test sends POST /users with a body, gets 201 and checks the response. This covers the route, validation, serialization and handler at once, closer to what a real client sees than any isolated unit.
The response's public contract deserves separate attention. You check not only that a needed field is present but that nothing extra is: id matches expect.any(String), and passwordHash is absent - not.toHaveProperty('passwordHash'). A leak of an internal field into the DTO is already a security defect, and the integration test catches it at the boundary, where data leaves the system.
The database boundary is kept according to its nature. Business rules - limits, status transitions, calculations - are checked quickly on an in-memory fake repository: they are not about SQL but about logic, and a real database would only slow things here. But SQL, transactions, cascades and migrations cannot be replaced by anything - they are run against a real test PostgreSQL, because that is exactly where the errors live that a mock by definition does not see.
A real database for tests is raised in a container. testcontainers starts a disposable PostgreSQL for the duration of the run, applies the migrations and gives a clean schema - the same as in production, not an approximation of it. This costs more than a mock in time, but it is the only way to check that a query really returns what is needed, that a unique index fires and that a transaction rolls back as a whole.
Between these levels runs a boundary of responsibility. A fake repository answers the question 'does the rule work correctly', an integration test with a real database the question 'does the storage work correctly'. Mixing them is a mistake: a rule checked only through a real database drags its slowness into every test, and storage checked only with a mock is not checked at all.
Asynchrony on the backend obeys the same laws as everywhere: the test must await the work. A database query, an external service call, a write to a queue - all of it is await, and unmocked outgoing calls in an integration test are better caught explicitly, so a stray real request does not leave the machine. An error is checked not only by the response code but by the body with a message clear to the client.
The typical failure is asserting database behavior through a mock ORM: the test claims a user is saved, though the real query never ran and could have failed on a constraint. The second failure is running every business rule through a real database and drowning the suite in seconds of waiting. Keep the proportion: many fast units on rules, a few honest integrations on real boundaries.
test('POST /users creates a user and does not return the hash', async () => {
const app = buildApp({ users: inMemoryUsers() })
const res = await app.inject({
method: 'POST',
url: '/users',
payload: { email: 'a@shop.io', password: 'secret123' },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body).toMatchObject({ id: expect.any(String), email: 'a@shop.io' })
expect(body).not.toHaveProperty('passwordHash')
})// A real PostgreSQL in a container - for SQL, transactions, migrations
const container = await new PostgreSqlContainer('postgres:16').start()
const db = drizzle(container.getConnectionUri())
await migrate(db, { migrationsFolder: './drizzle' })
afterAll(() => container.stop())