Testing in software development: A practical guide

huntereducative

Hunter Johnson

Posted on May 16, 2023

Testing in software development: A practical guide

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:

  1. 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.
  2. Integration tests: Jest also supports integration tests, which verify the interactions between different parts of the code, such as multiple functions or modules.
  3. 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);
});
Enter fullscreen mode Exit fullscreen mode

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 that addNumbers(2, 3) returns 5.

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);
});
Enter fullscreen mode Exit fullscreen mode

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 that convertFtoC 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);
});
Enter fullscreen mode Exit fullscreen mode

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 that filterArray(input, isEven) returns expectedOutput.

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example:

  • An HTTP GET request is sent to an API endpoint /api/books implemented in app.js by using the supertest.
  • The response status code (200) is checked, the response body is an array of three books, and the first book has the title Book 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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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 title Book 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();
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Whether the onClickHandler function of MyButton component works as expected is checked.
  • A mock function called handleClick is first created using jest.fn(), which will be passed to the component as the onClick prop.
  • Then the component is rendered with renderer.create(), and a snapshot of the initial tree is stored using toJSON().
  • The onClick event is triggered by calling tree.props.onClick(), and whether handleClick was called once using toHaveBeenCalledTimes(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();
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Whether selecting an item from the MyDropdown component works as expected is checked.
  • A mock function called handleChange using jest.fn() is first created, which will be passed to the component as the onChange prop.
  • Then renderer.create() is used to render the component and store a snapshot of the initial tree using toJSON().
  • An item is selected from the dropdown by simulating a change event with a target.value of item2.
  • Whether handleChange was called once with item2 using toHaveBeenCalledTimes(1) and toHaveBeenCalledWith('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();
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Whether editing a text field in the MyTextField component works as expected is checked.
  • A mock function called handleChange is created using jest.fn(), which will be passed to the component as the onChange prop.
  • The component is rendered with renderer.create(), and a snapshot of the initial tree is stored using toJSON().
  • Next, an edit to the text field is simulated by triggering a change event with a target.value of updated text.
  • Whether handleChange was called once with updated text using toHaveBeenCalledTimes(1) and toHaveBeenCalledWith('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:

Happy learning!

💖 💪 🙅 🚩
huntereducative
Hunter Johnson

Posted on May 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related