Unit testing, integration testing, and end-to-end testing

Unit testing

Unit testing involves testing individual components or functions in isolation. The goal is to ensure that each part of the code works correctly on its own. These tests are usually written by developers and are the first line of defense against bugs.

  • Scope: Single function or component
  • Tools: Jest, Mocha, Jasmine
  • Example: Testing a function that adds two numbers
Example
function add(a, b) {
  return a + b;
}
 
test("adds 1 + 2 to equal 3", () => {
  expect(add(1, 2)).toBe(3);
});

Integration testing

Integration testing focuses on verifying the interactions between different modules or services. The goal is to ensure that combined parts of the application work together as expected. These tests are usually more complex than unit tests and may involve multiple components.

  • Scope: Multiple components or services
  • Tools: Jest, Mocha, Jasmine, Postman (for API testing)
  • Example: Testing a function that fetches data from an API and processes it
Example
async function fetchData(apiUrl) {
  const response = await fetch(apiUrl);
  const data = await response.json();
  return processData(data);
}
 
test("fetches and processes data correctly", async () => {
  const apiUrl = "https://api.example.com/data";
  const data = await fetchData(apiUrl);
  expect(data).toEqual(expectedProcessedData);
});

End-to-end testing

End-to-end (E2E) testing simulates real user scenarios to verify the entire application flow from start to finish. The goal is to ensure that the application works as a whole, including the user interface, backend, and any external services.

  • Scope: Entire application
  • Tools: Cypress, Selenium, Puppeteer
  • Example: Testing a user login flow
Example
describe("User Login Flow", () => {
  it("should allow a user to log in", () => {
    cy.visit("https://example.com/login");
    cy.get('input[name="username"]').type("testuser");
    cy.get('input[name="password"]').type("password123");
    cy.get('button[type="submit"]').click();
    cy.url().should("include", "/dashboard");
  });
});

Unit tests in JavaScript

Installing Jest

To get started with Jest, you need to install it via npm:

npm install --save-dev jest

Configuring Jest

Add a script to your package.json to run Jest:

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

Writing test cases

Basic structure

A test file typically contains one or more describe blocks, which group related tests, and it or test blocks, which define individual test cases.

Example
// sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;
 
// sum.test.js
const sum = require("./sum");
 
describe("sum function", () => {
  test("adds 1 + 2 to equal 3", () => {
    expect(sum(1, 2)).toBe(3);
  });
 
  test("adds -1 + 1 to equal 0", () => {
    expect(sum(-1, 1)).toBe(0);
  });
});

Using assertions

Assertions are used to check if the output of your code matches the expected result. Jest provides various assertion methods like toBe, toEqual, toBeNull, etc.

Example
test("object assignment", () => {
  const data = { one: 1 };
  data["two"] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

Mocking dependencies

Sometimes, you need to mock dependencies to isolate the unit of code you are testing. Jest provides functions like jest.fn() and jest.mock() for this purpose.

Example
// fetchData.js
const fetch = require("node-fetch");
 
async function fetchData(url) {
  const response = await fetch(url);
  return response.json();
}
module.exports = fetchData;
 
// fetchData.test.js
const fetchData = require("./fetchData");
const fetch = require("node-fetch");
 
jest.mock("node-fetch");
 
test("fetches data from API", async () => {
  const mockResponse = { data: "12345" };
  fetch.mockResolvedValueOnce({
    json: async () => mockResponse,
  });
 
  const data = await fetchData("https://api.example.com/data");
  expect(data).toEqual(mockResponse);
});

Running the tests

To run your tests, use the following command:

npm test

Jest

Jest is a testing framework developed by Facebook, primarily used for testing React applications. It is known for its simplicity and ease of use.

  • It comes with a built-in test runner, assertion library, and mocking support
  • It has a zero-configuration setup, making it easy to get started
  • It provides features like snapshot testing and code coverage out of the box
Example
test("adds 1 + 2 to equal 3", () => {
  expect(1 + 2).toBe(3);
});

Mocha

Mocha is a flexible testing framework that can be used with various assertion libraries like Chai. It is known for its extensive configuration options and support for asynchronous testing.

  • It provides a simple and flexible API for writing tests
  • It supports both BDD (Behavior-Driven Development) and TDD (Test-Driven Development) styles
  • It can be easily integrated with other libraries and tools
Example
const assert = require("assert");
 
describe("Array", function () {
  describe("#indexOf()", function () {
    it("should return -1 when the value is not present", function () {
      assert.strictEqual([1, 2, 3].indexOf(4), -1);
    });
  });
});

Jasmine

Jasmine is a behavior-driven development framework that requires no additional libraries. It is known for its simplicity and ease of use.

  • It provides a clean and readable syntax for writing tests
  • It includes built-in assertion and mocking libraries
  • It supports asynchronous testing
Example
describe("A suite", function () {
  it("contains a spec with an expectation", function () {
    expect(true).toBe(true);
  });
});

Cypress

Cypress is an end-to-end testing framework that provides a great developer experience. It is known for its fast and reliable tests.

  • It provides a simple and intuitive API for writing tests
  • It includes built-in support for assertions, mocking, and stubbing
  • It provides real-time reloading and debugging capabilities
Example
describe("My First Test", function () {
  it("Visits the Kitchen Sink", function () {
    cy.visit("https://example.cypress.io");
    cy.contains("type").click();
    cy.url().should("include", "/commands/actions");
  });
});

Testing asynchronous code in JavaScript

Testing asynchronous code in JavaScript can be challenging, but modern testing frameworks like Jest and Mocha provide robust support for handling asynchronous operations. Here are some common methods to test asynchronous code:

Using async/await

One of the most straightforward ways to test asynchronous code is by using async/await. This approach makes your test code look synchronous, which can be easier to read and write.

Example with Jest
// fetchData.js
export const fetchData = async () => {
  const response = await fetch("https://api.example.com/data");
  return response.json();
};
 
// fetchData.test.js
import { fetchData } from "./fetchData";
 
test("fetches data successfully", async () => {
  const data = await fetchData();
  expect(data).toBeDefined();
});

Returning a promise

Another way to handle asynchronous tests is by returning a promise from your test function. Jest and Mocha will wait for the promise to resolve before finishing the test.

Example with Jest
// fetchData.js
export const fetchData = () => {
  return fetch("https://api.example.com/data").then((response) =>
    response.json()
  );
};
 
// fetchData.test.js
import { fetchData } from "./fetchData";
 
test("fetches data successfully", () => {
  return fetchData().then((data) => {
    expect(data).toBeDefined();
  });
});

Using callbacks

For older codebases or specific scenarios, you might need to use callbacks. In Jest, you can use the done function to signal the end of an asynchronous test.

Example with Jest
// fetchData.js
export const fetchData = (callback) => {
  fetch("https://api.example.com/data")
    .then((response) => response.json())
    .then((data) => callback(null, data))
    .catch((error) => callback(error));
};
 
// fetchData.test.js
import { fetchData } from "./fetchData";
 
test("fetches data successfully", (done) => {
  fetchData((error, data) => {
    expect(error).toBeNull();
    expect(data).toBeDefined();
    done();
  });
});

Using Mocha

Mocha also supports async/await, returning promises, and using callbacks. Here is an example using async/await with Mocha:

Example
// fetchData.js
export const fetchData = async () => {
  const response = await fetch("https://api.example.com/data");
  return response.json();
};
 
// fetchData.test.js
import { fetchData } from "./fetchData";
import { expect } from "chai";
 
describe("fetchData", () => {
  it("fetches data successfully", async () => {
    const data = await fetchData();
    expect(data).to.be.an("object");
  });
});

Mocks and Stubs

Stubs

Stubs are simple objects that provide predefined responses to function calls made during tests. They are used to isolate the code being tested from external dependencies, such as databases or APIs, by providing controlled responses.

Example
// A simple stub for a function that fetches user data
const fetchUserDataStub = sinon.stub();
fetchUserDataStub.returns({ id: 1, name: "John Doe" });
 
// Using the stub in a test
const userData = fetchUserDataStub();
console.log(userData); // { id: 1, name: 'John Doe' }

Mocks

Mocks are more complex than stubs. They not only provide predefined responses but also record information about how they were called. This allows you to verify interactions, such as whether a function was called, how many times it was called, and with what arguments.

Example
// A simple mock for a function that logs user data
const logUserDataMock = sinon.mock();
logUserDataMock.expects("log").once().withArgs({ id: 1, name: "John Doe" });
 
// Using the mock in a test
logUserDataMock.log({ id: 1, name: "John Doe" });
logUserDataMock.verify(); // Verifies that the log method was called once with the specified arguments

When to use stubs and mocks

  • Stubs: Use stubs when you need to isolate the code being tested from external dependencies and control the responses those dependencies provide.
  • Mocks: Use mocks when you need to verify that the code interacts correctly with external dependencies, such as ensuring that a function is called with the correct arguments.

Test Driven Development (TDD)

Test-driven development (TDD) is a software development methodology that emphasizes writing tests before writing the actual code. The primary goal of TDD is to ensure that the code is thoroughly tested and meets the specified requirements. The TDD process can be broken down into three main steps: Red, Green, and Refactor.

Red: Write a failing test
  1. Write a test for a new feature or functionality.
  2. Run the test to ensure it fails, confirming that the feature is not yet implemented.
// Example using Jest
test("adds 1 + 2 to equal 3", () => {
  expect(add(1, 2)).toBe(3);
});
Green: Write the minimum code to pass the test
  1. Write the simplest code possible to make the test pass.
  2. Run the test to ensure it passes.
function add(a, b) {
  return a + b;
}
Refactor: Improve the code
  1. Refactor the code to improve its structure and readability without changing its behavior.
  2. Ensure that all tests still pass after refactoring.
// Refactored code (if needed)
function add(a, b) {
  return a + b; // In this simple example, no refactoring is needed
}

Benefits of TDD

  1. Improved code quality: TDD ensures that the code is thoroughly tested, which helps in identifying and fixing bugs early in the development process.

  2. Better design: Writing tests first forces developers to think about the design and requirements of the code, leading to better-structured and more maintainable code.

  3. Faster debugging: Since tests are written for each piece of functionality, it becomes easier to identify the source of a bug when a test fails.

  4. Documentation: Tests serve as documentation for the code, making it easier for other developers to understand the functionality and purpose of the code.

Challenges of TDD

  1. Initial learning curve: Developers new to TDD may find it challenging to adopt this methodology initially.

  2. Time-consuming: Writing tests before writing the actual code can be time-consuming, especially for complex features.

  3. Overhead: Maintaining a large number of tests can become an overhead, especially when the codebase changes frequently.

Code coverage

Code coverage is a software testing metric that determines the amount of code that is executed during automated tests. It provides insights into which parts of the codebase are being tested and which are not.

  1. Statement coverage: Measures the number of statements in the code that have been executed.
  2. Branch coverage: Measures whether each branch (e.g., if and else blocks) has been executed.
  3. Function coverage: Measures whether each function in the code has been called.
  4. Line coverage: Measures the number of lines of code that have been executed.
  5. Condition coverage: Measures whether each boolean sub-expression has been evaluated to both true and false.
Example
function isEven(num) {
  if (num % 2 === 0) {
    return true;
  } else {
    return false;
  }
}

A test suite for this function might look like this:

test("isEven returns true for even numbers", () => {
  expect(isEven(2)).toBe(true);
});
 
test("isEven returns false for odd numbers", () => {
  expect(isEven(3)).toBe(false);
});

Running code coverage tools on this test suite would show 100% statement, branch, function, and line coverage because all parts of the code are executed.

Tools

  1. Istanbul: A popular JavaScript code coverage tool.
  2. Jest: A testing framework that includes built-in code coverage reporting.
  3. Karma: A test runner that can be configured to use Istanbul for code coverage.
Example with Jest

To measure code coverage with Jest, you can add the --coverage flag when running your tests:

jest --coverage

This will generate a coverage report that shows the percentage of code covered by your tests.

Benefits

  • Identifies untested code: Helps in finding parts of the codebase that are not covered by tests.
  • Improves test suite: Encourages writing more comprehensive tests.
  • Increases confidence: Higher coverage can increase confidence in the stability of the code.

Limitations

  • False sense of security: High coverage does not guarantee the absence of bugs.
  • Quality over quantity: 100% coverage does not mean the tests are of high quality. Tests should also check for edge cases and potential errors.

Best practices

  1. Write clear and descriptive test names

    • Use names that clearly describe the behavior being tested
    • Avoid abbreviations and keep names meaningful
  2. Keep tests focused

    • Ensure each test case focuses on a single behavior or functionality
    • Avoid testing multiple things in a single test
  3. Use the AAA pattern (Arrange, Act, Assert)

    • Arrange: Set up the initial state and dependencies

    • Act: Execute the behavior being tested

    • Assert: Verify the outcome

      Example
      test("should add two numbers correctly", () => {
        // Arrange
        const a = 1;
        const b = 2;
       
        // Act
        const result = add(a, b);
       
        // Assert
        expect(result).toBe(3);
      });
  4. Avoid hardcoding values

    • Use variables and constants to make tests more readable and maintainable
    Example
    const input = 5;
    const expectedOutput = 25;
     
    test("should return the square of a number", () => {
      const result = square(input);
      expect(result).toBe(expectedOutput);
    });
  5. Mock external dependencies

    • Use mocking libraries to simulate external dependencies like APIs, databases, or third-party services
    • Keep tests isolated from external factors
    Example
    jest.mock("axios");
     
    test("should fetch data from API", async () => {
      const data = { id: 1, name: "John Doe" };
      axios.get.mockResolvedValue({ data });
     
      const result = await fetchData();
     
      expect(result).toEqual(data);
    });
  6. Keep tests isolated

    • Ensure tests do not depend on each other
    • Reset state and clean up after each test
    Example
    afterEach(() => {
      jest.clearAllMocks();
    });
  7. Regularly review and refactor tests

    • Keep tests up-to-date with changes in the codebase
    • Remove redundant or outdated tests
    • Refactor tests to improve readability and maintainability
  8. Use test coverage tools

    • Measure test coverage to identify untested parts of the codebase
    • Aim for high coverage but prioritize meaningful tests over 100% coverage
  9. Write tests before fixing bugs

    • Reproduce the bug with a failing test
    • Fix the bug and ensure the test passes
  10. Use a consistent style

    • Follow a consistent style and conventions for writing tests
    • Use linters and formatters to enforce consistency