Unit Testing with Jest and Integration with GitHub Actions

How to approach Unit Testing with Jest and continuously monitor within development workflow?

Eleonora Belova
7 min readJan 23, 2024

--

What is Unit Testing?

Unit Testing is software testing technique where individual units or components of a software application are tested in isolation from the rest of the system.

The purpose of unit testing is to validate that each unit of the software performs as designed.

A unit in this context is the smallest testable part of an application, typically a function, a method or a class.

Best Practices for Unit Testing

  1. Unit tests should focus on smallest units of code, therefore test cases should ensure that individual functions, methods, classes work as expected.
  2. Keep test cases independent. Ensure that failures in one test will not cause failure in another one.
  3. Focus on verifying a single concept or behavior. Do not test multiple functionalities within a single test case. It will make it significantly harder to troubleshoot in case of a problem.
  4. Ensure that your test cases cover edge scenarios, boundary values and exceptional conditions.

Blog: https://medium.com/@kaanfurkanc/unit-testing-best-practices-3a8b0ddd88b5

Getting started with Jest

Repo with source code: https://github.com/nora-weisser/testing-with-jest

First, you need to have Node.js installed on your machine. You can download it from https://nodejs.org/.

Initialize a new Node.js project by running the following command in your terminal:

npm init -y

Install Jest using npm:

npm install --save-dev jest

Open your package.json file and add the following scripts section and past this code into scripts section:

"scripts": {
"test": "jest"
}

Write your first test case using Jest

Let’s consider a function that filters an array of numbers to include only even numbers (numberFilter.js).

function filterEvenNumbers(numbers) {
return numbers.filter((number) => number % 2 === 0);
}

module.exports = filterEvenNumbers;

Then create another file called numberFilter.test.js and write several test cases to cover major functionality:

const filterEvenNumbers = require('./numberFilter');

test('Filter even numbers', () => {
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const filteredNumbers = filterEvenNumbers(numbers);
expect(filteredNumbers).toEqual([2, 4, 6, 8, 10]);
});

test('Filter even numbers in an empty array', () => {
const numbers = [];
const filteredNumbers = filterEvenNumbers(numbers);
expect(filteredNumbers).toEqual([]);
});

test('Filter even numbers in an array with no even numbers', () => {
const numbers = [1, 3, 5, 7, 9];
const filteredNumbers = filterEvenNumbers(numbers);
expect(filteredNumbers).toEqual([]);
});

test('Filter even numbers in an array with all even numbers', () => {
const numbers = [2, 4, 6, 8, 10];
const filteredNumbers = filterEvenNumbers(numbers);
expect(filteredNumbers).toEqual([2, 4, 6, 8, 10]);
});

In this example, target function is important to the test file and four test cases created, where:

  1. Test case 1 validates if the function filters even numbers in array containing all types of numbers in the array
  2. Test case 2 considers an empty array as an input and validates if the function returns an empty array as a result.
  3. Test case 3 validates filtering in the array which doesn’t contain even numbers at all.
  4. Test case 4 validates if the function returns all the even numbers in array if the input array contains only even numbers.

All these test cases contain data (const numbers), function execution (const filteredNumbers = filterEvenNumbers(numbers)) and assertion. Jest uses “matchers” to let you test values in different ways. You can find the full list of matchers in the official documentation: https://jestjs.io/docs/expect

Run the tests using:

npm test

How to organize test cases

Let’s consider other functions:

  • The addItem function adds an item to the list.
  • The clearList function removes all items from the list.
  • The getList function returns the whole list with items.

You can find the implementation below:

// itemManager.js

let itemList = [];

function addItem(item) {
itemList.push(item);
}

function clearList() {
itemList = [];
}

function getList() {
return itemList;
}

module.exports = {
addItem,
clearList,
getList,
};

In order to isolate test cases, we will use beforeEach and afterEach hooks to set up and clean up the test environment.

Now, let’s create a file named itemManager.test.js with the following content:

const { addItem, clearList, getList } = require('./itemManager');

// Set up the test environment
beforeEach(() => {
addItem('Item 1');
addItem('Item 2');
addItem('Item 3');
});

// Clean up the test environment
afterEach(() => {
clearList();
});

// Test case 1: Adding an item to the list
test('Add item to the list', () => {
addItem('New Item');
expect(getList()).toContain('New Item');
});

// Test case 2: Clearing the list
test('Clear the list', () => {
clearList();
expect(getList()).toHaveLength(0);
});

// Test case 3: Getting the list
test('Get the list', () => {
const list = getList();
expect(list).toHaveLength(3);
expect(list).toEqual(['Item 1', 'Item 2', 'Item 3']);
});

In this example:

  • The beforeEach hook is used to set up the initial state by adding three items to the list before each test case.
  • The afterEach hook is used to clean up the state by clearing the list after each test case.

This ensures that each test case runs in an expected initial state and doesn’t interfere with other tests. The addItem, clearList, and getList functions from itemManager.js are used to perform actions and assertions in the test cases.

These hooks provide a convenient way to manage the state of your tests, especially when you need to set up or tear down common resources before and after test cases.

Another trick to organise test cases is Arrange-Act-Assert (AAA) Pattern: Arrange (setup a test environment, variables or any other information needed for test execution), Act (perform the action being tested) and Assert (verify the expected result). It helps with readability and improves the ease of maintenance process.

Blog: https://medium.com/@pjbgf/title-testing-code-ocd-and-the-aaa-pattern-df453975ab80

! Don’t forget to write clear and descriptive test names. It makes it easier to understand the purpose of each test case and facilitate maintenance.

Implement Continuous Integration (CI)

CI/CD pipeline integration ensures that tests are automatically run whenever changes are made to the codebase, providing rapid feedback to developers. Putting it to our particular situation: we would like to set up the pipeline that will always run in the feature branch before merging into main to make sure that it doesn’t introduce issues. It will be automated process which will conduct continuous monitoring during development. Our Jest tests should be successful for the feature branch before merging it to main.

To implement CI/CD pipeline I will use GitHub Actions. GitHub Actions has a very generous free tier for public repositories. If you’re using a public repository, GitHub Actions is completely free of charge.

Extra resource: GitHub Actions Tutorial — Basic Concepts and CI/CD Pipeline with Docker (video)

  1. Create a branch

Let’s first create a feature branch called test/github-actions to test out our first GitHub Actions workflow. You can do that by executing the following in your terminal:

git checkout -b test/github-actions

The feature branch should be created and you automatically should be checked out.

2. Create GitHub Actions directory

For GitHub Actions pipeline we have to create ymlfile and specify which step need to be taken to our pipeline. Yml file will be created in.github/workflows directory.

Run following command in terminal:

mkdir -p .github/workflows
touch .github/workflows/main.yml

3. Implement a pipeline by defining triggers for GitHub Actions

Our goals:

  • The workflow should be triggered only on the pull_request event targeting the main branch.
  • The status check should be automatically created on GitHub when this workflow runs as part of a pull request. The PR will not be mergeable until this status check passes.

Now, whenever we open a pull request, GitHub Actions will run the Jest tests and report the status back to the pull request. The PR can only be merged if the tests pass.

You can find implementation down below:

name: Run Tests

on:
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '14'

- name: Install dependencies
run: npm install

- name: Run Jest tests
run: npm test

4. Create a PR to test GitHub Actions pipeline.

Push your changes into remote branch and open a PR. Check out if the checks have been applied.

This is what you should see when reviewing a PR: your PR into main branch triggered Jest test cases execution and reported results back to pull request.

You can click on the Details link to navigate to the CI/CD pipeline to check the steps that you just defined in the .yml file. That status page looks like this:

Returning to the pull request’s status page, we can see that all GitHub Actions checks have been successfully completed. As a result, the pull request is ready to be merged without encountering any issues.

Conclusion

In this article we’ve gone through the aspects of writing unit tests using the Jest framework for ensuring the reliability and correctness of JavaScript functions. I uncovered some insights into structuring test files, creating meaningful test cases, and running tests using Jest.

Additionally, the integration of GitHub Actions has been showcased as an effective tool to automate the testing workflow. The configured workflow ensures that every push triggers a set of tests, maintaining the codebase’s integrity.

The complete source code can be found here.

Jest testing approach and integration with GitHub Actions have been nicely explained during Women Who Code London Workshop within Contributors Study Group. You can find video here.

--

--

Eleonora Belova

Passionate QA Engineer. Love automating testing routines, fan of exploratory testing. Enjoy volunteering for professional communities.