引用
【译者注】本文中,作者讲述了如何利用在ApiRequest类来让测试变得有趣和容易,同时提供了大量的代码示例供读者阅读和参考。
以下为译文:
测试,你可能会喜欢它,你也可能讨厌它,但是你应该同意好的测试代码对你和你的团队是有用的,甚至将来可能对执行你项目的合作者都是有益的。测试可能不是你工作中最令人兴奋的部分,但它确实非常有用。在重构和创建新特性时,经过测试的代码会让你感到很安心。
还是,如果这些测试代码不是你写的呢?你确定这些涵盖了所有事情吗?它们真的测试了什么或者只是模拟了整个应用程序吗?所以你还得花时间确保现有的测试是有用的,并且写得很好。
如果在项目中没有测试规则,那么就应该用如下所说的方式。
创建一些规则
现在你可能想提出关于测试的规则。是的,那就这样做吧!
从现在开始,让我们编写良好的测试代码并实现100%的代码覆盖率。
然后将这个想法传递给团队的其他成员,也让他们执行起来。
但这会奏效吗?你可能会得到一堆“测试代码”,这些“测试代码”拼拼凑凑,这样就可以在工作量少的情况下获得高覆盖率。那么“好的测试代码例”部分呢?谁会知道这是什么意思呢。我打赌你也对这样的结果不满意。所以让我们做出一些改变吧!
但是你真的知道这种方法有什么问题吗?首先,它并没有使编写代码变得更快或更简单。实际上,它的情况恰恰相反——至少编写两倍代码。如果你让别人写测试代码,他们很可能会这么做,但你觉得他们会用心去做吗?
开发人员需要的是奖励,而不是惩罚
既然惩罚不是好方法,那就试试奖励吧。如果写测试代码能立即得到奖励呢?如果没有额外的工作,如何生成一个API文档呢?如果你问我,那我觉得这是很好的,而这个特殊的“奖励”正是开始写更好的测试代码所需要的。
(别误会我的意思,好的测试代码本身就很好,而且从长远来看,总会有回报的。然而,一些即时的满足感可以成为一种真正的提高效率的助推器,特别是当你做一些琐碎的事情时)
在这一点上,你必须做出选择。
你可以继续阅读,来发现测试可以变得多么有趣,或者你可以直接跳到一个示例应用程序(但是你可能会错过很多)。
那么你选择阅读了吗?非常好!那么,如果你有一石二鸟的想法,那就来看看怎样才能让编写测试代码变得更容易。既然已经使用了 rspec,那就让 rspec_api_documentation gem使事情变得更简单。根据说明将其添加到你的项目中,这样你就可以创建第一个测试工程:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
with_options scope: :post do
parameter :title, 'Title of a post. Can be empty'
parameter :body, 'Main text of a post. Must be longer than 10 letters', required: true
end
response_field :id, 'Id of the created post'
response_field :title, 'Title of the created post'
response_field :body, 'Main text of the created post'
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
example_request 'Creating a post' do
explanation 'You can create a post by sending its body text and an optional title'
expect(status).to eq 201
response = JSON.parse(response_body)
expect(response['title']).to eq title
expect(response['body']).to eq body
end
end
end
来看看这段测试代码,你就能明白这个应用程序能做什么了。可以立即看到参数是什么,响应是什么,应该发送什么消息头。但在运行rakerake docs:generate之后,它会变得更好:生成并等待测试完成,同时你会得到以下的结果:
这是不是又快又容易呢?现在,如果想在这个文档中添加更多的例子,就必须继续为它编写测试代码。这可以覆盖所有3个情况:
现在,测试开始变得有用了。那我们是否遇到过中意外地中断了创建的帖子?不用担心——会有一个测试来负责这个问题。也许已经禁用了一些看起来没有必要的验证,但实际上不是这样的,由于需要文档来获取无效的参数,因此也会有一个测试。
刚刚解决了一个测试问题,所以现在它比以前更有趣了,并产生了一些即时可见的东西。但测试既不容易写更不容易写好。
测试的丑陋一面
我们有一个API允许创建帖子。如果用户可以选择在Twitter或Facebook上发布这些帖子,难道不是很好吗?听起来棒极了!但每次运行测试时,我们都不希望碰到第三方API,对吧?与此同时,检查是否会有一个请求会更好。
听起来像
webmock可以做的事情。我们将它添加到Gemfile并按照指令安装。从现在起,不能在测试中与网络进行连接,同时必须明确地告诉webmock,我们将会提出一个特定的请求,并记住要用rspec来设置一个期望值:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... Same as previously ...
before do
@request = stub_request(:post, 'https://api.twitter.com/1.1/statuses/update.json')
.with(body: { status: body })
.to_return(status: 200, body: { id: 1 }.to_json)
end
example_request 'Creating a post' do
# ... Same as previously ...
expect(@request).to have_been_requested
end
end
end
这看起来不太糟,但这只是看一个人写的一个测试,如果让10个人写同样的测试,可能会得到10种不同的解决方案。如果有人想要快速地越过stubbing,甚至不检查发送的参数,那该怎么办呢?如果别人忘了检查是否发出了请求怎么办?有些东西可能会被破坏,没有人会知道,直到为时已晚。
似乎又回到了起点——必须确保其他人的测试按照预期的方式运行。但是,如何确保所有人都以同样的方式编写测试呢?
让测试更容易
问题是,编写糟糕的测试比编写好的测试要容易得多。当可以用更少的工作量来“让它变得更环保”的时候,这就是为什么人们会在意这些请求,并设定良好的期望结果。毕竟,它们将有一个passing测试和一个生成的文档。
必须在某种程度上超越懒惰的开发人员,并让编写好的测试代码比编写糟糕的测试代码更容易。如果能给他们一个不错的写测试的方法,而实际上却没有他们写测试的感觉呢?嗯,也许吧。但这是不可能的。
这里的想法是创建某种内部
DSL(特定于领域的语言)来描述测试用例,不希望它过于花哨——只是提取常见测试逻辑的简单方法。并且我们还希望是一些已经熟悉rspec的人,因为将围绕现有的语法来构建它。
提取公共逻辑听起来像是一个共享示例的任务。创建shared_examples_for_api_request并将其初始化,来描述端点:
它看起来是这样的:
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example_request name do
explanation explanation
# ... Do some stuff here later ...
end
end
要使用这个,只需要调用:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
with_options scope: :post do
parameter :title, 'Title of a post. Can be empty'
parameter :body, 'Main text of a post. Must be longer than 10 letters', required: true
end
response_field :id, 'Id of the created post'
response_field :title, 'Title of the created post'
response_field :body, 'Main text of the created post'
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
现在可以开始研究最有趣的部分了。我们自己的DSL。
自己动手
我们的目标是创建一个对象,用于自动设置stub和测试的期望值。应该从一个新的类开始:
class ApiRequest
def initialize
end
end
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... Same as before ...
subject do
ApiRequest.new
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
现在共享示例中有了rspec,但它还没有真正起作用。首先要检查的是请求是否成功。你知道如何在ApiRequest上通过调用.success或.failure来指定它呢?
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... Same as before ...
subject do
ApiRequest.new.success
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
这些只是ApiRequest的方法,它可以改变它的内部状态来指定预期的响应代码。它们应该返回正在处理的对象,这样就可以在以后处理更多的东西:
class ApiRequest
attr_reader :status
def initialize
end
def success(code = 200)
@status = code
self
end
def failure(code = 422)
@status = code
self
end
end
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example name do
explanation explanation
do_request
expect(status).to eq(subject.status)
end
end
它现在开始变得有用了,但是仅仅检查状态代码是不够的,也需要检查一下响应代码。
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... Same as before ...
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
subject do
ApiRequest.test.success(201)
.response(:id, title: title, body: body)
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
现在,是实施的时候了。使用.test对初始化对象进行测试和.new一样简单。但在使用.response的时候必须记住,希望它接受关键字和键值对,而且必须把它们分开存储,因为它们将以不同的方式进行测试:
class ApiRequest
attr_reader :status,
:response_keys,
:response_spec
def initialize
@response_keys = []
@response_spec = {}
end
def self.test
new
end
def response(*extra_keys, **extra_spec)
@response_keys += extra_keys.map(&:to_sym)
@response_spec.merge!(extra_spec)
self
end
# ... Other methods written previously ...
end
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example name do
explanation explanation
do_request
expect(status).to eq(subject.status)
res = JSON.parse(response_body).deep_symbolize_keys
expect(res).to include(*subject.response_keys)
subject.response_spec.each do |k, v|
expect(res[k]).to eq(v), "Expected #{k} to equal '#{v}', but got '#{res[k]}'"
end
end
end
现在已经有了一些可靠的基础来测试请求。但在请求之后检查某个对象的状态通常是很必要的。然而,这可以与测试不同,因此不能将其描述为DSL的一部分。但是可以通过一些这样的定制测试来进行:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... Same as before ...
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
subject do
ApiRequest.test.success(201)
.response(:id, title: title, body: body)
.and do
expect(Post.count).to eq(1)
post = Post.last
expect(post.title).to eq(title)
expect(post.body).to eq(body)
end
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
在这里传递一个块,你可能会猜到实现的样子:
class ApiRequest
attr_reader :status,
:response_keys,
:response_spec,
:specs
def initialize
@response_keys = []
@response_spec = {}
@specs = proc {}
end
def and(&specs)
@specs = specs
self
end
# ... The rest stays unchanged ...
end
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example name do
res = JSON.parse(response_body).deep_symbolize_keys
# ... We only add this line ...
instance_exec(res, &subject.specs)
end
end
DSL已经开始看起来相当不错了,甚至还没有实现它的关键特性。现在为请求stubbing做准备,因为它会变得更加困难。
让我们掷重炮
在深入到stubbing API调用之前,还有一件事应该看看。假设除了在Twitter和Facebook上发布消息之外,应用程序还发送了一封电子邮件(不知道是发给谁,可能是CIA)。这听起来像是在验收测试中应该处理的事情。
假设要检查在创建新post之后是否发送通知电子邮件,我建议这样做:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... params and stuff ...
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
subject do
ApiRequest.test.success(201)
.response(:id, title: title, body: body)
.email.to('notify@cia.gov').with(subject: 'New Post published', body: body)
.and do
# ... Same as before ...
end
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
如果.to 和 .with不属于ApiRequest本身,.email应该创建其他类的对象,这与正在处理的请求绑定在一起。可以把它看作是一种ApiRequest的方法,来描述Mail。听起来合理吗?来看看代码:
class ApiRequest
attr_reader :status,
:response_keys,
:response_spec,
:specs,
:messages
def initialize
@response_keys = []
@response_spec = {}
@specs = proc {}
@messages = []
end
def email
@messages
收尾工作
用于生成文档的gem允许在单个示例中生成一些请求,但是我们的实现仅限于其中一个。为了解决这个问题,可以接受一个api请求数组,而不是一个实例。为了保持与现有代码的兼容性,将把主题包装在数组中(如果已经是数组,它将不会做任何事情):
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example name do
explanation explanation
Array.wrap(subject).each do |request|
ActionMailer::Base.deliveries = []
# ... previous test stuff goes here ...
# ... just remember to use request instead of subject ...
request.stubs.each do |stub|
expect(stub.data).to have_been_requested.at_least_once
WebMock::StubRegistry.instance.remove_request_stub(stub.data)
end
end
end
end
可以在一个例子中执行很多请求,但是它还不能很好地使用。所以必须添加一种方法来轻松地覆盖一些参数。让我们为ApiRequest类添加最后一个方法:
class ApiRequest
attr_reader :status,
:response_keys,
:response_spec,
:specs,
:messages,
:stubs,
:params
def initialize
@response_keys = []
@response_spec = {}
@specs = proc {}
@messages = []
@stubs = []
@params = {}
end
def with(params)
@params = params
self
end
# ... The rest stays the same ...
end
shared_examples 'api_requests' do |name, explanation|
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
example name do
explanation explanation
Array.wrap(subject).each do |request|
# ... Other stuff happening here ...
do_request(request.params)
# ... And here ...
end
end
end
有了这些,现在可以在每个示例中执行多个请求:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
# ... same as before ...
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
subject do
requests = []
requests << # ... previous "success" subject here
requests << ApiRequest.test.failure
.with(post: { body: 'Too short' })
.response(body: ['002'])
.and do
expect(Post.count).to eq(1)
end
requests
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
最好的方法是,在文档中立即有它们(注意前面的长图)
终于完成了。刚刚创建了一个很容易使用的DSL,它让我们能够改变这个长期且容易出错的测试:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
with_options scope: :post do
parameter :title, 'Title of a post. Can be empty'
parameter :body, 'Main text of a post. Must be longer than 10 letters', required: true
end
response_field :id, 'Id of the created post'
response_field :title, 'Title of the created post'
response_field :body, 'Main text of the created post'
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
before do
@twitter_request = stub_request(:post, 'https://api.twitter.com/1.1/statuses/update.json')
.with(body: { status: body })
.to_return(status: 200, body: { id: 1 }.to_json)
@facebook_request = stub_request(:post, 'https://graph.facebook.com/me/feed')
.with(body: hash_including(:access_token,
:appsecret_proof,
message: body))
.to_return(status: 200, body: { id: 1 }.to_json)
end
example 'Creating a post' do
explanation 'You can create a post by sending its body text and an optional title'
do_request
expect(status).to eq 201
response = JSON.parse(response_body)
expect(response.keys).to include 'id'
expect(response['title']).to eq title
expect(response['body']).to eq body
expect(Post.count).to eq(1)
post = Post.last
expect(post.title).to eq(title)
expect(post.body).to eq(body)
expect(ActionMailer::Base.deliveries.count).to eq(1)
email = ActionMailer::Base.deliveries.last
expect(email.to).to include 'notify@cia.gov'
expect(email.subject).to include 'New Post published'
expect(email.body).to include body
expect(@twitter_request).to have_been_requested
expect(@facebook_request).to have_been_requested
end
end
end
更易读和更容易使用的形式:
require 'acceptance_helper'
resource 'Posts' do
explanation 'Posts are entities holding some text information.
They can be created and seen by anyone'
post '/posts' do
with_options scope: :post do
parameter :title, 'Title of a post. Can be empty'
parameter :body, 'Main text of a post. Must be longer than 10 letters', required: true
end
response_field :id, 'Id of the created post'
response_field :title, 'Title of the created post'
response_field :body, 'Main text of the created post'
let(:title) { 'Foo' }
let(:body) { 'Lorem ipsum dolor sit amet' }
subject do
ApiRequest.test.success(201)
.response(:id, title: title, body: body)
.email.to('notify@cia.gov').with(
subject: 'New Post published',
body: body)
.request.twitter.with(status: body).status_update.success
.request.facebook.with(message: body).put_wall_post.success
.and do
expect(Post.count).to eq(1)
post = Post.last
expect(post.title).to eq(title)
expect(post.body).to eq(body)
end
end
include_examples 'api_requests',
'Creating a post',
'You can create a post by sending its body text and an optional title'
end
end
现在使用ApiRequest比手工编写测试更快,这样就可以很容易地说服团队的其他人使用它来进行验收测试。因此,现在可以得到值得信任的测试,以及API文档。目标实现了!
最后的话
在ApiRequest类的帮助下,可以在几分钟内编写新的端点测试,可以很容易地指定业务需求,因此进一步的开发也变得更容易。但请记住,这些只是验收测试。你仍然应该对代码进行单元测试,以捕获任何实现的错误。
为了向你展示如何在实际应用程序中使用这个方法,我已经准备了一个GitHub存储库,它具有一个完整的非常基本的用例。自己试一下:
https://github.com/Bombasarkadian/testifier
就这篇文章而言:让测试易于编写,同时保持高水平的可用性,当然这里还有很多事情可以做。例如,可以部分地生成基于stub的端点描述。或者,可以想出一种方法,提取一些共同的逻辑,然后进行分享。