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.
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.jsonThe test file carries the
suffix, and their Jest configuration sits in .e2e-spec.ts
. 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, test/jest-e2e.json
npm run test:e2e, precisely so they do not slow down the quick unit run.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.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.
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' }); // failsThe difference is fundamental.
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
toBe always fails here.
compares deeply: it walks the structure and checks the value of every field. For objects and arrays that is the right choice. Leave toEqual
toBe for numbers, strings and booleans, where "the same" and "equal" mean one thing.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.
The campaign has travelled the whole road:
.e2e-spec.ts suffix, the configuration sits in test/jest-e2e.json, and npm run test:e2e runs them,createTestingModule({ imports: [AppModule] }), .compile(), createNestApplication(), await app.init(),main.ts do not come in automatically - ValidationPipe and filters must be added, or the test passes falsely,request(app.getHttpServer()), .post('/address'), .send({...}), .expect(201),toEqual compares structure and values deeply - the choice for objects; toBe checks identity and suits only primitives,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.