本文首发于我的个人博客: https://teobler.com, 转载请注明出处
由于篇幅所限文章中并没有给出demo的所有代码,大家如果有兴趣可以将代码clone到本地从commit来看整个demo的TDD过程,配合文章来看会比较清晰。本文涉及的所有代码地址: teobler/TDD-with-React-hooks-demo
从进公司前认识了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,测试,实现,重构一条龙,具体咋写我们这里不讨论。
// 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(); };
这类组件我们没有一个个去测试组件里面的元素,而是按照UX的要求build完组件以后加上一个jest的json snapshot测试。
注意这里的snapshot并不是大家印象中的e2e测试中的截图,而是jest里将组件render出来之后使用json生成一份UI的dom结构,在下次测试时,生成一份新的快照与旧的快照进行比对,从而得出两个UI不一样的地方,实现对UI的保护。
但是其实使用snapshot测试有两个问题:
// 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> ); };
这个部分我们就需要hooks的帮忙了,这样的组件不是UI和逻辑强耦合嘛,那我们就可以将两者拆开。于是这样的组件我们会这样写:
// 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的测试),大家如果有更好的实践的话欢迎一起讨论。