惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
GRAHAM CLULEY
S
Security @ Cisco Blogs
P
Proofpoint News Feed
Cisco Talos Blog
Cisco Talos Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cyber Attacks, Cyber Crime and Cyber Security
T
Tor Project blog
WordPress大学
WordPress大学
Project Zero
Project Zero
S
Schneier on Security
P
Proofpoint News Feed
小众软件
小众软件
P
Privacy International News Feed
美团技术团队
L
LangChain Blog
Know Your Adversary
Know Your Adversary
J
Java Code Geeks
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The Register - Security
The Register - Security
N
Netflix TechBlog - Medium
Microsoft Security Blog
Microsoft Security Blog
Engineering at Meta
Engineering at Meta
I
InfoQ
量子位
Vercel News
Vercel News
博客园 - 三生石上(FineUI控件)
Spread Privacy
Spread Privacy
D
DataBreaches.Net
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
U
Unit 42
P
Privacy & Cybersecurity Law Blog
C
Cybersecurity and Infrastructure Security Agency CISA
T
The Blog of Author Tim Ferriss
Latest news
Latest news
K
Kaspersky official blog
MongoDB | Blog
MongoDB | Blog
L
LINUX DO - 热门话题
Simon Willison's Weblog
Simon Willison's Weblog
云风的 BLOG
云风的 BLOG
S
Securelist
AWS News Blog
AWS News Blog
F
Fortinet All Blogs
T
Threat Research - Cisco Blogs
Stack Overflow Blog
Stack Overflow Blog
Scott Helme
Scott Helme
Help Net Security
Help Net Security
Y
Y Combinator Blog
宝玉的分享
宝玉的分享
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
T
Tenable Blog

Kent C. Dodds Blog

Implementing Hybrid Semantic + Lexical Search Simplifying Containers with Cloudflare Sandboxes Migrating to Workspaces and Nx Offloading FFmpeg with Cloudflare Building Semantic Search on my Content Helping YOU ask ME questions with AI How I used Cursor to Migrate Frameworks The Dow's Start on the Covenant Path 2025 in Review The next chapter: EpicAI.pro AI is taking your job How I increased my visibility Launching Epic Web 2023 in Review Stop Being a Junior RSC with Dan Abramov and Joe Savona Live Stream Fixing a Memory Leak in a Production Node.js App 2022 in Review My Car Accident I Migrated from a Postgres Cluster to Distributed SQLite with LiteFS I'm building EpicWeb.dev A review of my time at Remix Remix: The Yang to React's Yin How I help you build better websites Why I Love Remix The State Initializer Pattern How to React ⚛️ Get a catch block error message with TypeScript Building an awesome image loading experience How Remix makes CSS clashes predictable Introducing the new kentcdodds.com How I built a modern website in 2021 How to use React Context effectively Static vs Unit vs Integration vs E2E Testing for Frontend Apps The Testing Trophy and Testing Classifications Array reduce vs chaining vs for loop Don't Solve Problems, Eliminate Them Super Simple Start to Remix Super Simple Start to ESModules in Node.js JavaScript Pass By Value Function Parameters How to write a Constrained Identity Function (CIF) in TypeScript How to optimize your context value How to write a React Component in TypeScript TypeScript Function Syntaxes Listify a JavaScript Array Build vs Buy: Component Libraries edition Using fetch with TypeScript Wrapping React.useState with TypeScript Define function overload types with TypeScript 2020 in Review Business and Engineering alignment Hi, thanks for reaching out to me 👋 useEffect vs useLayoutEffect Super simple start to Firebase functions Super simple start to Netlify functions Super Simple Start to css variables Favor Progress Over Pride in Open Source Testing Implementation Details How getting into Open Source has been awesome for me useState lazy initialization and function updates Use ternaries rather than && in JSX Application State Management with React Use react-error-boundary to handle errors in React JavaScript to Know for React How I structure Express apps What open source project should I contribute to? When I follow TDD AHA Programming 💡 How I Record Educational Videos Should I write a test or fix a bug? Stop mocking fetch Intentional Career Building Improve test error messages of your abstractions Tracing user interactions with React Eliminate an entire category of bugs with a few simple tools Common mistakes with React Testing Library Super Simple Start to React Stop using client-side route redirects The State Reducer Pattern with React Hooks Function forms Replace axios with a simple custom fetch wrapper How to test custom React hooks React Production Performance Monitoring Should I useState or useReducer? Stop using isLoading booleans Make Your Test Fail Make your own DevTools An Argument for Automation Fix the "not wrapped in act(...)" warning Super Simple Start to ESModules in the Browser Implementing a simple state machine library in JavaScript 2010s Decade in Review Why users care about how you write code Why I avoid nesting closures Don't call a React function component Why your team needs TestingJavaScript.com Inversion of Control Understanding React's key prop How to Enable React Concurrent Mode Profile a React App for Performance
Test Isolation with React
2018-07-02 · via Kent C. Dodds Blog

The inspiration for this blogpost comes from seeing React tests that look like this:

const utils = render(<Foo />)

test('test 1', () => {
	// use utils here
})

test('test 2', () => {
	// use utils here too
})

So I want to talk about the importance of test isolation and guide you to a better way to write your tests to improve the reliability of the tests, simplify the code, and increase the confidence your tests and provide as well.

Let's take this simple component as an example:

import React, { useRef } from 'react'

function Counter(props) {
	const initialProps = useRef(props).current
	const { initialCount = 0, maxClicks = 3 } = props

	const [count, setCount] = React.useState(initialCount)
	const tooMany = count >= maxClicks

	const handleReset = () => setCount(initialProps.initialCount)
	const handleClick = () => setCount((currentCount) => currentCount + 1)

	return (
		<div>
			<button onClick={handleClick} disabled={tooMany}>
				Count: {count}
			</button>
			{tooMany ? <button onClick={handleReset}>reset</button> : null}
		</div>
	)
}

export { Counter }

Here's a rendered version of the component:

a rendered version of the component

Our first test suite

Let's start with a test suite like the one that inspired this post:

// gives us the toHaveTextContent/toHaveAttribute matchers
import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import { Counter } from '../counter'

const { getByText } = render(<Counter maxClicks={4} initialCount={3} />)
const counterButton = getByText(/^count/i)

test('the counter is initialized to the initialCount', () => {
	expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
	userEvent.click(counterButton)
	expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
	expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
	userEvent.click(getByText(/reset/i))
	expect(counterButton).toHaveTextContent('3')
})

First of all, as of @testing-library/react@9.0.0 this style of testing won't even work properly, but let's imagine that it would.

These tests give us 100% coverage of the component and verify exactly what they say they'll verify. The problem is that they share mutable state. What is the mutable state they're sharing? The component! One test clicks the counter button and the other tests rely on that fact to pass. If we were to delete (or .skip) the test called "when clicked, the counter increments the click" it would break all the following tests:

broken tests

This is a problem because it means that we can't reliably refactor these tests, or run a single test in isolation of the others for debugging purposes because we don't know which tests are impacting the functionality of others. It can be really confusing when someone comes in to make changes to one test and other tests start breaking out of nowhere.

Better

So let's try something else and see how that changes things:

import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import { Counter } from '../counter'

let getByText, counterButton

beforeEach(() => {
	const utils = render(<Counter maxClicks={4} initialCount={3} />)
	getByText = utils.getByText
	counterButton = utils.getByText(/^count/i)
})

test('the counter is initialized to the initialCount', () => {
	expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
	userEvent.click(counterButton)
	expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
	userEvent.click(counterButton)
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
	userEvent.click(counterButton)
	userEvent.click(getByText(/reset/i))
	expect(counterButton).toHaveTextContent('3')
})

With this, each test is completely isolated from the other. We can delete or skip any test and the rest of the tests continue to pass. The biggest fundamental difference here is that each test has its own count instance to work with and it's unmounted after each test (this happens automatically thanks to React Testing Library). This significantly reduces the amount of complexity of our tests with minor changes.

One thing people often say against this approach is that it's slower than the previous approach. I'm not totally sure how to respond to that... Like, how much slower? Like a few milliseconds? In that case, so what? A few seconds? Then your component should probably be optimized because that's just terrible. I know it adds up over time, but with the added confidence and improved maintainability of this approach, I'd gladly wait an extra few seconds to render things this way. In addition, you shouldn't often have to run the entire test base anyway thanks to great watch mode support like we have in Jest.

Even better

So I'm actually still not super happy with the tests we have above. I'm not a huge fan of beforeEach and sharing variables between tests. I feel like they lead to tests that are harder to understand. Let's try again:

import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import { Counter } from '../counter'

function renderCounter(props) {
	const utils = render(<Counter maxClicks={4} initialCount={3} {...props} />)
	const counterButton = utils.getByText(/^count/i)
	return { ...utils, counterButton }
}

test('the counter is initialized to the initialCount', () => {
	const { counterButton } = renderCounter()
	expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
	const { counterButton } = renderCounter()
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
	const { counterButton } = renderCounter({
		maxClicks: 4,
		initialCount: 4,
	})
	expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
	const { counterButton } = renderCounter({
		maxClicks: 4,
		initialCount: 4,
	})
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
	const { getByText, counterButton } = renderCounter()
	userEvent.click(counterButton)
	userEvent.click(getByText(/reset/i))
	expect(counterButton).toHaveTextContent('3')
})

Here we've increased some boilerplate, but now every test is not only isolated technically, but also visually. You can look at a test and see exactly what it does without having to worry about what hooks are happening within the test. This is a big win in the ability for you to be able to refactor, remove, or add to the tests.

Even better better

I like what we have now, but I think we need to take things one step further before I feel really happy about things. We've split our tests up by functionality, but what we really want to have confidence in is the use case that our component satisfies. It allows clicks until the maxClicks is reached, then requires a reset. That's what we're trying to verify and gain confidence in. I'm much more interested in use cases when I'm testing than specific functionality. So what would these tests look like if we concerned ourselves more with the use case than the individual functionality?

import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import { Counter } from '../counter'

test('allows clicks until the maxClicks is reached, then requires a reset', () => {
	const { getByText } = render(<Counter maxClicks={4} initialCount={3} />)
	const counterButton = getByText(/^count/i)

	// the counter is initialized to the initialCount
	expect(counterButton).toHaveTextContent('3')

	// when clicked, the counter increments the click
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')

	// the counter button is disabled when it's hit the maxClicks
	expect(counterButton).toHaveAttribute('disabled')
	// the counter button no longer increments the count when clicked.
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')

	// the reset button has been rendered and is clickable
	userEvent.click(getByText(/reset/i))

	// the counter is reset to the initialCount
	expect(counterButton).toHaveTextContent('3')

	// the counter can be clicked and increment the count again
	userEvent.click(counterButton)
	expect(counterButton).toHaveTextContent('4')
})

I really love this kind of test. It helps me avoid thinking about functionality and focus more on what I'm trying to accomplish with the component. It serves as much better documentation of the component than the other tests as well.

In the past, the reason we wouldn't do this (have multiple assertions in a single test) is because it was hard to tell which part of the test broke. But now we have much better error output and it's really easy to identify what part of the test broke. For example:

broken tests

The code frame is especially helpful. It shows not only the line number, but the code around the failed assertion which shows our comments and other code to really help give us context around the error message that not even our previous tests gave us.

I should mention, this isn't to say that you shouldn't separate test cases for a component! There are many reasons you'd want to do that and most of the time you will. Just focus more on use cases than functionality and you'll generally cover most of the code you care about with that. Then you can have a few extra tests to handle edge cases.

Conclusion

I hope this is helpful to you! You can find the code for this example here. Try to keep your tests isolated from one another and focus on use cases rather than functionality and you'll have a much better time testing! Good luck!