写测试时若遇到含有 HTTP 请求的代码会让人有点头疼,

第一,外部的服务可能不稳定导致我们的测试有时成功,有时失败;

第二, 外部的服务并不会按需要提供给我们想要的响应;

第三,我们需要保证测试的健壮和速度,比如说测试不会因为网络断了而无法进行,或者因为大量的 HTTP 请求而变的奇慢无比等;

我现在做的一个彩票项目里就有大量的对外 HTTP 请求,因此写测试时,我用了一种看似简单粗暴的方法来解决这一问题,就是将这些 HTTP 请求定义成可控制的方法输出, 效果很不错,当然这需要用到一种技巧, 我会在后面详细描述这种技巧。

项目中遇到的最多的 HTTP 请求是向各个彩票上游渠道投注下单, 每次下单可能会发生三种情况:

  1. 下单成功;
  2. 下单失败;
  3. 发生网络异常或者其他异常,比如timeout;

下单成功比较简单就一种情况即下单成功,下单失败的原因会有很多种,比如投注期截止,签名错误等等, 发生网络异常或者其他异常时, 我们不知道有没有在渠道生成订单,这时需要向渠道查询下单状态。我们可以看到对渠道进行一次下单其实是一个有点复杂的流程,需要用到的测试用例挺多的,比如:

  • 下单成功
  • 下单失败-投注期截止
  • 下单失败-注码格式错误
  • 下单失败-数据库插入错误
  • 下单失败-金额错误
  • 下单状态未知-渠道响应为空
  • 下单状态未知-请求timeout
  • 下单状态未知-连接拒绝

在线上运营时我们可能会发现更多的但是不常见的错误,我们需要把这些错误变成测试用例放到测试里去。为了让我们的流程能够通过测试的洗礼,在运行测试时, 我们需要把外部的 HTTP服务屏蔽掉。社区里有一些 gem 可以做这个工作,比如 Webmock, Fakeweb, VCR 等,这些 gem 的工作原理是把一些 比较底层的 http client 库的方法覆盖重写,以达到屏蔽 HTTP 请求的目的,以 Webmock 为例子来说:

stub_request(:any, "www.example.com").
  to_return(:body => "abc", :status => 200, :headers => { 'Content-Length' => 3 })

Net::HTTP.get("www.example.com", '/')    # ===> "abc"

其中 stub_request(:any, "www.example.com").to_return(:body => "abc", :status => 200, :headers => { 'Content-Length' => 3 }) 的意思是: 任何到 www.example.com 的请求都会得到响应 “abc”,

Net::HTTP.get("www.example.com", '/') 在正常情况下会去 www.example.com 做一次真实的请求,但是因为 webmock 使用了一些”诡异”的技巧,阻止了 Net::HTTP 去真实请求 www.example.com,而是直接获得响应 “abc”。 Fakeweb, VCR 等的工作原理与此类似,这种解决方案主要有两个缺点:

  1. 重写了底层的一些东西,影响过大
  2. 配置麻烦且脆弱,需要通过 url, 请求头和请求参数等去伪造一个请求

其实我们可以通过一些 mock 框架直接造出我们需要的 HTTP 响应,并忽略请求的目的 url, 请求头,请求参数,请求报文等数据,因为在大多数时候,当我们测试时, 我们最关心的是外部服务的响应结果,而不是其他的细节。ruby 社区的 mock 框架有很多,我在这里选择 rr。

在我的项目里有这样一段代码,


  def request_for_resp_msg
    @resp_msg = HcChannel::Request.new(message: message).call
  end

方法 request_for_resp_msg的作用就是向彩票渠道做一次下单请求, 按前面的分析,我们需要处理8种甚至更多种的响应:

  • 下单成功
  • 下单失败-投注期截止
  • 下单失败-注码格式错误
  • 下单失败-数据库插入错误
  • 下单失败-金额错误
  • 下单状态未知-渠道响应为空
  • 下单状态未知-请求timeout
  • 下单状态未知-连接拒绝

我们可以从 HcChannel::Request#call 方法入手, 通过使用 rr 的 any_instance_of 方法去 mock HcChannel::Request#call,

  def stub_resp_msg resp_msg
    any_instance_of HcChannel::Request do |klass|
	  stub(klass).call {resp_msg}
	end
  end

  def stub_resp_exception resp_exception
    any_instance_of HcChannel::Request do |klass|
	  stub(klass).call {resp_exception.call}
	end
  end

比如说下单成功,

  stub_resp_msg "下单成功"
  HcChannel::Request.new().call #=> "下单成功"

真实的情况下, 渠道那边并不是返回”下单成功”来表示下单成功,为了便于描述我以”下单成功”表示下单成功,后面的描述类似。

下单失败-投注期截止,

  stub_resp_msg "下单失败-投注期截止"
  HcChannel::Request.new().call #=> "下单失败-投注期截止"

下单失败-注码格式错误,

  stub_resp_msg "下单失败-注码格式错误"
  HcChannel::Request.new().call #=> "下单失败-注码格式错误"

下单状态未知-请求timeout,

  stub_resp_exception ->{ raise Timeout::Error.new("下单状态未知-请求timeout")}
  HcChannel::Request.new().call #=> raise Timeout::Error

被我们改造后的 HcChannel::Request,它的任何一个实例调用 call 方法都不会去渠道做真实的请求,而只是按我们的需要做出响应。

和 Webmock 等解决方法比较, 使用 service object + mock 去测试外部 HTTP 服务的优点是简单直接,当然有一个可能的缺点就是, 我们发现测试跑通了,但是我们可能还没有为请求真实的 HTTP 服务写过一行代码,为什么加上可能这个词呢? 因为我们肯定会把这个 HTTP 请求调通的。 不管怎样现在我们的测试达到了下面几个要求:

  1. 测试结果不受外部 HTTP 服务的影响;
  2. 执行的比较快;
  3. 覆盖了我们想要的响应状态,并且在线上运营后,可以根据实际情况方便地增加响应用例;