测试 测试驱动开发在项目中的实践

lanzhiheng · December 06, 2020 · Last by line933200 replied at January 09, 2021 · 1557 hits
Topic has been selected as the excellent topic by the admin.

好久没有动笔写文章了,今天来写点什么。这篇文章主要简单谈谈最近把测试驱动开发应用在公司项目中的心得体会。原文链接: https://www.lanzhiheng.com/posts/tdd-in-our-project


好久没有动笔写文章了,今天来写点什么。这篇文章主要简单谈谈最近把测试驱动开发应用在公司项目中的心得体会。虽说主要技术栈是 Ruby 一系,但相信对其他的领域也有一定的参考价值吧。

前言

近期一直都在给公司的新产品添砖加瓦,眼看着第一版即将发布,也稍微能喘口气写点儿什么。第一次独自用 Ruby On Rails 编写项目代码,内心多少有一点忐忑。与在豆厂时期不同,周边没有许多已成规范的东西,技术选型上也没那么多理所当然,这一切得从 0 开始。

这其中各有各的好,有大量前辈的经验可以借鉴,以及可复用的代码,在某种程度上能够让自己少走些弯路,降低技术决策的成本。但如果没能站在巨人的肩膀上,那就只能自己去做些尝试,看看什么才是适合自己当前项目的,自由度会稍微高一些。多少让我想起《解忧杂货店》中的剧情,一张白纸,也意味着可以随意涂鸦。为了保证代码质量,也降低组员们介入测试时的工作量,我决定给项目引入测试驱动的开发方式。

个把月过去,不知不觉已经累计了大概有 500 多个测试用例,总体效果还是比较满意的:

500-test.png

如何写测试

编写测试大概有以下两个比较极端的场景:

1. 自顶向下

从开发者的层面指的其实就是端对端的测试。我们可以通过代码来模拟浏览器的行为,借此测试已有的业务逻辑。这种方式的好处在于能够模拟真实用户,一些繁琐的点击操作完全由机器来取代,降低日后回归测试的成本。然而这类测试往往编写难度较大,“杀鸡用牛刀” - 大概就是这种感觉。想象一下假设你要用这种方法来测试接口,那么你可能需要:

(用代码的方式)打开浏览器 --> 在浏览器输入接口的URL --> 点击确认 --> 等待页面加载 --> 检查页面内容(或者返回的数据)是否符合预期

看到这里也许有人会问:“等等,为什么我测试个接口要打开浏览器?”。对啊,为什么我们要打开浏览器?简单点不好吗?

2. 自底向上

直接从终端视角来编写测试“性价比”不高,很多时候你的网站还没活到能见到“终端”的阶段就已经夭折,而在这之前测试几乎不能给开发过程带来任何收益。或许可以换个角度,从最底层写起。也就是我们常说的单元测试。这种测试编写起来十分简便,熟练编写不仅能加快开发的速度,还能保证交付代码的质量。不过许多人刚开始写这种测试很容易就会迷失,因为自己也抓不准哪一个模块才是最有测试价值的,于是他们就想朝着测试覆盖率百分百的目标前进。最终的结果就是身心俱疲。

考虑下面这个代码

class A
  def end_method
    return_a + return_b + return_c + return_d
  end

  private

  def return_a
    'a'
  end

  def return_b
    'b'
  end

  def return_c
    'c'
  end

  def return_d
    'd'
  end
end

完美主义者会给每一个底层方法都加上测试,最后大概会是这样子:

RSpec.describe do
  describe 'method' do
    let(:instance) { A.new }

    it 'test size private methods' do
      expect(instance.send(:return_a)).to eq('a')
      expect(instance.send(:return_b)).to eq('b')
      expect(instance.send(:return_c)).to eq('c')
      expect(instance.send(:return_d)).to eq('d')
    end

    it 'test public method' do
      expect(instance.end_method).to eq('abcd')
    end
  end
end

其实在务实主义者眼中测试只需要

RSpec.describe do
  describe 'method' do
    let(:instance) { A.new }

    it 'test public method' do
      expect(instance.end_method).to eq('abcd')
    end
  end
end

把公有的方法测试好足矣,有些时候甚至公有方法都不需要写测试(尺度还需要开发者自己把控)。测试覆盖率 100% 除了能够带来围观群众们的惊叹,并不能带来任何实质的收益。相反,不必要的测试太多,还会带来更高的维护成本,试想如果你给一个简单的方法编写了十几个用例,试问谁敢去碰那个方法?并不是这个方法本身有多难大家不敢去改,而是没有人愿意同步去调整那十几个测试用例。

既然两种极端都不可取,那么我就采取一个折中的方案。接下来我想分享我自己在项目中所采用的测试策略。说不定能够给予那些迟迟不敢写测试的同学一点勇气吧?

测试策略

介绍策略之前先来简单说说项目背景。我司目前的项目主要是小程序,也就是说我后端这边需要做的东西有两个

  1. 日常运营所需的后台管理系统。
  2. 小程序应用所需要的接口。

技术栈为 Ruby On Rails。以下方略大多以 Ruby 为背景,不过大体策略应该都是通用的。

1. 有选择地去测试

当一个项目日趋庞大之后,构建项目所需要的类会慢慢增多,相应的,他们所包含的方法也越来越多。如果不加分辨地对所有方法加以测试覆盖,那么不但会给自己造成心理负担,还会占用掉编写更有价值测试的时间。减少对不必要测试的编写,也就是为那些真正重要的事情腾出时间。从长远来看那个执行复杂运算的方法,出错的几率更大,带来的损失可能也更直接。反之,像下面这种测试又有多大必要呢?

def return_true
  true
end

RSpec.describe do
 it 'test true' do
   expect(return_true).to be(true)
 end
end

对于类中的私有方法,除非真的十分复杂,否则个人不建议为他们编写测试。从实用的角度来看,私有方法往往是为了被类中的其他方法调用而存在的,他们的最终归宿就是服务于公有方法。那么换个角度想,公有方法在某种程度上其实就是私有方法的测试用例。与其累死累活地为每一个私有方法编写测试,还不如给那些暴露出去的公有方法多测试几组边界情况。

如果在许多场景下公有方法都能够得到正确的结果,那么服务于他的私有方法一般也不会错哪里去。哪怕真的错了,到时候再给那个出错的方法补测试即可,保证下次不犯同样的错误。也就是我接下来要说的,错误导向测试。

2. 错误导向测试

第一次写测试,很容易会陷入到“不知道应该测试什么的窘境”。接下来,为了让自己看起来很充实,我们会不加分辨地给所有方法加上单元测试,以求测试覆盖率 100%,这就是所谓的“战略上的懒惰,战术上的勤奋”吧?如果你是在一个通过代码行数来定工资的公司工作,这种做法无疑是明智的。然而如果项目在快速迭中,公司吃了上顿不知道有没有下顿,那咱们还是别这样做的好。

我推荐错误导向的测试。也就是说对一些自己有把握的方法,在一开始可以不用给他们加测试。然而这个方法一旦暴雷,那么修 bug 的时候就要给它加上测试,避免它下一次再暴雷(可参考《程序员修炼之道》一书对测试的阐述)。笔者也用这种策略补了很多测试代码。比如:

  • 前端跟我说接口漏了几个字段?马上编写测试保证接口会返回相应的字段。
  • 接口接收参数的时候没有做空判断,导致传入空值的时候会报 500 的异常?立马编写参数为空值的测试用例,然后修改代码让这个测试通过。
  • 数据库出现了跟预期不一致的数据?在测试中构造这种奇葩的数据,尝试入库,预期入库失败。然后调整校验代码,让测试通过。

这种针对系统薄弱的地方来编写测试的策略,编写起来更省力,所带来的效益也更高,最起码能做到孔子说的“不贰过”吧?同样,那些选择了测试后行的同学我也建议采用这种方式去补测试,这样更有针对性,毕竟每天都有 bug 要修,修的 bug 多了,有价值的测试也越来越多。

3. 尽可能少针对页面布局写测试

相对于后端的业务逻辑,其实前端的页面结构更改的的几率会大很多。假设你针对一个页面写了大量的断言(rspec-html-matchers了解一下)

RSpec.describe do
  it do
    expect('<p class="qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => 'qwe rty' })
    expect('<p class="qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => 'rty qwe' })
    expect('<p class="qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => ['rty', 'qwe'] })
    expect('<p class="qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => ['qwe', 'rty'] })
  end
end

上面的案例,不仅仅检测了元素的存在,还把类名都匹配出来。我感觉rspec-html-matchers的维护者用这些代码来做例子可能只是为了展示这个库的能力,应该不会希望使用者在项目里面这样去写测试。不然的话要是刚好要改个类名,我得调整多少测试?这种代码在笔者看来也是不健壮的。想想我要删掉一个按钮都要去改一下测试是多难受的事情。

页面布局,按钮有无这种场景,除非自己真的很闲,否则就别给他们写测试了。多一个少一个按钮对系统来说并不会造成太大的影响,要去维护这堆测试却特别累人。还不如参考 2 的建议,等到出了真正的 bug 的时候再针对他们来写一个测试?比如在订单的某个状态下应该显示 XX 按钮,这次我漏了,那么修复 bug 的时候就给这种场景加上测试,保证下次不出错即可。

4. 给所有接口都加上测试用例

接口的种类有很多,但目前最基础的无非就是 CRUD。我们要么就是通过接口来获取后台的信息,要么就是通过接口来修改后台的数据。那么接口的测试过程或许可以简化成:

  1. 组装请求链接。
  2. 利用客户端发送相关的请求。
  3. 检查返回的状态码,如果有必要再检查返回的数据正确性。
  4. 检查请求所造成的副作用。

对接口的测试,我采用的框架是rswag。它很好的贯彻了,测试即文档这一理念。用它来写测试还能顺便生成 Swagger 接口文档,真的不能更棒了。比如我要测试获取博客详情数据的接口,那么可以这样去写

describe 'Blogs API' do
  path '/blogs/{id}' do
    get 'Retrieves a blog' do
      tags 'Blogs'
      produces 'application/json'
      parameter name: :id, in: :path, type: :string

      response '200', 'blog found' do
        let(:id) { Blog.create(title: 'foo', content: 'bar').id }
        run_test! do |response|
          # Check the field of result
        end
      end

      response 404', 'blog not found' do
        let(:id) { 'fake-id' }
        run_test!
      end
    end
  end
end

代码做的事情很简单,就是测试/blogs/{id}这个模式的 URL,发送 GET 请求的场景。可以在请求发送之前通过RSpec的语法设置资源的id,当运行run_test!的时候框架会自动帮你发送请求。如上面所期待的,我们期望找到资源的时候得到状态码200,而找不到资源的时候接收404状态码。

如果需要进一步检测请求的响应情况,则可以在run_test!后面加上代码块,并解构response。这种测试的组装方式一开始看起来是很奇葩,我第一感觉就是嵌套层数太多了。后来写习惯了就好,也慢慢理解它为何这样做。不过这个框架给我的感觉就是文档用例有点少,不少资源/参数的定义方式都要去参考Swagger。这些大家用到的时候再慢慢去探索吧。

目前我用这种方式给我司的项目提供了大概 55 个接口,bug 率比想象中少。而且关键的是,在开发接口的过程中,几乎没有打开过浏览器,都是直接写测试,写业务,跑测试。不得不说其实工作效率要比开浏览器逐个场景去校验高效得多。

5. 测试最好不要后行

我们经常会说:“等有空再去补测试吧。”然而,多年的工作经验告诉我,补测试这个事情,其实是下下策。下面对比以下两个流程

测试先行:想想怎么写测试 -> 写测试 -> 写代码 -> 跑测试 -> 完工 测试后行:研究已有代码 -> 想想怎么写测试 -> 写测试 -> 跑测试 -> 调整测试以适应代码 -> 完工

研究已有的代码,意味着你要重新去审阅过去的代码,不管这段代码是不是自己写的,要重新理解并补上测试都是需要时间的。而且往往如果测试不通过,我们并不敢第一时间去改代码,因为我们害怕弄坏原来已经跑着正常的业务代码,多数情况下就是不断改测试,来“适应”已有的代码。不折腾几番之后根本就不知道,到底是原来的代码有问题,还是自己写的测试有问题。然而这些都是测试后行的时间成本。

个人感觉,除非是客户要求,或者钱给够,否则测试后行真的很难。而且过程极其痛苦。如果你真的有写测试的打算,还不如一开始就把用例给加上。

推荐技术

1. 数据构造

一开始项目比较简单的时候我是直接采用了 Rails 自带的fixture功能,也就是测试开始之前先把必要的数据入库,然后依赖这些数据来写测试。但后面发现,这种方式虽然测试运行的速度很可观,但是依赖性太强,写起来不是特别方便。笔者更倾向于跑测试的时候针对特定用例去构造数据,个人推荐用factory_bot。虽然这样测试运行起来要慢一些,但是写起来就很舒服了(似乎有点像动态语言跟静态语言之争)。这一个月大概写了 550 个测试用例,全面跑完花费时间大概是 5-7 分钟,还有很大的优化空间。

2. 测试框架

单纯地测试 API 的话其实 Rails 自带的测试套件就能够满足要求。本质上我们只需要

  1. 组装 api
  2. 发送请求
  3. 检测响应状态码,还有响应体。

不需要太多花里胡哨的功能,我之所以后面用RSpec,主要是因为我还要给前端小伙子提供 Swagger 的 API 文档,而刚好它的仓库rswag又是依赖了 RSpec,所以就干脆整个项目都换成 RSpec 了。然而它并非什么必需品,RubyChina 的Homeland项目至今都没有引入 RSpec,测试写起来也没什么毛病。

3. CI 服务

个人觉得更好的开发流程肯定还是有人相互 Review 代码。代码推送到托管平台之后有 CI 服务来帮忙跑测试,测试不过的一律不予合并。合并之后自动部署到测试环境。写了测试之后才觉得 CI 是如此重要,起码它能帮你把测试跟部署的工作都放到云端。你自己的机子就专门用于开发,并测试那些关键的模块就好,并不需要跑全局测试。

我们的项目托管在 Github 上,是采用了CircleCI来跑测试,你也可以跟社区一样采用Travis来跑。不过个人觉得有时间的话尝试自建或许更好一些。笔者本来也以为 CircleCI 的免费版都够我用了,无奈随着用例越来越多,CircleCI 所提供的每周信用额度在周三就消耗殆尽(周日清 0),导致后面几天只能在本地去跑测试了,后面会尝试使用自建的 CI 服务。

结语

以上就是近期尝试测试驱动开发的心得体会。反正目前看来还是觉得挺不错的,希望能够坚持下去。很多人会担心加了测试之后会降低开发的速度,其实从个人的使用角度来看倒还好。特别是编写接口的时候,不用打开浏览器就几乎能完成所有接口的开发,而且测试用例就能帮我检测一些字段的正确性(规避了肉眼检测的疏忽),许多重构代码时的暴雷都能很好地帮我检测出来。

虽说前期学习成本有点大,不过坚持一段时间之后好处渐渐显现出来了,Bug 率也明显降低。建议有条件的同学可以在项目中试试,我觉得它的坏处大概就是熟悉了这一套流程之后,会养成习惯,一旦不写测试都不知道怎么写代码了。

Rei mark as excellent topic. 06 Dec 12:54

这个测试

RSpec.describe do
  describe 'method' do
    let(:instance) { A.new }

    it 'test public method' do
      expect(instance.end_method).to eq('abcd')
    end
  end
end

class A
  def end_method
    return_a + return_b + return_c + return_d
  end

  private

  def return_a
    'a'
  end

  def return_b
    'b'
  end

  def return_c
    'c'
  end

  def return_d
    'd'
  end
end

代码覆盖率也是 100%,广义的覆盖率是指测试执行以后多少比率的被测代码被执行过。

另外我更推荐用 Rails 内置的测试框架,一是因为 RSpec 语法太花哨,增加新手学习成本,学会了也没什么好处;二是 Rails 跟内置的测试框架配合更好,rspec-rails 现在还不支持并行跑测试,Rails 多数据库的测试 RSpec 也需要打 patch 才能跑。

我支持提交的代码需要被测试覆盖,但是不一定要测试驱动,我更习惯先写代码再写测试,写代码的时候也能顺便理清思路。而且测试作用除了保证现有代码符合预期,也能增加日后重构/做改动的人的信心,对有年头的项目尤其重要。

rswag 是挺好用,最近写的项目用了。但是为了用它,不得不换成 rspec, 基于 minitest 的好像都不太成熟。

Reply to dawei

我回头也有打算用它试试。

Reply to gonglexin

是啊,minitest 好像还没有对应的东西。基于 Grape 倒是有见过。 https://github.com/ruby-grape/grape-swagger。其实单纯 api 的话应该可以 grape + minitest。

感谢分享,总结的很棒。

500 多个测试用例就 7min 多,感觉有点慢了。大一点的项目的用例很容易就上 1000 的,那就是 15min 以上。

可以试试用之前我推荐的 Test-Prof gem 跑一下分析,看看是否存在 Factory Cascade 的情况,优化一下。

可以参考我的有关博客:Ruby 测试的“工厂疗法”

Reply to apexy

是的,有打算找你之前的文章来看,我记得有个性能分析器。找时间要优化一下。 😂 感谢提醒。

Reply to apexy

我的数据肯定也构造得比较粗糙。项目比较赶就一直没去优化。部分跑测试倒是还能勉强忍。往后就不行了。

请问在测试第三方 gem 包的表现时,有没有什么好的技巧,是只测它的表现是否符合期望,还是另外执行 gem 自带的 test 呢?

You need to Sign in before reply, if you don't have an account, please Sign up first.