Bringing It All Together - Part 1


Back inside src/routes/codereviewvideos.ts, we can now start using a function that implements the IStorage interface. We have one such implementation - redisStorage.

How's this going to fit into our testing setup?

In the previous video we said we aren't going to explicitly test the redisStorage functions.

Does that mean we are going to test the redisStorage, but "indirectly" through the /codereviewvideos endpoint?

Alas, no.

We are going to mock our storage implementation for the purposes of testing.

Again, it's not true test driven development when you first write code and then write some tests. That's code driven development with tests :)

But when starting out with stuff like mocking in Jest, it can be really tricky to figure out how the heck all of this works when you have nothing working... seems like catch-22.

So, for me, pragmatism wins the day.

Let's look at a commented code example of our POST endpoint:

import {Context} from "koa";
import Router from "koa-router";

const router = new Router();

router.post(`/codereviewvideos`, async (ctx: Context) => {
  try {

    // validate the incoming request

    //   - return early if invalid

    // save the new game to storage

    // get all the games we know about

    ctx.status = 201;
    ctx.body = {
      games: [
        // ... a list of games
      ]
    };
  } catch (err) {
    console.error(err);
  }
});

export default router;

Inside the tests file (__tests__/routes/codereviewvideos.ts) we need to add in some test cases for invalid POST data.

We also need to mock out the storage implementation, so we don't need to worry about Redis during testing.

As validation is the first port of call, let's add testing around this.

Testing Validation Responses

The Class Validator library creates an array of ValidationError objects whenever one or more validation rules are not met.

We've already seen how to validate data during our testing of the AddGameRequest class.

The process for validating a real request is almost exactly the same. The only difference is we don't hardcode a value to the AddGameRequest.name property. Instead, we pass this value in from the POST'ed data sent by our API consumer.

We can see the sort of thing we will get back by examining the outcome of one of our unhappy path outcomes from __tests__/request/AddGameRequest.test.ts:

  console.log __tests__/request/AddGameRequest.test.ts:38
    error [ ValidationError {
        target: AddGameRequest { name: 'yyyyyyyyyyyyyyyyyyyyy' },
        value: 'yyyyyyyyyyyyyyyyyyyyy',
        property: 'name',
        children: [],
        constraints:
         { length: 'name must be shorter than or equal to 20 characters' } } ]

In other words, as above, an array of ValidationError objects, which are described in the docs.

In a real world application, I would recommend you take the individual ValidationError objects, and create your own error representations from them. Even if you copy the implementation 1:1, you will be in a much better place to maintain your system over the long term by taking control of the error representation.

Why?

Well, if you ever change your validation library for any reason, you can adapt the new implementation to meet the same interface, and your API consumers will be none the wiser. However, if you rely on the implementation provided by the library, changing the library will very likely become a major breaking change for API consumers. And that means sad pandas. Or... just angry consumers.

But with that said, we are just going to roll with the structure provided by ValidationError :D

Knowing the outcome as above, we can now write a test:

  it('should return a validation failure if the game data is incorrect', async () => {
    const response = await request(server)
      .post("/codereviewvideos")
      .send({ game: "" });

    expect(response.status).toEqual(400);
    expect(response.type).toEqual("application/json");
    expect(response.body).toEqual({
      status: 'error',
      data: [
        {
          'a': 'b'
        }
      ],
    });
  });

There's some unusual stuff happening here. We expect a 400 status code. We expect a specific shape of response. And the error data looks... funky.

Not to worry.

We need to handle all of this, we don't just get it all done for us for free.

How the error really looks isn't that important to us at this very moment. And besides, when Jest spits out the real thing, we will just copy / paste it, and update our test :)

Sending in the test now returns 2 passes and 2 failures. We still have that fail from earlier where we tried to add two games to the list. That's not gone away. But we have this new failure also, our new failing test:

  ● routes/codereviewvideos › should return a validation failure if the game data is incorrect

    expect(received).toEqual(expected) // deep equality

    Expected: 400
    Received: 201

      64 |       .send({ game: "" });
      65 | 
    > 66 |     expect(response.status).toEqual(400);
         |                             ^
      67 |     expect(response.type).toEqual("application/json");
      68 |     expect(response.body).toEqual({
      69 |       status: 'error',

      at Object.<anonymous> (__tests__/routes/codereviewvideos.ts:66:29)
      at step (__tests__/routes/codereviewvideos.ts:32:23)
      at Object.next (__tests__/routes/codereviewvideos.ts:13:53)
      at fulfilled (__tests__/routes/codereviewvideos.ts:4:58)

We need to add in validation, and handle a validation failure.

The updated router:

import {Context} from "koa";
import {AddGameRequest} from '../request/AddGameRequest';
import { validate } from "class-validator";
import Router from "koa-router";

const router = new Router();

router.post(`/codereviewvideos`, async (ctx: Context) => {
  try {

    // ALL BELOW THIS IS NEW
    const validatorOptions = {};

    const game = new AddGameRequest();
    game.name = ctx.request.body.name || '';

    const errors = await validate(game, validatorOptions);

    if (errors.length > 0) {
      ctx.status = 400;
      ctx.body = {
        status: 'error',
        data: errors
      };

      return ctx;
    }
    // ALL ABOVE THIS IS NEW

    ctx.status = 201;
    ctx.body = {
      games: [
        ctx.request.body.game,
      ]
    };
  } catch (err) {
    console.error(err);
  }
});

export default router;

There's nothing new here. We've covered all of this stuff individually already. Now it's just a case of combining the small bits we do know into something larger, and more useful. The old LEGO development approach.

What's the test saying?

  ● routes/codereviewvideos › should return a validation failure if the game data is incorrect

    expect(received).toEqual(expected) // deep equality

    - Expected
    + Received

      Object {
        "data": Array [
          Object {
    -       "a": "b",
    +       "children": Array [],
    +       "constraints": Object {
    +         "length": "name must be longer than or equal to 1 characters",
    +       },
    +       "property": "name",
    +       "target": Object {
    +         "name": "",
    +       },
    +       "value": "",
          },
        ],
        "status": "error",
      }

      66 |     expect(response.status).toEqual(400);
      67 |     expect(response.type).toEqual("application/json");
    > 68 |     expect(response.body).toEqual({
         |                           ^
      69 |       status: 'error',
      70 |       data: [
      71 |         {

We have two options here.

We can either check for a very specific key on our error array.

Or we can check the error array contains the full object.

My preference is to show the full object. This way, our tests become more like documentation.

Copying this out of the Jest terminal output is a bit of a pain. I would recommend using Postman or similar instead.

  it('should return a validation failure if the game data is incorrect', async () => {
    const response = await request(server)
      .post("/codereviewvideos")
      .send({ game: "" });

    expect(response.status).toEqual(400);
    expect(response.type).toEqual("application/json");
    expect(response.body).toEqual({
      "status": "error",
      "data": [
        {
          "target": {
            "name": ""
          },
          "value": "",
          "property": "name",
          "children": [],
          "constraints": {
            "length": "name must be longer than or equal to 1 characters"
          }
        }
      ]
    });
  });

And that gives us a passing test. It also highlights some inconsistencies in the test suite. Here's the test suite after updates:

import server from "../../src/server";
import request from "supertest";

afterEach((done) => {
  server.close();
  done();
});

describe("routes/codereviewvideos", () => {

  const games = [
    "World of Warships",
    "Battlefield",
  ];

  games.forEach((game: string) => {
    it(`should allow adding a game to the list - ${game}`, async () => {
      const response = await request(server)
        .post("/codereviewvideos")
        .send({ name: game });

      expect(response.status).toEqual(201);
      expect(response.type).toEqual("application/json");
      expect(response.body).toEqual({
        games: [
          game,
        ]
      });
    });
  });

  it(`should keep track of all games added to the list'`, async () => {
    const game1 = { name: "Half Life 3" };
    const response1 = await request(server)
      .post("/codereviewvideos")
      .send(game1);

    expect(response1.status).toEqual(201);
    expect(response1.type).toEqual("application/json");
    expect(response1.body).toEqual({
      games: [
        game1.name,
      ]
    });

    const game2 = { name: "FSX 2020" };
    const response2 = await request(server)
      .post("/codereviewvideos")
      .send(game2);

    expect(response2.status).toEqual(201);
    expect(response2.type).toEqual("application/json");
    expect(response2.body).toEqual({
      games: [
        game1.name,
        game2.name,
      ]
    });
  });

  it('should return a validation failure if the game data is incorrect', async () => {
    const response = await request(server)
      .post("/codereviewvideos")
      .send({ name: "" });

    expect(response.status).toEqual(400);
    expect(response.type).toEqual("application/json");
    expect(response.body).toEqual({
      "status": "error",
      "data": [
        {
          "target": {
            "name": ""
          },
          "value": "",
          "property": "name",
          "children": [],
          "constraints": {
            "length": "name must be longer than or equal to 1 characters"
          }
        }
      ]
    });
  });

});

Episodes