Ever since reading Alex Eagle’s article Change-Detector Tests Considered Harmful, whenever I write I a unit test, I ask myself, “is this a change detector?” It’s a useful reflex to have, but I think Eagle’s article wasn’t completely clear on what it means for a unit test to be a change detector. In this article, I’ll provide concrete examples delineating the difference between change detectors and good unit tests.

Unit Tests Test Value

The change detector concept was better articulated later, by Lukas Atkinson in this StackOverflow answer. Atkinson makes several important points.

  1. The purpose of a unit test is to test the value which the unit under test adds to the application.
  2. There is value in using an abstract parts combinator.
  3. Looking at Eagle’s unit test example, we don’t know if the Processor class is an abstract parts combinator.

An Abstract Whatinator?

“Combinator” is a math term (and a functional programming term). Its technical meaning is irrelevant for this discussion. In this article, think of it as a component which has subcomponents which are usable elsewhere.

Breaking Down the Example

Let’s rewrite Eagle’s unit test in Java. It would look something like this.

  @Test
  void testProcessing() {
      var part1 = mock(FirstPart.class);
      var part2 = mock(SecondPart.class);
      var w = new Work();
      new Processor(part1, part2).process(w);
      var inOrder = inOrder(part1, part2);
      inOrder.verify(part1).process(w);
      inOrder.verify(part2).process(w);
  }

And here is a minimal implementation of the implied classes.

  class Work {}
  interface SubProcessor {
      void process(Work w);
  }
  interface FirstPart extends SubProcessor {}
  interface SecondPart extends SubProcessor {}

  /** class under test */
  class Processor {
      private final FirstPart firstPart;
      private final SecondPart secondPart;

      Processor(FirstPart firstPart, SecondPart secondPart) {
          this.firstPart = firstPart;
          this.secondPart = secondPart;
      }
      
      public void process(Work w) {
          firstPart.process(w);
          secondPart.process(w);
      }
  }

Per Atkinson, the reason that we can’t tell if the testProcessing unit test is worthwhile is that we don’t know what this code is doing.

Atkinson provides two cases, one to demonstrate how the testProcessing unit test would be a useless change detector, and one to demonstrate how it would be a good unit test.

Change Detector Case: Woeful Interest Calculator

The example Atkinson uses to demonstrate how the testProcessing unit test would be a change detector is an interest calculator in which the calculation is split into two parts.

To make this concrete, here is a very basic implementation of said interest calculator which is structurally identical to the Processor class above. (Ignore the fact that you’d never write an interest calculator this way, mutating some object via void methods.)

  class InterestCalculation {
      final BigDecimal principal;
      final BigDecimal rate;
      final Integer years;
      BigDecimal principalMultiplier;
      BigDecimal accrued;
      /* impl... */
  }
  interface SubProcessor {
      void process(InterestCalculation w);
  }
  interface RateTimesYearsPlusOne extends SubProcessor {}
  interface TimesPrincipal extends SubProcessor {}

  /** class under test */
  class AccruedInterestCalculator {
      private final RateTimesYearsPlusOne rateTimesYearsPlusOne;
      private final TimesPrincipal timesPrincipal;
      AccruedInterestCalculator(RateTimesYearsPlusOne rateTimesYearsPlusOne, TimesPrincipal timesPrincipal) {
          this.rateTimesYearsPlusOne = rateTimesYearsPlusOne;
          this.timesPrincipal = timesPrincipal;
      }
      public void process(InterestCalculation w) {
          rateTimesYearsPlusOne.process(w);
          timesPrincipal.process(w);
      }
  }

Notice that, as Atkinson points out, if the interest calculation is wrong, this test still passes.

  @Test
  void testProcessing() {
      var part1 = mock(RateTimesYearsPlusOne.class);
      var part2 = mock(TimesPrincipal.class);
      var w = new InterestCalculation(/* values... */);
      new AccruedInterestCalculator(part1, part2).process(w);
      var inOrder = inOrder(part1, part2);
      inOrder.verify(part1).process(w);
      inOrder.verify(part2).process(w);
  }

This implementation is so horrendous that the best you could likely do with unit testing it is to make sure that both halves of the calculation actually happened (and then test the implementations elsewhere), but that’s mostly irrelevant. The point is, given how AccruedInterestCalculator works, the testProcessing test tests no added value and breaks when the implementation changes. It is a change detector.

The Responsibilities of the Unit

As an aside, if AccruedInterestCalculator were doing anything beyond what its subcomponents do (for example, applying an early withdrawal penalty rate after the other calculations), there would be something of substance to test.

      public void process(InterestCalculation w) {
          rateTimesYearsPlusOne.process(w);
          timesPrincipal.process(w);
          var penalty = new BigDecimal("0.5");
          w.accrued = w.accrued.multiply(penalty);
      }

This is what Atkinson means when he talks about the unit under test having a “more concrete responsibility within the problem domain”.

With our unfortunate implementation (which mutates values via void methods), you’d have to test that penalty with a fake instead of a mock.

  @Test
  void testPenalization() {
      var part1 = mock(RateTimesYearsPlusOne.class);
      TimesPrincipal part2 = w -> w.accrued = BigDecimal.TEN;
      var w = new InterestCalculation();
      new AccountBalanceCalculator(part1, part2).process(w);
      assertEquals(new BigDecimal("5.0"), w.accrued);
  }

Good Unit Test Case: Event Handler

The example which I will use to demonstrate how the testProcessing unit test would be a good unit test is a late payment event handler.

Though LatePaymentEventHandler is structurally identical to the original Processor class, the testProcessing unit test is actually useful for LatePaymentEventHandler because LatePaymentEventHandler is a combinator: its subcomponents, CreditCardCharger and a ReceiptEmailer, are useful elsewhere, and its value is in the way it combines them. This is Atkinson’s second case.

  interface Charge { /* methods... */ }
  interface Contact { /* methods... */ }
  class LatePaymentEvent implements Charge, Contact {
      final String email;
      final String accountNumber;
      final BigDecimal amount;
      /* impl... */
  }
  interface SubProcessor<T> {
      void process(T w);
  }
  interface CreditCardCharger extends SubProcessor<Charge> {}
  interface ReceiptEmailer extends SubProcessor<Contact> {}

  /** class under test */
  class LatePaymentEventHandler {
      private final CreditCardCharger creditCardCharger;
      private final ReceiptEmailer receiptEmailer;
      LatePaymentEventHandler(CreditCardCharger creditCardCharger, ReceiptEmailer receiptEmailer) {
          this.creditCardCharger = creditCardCharger;
          this.receiptEmailer = receiptEmailer;
      }
      public void process(LatePaymentEvent w) {
          creditCardCharger.process(w);
          receiptEmailer.process(w);
      }
  }

The value-add here is that the CreditCardCharger is used before the ReceiptEmailer, presumably throwing an error if something goes wrong and preventing an email from being sent out if the customer wasn’t actually charged. In this case, the subcomponent order does matter and so testing the subcomponent order is worthwhile.

  @Test
  void testProcessing() {
      var part1 = mock(CreditCardCharger.class);
      var part2 = mock(ReceiptEmailer.class);
      var w = new LatePaymentEvent(/* values... */);
      new LatePaymentEventHandler(part1, part2).process(w);
      var inOrder = inOrder(part1, part2);
      inOrder.verify(part1).process(w);
      inOrder.verify(part2).process(w);
  }

Lesson

The structure of the unit under test isn’t enough to determine whether or not a unit test is a change detector. You have to understand the work it is doing and how that adds value to the application, and test that.