Strategy design pattern in parameterized tests for Java

Adam Kotwasinski
4 min readJun 28, 2021

--

Parameterized tests in Java allow us to avoid duplication when writing multiple tests. I have found them to be very useful while writing system tests for client libraries, that need to provide very similar behaviour depending while slightly different user input is provided.

Parameterized tests are very expressive, we could use them not only to provide simple datatype arguments (such as strings or ints), but also use strategy design pattern to delegate a lot of setup / verification work to these configuration objects.

Let’s take a very simple example: a PizzaFactory that produces Pizza objects with flavour (type) that depends on consumer’s PizzaRequest, or uses factory’s default flavour (which is also configurable), if user did not specify any.

public class Pizza {
private final String type;
public Pizza(final String type) { this.type = type; }
public String getType() { return this.type; }
}
public class PizzaRequest {
private Optional<String> type = Optional.empty();
public PizzaRequest withType(final String type) {
Objects.requireNonNull(type);
this.type = Optional.of(type);
return this;
}
public Optional<String> getType() {return this.type;}
}
public class PizzaFactory {
@VisibleForTesting
static final String DEFAULT_PIZZA_TYPE = "margherita";
private String type = DEFAULT_PIZZA_TYPE;
public void setDefaultType(final String type) { this.type = type;} // "pizza business logic"
public Pizza makePizza(final PizzaRequest request) {
final String resultType = request.getType().orElse(this.type);
return new Pizza(resultType);
}
}

Now, we would want to write a test.
If we take a trivial approach, we’d end up with scenarios looking like these:

@Test
public void shouldCreatePizzaWithUserRequestedTypeOverFactoryOne() {
// given
final PizzaFactory pf = new PizzaFactory();
pf.setDefaultType("mushroom");
final PizzaRequest request = new PizzaRequest()
.withType("cheese");
// when
final Pizza result = pf.makePizza(request);
// then
assertThat(result.getType(), equalTo("cheese"));
}

But this covers only one of cases, we’d need at least 3 more tests — for situations when nothing is configured, only factory is configured, only request is configured.

In our “pizza” example, we can simply write these tests — as there are only two dimensions of work: factory config and request config. However with the “business logic” getting more and more complex we might need to add much more tests to have full coverage.

In this case parameterized tests might come useful — instead of effectively repeating setup over and over, we could provide parameters to test method, so that the responsibility for test setup is delegated to configuration:

@ParameterizedTest
@MethodSource("testParameters")
public void test(final PizzaTestConfiguration ptc) {
// given
final PizzaFactory pf = new PizzaFactory();
ptc.configurePizzaFactory(pf);
final PizzaRequest request = new PizzaRequest();
ptc.configureRequest(request);
// when
final Pizza result = pf.makePizza(request);
// then
assertThat(
result.getType(),
equalTo(ptc.expectedResultPizzaType()));
}

In this simple test, after creating PizzaFactory and PizzaRequest objects, we leave the real work to PizzaTestConfiguration entities. Right now they are basically glorified 3-tuples (factory config + request config + expected result):

// Simple object carrying data how the factory and pizza 
// should be customized (if at all!), and expected result.
private static class PizzaTestConfiguration {
private final Optional<String> factoryType;
private final Optional<String> pizzaType;
private final String expected;
PizzaTestConfiguration(final String factoryType,
final String pizzaType,
final String expected) {
this.factoryType = Optional.ofNullable(factoryType);
this.pizzaType = Optional.ofNullable(pizzaType);
this.expected = Objects.requireNonNull(expected);
}
void configurePizzaFactory(final PizzaFactory pizzaFactory) {
this.factoryType.ifPresent(pizzaFactory::setDefaultType);
}
void configureRequest(final PizzaRequest pizzaRequest) {
this.pizzaType.ifPresent(pizzaRequest::withType);
}
String expectedResultPizzaType() {
return this.expected;
}
}

The real test behaviour is defined in static testParameters method, that represents our “pizza business” logic:

private static final String CHEESE = "cheese";
private static final String MUSHROOM = "mushroom";
public static List<PizzaTestConfiguration> testParameters() { // No configuration -> we should get default pizza flavour.
PizzaTestConfiguration c1 = new PizzaTestConfiguration(
null, null, PizzaFactory.DEFAULT_PIZZA_TYPE);
// Only request configured -> we should get request's flavour.
PizzaTestConfiguration c2 = new PizzaTestConfiguration(
null, CHEESE, CHEESE);
// Only factory configured -> we should get factory's flavour.
PizzaTestConfiguration c3 = new PizzaTestConfiguration(
CHEESE, null, CHEESE);
// Both request & factory configured -> request wins.
PizzaTestConfiguration c4 = new PizzaTestConfiguration(
CHEESE, MUSHROOM, MUSHROOM);
return Arrays.asList(c1, c2, c3, c4);
}

In future, as our business logic grows we might leverage the strategy design pattern to provide even more custom behaviour. For example, if business logic were to say that only cheese-flavour pizza can be served with a soft drink, nothing stops us from delegating the whole validation step to configuration:

@ParameterizedTest
@MethodSource("testParameters")
public void test(final PizzaTestConfiguration ptc) {
... // then
ptc.assertResult(result);
}

where our PTC object would contain more of test logic:

// Extracting interface out of PTC might also be a good idea.
PizzaTestConfiguration c5 = new PizzaTestConfiguration(...) {
void assertResult(final Pizza pizza) {
// custom code related to toppings, soft drinks, etc.
}
};

Whether these strategy/configuration objects are useful depends on a test itself — in situation when the whole setup involves a lot of legwork, it might be easier to use these parameters instead of trying to refactor the code into multiple preparation and verification methods.

Libraries used:

  • org.junit.jupiter:junit-jupiter-engine (for the main engine)
  • org.junit.jupiter:junit-jupiter-params (for parameterized test support)
  • org.hamcrest:hamcrest-library (for matchers)

--

--

Adam Kotwasinski
Adam Kotwasinski

No responses yet