Front End Testing 101 at Cloud Academy

First, why do we need tests?

Let’s suppose you are a developer. Now let’s suppose you are a front-end developer. Imagine that you have been recently hired to perform this role in a company that has a large, complex React web application. You see that managing such a complex application is a struggle. You don’t understand the actual flow of information, you see that there’s some inexplicable logic that you are afraid to touch, there’s a deep component nesting architecture, and so on.

If someone told you to make some changes in this codebase, this would probably be your reaction: 😱.

But you recall hearing from some wise white-haired man that there’s a way to manage such complexity. That man used a specific word to address this problem: refactoring! This is typically done when you want to rewrite a piece of code in a simpler way, breaking the logic into smaller chunks to reduce complexity.

Before you proceed, you remember that this is a very complex application. How can you be sure that you’re not breaking anything? You recall something else from the wise old man: testing!

Our choice for front end

At Cloud Academy, we have our large complex application built on a stack composed of React/Styled Components/Redux/Redux saga. The sagas are responsible for making the Rest API calls and updating the global state, while components will receive that state, updating the UI accordingly.

So, given our React stack, our choice was:

  • jest as test runner, mocking and assertion library
  • @testing-library/react or enzyme for unit testing React components (depending on the test purpose, or the project)
  • cypress for end-to-end testing (which will not be covered in this article)

Testing Library vs. Enzyme

Even though these two libraries both result in testing React components, they differ significantly in how the test is performed.

  • Enzyme focuses testing on implementation details, since you need to know the internal structure of the components and how they interact with each other. This gives you more control over the test execution, enabling deep testing on state and property changes. However, it makes the tests brittle, since almost every modification to the implementation needs a test update. Generally speaking it can be useful when testing simple components in which you have some logic that you don’t want to be altered, or with less component aggregation and interaction and more focused on content rendering. We use Enzyme in our design system Bonsai library.

  • Testing Library for React focuses on how the components behave from a user standpoint. To perform the test, you don’t need to know the implementation details, but rather how it renders and how the user should interact with it. This enables testing of very complex components. Since you don’t need to be concerned about the internals, you only need to give meaningful props, mock dependencies where needed (independent from the chosen framework), and test the output, checking what you expect to be rendered (content, labels, etc.) or interacting as a user would. We use Testing Library for React in our main application project, with which you can test whole pages without worrying too much about their complex structure.

Let’s dig into how we perform tests our main React codebase leveraging Testing Library.

What should you test?

We can see our application structured in different layers:

  • Global state and API calls located in Redux layers (actions, reducers, sagas, selectors)
  • Business logic and state management in containers (or for more recent components in hooks)
  • Presentation logic in lower level components
  • Common utilities

Each of these layers deserves a thorough tour about how they should be tested, but this article is focused on how we test React layers, so we’ve identified four main categories of tests:

  • Containers: the most complex components, where you usually test behaviors and you need heavy use of dependency stubs and fixtures
  • Components: since they should be “dumb,” testing here should be simpler
  • Hooks: see them like “containers” as functions, so you have the same needs and same containers approach
  • Generic functions: not really bound to Testing Library, but still needed when you use them in components

Containers

This is usually the page entry point, where all the magic happens by provisioning the actual data to the underlying components and controlling their actions with callbacks.

A complete test usually needs:

  • Dependencies properly mocked (leveraging jest mocks and dependency injection)
  • Fixtures to mock data and make assertions (expectations on content can be done with them)
  • Stub actions or callbacks in order to assert the behavior of the interactive parts using spies, etc. (e.g., error notifications, browser events, API callbacks)
  • Manage or simulate asynchronicity

If the test is well prepared, assertions are very easy, as you probably just need to inject the fixture data and check if it’s rendering like you expect. This ensures fairly wide coverage of the internal components, without taking into account the implementation details.

One suggested practice is to test at least the “happy path” and the “error” situations. Testing all the other possible code branches is also highly recommended, but only after the most common paths have been covered.

beforeEach(() => {
  ...
  mockHistory = createMemoryHistory();

  initialState = {
    ....
     //some content here
     course: {
        description: '.....'
        nextStep: {
           title: '....'
        }
     }
     ...
  };
});

test('should render the course step content from the state', () => {

  connectedRender(
    <Router history={mockHistory}>
      <ContainerWithSomeContent  />
    </Router>,
    {
      initialState,
      reducer,
    },
  );

  expect(screen.getByText(initialState.course.description)).toBeInTheDocument();

  expect(screen.getByText(`Next: ${initialState.course.nextStep.title}`));
});

Connected components

Let’s take a small detour through how to test connected components. As you see above, the test is using the connectedRender API. This is a utility function to enable testing on containers that are connected to Redux store without having to set up its boilerplate code in every test.

In order to test those kind of components, you simply need to pass these items to this utility function: the jsx, the reducer and the initial state that will be used to construct the store that will forward the state using the redux Provider.

The following is the implementation of that utility.

import React from 'react';
import { render as rtlRender } from '@testing-library/react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';

function render(
  ui,
  {
    initialState,
    reducer,
    store = createStore(reducer, initialState),
    ...renderOptions
  } = {},
) {

  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>;
  }
  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}

// re-export everything
export * from '@testing-library/react';
// override render method
export { render as connectedRender };

Components

Given that the “happy path” is being tested on the container, there’s the possibility that you don’t need to perform tests on the internal components, that should be dumb as much as possible. If some kind of logic is present here (e.g., displaying different labels depending on props values), it’s also good to create tests to cover these specific situations, handling them here instead of containers.

If you have many possible combinations, a suggested practice is to write tabular tests.

test.each`
  time  | expected
  ${0}  | ${'0 minutes'}
  ${1}  | ${'1 minute'}
  ${2}  | ${'2 minutes'}
  ${10} | ${'10 minutes'}
`('should render estimated time "$time" as "$expected"', ({ time, expected }) => {
  props.estimatedTime = time;

  render(<ComponentThatRendersTime {...props} />);

  expect(screen.getByText(expected)).toBeInTheDocument();
});

Hooks

Hooks can be tested without being forced to render them inside components using renderHook from @testing-library/react-hooks library. Testing hooks is somewhat similar to testing functions and components at the same time.

The hook returns a result object on which you can make assertions by accessing its current property.

You should avoid immediate destructuring here if you are planning to call a function on the result that changes the hook state. In fact, this triggers the library to re-render the result, assigning a new reference to the result.current property.

//render the hook
const { result } = renderHook(() => useSomeSuffWithChangeableState())

//read its result value
const { changeState, value } = result.current

// if you expect to change the state here and have an updated value
act(() => {
  changeState()
})
// this assertion will fail, since the "value" will still be the old one
expect(value).toEqual(..)

// to assert the new value you need to re-read it
expect(result.current.value)

Generic functions

Generic functions usually don’t need anything from react testing library, since no jsx rendering should happen inside of them. In this case, you can simply assert using standard matchers, again with tabular testing if needed.

test.each`
  status            | expectedMessage
  ${'not_booked'}   | ${'Classroom Not Booked'}
  ${'booked'}       | ${'Classroom Booked'}
  ${'waiting_for'}  | ${'Classroom Booked'}
  ${'attending'}    | ${'Classroom Started'}
  ${'ended'}        | ${'Classroom Ended'}
  ${'not_existing'} | ${'Classroom Not Booked'}
`(
  'should display "$expectedMessage" for status "$status"',
  ({ expectedMessage, status }) => {
    const message = getMessageFromStatus(status);

    expect(message).toBe(expectedMessage);
  },
);

Managing asynchronicity

Since you’ll usually deal with asynchronous components that make either REST or GraphQL calls, you need to handle asynchronous rendering (mostly on the container components).

To test this behavior, you must use async tests and testing library async API (for example, waitFor) depending on the kind of test you need to implement.

test('should load the page properly', async () => {

  //here's an async operation since the component immediately loads the data
  render(
      <ContainerWithAsyncCall
        {...props}
      />
  );

  await waitFor(() => {
    expect(screen.getByText("Text in the document")).toBeInTheDocument();
  });

  // ...other expectations

});

Same deal, different APIs if you have asynchronous operations in hooks

test('should load the data properly', async () => {
  //render the hook
  const { result, waitForNextUpdate } = renderHook(() => useHookWithAsyncOperation())

  expect(result.current.status).toEqual('LOADING');

  // if you expect to change the state here and have an updated value
  await waitForNextUpdate(() => {
    // assert on the updated "current" value
    expect(result.current.status).toEqual('LOADED')
  })
})

GraphQL testing

We have introduced GraphQL recently (check out this article for more info about this journey), and we started to use Apollo Client to make queries. We don’t have only one way to perform testing on this, so here are some of the more common:

  • Using Apollo MockedProvider: this is probably the best option in terms of “close to reality” testing. It could seem very simple at first, and it is for simple tests. But if you begin more advanced tests, with a lot of different queries and also testing error conditions, the library seems to suffer from some kind of “bug”, recycling and matching previous requests. So you’ll probably waste a lot of time understanding what’s wrong with the test or the mock. It has also some functional quirks (sometimes you are required to put the typename in the mock) and error messages that are not very clear.
const mocks = [{
  request: {
    query: GET_SURVEY_INFO_QUERY,
    variables: {
      ...variables
    },
  },
  result: {
    data: {
      ...mockData,
    },
  },
}]
test('should load the page without errors', async () => {

  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <SurveyLandingContainerInternal {...props} />
    </MockedProvider>
  )

  await waitFor(() => {
    // ...assertions
  })
})
  • Using the actual Apollo Provider with a mock client: this probably is the best compromise, since you are actually using the Apollo API and just stubbing the responses by query.

  • Mocking Apollo hooks (injected as props or mocked using jest.mock): it’s by far the simplest solution, since you are completely mocking the Apollo APIs, simulating them with stubs over which you have control, and without the need to wrap them in the ApolloProvider. We made a couple of utilities for creating stubs for useQuery and useMutation hooks.

export function newMockUseQuery({ error, data }) {
  return jest.fn(function () {
    const [state, setState] = useState({
      loading: true,
      error: undefined,
      data: undefined,
    });

    useEffect(() => {
      async function doFakeQuery() {
        await Promise.resolve();

        setState({
          loading: false,
          error,
          data,
        });
      }
      doFakeQuery();
    }, []);

    return state;
  });
}
Here we’re mocking the hook to be able to use a fixture value
let mockUseQuery;

jest.mock('@apollo/client', () => {
  // needed in order to avoid mocking everything if importing other stuff
  const apolloClient = jest.requireActual('@apollo/client');
  return {
    ...apolloClient,
    useQuery: (...args) => {
      return mockUseQuery(...args);
    },
  };
});

//while in the test setup just do
beforeEach(() => {
  mockData = {
    ...
  };
  mockUseQuery = newMockUseQuery({ data: mockData });
});

In conclusion

To be able to scale the development and evolution of a complex application, and to ensure the quality of the code you write, you need a way to assess that what you’re writing is actually working in the way it is intended.

Testing solves this problem by giving you confidence in your codebase, ensuring that the logic is correctly implemented without the need to actually run it in production.

When you are confident that your code is right, you can improve it. You can tackle the complexities by doing optimizations, search for more efficient algorithms or libraries, delete old unused code without concerns, upgrade libraries, and do everything you need to make your code maintainable and reliable.

Writing tests can seem like too much effort since you end up writing a lot more code. But considering that it will be more robust and less prone to errors, you are less subject to bugs. And the next time you need to change it, you can simply update the tests (or write a new one if needed), run them, and when they’re green, you’re ready to go!

Cloud Academy