Introduction.
Working through the first three iterations of a workshop's exercise, we produced several application services that at some point collaborated with a users repository that we hadn't yet created so we used a test double in its place in their tests.
These are the tests:
require 'spec_helper'
require 'cos/actions/users/register_user'
require 'cos/core/users/errors'
describe Actions::RegisterUser do
let(:users_repository) { double('UsersRepository') }
let(:new_user_name) {'@foolano'}
before do
stub_const('Users::Repository', users_repository)
end
describe "Registering a user" do
it "can register a user that is not already registered" do
allow(users_repository).
to receive(:registered?).
with(new_user_name).and_return(false)
expect(users_repository).
to receive(:register).with(new_user_name)
Actions::RegisterUser.do(new_user_name)
end
it "fails when trying to register a user that is already registered" do
allow(users_repository).
to receive(:registered?).with(new_user_name).and_return(true)
expect{Actions::RegisterUser.do(new_user_name)}.
to raise_error(Users::Errors::AlreadyRegistered)
end
end
end
require 'spec_helper'
require 'cos/actions/users/follow_user'
require 'cos/core/users/errors'
describe Actions::FollowUser do
let(:follower_name) { "foolano" }
let(:followed_name) { "mengano" }
let(:users_repository) { double('UsersRepository') }
before do
stub_const('Users::Repository', users_repository)
end
describe "Following a user" do
describe "when both users are registered" do
it "succesfully adds a follower to a followed user" do
allow(users_repository).
to receive(:registered?).with(follower_name).and_return(true)
allow(users_repository).
to receive(:registered?).with(followed_name).and_return(true)
expect(users_repository).
to receive(:add_follower).with(follower_name, followed_name)
Actions::FollowUser.do follower_name, followed_name
end
end
describe "when any of them is not registered" do
it "raises an error when trying to add a registered follower to a followed user that does not exist" do
allow(users_repository).
to receive(:registered?).with(follower_name).and_return(true)
allow(users_repository).
to receive(:registered?).with(followed_name).and_return(false)
expect {Actions::FollowUser.do follower_name, followed_name}.
to raise_error(Users::Errors::NonRegistered)
end
it "raises an error when trying to add a follower that does not exist to a registered followed user" do
allow(users_repository).
to receive(:registered?).with(follower_name).and_return(false)
allow(users_repository).
to receive(:registered?).with(followed_name).and_return(true)
expect{Actions::FollowUser.do follower_name, followed_name}.
to raise_error(Users::Errors::NonRegistered)
end
end
end
end
require 'spec_helper'
require 'cos/queries/users/followers_of_user'
describe Queries::FollowersOfUser do
let(:follower_names) {["pepe", "juan"]}
let(:followed_name) {"koko"}
let(:users_repository) { double('UsersRepository') }
before do
stub_const('Users::Repository', users_repository)
end
describe "Getting the followers of a user" do
it "returns the list of followers" do
allow(users_repository).to receive(:followers_of)
.with(followed_name)
.and_return(follower_names)
expect(Queries::FollowersOfUser.do(followed_name)).to eq follower_names
end
end
end
In these tests, every time we allow or expect a method call on our repository double,
we are defining not only the messages that the users repository can respond to (its public interface)[1] but also what its clients can expect from each of those messages, i.e. its contract.
In other words, at the same time we were testing the application services, we defined from the point of view of its clients the responsibilities that the users repository should be accountable for.
The users repository is at the boundary of our domain. It's a port that allows us to not have to know anything about how users are stored, found, etc. This way we are able to just focus on what its clients want it to do for them, i.e., its responsibilities.
Focusing on the responsibilities results in more stable interfaces. As I heard Sandi Metz say once:
"You can trade the unpredictability of what others do for the constancy of what you want."[2]
which is a very nice way to explain the "Program to an interface, not an implementation"[3] design principle.
How those responsibilities are carried out is something that each different implementation (or adapter) of the users repository port is responsible for. However, the terms of the contract that its clients rely on, must be respected by all of the adapters. They must play their roles. In this sense, any adapter must be substitutable by any other without the clients being affected, (yes, you're right, it's the Liskov substitution principle).
Role or contract tests.
The only way to ensure this substitutability is by testing each adapter to check if it also respects the terms of the contract, i. e. it fulfils its role. Those tests would ensure that the Liskov substitution principle is respected[4].
I will use the term role test used by Sandi Metz because contract test has become overloaded[5].
Ok, but how can we test that all the possible implementations of the user repository respect the contract without repeating a bunch of test code?
Using shared examples in RSpec to write role tests.
There’s one very readable way to do it in Ruby using RSpec.
We created a RSpec shared example in a file named users_repository_role.rb where we wrote the tests that describes the behaviour that users repository clients were relying on:
require 'spec_helper'
RSpec.shared_examples "users repository" do
let(:user_name) { '@foolano' }
let(:follower_name) { '@zutano' }
let(:followed_name) { '@mengano' }
it "knows when a user is registered" do
given_already_registered(user_name)
expect(@repo.registered?(user_name)).to be_truthy
end
it "knows when a user is not registered" do
expect(@repo.registered?(user_name)).to be_falsy
end
it "registers a new user" do
@repo.register(user_name)
expect(@repo.registered?(user_name)).to be_truthy
end
it "adds a follower to a user" do
given_both_already_registered(follower_name, followed_name)
@repo.add_follower(follower_name, followed_name)
expect(@repo.followers_of(followed_name)).to eq [follower_name]
end
def given_both_already_registered(follower_name, followed_name)
given_already_registered(follower_name)
given_already_registered(followed_name)
end
def given_already_registered(user_name)
@repo.register(user_name)
end
end
Then for each implementation of the users repository you just need to include the role tests using RSpec it_behaves_like
method, as shown in the following two implementations:
require 'spec_helper'
require 'infrastructure/users/repositories/in_memory'
require_relative '../cos/repositories_contracts/users_repository_role'
describe "In memory users repository" do
before do
@repo = Users::Repositories::InMemory.new
end
it_behaves_like "users repository"
end
require 'spec_helper'
require 'infrastructure/users/repositories/mongoid'
require_relative '../cos/repositories_contracts/users_repository_role'
describe "Mongoid users repository" do
before do
@repo = Users::Repositories::Mongoid.new
end
it_behaves_like "users repository"
end
You could still add any other test that only has to do with a given implementation in its specific test.
This solution is very readable and reduces a lot of duplication in the tests. However, the idea of role tests is not only important from the point of view of avoiding duplication in test code. In dynamic languages, such as Ruby, they also serve as a mean to highlight and document the role of duck types that might otherwise go unnoticed because there is no interface construct.
Acknowledgements.
I’d like to thank my Codesai colleagues for reading the initial drafts and giving me feedback.
Notes.
[1] Read more about objects communicating by sending and receiving messages in Alan Kay's Definition Of Object Oriented
[2] You can find a slightly different wording of it in her great talk Less - The Path to Better Design at 29’48’’.
[3] Presented in chapter one of Design Patterns: Elements of Reusable Object-Oriented Software book.
[4] This is similar to J. B. Rainsberger's idea of contract tests mentioned in his Integrated Tests Are A Scam talk and also to Jason Gorman's idea of polymorphic testing.
[5] For example Martin Fowler uses contract test to define a different concept in Contract Test.
References.
- Practical Object-Oriented Design, An Agile Primer Using Ruby, Sandi Metz
- Defining Object-Oriented Design, Sandi Metz
- Less - The Path to Better Design, Sandi Metz
- Design Patterns: Elements of Reusable Object-Oriented Software, Erich Gamma, Ralph Johnson, John Vlissides, Richard Helm
- Liskov Substitution Principle
- Integrated Tests Are A Scam talk, J. B. Rainsberger
- 101 Uses For Polymorphic Testing (Okay... Three), Jason Gorman
Photo from Anna Rye in Pexels
Top comments (0)