返回 登录
0

如何让测试变得有趣和容易

原文:How We Made Writing Tests Fun and Easy
作者:Maciek Głowacki
译者:Teixeira10

【译者注】本文中,作者讲述了如何利用在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的端点描述。或者,可以想出一种方法,提取一些共同的逻辑,然后进行分享。

评论