实战单元测试之输出数据的测试

之前讲的都是外部的输入数据,这次我们讲下输出的数据如何测试。 说到输出数据,最常见的就是邮件的发送了,当然短信,各类 IM 的接口同理。 一般这类接收我们发出的数据的平台都有自己的 SDK, 比如邮件发送平台 SendGrid, 使用它的接口示例代码如下:

require 'sendgrid-ruby'
include SendGrid

from = SendGrid::Email.new(email: '[email protected]')
to = SendGrid::Email.new(email: '[email protected]')
subject = 'Sending with Twilio SendGrid is Fun'
content = SendGrid::Content.new(type: 'text/plain', value: 'and easy to do anywhere, even with Ruby')
mail = SendGrid::Mail.new(from, subject, to, content)

sg = SendGrid::API.new(api_key: ENV['SENDGRID_API_KEY'])
response = sg.client.mail._('send').post(request_body: mail.to_json)

肯定会有新手会写出这样的代码:

def create_order(params)
  # ......
  if order.save
    # ...
    to = order.buyer.email
    # ...
    response = sg.client.mail._('send').post(request_body: {amount: order.amount, number: order.number})
    # ...
  end
end

当然,稍有经验的程序员都应该知道,拿到一个 SDK, 第一件事就是把它包起来。

另外,这和接下来要讲的 Mock 也有关,关于 Mock 的一项准则就是,只 mock 你自己的类,对第三方的类进行 Mock 很容易出问题,何况真要这样做也很方法,所以不管怎么说,都应该把 SDK 封装一下。

class MyMailer

  # use send grid api
  def send(email, params)
    # ...
    to = email
    # ...
    response = sg.client.mail._('send').post(request_body: {params})
    # ...
  end
end

SDK 本身的接口只代表它自己的能力,我们的代码应该表达的是意图,比如通知买家去完成付款:

def create_order(params)
  if order.save
    notify_buyer_to_finish_payment(order)
  end
end

private
def notify_buyer_to_finish_payment(order)
  MyMailer.new.send(order.buyer.email, {amount: order.amount, number: order.number})
end

这样一来,SendGrid 这个依赖已经从代码中被封装起来了。

哦,对了,我们是来讲测试的,那么怎么测试这个邮件发送的功能呢?

首先,测试私有方法是 anti pattern, 那我们可以测试 create_order 方法,这样可以覆盖到私有方法。 但是仅仅是覆盖到了而已,邮件发送的正确性如何判断? 或者我们测试 MyMailer 类,可它就是一个 SKD 的包装而已,测试它的功能与业务的正确性无关。

那怎么办呢?看来在具体的实现(SendGrid 的 SDK)和意图(发给谁邮件,邮件内容是什么)之间还应该有一个类(职责):

class OrderNotifier
  attr_reader :order

  def initialize(order)
    @order = order
  end

  def notify_buyer_to_finish_payment
    MyMailer.new.send(order.buyer.email, {amount: order.amount, number: order.number})
  end
end

对于之前的代码,要测试邮件发送的功能是比较困难的,因为耦合太严重了,像现在这样明确了抽取为一个类后,终于可以比较方便的测试了,那么怎么测试呢?

首先,我们要明确,我们测试的是什么?我们只能测输出到外部的数据是否与预期一致。(真的检查发出的邮件如何那是 staging 环境的测试项目了)

在这个例子中,我们对外输出的数据有

  1. 收件人的 email (是买家而不是卖家)
  2. 订单的金额
  3. 订单的编号

我们可以先试一下一种测试方式(或者说风格),叫基于状态的测试(state based testing ),顺便说下,另外还有基于输出和基于交互的测试这两种测试风格。

不过我们要稍微修改一下代码:

class OrderNotifier
  attr_reader :email, :amount, :number

  def initialize(order)
    @email =  order.buyer.email
    @amount = order.amount
    @number = order.number
  end

  def notify_buyer_to_finish_payment
    MyMailer.new.send(email, {amount: amount, number: number})
  end
end

这样一来,我们可以通过 OrderNotifier 实例的字段来测试输出的数据了。

test "notify_buyer_to_finish_payment" do
  order = orders(:one)
  order_notifier = OrderNotifier.new(order)
  assert_equal "[email protected]", order_notifier.email
  assert_equal 100, order_notifier.amount
  assert_equal "123456", order_notifier.number
end

当然,这样做肯定也是不完美的。首先,没有真正测试到发送通知的方法,尽管我们可以看到发送方法中只有对这三个属性的引用,几乎不会有 bug, 但是这样的测试是脆弱的, 面对邮件内容的修改,这样的测试是发现不了问题的,何况,为了这个测试,我们把这三个属性暴露了出来,而这本来是不必要的,这样一来面向对象的封装就被破坏了,得不偿失。

可是,不真的调用发送方法,就不能真的测试发送方法啊。。。由于会产生未知副作用,所以输出的数据不能真的被输出。这里就是 Mock 发挥作用的时候了。

首先,我们要使用依赖注入的手法来修改一下 OrderNotifier 类:

class OrderNotifier
  attr_reader :order, :mailer

  def initialize(order, mailer = MyMailer.new)
    @order = order
    @mailer = mailer
  end

  def notify_buyer_to_finish_payment
    mailer.send(order.buyer.email, {amount: order.amount, number: order.number})
  end
end

可以看到,我们把发送邮件的工具类作为一个依赖通过初始化方法注入到了 OrderNotifier 类中。还使用了参数的默认值,把影响缩到了最小。

现在就可以这样写测试方法了:

test "send correct email" do
  order = orders(:one)
  mock_mailer = MiniTest::Mock.new
  mock_mailer.expect(:send, nil, ["[email protected]", 100, '123456'])

  OrderNotifier.new(order, mock_mailer).notify_buyer_to_finish_payment

  mock_mailer.verify
end

可见,Mock 是很强大的工具,但是千万不要乱用 mock, 当需要使用 mock 的时候,要多想想,这个地方一定要使用 mock 吗?

Mock 应该只在系统的边界才使用,如果不是的话,很可能是你的设计有问题,切记。