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.

  1. The methods (functions) created have clear and specific purposes, which helps in producing more robust APIs.
  2. 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.
  3. 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.
  4. 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);
    });
...
  1. 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() or toBe(). For comparing primitive types, toBe() is used, while toEqual() is used for comparing objects. For numbers, both toBe() and toEqual() work the same, so either can be used.
  2. 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).
  3. 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).
  4. 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();
    });
});
  1. 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.
  2. 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');
    })
});
  1. 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 uses toMatch() 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.

./sample_scope.PNG

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');
  });
});
  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'.
  2. Display State Component Test: This test checks if, after calling the method with parameter 1, the value displayed on the calculator screen (i.e., the state.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.

  1. Calculation Result Test: This test checks if the calculator expression entered during the setup is properly recorded.
  2. Calculation Result Set Test: After the first test is completed, afterEach() will start, and the handleClickOperator method will be called with the parameter 'CE'. This test checks whether the calculator's calculation result expression is correctly added to the formulas 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().

  1. ParentComponent setState Test: This test checks whether the updated state value is correctly reflected.
  2. 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.

React + MobX
SPA 라이브러리인 React를 MobX state 관리 라이브러리와 함께 배워봅시다.

References

Jest

https://jestjs.io

Enzyme

https://airbnb.io/enzyme/

React

https://reactjs.org/

Regular Expression

https://regexr.com/

Git

Below is the example Git Repository for the sample.

https://github.com/namoosori/react-blog