TDD 实践:基于 clojure.test 的 Maven 工程测试环境

每个人都知道自动化测试的重要性,但却很少有人真正去做。而其原因归根结底,是没有意识到自动化测试的重要性,或者被裸奔的、快速迭代和迅速变质的代码带来的伤害了解不足,教训不够深刻之故。

如果必须要写测试代码,那就让它尽量的不痛苦。简而言之,我们有如下的需求:测试代码必须能够快速编写 —— 测试代码需要和 IDE 深度集成,有良好的代码提示和代码补全、测试代码的表达能力必须足够丰富,尽可能短小精简,随处复制粘贴、测试代码必须能够和被测试的代码无缝交互,调用简单,构造场景简单。测试代码必须能够快速运行 —— 最好是动态语言,修改完原始代码或者测试代码后,能立刻看到结果,而不需要额外编译或者确保测试代码的正确性。

因此,对于 Java 代码自动化测试来说,测试工具最好是 JVM 平台交互良好的动态语言,没有类型束缚,有 REPL,解释运行,和 IDE 深度集成,代码提示和补全都很快。因此我们排除了 Java、Kotlin、Scala,在余下的 Groovy、Clojure、JPython、JRuby 中,排除后两者,只剩下 Groovy 和 Clojure 满足需求。

Clojure 是一种寄居在 JVM 平台上的动态语言,也是一门 LISP 方言,其可以和 Java 进行无缝的互操作。借助于 LISP 宏的丰富语义、Proxy 机制以及 Clojure REPL 的开发效率保证,可以对于 Java 工程进行快速验证和测试,以支持自底向上和自顶向下的开发模型,贯彻 TDD 测试驱动开发理念,提高代码鲁棒性,保证代码可重构与快速迭代。

以最常见的 Maven 工程为例,在 IDEA 开发环境下,需要安装 Cursive 插件以提供 Clojure/Script 的语法高亮、格式化、重构支持 —— 直接在“设置 - 插件 - 市场 - 键入‘Cursive’ 搜索并安装”即可。此外最好准备下 Clojure 包管理工具,比如 Leiningen 或者 Clojure CLI,此处以 Leiningen 为例,按照官网的指导,下载运行脚本,将其放在 PATH 下运行即可。

有两种测试架构方案,如下所示,其一(左边)将 Clojure 测试代码单独看做一个模块,这样能够将测试代码统一管理,一些共用的函数可以轻松复用,集成测试起来也很方便。其二(右边)将 Clojure 测试代码看做每个待测试的 Maven 模块的一部分 —— 放置在 src 同级别的 test/clj 目录下,这样的好处在于在开发过程中,对某个模块可以更快速的开始单元测试,且编写测试时能够参照业务代码,实现“白盒”测试,测试更有效,且出现问题后可以立刻修改代码,“开发-测试”循环更快。介于这两种测试的应用场景不同,本文均会对其进行介绍。

单元测试场景

在单元测试场景下,我们选择为 Maven 模块添加一些 Clojure 测试。最简单的方法莫过于在 “File - Project Structure... - Modules” 下右键添加 Clojure 依赖 —— IDEA 会自行下载 Clojure jar 包到本地。

直接在 Maven 模块 test 目录下新建 clj 子目录,在其中创建 xxx.clj 测试文件即可,IDEA 会自行为单元测试提供快速运行的按钮(绿色三角箭头),并且将当前项目 src 中的代码看做 ClassPath,且提供对于 Clojure Test 的支持。

编写一些单元测试代码,点击 “运行” 按钮运行测试后,IDEA 自动将测试结果集成到了 Run 选项卡中 —— 不过,这看起来并不怎么样,我们获得的仅仅是比 JUnit 稍快的时间 - 编译测试代码节省的时间,启动 JVM 并执行测试依旧很慢 —— 82ms 的测试实际用了大概 30s 之旧,体验并不好。

在右上角打开“运行”菜单,选择 Run/Debug Configurations 选项,Run with Intellij project classpath,然后打开 REPL:

然后执行函数 run-tests 来触发某个单元测试,其结果立刻显示在 REPL 中,为了简化这一过程,直接在测试代码中加入此函数,Ctrl + L 重载测试代码命名空间时,会自动执行测试,现在就得到了我们想要的“迅捷”的体验。

当然,测试少不了 DEBUG,DEBUG 时需要点选右上角的运行配置,选择 Debug xxx,之后在测试代码中的断点会触发,并且,如果将断点继续打在业务代码上,也会正常中断:

集成测试场景

Clojure 环境搭建

在 Maven 模块工程中,在 “File - New - Module...” 菜单下新建一个 Clojure Module,选择 Leiningen 或者 Deps 项目管理工具,此处以 Leiningen 为例,创建一个模块。创建好后,删除不必要的 “doc, resources, .gitignore, .hgignore, .CHANGELOG.md, LICENSE, README.md” 等模块生成文件,在一个默认的 Leiningen(下称 lein) 项目中,Clojure 源代码在 src 目录下,项目定义在 project.clj 中(类似于 Maven 的 pom.xml)。

现在即可在当前模块下,在命令行通过 lein 执行各种操作,比如 lein repl 打开一个 nREPL 交互式游乐场,lein run 执行 Clojure 主函数代码,lein uberjar 将 Clojure 基础库和代码打包成只依赖 JVM 的 jar 包等等。在 IDEA 中,“Run - Run... - Edit Configurations” 菜单下可直接新建一个 Clojure REPL,选择类型为 nREPL,选择 Run with Leiningen 选项可以按照 project.clj 中定义的依赖、源码位置等参数运行一个 REPL。此 REPL 会弹出一个窗口(默认在右侧),窗口分为上下两部分,上面显示执行结果,下面允许输入需要送入 REPL 的表达式。定位到刚刚创建的 Clojure Module 工程的某个源码文件 xxx.clj,然后右键 REPL 菜单下提供了当前文件、选中代码和打开的 REPL 交互的方式,默认情况下,Alt + Shift + L 加载当前文件到 REPL,Alt + Shift + P 运行当前选中或者光标所在的表达式,Alt + Shift + R 将 REPL 的命名空间切换到当前文件所在的空间下,方便我们直接在 REPL 下方窗口执行表达式。

和 Maven 工程的集成

现在就设置好了一个 Clojure 的本地开发环境,但这并不是很有用 —— 对于 Java 开发者而言,为了完成和 Maven 模块的交互,这里有多种方法,考虑到这里的 Clojure 代码仅仅是为了执行单元测试,所以只需要配合 IDEA 工作良好即可 —— 换言之,我们希望在编写 Clojure 测试的时候,不仅能提示 Clojure 函数和错误,还能提示我们 Maven 工程的代码。因此,打开 "File - Project Structure.." 面板,找到 "Modules -> 刚才创建的 Clojure Module",在 Dependencies 标签下点击 “+” 选择 “JAR or Dir..” 的菜单,将需要使用的 Maven 模块 target 下的一个或多个 jar 包作为此 Module 的依赖项,之后修改 “Run - Edit Configuration..”,将刚才创建的运行配置中,Run with Leiningen 改为 Run with Intelij project classpath,启动 REPL,观察启动 REPL 的命令行,能够看到这些 jar 包作为了依赖。这样的配置下,IDEA 在 Clojure 文件中的提示会包含 Maven 模块 Java 代码的内容,且在 REPL 中也存在这些类可供交互调用。

IDEA 自动提示

上述方案美中不足的是,每次修改 Java 代码后,需要手动在其所在模块执行 mvn clean install 然后重启 REPL 才能看到效果。当然,因为 Clojure 更多是作为测试使用,因此不使用 Clojure REPL 也不是什么大事,直接 lein test 或借助于 kaocha、com.jakemccrary/lein-test-refresh 等自动化测试库、插件也能贯彻 TDD 开发模型,但对于复杂模型代码,写完后简单丢到 REPL 中试一试也是不错的 —— 对于 Clojure 这种理论上持有最高语言灵活性的工具,REPL 能保证编码者尽量不犯错,如果犯错也能很快弥补,想象一下每次 lein test 启动 JVM 执行测试前的痛苦等待,或者 lein auto-refresh 执行增量测试时的报错,将测试代码和测试测试代码的代码区分开是有必要的,前者使用 Clojure 测试单元保证正确性,而后者则由 REPL 保证。

对于缺失的依赖都可以按照上述方法让 IDEA 加入到模块的 ClassPath,比如最常见的 org.slf4j.apiorg.slf4j.simple 包。

自我管理 or IDEA 代管?

上述添加依赖的业务 jar 包和其他依赖 jar 包,通过 IDEA Module 管理的好处是添加、删除方便,但缺点是仅限于本地开发。如果需要 Universal Plan,那么可以在 lein 的配置中添加 :resource-paths ["/tmp/SiebelJars/*" "/temp/Jar1.jar"] 这种方式指定业务和其他依赖 jar 包,启动 REPL 的配置项使用 Run with Leiningen 即可,这种方案不依赖 IDEA 配置,在其他 IDE 或编辑器中都可以使用,但缺点是 Maven 项目的代码不会加入 IDEA 索引,因此没有业务代码提示。

不足之处

目前发现的不足之处包括:依赖业务 jar 包后,业务 jar 包被锁定不能删除,除非关闭 IDEA 或者将 jar 包移除出 Clojure 工程的依赖。因此这种方式不太适合单元测试 —— 需要频繁编译、打包的场合:mvn clean install

结尾的话

这样一套操作下来,测试变的不枯燥了吗?遗憾的是,测试依旧是开发过程中最无聊的环节之一,使用这套流程并没有改变这一点 —— 不过减少了痛苦的过程。

相比较大脑中对于“程序如何运行”的想法还是比“测试代码”来的迅捷,以至于敲键盘的手速还是跟不上大脑运转的速度,这一过程是痛苦的,但我想深刻的原因不止于此 —— 而是:在编写测试用例的过程中,看待程序的方式也发生了变化。

我惊讶的发现,当我想要写测试用例的时候,会根据代码逻辑来针对性的进行检验 —— 往往就是左右分屏,这种看待代码的方式是一种新的视角 —— 尽管有人会狡辩说自己单纯看自己代码来检查错误也是如此,但其实差别很大,在社会心理研究领域,有一个现象,一个人无论如何也看不出自己错在哪里,但别人一眼就能看到,中学时背诗、默写往往每个人都有这种体会。这里的问题在于动机不同,负责架构设计的设计师很清楚代码的运行原理,以至于大脑对显而易见的错误熟视无睹,只要开发者还扮演者开发者的角色,这一困境就绝无改变的可能。而作为不了解整体设计的用户或旁人,则看什么都是陌生的,就更容易从自己熟悉的角度思考 —— 对于测试而言就是数据流动的角度,因此更能够看出来问题。看函数实现的目的并不是为了做“架构师”,而是试图更新视角看一个数据如何流动,在流动过程中可能发生什么样的问题。这个过程要求开发者在角色上进行不断转换,正如同线程的频繁切换,耗费的是心智能量,每次都要重新装载“缓存”,自然感觉很累,很烦躁。

但一旦克服了这点,你将收获 —— 很多 BUG,一些我每次改的时候在想:如果让我不写测试,只告诉我代码有问题,需要修改,我看一周甚至也看不出毛病。如果这些代码提交并作为核心系统运行,那不知道会有多少隐藏的隐患。而这些隐患,对于黑盒的集成测试来说,大概率是根本测试不出来的。

更妙的是,当出现一个 Bug 后,你可以通过 Debug 定位并尝试多种修改方案,当业务代码增加、扩充特性的时候,开发者可以大幅度、随意的进行各种重构,因为每次重构,都有成百上千条断言和测试在保证着各种边界行为的安全。从这个角度讲,自动化测试是降低软件工程成长和发展的生命周期中复杂度的重要基石,没有之一。

当然,并不是每个函数都有“资格”被自动化测试“保护”,选择测试的级别和方案是一种在安全和效率之间的平衡的艺术。这需要对业务的经验以及对测试的深刻理解。