Hitchhiker's Guide To Testing

Introduction

An event-sourced application usually performs two kinds of interactions with the FactCast server:

  • It subscribes to facts and builds up use-case specific views of the received data. These use-case specific views are called projections.
  • It publishes new facts to the event log.

Building up projections works on both APIs, low-level and Factus. However, to simplify development, the high-level Factus API has explicit support for this concept.

Unit Tests

Projections are best tested in isolation, ideally at the unit test level. In the end, they are classes receiving facts and updating some internal state. However, as soon as the projection’s state is externalized (e.g. see here) this test approach can get challenging.

Integration Tests

Integration tests check the interaction of more than one component. Here, we’re looking at integration tests that validate the correct behaviour of a projection that uses an external data store like a Postgres database.

Be aware that FactCast integration tests as shown below can start up real infrastructure via Docker. For this reason, they usually perform significantly slower than unit tests.


Testing FactCast (low-level)

This section introduces the UserEmails projection for which we will write

  • unit tests and
  • integration tests.

For interaction with FactCast we are using the low-level API.

The User Emails Projection

Imagine our application needs a set of user emails currently in use in the system. To provide this information, we identified these facts which contain the relevant data:

  • UserAdded
  • UserRemoved

The user UserAdded fact contains a user ID and the email address. UserRemoved only carries the user ID to remove.

Here is a possible projection using the FactCast low-level API:

@Slf4j
public class UserEmailsProjection {

    private final Map<UUID, String> userEmails = new HashMap<>();

    @NonNull
    public Set<String> getUserEmails() {
        return new HashSet<>(userEmails.values());
    }

    public void apply(Fact fact) {
        switch (fact.type()) {
            case "UserAdded":
                handleUserAdded(fact);
                break;
            case "UserRemoved":
                handleUserRemoved(fact);
                break;
            default:
                log.error("Fact type {} not supported", fact.type());
                break;
        }
    }

    @VisibleForTesting
    void handleUserAdded(Fact fact) {
        JsonNode payload = parsePayload(fact);
        userEmails.put(extractIdFrom(payload), extractEmailFrom(payload));
    }

    @VisibleForTesting
    void handleUserRemoved(Fact fact) {
        JsonNode payload = parsePayload(fact);
        userEmails.remove(extractIdFrom(payload));
    }

    // helper methods:

    @SneakyThrows
    private JsonNode parsePayload(Fact fact) {
        return FactCastJson.readTree(fact.jsonPayload());
    }

    private UUID extractIdFrom(JsonNode payload) {
        return UUID.fromString(payload.get("id").asText());
    }

    private String extractEmailFrom(JsonNode payload) {
        return payload.get("email").asText();
    }
}

The method apply acts as an entry point for the projection and dispatches the received Fact to the appropriate handling behavior. There, the Fact object’s JSON payload is parsed using the Jackson library and the projection’s data (the userEmails map), is updated accordingly.

Note, that we chose to avoid using a raw ObjectMapper here, but instead use the helper class FactCastJson as it contains a pre-configured ObjectMapper.

To query the projection for the user emails, the getUserEmails() method returns the values of our internal userEmails map’s values copied to a new Set.

Unit Tests

Unit testing this projection is very easier, as there are no external dependencies. We use Fact objects as input and check the customized view of the internal map.

Let’s look at an example for the UserAdded fact:

@Test
void whenHandlingUserAddedFactEmailIsAdded() {
    // arrange
    String jsonPayload = String.format(
        "{\"id\":\"%s\", \"email\": \"%s\"}",
        UUID.randomUUID(),
        "user@bar.com");
    Fact userAdded = Fact.builder()
        .id(UUID.randomUUID())
        .ns("user")
        .type("UserAdded")
        .version(1)
        .build(jsonPayload);

    // act
    uut.handleUserAdded(userAdded);

    // assert
    Set<String> emails = uut.getUserEmails();
    assertThat(emails).hasSize(1).containsExactly("user@bar.com");
}

Note the use of the convenient builder the Fact class is providing.

Since the focus of this unit test is on handleUserAdded, we execute the method directly. The full unit test also contains a test for the dispatching logic of the apply method, as well as a similar test for the handleUserRemoved method.

Checking your projection’s logic should preferably be done with unit tests in the first place, even though you might also want to add an integration test to prove it to work in conjunction with its collaborators.

Integration Tests

FactCast provides a Junit5 extension which starts a FactCast server pre-configured for testing plus its Postgres database via the excellent testcontainers library and resets their state between test executions.

Preparation

Before writing your first integration test

  • make sure Docker is installed and running on your machine
  • add the factcast-test module to your pom.xml:

<dependency>
    <groupId>org.factcast</groupId>
    <artifactId>factcast-test</artifactId>
    <version>${factcast.version}</version>
    <scope>test</scope>
</dependency>
  • to allow TLS free authentication between our test code and the local FactCast server, create an application.properties file in the project’s resources directory with the following content:
grpc.client.factstore.negotiationType=PLAINTEXT

This will make the client application connect to the server without using TLS.

Writing The Integration Test

Our integration test builds upon the previous unit test example. This time however, we want to check if the UserEmailsProjection can also be updated by a real FactCast server:

@SpringBootTest
@ExtendWith(FactCastExtension.class)
class UserEmailsProjectionITest {

    @Autowired FactCast factCast;

    private final UserEmailsProjection uut = new UserEmailsProjection();

    private class FactObserverImpl implements FactObserver {

        @Override
        public void onNext(@NonNull Fact fact) {
            uut.apply(fact);
        }
    }

    @Test
    void projectionHandlesUserAddedFact() {
        UUID userId = UUID.randomUUID();
        Fact userAdded = Fact.builder()
            .id(UUID.randomUUID())
            .ns("user")
            .type("UserAdded")
            .version(1)
            .build(String.format(
                "{\"id\":\"%s\", \"email\": \"%s\"}",
                userId,
                "user@bar.com"));

        factCast.publish(userAdded);

        SubscriptionRequest subscriptionRequest = SubscriptionRequest
            .catchup(FactSpec.ns("user").type("UserAdded"))
            .or(FactSpec.ns("user").type("UserRemoved"))
            .fromScratch();

        factCast.subscribe(subscriptionRequest, new FactObserverImpl()).awaitComplete();

        Set<String> userEmails = uut.getUserEmails();
        assertThat(userEmails).hasSize(1).containsExactly("user@bar.com");
  }
  //...

The previously mentioned FactCastExtension starts the FactCast server and the Postgres database once before the first test is executed. Between the tests, the extension wipes all old facts from the FactCast server so that you are guaranteed to always start from scratch.

Once a fact is received, FactCast invokes the onNext method of the FactObserverImpl, which delegates to the apply method of the UserEmailsProjection.

For details of the FactCast low-level API please refer to the API documentation.

Testing with Factus

Factus builds up on the low-level FactCast API and provides a higher level of abstraction. To see Factus in action we use the same scenario as before, an UserEmailsProjection which we will ask for a set of user emails.

These are the events we need to handle:

The UserAdded event contains two properties, the user ID and the email whereas UserRemoved only contains the user ID.

An Example Event

To get an idea of how the events are defined, let’s have a look inside UserAdded:

@Getter
@Specification(ns = "user", type = "UserAdded", version = 1)
public class UserAdded implements EventObject {

    private UUID userId;
    private String email;

    // used by Jackson deserializer
    protected UserAdded(){}

    public static UserAdded of(UUID userId, String email) {
        UserAdded fact = new UserAdded();
        fact.userId = userId;
        fact.email = email;
        return fact;
    }

    @Override
    public Set<UUID> aggregateIds() {
        return Collections.emptySet();
      }
}

We create a Factus compatible event by implementing the EventObject interface and supplying the fact details via the @Specification annotation. The event itself contains the properties userId and email which are simply fields of the UserAdded class. The protected no-args constructor is used by Jackson when deserializing from JSON back to a POJO. The of factory method is used by application- and test code to create an UserAdded event. For more details on how to define a Factus event read on here.

The User Emails Projection

Now that we know which events to handle, we can process them in the Factus based UserEmailsProjection:

public class UserEmailsProjection extends LocalManagedProjection {

    private final Map<UUID, String> userEmails = new HashMap<>();

    public Set<String> getEmails() {
        return new HashSet<>(userEmails.values());
    }

    @Handler
    void apply(UserAdded event) {
        userEmails.put(event.getUserId(), event.getEmail());
    }

    @Handler
    void apply(UserRemoved event) {
        userEmails.remove(event.getUserId());
    }
}

You will instantly notice how short this implementation is compared to the UserEmailsProjection class of the low-level API example before. No dispatching or explicit JSON parsing is needed. Instead, the event handler methods each receive their event as plain Java POJO which is ready to use.

As projection type we decided for a LocalManagedProjection which is intended for self-managed, in-memory use cases. See here for detailed reading on the various Factus supported projection types.

Unit Tests

The unit test for this projection tests each handler method individually. As an example, here is the test for the UserAdded event handler:

@Test
void whenHandlingUserAddedEventEmailIsAdded() {
    UUID someUserId = UUID.randomUUID();

    UserEmailsProjection uut = new UserEmailsProjection();
    uut.apply(UserAdded.of(someUserId, "foo@bar.com"));

    Set<String> emails = uut.getEmails();
    assertThat(emails).hasSize(1).containsExactly("foo@bar.com");
}

First we create a userAddedEvent which we then apply to the responsible handler method of the UserEmailsProjection class. To check the result, we fetch the Set of emails and, as last part, examine the content.

Integration Test

After we have covered each handler method with detailed tests on unit level, we also want an integration test to test against a real FactCast server.

Here is an example:

@SpringBootTest
@ExtendWith(FactCastExtension.class)
public class UserEmailsProjectionITest {

    @Autowired Factus factus;

    @Test
    void projectionHandlesUserAddedEvent() {
        UserAdded userAdded = UserAdded.of(UUID.randomUUID(), "user@bar.com");
        factus.publish(userAdded);

        UserEmailsProjection uut = new UserEmailsProjection();
        factus.update(uut);

        Set<String> emails = uut.getEmails();
        assertThat(emails).hasSize(1).containsExactly("user@bar.com");
    }
    //...

The annotations of the test class are identical to the integration test shown for the low-level API. Hence, we only introduce them quickly here:

  • @SpringBootTest
    • starts a Spring container to enable dependency injection of the factus Spring bean
  • @ExtendWith(FactCastExtension.class)
    • starts a FactCast and its Postgres database in the background
    • erases old events inside FactCast before each test

The test itself first creates a UserAdded event which is then published to FactCast. Compared to the low-level integration test, the “act” part is slim and shows the power of the Factus API: The call to factus.update(...) builds a subscription request for all the handled events of the UserEmailsProjection class. The events returned from FactCast are then automatically applied to the correct handler.

The test concludes by checking if the state of the UserEmailsProjection was updated as correctly.

Full Example Code

The code for all examples introduced here can be found here.

Last modified October 22, 2024 : #1784: added Guides section (9c702ac49)