前端测试集锦——如何写好前端测试保证代码质量?

4,184 阅读13分钟

“自动化测试”这个主题相关的文章千千万万,但是仔细去看会发现有很大一部分都是后端或BFF的测试,它们的测试覆盖率几乎可以达到100%(根据不同团队不同的要求)。

但是一说起前端测试,极少有人说能做到100%的覆盖率,当然这也是有原因的,前端的UI变化太快,经常调整,纯粹对UI的测试是否有价值花时间去写?大部分的前端都会Say NO。

今天这篇是一个前端测试集锦,从测试金字塔原理来剖析前端测试包涵的几种测试类型,介绍了每种测试类型的性价比、使用场景以及每种测试常采用的测试框架或者工具。因为单元测试经常是占比最大的自动化测试,所以从2个方面介绍了怎么去写好一个单元测试,怎么合理的使用测试替身隔离测试依赖,提高测试的独立性。期望前端的测试策略制定&实践可以帮助提高软件质量,减少“八(B)阿(U)哥(G)”。

测试金字塔

image.png

测试金字塔是Mike Cohn 在《Succeeding with Agile》中提出的概念,它把测试分为了三层:UI测试层、Service测试层和单元测试。从下往上成本代价越高,效率越低,所以建议从上往下,越靠下写的测试用例越多。但是从如今的技术角度考虑并不完全是这样,因为现在前端UI层的测试代价比之前少很多,例如UI快照测试、依托于前端框架的关键DOM元素测试等。

对标现在,整个测试金字塔的最具有参考意义的是:

  • (1)不同层级的测试粒度不一样
  • (2)层次越高,编写更少的测试,因为它的性价比低。

所以现在更合适的前端测试金字塔应该类似于:

  • (1)E2E测试

    有些团队前端还包含了E2E测试,为了保证已经稳定的功能可用性,但是E2E测试的成本相对于另外2层更高一些,所以尽可能把主流程的功能进行E2E的测试。

  • (2)集成测试

    当遇到复杂的前端业务时,经常会引入状态管理,从DOM操作触发状态变更,引起re-render,可以通过集成测试保证整个流程是否正确。

  • (3)单元测试

    说起单元测试,后端或者BFF的单元测试,大家都很容易理解,那么前端的单元测试一般包含哪些呢?单元测试适用于Util方法,一般util方法都是纯函数,而纯函数的单元测试是非常容易写的,因为每一个输入输出都已经明确了。当然除了以上的,还包含状态管理中数据处理的单元测试、UI层面的单元测试等等。具体的测试编写后续看具体案例。

前端常用测试方法

前面介绍了测试金字塔,那么在前端领域经常用的测试方法又有哪些呢?以下就是实践中常用的测试方法:

单元测试

在前端中,一个单元可以是一个UI组件、一个Util方法、一个状态管理的处理函数、一个业务逻辑函数等等,这些都可以通过单元测试来保证功能。

以下是Jest文档中的一个测试用例,该测试针对sum方法进行了测试,在真实的项目中,往往写了很多自定义的方法,每一个方法的职责都应该非常的清晰,可以明确入参出参后,对每一种场景进行测试。

function sum(a, b) {
  return a + b;
}
module.exports = sum;
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

快照测试

快照测试,顾名思义,是针对快照的测试。在前端测试框架中,快照测试往往会在第一次执行的时候,生成一份快照,以Jest为案例,React组件的快照测试如下:

import React from 'react';
import renderer from 'react-test-renderer';
import Link from '../Link.react';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

具体可参考jest文档中的 快照测试(www.jestjs.cn/docs/snapsh…)

以上测试在第一次运行时,创建了以下的一份快照文件,保存了第一次运行的快照结果。当组件发生变更后,再次运行该快照测试时,会重新生成新的快照结果,这一份新的快照结果会和之前保存的快照文件进行对比,如果不一致,快照测试就会失败。当然测试框架一般都提供了更新快照的命令,Jest框架中就是U命令。

exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

根据以上快照测试的解析,可以发现对于还在开发中的UI组件并不适合加上快照测试,因为几乎每次都是更新快照来通过测试,那就失去了快照测试的意义了。所以快照测试一般适用于已经稳定的前端页面或者组件,可以通过快照测试来避免错误的变更。

契约测试

现在很多团队都是前后端分离的团队,在联调上往往会花费很大的时间(例如接口可用性?接口是否符合文档?接口是否稳定?)。契约测试的提出就是基于接口的提供方和消费方的一份契约,这份契约一般是接口规范文档,往往包含了请求URL、Method、请求参数、response数据结构等等。在前后端分离的场景中,契约测试更偏向于Service之间的 API 测试,主要是为了解耦服务之间的依赖关系,加快API验证的速度。

前端作为契约的消费者,契约测试经常做的就是创建相应的单元测试,该单元测试中发起符合契约规范的request到Mock Server得到对应的response来验证结果,当没有mock server时,前端也可以自行添加符合契约的mock response 文件来解耦API。常用的工具有基于YAML的Swagger Specification,和基于JSON格式的Pact Specification,具体可参考它们的文档。

E2E测试

E2E测试,也叫端到端测试,更多的是功能性的自动化测试。这一类测试往往模拟一个真实的用户操作来校验结果是否符合预期。E2E测试一般是黑盒测试,关注整个系统是否符合用户期望。

以上测试方法可以使用的工具也很多,具体的可以看下一篇章。

前端测试工具

测试工具中一般包含了测试框架Test Framework、断言库Assertion Library、mock库、测试报告工具库等几大类。

下面来介绍一下前端测试常用的一些库,附上官方文档。

Testing Library

Test Library 是一系列测试库的集合,它通过模拟用户交互,验证组件外在状态变化来进行测试,这样可以不深入组件的实现细节,提高测试的效率。下面大概介绍React Testing Library,当然Testing Library中还有vue testing library和angular testing library等库。

目前React官方推荐的测试方案是 React Testing Library + Jest 的组合。

我们推荐使用 React Testing Library,它使得针对组件编写测试用例就像终端用户在使用它一样方便。 当使用的 React 版本 <= 16 时,可以使用 Enzyme 的测试工具,通过它能够轻松对 React 组件的输出进行断言、操控和遍历。

React Testing Library 是Testing Library中的一个测试库,当想要编写可维护的React components测试,不需要关注React组件的详细内部实现,可以使用该库来进行组件测试。React Testing Library 提供了很多函数去定位元素,定位后的元素可以用作断言或用户交互。具体使用可参考文档

参考链接:

Jest

Jest是Facebook开源的测试框架,内置了 JSDOM 运行环境、断言库,提供覆盖率(coverage)、快照对比(snapshots)、模拟函数(Mock Funtion) 等功能,是目前前端使用最广泛的测试框架之一。 如果你是使用 Create React App 初始化的React框架,它已经内置了Jest作为它的测试库。如果是自己从0开始搭建的框架,根据官方文档也很容易引入Jest,这里就不详细介绍了,Jest上手非常容易,需要的话可以查看《JEST文档》

参考链接:

Mocha

Mocha 是一个基于JS的灵活的测试框架,包含了异步处理(beforeEach、afterEach等钩子函数,Promise的处理、timeout处理等等)、简洁的测试报告、并且可以自行定制化断言工具:

以上这些断言都可以自定义添加使用。

Jasmine

Angular 的默认测试框架就是 Karma(由Google团队开发的前端测试运行框架) + Jasmine。

Jasmine是一个功能很齐全的测试框架,有着完备的断言方法、Setup和Teardown方法、异步处理、Mock函数等。 Jasmine文档链接:github.com/jasmine/jas…

Cypress

Cypress是在 Mocha API 的基础上开发的 E2E 测试框架,并不依赖前端框架,也无需其他测试工具库,配置简单,并且提供了强大的 GUI 图形工具,可以自动截图录屏,也能在测试流程中 Debug 。是目前比较流行的端到端测试工具。具体可参考文档,根据文档编写用例熟悉即可。 Cypress文档链接:www.cypress.io/

还有很多其他的工具库,类似于Jasmine、Selenium、Puppeteer、phantomjs这里不一一列举了,需要的可以看文档自行使用,更多的库可以看另外一位小伙伴整理的测试相关的库github.com/huaize2020/…)。

前端测试策略

前端有着大量的UI交互,这部分测试的性价比相对较低,因为DOM结构经常会发生改变,如果真的需要测试可以考虑快照测试。

在前端项目中需要着重测试的是前端代码中的逻辑部分,现在的前端不仅仅是UI,还包括了状态管理和业务逻辑,针对这部分可以通过单元测试来保证。

在测试策略中还可以考虑写集成测试,例如模拟某个dom操作,触发状态变更和重新渲染,最终通过校验渲染结果来确认是否符合预期。这种类型的测试也是黑盒测试的一种,因为没有在测试中暴露状态变更和re-render的逻辑,而是通过触发操作验证最终结果来保证功能。

当然不同的项目还是要考虑项目的特殊性,根据不同测试的侧重点和成本来考虑采用什么样的测试策略。

如何写好单元测试

如何写单元测试可以从2种角度来写,业务角度和技术角度不是互相冲突的,只是思考维度不一样:

从业务角度写单元测试

从业务角度可以通过Given\When\Then来描述一个单元测试,given 是提供了什么样的前提(一般是用来准备测试数据),when 是当做了什么(一般是调用具体方法),then发生了什么结果(一般是测试断言)。

看一个具体的案例: 定义了一个单元测试,该测试有着自己的描述和测试内容。测试内容中分为了准备products数据(given)、调用getTotalAmount被测函数(when)、expect断言计算result结果(then)。

// 实现
const getTotalAmount = (products) => {
  return products.reduce((total, product) => total + product.price, 0); 
}
// 测试
it('should return total amount 600 when there are three products priced 100, 200, 300', () => {
  // given - 准备数据
  const products = [
    { name: 'nike', price: 100 },
    { name: 'adidas', price: 200 },
    { name: 'lining', price: 300 },
  ]

  // when - 调用被测函数
  const result = getTotalAmount(products)

  // then - 断言结果
  expect(result).toBe(600)
})

从技术角度写单元测试

从技术角度可以分为四个测试阶段:准备阶段(Setup) 、执行阶段(Exercise) 、验证阶段(Verify)、 拆卸阶段(Teardown)。

大部分的测试框架四个阶段都有很多对应的方法,以下就是一个案例: 通过beforeEach做好测试准备,在单个测试中,执行具体的被测方法,通过expect断言验证测试结果,测试完成后通过afterEach清空数据,避免对别的测试造成影响,需要在Teardown阶段清空的数据一般都是不同测试共享的数据。

// 测试
describe("getTotalAmount test", function () {   
    let products=[]
    // Setup
    beforeEach(function () {        
        products = [
          { name: 'nike', price: 100 },
          { name: 'adidas', price: 200 },
          { name: 'lining', price: 300 },
        ]   
    });    

    // Teardown
    afterEach(function () {        
      products=[] 
    });       

    it("should return total amount 600 when there are three products priced 100, 200, 300", function () {
        // Exercise
        const result = getTotalAmount(products)
        // Verify
        expect(result).toBe(600)    
    });
});

测试替身

测试替身(Test Doubles)的使用时为了隔离一些影响测试的依赖,例如第三方的UI组件、第三方的工具类、接口等等。隔离依赖后,可以专注在前端本身的功能,保证自身的代码功能。测试替身一般分为以下几类用法:

Test Stub

Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.

Test Stub为每个调用提供一个封装好的响应,一般不会对测试之外的请求进行响应。 Stub 经常被翻译成“桩”,在测试中,经常用Stub来完全代替被测组件(系统)中的依赖对象,我们给它设置了输出(返回值),它就像是被测组件(系统)中的一个桩,只用于测试,我们不会去验证桩内部的逻辑也不会去验证桩是否被调用。测试所需的仅仅是它的影响,即预设的返回值。

image.png

类似于jest.fn()就是一个Stub,可以mock它的返回值,也可以默认返回是个undefined的方法。

const myObj = {
  doSomething() {
    console.log('does something');
  }
};

test('stub .toHaveBeenCalled()', () => {
  const stub = jest.fn();
  stub();
  expect(stub).toHaveBeenCalled();
});

test('Stub jest.fn() return value', () => {
  let stub = jest.fn().mockReturnValue('default');
  expect(stub()).toBe('default');
})

Test Spy

Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.

正如下图描述的,Test Spy指的是使用测试替身来捕获被测试系统对其他组件发出的调用,以便在之后的测试中通过测试进行验证。它和后面提到的Mock最大的区别是“捕获”,Mock是setUp的时候把Object给Mock掉,而Spy则是捕获了调用。

image.png

例如在以下案例中,添加了incrementSpy测试替身,捕获了counter的increment方法调用,在执行测试的时候,校验了该测试替身确实被调用了1次(当然并不关心increment中的具体实现)。

let count = 0;
const counter = {
  increment() {
    count += 1;
  },
  getCount() {
    return count;
  }
};
const app = counter => {
  counter.increment();
};

test('app() with jest.spyOn(counter) .toHaveBeenCalledTimes(1)', () => {
  const incrementSpy = jest.spyOn(counter, 'increment');
  app(counter);
  expect(incrementSpy).toHaveBeenCalledTimes(1);
});

Mock Object

Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don't expect and are checked during verification to ensure they got all the calls they were expecting.

用特定的测试对象(Mock Object)替换测试依赖的对象,在测试中只验证是否正确调用即可。 如下图,Mock object在coding的时候就预设了它要接收到调用,并且会在验证时检查是否收到了预期的所有调用,如果收到了非预期的调用,则会抛出异常。

image.png

例如Jest中的一个Mock案例,Mock了一个counter object,并且把mock的counter object作为入参来隔离counter对测试的影响,在测试中校验counter的increment是否被正确调用,而不关心counter的increment方法的具体实现。

let count = 0;
const counter = {
  increment() {
    count += 1;
  },
  getCount() {
    return count;
  }
};
const app = counter => {
  counter.increment();
};

test('app() with mock counter .toHaveBeenCalledTimes(1)', () => {
  const mockCounter = {
    increment: jest.fn()
  };
  app(mockCounter);
  expect(mockCounter.increment).toHaveBeenCalledTimes(1);
});

Fake Object

Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example).

Fake Object是用更简单、更轻量的实现替换测试中依赖的组件。在后端测试中典型的Fake Object是内存数据库,用它来模拟真实的数据库操作,但这种Fake Object不适合在生产环境中使用,一般只用于测试。 综上所述,Fake Object听起来和Test Stub的定位非常相似,但Fake Object是依赖组件更轻量的实现,它只提供依赖组件一样的接口便于被测系统(组件)去调用,而前面提到的Stub则是确确实实的依赖,会根据不同的测试场景设置返回不同的值来进行测试。 ​

在前端测试中,经常会依赖于其他的组件或系统,例如引用了第三方的UI组件库。然而在测试中,我们并不关心第三方UI库的具体实现。这些就可以使用Fake Object了。

image.png

例如使用react框架经常会结合redux一起使用,当对一个connect了redux的组件进行测试时,可以把redux使用redux-mock-store这种轻量的测试库来替换。

import configureStore from 'redux-mock-store';
 
const mockStore = configureStore([]);
 
describe('My Connected React-Redux Component', () => {
  let store;
 
  beforeEach(() => {
    store = mockStore({
      myState: 'sample text',
    });
  });
 
  it('should render with given state from Redux store', () => {
 
  });
 
  it('should dispatch an action on button click', () => {
 
  });
});

Dummy Object

Dummy Object指的是为了测试而传入的假参数,作为参数传入方法的Dummy Object只是为了成功调用被测试方法,没有任何其他的作用。

例如前端util中需要透传一个参数,这个参数没有被真正使用,仅仅用来透传一下,那这些被传递但不被真正使用的Object就是Dummy Object。

以上这些测试替身在实践中要看具体情况来使用,不同的流派有着不同使用方案。 在测试中,存在组件依赖时,测试是状态验证还是行为验证。如果是状态验证,就不关心被调用的次数,一般使用Stub和Fake Object;如果是行为验证一般会使用Spy或Mock来验证调用结果。

测试替身相关文档链接:

总结

前端测试的类型大致上就是上面提到的几种,其中单元测试的比率最高,因为每个单元测试的成本相对其他测试更小一些,而且也能更加全面的测试各种场景的case。前端中纯JS函数的测试就不多说了,几乎所有的测试工具都支持良好,如果涉及到DOM相关的组件测试,Test Library是一个不错的选择,具体采用什么工具来测试可根据项目情况来(结合项目技术选型和测试策略来)。当然测试框架只是写测试的工具而已,写好测试还是需要明确每一个测试用例的测试职责,尽可能覆盖全面的测试场景,避免出错,也避免后续重构导致的功能失败,提高软件质量。

最后

微信搜索公众号Eval Studio,关注更多动态。