Handbook
Fundamentals

Fundamentals of Frontend Testing

Last updated by Noel Varanda (opens in a new tab),
Unit testing
Visual regression testing
Integration testing
End-to-end testing
Test-driven development
Behavior-driven development
Shifting testing left

Frontend testing ensures that the user interface functions as expected, catches bugs early in the development process, improves software quality, and enhances user experience.

Types of tests

There are many types of tests, however there are a few that can be selected for an effective front end testing strategy.

Unit testing

Focuses on testing individual units or components of code to ensure their functionality in isolation.

  • Catch logical errors, edge cases, and boundary conditions within a specific function or component.

  • Covers various execution paths.
  • Provide fast feedback during development.

Example:

function capitalizeString(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1);
}
test('capitalizeString should capitalize the first letter of a string', () => {
  const result = capitalizeString('hello');
  expect(result).toBe('Hello');
});

Visual regression testing

VR (Visual Regression) tests try to catch any visual regressions or unintended visual changes in the user interface caused by code changes.

  • Capture screenshots or visual representations of the software's UI before and after code changes and compare them.

  • Detect differences in visual elements such as layout, styling, and content placement.

  • Help maintain the expected visual appearance and prevent regressions due to CSS changes, responsive design modifications, or unintentional UI alterations.

Example:

Below is an example of Storybook's Visual Testing (opens in a new tab) implementation, where designers/engineers need to approve visual changes.

Storybook's Visual Testing

Integration testing

Integration tests focus on verifying the interaction and collaboration between different components or modules within a system.

  • Identify issues such as incorrect data passing, API compatibility problems, and component coordination errors.

  • Catch errors that arise from the integration of different components, ensuring they work together correctly.

  • Test the communication and data flow between modules, ensuring that data is exchanged correctly and the integrated system functions as expected.

Examples:

These examples illustrate various aspects of integration testing in frontend development. It's important to tailor the tests to the specific requirements and functionality of your application.

  1. Testing API Integration
  2. Component Interaction
  3. Routing and Navigation
  4. Form Submissions
  5. Authentication and Authorization

End-to-end testing

E2E (End-to-End) tests, also known as critical path tests, simulate real user scenarios by testing the entire application flow from start to finish.

  • Aim to catch bugs or issues that may arise from the interaction of multiple components and systems working together.

  • Validate the integration and functionality of the entire application, including user interactions, API calls, database operations, and navigation.

  • Help ensure that critical paths of the application, such as user registration, login, and checkout processes, work as expected and provide a comprehensive overview of the system's behavior.

Best Practices

TDD vs. TLD

When it comes to testing in software development, two common approaches are Test-driven development (TDD) and Test later development (TLD). Let's explore the advantages and considerations for each approach:

Test-driven development

Tests are written before coding, following a typical flow:

  1. Write a failing test.
  2. Implement the simplest code to pass the test.
  3. Refactor and improve the code as necessary.

Pros

  • Writing tests first helps ensure focused implementation and prevents over-architecting.

  • Confidence in code refactoring due to the existence of pre-written tests.

  • Automatic high test coverage, reducing the likelihood of obvious bugs.

  • Assurance that all tests are running, preventing false positives in large test suites.

Test later development

Testing after code is written is known as Test Later Development (TLD).

Pros

  • Shorter development time as the focus remains on coding without switching between code and tests.

  • Reduces the likelihood of trivial tests in the test suite.

Choosing between TDD and TLD

⚡️

TDD is preferred in Agile processes, promoting structured development and improved code quality.

Use TDD if

  • Your product is in exploratory phases or your company is an emerging startup.
  • You have the time and resources to dedicate to building a testing culture.

Use TLD if

  • You need to move quickly in the development process, especially for exploratory or time-sensitive projects.

Remember, regardless of the approach, writing maintainable and readable tests is crucial for the long-term scalability of your codebase.

Behavior-driven development

Behavior-Driven Development (BDD) is an approach to software development that focuses on collaboration between developers, testers, and business stakeholders.

It aims to improve communication and ensure that the software meets the desired behavior and business requirements.

Tests in a human-readable format

In BDD, tests are written in a human-readable format using Gherkin syntax (opens in a new tab) (Given, when, then). While Gherkin syntax is commonly associated with BDD, in frontend testing, the same principles can be applied by using descriptive test names and organizing tests in a logical structure that reflects the desired behavior of the system.

Although the following doesn't follow the specific Given-When-Then structure of BDD, it still maintains the practice of using descriptive test names that convey the expected behavior or outcome:

// Bad 👎
describe('User Authentication', () => {
  it('Test 1', () => {
    // ...
  });
 
  it('Test 2', () => {
    // ...
  });
 
  it('Test 3', () => {
    // ...
  });
});
 
// Good 👍
describe('User Authentication', () => {
  it('should authenticate the user successfully', () => {
    // ...
  });
 
  it('should display an error message for invalid credentials', () => {
    // ...
  });
 
  it('should redirect the user to the dashboard after successful authentication', () => {
    // ...
  });
});

Mapping User Stories to BDD Scenarios

💡

Collaborating with product owners and stakeholders in the test definition process is vital for creating meaningful and effective tests that align with the product vision and customer requirements.

BDD helps in mapping user stories or requirements to specific test scenarios, ensuring that the tests cover the expected behaviors and functionalities. By understanding the desired behavior from the user's perspective, tests can be designed to verify the system's compliance with those requirements. For example:

User Story: As a user, I want to be able to add items to my shopping cart.

Feature: Shopping Cart
  As a user
  I want to add items to my shopping cart
  So that I can keep track of the items I want to purchase
Scenario: User can add items to the shopping cart
  Given I am on the product page
  When I click the "Add to Cart" button
  Then I should see a message "Item added to cart"
  And the cart item count should be updated to "1"

The scenario can then be translated to a Jest test:

describe('User can add items to the shopping cart', () => {
  it('displays success message and updates cart item count', () => {
    // Arrange
    render(<ProductPage />);
    const addToCartButton = screen.getByTestId('add-to-cart-button');
    const cartItemCount = screen.getByTestId('cart-item-count');
 
    // Act
    userEvent.click(addToCartButton);
 
    // Assert
    expect(screen.getByText('Item added to cart')).toBeInTheDocument();
    expect(cartItemCount).toHaveTextContent('1');
  });
});

BDD enhances shifting testing left by promoting collaboration and communication among team members. BDD uses a common language to describe the system's behavior.

Shifting testing left

The following is a traditional software development lifecycle:

Shifting left in testing refers to the practice of involving developers earlier in the testing process, in an Agile world, to accelerate delivery, improve quality, and reduce testing costs.

Meaning that the traditional software lifecyle becomes:

Benefits of Shifting Testing Left

  • Early Bug Detection
  • Improved collaboration between developers, testers, and stakeholders

  • Faster feedback loop

For a comprehensive guide on shifting testing left and leveraging BDD, refer to the Shift Left Testing Guide (opens in a new tab).

Practical tips

AAA (Arrange-Act-Assert)

The AAA (Arrange-Act-Assert) pattern is a testing pattern that structures test cases into three distinct phases:

  1. Arranging the test setup.
  2. Performing the action being tested.
  3. Asserting the expected outcome.
it('should return the sum of two numbers', () => {
  // Arrange
  const num1 = 2;
  const num2 = 3;
 
  // Act
  const result = addNumbers(num1, num2);
 
  // Assert
  expect(result).toBe(5);
});

Avoiding flaky tests

To ensure consistent and predictable test results, it's important to handle asynchronous operations properly. The act function from React Testing Library is a powerful tool for managing async updates and avoiding flaky tests.

import { render, screen, act } from '@testing-library/react';
import AsyncComponent from './AsyncComponent';
 
describe('AsyncComponent', () => {
  it('renders data after asynchronous update', async () => {
    // Arrange
    const mockData = 'Async Data';
    jest.spyOn(global, 'fetch').mockResolvedValueOnce({
      json: jest.fn().mockResolvedValueOnce(mockData),
    });
 
    // Act
    await act(async () => {
      render(<AsyncComponent />);
    });
 
    // Assert
    expect(screen.getByText(mockData)).toBeInTheDocument();
  });
});

In this example, there is an AsyncComponent that fetches data asynchronously using the fetch function. We mock the fetch function using jest.spyOn to control its behavior. We then use act to wrap the rendering of the component and await any async updates.

By wrapping the rendering in act, we ensure that any async updates triggered by the component are properly handled before making assertions. This helps to avoid timing-related issues and ensures that our test produces consistent and reliable results.

Keep tests focused

Write tests that focus on testing specific behavior or functionality. Avoid testing multiple unrelated behaviors in a single test case, as it can make the tests harder to understand and maintain.

// Bad 👎
describe('User Management', () => {
  it('should create a user, update their profile, and delete the account', () => {
    // Test logic...
  });
});
 
// Good 👍
describe('User Creation', () => {
  it('should create a new user', () => {
    // Test logic...
  });
});
 
describe('Profile Update', () => {
  it('should update user profile information', () => {
    // Test logic...
  });
});
 
describe('Account Deletion', () => {
  it('should delete a user account', () => {
    // Test logic...
  });
});

Organizing test files

Maintaining a well-organized test file and directory structure is crucial for scalability and maintainability. Arrange your test files in a way that reflects the structure of the source code and makes it easy to locate and run tests.

Example directory structure
// Bad 👎
tests/
  ...
src/
  components/
    Button/
      Button.tsx
    ...
  utils/
    helperFunctions.ts
  ...
 
// Good 👍
src/
  components/
    Button/
      Button.tsx
      Button.test.tsx
    ...
  utils/
    helperFunctions.ts
    helperFunctions.test.ts
  ...

In this example, each component has its own corresponding test file, keeping the tests close to the source code they are testing. Similarly, utility functions have separate test files to ensure comprehensive test coverage. This organization makes it easier to locate tests and maintain the codebase as it grows.


Keep up to date with any latest changes or announcements by subscribing to the newsletter below.