Unit Testing with Jest and Integration with GitHub Actions
How to approach Unit Testing with Jest and continuously monitor within development workflow?
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
- Unit tests should focus on smallest units of code, therefore test cases should ensure that individual functions, methods, classes work as expected.
- Keep test cases independent. Ensure that failures in one test will not cause failure in another one.
- 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.
- 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:
- Test case 1 validates if the function filters even numbers in array containing all types of numbers in the array
- Test case 2 considers an empty array as an input and validates if the function returns an empty array as a result.
- Test case 3 validates filtering in the array which doesn’t contain even numbers at all.
- 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)
- 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 yml
file 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 themain
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.