Testing Patterns
Resources
Intro
When writing tests in Jest and Testing Library, one of the most common and foundational patterns you’ll see involves the trio of describe, it (or test), and expect. These constructs form the backbone of how tests are grouped, run, and validated. Understanding these core patterns—and a few others that commonly appear alongside them—can help you write more organized, readable, and maintainable test suites.
Describe, It/Test, Expect
describe:
A describe block is used to group related tests together. It essentially creates a test suite with a descriptive label, usually representing a unit of functionality—a component, a module, a function, or a specific feature area.
This hierarchical grouping helps:
- Organization: Tests are neatly sectioned off into logical categories. When reading the output in your terminal, describe blocks show up as headings, making it easy to quickly pinpoint where a specific test failure is coming from.
- Readability: Nested describe blocks allow you to mirror the structure of your code or break down complex functionalities into smaller test contexts.
it (or test):
Inside a describe block, individual test cases are defined using it or test. While these two functions are interchangeable (both are aliases), it often reads more naturally in sentence form: “it should render correctly,” “it should return an error when input is invalid.”
This pattern:
- Clarity: Each it block tests a single aspect or scenario, resulting in more granular and easier-to-understand test logs.
- Maintainability: When tests are small and focused, it’s simpler to pinpoint and fix problems, refactor code, or adjust test coverage as the underlying functionality evolves.
expect:
expect is the assertion library’s entry point. You pass it a value and chain matcher functions onto it—such as .toBe()
, .toEqual()
, .toContain()
, .toHaveLength()
, and many others—to verify that the output matches what you expect.
This pattern:
- Expressive Assertions: By choosing the right matcher, tests read like plain English: “expect the returned array toContain ‘apple’”.
- Immediate Feedback: If a matcher fails, Jest provides detailed, human-readable feedback, making it straightforward to understand the mismatch between expected and actual values.
example
import React from 'react';
export function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting component', () => {
it('renders the greeting message with the provided name', () => {
// Arrange & Act: Render the component with a given prop
render(<Greeting name="Alice" />);
// Assert: Use screen queries to verify the expected text is in the document
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
it('updates the greeting message when the name changes', () => {
// Arrange & Act: Render the component with another name
render(<Greeting name="Bob" />);
// Assert: Expect the new text to appear
expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
});
});
Test Tags
Test tags are a way to categorize tests so that you can run specific groups of tests. This can be useful when you want to run only a subset of tests, such as smoke tests, integration tests, or tests that are slow to run.
it('smoke: renders COMPONENT', async () => {})
it('integration: COMPONENT displays DATA', async () => {})
it('functional: COMPONENT INTERACTION', async () => {})
Or another common method is to use #smoke
, or @smoke
at the end of the test name.
.todo
If you have a test that you want to write but haven't yet, you can use .todo
to mark it as a pending test. This will show up in the test output as a reminder that the test is incomplete.
it.todo('renders the greeting message with the provided name');
Selectively Running Tests
If we have used Test Tags to categorize our tests then we can run all smoke tests for example like this:
npm test -- -t="smoke"
To run all the tests in a single testing file, you can use the following command:
npm test -- --testPathPattern="FILENAME.test.js"
Integration vs Functional Tests
Integration Tests:
Integration tests focus on verifying that different parts of a system work correctly when combined. Whereas unit tests validate individual functions or components in isolation, integration tests ensure that these units interact as intended. For example, you might test that a front-end component correctly retrieves data from an API endpoint, processes it, and displays the expected output. These tests typically run against multiple layers of the application—such as the UI, business logic, and data layer—but might still involve controlled test doubles or limited external dependencies. The primary goal is to confirm that modules integrate seamlessly and data flows correctly between them.
- Scope: Larger than unit tests but still not covering the entire system.
- Focus: Interactions between components, modules, or services.
- Typical Setup: Often uses more realistic data than unit tests and may involve some mocking or stubbing for external services.
Functional (or End-to-End) Tests:
Functional tests, also known as end-to-end (E2E) tests, verify that the entire application’s functionality works as expected from the user’s perspective. They simulate real-world usage by interacting with the system through its user interface or public API boundaries, rather than calling functions directly. For instance, a functional test might involve launching the app, logging in as a user, performing a series of steps, and confirming that the final screen matches the expected results. These tests run through all layers of the application—front-end, back-end, database, and external services—to validate not just integration, but the correctness and reliability of the full system under realistic conditions.
- Scope: Broadest coverage, often spanning the entire application stack.
- Focus: The overall user journey and application behavior, not just the pieces.
- Typical Setup: Uses live data or staging environments and minimal mocking. They test the system in conditions closest to production.
In Summary:
- Integration Tests ensure that parts of your system work together correctly.
- Functional (E2E) Tests ensure the entire system works as a cohesive whole and delivers the desired user experience.
Smoke Tests
A smoke test is a minimal test designed to confirm that a component or application renders and runs without immediately throwing errors. It doesn’t check in-depth functionality or behavior; instead, it focuses on verifying that your setup is correct and that the component can mount without crashing. In other words, if this test fails, something fundamental is broken.
In the context of React Testing Library, a smoke test often involves rendering the component with render() and making a basic assertion like “it successfully renders” or verifying that a known element appears in the document.
Example 1
import React from 'react';
export function App() {
return <div>Hello, world!</div>;
}
import React from 'react';
import { render, screen } from '@testing-library/react';
import { App } from './App';
test('it renders without crashing', () => {
// If this test passes, it means the component successfully mounted.
render(<App />);
// A minimal assertion: check that some known text is present.
expect(screen.getByText('Hello, world!')).toBeInTheDocument();
});
Example 2
Additional Jest Patterns
Beyond describe, it, and expect, Jest provides several other patterns and functions that help shape the structure and reliability of your tests.
Hooks (beforeAll, beforeEach, afterAll, afterEach):
When tests need setup or teardown—like initializing data, mounting components, or cleaning up after certain operations—Jest’s hooks provide a clear pattern. These run at predictable times relative to your tests:
- beforeAll/afterAll: Run once before and after all tests in a describe block. This is useful for expensive operations like connecting to a database or starting a server.
- beforeEach/afterEach: Run before and after each individual test. This ensures a clean slate for every test, preventing “test pollution” where one test’s side effects leak into another.
Snapshot Testing:
Snapshot testing, using expect(...).toMatchSnapshot(), allows you to record the expected output (often the rendered output of a UI component) and automatically compare it against future test runs:
- Regressions Caught Early: If the rendered output of a component changes unexpectedly, the snapshot test fails. This quickly alerts you to unintended UI or DOM changes.
- Fast Refactoring: When intentional changes occur, you can update the snapshot with a single command, making it frictionless to keep tests current.
Testing Library Patterns
When using Testing Library (such as React Testing Library), you’ll also encounter common testing patterns that emphasize testing user behavior over implementation details.
User-Centric Queries (getByRole, getByText, etc.):
Rather than selecting elements by their implementation details (like className or id), Testing Library encourages querying elements by how a user perceives them—role, displayed text, labels, and so forth. This pattern:
- Better Alignment With Accessibility: Tests become more resilient and meaningful, since they rely on semantic roles and accessible text, mirroring how real users interact with the UI.
- Less Brittle Tests: Since selectors are less dependent on internal structure and class names, your tests are more robust against minor refactors.
Asynchronous Utilities (findBy, waitFor, fireEvent, userEvent):*
For interactions that don’t happen instantly—like fetching data or waiting for animations—Testing Library provides async patterns:
- findBy* Queries: They return promises, making it simpler to wait for elements to appear on the screen after asynchronous data loading.
- waitFor: Helps handle transitions between states, ensuring your assertions run only when the UI is fully updated.
- fireEvent and userEvent: Allows you to simulate clicks, type in input fields, and trigger other DOM events, making it easy to reproduce user interactions in tests.
Advantages of These Patterns
- Improved Code Quality: By breaking down tests into logical groups (describe) and small, focused test cases (it), your codebase gains clarity and maintainability.
- Enhanced Readability and Understanding: expect’s fluent assertions and Testing Library’s user-centric queries ensure that tests read like documentation, clarifying intent for future maintainers.
- Reduced Flakiness: With hooks and asynchronous utilities, you can reliably set up the correct environment before tests run and handle dynamic UI changes, leading to more stable test outcomes.
- Better Alignment With User Experience: By thinking like a user (rather than focusing on internal mechanics), you ensure that passing tests reflect a working, user-friendly application.
Comments
Recent Work
Basalt
basalt.softwareFree desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.
BidBear
bidbear.ioBidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.