Clean Code: Writing Readable Unit Tests with Domain Specific Languages

I'm currently reading Clean Code: A Handbook of Agile Software Craftsmanship by 'Uncle Bob' Martin, which includes a section on writing readable unit tests. I've had a blog about using Domain Specific Languages (DSLs) to write readable unit tests in the works for a while now, and was inspired inspired to finish it off using a 'clean' unit test from the book.

Here's the original, 'smelly' unit test from the book; I've turned it into C# and NUnit (from Java and JUnit) simply because that's the language I use. The test is for an environment control system, and "checks that the low temperature alarm, the heater, and the blower are all turned on when the temperature is 'way too cold.' " [1]

[Test]
public void TurnOnLowTemperatureAlarmAtThreshold()
{
  environment.Temperature = WAY_TOO_COLD;
  controller.Tick();
  Assert.True(environmentalSystem.IsHeaterOn);
  Assert.True(environmentalSystem.IsBlowerOn);
  Assert.False(environmentalSystem.IsCoolerOn);
  Assert.False(environmentalSystem.IsHighTemperatureAlarmOn);
  Assert.True(environmentalSystem.IsLowTemperatureAlarmOn);
}

The book goes on to refactor the test into the below, 'clean' version:

[Test]
public void TurnOnLowTemperatureAlarmAtThreshold()
{
  wayTooCold();
  Assert.Equals("HBchL", environment.State);
}

The explanation for the magic string in the Assert.Equals() is as follows: "Upper case means “on,” lower case means “off,” and the letters are always in the following order: {heater, blower, cooler, hi-temp-alarm, lo-temp-alarm}." Now in my opinion, that's not particularly clean. It's certainly cleaner than the previous version, but I don't really like the magic string and the specialist knowledge required to understand it. If you came back to this test in 3 months, how easily would you be able to tell what it was testing? I think this part of the book demonstrates an improvement rather than an ideal end goal, but I'm pretty sure we can make it cleaner.

You create a DSL in a test class by setting up methods and properties which form a test vocabulary. You then chain and combine your test vocabulary to form sentences which state the test conditions and expected results. I find NUnit's Constraint model lends itself perfectly to it, and using that I'd write the above test like this:

[Test]
public void TurnOnLowTemperatureAlarmAtThreshold()
{
  Assert.That(
    TheEnvironmentMonitor(WhenTheEnvironmentIs(WayTooCold)),
    SwitchesThe(
      Heater(On),
      Blower(On),
      Cooler(Off),
      HighTemperatureAlarm(On),
      And(LowTemperatureAlarm(Off))));
}

This gives us a test we can read as a sentence, stating as clearly as possible what the test demonstrates. The DSL is really an abstraction of the code which sets up the test and runs assertions against the system's state afterwards. The above properties and methods include those specific to the environmental system being tested, and generic 'helper' methods like And(), which I use solely to chain clauses together and improve readability. These methods look like this:

private static T And<T>(T item)
{
  return item;
}

A method which does basically nothing can look a little jarring when you're not used to writing tests like this, but the requirements of test code are different from production code; readability is more important than performance, and a method like this which helps the test more clearly state its purpose becomes essential.

Assert.That() takes two arguments; the first is the object under test, and the second is an NUnit Constraint which performs an assertion on the test result to find out if the test passed. NUnit takes care of passing the object under test to the Constraint for you.

I've found a good way of setting up objects under test in this model is to set up an object in a 'normal' state, then pass in a collection of Action objects which alter state to match the condition being tested. So in this test, TheEnvironmentMonitor() would set up the object under test, and WayTooCold would return an Action which alters it to be in the 'cold' state so it can be tested (remember that WhenTheEnvironmentIs() doesn't do anything - it's just syntactic sugar). For example:

private static EnvironmentMonitor TheEnvironmentMonitor(
  params Action<Environment>[] testSetupMethods)
{
  Environment environment = new Environment();
  EnvironmentMonitor monitor = new EnvironmentMonitor(environment);
  EnvironmentController controller = new EnvironmentController(environment);

  foreach (var testSetupMethod in testSetupMethods)
  {
    testSetupMethod.Invoke(environment);
  }

  controller.Tick();

  return monitor;
}

Notice that the method receives a params array - this provides two benefits. Firstly, setup clauses can be passed comma-separated into the method in the order you want them executed, which lends itself to readability. Secondly, this argument will never be null (it'll be an empty array if no clauses are passed in) and the test method can happily iterate over it without bothering to check. Our WayTooCold setup 'method' (it's actually a property) then looks like this:

private static Action<Environment> WayTooCold
{
  get { return environment => environment.Temperature = WAY_TOO_COLD; }
}

The SwitchesThe() method takes a series of clauses which each test a different aspect of the state of the system. A nice feature of NUnit constraints is that they can be combined, and that's what SwitchesThe() needs to do. I have a helper method which does that, so SwitchesThe() can just call it:

private static Constraint SwitchesThe(params Constraint[] constraints)
{
  return CombineConstraints(constraints);
}

private static Constraint CombineConstraints(Constraint[] constraints)
{
  Constraint combinedConstraints = constraints.First();

  foreach (var constraint in constraints.Except(new[] { constraints.First() }))
  {
    combinedConstraints &= constraint;
  }

  return combinedConstraints;
}

Finally, we need to define the various methods which pass Constraints into SwitchesThe(), and the On and Off helper properties which are used to supply the expected values. The Constraints just compare a property value to an expected value, so there's a generic helper method (using my FriendlyPredicateConstraint) for that, too; here's two of them, the On property and the helper method:

private static bool On
{
  get { return true; }
}

private static Constraint Heater(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsHeaterOn);

    expectedIsOnValue);
}

private static Constraint Blower(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsBlowerOn);

    expectedIsOnValue);
}

private static Constraint IsPropertyValueAsExpected<TObj, TProp>(
  Expression<Func<TObj, TProp>> propertyExpression,
  TProp expectedValue)
{
  string propertyName = ((MemberExpression)propertyExpression.Body).Member.Name;
  Func<TObj, TProp> propertyValueGetter = propertyExpression.Compile();

  return new FriendlyPredicateConstraint<TObj>(
    obj => (propertyValueGetter(obj) != null) && propertyValueGetter(obj).Equals(expectedValue),
    actualValue: obj => propertyName + " = " + propertyValueGetter(obj),
    expectedValue: propertyName + " = " + expectedValue);
}

Part of the beauty of this approach is that the vocabulary we've defined can easily be used to create other tests, each one of which clearly states what it is testing. I remember reading that unit tests should be thought of as executable specifications; this is the best technique I've found for bringing them closer to that idea.

The full test code can be found below.

[Test]
public void TurnOnLowTemperatureAlarmAtThreshold()
{
  Assert.That(
    TheEnvironmentMonitor(WhenTheEnvironmentIs(WayTooCold)),
    SwitchesThe(
      Heater(On),
      Blower(On),
      Cooler(Off),
      HighTemperatureAlarm(On),
      And(LowTemperatureAlarm(Off))));
}

#region Syntax Helper Members

private static EnvironmentMonitor TheEnvironmentMonitor(
  params Action<Environment>[] testSetupMethods)
{
  Environment environment = new Environment();
  EnvironmentMonitor monitor = new EnvironmentMonitor(environment);
  EnvironmentController controller = new EnvironmentController(environment);

  foreach (var testSetupMethod in testSetupMethods)
  {
    testSetupMethod.Invoke(environment);
  }

  controller.Tick();

  return monitor;
}

private static T WhenTheEnvironmentIs<T>(T item)
{
  return item;
}

private static Action<Environment> WayTooCold
{
  get { return environment => environment.Temperature = WAY_TOO_COLD; }
}


private static Constraint SwitchesThe(params Constraint[] constraints)
{
  return CombineConstraints(constraints);
}


private static Constraint Heater(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsHeaterOn);

    expectedIsOnValue);
}

private static Constraint Blower(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsBlowerOn);

    expectedIsOnValue);
}


private static Constraint Cooler(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsCoolerOn);

    expectedIsOnValue);
}

private static Constraint HighTemperatureAlarm(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsHighTemperatureAlarmOn);

    expectedIsOnValue);
}

private static Constraint LowTemperatureAlarm(bool expectedIsOnValue)
{
  return IsPropertyValueAsExpected<EnvironmentMonitor, bool>(
    monitor => monitor.IsLowTemperatureAlarmOn);

    expectedIsOnValue);
}


private static bool On
{
  get { return true; }
}

private static bool Off
{
  get { return false; }
}

private static T And<T>(T item)
{
  return item;
}



#endregion

#region Helper Methods

private static Constraint IsPropertyValueAsExpected<TObj, TProp>(
  Expression<Func<TObj, TProp>> propertyExpression,
  TProp expectedValue)
{
  string propertyName = ((MemberExpression)propertyExpression.Body).Member.Name;
  Func<TObj, TProp> propertyValueGetter = propertyExpression.Compile();

  return new FriendlyPredicateConstraint<TObj>(
    obj => (propertyValueGetter(obj) != null) && propertyValueGetter(obj).Equals(expectedValue),
    actualValue: obj => propertyName + " = " + propertyValueGetter(obj),
    expectedValue: propertyName + " = " + expectedValue);
}

private static Constraint CombineConstraints(Constraint[] constraints)
{
  Constraint combinedConstraints = constraints.First();

  foreach (var constraint in constraints.Except(new[] { constraints.First() }))
  {
    combinedConstraints &= constraint;
  }

  return combinedConstraints;
}

#endregion

[1] Clean Code: A Handbook of Agile Software Craftsmanship  - page 159

Print | posted @ Monday, February 13, 2012 8:35 PM

Comments on this entry:

Gravatar # re: Clean Code: Writing Readable Unit Tests with Domain Specific Languages
by Brad at 2/14/2012 7:19 PM

I drastically prefer the first version. The second is silly and the 3rd is so far into overcomplication land there is no return.
Gravatar # re: Clean Code: Writing Readable Unit Tests with Domain Specific Languages
by Steve Wilkes at 2/15/2012 9:13 AM

I've heard that reaction quite a bit - a colleague of mine said "that's not code - it's English!". It's all personal taste of course, but not only is it not that complicated once you get used to it, I find it a much more expressive way of writing tests.
Gravatar # re: Clean Code: Writing Readable Unit Tests with Domain Specific Languages
by Sam Shiles at 3/7/2012 1:02 PM

Hi Brad,

I've had the (mis?)fortune ;) of working with Steve and have spent considerable time writing tests that conform to this pattern. My initial reaction to this method of unit testing was similar to yours.

It appears very complicated at first but once you've built up a decent vocabulary of conjunctive generic methods and a set of useful constraints, it actually makes writing unit tests, in my opinion, much quicker and much simpler.

The main benefit is that it helps to isolate the arrange, act and assert into extremely easy to write individual methods and properties; makes the test code very communicative and easy to refactor; makes going back to the tests after a period of absence a pleasure rather than a chore; acts as great documentation of the objects under test; and allows for quite a lot of reuse within the tests themselves.

There is an argument that this method of testing is perhaps better suited to integration and acceptance testing where high-level, very readable code is of a greater advantage, but that is likely to a matter of personal preference. You could also argue that a tool like Specflow is better suited to this purpose.

Other disadvantages to this method is inherent verbosity (especially when making very simple assertions, where perhaps the traditional assertion model is better suited); a distribution of Arrange, Act and Assert across disparate methods and properties which makes understanding the underlying operations difficult to see at a glance; and the need to change the conjunctive words used when refactoring due to the rules of the English language. And of course, added complexity.

I no longer work with Steve but I do still use this method of unit testing in the majority of cases, but, as with all things code, each situation requires a judgement call and a single pattern is never going to be the best option in all cases. YMMV!

Cheers
Sam
Post A Comment
Title:
Name:
Email:
Comment:
Verification: