Writing TCKs with JUnit Jupiter's Dynamic Tests

When you develop a library that provides some abstraction, or write a spec, it is a good practice to include a TCK (Technology Compatibility Kit) that the implementers can use to verify the behavior of their implementation and how well it matches the spec.

You may find various TCKs out there, e.g. the Reactive Streams TCK.

I just need something to test

Let’s start with a simple example first. Consider we have the following contract defined:

interface Action<I, O> {

	/**
	 * Applies transformation to the provided stream.
	 * The result MUST NOT emit null.
	 * If error happens, it MUST be of {@link ActionException} type.
	 *
	 * @param input source {@link Stream}
	 * @return transformed {@link Stream}
	 * @throws ActionException on error
	 */
	Stream<O> perform(Stream<I> input) throws ActionException;
}

It looks like we have a framework that allows us to implement some actions on java.util.stream.Stream.

Let’s write such action:

class MappingAction<I, O> implements Action<I, O> {

	final Function<I, O> mapper;

	public MappingAction(Function<I, O> mapper) {
		this.mapper = mapper;
	}

	@Override
	public Stream<O> perform(Stream<I> input) {
		return input.map(it -> {
			O result = mapper.apply(it);
			if (result == null) {
				throw new ActionException(new NullPointerException("result"));
			}
			return result;
		});
	}
}

As you can see, we’ve attempted to implement it correctly after reading the javadoc. But documentation is not always up to date and may not have all the edge cases mentioned.

Let’s write a test for it!

class MappingActionTests {

	@Test
	void shouldMap() {
		var source = Stream.of("Hello", "World");

		var action = new MappingAction<>(it -> it.toUpperCase());

		var result = action.perform(source);

		assertThat(result).containsExactly("HELLO", "WORLD");
	}

Yay! It works! Here we test the concrete behavior of this Action.

But do we actually test that our Action conforms to the contract?
And, a better question would be: do we really want to write (or… repeat, in fact) tests for checking that?

This is where a TCK would be helpful!

Writing a TCK with JUnit

Some prefer to write TCKs with TestNG due to how flexible it is. While I have nothing against TestNG, I prefer to use JUnit.

Unfortunatelly, there aren’t many resources on how to write “abstact” test suites with JUnit.
(btw, this statement is always true. There can’t be too many resources about testing! =P)

⚠️ WARNING!
I will describe how I prefer to write such tests in JUnit Jupiter.
This is not the only way to do so (e.g. you can extend from a common class or use helpers).
You have been warned!

Since the TCK tests should be reusable, I will use JUnit’s support for interfaces and default methods.

We will test the tricky part first - exception handling:

public interface ActionExceptionsTest {

	Stream<Object> createFaultyInput();

	Action<Object, Object> createFailingAction();

	@Test
	default void shouldAlwaysWrapExceptions() {
		assertThrows(ActionException.class, () -> {
			Stream<Object> input = createFaultyInput();

			Action<Object, Object> action = createFailingAction();

            // Trigger it
			action.perform(input).limit(1).toArray();
		});
	}
}

As you can see, we need to ask the user to provide something that we can’t assume about the implementation:

  1. faulty input that should trigger the error
  2. an action that is supposed to fail

How can we run this test? Easy! Just implement it in MappingActionTests:

class MappingActionTests implements ActionExceptionsTest {

	@Override
	public Stream<Object> createFaultyInput() {
		return Stream.of(null);
	}

	@Override
	public Action<Object, Object> createFailingAction() {
		return new MappingAction<>(it -> it.toString());
	}

	@Test
	void shouldMap() {
        // ...
    }

But what if we need more parameters (e.g. expected output)? Let’s add a concept of “Scenario”:

public class Scenario {

    String name;

	Action<Object, Object> action;

	Stream<Object> input;

	Consumer<ListAssert<Object>> expect;

So that we can change out code to:

class MappingActionTests implements ActionExceptionsTest {

	@Override
	public Scenario createFailingScenario() {
		return Scenario.builder("returns null")
				.input(Stream.of("foo"))
				.action(new MappingAction<>(it -> null))
				.build();
	}

	@Test
	void shouldMap() {
        // ...
    }

It is passing again (since we handle null after applying the mapper). But we only check one scenario when an Action may throw, what about others?

Parameterize all the things!

We could create more tests, but they will follow the same pattern and it would be nice to parameterize them.

JUnit Jupiter does support Parameterized Tests, but, if we want the parameters factory to be a method, it must be static. It does not really work for us, since we let the end users implement them.

Instead, we will be using another feature of JUnit Jupiter - Dynamic Tests.

Before we change our ActionExceptionsTest test, I suggest introducing a base interface for our TCK tests:

public interface ActionTestSupport {

	Stream<Scenario> successfulScenarios();

	Stream<Scenario> failingScenarios();

	default Stream<DynamicTest> toDynamicTests(
        Stream<Scenario> scenarios,
        ThrowingConsumer<Scenario> executable
    ) {
		return scenarios.map(scenario -> {
			return dynamicTest(scenario.toString(), executable);
		});
	}
}

Note that we define two sets of scenarious that we can reuse later.

Now we can use it as follows:

public interface ActionExceptionsTest extends ActionTestSupport {

	@TestFactory
	default Stream<DynamicTest> shouldAlwaysWrapExceptions() {
		return toDynamicTests(failingScenarios(), scenario -> {
			assertThrows(ActionException.class, () -> {
				scenario.getAction().perform(scenario.getInput()).limit(1).toArray();
			});
		});
	}
}

Now we can implement more failing scenarios:

class MappingActionTests implements ActionTests {

	@Override
	public Stream<Scenario> failingScenarios() {
		return Stream.of(
				Scenario.builder("returns null")
				        .action(new MappingAction<>(it -> null))
				        .input(Stream.of("foo"))
				        .build(),

				Scenario.builder("receives null")
				        .action(new MappingAction<>(it -> it.toString()))
				        .input(Stream.of((Object) null))
				        .build(),

				Scenario.builder("throws")
				        .action(new MappingAction<>(it -> {
					        throw new RuntimeException("Ooops!");
				        }))
				        .input(Stream.of("foo"))
				        .build()
		);
	}

And guess what? Our implementation is only 33% correct!

@Override
public Stream<O> perform(Stream<I> input) {
    return input.map(it -> {
        O result = mapper.apply(it);
        if (result == null) {
            throw new ActionException(new NullPointerException("result"));
        }
        return result;
    });
}

We did wrap the NPE with ActionException but forgot to wrap mapper.apply which is a user-provided function that we cannot control.

Improving the DX

If you run the tests in the IDE, you may notice that the tests have nice names:

VNC

.. but it is impossible to go to the scenario definition from a test run.

No surprise, since we generate the test cases dynamically and IDE or JUnit have no idea about it.

To workaround that, we will use the following trick:

public class Scenario {

	public static ScenarioBuilder builder(String name) {
		StackTraceElement[] stackTrace = new Exception().getStackTrace();
		return new ScenarioBuilder().frame(stackTrace[1]).name(name);
	}

    // ...
}
public interface ActionTestSupport {

	default Stream<DynamicTest> toDynamicTests(
        Stream<Scenario> scenarios,
        ThrowingConsumer<Scenario> executable
    ) {
		return scenarios.map(scenario -> {
			return dynamicTest(scenario.toString(), () -> {
				System.out.println("Scenario defined at " + scenario.getFrame());
				executable.accept(scenario);
			});
		});
	}

Now every test will have a link to the scenario definition and some IDEs will make it clickable:

VNC

Going forward

It may not be the only approach to write TCKs with JUnit Jupiter (if you think that you have a better one, please do share it in the comments!), but I like how it works and how it scales.

You can easily add more cases:

public interface ParallelStreamsTest extends ActionTestSupport {

	@TestFactory
	default Stream<DynamicTest> shouldWorkWithParallelStreams() {
		return toDynamicTests(successfulScenarios(), scenario -> {
			Consumer<ListAssert<Object>> expect = scenario.getExpect();

			Assumptions.assumeTrue(expect != null, "expect is provided");

			Stream<Object> input = scenario.getInput().parallel();

			Stream<Object> result = scenario.getAction().perform(input);

			if (scenario.getLimit() != null) {
				result = result.limit(scenario.getLimit());
			}

			expect.accept(assertThat(result));
		});
	}
}

and you can “group” them too:

public interface ActionTests extends ActionExceptionsTest, ParallelStreamsTest {

}
class MappingActionTests implements ActionTests {

	@Override
	public Stream<Scenario> successfulScenarios() {
		return Stream.of(
            Scenario.builder("two items")
                    .action(new MappingAction<>(it -> it.toString().toUpperCase()))
                    .input(Stream.of("hello", "world"))
                    .expect(it -> it.containsExactlyInAnyOrder("HELLO", "WORLD"))
                    .build(),

            Scenario.builder("empty")
                    .action(new MappingAction<>(it -> it.toString().toUpperCase()))
                    .input(Stream.of())
                    .expect(it -> it.isEmpty())
                    .build(),

            Scenario.builder("infinite stream")
                    .action(new MappingAction<>(it -> it.toString().toUpperCase()))
                    .input(Stream.generate(() -> "hello"))
                    .limit(1)
                    .expect(it -> it.startsWith("HELLO"))
                    .build()
		);
	}

	@Override
	public Stream<Scenario> failingScenarios() {
        // ...
	}

    // ...
}
comments powered by Disqus