🌑

React 测试中的常见错误

Read in English

大多数前端开发者(至少是我遇到的那些)不喜欢写测试。我问过他们原因,答案很简单:我们没有时间做这件事! 好吧,我想告诉你,用正确的方式写测试会显著提升你的开发效率(尤其是从长远来看),而不是浪费你的时间。

写测试会迫使你思考自己的抽象是否合理,如果不合理,你甚至根本无法对它进行测试。而如果你对自己的抽象能做什么没有充分的理解,在为它写测试时就会手忙脚乱。这正是一些人不喜欢写测试的原因所在。但如果你开始为项目写测试,你将获得提升编程能力的好处。

我希望在你测试之旅的起点,就帮助你写出更简洁、更合理的测试,让你真正从测试中受益。以下是你在写测试时应该避免的一些错误:

错误一:测试实现细节

这是测试中的第一大错误,也是开发者放弃测试的最主要原因(我认为)。

什么是实现细节?简单来说,它关注的是如何做。我们应该牢记,我们只想测试是什么

为什么测试如何这么糟糕?难道我们不应该确保函数的运行方式和我们预期的完全一样吗?下面是一个简单的例子:

我们想要测试的组件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Calculator.jsx

class Calculator extends React.Component {
state = { number: 0 }

setNumber = number => this.setState({ number })

render() {
const { number } = this.state

return (
<div>
<div>Count: {number}</div>
<button onClick={() => this.setNumber(number + 1)}>add1</button>
</div>
)
}
}

我们可能会像这样测试它的实现细节(我们将使用 Enzyme,因为它暴露了可以测试实现细节的 API):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Calculator.test.jsx

import * as React from 'react'
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Calculator from '../Calculator'

// Setup enzyme's react adapter
Enzyme.configure({adapter: new EnzymeAdapter()})

test('Calculator render number in state', () => {
const wrapper = mount(<Calculator />)

expect(wrapper.props().children).toBe(0)
})

test('setNumber should sets the number state properly', () => {
const wrapper = mount(<Calculator />)

expect(wrapper.state('number')).toBe(0)

wrapper.instance().setNumber(1)

expect(wrapper.state('number')).toBe(1)
})

假阴性(False Negatives)

通过测试实现细节,你实际上是在说:应用程序必须按这种方式运行! 因此你同时排除了使用其他实现方式的可能性。如果我们需要用其他状态管理库(如 Redux、Mobx)进行重构呢?即使你的实现是正确的,重构之后也一定会测试失败。这就是假阴性。

假阳性(False Positives)

因为你在测试如何做,你很可能会遗漏一些细节(因为细节总是如此复杂),这让你对结果是否符合预期没有信心。

假设开发者 Joe 刚加入我们的团队,他决定把内联函数移到外部。改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Calculator.jsx

class Calculator extends React.Component {
state = { number: 0 }

setNumber = number => this.setState({ number })

render() {
const { number } = this.state

// Joe carelessly missed the value of setNumber
const handleOnClick = () => this.setNumber()

return (
<div>
<div>Count: {number}</div>
<button onClick={handleOnClick}>add1</button>
</div>
)
}
}

他运行了所有测试,全部通过了。于是他开心地提交了代码并推送到代码库。很快,他就收到了用户的投诉:为什么计算器不工作了?他也不知道为什么,因为所有测试都通过了。于是他不得不打开笔记本电脑,再次查看代码库来修复这个问题。

这是因为你的测试根本没有保证代码结果是正确的,所以即使产品结果是错误的,测试依然通过,这也就不奇怪了。

写了太多有害的测试

在我们的例子中,实现非常简单,所以测试实现细节时不需要写太多测试。但对于一些复杂的组件呢?你需要写成千上万行的测试,这不仅不能增加你的信心,还会限制重构的可能性。这就是为什么大多数开发者开始写测试然后很快就放弃的原因。

测试用例(Use Cases)

如果我们不测试如何,而是测试什么呢?通过测试用例(在特定情况下会做什么),我们是在说:给定某个输入(事件),它应该做某件事。

这将给你重构或替换某些实现的可能性,同时你可以保证产品做的是同样的事情。

让我们为上面的例子写正确的测试(我们将使用 react-testing-library):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { screen, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Calculator from './Calculator'

// zero
test('it should show 0 initially', () => {
render(<Calculator />)

// expect screen show text Count: 0
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})

// one
test('it should add 1 when user click add1', async () => {
render(<Calculator />)

// simulate user click on button
await userEvent.click(screen.getByRole('button'))

// expect screen show text Count: 1
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})

// many
test('it should add 3 when user click add1 three times', async () => {
render(<Calculator />)

// simulate user click on button
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button'))

// expect screen show text Count: 3
expect(screen.getByText('Count: 3')).toBeInTheDocument()
})

你可以看到,这样的测试允许你在重构的同时,保证应用程序正确运行的信心。太棒了!

错误二:100% 代码覆盖率

如果你试图在所有情况下都达到 100% 的覆盖率,你很可能是在浪费时间,尤其是在前端。代码覆盖率可能会给你代码测试充分的错觉。但实际上,它只能告诉你测试期间有多少代码会被执行,而不是这段代码是否按照业务需求工作

始终牢记,你写测试是为了增加信心,而对于哪些代码应该被覆盖,存在一个权衡。有些事情比其他事情更重要。例如,为”关于我们”页面写测试不会给你增加太多信心,对吧?前端有很多纯 UI 页面,我们可以用快照测试来覆盖它们,从而腾出手来做更重要的事情。

对于一些公共库,追求 100% 的覆盖率是完全合适的,因为它们通常更独立、更小,而且由于被多个项目共享,它们确实是非常重要的代码。

错误三:重复/耦合测试

测试应该始终在隔离状态下工作。 有两种测试是不隔离的:

重复测试

假设你有 100 个 E2E 测试需要一个已认证的用户。你需要运行多少次注册流程才能确信该流程有效?100 次还是 1 次?我认为可以安全地说,如果它运行一次有效,它应该每次都有效。那 99 次额外运行是浪费精力。

因此,与其每次都走一遍 happy path,不如直接发送与你的应用程序注册和登录新用户时相同的 HTTP 请求。这些请求会比在页面上点击和输入快得多

耦合测试(这个名字是我起的🤣)

让我们来看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import * as React from 'react'

const Counter = (props) => {
const [count, setCount] = React.useState(0)
const isMax = count >= props.maxCount

const handleReset = () => setCount(0)
const handleClick = () => setCount(currentCount => currentCount + 1)

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

export {Counter}

我们可以像这样为该组件写测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import * as React from 'react'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {Counter} from '../counter'

const { getByText, getByRole } = render(<Counter maxCount={2} />)
const addOneButton = getByRole('button', { name: 'add one' })


test('it should render initial count 0', () => {
expect(getByText('Count: 0')).toBeInTheDocument()
})

test('it should render 1 when click add one button', async () => {
await userEvent.click(addOneButton)

expect(getByText('Count: 1')).toBeInTheDocument()
})

test(`it should disable add one button and show reset button when it's hit the maxCount`, async () => {
await userEvent.click(addOneButton)

expect(getByRole(addOneButton)).toBeDisabled()
expect(getByRole('button', { name: 'reset' })).toBeInTheDocument()
})

test('it should reset count to 0 when click reset button', async () => {
await userEvent.click(addOneButton)

expect(getByText('Count: 0')).toBeInTheDocument()
})

看起来很不错,对吧?我们拥有 100% 的代码覆盖率,我们非常有信心说代码被很好地测试了。但等等,现在开发者 Joe 拿到了我们的项目,他看了这些测试后认为第二个测试是多余的,因为加一按钮已经在后续的测试用例中被测试了,所以他决定删除它。砰!他立刻就有 3 个测试失败了:

这是因为这些测试不是隔离的,而是相互依赖的。所以如果我们重构或跳过其中一个,其他测试很可能会失败。

解决方案是将渲染工具包装成一个函数,并在每个测试中调用它。这样每个测试都会得到自己的实例,与其他测试隔离。(你也可以用 beforeEach 来做同样的事情,但我稍后会告诉你为什么我不喜欢这种方式。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import * as React from 'react'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {Counter} from '../counter'

function renderCounter(props) {
const { getByText, getByRole } = render(<Counter maxCount={2} {...props} />)
const addOneButton = getByRole('button', { name: 'add one' })
return { getByText, getByRole, addOneButton }
}

test('it should render initial count 0', () => {
const { getByText } = renderCounter()
expect(getByText('Count: 0')).toBeInTheDocument()
})

test('it should render 1 when click add one button', async () => {
const { getByText, addOneButton } = renderCounter()
await userEvent.click(addOneButton)

expect(getByText('Count: 1')).toBeInTheDocument()
})

test(`it should disable add one button and show reset button when it's hit the maxCount`, async () => {
const { getByRole, addOneButton } = renderCounter()

await userEvent.click(addOneButton)
await userEvent.click(addOneButton)

expect(addOneButton).toBeDisabled()
expect(getByRole('button', { name: 'reset' })).toBeInTheDocument()
})

test('it should reset count to 0 when click reset button', async () => {
const { getByText, getByRole } = renderCounter()

await userEvent.click(addOneButton)
await userEvent.click(addOneButton)
await userEvent.click(getByRole('button', { name: 'reset' }))

expect(getByText('Count: 0')).toBeInTheDocument()
})

— Jul 24, 2022