Skip to content

Testing

dkettner edited this page Sep 13, 2023 · 23 revisions

Testing Strategy

Due to the complexity of event-driven software testing is not straightforward and a strategy is needed.

To test the interaction of multiple software components (which will mostly be the case when testing events) integration tests are needed. Adding unit tests would have been the most thorough approach but it would also have taken a lot more time. Eventually unit tests should also get added but for now there will only be rather naive form of integration tests.

We will use the modular design of the Ticketing System to think of each aggregate as an isolated box which may get input from outside. So it is not a blackbox (meaning that we are actually able to look inside -> the database) but rather an island and we do not exactly know (or care) what is outside.

An alternative way to look at it would be to also view the changes in the database as a form of output. In this version the island would actually be treated as a blackbox. However, the following way of thinking and testing felt more natural during development.


Following these thoughts, there are only two possible types of inputs:

  1. Incoming HTTP requests.

    or

  2. Events of other aggregates.

There are also only two possible outputs:

  1. The HTTP response.

    and/or

  2. Outgoing events (events that the aggregate itself publishes for the outer world in response to the input).

What did actually take place on the island? -> Checking the database.


Now to the actual testing strategy. Each test has the same structure:

  1. Starting the EventCatcher. A tool written for these tests to catch outgoing events.
  2. Mocking the input (HTTP request or event).
  3. Checking if the output is as expected. This means checking both types of possible outputs: The HTTP response (if applicable) and also the outgoing events (did the aggregate publish the correct events with the expected content).
  4. Checking if the input triggered the expcted changes in the database because we actually do care about what happened on the island.

Testing if the outgoing events get consumed correctly by other aggregates is not part of the tests of the current aggregate because each aggregate has to check itself if it correctly consumes external events.


EventCatcher

Older iterations of the tests used Thread.sleep() to give the Ticketing System enough time to fully finish its tasks because on slow machines it might actually take a few extra milliseconds before the correct events gets published. Not having waiting intervals at all would cause awkward situations where some tests would fail on slow machines but succeed on fast ones.

But having to rely on Thread.sleep() with a fixed amount of time (even slower machines could maybe take even longer that a few milliseconds) had a bad smell about it. Setting the interval longer would mean that even fast machines would take very long during the tests.

The EventCatcher fulfills the simple task of catching the first event of a specific type asynchronously after it has been started. It will wait at a chosen point in the code for at most 10 seconds but will return earlier if an event of the correct type has been caught. This means that slow machines get enough time to perform their tasks and fast ones do actually benefit from their higher performance.

Below is the source code of the EventCatcher:

@Service
public class EventCatcher {
    private DomainEvent event = null;
    private Class<? extends DomainEvent> typeOfClass = null;

    @Getter
    private Boolean isListening = false;

    public void catchEventOfType(Class<? extends DomainEvent> eventClass) {
        typeOfClass = eventClass;
        event = null;
        isListening = true;
    }

    public Boolean isOfSpecifiedType(Object newEvent) {
        return typeOfClass.isInstance(newEvent);
    }

    public Boolean hasCaughtEvent() {
        return event != null;
    }

    public DomainEvent getEvent() {
        DomainEvent tempEvent = event;
        event = null;
        return tempEvent;
    }

    @EventListener(condition = "@eventCatcher.isListening && @eventCatcher.isOfSpecifiedType(#newEvent)")
    public void onApplicationEvent(DomainEvent newEvent) {
        isListening = false;
        event = newEvent;
    }
}

(/tests/java/blob/util/EventCatcher.java)

Here is an example of how to use the EventCatcher. First it gets started by calling catchEventOfType().

// Start catching.
eventCatcher.catchEventOfType(MembershipAcceptedEvent.class);

// Mocking an event.
MembershipAcceptedEvent event1 = 
    new MembershipAcceptedEvent(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
eventPublisher.publishEvent(event1);

// Pausing at this point for at most 10 seconds, otherwise an exception gets thrown. 
// If the event got caught before reaching this point, it will return immediately.
await().until(eventCatcher::hasCaughtEvent);

// Getting the event from the event catcher. A cast is needed.
MembershipAcceptedEvent membershipAcceptedEvent = (MembershipAcceptedEvent) eventCatcher.getEvent();

Examples

The following two examples will illustrate how the testing strategy got implemented.

1. Example: Incoming HTTP request.

First, the EventCatcher gets configured.

Then an HTTP request gets mocked and the first type of output gets tested: The HTTP response.

Afterwards, the second type of output gets tested: The outgoing event.

At the end, when all changes have been made, the database gets checked to see if the expected changes have actually taken place.

@Test
public void postProjectTest() throws Exception {
    // starting the EventCatcher
    eventCatcher.catchEventOfType(ProjectCreatedEvent.class);
    
    // mocking an HTTP request and checking the returned response
    ProjectPostDto projectPostDto = new ProjectPostDto(projectName, projectDescription);
    MvcResult postResult =
            mockMvc.perform(
                            post("/projects")
                                    .contentType(MediaType.APPLICATION_JSON)
                                    .content(objectMapper.writeValueAsString(projectPostDto))
                                    .cookie(new Cookie("jwt", jwt)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.id").exists())
                    .andExpect(jsonPath("$.name").value(projectPostDto.getName()))
                    .andExpect(jsonPath("$.description").value(projectPostDto.getDescription()))
                    .andReturn();

    // testing the outgoing event -> ProjectCreatedEvent
    await().until(eventCatcher::hasCaughtEvent);
    ProjectCreatedEvent projectCreatedEvent = (ProjectCreatedEvent) eventCatcher.getEvent();
    assertEquals(projectId, projectCreatedEvent.getProjectId());
    assertEquals(userId, projectCreatedEvent.getUserId());

    // testing the changes in the database
    String postResponse = postResult.getResponse().getContentAsString();
    projectId = UUID.fromString(JsonPath.parse(postResponse).read("$.id"));
    Project project = projectDomainService.getProjectById(projectId);
    assertEquals(projectId, project.getId());
    assertEquals(projectPostDto.getName(), project.getName());
    assertEquals(projectPostDto.getDescription(), project.getDescription());
}

(/tests/java/blob/project/ProjectControllerTests.java)


2. Example: Incoming Event.

In this case a user entity got created which should trigger the creation of a default project for that user. The testing process is very similar to the first example:

Starting the EventCatcher.

Mocking the input (the incoming event).

Checking the output (no HTTP response here so only the outgoing event).

Checking the database. Here it only checks if there is actually a project with the correct UUID.

@Test
public void consumeUserCreatedEventTest() throws Exception {
    // starting the EventCatcher
    eventCatcher.catchEventOfType(DefaultProjectCreatedEvent.class);

    // mocking an incoming event
    UUID mockUserId = UUID.randomUUID();
    String mockUserName = "Stefan Stephens";
    EmailAddress mockEmailAddress = EmailAddress.fromString("Stef.steph@timeless.de");    
    eventPublisher.publishEvent(
            new UserCreatedEvent(
                    mockUserId,
                    mockUserName,
                    mockEmailAddress
            )
    );

    // testing the outgoing event -> DefaultProjectCreatedEvent
    await().until(eventCatcher::hasCaughtEvent);
    DefaultProjectCreatedEvent defaultProjectCreatedEvent = (DefaultProjectCreatedEvent) eventCatcher.getEvent();
    assertEquals(mockUserId, defaultProjectCreatedEvent.getUserId());

    // testing the changes in the database -> has the project actually been created (throws an exception otherwise)
    Project defaultProject = projectDomainService.getProjectById(defaultProjectCreatedEvent.getProjectId());
}

(/tests/java/blob/project/ProjectControllerTests.java)


Problems

  • As mentioned above, this approach is still missing unit tests.
  • Many things get tested together within one test block. It is a rather naive approach to save time.
  • There may be race conditions which only appear in stress tests with multiple requests/events coming at once. Currently there are no stress tests.