测试 [译] 关于 RSpec 的一点方法总结

rennyallen · July 23, 2017 · Last by w569893882 replied at February 27, 2019 · 11348 hits

两个主要的框架垄断了 Ruby 的测试界:Rspec 和 MiniTest. Rspec 是一个非常有表现力的测试框架,它有很多好的特性和辅助方法来让测试变得可读。当我们用 Rspec 写测试的时候,有几个小的方法,或许可以让测试更好写、更易读、更利于维护。

现在假设有一个系统,有书 (Books) 和 作者 (Authors),让我们使用一些方法来简化测试。

class Book
  attr_reader :title, :genre

  def initialize(title, genre)
    @title = title
    @genre = genre
  end
end

class Author
  attr_reader :books

  def initialize(name, books)
    @name = name
    @books = Array(books)
  end

  def has_written_a_book?
    !books.empty?
  end
end

let 和 subject

let() 有两个好处:

  • 不用赋值给实例变量就可以缓存值;
  • 定义的变量是“惰性计算”的,不调用就不会执行赋值操作;

subject{} 可用来声明测试的对象,后续的测试用例就无需明确指定了。

通过声明 letsubject 变量是一个保持测试可读性和不重复自己 (DRY) 的好方法。

举个例子,如果我们想确认一个作者有名字 (assert an Author has a name),如果不用 letsubject变量,测试大概长这样:

describe Author do
  before do
    @book_genre = 'Historical Fiction'
    @book_title = 'A Tale of Two Cities'
    @book = Book.new(@book_genre, @book_title)
    @author_name = 'Charles Dickens'
    @author = Author.new(@author_name, [@book])
  end

  describe '#name'do
    it 'has a name set' do
      expect(@author.name).to eq(@author_name)
    end
  end
end

如果再加上别的测试,比如确认书的数量,不同的名字,或者其他关于这个作者的东西的话,这测试就会变得很冗长。

所以,我们可以使用 letsubject 变量来实现 DRY:

describe Author do
  let(:book_genre) { 'Historical Fiction' }
  let(:book_title) { 'A Tale of Two Cities' }
  let(:book) { Book.new(book_genre, book_title) }
  let(:book_array) { [book] }
  let(:author_name) { 'Charles Dickens' }
  subject { Author.new(author_name, book_array) }

  describe '#name'do
    it 'has a name set' do
      expect(subject.name).to eq(author_name)
    end
  end

  describe '#books' do
    context 'with books' do
      it 'has books set' do
        expect(subject.books).to eq(book_array)
      end
    end

    context 'without books' do
      context 'books variable is nil' do
        let(:book_array) { nil }

        it 'sets books to an empty array' do
          expect(subject.books).to eq([])
        end
      end

      context 'books variable is an empty array' do
        let(:book_array) { [] }

        it 'sets books to an empty array' do
          expect(subject.books).to eq([])
        end
      end
    end
  end
end

不再需要几个 before 块来定义一个个实例变量,这段代码用了 let,简洁易读。更具体地说,这些测试的工作机制是:每当一个 it 块运行的时候,context里面离得最近的 let就被用来初始化这个 subject

通过设置 let 变量,如果想要测试 subject.books是不是一个数组,判断输入是nil或者[],只需要简单地修改一下 let 声明:let(:book_array) { nil }

Loose Expectations

如果一个测试不关心细节,Rspec 允许使用 general expectations 和 占位符 (placeholders). 这些占位符可以只专注于真正重要的东西,从而简化测试。

1. anything

正如其名,当一个方法要求一个参数但是这个具体的参数又对测试没有影响的时候,我们可以使用 anything 这个参数匹配符。

如果一个测试,想要确认一个作者已经写过书了,但不关心这本书的标题和类型,就可以用 anything

describe Author do
  describe '#has_written_a_book?' do
    context 'when books are passed in' do
      subject { Author.new(name, books) }
      let(:books) { [Book.new(anything, anything)] }
      it 'is true' do
        expect(subject.has_written_a_book?).to eq(true)
      end
    end
  end
end

2. hash_including

当我们要测试一个期望是 Hash 的方法,这个 Hash 中的某些元素可能比其他的元素更重要。hash_including 匹配符允许开发者 assert 一个或多个 hash 的键值对,而不用指定整个 hash。

假设 Book 类有一个方法,实例化了一个 HTTP client (用来取得一些附加信息):

class Book
  # ...
  def fetch_information
    HTTPClient.new({ title: title, genre: genre, time: Time.now })
              .get('/information')
  end
end

给这个方法写测试应该 assert 这个 client 已经初始化了几个关键点,这个场景就可以用 hash_including

describe Book do
  describe '#fetch_information' do
    let(:book_genre) { 'Historical Fiction' }
    let(:book_title) { 'A Tale of Two Cities' }
    subject { Book.new(title, genre) }

    it 'instantiates the client correctly' do
      expect(HTTPClient).to receive(:new)
                        .with(hash_including(title: book_title,
                                             genre: book_genre))
      subject.fetch_information
    end
  end
end

hash_including可以指定一个期望的 hash 中的键值对或者只是键。这里,这个测试只关心传入的书的标题 (title) 和类型 (genre)。

3. match_array

在 Ruby 中,当且仅当两个数组包含同样的元素且顺序相同时,这两个数组是相等 (equal) 的。在一些测试中,这个严格相等的准则可能不是必须的。那些情况下,RSpec 提供了一个叫做 match_array的方法来让测试顺利进行。

如果 Author 类从数据库中取得了它的书的清单,由于默认的 scopes 或者记录的更新操作,书的顺序可能不是连续的。

现定义一个 fetch_books 方法:

class Author
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def fetch_books
    BookDB.find_by(author_name: name)
  end
end

使用 match_array,测试可以确认返回了合适的书,无视顺序:

describe Author do
  describe '#fetch_books' do
    let(:name) { 'Jane Austen' }
    let!(:books) do
      Array.new(2) do
        BookDB.create_book(author_name: name)
      end
    end

    subject { Author.new(name: name) }

    it 'fetches the books correctly' do
      expect(subject.fetch_books).to match_array(books)
    end
  end
end

原文翻译时有删改)

Rspec 可以让测试代码快速膨胀。

Reply to Rei

求科普 Rspec 是如何让测试代码快速膨胀的,MiniTest 就不会吗?

Reply to edwardzhou

Minitest 不推崇不必要的抽象。

Reply to Rei

rspec 的写法有助于改善可读性,这也是受欢迎的原因吧。

Reply to chenge

Rspec 难写难读,读的时候要把每层 context 的 before 和 let 都看一遍,才知道 case 的上下文是什么。

Minitest 是 plain Ruby,Ruby 的代码规范、重构方法、抽象逻辑都适用,懂得写好 Ruby 的也就懂得写好 Minitest。这也就是为什么 Rspec 的文章比 Minitest 多那么多,其实是 Rspec 里面乱七八糟的东西太多了。

Reply to Rei

我同意你,我写得也不多。

我说的可读性是从初学者来说的,这样的语法比较有好感,这个是很多人的体验。 所以还是要接受这个,想办法写好,两个都用。

rspec 设定这么多的规则真是没必要啊,都是程序员自己写,何必非要搞的那么花里胡哨

规范 describe context it 后面的文字说明,测试的层次结构还是很清晰的

You need to Sign in before reply, if you don't have an account, please Sign up first.