|
| 1 | +对于基础类库来说,保证其质量和稳定性是最重要的。开源工具的架构设计中,lodash、vue、react、ant-design,无一例外其中都包含了大量的前端自动化测试内容,用来保证类库的稳定性。 |
| 2 | + |
| 3 | +再从研发的整个过程来看测试集成,**测试环节是保证持续集成和交付的关键。** |
| 4 | + |
| 5 | +### 测试集成 |
| 6 | + |
| 7 | +借用经典的软件开发测试模型--V-Model模型来说明,对测试集成的分类可以分为单元测试、集成测试、冒烟测试、验收测试4个环节。通过CI/CD流水线,其中的一些测试方式可以集成到其中。 |
| 8 | + |
| 9 | +### 单元测试 |
| 10 | + |
| 11 | +单元测试(Unit Test),属于对编码实现细节的测试,把模块、组件或者函数作为一个单元编写测试用例,来对功能做验证。单测的用例代码和模块的源码放在一起,随着模块的编译构建一起执行。 |
| 12 | + |
| 13 | +前端开发中经常见到的 jest、mocha都属于单元测试库。 |
| 14 | + |
| 15 | +### 集成测试 |
| 16 | + |
| 17 | +集成测试(Integration Test),属于泛指系统的功能性测试,确保了系统的所有单元可以按照预期的功能运行。把相关的组件、模块集成在 Web页面中进行统一测试,通常和端到端的一起进行。一般在模块编译构建结束后执行。集成测试关注的是产品的单独一块功能,端到端测试关注的是产品功能之前的使用链路和数据流向。 |
| 18 | + |
| 19 | +集成测试也可以用单测库进行。前端常用的端到端类库有PhantomJS,casperJs,puppeteer。 |
| 20 | + |
| 21 | +### 系统测试 |
| 22 | + |
| 23 | +系统测试(System Test),属于对业务系统兼容性、性能、回归、可伸缩性、安全性等方面(web应用中可能还包括网络服务、IO、物理机CPU占用、内存消耗等等)的测试。通常需要配合使用一些GUI工具进行测试,一般在做系统功能测试时同步进行。 |
| 24 | + |
| 25 | +### 验收测试 |
| 26 | + |
| 27 | +验收测试(Acceptance Test),指从一个从用户的角度出发执行的测试,因此称为验收测试。这种测试在将软件交付给客户(即生产环境)之前执行。 |
| 28 | + |
| 29 | +### 单元测试 |
| 30 | + |
| 31 | +上面已经介绍了单元测试的定义,关于单元测试有几个概念需要了解下。**测试用例**是组成单元测试模块的最小结构,也就是说把测试用例放到一个测试模块里,就是一个完整的单元测试。**断言**是测试用例中最核心的部分,比如nodejs中的 assert 模块,如果当前程序的某种状态符合 assert 的期望此程序才能正常执行,否则直接退出应用。 |
| 32 | + |
| 33 | +断言是单元测试框架中核心的部分,断言失败会导致测试不通过,或报告错误信息。 |
| 34 | + |
| 35 | +对于常见的断言,举一些例子如下: |
| 36 | + |
| 37 | +- 同等性断言 Equality Asserts |
| 38 | + - expect(sth).toEqual(value) |
| 39 | + - expect(sth).not.toEqual(value) |
| 40 | +- 比较性断言 Comparison Asserts |
| 41 | + - expect(sth).toBeGreaterThan(number) |
| 42 | + - expect(sth).toBeLessThanOrEqual(number) |
| 43 | +- 类型性断言 Type Asserts |
| 44 | + - expect(sth).toBeInstanceOf(Class) |
| 45 | +- 条件性测试 Condition Test |
| 46 | + - expect(sth).toBeTruthy() |
| 47 | + - expect(sth).toBeFalsy() |
| 48 | + - expect(sth).toBeDefined() |
| 49 | + |
| 50 | +用一个函数来说明: |
| 51 | + |
| 52 | +```js |
| 53 | +// 待测试函数 multiple |
| 54 | + |
| 55 | +function multiple(a, b) { |
| 56 | + let result = 0; |
| 57 | + for (let i = 0; i < b; ++i) |
| 58 | + result += a; |
| 59 | + return result; |
| 60 | +} |
| 61 | + |
| 62 | +// 断言 |
| 63 | +const assert = require('assert'); |
| 64 | +assert.equal(multiple(1, 2), 2); |
| 65 | +``` |
| 66 | + |
| 67 | +但是nodejs 自带的 assert 模块只能满足一些简单场景的需要,而且提供的错误信息提示不太友好,其次输出的内容是程序的错误报告,而不是一个单元测试报告,所以在做单元测时需要专业的断言库提供测试报告,这样才能看到有哪些断言通过没通过。 |
| 68 | + |
| 69 | +### 断言库 |
| 70 | + |
| 71 | +断言库主要提供上述断言的语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。常见的断言库有 Should.js, Chai.js 等。 |
| 72 | + |
| 73 | + |
| 74 | + |
| 75 | +## 测试工具 |
| 76 | + |
| 77 | +首先要明确一点,所有的单元测试要在不同的环境下执行就要打不同环境对应的包,所以在搭建测试工具链时要确定自己运行在什么环境中,如果在 Node 中只需要加一层 babel 转换,如果是在真实浏览器中,则需要增加 webpack 处理步骤。 |
| 78 | + |
| 79 | + |
| 80 | + |
| 81 | +## mocha |
| 82 | + |
| 83 | +mocha 是一个经典的测试框架,它提供了一个单元测试的骨架,可以将不同子功能分成多个文件进行测试,最后生成一份结构型的测试报告。karma是一个测试执行过程管理工具,可以watch文件更新。 |
| 84 | + |
| 85 | +Node 环境下测试 : mocha + chai + babel |
| 86 | + |
| 87 | +浏览器环境测试 : karma + mocha + chai + webpack + babel + jsdom |
| 88 | + |
| 89 | +但是mocha配置起来比较繁琐,还有一些额外的工具例如单元覆盖率(istanbul),函数模拟 (sinon.js)等辅助工具,选型的成本比较高。 |
| 90 | + |
| 91 | + |
| 92 | + |
| 93 | +## jasmine |
| 94 | + |
| 95 | +jasmine 也是一个常用的测试框架,里面包含了 测试流程框架,断言函数,mock工具等测试中会遇到的工具。可以近似地看作 jasmine = mocha + chai + 辅助工具。 |
| 96 | + |
| 97 | +Node 环境下测试 : Jasmine + babel |
| 98 | + |
| 99 | +浏览器环境测试 : karma + Jasmine + webpack + babel + jsdom |
| 100 | + |
| 101 | + |
| 102 | + |
| 103 | +## jest |
| 104 | + |
| 105 | +Jest 是 facebook 出的一个完整的单元测试技术方案,集 测试框架, 断言库, 启动器, 快照,沙箱,mock工具于一身,也是 React 官方使用的测试工具。Jest的优势是明显的: |
| 106 | + |
| 107 | +速度快,具备监控模式,API 简单,易配置,隔离性好,Mock 丰富,多项目并行。 |
| 108 | + |
| 109 | + |
| 110 | + |
| 111 | +Node 环境下测试 : Jest + babel |
| 112 | + |
| 113 | +浏览器环境测试 : Jest + babel + JSDOM(组件需要 [react-testing-library](https://github.com/kentcdodds/react-testing-library), [Enzyme](http://airbnb.io/enzyme/), [TestUtils](https://reactjs.org/docs/test-utils.html) 三选一) + webpack |
| 114 | + |
| 115 | +### 安装 |
| 116 | + |
| 117 | +项目里安装 Jest 、babel |
| 118 | + |
| 119 | +```bash |
| 120 | +npm install --save-dev jest babel-jest @babel/core @babel/preset-env @babel/preset-typescript @types/jest |
| 121 | +``` |
| 122 | + |
| 123 | +### 初始化配置 |
| 124 | + |
| 125 | +在根目录下生成 jest.config.js 的配置文件,scripts里添加的jest命令会在 jest.config.js 里找配置。 |
| 126 | + |
| 127 | +```bash |
| 128 | +jest --init |
| 129 | +``` |
| 130 | + |
| 131 | +配置 jest |
| 132 | + |
| 133 | +```js |
| 134 | +// jest.config.js |
| 135 | + |
| 136 | +module.exports = { |
| 137 | + verbose: true, |
| 138 | + roots: ['<rootDir>/packages'], // 文件入口 |
| 139 | + moduleNameMapper: { |
| 140 | + '\\.(css|less|scss)$': 'identity-obj-proxy' // 使用 identity-obj-proxy mock CSS Modules |
| 141 | + }, |
| 142 | + testRegex: '(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$', // 匹配测试文件 |
| 143 | + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], |
| 144 | + testPathIgnorePatterns: ['<rootDir>/node_modules/', '/node_modules/', '/lib/', '/demo/', '/dist/'], |
| 145 | + preset: 'ts-jest', |
| 146 | + testEnvironment: 'jsdom', |
| 147 | + transform: { |
| 148 | + '\\.[jt]sx?$': 'babel-jest', // 文件处理 |
| 149 | + '^.+\\.svg$': 'jest-svg-transformer', // svg转换 |
| 150 | + '^.+\\.(ts|tsx)$': 'ts-jest' |
| 151 | + }, |
| 152 | + setupFiles: ['jest-canvas-mock'], |
| 153 | + transformIgnorePatterns: ['<rootDir>/node_modules/(?!lodash-es)'], // transform编译忽略哪些文件 |
| 154 | + collectCoverage: true, // 开启收集Coverage(测试覆盖范围) |
| 155 | + coverageDirectory: '<rootDir>/coverage/', // 指定生成的coverage目录 |
| 156 | + coveragePathIgnorePatterns: ['<rootDir>/coverage/'] //该路径下的测试,忽略测试覆盖率 |
| 157 | +} |
| 158 | +``` |
| 159 | + |
| 160 | +babel配置: |
| 161 | + |
| 162 | +```js |
| 163 | +{ |
| 164 | + presets: [ |
| 165 | + '@babel/preset-react', |
| 166 | + [ |
| 167 | + '@babel/preset-env', {targets: {node: 'current'}} |
| 168 | + ], |
| 169 | + '@babel/preset-typescript' |
| 170 | + ], |
| 171 | + plugins: [ |
| 172 | + [ |
| 173 | + 'import', |
| 174 | + { libraryName: 'antd', libraryDirectory: 'es', style: true } |
| 175 | + ], |
| 176 | + ['@babel/plugin-transform-runtime'], |
| 177 | + ['@babel/plugin-transform-modules-commonjs'] // jest不支持es模块,用babel处理 |
| 178 | + ] |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +### 基础语法 |
| 183 | + |
| 184 | +#### 匹配器 matchers |
| 185 | + |
| 186 | +断言api。查看jest支持的所有断言api:https://www.jestjs.cn/docs/expect |
| 187 | + |
| 188 | +```js |
| 189 | +// 测试相等 |
| 190 | +test('two plus two is four', () => { |
| 191 | + // toBe 匹配器 |
| 192 | + expect(2 + 2).toBe(4); |
| 193 | +}); |
| 194 | + |
| 195 | +// 测试对象相等 内容相等 |
| 196 | +test('object assignment', () => { |
| 197 | + const data = {one: 1}; |
| 198 | + data['two'] = 2; |
| 199 | + // toEqual 匹配器 |
| 200 | + expect(data).toEqual({one: 1, two: 2}); |
| 201 | +}); |
| 202 | +``` |
| 203 | + |
| 204 | +#### 异步代码测试 |
| 205 | + |
| 206 | +测试异步代码的执行结果 |
| 207 | + |
| 208 | +```js |
| 209 | +test('the data is peanut butter', () => { |
| 210 | + return fetchData().then(data => { |
| 211 | + expect(data).toBe('peanut butter'); |
| 212 | + }); |
| 213 | +}); |
| 214 | + |
| 215 | +test('the data is peanut butter', async () => { |
| 216 | + await expect(fetchData()).resolves.toBe('peanut butter'); |
| 217 | +}); |
| 218 | + |
| 219 | + |
| 220 | +// 测试reject |
| 221 | +test('the fetch fails with an error', () => { |
| 222 | + // expect至少执行一次,如果不加则不会执行 catch |
| 223 | + expect.assertions(1); |
| 224 | + return fetchData().catch(e => expect(e).toMatch('error')); |
| 225 | +}); |
| 226 | +``` |
| 227 | + |
| 228 | +#### 钩子函数 |
| 229 | + |
| 230 | +在测试运行前后进行一些设置,例如初始化测试代码等,保证每个测试的输入都是一致的。 |
| 231 | + |
| 232 | +```js |
| 233 | +export default class Counter { |
| 234 | + constructor (){ |
| 235 | + this.number = 0; |
| 236 | + } |
| 237 | + addOne(){ |
| 238 | + this.number += 1 |
| 239 | + } |
| 240 | + minusOne() { |
| 241 | + this.number -= 1 |
| 242 | + } |
| 243 | +} |
| 244 | + |
| 245 | +test('加一', function() { |
| 246 | + counter.addOne(); |
| 247 | + expert(counter.number).toBe(1) |
| 248 | +}) |
| 249 | + |
| 250 | +test('减一', function() { |
| 251 | + counter.minusOne(); |
| 252 | + expert(counter.number).toBe(0) |
| 253 | +}) |
| 254 | + |
| 255 | + |
| 256 | +/* ----------------------------------- */ |
| 257 | + |
| 258 | + |
| 259 | +let counter = null; |
| 260 | +// 所有测试用例生成单独的 counter对象 保证独立 |
| 261 | +beforeEach(() => { |
| 262 | + counter = new Counter(); |
| 263 | +}) |
| 264 | + |
| 265 | +test('加一', function() { |
| 266 | + counter.addOne(); |
| 267 | + expert(counter.number).toBe(1) |
| 268 | +}) |
| 269 | + |
| 270 | +test('减一', function() { |
| 271 | + counter.minusOne(); |
| 272 | + expert(counter.number).toBe(-1) |
| 273 | +}) |
| 274 | +``` |
| 275 | + |
| 276 | +#### mock |
| 277 | + |
| 278 | +函数执行mock。对函数是否被调用,调用结果是否正常做验证。 |
| 279 | + |
| 280 | +```js |
| 281 | +const mockCallback = jest.fn(x => 42 + x); |
| 282 | +forEach([0, 1], mockCallback); |
| 283 | + |
| 284 | +// 函数被调用两次 |
| 285 | +expect(mockCallback.mock.calls.length).toBe(2); |
| 286 | + |
| 287 | +// 函数第一次被调用时,第一个参数是0 |
| 288 | +expect(mockCallback.mock.calls[0][0]).toBe(0); |
| 289 | + |
| 290 | +// 函数第二次被调用时,第一个参数是0 |
| 291 | +expect(mockCallback.mock.calls[1][0]).toBe(1); |
| 292 | + |
| 293 | +// 函数第一次被调用时,返回值是42 |
| 294 | +expect(mockCallback.mock.results[0].value).toBe(42); |
| 295 | +``` |
| 296 | + |
| 297 | +#### snapshot 快照测试 |
| 298 | + |
| 299 | +用来测试配置文件或组件每次渲染是否一致。例如测试输入 schema form 配置功能,获取到输出的 data 快照是否一致。如果不一致,说明本次代码修改导致 data 的输出发生变化。 |
| 300 | + |
| 301 | +```js |
| 302 | +it('校验配置项是否正确', () => { |
| 303 | + const user = { |
| 304 | + createdAt: new Date(), |
| 305 | + id: Math.floor(Math.random() * 20), |
| 306 | + name: 'LeBron James', |
| 307 | + }; |
| 308 | + |
| 309 | + expect(user).toMatchSnapshot({ |
| 310 | + createdAt: expect.any(Date), |
| 311 | + id: expect.any(Number), |
| 312 | + }); |
| 313 | +}); |
| 314 | + |
| 315 | +// Snapshot |
| 316 | +exports[`will check the matchers and pass 1`] = ` |
| 317 | +Object { |
| 318 | + "createdAt": Any<Date>, |
| 319 | + "id": Any<Number>, |
| 320 | + "name": "LeBron James", |
| 321 | +} |
| 322 | +`; |
| 323 | +``` |
| 324 | + |
| 325 | +初次运行,生成 .snap 文件。第二次运行会校验快照是否一致。 |
| 326 | + |
| 327 | +#### dom 测试 |
| 328 | + |
| 329 | +jest 测试dom操作,内置类似jsdom的能力。 |
| 330 | + |
| 331 | +```js |
| 332 | +// displayUser.js |
| 333 | +const $ = require('jquery'); |
| 334 | +const fetchCurrentUser = require('./fetchCurrentUser.js'); |
| 335 | + |
| 336 | +$('#button').click(() => { |
| 337 | + fetchCurrentUser(user => { |
| 338 | + const loggedText = 'Logged ' + (user.loggedIn ? 'In' : 'Out'); |
| 339 | + $('#username').text(user.fullName + ' - ' + loggedText); |
| 340 | + }); |
| 341 | +}); |
| 342 | + |
| 343 | + |
| 344 | +// __tests__/displayUser-test.js |
| 345 | +jest.mock('../fetchCurrentUser'); |
| 346 | + |
| 347 | +test('验证用户登录', () => { |
| 348 | + // 初始化dom |
| 349 | + document.body.innerHTML = |
| 350 | + '<div>' + |
| 351 | + ' <span id="username" />' + |
| 352 | + ' <button id="button" />' + |
| 353 | + '</div>'; |
| 354 | + |
| 355 | + require('../displayUser'); |
| 356 | + |
| 357 | + const $ = require('jquery'); |
| 358 | + const fetchCurrentUser = require('../fetchCurrentUser'); |
| 359 | + |
| 360 | + // mock 函数返回 |
| 361 | + fetchCurrentUser.mockImplementation(cb => { |
| 362 | + cb({ |
| 363 | + fullName: 'Johnny Cash', |
| 364 | + loggedIn: true, |
| 365 | + }); |
| 366 | + }); |
| 367 | + |
| 368 | + // 执行按钮点击事件 |
| 369 | + $('#button').click(); |
| 370 | + |
| 371 | + expect(fetchCurrentUser).toBeCalled(); |
| 372 | + expect($('#username').text()).toEqual('Johnny Cash - Logged In'); |
| 373 | +}); |
| 374 | +``` |
0 commit comments