导航
导航

用React hooks实现TDD

本文首发于我的个人博客: https://teobler.com, 转载请注明出处

由于篇幅所限文章中并没有给出demo的所有代码,大家如果有兴趣可以将代码clone到本地从commit来看整个demo的TDD过程,配合文章来看会比较清晰。本文涉及的所有代码地址: teobler/TDD-with-React-hooks-demo

前端TDD的痛

从进公司前认识了TDD,到实践TDD,过程中自己遇到或者小伙伴们一起讨论的比较频繁的一个问题是 — 前端不太好TDD / 前端TDD的投入收益比不高。为啥会这样呢?

我们假设你在写前端时全程TDD,那么你需要做的是 — 先assert页面上有一个button,然后去实现这个button,之后assert点击这个button之后会发生什么,最后再去实现相应的逻辑。

这个过程中有一个问题,因为前端中UI和逻辑强耦合,所以在TDD的时候你需要先实现UI,然后选中这个UI上的组件,trigger相应的行为,这个过程给开发人员增加了不少负担。

诚然,这样写出来的代码严格遵循了TDD的做法,也得到了TDD给我们带来的各种好处,但是据我观察下来,身边的小伙伴们没有一个人认同这样的做法。大家的痛点在于UI部分的TDD过于痛苦并且收益太低,而且由于UI和逻辑强耦合,后续的逻辑部分也需要先选取页面上的元素trigger出相应的执行逻辑。

这些痛点在项目组引入了hooks之后有了显著的改善,从引入hooks到现在快一年的时间,组里的小伙伴们一起总结除了一套测试策略。在此我们将React的组件分为三类 — 纯逻辑组件(比如request的处理组件,utils函数等),纯UI组件(比如展示用的Layout,Container组件等)和两者结合的混合组件(比如某个页面)。

纯逻辑组件

这部分组件没啥好说的,全都是逻辑,tasking,测试,实现,重构一条龙,具体咋写我们这里不讨论。

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
// combineClass.test.ts
describe('combineClass', () => {
it('should return prefixed string given only one class name', () => {
const result = combineClass('class-one');
expect(result).toEqual('prefix-class-one');
});

it('should trim space for class name', () => {
const result = combineClass('class-one ');
expect(result).toEqual('prefix-class-one');
});

it('should combine two class name and second class name should not add prefix', () => {
const result = combineClass('class-one', 'class-two');
expect(result).toEqual('prefix-class-one class-two');
});

it('should combine three class name and tail class name should not add prefix', () => {
const result = combineClass('class-one', 'class-two', 'class-three');
expect(result).toEqual('prefix-class-one class-two class-three');
});
});

// combineClass.ts
const CLASS_PREFIX = "prefix-";
export const combineClass = (...className: string[]) => {
const resultName = className.slice(0);
resultName[0] = CLASS_PREFIX + className[0];

return resultName
.join(' ')
.trim();
};

纯UI组件

这类组件我们没有一个个去测试组件里面的元素,而是按照UX的要求build完组件以后加上一个jest的json snapshot测试。

注意这里的snapshot并不是大家印象中的e2e测试中的截图,而是jest里将组件render出来之后使用json生成一份UI的dom结构,在下次测试时,生成一份新的快照与旧的快照进行比对,从而得出两个UI不一样的地方,实现对UI的保护。

但是其实使用snapshot测试有两个问题:

  1. snapshot相比较于一般的单元测试来说运行速度较慢,如果项目中大量使用的snapshot测试的话,在运行所有单元测试的时候会比较明显的感受到单元测试的速度被拖慢了,一定程度上违背了单元测试快速反馈的初衷;
  2. 维护snapshot的人工成本较大,snapshot测试最大的问题在于你只要改动了任何UI的部分,这个测试都会挂掉,这个时候就需要仔细对比不同的地方以决定是更新snapshot还是改错地方了,而如果此时团队里有“省心”的队友无脑更新snapshot的话,这个测试相当于浪费了资源。
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
// Content.test.tsx
describe('Content', () => {
it('should render correctly', () => {
const {container} = render(<Content/>);
expect(container).toMatchSnapshot();
});
});

// Content.test.tsx.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Content should render correctly 1`] = `
<div>
<main
class="prefix-layout-content"
/>
</div>
`;

// Content.tsx
export const Content: React.FC<React.HTMLAttributes<HTMLElement>> = (props) => {
const { className = '', children, ...restProps } = props;

return (
<main className={combineClass('layout-content', className)} {...restProps}>
{children}
</main>
);
};

逻辑与UI混合组件

这个部分我们就需要hooks的帮忙了,这样的组件不是UI和逻辑强耦合嘛,那我们就可以将两者拆开。于是这样的组件我们会这样写:

  1. 首先将UI页面build出来,但是需要的callback全部写成空函数

  2. 将所有callback或者是页面需要用到的逻辑抽到一个hook中

  3. 此时hook里的代码没有UI只有逻辑,故可以使用测试库对hook进行单独的逻辑测试,所以此时hook的开发可以按照逻辑组件的开发进行TDD

  4. 整个混合组件开发完成后,补上一个snapshot测试,需要注意的是可能该组件在渲染时需要一些数据,在写snapshot测试时应该确保准备的数据是完备的,否则快照会渲染出一份根本没有数据的错误组件

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// usePageExample.test.ts
import {act, renderHook} from "@testing-library/react-hooks";

describe('usePageExample', () => {
let mockGetUserId: jest.Mock;
let mockValidate: jest.Mock;


beforeAll(() => {
mockGetUserId = jest.fn();
mockValidate = jest.fn();

jest.mock('../../../../request/someRequest', () => ({
getUserId: mockGetUserId,
}));
jest.mock('../../../../validator/formValidator', () => ({
formValidate: mockValidate,
}));
});

afterAll(() => {
mockGetUserId.mockReset();
mockValidate.mockReset();
});

it('should trigger request with test string when click button', () => {
const {usePageExample} = require('../usePageExample');
const {result} = renderHook(() => usePageExample());

act(() => {
result.current.onClick();
});

expect(mockGetUserId).toBeCalled();
});

it('should validate form values before submit', () => {
const {usePageExample} = require('../usePageExample');
const {result} = renderHook(() => usePageExample());
const formValues = {id: '1', name: 'name'};

act(() => {
result.current.onSubmit(formValues);
});

expect(mockValidate).toBeCalledWith(formValues);
});

});

// usePageExample.ts
import {getUserId} from "../../../request/someRequest";
import {formValidate} from "../../../validator/formValidator";

export interface IFormValues {
email: string;
name: string;
}

export const usePageExample = () => {
const onClick = () => {
getUserId();
};
const onSubmit = (formValues: IFormValues) => {
formValidate(formValues);
};

return {onClick, onSubmit};
};

// PageExample.tsx
import * as React from "react";
import {usePageExample} from "./hooks/usePageExample";

export const PageExample: React.FC<IPageExampleProps> = () => {
const {onClick, onSubmit} = usePageExample();

return (
<div>
<form onSubmit={() => onSubmit}>
<input type="text"/>
</form>
<button onClick={onClick}>test</button>
</div>
);
};

这篇文章算是给大家提供了一个hooks的TDD思路,当然其中还有一些我们也觉得不是很完善的地方(比如UI的测试),大家如果有更好的实践的话欢迎一起讨论。