Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What's the "right" way to test functions that call methods on new instances?

I seem to have encountered literature alluding to it being bad practice to use RSpec's any_instance_of methods (e.g. expect_any_instance_of). The relish docs even list these methods under the "Working with legacy code" section (http://www.relishapp.com/rspec/rspec-mocks/v/3-4/docs/working-with-legacy-code/any-instance) which implies I shouldn't be writing new code leveraging this.

I feel that I am routinely writing new specs that rely on this capability. A great example is any method that creates a new instance and then calls a method on it. (In Rails where MyModel is an ActiveRecord) I routinely write methods that do something like the following:

def my_method
    my_active_record_model = MyModel.create(my_param: my_val)
    my_active_record_model.do_something_productive
end

I generally write my specs looking for the do_something_productive being called with use of the expect_any_instance_of. e.g.:

expect_any_instance_of(MyModel).to receive(:do_something_productive)
subject.my_method

The only other way I can see to spec this would be with a bunch of stubs like this:

my_double = double('my_model')
expect(MyModel).to receive(:create).and_return(my_double)
expect(my_double).to receive(:do_something_productive)
subject.my_method

However, I consider this worse because a) it's longer and slower to write, and b) it's much more brittle and white box than the first way. To illustrate the second point, if I change my_method to the following:

def my_method
    my_active_record_model = MyModel.new(my_param: my_val)
    my_active_record_model.save
    my_active_record_model.do_something_productive
end

then the double version of the spec breaks, but the any_instance_of version works just fine.

So my questions are, how are other developers doing this? Is my approach of using any_instance_of frowned upon? And if so, why?

like image 683
John Hinnegan Avatar asked May 12 '16 17:05

John Hinnegan


1 Answers

This is kind of a rant, but here are my thoughts:

The relish docs even list these methods under the "Working with legacy code" section (http://www.relishapp.com/rspec/rspec-mocks/v/3-4/docs/working-with-legacy-code/any-instance) which implies I shouldn't be writing new code leveraging this.

I don't agree with this. Mocking/stubbing is a valuable tool when used effectively and should be used in tandem with assertion style testing. The reason for this is that mocking/stubbing enables an "outside-in" testing approach where you can minimize coupling and test high level functionality without needing to call every little db transaction, API call, or helper method in your stack.

The question really is do you want to test state or behavior? Obviously, your app involves both so it doesn't make sense to tether yourself to a single paradigm of testing. Traditional testing via assertions/expectations is effective for testing state and is seldom concerned with how state is changed. On the other hand, mocking forces you to think about interfaces and interactions between objects, with less burden on the mutation of state itself since you can stub and shim return values, etc. I would, however, urge caution when using *_any_instance_of and avoid it if possible. It's a very blunt instrument and can be easy to abuse especially when a project is small only to become a liability when the project is larger. I usually take *_any_instance_of as a smell that either my code or tests, can be improved, but there are times wen it's necessary to use.

That being said, between the two approaches you propose, I prefer this one:

my_double = double('my_model')
expect(MyModel).to receive(:create).and_return(my_double)
expect(my_double).to receive(:do_something_productive)
subject.my_method

It's explicit, well-isolated, and doesn't incur overhead with database calls. It will likely require a rewrite if the implementation of my_method changes, but that's OK. Since it's well-isolated it probably won't need to be rewritten if any code outside of my_method changes. Contrast this with assertions where dropping a column in a database can break almost the entire test suite.

like image 182
Anthony E Avatar answered Oct 25 '22 09:10

Anthony E