LiveLoveApp logo

Testing React Apps using SIFERS

Published by Brian Love on
Testing React Apps using SIFERS
Testing React Apps using SIFERS

Simple Injectable Functions Explicitly Returning State (SIFERS) is a testing strategy that seeks to improve the developer experience for writing automated tests that are easier to write, maintain, and less flaky.

In this article, you'll learn:

  • What are SIFERS
  • How to unit test using SIFERS
  • Why use SIFERS

What is SIFERS?

SIFERS is an acronym for:

  • Simple
  • Injectable
  • Functions
  • Explicitly
  • Returning
  • State

SIFERS is a replacement for the Jasmine-like API hooks:

  • beforeAll and beforeEach
  • afterAll and afterEach

Rather, our tests will contain a single setup() function that is responsible for explicitly returning the state of our system under test. The setup() function is invoked within each test.

Here is an example of a test using SIFERS.

interface SetupOptions {
  throwErrorWhenLoading?: boolean
}

function setup(options: SetupOptions) {
  const book = {
    id: uuid.v4(),
    name: 'The Lord of the Rings',
    earnings: '100',
    description:
      'The Lord of the Rings is an epic high fantasy novel written by English author and scholar J. R. R. Tolkien.',
  };

  BooksApi.deleteAllBooks();
  BooksApi.createBook(book);

  AuthApi.login(process.env.REACT_API_USERNAME, process.env.REACT_API_PASSWORD);

  return { book };
}

test('should work', () => {
  // arrange
  const { book } = setup();

  // act

  //assert
});

Let's review this first example of a test using SIFERS.

  • First, we may need some options to configure the state of our system under test. As such, we have defined a SetupOptions interface.
  • The setup() function is a single (and simple) function that predictably creates and returns the state.
  • First, we create a new book that our tests will use. We'll explicitly return this as part of the state object.
  • Next, we use the API to delete all of the books and then create the single book that we defined.
  • Next, we authenticate to the API.
  • Finally, we return the state object.

Demo

Check out a fully working demo. You can even run the tests using the Jest test runner on codesandbox.

The UserList Component

Before we dive into writing our tests using SIFERS, let's look at the UserList component that we are testing.

type Props = {
  onToggleUser: (user: User) => void;
  selectedUserIds: number[];
  users: User[];
};

export default function UserList({
  onToggleUser,
  selectedUserIds,
  users,
}: Props) {
  return (
    <Box sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}>
      <List data-testid="user-list">
        {users.map((user) => (
          <ListItem
            key={user.id}
            secondaryAction={
              <Checkbox
                edge="end"
                onChange={() => {
                  onToggleUser(user);
                }}
                checked={selectedUserIds.indexOf(user.id) > -1}
              />
            }
            data-testid={`user-${user.id}`}
            disablePadding
          >
            <ListItemAvatar>
              <Avatar src={user.avatar} />
            </ListItemAvatar>
            <ListItemText primary={user.first_name} />
          </ListItem>
        ))}
      </List>
    </Box>
  );
}

The UserList component is responsible for:

  • Rendering a list of users
  • Setting the checkbox state of selected users
  • Invoking a provided callback function when a user selection is toggled

How to unit test with SIFERS

Let's create a SIFERS for testing the UserList component above.

interface SetupOptions {
  onToggleUser?: (user: User) => void;
}

function setup(options?: SetupOptions) {
  const users = [
    {
      id: 1,
      first_name: 'Brian',
      last_name: 'Love',
      email: 'brian@liveloveapp.com',
      avatar: '',
    },
    {
      id: 2,
      first_name: 'Mike',
      last_name: 'Ryan',
      email: 'mike@liveloveapp.com',
      avatar: '',
    },
  ] as User[];
  const selectedUserIds = [users[0].id];

  render(
    <UserList
      onToggleUser={options?.onToggleUser ?? jest.fn()}
      selectedUserIds={selectedUserIds}
      users={users}
    />,
  );

  return {
    selectedUserIds,
    users,
  };
}

Let's review our setup() function:

  • We declare a new setup() function at the top of our spec file.
  • The setup() function optionally excepts a SetupOptions object. This enables our SIFERS to conditionally prepare the state or configure the system under test.
  • We define an array of mock users
  • We define an array of mock selectedUserIds
  • Render the UserList component
  • Return the state object.

Our setup() function:

  • Performs the necessary setup for our tests
  • Renders the UserList component
  • Returns the state of our system.

Further, it may be necessary to return a teardown() function as a property in order to perform any required cleanup after each test.

First Test

Now, let's look at our first test.

test('should display a list of users', () => {
  // arrange
  const { users } = setup();
  const list = screen.getByTestId('user-list');

  // assert
  expect(list.children).toHaveLength(users.length);
  users.forEach((user) => {
    expect(screen.getByText(user.first_name)).toBeTruthy();
  });
});

Let's review the test above:

  • First, we arrange the test by invoking the setup() function that explicitly returns the state, or in this case, the list of users. We then get the List element using the data-testid attribute.
  • Then, we assert that the list of users matches the length of the users provided and that each user is displayed as we expect.

Second Test

Next, let's look at a test that asserts that the UserList component selects the initially selected users.

test('should select the initially selected users', () => {
  // arrange
  const { selectedUserIds } = setup();

  // assert
  selectedUserIds.forEach((id) => {
    const el = screen.getByTestId(`user-${id}`);
    const checkbox = getByRole(el, 'checkbox');
    expect(checkbox.getAttribute('checked')).toBe('');
  });
});

Let's review the test above:

  • First, we arrange the test by invoking the setup() function that explicitly returns the state. In this case, we're only interested in the initial state of the selected users.
  • Then, we assert that each user's checkbox is checked as we expect.

Third Test

Finally, let's look at the final test that asserts that the UserList component should select a user.

test('should select a user', () => {
  // arrange
  const onToggleUser = jest.fn();
  const { users } = setup({ onToggleUser });
  const el = screen.getByTestId(`user-${users[0].id}`);
  const checkbox = getByRole(el, 'checkbox');

  // act
  fireEvent.click(checkbox);

  // assert
  expect(onToggleUser).toHaveBeenCalled();
});

Let's review the test above:

  • First, we arrange the test by creating a new mock function and assigning it to the onToggleUser constant. We then invoke the setup() function that explicitly returns the state providing the SetupOptions parameter with the mock function. We can now get the checkbox for the first user in the list.
  • Next, we act upon the system by firing the click event on the checkbox.
  • Then, we assert that the onToggleUser mock function has been called.

Why use SIFERS?

At this point, you may be wondering, "but, why use SIFERS?" In my experience, SIFERS provides 3 distinct advantages:

  1. A clear expectation of the state of the system under each test.
  2. Reduced clutter in beforeEach and afterEach functions.
  3. Reduced opportunity for flaky tests due to stale state.

Next, we'll learn how to migrate an existing Jest test to use SIFERS. In the process, we'll also identify each of these advantages.

Migrating to SIFERS

First, let's look at an example of a test without using SIFERS.

describe('Login Page Component', () => {
  const server = setupServer(
	  rest.get('/login', (req, res, ctx) => {
	    return res(ctx.json({
	      id: 123,
	      firstName: 'Brian',
	      lastName: 'Love'
	    }));
	  }),
	);

	beforeAll(() => server.listen());
	afterEach(() => server.resetHandlers());
	afterAll(() => server.close());

	test('should compile', () => {
	  const { asFragment } = render(<UserPage />);

	  expect(asFragment()).toMatchSnapshot();
	});

  test('should display an error', async () => {
	  server.use(
      rest.get('/greeting', (req, res, ctx) => {
        return res(ctx.status(500))
      }),
    );

	  render(<UserPage />);

		await waitFor(() => {
      expect(
        screen.queryByRole('error', {name: 'Uh oh. We ran into an error. Try again.'})
      ).toBeInTheDocument();
    });
  });
});

Now, let's look at an example of refactoring the test to use SIFERS:

interface SetupOptions {
  throwErrorWhenLoadingUser?: boolean;
}

function setup(options: SetupOptions) {
  const server = setupServer(
	  rest.get('/login', (req, res, ctx) => {
      if (options?.throwErrorWhenLoadingUser) {
        return res(ctx.status(500));
      } else {
		    return res(ctx.json({
		      id: 123,
		      firstName: 'Brian',
		      lastName: 'Love'
		    }));
      }
	  }),
	);
  server.listen();

  const result = render(<UserPage />);

  return {
    asFragment: result.asFragment;
    teardown: async () => {
      server.close();
      return Promise.resolve();
    }
  };
}

test('should compile', () => {
  const { asFragment, teardown } = setup();

  expect(asFragment()).toMatchSnapshot();

  return teardown();
});

test('should display an error', async () => {
  const { teardown } = setup({ throwErrorWhenLoadingUser: true });

	await waitFor(() => {
    expect(
      screen.queryByRole('error', {name: 'Uh oh. We ran into an error. Try again.'})
    ).toBeInTheDocument();
  });

  return teardown();
});

Let's review the migrated test.

  • First, we have a clear expectation of the state given that each test explicitly invokes the setup() function that returns the state. We also provide a teardown() callback function that is used to explicitly
  • Second, we have reduced the clutter of having multiple lifecycle hooks in our test. We have a single, easy-to-read, and grok, setup() function.
  • Third, we have reduced the flake of our tests. In the case of the “server error” test, we instruct our SIFERS to throw an error loading the user. If we compare this to the previous test, we had to override the behavior of our mock server and rely on resetting the handlers between each test.

Conclusion

Here at LiveLoveApp, we are proponents of using SIFERS for writing unit tests, integration tests, and end-to-end tests. Here are some suggestions:

  • If you are experiencing flaky tests, consider refactoring to use SIFERS
  • As a team and organization, consider using SIFERS for crafting future tests
Design, Develop and Deliver Absolute Joy
Schedule a Call