简介

榜样很重要。

— 警官亚历克斯·J·墨菲 / 机器战警

本 RSpec 风格指南概述了现实世界程序员编写可供其他现实世界程序员维护的代码的推荐最佳实践。

RuboCop,一个静态代码分析器(linter)和格式化程序,有一个 rubocop-rspec 扩展,提供了一种方式来强制执行本指南中概述的规则。

注意
本指南假设您使用的是 RSpec 3 或更高版本。

您可以使用 AsciiDoctor PDF 生成本指南的 PDF 版本,以及使用 AsciiDoctor 生成 HTML 版本 with,使用以下命令

# Generates README.pdf
asciidoctor-pdf -a allow-uri-read README.adoc

# Generates README.html
asciidoctor README.adoc
提示

安装 rouge gem 以在生成的文档中获得漂亮的语法高亮。

gem install rouge

如何阅读本指南

本指南根据整个规范文件的不同部分进行划分。我们试图省略所有显而易见的信息,如果任何内容不清楚,请随时打开一个问题以寻求进一步的说明。

活文档

正如上面的评论所说,本指南仍在不断完善 - 一些规则只是缺乏全面的示例,但 RSpec 世界中的一些东西每周或每月都在变化。也就是说,随着标准的改变,本指南也应该随之改变。

布局

示例组内的空行

不要在 featurecontextdescribe 描述之后留空行。这不会使代码更易读,并降低了逻辑块的价值。

# bad
describe Article do

  describe '#summary' do

    context 'when there is a summary' do

      it 'returns the summary' do
        # ...
      end
    end
  end
end

# good
describe Article do
  describe '#summary' do
    context 'when there is a summary' do
      it 'returns the summary' do
        # ...
      end
    end
  end
end

示例组之间的空行

featurecontextdescribe 块之间留一个空行。不要在组中最后一个此类块之后留空行。

# bad
describe Article do
  describe '#summary' do
    context 'when there is a summary' do
      # ...
    end
    context 'when there is no summary' do
      # ...
    end

  end
  describe '#comments' do
    # ...
  end
end

# good
describe Article do
  describe '#summary' do
    context 'when there is a summary' do
      # ...
    end

    context 'when there is no summary' do
      # ...
    end
  end

  describe '#comments' do
    # ...
  end
end

let 之后的空行

letsubjectbefore/after 块之后留一个空行。

# bad
describe Article do
  subject { FactoryBot.create(:some_article) }
  describe '#summary' do
    # ...
  end
end

# good
describe Article do
  subject { FactoryBot.create(:some_article) }

  describe '#summary' do
    # ...
  end
end

Let 分组

仅将 letsubject 块分组,并将它们与 before/after 块分开。这使得代码更易读。

# bad
describe Article do
  subject { FactoryBot.create(:some_article) }
  let(:user) { FactoryBot.create(:user) }
  before do
    # ...
  end
  after do
    # ...
  end
  describe '#summary' do
    # ...
  end
end

# good
describe Article do
  subject { FactoryBot.create(:some_article) }
  let(:user) { FactoryBot.create(:user) }

  before do
    # ...
  end

  after do
    # ...
  end

  describe '#summary' do
    # ...
  end
end

示例周围的空行

it/specify 块周围留一个空行。这有助于将期望与它们的条件逻辑(例如上下文)分开。

# bad
describe '#summary' do
  let(:item) { double('something') }

  it 'returns the summary' do
    # ...
  end
  it 'does something else' do
    # ...
  end
  it 'does another thing' do
    # ...
  end
end

# good
describe '#summary' do
  let(:item) { double('something') }

  it 'returns the summary' do
    # ...
  end

  it 'does something else' do
    # ...
  end

  it 'does another thing' do
    # ...
  end
end

示例组结构

前导 subject

当使用 subject 时,它应该是示例组中的第一个声明。

# bad
describe Article do
  before do
    # ...
  end

  let(:user) { FactoryBot.create(:user) }
  subject { FactoryBot.create(:some_article) }

  describe '#summary' do
    # ...
  end
end

# good
describe Article do
  subject { FactoryBot.create(:some_article) }
  let(:user) { FactoryBot.create(:user) }

  before do
    # ...
  end

  describe '#summary' do
    # ...
  end
end

声明 subjectlet!/letbefore/after 钩子

声明 subjectlet!/letbefore/after 钩子时,应按以下顺序进行

  • subject

  • let!/let

  • before/after

# bad
describe Article do
  before do
    # ...
  end

  after do
    # ...
  end

  let(:user) { FactoryBot.create(:user) }
  subject { FactoryBot.create(:some_article) }

  describe '#summary' do
    # ...
  end
end

# good
describe Article do
  subject { FactoryBot.create(:some_article) }
  let(:user) { FactoryBot.create(:user) }

  before do
    # ...
  end

  after do
    # ...
  end

  describe '#summary' do
    # ...
  end
end

使用上下文

使用上下文使测试清晰、井井有条且易于阅读。

# bad
it 'has 200 status code if logged in' do
  expect(response).to respond_with 200
end

it 'has 401 status code if not logged in' do
  expect(response).to respond_with 401
end

# good
context 'when logged in' do
  it { is_expected.to respond_with 200 }
end

context 'when logged out' do
  it { is_expected.to respond_with 401 }
end

上下文案例

context 块几乎总是应该有一个相反的负面案例。如果只有一个上下文(没有匹配的负面案例),则这是一个代码异味,并且此代码需要重构,或者可能没有目的。

# bad - needs refactoring
describe '#attributes' do
  context 'the returned hash' do
    it 'includes the display name' do
      # ...
    end

    it 'includes the creation time' do
      # ...
    end
  end
end

# bad - the negative case needs to be tested, but isn't
describe '#attributes' do
  context 'when display name is present' do
    before do
      article.display_name = 'something'
    end

    it 'includes the display name' do
      # ...
    end
  end
end

# good
describe '#attributes' do
  subject(:attributes) { article.attributes }
  let(:article) { FactoryBot.create(:article) }

  context 'when display name is present' do
    before do
      article.display_name = 'something'
    end

    it { is_expected.to include(display_name: article.display_name) }
  end

  context 'when display name is not present' do
    before do
      article.display_name = nil
    end

    it { is_expected.not_to include(:display_name) }
  end
end

let

letlet! 用于在示例组中的多个示例中使用的數據。使用 let! 定义变量,即使它们在某些示例中没有被引用,例如在测试平衡负面案例时。不要过度使用 let 来定义原始数据,在使用频率和定义复杂度之间找到平衡。

# bad
it 'finds shortest path' do
  tree = Tree.new(1 => 2, 2 => 3, 2 => 6, 3 => 4, 4 => 5, 5 => 6)
  expect(dijkstra.shortest_path(tree, from: 1, to: 6)).to eq([1, 2, 6])
end

it 'finds longest path' do
  tree = Tree.new(1 => 2, 2 => 3, 2 => 6, 3 => 4, 4 => 5, 5 => 6)
  expect(dijkstra.longest_path(tree, from: 1, to: 6)).to eq([1, 2, 3, 4, 5, 6])
end

# good
let(:tree) { Tree.new(1 => 2, 2 => 3, 2 => 6, 3 => 4, 4 => 5, 5 => 6) }

it 'finds shortest path' do
  expect(dijkstra.shortest_path(tree, from: 1, to: 6)).to eq([1, 2, 6])
end

it 'finds longest path' do
  expect(dijkstra.longest_path(tree, from: 1, to: 6)).to eq([1, 2, 3, 4, 5, 6])
end

实例变量

使用 let 定义而不是实例变量。

# bad
before { @name = 'John Wayne' }

it 'reverses a name' do
  expect(reverser.reverse(@name)).to eq('enyaW nhoJ')
end

# good
let(:name) { 'John Wayne' }

it 'reverses a name' do
  expect(reverser.reverse(name)).to eq('enyaW nhoJ')
end

共享示例

使用共享示例来减少代码重复。

# bad
describe 'GET /articles' do
  let(:article) { FactoryBot.create(:article, owner: owner) }

  before { page.driver.get '/articles' }

  context 'when user is the owner' do
    let(:user) { owner }

    it 'shows all owned articles' do
      expect(page.status_code).to be(200)
      contains_resource resource
    end
  end

  context 'when user is an admin' do
    let(:user) { FactoryBot.create(:user, :admin) }

    it 'shows all resources' do
      expect(page.status_code).to be(200)
      contains_resource resource
    end
  end
end

# good
describe 'GET /articles' do
  let(:article) { FactoryBot.create(:article, owner: owner) }

  before { page.driver.get '/articles' }

  shared_examples 'shows articles' do
    it 'shows all related articles' do
      expect(page.status_code).to be(200)
      contains_resource resource
    end
  end

  context 'when user is the owner' do
    let(:user) { owner }

    include_examples 'shows articles'
  end

  context 'when user is an admin' do
    let(:user) { FactoryBot.create(:user, :admin) }

    include_examples 'shows articles'
  end
end

# good
describe 'GET /devices' do
  let(:resource) { FactoryBot.create(:device, created_from: user) }

  it_behaves_like 'a listable resource'
  it_behaves_like 'a paginable resource'
  it_behaves_like 'a searchable resource'
  it_behaves_like 'a filterable list'
end

冗余的 before(:each)

不要为 before/after/around 块指定 :each/:example 范围,因为这是默认值。明确指定范围时,优先使用 :example

# bad
describe '#summary' do
  before(:example) do
    # ...
  end

  # ...
end

# good
describe '#summary' do
  before do
    # ...
  end

  # ...
end

不明确的 Hook 范围

before/after 钩子中使用 :context 代替不明确的 :all 范围。

# bad
describe '#summary' do
  before(:all) do
    # ...
  end

  # ...
end

# good
describe '#summary' do
  before(:context) do
    # ...
  end

  # ...
end

避免使用 :context 范围的 Hook

避免使用 :context 范围的 before/after。注意示例之间状态泄漏的问题。

示例结构

每个示例一个预期值

对于示例,两种风格都被认为是可以接受的。第一种变体是为每个预期值设置单独的示例,这会导致重复的上下文初始化成本。第二种变体是每个示例包含多个预期值,并为组或示例设置 aggregate_failures 标签。在每种情况下,请根据您的判断做出最佳选择,并始终如一地应用您的策略。

# good - one expectation per example
describe ArticlesController do
  #...

  describe 'GET new' do
    it 'assigns a new article' do
      get :new
      expect(assigns[:article]).to be_a(Article)
    end

    it 'renders the new article template' do
      get :new
      expect(response).to render_template :new
    end
  end
end

# good - multiple expectations with aggregated failures
describe ArticlesController do
  #...

  describe 'GET new', :aggregate_failures do
    it 'assigns new article and renders the new article template' do
      get :new
      expect(assigns[:article]).to be_a(Article)
      expect(response).to render_template :new
    end
  end

  # ...
end

主题

当多个测试与同一个主题相关时,使用 subject 来减少重复。

# bad
it { expect(hero.equipment).to be_heavy }
it { expect(hero.equipment).to include 'sword' }

# good
subject(:equipment) { hero.equipment }

it { expect(equipment).to be_heavy }
it { expect(equipment).to include 'sword' }

命名主题

尽可能使用命名 subject。仅当您在任何测试中都没有引用它时才使用匿名主题声明,例如,当使用 is_expected 时。

# bad
describe Article do
  subject { FactoryBot.create(:article) }

  it 'is not published on creation' do
    expect(subject).not_to be_published
  end
end

# good
describe Article do
  subject { FactoryBot.create(:article) }

  it 'is not published on creation' do
    is_expected.not_to be_published
  end
end

# even better
describe Article do
  subject(:article) { FactoryBot.create(:article) }

  it 'is not published on creation' do
    expect(article).not_to be_published
  end
end

上下文中的主题命名

当您在不同的上下文中使用不同的属性重新分配主题时,请为主题命名不同的名称,以便更容易地了解实际主题代表什么。

# bad
describe Article do
  context 'when there is an author' do
    subject(:article) { FactoryBot.create(:article, author: user) }

    it 'shows other articles by the same author' do
      expect(article.related_stories).to include(story1, story2)
    end
  end

  context 'when the author is anonymous' do
    subject(:article) { FactoryBot.create(:article, author: nil) }

    it 'matches stories by title' do
      expect(article.related_stories).to include(story3, story4)
    end
  end
end

# good
describe Article do
  context 'when article has an author' do
    subject(:article) { FactoryBot.create(:article, author: user) }

    it 'shows other articles by the same author' do
      expect(article.related_stories).to include(story1, story2)
    end
  end

  context 'when the author is anonymous' do
    subject(:guest_article) { FactoryBot.create(:article, author: nil) }

    it 'matches stories by title' do
      expect(guest_article.related_stories).to include(story3, story4)
    end
  end
end

不要模拟主题

不要模拟被测对象的的方法,这是一种代码异味,通常表明对象的自身设计存在问题。

# bad
describe 'Article' do
  subject(:article) { Article.new }

  it 'indicates that the author is unknown' do
    allow(article).to receive(:author).and_return(nil)
    expect(article.description).to include('by an unknown author')
  end
end

# good - with correct subject initialization
describe 'Article' do
  subject(:article) { Article.new(author: nil) }

  it 'indicates that the author is unknown' do
    expect(article.description).to include('by an unknown author')
  end
end

# good - with better object design
describe 'Article' do
  subject(:presenter) { ArticlePresenter.new(article) }
  let(:article) { Article.new }

  it 'indicates that the author is unknown' do
    allow(article).to receive(:author).and_return(nil)
    expect(presenter.description).to include('by an unknown author')
  end
end

itspecify

如果示例没有描述,请使用 specify,如果示例有描述,请使用 it。单行示例是一个例外,在这种情况下,it 更可取。当文档字符串不能很好地从 it 中读取时,specify 也很有用。

# bad
it do
  # ...
end

specify 'it sends an email' do
  # ...
end

specify { is_expected.to be_truthy }

it '#do_something is deprecated' do
  ...
end

# good
specify do
  # ...
end

it 'sends an email' do
  # ...
end

it { is_expected.to be_truthy }

specify '#do_something is deprecated' do
  ...
end

it 在迭代器中

不要编写迭代器来生成测试。当另一个开发人员向迭代中的某个项目添加功能时,他们必须将其分解为单独的测试 - 他们被迫编辑与他们的拉取请求无关的代码。

# bad
[:new, :show, :index].each do |action|
  it 'returns 200' do
    get action
    expect(response).to be_ok
  end
end

# good - more verbose, but better for the future development
describe 'GET new' do
  it 'returns 200' do
    get :new
    expect(response).to be_ok
  end
end

describe 'GET show' do
  it 'returns 200' do
    get :show
    expect(response).to be_ok
  end
end

describe 'GET index' do
  it 'returns 200' do
    get :index
    expect(response).to be_ok
  end
end

偶然状态

尽可能避免偶然状态。

# bad
it 'publishes the article' do
  article.publish

  # Creating another shared Article test object above would cause this
  # test to break
  expect(Article.count).to eq(2)
end

# good
it 'publishes the article' do
  expect { article.publish }.to change(Article, :count).by(1)
end

DRY

小心不要过早地将重复的期望移动到共享环境中,以专注于“DRY”,因为这会导致过于依赖彼此的脆弱测试。

通常,最好从直接在您的 it 块中执行所有操作开始,即使是重复操作,然后在测试正常工作后重构您的测试,使其更 DRY。但是,请记住,测试套件中的重复并不令人反感,事实上,如果它能提供更轻松的理解和阅读测试,它是首选的。

工厂

使用 Factory Bot 在集成测试中创建测试数据。您应该很少需要在集成规范中使用 ModelName.create。不要使用夹具,因为它们不像工厂那样易于维护。

# bad
subject(:article) do
  Article.create(
    title: 'Piccolina',
    author: 'John Archer',
    published_at: '17 August 2172',
    approved: true
  )
end

# good
subject(:article) { FactoryBot.create(:article) }
注意
在谈论单元测试时,最佳实践是不使用夹具或工厂。将尽可能多的域逻辑放在库中,这些库可以在没有工厂或夹具的复杂、耗时的设置的情况下进行测试。

所需数据

不要加载超过测试代码所需的数据。

# good
RSpec.describe User do
  describe ".top" do
    subject { described_class.top(2) }

    before { FactoryBot.create_list(:user, 3) }

    it { is_expected.to have(2).items }
  end
end

双重

优先使用验证双重而不是普通双重。

验证双重是普通双重的更严格的替代方案,它提供保证,例如,如果正在存根无效方法或方法被调用时参数数量无效,则会触发失败。

通常,将双重与更隔离/行为测试一起使用,而不是与集成测试一起使用。

注意
没有理由关闭 verify_partial_doubles 配置选项。这将大大降低对部分双重的信心。
# good - verifying instance double
article = instance_double('Article')
allow(article).to receive(:author).and_return(nil)

presenter = described_class.new(article)
expect(presenter.title).to include('by an unknown author')


# good - verifying object double
article = object_double(Article.new, valid?: true)
expect(article.save).to be true


# good - verifying partial double
allow(Article).to receive(:find).with(5).and_return(article)


# good - verifying class double
notifier = class_double('Notifier')
expect(notifier).to receive(:notify).with('suspended as')
注意
如果您存根了一个可能导致假阳性测试结果的方法,那么您走得太远了。

处理时间

始终使用 Timecop,而不是存根 Time 或 Date 上的任何内容。

describe InvoiceReminder do
  subject(:time_with_offset) { described_class.new.get_offset_time }

  # bad
  it 'offsets the time 2 days into the future' do
    current_time = Time.now
    allow(Time).to receive(:now).and_return(current_time)
    expect(time_with_offset).to eq(current_time + 2.days)
  end

  # good
  it 'offsets the time 2 days into the future' do
    Timecop.freeze(Time.now) do
      expect(time_with_offset).to eq 2.days.from_now
    end
  end
end

存根 HTTP 请求

当代码正在发出 HTTP 请求时,存根 HTTP 请求。避免访问真实的外部服务。

分别使用 webmockVCR一起使用

# good
context 'with unauthorized access' do
  let(:uri) { 'http://api.lelylan.com/types' }

  before { stub_request(:get, uri).to_return(status: 401, body: fixture('401.json')) }

  it 'returns access denied' do
    page.driver.get uri
    expect(page).to have_content 'Access denied'
  end
end

声明常量

不要在示例组中显式声明类、模块或常量。 改为存根常量

注意
常量(包括类和模块)在块作用域中声明时,是在全局命名空间中定义的,并在示例之间泄漏。
# bad
describe SomeClass do
  CONSTANT_HERE = 'I leak into global namespace'
end

# good
describe SomeClass do
  before do
    stub_const('CONSTANT_HERE', 'I only exist during this example')
  end
end

# bad
describe SomeClass do
  class FooClass < described_class
    def double_that
      some_base_method * 2
    end
  end

  it { expect(FooClass.new.double_that).to eq(4) }
end

# good - anonymous class, no constant needs to be defined
describe SomeClass do
  let(:foo_class) do
    Class.new(described_class) do
      def double_that
        some_base_method * 2
      end
    end
  end

  it { expect(foo_class.new.double_that).to eq(4) }
end

# good - constant is stubbed
describe SomeClass do
  before do
    foo_class = Class.new(described_class) do
                  def do_something
                  end
                end
    stub_const('FooClass', foo_class)
  end

  it { expect(FooClass.new.double_that).to eq(4) }
end

隐式块期望

避免使用隐式块期望。

# bad
subject { -> { do_something } }
it { is_expected.to change(something).to(new_value) }

# good
it 'changes something to a new value' do
  expect { do_something }.to change(something).to(new_value)
end

命名

上下文描述

上下文描述应该描述所有示例共享的条件。完整的示例名称(由所有嵌套块描述的串联形成)应该形成一个可读的句子。

典型的描述将是一个附加短语,以“when”、“with”、“without”或类似词语开头。

# bad - 'Summary user logged in no display name shows a placeholder'
describe 'Summary' do
 context 'user logged in' do
   context 'no display name' do
     it 'shows a placeholder' do
     end
   end
 end
end

# good - 'Summary when the user is logged in when the display name is blank shows a placeholder'
describe 'Summary' do
 context 'when the user is logged in' do
   context 'when the display name is blank' do
     it 'shows a placeholder' do
     end
   end
 end
end

示例描述

it/specify 块描述不应该以条件结尾。这是一个代码异味,表明it很可能需要包装在context中。

# bad
it 'returns the display name if it is present' do
  # ...
end

# good
context 'when display name is present' do
  it 'returns the display name' do
    # ...
  end
end

# This encourages the addition of negative test cases that might have
# been overlooked
context 'when display name is not present' do
  it 'returns nil' do
    # ...
  end
end

保持示例描述简短

保持示例描述小于 60 个字符。

编写自文档化的示例,并生成正确的文档格式输出。

# bad
it 'rewrites "should not return something" as "does not return something"' do
  # ...
end

# good
it 'rewrites "should not return something"' do
  expect(rewrite('should not return something')).to
    eq 'does not return something'
end

# good - self-documenting
specify do
  expect(rewrite('should not return something')).to
    eq 'does not return something'
end

示例文档字符串中的“Should”

不要在示例文档字符串的开头写“should”或“should not”。描述代表实际功能,而不是可能发生的事情。使用第三人称现在时。

# bad
it 'should return the summary' do
  # ...
end

# good
it 'returns the summary' do
  # ...
end

描述方法

清楚地说明您要描述的方法。使用 Ruby 文档约定,在引用类方法名称时使用.,在引用实例方法名称时使用#

# bad
describe 'the authenticate method for User' do
  # ...
end

describe 'if the user is an admin' do
  # ...
end

# good
describe '.authenticate' do
  # ...
end

describe '#admin?' do
  # ...
end

使用expect

始终使用较新的expect语法。

配置 RSpec 仅接受新的expect语法。

# bad
it 'creates a resource' do
  response.should respond_with_content_type(:json)
end

# good
it 'creates a resource' do
  expect(response).to respond_with_content_type(:json)
end

匹配器

谓词匹配器

尽可能使用 RSpec 的谓词匹配器方法。

describe Article do
  subject(:article) { FactoryBot.create(:article) }

  # bad
  it 'is published' do
    expect(article.published?).to be true
  end

  # good
  it 'is published' do
    expect(article).to be_published
  end

  # even better
  it { is_expected.to be_published }
end

内置匹配器

使用内置匹配器。

# bad
it 'includes a title' do
  expect(article.title.include?('a lengthy title')).to be true
end

# good
it 'includes a title' do
  expect(article.title).to include 'a lengthy title'
end

be 匹配器

避免在没有参数的情况下使用 be 匹配器。它过于通用,因为它会通过所有不是 nilfalse 的内容。如果这是确切的意图,请使用 be_truthy。在所有其他情况下,最好指定期望值是什么。

# bad
it 'has author' do
  expect(article.author).to be
end

# good
it 'has author' do
  expect(article.author).to be_truthy # same as the original
  expect(article.author).not_to be_nil # `be` is often used to check for non-nil value
  expect(article.author).to be_an(Author) # explicit check for the type of the value
end

将常见的期望部分提取到匹配器中

将示例中经常使用的通用逻辑提取到 自定义匹配器 中。

# bad
it 'returns JSON with temperature in Celsius' do
  json = JSON.parse(response.body).with_indifferent_access
  expect(json[:celsius]).to eq 30
end

it 'returns JSON with temperature in Fahrenheit' do
  json = JSON.parse(response.body).with_indifferent_access
  expect(json[:fahrenheit]).to eq 86
end

# good
it 'returns JSON with temperature in Celsius' do
  expect(response).to include_json(celsius: 30)
end

it 'returns JSON with temperature in Fahrenheit' do
  expect(response).to include_json(fahrenheit: 86)
end

any_instance_of

避免使用 allow_any_instance_of/expect_any_instance_of。这可能表明被测对象过于复杂,并且在与接收计数一起使用时会产生歧义。

# bad
it 'has a name' do
  allow_any_instance_of(User).to receive(:name).and_return('Tweedledee')
  expect(account.name).to eq 'Tweedledee'
end

# good
let(:account) { Account.new(user) }

it 'has a name' do
  allow(user).to receive(:name).and_return('Tweedledee')
  expect(account.name).to eq 'Tweedledee'
end

匹配器库

使用提供便利帮助程序的第三方匹配器库,这些帮助程序将大大简化示例,Shoulda Matchers 是值得一提的一个。

# bad
describe '#title' do
  it 'is required' do
    article.title = nil
    article.valid?
    expect(article.errors[:title])
      .to contain_exactly('Article has no title')
    not
  end
end

# good
describe '#title' do
  it 'is required' do
    expect(article).to validate_presence_of(:title)
      .with_message('Article has no title')
  end
end

Rails:集成

测试你所看到的。深入测试你的模型和应用程序行为(集成测试)。不要添加无用的复杂性测试控制器。

这是 Ruby 社区中一个公开的争论,双方都有很好的论据来支持他们的观点。支持测试控制器必要性的人会告诉你,你的集成测试没有涵盖所有用例,而且它们很慢。两者都是错误的。可以涵盖所有用例,也可以让它们变得更快。

Rails:视图

视图目录结构

视图规范的目录结构 spec/viewsapp/views 中的目录结构相匹配。例如,app/views/users 中视图的规范位于 spec/views/users 中。

视图规范文件名

视图规范的命名约定是在视图名称中添加 _spec.rb,例如视图 _form.html.erb 具有相应的规范 _form.html.erb_spec.rb

查看外部 describe

外部 describe 块使用视图的路径,不包含 app/views 部分。这在 render 方法没有参数调用时使用。

# spec/views/articles/new.html.erb_spec.rb
describe 'articles/new.html.erb' do
  # ...
end

查看模拟模型

在视图规范中始终模拟模型。视图的唯一目的是显示信息。

视图 assign

assign 方法提供视图使用的实例变量,这些变量由控制器提供。

# spec/views/articles/edit.html.erb_spec.rb
describe 'articles/edit.html.erb' do
  it 'renders the form for a new article creation' do
    assign(:article, double(Article).as_null_object)
    render
    expect(rendered).to have_selector('form',
      method: 'post',
      action: articles_path
    ) do |form|
      expect(form).to have_selector('input', type: 'submit')
    end
  end
end

Capybara 负选择器

优先使用 Capybara 负选择器,而不是使用 to_not 和正选择器。

# bad
expect(page).to_not have_selector('input', type: 'submit')
expect(page).to_not have_xpath('tr')

# good
expect(page).to have_no_selector('input', type: 'submit')
expect(page).to have_no_xpath('tr')

视图助手存根

当视图使用助手方法时,需要对这些方法进行存根。助手方法的存根是在 template 对象上完成的。

# app/helpers/articles_helper.rb
class ArticlesHelper
  def formatted_date(date)
    # ...
  end
end
# app/views/articles/show.html.erb
<%= 'Published at: #{formatted_date(@article.published_at)}' %>
# spec/views/articles/show.html.erb_spec.rb
describe 'articles/show.html.erb' do
  it 'displays the formatted date of article publishing' do
    article = double(Article, published_at: Date.new(2012, 01, 01))
    assign(:article, article)

    allow(template).to_receive(:formatted_date).with(article.published_at).and_return('01.01.2012')

    render
    expect(rendered).to have_content('Published at: 01.01.2012')
  end
end

视图助手

助手规范与 spec/helpers 目录中的视图规范分开。

Rails: 控制器

控制器模型

模拟模型并存根其方法。测试控制器不应该依赖于模型的创建。

控制器行为

只测试控制器应该负责的行为

  • 特定方法的执行

  • 从操作返回的数据 - 赋值等。

  • 操作的结果 - 模板渲染、重定向等。

# Example of a commonly used controller spec
# spec/controllers/articles_controller_spec.rb
# We are interested only in the actions the controller should perform
# So we are mocking the model creation and stubbing its methods
# And we concentrate only on the things the controller should do

describe ArticlesController do
  # The model will be used in the specs for all methods of the controller
  let(:article) { double(Article) }

  describe 'POST create' do
    before { allow(Article).to receive(:new).and_return(article) }

    it 'creates a new article with the given attributes' do
      expect(Article).to receive(:new).with(title: 'The New Article Title').and_return(article)
      post :create, message: { title: 'The New Article Title' }
    end

    it 'saves the article' do
      expect(article).to receive(:save)
      post :create
    end

    it 'redirects to the Articles index' do
      allow(article).to receive(:save)
      post :create
      expect(response).to redirect_to(action: 'index')
    end
  end
end

控制器上下文

当控制器操作根据接收到的参数具有不同的行为时,使用上下文。

# A classic example for use of contexts in a controller spec is creation or update when the object saves successfully or not.

describe ArticlesController do
  let(:article) { double(Article) }

  describe 'POST create' do
    before { allow(Article).to receive(:new).and_return(article) }

    it 'creates a new article with the given attributes' do
      expect(Article).to receive(:new).with(title: 'The New Article Title').and_return(article)
      post :create, article: { title: 'The New Article Title' }
    end

    it 'saves the article' do
      expect(article).to receive(:save)
      post :create
    end

    context 'when the article saves successfully' do
      before do
        allow(article).to receive(:save).and_return(true)
      end

      it 'sets a flash[:notice] message' do
        post :create
        expect(flash[:notice]).to eq('The article was saved successfully.')
      end

      it 'redirects to the Articles index' do
        post :create
        expect(response).to redirect_to(action: 'index')
      end
    end

    context 'when the article fails to save' do
      before do
        allow(article).to receive(:save).and_return(false)
      end

      it 'assigns @article' do
        post :create
        expect(assigns[:article]).to eq(article)
      end

      it "re-renders the 'new' template" do
        post :create
        expect(response).to render_template('new')
      end
    end
  end
end

Rails: 模型

模型模拟

不要在模型自己的规范中模拟模型。

模型对象

使用 FactoryBot.create 创建真实对象,或者只使用 subject 创建一个新的(未保存的)实例。

describe Article do
  subject(:article) { FactoryBot.create(:article) }

  it { is_expected.to be_an Article }
  it { is_expected.to be_persisted }
end

模型模拟关联

模拟其他模型或子对象是可以接受的。

避免模型测试中的重复

为规范中的所有示例创建模型以避免重复。

describe Article do
  let(:article) { FactoryBot.create(:article) }
end

检查模型有效性

添加一个示例以确保使用 FactoryBot.create 创建的模型是有效的。

describe Article do
  it 'is valid with valid attributes' do
    expect(article).to be_valid
  end
end

模型验证

在测试验证时,使用 expect(model.errors[:attribute].size).to eq(x) 指定应验证的属性。使用 be_valid 不能保证问题出在预期的属性上。

# bad
describe '#title' do
  it 'is required' do
    article.title = nil
    expect(article).to_not be_valid
  end
end

# preferred
describe '#title' do
  it 'is required' do
    article.title = nil
    article.valid?
    expect(article.errors[:title].size).to eq(1)
  end
end

为属性验证创建单独的示例组

为每个具有验证的属性添加一个单独的 describe

describe '#title' do
  it 'is required' do
    article.title = nil
    article.valid?
    expect(article.errors[:title].size).to eq(1)
  end
end

describe '#name' do
  it 'is required' do
    article.name = nil
    article.valid?
    expect(article.errors[:name].size).to eq(1)
  end
end

命名另一个对象

在测试模型属性的唯一性时,将另一个对象命名为 another_object

describe Article do
  describe '#title' do
    it 'is unique' do
      another_article = FactoryBot.create(:article, title: article.title)
      article.valid?
      expect(article.errors[:title].size).to eq(1)
    end
  end
end

Rails:邮件器

邮件器模拟模型

邮件器规范中的模型应该被模拟。邮件器不应该依赖于模型的创建。

邮件器期望

邮件器规范应该验证

  • 主题是否正确

  • 发件人电子邮件是否正确

  • 电子邮件是否发送到正确的收件人

  • 电子邮件是否包含所需的信息

describe SubscriberMailer do
  let(:subscriber) { double(Subscription, email: '[email protected]', name: 'John Doe') }

  describe 'successful registration email' do
    subject(:email) { SubscriptionMailer.successful_registration_email(subscriber) }

    it { is_expected.to have_attributes(subject: 'Successful Registration!', from: ['infor@your_site.com'], to: [subscriber.email]) }

    it 'contains the subscriber name' do
      expect(email.body.encoded).to match(subscriber.name)
    end
  end
end

建议

正确设置

正确设置全局 RSpec 配置(~/.rspec)、每个项目(.rspec)以及项目覆盖文件(.rspec-local),该文件应该被保留在版本控制之外。使用 rspec --init 生成 .rspecspec/spec_helper.rb 文件。

# .rspec
--color
--require spec_helper

# .rspec-local
--profile 2

贡献

本指南中没有什么是不可更改的。欢迎大家贡献,以便我们最终能够创建一个对整个 Ruby 社区都有益的资源。

随时提出问题或发送包含改进的拉取请求。感谢您的帮助!

您也可以通过 Patreon 为该项目(以及 RuboCop)提供经济支持。

如何贡献?

很简单,只需遵循以下贡献指南

  • Fork 在 GitHub 上的 项目

  • 在功能分支中进行功能添加或错误修复

  • 包含对更改的 良好描述

  • 将您的功能分支推送到 GitHub

  • 发送 拉取请求

许可证

鸣谢

灵感来自以下内容

本指南由 ReachLocal 长期维护。

本指南包含最初存在于 BetterSpecs (较新网站 较旧网站) 中的材料,由 Lelylan 赞助,由 Andrea Reginato许多其他人 长期维护。