新手区 聊聊如何写单元测试

JiCheng · 2016年11月22日 · 最后由 Jwong 回复于 2016年11月22日 · 2282 次阅读

这篇文章主要讨论一下几点问题

  • 如何开始写一个单元测试
  • 单元测试有我自己的一些实践

这篇文章的假设为你明确自己要写单元测试了,如果您不符合这个假设,可以参看这篇文章 先解决思想:为何要写单元测试

当打算开始写单元测试时。你调整了下坐姿,气运丹田,感觉到冥冥之中又向高质量代码迈进了一步,但当你的手下意识的敲击键盘的时候,又觉得似乎哪里不太对劲:“恩,应该怎样开始写一个单元测试呢?”

如何写单元测试

首先我们需要明确,什么叫做单元测试。

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。 程序单元是应用的最小可测试部件。

我的理解是:测试某个具体的函数,是否符合编写者的预期。

其实也很好理解,就是将你编写某个函数的功能与你的预期做一个比较,如果函数运行的结果与你的预期相符,则说明测试通过,反之则失败。

举个很简单的例子

class Person
  attr_accessor :name, :gender

  def initialize(name, gender)
    self.name = name
    self.gender = gender
  end

  def say_hello
      puts "#{self.title} #{name} said hello."
  end

  def title
     self.gender == "male" ? "Mr" : "Ms"
  end
end

我想测试一下 title 这个函数是否符合我预期,于是我会这样写测试 (假设使用 Rails 原生的 test 框架)

require 'test_helper'

class PersonTest < ActiveSupport::TestCase
  test "should return title correctly" do
    person = Person.new("Ji Cheng", "Male")
    assert_equal "Mr", person.title

    person = Person.new "Han Meimei", "Female"
    assert_equal "Ms", person.title
  end
end

语言不同,测试框架不同都会导致代码不同,但是思想都是一样的,都是去 assert 一个值,与运行后的函数值保持一致。

也许有人会说你这个函数太简单了,简直不用看就知道会发生什么,为什么还要写个测试? 其实想想,写出这个测试也花多长时间,更重要的是,你在写这个测试的时候,会更加清楚这个函数输入与输出,是否满足预期,以及多一次使用自己写的函数的机会,体谅下调用你写的函数的人。

细心的朋友肯定已经看出来了,这个测试是会报错的,如果你真没看出来,就更加说明单元测试的重要性。实际情况中太多函数是看不出来的,但是例如下面的 analysis_message

# encoding: utf-8

class JobStepService
  attr_accessor :project, :job, :flow

  # Some functions ...

  class << self
    def analysis_message(hash)
      job_id = hash[:job_id]
      index = hash[:index]
      job_step = JobStep.find_by(job_id: job_id, index: index)
      return if job_step.nil? || job_step.status == "stopped"

      try_mark_last_job_status(job_id, index)
      where = JobStep.where(job_id: job_id, index: index)
      safe_update_job_hash(where, hash)
      job_step.reload
    end

    private

    def try_mark_last_job_status(job_id, index)
      return if index.to_i.zero?
      # 必须得找到, 不找到肯定是哪里出错了,应该抛异常
      JobStep.find_by(job_id: job_id, index: index.to_i - 1).update_attribute(:status, "success")
    end
  end
end

当不是那么容易看出的时候,去写一个单元测试是跟你在命令端调试所花的时间是差不多的。


class JobStepTest < ActiveSupport::TestCase
  setup do
    # do some initialize work...
  end
  test "could analysis message correctly" do
    JobStepService.new(@job).generate_job_steps
    assert_equal false, JobStep.count == 1
    hash = { index: 0, status: "success", return_value: 0, log: "hello world\n", job_id: @job.id.to_s, category: "step" }
    JobStepService.analysis_message hash
    assert_equal "success", JobStep.asc(:index).first.status
    assert_equal "pending", JobStep.asc(:index).last.status

    hash = { index: 2, status: "failure", return_value: 0, log: "hello world\n", job_id: @job.id.to_s, category: "step" }
    JobStepService.analysis_message hash
    assert_equal "success", JobStep.asc(:index).to_a[1].status
    assert_equal "failure", JobStep.asc(:index).to_a[2].status
  end

想必大家也看出来了,测试甚至有些随意不太友好,但是至少在跑了这段测试之后,我很信任之前写的函数是没有问题的(就算有,也不会是那些会被同事耻笑的低级错误)。

写单元测试一些实践

大前提

所有的实践前面都有一个大前提:首先你得写单元测试。我非常喜欢写一些显而易见的单元测试当做休息放松,当别人问我为什么写这种测试的时我通常是以 “增加代码测试覆盖率” 来忽悠他们。(但是实际上还是有 30% 左右的概率会测出各种问题,包含各种语法错误,误触某回调等奇怪的错误校验不过,也许我就是一个粗心的人),这样做还有另外一个好处,培养自己对每个方法都写测试的习惯:连很简单的方法都写了,那稍微复杂点的,简直不能忍。

谁来写

开发来写。单测主要测试的是具体的函数,没有比开发人员更熟悉自己写的函数了,同时本着 “吃自己的狗粮” 的原则,也可以反省下自己设计的函数是否合理。最重要的,当自己写完一个的时候,就可以把单元测试当做自己手动调试代码,这样就可以很自然的无缝的将单测写上,而不用等测试人员排队做。

关于测试覆盖率

虽然这个东西听起来很虚,但我觉得是个必需品,必须得上。当有一个标准去衡量自己的工作进度的时候,潜意识中大家会努力的提高这个指标。同时绝大多数测试覆盖率统计工具,都能通过界面显示出你函数中未覆盖的逻辑,避免自己漏测。我自己使用simplecov 这个 gem 来统计我自己的 Rails 项目的测试覆盖。

写的测试跑着要快

我非常赞同,写的测试越慢,由于人的惰性,会导致自己因为不想等太久而不跑测试。测试写的再多,不跑全是白搭。

其实一个单元测试的内容很少,那么一般慢是慢在哪里呢?

我觉得有以下方面

  1. IO
  2. sleep/wait 语句
  3. 数据库的大量写入

关于 IO
目前我遇见比较多的是关于网络的 IO, 有些第三方组件会接入网络,这种一般都会带来 500ms 左右的延时,运气不好连国外(比如我们的项目连 github API)没准就会变成假摔(一定概率的跑出错,非必现的错误)。常见的操作是 Stub 解决问题,各大语言都有很成熟的解决方案。比如我现在使用的 webmock 这个 gem ,当然以 ruby 这种 “开放式” 语言的能力,就算不引入任何 gem,写个猴子补丁也会非常的轻松。

关于 sleep/ wait
大多数使用 sleep/ wait 的时候都是在等待某个异步方法的执行完成,我的处理方式是将异步的处理以及等待后面的语句都抽成两个独立的函数,分别测试这两个函数,从而避免走 sleep 这种慢的操作

关于数据库
很多测试相关的文章和书籍都强调 数据库太慢了,所以不能使用数据库。我不太认同,因为其实很多时候写的代码都需要依赖数据库的一些特性,或者离开数据库而存在内存中会很麻烦(比如查询语句,脱离数据库 mock 个 where 很麻烦)。我的策略(当然是 Rails 的策略)是使用专门用于测试的数据库,每当运行一个单侧的时候就会把它清除掉。这样,测试数据库的数据会非常的少,查询、新增起来大多数情况下其实也在 20ms 以内。

我是非常反对当一个单元测试跑完后,不清除数据库的,可能这些数据会影响到其他单元测试,进一步造成了测试的假摔,假摔是大忌,应该尽量避免。当然清数据库也不是绝对的,需要自己灵活判别,比如下面的情况。

我运行测试之前会生成 100 条左右的模板数据,这些数据是我在进行单元测试的时候绝对不会操作的,所以没必要每次执行一个单元测试删除再新建。但是为了防止我自己有时候没想清楚改掉模板数据从而有可能造成假摔,所以我在执行每个单元测试之前会判断下这些模板数据的行数是否是我最初的生成的行数。

单元测试不是万能的

会有人觉得我花了那么大的功夫,覆盖率 90% 了,上 jenkins 或者 flow.ci 了,那我的程序就很稳定了。这种观念当然是不对的,就如同你买了一把 200 块的锁就指望自己的自行车永远不会被偷一样。良好的单元测试会极大的提高程序稳定性,但是不会百分百的保证程序一定 ok,毕竟人无完人。

从入门到放弃?

相信很多朋友其实也写过单测,但或因需求变更过快,或因一次次的失败无力解决,导致了最终没有坚持下来。这当中其实是有一定技巧的,使用良好的技巧会在保证测试覆盖率的同时,降低测试失败的频率。下次就来说说 如何使用一些技巧,让我们容易坚持执行这个应该坚持的单元测试

最后,有兴趣的朋友可以关注一下 “持续集成慢慢来” 这个公众号与我交流,如果对持续集成感兴趣,也可以试试我司基于 SaaS 的持续集成产品 flow.ci。

共收到 3 条回复 时间 点赞

fir.im 的同学?

是的啦

4楼 已删除
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册