technical skills - testing without mocks - logic patterns

Logic code is pure computation. To qualify, code can’t involve external systems or state. That means it can’t talk to a database, communicate across a network, touch the file system, read the date and time, look at environment variables, or use most random number generators. It can’t depend on any code that does these things, either.

Pure computation is easy to test. The following patterns make it even easier.

Easily-Visible Behavior

Logic computation can only be tested by State-Based Tests if the results of the computation are visible to tests. Therefore:

Prefer pure functions where possible. Pure functions’ return values are determined only by their input parameters.

// JavaScript
function add(a, b) {
  return a + b;
}

When working with objects, prefer immutable objects, which are the object-oriented equivalent of pure functions. The state of immutable objects is determined when the object is constructed, and never changes afterwards.

// JavaScript
class Value {
  constructor(initialValue) {
    this._value = initialValue;
  }
 
  plus(addend) {
    return new Value(this._value + addend);
  }
}

For mutable objects, provide a way for changes in state to be observed, either with a getter method or an event.

// JavaScript
class RunningTotal {
  constructor(initialValue) {
    this._total = initialValue;
  }
 
  add(addend) {
    this._total += addend;
  }
 
  getTotal() {
    return this._total;
  }
}

In all cases, avoid writing code that explicitly depends on (or changes) the state of dependencies more than one level deep. That makes test setup difficult, and it’s a sign of poor design anyway. Instead, design dependencies so they completely encapsulate their next-level-down dependencies.

Testable Libraries

Third-party code doesn’t always have Easily-Visible Behavior. It also tends to introduce breaking API changes with new releases, or simply stop being maintained. Therefore:

Wrap third-party code in code you control. Ensure your application’s use of the third-party code is mediated through your wrapper. Write your wrapper’s API to match the needs of your application, not the third-party code, and add methods as needed to provide Easily-Visible Behavior. (This will typically involve writing getter methods to expose deeply-buried state.) When the third-party code introduces a breaking change, or needs to be replaced, modify the wrapper so no other code is affected.

Frameworks and libraries with sprawling APIs are more difficult to wrap, so prefer libraries that have a narrowly-defined purpose and a simple API.

Some third-party code is pervasive and stable, such as core language frameworks. Other code, such as UI frameworks, can be very costly to wrap. You may be better off not creating a wrapper for these cases.

If the third-party code interfaces with an external system or state, use an Infrastructure Wrapper instead.

Collaborator-Based Isolation

Overlapping Sociable Tests ensure your tests will fail if your code’s behavior changes, no matter how far down the dependency chain those changes may be. On the one hand, this is nice, because you’ll learn when you accidentally break something. On the other hand, this could make feature changes terribly expensive. We don’t want a change in the formatting of addresses to break hundreds of unrelated reports’ tests. Therefore:

When a dependency’s behavior isn’t relevant to the code under test, use the dependency to help define test expectations. For example, if you’re testing a report that includes an address in its header, don’t hardcode “123 Main St.” as your expectation. Instead, ask the address how it would render itself, and use that as part of your test expectation.

Be careful not to write tests that are a copy of the code under test. Collaborator-Based Isolation is for writing Narrow Tests that ignore irrelevant details. For example, in the following code, the test is checking the special case of a report with a single address, not the behavior of address rendering. Address rendering is expected to have its own Narrow Tests.

// JavaScript
 
// Example test
it("includes the address in the header when reporting on one address", () => {
  // Instantiate the unit under test and its dependency
  const address = Address.createTestInstance();                 // Parameterless Instantiation
  const report = new InventoryReport(Inventory.create(), [ address ]);
 
  // Define the expected result using the dependency
  const expected = "Inventory Report for " + address.renderAsOneLine();
 
  // Run the production code and make the assertion
  assert.equal(report.renderHeader(), expected);
});
 
// Example production code
class InventoryReport {
  constructor(inventory, addresses) {
    this._inventory = inventory;
    this._addresses = addresses;
  }
 
  renderHeader() {
    let result = "Inventory Report";
    if (this._addresses.length === 1) {
      result += " for " + this._address[0].renderAsOneLine();
    }
    return result;
  }
}

This provides the best of both worlds: Overlapping Sociable Tests ensure that your application is wired together correctly and Collaborator-Based Isolation allows you to change behavior without breaking a lot of tests. However, it also ties the tests more tightly to the production code’s implementation, so it should be used sparingly.