Jest is a popular and powerful testing framework for JavaScript applications. It provides a comprehensive set of features and tools to write and run tests efficiently, ensuring the quality and reliability of your codebase.
In this topic, you'll learn how to leverage Jest's advanced features to write effective tests for UI components, explore techniques for mocking, stubbing, and spying, and gain hands-on experience in creating comprehensive test suites for JavaScript functions and modules.
Testing UI Components with Jest and React Testing Library
When it comes to testing UI components, Jest integrates seamlessly with libraries like React Testing Library. React Testing Library provides a set of utilities that allow you to render components, simulate user interactions, and make assertions about the rendered output.
To get started, you'll need to install Jest and React Testing Library as development dependencies in your project. You can do this by running the following command:
npm install --save-dev jest @testing-library/react @testing-library/jest-domOnce installed, you can create test files for your React components.
First, you need to initialize jest from inside your project folder.
npx jest config:initThis creates a config file for jest called jest.config.js with the following content:
const config = {
verbose: true,
};
module.exports = config;Alternatively, you can use a JSON file called jest.config.json, it will work well either way so long as it is in the same folder as you package.json file. Some of the configs you can use are:
verbose- Indicates whether each test should be reported during the run (logged on the terminal)testEnvironment- Environment in which the test is run, default isnodebut canjsdomfor front-end testing.moduleFileExtensions- This is an array of file extensions your modules use, for example,["js", "jsx", "ts", "tsx", "json"].testMatch- This is a pattern Jest uses to detect test files.testTimeout- This is the maximum time Jest will wait for tests to complete in milliseconds. The default value is 5000 by default.
To learn more about configuring jest for a project, click here.
Next, you will have to update the package.json file scripts section by adding/updating the test script.
{
"scripts": {
"test": "react-script test"
}By default, jest will look for files with .test.js suffix or files in the __test__ folder but you can also modify this in the config file. Next, it's time to write our tests.
Say we had a simple button component.
// Fallback click handler if none is provided
function logClick() {
console.log("Button clicked");
}
// Button component
export default function Button({label, onClick=logClick}) {
return <button onClick={onClick}>{label}</button>
}Here's an example of how you can test this component:
import { render, fireEvent, screen } from '@testing-library/react';
import Button from './Button';
test('renders button correctly', () => {
render(<Button label="Click me" />);
const buttonElement = screen.getByText('Click me');
expect(buttonElement).toBeInTheDocument();
});
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn(() => console.log("Prop-Handler called"));
render(<Button label="Click me" onClick={handleClick} />);
const buttonElement = screen.getByText('Click me');
fireEvent.click(buttonElement);
expect(handleClick).toHaveBeenCalledTimes(1); // Same as expect(handleClick.mock.calls).toHaveLength(1)
});In the first test, we render the button component using the render function and use the getByTextno query to find the button element. There are different types of type queries, "get", "find", and "query", their difference being whether a query will throw an error if no element is found or it will return a promise. To learn more about this, click here.
Then we assert that the button is present in the document using the toBeInTheDocument matcher. This matcher is from @testing-library/jest-dom. This ensures that the component renders correctly and displays the expected text.
There are different types of matchers. Some of the most common ones are:
toBe- used to test the exact equality of values by usingObject.is()method.toEqual- used to test the equality of an object by recursively checking every field in the object.toBeNull- used to check if the value is null.toBeUndefined- used to check if the value is undefined.toBeFalsy- used to check if the value is anything that an if statement can be treated as false.toBeTruthy- used to check if the value is anything that an if statement can be treated as true.toMatch- used to check if strings are equal.toContain- used to verify if an array or iterable contains a particular item among its elements.toThrow- used to check that executing a function throws an error after meeting specified conditions.not- used to test of opposite of matcher, for example,not.toMatch.
You can even customize matchers to suit your specific use case. To learn more about matchers, link here.
In the second test, we simulate a click event on the button using fireEvent.click and assert that the onClick, handler is called using a Jest mock function. Mock functions are described in the next section. This test verifies that the component behaves as expected when user interactions occur, ensuring the correct actions are triggered. You can fire other events on components such as blur, focus, change, submit, reset, and many more.
Testing user interactions and component behavior is crucial to guarantee that your UI components function properly and provide a smooth user experience. By simulating user actions and making assertions about the component's state and output, you can catch potential issues early in the development process.
Advanced Jest Techniques: Mocking, Stubbing, and Spying
Jest provides features for mocking, stubbing, and spying on functions and modules. These techniques allow you to isolate the code under test and control the behavior of dependencies.
Mocking is useful when you want to replace a real implementation with a fake one. These are called mock functions. Jest provides the jest.mock function to create a mock version of a module or function. Here's an example:
// api.js
export const fetchData = async () => {
const response = await fetch("/app/user");
return response.json();
};import { fetchData } from './api';
// Mock the module
jest.mock('./api', () => ({
fetchData: jest.fn(),
}));
const resp = { id: 1, name: 'John' };
fetchData.mockResolvedValue(resp);
test('test fetch data function', async () => {
const data = await fetchData();
expect(data).toEqual(resp);
});In this example, we mock the api module using jest.mock. Note that when mocking a module in jest you need to define the functions within the module that you need to mock. Then we import the fetchData function from the mocked module and use mockResolvedValue to specify the value it should resolve with. Mocking is particularly useful when dealing with external dependencies or when you want to simulate specific scenarios without relying on the actual implementation. This can be useful in different scenarios, for example, simulating API calls thus avoiding writing slow tests.
In the example above, we are also testing an asynchronous function using a promise chain. There are also other ways to test asynchronous functions, not just by using a promise chain. Here is the same block of code using async/await:
// Your test code that uses the mocked fetchData function
test('test fetch data function', async () => {
const data = await fetchData();
expect(data).toEqual(resp);
});Using async/await in your test code makes your test code more readable, but whatever you decide to use can be determined by preference or company standards. Another way is to use the .resolves matcher to test what the promise of an asynchronous function resolves to. The .resolves matcher is used to test whether a promise is fulfilled and fails if a promise is not fulfilled. The .rejects matcher checks for when a promise is not fulfilled and fails if the promise is fulfilled.
test('test fetch data function', async () => {
const data = fetchData();
expect(data).resolves.toEqual(resp);
});Mock functions are especially useful since they assist testing by removing the implementation of functions, allowing us to capture function calls, parameters for each call, and even instances of class constructors.
Stubbing is very similar to mocking but allows you to replace a specific function or method with a stub implementation. You can use jest.fn to create a stub function:
const calculateTotal = jest.fn(); // Define mock function
// Define return values for first two calls
calculateTotal.mockReturnValueOnce(100).mockReturnValueOnce(200);
// Your test code that uses the stubbed calculateTotal function
test('Test total cal function', () => {
expect(calculateTotal(10)).toBe(100); // true
expect(calculateTotal(20).toBe(200); // true
expect(calculateTotal.mock.calls.length).toBe(2); // number of times called
expect(calculateTotal.mock.calls[0][0]).toBe(10); // first parameter of first call
expect(calculateTotal.mock.calls[1][0]).toBe(20); // first parameter of second call
});Stubbing is handy when you want to control the behavior of a function within the code under test. By replacing the original implementation with a stub, you can define the expected return value or side effects, making your tests more predictable and focused.
You can also do this for modules as well. Here is an example I think is very useful in demonstrating this:
Say we had an API call in our app that used Axios to fetch data from an endpoint. Let us start with an object like the one below with a method that made that call.
import axios from 'axios';
const userService = {
createUser: async (user) => {
const response = await axios.post('/users', user);
return response.data;
}
};
export default userService;We can mock the axios.post method to prevent the test from making an actual API call. Here is how:
import axios from 'axios';
import userService from './userService';
// Mock the entire axios module
jest.mock('axios');
describe('userService', () => {
it('should create a user and return the new user data', async () => {
const newUser = { name: 'Alice' };
const resp = { data: { id: 1, name: 'Alice' } };
axios.post.mockResolvedValue(resp); // Fake data to be returned on resolve
const result = await userService.createUser(newUser);
// Assert
expect(result).toEqual(resp.data);
expect(axios.post).toHaveBeenCalledWith('/users', newUser); // testing endpoint
});
it('should handle errors when creating a user', async () => {
const newUser = { name: 'Alice' };
const errorMessage = 'Network Error';
axios.post.mockRejectedValue(new Error(errorMessage));
// Mocking the reject and throwing an error
await expect(userService.createUser(newUser)).rejects.toThrow(errorMessage);
expect(axios.post).toHaveBeenCalledWith('/users', newUser);
});
});Do note that the above implementation works for all types of requests, not just post requests.
Spying allows you to observe and assert the behavior of a function without modifying its implementation. This also means that we can change the behavior of a function during testing if need be and not have to worry about changing the original function itself. Jest provides the jest.spyOn function for this purpose:
const mathUtils = {
add: (a, b) => a + b,
};
// Creating a spy on add
const addSpy = jest.spyOn(mathUtils, 'add');
// Your test code that uses the spied add function
test('testing the add function', () => {
const results = mathUtils.add(2, 3);
expect(results).toBe(5); // true
expect(addSpy).toHaveBeenCalled();
expect(addSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenCalledWith(2, 3);
jest.mockClear(); // clean up afterwards
});To use jest.spyOn, the test function has to be implemented in an object. jest.spyOn takes two arguments, the object containing the function and a string which is the key of the function inside the object, which in this case is 'add'. The jest.mockClear() is a utility function that restores the original implementation of the add function, that is, cleaning up the spy.
Other utility functions used for cleanup include:
mockResetmockRestore
To learn more about them and other utility functions, visit the mock function API documentation by clicking here.
Spying is useful when you want to verify that a certain function is called with the expected arguments or to track the number of times it is invoked. It allows you to monitor the behavior of the code under test without interfering with its actual implementation.
When deciding which technique to use, consider the following guidelines:
Use mocking when you want to replace an entire module or function with a fake implementation.
Use stubbing when you want to replace a specific function or method with a predefined behavior.
Use spying when you want to observe and assert the behavior of a function without modifying its implementation.
Comprehensive Testing of JavaScript Functions and Modules
Writing comprehensive test suites for JavaScript functions and modules is crucial to ensure their correctness and maintain code quality. Jest provides a simple and intuitive API for writing tests.
When testing functions, focus on covering different scenarios, including edge cases and error handling. Here's an example of testing a function that calculates the sum of an array with error handling:
function sum(numbers) {
if (!Array.isArray(numbers)) {
throw new Error('Input must be an array');
}
return numbers.reduce((acc, curr) => acc + curr, 0);
}
test('returns the correct sum of numbers', () => {
expect(sum([1, 2, 3])).toBe(6);
expect(sum([-1, 0, 1])).toBe(0);
expect(sum([])).toBe(0);
});
test('throws an error for non-array input', () => {
expect(() => sum(123)).toThrow('Input must be an array');
expect(() => sum('abc')).toThrow('Input must be an array');
});In the first test, we check that the sum function returns the correct sum for different input arrays. We test both positive and negative numbers, as well as an empty array, to ensure the function handles various cases correctly.
In the second test, we verify that the function throws an error when passed non-array inputs. Testing error handling is important to ensure that your code gracefully deals with unexpected or invalid inputs.
When testing modules, it's important to test the public interface and ensure that the module behaves as expected. The size of these tests can grow large and long. You can use Jest's describe and it blocks to organize your tests into blocks that test specific modules/functions like below:
import { validateEmail, formatDate } from './utils';
describe('utils module', () => {
describe('validateEmail', () => {
it('returns true for valid email addresses', () => {
expect(validateEmail('[email protected]')).toBe(true);
expect(validateEmail('[email protected]')).toBe(true);
});
it('returns false for invalid email addresses', () => {
expect(validateEmail('invalid-email')).toBe(false);
expect(validateEmail('john@example')).toBe(false);
});
});
describe('formatDate', () => {
it('formats the date correctly', () => {
const date = new Date('2023-05-18');
expect(formatDate(date)).toBe('May 18, 2023');
});
});
});In this example, we use describe blocks to group related tests and it blocks to define individual test cases. We test the validateEmail function with both valid and invalid email addresses to ensure it correctly identifies the validity of the input.
For the formatDate function, we provide a sample date and verify that it formats the date correctly according to the expected output format. Testing both positive and negative cases helps ensure the robustness and reliability of your modules.
Jest also provides a way to define functions for setup and teardown. These are:
beforEach- To be run before each test block.afterEach- To be run after each test block.beforeAll- To be run before all the test blocks.afterAll- To be run after all the test blocks have been executed.
These hooks take both synchronous and asynchronous functions. They handle asynchronous functions by either taking a done parameter or returning a promise. Say we had a setUpDatabase function that returned a promise that will be resolved once setup is done, this is how you would use it in a beforeAll hook.
beforAll(() => {
return setUpDatabase();
});You can also nest these hooks inside describe, it and test blocks. Here is an example of how you would do that:
describe('Outer describe block', () => {
beforeAll(() => { console.log('Outer beforeAll'); });
beforeEach(() => { console.log('Outer beforeEach'); });
afterEach(() => { console.log('Outer afterEach'); });
afterAll(() => { console.log('Outer afterAll'); });
test('Outer test 1', () => {
console.log('Outer test 1');
expect(true).toBe(true);
});
test('Outer test 2', () => {
console.log('Outer test 2');
expect(true).toBe(true);
});
describe('Inner describe block', () => {
beforeAll(() => { console.log('Inner beforeAll'); });
beforeEach(() => { console.log('Inner beforeEach'); });
afterEach(() => { console.log('Inner afterEach'); });
afterAll(() => { console.log('Inner afterAll'); });
test('Inner test 1', () => {
console.log('Inner test 1');
expect(true).toBe(true);
});
test('Inner test 2', () => {
console.log('Inner test 2');
expect(true).toBe(true);
});
});
});From the example above, you can nest different types of set-up and tear-down hooks in one test file and they will be executed. You can also insert other set-up and tear-down hooks inside nested describe blocks. Below is the output of running the test file above:
Outer beforeAll
Outer beforeEach
Outer test 1
Outer afterEach
Outer beforeEach
Outer test 2
Outer afterEach
Inner beforeAll
Outer beforeEach
Inner beforeEach
Inner test 1
Inner afterEach
Outer afterEach
Outer beforeEach
Inner beforeEach
Inner test 2
Inner afterEach
Outer afterEach
Inner afterAll
Outer afterAllConclusion
Jest is a testing framework that provides a rich set of features for testing JavaScript applications. By leveraging Jest's capabilities, you can write effective tests for UI components, utilize advanced techniques like mocking, stubbing, and spying, and create comprehensive test suites for functions and modules.
Remember to focus on covering different scenarios, including edge cases and error handling, and organize your tests using Jest's describe and it blocks for better readability and maintainability.
With the knowledge gained from this topic, you're now equipped to write robust and reliable tests for your JavaScript projects. Put your newfound skills into practice and start building high-quality applications with confidence.
Get ready to dive into some hands-on coding challenges and solidify your understanding of advanced testing techniques.