NestJS – Testing

September 2, 20268 min readUpdated 9/5/2026

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.