We use cookies to enhance your experience on the site
CodeWorlds

PROJECT - comprehensive testing of the legion system

Throughout this module we wrote tests one at a time: a service test here, a controller test there, an HTTP test somewhere else. The project is where they must become a suite - one that somebody runs six months from now and believes the result of.

Your task is to cover the legion management system with tests: legions and cohorts, assigning legionaries, planning expeditions, tracking tributes.

Three levels, three questions

Before you write the first file, settle what question each level answers:

  • A unit test - does this method compute correctly when everything around it is mocked?
  • An integration test - do the modules understand one another, with a real dependency container?
  • An E2E test - does the full user path work over HTTP, from request to response?

Each level costs differently. Write the most unit tests, because they are cheap and point straight at the culprit; the fewest E2E, because they are slow and, when they fail, say only "something is wrong".

Given-When-Then

Every test, whatever its level, has the same threefold shape.

Given
is the preconditions,
When
is the action,
Then
is the expected result
:

1it('should create a legion with the given name', async () => {
2  // Given - preconditions
3  const dto = { name: 'Legio X Equestris', maxSoldiers: 5000 };
4  mockRepository.save.mockResolvedValue({ id: 1, ...dto });
5
6  // When - the action
7  const result = await service.create(dto);
8
9  // Then - the expected result
10  expect(result.id).toBe(1);
11  expect(mockRepository.save).toHaveBeenCalledWith(dto);
12});

Do not confuse this division with the three levels of testing -

Given
does not stand for a unit test, nor
Then
for an E2E one. Nor is it about kinds of test double: a mock, a spy and a stub are tools you use within the
Given
part. Nor about HTTP layers: data, request and response code are merely one possible filling of this skeleton.

The pattern's value is practical. When a test has no distinct

When
, it usually means it is checking two actions at once - and on failure you will not know which one gave way.

Two principles that decide whether a suite can be believed

Tests must be independent from each other and isolated. This is the first principle of unit testing and the only one whose breach spoils the whole suite at once.

Tests sharing state pass in the order they were written and fail once the order changes, once they run in parallel, or once somebody adds a test in the middle of the file. Worst of all, they then look as if they had caught a bug - when all they have caught is their own coupling.

Two misunderstandings are worth clearing away at once. Test names should be long and descriptive, not as short as possible: a test's name is the error message you will see on a red background in CI, and

t1
will tell you nothing then. And edge cases must be tested too, not only the happy path - because bugs live exactly where nobody looked: an empty list, a zero, a special character in a name.

Hooks and mocking dependencies

Isolation is achieved with hooks, and their order of execution is fixed:

beforeAll()
beforeEach()
test/it()
afterAll()
:

1describe('LegionsController', () => {
2  let controller: LegionsController;
3  let module: TestingModule;
4
5  const mockLegionsService = {
6    findAll: jest.fn(),
7    create: jest.fn(),
8  };
9
10  beforeAll(async () => {
11    module = await Test.createTestingModule({
12      controllers: [LegionsController],
13      providers: [{ provide: LegionsService, useValue: mockLegionsService }],
14    }).compile();
15
16    controller = module.get(LegionsController);
17  });
18
19  beforeEach(() => {
20    jest.clearAllMocks();
21  });
22
23  afterAll(async () => {
24    await module.close();
25  });
26});

The division of labour between the hooks follows straight from their order.

beforeAll
runs once - here it builds the expensive testing module.
beforeEach
runs before every test, and it is the one that enforces isolation:
jest.clearAllMocks()
wipes the call history so that a test sees no traces of its predecessor.
afterAll
clears up at the end - closing the module, connections and open handles.

Note how the double is written in

providers
. The order is always the same:
{ provide:
opens the object,
LegionsService,
names the token to be replaced,
useValue:
announces a value, and
mockLegionsService }
supplies it. It reads like a sentence: "in place of
LegionsService
, use this value".

Mocking a repository itself comes down to one line per case:

mockRepository.save.mockResolvedValue(expectedResult)
makes the method return a ready result instead of touching the database. For the error path you use
mockRejectedValue
.

The E2E test

At the highest level you speak to the application the way a client will - over HTTP, through the

supertest
library:

1it('GET /legions returns all legions', async () => {
2  const response = await request(app.getHttpServer())
3    .get('/legions')
4    .expect(200)
5    .expect((res) => expect(res.body).toHaveLength(3));
6});
7
8it('POST /legions rejects a body without a name', async () => {
9  await request(app.getHttpServer())
10    .post('/legions')
11    .send({ maxSoldiers: 5000 })
12    .expect(400);
13});

The assertion is built of four links in a fixed order:

const response = await request(app.getHttpServer())
opens a request to the running application,
.get('/legions')
names the method and path,
.expect(200)
checks the response code, and
.expect(res => expect(res.body).toHaveLength(3))
looks inside its body.

The second test shows something easily forgotten: check the rejections too. A request without a required field must receive a

400
, and that is the only way to be sure
ValidationPipe
really is wired into the test configuration and not merely into
main.ts
.

When a test fails

A red test is debugged in four steps, always in this order:

  1. Read the error message and the stack trace. It usually holds everything: what was expected, what came back, and on which line.
  2. Identify which assertion failed. With several
    expect
    calls in one test that is not obvious - hence the advice not to overload a test.
  3. Check the input data and the mocks. The commonest cause lies not in the code but in a double returning something other than you think - or remembering a call from the previous test.
  4. Fix the test or the tested code. Only now, once it is clear what is broken.

The order matters, because the natural instinct - to start at step four and adjust the code by feel - ends in changing a working implementation to satisfy a faulty test.

What you hand in

The project is finished when it contains:

  1. Unit tests of the services with mocked repositories, covering the error paths as well.
  2. Integration tests checking that modules cooperate, on a real
    TestingModule
    .
  3. E2E tests for the key paths, with assertions on both the response code and the body.
  4. Separate
    describe
    blocks
    for each level, so that they can be run independently.
  5. A coverage report with a justification for whatever you deliberately left uncovered.

Finish with a trial that tests the whole suite at once: run the tests in random order (

jest --randomize
). If any of them fails, you have shared state somewhere, @name - and a suite that passes in only one order is not telling the truth about your code.

Send the link to your repository when you are done.

Go to CodeWorlds