1. 概述

对于稍微有一些开发经验的开发同学在开发过程中总会有一些经验总结。

比如说当代码复杂度达到一定的级别,并且维护者的数量不止一个,就应该察觉到在开发新功能或者修复bug的时候,会变得小心翼翼。即使代码看起来没什么问题,但心里还是难免担忧,这个Feature会不会带来其它bug,这个fix会不会引入其它Feature

当想对项目中的代码进行重构的时候,会花费大量的时间进行回归测试。

以上这些问题都是由于大多数开发者所使用最基本的手动测试的方式所带来的问题,解决它的根本原因就在于引入自动化测试方案。

在日常的开发中,代码的完工其实并不等于开发的完工,如果没有测试,不能保证代码能够正常运行。应用程序测试是指检查程序运行过程是否正确。一般分为手动测试和自动化测试。手动测试不适合大型项目,而且容易忘记测试某项功能,大部分时间都在做回归测试。

自动化测试是利用计算机程序检查软件是否运行正常的测试方法,就是用其它额外的代码检查被测软件的代码。当测试代码编写完成之后可以无限重复使用。编写自动化测试脚本的方式有很多,可以通过浏览器自动执行的程序,也可以直接调用源代码里的函数,还可以直接对比程序渲染之后的截图。

自动化测试可以今早的发现程序的bug和不足,可以增强程序员对程序健壮性,稳定性的信心,还可以改进设计,快读反馈,减少调试时间,甚至是促进重构。

当然自动化测试不可能保证一个程序是完全正确的,而且事实上,在实际开发过程中,编写自动化测试代码通常是开发人员不太喜欢的一个环节,大多数情况下,前端开发人员在开发完一项功能后,只是打开浏览器手动点击查看效果是否正确,之后就很少对该代码进行管理。

前端开发常见的测试主要分为4种,单元测试是验证独立的单元是否正常工作。集成测试验证多个单元协同工作。端到端测试可以从用户角度以机器的方式在真实浏览器环境验证应用交互。快照测试是验证程序的UI变化。

2. 单元测试

单元测试是应对程序最小的部分运行测试的过程。通常测试的单元是函数,但在前端应用中,组件也是被测单元。单元测试可以单独调用源代码中的函数并断言其行为是否正确。

// 源码
export default const sum(a, b) => {
    reurn a + b;
}

// 测试
import sum from './sum';

const testSum = () => {
    if (sum(1, 1) !== 2) {
        throw new Error('sum(1, 1) did not return 2');
    }
}

testSum();

与端到端测试不同,单元测试的运行速度很快,只需要几秒钟的运行时间,因此可以在每次代码变更后都运行单元测试,从而快速得到变更是否破坏现有功能的反馈。单元测试应该避免依赖性问题,比如不存取数据库,不访问网络等等,而是使用工具虚拟出运行环境,这种虚拟使得测试成本最小,不用花大力气搭建环境。不过由于单元测试是独立的,所以无法保证多个单元运行到一起是否正确。

常见的javascript单元测试框架有jestmochajasminekarmaavatape

mochajest是目前最火的两个单元测试框架,基本上目前单元测试就在这两个库之间选,总的来说jest功能齐全,配置方便,mocha自由灵活自由配置,两者功能范围可以粗略表示为。

jest === mocha + chai + sinon + mockserver + istanbul

3. 集成测试

开发者定义集成测试的方式并不相同,由于是对于前端,有些人认为在浏览器环境上运行的测试是集成测试,有些人认为具有模块依赖性的单元测试的任何测试都是集成测试,也有些人认为任何完全渲染的组件测试都是集成测试。

集成测试由于是从用户使用角度出发,更容易获得软件使用过程中的正确性。集成测试就相对于写了软件的说明文档。由于不关注底层代码实现细节,所以更有利于快速重构。相比单元测试,集成测试的开发速度要更快一些。

不过集成测试失败的时候无法快速定位问题,代码覆盖率较低,速度也要比单元测试慢很多。

test('integration: new todo', async () => {
    const wrapper = mount(TodoApp);
    const todoInput = wrapper.find('[data-testid="todo-input"]');
    const text = 'Hello world';
    await todoInput.setValue(text);
    await todoInput.trigger('keyup.enter');
    const todoText = wrapper.find('[data-testid="todo-text"]');
    expect(todoText.text()).toBe(text);
})

4. 端到端测试

端到端测试是最直观可以理解的测试类型,在前端应用程序中,端到端测试可以从用户的视角通过浏览器自动检查应用程序是否正常工作。

想想一下,在编写一个计算器应用程序,并且想测试两个数求和的运算方法是否正确,可以编写一个端到端测试,打开浏览器,加载计算器应用程序,单击1按钮,单击+号按钮,再次单击1按钮,单击=按钮,最后检查屏幕是否显示正确结果2

describe('My First Test', () => {
    it('Visits the app root url', () => {
        cy.visit('http://localhost:8080/');
        cy.contains('h1', 'todos');
    })
    it('Visits the app root url', () => {
        cy.visit('http://localhost:8080/');
        const text = 'Hello World';
        cy.get('[data-testid="todo-input"]').type(`${text}{enter}`);
        cy.contains(text);
    })
})

端到端测试运行不够快,启动浏览器需要占用几秒钟,网站响应速度又慢,通过一套端到端测试需要30分钟的运行时间,如果应用程序完全依赖于端到端测试,那么测试套件将需要数小时的运行时间。

端到端调试起来比较困难,要调试端对端测试,需要打开浏览器并逐步完成用户操作以重现bug,本地运行这个调试过程就已经够糟糕了,如果测试是在持续集成服务器上失败而不是本地计算机上失败,那么整个过程会变得更加糟糕。

目前比较流行的端到端测试框架有Cypressnightwatchwebdriverioplaywright

5. 快照测试

快照测试会给运行中的应用程序拍一张图片,并将其与以前保存的图片进行比较,如果图像不同,则测试失败,这种测试方法对确保应用程序代码变更后是否仍然可以正确渲染很有帮助。快照其实是将页面生成的结构放在一个文件中保存,新生成的与旧的进行比较。

6. 测试金字塔

这么多的测试类型到底该编写哪种测试类型呢?一般是都写,并且根据情况灵活分配。

如果真的想为软件构建自动化测试,必须知道一个关键的概念就是测试金字塔。意思是测试是需要分层的。金字塔模型自下而上分为单元测试,集成测试,UI测试,之所以是金字塔结构是因为单元测试的成本最低,与之相比,UI测试的成本最高。所以单元测试写的数量最多,UI测试写的数量最少。同时需要注意的是越是上层的测试,其通过率给开发者带来的信心越大。

如果开发纯函数库,建议写更多的单元测试和少量的集成测试。如果是开发组件库,建议写更多的单元测试为每个组件编写快照测试,写少量的集成测试和端到端测试。如果开发业务系统建议写更多的集成测试,为工具类库算法写单元测试,写少量的端到端测试。

7. 测试覆盖率

测试覆盖率是恒量软件测试完整性的重要指标,掌握测试覆盖率数据,有利于客观认识软件质量,正确了解测试状态,有效改进测试工作。

测试覆盖率可以通过代码覆盖率和需求覆盖率来恒量。

最著名的测试覆盖率就是代码覆盖率,这是一种面向软件开发和实现的定义。它关注的是在执行测试用例时,有哪些软件代码被执行到了,有哪些软件代码没有被执行到。被执行的代码数量与代码总数量之间的比值就是代码覆盖率。

根据颗粒度的不同代码覆盖率可以进一步分为源文件覆盖率,类覆盖率,函数覆盖率,分支覆盖率,语句覆盖率等。他们形式各异,但本质是相同的。

测量代码覆盖率一般可以通过jest自带的测试覆盖率统计。不过一般只适用白盒测试尤其是单元测试,对于黑盒测试来说,度量覆盖率就会困难很多。

对于黑盒测试例如功能测试,集成测试,系统测试来说,测试用例通常是基于软件需求而不是软件实现所涉及的。因此度量这类测试完整性的手段一般是需求覆盖率,即测试所覆盖的需求数量与总需求数量的比值。

视需求颗粒度的不同,需求覆盖率的具体表现也不同,例如系统测试针对的是比较粗的需求,而功能测试针对的是比较细的需求。当然他们的本质是一致的。

度量需求覆盖率通常没有现成的工具可以使用,需要依赖人工计算,尤其是需要依赖人工标记每个测试用例和需求之间的映射关系。对于代码覆盖率来说,广为诟病的一点就是100%的代码覆盖率并不能说明代码就被完全覆盖没有遗漏了,因为代码的执行顺序和函数的参数值,都可能是千变万化的,一种情况被覆盖到不代表所有情况被覆盖到。

对于需求覆盖率来说,100%的覆盖率也不能说万事大吉,因为需求可能有遗漏或存在缺陷,测试用例与需求之间的映射关系,尤其是用例是否真正能够覆盖对应的测试需求,也可能是存在疑问的。

代码覆盖率和需求覆盖率适用于不同的场景,有各自的优势和不足,需要注意的是,他们不是互相排斥而是互相补充的。

关于测试覆盖率,最重要的一点应该是迈出第一步,有意识的去收集这种数据,没有覆盖率数据测试工作会有点像在黑暗中走路,有了测试覆盖率并持续检测,利用和改进这个数据,才是一条让测试工作越来越好的光明大道。

既然测试这么好,那是不是所有代码都要有测试用例支持。

测试覆盖率还是要和测试成本结合起来,比如一个不会经常变的公共方法就尽可能的讲测试覆盖率做到趋于100%,而对于一个完整项目,建议前期先做最短的时间覆盖80%的测试用例,后期慢慢完善。

经常做更改的活动页面没有必要趋近100%,因为要不断的更改测试用例,维护成本太高。

大多数情况下,100%代码覆盖率作为目标并没有意义,当然如果是极其重要的支付应用,存在bug可能导致百万美元的损失,那么100%的代码覆盖率还是很有用的。

实现100%的代码覆盖率不仅耗时,而且即使代码覆盖率达到100%,测试也并非总能发现bug,有时还可能做出错误的假设。比如调用API时假定该API不会返回错误,然而生产环境是不确定的,可能就真的返回了错误,程序一样会崩溃。

8. TDD测试驱动开发

测试不仅能够验证软件的功能,保证代码质量也能影响开发的模式。

TDD就是test-dirven development测试驱动开发。先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过,这样的优点是可以很好的诠释代码既文档,清晰了解软件的需求。他是敏捷开发中的一项核心实践和技术,也是一种软件设计方法论。

TDD更多的需要编写独立的测试用例,比如只测试一个组件的某个功能点,某个工具函数等。

独立测试,不同的代码测试应该相互独立,一个类对应一个测试类,一个函数对应一个测试函数。用例也应各自独立,每个用例不能使用其他用例的结果数据,结果也不能依赖用例执行顺序。开发过程包含多种工作如编写测试用例,编写产品代码,代码重构等,做不同的工作时,应专注于当前的角色,不要过多考虑其他方面的细节。

测试列表,代码的功能点可能很多,并且需求可能是陆续出现的,任何阶段想添加功能时应该相关功能点加到测试列表中,然后才能继续手头工作,避免疏漏。

测试驱动,利用测试来驱动开发是TDD的核心,要实现某个功能要编写某个类或某个函数,应先编写测试代码,明确这个类或函数的使用并且明确如何测试,然后在对其进行设计,编码。

先写断言,编写测试代码时,应该首先编写判断代码功能的断言语句,然后编写必要的辅助语句。

可测试性。产品代码设计开发时,应尽可能提高可测试性,每个代码单元的功能应该比较单纯,每个类每个函数应该只做他该做的事,不要弄成大杂烩。尤其是增加新功能时,不要为了图一时之便,随便在原有代码中添加功能,对于C++编程,应多考虑使用子类,继承,重载等OO方法。

及时重构,对结构不合理重复等不好的代码,在测试通过后,应及时进行重构。

小步前进,软件开发是复杂性非常高的工作,小步前进时降低复杂性的好办法。

TDD可以保证代码质量因为先编写测试,所以可能出现的问题都被提前发现了,并且可以促进开发人员色考,有利于程序的模块设计,测试覆盖率也高,因为是后编写代码,所以测试用例基本都照顾到了。

同样缺点也很明显,代码量会很多,大多数情况下测试代码是功能代码的两倍甚至更多。业务耦合度高,测试用例中使用了业务中一些模拟的数据,当业务代码变更的时候,要去重新组织测试用例。关注点也过于独立,由于单元测试只关注这一个单元的健康情况,无法保证多个单元组成的整体是否正常。

TDD更适合开发纯函数库,比如lodashreactvue

9. BDD行为驱动开发

TDD最大的问题是开发人员最终做出来的东西和实际需求可能偏离,为了解决这个问题有人发明了BDD

BDD全称是Behavior Driven development行为驱动开发。由系统业务专家,开发者,测试人员一起合作,分析软件的需求,然后将这些需求写成一个个的故事,开发者负责填充内容,保证程序实现效果与需求一致。

BDD解决的另一个关键问题是如何定义TDD或单元测试过程中的细节,一些不良的单元测试常见问题是过于依赖被测试功能的实现逻辑。这通常意味着如果要修改实现逻辑,即使输入输出么有变,通常也需要去更新测试代码。这就会让开发人员对测试用例的维护感觉乏味和厌烦。

BDD是测试驱动开发延伸出来的一种敏捷软件开发技术,核心是为了解决TDD模式下开发和实际功能需求不一致而诞生。BDD不需要再面向实现细节设计测试,取而代之的是面向行为来测试,它是从产品角度出发,鼓励开发人员和非开发人员之间的协作。由于BDD的核心是关注软件功能测试,所以BDD更多的是结合集成测试进行,是黑盒的。

开发流程是开发人员和非开发人员一起讨论确认需求,以一种自动化的方式将需求建立起来,并确认是否一致,最后实现每个文档示例描述的行为,并从自动化测试开始以指导代码的开发。

这样的想法是使每个更改较小并快速迭代,每次需要更多信息时都将其上移,每次自动化并实现一个新示例时,便为系统添加一些有价值的内容,并准备响应反馈。理想中的BDD解决方案最流行的是cucumber,使用gherkin语法将功能需求转换为需求文档。用描述性自然语言定义的测试,客户,测试人员和开发人员都能看得懂,能达成共识,这种语法叫做gherkin syntax,小黄瓜语法。

通过scenariofeature等描述场景,givenwhenthen描述步骤。

Feature: 添加任务
    Scenario: 在输入框中输入任务名敲回车确定,输出到任务列表中
        Given "Hello World"
        When 在输入框中敲回车
        Then 任务列中增加一个名称为 "Hello World" 的任务

    Scenario: 在输入框中输入空内容,不输出到任务列表中
        Given ""
        When 在输入框中敲回车
        Then 任务列表中不增加任何内容

Cucumber读取Gherkin语法描述的纯文本形式的可执行规范,并验证该软件是否满足那些规范所说的内容,规范包含多个实例或方案。

每个方案都是Cucumber要执行的步骤的列表,Cucumber验证软件是否符合规范,并生成一个报告,指出每种情况的成功或失败。

BDD注重的是产品功能,可能无法保证很好的代码质量和测试覆盖率。

可以把BDD看做是需求与TDD之间架起的一座桥梁,他将需求进一步场景化,更具体的描述系统应该满足哪些行为和场景,让TDD的输入更优雅更可靠。

由于侧重于需求功能的完整度,所以BDD能给开发人员增加更多对程序的信心。测试仅关注功能,不关注实现细节,有利于测试代码和实际代码解耦,由于大多数为编写集成测试,相比TDD有更好的开发效率。

不过BDD的代码覆盖率较低,没有TDD那么严格的保证代码质量。

功能 TDD BDD
定义 测试驱动开发 行为驱动开发
思想 从代码角度出发,以完成高质量的代码为目的 从用户角度出发,为完成功能需求为目的
开发流程 需求分析、编写单元测试、运行测试、编写代码、运行测试、重构优化、重复上述流程 开发人员与产品、测试、客户等人员沟通并确定功能需求;使用统一格式文档描述功能需求;根据功能需求建立测试用例;运行测试;编写代码实现功能
代码覆盖率 一般
软件安全感 一般
测试类型 单元测试 集成测试
代码解耦 一般
开发效率 一般
代码质量 一般
测试代码量 一般

一安逸开发函数库功能使用TDD方案,开发业务系统使用BDD方案。

10. 总结

编写自动化测试时,请务必牢记编写测试的目的,通常测试的目的是为了节省时间,如果正在进行的项目是稳定的并且会长期开发,那么测试是可以带来收益的。但是如果测试编写与维护的时间长于可以节省的时间,那就不应该编写测试。

当然在编写代码之前很难知道通过测试可以节省多少时间,会随着时间的推移去了解。但是假设正在一个短期项目中创建原型,或者是在一个创业公司迭代一个想法,就可能不会从编写测试中获得收益。

凡事都有两面性,软件测试也不是银弹,好处虽然明显却并不是所有的项目都值得引入测试框架,毕竟维护测试用例也是需要成本的。对于一些需求频繁变更,复用性较低的内容,比如活动页面,让开发专门抽出人力来写测试用例确实得不偿失。

适合测试的场景一般是需要长期维护的项目,并且需要测试来保障代码可维护性,功能的未定型。较为稳定的项目或项目中较为稳定的部分,给他们写测试用例维护成本低。被多次复用的部分,比如一些通用组件和库函数,因为多处复用,更要保障质量。

测试确实会带来相当多的好处,但不是立刻就能体现出来。正如买保险,可能十几年都用不上,单元测试也是一样,写了一个买个放心,对代码是一种保障,有bug尽快测出,没bug最好,总不能说写那么多单元测试,结果测不出bug浪费时间。

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - zhiqianduan.com】 前端自动化测试基础概念  "隐冬"
请输入评论...