React 测试中的常见错误
Read in English
大多数前端开发者(至少是我遇到的那些)不喜欢写测试。我问过他们原因,答案很简单:我们没有时间做这件事! 好吧,我想告诉你,用正确的方式 写测试会显著提升你的开发效率(尤其是从长远来看),而不是浪费你的时间。
写测试会迫使你思考自己的抽象是否合理,如果不合理,你甚至根本无法对它进行测试 。而如果你对自己的抽象能做什么没有充分的理解,在为它写测试时就会手忙脚乱 。这正是一些人不喜欢写测试的原因所在。但如果你开始为项目写测试,你将获得提升编程能力的好处。
我希望在你测试之旅的起点,就帮助你写出更简洁 、更合理 的测试,让你真正从测试中受益。以下是你在写测试时应该避免的一些错误:
错误一:测试实现细节 这是测试中的第一大错误,也是开发者放弃测试的最主要原因(我认为)。
什么是实现细节?简单来说,它关注的是如何做 。我们应该牢记,我们只想测试是什么 。
为什么测试如何 这么糟糕?难道我们不应该确保函数的运行方式和我们预期的完全一样吗?下面是一个简单的例子:
我们想要测试的组件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 import * as React from 'react' import Enzyme , {mount} from 'enzyme' import EnzymeAdapter from 'enzyme-adapter-react-16' import Calculator from '../Calculator' 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 class Calculator extends React.Component { state = { number : 0 } setNumber = number => this .setState ({ number }) render ( ) { const { number } = this .state 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' test ('it should show 0 initially' , () => { render (<Calculator /> ) expect (screen.getByText ('Count: 0' )).toBeInTheDocument () }) test ('it should add 1 when user click add1' , async () => { render (<Calculator /> ) await userEvent.click (screen.getByRole ('button' )) expect (screen.getByText ('Count: 1' )).toBeInTheDocument () }) test ('it should add 3 when user click add1 three times' , async () => { render (<Calculator /> ) await userEvent.click (screen.getByRole ('button' )) await userEvent.click (screen.getByRole ('button' )) await userEvent.click (screen.getByRole ('button' )) 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 () })
testing — Jul 24, 2022