This is part of Academy's technical curriculum for The Mark. All parts of that curriculum, including this project, are licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.
We're going to be interpreting and extending a server using TDD.
- Test HTTP GET requests with supertest
- Distinguish between unit tests and integration tests
- Use test-driven development with both unit tests and integration tests
- Distinguish between testing the server response and testing state change
(can be completed alongside 1b, or before/after)
๐ฏ Success criterion: a document which outlines how you think this Express server works. You don't have to achieve a theory which explains 100%, but you should strive to explain as much as possible.
- Clone/fork the repo
- Take some time to read and digest the code
- Explore and run the tests
- Play around with it via Postman
- Google things that you don't understand
- Experiment with changing things
- Produce a narrative document
(can be completed alongside 1a, or before/after)
๐ฏ Success criterion: you can demonstrate the independence of different units within the system of walking your digipet by (deliberately) breaking one set of unit tests without breaking other sets of unit tests
The /digipet
folder is for all functions that read or update digipet data.
The job of server.ts
is to set up our server endpoints and dictate server responses (and sometimes calling a function from /digipet
to make side-effects happen).
Some software architectural patterns distinguish between 'Model' and 'Controller' (a famous pattern is MVC: Model-View-Controller).
We're not formally using MVC (e.g. it's traditionally object-oriented, which this example is not). However, we're repurposing its vocabulary to make an approximate distinction between things in our digipet code:
- Model: the code that creates the levers which can be pulled to read/update digipet data (the puppet with strings)
- Controller: the functions that pull the digipet model's levers in order to effect changes (the puppeteer pulling strings)
For example: walkDigipet
is a descriptive controller function which calls the updateDigipetBounded
model function.
There are lots of different types of testing.
In this exercise, we're focusing on two types:
- Unit tests
- Non-unit tests
- Integration tests
- End-to-end (E2E) tests
Whilst there is a distinction between integration and E2E tests, for now, we'll lump them together under 'non-unit tests', and focus on distinguishing between unit and non-unit tests.
Start by reading this Google blog on unit vs E2E tests.
Once you have read that, we'll consider how types of test manifest in the codebase.
Let's look at how we're testing "walking a digipet".
Before you attempt this section, you should experiment with walking your digipet through Postman and the /digipet/walk
endpoint.
The desired behaviour of walking a digipet (which you should be able to observe) is:
- If we have a digipet, we should be able to walk our digipet through the
/digipet/walk
endpoint- Data change: Walking a digipet should increase its happiness by
10
and decrease its nutrition by5
(to model needing to replenish energy)- Happiness can increase only as far as to
100
- Nutrition can decrease only as far as to
0
- Happiness can increase only as far as to
- Server response: The endpoint should respond with a message indicating that the digipet has been taken for a walk
- Data change: Walking a digipet should increase its happiness by
- If we don't have a digipet, the
/digipet/walk
endpoint doesn't walk any digipet- Server response: The endpoint should respond with a message indicating that it isn't possible to walk a non-existent digipet
Server response and data change are two different jobs, so it is helpful to reason about them and write them as separate bits of functionality.
It also makes sense to therefore test them separately. If the overall 'walking a digipet' behaviour is not working as expected, it's helpful to have focused tests which tell us more precisely which part of it is not working as expected.
We might divide up the tests as follows:
1. Data change: Does the walkDigipet
controller function change Digipet stats as expected?
We could test a walkDigipet
function that should:
- Increases the digipet's happiness by
10
, up to ceiling of100
- Decreases the digipet's nutrition by
5
, down to a floor of0
This is tested as isolated behaviour in src/digipet/controller.test.ts
.
๐ง Make sure that you can find the relevant test.
This is unit testing: tightly focused to a single function behaviour, and not dependent on the behaviour of other functions.
2. Server response: Does the server's /digipet/walk
endpoint give back a sensible response?
We could test a /digipet/walk
endpoint that should:
- Sends back an acknowledgement message about walking the digipet when we have one
- Sends back a helpful message explaining that we can't walk a digipet when we don't have one
- Delegates actual data change to the
walkDigipet
function
This is tested as isolated behaviour in src/server.test.ts
.
๐ง Make sure that you can find the relevant test.
This is, again, unit testing: tightly focused to a single endpoint, and not dependent on the behaviour of other endpoints or functions.
(Importantly: this unit test does not care at all about the implementation or behaviour of walkDigipet
. It tests that walkDigipet
gets called, but it would be possible for us to entirely change the behaviour of walkDigipet
and our endpoint unit test would not break.)
3. Does this come together as expected?
Unit tests check small individual parts of a system - the 'non-unit' tests then check the wider system:
- integration tests check a small number of parts of the system work together
- end-to-end (E2E) tests check the system as a whole against user journeys, from start to finish
We could test that, when we hit the /digipet/walk
endpoint repeatedly over time, the server response and data change happen in tandem as we expect - the server response demonstrates the increase and decrease in happiness and nutrition respectively, up to the ceiling of 100
and floor of 0
.
Because we have a very small system (which doesn't have many parts), the distinction between 'integration tests' and 'E2E tests' is a little more grey, so this isn't the critical distinction to focus on right now - what's most important to note is that the tests in /__tests__/walking.test.ts
are non-unit tests.
Well-designed unit tests, which test separate units of the system, should be independent as much as possible.
Specifically, in this example: it should be possible for our walkDigipet
unit tests to fail without the /digipet/walk
unit tests failing, and vice-versa.
The unit tests have been written in such a way to make this work.
So, without changing the test code:
- Make the
walkDigipet
unit tests fail without making the/digipet/walk
unit tests fail - Make the
/digipet/walk
unit tests fail without making thewalkDigipet
unit tests fail
(These are temporary things to do - you should revert the code back to a non-breaking state after each task.)
Does the non-unit test break when one of its unit components break?
๐ฏ Success criterion: the tests (both unit and integration) for training and feeding the digipet all pass
There are unit and integration tests already written for training and feeding digipets. It's your task to make them pass.
They have a very similar structure to the tests for walking a digipet: a set of integration tests, and two sets of unit tests.
A sensible approach is to build up from the unit tests to the integration tests (and it may be sufficient to get the unit tests to pass for the integration tests to pass).
Try the following two approaches:
- For training the digipet, start with making the
trainDigipet
unit tests pass and then move onto the/digipet/train
unit tests - For feeding the digipet, start with making the
/digipet/feed
unit tests pass and then move onto thefeedDigipet
unit tests
๐ฏ Success criterion: you have added passing tests for ignoring the digipet following the below criteria
Now, we've added to walking, training and feeding the digipet to our game - we'll now add ignoring the digipet (which leads to its sad deterioration).
The desired behaviour is as follows:
- GIVEN that the user does not have a digipet, WHEN they send a
GET
request to the/digipet/ignore
endpoint, THEN the server responds with a message informing them that they don't have a digipet and suggesting that they try hatching one - GIVEN that the user has a digipet with all stats above
10
, WHEN they send aGET
request to the/digipet/ignore
endpoint, THEN the server responds with a message confirming that they have ignored their digipet and includes digipet stats that show a decrease in all stats by10
- GIVEN that the user has a digipet with some stats below
10
, WHEN they send aGET
request to the/digipet/ignore
endpoint, THEN the server responds with a message confirming that they have ignored their digipet and includes digipet stats that have decreased by10
down to a possible floor of0
Write some unit tests and integration tests for this, then write the code to make it pass.
๐ฏ Success criterion: you have a specification, tests and passing code for rehoming a digipet
Add a rehoming feature to our digipet endpoint game - where the user is able to rehome their digipet, freeing up space for them to hatch a new one if desired.
Unlike above, we're not giving you the prescriptive behaviour - it's up to you to:
- Create a specification for the desired behaviour
- Write tests for this behaviour (unit tests and integration tests)
- Write the code to pass your tests
๐ฏ Success criterion: documented reflections.