Extracting Parameterized Unit Tests

Duplication across tests may not be as harmful as duplication in production code, but a parameterized unit test can reduce duplication and help us create better tests.

Judging Tests – The Pause

Spotting duplication in tests is like spotting it elsewhere – perhaps easier, since related tests are often close together.

We see two methods that have a very similar structure: the same overall text, with a few variations thrown in (e.g., variations in inputs and outputs). We may see many tests with the same structure.

Before eliminating duplication, pause a little and consider the test. Do the tests test different underlying behaviors, or are they just variations that run the same code? Can you tell that you’ve tested all the cases you intended to? Is the test easy to understand, with simple setup, execution, and assertions?

It is often easier to address these issues after fixing duplication. Mechanical fixes are great – but deeper judgment can pay off well.

Test Helper Method – Four Approaches

When I want to create a parameterized test, I start with a test helper method. This will be the seed of the parameterized test.

There are several ways to create this helper method; which approach works best is a function of the IDE and language.

The approaches are:

  1. Extract Method with Duplication Removal
  2. Introduce Parameter Refactoring
  3. Introduce Variable and Extract Method Refactorings
  4. Manual

Whichever approach you use, start by making a duplicate of one of the tests (with a new name) and modifying it.

Extract Method with Duplication Removal

If you’re very lucky, you can Extract Method on the whole body of the method, and the IDE will recognize that a number of methods could use the extracted code, if it parameterizes it a certain way.

This is another case of “Forcing the Right Extract Method” (see References). Examine what the IDE proposes, and make sure it’s using the parameters you want. Make sure it’s going to affect the test methods you expect.

For refactoring that make large changes, tools often provide a preview window that will let you see the impact of the proposed change.

Introduce Parameter Refactoring

If your IDE supports Introduce Parameter, first Extract Method on the whole body of the test. Then, for each place of variation in the new body: select the value, Introduce Parameter, and the IDE will redefine your method definition to take a parameter, and update your call site to pass the right value in.

Repeat this for every place of variation, typically inputs, outputs, and possibly other values.

At this point, your original test method passes in the appropriate values, and your extracted method has parameters for each point of variation.

Introduce Variable then Extract Method

If your IDE doesn’t support Introduce Parameter, do the work before you Extract Method.

Turn the test into two parts: an initial part that is definitions of variables for the varying parts, followed by the body referencing those variables.

Go through each place of desired variation, and Introduce Variable for each one. (It may be called “Extract Variable”, “Extract Local Variable”, or something else.)

Select the code after the variable definitions, and Extract Method. This should give you a parameterized helper method.

Manual

If your tool can’t handle the above approaches – either because it lacks the refactorings or supposedly has them but never seems to turn them on (looking at you, Xcode!), refactor manually. Use either Extract Method + Introduce Parameter or Introduce Variable + Extract Method to create a parameterized test helper method.

A Couple Tweaks

It may be helpful to add one more parameter, usually a string: the “reason” for the test. For the value of this, the test name is a good starting point. (See “Parameterized Unit Testing” in the References for a description of types of parameters.)

Another case that comes up is when there are data values that aren’t used quite the same way. For example, some tests call setName(), others setAddress(), and others use both. You may be able to add some optional parameters that help with these cases. Then the test code may have both calls, something like this:

  :
if name != null { customer.setName(name!) }
if address != null { customer.setAddress(address!) }
  :

Test Helper → Parameterized Test

Testing packages that support parameterized testing have a way to feed data to the test helper method (which will be our main parameterized method).

One approach is to have tags or tuples or something that let you put the test data right on the test helper method. For example, JUnit 5 has @ValueSource, @EnumSource, and @CsvSource attributes to let you do that.

The other common approach is that there’s a way to specify another method that will provide a stream of values for testing. In JUnit 5, this is a @MethodSource or @ArgumentSource.

If your tools don’t support parameterized tests, you can do it yourself. Make a data structure that holds the parameter values, put them in an array, and have your new test iterate through the array and call the test helper method.

However you do it, your next step is to go through duplicated tests one at a time, adding their data to the parameterized call.

Don’t delete the original tests until you’ve run all the new ones and convinced yourself that nothing is messed up.

At that point, delete the (corresponding!) non-parameterized tests, and apply that judgment we talked about earlier: Does the test method need work? Can you clearly understand the story told by the test data in use? Does seeing it all in one place make you want to change anything? Does the code you’re calling support your test well?

Conclusion

When you have tests that are alike except for a little bit of data, consider a parameterized test.

There are several approaches to creating a test helper method that forms the basis for the approach: 1. Extract method with duplication removal; 2. Extract method then introduce parameter; 3. Introduce variable(s) then extract method; 4. Form it manually

Once you have the test helper method, either use the mechanism your test framework provides for getting data to it, or make your own if needed.

Finally, pause before you’re done and consider whether the tests and production code tell the story you want.

References

“Isolate-Inline-Improve: The 3-I Refactoring Tactic”, by Bill Wake. https://www.industriallogic.com/blog/isolate-improve-inline-refactoring-tactic/

“Parameterized Unit Testing”, by Bill Wake. https://xp123.com/articles/parameterized-unit-testing/

“Refactoring: Forcing the Right Extract Method”, by Bill Wake. https://xp123.com/articles/refactoring-forcing-the-right-extract-method/