Sunday, May 27, 2007

Testing: Replace Collaborator with Stub

If you've done much testing I'm sure you've been annoyed by cascading failures. Cascading failures are most often a symptom of tests that rely on concrete classes instead of isolating the class under test. A solution for creating more robust tests is to replace the collaborators with stubs.

Example: Testing the AccountInformationPresenter independent of the Account class.

The prefactored test involves testing the presenter by creating the Account ActiveRecord instance and testing that the presenter correctly formats the phone number of the instance.
class AccountInformationPresenterTest < Test::Unit::TestCase
def test_phone_number_is_formatted_correctly
Account.delete_all
Account.create(:phone_number => '1112223333')
account = Account.find :first
presenter = AccountInformationPresenter.new(account.id)
assert_equal "(111) 222-3333", presenter.phone_number
end
end
The potential issue with the above test is that if the Account class changes this test could fail. For example, if a validation is added to Account that verifies that the area code of the phone number is valid the above test will fail despite the fact that nothing about the presenter changed.

The same behavior of the presenter can be tested without relying on an actual instance of the Account class. The following test takes advantage of the Mocha mocking and stubbing framework. The new version of the test mocks the interaction with the Account class and returns a stub instead of an actual account instance.
class AccountInformationPresenterTest < Test::Unit::TestCase
def test_phone_number_is_formatted_correctly
Account.expects(:find).returns(stub(:phone_number => '1112223333'))
presenter = AccountInformationPresenter.new(:any_number)
assert_equal "(111) 222-3333", presenter.phone_number
end
end
The new test will not fail regardless of any changes to the Account class.

Where the refactoring applies
This refactoring is most often applicable to unit tests. The unit test suite is often a good place to test edge cases and various logic paths. However, there is much value in testing collaboration among the various classes of your codebase. For these reasons you wouldn't want to apply this refactoring to your functional tests. If you do apply this refactoring, you should ensure you have proper coverage in the functional suite.

The code for the AccountInformationPresenter remains the same regardless of test implementation and is only shown for completeness.
class AccountInformationPresenter
def initialize(account_id)
@account = Account.find(account_id)
end

def phone_number
@account.phone_number.gsub(/(\d{3})(\d{3})(\d{4})/, '(\1) \2-\3')
end
end

4 comments:

  1. I'm new to testing and see the merit here. But, if you do stub the Account model and then decide to refactor the Account model to separate area code from the phone number (two different columns) the test on the Presenter will continue to pass.
    How do you handle that, since the Presenter and stubbing code will need to be updated, although the tests won't complain?

    ReplyDelete
  2. Anonymous9:22 AM

    Hello Jacob,

    The situation you describe is a valid concern, thus the need for a functional test that verifies the behavior between the Presenter and the Account classes.

    Then, if the Account class were to change that test and the Presenter would need to be updated. At that time the Presenter test would fail due to the change in the Presenter.

    So, after providing the justification for the functional test, you might ask what's the point in having the unit test? The answer lies in a more realistic scenario.

    Lets take our example and assume that we need formatting for various countries. Thus the method on the presenter needs to become:

    def phone_number(format=:us)
    case format
    when :us then ....
    when :fr then ....
    ....
    end
    end

    It's necessary to test the various scenarios, but it's not necessary to test them in the functional test suite. In fact, it's more efficient to test the various scenarios in the unit tests and test the collaboration in the functional tests. The resulting suites will both run faster and you'll still have all the scenarios covered.

    ReplyDelete
  3. Thank you for the excellent walkthrough. It seems to me that the functional tests are where most people say you will find and use mocking and stubbing. Are you proposing that one should not mock/stub in the functional tests so that you may still find the breakage when associated models break from refactoring? It seems like if you mock everything, then you will always have easy to fix tests, but most likely the app will not be functional, although the associated test still pass.

    It seems like mocking and stubbing are a great idea, but since I write my tests early, they end up changing all the time due to continual changes in all the models (as new functionality is added or I think of a better way to handle something). If all the associated tests break after I refactor, then I know what needs to be updated. It would seems to be harder (since you have to go looking for every associated bit of code) when mocking and stubbing.

    I want to embrace mocking and stubbing, but see this as a major hurdle. Is it just a methodology difference, or am I missing something?

    ReplyDelete
  4. Anonymous11:40 AM

    I am proposing that you should not use mocks or stubs in functional tests. I use functional tests to test that my classes collaborate correctly. I use unit tests to test that the units of my application work as I expect them to.

    I shouldn't need to spend time looking at tests to ensure they are correct, if they are passing then they should be correct. If I'm getting false positives then the tests are not written correctly.

    Take your example of changing the account class to have an area code field. The stub that returned the phone number wouldn't return anything for the area code. Thus, the new implementation of the presenter (which would rely on the area code) wouldn't produce the correct result and the test would fail despite using the stub.

    For a great explanation on mocks and stubs you can check out http://www.martinfowler.com/articles/mocksArentStubs.html

    ReplyDelete

Note: Only a member of this blog may post a comment.