Everything in a Nest application arrives through a constructor, which means anything can be replaced. That is the whole basis of testing it, and it lets you put the seam anywhere between two classes and the entire system.
Test.createTestingModule
const moduleRef = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile()
guard = moduleRef.get(RolesGuard)That is a two-provider application. No controllers, no database, no HTTP server — and
RolesGuard cannot tell, because it is constructed by the same container that would
construct it in production.
new RolesGuard(new Reflector()) would also work here. The testing module is worth
preferring because it keeps working when the class grows a second dependency, and because it is the
only way to substitute something the class does not receive directly.
Mocking with useValue
The pattern for testing a service is to supply its collaborators as values:
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
{
provide: getDataSourceToken(),
useValue: {
getRepository: () => ({ findOne: async () => storedUser }),
},
},
{ provide: JwtService, useValue: { signAsync: async () => 'signed.jwt.token' } },
],
}).compile()
service = moduleRef.get(AuthService)A mock only needs the methods the code under test calls. AuthService.login uses
dataSource.getRepository(User).findOne(...) and nothing else, so the mock is two
nested functions.
Get the token right
The single most common mistake, and it fails in a way that looks like something else:
// The wrong way. @InjectDataSource() does not ask for the DataSource class — it asks
// for a named token. This override compiles, runs, and is never looked up, so the
// service holds the real connection and the test tries to reach Postgres.
{ provide: DataSource, useValue: fakeDataSource }Use the helper the library provides: getDataSourceToken() for the connection,
getRepositoryToken(User) for a repository. The rule generalises — whenever a dependency
arrives through an @InjectSomething() decorator rather than by its type, find the
matching token helper.
What a unit test is for
The useful test is not one that repeats what an end-to-end test already covers. It is one that pins down something end-to-end cannot reach.
it('gives the same message whether the account is missing, deleted, or the password is wrong', async () => {
const messages: string[] = []
storedUser = null
messages.push(await messageFrom(attempt('anything')))
storedUser = userRow({ deleted: true })
messages.push(await messageFrom(attempt('correct-horse-battery')))
storedUser = userRow({ deleted: false })
messages.push(await messageFrom(attempt('wrong-password')))
expect(messages).toHaveLength(3)
expect(new Set(messages).size).toBe(1)
expect(messages[0]).toBe('That email and password do not match an account.')
})Three failure paths driven one at a time by setting a variable — which is exactly what a real database makes awkward, because putting it into all three states means three fixtures and a lot of setup.
And what is being proved is a property rather than a behaviour: the three messages must be identical. That is a security requirement, and it is invisible to any test that checks one path at a time.
Note what is not mocked: bcrypt. Faking the hash comparison would leave the test asserting that the code calls a function, rather than that a wrong password is rejected. The rule of thumb is to mock what is slow, external or hard to control — not the logic you are testing.
correctHash = await bcrypt.hash('correct-horse-battery', 4)Cost 4, not the application's 12. The cost is encoded in the hash and compare
follows it, so this is the same comparison a hundred times faster. The production cost is a
production decision and has no business slowing a unit test.
Fakes for the pipeline classes
Guards, pipes, interceptors and filters are plain classes, and the only awkward part is
ExecutionContext. Fake the three methods the class actually calls:
function contextFor(roles: string[] | undefined, user: AuthenticatedUser | undefined) {
const handler = () => undefined
if (roles !== undefined) Reflect.defineMetadata(ROLES_KEY, roles, handler)
return {
getHandler: () => handler,
getClass: () => class StubController {},
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext
}Implementing the whole interface would be a lot of code asserting nothing. The cast says "this is
a stub, on purpose" — and unlike any, it still fails to compile if the guard starts
needing something else.
The metadata is attached with Reflect.defineMetadata, exactly as
SetMetadata does it, so the Reflector under test does its real work rather
than a stub's.
A pipe needs even less:
it('turns a whitespace-only field into the empty string, so @Length can reject it', () => {
expect(pipe.transform({ firstName: ' ' }, body)).toEqual({ firstName: '' })
})new TrimPipe() is the entire setup. Anything that fits in a test this cheap belongs
in one.
End-to-end
The other extreme imports the real root module and gets the whole application:
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
app = moduleRef.createNestApplication()Then supertest drives it over HTTP without binding a port:
async function signIn(who: { email: string; password: string }): Promise<string> {
const response = await http.post('/api/v1/auth/login').send(who).expect(200)
return response.body.token
}This is where business rules spanning several classes get tested — the ones that justify the architecture. Accepting a quote declines the others; a contractor cannot bid outside their trades; a project stops accepting bids once someone is hired. Each involves a controller, a guard, a service and a transaction, and only end-to-end proves they cooperate.
The trap that makes a suite pass vacuously
app.useGlobalPipes(
new TrimPipe(),
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
)The same configuration as main.ts, repeated by hand — and it has to be, because
pipes bound with app.useGlobalPipes() live on the application instance rather than in
the module, so importing AppModule does not bring them.
Leave it out and every DTO decorator is inert. Every validation assertion in the suite then passes vacuously: a test suite that proves nothing while looking thorough, and nothing about the output says so.
The globals bound with APP_FILTER and APP_INTERCEPTOR need no such
repetition, because they are providers and travel with the module. That difference is the practical
argument for the token form.
Tests that write to a real database
const PREFIX = `RULETEST-${Date.now().toString().slice(-6)}`Every row this suite creates carries that prefix, so cleanup can find them all:
if (dataSource?.isInitialized) {
await dataSource.query(`DELETE FROM projects WHERE title LIKE $1`, [`${PREFIX}%`])
// ...
}
await app?.close()A leftover project from a failed run shows up in the next run's data and in the browser tests,
and the failure looks like a bug in the application. The optional chaining matters too — if
beforeAll threw, app is undefined, and a cleanup that crashes replaces a
useful failure with a confusing one.
Derived values need care beyond deleting rows. A contractor's rating is cached on their profile, so deleting a test review must also recompute it, or the next run starts from a polluted baseline.
A trap specific to supertest
function quote(token: string, projectId: string, amount = 400) {
return http
.post('/api/v1/quotes')
.set('Authorization', `Bearer ${token}`)
.send({ projectId, amount, estimatedDays: 1, message: 'A test quote.' })
}Deliberately not async. Supertest's request object is thenable and carries
.expect. Marking the helper async wraps it in a plain promise, so
quote(...).expect(201) becomes "expect is not a function" — and because the request
still fires, the failure appears one line later than the mistake.
overrideProvider
The two extremes have a middle: import the real module and replace one thing inside it.
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(MailService)
.useValue({ send: async () => undefined })
.compile()Everything else is real — the same modules, guards, filters and interceptors as production — with one seam where the test needs it. That is the right shape when an end-to-end test would otherwise send email, charge a card, or call an API you do not control.
overrideGuard, overrideInterceptor, overridePipe and
overrideFilter do the same for the pipeline. overrideGuard is the tempting
one and worth resisting by default: stubbing out authentication to make a test simpler removes the
guard from the thing being tested, and a suite that never exercises its own guards is how an
authorization bug ships.
Keeping tests independent
Two habits do most of the work.
Build the module in beforeEach, not beforeAll, for
unit tests. A fresh container per test means no state leaks between them — and it is cheap, because
there is no application to start.
Close what you open. await app.close() in afterAll
shuts down the connection pool. Without it a suite finishes and the process hangs, which in CI is a
timeout rather than an obvious error.
Test doubles that stay honest
The failure mode of mocking is a test that passes while the real thing is broken, and it has one common cause: the mock and the real implementation drifting apart.
{ provide: JwtService, useValue: { signAsync: async () => 'signed.jwt.token' } }That is fine because nothing under test inspects the token. It stops being fine the moment a test asserts something about what was signed — at which point the mock has to be right about a second thing, and nothing checks that it is.
Two ways to keep this honest. Prefer mocks so thin they cannot be wrong, as above. And where the collaborator's behaviour actually matters, use the real one — the reason bcrypt is not mocked in the login tests is that the property being proved is meaningless without it.
Which test to write
Unit tests for anything decidable without a database: pipes, guards, filters, pure functions like the image sniffer, and service logic whose collaborators can be faked in a couple of lines. They run in milliseconds and can drive states a real database makes expensive to reach.
End-to-end for rules that span classes, for anything involving a transaction or a lock, and for the pipeline itself — validation, guard ordering, error shapes.
Where this application draws the line is worth copying. Before the unit tests were added,
npm test found no files at all: nothing could be tested without Docker and a migrated
Postgres. Both suites now exist, one needs nothing, and they answer different questions rather than
the same one twice.
Last: interview questions, drawn from the nineteen lessons before it.