Unit Testing

Mirza Leka
10 min readAug 31, 2024

--

This article will teach you the basic concepts of unit testing, why it’s essential in software development & how to test JavaScript apps using Jest.

What is Unit Testing?

Unit testing is the process of testing the smallest functional unit of code, such as functions, modules, or components.
Why is this important?

In software development, things change over time. New features are constantly added, bugs are fixed, and change requests are common.
That’s why it’s crucial to write tests to verify the code quality and ensure it is correct when changes are made.

Popular testing tools in the JavaScript ecosystem:

  • Testing Frameworks
    Jest, Jasmine, Mocha & Chai
  • API Testing
    Supertest
  • Load Testing
    Load Test, Artillery
  • Mocking and Stubbing
    Sinon
  • E2E Testing
    WebDriver, Puppeteer, Cypress

Project Setup

To get started, initialize the NPM project:

> npm init -y

Then install Jest:

> npm i jest

Now open the package.json file and add jest to the script you’ll run. I used the built-in test script:

{
"name": "unit-testing",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"jest": "^29.7.0"
}
}

You'll type npm test in the terminal every time you want to run the test. This will trigger jest. Jest will look for any file that ends with .test.js and run a test.

First Unit Test

To get started, you need a code to test.
Create a simple math function:

// sum.js

function sum(a, b) {
return a + b;
}

module.exports = sum;

Then, create a test file and write your test:

// sum.test.js

test('should add up numbers', () => {

const firstNumber = 2;
const secondNumber = 3;

const addition = sum(firstNumber, secondNumber);

expect(addition).toBe(5);
});

Now run the test command to verify that it works:

> npm test

PASS ./sum.test.js
√ should add up numbers (3 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.489 s, estimated 1 s

Clarification:

  • test() is the keyword that the testing framework (Jest) recognizes as a unit test.
  • should add up numbers is the test description. Every test must have a description, and it’s a common practice for the description to begin with the word should.
  • () => {…} is a callback function where you call the functions/modules you are testing.
  • The except() function is used to assert the outcome against the desired toBe() effect. E.g. expect(apple).toBe(fruit).

Matchers

You can have multiple expect() functions within a single test and different types of matches.

toBe()

The function toBe() is a matcher used to verify that one value equals the other.

test('should compare names', () => {

const name1 = 'Armin'
const name2 = 'Armin'

expect(name1).toBe(name2); // Passed
});

If you were to compare objects or arrays, toBe() would fail:

test('should compare objects', () => {

const user1 = { name: 'Mirza' }
const user2 = { name: 'Mirza' }

expect(user1).toBe(user2);
});
> npm test

Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total

In this comparison, using the toEqual() matcher is better.

toEqual()

test('should compare objects', () => {

const user1 = { name: 'Mirza' }
const user2 = { name: 'Mirza' }

expect(user1).toEqual(user2); // Passed
});

toBeGreaterThan() & toBeLessThan()

These matchers are used to compare number values.

test('should compare ages', () => {

const childAge = 10;
const adultAge = 20;

expect(childAge).toBeLessThan(adultAge); // Passed
expect(adultAge).toBeGreaterThan(childAge); // Passed
});

Not

The not is a modifier that lets you test the opposite outcome:

test('should not equal', () => {

const name1 = 'Sead'
const name2 = 'Alen'

expect(name1).not.toBe(name2); // Passed
});

toBeTruthy() & toBeFalsy()

These matchers are used to assert the value evaluates to true or false.

test('should return true if empty', () => {

const name = ''
const isEmpty = name.length === 0; // true

expect(isEmpty).toBeTruthy(); // Passed
});

test('should return false if not empty', () => {

const name = 'Faruk'
const isEmpty = name.length === 0; // false

expect(isEmpty).toBeFalsy(); // Passed
});

toThrow()

In this example, the code will throw an error if the number being divided is zero:

function division(a, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed!')
}

return a / b;
}

toThrow() matcher is used to test exceptions that will be thrown.

test('Should throw an error when dividing by zero', () => {
const firstNumber = 5;
const secondNumber = 0;

expect(() => division(firstNumber, secondNumber)).toThrow(Error);
// Passed
});

Additionally, you can also test that the expected error message will be returned:

test('Should throw an error when dividing by zero', () => {
const firstNumber = 5;
const secondNumber = 0;

const errorMessage = 'Division by zero is not allowed!';

expect(() => division(firstNumber, secondNumber)).toThrow(Error);
// Passed
expect(() => division(firstNumber, secondNumber)).toThrow(errorMessage);
// Passed
});

Check the official Jest docs for the complete list of matcher functions.

Test Suites

A common practice when writing tests is to organize them into suites.
This is done using the describe block. The describe block contains multiple tests:

describe('Additions', () => {

test('should add two numbers', () => {
const firstNumber = 2;
const secondNumber = 3;

expect(firstNumber + secondNumber).toBe(5);
});

})

describe('Multiplications', () => {

test('should multiply two numbers', () => {
const firstNumber = 2;
const secondNumber = 3;

expect(firstNumber * secondNumber).toBe(6);
});

})

As well as other describe blocks:

describe('Additions', () => {

describe('Additions with positive numbers', () => {
test('should add two numbers', () => {
const firstNumber = 2;
const secondNumber = 3;

expect(firstNumber + secondNumber).toBe(5);
});

test('should add three numbers', () => {
const firstNumber = 2;
const secondNumber = 3;
const thirdNumber = 4;

expect(firstNumber + secondNumber + thirdNumber).toBe(9);
});
});


describe('Additions with negative numbers', () => {
test('should add two numbers', () => {
const firstNumber = -2;
const secondNumber = 3;

expect(firstNumber + secondNumber).toBe(1);
});
})

});

Organization within tests

It’s a common practice to organize a test into three parts:

  • Arrange
    This is where you prepare data for testing and the expected result.
  • Act
    In the act section, you call the function you’re testing by providing the data you prepared earlier.
  • Assert
    In the final section, you verify the received result (actual) is the same as the result you’re expecting.
test('should add up numbers', () => {

// Arrange
const firstNumber = 2;
const secondNumber = 3;
const expected = 5;

// Act
const actual = sum(firstNumber, secondNumber);

// Assert
expect(actual).toBe(expected);
});

This pattern is also referred to as Triple-A Testing.

Before & After

You can also use the before and after hooks to prepare data for testing.
The beforeEach hook is executed before each test within the test suite.

describe('Multiplications', () => {
let firstNumber;
let secondNumber;

// Sets default values for each test
beforeEach(() => {
firstNumber = 5;
secondNumber = 3;
});

test('should multiply two numbers', () => {
expect(firstNumber * secondNumber).toBe(15);
});

test('multiplication with zero is always zero', () => {
secondNumber = 0;
expect(firstNumber * secondNumber).toBe(0);
});
});

This is a good place to set the default values for each test or mock value.
As you can see, the values set in beforeEach blocks can be overridden within the tests.

The same applies to beforeAll block that runs once before all tests in the suite:

describe('Multiplications', () => {
let firstNumber;
let secondNumber;

// Runs once before all tests
beforeAll(() => {
firstNumber = 5;
secondNumber = 3;
});

The afterEach and afterAll blocks, as the name applies, run after each or after all tests in the suite.

describe('Multiplications', () => {
let firstNumber;
let secondNumber;

// Runs once after all tests
afterAll(() => {
/* Dispose resources */
});

// Runs after each test
afterEach(() => {
/* Reset values */
});

These can be useful when:

  • Resetting global variables changed in the test.
  • Disposing of resources (such as closing database connections).
  • Rerunning the suite after the existing has finished, etc.

Prioritizing Tests

When testing, you can choose to run some tests and ignore the other:

test.only('should evaluate to true', () => {

const condition = 100 > 10;

expect(condition).toBeTruthy();
});

The phrase test.only only runs this test in the entire suite. Others are skipped. This can be used multiple times within the suite:

test.only('should evaluate to true', () => {

const condition = 100 > 10;
expect(condition).toBeTruthy();
});

test.only('should evaluate to false', () => {

const condition = 100 > 1000_000;
expect(condition).toBeFalsy();
});

Another way to exclude tests from running is to purposely skip them using the x prefix:

xtest('this test will be skipped', () => {

const condition = 100 > 10;
expect(condition).toBeTruthy();
});

Testing Asynchronous Code

This is where things get trickier. Unlike synchronous code, which executes line by line, in the async world, code executes in the back without pausing program execution, causing unpredictable behaviors.

The function I prepared for testing is slightly more complex than the examples used prior.

// transaction.js

async function mapTransactionAsync(transaction) {

let mappedTransaction = {};

if (transaction.transferType === 'international') {
// international transfer
mappedTransaction.account = transaction.account;
mappedTransaction.SWIFT = await getSWIFTCode(transaction.currency);
mappedTransaction.currency = transaction.currency;

const transferFee = 1.015;
mappedTransaction.amount = transaction.amount * transferFee;

} else {
// local transfer
mappedTransaction.account = transaction.account;
mappedTransaction.SWIFT = null;
mappedTransaction.currency = 'BAM';
mappedTransaction.amount = transaction.amount;
}

return mappedTransaction;
}

module.exports = {
mapTransactionAsync,
}
// swift.js

async function getSWIFTCode(currency) {

if (currency === 'BAM') {
return Promise.reject('Unable to generate SWIFT!')
}
return Promise.resolve('AAAA-BB-CC-789')
}

module.exports = { getSWIFTCode }

Based on the provided transaction details, the code above:

  • maps a new transaction
  • generates a swift code
  • adds a fee to the amount

Async Test Local Transaction

Because the function being tested returns a promise, it’s necessary for the test to make use of async & await to wait for the promise to resolve:

// transaction.test.js

test('should map local transaction', async () => {
// Arrange
const transaction = {
account: '98765',
currency: 'BAM',
amount: 500,
transferType: 'local',
};

const expected = {
account: '98765',
currency: 'BAM',
amount: 500,
SWIFT: null
}

// Act
const actual = await mapTransactionAsync(transaction);

// Assert
expect(actual).toEqual(expected); // Passed

// Asserting the SWIFT was not generated for the local payment
expect(actual.SWIFT).toBeNull(); // Passed
});

Async Test International Transaction

In the case of the international transaction, the expected result should have a SWIFT code and fee added to the amount:

// transaction.test.js

test('should map international transaction', async () => {
// Arrange
const transaction = {
account: '98765',
currency: 'USD',
amount: 500,
transferType: 'international',
};

const expectedSWIFT = 'AAAA-BB-CC-789';
const expectedAmount = transaction.amount * 1.015;

// Act
const actual = await mapTransactionAsync(transaction);

// Assert
expect(actual.SWIFT).toEqual(expectedSWIFT); // Passed
expect(actual.amount).toEqual(expectedAmount); // Passed
});

Async Test Exception Handling

One should always prepare the tests for possible exceptions. Let’s simulate what would happen if SWIFT creation failed:

// swift.js

async function getSWIFTCode(currency) {

if (currency === 'BAM') {
return Promise.reject('Unable to generate SWIFT!')
}
return Promise.resolve('AAAA-BB-CC-789')
}

module.exports = { getSWIFTCode }
// swift.test.js

const { getSWIFTCode } = require('./swift');

test('should fail to generate SWIFT for BAM currency', async () => {
// Arrange
const transaction = {
account: '98765',
currency: 'BAM',
amount: 500,
transferType: 'local',
};

// Act
try {
await getSWIFTCode(transaction);
} catch (error) {
// Assert
expect(error).toBe('Unable to generate SWIFT!');
}
});

Jest also supports fake timers that can simulate the duration of time in the test. Check the docs for more.

Mocking Tests

Unit tests should test the code in isolation and thus not cause any side effects, such as:

  • generates entities
  • inserting data into the database
  • invoking APIs
  • emailing users, etc.

When the code you’re testing (mapTransactionAsync()) has a dependency on the functionalities that create side effects (getSWIFTCode()), the recommended practice is to apply the mocking techniques.

Mocks help us simulate the desired behavior without executing the code, like generating real SWIFT. Now, let’s mock the getSWIFTCode() function to always return a successful result.

// transaction.test.js

const { mapTransactionAsync } = require('./transaction');

jest.mock('./swift', () => ({
getSWIFTCode: jest.fn().mockResolvedValue('Mocked SWIFT Code'),
}));

With this in place, the getSWIFTCode() function will not generate an actual SWIFT when called in the test but instead always return the mocked result.

test('should map international transaction', async () => {
// Arrange
const transaction = {
account: '98765',
currency: 'USD',
amount: 500,
transferType: 'international',
};

// this needs to be adjusted to align with the mock
const expectedSWIFT = 'Mocked SWIFT Code';
const expectedAmount = transaction.amount * 1.015;

// Act
const actual = await mapTransactionAsync(transaction);

// Assert
expect(actual.SWIFT).toEqual(expectedSWIFT); // Passed
expect(actual.amount).toEqual(expectedAmount); // Passed
});
// Alternatively you can mock the function to always fail:

jest.mock('./swift', () => ({
getSWIFTCode: jest.fn().mockRejectedValue('Error!'),
}));

Once again, Jest supports a wide range of mocks and spies. Check out the Jest docs for more.

Test Coverage

Another helpful feature when testing is the test coverage. The test coverage shows the percentage of the tested code in the application, which is typically used for

  • better quality
  • analytics
  • approving pull requests

To get test coverage, add the coverage script to the package.json file:

  "scripts": {
"test": "jest",
"test-coverage": "jest --coverage"
},

Now run the script:

> npm run test-coverage

----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------------|---------|----------|---------|---------|-------------------
All files | 90 | 75 | 66.66 | 90 |
sum.js | 50 | 100 | 0 | 50 | 2
swift.js | 75 | 50 | 100 | 75 | 4
transaction.js | 100 | 100 | 100 | 100 |
----------------|---------|----------|---------|---------|-------------------

This can also be visualized in the web view.
Expand the newly generated coverage directory in the root folder and open the index.html file in the web browser.

This should open the test coverage page of the whole application.

You can peek into each section by clicking on the test.
Looking at the swift.js file, the code highlighted in red is indicated as not covered by tests.

Wrapping up

This article explains how software testing helps ensure code quality.
Unit testing sits at the bottom of the testing pyramid, as it is used to test functionalities in isolation. Unit tests can be used to test web components in frameworks like React, Angular, or Vue. They can also run tests CI pipelines on each push or pull using GitHub Actions.

That’s all from me. Be sure to follow me for more awesome content!

--

--

Mirza Leka

Web Developer. DevOps Enthusiast. I share my experience with the rest of the world. Follow me on https://twitter.com/mirzaleka for news & updates #FreePalestine