UI Testing Best Practices 📜

Bryce Dorn
Glassdoor Engineering Blog
5 min readJul 9, 2021

--

Here at Glassdoor we take UI testing seriously, but the main focus has been on end-to-end (E2E) integration tests as opposed to small, quick unit tests for front-end testing coverage. I’ve been writing a decent amount of UI tests these past few weeks and thought I’d share a handful of patterns I’ve been adhering to — hopefully this can help guide good decision making when writing tests and make it easy to write more maintainable code.

The function names and examples I will provide are specific to Jest and RTL, but the concepts apply to other frontend testing libraries.

Know what not to test 🧠

Yes, the most important concept I have to share is about not testing. This may not apply to all situations but at Glassdoor we have thorough E2E integration testing, and it’s essential to understand the coverage that these tests provide and the use cases that should be covered by them, in lieu of a UI test.

Not every feature will require an integration test. If a use case requires ~3–4 mocks and the experience opens/closes modals and updates state, it should be left to integration testing. But when adding to or creating a new frontend component, a simple unit test should suffice.

❌ Bad example for a unit test:

  • Ensuring user login (user input, response) works as expected and lets a user view an admin page.

✅ Good examples:

  • Adding a new <option> to a <select> and verifying it’s shown.
  • Adding a click event to a button and confirming it fires.

Use snapshots wisely 📸

Snapshot testing is a great way to keep track of unexpected changes to a component. But it should not be confused with an actual functional test.

The use case for snapshots is when making changes to a shared component, it will provide a list of components that are affected. But that’s it! There is still manual effort required to confirm the change didn’t break those components.

Make it readable 📖

Tests, just like code, end up being compiled to a garbled mess of characters. It’s the duty of the developer to write clean, clear code to convey an idea to both the computer that interprets and the other developers that read it.

Jest provides a very readable syntax for test documentation, so use it!

❌ Bad:

describe(‘component’, () => {
it(‘performs correctly’, () => {

});
});

✅ Good:

describe(‘the admin page’, () => {
describe(‘when a user is not logged in’, () => {
it(‘shows a login button’, () => {

});
});
});

Notice how the test output will read like a complete sentence — this is what you should always strive for. That way, if a test fails on commit or in CI, there’s a clear reason for it.

Be concise & consistent 🔍

Each test should be as small as possible. The same concepts apply to DRY principles; here are some examples of good patterns to follow:

  • If there are multiple tests that share the same logic, share it via beforeEach or afterEach.
  • If testing multiple aspects of one component, define the render once in beforeEach.
  • If there are values inside a component that are referenced in a test, pull them out into consts and import them in both the test and in the component.
  • For example, when checking internationalized strings, instead of hardcoding the English value you can instead reference the output of an i18n library for that key.
  • Prioritize using test IDs over matching raw text, in case that text ever changes. If your team has a different pattern than what RTL encourages (data-testid), specify this in your config (at Glassdoor we use data-test).
  • If the same mock is used in multiple tests, define the response outside of the test and reference it in both places.

Mock fetches 🔮

For data-driven components, mocking an API response is easy and allows tests to mirror their use in production. Given the advent of hooks, it’s now much easier to position a GET request next to the output of a component, and mocking this data is just as easy!

I’ve been using @react-mock/fetch which makes it super easy to mock any HTTP request that a component relies on. It’s as simple as wrapping a component in a <FetchMock> and providing the response:

import { FetchMock } from ‘@react-mock/fetch’;const mockedResponse = {
matcher: ‘/ay’,
method: ‘GET’,
response: JSON.stringify({ body: ‘yo’ })
};
render(
<FetchMock options={mockedResponse}>
<MyComponent />
</FetchMock>
);

Depending on the use case, you may need to wrap the test in an act() or setImmediate() to proceed to the next iteration of the event loop and allow the component to render.

Auto-run tests 🚀

The way we do it here at Glassdoor is in multiple stages:

  • Husky pre-push hook before pushing to remote, as well as
  • A Jenkins merge build before merging a pull request into the target branch

It’s up to your team & how you’d like to organize your CI, but you should be doing at least one of these to position your tests as a line-of-defense against breakages.

The end 👋

That’s all for now, go write some tests!

--

--