Skip to content

etrandafir93/utilitest

Repository files navigation

Utilitest

Utilitest is a collection of test-specific utilities for working with popular testing libraries, such as AssertJ, Mockito, and Awaitility.

The tracing module offers support for injecting traceIDs into tests, for correlating the test logs or verifying the correct trace propagation.

Distributed Tracing Module

The tracing module allows us to test the correct trace propagation for SpringBoot applications.

Here's a typical flow of a request through multiple services with automatic trace propagation:

@startuml Epic Flow
!theme plain

participant "Kafka\nBroker" as Kafka #LightYellow
box "Application" #LightBlue
    participant "Epic Module" as Epic
    participant "Task Module" as Task
end box
participant "External\nTodo Service" as External #LightGreen

Kafka -> Epic : 1. create.epic.command
Epic -> Task : 2. HTTP GET /api/ticket/{id}/title
Task -> External : 3. HTTP GET /todos/{id}
External -> Task : 4. HTTP Response
Task -> Epic : 5. HTTP Response
Epic -> Kafka : 6. epic.created.event

@enduml

We can test the trace propagation for Kafka and HTTP requests, using the TracingKafkaExtension and TracingHttpExtension JUnit extensions.

Here is an example of a test that uses the TracingKafkaExtension, which will inject a KafkaTemplate object into the test. As a result, a traceparent header will be automatically injected into all messages sent using this kafkaTemplate instance:

@ExtendWith(KafkaTracingExtension.class)
class TracingKafkaTest extends IntegrationTest {

    @Test
    void integrationTest(
        @Traceable Traceparent traceparent,
        @Traceable KafkaTemplate<Object, Object> kafkaTemplate
    ) throws Exception {

        // given
        kafkaTemplate.send("create.epic.command",
            new CreateEpicCommand("Migrate to JUnit5", List.of(1L, 2L))).get();

        // when
        var messageOut = consumeOneMessage("epic.created.event", ofSeconds(10));

        // then
        var headerOut = messageOut.headers()
            .headers("traceparent")
            .iterator().next();
        assertThat(headerOut)
            .extracting(it -> new String(it.value())).asString()
            .contains(traceparent.traceId())
            .doesNotContain(traceparent.spanId());
    }
}

As we can see, we can use the injected Traceparent instance to inspect the traceId and spanId of the current test trace.

In our test, we treat the application like a black box, only decorating the input message with a traceparent header and verifying the outgoing messages to see if the traceId is propagated.

JUnit Lambdas

Utilitest provides a JUnit extension that enables us to use lambda expressions instead of the commonly used Before/After Each/All block methods.

This library provides annotations to simplify test lifecycle management in JUnit by allowing you to define setup and cleanup logic directly on fields. The annotations @DoBeforeAll, @DoBeforeEach, @DoAfterAll, and @DoAfterEach, enable concise and readable test code by invoking specified methods or functional interfaces before/after tests.

We can use these annotations at a field level in two ways:

  1. We can annotate fields that are functional interfaces:
static File resource;

@DoBeforeAll
static ThrowingRunnable setUp = () -> 
    resource = Files.createTempFile("test", ".tmp").toFile();

@DoAfterAll
static ThrowingRunnable tearDown = () -> 
    resource.delete();
  1. We can annotate other fields and define the method to invoke:
@DoBeforeEach(invoke = "init")
@DoAfterEach(invoke = "reset")
static Resource resource = ...;

Example

Here is a simple test which doesn't use any of the annotations:

import java.io.IOException;

class JunitLambdasReadmeTests {

    static File resource = null;

    @BeforeAll
    static void setUp() throws IOException {
        resource = Files.createTempFile("test", ".txt").toFile();
    }

    @BeforeEach
    void clean() throws IOException {
        Files.writeString(resource.toPath(), "", TRUNCATE_EXISTING, WRITE);
    }

    @AfterAll
    static void tearDown() throws IOException {
        Files.delete(resource.toPath());
    }

    // some tests...
}

With Junit-lambdas, we can simplify the code to:

@ExtendWith(JunitLambdasExtension.class)
class JunitLambdasReadmeTests {

    @DoAfterAll(invoke = "delete")
    static File resource = null;

    @DoBeforeAll
    static ThrowingRunnable setUp = () -> 
        resource = Files.createTempFile("test", ".txt").toFile();

    @DoBeforeEach
    ThrowingRunnable clean = () -> 
        Files.writeString(resource.toPath(), "", TRUNCATE_EXISTING, WRITE);

    // some tests...
}

AssertJ + Awaitility

This module offers a simple way to combine AssertJ assertions with Awaitility.

We'll take advantage of the Condition<> class, one of AssertJ's extension points. We can use eventually().goingTo(...) to create an eventual condition:

assertThat(legolas)
    .is(
      eventually()
        .goingTo(
          it -> it.hasFieldOrPropertyWithValue("age", 100)));

Or we can use a more concise syntax:

assertThat(legolas)
    .is(
      eventually(
          it -> it.hasFieldOrPropertyWithValue("age", 100));

For the polling interval and timeout, we'll rely on Awaitility's default settings. They can be set via the following config:

assertThat(legoals)
    .is(
        eventually()
            .within(5, SECONDS)
            .checkingEvery(1, MILLISECONDS)
            .goingTo(
                it -> it.hasFieldOrPropertyWithValue("age", 555)));

AssertJ + Mockito's AssertMatcher

To use verify() mocks, we generally have two options. We can either capture the arguments using a Captor, which adds some boilerplate code and overhead, or we can use Mockito's ArgumentMatchers to verify the arguments directly with a lambda:

@Test
void customAssertMatcher() {
  FooService mock = Mockito.mock();
  mock.process(new Account(1L, "John Doe", "[email protected]"));

  Mockito.verify(mock).process(
    Mockito.argThat(
      it -> it.getAccountId().equals(1L)
        && it.getName().equals("John Doe")
        && it.getEmail().equals("[email protected]")));
}

However, the failure messages from these custom argument matchers are often cryptic, making it difficult to pinpoint the cause of the test failure.

As a workaround, we can use a fluent AssertJ assertion within the custom ArgumentMatcher and always return true.

While this solution provides much clearer error messages, it comes with the downside of adding a lot of boilerplate code. If the code is repetitive, it can make the tests harder to read and maintain.

So, let's remove all this ceremony and use utilitest's MockitoAndAssertJ::argHaving instead:

Mockito.verify(mock).process(
  argHaving(it -> it
    .hasFieldOrPropertyWithValue("accountId", 1L)
    .hasFieldOrPropertyWithValue("name", "John Doe")
    .hasFieldOrPropertyWithValue("email", "[email protected]")));

This approach brings together the best of both worlds: the convenience of verifying the argument using a lambda expression and the fluent API of AssertJ, providing clear and descriptive error messages:

java.lang.AssertionError: 
Expecting
  io.github.etr.utilitest.mockito.ReadmeExampelsTest$Account@f5c79a6
to have a property or a field named "email" with value
  "[email protected]"
but value was:
  "[email protected]"

Other Assertions

The MockitoAndAssertJ::argHaving from the previous example enables us to consume an ObjectAssert from AsserJ, that provides some basic assertions. The assertJ API allows us to change this type to a more specialized instance of assertion, to verify specific properties. For example, we can change the assertion type to a MapAssert to be able to check specific key-value entries:

@Test
void asInstanceOf() {
  Map<Long, String> data = Map.of(
    1L, "John",
    2L, "Bobby"
  );

  FooService mock = Mockito.mock();
  mock.processMap(data);

  verify(mock).processMap(
    argHaving(it -> it
      .asInstanceOf(InstanceOfAssertFactories.MAP)
      .containsEntry(1L, "John")
      .containsEntry(2L, "Bobby")));
}

With MockitoAndAssertJ, we can also specify InstanceOfAssertFactories upfront. To achieve this, we split the argThat into two separate methods: MockitoAndAssertJ.arg(InstanceOfAssertFactories.MAP).that(it -> ...).

Let's use this API to verify a method that accepts a LocalDateTime and a List of Strings:

@Test
void arg_that() {
  FooService mock = Mockito.mock();
  mock.processDateAndList(now(), List.of("A", "B", "C"));

  verify(mock).processDateAndList(
     arg(TEMPORAL).that(time -> time.isCloseTo(now(), within(500, MILLIS))),
     arg(LIST).that(list -> list.containsExactly("A", "B", "C"))
  );
}

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages