How do I write tests for my JS app
Test, it is a word that a lot of software engineers say but don't really do. If you are here then I'm sure you are writing tests 😉. So what is it?
My interpretation of testing is it's a system to ensure our code still works with the addition or deletion of codes in an autonomous manner. Simply put, it is here to ensure our code works the way it's supposed to work. Test gives us confidence that our code works. It also acts as a signal when an existing feature doesn't work. Better find this out during development rather than hearing a complaints from customer.
There are different types of test including unit test, integration test, and end to end test. We will focus on unit test in this post.
Unit Test ​
Our codes are essentially functions that are taking inputs and product outputs for others to use. We want to ensure the functions work as expected given the inputs and compare to the expected output. Imagine we have a simple function that returns the sum of 2 numbers:
export function func1(a, b) {
return a + b;
}
We can simply test if it does return the sum or other types of tests like giving it string and see test how it should respond. The test may look like:
import { func1 } from "../src/code";
describe('Test Code File', () => {
it('should return the sum of 2 numbers', () => {
expect(func1(1, 2)).toBe(3);
});
});
This is the nutshell of unit test and don't worry if you sometimes don't know what to test. As you develop more and write more tests, you would know what to test. Or you can learn it the hard way like I did: see a production error then write a test case to cover that use case.
Setup ​
In this post I will use jest, one of the popular testing libraries in the market for testing your JavaScript app and it also supports many different frameworks. One of the useful features it has is called Snapshot Testing in which Jest compares the previously function outputs against the current output and see if they match.
First we need to install the necessary plugins:
# npm
npm install jest @babel/preset-env
# yarn
yarn add jest @babel/preset-env
The reason I install babel is so I can write modern JavaScript in test file. For example I can write import
instead of require
or other fancier JavaScript syntaxes when running the test cases. By default it doesn't understand import
right out of the box is because Node doesn't understand import
by default.
We need to create a jest.config.js file at root that Jest will consume and know how to do certain things. This includes where the test files are located, or how to transform certain files. Here is the most basic one:
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// The glob patterns Jest uses to detect test files
testMatch: ['**/test/**/*.spec.js'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/']
};
clearMocks
clears the mocks between tests so whatever mocks or conditions you set for previous tests are not brought into the next ones.testMatch
tells Jest where our tests are located. You should change it however you see fit for your project. You may have different file structure than I do.testPathIgnorePatterns
tells Jest to ignore certain tests. In this case we ignore node_modules directory if it's matched intestMatch
.
I also created the babel.config.js file at root:
module.exports = {
env: {
test: {
presets: [["@babel/preset-env"]],
},
},
};
You may have other configurations here but the test
prop tells babel when the runtime environment is test
then use this following babel configs.
My file structure looks something like:
To run my unit test I added couple scripts to my package.json file so the file becomes:
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^26.6.3"
},
"dependencies": {
"@babel/preset-env": "^7.12.11"
}
}
Run Tests ​
Now we can run our test by running:
# npm
npm run test
npm run test:coverage
# yarn
yarn test
yarn test:coverage
Here is a image of the test result when I run yarn test:coverage
.
After you run the coverage, you should find a coverage directory that contains an HTML page that you can see the coverage results of your tests. It is very helpful and give you satisfactory feeling or a way to shame yourself for not writing enough tests.
Mocking ​
In unit test, we want to ensure our functions work in a controlled environment and only test if the output is expected with given inputs. This means if it's using external functions or calling external data we want to mock or give it a fake value so we know those external values are not the reasons why our unit tests fail. If our unit test is actually sending an API request, our test will fail if for some reason the service stops working.
Most of the time you mock your class with:
jest.mock("../src/yourclass.js");
Mock Exported Function ​
Imagine we have another function in another file that returns 0 or 1:
export function getRandomValue() {
return Math.round(Math.floor() * 2);
}
And we import and use it in our original code:
import { getRandomValue } from "./code2";
export function func1(a, b) {
return (a + b) * getRandomValue();
}
To help make the unit test more deterministic, we need to mock the result of getRandomValue
method. Let's assume it should return 1.
import { func1 } from "../src/code";
// mock the file
jest.mock("../src/code2.js", () => {
return {
// Make this method always return 1
getRandomValue: jest.fn().mockImplementation(() => 1),
};
});
describe('Test Code File', () => {
it('should return the sum of 2 numbers', () => {
expect(func1(1, 2)).toBe(3);
});
});
If you need to mock the function to a specific value in that test, this is what I did:
import { func1 } from "../src/code";
// Import the function in question
import { getRandomValue } from '../src/code2';
// mock the file
jest.mock("../src/code2.js", () => {
return {
// Make this method always return 1
getRandomValue: jest.fn().mockImplementation(() => 1),
};
});
describe('Test Code File', () => {
it('should return the sum of 2 numbers', () => {
expect(func1(1, 2)).toBe(3);
});
it('should return 0 when random value is 0', () => {
// mock the function with specific value, in this case it's 0
getRandomValue.mockImplementationOnce(() => 0);
expect(func1(1, 2)).toBe(0);
});
});
And our result:
Mock Default Function ​
At work we are in the middle of tech migration and we saw some benefits from rewriting our Vuex state management into Composition API. This is probably a story for another time. In Composition API, or React Hooks in React, it's mostly a function that returns multiple states and function that mutates those states. The code looks something like:
export default function useCounter() {
const count = ref(0);
function incrementCount() {
count.value += 1;
}
return {
count,
incrementCount,
};
}
How do you mock this?
jest.mock('../src/useCounter.js', () => {
return {
__esModule: true, // This is a must
default: jest.fn().mockReturnValue({
count: 0,
incrementCount: jest.fn(),
}),
};
});
Are you writing tests or have tests helped you prevent disaster? Tweet to me to let me know!