We use cookies to enhance your experience on the site
CodeWorlds

E2E Testing - simulating a full campaign

The catapult fires, the parts fit together. But a campaign is not one shot: the scouts find the enemy, a messenger carries the order, the legion forms up, the machines strike, the camp packs at dusk. Checking each stage separately will not tell you whether the whole expedition reaches its end.

An E2E test - end to end - walks that road in full. It starts a real application, sends real requests and checks what a user would see. No doubles: if validation, authentication or a database write fails along the way, the test catches it.

Where E2E tests live

We keep E2E tests apart from the rest, because they have their own configuration and their own pace:

1test/
2  app.e2e-spec.ts
3  jest-e2e.json

The test file carries the

.e2e-spec.ts
suffix, and their Jest configuration sits in
test/jest-e2e.json
. It tells Jest to look for files by a different pattern and sets a longer timeout, because the full road takes longer than calling a single method. You run them with a separate command,
npm run test:e2e
, precisely so they do not slow down the quick unit run.

Four steps to a running application

Preparing the application always goes the same way, and the order is worth knowing:

1describe('Legions (e2e)', () => {
2  let app: INestApplication;
3
4  beforeAll(async () => {
5    const moduleFixture = await Test.createTestingModule({
6      imports: [AppModule],
7    }).compile();
8
9    app = moduleFixture.createNestApplication();
10    app.useGlobalPipes(new ValidationPipe());
11    await app.init();
12  });
13
14  afterAll(async () => {
15    await app.close();
16  });
17});

Step one:

Test.createTestingModule({ imports: [AppModule] })
- we import
AppModule
, the root of the whole application. That is the difference from an integration test, where we took a single module: here everything comes in.

Step two:

.compile()
resolves the dependencies and creates the instances.

Step three:

createNestApplication()
turns the module into an application able to handle a request.

Step four:

await app.init()
starts it.

Between steps three and four there is room for one thing that is easily forgotten: the global elements normally configured in

main.ts
.
ValidationPipe
, exception filters, a route prefix - none of them comes in automatically, because
main.ts
never runs in a test. If you leave them out, the test application will accept data production would reject - and the test passes falsely.

Walking the whole road

With the application in place, we send requests with the supertest library - the same one you met in integration tests:

1it('creates a legion and lets you fetch it', async () => {
2  const created = await request(app.getHttpServer())
3    .post('/legions')
4    .send({ name: 'Legio I' })
5    .expect(201);
6
7  await request(app.getHttpServer())
8    .get(`/legions/${created.body.id}`)
9    .expect(200)
10    .expect((res) => {
11      expect(res.body).toEqual({
12        id: created.body.id,
13        name: 'Legio I',
14      });
15    });
16});

The chain reads like a description of a conversation with the server:

request(app.getHttpServer())
takes the application's server,
.post('/legions')
picks the method and address,
.send({...})
adds the request body,
.expect(201)
checks the response code. Remember that order - it is the skeleton of every E2E test.

But the most interesting part is elsewhere: the second request uses the result of the first. We saved a legion and then fetched it by the identifier the server returned. That is what no lower-level test will do - we are checking not a single endpoint but a user scenario made of several steps.

toEqual versus toBe

In that last assertion we used

toEqual
, and that is no accident:

1expect(res.body).toEqual({ id: 1, name: 'Legio I' });   // passes
2expect(res.body).toBe({ id: 1, name: 'Legio I' });      // fails

The difference is fundamental.

toBe
asks whether it is the same object - the same cell of memory. The server's response arrived over the wire and was rebuilt from JSON, so it will never be the same object as your literal;
toBe
always fails here.

toEqual
compares deeply: it walks the structure and checks the value of every field. For objects and arrays that is the right choice. Leave
toBe
for numbers, strings and booleans, where "the same" and "equal" mean one thing.

When not E2E - a controller in isolation

The full road gives the most confidence but costs the most time. When you want to check the controller's own logic - whether it passes the right arguments on - build it with a mocked service:

1const mockService = { findAll: jest.fn().mockResolvedValue([]) };
2
3const module = await Test.createTestingModule({
4  controllers: [LegionsController],
5  providers: [{ provide: LegionsService, useValue: mockService }],
6}).compile();
7
8const controller = module.get(LegionsController);
9
10it('passes the status filter to the service', async () => {
11  await controller.findAll('active');
12
13  expect(mockService.findAll).toHaveBeenCalledWith({ status: 'active' });
14});

The service is a double here, so we examine the controller alone. The

toHaveBeenCalledWith
assertion checks which arguments the double was called with - and that is the only way to confirm the controller correctly translated a query parameter into a filter object. The return value does not interest us here; the conversation between layers does.

Choosing between the two comes down to one question: am I checking one layer's logic or the road through all of them. E2E for scenarios, isolation for details.

Summary

The campaign has travelled the whole road:

  • an E2E test starts a real application and walks a request's full road, with no doubles,
  • files carry the
    .e2e-spec.ts
    suffix, the configuration sits in
    test/jest-e2e.json
    , and
    npm run test:e2e
    runs them,
  • four preparation steps:
    createTestingModule({ imports: [AppModule] })
    ,
    .compile()
    ,
    createNestApplication()
    ,
    await app.init()
    ,
  • the global elements from
    main.ts
    do not come in automatically
    -
    ValidationPipe
    and filters must be added, or the test passes falsely,
  • the supertest chain:
    request(app.getHttpServer())
    ,
    .post('/address')
    ,
    .send({...})
    ,
    .expect(201)
    ,
  • E2E's strength is a multi-step scenario where each request uses the previous one's result,
  • toEqual
    compares structure and values deeply
    - the choice for objects;
    toBe
    checks identity and suits only primitives,
  • to examine a controller alone, swap the service for a double and use
    toHaveBeenCalledWith
    to check the arguments passed,
  • app.close()
    in
    afterAll
    is mandatory.

In the next lesson we will look at the doubles themselves - you will meet the four kinds and the difference between

jest.fn()
and
jest.spyOn()
. For now remember: E2E checks whether the campaign reaches its end; isolation checks whether a single messenger carries the right order.

Go to CodeWorlds