测试含有HTTP请求的代码
写测试时若遇到含有 HTTP 请求的代码会让人有点头疼,
第一,外部的服务可能不稳定导致我们的测试有时成功,有时失败;
第二, 外部的服务并不会按需要提供给我们想要的响应;
第三,我们需要保证测试的健壮和速度,比如说测试不会因为网络断了而无法进行,或者因为大量的 HTTP 请求而变的奇慢无比等;
我现在做的一个彩票项目里就有大量的对外 HTTP 请求,因此写测试时,我用了一种看似简单粗暴的方法来解决这一问题,就是将这些 HTTP 请求定义成可控制的方法输出, 效果很不错,当然这需要用到一种技巧, 我会在后面详细描述这种技巧。
项目中遇到的最多的 HTTP 请求是向各个彩票上游渠道投注下单, 每次下单可能会发生三种情况:
- 下单成功;
- 下单失败;
- 发生网络异常或者其他异常,比如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 等的工作原理与此类似,这种解决方案主要有两个缺点:
- 重写了底层的一些东西,影响过大
- 配置麻烦且脆弱,需要通过 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 请求调通的。 不管怎样现在我们的测试达到了下面几个要求:
- 测试结果不受外部 HTTP 服务的影响;
- 执行的比较快;
- 覆盖了我们想要的响应状态,并且在线上运营后,可以根据实际情况方便地增加响应用例;