Often in a modular application you will have functionality that is reused by extracting to a common location. For example, you might move your authentication logic to a server-side middleware so that logic is consistent and only declared in one place. Or you might have a mix-in that multiple classes or modules use to avoid duplicating code.
These are common application patterns, but one question that often comes up is: how can we easily test this and make sure that the code works as we think it should? Since there may be multiple places that use the same logic or functionality, it would be wasteful and boring to test it in multiple places. And if the code in a shared module is changed, we’ll have to change multiple tests.
There is the school of thought that tests should be really dumb so you can be sure of what you are testing. There is merit in this philosophy, but I think that copying many tests has low return on investment and a high maintenance cost. Let’s look at some other approaches.
Test scaffold
One of my favorite approaches to test the functionality of the shared module just once is to use a test scaffold. The test scaffold is responsible for creating a class, module, or application that includes, imports, extends, or uses the code we want to test. (That was a mouthful!) For example, if we have a Rack middleware that we want to test, I can create a new testing application that uses the middleware and run tests against that testing application:
require 'test_helper'
class TestApp < Sinatra::Base
# middleware that I want to test
use API::HandleAuthenticationMiddleware
get '/api/test' do
[201]
end
end
class TestAppTest
include Rack::Test::Methods
def app
TestApp
end
end
Based on the above, we’ll have an endpoint in TestAppTest
that is accessible from /api/test
and we can hit it to assert that the authentication middleware works as expected. If the middleware is supposed to respond with 401 when the user isn’t logged in, I would test:
- that we get 401 when no authentication is passed
- that we get 401 when invalid authentication is passed
- that we get a 201 response when valid authentication is passed
Similarly, we could test a mixin by doing something like:
require 'test_helper'
class TestClass
# mixin that I want to test
include TheMixin
end
Then we can test that TestClass
has the behavior that we are looking for when TheMixin
is included in it. This would logically extend to other classes that include TheMixin
in our application.
Behavior
The above tests get us pretty far. Depending on the importance of the code working correctly, this might be enough.
Tests like these don’t assure that the middleware or mixin handles all of the logic when it is used each time. There might be conflicts with the way that we import it (maybe two methods have the same name), or we might forget to import or include it at all. There are a few ways to get around this.
The first method of solving this problem is through the use of conventions. I usually try to structure the application so that all controllers that need authentication will use the authentication without much thought. Making their routes start with a similar prefix (/api/*
) is one way to handle this, and you can specify exceptions (for the login API, for instance.) Another approach is to have the controllers extend a base class that specifies the authentication.
A second way of approaching this is to test whether the class or module includes the mixin we want. This is less verbose and faster than testing all of the methods, but it can be brittle if the functionality moves to a different location. We might be asserting that A
includes B
, but really the functionality that A
should have has moved to C
.
The last way is to use something like RSpec’s shared examples to ensure that the module does what we think it should do. Shared examples are basically a fancy way of saying “this class should have all of these behaviors.” Under the hood, Rspec runs the same tests for each test that says it behaves like the shared example. This results in N runs if there are N tests that use a given shared example. So these could be prohibitively expensive to run, but are the most accurate and are still easy to define.
Hope this helped!
I like testing, and since I have done it for a while, I am happy to share what I know. Did you like this post? Would you like more posts that explore testing? What are areas that are hardest for you to test?