Tree Specs vs. Exception Specs
After years of fighting, the zombies have been contained and now it’s time to rebuild civilization. Everyone has a particular job to do in this technological reboot and yours is to write a blog platform. It could be worse. Someone is out there inspecting septic tank interiors.
Your post-apocalyptic project manager used to be a newspaper editor and as such he’s a stickler for some journalism best practices. For instance, he wants to make sure that posts cannot be edited after they have been published.
So you write this spec:
RSpec.describe Post do
describe "#editable?" do
context "when the post is published" do
subject { Post.new published_at: Time.now.utc }
it { is_expected.not_to be_editable }
end
context "when the post is not published" do
subject { Post.new published_at: nil }
it { is_expected.to be_editable }
end
end
end
Before the cataclysm, your PM was a bit of a control freak. He almost burst a blood vessel when a writer changed an article that he had already approved. He wants to make sure that cannot happen in your new blog platform.
So you modify your specs to look like this:
RSpec.describe Post do
describe "#editable?" do
subject do
Post.new published_at: published_at, editor_approved: editor_approved
end
context "when the post is published" do
let(:published_at) { Time.now.utc }
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
context "when the editor has not approved" do
let(:editor_approved) { false }
it { is_expected.not_to be_editable }
end
end
context "when the post is not published" do
let(:published_at) { nil }
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
context "when the editor has not approved" do
let(:editor_approved) { false }
it { is_expected.to be_editable }
end
end
end
end
The newspaper industry was struggling even before the end of the world as we knew it and your PM knows how important it is to pinch pennies. To avoid paying translators more than once for the same article he wants to make sure that no one changes an article after it has been translated.
So you modify your spec again. Now it looks like this:
RSpec.describe Post do
describe "#editable?" do
subject do
Post.new(
published_at: published_at,
editor_approved: editor_approved,
translated: translated,
)
end
context "when the post is published" do
let(:published_at) { Time.now.utc }
context "when the editor has approved" do
let(:editor_approved) { true }
context "when the post has been translated" do
let(:translated) { true }
it { is_expected.not_to be_editable }
end
context "when the post has not been translated" do
let(:translated) { false }
it { is_expected.not_to be_editable }
end
end
context "when the editor has not approved" do
let(:editor_approved) { false }
context "when the post has been translated" do
let(:translated) { true }
it { is_expected.not_to be_editable }
end
context "when the post has not been translated" do
let(:translated) { false }
it { is_expected.not_to be_editable }
end
end
end
context "when the post is not published" do
let(:published_at) { nil }
context "when the editor has approved" do
let(:editor_approved) { true }
context "when the post has been translated" do
let(:translated) { true }
it { is_expected.not_to be_editable }
end
context "when the post has not been translated" do
let(:translated) { false }
it { is_expected.not_to be_editable }
end
end
context "when the editor has not approved" do
let(:editor_approved) { false }
context "when the post has been translated" do
let(:translated) { true }
it { is_expected.not_to be_editable }
end
context "when the post has not been translated" do
let(:translated) { false }
it { is_expected.to be_editable }
end
end
end
end
end
This is spec is starting to get pretty long and hard to follow. And being the sort of engineer who is smart enough to survive a zombie apocalypse you can see that the spec doubles in size every time a new condition is added.
That’s not tenable. Let’s rewind and see if we can find a better way.
Before the translation requirement, the spec looked like this:
RSpec.describe Post do
describe "#editable?" do
subject do
Post.new published_at: published_at, editor_approved: editor_approved
end
context "when the post is published" do
let(:published_at) { Time.now.utc }
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
context "when the editor has not approved" do
let(:editor_approved) { false }
it { is_expected.not_to be_editable }
end
end
context "when the post is not published" do
let(:published_at) { nil }
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
context "when the editor has not approved" do
let(:editor_approved) { false }
it { is_expected.to be_editable }
end
end
end
end
The specification is structured like a binary tree with an expectation at each terminal leaf. Every condition we add adds another level to the tree, doubles the number of terminal leaves, and doubles the number of expectations.
Part of the reason you’ve survived this long is your ability to tell humans apart from zombies. When you apply the same pattern-matching skills to this spec you notice one of the expectations is different from all of the rest.
In one situation the post is editable. All the other specs are exceptions to that situation. When you re-factor the specs to reflect that they look more like this:
RSpec.describe Post do
describe "#editable?" do
subject do
Post.new published_at: published_at, editor_approved: editor_approved
end
let(:published_at) { nil }
let(:editor_approved) { false }
it { is_expected.to be_editable }
context "when the post is published" do
let(:published_at) { Time.now.utc }
it { is_expected.not_to be_editable }
end
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
end
end
The spec is now more concise, easier to read, and easier to modify. When you add the translation requirement it looks like this:
RSpec.describe Post do
describe "#editable?" do
subject do
Post.new(
published_at: published_at,
editor_approved: editor_approved,
translated: translated,
)
end
let(:published_at) { nil }
let(:editor_approved) { false }
let(:translated) { false }
it { is_expected.to be_editable }
context "when the post is published" do
let(:published_at) { Time.now.utc }
it { is_expected.not_to be_editable }
end
context "when the editor has approved" do
let(:editor_approved) { true }
it { is_expected.not_to be_editable }
end
context "when the post has been translated" do
let(:translated) { true }
it { is_expected.not_to be_editable }
end
end
end
When your objects have one normal case and a series of exceptions it makes sense to organize your specs that way. They will be shorter, clearer, easier to understand, and easier to modify; exactly what you will need them to be in order to thrive in the post-zombie apocalypse world.