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"
}
}
]
});
});
});