Writing Unit Tests in Jest
12 min read·Jan 1, 2025
Unit testing is a software testing technique that consists in testing the smallest individual components of a software application (i.e. a "unit") in complete isolation from external dependencies such as a database, the filesystem, or HTTP services.
Its goal is to test the logic of these units rather than their interaction with other components, in order to ensure that they behave as expected.
In general, unit tests should be:
- Automated, making them easy to run after each codebase update to ensure that nothing was broken in the process.
- Fast and kept as short as possible, since otherwise, you and other developers working on the project will be less inclined to run them.
- Readable, as they act as the best form of documentation since they are not supposed to get out of sync with the code they document.
- Deterministic, which means that no matter how many times we run them, they should always behave the same way as long as the underlying code has not changed, as otherwise, there is no reason for developers to trust them.
It is important to note that while unit testing is designed to improve the code quality by facilitating the detection of bugs early on in the development cycle and the refactoring of existing code, unit tests alone are not enough to prevent integration or system-level issues, and must be complemented by other types of tests, such as integration or end-to-end tests.
Unit tests and test cases
A unit test is often composed of several test cases (scenarios) to which the tested unit must respond in a consistent and predictable manner.
Each test case has its own set of inputs and conditions, and is meant to cover a specific aspect of the unit's behavior using one or more assertions, which are statements that verify whether a condition is true.
Common assertions often include testing whether the unit outputs or returns a predefined value, calls a specific function during its execution, throws a specific type of error if its inputs are invalid or missing, and so on.
Declaring a test case
In Jest, test cases are declared using the global test() or it() function as follows:
test(description, callback);
Where:
descriptionis a short string of characters describing the test case.callbackis a function containing one or more logical assertions.
Note: In Jest, the
test()andit()functions are global and do not require to be explicitly imported into the test file.
Declaring an assertion
Within the callback function of the test() or it() function, assertions are created using a combination of the expect() function and a "matcher" function as follows:
test(description, () => {
expect(expression).matcher(expectedValue);
});
Where:
descriptionis a short string describing the test.expressionis the value of the expression you want to test, usually the return value of the unit.matcheris a chainable function that takes as argument an optional value that will be compared to the resulting expression.expectedValueis the expected value the expression will be compared against.
Notes:
In Jest, the
expect()function is global and doesn't require to be explicitly imported into the test file.You can use the
notmodifier to negate an assertion:test(description, () => { expect(expression).not.matcher(expectedValue); });
Example
In this example, the calculator module exports a single function named add that returns the sum of two variables:
// File: calculator.js
module.exports = {
add: (a, b) => {
return a + b;
}
};
To assert that the add function does return the sum of two numbers, we can import it into a test file and write the following test:
// File: calculator.test.js
const { add } = require('./calculator');
test('it should return 3 when operands are 1 and 2', () => {
expect(add(1, 2)).toBe(3);
});
Where the toBe() matcher function checks whether the return value of the add function equals 3.
Good practices
Here are a list of good practices you should follow to improve the clarity, readability, and maintainability of unit tests.
Follow the same naming convention
Each test description should follow the same naming convention using the formula: "should + expected behaviour + when + state under test".
For example:
it('should return 3 when operands are 1 and 2', () => { ... });
it('should throw an error when operands are not numbers', () => { ... });
Write BDD-style test cases
Each test should follow the Arrange, Act, Assert pattern, or in Behavior-driven development the Given, When, Then pattern, where:
- Arrange / Given: sets the initial context and test data.
- Act / When: executes the unit being tested.
- Assert / Then: verifies the expected outcome.
For example:
it('should return 3 when operands are 1 and 2', () => {
// Given
const expected = 3;
// When
const result = add(1, 2);
// Then
expect(result).toBe(expected);
});
Only use one assertion per test
Each test should focus on a single aspect of the unit's behavior and outcome, rather than its internal implementation details, and should preferably have only one logical assertion.
Cover all edge cases
Units should be tested against both typical and edge cases to ensure that they properly handle all possible scenarios, including errors and unusual inputs.
Avoid using magic values
In programming, magic values (or magic numbers) refer to unique values with unexplained meaning or multiple occurrences in code.
Rather than used directly, magic values should be assigned to constants with explicit names that describe specifically why these values were chosen.
For example:
it('should return a decoded password when the hash and the secret are valid', () => {
// Given
const passwordHash = 'kmdS@#Ai39mA-mxI#_';
const encryptionSecret = '39kdsxD';
const expectedValue = 'hello_world';
// When
const decodedPassword = decodePasswordHash(passwordHash, encryptionSecret);
// Then
expect(decodedPassword).toBe(expectedValue);
});
Avoid relying on external dependencies
Since unit tests are supposed to be deterministic, they should never depend on:
- Other test cases.
- Environmental values that are likely to change, such as the current time.
- The file system, the network, or APIs that are likely to fail.
Summary
Here's a summary of what you've learned in this lesson:
- Unit testing is a testing technique that consists in testing the smallest individual components in complete isolation.
- Unit tests should be automated, fast, short, readable, and deterministic.
- Unit tests are made of test cases (i.e. scenarios) used to cover every aspect of a component's behavior.
- An assertion is a statement that verifies whether a condition is true.
- Test cases are declared using the
test()orit()function. - Assertions are declared using the
expected()function and a "matcher" method. - Test case descriptions should follow the formula: "should + expected behaviour + when + state under test".
- Test cases should follow the structure: "given / when / then".
- Test cases should ideally contain only one assertion.
- Test cases should not contain magic values.
- Test cases should not rely on external dependencies.
Enjoying the courses?
I've made these courses completely free so anyone can learn from them. If they've helped you and you'd like to actively support the work behind BackendBrewery, you can leave a tip:
Support BackendBrewery