Speed Up End-to-End Testing Using React Testing Library: A Simpler and Faster Alternative

End-to-End (E2E) testing is vital for ensuring that your application works correctly from the user’s perspective by testing the entire workflows, from start to finish. However, traditional E2E testing can be slow, resource-intensive, and challenging to set up and maintain. To address these challenges, we can adopt an alternative strategy using React Testing Library (RTL), which offer a faster, more efficient way to test applications without the overhead of traditional E2E tests. We do this by setting up our test prerequisites and rendering the entire app context with RTL, in this example we’ll use tRPC to setup test prerequisites.

The Drawbacks of Traditional E2E Testing

Traditional E2E testing tools, such as Selenium and Cypress, run tests in a real browser environment to simulate user actions and interactions with the application. While these tools provide comprehensive test coverage, they come with several drawbacks:

  1. Slow Execution: Traditional E2E tests can be slow because they simulate every aspect of the application, including network requests, database interactions, and UI rendering.
  2. Complex Setup and Maintenance: Setting up and maintaining traditional E2E tests requires managing test environments, handling network and UI dependencies, and ensuring compatibility across different browsers and devices.
  3. Flakiness: E2E tests are often flaky, meaning they can fail intermittently due to timing issues, network instability, or other non-deterministic factors.
  4. Resource Intensive: Running a full suite of E2E tests can consume significant computational resources, slowing down development cycles and continuous integration pipelines.

React Testing Library: A Modern, Efficient Approach to E2E Testing

React Testing Library (RTL) provides a modern, efficient way of testing by focusing on speed, simplicity, and reliability. RTL promotes testing from the user’s perspective, focusing on how components behave in response to user interactions. What we will do is leverage this speed an load the entire app context instead of a single component so we can execute fully-fledged E2E tests. In addition it is important to setup the test prequisite, we are using tRPC for this.

Key Benefits of Using React Testing Library and tRPC
  1. Faster Execution: Tests run much faster than traditional E2E tests because Jest leverages jsdom which simulates a DOM environment as if you were in the browser.

  2. Simpler Setup and Maintenance: RTL and tRPC require minimal setup and maintenance compared to traditional E2E testing tools, reducing the complexity of your testing infrastructure.

  3. Reduced Flakiness: By avoiding full-stack dependencies and focusing on component interactions, tests are less likely to be flaky or fail intermittently due to timing or network issues.

  4. Improved Developer Experience: With type-safe API calls and a focus on user-centric testing, RTL and tRPC provide a more intuitive and reliable testing experience.

Writing E2E Tests with React Testing Library and tRPC

Let’s dive into an example of how to set up and write E2E tests using React Testing Library and tRPC. In this example, we’ll simulate rendering the dashboard for a logged in user.

Step 1: Setting Up Fixtures and API

To begin, we’ll set up some fixtures that represent the data and API handlers we need for our tests. This setup allows us to control the test environment and simulate various scenarios.

/__mocks__/api.ts

import {createTRPCProxyClient, httpBatchLink} from '@trpc/client'
import {fetchAuthSession} from 'aws-amplify/auth'
// const t = initTRPC.context<Context>().create()
// export const router = t.router
// export type AppRouter = typeof router;
import type {AppRouter} from 'backend/src/api'

export const trpcClient = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: process.env.REACT_APP_API_URL || '/api',
      // You can pass any HTTP headers you wish here
      async headers() {
        const commonHeaders = {'x-cognito': 'local'}
        const data = await fetchAuthSession()
        const token = data.tokens?.idToken?.toString()
        return {
          ...commonHeaders,
          ...(token && {authorization: `Bearer ${token}`})
        }
      }
    })
  ]
})

/utils/fixtures.ts

import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { v4 as uuidv4 } from 'uuid';
import { trpcClient } from '../__mocks__/api';
import { signIn } from 'aws-amplify/auth'
import { RouterInputs, RouterOutputs } from 'services/api'
// import {inferRouterInputs, inferRouterOutputs} from '@trpc/server'
// export type RouterInputs = inferRouterInputs<AppRouter>;
// export type RouterOutputs = inferRouterOutputs<AppRouter>;


// just some example data to use for setting up test prerequisites
export const person = {
  gender: 'Female' as const,
  name: 'Jane Doe'
}
export const user = {
  ...person,
  birthDate: '1991-05-05T00:00:00.000Z',
  isAlive: true
}


export const signup = async(
  data: any // any data you need for signup e.g which contry user is from
) => {
  const email = `test+${uuidv4()}@erblotse.de`
  const user = await trpcClient.signup.signup.mutate({
    credentials: {
      email,
      password: 'password'
    },
    data: {
      ...data,
    }
  })

  await signIn({username: email, password: 'password', options: {authFlowType: 'USER_PASSWORD_AUTH'}})

  // eslint-disable-next-line no-console
  console.log(`Signed up as ${email}`)

  return user.userId
}

export const updateUser = async(data?: Partial<RouterInputs['user']['updateUser']>) => {
  return trpcClient.user.updateUser.mutate({
    ...user,
    ...data
  })
}


// export more fixtures that modify the state

Step 2: Writing E2E Tests Using React Testing Library and tRPC

Now, let’s write a test that simulates the user signup process and verifies that the user is correctly signed up and their data is fetched properly.

import { render } from '@testing-library/react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PATH } from 'app/paths';
import { signupUser, fetchUserData } from '@utils/fixtures';
import { renderApp } from '@utils/test-utils';

describe('Dashboard', () => {
  it('should show the dashboard for a user', async () => {
    const userId = await signup()
    const user = await updateUser() // set any other user data as param if needed

    // Call other fixtures as needed to udpdate state
    // await updateChild({
    //   ...child1,
    //   id: childId,
    //   birthDate: '2022-10-12T00:00:00.000Z',
    //   parents: [{
    //     parentId: userId,
    //     // any other data
    //   }]
    // })

    await renderApp()

    await screen.findByText(t('dashboard.headline'))
    expect(screen.getByText(t('dashboard.userName'))).toBe(user.name)
});
Running Your Tests

Once you’ve set up your fixtures, mocks, and tests, you can run them using your preferred testing framework (e.g., Jest). The tests will execute quickly and provide immediate feedback on the application’s behavior without the overhead of traditional E2E test setups.

npx jest --watch

When to Use Traditional E2E

While RTL and tRPC provide a faster and more efficient approach to E2E testing, traditional E2E tests are still necessary for Cross-Browser Compatibility to ensure compatibility across different browsers and devices.

Conclusion

Using React Testing Library for E2E testing by rendering the entire app context offers a powerful and much faster alternative to traditional E2E tests. This approach reduces complexity and speeds up test execution. By focusing on fixtures, and setting up test prerequisites, developers can achieve comprehensive testing with a more streamlined workflow.