Fairly recently we investigated a runtime error while running cron jobs against our subscription purchases. The error stemmed from a class mismatch when a method method_foo
was returning a SubscriptionFoo
object when we were expecting a SubscriptionBar
object. The solution was fairly straight forward, however writing a well-scoped test for method_foo
and its return value was not. Why? Because it is often overlooked to write well-scoped tests for relatively large applications in favor of writing narrow-scoped tests that strictly test what you want. Tests should be just as scalable, flexible and maintainable as our main code.
The initial attempt was to test method_foo
's class return types: SubscriptionFoo
or SubscriptionBar
.
The code was relatively simple:
describe FooBar do
describe '#method_foo' do
context 'when it should return a SubscriptionTypeFoo' do
...
expect { FooBar.method_foo }.to be_an_instance_of(SubscriptionFoo)
end
context 'when it should return a SubscriptionTypeBar' do
...
expect { FooBar.method_foo }.to be_an_instance_of(SubscriptionBar)
end
end
end
However there are a few issues with this:
- It seems wrong to test static return types within a dynamic language, especially when it comes to class names.
- What would happen if we change the class name from
SubscriptionFoo
toSubscriptionFooBar
? Should that fundamentally fail the test?
Class names do not strike the core of the issue, nor do class names describe what we are really testing. So WHAT if method_foo
returns SubscriptionFoo
? What does that mean? The issue here is that class names do not REPRESENT anything more meaningful than the name itself. The lack of abstraction ends up directly relating to the lack of descriptiveness in our test. And we can properly abstract SubscriptionFoo
and SubscriptionBar
into what we call interfaces or behaviours.
So instead of testing class names, we want to test BEHAVIOUR. Abstract the idea of testing a class to testing something that adheres to an interface instead. In other words, instead of testing SubscriptionFoo
or SubscriptionBar
, test that whatever method_foo
returns, adheres to an interface and behaves consistently.
The code will instead look like:
shared_context 'a foo subscription' do
it { is_expected.to respond_to 'subscription_method_1' }
it { is_expected.to respond_to 'subscription_method_2' }
it { is_expected.to respond_to 'subscription_method_foo' }
end
shared_context 'a bar subscription' do
it { is_expected.to respond_to 'subscription_method_1' }
it { is_expected.to respond_to 'subscription_method_2' }
it { is_expected.to respond_to 'subscription_method_bar' }
end
describe FooBar do
describe '#method_foo' do
context 'for a Foo-like subscription' do
it_behaves_like 'a foo subscription'
end
context 'for a Bar-like subscription' do
it_behaves_like 'a bar subscription'
end
end
end
This type of testing is very much in line with the concept of duck typing. By testing if the returned value walks
like a duck and quacks
like a duck, we have much more assurance that it IS a duck than just testing the class name Duck
. In particular, we are testing the interfaces of SubscriptionFoo
and SubscriptionBar
instead of just the static class names. This gives a much deeper scope and integrity to what we are testing, in addition to giving the developer documentation of how we expect our return types to behave.
Overall this is a beautiful way of compromising with the dynamic nature of the ruby language. Instead of testing for hardcoded values and testing if your return values are of a specific type, test their behaviour. Test the interfaces they adhere to and you have added more integrity and depth to the test, while having no constraints on hardcoded values.
👌 💡 I totally get what you're saying and I agree. Reading this again seems like just a recount of history events that lead up to a conclusion: "
1
happened, and then I did2
, then3
which lead me to4
". Its dry, mechanical and not engaging. Flip it around and make the conclusion what engages the reader from the beginning and focus on that as the journey.