Skip to content

Testing NestJS: modules, overrides and a real database

Fake only the edges, keep Postgres real, and give every test a clean database of its own.

Predict first

Before you read: predict

ExamHall's POST /attempts/:id/submit stores each candidate's answer in this Postgres 17 table:

CREATE TABLE submissions (id serial PRIMARY KEY, attempt_id int NOT NULL, answer_text varchar(2000) NOT NULL);

The controller stores the answer with one INSERT … RETURNING id. Its e2e test overrides the Db provider with an in-memory SQLite database built from the same CREATE TABLE, posts a 2,400-character answer with supertest and expects 201. The test passes.

In production, on Postgres, what does that request return?

Pick one

The 30-second version

Build the real app with Test.createTestingModule, override only what you do not own, and drive it over HTTP with supertest. Use real Postgres: SQLite or a faked repository passes code that Postgres rejects, and the client gets a 500. Isolate tests by rolling back on the connection the app uses, or by truncating.

Fake only the edges, keep Postgres real, and give every test a clean database of its own.

How it works

Step 1 of 13

Build the real app. Test.createTestingModule({ imports: [AppModule] }), compile(), then createNestApplication() and init() give the whole app, with no port opened.
In the kitchen
  • she flies her own spars, sail and bridle; only the customer's hand is replaced, by a spring scale on the line that logs every pull → the real app with only the edges overridden; the fake records each call (steps 1, 4)

Analogy: A kite maker's test flights on the beach

Before she sells a new kite, the maker flies it in the sea wind.

On the beach In the test suite
she flies her own spars, sail and bridle; only the customer's hand is replaced, by a spring scale on the line that logs every pull the real app with only the edges overridden; the fake records each call (steps 1, 4)
a sail cut from card to the same pattern flies in the workshop and tells her nothing about the cloth a faked repository (step 5)
the workshop fan's steady air lifts a kite whose spar snaps in the first real gust code that SQLite accepts and Postgres rejects (steps 6–7)
each flight starts from a freshly wound line, the last flight's knots undone a clean database per test (steps 11, 13)
she undoes the knots on the reel in her hand, but a line already tied to a stake stays knotted a rollback that misses another connection's writes (step 12)
two makers flying on one patch of beach tangle each other's lines parallel workers sharing one database (step 13)

In real terms: for each suite, write down every override and why it cannot run in CI; anything you own that touches the database stays real.

Breaks down when: the wind never repeats itself, while a test database can be put back into exactly the same state before every test.

Contrasting case: a pure function, such as a price calculation, needs no app and no database; a unit test covers it.

Examples, run for real

4.1 ExamHall: the suite that faked the repository

EXH-5080: ExamHall's submit suite replaced both AttemptsRepository and GradingQueue with in-memory fakes and passed. In production the repository's INSERT … ON CONFLICT (attempt_id) failed, because the deployed table had no unique index on attempt_id. MR !5380 overrides only the queue, whose client needs a Redis the tests do not start, and lets the real repository run on Postgres:

const jobs: number[] = [];
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideProvider(GradingQueue).useValue({ enqueue: async (job) => { jobs.push(job.attemptId); } })
  .compile();
beforeEach(() => db.query('TRUNCATE submissions RESTART IDENTITY'));
4.1 EXH-5080 AttemptsRepository and GradingQueue both overridden with in-memory fakes: 2 of 2 tests pass
4.1 EXH-5080 the repository's INSERT … ON CONFLICT (attempt_id) on Postgres 17.11, schema as deployed: SQLSTATE 42P10 (no unique or exclusion constraint matches the ON CONFLICT target)
4.1 MR !5380 only GradingQueue overridden, real repository on Postgres, schema as deployed: 0 of 2 tests pass
4.1 MR !5380 after the migration adds UNIQUE (attempt_id): 2 of 2 tests pass · jobs the fake queue recorded 3, each for attempt 7 · rows left after the last test: 1 (answer C)

The fakes accepted an upsert that Postgres refuses (step 5). With only the queue faked, the suite went red on the deployed schema and green once the migration added the index, while the fake recorded every job the endpoint sent (steps 4, 13).

4.2 Media streaming: a search the stand-in cannot run

A video service searches titles with WHERE name ILIKE '%' || $1 || '%', and its test expects ?q=night to find "The Night Manager".

4.2 media streaming search with ILIKE · the test on the SQLite stand-in: fails (500; SQLite says: near "ILIKE": syntax error) · on Postgres 17.11: passes
4.2 media streaming search rewritten with LIKE to suit the stand-in · on SQLite: passes · on Postgres: fails (0 of 1 titles found for "night")

SQLite has no ILIKE, so the stand-in failed a correct query. Rewritten with LIKE to satisfy it, the search passed on SQLite, whose LIKE ignores case, and found nothing on Postgres, whose LIKE does not (step 6).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A video service's playlist suite runs on Postgres 17. POST /playlists inserts the playlist through the app's Db provider, then writes an audit row through AuditLog, a provider that holds a pg.Pool of its own. The suite overrides Db with the test's own connection and wraps each test in a transaction on it:

The test passes.

After the file has run, which of the two rows are left in the database?

Code for this question
beforeAll(async () => {  client = new Client(testDb);  await client.connect();  const moduleRef = await Test.createTestingModule({ imports: [AppModule] })    .overrideProvider(Db)    .useValue({ query: async (sql: string, params?: unknown[]) => (await client.query(sql, params)).rows })    .compile();  app = moduleRef.createNestApplication();  await app.init();});beforeEach(() => client.query('BEGIN'));afterEach(() => client.query('ROLLBACK')); it('creates a playlist', () =>  request(app.getHttpServer()).post('/playlists').send({ name: 'Favourites' }).expect(201));
Pick one

Question 2 of 6

On this lesson's model of two workers' scheduling, a video service's e2e suite has two files, titles.e2e-spec.ts and playlists.e2e-spec.ts, each with one test. Jest runs them in two parallel workers against one test database and sets JEST_WORKER_ID to 1 in one worker and 2 in the other; both workers run inside one CI job, whose GITHUB_RUN_ID and GITHUB_REF_NAME they share. Each file begins its test with beforeEach(() => db.query('TRUNCATE titles, playlists')). In one CI run, the titles test's GET /titles/:id returned 404 right after its POST /titles had returned 201.

Which one change stops the two files from breaking each other's tests, whatever the order?

Pick one

Question 3 of 6

A video service's GET /playlists/:id/summary returns, as it is, the row of the second statement below:

CREATE TABLE playlists (id int PRIMARY KEY, name text NOT NULL);
SELECT p.id, p.name, count(i.title_id) AS items FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id WHERE p.id = $1 GROUP BY p.id, p.name

Its e2e test overrides Db with an in-memory SQLite database, adds three titles to playlist 1 over HTTP, then calls .expect(200, { id: 1, name: 'Favourites', items: 3 }). It passes. The team runs the same test with Db left on a Postgres 17 database, through node-postgres 8.23.0.

What happens?

Pick one

Question 4 of 6

A video service's POST /titles/:id/rating saves a viewer's thumbs up or down through RatingsRepository, counts the view through HistoryRepository, and tells a recommender service through RecommenderClient. The deployed tables and the two repositories' statements:

CREATE TABLE ratings (viewer_id int NOT NULL, title_id int NOT NULL, thumbs_up boolean NOT NULL, UNIQUE (viewer_id, title_id));
CREATE TABLE history (viewer_id int NOT NULL, title_id int NOT NULL, views int NOT NULL DEFAULT 1, UNIQUE (viewer_id, title_id));
-- RatingsRepository.save
INSERT INTO ratings (viewer_id, title_id, thumbs_up) VALUES ($1, $2, $3)
-- HistoryRepository.add
INSERT INTO history (viewer_id, title_id) VALUES ($1, $2) ON CONFLICT (viewer_id, title_id) DO UPDATE SET views = history.views + 1

Its e2e suite replaces all three with in-memory fakes:

Its one test rates title 7 thumbs up, then thumbs down, each expecting 201. It passes.

Which one override, removed so that the real repository runs on Postgres 17 with these tables, turns that test red?

Code for this question
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })  .overrideProvider(RatingsRepository).useValue(fakeRatings)  .overrideProvider(HistoryRepository).useValue(fakeHistory)  .overrideProvider(RecommenderClient).useValue(fakeRecommender)  .compile();
Pick one

Question 5 of 6

A box office's hold service runs its own transaction:

Its e2e suite overrides Db with { query: async (sql, params) => (await client.query(sql, params)).rows }, on the test's own client, and runs on Postgres 17:

beforeEach(() => client.query('BEGIN'));
afterEach(() => client.query('ROLLBACK'));

it('holds seat A1', () =>
  request(app.getHttpServer()).post('/holds').send({ seat: 'A1' }).expect(201));

it('starts with no holds', async () => {
  const { rows } = await client.query('SELECT count(*)::int AS n FROM holds');
  expect(rows[0].n).toBe(0);
});

The two tests run in the order shown.

What does the run report?

Code for this question
async hold(seat: string) {  await this.db.query('BEGIN');  await this.db.query('INSERT INTO holds (seat) VALUES ($1)', [seat]);  await this.db.query('UPDATE seats SET held = true WHERE code = $1', [seat]);  await this.db.query('COMMIT');}
Pick one

Question 6 of 6

A ledger service lists entries:

The test database holds 50 entries, and Postgres treats LIMIT NULL as no limit. A teammate is about to change the default to DefaultValuePipe(200) by mistake.

In a suite built with Test.createTestingModule, where controller = app.get(EntriesController), which test passes today and fails after that change?

Code for this question
@Get()list(@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number) {  return this.db.query('SELECT id, amount_cents FROM entries ORDER BY id LIMIT $1', [limit]);}
Pick one

Go deeper

Pro tips
  1. Write the override list first. Before the testing module, list each provider you will override and the reason it cannot run in CI; a reason like "it's slow" means keep it real. When: starting any NestJS e2e suite (steps 4–5).
  2. Pin the test database to production's image. Run the suite on the same Postgres major version as production. Testcontainers describes itself as "lightweight, throwaway instances of common databases"; a container you start from the same image does the same job. When: setting up CI for a service that runs on Postgres (steps 6–7, 10).
Source
  • NestJS docs, Testing (source content/fundamentals/unit-testing.md, nestjs/docs.nestjs.com branch master): https://docs.nestjs.com/fundamentals/testing · fetched 2026-10-18. "In addition to the compile() method, it uses the createNestApplication() method to instantiate a full Nest runtime environment"; "Hence the construction request(app.getHttpServer())"; "initiates a request to the Nest app that is identical to an actual GET /cats HTTP request coming in over the network"; "For example, you can mock a database service instead of connecting to a live database" (steps 1–2, 4). The docs offer mocking the database as an example; this lesson's point (steps 5–7) is what such a mock cannot catch. Valid as of 2026-10-18.
  • Testcontainers for Node.js docs, About, Global setup and Configuration (sources docs/index.md, docs/quickstart/global-setup.md, docs/configuration.md, testcontainers/testcontainers-node branch main): https://node.testcontainers.org/ · fetched 2026-10-18. "providing lightweight, throwaway instances of common databases"; "But keep in mind that this limits your tests to run sequentially, and you may need to manage the state of the container between tests"; "Custom image for ryuk" (step 10, tip 2). The library starts its own Ryuk container image, which this lesson does not pull; every script starts postgres:17-alpine itself. Valid as of 2026-10-18.
  • PostgreSQL 17 docs, Character Types: https://www.postgresql.org/docs/17/datatype-character.html · fetched 2026-10-18. "An attempt to store a longer string into a column of these types will result in an error, unless the excess characters are all spaces"; "if one explicitly casts a value to character varying(n) or character(n), then an over-length value will be truncated to n characters without raising an error" (step 7). SELECT: https://www.postgresql.org/docs/17/sql-select.html. "If the count expression evaluates to NULL, it is treated as LIMIT ALL, i.e., no limit".
  • SQLite docs, Datatypes In SQLite: https://www.sqlite.org/datatype3.html · fetched 2026-10-18. "SQLite does not impose any length restrictions (other than the large global SQLITE_MAX_LENGTH limit) on the length of strings, BLOBs or numeric values" (step 6).
  • node-pg-types, README (brianc/node-pg-types branch master): https://github.com/brianc/node-pg-types · fetched 2026-10-18. "node-postgres just returns int8 results as strings and leaves the parsing up to you"; "you're tired of receiving results from the COUNT(*) function as strings (because that function returns int8)" (step 8).

Checked against the tools and docs as of 2026-10-18.