Unit Testing 101
“Why do most developers fear to make continuous changes to their code? They are afraid they’ll break it! Why are they afraid they’ll break it? Because they don’t have tests.”
― Robert C. Martin, The Clean Coder: A Code of Conduct for Professional Programmers
Good software is well tested. Here are some basics using React and Jest to help get started on what makes up a unit test.
What is unit testing?
Unit testing is where we test individual sections of code. In React, this could be be a component or a function.
Why do we do unit testing?
Unit testing is the smallest, simplest test we can write. It allows for bugs to be caught early which therefore means that much less effort is required in the later tests (integration, regression, etc). We want to be focusing on unit tests as these can be written at the development stage and keeps efficiency on point.
Source: http://www.testingreferences.com/here_be_pyramids.php
What should we be testing?
We want to be testing both the happy and non-happy path for the component. The happy path is assuming that everything the user does is as expected. The non-happy path assumes the user tries to do something unexpected, for example if the user tries to enter numbers into a text only field.
Tests should be checking things that the user can see, testing the behaviour of the application rather than the implementation details. Behavioural testing would be like checking that the pop-up did indeed show up after that button was pressed. Implementation details would be something like checking the state of that component after button press. Testing things the user can actually see/use will ensure we better cover the user’s experience. “Automated tests should verify that the application code works for the production users.” (https://kentcdodds.com/blog/testing-implementation-details)
Once we write, and are happy with a test, it shouldn’t need to be rewritten. The only case where it would need to be changed is if the functionality changed. Non-functional requirements should not stop the tests from passing.
When does it stop being a unit test?
A test is not a unit test if:
- It talks to the database
- It communicates across the network
- It touches the file system
- It can’t run at the same time as any of your other unit tests
- You have to do special things to your environment (such as editing config files) to run it.
(https://www.artima.com/weblogs/viewpost.jsp?thread=126923)
This is not an exhaustive list but some good examples of things a unit test should NOT cover. Unit tests should be standalone and not rely on each other. If the test relies on the state of the previous test or another component, it is NOT unit.
Code Coverage
Although it would be nice to have 100% code coverage, it is not the aim. Code coverage does not take into consideration what the tests are actually testing and the quality of them. Code coverage gives us an estimation of how well tested our code is. However, our focus should be on functionality.
A good discussion on other metrics vs code coverage: https://stackoverflow.com/questions/90002/what-is-a-reasonable-code-coverage-for-unit-tests-and-why
Test Doubles
Using test doubles allow us to isolate the behaviour we want, to focus on vital parts of the test and have everything else be simulated. Jest is a good Framework we can use (https://jestjs.io/) for this.
For example, we can mock not essential methods as so:
- When we don’t care about the outcome, we can use jest.fn()
const { getByTestId } = render( <MUIPicker testid="endDate" isDisabled={false} onBlur={jest.fn} />);
- When we do care about the outcome of the method, we can use mockImplementation().
API.post = jest.fn().mockImplementation(() => { return Promise.reject({ response: { data } });});
If we mock things, we also need to reset the mocks after each test and initialise again them beforehand. beforeEach() and afterEach() can be used for this.
beforeEach(() => { jest.fn().mockRestore();});afterEach(() => { jest.fn().mockClear();});
Further information: https://jestjs.io/docs/en/mock-function-api
Looping
If you have similar tests that may only require different inputs, to follow the DRY (Don’t repeat yourself) principle of clean code, we can use loops.
it.each`errorObject | id | message${"success"} | ${"elementID1"} | ${"successful import"}${"warning"} | ${"elementID2"} | ${"import with warnings"}${"failure"} | ${"elementID3"} | ${"failed import"}`(`Should show correct ($errorObject) on error`, ({ id, message }) => { fireEvent.click(testRender.getByTestID(id)); expect(testRender.getByTestID("message")).toEqual(message); });
Async
Not all tests have to be synchronous. Using async, we can wait for desired results for things such as API calls or changes of state.
test('the data is peanut butter', async () => { const data = await fetchData(); expect(data).toBe('peanut butter');});
Further information: https://jestjs.io/docs/en/asynchronous.html