0%

数据测试实践

data testing

在数据平台建设过程中,测试怎么做是一个值得思考的问题。由于数据应用开发和功能性软件系统开发存在很大的不同,在我们实践过程中,在开发人员和质量保证人员间常常有大量关于测试如何实施的讨论。下文将尝试总结一下数据应用开发的特点,并讨论在这些特点之下,对应的测试策略应该是怎么样的。

功能性软件的测试

先来回顾一下功能性软件系统开发中的测试。

测试一般分为自动化测试和手工测试。由于手工测试对人工依赖程度很高,如果主要依赖手工测试来保证软件质量,将无法满足软件快速迭代上线的需要。现代软件开发越来越强调自动化测试的作用,这也是敏捷软件开发的基本要求。有了全方位的自动化测试保障,就有可能做到每周上线,每日上线甚至随时上线。

这里主要讨论自动化测试。

测试金字塔

我们一般会按照如下测试金字塔的原则来组织自动化测试。

testing pyramid

测试金字塔分为三层,自下而上分别对应单元测试、集成测试、端到端测试。

单元测试是指函数或类级别的,较小范围代码的测试,一般不依赖外部系统(可通过Mock或测试替身等实现)。单元测试的特点是运行速度非常快(最好全部在内存中运行),所以执行这种测试的成本也就很低。单元测试在测试金字塔的最底端,占的面积最大。这指导我们应该构建大量的这类测试,并以这类测试为主来保证软件质量。

集成测试是比单元测试集成程度更高的测试,它在运行时执行的代码路径更广,通常会依赖数据库、文件系统等外部环境。由于依赖了外部环境,集成测试的运行速度更慢,执行测试的成本更高。集成测试在测试金字塔的中间,这指导我们应该构建中等数量的这类测试。集成测试在Web应用场景中也常常被称为服务测试(Service Test)或API测试。

端到端测试是比集成测试更靠后的测试,通常通过直接模拟用户操作来构建这样的测试。由于需要模拟用户操作,所以它常常需要依赖一整套完整集成好的环境,这样一来,其运行速度也是最慢的。端到端测试在Web应用场景中也常常被称为UI测试。端到端测试在测试金字塔的顶端,这指导我们应该构建少量的这类测试。

测试的范围非常广,实施方法也非常灵活。哪里是重点?我们要在哪里发力?测试金字塔为我们指明了方向。

一般软件的测试

为了更深入的理解一般软件的测试要怎么做,我们需要进一步深入分析一下测试金字塔。

测试带来的信心

上文中的金字塔图示有一个特点并没有反映出来,那就是,越上层的测试给团队带来的信心越强。这还算好理解,试想,如果没有单元测试,只有端到端测试,我们是不是可以认为程序大部分还是可以正常工作的(可能存在一些边界场景有问题)?但是如果只有单元测试而没有端到端测试,我们连程序能不能运行都不知道!

端到端测试能带来很强的信心,但这常常构成另一个陷阱。由于端到端测试对团队有很大的吸引力,一些团队可能会选择直接构建大量的端到端测试而忽略单元测试。这些端到端测试运行缓慢,一般也难以修改,很快就会让团队举步维艰。缓慢的测试带来了缓慢的持续集成,高频率的上线就慢慢变得遥不可及。

单元测试虽然不能直接给人很强的信心,但是常常是更有效的测试手段,因为它可以很容易的覆盖到各种边界场景。

测试金字塔是敏捷软件开发所推崇的测试原则,它是在测试带来的信心和测试本身的可维护性两者中权衡做出的选择。测试金字塔可以指导我们构建足够的测试,使得团队既对软件质量有足够的信心,又不会有太多的测试维护负担。

既然是权衡,那么我们是否可以以单元测试和集成测试为主,而根本不构建端到端测试(此时端到端测试的功能通过手工测试完成)呢?

测试集成度

对于一些没有UI(或者说GUI)的应用,或者一些程序库、框架(如Spring)等,很多时候测试金字塔中的三类测试并不直接适用。我们可以这样理解:测试金字塔并非只是三层,它更多的是帮我们建立了在项目中组织测试的原则。

事实上,对于通用的软件测试,我们可以理解为存在一个集成度的属性。沿着金字塔往上,测试的集成度越高(依赖外部组件越多)。由于集成度更高,测试过程所要运行的代码就更多更复杂,测试运行时间就越长,测试构建和维护成本就越高。实践过程中,为了提高软件质量和可维护性,我们应当构建更多集成度低的测试。

有了测试集成度的理解,我们就可以知道,其实金字塔可以不是三层,它完全可以是两层或者四层、五层。这取决于我们怎么划定某一类测试的范围。同时,我们还可以知道,其实单元测试、集成测试与端到端测试其实并没有特别明显的界限。

下面,我们从测试集成度的角度来看如何构建单元测试。

上文提到,测试最好通过Mock或测试替身等实现,从而可以不依赖外部系统。但是,如果测试Mock或测试替身难以构造,或者构造之后我们发现测试代码和产品代码耦合非常严重,这时应该怎么办呢?一个可能的选择是考虑使用更高集成度的测试。

Spark程序就是这样的一个例子。一旦使用了SparkDataFrame API去编写代码,我们就几乎无法通过Mock SparkAPI或构造一个Spark测试替身的方式编写测试。这时的测试就只能退一步选择集成度更高一些的测试,比如,启动一个本地的Spark环境,然后在这个环境中运行测试。

此时,上面的测试属于哪种测试呢?如果我们用三层测试金字塔的测试划分来看待问题,就很难给这样的测试一个准确的定位。不过,通常我们无需考虑这样的分类,而是可以把它当做集成度低的测试,即金字塔靠底端的测试。如果团队成员能达成一致,我们可以称其为单元测试,如果不能,称其为Spark测试也并非不可。

一般软件的测试

所以,对于一般的软件测试,我们可以认为测试策略应当符合一般意义的金字塔。金字塔的细节,比如应该有几层塔,每一层的范围应该是什么样,每一层应该用什么样的测试技术等等,这些问题需要根据具体的情况进行抉择。

在讨论一般软件的测试时,需要关注软件的测试何时停止,即,如何判断软件测试已经足够了呢?

在老马的《重构 第二版》中,有对于何时停止测试的观点:

有一些测试规则建议会尝试保证我们测试一切的组合,虽然这些建议值得了解,但是实践中我们需要适可而止,因为测试达到一定程度之后,其边际效用会递减。如果编写太多测试,我们可能因为工作量太大而气馁。我们应该把注意力集中在最容易出错的地方,最没有信心的地方。

一些测试的指标,如覆盖率,能一定程度上衡量测试是否全面而有效,但是最佳的衡量方式可能来自于主观的感受,如果我们觉得对代码比较有信心,那就说明我们的测试做的不错了。

主观的信心指数可能是衡量测试是否足够的重要参考。如果要问测试是否足够,我们要自问是否有信心软件能正常工作。

在实践过程中,我们还可以尝试分析每次bug出现的原因,如果是由于大部分bug是由于代码没有测试覆盖而产生的,此时我们可能应该编写更多的测试。但如果是由于其他的原因,比如需求分析不足或场景设计不完备而导致的,则应该在对应的阶段做加强,而不是一味的去添加测试。

数据应用的测试

有了前面对测试策略的分析,我们来看看数据应用的测试策略。

数据应用相比功能性软件有很大的不同,但数据应用也属于一般意义上的软件。数据应用有哪些特点,应该如何针对性的做测试呢?下面我们来探讨一下这几个问题。

根据前面的文章分析,数据应用中的代码可以大致分为四类:基础框架(如增强SQL执行器)、以SQL为主的ETL脚本、SQL自定义函数(udf)、数据工具(如前文提到的DWD建模工具)。

基础框架的测试

基础框架代码是数据应用的核心代码,它不仅逻辑较为复杂,而且需要在生产运行时支持大量的ETL的运行。谁也不想提交了有问题的基础框架代码而导致大规模的ETL运行失败。所以我们应当非常重视基础框架的测试,以保证这部分代码的高质量。

基础框架的代码通常由PythonScala编写,由于PythonScala语言本身都有很好的测试支持,这十分有利于我们做测试。

基础框架的另一个特点是它通常没有GUI

按照测试金字塔原理,我们应当为其建立更多的集成度低的测试(下文称单元测试)以及少量的集成度高的测试(下文称集成测试)。

比如,在前面的文章中,我们增强了SQL的语法,加入了变量、函数、模板等新的语法元素。在运行时进行变量替换、函数调用等等功能通过基础框架实现。这部分功能逻辑较为复杂,应当建立更多的单元测试及少量的集成测试。

ETL脚本的测试

ETL脚本的测试可能是数据应用中的最大难点。

采用偏集成的测试

ETL脚本一般基于SQL实现。SQL本身是一个高度定制化的DSL,如同XML配置一样。

XML要如何测试?很多团队可能会直接忽略这类测试。但是用SQL编写的ETL代码有时候还是可以达到几百行的规模,有较多的逻辑,不测试的话难以给人以信心。如何测试呢?

如果采用基于Mock的方法写测试,我们会发现测试代码跟产品代码是一样的。所以,这样做意义不大。

如果采用高集成度的测试方式(下文称集成测试),即运行ETL并比对结果,我们将发现测试的编写和维护成本都较高。由于ETL脚本代码本身可能是比较简单且不易出错的,为了不易出错的代码编写测试本身就必要性不高,更何况测试的编写和维护成本还比较高。这就显得集成测试这一做法事倍功半。

这里可以举一个例子。比如对于一个分组求和并排序输出的SQL,它的代码可能是select a, b, c, count(1) from t group by a, b, c order by a, b, c, count(1)。如果我们去准备输入数据和输出数据,考虑到各种数据的组合场景,我们可能会花费很多的时间,这带来了较高的测试编写成本。并且,当我们要修改SQL时,我们还不得不修改测试,这带来了维护成本。当我们要运行这个测试时,我们不得不完成建表、写数据、运行脚本、比对结果的整个过程。这些过程都需要依赖外部系统,从而导致测试运行缓慢。这也是高维护成本的体现。

可见这两种测试方式都不是好的测试方式。

测试构建原则

那么有没有什么好的原则呢?我们从实践中总结出了几点比较有价值的思路供大家参考。

  • ETL脚本分为简单ETL和复杂ETL(可以通过代码行数,数据筛选条件多少等进行衡量)。简单的ETL通过代码评审或结对编程来保证代码质量,不做自动化测试。复杂的ETL通过建立集成测试来保证质量。
  • 由于集成测试运行较慢,可以考虑:
    • 尽量少点用例数量,将多个用例合并为一个来运行(主要是将数据可以合并成单一的一套数据来运行)
    • 将测试分级为需要频繁运行的测试和无需频繁运行的测试,比如可将测试分级P0-P5,P3-P5是经常(如每天或每次代码提交)要运行的测试,P0-P2可以低频(如每周)运行
    • 开发测试支持工具,使得运行时可以尽量脱离缓慢的集群环境。如使用Spark读写本地表
  • 考虑将复杂的逻辑使用自定义函数实现,降低ETL脚本的复杂度。对自定义函数建立完整的单元测试。
  • 将复杂的ETL脚本拆分为多个简单的ETL脚本实现,从而降低单个ETL脚本的复杂度。

加深对业务和数据的理解

我们在实践过程中发现,其实大多数时候ETL脚本的问题不在于代码写错了,而在于对业务和数据理解不够。比如,前面文章中的空调销售的例子,如果我们在统计销量的时候不知道存在退货或者他店调货的业务实际情况,那我们就不知道数据中还有一些字段能反映这个业务,也就不能正确的计算销量了。

想要形成对数据的深入理解需要对长时间的业务知识积累和长时间对数据的探索分析(业务系统通常经历了长时间的发展,在此期间内业务规则复杂性不断增加,导致数据的复杂性不断增加)。对于刚加入团队的新人,他们更容易由于没有考虑到某些业务情况而导致数据计算错误。

加深对业务和数据的理解是进行高效和高质量ETL脚本开发的必由之路。

有没有什么好的实践方法可以帮助我们加深理解呢?以下几点是我们在实践中总结的值得参考的建议:

  • 通过思维导图/流程图来整理复杂的业务流程(或业务知识),形成知识库
  • 尽量多的进行数据探索,发掘容易忽略的领域业务知识,并通过第一步进行记录
  • 找业务系统团队沟通,找出更多的领域业务知识,并通过第一步进行记录
  • 如果有条件,可以更频繁的实地使用业务系统,总结更多的领域业务知识,并通过第一步进行记录
  • 针对第一步搜集到的这些容易忽略的特定领域业务流程,设计自动化测试用例进行覆盖

SQL自定义函数的测试

在基于Hadoop的分布式数据平台环境下,SQL自定义函数通常通过PythonScala编写。由于这些代码通常对外部的依赖很少,通常只是单纯的根据输入数据计算得到输出数据,所以对这些代码建立测试是十分容易的事。事实上,我们很容易实现100%的测试覆盖率。

在组织测试时,我们可以用单元测试的方式,不依赖计算框架。比如,以下Scala编写的自定义函数:

1
2
val array_join_f = (arr: Seq[Double], item_format: String, sep: String) => ...
val array_join = udf(array_join_f)

对其建立测试时,可以直接测试内部的转换函数array_join_f,一些示例的测试场景比如:

1
2
3
4
assertEquals(array_join_f(null, null, null), ...)
assertEquals(array_join_f(Array[Double](), null, null), ...)
assertEquals(array_join_f(Array[Double](1, 2), "%.2f", ","), ...)
...

在建立了单元测试之后,一般还需要考虑建立少量的集成测试,即通过Spark框架运行SQL来测试此自定义函数,一个示例可以是:

1
assertEquals(spark.sql('select array_join(array(), "%.2f", ",")'), ...)

如果自定义函数本身十分简单,我们也可以直接通过Spark测试来覆盖所有场景。

从上面的讨论可以看出,SQL自定义函数是很容易测试的。除了好测试之外,SQL自定义函数还有很多好的特性,比如可以很好的降低ETL复杂度,可以很方便的被复用等。所以,我们应该尽量考虑将复杂的业务逻辑通过自定义函数封装起来。这也是业界数据开发所建议的做法(大多数的数据开发框架都对自定义函数提供了很好的支持,如Hive Presto ClickHouse等,大多数ETL开发工具也都支持自定义函数的开发)。

数据工具的测试

数据工具的实例可以参考文章《数据仓库建模自动化》《数据开发支持工具》

这些工具的一大特点是,它们是用于支持ETL开发的,仅在开发过程中使用。由于它们并不是在产品环境中运行的代码,所以我们可以降低对其的质量要求。

这些工具通常只是开发人员为了提高开发效率而编写的代码,存在较大的修改和重构的可能,所以,过早的去建立较完善的测试必要性不高。

在我们的实践过程中,这类代码通常只有很少的测试,我们只对那些特别复杂、没有信心能正确工作的地方建立单元测试。如果这些工具代码是通过TDD的方式编写的,通常其测试会更多一些。

在持续集成流水线中运行测试

前面我们讨论了如何针对数据应用编写测试,还有一个关于测试的重要话题,那就是如何在持续交付流水线中运行这些测试。

在功能性软件项目中,如果我们按照测试金字塔的三层来组织测试,那么在流水线中一般就会对应三个测试过程。

从上面的讨论可知,数据应用的测试被纵向分为四条线,如何对应到流水线上呢?如果我们采用同一个代码库管理所有的代码,可以考虑直接将流水线分为四条并行的流程,分别对应这四条线。如果是不同的代码库,则可以考虑对不同的代码库建立不同的流水线。在每条流水线内部,就可以按照单元测试、低集成测试、高集成测试这样的方式组织流水线任务。

独立的ETL流水线

对于ETL代码的测试,有一个值得思考的问题。那就是,ETL脚本之间通常独立性非常强,相互之间没有依赖。这是由于ETL代码常常由完善的领域特定语言SQL开发而成,与PythonScala等通用编程语言编写的代码不同,SQL文件之间是没有依赖的(如果说有依赖,那也是通过数据库表产生依赖)。

既然如此,假设我们修改了某一个ETL文件的代码,是不是我们可以不用运行其他的ETL文件的测试呢?其实不仅如此,我们甚至可以单独上线部署此ETL,而不是一次性部署所有的ETL。这在一定程度上还降低了部署代码带来的风险。

有了上面的发现,我们可能要重新思考数据应用的持续交付流水线组织形式。

一个可能的办法是为每一个ETL文件建立一个流水线,完成测试、部署的任务。此时每个ETL可以理解为一个独立的小程序。

这样的想法在实践中不容易落地,因为这将导致大量的流水线存在(常常有上百条),从而给流水线工具带来了很大的压力。常用的流水线工具,如Jenkins,其设计是难以支撑这么大规模的流水线的创建和管理的。

要如何来支持上面这样的ETL流水线呢?可能需要我们开发新的流水线工具才行。

云服务中的ETL流水线

现在的一些云服务厂商在尝试这样做。他们通常会提供一个基于WebETL开发工具,同时会提供工具对当前的ETL的编写测试。此时,ETL开发人员可以在一个地方完成开发、测试、上线,这可以提高开发效率。

这类服务的一个常见缺点在于它尝试用一套Web系统来支持所有的ETL开发过程,这带来了大量繁杂的配置。这其实是将ETL开发过程的复杂性转化为了配置的复杂性。相比编写代码而言,多数开发人员不会喜欢这样的工作方式。(当前软件开发所推崇的是Everthing as Code的做法,尝试将所有开发相关过程中的东西代码化,从而可以更好的利用成熟的代码编辑器、版本管理等功能。而Web配置的方式与Everthing as Code背道而驰。)

对于这些数据云服务厂商提供的数据开发服务,如果可以同时支持通过代码和Web界面配置来实现数据开发,那将能得到更多开发者的喜爱。这在我看来是一个不错的发展方向。

总结

本文讨论了如何在数据平台建设过程中做测试这个话题。由于数据应用开发有很强的独特的特点(比如以SQL为主、有较多的支撑工具等),其测试与功能性软件开发的测试也存在很大的不同。

本文分析了如何在测试金字塔的指导下制定测试策略。测试金字塔不仅可以很好的指导功能性软件开发,在进行一般意义上的推广之后,可以很容易得到一般软件的测试策略。关于测试金字塔,本文分析了测试带来的质量信心及测试集成度,这两个概念可以帮助我们更深刻的理解测试金字塔背后的指导原则。

在最后,结合我们的实践经验,给出了一些数据应用中的测试构建实践。将数据应用分为四个不同模块来分别构建测试,可以很好的应对数据应用中的质量要求,同时保证有较好的可维护性。最后,我们讨论了如何在持续集成流水线中设计测试任务,留下了一个有待探索的方向,即如何针对单个ETL构建流水线。

数据应用的质量保证是不容易做到的,常常需要我们进行很多的权衡取舍才能找到最适合的方式。想要解决这一问题,还要发挥团队中所有人的能动性,多总结和思考才行。

欢迎关注我的其它发布渠道