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

推荐订阅源

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
AHA Testing 💡
2019-04-07 · via Kent C. Dodds Blog

The AHA Programming Principle stands for "Avoid Hasty Abstraction." I have specific feelings about how this applies to writing maintainable tests. Most of the tests that I've seen in the wild have been wildly on one side of the spectrum of abstraction: ANA (Absolutely No Abstraction), or completely DRY (Don't Repeat Yourself). (I made up ANA just now).

ANA(Absolutely No Abstraction)

AHA(Avoid Hasty Abstraction)

DRY(Don't Repeat Yourself)

TheSpectrumofAbstraction

Finding a sweet spot in the middle of the spectrum of abstraction is key to developing maintainable tests.

ANA Testing

The best example of "Absolutely No Abstraction" I've seen in testing is for ExpressJS route handlers. For you to understand what I mean when I say "ANA is bad for testing" I'm going to give you a typical test file and ask you to pretend you're going to maintain this codebase and these tests. It's important for you to understand how this route works. You're relieved that there are tests in place which will help you make sure you're not going to break something. So now you're going to use the tests to understand the nuances of the route handler.

Try to read this test and understand the one nuance between the two of them.

Don't spend too long on this...
import * as blogPostController from '../blog-post'

// load the application-wide mock for the database.
// I guess that means this is AANA (Almost Absolutely No Abstraction)
// but I didn't want to write out a whole db mock for this blog post 😅
jest.mock('../../lib/db')

test('lists blog posts for the logged in user', async () => {
	const req = {
		locale: {
			source: 'default',
			language: 'en',
			region: 'GB',
		},
		user: {
			guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
			isActive: false,
			picture: 'http://placehold.it/32x32',
			age: 30,
			name: {
				first: 'Francine',
				last: 'Oconnor',
			},
			company: 'ACME',
			email: 'francine.oconnor@ac.me',
			latitude: 51.507351,
			longitude: -0.127758,
			favoriteFruit: 'banana',
		},
		body: {},
		cookies: {},
		query: {},
		params: {
			bucket: 'photography',
		},
		header(name) {
			return {
				Authorization: 'Bearer TEST_TOKEN',
			}[name]
		},
	}
	const res = {
		clearCookie: jest.fn(),
		cookie: jest.fn(),
		end: jest.fn(),
		locals: {
			content: {},
		},
		json: jest.fn(),
		send: jest.fn(),
		sendStatus: jest.fn(),
		set: jest.fn(),
	}
	const next = jest.fn()

	await blogPostController.loadBlogPosts(req, res, next)

	expect(res.json).toHaveBeenCalledTimes(1)
	expect(res.json).toHaveBeenCalledWith({
		posts: expect.arrayContaining([
			expect.objectContaining({
				title: 'Test Post 1',
				subtitle: 'This is the subtitle of Test Post 1',
				body: 'The is the body of Test Post 1',
			}),
		]),
	})
})

test('returns an empty list when there are no blog posts', async () => {
	const req = {
		locale: {
			source: 'default',
			language: 'en',
			region: 'GB',
		},
		user: {
			guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
			isActive: false,
			picture: 'http://placehold.it/32x32',
			age: 30,
			name: {
				first: 'Francine',
				last: 'Oconnor',
			},
			company: 'ACME',
			email: 'francine.oconnor@ac.me',
			latitude: 31.230416,
			longitude: 121.473701,
			favoriteFruit: 'banana',
		},
		body: {},
		cookies: {},
		query: {},
		params: {
			bucket: 'photography',
		},
		header(name) {
			return {
				Authorization: 'Bearer TEST_TOKEN',
			}[name]
		},
	}
	const res = {
		clearCookie: jest.fn(),
		cookie: jest.fn(),
		end: jest.fn(),
		locals: {
			content: {},
		},
		json: jest.fn(),
		send: jest.fn(),
		sendStatus: jest.fn(),
		set: jest.fn(),
	}
	const next = jest.fn()

	await blogPostController.loadBlogPosts(req, res, next)

	expect(res.json).toHaveBeenCalledTimes(1)
	expect(res.json).toHaveBeenCalledWith({
		posts: [],
	})
})

Did you find the difference? Yeah! We expect to find a post in the first one and not in the second one! Cool! Great job. But... what causes that? Why does blogPostController.loadBlogPosts(req, res, next) call res.json with a blog post in the first one and not in the second one?

If you didn't figure that out, don't feel bad and don't worry, I'll show you later. If you did, you're probably really good at "Where's Wally" and that's my point. Tests like this make it harder than it needs to be to understand and maintain the tests.

Now imagine that there are twenty such tests in a single file. You think it's terrible? Yes, it's pretty bad. Never seen tests like this before? You're lucky! I've seen it a lot. Here's how it gets this way:

  1. Engineer Joe joins a team
  2. Joe needs to add a test
  3. Joe copies a previous test that looks like what they need and modifies it for their use case.
  4. Reviewers observe that the tests pass and assume Joe knows what they're talking about.
  5. PR is merged.

Here's your litmus test:

How easy is it to determine the difference between assertions of two similar tests and what causes that difference?

Absolutely No Abstraction testing makes this very difficult.

DRY Testing

I don't have time at the moment to give you a good example of a DRY test. Just know that often what happens when people apply DRY to anything they typically wind up being harder to maintain due to this process:

  1. Engineer Joe joins a team
  2. Joe needs to add a test
  3. Joes copies a previous test that looks basically exactly like what they need and adds another if statement to the testing utility for their case.
  4. Reviewers observe that the tests pass and assume Joe knows what they're talking about.
  5. PR is merged.

Another thing that I see a lot in DRY testing is the overuse of describe and it nesting + beforeEach. The more you nest and use shared variables between tests, the harder it is to follow the logic. I write about this problem a little bit in Test Isolation with React which I recommend you read.

AHA Testing

That first test is absolutely screaming for abstraction (which is the guiding principle for AHA programming). So let's write a thoughtful abstraction for that test. Now try to figure out what makes the difference in these tests:

import * as blogPostController from '../blog-post'

// load the application-wide mock for the database.
jest.mock('../../lib/db')

function setup(overrides = {}) {
	const req = {
		locale: {
			source: 'default',
			language: 'en',
			region: 'GB',
		},
		user: {
			guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
			isActive: false,
			picture: 'http://placehold.it/32x32',
			age: 30,
			name: {
				first: 'Francine',
				last: 'Oconnor',
			},
			company: 'ACME',
			email: 'francine.oconnor@ac.me',
			latitude: 51.507351,
			longitude: -0.127758,
			favoriteFruit: 'banana',
		},
		body: {},
		cookies: {},
		query: {},
		params: {
			bucket: 'photography',
		},
		header(name) {
			return {
				Authorization: 'Bearer TEST_TOKEN',
			}[name]
		},
		...overrides,
	}

	const res = {
		clearCookie: jest.fn(),
		cookie: jest.fn(),
		end: jest.fn(),
		locals: {
			content: {},
		},
		json: jest.fn(),
		send: jest.fn(),
		sendStatus: jest.fn(),
		set: jest.fn(),
	}
	const next = jest.fn()

	return { req, res, next }
}

test('lists blog posts for the logged in user', async () => {
	const { req, res, next } = setup()

	await blogPostController.loadBlogPosts(req, res, next)

	expect(res.json).toHaveBeenCalledTimes(1)
	expect(res.json).toHaveBeenCalledWith({
		posts: expect.arrayContaining([
			expect.objectContaining({
				title: 'Test Post 1',
				subtitle: 'This is the subtitle of Test Post 1',
				body: 'The is the body of Test Post 1',
			}),
		]),
	})
})

test('returns an empty list when there are no blog posts', async () => {
	const { req, res, next } = setup()
	req.user.latitude = 31.230416
	req.user.longitude = 121.473701

	await blogPostController.loadBlogPosts(req, res, next)

	expect(res.json).toHaveBeenCalledTimes(1)
	expect(res.json).toHaveBeenCalledWith({
		posts: [],
	})
})

Now can you tell? What's the difference between the first and the second test? In the first our user is in London and in the second our user is in Shanghai! Gee, sure would've been nice if our co-workers had told us we were working on a location-based blogging platform (hey... now that's an interesting product idea 🤔).

By adding just a little mindful abstraction, we've been able to make it much more clear what actually matters in the difference of the inputs and outputs leading to tests which make a LOT more sense and are WAY easier to maintain.

AHA Testing with React

In a react world, I will sometimes have a renderFoo function that acts like the setup function here. Here's a simple example:

import * as React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from '../login-form'

function renderLoginForm(props) {
	render(<LoginForm {...props} />)
	const usernameInput = screen.getByLabelText(/username/i)
	const passwordInput = screen.getByLabelText(/password/i)
	const submitButton = screen.getByText(/submit/i)
	return {
		usernameInput,
		passwordInput,
		submitButton,
		changeUsername: (value) => userEvent.type(usernameInput, value),
		changePassword: (value) => userEvent.type(passwordInput, value),
		submitForm: () => userEvent.click(submitButton),
	}
}

test('submit calls the submit handler', () => {
	const handleSubmit = jest.fn()
	const { changeUsername, changePassword, submitForm } = renderLoginForm({
		onSubmit: handleSubmit,
	})
	const username = 'chucknorris'
	const password = 'ineednopassword'
	changeUsername(username)
	changePassword(password)
	submitForm()
	expect(handleSubmit).toHaveBeenCalledTimes(1)
	expect(handleSubmit).toHaveBeenCalledWith({ username, password })
})

Note: I would consider this pre-mature abstraction if you've only got two or three tests in the file that is using it and those tests are short. But if you've got some nuance you're testing (like error states for example), then this kind of abstraction is great.

Nesting

I'd recommend you give Avoid Nesting in Tests a read.

jest-in-case and test.each

If you're writing tests for a pure function, you're in luck because those are often the easiest to test for. You can seriously simplify your tests by using a simple abstraction that calls out VERY clearly the outputs and inputs.

For (contrived) example:

import add from '../add'

test('adds one and two to equal three', () => {
	expect(add(1, 2)).toBe(3)
})

test('adds three and four to equal seven', () => {
	expect(add(3, 4)).toBe(7)
})

test('adds one hundred and two to equal one hundred two', () => {
	expect(add(100, 2)).toBe(102)
})

That's pretty simple to follow, but it can be improved with jest-in-case:

import cases from 'jest-in-case'
import add from '../add'

cases(
	'add',
	({ first, second, result }) => {
		expect(add(first, second)).toBe(result)
	},
	[
		{ first: 1, second: 2, result: 3 },
		{ first: 3, second: 4, result: 7 },
		{ first: 100, second: 2, result: 102 },
	],
)

I probably wouldn't bother doing this for this simple example, but what's cool about it is that you can add more test cases very easily by simply adding more elements to that array. A good example of this concept (that actually doesn't use jest-in-case) is the rtl-css-js tests. Contributors to this codebase find it very easy to add new test cases with this structure.

This can also be applied to impure functions and modules as well, though it takes a little bit more work. (Here's a test that does this which I'm not totally proud of, but it's not too bad)

I personally prefer jest-in-case but Jest has a built-in test.each functionality that you may find useful.

Conclusion

Certainly our tests could've been improved by providing better names and/or comments as well, but our simple setup abstraction (by the way, that's called a "Test Object Factory") doesn't really need them. So my point is: it takes less work to write and maintain tests that have mindful abstractions applied to them.

I hope that's helpful! Good luck!