自动化测试原则

自动化测试原则


转自 Principles of Automated Testing

自动化测试是编写可靠软件的核心部分;手工测试仅能测试一部分,人不可能像机器一样对某功能彻底测试,或始终如一的谨慎。某些人花费极多时间在工作和开源工程的自动化测试系统上,这个帖子描述了我对此是如何看的。哪些区别是意义重大的,而哪些并不是;哪些实践有区别哪些没有;构建关于任意软件系统自动化测试的一套统一的标准。

或许我比大多数软件工程师更关注自动化测试。在上一个工作中,我开始关注和开始Selenium集成测试作为软件工程师开发过程的一部分,开发了静态分析测试以避免低级的错误和代码质量问题,让工程克服不可靠的测试灾难以使CI过程重新流畅。在我的开源成果中,例如,Ammonite或FastParse中,我所写的测试代码与主应用代码的比例基本上会达到1:1。

有很多关于自动化测试实践的文章,如单元测试,基于属性的测试,集成测试,以及其它主题。毫不意外的,大多数你从互联网上获取的信息并不完整,有可能互相矛盾,或仅仅只能应用于某些特定工程或场景。

这个帖子并不讨论特定的工具或技术,而是试图定义某种思考关于自动化测试的方式,可以广泛的应用于任何你所工作的软件工程。希望这能形成某种有用的基础,在你最终从日常开发中抽出注意力,开始关注和思考更广泛的自动化测试的策略。

自动化测试的目的

自动化测试的目的就是尝试并且确保你的软件干了你希望它干的事,不管是现在还是将来。

这是个宽泛的定义,并且引出各种各样尝试和验证软件的方法:

  • 以已知的输入调用函数,并且断言期待中的结果
  • 构建测试网站并且验证网页,同时也检验了在网页之后的所有系统,可以正确执行简单的操作
  • 用大量随机输入看系统会不会挂掉
  • 将你的系统与另外的已知是好的参考实现对比,确保两者行为相同

注意描述的目标没有提单元测试或集成测试。因为它们并不是最终目标:你希望用测试脚本自动检测你的软件是否按你所想的工作,以任何你觉得必要的方式。单元测试或集成测试与以各种方式进行的自动化测试仅有一个区别,我们后面会讨论这些方式中的一部分。

现在我们定义了高层次的目标,在帖子余下部分将对细节进一步展开讨论。

单元测试 vs 集成测试

当你工作于自动化测试时,常会有一些相关讨论:

  • 我们正在写的是单元测试还是集成测试?
  • 我们应该写单元测试还是集成测试?
  • 如何定义单元测试或集成测试?

有数不清的“真相只有一个”的区别来区分单元测试和集成测试,各不相同。类似于:

  • 单元测试必须始终在单个进程中运行
  • 单元测试不允许执行超过一个文件的代码:所有import必须被mock
  • 单元测试不能跨越服务器-客户端的边界

然而,我认为这样的讨论常常缺少远见。在现实中,两者的精确边界经常是任意的。任何代码片段或系统总是集成了更小单元的单元:

  • 集群集成了多台物理或虚拟机器
  • 某台机器(虚拟或物理)集成了多个进程
  • 某个进程集成了多个子进程(如:DB,worker等)
  • 某个子进程集成了多个模块
  • 某个模块或包集成了多个更小的模块
  • 某个模块集成了单独的函数
  • 某个函数以基本算法集成了基本元素如Int等

任何代码或系统片段可以被看成是一个单元进行测试,同时也可以被看成是更小单元的集成。基本上任何已经开发出来的软件都可以以下列层级方式进行分解:

                            _________
                           |         |
                           | Machine |
                           |_________|
                           /         \
                _________ /           \ _________
               |         |             |         |
               | Process |             | Process |
               |_________|             |_________|
               /                       /         \
              /              ________ /           \ ________
            ...             |        |             |        |
                            | Module |             | Module |
                            |________|             |________|
                            /        \                      \
                __________ /          \ __________           \ __________
               |          |            |          |           |          |
               | Function |            | Function |           | Function |
               |__________|            |__________|           |__________|
               /          \            /          \           /          \
              /            \          /            \         /            \
            ...            ...      ...            ...     ...            ...

早先我们定义了自动化测试的目标是“尝试并且确保你的软件干了你希望它干的事”,并且在软件的任何部分,你都会在处于这个层级上所有等级的代码。所有这些代码当然都应该是要测试和验证的。

为了让术语有连贯性,我将把对层级中处于较低级别代码(如,集成了基本元素的函数)的测试叫作单元测试,而把对于较高级别代码(如,集成了虚拟机的集群)的测试叫作集成测试。但是那边标签仅是简单是在一个范围中的方向,并不存在你可以应用在任意工程的,可以直接画在单元测试和集成测试之间的一条明显的界线。

                            大多数测试位于此之间
  单元测试 <--------------------------------------------------------> 集成测试
              |           |           |            |          |
             函数        模块        进程          机器       集群

真正有关系的是你清醒的认识到你的软件划分为层级的方式,以及自动化测试可以位于代码层级的任意层次,在单元测试和集成测试这一区间的任意一点上。

单元测试到集成测试之间是一个范围并不意味着它们之间的区别没有意义。虽然两种测试间有没明显界线,但向着两个区间端点靠近的测试有一些不同的属性:

单元测试集成测试
位于较低层位于较高层
较快较慢
更可靠更不可靠
不怎么需要设置需要一大堆设置
依赖较少依赖非常多
出错方式较少各种错误方式
特定错误信息广泛的错误信息
  • 单元测试趋于更快,因为它们只需要运行较少代码
  • 单元测试趋于更可靠,因为它们不太需要运行包含非确定错误的代码
  • 单元测试不太需要预先设置,办为它们依赖较少
  • 单元测试趋于发生相对特定的以较少的可能原因引发的错误(函数应返回1,但返回了2),而集成测试趋于更广泛的出错,由非常多种可能原因导致的各种无意义的错误(网页打不开)

作为测试开发者,这对你意味着什么?

单元测试和集成测试之间的区别由你来决定

对于单元测试和集成测试,一套算法库与一个网站对其的定义是不同的,而一个网站与一套集群部署系统对其的定义也可能是不同的。

  • 算法库可能定义单元测试为以某些小数值作为输入运行一个函数(如,对0,1,2,3的数字列表进行排序),而集成测试是使用多个函数构造通用算法
  • 网站可能会定义单元测试为任何不涉及HTTP API的功能,而集成测试则是需要HTTP交互的那部分
  • 另一种选择是,网站可能定义单元测试为不引发浏览器切换(直到且包括API交互),而集成测试则是那些用Selenium通过UI/JavaScript与服务器的交互引发的浏览器切换
  • 集群部署系统则可能定义单元测试为任何不会物理上创建虚拟机的操作,直到且包括使用HTTP API或数据库访问的测试,而集成测试则是那些在测试环境中切换真实集群的操作

尽管有一些区别(如,算法库的集成测试可能比集群部署系统的单元测试更快),所有这些系统都有其从“更单元”到“更集成”端的各自范围。

因此,在它们之间画线取决于项目所有者,并且围绕此线展开实践。上面所述将给你一些关于在各种项目中如何画线的概念,并且围绕此线的实践可能看起来是这样的:

  • 单元测试必须在提交前运行,而集成测试每天只在每夜构建前运行一次
  • 与单元测试不同,集成测试运行于单独的CI服务器/集群,因为不同的设置需要

它们可能在将测试区分为纹理清晰的分区时有些价值。再一次,它取决于项目拥有者,要画几条线,画在哪里,这些测试都叫啥(如,单元测试,集成测试,端到端测试,功能测试?),以及它们在项目中如何处理。

在现今数量众多的各种软件项目中,并没有一个统一的关于单元测试和集成测试的分类,但并不意味着区分没有意义。只是简单的需要根据不同项目来画一条有意义且有用的线。

每一层级的测试

软件的任何部分都是以层级方式编写的,单元套着单元。在每一级别,程序员们都有可能犯错:理想情况下,我们的自动化测试能捕获的错误。

因此,千万不要有类似规则:仅写单元测试,不写集成测试;只写集成测试,没有单元测试等等。

  • 不能只写单元测试。不管是所有函数都已被严格测试,但模块以不正确的方式组合了各函数,或者所有模块都已被严格测试,但应用进程以不正确的方式使用了模块。虽然一个测试套件能以极快的速度运行很棒,但如果它无法捕获程序上层的错误,则事实上并没什么用。
  • 不应该只写集成测试。理论上它能工作,在层次结构上层的代码调用与其相临层的代码,但你需要极大数量的集成测试以有效的运行低层代码。例如,当你想检测某个函数在10组不同基础数据参数情况下的行为,以集成测试的方式测试可能意味着你需要设置/释放整个应用进程10次:一种缓慢且严重浪费计算资源的行为。

相反,你的测试结构应大致反映你的软件结构。你希望在所有层级上进行测试,某层上的代码量与出错概率/严重性基本成正比。这防止了在你软件片段的任何层级上引入错误的可能。

如何对测试排优先级

自动化测试服务于两个主要目的:确信你的代码没有破坏任何事(或许以某种手工测试难以捕获的方式),并且确保能正常工作的代码不会在将来某个时刻被破坏(回归)。前一种可能是由于未完成的实现,而后一种可能是因为随着时间推移,某次代码基线升级中的错误。

因此,对于一些代码来说,自动化测试并不重要,比如看上去就不太会出错的代码,就算出错了也没什么关系的代码,或者某些除非有人特意搞挂掉都完全想不起来存在的代码。

对于决定系统或代码片段需要怎样的测试程度,更像艺术而非科学,但还是会有一些准则:

  • 重要的事情需要更多测试!密码/鉴权肯定需要重点测试以确保坏密码不会让人以任何方式登录进系统,至少也要比应用的其它随机算法测的更多。
  • 不太重要的事情,只需要比较少的测试甚至不需要测试。或许你网站上的促销信息消失了几天,直到下次部署才重新出现,仅有的合适测试是通过昂贵的/缓慢的/不可靠的 Selenium集成测试。如果是这样,测试花费会指出,你或许并不需要为此做自动化测试。
  • 正在开发的代码需要更多测试,未在开发中的代码则只需要更少测试。如果某段可怕的代码片段几年没动过了,如果它以前没出过问题,那它就不太可能会出错。现在,你可能希望测试以确保它还没有被破坏,但你并不需要用测试来防止回归和出现新的错误。
  • 将要一直使用的API比可能被废弃的API需要更多测试。你应该聚焦于更有效的测试你应用中稳定的接口,而不是去测试可能在一周后完全消失的未稳定接口。结合前面的准则,稳定但内部正在集中开发的API需要更多的测试。
  • 如果代码的复杂性位于比较难测试的位置(进程间,浏览器-服务器,数据库交互),那不管多难测试,你都应该确保你测试了这个逻辑。造成不要只测简单的事:不管你对单个功能测试的多好,如果将它们连接起来的代码很脆弱,最终结果很可能就会出问题。

所有这些观点都是主观的,并不能单从代码本身来判断。尽管如此,在对测试优先级排序时,你必须决定你开发自动化测试的精力该聚焦于何处。

测试即代码

测试脚本与其它的代码一样也是代码:测试套件是软件的一部分,其检测你的主软件以特定的方式运行。因此,你的测试代码应与其它任何软件片段一样被对待:

  • 公用测试逻辑应重构为助手(helper)。如果某个公用测试逻辑本身有BUG,那应该只需要修改一个地方,而非在整个套件中到处复制-粘贴以应用此修复。
  • 测试脚本应与普通代码一样有相同的代码质量标准:合适的命名,格式化,注释,在线文档,代码组织,代码风格和约定。
  • 测试脚本需要被重构以维护代码质量。任何代码在日益增长时都会变在混乱和难以维护,会需要重构以保持简捷无重复和良好组织。测试代码与此没有任何区别,当主应用的需求日益增长时,测试代码同样增长和变化以支持测试这些功能,它需要定期重构以保持无重复和良好的代码质量。
  • 测试套件应该足够灵活。如果要测试的API改变了,应可以迅速且容易的修改测试套件。如果某段代码被删掉,应该很放心的简单删除对应的测试代码,如果代码被重写,重写相应的测试代码不应该是件很困难的工作。适当的abstractions/helpers/fixtures能帮助确保修改或重写测试套件的某一部分不会是个繁重的负担。
  • 如果你的abstractions/helpers/fixtures开始变的复杂,其本身也应被测试,至少也应该最低程度被测试。

并不是所有人都同意这些准则。我看到有人争论测试代码与普通代码并不相同。复制-粘贴测试代码并不仅仅是可接受,而是最好设置测试抽象和helper,以保持没有重复代码。争议部分在于只简单的看是否在没有抽象时测试代码是有没错误。我并不同意这个观点。

我的观点是测试代码与其它代码一样,必须相同对待。

DRY 数据驱动测试

测试即是代码,代码应是无重复且只有必要的逻辑可见,不能有重复的样板。一个好的例子是定义测试助手以让你可以在你的测试套件中简单的堆放一堆测试用例,并且可以只简单的看一眼就知道你的测试套件所测试东西的输入。例如,有下面的测试代码:

// 在交互解释环境中按下ENTER时的完整性检查,以检测一组输入是否...
//
// - 完整,无需额外输入即可提交
// - 不完整,因此需要从用户获取更多输入行

def test1 = {
  val res = ammonite.interp.Parsers.split("{}")
  assert(res.isDefined)
}
def test2 = {
  val res = ammonite.interp.Parsers.split("foo.bar")
  assert(res.isDefined)
}
def test3 = {
  val res = ammonite.interp.Parsers.split("foo.bar // 行注释")
  assert(res.isDefined)
}
def test4 = {
  val res = ammonite.interp.Parsers.split("foo.bar /* 块注释 */")
  assert(res.isDefined)
}
def test5 = {
  val res = ammonite.interp.Parsers.split(
    "val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0).sum"
  )
  assert(res.isDefined)
}
def test6 = {
  val res = ammonite.interp.Parsers.split("{")
  assert(res.isEmpty)
}
def test7 = {
  val res = ammonite.interp.Parsers.split("foo.bar /* 不完整的块注释")
  assert(res.isEmpty)
}
def test8 = {
  val res = ammonite.interp.Parsers.split(
    "val r = (1 until 1000.view.filter(n => n % 3 == 0 || n % 5 == 0)"
  )
  assert(res.isEmpty)
}
def test9 = {
  val res = ammonite.interp.Parsers.split(
    "val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0"
  )
  assert(res.isEmpty)
}

你能看到相同的事情被反复的干着。真应该被写成:

// 在交互解释环境中按下ENTER时的完整性检查,以检测一组输入是否...
//
// - 完整,无需额外输入即可提交
// - 不完整,因此需要从用户获取更多输入行

def checkDefined(s: String) = {
  val res = ammonite.interp.Parsers.split(s)
  assert(res.isDefined)
}
def checkEmpty(s: String) = {
  val res = ammonite.interp.Parsers.split(s)
  assert(res.isEmpty)
}
def testDefined = {
  checkDefined("{}")
  checkDefined("foo.bar")
  checkDefined("foo.bar // 行注释")
  checkDefined("foo.bar /* 块注释 */")
  checkDefined("val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0).sum")
}
def testEmpty = {
  checkEmpty("{")
  checkEmpty("foo.bar /* 不完整的块注释")
  checkEmpty("val r = (1 until 1000.view.filter(n => n % 3 == 0 || n % 5 == 0)")
  checkEmpty("val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0")
}

这只是任何编程语言代码中该做的普通重构。尽管如此,这立刻将复制-粘贴繁重样板的测试方法转为灵活的,无重复的代码,并且只一眼就能看出正在测试什么输入,以及该有什么样的预期输出。也可以用其它方式来做,比如,定义所有Defined用例到数组中,所有Empty用例到另一个数组,并且循环以完成所有断言:

def definedCases = Seq(
  "{}",
  "foo.bar",
  "foo.bar // line comment",
  "foo.bar /* block comment */",
  "val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0).sum"
)

for(s <- definedCases){
  val res = ammonite.interp.Parsers.split(s)
  assert(res.isDefined)
}

def emptyCases = Seq(
  "{",
  "foo.bar /* incomplete block comment",
  "val r = (1 until 1000.view.filter(n => n % 3 == 0 || n % 5 == 0)",
  "val r = (1 until 1000).view.filter(n => n % 3 == 0 || n % 5 == 0"
)

for(s <- emptyCases){
  val res = ammonite.interp.Parsers.split(s)
  assert(res.isEmpty)
}

两种重构方法完成同样的事情,并且有数不清的其它方法可以对代码去重。哪种方式完全取决于你自己。

围绕于这个想法,有许多华丽的工具/术语:表驱动测试,数据驱动测试,等等。但从根本上来说,你想要的就是让你的测试用例简洁,并且期待/断言更尽于一眼可见。这是普通的代码重构技术可以帮助你完成而无需任何华丽工具的。只有当你尝试了手工重构,并且发现在某些方面的缺失,这时才值得开始寻找更特定的工具和技术。

测试DSL

有各种测试DSL,让你编写测试与普通代码时大相径庭。我发现通用的测试DSL一般并没什么用,不过另有一些专用于特定目的的特定DSL。

通用测试DSL

这些包括外部DSL,如Cucumber系列,提供了一种全新的编写测试的语法:

Scenario: Eric wants to withdraw money from his bank account at an ATM
    Given Eric has a valid Credit or Debit card
    And his account balance is $100
    When he inserts his card
    And withdraws $45
    Then the ATM should return $45
    And his account balance is $55
Scenario Outline: A user withdraws money from an ATM
    Given <Name> has a valid Credit or Debit card
    And their account balance is <OriginalBalance>
    When they insert their card
    And withdraw <WithdrawalAmount>
    Then the ATM should return <WithdrawalAmount>
    And their account balance is <NewBalance>

    Examples:
      | Name   | OriginalBalance | WithdrawalAmount | NewBalance |
      | Eric   | 100             | 45               | 55         |
      | Pranav | 100             | 40               | 60         |
      | Ed     | 1000            | 200              | 800        |

而对于内部/内嵌的DSL,如Scalatest,能将宿主语言的语法转变为类似英语,以让你编写测试:

"An empty Set" should "have size 0" in {
  assert(Set.empty.size == 0)
}

"A Set" can {
  "empty" should {
    "have size 0" in {
      assert(Set.empty.size == 0)
    }
    "produce NoSuchElementException when head is invoked" in {
      intercept[NoSuchElementException] {
        Set.empty.head
      }
    }
    "should be empty" ignore {
      assert(Set.empty.isEmpty)
    }
  }
}
val result = 8
result should equal (3) // By default, calls left == right, except for arrays
result should be (3)    // Calls left == right, except for arrays
result should === (3)   // By default, calls left == right, except for arrays

val one = 1
one should be < 7       // works for any T when an implicit Ordered[T] exists
one should be <= 7
one should be >= 0

result shouldEqual 3    // Alternate forms for equal and be
result shouldBe 3       // that don't require parentheses

我对于这样的DSL的观点是,它们通常不值得努力。它们增加了间接且复杂的层次,不管是通过特定如 Cucumber 那样的语法解释器,还是通过如 Scalatest 那样特定扩展方法和语法的方式。这两者都让我更难以指出测试正在测什么。

我认为这样的语法不如仅用assert和普通的助手方法/for循环等,来编写测试。一般它们都会提供额外功能如比较好看的错误信息,然而现今的测试框架如PyTest和uTest同样可以用老的纯assert提供类似“好看”的错误信息。

$ cat test_foo.py
def test_simple():
    result = 8
    assert result == 3

$ py.test test_foo.py
=================================== FAILURES ===================================
_________________________________ test_simple __________________________________

    def test_simple():
        result = 8
>       assert result == 3
E       assert 8 == 3

test_foo.py:3: AssertionError
=========================== 1 failed in 0.03 seconds ===========================

正如先前所指出的,我认为测试即代码,并且因此开发普通代码时的编码工具,如函数,对象和抽象等同样可以很好的为编写测试服务。如果你并没在使用像 Cucumber 之类的外部DSL或 Scalatest 之类的内嵌类英语DSL来写你的主项目,那也不应该用类似的东东来写你的测试套件。

专用测试DSL

虽然我认为通用测试DSL如 Scalatest 或 Cucumber 并不是什么好语音,但专用测试DSL(如专用于定义测试用例输入/输出)还是有用处的。

例如,MyPy 项目使用特定语法来定义测试用例的输入/输出来进行python类型检查:

[case testNewSyntaxBasics]
# flags: --python-version 3.6
x: int
x = 5
y: int = 5

a: str
a = 5  # E: Incompatible types in assignment (expression has type "int", variable has type "str")
b: str = 5  # E: Incompatible types in assignment (expression has type "int", variable has type "str")

zzz: int
zzz: str  # E: Name 'zzz' already defined

这里的 # E: 注释用于断言类型检查将在检查此文件时在特定位置抛出一个指定错误。

我自己的 Ammonite 项目有其特定的语法用以断言在交互式解释环境会话中断言:

@ val x = 1
x: Int = 1

@ /* trigger compiler crash */ trait Bar { super[Object].hashCode }
error: java.lang.AssertionError: assertion failed

@ 1 + x
res1: Int = 2

在这些例子中,DSL都被缩小了范围到其明显正在测试的东东。此外,这些DSL仅在当普通代码的方式有太多“杂音”时才是必须的。例如,上述Ammonite测试用例以普通代码方式定义时看起来是这样的:

checker.run("val x = 1")
checker.assertSuccess("x: Int = 1")

checker.run("/* trigger compiler crash */ trait Bar { super[Object].hashCode }")
checker.assertError("java.lang.AssertionError: assertion failed")

checker.run("1 + x")
checker.assertSuccess("res1: Int = 2")

你能看到,Ammonite 的交互环境测试DSL对于普通代码而言,代码可读性得到明显提升!在这些情况下,DSL比普通代码能实际的减少了“杂音”,这时你就该用特定DSL来完成。在其它情况下,当然是默认情况下,你的测试应该以与其要测试的主代码基线一致的风格来编写。

范例测试 vs 批量测试

范例测试是指以单个(或少量)样例,测试整段代码,并且一路上仔细断言以确保代码做了正确的事情。批量测试,正好与之相反,是指以一批样例来测试代码,比较少对每个样例的行为详尽检查:只是确保它不会崩掉,以及或许粗略检查以确保其不是完全不正确的运行。Fuzz Testing 或 Property-based Testing 是这类测试的两种通用方式。

就像单元测试和集成测试的区别一样,范例测试与批量测试也是个范围区间,大多数测试落于这个区间中的某个地方。例如,之前的DRY 数据驱动测试位于区间中:以相同规则检查超过一组数据,而非成百上千组不同的输入。

范例测试-批量测试的范围区间与单元测试-集成测试的范围区间正交,你能轻松找到两个区间的每一极端例子:

+单元测试集成测试
范例测试输入[1, 0]到排序算法并且确保变为[0, 1]点击网站上的单一流程以确保该流程工作
批量测试输入大量的随机数到排序算法并确保最终被排序整夜不停的随机点击网站并确保没有500错误出现

范例测试

范例测试经常是当人们听到自动化测试时首先想到的:测试以某种方式使用API,并且检测结果。这里有个来自我的FastParse库中的例子,用于测试一个parser在分析单个字符 a 的情况:

import fastparse.all._
val parseA = P( "a" )

val Parsed.Success(value, successIndex) = parseA.parse("a")
assert(
  value == (),
  successIndex == 1
)

val failure = parseA.parse("b").asInstanceOf[Parsed.Failure]
assert(
  failure.lastParser == ("a": P0),
  failure.index == 0,
  failure.extra.traced.trace == """parseA:1:1 / "a":1:1 ..."b""""
)

你可以看到,有几个步骤:

  • 定义parser
  • 用它分析不同的字符串
  • 检测它在该成功或失败时成功或失败
  • 检测每个成功或失败的内容是我们所期望的

这就像在REPL中乱逛一样,除了在REPL中,我们通常用眼睛关注库的返回值,而在这儿我们用assert。

经常的,范例测试会与手工测试一起:在REPL中逛逛或运行开发的主方法以确保功能工作正常。然后你将做同样事情的测试添加到套件中以确保功能继续正常工作并且避免回归。如果你遵循测试驱动开发,你可能会先写测试,但其它的事情都是一样的。

范例测试良好的文档化:仅仅是读些范例,就能相对清楚某模块干了什么以及它该被怎么样使用。范例测试对于覆盖期待中的成功或失败用例很管用。你可以用DRY数据驱动测试轻松覆盖一组测试用例的输入/输出,但最终你还是会被局限于你能想象出什么样的用例,其只是所有可能输入的一个子集。这时批量测试上场了。

批量测试

批量测试可以比你手工测试覆盖更多用例:代码并非只被运行一次然后检查,批量测试以不同的输入运行成百上千次。这让你可以覆盖你以手工方式未能想到的测试用例,或添加到范例测试中。

有著名的方法进行批量测试,如Fuzz Testing 或 Property-based Testing,并且有框架如 QuickCheck 或 ScalaCheck 能帮助干这事儿,并且提供大量花哨的功能,但最终,批量测试能精简描述为下面类似的事情:

for i in range(0, 9999):
    for j in range(0, 9999):
        result = func(i, j)
        assert(sanity_check(result))

这里,我们以不同的输入调用 func 上亿次,以简单的 sanity_check 函数来检查,其并不知道所有输入的输出,但能检查基本的诸如“输出非负”。同时,我们检查 func 并未由于某些输入抛出异常或无限循环。

何时要做批量测试

由于测试输入数,批量测试要慢于单个用例的测试。因此它们的测试花费要远高,并且应被谨慎选用。尽管如此,当某个功能的输入范围极大且难以手工挑选测试用例来覆盖所有边界时,它们仍然是值得的。比如:

  • 数学算法会有大量 while 循环,如果实现不正确,则某些组合的输入极有可能导致无限循环
  • 编程语言分析工具,由于可能的输入程序有非常巨大的可能并且经常会包含一些不满足期望的样式
  • 日志文件分析工具,由于日志经常是混乱无结构的,很难知道何种模式该被接受或拒绝

这种情况下,输入大量不同测试数据有助于找出你未曾注意到的边界用例。测试数据可以是大范围的随机数字,从互联网上找到的各种源代码,从你生产环境拉下来的一整天的日志。

如何进行批量测试

在进行大量输入集合的时候,“正确”并不是由同样大量的一组预期输出。相反,“正确”通常都定义为输入输出间的相对关系是否如预期般的正确,而并不去管输入究竟是什么:

  • 任何输入不应导致程序抛出异常或无限循环
  • 排序函数的输出必须包含所有的输入列表中的值正好一次,输出列表必须已排序
  • 从样例日志处理过的所有行必须在X和Y之间包括一个日期,该日志的其它行必须没有标识 USER_CREATED

与范例测试相比,对批量测试的检查通常会比较简单和没那么精确。一般不会有人在处理很大的日志文件后,对返回结果与某个几千行的预期值列表进行精确断言:很可能在预期结果中也会不小心搞个错误,而你真正需要关注的是处理器本身。然而,不论程序的输出的准确值到底是什么,我们知道一些属性应该永远为true。那些属性才是批量测试时该真正验证的。

除了用for循环生成一堆输入数据之外,你也可以找到大量真实世界的输入数据喂给你的代码。假设如果我们要测试的程序要处理 Python 源代码,这样的批量测试可能看起来是这样的:

repos = [
    "dropbox/changes",
    "django/django",
    "mitsuhiko/flask",
    "zulip/zulip",
    "ansible/ansible",
    "kennethresitz/requests"
]
for repo in repos:
    clone_repo("https://github.com/" + repo)
    for file in os.walk(repo):
        if file.endswith(".py"):  
            result = process_python_source(file)
            assert(sanity_check(result))

批量测试通常会比范例测试慢很多:或许几秒或几分钟,而不是多少毫秒。此外批量测试趋于难读难懂:当你生成几千组测试数据或从互联网加载几千组测试输入时,很难判断哪些输入是连界用例,哪些输入只是普通不需要太关注的。

缩减批量测试至范例测试

因此,经常值得缩减批量测试将那些引发BUG的用例并添加到范例测试套件中。这意味着你的范例测试最终将包含在批量测试中出现的很好的边界用例。这也能成为很好的边界用例的文档,以让某位在将来修改代码的人引起注意,并让他们可以在毫秒级的时间里迅速的测试某些最重要的边界用例。

我的 FastParse 库有一套这种风格的测试套件:以一套可扩充的批量测试花费N分钟从互联网下载数以千计的源代码,处理分析,并执行基本检查(所有已有的处理工具可以成功处理的文件,我们也应该可以处理)。这伴随着大量DRY数据驱动的范例测试。这些范例中包括了所有曾在批量测试中发现过的BUG范例,并可以在不到1秒内运行完成。

再说一遍,有很多基于属性的测试工具,如 QuickCheck 或 ScalaCheck 能帮助编写这类批量测试。它们让生成大批量典型数据以喂到你的程序变得很容易,并自动找到少量引发失败的输入,以便更易于调试,以及其它很多优秀的功能。然而,它们并不是必须的:一些时候,几个for循环,一堆生产环境数据,或几个从“野外”找到的大型输入数据就能干到这个。如果你发现缺少快速生成有效数据的方法时,才应该去找更精密的工具。

测试花费

测试并非无花费的:毕竟,总得有人去写测试脚本!即使已经写完了,测试仍然并非无花费!每个测试都会增加测试套件的花费。每个测试:

  • 让测试套件变慢
  • 让测试套件更不可靠
  • 需要维护:当主代码改变时更新,在重构时检查,等

这些并非理论上的考虑:

  • 我在代码基线上开发,每天运行几百次遍上万个测试:即使每个测试有百万分之一的可能的不可靠,那每天也会出个几次错误了,这样的错误会让工程师迷惑和沮丧。运行测试每月几十万次,并且我们维护的上百万行代码都会把我们拖慢。
  • 在我的开源工作中,每个项目都需要大约半个小时来运行CI中的测试(在5个并行进程的情况下!),并且由于不可靠引起的编译错误让我非常沮丧。

在多台机器上并发运行测试可以加速缓慢的测试,但是需要¥¥¥,每台机器的设置开销远超只在一台机器上运行测试。

自动测试在一些情况下会变得毫无价值:必须持续不停的运行,不可靠,很难维护且/或去覆盖低测试优先级的内容。这些测试实际上是有害的:它们就不该被开发,并且如果已被开发,就该被删掉。我个人就删了很多类似测试,比如,一个网站促销体验的 selenium 测试:

  • 给测试套件增加了15分钟时间:每个 selenium 测试都能轻松增加一分钟,而这个测试大部分通过 selenium
  • 每天都要出错几次
  • 要测试的功能如果不正确应该立刻就被注意到了:用户体验应该已经由AB测试所记录并追踪用户反馈
  • 哪怕真的挂掉一天也没什么实质损失:没有数据丢失,没有功能受损,甚至不会有用户注意到
  • 哪怕真的很重要,在体验被废弃前的2-3周内也无法捕获任何BUG或回归

这种情况下,你该感谢开发者已经尽最大可能成为优秀工程师并测试了他们的代码,但若无法提升权重,无论如何直接删除掉这些测试。

在我的开源工作如 Ammonite 中,我类似的最终从我的测试套件中删除了许多测试,这些测试增加了几十分钟的运行时间却没办法捕获测试矩阵中其它测试无法捕获的BUG。虽然在每个 Scala 版本和每个 JVM 版本的生产版本上运行测试很好,但如果它花费太多时间只能捕获非常少的BUG,那在实践中就没有什么价值。

重构以减少测试花费

除了不写或删除花费太高的测试,你也可以将精力放到减少已有测试的花费上。例如,重构/模块化代码通常会将你的测试代码从大型的集成测试推向小型化的单元测试,其更快更可靠:

  • 全局变量常常迫使你要启动新的子进程去测试应用逻辑。如果你重构逻辑以去掉对全局变量的依赖,你可以在同一个进程中测试这个逻辑,这通常会更快。这对于启动缓慢的平台如JVM特别重要,但哪怕快捷的解释器如 Python 在运行前加载模块也能轻松用去10到100毫秒。
  • 数据库访问比内存逻辑更慢;例如,在你代码中从头到尾的都要从数据库加载一些数据,不如先加载必须的数据然后吐到核心业务逻辑中。这样你可以只靠内存运行你的核心业务逻辑的测试而无需数据库,这远远快于它们必须时刻与数据库交互。

本质上来说,这将会进化你的看起来这样的整体应用:

                     ____________________
                    |                    |
                    |                    |
                    |        应用        | <-- 无数集成测试
                    |                    |
                    |____________________|

并将其拆分为看起来这样的一堆:

                         __________
                        |          |
                        |   主模块  |  <------- 少量集成测试
                        |__________|
                        /   |  |   \
             __________/    |  |    \__________
            /               /  \               \
 __________/     __________/    \__________     \__________
|          |    |          |    |          |    |          |
|   模块   |    |   模块    |    |   模块   |    |   模块    | <-- 大量单元测试
|__________|    |__________|    |__________|    |__________|

现在原先的庞然大物变成了更小的单元,你可以从测试区间的集成测试端迁移到单元测试端:这么多之前测试庞大应用的集成测试现在可以迁移到对单独模块的单元测试,可以参考之前的“每一层级的测试”。

这种情况下,你通常会只留少量的集成测试以运行主模块以检测整体流程,并确保不同的模块能正确的在一起工作。这样,将庞大应用拆分为模块,并更新测试以适合这种模式,可以让你的测试套件运行的远快于之前并且更可靠,而并不会损失很多BUG捕获点。

再提醒一遍,这个策略应该应用于你代码的每一个层级,不管你是拆分一个庞大的集群,庞大的应用进程,或是一个庞大的模块。

如果你的测试套件变得很大/很慢/不可靠,并且你不愿意删除测试或砸钱让它们并发运行于多台机器,那么尝试重构代码发将集成测试转为单元测试是条很好的路子。

写出负面价值的测试令人惊讶的轻松。测试都有不断投入的花费:运行时,不稳定,和维护它们。开发者们绝对应该记住这点,并且经常使用,以最大化编写和维护自动化测试套件的投入产出比。

结论

此文章总结了我在写自动化测试时的考虑因素:

此文章的目的是描绘出与普通讨论不同的关于自动化测试的画面:自动化测试位于连续的测试区间中,而非不连续的某个点上,并且完全取决于项目拥有者来组织它们。由于测试即是代码,也应有同样的约束并可以用相同的技术,而非被当成特殊而不同的东东。由于测试有优先级,那些其提供的价值低于其不断的花费的测试就应被剔除。

此文章特意淡化了测试相关的热门话题:测试驱动开发,代码覆盖率,UI测试,以及其它很多东西。在使用特定工具和应用特定技术之外,此文章旨在描绘如何思考任意软件项目自动化测试的准则。

即使没有这些指导,希望此文章也能给你提供一个基础,能帮你制定,讨论,评估任意关于自动化测试的工具,技术,或实践,而不用管你正在工作于何项目。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值