RizTech Academy logo
RizTech Academy
Testing the Whole StackLesson 3 of 535 min

Testing the API end to end

These are the expensive tests, and the ones worth every second. They boot the real application against a real PostgreSQL and drive it over HTTP.

Everything they prove is behaviour that only exists when the pieces are joined: a cookie minted by one request and read by another, a transaction that has to roll back as a whole, a conditional UPDATE against a real row lock, a signature over bytes that actually travelled.

None of it survives being mocked. Mocking the database in a test about what the database does is how you get a green suite and an oversold shop.

Boot the application you ship

export async function createTestApp() {
  const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();

  const app = moduleRef.createNestApplication({ rawBody: true });

  app.setGlobalPrefix("api");
  app.use(cookieParser());
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
  app.useGlobalFilters(new ApiExceptionFilter());

  await app.init();

  return { app, prisma: app.get(PrismaService) };
}

Every line mirrors main.ts, and that duplication is the point of the file.

A test that configures a different application proves nothing about the one that ships. Forget cookieParser here and every cart test passes while the real API cannot read a cookie. Forget the ValidationPipe and a test asserting that an unknown field is rejected passes for the wrong reason.

Here Test.createTestingModule does earn its place: you want the real provider graph, exactly as the application builds it.

Note await app.init() and not app.listen(). Supertest can drive the HTTP server without binding a port, which means no port conflicts and nothing left running if a test file crashes.

export function cookiesFrom(response: { headers: Record<string, unknown> }): string {
  const header = response.headers["set-cookie"];
  const list = Array.isArray(header) ? header : header ? [String(header)] : [];
  return list.map((cookie) => String(cookie).split(";")[0]).join("; ");
}

Supertest is not a browser: it will not store a cookie and send it back. You do that.

split(";")[0] keeps only name=value. Everything after the semicolon — Path, HttpOnly, SameSite, Max-Age — is an instruction to a browser and is never sent back by one.

This ten-line helper is what makes the whole session and cart story testable.

Fixtures, or the seed?

/**
 * Read-only, so it needs no fixtures of its own — it runs against the seed.
 */

A real trade, and worth making deliberately.

Own fixtures: independent, order-free, and slow — every test creates and tears down its own data.

The seed: fast and readable, and the suite now depends on the seed's contents. Rename a product there and a test breaks.

The catalogue tests lean on the seed, because the seed exists to populate a catalogue and the coupling is honest. The checkout tests create their own carts and orders, because those are the things under test.

When a test must change shared data, it puts it back:

const originalStock = scarce.stock;
await prisma.variant.update({ where: { id: scarce.id }, data: { stock: 0 } });

try {
  …
} finally {
  await prisma.variant.update({ where: { id: scarce.id }, data: { stock: originalStock } });
}

finally, so a failing assertion does not poison every test after it. A suite where one failure causes five more is a suite nobody can debug.

The tests that only this layer can write

A read must not create a row

it("does not create a cart for a read", async () => {
  const before = await prisma.cart.count();

  await request(app.getHttpServer()).get("/api/cart").expect(200);
  await request(app.getHttpServer()).get("/api/cart").expect(200);

  expect(await prisma.cart.count()).toBe(before);
});

Counting rows before and after. There is no way to express this in a unit test — the whole claim is about what reached the database.

const setCookie = response.headers["set-cookie"];
expect(String(setCookie)).toContain("kirana_cart");
expect(String(setCookie)).toContain("HttpOnly");

A security property asserted from the outside, the way an attacker would see it. Somebody removing that flag in a refactor gets a red test rather than a vulnerability.

A transaction rolls back as a whole

const ordersBefore = await prisma.order.count();
const plentyBefore = await prisma.variant.findFirstOrThrow({ where: { id: plenty.id } });

const response = await request(app.getHttpServer())
  .post("/api/checkout").set("Cookie", cookie).send(address()).expect(409);

expect(response.body.code).toBe("INSUFFICIENT_STOCK");

const plentyAfter = await prisma.variant.findFirstOrThrow({ where: { id: plenty.id } });
expect(plentyAfter.stock).toBe(plentyBefore.stock);
expect(await prisma.order.count()).toBe(ordersBefore);

Two items in the cart: one plentiful, one emptied behind the cart's back the way another customer buying the last of it would.

The assertion is not that checkout failed. It is that the in-stock item was not taken and no order exists — that the failure left nothing behind. That is the claim module 12's transaction makes, and this is the only place it can be checked.

The stored date, not the echoed one

const row = await prisma.order.findFirstOrThrow({
  where: { orderNumber: order.body.orderNumber },
});

expect(row.slotDate?.toISOString().slice(0, 10)).toBe(slot.date);

Read straight from the database, deliberately.

The day-early bug in module 12 returned 201 and echoed the right date back, because the response was formatting the value it had just been sent rather than the value that was stored. A test asserting on the response body would have passed.

When a bug can hide between what the API says and what the database holds, the test must read the database.

Signatures and idempotency

it("rejects a body changed after signing", async () => {
  const original = event();
  const tampered = event({ amountPaise: 100 });

  await request(app.getHttpServer())
    .post("/api/payments/webhook")
    .set("Content-Type", "application/json")
    .set("x-kirana-signature", sign(original))
    .send(tampered)
    .expect(401);
});

.send(string) with an explicit Content-Type, not .send(object). Passing an object lets supertest serialise it, and then you are signing one set of bytes and sending another — which is the very confusion the test exists to rule out.

And the one that matters most:

it("changes nothing when the same webhook arrives again", async () => {
  const before = await request(…).get(`/api/orders/${orderNumber}`)…;
  await request(…).post("/api/payments/webhook")…expect(200);
  const after = await request(…).get(`/api/orders/${orderNumber}`)…;

  expect(after.body.events).toHaveLength(before.body.events.length);
  expect(after.body.status).toBe("PLACED");
});

Gateways retry by design. This asserts that the second delivery is genuinely a no-op — not "returns 200", but changed nothing.

One at a time

// vitest.config.ts
fileParallelism: false,

These tests buy real stock from one shared database. Run two files at once and they compete for the last bag of atta, and the loser fails — which looks exactly like a flaky test and is not one.

The alternatives, when a suite outgrows this:

  • A database per worker. Fastest and most complex: a template database, a fresh copy per worker, dropped afterwards.
  • A transaction per test, rolled back. Elegant, and it cannot test code that uses transactions itself — which here is the code most worth testing.
  • One at a time. Three seconds for 29 tests. Chosen, and correct until it is not.

Pick the simplest thing that is not yet a problem, and know what you would do next.

Check your work

Why the test app duplicates main.ts: a test that boots a different application proves nothing about the one that ships.

Why app.init() rather than listen(): supertest drives the server directly, so no port is bound and nothing is left running.

Why cookies must be carried by hand: supertest is not a browser and stores nothing.

Why only name=value is sent back: the rest are instructions to a browser.

The trade between fixtures and the seed: independence and slowness against speed and coupling.

Why restoration goes in finally: one failure must not poison every test after it.

What only this layer can prove: that a read created no row, that a cookie is HttpOnly, that a failed transaction left nothing behind.

Why the date is read from the database: the day-early bug echoed the right value while storing the wrong one.

Why .send(string) for a signed webhook: signing one set of bytes and sending another is the confusion under test.

Why files run one at a time: they compete for real stock, and the loser looks flaky.

Practice

  1. Run the API end-to-end suite and time it. Then set fileParallelism: true and run it several times.
  2. Remove app.use(cookieParser()) from the factory. Note which tests fail and whether the message points at the cause.
  3. Remove the ValidationPipe and find the test that now passes for the wrong reason.
  4. Change the stored-date assertion to read order.body.slotDate instead. Then reintroduce the setHours bug and confirm the test no longer catches it.
  5. Change .send(tampered) to .send(JSON.parse(tampered)) and work out why the test now fails for a different reason.
  6. Delete the finally block in the rollback test and force a failure inside it. Run the whole suite and count the casualties.
  7. Add a test asserting the session cookie is HttpOnly and SameSite=Lax.
  8. Write a test proving two carts cannot see each other's items.
  9. Add an order to the seed and find every test that breaks.
  10. Sketch what "a database per worker" would need here: the commands, and where they would go in CI.

Next: testing what a person actually sees.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship