What is Unit Testing?
Unit testing refers to testing an application by separating it into the smallest possible units and testing them individually. Through unit tests performed on the smallest units (such as functions or methods), you can predict and fix incorrect behaviors and unexpected bugs in the program. When test code is written or modified in advance, unit tests are used to check whether the application is functioning correctly, which improves the application’s architecture and maintainability.
For example, let's assume that you have code that is already developed. In maintenance after development, another developer might refactor the program. If the previous developer didn't write unit tests, the developer doing the refactoring would have to analyze the code from scratch. Even if they complete the analysis and proceed with the refactoring, if the code is modified differently from the original developer’s intent, the refactored code might cause potential errors later on. However, if unit tests were written, the refactoring developer could run the unit tests after refactoring to check for any discrepancies in the expected results and quickly identify areas that need to be fixed.
Now, let's prepare for unit testing together. First, we need to decide where to write the tests. Unit tests are typically applied to pure methods (functions). A pure method (function) is one that always returns the same output (return value) given the same input parameters. With this in mind, when developers implement code thinking about unit tests, there are several benefits.
- The methods (functions) created have clear and specific purposes, which helps in producing more robust APIs.
- Without unit tests, debugging would involve checking logs through the console (
console.log()
), but if unit tests are written to check the output of methods (functions), you can test the changes without additional debugging when modifying the code. - Events are also simplified. After modifying the code, developers do not have to manually trigger events. Unit tests can trigger events, which significantly reduces testing time.
- When the code is modified during test execution, the tests will automatically restart with the modified code, so if there are mistakes, they can be identified immediately.
Testing Libraries
The official React site provides the following testing utilities and libraries for React:
- Enzyme: A JavaScript testing utility for React.
- Jest: A JavaScript testing framework that supports React applications.
- react-testing-library: A lightweight testing utility for React DOM.
- React-unit: A lightweight unit testing library for React.
- Skin-deep: A React testing utility that supports shallow rendering.
- Unexpected-react: A plugin for triggering React components and events.
We will perform testing using Jest and Enzyme.
Jest
Key Features
Jest is a testing library created by the Facebook team, just like React. It can run independently and supports testing any JavaScript code.
The key features of Jest are:
- Matcher: Compares the expected value with the actual returned value from a function call during unit testing and reports success or failure.
- Asynchronous code testing: Allows comparison of results for functions that are processed asynchronously.
- Setup/Teardown configuration: Supports pre- and post-processing for tests and test groups.
- Mock functions: Supports the creation of mock functions and objects for testing.
- Jest utility functions: Utility functions provided by the Jest library.
- Snapshot comparison: Compares whether the rendered output matches the previous version.
Let’s explore this with a sample code.
Preparation
We will explain with a project example using the following tools:
- Yarn (Package Manager)
- RCA (React Create App)
- VS Code (Visual Studio Code)
Once the environment is set up, the preparation for JS testing is complete.
- Although we will use a sample project to conduct the tests, when starting a new project, open VS Code and navigate to the project directory in the Terminal. Then, enter
create-react-app <project-name>
, which will install Jest, the JavaScript testing framework. - Add Jest IntelliSense to VS Code.
- Before running the tests, since RCA (React Create App) already has a test execution script, simply enter
yarn test
to run the tests.
Sample
Let's proceed with testing a simple calculator built with React as a sample.
The test code is located under src/test
, and the file name is written as UnitTest.test.js
. By using the *.test.js
format, Jest will read the file during test execution.
Other naming conventions for files and folders are also used, such as:
- Files with the
.js
extension inside the__test__
folder - Files in the
*.test.js
format - Files in the
*.spec.js
format
Please refer to the Git Repository at the bottom of the article for the sample code.
Matcher
Simple Comparison
This is the most basic test for comparing return values. You can compare strings, numbers, objects, arrays, and even check if the result falls within a desired range or decimal precision.
Below is an example from src/test/UnitTest.test.js
:
import * as calculation from '../calculator/Calculation';
import Calculator from '../calculator/Calculator';
...
it('1. Addition Test', () => {
expect(calculation.applyAddition(3, 5)).toEqual(8);
});
it('2. Subtraction Test', () => {
expect(calculation.applySubtraction(5, 3)).toBeLessThanOrEqual(2);
});
it('3. Multiplication Test', () => {
expect(calculation.applyMultiplication(5, 3)).toBeGreaterThanOrEqual(15);
});
it('4. Division Test', () => {
expect(calculation.applyDivision(6, 4)).toBeCloseTo(1.5);
});
...
- Addition Test: In this test, the calculator’s addition function is called with two parameters, and the result is tested. The result can be verified using either
toEqual()
ortoBe()
. For comparing primitive types,toBe()
is used, whiletoEqual()
is used for comparing objects. For numbers, bothtoBe()
andtoEqual()
work the same, so either can be used. - Subtraction Test: This tests the subtraction function of the calculator. In this case, the result should be 2. The test checks whether the result is less than or equal to 2 using
toBeLessThanOrEqual(2)
. - Multiplication Test: This tests the multiplication function of the calculator. In this case, the result should be 15. The test checks whether the result is greater than or equal to 15 using
toBeGreaterThanOrEqual(15)
. - Division Test: This tests the division function of the calculator. In this case, the result should be 1.5. The test uses
toBeCloseTo(1.5)
to check for close equality, useful for preventing unexpected rounding errors when working with decimals (e.g.,0.33333333 ≒ 0.33333334
).
Truthy and Falsy
This test checks whether a value is truthy or falsy. The following values can be tested:
Value | Matcher | Example |
---|---|---|
null |
toBeNull() |
not.toBeNull() |
undefined |
toBeDefined() |
not.toBeDefined() |
true |
toBeTruthy() |
not.toBeTruthy() |
false |
toBeFalsy() |
not.toBeFalsy() |
describe('Truthiness Test', () => {
const calculator = new Calculator();
calculator.handleClickOperator('+')
it('1. OperatorButtonClick("+")', () => {
expect(calculator.isOperationButtonClicked).toBeTruthy();
});
it('2. ReadyToCalculate', () => {
expect(calculator.readyToCalculate).not.toBeFalsy();
});
});
- OperatorButtonClick("+"): When the "+" button of the calculator is pressed, this tests whether the button is recognized as an operation button. The test uses
toBeTruthy()
to check if the value is truthy. - ReadyToCalculate: This checks whether the calculator is ready to perform calculations. Since the calculator is ready, it returns a truthy value, so the test
not.toBeFalsy()
will pass.
String Comparison
This test checks whether the string being tested matches the expected string.
describe('RemoveLastWord Method Test', () => {
it('1. Remove Last Word Test', () => {
let calculator = new Calculator();
let removedSentence = calculator.removeLastWord('Words : Remove a Last Words', 'Word');
expect(removedSentence).toMatch('Words : Remove a Last s');
})
});
- Remove Last Word Test: The
removeLastWord(string): string
method removes the last occurrence of a given substring from a string. In this case, the substring'Word'
is removed from the sentence, and the result is expected to match'Words : Remove a Last s'
. The test usestoMatch()
to check if the string matches the expected pattern. This method also supports regular expressions, so you could use it like this:
expect(removedSentence).toMatch(/^[a-zA-Z]+ : [a-zA-Z]+ a [a-zA-Z]+ s/);
Setup Teardown
Next, let’s introduce setup and teardown functions, which are used to handle pre- and post-processing before and after running tests. These are used when tests require specific preparation or cleanup.
Setup and Teardown Functions:
- afterEach(name, function, [timeout]): Runs after each individual test.
- afterAll(name, function, [timeout]): Runs after all tests have been completed.
- beforeEach(name, function, [timeout]): Runs before each individual test.
- beforeAll(name, function, [timeout]): Runs before all tests have been executed.
The function
parameter is the function that will be executed for setup or teardown. The timeout
is optional and can be used to specify the timeout for the function execution. If no timeout is provided, the default is 5 seconds.
Test Scope
When you have many tests in one file, it’s helpful to group similar tests together and set up different setup/teardown for each group. This can be done using the describe()
function, which allows you to specify a scope for each group of tests.
Here’s how you can organize tests by scope:
beforeAll('beforeAll', () => {
...
});
afterAll('AfterAll', () => {
...
});
beforeEach('beforeEach', () => {
...
});
afterEach('afterEach', () => {
...
});
describe('Test Group 1', () => {
beforeAll('beforeAll', () => {
...
});
afterAll('AfterAll', () => {
...
});
beforeEach('beforeEach', () => {
...
});
afterEach('afterEach', () => {
...
});
it('Test 1-1', () => {
...
});
});
describe('Test Group 2', () => {
beforeAll('beforeAll', () => {
...
});
afterAll('AfterAll', () => {
...
});
beforeEach('beforeEach', () => {
...
});
afterEach('afterEach', () => {
...
});
it('Test 2-1', () => {
...
});
});
Although it might be hard to grasp the order of execution just from the code, the sequence follows the structure laid out above. The functions will be called in the order of beforeAll()
, beforeEach()
, the tests, and then afterEach()
and afterAll()
in reverse order.
Snapshot Testing
Snapshot testing is useful for ensuring that your components' DOM structures remain consistent over time. It allows you to take a "snapshot" of a component's output and compare it with future versions to check for unintended changes.
First, you need to install the react-test-renderer
package for snapshot testing.
yarn add react-test-renderer
After installing the package, you can proceed to write the snapshot test. In the test file, you import the component and convert its DOM structure into a JSON format to register it as a snapshot. The first time you run the test, Jest will generate a snapshot based on the current component structure. On subsequent runs, it will compare the new structure with the existing snapshot.
import React from 'react';
import Calculator from '../calculator/Calculator';
import renderer from 'react-test-renderer';
it('renders correctly', () => {
const tree = renderer
.create(<Calculator />)
.toJSON();
expect(tree).toMatchSnapshot();
});
The snapshot will be saved in a directory named __snapshots__
at the same level as your test file. It will be named YourTestFileName.snap
. If the component changes in the future, Jest will compare the new output with the snapshot and alert you if there is any difference.
Enzyme
Jest is a testing framework for JavaScript and supports testing all JavaScript code. Enzyme is a testing library specifically for React. It cannot be used independently and must be used alongside Jest to conduct tests. The reason for using Enzyme in testing is to perform DOM testing, and it allows you to manipulate components, trigger events, and compare the props or state values of the rendered component or its child components.
Enzyme provides two primary rendering methods:
- Shallow Rendering: Renders only the component being tested, not its children. This is ideal for unit testing.
- Full Rendering: Renders the entire component tree, including child components. This is useful for integration tests or when you need to test component interactions.
Enzyme Setup
You need to add 2 modules to use Enzyme.
yarn add enzyme
yarn add enzyme-adapter-react-16
After installing, create a setupTests.js
file at the same directory level as src
and configure Enzyme like this:
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
Now you're ready to use Enzyme in your tests!
Shallow Rendering
Shallow Rendering refers to the technique where only the specified component is rendered, without affecting any child components or instances that the component references. This means you can perform tests on the component in isolation, without rendering or interacting with its children.
To perform Shallow Rendering, you can render a component like this: shallow(<Calculator/>)
. In the example below, only the Calculator
component is rendered and tested.
Method Call
The following is a method called when a button event is triggered. The test compares the values of the variables and state within the instance after the method is called.
import React from 'react';
import { shallow } from 'enzyme';
import Calculator from '../calculator/Calculator';
describe('Shallow Rendering - Button Event Testing', () => {
const wrapper = shallow(<Calculator />);
beforeAll(() => {
wrapper.instance().handleClickNumber(1);
});
it('1. handleClickNumber Test', () => {
expect(wrapper.instance().formulaExpression).toBe('1');
});
it('2. Display State Component Test', () => {
expect(wrapper.state().displaiedValue).toBe('1');
});
});
- handleClickNumber Test: This test checks if the method called with the parameter
1
correctly updates the temporary variable storing the calculator expression to'1'
. - Display State Component Test: This test checks if, after calling the method with parameter
1
, the value displayed on the calculator screen (i.e., thestate.displaiedValue
) changes to'1'
.
Method Flow
You can also test the flow of multiple methods interacting with each other. Here's an example of testing a sequence of button presses on the calculator.
describe('Shallow Rendering - Input Testing', () => {
beforeAll(() => {
wrapper.instance().handleClickNumber(1);
wrapper.instance().handleClickOperator('+');
wrapper.instance().handleClickNumber(3);
wrapper.instance().handleClickOperator('=');
});
afterEach(() => {
const wrapper = shallow(<Calculator />);
wrapper.instance().handleClickOperator('CE');
});
it('1. Calculation Result Test', () => {
expect(wrapper.instance().formulaExpression).toMatch('1+3=');
});
it('2. Calculation Result Set Test', () => {
expect(wrapper.state().formulas[0]).toMatch('1+3=4');
});
});
Before running the tests, we set up the calculator functionality using beforeAll()
.
This setup runs before the two tests within describe('Shallow Rendering - Input Testing', () => {...})
are executed.
- Calculation Result Test: This test checks if the calculator expression entered during the setup is properly recorded.
- Calculation Result Set Test: After the first test is completed,
afterEach()
will start, and thehandleClickOperator
method will be called with the parameter'CE'
. This test checks whether the calculator's calculation result expression is correctly added to theformulas
array in the state.
Full Rendering
Full rendering is used to render all components that interact with or reference the DOM. Unlike shallow rendering, it tests both the component and its child components by rendering them into the actual DOM.
Child Component
Below are two test cases that check if the state of the Calculator component is updated correctly and if the props of the child component are updated according to the changed state.
describe('Full DOM Rendering - Child Component Testing', () => {
const wrapper = mount(<Calculator />);
let tempFormulas = [];
tempFormulas.push('TestFormulas');
beforeAll(() => {
wrapper.instance().setState({ formulas: tempFormulas });
wrapper.update();
});
it('1. ParentComponent setState Test', () => {
expect(wrapper.state().formulas[0]).toMatch('TestFormulas');
});
it('2. ChildComponent props Check', () => {
const childWrapper = wrapper.find('ResultBoard');
const formulas = childWrapper.props().formulas;
expect(formulas[0]).toMatch('TestFormulas');
});
});
Before running the tests, the state of the rendered component is changed using beforeAll()
.
- ParentComponent setState Test: This test checks whether the updated state value is correctly reflected.
- ChildComponent props Check: This test verifies that the updated state is properly reflected in the props of the child component,
ResultBoard
.
Event Simulation
You can also simulate events in child components and ensure they affect the parent component.
describe('Full DOM Rendering - Event Simulation', () => {
const wrapper = mount(<Calculator />);
let tempFormulas = [];
tempFormulas.push('TestFormulas');
beforeAll(() => {
wrapper.instance().setState({ formulas: tempFormulas });
wrapper.update();
});
it('1. Clear History Event Test within ChildComponent', () => {
let childWrapper = wrapper.find('ResultBoard');
childWrapper.find('Icon').simulate('click');
childWrapper = wrapper.find('ResultBoard');
expect(childWrapper.props().formulas).toHaveLength(0);
});
});
The state of the component is modified using beforeAll()
.
Then, an event simulation is triggered by clicking the Icon
of the child component ResultBoard
, which simulates a button event to reset all results. After the event occurs, the test checks whether the props of the child component have been properly reset.
References
Jest
Enzyme
React
Regular Expression
Git
Below is the example Git Repository for the sample.