从多个角度理解软件测试

大家好,我是 ONDA 的后端工程师 Gunner(郑在勋,정재훈)。我在多个领域做了 N 年后端开发,最深的感受是:软件测试这个领域覆盖面极广,而且对"测试是不是必需的"这个问题,开发者们的看法也很不统一。
所以我打算分两篇文章,聊聊软件测试的分类,以及开发者写测试时值得遵循的标准。
1. 软件测试的分类
软件测试,就是验证软件能否正确运行、有没有 bug 的过程。
软件测试大致分为**黑盒测试(Black-Box)和白盒测试(White-Box)**两类。
-
黑盒测试(功能测试): 把软件当成"黑箱"来测,看不到内部怎么运行,只从外部用户的视角验证功能是否正常。源代码不是测试对象。这类测试主要由 QA(质量保证,负责验证服务功能和质量的)团队负责。
-
白盒测试(结构测试): 要看代码、看程序结构、看数据流,在此基础上验证软件功能。主要由开发者负责。
如果按测试针对的层级来分,还可以这样分类:
-
单元测试(Unit): 测试程序的基本单元。也就是把程序的基本单元从其他部分隔离出来,独立运行,看结果是否符合预期。单元的标准因编程语言或框架而异。比如在函数式编程里,函数就是一个单元。
-
集成测试(Integration): 系统由多个组件相互协作构成,集成测试验证的就是同一系统内、或不同系统之间的组件交互是否正常。
◦ 回归测试(Regression): 在添加新功能或修复 bug 后,验证这些改动有没有对系统原有功能产生负面影响。
*集成测试可能同时是回归测试,也可能不是,两者不完全重合。
-
系统测试(System): 对一个完整的、集成好的系统进行测试,验证是否满足需求规格。因为需要了解内部实现,所以和黑盒测试不同——可以启动浏览器来测代码;同时因为是从"完整系统"的角度测功能,所以和集成测试也有区别。系统测试既要验证功能性(Functional)指标,也要验证非功能性(Non-Functional)指标(比如稳定性、扩展性等)。
2. 面向对象系统里的测试
现在很多软件系统都用面向对象方法开发。面向对象范式最早出现在 1960 年代为仿真目的开发的 Simula 语言,如今已经广泛应用于各个领域。
面向对象系统做单元测试时,单元的标准是什么?
面向对象系统的基本单元是对象(object),所以单元测试的基准通常是描述对象信息的类(class)。
一个类由字段(field)、方法(method)、继承等多种元素交织而成。实际写类的单元测试时,我们一般针对方法写测试,所以也有人把方法当作基本单元——但这不太合适,因为方法本来就不能脱离类独立存在。
如果把类作为单元标准,单元测试大致可分为两类:
- 类内测试(intra-class): 只在目标类内部进行测试
- 类间测试(inter-class): 测试目标类时,还涉及其他类
等一下!类间单元测试和集成测试有什么区别?
面向对象系统的特点是,一个测试用例常常涉及多个类,这时单元测试和集成测试的边界就模糊了。因为集成测试的定义是"测试同一系统内、或不同系统之间的组件交互"。
关键在于怎么定义"组件"。如果把组件定义为单个类,那单元测试和集成测试的界限确实不清晰。
虽然没有标准答案,但一般可以这样区分:
- 单元测试强调独立执行(与其他系统组件隔离、能快速运行的测试),推荐用 mock;集成测试不用 mock,直接测真实代码。
- 单元测试涉及的类比集成测试少。
3. BDD(行为驱动开发)与 TDD(测试驱动开发)
这种模糊性在行为驱动开发(Behaviour-Driven Development, BDD)¹ 和测试驱动开发(Test-Driven Development, TDD)这类测试工具里也能看到。TDD 聚焦单元测试,BDD 则是从 TDD 衍生出来的,更强调明确表达和测试系统的行为。
BDD 写测试规格时用的 "Given/When/Then" 准则很能体现这个特点:
- Given: 为触发测试目标行为做初始化准备。
- When: 描述触发目标行为的事件。
- Then: 验证事件发生后的预期结果。
下面是一段用 "Given/When/Then" 准则写的 Scala 测试代码示例:
package com.acme.pizza
import org.scalatest.FunSpec
import org.scalatest.BeforeAndAfter
import org.scalatest.GivenWhenThen
class PizzaSpec extends FunSpec with GivenWhenThen {
var pizza: Pizza = _
describe("披萨") {
it ("应该能添加配料") {
Given("一个新披萨")
pizza = new Pizza
When("添加配料时")
pizza.addTopping(Topping("green olives"))
Then("配料数量会增加")
expectResult(1) {
pizza.getToppings.size
}
And("添加的配料种类会被保留")
val t = pizza.getToppings(0)
assert(t === new Topping("green olives"))
}
}
}
BDD 为什么强调 "Given/When/Then"?
面向对象系统的功能,很多时候靠对象间的交互实现,单靠单元测试有局限。所以写测试用例(Test Case, TC)时,最好把重点放在交互上。也就是说,TC 设计时不要把范围限制在类内测试,要往类间测试的方向想。
如果用 JUnit 之类的单元测试工具,很容易受"一个类对应一个测试文件"这种潜规则影响,设计 TC 时也只考虑那一个类。但如果一开始就按 "Given/When/Then" 准则思考,自然就会想到类之间的交互。
换句话说,用 BDD 会让你在写 TC 时考虑类间交互,自然而然就写出了好的类间测试。
当然,光遵守 "Given/When/Then" 准则,不代表一定能写出考虑交互的测试。这个准则既可以用于单元测试,也可以用于集成测试。最重要的,还是测试编写者对 TC 层级的理解。
那么 TDD(测试驱动开发)又是什么?业界对它有哪些看法?我们下篇再聊。