GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Testing Null Objects

A Null Object is a drop in replacement for one of the other objects in your system that provides sensible defaults when the other object is unavailable. For example, I recently wrote about returning the path to a blank partial as a sensible default for to_partial_path. Null Objects remove respond_to? and nil? checks, and make code cleaner and easier to understand. It almost seems like there's no downside. Almost.

Too good to be true

As with everything else in software development, there is a trade-off involved in introducing a Null Object: Whenever we change the public interface of the real object, we have to make a corresponding change in the Null Object that shadows it. If the interfaces diverge, then the Null Object ceases to be useful; instead of hiding complexity, it hides the potential for unexpected NoMethodError exceptions. I might even go so far as to say that Null Objects usually smell a little bit like Shotgun Surgery.

Unit tests to the rescue

Our Null Objects are only doing their job when they have the same public interface as another object, so we should treat this like any other expectation we have about the public interface of one of our objects and ensure it with a unit test.

To ensure this requirement I recently added a test that looked something like this:

describe NullGraph do
  it 'exposes the same public interface as Graph' do
    expect(described_class).to match_the_interface_of Graph
  end
end

When the public interface of NullGraph includes all of the methods from Graph's public interface then the test passes. When the interfaces differ, the test fails with a helpful message telling me which methods are missing from NullGraph. When I see this test fail, I can add another test with an expectation of what the Null Object's implementation of the missing method should return.

This is all made possible by a custom RSpec matcher:

RSpec::Matchers.define :match_the_interface_of do
  match do
    missing_methods.empty?
  end

  failure_message_for_should do
    "expected #{actual.name} to respond to #{missing_methods.join(', ')}"
  end

  def missing_methods
    expected_methods - actual_methods - common_methods
  end

  def expected_methods
    expected.map(&:instance_methods).flatten
  end

  def actual_methods
    actual.instance_methods
  end

  def common_methods
    Object.instance_methods
  end
end

The big picture

I've found this technique and this matcher to be useful, and I hope you do too. There's a more important principle at work here though: Everything is a trade-off, even good (or trendy) solutions add complexity. We should always be asking ourselves what form that complexity takes, so that we can understand it and mitigate its effects on our ability to change the code in future.

When it comes to thinking about the trade-offs involved in your design decisions, I can heartily recommend POODR by Sandi Metz, and thoughtbot's own Ruby Science. Both books have helped me see these issues more clearly.

What's next?

If you found this useful, you might also enjoy: