软件测试

stub是为测试而准备的外部依赖模拟, 用于插入被测试对象的接缝(seam).

stub经常会过度深入被测试对象的实现细节, 这可能大幅增加维护成本.
如果测试可以通过集成测试或对象暴露的接口来完成, 则应该优先考虑它们而不是使用stub.

如果测试框架支持注入stub, 则应该尽可能不使用以下手动方法, 因为手动方法经常会降低测试的可维护性.

限制: 要求被测试对象的构造函数接收外部依赖作为参数.
优点: 简单.
缺点: 可能导致被测试对象的构造函数参数过多.

class Foo {
constructor(database: Database) {
this.database = database
}
}
class FakeDatabase extends Database {
}

限制: 要求被测试对象使用工厂模式.
优点: 被测试对象的构造函数不变.
缺点: 需要改造工厂函数使工厂函数可以注入stub.

class Foo {
constructor() {
this.database = DatabaseFactory.create()
}
}
class DatabaseFactory {
private constructor() {
this.databaseConstructor = RealDatabaseConstructor
}
static create() {
return new this.databaseConstructor()
}
static setDatabase(fakeDatabaseConstructor) {
this.databaseConstructor = fakeDatabaseConstructor
}
}

限制: 要求被测试对象的方法分工明确.
优点: 破坏性小.
缺点: 被测试对象变成其子类.

class Foo {
getDatabase() {
return new Database()
}
}
class FooForTest extends Foo {
getDatabase() {
return new FakeDatabase()
}
}

mock是一种特殊的通用stub, 用来测试对(无法控制的)外部对象的调用是否正确.
mock的特殊之处在于mock在内部保存调用的历史记录.

使用stub的情况下, 断言的目标是(被测试对象的)函数返回值或对象的状态.
使用mock的情况下, 断言的目标是(mock对象的)调用的参数和次数.

spy相当于不替换实现的mock, 它只用于检测对象的调用.

集成测试和单元测试的主要区别在于: 集成测试是跨越多层的测试, 单元测试是不跨层的测试.

  1. 1.
    单元测试的目的是保证单元的功能正确, 尽管测试对象可能不会在更高层被使用,
    因此集成测试很可能无法覆盖被测试的单元.
  2. 2.
    单元测试涉及的代码范围更小, 因此更容易确定问题所在.
    当我们用集成测试替代单元测试时, 由于集成测试涉及的代码范围较大, 排查错误的成本较高.
  3. 3.
    单元测试的耦合性普遍较低, 即使存在耦合的部件, 也可以通过mock和stub替换(尽管这在技术上并不总是容易实现).
    集成测试的耦合性普遍较高, 被耦合的部分只能通过准备专门的测试环境来使测试成立.
  4. 4.
    单元测试的运行速度更快, 因此反馈速度也更快.
    集成测试需要准备环境, 因此运行速度慢, 反馈速度也慢.

有时会把数据库操作误认为是一种单元测试, 但实际上它是一种集成测试:

  • 数据库版本会影响测试结果
  • 测试依赖于特定的网络条件
  • 数据库本身就有自己的单元测试

单元测试通常需要通过Stub和Mock来隔离对于耦合代码的依赖.

当测试这种代码的时候, 可能更多的是在测试"胶水"部分.
编写这种测试很可能只是白费功夫, 因为高层的集成测试(例如业务逻辑层的集成测试)可以覆盖相同的代码.

一个被测试的对象可能因为参数的组合激增产生很多条执行路径(甚至是无数个),
因此代码覆盖率根本无法作为参考指标——代码被覆盖是测试的基本, 而不是目标.

达到100%的代码覆盖率通常会扭曲测试用例, 变成为了测试而测试.

一个简单的例子可以证明这个原则有效:
考虑测试将文件从文件夹A移动到文件夹B的功能, 除了最常见的情况外, 还存在以下可能性:

  • 尝试在打开文件时移动文件
  • 缺乏写入文件夹B的权限
  • 文件夹B所在的驱动器容量已满
  • 文件夹B已经有一个同名的文件
  • ...
    仅这样的一个简单的功能就可以举出如此之多的情况, 以至于编写详尽的测试是不可能的.

有效率地编写测试的关键在于遵守80/20法则:
测试会带来严重后果的情况, 放弃细枝末节的边缘情况.

红: 创建测试, 且测试至少应该失败一次
绿: 修改生产代码以使测试通过
重构: 基于测试来重构代码, 增强可读性

保持"红-绿-重构流程"的关键在于每个测试只测试我们关注的一个部分,
一个测试不应该测试多个部分, 也不应该影响其他的测试.

测试对象_测试条件_预期结果
Foo_GoodCondition_ReturnTrue

// BAD
expect(new Foo().bar(val)).toBe(true)
// GOOD
const foo = new Foo()
const result = foo.bar(val)
expect(result).toBe(true)

在断言阶段不应包含操作阶段的代码,将返回值或状态保存在result也是操作阶段的一部分。

// BAD
expect(foo(bar)).toBe(true)
// GOOD
const result = foo(bar)
expect(result).toBe(true)

这两个方法可以大幅减少样板代码, 但缺点是会分离测试代码和环境设置代码,
在保持良好的代码结构的前提下, 这个缺点很少发展成真正的问题.

使用stub、mock、fake来摆脱不可控的外部依赖, 不可控的外部依赖会使单元测试代码不稳定和难以维护.

如果一个测试里使用了mock, 则只应该断言需要的部分, 而不是既断言目标(状态, 返回值)又断言mock的调用记录.

假如一个测试用例包含3个断言, 测试目标对象的三种不同的调用参数,
那么其中一个断言的失败不应该导致其他断言无法执行(由于前一个断言导致运行中断).
理想状态下这三个断言应该放在三个不同的测试用例里.

这会使其他开发者无法理解该预期结果的意义,
如果使用了硬编码的数字或其他不清晰的预期结果, 则至少将其常量化, 用有意义的常量名表达其意义.

BDD在测试金字塔上方更常见.
BDD的侧重点是促进团队沟通, 而不是用自然语言的描述的"可测试文档"与测试文件的协同.

和DDD这样的方法论类似, BDD在某种程度上也是为了削弱开发人员在软件项目中的核心地位而出现的.

很多BDD尝试都以失败告终:

  • 开发人员以外的角色大多数并不真正理解软件测试, 他们不知道如何正确描述和规划测试.
  • 迭代BDD文档事实上有很高的沟通成本.
  • BDD实践中的功能描述语言很容易因为缺乏灵活性(例如刚好不适合这个场景)而成为编写测试的障碍.
  • 在很多不是很深奥的专业领域里, 让开发人员理解业务如何运行, 然后让他们自行编写测试效率更高.
  • BDD工具是阻抗不匹配的, 很容易复杂化测试用例, 比如引入一些该死的正则表达式.
  • 项目有精确且明确的业务需求定义.
  • 项目很大, 预期开发周期很长.
  • 有专门的BA/QA人员.
  • 团队愿意接受, 学习, 实践BDD方法.

最流行的BDD工具, 其核心是Gherkin语言, 该语言用自然语言描述预期的软件行为(即"用户故事", user story).

将用Gherkin语言定义的功能文件(扩展名为 .feature)与实际的BDD测试文件关联起来,
就可以用Cucumber生成报告, 以便每个人都能确定功能是否被实现, 是否通过测试.

TDD是决定单元测试用例的好方法, 在编写实现之前先写测试用例相当于通过测试用例对需要实现的目标进行设计.

TDD可以保证开发人员一开始就写出可测试的代码.
代码的进度也可以通过测试用例的通过数量进行追踪.

不难意识到TDD在很多场合下不适用, 在所有领域推广TDD只会增加很多无意义的测试用例.
不适用的主要原因是 实施TDD的性价比太低, 以至于实施它们是在浪费用于测试的预算.

哪些部分需要被测试, 取决于项目的边界.
不需要测试的部分往往是这个项目可以被重构的部分.

如果一个项目是一个库, 那么它的边界就是暴露给外侧的API, 因此API是需要被测试的.
内部细节通常不需要测试, 因为会被高层测试覆盖.

服务通常是分层的, 让服务可测试的重点在于高层代码不应该跨层访问低层代码.

  • 用户界面层: 对用户界面层接近于验收测试的概念, 与GUI相关的测试可能非常脆弱, 不推荐测试.
  • 应用层:
    应用层的行为通常是稳定的(例如将商品加入购物车), 因此可以测试它.
    如果没有对用户界面进行测试, 则对应用层进行测试是必须的.
  • 领域层: 不应该测试, 因为测试性价比太低.
  • API接口层: 必须被测试.
  • 应用层: 应用层的行为通常是稳定的(例如将商品加入购物车), 因此可以测试它.
  • 领域层: 不应该测试, 因为测试性价比太低.

一个函数常常会包括多个参数, 尽管在设计时我们应该尽可能减少函数的参数以便于测试和可读性, 但有时这不可避免.
这样的函数需要基于组合的测试, 即将不同的参数组合在一起, 生成一个数量庞大的测试集
(相当于每个参数的可能性相乘得到的积),
这不可能手动编码来完成.

行业内存在[fast-check]这样的模块用来辅助实现基于组合的测试.
这可能被认为是过度测试, 因为测试集实际上不需要那么多,
我们之所以能注意到这一点是因为测试对象对我们来说是一个白盒.
如果测试对象是直接暴露给公共区域的, 且在各种参数的组合调用下结果是可以预料的,
则可能有必要使用基于组合的测试(配合[faker.js]这样的测试集生成器), 以发现固定测试用例无法发现的错误,
但这通常很少见.

[fast-check]: (https://github.com/dubzzz/fast-check)
[faker.js]: (https://github.com/Marak/Faker.js)

"端到端测试"一词被主要用于GUI测试, GUI客户端以外的软件类型较少使用这个术语.

E2E测试被认为是最接近用户行为的测试, 测试只在乎用户交互后相对应的可见变化是否发生.
E2E测试通常在真实的浏览器里运行, 是集成度最高的集成测试, 一项测试会跨越并覆盖前端到后端之间的所有部分.

尽管自动化的E2E测试可以给开发人员提供信心, 却并不是那么值得实施:

  • E2E测试非常脆弱, 哪怕只是一些小改变就会导致测试失败, 也很容易受各种环境因素影响.
  • E2E测试运行的速度很慢, 成本很高, 会对CI造成负担.
  • E2E测试缺乏隔离性, 发现故障点非常困难.
    出于这些原因, 一般只会在关键业务上实施E2E测试.

E2E测试的主要形式:

  1. 1.
    访问客户端/页面, 找到需要测试的元素
  2. 2.
    与它交互
  3. 3.
    检验交互的结果

测试Web UI的一种方式, 快照测试只需要提供被测试组件和输入, 组件的输出(DOM)会被自动保存和在未来的测试中用于对比.
快照测试的主要目的是防止意外的DOM变更, 所以算是一种回归测试.

快照测试经常制造一种虚假的安全感, 开发人员必须意识到它不能用来替代功能测试.

一种非常快速和简单的集成测试, 名称源自硬件开发: 只要电路通电后没冒烟, 就算测试通过.

大多数冒险测试只测试以下几种项目:

  • 程序是否启动
  • 页面是否可访问
  • 页面上是否有特定的链接