Mocking với RSpec: Double và Expectation

Mocking là một kĩ thuật Test-Driven Development mạnh mẽ nhưng cũng cần được sử dụng một cách cẩn thận. Trong phần tiếp theo của chuỗi bài tìm hiểu RSpec, chúng ta sẽ tim hiểu và học cách áp dụng nó trong viết test RSpec cho Ruby.

Giới thiệu

Mocking là mộk kĩ thuật trong test-driven development (TDD) liên quan tới việc sử dụng các đối tượng fake độc lập hoặc các method để viết test. Có những lý do sau để bạn quyết định có sử dụng mock object hay không:

  • Là một sự thay thế cho các đối tượng chưa tồn tại.
  • Khi bạn làm việc với các đối tượng trả lại các giá trị không xác định hoặc phụ thuộc vào nguồn bên ngoài, ví dụ: Một phương thức trả về RSS feed từ máy chủ.
  • Để tránh thiết lập schema data phức tạp hoặc các đối tượng phụ thuộc để viết test.
  • Để tránh gọi code làm giảm performance của test và có thể khi đó đoạn code không liênt quan tới test mà bạn đang viết

Lý do đầu tiên rất phổ biến đối với những người sử dụng behavior-driven development (BDD). Cần tồn tại các đối tượng độc lập, tuy nhiên nó lại không phải thứ mà bạn đang làm việc cùng. Bạn có thể giải phóng hoàn toàn object sau khi dùng xong, khiến cho code trở nên "đẹp" và "sạch" nhất có thể, điều mà được coi là tối thiểu nhất mà đoạn code viết code cần thể hiện được.

Với moking object, bạn có thể cho phép bản thân tập trung vào những thứ mà bạn đang làm việc cùng tại một thời điểm. Ví dụ, khi bạn đang làm việc với một phần cuả hệ thông, và bạn nhận thấy rằng code đang describe và implement sẽ cần 2 object mới liên quan. Sử dụng mocks, bạn có thể định nghĩa chúng khi viết spec cho đoạn code mà bạn đang làm việc.

Bằng cách đó, bạn xây dựng được một test gọn gàng và tất cả test sẽ pass trước khi chuyển sang implement các object liên quan. Nếu không có mock, Bạn phải cần tìm tới đoạn implement các object liên quan trước khi test cho pass. Điều mà sẽ khiến bạn mất tập trung, dẫn tới viết sai code chẳng hạn. Mocking sẽ giúp giảm bớt một số lượng lớn những thứ mà chúng ta chỉ cần chúng trong một lúc, khi mà code cần tới.

Mocking trong RSpec rất đơn giản, hân hạnh được tài trợ bởi "rspec-mocks gem". Nếu bạn đã cài rspec trong Gèmfile, vậy là đã sẵn sàng để sử dụng rspec-mock.

Double

Một test double là một object được đơn giản hoá mà sẽ thay thế chỗ của một object khác trong test. Tạo một double với RSpec như sau:

feed = double

# Optionally, you may give your double an identifier, which may come handy
# when debugging and inspecting objects:
feed = double("feed")

Method Stub

Một double mới giống như một plain Ruby Object - không được hữu ích nếu chỉ có riêng mình nó. Nó thường là bước đầu tiên trước khi khởi tạo, định nghĩa một method fake dựa trên nó. Điều này được gọi là method stubbing. Với RSpec 3, chúng ta sử dụng allow() và receive() method:

allow(feed).to receive(:fetch).and_return("imagine I'm a JSON string")

feed.fetch
=> "imagine I'm a JSON string"

Giá trị được cung cấp để method and_return() định nghĩa và trả về khi stubbing method được gọi. Các sử dụng and_return() là tuỳ chọn, nếu bạn không sử dụng, stub method sẽ trả về nil.

Bạn cũng có thể áp dụng cho real object (không phải một double). Khi test Rail app, một ví dụ thông thường, để mock một method mà làm việc với database và nó trả về một double được định nghĩa trước không cần quan tâm dữ liệu database có đang được gọi lên để xử lý hay không, từ đó test có thể chạy nhanh hơn theo.

comment = double("comment")
expect(Comment).to receive(:find).and_return(comment)

Chúng ta sẽ xem cách ứng dụng của nó:

Message Expectations

Expecting messages - là định nghĩa mong muốn của test double mà một sổ method sẽ được gọi tới sau khi một số code khác chạy - một mô hình phổ biến khi làm việc với double. Ví dụ, việc chúng ta làm với các background job như gửi mail thông qua mailer class. Với BDD mocking, ta nên viết spec cho nó bằng việc thiết lập message expectation.

Đầu tiên, chúng ta biết rằng class cần làm việc với email record, thông qua model Email. Vì vậy, việc đầu tiên là định nghĩa job class bằng việc tìm đúng email record dựa trên ID nó nhận được qua argument.

describe EmailVerificationJob do
  describe "#perform" do

    it "finds the email by id" do
      expect(Email).to receive(:find).with(12)

      EmailVerificationJob.new.perform(12)
    end
  end
end

Trong đoạn code trên, ta tạo ra một expectation mà nếu chạy EmailVerificationJob.perform(12), nó sẽ implement và gọi Email.find(12). Chúng ta có thể làm với đoạn code sau đây:

class EmailVerificationJob
  def perform(email_id)
    email = Email.find(email_id)
  end
end

Tiếp theo, chúng ta muốn một mailer class nên lấy email recode được trả về bởi Email#find và sử dụng nó để gửi một email thật Để kết nối được hai action với nhau, chúng ta cần mock Email#find trả về một double mà có thể control. Sau đó, chúng ta có thể sử dụng double để tạo 1 expectation mà mailer class đang sử dụng nó để gửi email

describe EmailVerificationJob do
  describe "#perform" do

    it "finds the email by id" { ... }

    it "sends the verification email" do
      email = double
      allow(Email).to receive(:find) { email }

      expect(UserMailer).to receive(:send_verification_email).with(email)

      EmailVerificationJob.new.perform(12)
    end
  end
end

Lưu ý cách chúng ta có thể tạo ra receive để chắc chắn send_verification_email method sẽ pass với param đúng. RSpec hỗ trọ rất nhiều cách để viết argument matcher. bước cuối cùng để implement job class:

class EmailVerificationJob
  include Sidekiq::Worker

  def perform(email_id)
    email = Email.find(email_id)
    UserMailer.send_verification_email(email)
  end
end

Tuy nhiên, khi run spec, ta nhận đc fail:

$ bundle exec rspec
...
EmailVerificationJob
  #perform
    finds the email by id (FAILED - 1)
    sends the verification email

Failures:

  1) EmailVerificationJob#perform finds the email by id
     Failure/Error: EmailVerificationJob.new.perform(12)
     NoMethodError:
       undefined method `send_verification_email' for UserMailer:Class
     # ./lib/email_verification_job.rb:8:in `perform'
     # ./spec/lib/email_verification_job_spec.rb:12:in `block (3 levels)'

Finished in 0.30142 seconds (files took 4.91 seconds to load)
2 examples, 1 failure

UserMailer#send_verification_email chưa tồn tại nhưng chúng ta biết và có thể tạo ra nó, vì vậy, có thể pass test bằng mocking send_verification_email method:

describe "#perform" do
  it "finds the email by id" do
    allow(UserMailer).to receive(:send_verification_email)
    expect(Email).to receive(:find).with(12)

    EmailVerificationJob.new.perform(12)
  end
end

Và bây giờ tất cả đều pass:

$ bundle exec rspec
...
EmailVerificationJob
  #perform
    finds the email by id
    sends the verification email

Finished in 0.29838 seconds (files took 4.92 seconds to load)
2 examples, 0 failures

Message Chaining

Bạn có thể cần dùng tới mock chained method. ví dụ như sau:

@comments = Comment.where(:post_id => @post.id).order("created_at DESC")

Spec code tương ứng có thể sử dụng RSpec's receive_message_chain:

allow(Comment).to receive_message_chain(:where, :order) { ... }

Tương tự receive_message_chain trong cú pháp pre-RSpec 3 thường được biểu diễn như một basic feature của library. Nếu bạn đọc spec bên trên, nên chú ý tới rằng nó không nói cho bạn bất kì điều gì về mục đichs việc implemet này. Frequent method chaining thường dẫn đến sự vi phạm Luật Demeter, làm cho code khó lý giải và refactor trong tương lai. Chaining method đôi khi không thể hoàn toàn trách khỏi và phải kết thúc. Vì lý do này, bạn nên đóng gói chain với một domain-defining method:

class Comment
  def self.for_post(post)
    where(:post_id => post.id).order("created_at DESC")
  end
end

Spec của đoạn code sử dụng method để làm work có trật tự cao hơn, sẽ dễ dàng mock high-level method

allow(Comment).to receive(:for_post) { ... }

Chúng ta có thể viết một spec full non-mocking cho Comment.for_post method một cách riêng biệt

Tổng kết

Bài hướng dẫn này cho bạn thấy những cách sử dụng test double và thiết lập message expectation với RSpec để bạn có thể áp dụng vào rails app của mình. Tuy nhiên trên đây chỉ là những hướng dẫn ví dụ cơ bản, bạn có thể tìm hiểu thêm nhiều hơn về rspec-mock tại đây: