Unit Testing Services

Unit tests should be small, fast, and numerous. One big challenge is testing components that interact with a network service or external database. We don't want to actually call a network API or login a user in a unit test for many reasons. Such tests would be slow, might cost money, and might require private information, such passwords and tokens, to be stored in code files. The solution is to mock those calls, i.e., replace the functions that interact with the network or database with functions that simulate such calls.

For this walk-through, we'll show how to unit test the Quick, React tutorial, unchanged except for some cleanup, using mocks to avoid actually connecting to the network or Firebase services. This tutorial uses Vitest and the React Testing Library that come with React. Pretty much everything is the same if you use Jest with create-react-app.

Unit testing without mocks

We'll start by testing the App component in the Quick, React! tutorial. Open src/App.test.jsx. Replace the default test code that was installed with the following code that tests that the app shows the correct schedule title

import { it } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';

it('shows the schedule year', () => {
  render(<App />);
  screen.getByText(/2018-2019/);
});

This code is in red because it won't work. Run the tests. If you are using VS Code, click the test button under NPM SCRIPTS. Otherwise, open a command shell and run

npm run test

You should see the test fail. Why? Because when the scheduler app first starts, it has no schedule data. The data is loaded asynchronously from either a URL or Firebase, so the first rendering of the app is empty. The first rendering is what the tests see and so they fail.

We can fix this by using a findBy query. These methods are designed for testing for elements that appear asynchronously. Since these methods are themselves asynchronous, we need to add async and await to our test code.

it('shows the schedule year', async () => {
  render(<App />);
  await screen.findByText(/2018-2019/);
});

If your network is reasonably fast, the test should pass. If not, make the query wait a little longer by passing a third argument to findByText() like this:

await screen.findByText(/.../, {}, { timeout: 3000 })

But this is a poor solution. It makes our unit tests very slow and dependent on both the network and the server where the data is stored being up and running. That's not appropriate when unit testing. This is where mocking becomes important.

Mocking a network call

First, we show how to mock a simple network call. Consider useJsonQuery() in the React example library. This is imported and called in applications to fetch data from a URL.

When unit testing, we don't want to a real network call. It's slow, it might cost money if we're using a third-party API, and we're not in control of the data returned, making it hard to write a reproducible test. A better approach is to mock fetchSchedule(). That means we redefine fetchSchedule() in our testing code to be a function that returns specific example data that we will use in our testing.

When testing, we can't go inside application code and replace calls to useJsonQuery(). What we can do is replace the definition of useJsonQuery() that gets used. This is one of the advantages of refactoring utility code into modules.

In our test code, we can create mock version of fetchSchedule() using Vitest. To do so, import the vi object from vitest, the module you wish to mock, and mock it. and

import { it, vi } from 'vitest';
import { useJsonQuery } from '../utilities/fetch';

vi.mock('../utilities/fetch', () => {
  useJsonQuery: vi.fn()
});

Vitest will replace every function that is exported from the module with a mock definition that, by default, does nothing and returns undefined. It does this by calling vi.fn() to create an empty mock function for every function exported from the module.

For each test, we assign specific behavior to the mocked functions. We have to mock every function that is called by the component we are going to render, including functions called by any subcomponents. Otherwise, your code will probably break when it calls a mocked function that returns undefined.

If function is the name of a function we have mocked, there are many ways to define its behavior, but the two simplest are:

  • mocked-function.mockReturnValue(value) -- use this to specify the return value of a normal function
  • mocked-function.mockResolvedValue(value) -- use this to specify the return value for an async function

Since useJsonQuery() is a normal function that returns immediately, we use mockReturnValue to define a return value for testing. To keep the test code short, we'll put the mock schedule data in a separate variable. We deliberately put in some data that is not in our real database, so that we can be sure that our unit test is using our test data.

import { it, vi } from 'vitest';
import { App } from './App';
import { useJsonQuery } from '../utilities/fetch';

vi.mock('../utilities/fetch');

const mockSchedule = {
  "title": "CS Courses for 1850-1851",
  "courses": { }
};

it('shows the schedule year', () => {
  useJsonQuery.mockReturnValue([mockSchedule, false, null]);
  render(<App />);
  screen.getByText(/1850-1851/);
});
  

Some important notes about the new code:

  • The mock return value in this case is a triple, because the real useJsonQuery() returns a triple, with the data, a false value for isLoading, and a null value for error. Note how this also makes it easy to write tests to make sure the app can handle errors and show something when loading is delayed.
  • We look for a value specific to our test data -- "1850" in this case -- to make sure we are seeing mock data, not real data.
  • We can use getByText(). We don't need to wait using with findByText(), async, and await;

Mocking a Firebase hook

Now let's mock the Firebase module that was defined in the Quick, React tutorial. This module exports several functions. Two of them are called when we render Main that we need to define behaviors for: useData() and useUserState().

    export const useData = (path, transform) => {
      const { data, isLoading, error } = useDatabaseValue([path], ref(database, path), { subscribe: true });
      const value = (!isLoading && !error && transform) ? transform(data) : data;
    
      return [ value, isLoading, error ];
    };
    ...
    export const useUserState = () => {
      const [user, setUser] = useState();
    
      useEffect(() => {
        onIdTokenChanged(auth, setUser);
      }, []);
    
      return [user];
    };
    import { ... useData, useUserState } from './firebase';
    ...
    const TermSelector = ({term, setTerm}) => {
      const [user] = useUserState();
      ...
    };
    ...
    const App = () => {
      const [schedule, loading, error] = useData('/schedule', addScheduleTimes); 
      ...
    };

    As before, to mock the module, we need to import the functions we're going to mock, then mock the module. We'll also define the same mock schedule as before.

    ...
    import { useData, useUserState }from './firebase';
    ...
    vi.mock('./firebase');
    ...
    const mockSchedule = {
      "title": "CS Courses for 1850-1851",
      "courses": { }
    };

    In the test shows the schedule year, we need to specify a return value for useData() and for useUserState() because both of those function are called.

    The real useData() returns an array of three values: (1) the schedule, if available, (2) a boolean that is true if the data is being loaded, and (3) an error object, if something went wrong, so we define a mock return value that fits that pattern. useData() is not asynchronous, so we use mockReturnValue to specify its behavior

    Since we don't need a user for our test, we can define the return value of useUserState() to be null. Since

    it('shows the schedule year', () => {
      useData.mockReturnValue([mockSchedule, false, null]);
      useUserState.mockReturnValue(null);
      render(<App />);
      const title = screen.getByText(/1850-1851/i);
      expect(title).toBeInTheDocument();
    };

    Note that the unit tests can use getByText() because there is no asynchronous code involved.

    Run these tests and verify that they both pass. Change the texts looked for and verify that the unit tests fail.

    Verifying proper calls with spies

    So far, we have just used our mock functions as stubs, that simulate getting values from some service. Mocks have another important function: they are spies that record every time they are called. That lets us write unit tests to make sure that our code calls a service correctly, the right number of times, with the right data. Vitest provides several expect() methods to make it easy to test how often a mock function was called and with what arguments.

    For example, here is a test to verify that App calls useData() once, when rendered, and when it does, it passes the string '/schedule' and some function.

    it('asks for data once with a schedule path', () => {
      useData.mockReturnValue([mockSchedule, false, null]);
      useUserState.mockReturnValue([null]);
      render(<App />);
      expect(useData).toHaveBeenCalledTimes(1);
      expect(useData).toHaveBeenCalledWith('/schedule', expect.any(Function)));
    });

    As before, we define return values for the two mocked methods and then render App. Instead of looking at what's rendered, we test that useData() was called just once, and that when it was called, it was passed the proper arguments.

    Add this test to App.test.jsx. Verify that all the tests pass. Try changing what's expected and verify that it fails.

    Mocking a logged in user

    Most apps require user authentication for certain functionality. They may do different things depending on what role a user has. This kind of code is important to test, but tricky to write and run. We don't want our test code to have user names and passwords in it. We don't want our unit tests to have to wait for an authentication service to process a log in. With mocks, we can simulate having a user with none of these issues.

    For example, the Quick, React app shows a Sign In button when no user is logged in, and a Sign Out button when there is a user. The code to do this uses the hook useUserState() defined in ./firebase.

    const TermSelector = ({term, setTerm}) => {
      const [user] = useUserState();
      return (
        <div>
          ...
          { user ? <SignOutButton /> : <SignInButton /> }
        </div>
      );
    };

    Let's add tests to verify that the proper buttons appear when a user is and is not logged in. To test for what happens when there is a user, we mock the return value of useUserState() to be a dummy Firebase User object.

    it('shows Sign In if not logged in', () => {
      useData.mockReturnValue([mockSchedule, false, null]);
      useUserState.mockReturnValue([null]);
      render(<App />);
      const button = screen.getByText(/Sign In/i);
      expect(button).toBeInTheDocument();
    });
      
    it('shows Sign Out if logged in', () => {
      useData.mockReturnValue([mockSchedule, false, null]);
      useUserState.mockReturnValue([{ displayName: 'Joe' }]);
      render(<App />);
      const button = screen.getByText(/Sign Out/i);
      expect(button).toBeInTheDocument();
    });

    Add this test and verify that all the tests pass. Change what it expects and verify that it fails.

    If your app has a login screen, be sure that your app skips that if the user is already logged in. Forcing a login when the user is known not only makes testing difficult, it makes the app annoying for real users. The normal way to do this is to have your entry URL go to the login page if there is no user, otherwise it goes to your main content page. Here's an example using React Router,

    const [user] = useUserState();
    ...
    <Route exact path="/">
      {user ? <Redirect to="/dashboard" /> : <LoginPage />}
    </Route>

    Resetting mocks

    Unit tests should be independent. That means that they can run in any order. No test should depend on another test running first or affect the results of any test running later. Some testing frameworks enforce this by running tests in a random order.

    Using mocks to avoid calls to a database helps keep tests independent but the mock operations themselves have side effects. If a mock function is changed or called in a test, that can affect how the same mock behaves in another test.

    You can call Vitest functions to clear, reset, or restore all mocks in your test code, but an even simpler thing is to configure Vitest to do this automatically by adding the following to vite.config.js:

    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      test: {
        globals: true,
        environment: 'jsdom',
        mockReset: true
      }
    });

    Vitest tips and pitfalls

    Tip: While Vitest expect() is very useful, the following test is overkill.

    expect(screen.getByText(/welcome/i)).toBeDefined();

    To check for the existence of some element, just ask for it.

    screen.getByText(/welcome/i);

    Pitfall: A test like the following will usually fail.

    screen.getByText('welcome');

    This test only passes if some item has exactly welcome, no more or less. To look for elements that contain some text, ignoring case, use regular expressions.

    screen.getByText(/welcome/i);

    Pitfall: The following test will always pass, no matter what's on the page.

    screen.findByText(/welcome/i);

    That's because findByText() is asynchronous and always returns a promise. You can't tell if it failed unless you await the result.

    await screen.findByText(/welcome/i);

    Test coverage

    An important characteristic of any test suite is code coverage. This measures what percentage of your code has been executed by at least one test. If code coverage is less than 100%, it means there are lines of code that your tests never execute. They could be just waiting to fail in real use.

    Vitest makes it fairly easy to evaluate your test code coverage. First, add the following script to your package.json file.

        "scripts": {
          ...
          "test": "vitest --ui",
          "coverage": "vitest run --coverage",
          ...

    The vitest run part says to run Vitest just once, rather than running and waiting for changes. Collecting code coverage data is a slow process so you don't normally do it repeatedly.

    Add the following to your vite.config.js file:

    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      test: {
        globals: true,
        environment: 'jsdom',
        mockReset: true
        coverage: {
          reporter: ['text', 'html'],
        },
      }
    });

    This tell Vitest to print the code coverage data in the console and also create an interactive HTML file to explore what is and isn't covered.

    Now run the script:

    npm run coverage

    The first time you do this, Vitest will ask if you want to install c8. This is a code coverage library that Vitest uses. Say yes.

    When it finishes, a table will be printed that summarizes how much code was covered in each component file. Even better, there will be an HTML file in the coverage directory that you can open in a browser to see exactly what pieces of code were and were not executed by the tests.

    Final Notes

    There's a lot more to unit testing and Vitest. For example, most unit testing should be on components of the app, not the entire app itself. There's an art to writing tests that focus on how the app is used, but aren't so fragile that they break with every small change in the interface. There are many pages on this topic.

    There are many other functions to help define mock function behavior. For the above examples, we only needed the Vitest method mockReturnValue(). If the return value depends on the arguments passed to the mocked function, use mockImplementation(). If we need to simulate a sequence of return values, use mockReturnValueOnce(). For more ways to get elements from a page, see types of queries.

    Sources

    Documentation on how to do this was surprisingly hard to find. Most pages on mocking with Jest or Vitest only show how to mock individual functions and modules that export a single class component. None of that is useful here.

    Richard Kotze's Mocking React hooks when unit testing using Jest was particularly helpful for writing this tutorial.

    © 2024 Chris Riesbeck
    Template design by Andreas Viklund