Elevate Your Code Quality with Effective Unit Testing

Written by andreydenisov | Published 2024/03/11
Tech Story Tags: software-testing | unit-testing | web | code-quality | react-development | semantic-web | web-accessibility | javascript-development

TLDRDelve into effective unit testing with this guide, focusing on user interaction alignment, clear tests, and optimized component testing. Learn the impact of semantic structure on search engine performance. Elevate your practices with practical examples and insights. Improve code quality and stay informed on testing best practices, JavaScript, React, and frontend development.via the TL;DR App

Explore this comprehensive guide on unit testing that delves into aligning tests with user interactions, the pivotal role of clear unit tests, and optimizing component interaction testing. Learn about the significance of maintaining semantic structure for improved search engine performance and enhanced user experience. Discover practical examples and a prioritized approach for effective component element selection. The article also provides insights into utilizing userEvent over fireEvent and offers valuable tips on selecting appropriate queries for robust testing. This resource is a must-read for developers aiming to elevate their unit testing practices.

General Approach

In the realm of unit testing, it's crucial to align your tests with user interactions. Aim to replicate how users engage with components, focusing on factors like role, text content, and labels instead of technical details such as class or id.

The Significance of Clear and Appropriate Unit Tests

Unit tests serve a dual purpose: not only do they validate both existing and new functionality to ensure proper functioning, but they also encourage engineers to produce code that is semantically reliable. Maintaining semantic structure impacts search engine performance, enhances web accessibility, and contributes to an improved user experience.

Components Interaction

User interactions with components often involve actions like clicking, typing, and hovering. To effectively test these interactions, steer clear of using fireEvent, which dispatches DOM events. Opt for userEvent instead; it simulates comprehensive interactions, triggering multiple events and performing additional checks along the way for a more thorough assessment of your components' behavior.

fireEvent is a lightweight wrapper around the browser's low-level dispatchEvent API, which allows developers to trigger any event on any element.

userEvent is a wrapper around fireEvent that allows you to describe a user interaction instead of a concrete event. It adds visibility and interactability checks along the way and manipulates the DOM just like a user interaction in the browser would. It factors in that the browser e.g. wouldn't let a user click a hidden element or type in a disabled text box.

Example

test('types into text box', () => {
  render(<textarea />)

  // ❌ Bad approach
  fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'Hello World!' },
  })

  // ✅ Good approach
  userEvent.type(screen.getByRole('textbox'), 'Hello World!')

  expect(screen.getByRole('textbox')).toHaveValue('Hello,\nWorld!')
})

React documentation about the differences

Selection of component elements

Tests should look for component elements in the same way as a user would. The user cannot search the page for elements by their class, id, or test-id. But the user can search for elements by their role, by the text that the element contains, by the label.

Use data-testid only in edge cases. If you cannot select the element by role, containing text, label, etc. Then it indicates to you that the component structure should be probably refactored.

Using data-testid attributes do not resemble how your software is used and should be avoided if possible. That said, they are way better than querying based on DOM structure or styling css class names.

Example

test('types into text box', () => {
  const { container } = render(<Example />)

  // ❌ Bad approach. Don't use querySelector or other DOM methods. The user doesn't see the class name on the page.
  const button = container.querySelector('.btn-primary')

  // ❌ Bad approach. The user doesn't test-id of elements. The user doesn't see the DOM stracture and nested attributes.
  const button = screen.getByTestId('foot').firstChild

  // ✅ Good aproach. The user can find a button element.
  const button = screen.getByRole('button')

  // ✅ Good aproach. The user can find an element by text inside that.
  const button = screen.getByText('Loaded')

  expect(message).toHaveTextContext('Loaded')
})

Good explanation about test-id

Priority

Based on the Guiding Principles, your test should resemble how users interact with your code (component, page, etc.) as much as possible. With this in mind, we recommend this order of priority:

  1. Queries Accessible to Everyone Queries that reflect the experience of visual/mouse users as well as those that use assistive technology.

    1. getByRole: This can be used to query every element that is exposed in the accessibility tree. With the name option you can filter the returned elements by their accessible name. This should be your top preference for just about everything. There's not much you can't get with this (if you can't, it's possible your UI is inaccessible). Most often, this will be used with the name option like so: getByRole('button', {name: /submit/i}). Check the list of roles.
    2. getByLabelText: This method is really good for form fields. When navigating through a website form, users find elements using label text. This method emulates that behavior, so it should be your top preference.
    3. getByPlaceholderText: A placeholder is not a substitute for a label. But if that's all you have, then it's better than alternatives.
    4. getByText: Outside of forms, text content is the main way users find elements. This method can be used to find non-interactive elements (like divs, spans, and paragraphs).
    5. getByDisplayValue: The current value of a form element can be useful when navigating a page with filled-in values.
  2. Semantic Queries HTML5 and ARIA compliant selectors. Note that the user experience of interacting with these attributes varies greatly across browsers and assistive technology.

    1. getByAltText: If your element is one which supports alt text (img, area, input, and any custom element), then you can use this to find that element.
    2. getByTitle: The title attribute is not consistently read by screenreaders, and is not visible by default for sighted users
  3. Test IDs

    1. getByTestId: The user cannot see (or hear) these, so this is only recommended for cases where you can't match by role or text or it doesn't make sense (e.g. the text is dynamic).

Queries

DOM Testing Library, which React Testing Library is built on top of, now exposes a screen object which has every query built-in. The changed best practice is to always use screen object and no longer destructure the object returned by render. The benefit of using a screen is you no longer need to keep the render call destructure up-to-date as you add/remove the queries you need. You only need to type screen and react testing library implementation will take care of the rest.

Example

test('renders a message', () => {
  const { getByText } = render(<Greeting />)

  // ❌ Bad approach.
  expect(getByText('Hello, world!')).toBeInTheDocument()

  // ✅ Good aproach
  expect(screen.getByText('Hello, world!')).toBeInTheDocument()
})

getBy* vs queryBy*

The get* methods throw an error when the element is not found. So when we are asserting if an element is present (e.g. .toBeInTheDocument()) and it’s not found, using the get* methods will offer a better error message over query* or find*. Similarly, the query* methods return null instead of throwing, which is perfect when testing when an element is not present. That way the test will fail on the assertion (.not.ToBeInTheDocument()) instead of throwing an error with the get* methods.

Example

test('renders a message', () => {
  render(<Greeting />)

  // ✅ use get* when asserting presence
  expect(screen.getByText('Hello, world!')).toBeInTheDocument()

  // ✅ use query* when asserting absence
  expect(screen.queryByRole('region')).not.toBeInTheDocument()
})

findBy*

The findBy* returns a Promise which resolves when an element is found which matches the given query. The promise is rejected if no element is found or if more than one element is found after a default timeout of 1000ms. So, use when you expect that the element will appear after some async changes.

Async calls

findBy*

Use findBy* when handling the element that is not available right away, as findBy* throws better error message.

// ❌
const submitButton = await waitFor(() =>
  screen.getByRole('button', {name: /submit/i}),
)

// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})

waitFor

Do not use side effects in waitFor block

WaitFor is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing. Because of this, the callback can be called (or checked for errors) a non-deterministic number of times and frequency (it's called both on an interval as well as when there are DOM mutations). So this means that your side-effect could run multiple times!

// ❌
await waitFor(() => {
  fireEvent.keyDown(input, {key: 'ArrowDown'})
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

// ✅
userEvent.keyboard('[ArrowDown]')
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

Checking visibility via CSS styles

toBeVisible

test('renders a hidden message', () => {
  const { getByText } = render(
      <div style="display: none">
        Hello, world!
      </div>
);

  // ❌ Bad approach.
  expect(getByText('Hello, world!')).toHaveStyle('display: none');

  // ✅ Good aproach
  expect(screen.getByText('Hello, world!')).not.toBeVisible();
})

test('renders a message', () => {
  render(
        <div style="display: none">
          <button>Hello, world!</button>
        </div>)

  // ❌ Wont work.
  expect(screen.getByText('Hello, world!')).not.toBeVisible();
})

Useful links

  • https://chrome.google.com/webstore/detail/testing-library-which-que/olmmagdolfehlpjmbkmondggbebeimoh - the extension that helps to choose the appropriate selector for the element

  • https://chrome.google.com/webstore/detail/testing-library-which-que/olmmagdolfehlpjmbkmondggbebeimoh - the extension that helps to choose the appropriate selector for the element

  • https://chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano - the extension that allows user to write live unit tests (on the page)

  • https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#having-multiple-assertions-in-a-single-waitfor-callback

Also published here.


Written by andreydenisov | Web engineering, system architecture, testing, web-performance, accessibility
Published by HackerNoon on 2024/03/11