YouTube Courses - Learn Smarter

YouTube-Courses.site transforms YouTube videos and playlists into structured courses, making learning more efficient and organized.

React Testing Crash Course: A Comprehensive Guide



Introduction to Testing React Applications

Welcome to this comprehensive crash course on testing React applications. In this chapter, we will explore the essential aspects of testing React apps, equipping you with the knowledge to implement efficient and effective testing strategies using modern tools.

This chapter will cover the following key areas:

  • Why Test?: Understanding the fundamental reasons for incorporating testing into your development workflow.
  • What to Test?: Defining a strategic approach to prioritize testing efforts based on feature importance and potential risks.
  • How to Test?: Delving into different types of tests, including unit, integration, and end-to-end tests, and when to apply each.
  • Tools of the Trade: Introduction to React Testing Library, Jest, and Cypress, and how they facilitate effective testing.

By the end of this chapter, you will have a solid foundation in React testing, enabling you to build robust and reliable applications with confidence.

The Importance of Testing: Why Should You Test Your Applications?

The primary goal of testing is to ensure that your application behaves as intended. Testing acts as a safeguard against unexpected behavior, commonly known as bugs, which can arise during various stages of development, such as:

  • Adding new features
  • Refactoring existing code
  • Updating application dependencies

Bug: An error, flaw, or fault in a computer program or system that causes it to produce an incorrect or unexpected result, or to behave in unintended ways. Bugs can arise from mistakes and errors, made by people, in either a program’s source code, its design, its operating system, or in compilers that create the code.

Automated tests provide significant confidence, whether you are working on personal projects or developing applications for clients. By automating the testing process, you can quickly verify that your application is functioning correctly after making changes, eliminating the need for time-consuming manual testing, especially in large and complex applications.

Establishing Test Priorities: Focusing Your Testing Efforts

Testing is a valuable but time-consuming process. Therefore, it’s crucial to establish a test priority to ensure that your testing efforts are focused on the most critical aspects of your application. Similar to prioritizing cleaning the most visible parts of a car over every nook and cranny, you should prioritize testing based on impact and risk.

While there is no rigid “golden rule” for test prioritization in React, a widely accepted and effective approach is outlined below:

  • High-Value Features: Focus on features that deliver the most significant value or revenue to the application. Consider examples like:

    • E-commerce applications (e.g., Amazon): Product viewing and purchasing functionalities.
    • Music streaming services (e.g., Spotify): Music playback and subscription services.
    • Email clients (e.g., Gmail): Sending and receiving emails.

    Within these high-value features, prioritize testing the “happy paths,” which represent the intended and successful execution of these core functionalities.

    Happy Path: In software testing, the happy path is the default scenario, assuming no error conditions occur. It is a test case that validates that the application works as expected under ideal conditions.

  • Edge Cases in High-Value Features: After ensuring happy paths are covered, focus on testing “sad paths” or edge cases within high-value features. These tests examine how the application handles unexpected inputs or scenarios. Examples include:

    • E-commerce shops with order limits: Testing what happens when a user attempts to order quantities exceeding the store-wide limit (e.g., adding 1,000 items when the limit is 100).

    Edge Case (Sad Path): An edge case, or sad path, in software testing is a test case that occurs at an extreme (maximum or minimum) operating parameter. It is used to test situations beyond normal operating conditions.

  • Features Prone to Breakage: Identify and test features that have historically been fragile or prone to breaking during development or updates. Even if fixes have been implemented, these sensitive areas warrant focused testing to prevent regressions.

  • Basic React Component Testing: Focus on testing critical and frequently used React components. Prioritize components used in high-value features or those reused extensively throughout the codebase. Within these components, concentrate on testing:

    • User Interactions: Events like onClick, onChange, and other user-driven actions.

    • Conditional Rendering: Behavior based on state changes, prop updates, context changes, or state management solutions like Redux.

      Props (Properties): In React, props are inputs to components. They are single values or objects containing a set of values that are passed down to React Components from a parent component. State: In React, state is a built-in React object that is used to contain data or information about the component. The state in a component can change over time, and whenever it changes, the component re-renders. Context: React Context provides a way to pass data through the component tree without having to pass props down manually at every level. It is designed to share data that can be considered “global” for a tree of React components. Redux: Redux is a popular open-source JavaScript library for managing application state. It is most commonly used with libraries such as React or Angular for building user interfaces.

    • Utils and Hooks: Testing utility functions and custom hooks used within components.

      Hooks: In React, Hooks are functions that let you “hook into” React state and lifecycle features from within functional components. Hooks do not work inside classes — they let you use React without classes.

  • Implementation Details to Avoid Testing: Refrain from testing implementation details that do not directly impact user-observable behavior. For instance, testing internal state changes after a mutation using useState is generally discouraged. This approach is unreliable, prone to breaking with code refactoring, and doesn’t reflect actual user interaction.

    Mutation: In programming, a mutation is a change in the state of an object. In React state management, mutations often refer to updates to the application’s state, potentially triggering re-renders and UI updates. useState Hook: In React, useState is a Hook that allows you to add React state to function components. It returns a pair: a state value and a function to update it.

By following this test priority framework, you can strategically allocate your testing resources and ensure comprehensive coverage of the most critical aspects of your React applications.

Setting Up the React Demo Application

To illustrate the practical application of testing principles, we will use a demo React banking application. A starter repository is available on GitHub for this purpose.

Accessing the Demo Application:

  1. Navigate to the provided GitHub repository link (available in the video description).

  2. Clone the repository using SSH or download it as a ZIP file.

  3. Extract the downloaded ZIP file to your desired project directory.

  4. Open the project folder in your code editor.

  5. Open your terminal and navigate to the project directory using the cd command.

  6. Run the command yarn install to install project dependencies.

  7. After installation, run yarn dev to start the development server and launch the application in your browser.

    Yarn: Yarn is a package manager for your code. It allows you to use and share (JavaScript, Node.js) code with other developers all over the world. Yarn does this via packages and package registries (like npmjs.com).

If you encounter any issues starting the application, try restarting your terminal and running yarn dev again.

Exploring the Demo Application:

The demo application is a simplified banking platform. Key features include:

  • Login and Logout: Log in with the username “john doe” and password “secret” (note: the first ‘e’ in “secret” is a ‘3’). The “Remember Me” option is available.
  • Account Overview: View account balance and transaction history.
  • Account Management: Manage account details and create new bank accounts.
  • Notifications: Access application notifications.
  • Payment Functionality: Make payments to other users.

This application, while relatively small, provides a practical context for demonstrating various testing strategies and techniques discussed in this chapter. We will focus on testing features based on the testing priority list outlined earlier.

Test Types: Unit, Integration, and End-to-End

In automated testing, three primary types of tests are commonly employed:

  • Unit Tests: These tests focus on verifying the functionality of small, isolated units of code, typically individual functions or components. In functional programming paradigms, unit tests primarily target functions.

    Functional Programming: A programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data.

  • Integration Tests: Integration tests assess the interaction between multiple units or components within the application. They verify that these units work correctly together as a cohesive system. Integration tests often combine multiple unit tests into larger, more comprehensive tests.

  • End-to-End (E2E) Tests: End-to-end tests simulate real user scenarios, testing the application from the user interface (frontend) all the way to the backend systems and databases. E2E tests mimic how a user interacts with the application in a browser, ensuring all layers of the application work together seamlessly.

It’s important to note that the definitions and boundaries between unit and integration tests can be subjective and debated within the testing community. The key is to understand the purpose of each test type and apply them appropriately to ensure comprehensive test coverage.

In this chapter, we will explore these test types in a bottom-up approach, starting with unit tests and progressing towards end-to-end tests. This approach allows us to gradually increase the complexity of the tests and build a solid understanding of testing principles step by step.

Unit Testing with React Testing Library and Jest

We will begin by writing unit tests for a specific component within the demo application: the payment creation step two component (TransactionCreateStepTwo). We will use the React Testing Library and Jest for this purpose. React Testing Library is included by default in Create React App installations, making it readily available in the demo project.

React Testing Library: React Testing Library is a very light-weight solution for testing React components. It provides light utility functions on top of react-dom and react-dom/test-utils, in a way that encourages better testing practices. Its primary guiding principle is that the more your tests resemble the way your software is used, the more confidence they can give you. Jest: Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue, and more! Jest aims to work out of the box, config free, on most JavaScript projects.

Test Case 1: Initial Render - Pay Button Disabled

  1. Create Test File: Create a new file named TransactionCreateStepTwo.test.js (or .jsx if using JSX) in the same directory as the TransactionCreateStepTwo component.

  2. Import Necessary Modules: Import render and screen from @testing-library/react.

    import { render, screen } from '@testing-library/react';
    import TransactionCreateStepTwo from './TransactionCreateStepTwo'; // Adjust path as needed
  3. Describe Test Suite: Use the describe block to group related tests.

    describe('TransactionCreateStepTwo Component', () => {
      // Tests will go here
    });
  4. Define Test Case: Use the test function (or it) to define an individual test case.

    test('on initial render the pay button is disabled', () => {
      // Test logic
    });
  5. Render the Component: Use the render function to render the TransactionCreateStepTwo component. Note that this component expects sender and receiver props, so we’ll provide mock values.

    render(
      <TransactionCreateStepTwo sender={{ id: 1 }} receiver={{ id: 'receiver-id' }} />
    );
  6. Assertion: Assert that the “Pay” button is initially disabled. We will use screen.getByRole('button', { name: /pay/i }) to select the button by its role and name (case-insensitive) and then use the toBeDisabled() matcher from Jest (provided by @testing-library/jest-dom).

    Matcher: In Jest, a matcher is a function that is used to make assertions about values in your tests. Matchers are chained off of the expect function to assert in different ways. toBeDisabled() is a matcher provided by @testing-library/jest-dom to assert that an element is disabled.

    expect(screen.getByRole('button', { name: /pay/i })).toBeDisabled();
  7. Run Tests: Execute the tests using the command yarn test in your terminal. Jest will run the tests and provide results.

Addressing Asynchronous Behavior (Formik and Button Enablement)

During testing, you might encounter scenarios where component behavior is asynchronous, such as with form libraries like Formik. In our demo application, Formik’s behavior might cause the “Pay” button to momentarily enable and then disable on initial render.

Formik: Formik is a popular open source form library for React and React Native. It helps you with the three most annoying parts of forms: getting values in and out of form state, validation and error messages, and handling form submission.

To handle such asynchronous behavior in tests, utilize async and await in conjunction with findByRole instead of getByRole. findByRole returns a Promise that resolves when the element is found (or rejects after a timeout).

test('on initial render the pay button is disabled', async () => { // Added async
  render(
    <TransactionCreateStepTwo sender={{ id: 1 }} receiver={{ id: 'receiver-id' }} />
  );
  expect(await screen.findByRole('button', { name: /pay/i })).toBeDisabled(); // Used findByRole and await
});

Test Case 2: Pay Button Enabled After Amount and Note Entered

  1. Copy and Modify Test Case 1: Duplicate the previous test case and modify it to test button enablement after user input.

  2. Update Test Name: Change the test name to reflect the new scenario.

    test('if an amount and note is entered the pay button becomes enabled', async () => {
      // Test logic
    });
  3. Simulate User Input: Use userEvent.type from @testing-library/user-event to simulate user typing in the “Amount” and “Add a note” input fields. Target input fields using screen.getByPlaceholderText.

    userEvent: @testing-library/user-event is a companion library for React Testing Library that provides more advanced user interaction simulation than the built-in fireEvent from react-dom/test-utils. It more closely simulates actual browser events.

    import userEvent from '@testing-library/user-event';
    
    // ... inside the test ...
    userEvent.type(screen.getByPlaceholderText(/amount/i), '50');
    userEvent.type(screen.getByPlaceholderText(/add a note/i), 'Dinner');
  4. Assertion: Assert that the “Pay” button is now enabled using toBeEnabled().

    expect(await screen.findByRole('button', { name: /pay/i })).toBeEnabled();

Arrange-Act-Assert Pattern

The structure of these unit tests follows the Arrange-Act-Assert pattern:

  • Arrange: Set up the test environment, including rendering the component and providing necessary props.
  • Act: Perform actions, such as simulating user interactions (typing, clicking).
  • Assert: Verify the expected outcome by making assertions about the component’s state or behavior.

By writing these unit tests, we have verified user interactions and conditional rendering within the TransactionCreateStepTwo component.

Integration Testing: Combining Units for Realistic Scenarios

Integration tests extend beyond unit tests by examining the interaction of multiple units or components. They aim to simulate more realistic user workflows and ensure that different parts of the application work together harmoniously.

Combining Unit Tests into an Integration Test

In our previous unit tests, we verified individual aspects of the payment form. We can combine these tests into a single integration test that reflects a typical user flow:

  1. Combine Test Steps: Take the steps from the two unit tests and merge them into a single test case.
  2. Maintain Assertions: Keep both assertions within the combined test to verify different stages of the user interaction.
test('on initial render the pay button is disabled and becomes enabled after amount and note entered', async () => {
  render(
    <TransactionCreateStepTwo sender={{ id: 1 }} receiver={{ id: 'receiver-id' }} />
  );

  // Assertion 1: Initial state - button disabled
  expect(await screen.findByRole('button', { name: /pay/i })).toBeDisabled();

  // Act: Simulate user input
  userEvent.type(screen.getByPlaceholderText(/amount/i), '50');
  userEvent.type(screen.getByPlaceholderText(/add a note/i), 'Dinner');

  // Assertion 2: State after input - button enabled
  expect(await screen.findByRole('button', { name: /pay/i })).toBeEnabled();
});

This integration test provides a more holistic view of the component’s behavior within a user context. While the distinction between unit and integration tests can be nuanced, prioritizing tests that resemble realistic user flows is generally beneficial.

End-to-End Testing with Cypress

End-to-end (E2E) testing provides the most comprehensive level of testing by simulating complete user journeys through the application. We will use Cypress, a powerful E2E testing framework, for this purpose. Cypress is not included in Create React App by default, so we need to set it up.

Cypress: Cypress is a next generation front end testing tool built for the modern web. It is most often used for end-to-end testing, but can also be used for integration testing and unit testing. Cypress is built on a Node.js server and is designed to make testing as fast and easy as possible.

Setting Up Cypress

  1. Install Cypress and Testing Library Cypress Integration: Run the following command in your terminal to install Cypress and the necessary integration to use React Testing Library commands within Cypress tests as development dependencies:

    yarn add cypress @testing-library/cypress -D
  2. Open Cypress: Run the command yarn run cypress open to open the Cypress Test Runner. This will open a Cypress window and might automatically add example tests.

  3. Remove Example Tests (Optional): To start with a clean slate, navigate to the cypress/integration folder and delete the example test folders (examples and my-first-test.js).

  4. Add Testing Library Cypress Commands (Optional but Recommended): To use React Testing Library commands like findByRole in Cypress tests, modify the cypress/support/commands.js file by adding the following line:

    import '@testing-library/cypress/add-commands';

Writing an End-to-End Test: Payment Workflow

  1. Create Test File: Create a new file named payment_spec.js in the cypress/integration folder.

  2. Describe Test Suite: Use describe to define a test suite for payment functionality.

    describe('Payment Workflow', () => {
      // Tests will go here
    });
  3. Define Test Case: Use it to define a test case for the payment workflow.

    it('User can make a payment', () => {
      // Test steps
    });
  4. Plan Test Steps (User Flow): Before writing code, outline the user steps for a payment workflow. This helps structure the test logically:

    it('User can make a payment', () => {
      // 1. Login
      // 2. Check account balance
      // 3. Click on "Pay" button
      // 4. Search for user
      // 5. Add amount and note, click "Pay"
      // 6. Return to transactions
      // 7. Go to personal payments tab
      // 8. Click on the payment transaction
      // 9. Verify payment details
      // 10. Verify account balance deduction
    });
  5. Implement Test Steps using Cypress Commands: Translate each step of the user flow into Cypress commands. Utilize Cypress commands like cy.visit(), cy.findByRole(), cy.findByLabelText(), cy.type(), cy.click(), cy.get() (for data-testid), and assertions using .should().

    it('User can make a payment', () => {
      // 1. Login
      cy.visit('/'); // Visit the root URL
      cy.findByRole('textbox', { name: /username/i }).type('john doe');
      cy.findByLabelText(/password/i).type('secret');
      cy.findByRole('checkbox', { name: /remember me/i }).check();
      cy.findByRole('button', { name: /sign in/i }).click();
    
      // 2. Check account balance (using data-testid - example of using test ids when dynamic content is involved)
      cy.get('[data-testid="user-balance"]')
        .invoke('text')
        .then((oldBalance) => {
          const initialBalance = oldBalance; // Store initial balance
    
          // 3. Click on "New" Pay button
          cy.findByRole('button', { name: /new/i }).click();
    
          // 4. Search for user
          cy.findByRole('textbox').type('Devon Becker');
          cy.findByText(/Devon Becker/i).click();
    
          // 5. Add amount and note, click "Pay"
          const paymentAmount = '5.00';
          const note = `Payment Note ${Date.now()}`; // Unique note
          cy.findByPlaceholderText(/amount/i).type(paymentAmount);
          cy.findByPlaceholderText(/add a note/i).type(note);
          cy.findByRole('button', { name: /pay/i }).click();
    
          // 6. Return to transactions
          cy.findByRole('button', { name: /return to transactions/i }).click();
    
          // 7. Go to personal payments tab
          cy.findByRole('tab', { name: /personal/i }).click();
    
          // 8. Click on the payment transaction (force: true to handle potential overlay issues)
          cy.findByText(note).click({ force: true });
    
          // 9. Verify payment details (amount and note visible)
          cy.findByText(`-${paymentAmount}`).should('be.visible');
          cy.findByText(note).should('be.visible');
    
          // 10. Verify account balance deduction
          cy.get('[data-testid="user-balance"]')
            .invoke('text')
            .then((newBalance) => {
              const convertedOldBalance = parseFloat(initialBalance.replace(/[$,]/g, ''));
              const convertedNewBalance = parseFloat(newBalance.replace(/[$,]/g, ''));
              const parsedPaymentAmount = parseFloat(paymentAmount);
    
              expect(convertedNewBalance).to.be.closeTo(convertedOldBalance - parsedPaymentAmount, 0.01); // Use closeTo for floating-point comparisons
            });
        });
    });
  6. Run Cypress Test: In the Cypress Test Runner window, select the payment_spec.js file and run the test in your preferred browser environment (e.g., Electron, Chrome). Observe the test execution in the Cypress browser, which visually simulates user interactions.

Testing Playground Chrome Extension (Optional but Helpful)

The Testing Playground Chrome extension, created by a React Testing Library maintainer, can assist in identifying optimal queries for targeting elements within Cypress tests. While not always perfect, it provides valuable suggestions for effective element selection.

Handling Element Overlap Issues in Cypress

In E2E tests, you might encounter situations where elements are covered or obscured by other elements, preventing Cypress from interacting with them directly. In such cases, using { force: true } within the .click() command can force Cypress to interact with the element, even if it’s technically covered. Use this judiciously as it might mask underlying UI issues.

Conclusion: Embracing a Strategic Testing Approach

Testing is an indispensable part of the software development lifecycle. By adopting a strategic testing approach, you can significantly enhance the quality, reliability, and maintainability of your React applications.

Key Takeaways:

  • Prioritize Testing: Focus testing efforts on high-value features, edge cases, and fragile areas of your application.
  • Choose the Right Test Type: Select the appropriate test type (unit, integration, E2E) based on the scope and complexity of the functionality being tested.
  • Utilize Modern Testing Tools: Leverage tools like React Testing Library, Jest, and Cypress to streamline testing and improve efficiency.
  • Focus on User Behavior: Design tests that closely mimic how users interact with your application, ensuring a user-centric testing perspective.
  • Embrace Continuous Learning: Testing is an evolving field. Stay updated with best practices, new tools, and techniques to continuously refine your testing strategies.

While this chapter provided a comprehensive overview of React testing, there are advanced topics such as mocking, test coverage analysis, and various testing methodologies that can further enhance your testing expertise.

Mocking: In software testing, mocking is primarily used in unit testing. An object under test may have dependencies on other (complex) objects. To isolate the behavior of the object you want to test you replace the other dependencies by mocks that simulate the behavior of the real objects.

By applying the principles and techniques outlined in this chapter, you are well-equipped to embark on your React testing journey and build robust, user-centric applications with confidence.