{"id":13671255,"url":"https://github.com/deepfunc/react-test-demo","last_synced_at":"2025-04-09T18:08:45.406Z","repository":{"id":178500976,"uuid":"143241675","full_name":"deepfunc/react-test-demo","owner":"deepfunc","description":"Web 前端单元测试到底要怎么写？看这一篇就够了","archived":false,"fork":false,"pushed_at":"2019-04-16T06:23:30.000Z","size":269,"stargazers_count":210,"open_issues_count":0,"forks_count":36,"subscribers_count":7,"default_branch":"master","last_synced_at":"2024-03-02T04:34:29.879Z","etag":null,"topics":["react","unit-testing","web"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/deepfunc.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2018-08-02T04:17:52.000Z","updated_at":"2024-02-19T11:33:55.000Z","dependencies_parsed_at":"2023-07-25T22:46:07.282Z","dependency_job_id":null,"html_url":"https://github.com/deepfunc/react-test-demo","commit_stats":null,"previous_names":["deepfunc/react-test-demo"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Freact-test-demo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Freact-test-demo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Freact-test-demo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Freact-test-demo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/deepfunc","download_url":"https://codeload.github.com/deepfunc/react-test-demo/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248084351,"owners_count":21045125,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["react","unit-testing","web"],"created_at":"2024-08-02T09:01:04.277Z","updated_at":"2025-04-09T18:08:45.378Z","avatar_url":"https://github.com/deepfunc.png","language":"JavaScript","readme":"# Web 前端单元测试到底要怎么写？看这一篇就够了\n\n```sh\n# 先说一下示例怎么运行，先确定本机安装好 node 环境\n\n# 安装项目依赖\nnpm install\n\n# 首先启动 webpack-dev-server\nnpm run start-dev\n\n# 上一个运行完毕后不要关闭，开一个新的命令行，启动 node server 服务\nnpm run start-server\n\n#上述两个启动好后打开浏览器访问 http://localhost:3000 即可\n\n# 跑测试用例\nnpm test\n\n# 生成测试覆盖报告，跑完后看 coverage 子目录下的内容\nnpm run test-coverage\n\n# 以上脚本定义都在 package.json 中\n```\n\n\n\n随着 Web 应用的复杂程度越来越高，很多公司越来越重视前端单元测试。我们看到的大多数教程都会讲单元测试的重要性、一些有代表性的测试框架 api 怎么使用，但在实际项目中单元测试要怎么下手？测试用例应该包含哪些具体内容呢？\n\n本文从一个真实的应用场景出发，从设计模式、代码结构来分析单元测试应该包含哪些内容，具体测试用例怎么写，希望看到的童鞋都能有所收获。\n\n\n\n## 项目用到的技术框架\n\n该项目采用 `react` 技术栈，用到的主要框架包括：`react`、`redux`、`react-redux`、`redux-actions`、`reselect`、`redux-saga`、`seamless-immutable`、`antd`。\n\n\n\n## 应用场景介绍\n\n![](images/components.gif)\n\n\n\n这个应用场景从 UI 层来讲主要由两个部分组成：\n\n- 工具栏，包含刷新按钮、关键字搜索框\n- 表格展示，采用分页的形式浏览\n\n\n\n看到这里有的童鞋可能会说：切！这么简单的界面和业务逻辑，还是真实场景吗，还需要写神马单元测试吗？\n\n别急，为了保证文章的阅读体验和长度适中，能讲清楚问题的简洁场景就是好场景不是吗？慢慢往下看。\n\n\n\n## 设计模式与结构分析\n\n在这个场景设计开发中，我们严格遵守 `redux` 单向数据流 与 `react-redux` 的最佳实践，并采用 `redux-saga` 来处理业务流，`reselect` 来处理状态缓存，通过 `fetch` 来调用后台接口，与真实的项目没有差异。\n\n分层设计与代码组织如下所示：\n\n![](images/source-folders.png)\n\n\n\n中间 `store` 中的内容都是 `redux` 相关的，看名称应该都能知道意思了。\n\n具体的代码请看 [这里](https://github.com/deepfunc/react-test-demo)。\n\n\n\n## 单元测试部分介绍\n\n先讲一下用到了哪些测试框架和工具，主要内容包括：\n\n- `jest` ，测试框架\n- `enzyme` ，专测 react ui 层\n- `sinon` ，具有独立的 fakes、spies、stubs、mocks 功能库\n- `nock` ，模拟 HTTP Server\n\n\n\n如果有童鞋对上面这些使用和配置不熟的话，直接看官方文档吧，比任何教程都写的好。\n\n接下来，我们就开始编写具体的测试用例代码了，下面会针对每个层面给出代码片段和解析。那么我们先从 `actions` 开始吧。\n\n\u003e 为使文章尽量简短、清晰，下面的代码片段不是每个文件的完整内容，完整内容在 [这里](https://github.com/deepfunc/react-test-demo) 。\n\n\n\n## actions\n\n业务里面我使用了 `redux-actions` 来产生  `action`，这里用工具栏做示例，先看一段业务代码：\n\n```javascript\nimport { createAction } from 'redux-actions';\nimport * as type from '../types/bizToolbar';\n\nexport const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE);\n\n// ...\n```\n\n\n\n对于 `actions` 测试，我们主要是验证产生的 `action` 对象是否正确：\n\n```javascript\nimport * as type from '@/store/types/bizToolbar';\nimport * as actions from '@/store/actions/bizToolbar';\n\n/* 测试 bizToolbar 相关 actions */\ndescribe('bizToolbar actions', () =\u003e {\n    \n    /* 测试更新搜索关键字 */\n    test('should create an action for update keywords', () =\u003e {\n        // 构建目标 action\n        const keywords = 'some keywords';\n        const expectedAction = {\n            type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,\n            payload: keywords\n        };\n\n        // 断言 redux-actions 产生的 action 是否正确\n        expect(actions.updateKeywords(keywords)).toEqual(expectedAction);\n    });\n\n    // ...\n});\n```\n\n\n\n这个测试用例的逻辑很简单，首先构建一个我们期望的结果，然后调用业务代码，最后验证业务代码的运行结果与期望是否一致。这就是写测试用例的基本套路。\n\n我们在写测试用例时尽量保持用例的单一职责，不要覆盖太多不同的业务范围。测试用例数量可以有很多个，但每个都不应该很复杂。\n\n\n\n## reducers\n\n接着是 `reducers`，依然采用  `redux-actions` 的 `handleActions` 来编写 `reducer`，这里用表格的来做示例：\n\n```javascript\nimport { handleActions } from 'redux-actions';\nimport Immutable from 'seamless-immutable';\nimport * as type from '../types/bizTable';\n\n/* 默认状态 */\nexport const defaultState = Immutable({\n    loading: false,\n    pagination: {\n        current: 1,\n        pageSize: 15,\n        total: 0\n    },\n    data: []\n});\n\nexport default handleActions(\n    {\n        // ...\n\n        /* 处理获得数据成功 */\n        [type.BIZ_TABLE_GET_RES_SUCCESS]: (state, {payload}) =\u003e {\n            return state.merge(\n                {\n                    loading: false,\n                    pagination: {total: payload.total},\n                    data: payload.items\n                },\n                {deep: true}\n            );\n        },\n        \n        // ...\n    },\n    defaultState\n);\n```\n\n\u003e 这里的状态对象使用了 `seamless-immutable`\n\n\n\n对于 `reducer`，我们主要测试两个方面：\n\n1. 对于未知的 `action.type` ，是否能返回当前状态。\n2. 对于每个业务 type ，是否都返回了经过正确处理的状态。\n\n\n\n下面是针对以上两点的测试代码：\n\n```javascript\nimport * as type from '@/store/types/bizTable';\nimport reducer, { defaultState } from '@/store/reducers/bizTable';\n\n/* 测试 bizTable reducer */\ndescribe('bizTable reducer', () =\u003e {\n    \n    /* 测试未指定 state 参数情况下返回当前缺省 state */\n    test('should return the default state', () =\u003e {\n        expect(reducer(undefined, {type: 'UNKNOWN'})).toEqual(defaultState);\n    });\n    \n    // ...\n    \n    /* 测试处理正常数据结果 */\n    test('should handle successful data response', () =\u003e {\n        /* 模拟返回数据结果 */\n        const payload = {\n            items: [\n                {id: 1, code: '1'},\n                {id: 2, code: '2'}\n            ],\n            total: 2\n        };\n        /* 期望返回的状态 */\n        const expectedState = defaultState\n            .setIn(['pagination', 'total'], payload.total)\n            .set('data', payload.items)\n            .set('loading', false);\n\n        expect(\n            reducer(defaultState, {\n                type: type.BIZ_TABLE_GET_RES_SUCCESS,\n                payload\n            })\n        ).toEqual(expectedState);\n    });\n    \n    // ...\n});\n```\n\n\n\n这里的测试用例逻辑也很简单，依然是上面断言期望结果的套路。下面是 selectors 的部分。\n\n\n\n## selectors\n\n`selector` 的作用是获取对应业务的状态，这里使用了 `reselect` 来做缓存，防止 `state` 未改变的情况下重新计算，先看一下表格的 selector 代码：\n\n```javascript\nimport { createSelector } from 'reselect';\nimport * as defaultSettings from '@/utils/defaultSettingsUtil';\n\n// ...\n\nconst getBizTableState = (state) =\u003e state.bizTable;\n\nexport const getBizTable = createSelector(getBizTableState, (bizTable) =\u003e {\n    return bizTable.merge({\n        pagination: defaultSettings.pagination\n    }, {deep: true});\n});\n```\n\n\n\n这里的分页器部分参数在项目中是统一设置，所以 reselect 很好的完成了这个工作：如果业务状态不变，直接返回上次的缓存。分页器默认设置如下：\n\n\n\n```javascript\nexport const pagination = {\n    size: 'small',\n    showTotal: (total, range) =\u003e `${range[0]}-${range[1]} / ${total}`,\n    pageSizeOptions: ['15', '25', '40', '60'],\n    showSizeChanger: true,\n    showQuickJumper: true\n};\n```\n\n\n\n那么我们的测试也主要是两个方面：\n\n1. 对于业务 selector ，是否返回了正确的内容。\n2. 缓存功能是否正常。\n\n\n\n测试代码如下：\n\n```javascript\nimport Immutable from 'seamless-immutable';\nimport { getBizTable } from '@/store/selectors';\nimport * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';\n\n/* 测试 bizTable selector */\ndescribe('bizTable selector', () =\u003e {\n    \n    let state;\n\n    beforeEach(() =\u003e {\n        state = createState();\n        /* 每个用例执行前重置缓存计算次数 */\n        getBizTable.resetRecomputations();\n    });\n\n    function createState() {\n        return Immutable({\n            bizTable: {\n                loading: false,\n                pagination: {\n                    current: 1,\n                    pageSize: 15,\n                    total: 0\n                },\n                data: []\n            }\n        });\n    }\n\n    /* 测试返回正确的 bizTable state */\n    test('should return bizTable state', () =\u003e {\n        /* 业务状态 ok 的 */\n        expect(getBizTable(state)).toMatchObject(state.bizTable);\n        \n        /* 分页默认参数设置 ok 的 */\n        expect(getBizTable(state)).toMatchObject({\n            pagination: defaultSettingsUtil.pagination\n        });\n    });\n\n    /* 测试 selector 缓存是否有效 */\n    test('check memoization', () =\u003e {\n        getBizTable(state);\n        /* 第一次计算，缓存计算次数为 1 */\n        expect(getBizTable.recomputations()).toBe(1);\n        \n        getBizTable(state);\n        /* 业务状态不变的情况下，缓存计算次数应该还是 1 */\n        expect(getBizTable.recomputations()).toBe(1);\n        \n        const newState = state.setIn(['bizTable', 'loading'], true);\n        getBizTable(newState);\n        /* 业务状态改变了，缓存计算次数应该是 2 了 */\n        expect(getBizTable.recomputations()).toBe(2);\n    });\n});\n```\n\n\n\n测试用例依然很简单有木有？保持这个节奏就对了。下面来讲下稍微有点复杂的地方，sagas 部分。\n\n\n\n## sagas\n\n这里我用了 `redux-saga` 处理业务流，这里具体也就是异步调用 api 请求数据，处理成功结果和错误结果等。\n\n可能有的童鞋觉得搞这么复杂干嘛，异步请求用个 `redux-thunk` 不就完事了吗？别急，耐心看完你就明白了。\n\n\n\n这里有必要大概介绍下  `redux-saga` 的工作方式。saga 是一种 `es6` 的生成器函数 - Generator ，我们利用他来产生各种声明式的 `effects` ，由   `redux-saga` 引擎来消化处理，推动业务进行。\n\n\n\n这里我们来看看获取表格数据的业务代码：\n\n```javascript\nimport { all, takeLatest, put, select, call } from 'redux-saga/effects';\nimport * as type from '../types/bizTable';\nimport * as actions from '../actions/bizTable';\nimport { getBizToolbar, getBizTable } from '../selectors';\nimport * as api from '@/services/bizApi';\n\n// ...\n\nexport function* onGetBizTableData() {\n    /* 先获取 api 调用需要的参数：关键字、分页信息等 */\n    const {keywords} = yield select(getBizToolbar);\n    const {pagination} = yield select(getBizTable);\n\n    const payload = {\n        keywords,\n        paging: {\n            skip: (pagination.current - 1) * pagination.pageSize, max: pagination.pageSize\n        }\n    };\n\n    try {\n        /* 调用 api */\n        const result = yield call(api.getBizTableData, payload);\n        /* 正常返回 */\n        yield put(actions.putBizTableDataSuccessResult(result));\n    } catch (err) {\n        /* 错误返回 */\n        yield put(actions.putBizTableDataFailResult());\n    }\n}\n```\n\n\n\n不熟悉 `redux-saga` 的童鞋也不要太在意代码的具体写法，看注释应该能了解这个业务的具体步骤：\n\n1. 从对应的 `state` 里取到调用 api 时需要的参数部分（搜索关键字、分页），这里调用了刚才的 selector。\n2. 组合好参数并调用对应的 api 层。\n3. 如果正常返回结果，则发送成功 action 通知 reducer 更新状态。\n4. 如果错误返回，则发送错误 action 通知 reducer。\n\n\n\n那么具体的测试用例应该怎么写呢？我们都知道这种业务代码涉及到了 api 或其他层的调用，如果要写单元测试必须做一些 mock 之类来防止真正调用 api 层，下面我们来看一下 怎么针对这个 saga 来写测试用例：\n\n```javascript\nimport { put, select } from 'redux-saga/effects';\n\n// ...\n\n/* 测试获取数据 */\ntest('request data, check success and fail', () =\u003e {\n    /* 当前的业务状态 */\n    const state = {\n        bizToolbar: {\n            keywords: 'some keywords'\n        },\n        bizTable: {\n            pagination: {\n                current: 1,\n                pageSize: 15\n            }\n        }\n    };\n    const gen = cloneableGenerator(saga.onGetBizTableData)();\n\n    /* 1. 是否调用了正确的 selector 来获得请求时要发送的参数 */\n    expect(gen.next().value).toEqual(select(getBizToolbar));\n    expect(gen.next(state.bizToolbar).value).toEqual(select(getBizTable));\n\n    /* 2. 是否调用了 api 层 */\n    const callEffect = gen.next(state.bizTable).value;\n    expect(callEffect['CALL'].fn).toBe(api.getBizTableData);\n    /* 调用 api 层参数是否传递正确 */\n    expect(callEffect['CALL'].args[0]).toEqual({\n        keywords: 'some keywords',\n        paging: {skip: 0, max: 15}\n    });\n\n    /* 3. 模拟正确返回分支 */\n    const successBranch = gen.clone();\n    const successRes = {\n        items: [\n            {id: 1, code: '1'},\n            {id: 2, code: '2'}\n        ],\n        total: 2\n    };\n    expect(successBranch.next(successRes).value).toEqual(\n        put(actions.putBizTableDataSuccessResult(successRes)));\n    expect(successBranch.next().done).toBe(true);\n\n    /* 4. 模拟错误返回分支 */\n    const failBranch = gen.clone();\n    expect(failBranch.throw(new Error('模拟产生异常')).value).toEqual(\n        put(actions.putBizTableDataFailResult()));\n    expect(failBranch.next().done).toBe(true);\n});\n```\n\n\n\n这个测试用例相比前面的复杂了一些，我们先来说下测试 saga 的原理。前面说过 saga 实际上是返回各种声明式的 `effects` ，然后由引擎来真正执行。所以我们测试的目的就是要看 `effects` 的产生是否符合预期。那么`effect` 到底是个神马东西呢？其实就是字面量对象！\n\n我们可以用在业务代码同样的方式来产生这些字面量对象，对于字面量对象的断言就非常简单了，并且没有直接调用 api 层，就用不着做 mock 咯！这个测试用例的步骤就是利用生成器函数一步步的产生下一个 `effect` ，然后断言比较。\n\n\u003e 从上面的注释 3、4 可以看到，`redux-saga` 还提供了一些辅助函数来方便的处理分支断点。\n\n\n\n这也是我选择 `redux-saga` 的原因：强大并且利于测试。\n\n\n\n## api 和 fetch 工具库\n\n接下来就是api 层相关的了。前面讲过调用后台请求是用的 `fetch` ，我封装了两个方法来简化调用和结果处理：`getJSON()` 、`postJSON()` ，分别对应 GET 、POST 请求。先来看看 api 层代码：\n\n```javascript\nimport { fetcher } from '@/utils/fetcher';\n\nexport function getBizTableData(payload) {\n    return fetcher.postJSON('/api/biz/get-table', payload);\n}\n```\n\n\n\n业务代码很简单，那么测试用例也很简单：\n\n```javascript\nimport sinon from 'sinon';\nimport { fetcher } from '@/utils/fetcher';\nimport * as api from '@/services/bizApi';\n\n/* 测试 bizApi */\ndescribe('bizApi', () =\u003e {\n    \n    let fetcherStub;\n\n    beforeAll(() =\u003e {\n        fetcherStub = sinon.stub(fetcher);\n    });\n\n    // ...\n\n    /* getBizTableData api 应该调用正确的 method 和传递正确的参数 */\n    test('getBizTableData api should call postJSON with right params of fetcher', () =\u003e {\n        /* 模拟参数 */\n        const payload = {a: 1, b: 2};\n        api.getBizTableData(payload);\n\n        /* 检查是否调用了工具库 */\n        expect(fetcherStub.postJSON.callCount).toBe(1);\n        /* 检查调用参数是否正确 */\n        expect(fetcherStub.postJSON.lastCall.calledWith('/api/biz/get-table', payload)).toBe(true);\n    });\n});\n```\n\n\n\n由于 api 层直接调用了工具库，所以这里用 `sinon.stub()` 来替换工具库达到测试目的。\n\n\n\n接着就是测试自己封装的 fetch 工具库了，这里 fetch 我是用的 `isomorphic-fetch` ，所以选择了 `nock` 来模拟 Server 进行测试，主要是测试正常访问返回结果和模拟服务器异常等，示例片段如下：\n\n```javascript\nimport nock from 'nock';\nimport { fetcher, FetchError } from '@/utils/fetcher';\n\n/* 测试 fetcher */\ndescribe('fetcher', () =\u003e {\n\n    afterEach(() =\u003e {\n        nock.cleanAll();\n    });\n\n    afterAll(() =\u003e {\n        nock.restore();\n    });\n\n    /* 测试 getJSON 获得正常数据 */\n    test('should get success result', () =\u003e {\n        nock('http://some')\n            .get('/test')\n            .reply(200, {success: true, result: 'hello, world'});\n\n        return expect(fetcher.getJSON('http://some/test')).resolves.toMatch(/^hello.+$/);\n    });\n\n    // ...\n\n    /* 测试 getJSON 捕获 server 大于 400 的异常状态 */\n    test('should catch server status: 400+', (done) =\u003e {\n        const status = 500;\n        nock('http://some')\n            .get('/test')\n            .reply(status);\n\n        fetcher.getJSON('http://some/test').catch((error) =\u003e {\n            expect(error).toEqual(expect.any(FetchError));\n            expect(error).toHaveProperty('detail');\n            expect(error.detail.status).toBe(status);\n            done();\n        });\n    });\n\n   /* 测试 getJSON 传递正确的 headers 和 query strings */\n    test('check headers and query string of getJSON()', () =\u003e {\n        nock('http://some', {\n            reqheaders: {\n                'Accept': 'application/json',\n                'authorization': 'Basic Auth'\n            }\n        })\n            .get('/test')\n            .query({a: '123', b: 456})\n            .reply(200, {success: true, result: true});\n\n        const headers = new Headers();\n        headers.append('authorization', 'Basic Auth');\n        return expect(fetcher.getJSON(\n            'http://some/test', {a: '123', b: 456}, headers)).resolves.toBe(true);\n    });\n    \n    // ...\n});\n```\n\n\n\n基本也没什么复杂的，主要注意 fetch 是 promise 返回，`jest` 的各种异步测试方案都能很好满足。\n\n剩下的部分就是跟  UI 相关的了。\n\n\n\n## 容器组件\n\n容器组件的主要目的是传递 state 和 actions，看下工具栏的容器组件代码：\n\n```javascript\nimport { connect } from 'react-redux';\nimport { getBizToolbar } from '@/store/selectors';\nimport * as actions from '@/store/actions/bizToolbar';\nimport BizToolbar from '@/components/BizToolbar';\n\nconst mapStateToProps = (state) =\u003e ({\n    ...getBizToolbar(state)\n});\n\nconst mapDispatchToProps = {\n    reload: actions.reload,\n    updateKeywords: actions.updateKeywords\n};\n\nexport default connect(mapStateToProps, mapDispatchToProps)(BizToolbar);\n```\n\n\n\n那么测试用例的目的也是检查这些，这里使用了 `redux-mock-store` 来模拟 redux 的 store ：\n\n```react\nimport React from 'react';\nimport { shallow } from 'enzyme';\nimport configureStore from 'redux-mock-store';\nimport BizToolbar from '@/containers/BizToolbar';\n\n/* 测试容器组件 BizToolbar */\ndescribe('BizToolbar container', () =\u003e {\n    \n    const initialState = {\n        bizToolbar: {\n            keywords: 'some keywords'\n        }\n    };\n    const mockStore = configureStore();\n    let store;\n    let container;\n\n    beforeEach(() =\u003e {\n        store = mockStore(initialState);\n        container = shallow(\u003cBizToolbar store={store}/\u003e);\n    });\n\n    /* 测试 state 到 props 的映射是否正确 */\n    test('should pass state to props', () =\u003e {\n        const props = container.props();\n\n        expect(props).toHaveProperty('keywords', initialState.bizToolbar.keywords);\n    });\n\n    /* 测试 actions 到 props 的映射是否正确 */\n    test('should pass actions to props', () =\u003e {\n        const props = container.props();\n\n        expect(props).toHaveProperty('reload', expect.any(Function));\n        expect(props).toHaveProperty('updateKeywords', expect.any(Function));\n    });\n});\n```\n\n\n\n很简单有木有，所以也没啥可说的了。\n\n\n\n## UI 组件\n\n这里以表格组件作为示例，我们将直接来看测试用例是怎么写。一般来说 UI 组件我们主要测试以下几个方面：\n\n- 是否渲染了正确的 DOM 结构\n- 样式是否正确\n- 业务逻辑触发是否正确\n\n\n\n下面是测试用例代码：\n\n```react\nimport React from 'react';\nimport { mount } from 'enzyme';\nimport sinon from 'sinon';\nimport { Table } from 'antd';\nimport * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';\nimport BizTable from '@/components/BizTable';\n\n/* 测试 UI 组件 BizTable */\ndescribe('BizTable component', () =\u003e {\n    \n    const defaultProps = {\n        loading: false,\n        pagination: Object.assign({}, {\n            current: 1,\n            pageSize: 15,\n            total: 2\n        }, defaultSettingsUtil.pagination),\n        data: [{id: 1}, {id: 2}],\n        getData: sinon.fake(),\n        updateParams: sinon.fake()\n    };\n    let defaultWrapper;\n\n    beforeEach(() =\u003e {\n        defaultWrapper = mount(\u003cBizTable {...defaultProps}/\u003e);\n    });\n\n    // ...\n\n    /* 测试是否渲染了正确的功能子组件 */\n    test('should render table and pagination', () =\u003e {\n        /* 是否渲染了 Table 组件 */\n        expect(defaultWrapper.find(Table).exists()).toBe(true);\n        /* 是否渲染了 分页器 组件，样式是否正确（mini） */\n        expect(defaultWrapper.find('.ant-table-pagination.mini').exists()).toBe(true);\n    });\n\n    /* 测试首次加载时数据列表为空是否发起加载数据请求 */\n    test('when componentDidMount and data is empty, should getData', () =\u003e {\n        sinon.spy(BizTable.prototype, 'componentDidMount');\n        const props = Object.assign({}, defaultProps, {\n            pagination: Object.assign({}, {\n                current: 1,\n                pageSize: 15,\n                total: 0\n            }, defaultSettingsUtil.pagination),\n            data: []\n        });\n        const wrapper = mount(\u003cBizTable {...props}/\u003e);\n\n        expect(BizTable.prototype.componentDidMount.calledOnce).toBe(true);\n        expect(props.getData.calledOnce).toBe(true);\n        BizTable.prototype.componentDidMount.restore();\n    });\n\n    /* 测试 table 翻页后是否正确触发 updateParams */\n    test('when change pagination of table, should updateParams', () =\u003e {\n        const table = defaultWrapper.find(Table);\n        table.props().onChange({current: 2, pageSize: 25});\n        expect(defaultProps.updateParams.lastCall.args[0])\n            .toEqual({paging: {current: 2, pageSize: 25}});\n    });\n});\n```\n\n\n\n得益于设计分层的合理性，我们很容易利用构造 `props` 来达到测试目的，结合 `enzyme` 和 `sinon` ，测试用例依然保持简单的节奏。\n\n\n\n## 总结\n\n以上就是这个场景完整的测试用例编写思路和示例代码，文中提及的思路方法也完全可以用在 `Vue` 、`Angular` 项目上。完整的代码内容在 [这里](https://github.com/deepfunc/react-test-demo) （重要的事情多说几遍，各位童鞋觉得好帮忙去给个 :star: 哈）。\n\n最后我们可以利用覆盖率来看下用例的覆盖程度是否足够（一般来说不用刻意追求 100%，根据实际情况来定）：\n\n![](images/coverage.png)\n\n\n\n单元测试是 TDD 测试驱动开发的基础。从以上整个过程可以看出，好的设计分层是很容易编写测试用例的，单元测试不单单只是为了保证代码质量：他会逼着你思考代码设计的合理性，拒绝面条代码  :muscle: \n\n\n\n借用 Clean Code 的结束语：\n\n\u003e 2005 年，在参加于丹佛举行的敏捷大会时，Elisabeth Hedrickson 递给我一条类似 Lance Armstrong 热销的那种绿色腕带。这条腕带上面写着“沉迷测试”（Test Obsessed）的字样。我高兴地戴上，并自豪地一直系着。自从 1999 年从 Kent Beck 那儿学到 TDD 以来，我的确迷上了测试驱动开发。\n\u003e\n\u003e 不过跟着就发生了些奇事。我发现自己无法取下腕带。不仅是因为腕带很紧，而且那也是条精神上的紧箍咒。那腕带就是我职业道德的宣告，也是我承诺尽己所能写出最好代码的提示。取下它，仿佛就是违背了这些宣告和承诺似的。\n\u003e\n\u003e  \n\u003e\n\u003e 所以它还在我的手腕上。在写代码时，我用余光瞟见它。它一直提醒我，我做了写出整洁代码的承诺。\n\n","funding_links":[],"categories":["JavaScript"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdeepfunc%2Freact-test-demo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdeepfunc%2Freact-test-demo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdeepfunc%2Freact-test-demo/lists"}