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.
- The purpose of a unit test is to test the value which the unit under test adds to the application.
- There is value in using an abstract parts combinator.
- 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.
And here is a minimal implementation of the implied classes.
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.)
Notice that, as Atkinson points out, if the interest calculation is wrong, this test still passes.
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.
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.
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.
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.
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.