Extension Methods for TDD

Some languages (e.g., Swift, C#, Java) support extension methods. These let you add new methods to an existing class, even a system class. Extension methods support testing by providing convenient improvements in control and visibility.

Extension Methods

I’ll show Swift code as each language has its own syntax.

Suppose you have a class:

class Tree {
  // fields
  // methods
} 

To add a new method, you declare an extension (often in a separate file, located with the tests if it’s only available to them):

extension Tree {
  func dump() -> String { ... }
  // more methods (but no fields)
}

A caller can call the new method as if it were originally part of the class.

Maintain or Violate Encapsulation

Extension methods may maintain encapsulation, or they may violate it.

Encapsulation is the idea that a class has two aspects:

  • a public interface – a set of fields and methods it makes available to its callers
  • a “secret” – the details of how the implementation works.

The goal of encapsulation is that if the class changes its secret (its implementation), but maintains the public interface, callers don’t have to change. This lets us evolve classes and their clients independently.

Extension methods maintain encapsulation when they use only the public methods. They’re thus providing a more readable way to do things that can already be done. A change of implementation doesn’t affect the tests that use these.

Extension methods violate encapsulation when they reveal or use parts of the secret. This couples your test to implementation, and a change of implementation can break your tests. Consider finding another approach.

Extension Methods for Control

Tests often need to force an object into a particular state.

I had an example of this in my kilt calculator project. Driving the calculator into a particular state requires multiple steps:

calculator.press(.digit(3))
calculator.press(.plus)
calculator.press(.digit(2))
calculator.press(.equals)
calculator.press(.clear)
assert calculator.previous == "5"
assert calculator.display == "0"

This is accurate but tedious. With a little work, I defined a mini-language that simulated each keypress. This helps the test greatly. It could be a test helper method, but it reads even better as an extension:

calculator.enter("3+2=C")
assert calculator.previous == "5"
assert calculator.display == "0"

This is a shortcut to things you can do with public methods, so it doesn’t violate encapsulation.

Extension Methods for Visibility

Tests also need to examine objects to check whether they’ve been changed appropriately.

For example, I was checking that a parser built an appropriate tree structure. One way to do this is to walk through the tree and check the parts, something like this (pseudocode):

A tree for 1+2*3
let tree = parser.parse("1+2*3")
assert tree.name == "+"
let left = tree.left
assert left.isLeaf
assert left.name == "1"
let right = tree.right
assert !right.isLeaf
assert right.name == "*"
assert right.left.isLeaf
assert right.left.name == "2"
assert right.right.isLeaf
assert right.right.name == "3"

This is tedious when you have to check many trees. Instead, I defined an extension method on tree to display the whole tree in a Lisp-like string:

let tree = parser.parse("1+2*3")
assert tree.dump() == "(+ 1 (* 2 3))"

No production code needs that dump routine. If you write it as a standalone test helper, it has feature envy relative to the Tree class. Making it an extension method on Tree makes it available to other tests. It’s concise enough that we can write many tests as parameterized tests.

Visibility Violating Encapsulation

Suppose we are testing an editor with the usual facilities to insert, delete, move the cursor, and so on. It uses a “buffer gap” implementation.

A buffer gap - text to the left of the editing point, followed by the buffer gap, with text to the right of the editing point at the far right

A buffer gap uses an array with more space than the text is expected to need. When someone inserts or deletes text, the text is divided so that the text before the cursor is at the left, the text after the cursor is at the right, and the space in the middle is the buffer gap, where the editing happens.

Since the buffer gap is a little complicated, we want to examine its state in our tests. We have an encapsulation problem: the buffer gap is not part of the public interface, so our test would be implementation-dependent.

An extension method providing the gap information would be expedient, letting us check that the right thing happens on insert, delete, or move. It would work, but I don’t prefer this option.

Instead, I’d use another approach: split out the buffer gap to its own class. Test the top-level class through its interface, and test the buffer gap with its public interface (which will include information about the gap). We’ll test the buffer gap at a lower level, so we can more easily drive out its behavior in corner cases.

Conclusion

Extension methods let us add to classes in a way that makes testing (and test-driving) more convenient, without affecting the production uses of a class. However, extension methods have access to the internals of a class, so their use requires care if you don’t want to break encapsulation.