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: '[email protected]',
      avatar: '',
    },
    {
      id: 2,
      first_name: 'Mike',
      last_name: 'Ryan',
      email: '[email protected]',
      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

要查看或添加评论,请登录

LiveLoveApp的更多文章

社区洞察