Testing in software development: A practical guide
Hunter Johnson
Posted on May 16, 2023
This article was authored by Aasim Ali, a member of Educative's technical content team.
Software testing for a developer involves creating and running tests to identify and isolate defects, bugs, or issues in the code, as well as ensure that the software is reliable, robust, and scalable. By performing various types of testing, developers can improve the overall quality and reliability of the software and ultimately deliver a better product to their users.
Jest is the testing framework for JavaScript. It is good for any JavaScript framework, be it Vue, Angular, React, Meteor.js, Express, jQuery, Laravel, Bootstrap, Node.js, or others. It applies to any kind of testing---client and server, unit and snapshot, and internal and external dependency. This blog is great for software engineers looking to upgrade their skills so they can build better applications in terms of stability and scalability, and it will focus on unit, integration, and snapshot tests only.
We'll cover:
- Testing with Jest
- Types of tests
- Unit tests
- Unit test examples
- Integration tests
- Integration test examples
- Snapshot tests
- Snapshot test examples
- Wrapping up and next steps
Testing with Jest
Welcome to the practical guide to using Jest---a JavaScript testing framework that focuses on simplicity. Quality tests allow for confident and quick iteration, while poorly written tests can provide a false sense of security.
Jest is a test runner that executes tests seamlessly across environments and JavaScript libraries. This blog aims to teach Jest in a framework-agnostic context so that it can be used with different JavaScript frameworks. Jest provides an environment for documenting, organizing, and making assertions in tests while supporting the framework-specific logic that will be tested.
In Jest, matchers are functions that are used to test if a value meets certain criteria. Other than testing the expected flow, Jest also allows testing for errors and exceptions. This is an important part of writing high-quality software. Apart from the various matchers available in Jest, there are additional matchers that extend its functions that are provided by external libraries.
A test checks if Y
is true or false given X
. The X
can be an argument, API response, state, or user behavior, while Y
can be a return value, error, or presence in the DOM. To test, use expect(value)
and a matcher like .toEqual(expectedValue)
. The evaluation of the value in a test is called an assertion, and it’s done using matchers, which are a set of functions accessible via expect
. For example: expect(getSum(1,2)).toEqual(3);
is a way of writing the test in Jest. There are various types of matchers in Jest.
Types of tests
Jest supports several types of tests, but this blog will focus only on the following:
- Unit tests: Jest allows unit tests to be written that verify the functionality of small, individual parts of your code, such as functions and methods.
- Integration tests: Jest also supports integration tests, which verify the interactions between different parts of the code, such as multiple functions or modules.
- Snapshot tests: With Jest, you can easily create snapshot tests, which are used to test the visual output of your code, such as HTML or CSS.
Overall, Jest provides a comprehensive testing solution for JavaScript developers, with support for a wide range of testing types and easy integration with other tools and frameworks.
Unit tests
Although unit tests are the most basic type of test, they play a critical role in ensuring the stability of a system. Unit tests are essential for creating a dependable and resilient testing suite since they are straightforward to write and determine what to test. Additionally, writing unit tests helps to enforce best practices in code.
Unit tests are designed to test individual units of code, such as a single function, conditional statement, or low-level display component. Focusing on one thing at a time ensures that the code behaves as expected, including edge cases and error handling. Unit tests ensure that a small block of code functions well on its own.
Unit test examples
The single responsibility principle states that a function should do only one thing, making testing easier. By separating concerns and creating clean interfaces, test maintenance can be limited to the functions directly related to the changed code. This is important for overall code maintenance and scalability, but it also helps with testing, allowing the focus to remain on one section and one test suite when making changes to the code base. Here are three simple example codes for unit tests in Jest.
1. Testing a function that adds two numbers
function addNumbers(a, b) {
return a + b;
}
test('adds two numbers', () => {
expect(addNumbers(2, 3)).toBe(5);
});
In this example:
- A function
addNumbers
is defined, which takes two numbers as inputs and returns their sum. - A test that uses the
expect
method is written to verify thataddNumbers(2, 3)
returns5
.
2. Testing a function that converts Fahrenheit to Celsius
function convertFtoC(fahrenheit) {
return (fahrenheit - 32) * 5/9;
}
test('converts Fahrenheit to Celsius', () => {
expect(convertFtoC(32)).toBe(0);
expect(convertFtoC(68)).toBe(20);
expect(convertFtoC(100)).toBeCloseTo(37.7778);
});
In this example:
- A function
convertFtoC
is defined, which takes a temperature in Fahrenheit as input and returns the temperature in Celsius. - Three tests that use the
expect
method are written to verify thatconvertFtoC
returns the correct output for three different inputs.
3. Testing a function that filters an array
function filterArray(arr, condition) {
return arr.filter(condition);
}
test('filters an array', () => {
const input = [1, 2, 3, 4, 5];
const expectedOutput = [2, 4];
const isEven = (num) => num % 2 === 0;
expect(filterArray(input, isEven)).toEqual(expectedOutput);
});
In this example:
- A function
filterArray
is defined, which takes an array and a condition function as inputs and returns a new array that only contains elements that satisfy the condition. - A test is written that creates an input array
[1, 2, 3, 4, 5]
, an expected output array[2, 4]
, and a condition function that filters for even numbers. - The
expect
method is used to verify thatfilterArray(input, isEven)
returnsexpectedOutput
.
Code is complex, and testing large sequences of code can be difficult to do well. Unit tests allow developers to concentrate on one thing and test it thoroughly. Once this is done successfully, that block of code can be used elsewhere with confidence because it has been tested thoroughly. Breaking up code into smaller, independent units allows those units to be strung together in more complex ways through a few additional tests.
Writing tests can be challenging and time-consuming, but certain best practices in code can make writing tests more accessible, less tedious, and less time intensive. The DRY principle, which stands for ‘don’t repeat yourself,’ encourages the abstraction of common code into a unique, singular function, making code more maintainable and saving time. When it comes to testing, this principle allows logic to be tested only once rather than for each context in which it is used.
Integration tests
While unit tests are designed to test individual units of code, integration tests verify that different parts of the system work well together. Integration testing is a critical part of software testing since it can uncover issues that may not be apparent when testing individual units of code.
Integration testing typically involves testing the interaction between multiple components, such as different modules or services within an application. For example, an integration test might ensure that data is correctly passed between a front-end component and a back-end API. Integration tests can also verify that third-party services, such as payment gateways or authentication providers, are correctly integrated with the system.
Integration test examples
Integration tests can help uncover issues such as miscommunication between different components, incorrect data formatting or transfer, and compatibility problems between different parts of the system. These issues can be challenging to uncover with unit tests alone, making integration testing a critical part of a comprehensive testing strategy. Here are three example codes for integration testing in Jest.
1. Testing a simple Express route
const request = require('supertest');
const app = require('../app');
describe('GET /hello', () => {
it('responds with "Hello, world!"', async () => {
const response = await request(app).get('/hello');
expect(response.statusCode).toBe(200);
expect(response.text).toBe('Hello, world!');
});
});
In this example:
- The
supertest
library makes an HTTP request to the Express app and verifies that the response has the expected status code and body text.
2. Testing an API endpoint with supertest
const request = require('supertest');
const app = require('../app');
describe('GET /api/books', () => {
it('responds with a list of books', async () => {
const res = await request(app).get('/api/books');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveLength(3);
expect(res.body[0].title).toBe('Book 1');
});
});
In this example:
- An HTTP
GET
request is sent to an API endpoint/api/books
implemented in app.js by using thesupertest
. - The response status code (
200
) is checked, the response body is an array of three books, and the first book has the titleBook 1
.
3. Testing a database connection with knex
const knex = require('knex');
const config = require('../knexfile');
const { createTable, insertData, getData } = require('../db');
describe('database connection', () => {
let db;
beforeAll(async () => {
db = knex(config);
await createTable(db);
await insertData(db);
});
afterAll(async () => {
await db.destroy();
});
it('retrieves data from the "books" table', async () => {
const books = await getData(db, 'books');
expect(books).toHaveLength(3);
expect(books[0].title).toBe('Book 1');
});
});
In this example:
- A set of functions that create a table, insert data, and retrieve data are tested using
knex
, which connects to the database. - The
getData
function is checked to return an array of three books, and the first book has the titleBook 1
.
While integration testing can be more complex than unit testing, there are several best practices that can make it easier to write effective integration tests. One approach is to use mocks or stubs to simulate the behavior of external services or components that are not available during testing. This approach can help isolate the system being tested and reduce the complexity of integration testing.
Another best practice is to use a test environment that closely mirrors the production environment. This can help ensure that the system is tested under realistic conditions and that any issues uncovered during testing are likely to occur in production. Additionally, it is essential to automate integration tests as much as possible to ensure that they run regularly and consistently.
Snapshot tests
Snapshot tests are another type of test that Jest supports. They capture a snapshot of the output of a component or function and compare it to a previously saved version. This type of test ensures that the output of a component or function remains consistent over time and prevents unexpected changes.
Snapshot tests are useful for UI components because they ensure that changes to the interface are intentional and predictable. When a snapshot test fails, it means that something has changed in the UI that was not expected and that it's necessary to investigate the change and determine whether it is intentional or not.
Snapshot test examples
To write a snapshot test, Jest takes a snapshot of the component or function’s output and saves it as a file. The next time the test runs, Jest compares the current output to the saved snapshot. If the output has changed, Jest highlights the difference and prompts the developer to either update the snapshot or investigate the change. Here are three examples of snapshot tests in Jest.
1. An example code for a button click
import React from 'react';
import renderer from 'react-test-renderer';
import MyButton from './MyButton';
describe('MyButton', () => {
test('onClickHandler renders correctly', () => {
const handleClick = jest.fn();
const tree = renderer.create(<MyButton onClick={handleClick} />).toJSON();
expect(tree).toMatchSnapshot();
tree.props.onClick();
expect(handleClick).toHaveBeenCalledTimes(1);
expect(tree).toMatchSnapshot();
});
});
In this example:
- Whether the
onClickHandler
function ofMyButton
component works as expected is checked. - A mock function called
handleClick
is first created usingjest.fn()
, which will be passed to the component as theonClick
prop. - Then the component is rendered with
renderer.create()
, and a snapshot of the initial tree is stored usingtoJSON()
. - The
onClick
event is triggered by callingtree.props.onClick()
, and whetherhandleClick
was called once usingtoHaveBeenCalledTimes(1)
is checked. - Finally, whether the resulting tree matches a previously stored snapshot using
toMatchSnapshot()
is checked.
2. An example code that selects an item from a dropdown list
import React from 'react';
import renderer from 'react-test-renderer';
import MyDropdown from './MyDropdown';
describe('MyDropdown', () => {
test('selecting an item from dropdown renders correctly', () => {
const handleChange = jest.fn();
const tree = renderer.create(<MyDropdown onChange={handleChange} />).toJSON();
expect(tree).toMatchSnapshot();
const select = tree.children[0];
select.props.onChange({ target: { value: 'item2' } });
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('item2');
expect(tree).toMatchSnapshot();
});
});
In this example:
- Whether selecting an item from the
MyDropdown
component works as expected is checked. - A mock function called
handleChange
usingjest.fn()
is first created, which will be passed to the component as theonChange
prop. - Then
renderer.create()
is used to render the component and store a snapshot of the initial tree usingtoJSON()
. - An item is selected from the dropdown by simulating a change event with a
target.value
ofitem2
. - Whether
handleChange
was called once withitem2
usingtoHaveBeenCalledTimes(1)
andtoHaveBeenCalledWith('item2')
is checked. - Finally, whether the resulting tree matches a previously stored snapshot using
toMatchSnapshot()
is checked.
3. An example code for editing a text field
import React from 'react';
import renderer from 'react-test-renderer';
import MyTextField from './MyTextField';
describe('MyTextField', () => {
test('editing text field renders correctly', () => {
const handleChange = jest.fn();
const tree = renderer.create(<MyTextField onChange={handleChange} />).toJSON();
expect(tree).toMatchSnapshot();
const input = tree.children[0];
input.props.onChange({ target: { value: 'updated text' } });
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('updated text');
expect(tree).toMatchSnapshot();
});
});
In this example:
- Whether editing a text field in the
MyTextField
component works as expected is checked. - A mock function called
handleChange
is created usingjest.fn()
, which will be passed to the component as theonChange
prop. - The component is rendered with
renderer.create()
, and a snapshot of the initial tree is stored usingtoJSON()
. - Next, an edit to the text field is simulated by triggering a change event with a
target.value
ofupdated text
. - Whether
handleChange
was called once withupdated text
usingtoHaveBeenCalledTimes(1)
andtoHaveBeenCalledWith('updated text')
is checked. - Finally, whether the resulting tree matches a previously stored snapshot using
toMatchSnapshot()
is checked.
Snapshot tests are helpful when designed and maintained properly. However, Jest cannot distinguish between intentional and unintentional changes in our user interface. Client-side code can be placed into two buckets---direct UI changes and functionality changes. Direct UI changes require updating snapshots, while functionality changes may or may not require updates. If a bug in the functionality affects what the user sees, snapshots may need updating. If changes fundamentally impact the end result for the user, snapshots will need updating.
Snapshot tests can also be helpful when refactoring code or when making small changes to an application. They provide a way to ensure that changes don't inadvertently affect other parts of the system. While snapshot tests are not a replacement for unit tests, they complement them and help to create a robust and dependable testing suite.
Wrapping up and next steps
Jest provides a powerful set of tools for developers to create and maintain unit, integration, and snapshot tests. These tests enable developers to identify issues early in the development process, ensure that changes to one part of the system do not break the other parts, and monitor changes to the user interface. By leveraging Jest for testing, software development teams can improve the overall quality of their code, increase efficiency, and reduce the risk of introducing new bugs and issues.
Educative currently offers the following career-specific paths for developers:
The following specific courses on software testing are also offered by Educative:
- Testing React Apps with Jest and React Testing Library
- Complete Guide to Testing React Apps with Jest and Selenium
- Testing Vue.js Components with Jest
Happy learning!
Posted on May 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.