RSpec: sự khác biệt giữa mocks và stubs

Trong bài viết này mình sẽ giới thiệu với bạn cách phân biệt mocks và stubs trong Rspec. Trước tiên, ta cần hiểu mock là gì, stub là gì. Trong cuốn Effective Testing with RSpec 3 có định nghĩa thế này: Stub

Returns canned responses, avoiding any meaningful computation or I/O

Code của nó sẽ trông thế này:

allow(some_object).to receive(some_method).and_return(some_value)

Mock

Expects specific messages; will raise an error if it doesn’t receive them by the end of the example

Ví dụ về mock

expect(some_object).to receive(some_method).and_return(some_value)

mocks là kỳ vọng một phương thức được gọi đến sẽ trả về một hay nhiều giá trị nào đó, trong khi stubs chỉ quan tâm đến trạng thái trả về của một đối tượng khi nhận một message nào đó.

Hãy sử dụng một ví dụ để dễ dàng hiểu hơn về khái niệm này. Chúng ta sẽ viết Rspec cho đoạn code này:

class DataProcessor
  Error = Class.new(StandardError)

  def process(data, validator)
    raise Error unless validator.valid?(data)

    # simple logic to show the idea
    "#{data} processed"
  end
end

Chúng ta có lớp DataProcessor với phương thức process. Nó được truyền vào tham số datavalidator. Nếu validator trả về true khi gọi đến phương thức valid? thì sẽ thêm chuỗi "processed" vào cuối data. Đơn giản. Giờ ta muốn viết spec kiểm tra xem có chuỗi "processed" cuối data sau khi gọi đến phương thức hay không. Nhưng phương thức process vẫn yêu cầu tham số validator phải qua được phương thức valid?. Hiện tại chúng ta không quan tâm tới validator, vì vậy chúng ta có thể sử dụng stub.

Trước tiên tạo ra spec rỗng cho DataProcessor

require 'spec_helper'

describe DataProcessor do
  let(:processor) { described_class.new }    
end

Giờ ta có thể viết một trường hợp với sự kì vọng:

require 'spec_helper'

describe DataProcessor do
  let(:processor) { described_class.new }

  it 'adds processed to valid data' do
    expect(processor.process('foo', validator)).to eq('foo processed')
  end
end

Nhưng chúng ta chưa có validator nên không thể trả về kết quả như kì vọng nên ta cần tạo một đối tượng validator gọi tới phương thức valid? và trả về true. Đầu tiên tạo ra bằng double (double dùng thể thay thế cho một đối tượng nào đó trong khi test, có thể sử dụng allow()receiver() tùy ý):

validator = double(:validator)

Khi đã có double validator, chúng ta cho phép nó gọi valid?.

allow(validator).to receive(:valid?).and_return(true)

Giờ validator đã nhận valid? và trả về true. Đó gọi là stubs, có thể viết gọn lại thành 1 dòng:

validator = double(:validator, valid?: true)

Giờ spec sẽ thế này:

require 'spec_helper'

describe DataProcessor do
  let(:processor) { described_class.new }

  it 'adds processed to valid data' do
    validator = double(:validator, valid?: true)
    expect(processor.process('foo', validator)).to eq('foo processed')
  end
end

Spec đã chuyển màu xanh. Validator chúng ta truyền vào phương thức process trả về true khi gọi valid?, vì vậy data của chúng ta đã được xử lý.

Giờ ta sẽ thêm 1 trường hợp kiểm tra phương thức process ném ra Error với dữ liệu không hợp lệ. Chúng ta vẫn không cần quan tâm đến validator mà chỉ cần nó trả về false cho phương thức valid?

require 'spec_helper'

describe DataProcessor do
  let(:processor) { described_class.new }

  context 'with valid data' do
    it 'adds processed to data' do
      validator = double(:validator, valid?: true)
      expect(processor.process('foo', validator)).to eq('foo processed')
    end
  end

  context 'with invalid data' do
    it 'raises Error' do
      validator = double(:validator, valid?: false)
      expect { processor.process('foo', validator) }.to raise_error(DataProcessor::Error)
    end
  end
end

Đối với trường hợp này ta cần stub phương thức valid? trả về false

validator = double(:validator, valid?: false)

Giờ chúng ta đã biết nó sẽ trả về exception với dữ liệu không hợp lệ và thêm chuỗi "processed" với dữ liệu hợp lệ. Bước cuối cùng là cần chắc chắn validator đã gọi đến phương thức valid?. Chúng ta sẽ viết 1 kì vọng validator gọi đến phương thức valid? Nếu bạn muốn đảm bảo đối tượng sẽ nhận bất kì message nào trong quá trình thực thi, bạn nên dùng mocks. Giờ hãy viết thêm 1 trường hợp để đảm bảo rằng phương thức process đã gọi validator.valid?(data) trong quá trình thực thi.

it 'calls validator.valid?' do
    validator = double(:validator)

    expect(validator).to receive(:valid?).with('foo').and_return(true)
    processor.process('foo', validator)
  end

Đối với trường hợp này, ta tạo ra một đối tượng đơn giản bằng double và thêm kì vọng cho nó. Chúng ta kì vọng nó sẽ nhận valid?foo, trả về true. Đó chính là sự khác biệt chính giữa mocks và stubs. Đối với stubs chúng ta allow đối tượng nhận một thông điệp, còn với mocks ta expect nó nhận. Nếu chúng ta bỏ dòng này trong code:

raise Error unless validator.valid?(data)

Trường hợp cuối cùng sẽ fail, với lỗi sau:

(Double :validator).valid?("foo")
expected: 1 time with arguments: ("foo")
received: 0 times

Nếu như dùng stub (allow) sẽ không bao giờ bị fail, nhưng nếu dùng mock, nó kì vọng validator sẽ nhận valid? ít nhất 1 lần. Stub và mock không chỉ dùng cho double, nó còn có thể dùng có đối tượng thật.

class DataProcessor
  Error = Class.new(StandardError)

  def process(data)
    raise Error unless Validator.new.valid?(data)

    "#{data} processed"
  end
end

class Validator
  def valid?(data)
    true
  end
end

Chúng ta có thể thấy ở ví dụ trên là process giờ chỉ nhận tham số data, và dùng Validator để kiểm tra tính hợp lệ. Vậy chúng ta có thể thay đổi specs để bao quát được hết các trường hợp hay không? Trong ví dụ này chúng ta có thể sử dụng các phương thức có sẵn của Rspec:

  • allow_any_instance_of
  • expect_any_instance_of Chúng ta có thể sử dụng các phương thức trên cho bất kì instance nào của Validator.
require 'spec_helper'

describe DataProcessor do
  let(:processor) { described_class.new }

  context 'with valid data' do
    it 'adds processed to data' do
      # it works because true is default value for Validator
      expect(processor.process('foo')).to eq('foo processed')
    end
  end

  context 'with invalid data' do
    it 'raises Error' do
      allow_any_instance_of(Validator).to receive(:valid?).and_return(false)
      expect { processor.process('foo') }.to raise_error(DataProcessor::Error)
    end
  end

  it 'calls validator.valid?' do
    expect_any_instance_of(Validator).to receive(:valid?).with('foo').and_return(true)
    processor.process('foo')
  end
end

Tất cả đã lại chuyển sang màu xanh. Hy vọng bài viết này có thể giúp bạn hiểu được sự khác biệt giữa stub và mock. Cảm ơn đã đọc đến hết bài viết!

Nguồn tham khảo: http://rubyblog.pro/2017/10/rspec-difference-between-mocks-and-stubs