Best Practices for Testing with Jest - part 1

Best Practices for Testing with Jest - part 1

Writing tests is an essential part of maintaining reliable and scalable applications. With Jest being one of the most popular testing frameworks in the JavaScript ecosystem, it's crucial to understand the best practices that can help you write clean, efficient, and maintainable tests.

In this post, we’ll explore some of the best practices for writing tests with Jest, focusing on test structure, naming conventions, isolation, readability, and ensuring tests reflect real-world user behavior.

By following these guidelines, you can enhance the quality of your tests and ensure that your code performs as expected, even as your application evolves. Let’s dive in!


1 - Test Structure: Write Tests in AAA Style

The Arrange-Act-Assert (AAA) pattern keeps your tests clear and structured:

  • Arrange: Set up the necessary preconditions and inputs.
  • Act: Perform the operation that you are testing.
  • Assert: Verify that the outcome is as expected.

Example:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MyComponent from './MyComponent';

test('should update input value', async () => {
  // Arrange
  render(<MyComponent />);
  const input = screen.getByRole('textbox');

  // Act
  await userEvent.type(input, 'Hello, Jest!');

  // Assert
  expect(input).toHaveValue('Hello, Jest!');
});
        

2 - Descriptive test names and describe blocks

Good test names act as documentation and make it easy to understand what is being tested.

  • Use meaningful and descriptive test names.
  • Use describe blocks to group related tests together.

Example:

describe('User Login', () => {
  test('should allow a user to log in with valid credentials', () => {
    // test logic
  });

  test('should show an error message for incorrect credentials', () => {
    // test logic
  });
});
        

3 - Independence: Keep Tests Isolated

  • Problem: If a test depends on the results of another, you can end up with a domino effect of failures. For example, if the first test fails, subsequent tests might also fail even if they aren't directly related.
  • Solution: Ensure that each test sets up its own environment and state, and doesn’t rely on the execution order to pass.

Example:

Wrong ?:

import Counter from './Counter';

describe('Counter', () => {
  let counter;

  // First test: rely on previous state
  test('should increment value', () => {
    counter = new Counter();
    counter.increment();
    expect(counter.getValue()).toBe(1);
  });

  // Second test: this depends on the first test, which is problematic
  test('should increment value again', () => {
    expect(counter.getValue()).toBe(2);  // This assumes the first test already ran successfully
  });
});        

Right ?:

import Counter from './Counter';

describe('Counter', () => {
  // First test: isolated
  test('should increment value', () => {
    const counter = new Counter();  // Initialize inside the test
    counter.increment();
    expect(counter.getValue()).toBe(1);
  });

  // Second test: isolated
  test('should increment value again', () => {
    const counter = new Counter();  // Initialize again
    counter.increment();
    counter.increment();
    expect(counter.getValue()).toBe(2);
  });
});        

4 - Cleanup (Teardown) after each test:

  • Problem: Sometimes, a test can leave a "dirty" state that affects subsequent tests, such as changes in a global variable or a shared database.
  • Solution: After each test, ensure any changed state is cleaned up, whether that’s resetting variables, restoring mocks, or clearing database data. Tools like Jest’s beforeEach and afterEach help ensure that state is cleaned between tests.

Example:

// resets mocks after each test
afterEach(() => {
  jest.clearAllMocks();
});        

5 - Avoid getByTestId – Don’t tie tests to implementation

Using getByTestId can make your tests too dependent on internal details, leading to brittle tests when refactoring.

When you use getByTestId, the test depends on a specific data-testid attribute in the component's code. This means that if the name or structure of the data-testid changes during refactoring or updates to the code, the tests will break even if the functionality of the application remains unchanged.

getByTestId accesses elements based on internal implementation details. In contrast, testing with methods like getByRole, getByLabelText, or getByText focuses on how the user interacts with the UI rather than the internal details.

For example, a user doesn't care about the data-testid attribute of a button. They care about the button's name or its function, such as "Submit" or "Send." Using getByRole("button", { name: /submit/i }) better reflects actual user behavior and ensures that the test is resilient to changes in the internal implementation.

Prefer using getByRole, getByLabelText, getByText, or getByPlaceholderText to query elements in the DOM. These methods focus on how users interact with the elements, making the test more resilient to changes in the implementation.

? Prefer role-based queries:

const button = screen.getByRole('button', { name: 'Submit' });        

? Avoid:

const button = screen.getByTestId('submit-button');        

6 - Why use userEvent instead of fireEvent?

When testing React components, it's essential to simulate user interactions in a way that reflects how users interact with the app in real life. While both userEvent and fireEvent allow you to trigger events in tests, userEvent is often preferred because it provides a more realistic simulation of actual user behavior.

  • fireEvent: This function is used to manually trigger events like clicks, typing, or focus changes. While it can be effective for triggering events, it does not simulate the nuances of how real users interact with the app. For example, when simulating a click event, fireEvent simply fires the event without considering whether it was triggered by a real mouse click (e.g., mouse movements, delay between clicks, etc.).
  • userEvent: This API was introduced as a more user-friendly alternative to fireEvent because it simulates real user interactions more closely. For example, userEvent mimics the typing speed and behavior of a real user when entering text into an input field. It also simulates focus changes, mouse movements, and clicks in a more natural way.

Example Comparison:

  • Using fireEvent:

fireEvent.change(inputElement, { target: { value: 'Hello' } });        

This only triggers the change event and doesn't consider typing speed or focus.

  • Using userEvent:

await userEvent.type(input, 'Hello, world!');        

This makes tests more reliable and realistic compared to fireEvent.input(input, { target: { value: 'Hello' } }).


?? Conclusion

By following these best practices, you'll write clean, maintainable, and effective tests with Jest. Good testing leads to fewer bugs, more confidence in deployments, and a better development experience. ??

What are your go-to Jest best practices? Let’s discuss in the comments! ??

Kaique Perez

Fullstack Software Engineer | Node | Typescript | React | Next.js | AWS | Tailwind | NestJS | TDD | Docker

3 周

Well said and thanks for sharing! Lucas Mendon?a

Bruno Freitas

Senior React Developer | Full Stack Developer | JavaScript | TypeScript | Node.js

3 周

Nice, thanks for sharing !

Gabriel Levindo

Android Developer | Mobile Software Engineer | Kotlin | Jetpack Compose | XML

3 周

Well done!!

Cassio Almeron

Senior Full Stack Software Engineer | C# | .NET | SQL | Javascript | React | JQuery | TDD

3 周

Great post, I am a unit test enthusiast as well, and I believe this same concept goes beyond Jest and JavaScript, and is valid for any stack.

Otávio Prado

Senior Business Analyst | ITIL | Communication | Problem-Solving | Critical Thinking | Data Analysis and Visualization | Documentation | BPM | Time Management | Agile | Jira | Requirements Gathering | Scrum

3 周

Insightful! Thanks for sharing Lucas! ????

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

Lucas Mendon?a的更多文章

社区洞察

其他会员也浏览了