次の方法で共有


Test-Specific APIs With Extension Methods

Writing maintainable tests that clearly communicate intent can sometimes be a challenging undertaking. Ideally, a unit test suite should act as executable documentation and be robust when faced with refactoring of the SUT. Some software lends itself quite naturally towards clear and concise test code, while other SUTs naturally pull you in the direction of obscure and fragile tests. The remedy is SUT API Encapsulation in the form of helper methods and classes that hide any unnecessary complexity of the SUT during each test.

Such a test-specific API can be implemented with extension methods, which can increase readability of the test code, and as such help ensure that the tests can act as executable documentation.

State machines provide very good examples of the need for SUT API Encapsulation, since the legal state transitions may often force you to take a long detour through intermediate states to be able to test a specific state. As an example, consider a state machine called MyStateMachine, that can be in either of the states A, B, C, or D. In this example, the API and legal state transitions are complex; for instance, to go from A to B, you must invoke a complex method:

 public void FirstComplexOperation(DateTime anonymousDate, Something s)

The type Something used as a parameter is in itself a complex type, and to make a legal transition from A to B, you need to provide specific values for the members of that class.

Writing a unit test for the transition from A to B is complex, but manageable. However, the legal transition from B to D is another complex operation involving specific values, so now the unit test is already beginning to look obscure:

 [TestMethod]
 public void WhenInStateBSettingComplexPropertyWillTransitionToStateD()
 {
     // Fixture setup
     MyState expectedState = MyState.D;
  
     string anonymousS = "Anonymous string";
     int i = 8;
     decimal anonymousD = 8.34m;
     Something s = new Something(anonymousS, i, anonymousD);
  
     DateTime anonymousDate = new DateTime(2008, 5, 30);
  
     string anonymousOther = "Other anonymous string";
     bool anonymousBool = true;
     Floobudizer<bool> floob = new Floobudizer<bool>(anonymousOther, anonymousBool);
  
     MyStateMachine sut = new MyStateMachine();
  
     // Need to get into state B first
     sut.FirstComplexOperation(anonymousDate, s);
  
     // Exercise system
     sut.ComplexThing = floob;
     MyState result = sut.State;
     // Verify outcome
     Assert.AreEqual<MyState>(expectedState, result, "State");
     // Teardown
 }

When you look at this test, it's not very clear which parts of the fixture setup that are relevant to the test, and which parts are there only to get the SUT into the desired state (B). I've even had to resort to an apology to explain that FirstComplexOperation is being invoked to transition to state B. Imagine what a test will be like if you want to test the transition from D to C in this way: Not very readable.

A conventional implementation of SUT API Encapsulation involves moving all the irrelevant fixture setup code to a utility method: In this case a private static method on the test class called MoveToStateB. It takes the SUT as a parameter, as well as the integer called i in the previous test; imagine that this parameter is a critical parameter that I need to vary in different tests. While I'll leave the implementation of MoveToStateB to the interested reader, here's how the refactored test looks:

 [TestMethod]
 public void WhenInStateBSettingComplexPropertyWillTransitionToStateDRefactored()
 {
     // Fixture setup
     MyState expectedState = MyState.D;
  
     string anonymousOther = "Other anonymous string";
     bool anonymousBool = true;
     Floobudizer<bool> floob = new Floobudizer<bool>(anonymousOther, anonymousBool);
  
     MyStateMachine sut = new MyStateMachine();
     MyStateMachineTest.MoveToStateB(sut, 8);
  
     // Exercise system
     sut.ComplexThing = floob;
     MyState result = sut.State;
     // Verify outcome
     Assert.AreEqual<MyState>(expectedState, result, "State");
     // Teardown
 }

Better, but still not quite there...

While it's more obvious what's going on (I got rid of the apology, among other improvements), the sut parameter sort of drowns in the call to MoveToStateB, as it just sits there as one among other parameters to the method call. If the number of parameters had been larger it would only have exacerbated this issue.

This is where extension methods come in handy. Instead of implementing MoveToStateB as a private utility method, I can implement it as an extension method (again, I'll leave the details to the interested reader), and the test now looks like this:

 [TestMethod]
 public void WhenInStateBSettingComplexPropertyWillTransitionToStateDRefactoredWithExtensionMethod()
 {
     // Fixture setup
     MyState expectedState = MyState.D;
  
     string anonymousOther = "Other anonymous string";
     bool anonymousBool = true;
     Floobudizer<bool> floob = new Floobudizer<bool>(anonymousOther, anonymousBool);
  
     MyStateMachine sut = new MyStateMachine();
     sut.MoveToStateB(8);
  
     // Exercise system
     sut.ComplexThing = floob;
     MyState result = sut.State;
     // Verify outcome
     Assert.AreEqual<MyState>(expectedState, result, "State");
     // Teardown
 }

The number of code lines are the same as before, but this is much more communicative, since it's immediately obvious that the sut variable is the one being modified.

While it's obvious to think that I could have done the same by deriving from MyStateMachine and added MoveToStateB to the derived class, there are subtle differences:

  • You wouldn't be testing the real class, but a derived class that only exists for testability purposes.
  • You can't be sure that someone else doesn't come along at a later date and overrides a virtual method on the SUT. This could change the behavior of the SUT and break the test. Worst of all, it may produce a false negative.
  • This approach doesn't work if the SUT is sealed.

Extension methods comes with the overhead of managing new, test-specific namespaces, since you should always put extension methods in a separate namespace, but used wisely, they are a great addition to your arsenal of unit testing techniques.

Comments