rails Minitest

前言:说起“软件测试”四个字,很多开发人员能够想到黑盒测试、白盒测试,还有集成测试、系统测试、压力测试等,可能许多人没有想到会有单元测试。同时开发人员也会习惯性地将这些测试全部归类为测试人员的工作。绝大多数的开发人员都是忙于把手头的工作做好,按时完成公司的任务,并不会把单元测试纳入工作范畴。许多人会说,连功能开发都忙不过来了,哪有时间去做单元测试,况且还要写测试代码,那不是重复写一遍代码功能吗,实际真的是这样吗?(本文主要以介绍单元测试为主)

一、测试概念

1、测试是什么

回想我们在开发一个功能时的过程,写完某个功能的代码,大多数人经常会刷新一下页面点一下功能,或者在 Rails console 里手动调用方法来查看结果是否正确。这其实也是测试的一种表现形式(手动测试,俗称肉测)。从这点来说,测试无处不在。因为你总要验证你的功能是否正确。

2、手动测试的效率

既然“测试”这件事情是必须要做的,那我们考虑的就不是 “测不测”的问题了,而是 “怎么更有效率地测” 。测试一般发生在新的代码或代码修改之后(没有修改自然不会引入新的错误),为了保险起见,在代码修改达到一定量的时候,需要把被影响的功能全部测试一遍。

这就产生两个问题是:

1、这一定是一个非常重复的过程。
2、如何界定什么是 “被影响的功能” ?

人做重复劳动是非常低效且容易犯错的,而且在大量重复劳动下很容易草草了事,比如 “我觉得这次改动对这个功能或其他功能没什么影响,不测也行”。主观的判断并不能在技术层面上做到保证,这会产生一些潜在的 bug,成为以后开发过程的拖累。

3、自动化测试的好处

一定要做而且重复的工作,自然是交给程序来做更好。为一个功能写测试代码,第一次会花时间,但以后就可以非常快速地检查这个功能是否正确,在项目变复杂后也不用担心添加新功能或者重构会不会破坏已有的功能(这也是我们系统中经常遇到的bug类型之一)。

4、单元测试

单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。比如对一个获取正数的函数get_positive_number(),我们可以编写出以下几个测试用例:

  1. 输入正数,比如11.20.99,期待返回值与输入相同;
  2. 输入负数,比如-1-1.2-0.99,期待返回值与输入相反;
  3. 输入0,期待返回0
  4. 输入非数值类型,比如&[等,期待抛出TypeError

把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。

单元测试通过后有什么意义呢?如果我们对get_positive_number()函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对get_positive_number()函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。

二、如何测试

下面这部分将为我们直观地展示如何理解测试、如何去测试(例子出自ruby-china)。

1、理解测试

测试不是一个新概念,相反部分社区可能过度狂热,制造了太多的测试框架和库,增加了很多复杂性,以至于让人敬而远之。其实测试只是一个简单的概念,下面的例子将尝试说明这一点。

先看一个例子,假如我们需要实现一个方法sing_dance(n),要求 n是一个整数,如果n3的倍数,就返回'Sing';如果n是 5 的倍数,就返回'Dance';其余则返回n 本身。这个方法没什么实际作用,但用来做例子很合适,我们假设这个方法是某个生产应用的关键算法。

这个方法很简单,一会就能写出来:

# sing_dance.rb
def sing_dance(n)
  if n % 3 == 0
    'Sing'
  elsif n % 5 == 0
    'Dance'
  else
    n
  end
end

要验证这个方法是否正确,可以在终端执行这个方法查看结果:

> require './sing_dance.rb'
> sing_dance 1
=> 1
> sing_dance 2
=> 2
> sing_dance 3
=> "Sing"
> sing_dance 4
=> 4
> sing_dance 5
=> "Dance"

看起来没问题,于是就把这个方法用到产品环境中了……然后有一天,需求更改了,要求增加一个逻辑:如果 n 同时是 3 和 5 的倍数,就返回 SingDance。而当前的实现只会返回 Sing

> sing_dance 15
=> "Sing"

于是修改这个方法:

# sing_dance.rb
def sing_dance(n)
  if n % 3 == 0 and n % 5 == 0
    'SingDance'
  elsif n % 3 == 0
    'Sing'
  elsif n % 5 == 0
    'Dance'
  else
    n
  end
end

然后到终端调试:

> sing_dance 15
=> "SingDance"

但是这个修改有没有破坏以前的行为呢?这时候再用以前的数据调试一下:

> sing_dance 1
=> 1
> sing_dance 2
=> 2
> sing_dance 3
=> "Sing"
...

这里遇到一个问题,我们在重复以前的调试内容。重复一两次还没问题,三次以上就很烦人了。并且随着代码量上升,越来越难确定修改会影响什么地方的逻辑,容易引入bug。高效程序员会将调试代码固化下来,写成测试代码。新建一个文件,写入测试代码:

# sing_dance_test.rb
require './sing_dance.rb'
sing_dance(1) == 1 ? print('.') : raise("sing_dance 1 should be 1")
sing_dance(3) == 'Sing' ? print('.') : raise("sing_dance 3 should be Sing")
sing_dance(5) == 'Dance' ? print('.') : raise("sing_dance 5 should be Dance")
puts 'done'

这个脚本会对比程序输出和预期结果,如果结果一致就会打印一个点.,否则会抛出异常,中止测试并打印错误信息。

$ ruby sing_dance_test.rb
...done

我们可以故意把方法写错,看看有什么结果:

# sing_dance.rb
def sing_dance(n)
  n
end

再次运行,结果就是:

$ ruby sing_dance_test.rb
.sing_dance_test.rb:4:in `<main>': sing_dance 3 should be Sing (RuntimeError)

有了测试脚本的帮助,我们就能知道对代码的修改有没有破坏以前的逻辑。修改了代码之后,别忘了加上新增部分功能的测试:

sing_dance(15) == 'SingDance' ? print('.') : raise("sing_dance 15 should be SingDance")
2、assert(断言)

之前的测试代码里面有不少重复代码,例如 printraise 等等。我们可以把这些跟测试用例没有直接关系的代码抽取出通用方法,这类方法有一个惯用名称 assert,于是测试代码简化成:

def assert(test, msg = nil)
  test ? print '.' : raise(msg)
end
assert sing_dance(1) == 1, "sing_dance 1 should be 1"
assert sing_dance(3) == 'Sing', "sing_dance 3 should be Sing"
assert sing_dance(5) == 'Dance', "sing_dance 5 should be Dance"
assert sing_dance(15) == 'SingDance', "sing_dance 15 should be SingDance"
puts 'done'

测试代码多了之后,会发现有一类测试有固定的模式,例如上面的测试就是判断一个方法的输出跟另一个值是否相等,这样又可以抽取出一个 assert_equal 方法:

def assert(test, msg = nil)
  test ? print '.' : raise(msg)
end

def assert_equal(except, actual, msg = nil)
  assert(except == actual, msg)
end

assert_equal 1, sing_dance(1), "sing_dance 1 should be 1"
assert_equal 'Sing', sing_dance(3), "sing_dance 3 should be Sing"
assert_equal 'Dance', sing_dance(5), "sing_dance 5 should be Dance"
assert_equal 'SingDance', sing_dance(15), "sing_dance 15 should be SingDance"
puts 'done'

常见的assert_* 方法还有:

assert_nil(object, msg) 测试对象是否为 nil。
assert_empty(object, msg) 测试对象调用 .empty? 是否返回 true。
assert_includes(collection, object, msg) 测试集合 collection 是否包含 object。

这些方法都不过是 assert的包装,只要知道 assert 的原理,这些辅助方法都能自己实现,或者实现其他适合场景的断言方法。
现在每个主流语言都会有一个测试库,在 Ruby 中就是 Minitest。测试库除了包含一些断言方法外,还提供测试代码隔离、测试环境重置、更好的错误提示等功能。

为了项目的可维护性,也为了节约自己的时间,应该积极的拥抱测试。但也不要忘了测试只是辅助开发的工具,不要本末倒置,使用太复杂的测试工具增加维护难度。同时不要为了测试而添加不必要的代码。

三、miniTest

MinitestRails 默认使用的测试库。
下面以miniTest + mocha + factory_bot_rails 组成的测试环境来介绍如何测试。

  1. minTest: rails的测试框架
  2. factory_bot_rails: 生成测试数据
  3. mocha: 各种模拟方法
1、miniTest
测试文件路径:
$ ls test # 在项目根目录下有个test文件
factories/ # 测试数据(factory_bot_rails)
requests/ # 接口测试
models/  #模型测试
test_helper.rb #测试的默认配置文件
测试数据库配置:
# 在config/database.yml 文件中配置
test:
  database: postgres_test
  username: postgres
  password:123456
  host: localhost
  port: 5432
写第一个测试
# test/models/app/app_user_test.rb
require 'test_helper'
class App::UserTest < ActiveSupport::TestCase
  # Rails为我们提供了两种测试写法
  # 方式一
  test 'the truth' do
    assert true
  end

  # 方式二
  def test_the_truth
    assert true
  end
end

我们用App::UserTest 类定义一个测试用例(test case),它继承自 ActiveSupport::TestCase,在继承了Minitest::TestActiveSupport::TestCase 的超类)的类中定义的方法后,只要名称以 test_开头(区分大小写),就是一个可执行的“测试”。

执行测试:
bin/rails test test/models/app/user_test.rb:6 
# 1、可以加上行号6,即只执行第六行代码对应的测试;
# 2、也可指定测试方法的名称 -n test_the_truth 或者 -name test_the_truth
# 3、如果什么都不加,则执行文件user_test.rb下的所有测试用例。
执行成功的结果:
# 执行的结果输出一目了然,无任何异常
Run options: --seed 3259
# Running:
...............................................................................................................
Finished in 1.469483s, 75.5368 runs/s, 11.5687 assertions/s.
111 runs, 17 assertions, 0 failures, 0 errors, 0 skips
执行失败的结果:
Run options: --seed 16319
# Running:
..................F

Failure:
App::UserTest#test_to_s [.../test/models/app/user_test.rb:31]:
Expected "王总监" to not be equal to "王总监".

bin/rails test test/models/app/user_test.rb:30

............................................................................................

Finished in 1.498823s, 74.0581 runs/s, 11.3422 assertions/s.
111 runs, 17 assertions, 1 failures, 0 errors, 0 skips

断言部分已经在上面介绍过了, Minitest也为我们提供了多种断言,更多内容请参考Rails测试指南

2、数据工厂factory_bot
配置:
# Gemfile
group :development, :test do
  gem 'factory_bot_rails'
end

# test/test_helper.rb文件中引入
class ActiveSupport::TestCase
  include FactoryBot::Syntax::Methods
end
文件位置:

默认情况下,factory_bot_rails将自动加载 在以下位置定义的数据

factories.rb
test/factories.rb
spec/factories.rb
factories/*.rb
test/factories/*.rb # 我们在用的目录
spec/factories/*.rb

factory_bot定义数据:

# test/factories/app_user.rb
FactoryBot.define do
  factory :app_user, class: 'App::User' do
    id 10001
    name {'张三'}
    user_type: 1
  end
end

使用:

test 'user name'
  user = create(:app_user)
  assert_equal '张三', user.name
end

更多用法参考:https://github.com/thoughtbot/factory_bot/wiki

3、mocha

配置:

# Gemfile
gem 'mocha'

# test/test_helper.rb
require 'mocha/minitest'

使用场景:

# 例如有下面一个实例方法
# app/models/app/user.rb
def get_user_type
  return 1 if self.is_valid?
  return 2 if self.user_type == 2
  return 0
end

我们该如何测试呢?我们在测试方法get_user_type,但发现这个方法里面调用了另外一个实例方法。在这里,我们并不需要过多的考虑is_valid?方法是如何执行的,我们只要确保它返会truefalse即可。我们只需要考虑get_user_type方法的执行逻辑是正确的就行,而 is_valid?方法自有其对应的测试方法,不需要get_user_type对其测试,这样我们就能将方法与方法的测试隔离开,使其不会相互影响。

 # test/models/app/user_test.rb
 test 'get user type' do
    user = create(:app_user)
    user.expects(:is_valid?).returns(true)
    assert_equal 1, user.get_user_type
    ...
 end

下面说一说stubsexpectsmock的区别

我们可以将stubsexpects当做是伪造方法, mock是伪造对象。
stubs不限制它所伪造的方法的调用次数:0~n
expects限制调用次数:1 次。当被执行多次或0次都回报错。如果加上了at_least_once则多次调用时将不会再报错。

stubs(模拟调用):

在写测试的过程中,我们常常会希望某个方法返回我们希望的值,不管它如何执行的,这时可以用stubs

在下面这段代码中,我们需要测试可以成功申请支付宝退款,而实际代码中,申请支付宝退款是一个http请求,没有真实的订单号我们一定会申请失败,所以我们模拟一下它的返回。

class PayService
  def do_drawback(order)
    if apply(order)
      return order.update(state: 1)
    else
      return false
    end
  end

  def apply(order)
    RestClient.post(url, order.id) # 调用成功会返回 true
  end
end
test 'apply pay drawback success' do
  #any_instance表示该service的任意实例对象
  PayService.any_instance.stubs(:apply).returns(true)

  order = Order.first
  service = PayService.new()
  service.do_drawback
  assert_equal order.state, 1
end
expects( 期待调用)

expects模拟的类或实例方法必须调用一次,否则会报错'not all expectations were satisfied unsatisfied expectations: - expected exactly once, not yet invoked:'

某个功能在执行过程中会调用一个其他系统服务,或者某个功能会插入一个任务到异步队列。这是我们需要秉承一个原则:自己的功能自己测。即我不关心其他服务的功能是否正确,我认为只要我成功调用了就是正确的。

#一个消息队列的pusher
class Msg::Publisher
  def self.publish(key, msg = {})
    # push 消息体到队列
  end
end
class Order
  def submit
    # ...业务逻辑
     Msg::Publisher.puhlish('pay/order/submit', {xxx})
  end
end

test 'send a message if order submit success' do
  #注意,期待调用的方法一定要写在实际调用前
  #这段代码表示期待Msg::Publisher的publish方法在本测试中至少调用一次,并且第一个参数是"pay/order/submit",any_parameters表示后面的可以是任意参数
  Msg::Publisher.expects(:publish).with("pay/order/submit",any_parameters).at_least_once.returns(true)

  order = Order.new
  order.submit
  # order.submit
  #如果Msg::Publisher没有调用publish,测试结果会是失败

  # 如果未加at_least_once,却曾经调用两次order.submit,也会导致失败
  # ‘unsatisfied expectations:- expected exactly once, invoked twice’
end
mock对象

有时某个方法可能会需要一个很复杂的参数,或者某个方法返回的一个结果对象会影响剩余方法的执行,这时我们可以使用mock

def require_key_code
    if self.app_key_set && self.app_key_set.is_valid?
      return 'no'
    end
    return 'no' if self.app_user_type.in?([12,13])
    return 'yes' if self.app_user_type.in?([2,3,21])
    self.next_verify_at && self.next_verify_at > Time.now ? 'no' : 'yes'
 end

test 'require key code' do
    #创建一个Mock对象,设置它的is_valid?方法返回false
    key_set = mock()
    key_set.stubs(:is_valid?).returns(false) 

    #设置app_user查到任意实例对象调用app_key_set方法都返回mock
    App::User.any_instance.stubs(:app_key_set).returns(key_set)
    assert_equal @employee_user.require_key_code, 'yes'
    ...
    ...
end

mocha的更多内容和事例请参考:https://github.com/freerange/mocha/

四、总结:

对于测试覆盖率问题,不要尝试去达到100%
单元测试可以有效地测试某个程序模块的行为;
单元测试的测试用例要覆盖基本逻辑、边界条件和异常;
单元测试代码要非常简单,如果测试代码太复杂,那么测试代码本身就可能有bug。
(欢迎补充、提问)
参考:
https://ruby-china.github.io/rails-guides/testing.html
https://github.com/freerange/mocha/
https://github.com/thoughtbot/factory_bot_rails
https://github.com/thoughtbot/factory_bot
https://chloerei.com/2015/10/26/testing-guide/
https://ruby-china.org/