Testing React Applications
Learn how to test React components and applications using Jest and React Testing Library. Master unit testing, integration testing, and testing best practices.
In this lesson, we'll join Ollie the Owl as he builds a reusable ToggleSwitch component for his React component library. By the end, you will know how to set up a testing environment with Jest and React Testing Library (RTL), write unit tests for React components (including user interactions), test custom hooks, work with context providers, mock API calls, and handle asynchronous behavior and error states. We'll follow a project-based approach – learning each concept by writing code for real-world scenarios, with tips on best practices and avoiding common mistakes.
What We'll Build & Learn:
- ToggleSwitch Component: A reusable on/off switch with internal state. We'll implement it and write unit tests for its rendering and toggle behavior.
- User Interactions: Simulating clicks and other events using @testing-library/user-event to ensure our UI responds to user actions.
- Custom Hook Tests: Abstracting toggle logic into a useToggle hook and testing it independently.
- Context Providers: Using React Context (e.g., a Theme or Auth context) in components and testing context-driven behavior by wrapping components in providers.
- Mocking API Calls: Using Jest to mock network requests (e.g. fetch) so we can test components that fetch data without making real HTTP calls.
- Async & Error States: Writing tests for asynchronous operations (like data loading) and error handling in the UI.
- Best Practices: Guidelines on organizing test files, naming conventions, structuring tests, and common pitfalls to avoid for robust, maintainable tests.
Let's get started with setting up our testing tools and then dive into building and testing our ToggleSwitch component!
Setting up Jest and React Testing Library
Most modern React projects come with Jest as the test runner (for example, Create React App sets up Jest by default). Jest runs tests in a Node.js environment using JSDOM to emulate the browser. We will use React Testing Library to render components and query the DOM in a way that resembles real user interactions . RTL focuses on testing the application's behavior rather than implementation details.
Installation: If your project doesn't already include it, install React Testing Library and related packages:
Loading syntax highlighting...
- @testing-library/react provides utilities to render components and query the DOM.
- @testing-library/jest-dom provides custom Jest matchers (like .toBeInTheDocument(), .toHaveTextContent(), etc.) for better assertions .
- @testing-library/user-event lets us simulate user interactions more realistically than Jest's basic fireEvent .
After installing, configure Jest to use these tools:
- Jest DOM: Import it once in your test setup (e.g., in a setupTests.ts file that Jest loads before tests). For example, in src/setupTests.ts:
This adds matchers like expect(element).toBeInTheDocument() globally .
- User Event: Import userEvent in tests when you need to simulate clicks, typing, etc. (We'll see this in action soon).
Filename & Folder Setup: Jest looks for files with .test.ts or .spec.ts suffix, or files inside a tests folder . A common approach is to co-locate test files with the components they test. For example, you might have ToggleSwitch.tsx alongside ToggleSwitch.test.tsx in the same folder. This makes it easy to find tests and use short relative imports .
Running Tests: Use npm test (for CRA or similar setups) to run in watch mode. Jest will run tests related to changed files by default, or you can press "a" to run all tests. Make sure all tests pass before moving on.
Now that our environment is ready, let's build the ToggleSwitch and write our first tests!
Tracking state in a toggle component
First, we create the ToggleSwitch component. This is a simple UI component that displays an "On" or "Off" state and allows the user to toggle it. Ollie designs it with reusability in mind: it should manage its own state internally but also call an optional callback (e.g., onToggle) so parent components or contexts can respond to the change.
Implementation details: The toggle will be a button that shows its current state. We'll use React's useState hook to track whether it's on or off. Each click will flip the state. We'll also ensure it's accessible – using a role or aria-attribute so it can be queried and interacted with in tests (and by assistive tech for real users).
Loading syntax highlighting...
In this code:
- We initialize state with a prop (initialOn), defaulting to false (off).
- On click, we flip isOn and call onToggle with the new state if an external callback is provided.
- The button's text shows a simple indicator (blue dot for On, white for Off) and the word "ON" or "OFF". We also use the aria-pressed attribute on the button to indicate a toggle button's pressed state for accessibility. This gives the button an implicit role of "button" which we can query in tests.
Why use aria-pressed? This attribute turns a regular button into a toggle button that indicates on/off state to screen readers and also allows Testing Library to query its state if needed. It's not strictly required, but it's a good practice for accessible design. In tests, we could use getByRole('button', { pressed: true })
to find the "ON" state, or simply check the text content.
Now that ToggleSwitch is implemented, let's verify it works as intended by writing tests.
Writing unit tests for the ToggleSwitch
We will create a file ToggleSwitch.test.tsx for our tests. These tests will cover:
- Initial Render: Does the component render with the correct initial state (text should show "OFF" by default or according to initialOn prop)?
- Toggle Behavior: Does clicking the button change its state from Off to On, and vice versa? Does it call the onToggle callback with the right value?
Before writing tests, we'll import the necessary utilities: render and screen from RTL, and userEvent to simulate clicks. It's common to import screen so you can use it for queries globally , as this avoids the need to extract methods from the render result.
Loading syntax highlighting...
Test: Renders with correct initial state
We want to ensure that by default the toggle shows "OFF". We'll render the component without any props and look for the text "OFF" on screen.
Loading syntax highlighting...
We use screen.getByText(/off/i) to find an element with text "off" (case-insensitive). This should find our button. We then assert it's in the document (meaning the component rendered it) . We also demonstrate using getByRole('button') – since our button has an implicit role, this returns the element. We check that it contains "OFF" and aria-pressed="false".
Queries: Notice we didn't use getByTestId or query DOM classes. A best practice is to query elements as a user would – by accessible roles or text content . Here, using text and role makes our test reflect actual UI and avoids coupling to internal implementation (like specific class names) .
Test: Toggles state on click
Next, we simulate a user clicking the toggle and verify the behavior:
Loading syntax highlighting...
Here we:
- Find the button by role, additionally filtering by accessible name
{ name: /off/i }
to match the text "OFF". getByRole is powerful because it ensures the element is accessible and can also match the text via the name option (which corresponds to the button's label) . - Use
userEvent.click(button)
to simulate a real click. We await the click because userEvent in v14+ is asynchronous – it simulates the full browser interaction which might involve events that are async. (In this simple case, it's immediate, but we follow the recommended pattern to always await user interactions .) - After the click, we expect the button's text to now contain "ON", and aria-pressed to be "true". We then click again and verify it toggled back to "OFF".
Tip: By using userEvent instead of fireEvent, we ensure our test is closer to how a user actually interacts with the button (it will, for example, ensure the element can be focused and is not disabled or hidden before clicking) . This helps catch issues that pure fireEvent might not (like clicking an element that isn't actually clickable) .
Test: Calls onToggle callback
We should also test that if an onToggle prop is passed, it gets called with the correct values. We can use a Jest mock function (jest.fn()) to pass as the callback and inspect if it was called.
Loading syntax highlighting...
We verify the mock function was called with true on the first click (turning on) and false on the second click, and that it was called twice in total. Using toHaveBeenCalledWith and toHaveBeenCalledTimes (Jest assertions) makes it clear what interactions occurred.
At this point, we've covered basic unit tests for our component: rendering, state toggling, and callback invocation. We've effectively treated the ToggleSwitch as a unit, isolating it from any external context. The tests focus on what the user sees and does (text content changes and button clicks) rather than how the component manages state internally – this is exactly the approach RTL encourages.
Testing user interactions and events
We already used userEvent.click
for basic clicks. Let's expand on user interactions with a couple more examples and tips:
- Simulating Different Events: The userEvent library can simulate many events – typing, hovering, keyboard navigation, etc. For example, if our toggle could also be triggered by pressing the spacebar (as toggle buttons often allow), we could do:
Loading syntax highlighting...
This would simulate pressing the space key on the focused button (which toggles it if the button handles keyboard events). userEvent.type
types text into inputs, but for a button, sending a {space}
triggers a click activation.
- Pointer Events and Others: You can simulate double clicks (
userEvent.dblClick(element)
), right clicks, hover (userEvent.hover(element)
), unhover, and more. Each userEvent method ensures the sequence of events is realistic (e.g., a click will also fire a mousedown and mouseup, and a focus if appropriate, etc.). In contrast, usingfireEvent.click
would just trigger the click event with none of the associated behavior. - Asserting After Interactions: Often, after an interaction, the DOM will change (like our toggle text). It's important to assert the outcome after the event, and if the outcome is asynchronous (e.g., after an API call or a state update happening in a setTimeout), you need to wait for it (more on that soon). In our toggle tests, state updates are synchronous, so our assertions can run immediately after the
await userEvent.click
.
Common Mistake – Not Using userEvent: A frequent testing mistake is using low-level events (fireEvent
) for interactions that have higher-level consequences, or not triggering the right sequence of events. For instance, manually toggling a state in a test without simulating the user event that causes it can lead to testing implementation details rather than user behavior. Always prefer simulating the user's actions. The Testing Library docs put it well: "The more your tests resemble the way your software is used, the more confidence they can give you."
In summary, use user-event to interact with your components in tests, and then check that the UI responded appropriately. We've done this with clicks on the ToggleSwitch. Next, we'll move on to another dimension: testing a piece of logic extracted as a custom hook.
Testing custom React hooks
Ollie notices that the toggle logic (managing a boolean state and flipping it) could be abstracted into a reusable hook. He creates a useToggle
hook so it can be used in multiple components. Our job now is to write tests for this hook's behavior, independent of any UI.
The useToggle Hook:
Imagine a simple implementation like this:
Loading syntax highlighting...
This hook returns a [value, toggleFunction]
. Now, how do we test a hook by itself? We can't directly call it like a regular function in a test, because hooks can only be called within a React function component. This is where React Testing Library's renderHook utility (from @testing-library/react) comes in handy. renderHook
lets us invoke a hook as if it were used in a component and gives us access to the result.
First, import renderHook
(and possibly act
, which we'll use shortly):
Loading syntax highlighting...
Test: Initial state is respected – We'll verify that the hook returns the initial value we provide.
Loading syntax highlighting...
Here, renderHook(() => useToggle())
calls our hook and returns an object with a result
. The result.current
property holds the hook's return value (our [value, toggle]
array). We check result.current[0]
(the value) is false by default, and then do another render with initial true to ensure it honors that. (We could also use the initialProps
option of renderHook
to pass an argument to the hook if it were defined to accept props , but for simplicity, we just call it directly with the parameter.)
Test: Toggling the value – We want to simulate calling the toggle function and see if the value changes. However, updating hook state is like updating component state – we need to wrap it in RTL's act
to make sure all React state updates are processed before assertions
Loading syntax highlighting...
We retrieve the toggle function from result.current
. Calling this function will update the hook's state, but if we do it outside of act
, React will warn that we updated state without wrapping in act
. By using act(() => { toggle(); })
, we tell React Testing Library to flush all state updates and effects inside that callback. After the act
, we assert the new state.
Why act? In general, RTL automatically wraps render and user-event actions in act
internally, so you rarely need to call it yourself . But when testing custom hooks (or when directly calling state-updating functions like we did), you have to manually use act
to simulate the component lifecycle. The act
ensures that all updates have been applied before we read result.current
. If we forget act
here, our expect might see the old value and the test could fail or warn. In our example, calling toggle()
without act
would leave the state update pending when we do the assertion, causing a failure . Wrapping it in act
resolves the issue.
Custom hook testing recap: We used renderHook
to test the hook in isolation from any components. We verified initial state and state transitions via the hook's API. This is a unit test for our hook. Alternatively, we could have tested the hook indirectly via the component (the ToggleSwitch tests already cover that toggling works from a user perspective). Both approaches are useful: testing the hook directly gives confidence in the hook's logic (especially if it's complex), while testing via components ensures the integration of hook + component works. Use renderHook
for fine-grained tests of hook behavior, and component tests for end-to-end usage.
Testing context provider behavior
React Context is often used in real apps (for theming, authentication, global settings, etc.), so it's important to know how to test components that depend on context. In our scenario, suppose Ollie adds a context to manage theme or some global state that the ToggleSwitch (or another component) uses. We'll illustrate testing context with a simple example: a component that reads from a User context and displays a greeting.
Imagine we have a context and component like this:
Loading syntax highlighting...
The UserGreeter component says "Hello, [name]!" if a user object is provided in context, or "Hello, stranger!" if user is null/undefined. How do we test this? The key is to render the component with a context provider wrapping it, so that it receives the desired context value . There's no need to mock the context or override React – just provide the context the same way the app would.
Test: Greets a logged-in user and a stranger
We will write two tests: one with a UserProvider supplying a user, and one with no provider (or an explicit user = null).
Loading syntax highlighting...
In the second test, we wrap UserGreeter
with UserProvider
and pass a dummy user. The component now sees user.name = "Ollie"
via context and should render "Hello, Ollie!". Our assertion confirms that. In the first test, by not wrapping with a provider (or we could wrap with UserProvider
having user={null}
), the context value is undefined (or null), and we expect the "Hello, stranger!" message .
Key Takeaway: To test components that use context, render them within the appropriate provider with test values. This way, you're testing the component under the same conditions as in the real app . You typically do not need to mock the context with Jest. (Only in very complex cases might you consider mocking context, but that's rarely needed if you can just provide real or fake values via the provider.)
Tip: If you have many tests needing the provider, you can create a custom render function that always wraps components with context. RTL allows a wrapper
option in render
for this purpose . For example, you could do:
Loading syntax highlighting...
This can reduce repetition. In our example, the manual render calls were simple enough.
We could also tie this back to our ToggleSwitch: imagine ToggleSwitch's onToggle
updates a context value (like a ThemeContext toggling dark/light mode). We would test that by rendering ToggleSwitch within the ThemeProvider and asserting that the context value changed, or that some effect of context took place. The approach is the same: wrap in provider, perform actions, assert results.
Mocking API calls with Jest
In real applications, components often fetch data from APIs. Testing such components requires controlling the network calls so we're not hitting real endpoints. We can mock API calls in tests to return consistent fake data or errors. Jest provides functions to spy on or replace global functions like fetch
, or modules like Axios.
Let's consider a component that fetches a list of users and displays them. We won't write the full component here, but assume something like:
Loading syntax highlighting...
This component shows "Loading…" initially, then either a list of user names or an error message. We want to test both the successful data load and the error case, without making actual HTTP requests.
Mocking fetch: We can use jest.spyOn
to spy on the global fetch
function and provide a custom implementation for the test. Another approach is to override global.fetch
with a jest mock. For simplicity, we'll use jest.spyOn(global, 'fetch')
here.
Loading syntax highlighting...
Let's break down what we did:
- We used
mockResolvedValueOnce
onfetch
to make it return a promise that resolves to an object with ajson
method. When our component callsfetch(...).then(res => res.json())
, it will getfakeUsers
as data. (We used an async function forjson
to mimic the real fetch response behavior, which returns a promise.) - We render the component. Immediately after rendering, it likely shows "Loading…" (we assert that).
- We then use
screen.findByText(user.name)
for each fake user.findByText
is a convenient RTL query that combines waiting and getting – it will retry until the text appears in the DOM or timeout. We await eachfindByText
, which will pause the test until the user's name is rendered in a list item . This indicates the component received the data and updated its state. - We also check that "Loading…" is no longer in the DOM using
queryByText
(which returns null if not found, instead of throwing). - Finally, we assert that
fetch
was called exactly with the endpoint we expect ('/api/users'). We could also checktoHaveBeenCalledTimes(1)
if needed.
Using this approach, we've mocked the network request and tested how our component handles the success case. The test is fast and doesn't depend on any actual backend.
Test: handles error state – We should also test what happens on network error. We can mock fetch
to reject (simulate a thrown error or network failure):
Loading syntax highlighting...
In this case, our component catches the error and sets an error state which renders "Error loading users". The test awaits findByText(/error loading users/i)
to ensure that appears. We again confirm "Loading…" disappears. We don't necessarily need to verify fetch call here again, but we could if desired.
Why mock instead of calling real API? Hitting a real API in tests is slow, and the data could change or the call could fail unexpectedly, leading to flaky tests. Mocking gives us control to simulate various responses (success, error, slow response, etc.) consistently . It also lets us run tests offline and without needing a test server. This isolation is crucial for reliable unit tests.
Alternate approaches: Instead of manually mocking fetch
, you can use libraries like jest-fetch-mock or MSW (Mock Service Worker). Jest-fetch-mock allows you to do fetchMock.mockResponseOnce(JSON.stringify(fakeData))
in tests; MSW sets up a fake API server that responds to requests. MSW is great for integration tests and is quite powerful (and advocates not stubbing fetch at all ), but it's a bit advanced for a fundamentals lesson. For now, knowing how to spy on fetch
or axios
is sufficient.
Cleaning up: Notice we called jest.restoreAllMocks()
in a beforeEach
. This resets any mocked functions to their original implementation before each test, to avoid interference between tests. It's a good practice when you use jest.spyOn
or jest.fn()
on global modules. Alternatively, one could call global.fetch.mockRestore()
at the end of the test, as seen in some examples . Using restoreAllMocks
in a beforeEach
is a convenient way to ensure a clean slate.
Now that we've covered mocking API calls and testing both loading and error states, let's discuss some general strategies for testing asynchronous operations effectively.
Testing asynchronous operations and error states
When testing any async behavior in React components (data fetching, setTimeout delays, etc.), there are a few patterns and best practices to ensure your tests are both correct and reliable:
- Use findBy and waitFor: In RTL,
findBy*
queries (e.g.findByText
,findByRole
) automatically wait up to a timeout for the element to appear. This is perfect for most cases like waiting for content after an API call . We usedscreen.findByText
in our tests above. Alternatively,waitFor
is a utility where you pass a callback that will be retried until it passes or times out. For example:
Loading syntax highlighting...
This would keep checking that "Loading…" disappears. We could use this instead of the last queryByText
check in our test. In general, prefer findBy
for simplicity, but use waitFor
if you need to make multiple assertions together or need more control. (RTL automatically wraps these in act
, so you don't need to in your test code.)
- Don't rush to use act manually: As mentioned,
waitFor
andfindBy
are built on RTL's internal handling ofact
, so you typically don't need to wrap those calls. If you see a warning like "An update to component X was not wrapped in act(…)", it often means you either didn't wait for something or you triggered an update outside of a React act scope. The solution is usually to await afindBy
or usewaitFor
properly, not necessarily to sprinkleact
everywhere . - Testing intervals or timers: If your component uses
setTimeout
or an interval, Jest's fake timer utilities (jest.useFakeTimers()
) can be helpful. You can fast-forward time in tests. For example, if a component shows a message for 5 seconds then hides it, you could usejest.advanceTimersByTime(5000)
and then assert the message is gone. Just remember to runjest.useRealTimers()
after if needed, and wrap timer advances inact
if they trigger state updates. - Consistent initial conditions: When testing error states, ensure the component truly enters that state. In our UserList example, we confirmed "Loading…" first to be sure the component started loading, then we waited for the error message. This double-check ensures our test isn't falsely passing due to a previous state. Similarly, if testing a loading spinner disappears after data load, first assert it's present, then later assert it's gone.
- Avoiding false passes: A common mistake is to use
getBy
for something that appears later.getBy
will immediately throw if not found, so if you use it too early, the test will fail. That's why we usefindBy
(async) for things that appear after an async operation. Conversely, usingfindBy
for something that is already present might mask issues – e.g. if you mistype the text,findBy
will wait the full timeout before failing, slowing your tests. Use the right query for the situation:getBy
for synchronous expectations,findBy
for async. - Checking side effects: In an error scenario, besides showing an error message, you might want to ensure no stale data is displayed. For instance, if the list was previously showing data and then we simulate an error on refresh, does it clear the old list? You can assert that old content is gone (using
queryBy
to confirm an item is not present). - Multiple async steps: Sometimes one action triggers multiple async events (like a cascade of fetches). You might need multiple
await findBy...
or awaitFor
that checks a condition after all have completed. It's often helpful to chain multiplemockResolvedValueOnce
calls onfetch
for sequential calls, and then assert on final output . Ensure each asynchronous step is awaited in the test.
By carefully controlling and awaiting asynchronous operations, we can make our tests deterministic and avoid those pesky intermittent failures.
Best practices and common mistakes in React testing
Writing good tests is as much an art as a science. Here are some best practices to follow, along with common mistakes to avoid:
- Test Behavior, Not Implementation: Focus on the output and effects of a component (DOM elements, calls to callbacks, context interactions) rather than its internal state or the exact implementation. For example, we tested that ToggleSwitch displays "ON" or "OFF" after clicks, not that a
useState
value was true or false internally. This makes tests more robust to refactoring – if we changed ToggleSwitch to use a Redux store or context to manage state, the behavior (text changes and callback calls) could remain the same and our tests would still pass. A bad test would be one that tries to dig into ToggleSwitch's state variables or calls its internal functions (which we can't do directly anyway without hacking the component). - Use Queries That Reflect User Experience: As emphasized earlier, prefer queries like
getByRole
,getByText
, orgetByLabelText
overgetByTestId
. Testing Library has a recommended hierarchy for queries – with Role and Text at the top because they best simulate how users find elements . Test IDs are fine as a last resort (for something purely visual with no accessible label), but overusing them leads to tests that are less tied to real usage. Also avoid querying by specific styling or CSS classes (those can change without breaking functionality). Our tests used roles and text which are very resilient. - Leverage screen for Simplicity: Rather than extracting queries from the render result, we used the
screen
object. This is recommended because it leads to clearer tests and you don't have to constantly capture the return ofrender
. All the queries are available onscreen
after you callrender(<Component/>)
. This also makes it easier to copy-paste or refactor tests, as you don't need to pass around the destructured queries. - Include Accessibility in Tests: If a component is supposed to be accessible, your tests can enforce that. For example, checking that an image has alt text via
getByAltText
, or a form input has the proper label viagetByLabelText
. This not only tests functionality but also ensures you don't break accessibility attributes. Our use ofaria-pressed
in ToggleSwitch allowed us to use role queries effectively. - Don't Overuse act(): As discussed, many things are already wrapped in
act
. If you find yourself writingact(async () => { ... await userEvent.click() ... })
, that's not needed –userEvent
already handles it. Kent C. Dodds (RTL's creator) notes that wrapping things unnecessarily inact
is a common mistake . Instead, understand whenact
is truly needed (usually when directly triggering state updates outside of the React events cycle, like our hook case). If you seeact
warnings, read them closely – often it means you missed anawait
on something likefindBy
or forgot to wait for an effect to finish . The solution is to fix the test flow, not just wrap everything inact
to silence the warning. - Avoid Memory Leaks Between Tests: Each test should render components in isolation. RTL by default unmounts components after each test, so you usually don't need to manually clean up (in fact, calling
cleanup()
manually is now unnecessary ). However, if you mock modules or global objects, be sure to reset or restore them (usingjest.resetAllMocks()
orjest.restoreAllMocks()
) so one test's mocks don't spill into another. We demonstrated that with thebeforeEach
forglobal.fetch
. - Meaningful Test Names: Name your tests for what they verify. Instead of "should work correctly" be specific: "toggles from OFF to ON when clicked" or "renders error message on failed fetch". This helps understand test failures at a glance.
- Keep Tests Focused: Each test should generally cover one behavior. If a user action triggers multiple outcomes, it's okay to assert multiple things in one test (we did so for toggling text and
aria-pressed
together). But avoid writing a single test that covers an entire component's lifecycle of events – that can become brittle and hard to debug. It's better to have a few smaller tests, each for a specific scenario or edge case, than one mega-test. - Don't Duplicate Implementation in Tests: For example, if you have a complex conditional rendering in a component, don't replicate the same if/else in your test to decide what to expect – that just copies the logic into the test, defeating the purpose. Instead, hard-code the expected outcomes for given inputs/events. The test should serve as documentation of what the component should do.
- Use jest-dom Matchers: Functions like
toBeInTheDocument
,toHaveTextContent
,toHaveAttribute
,toBeDisabled
,toHaveClass
, etc., make assertions more expressive. For instance,expect(button).toBeDisabled()
is clearer thanexpect(button.disabled).toBe(true)
and gives better error messages on failure . We usedtoBeInTheDocument
a lot, and also demonstratedtoHaveTextContent
andtoHaveAttribute
. These come from@testing-library/jest-dom
which we set up. Always include that in your toolkit – it's widely considered a best practice to use it . - Snapshots Sparingly: We didn't cover snapshot testing explicitly, but a note: create-react-app includes a sample snapshot test (e.g., using react-test-renderer). Snapshot tests can catch unintended changes in UI output, but they can also be noisy. Avoid over-relying on snapshots for dynamic components – prefer explicit assertions. Snapshots are best for things like ensuring a complex component output doesn't drastically change, but testing-library queries are usually more targeted and meaningful. In a fundamentals course, focus on behavioral tests rather than snapshots (which test implementation output and can fail even if functionality is unaffected).
Common mistakes summary: Using wrong queries (e.g., only test IDs), not waiting for async updates, wrapping everything in act
, testing internal state, leaving tests dependent on each other, and ignoring accessibility are pitfalls to avoid. By following the best practices above, we ensure our tests are effective and maintainable. As Kent Dodds says, tests should give you confidence that your app works for users – always keep that in mind.
Organizing test files and structure
How you structure your tests in the project can influence readability and maintenance:
File Organization Patterns
There are two common approaches to organizing test files:
1. Co-located tests (recommended):
File Structuresrccomponents│ ├── ToggleSwitch.tsx│ ├── ToggleSwitch.test.tsx│ ├── UserProfile.tsx│ └── UserProfile.test.tsxhooks│├─useToggle.ts│└─useToggle.test.ts
2. Separate test directories:
File Structuresrccomponents│ ├── ToggleSwitch.tsx│ └── UserProfile.tsx__tests__│├─components│ ├── ToggleSwitch.test.tsx│ └── UserProfile.test.tsx│├─hooks││└─useToggle.test.ts
- File Location: As mentioned, colocating tests with their component (e.g.,
ToggleSwitch.test.tsx
next toToggleSwitch.tsx
) is a good default. It keeps component and test together, making it easy to find and update both when needed . In larger projects, some prefer a separate__tests__
directory mirroring the component structure – that's fine too. The key is consistency. Jest will find tests in either pattern as long as the naming matches. Choose a convention and stick with it. - Test Naming: Use the
.test.ts
(or.test.tsx
) suffix for clarity. Some also use.spec.ts
. Jest supports both. Again, consistency matters. Many projects use.test.
because it's clear these are test files. - Grouping Tests with describe: You can use
describe
blocks to group related tests, especially if a component has many behaviors. For example:
Loading syntax highlighting...
This can organize output in test results. However, don't over-nest describes; one level of grouping by component or context is usually enough. (CRA's docs note that describe
is optional and not required for every file – you can use it as needed for clarity).
-
Arrange-Act-Assert pattern: Within each test, follow a structure:
- Arrange: set up the component and any data (e.g., render component, initialize mock data or context).
- Act: perform user interactions or trigger the behavior (e.g., click a button, call a function, etc.).
- Assert: check the outcomes (e.g., expected text appears, function called, state value changed, etc.).
We applied this pattern in our tests. It makes tests easier to read. You can even comment the sections or use blank lines to separate them visually.
-
Avoid logic in tests: If you find yourself writing loops, conditions, or complex calculations in tests, step back and reconsider. Tests should be straightforward. (A simple loop to iterate over a few expected items, like we did for
for (const user of fakeUsers) expect(await screen.findByText(user.name))...
, is okay – we knew exactly what we expected for each user. But we wouldn't write a function in the test to filter users or anything – that's the app's job.) -
Test Environment configuration: If using a framework like Next.js or Vite, ensure Jest (or the alternative test runner) is configured to use the JS DOM environment so that
document
andwindow
are available for RTL. In Jest, this is usually the default (testEnvironment: "jsdom"
in Jest config). CRA handles it for you. This isn't something you do per test, but it's part of organizing your overall setup (like thesetupTests.ts
we mentioned for jest-dom). -
Keep tests independent: Each test should be able to run in isolation. That's why we reset mocks and don't rely on side effects from other tests. Jest runs tests in random order (by default) and possibly parallel, so never assume one test's outcome affects another. Shared setup should go in a
beforeEach
or at least at the top of the test file (like importing the same modules). We usedbeforeEach
for resetting mocks. You could also use it for rendering a common UI if every test starts from the same point, but often it's clearer to just render within each test so you see all relevant code within that test.
Following these organization tips makes your test suite easier to navigate and scale as the project grows.
Recap: what we learned 🚀
- Setting up Jest and React Testing Library for a React project, including installing
@testing-library/react
,jest-dom
, anduser-event
. - Building a ToggleSwitch component and understanding its state management (using
useState
to track on/off). - Writing unit tests for components using RTL's
render
and querying the output viascreen
. We wrote tests to verify initial UI and state changes in response to events. - Simulating user interactions with
@testing-library/user-event
to click buttons and more, rather than manually calling component methods . This ensures our tests simulate real usage and fire the correct sequence of events. - Testing custom hooks using
renderHook
andact
to verify hook logic in isolation . We tested auseToggle
hook's initial value and toggle functionality. - Testing context-dependent components by wrapping components with context providers in tests, instead of trying to mock context directly . This let us assert that components react correctly to different context values.
- Mocking API calls using Jest spies/mocks for
fetch
(or any API client). We simulated successful responses and errors and tested how our components handle loading states, render fetched data, and display error messages – all without making real network requests. - Handling asynchronous operations in tests with
findBy
queries andwaitFor
, ensuring we wait for updates to occur before asserting outcomes. We learned to always await async interactions and DOM updates to avoid race conditions. - Best practices such as querying by role/text (avoiding overly brittle queries), using
screen
andjest-dom
for clear assertions, focusing on user-facing behavior, and keeping tests simple but thorough. We also highlighted common mistakes like overusingact
, not waiting for async events, or testing implementation details – and how to avoid them. - Organizing tests with clear file naming, structure, and using
describe
blocks and proper setup/teardown. We emphasized isolated tests (resetting mocks, not sharing state) and using the AAA (Arrange-Act-Assert) pattern for clarity.
By following these principles, you can write tests that serve as a safety net for your React application. They will catch regressions early and give you confidence when refactoring or adding new features. And as Ollie the Owl continues to build out the component library, these testing skills will ensure each new component and hook is reliable and bug-free. Happy testing! 🎉