先说结论: 在绝大部分的业务前端开发中,写单元测试的收益非常小,但是带来的维护成本却非常大,请不要再写单元测试感动自己,为难同事了。
现在很多单元测试的教程都是那种很简单的比如,测个 1+1=2,这需要测吗?下面这段代码已经出现过很多次了,纯纯的误导人。
js
const sum = (a,b) => a+b
it('1+1=2',()=> {
expect(sum(1,1)).toBe(2)
})
稍微上点复杂度,来写个组件的单测,比如一个用户信息展示组件,叫 UserInfoBlock,支持设置头像的大小,点击名字支持跳转到个人主页,组件代码大致长这样
ts
interface UserInfoBlockProps {
name: string
size: 16 | 24
id: string
icon: string
}
export const UserInfoBlock:FC<UserInfoBlockProps> = (props) => {
const navigate = useNavigate()
return <div
class='xxxxxx'
style={{width: props.size}}
onClick={() => {navigate(`/user/${props.id}`)}}>
<img src={props.icon}/>
{props.name}
</div>
}
然后开始给组件写单测,先测头像大小的功能
ts
import { UserInfoBlock, UserInfoBlockProps } from './user-info-block'
import { fireEvent, render, screen } from '@testing-library/react'
describe('UserInfoBlock 组件单测', () => {
const userInfo:UserInfoBlockProps = {
name: '张三',
icon:'https://xxx.png',
id:'abcd1234',
size: 16
}
const userInfoLarge:UserInfoBlockProps = {
name: '张三',
icon:'https://xxx.png',
id:'abcd1234',
size: 24
}
describe('展示默认头像大小', () => {
const component = render(<UserInfoBlock {...userInfo}/>)
it('img 标签的宽度为 16px', () => {
expect(screen.getByTag('img').style.width).toBe(16)
})
})
describe('展示large头像大小', () => {
const component = render(<UserInfoBlock {...userInfoLarge}/>)
it('img 标签的宽度为 24px', () => {
expect(screen.getByTag('img').style.width).toBe(24)
})
})
})
接下来测一下跳转,因为用了 react-router,所以在渲染组件的时候必须包裹一下 RouterProvider
ts
...
describe('点击可以跳转', () => {
const component = render(<MemoryRouter>
<UserInfoBlock {...userInfoLarge}/>
</MemoryRouter>
)
fireEvent.click(component.div)
it('url 会变成 user/abcd1234', () => {
expect(location.pathname).toBe('user/abcd1234')
})
})
...
这个组件的测试就写完了,看起来挺有用,但实际没啥用。
首先这个测试的收益不高,因为这是一个很简单的组件,五分钟写完,但是写这个测试需要翻倍的时间,因为需要构造数据,之前没有经验不知道要加 MemoryRouter
,jest
测 location
对象不方便,还要 mock
一下。等把这一套搞完了才能跑通,这就好像你疯狂锻炼,练出麒麟臂,就是为了举自拍杆举的稳一点。如果组件内要发请求,要做的准备工作就更加多了。
其次,user/abcd1234
是什么,断言这个没用,因为别人改了链接,你的测试也一样会过,应该断言成功的打开了用户主页,比如断言一个必定会存在的文字expect(screen.getByText('用户详情')).toBeInDocument()
这才能证明真的打开了用户主页。
1+1 什么时候不等于 2。头像设置 16px,什么时候不会是 16 px。什么时候点击不跳转到用户主页。肯定是有人修改了这里的业务逻辑才会这样,只有在做产品需求,代码优化的时候才会去改以前的代码。那么这时,这段测试对接手这段代码的人就是负担。
假设我接到需求,用户主页url 变了,改成 user?id=xxx
。我心想这个简单,一秒钟解决,提了 pr 发现测试挂了,然后我就得把测试也改一下。
如果一段测试代码在我每次修改需求的时候都要改,甚至有的时候还会为了便于测试在业务代码中留一些后门,那这个测试就是纯纯的副作用。
大家肯定深有体会,一般一个模块自己不会出问题,但是多个模块组合起来就很容易出问题了,所以测试的重点不应该是一个组件内部各种细节,而应该测这个组件在被使用的地方是否正确。
举个例子,比如在一个列表里面,使用头像为 24px 的用户组件,然后有一天出 bug 了,说这里变成 16 了。那这会是组件的 bug 吗,这肯定是使用的地方没有用对。所以应该要测试的是这里。如果有个测试断言了在列表中用户头像必须为 24px,即使没有组件的单测,这个组件也不会被改坏,因为上层已经覆盖到了。
什么是好的测试?
- 我认为好的测试是稳定的测试,不仅不需要经常修改,甚至还能扛住代码重构。比如你把项目从
react
完全迁移到了vue
,测试都能跑过。 - 好的测试是可以当作项目文档的,组内来新人了,不需要给他介绍过多,有问题让他自己看测试用例就能理解业务。
那么怎么写出这样的测试呢?答案就是写集成测试,集成测试说白了就是一个超级大的单测,之前测试 render
的是一个组件,现在直接 render(<App/>)
,将路由跳转,请求 mock,fake数据库等等都统一处理。可以将上面写的测试简单的改为
ts
import { creatAppContext, AppContext } from './app-test-context'
describe('S: 测试头像组件', () => {
let _:AppContext
beforeEach(() => {
- = creatAppContext()
})
describe('W: 去用户列表也', () => {
beforeEach(() => {
_.click(screen.getByText('查看用户列表'))
})
it('T: 列表页有十个用户,用户头像是 24px', ()=>{
expect(screen.getAllByTestid('user-item').length).toBe(10)
expect(screen.getAllByTestid('user-item')[0].img.style.width).toBe(24)
})
describe('W: 点击第一个用户', () => {
beforeEach(() => {
_.click(screen.getAllByTestid('user-item')[0])
})
it('T: 打开了用户主页', () => {
expect(screen.getByText('用户详情')).toBeInDocument()
})
})
})
})
关于怎么写好测试可以展开的点实在太多了,就不过多阐述,想要了解的朋友可以查看我的主页,之前专门出过一些列文章详细的讲这些事情。