Part 1: Introduction to test double and usage in RSpec
What is a test double?
Test Double is a general term for any situation where you replace an object that is actually used for testing purposes.
The main purpose of test double is to reduce dependencies and increase the independence of test cases. This is extremely important in unit test because we all want the test case to run as fast, independent and as less dependent on other “units” as possible.
Martin Fowler defines a double test as five different types, depending on the intended use:
- Dummy : objects that are passed in but never actually used. Usually they are only used to fill a parameter list.
- Fake : objects actually have the active implementation, but are not stored as in practice ( InMemoryTestDatabase is a prime example).
- Stubs : objects that contain predefined data and use it to return data when calling to certain methods.
- Spies : objects that record how it behaves like the number of times it was called, parameters received, etc.
- Mocks : Just like stub can return the given data, but it is required to verify action called in test case.
Dummy
Usually used to fill a function’s parameters in cases where that parameter is not used to speed up test cases.
An example in Rspec:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class Dummy define method1 excute_method2, very_complex_object if excute_method2 method2 very_complex_object end end define method2 very_complex_object # excute some logic end end # Rspec # Trong case excute_method2 = false very_complex_object không được sử dụng, # method chỉ đơn giản return nil nên việc tạo ra 1 object phức tạp như # thực tế là không cần thiết ... describe ".method1" do it "should return nil" do dummy = double("dummy") expect(Dummy.new.method1(false, dummy)).to be_nil end end ... |
Fake
Less commonly used in unit tests, however you can learn more about InMemoryTestDatabase here.
Stubs
Use to fake the return result of a function that you don’t really want to run that function. In rails I often use to stub unnecessary callback models in the test case, especially callbacks that affect the database or elasticsearch …
Rspec provides the following syntax for the stub method of an object:
1 2 3 4 5 6 7 | book = double("book") allow(book).to receive(:title) { "The RSpec Book" } allow(book).to receive(:title).and_return("The RSpec Book") allow(book).to receive_messages( :title => "The RSpec Book", :subtitle => "Behaviour-Driven Development with RSpec, Cucumber, and Friends") |
Or you can use the following abbreviation:
1 2 | book = double("book", :title => "The RSpec Book") |
An example of stub usage in Rspec:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | class Stub define method1 if method2 return "ok" end end define method2 if condition # excute some logic return true else # excute some logic return false end end end # Rspec # Ở đây mình stub method2 trả về true ... describe ".method1" do it "should return ok" do stub = Stub.new allow(stub).to receive(:method2) {true} expect(stub.method1).to eq "ok" end end ... |
Spies
Use to verify the actions in the method such as logging, firing noti, … We can verify the method is called several times, with any parameters, … Syntax to use spy in Rspec:
1 2 3 4 5 6 7 8 9 10 11 | invitation = spy('invitation') user.accept_invitation(invitation) expect(invitation).to have_received(:accept) # You can also use other common message expectations. For example: expect(invitation).to have_received(:accept).with(mailer) expect(invitation).to have_received(:accept).twice expect(invitation).to_not have_received(:accept).with(mailer) |
An example of using spies in Rspec:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | class SomeCommand def call(arg:, other:) if arg <= 0 logger.warn("args should be positive") else logger.debug("all fine") end # some logic end def logger Rails.logger end end describe SomeCommand let(:logger) { spy('Logger') } # stub method logger trả về spy logger before { allow(subject).to receive(:logger) { logger } } context 'with negative value' do it 'warns' do subject.call(arg: -1, other: 6) # verify việc ghi log expect(logger).to have_received(:warn).with("args should be positive") expect(logger).not_to have_received(:debug) end end context 'with positive value' do it 'logs as debug' do subject.call(arg: 1, other: 6) # verify việc ghi log expect(logger).not_to have_received(:warn) expect(logger).to have_received(:debug).with("all fine") end end end |
Mock
As far as I can see, the mock is quite similar to stub + spies combined. Syntax of using mock in Rspec:
1 2 3 4 | person = double("person") expect(Person).to receive(:find) { person } expect(Person).to receive(:find).with("abc") { person } |
An example of using mock in rspec:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | class Mock define test_key key if is_valid_key open_door key end end define open_door # some logic return "door is opened" end define valid_key? key # some logic end end # Rspec ... describe ".test_key" do it "should return door is opened" do mock = Mock.new # Ở đây mình đang test case key valid nên sẽ mock method valid_key? trả về true expect(mock).to receive(:valid_key?).with("key") {true} expect(mock).to receive(:open_door).with("key") {"door is opened"} mock.test_key("key") expect(mock.test_key("key")).to eq "door is opened" end end ... |
References
https://www.martinfowler.com/articles/mocksArentStubs.html
https://martinfowler.com/bliki/TestDouble.html
https://rubydoc.info/gems/rspec-mocks/frames
https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da