实战单元测试之输出数据的测试
之前讲的都是外部的输入数据,这次我们讲下输出的数据如何测试。 说到输出数据,最常见的就是邮件的发送了,当然短信,各类 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 环境的测试项目了)
在这个例子中,我们对外输出的数据有
- 收件人的 email (是买家而不是卖家)
- 订单的金额
- 订单的编号
我们可以先试一下一种测试方式(或者说风格),叫基于状态的测试(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 应该只在系统的边界才使用,如果不是的话,很可能是你的设计有问题,切记。