Skip to content

Commit

Permalink
feat(scenario): improve the usage providing clarity on the Then decla…
Browse files Browse the repository at this point in the history
…ration and adding definition of custom asserters
  • Loading branch information
tmorin committed Feb 17, 2024
1 parent 4e3c4a1 commit a513e68
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 67 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
java-version: "11"
java-version: "17"
distribution: "adopt"
cache: maven
- name: Build with Maven
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ buildNumber.properties
.mvn/timing.properties
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
.mvn/wrapper/maven-wrapper.jar
*.iml
1 change: 1 addition & 0 deletions .idea/prettier.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions core-api/src/main/java/io/morin/faggregate/api/OutputBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ public OutputBuilder<R> add(Object... events) {
return this;
}

/**
* Add events to the set of generated events.
*
* @param events a list of events
* @return the builder
*/
public OutputBuilder<R> add(List<Object> events) {
this.events.addAll(events);
return this;
}

/**
* Build the {@link Output}.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.morin.faggregate.core.validation;

import java.util.List;
import java.util.function.BiConsumer;
import lombok.Builder;
import lombok.NonNull;
import lombok.Singular;
Expand Down Expand Up @@ -91,13 +92,27 @@ public static class Then {

/**
* The expected state.
* <p>
* When null, the state is not checked.
*/
Object state;

/**
* The expected set of events.
* <p>
* When empty, the events are checked to be empty.
* <p>
* When null, the events are not checked.
*/
@Singular
List<?> events;

/**
* A list of asserters.
* <p>
* Where the first argument is the final State and the second argument is the list of produced Events.
*/
@Singular
List<BiConsumer<Object, List<?>>> asserters;
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package io.morin.faggregate.core.validation;

import io.morin.faggregate.api.AggregateManager;
import io.morin.faggregate.api.Output;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.NonNull;
import java.util.function.BiConsumer;
import lombok.*;
import lombok.experimental.FieldDefaults;
import lombok.val;

/**
* Execute {@link Scenario}.
Expand Down Expand Up @@ -43,6 +42,24 @@ public class ScenarioExecutor {
@NonNull
After after;

/**
* The asserter to validate the state of the aggregate.
*/
@Builder.Default
StateAsserter stateAsserter = new DefaultStateAsserter();

/**
* The asserter to validate the events produced by the aggregate.
*/
@Builder.Default
EventsAsserter eventsAsserter = new DefaultEventsAsserter();

/**
* The processor to execute custom asserters.
*/
@Builder.Default
AssertersProcessor assertersProcessor = new DefaultAssertersProcessor();

/**
* Execute the scenario.
* <ul>
Expand All @@ -56,9 +73,12 @@ public class ScenarioExecutor {
*/
public CompletionStage<Void> execute() {
return Optional
// GIVEN STEP - initialize the state of the aggregate with a given state
.ofNullable(scenario.getGiven().getState())
.map(state -> before.invoke(scenario.getGiven().getIdentifier(), state, scenario.getGiven().getEvents()))
.map(state -> before.initialize(scenario.getGiven().getIdentifier(), state, scenario.getGiven().getEvents())
)
.orElseGet(() -> CompletableFuture.completedStage(null))
// GIVEN STEP - mutate the aggregate with given commands
.thenAccept(unused -> {
try {
for (Object command : scenario.getGiven().getCommands()) {
Expand All @@ -69,47 +89,32 @@ public CompletionStage<Void> execute() {
throw new IllegalStateException(e);
}
})
// WHEN - mutate the aggregate with the tested command
.thenCompose(unused -> am.execute(scenario.getGiven().getIdentifier(), scenario.getWhen().getCommand()))
.thenCombine(
after.invoke(scenario.getGiven().getIdentifier()),
(output, currentState) -> {
// check the state
Optional
.ofNullable(scenario.getThen().getState())
.ifPresent(expectedState -> {
assert currentState.equals(expectedState) : String.format(
"%s - the expected state doesn't match the current one:%n%s%n%s",
scenario.getName(),
expectedState,
currentState
);
});
// check the events
val currentEvents = output.getEvents();
val expectedEvents = scenario.getThen().getEvents();
assert expectedEvents.size() == currentEvents.size() : String.format(
"%s - the number of expected events (%s) doesn't match the current ones (%s)",
scenario.getName(),
expectedEvents.size(),
currentEvents.size()
// WHEN - create the outcome based on the fetch aggregate state and the output of the command
.thenCompose(output ->
after
.fetch(scenario.getGiven().getIdentifier())
.thenApply(currentState -> new Outcome(output, currentState))
)
// THEN - assert the outcome
.thenApply(outcome -> {
// THEN - check actual state with the expected one
Optional
.ofNullable(scenario.getThen().getState())
.ifPresent(expectedState -> stateAsserter.process(scenario, outcome.state, expectedState));
// THEN - check actual events with the expected ones
Optional
.ofNullable(scenario.getThen().getEvents())
.ifPresent(expectedEvents ->
eventsAsserter.process(scenario, outcome.output.getEvents(), expectedEvents)
);
// check event by event
var index = 0;
for (val expectedEvent : expectedEvents) {
val currentEvent = currentEvents.get(index);
assert expectedEvent.equals(currentEvent) : String.format(
"%s - the expected event (%s) doesn't match the current one (%s):%n%s%n%s",
scenario.getName(),
index,
index,
expectedEvent,
currentEvent
);
index++;
}
return null;
}
);
// THEN - check custom asserters
Optional
.ofNullable(scenario.getThen().getAsserters())
.ifPresent(asserters -> assertersProcessor.process(scenario, outcome, asserters));
return null;
});
}

/**
Expand All @@ -125,7 +130,7 @@ public interface Before {
* @param events a set of initial domain events
* @return a completion stage
*/
CompletionStage<Void> invoke(@NonNull Object identifier, @NonNull Object state, @NonNull List<?> events);
CompletionStage<Void> initialize(@NonNull Object identifier, @NonNull Object state, @NonNull List<?> events);
}

/**
Expand All @@ -137,8 +142,128 @@ public interface After {
* Fetch the state of the aggregate.
*
* @param identifier the identifier of the aggregate
* @return a completion stage
* @return the embedded in a CompletionStage
*/
CompletionStage<Object> fetch(@NonNull Object identifier);
}

/**
* The outcome of a scenario execution.
*/
@RequiredArgsConstructor
static class Outcome {

/**
* The output of the Command Handler.
*/
final Output<?> output;

/**
* The state fetched using {@link After#fetch(Object)}.
*/
CompletionStage<Object> invoke(@NonNull Object identifier);
final Object state;
}

/**
* The asserter to validate the state of the aggregate.
*/
interface StateAsserter {
/**
* Assert the state of the aggregate.
*
* @param scenario the scenario
* @param actualState the actual state of the aggregate
* @param expectedState the expected state of the aggregate
*/
void process(Scenario scenario, Object actualState, Object expectedState);
}

private static class DefaultStateAsserter implements StateAsserter {

@Override
@SuppressWarnings("java:S4274")
public void process(@NonNull Scenario scenario, @NonNull Object actualState, @NonNull Object expectedState) {
assert actualState.equals(expectedState) : String.format(
"%s - the actual state doesn't match the expected one:%n%s%n%s",
scenario.getName(),
actualState,
expectedState
);
}
}

/**
* The asserter to validate the events produced by the aggregate.
*/
interface EventsAsserter {
/**
* Assert the events produced by the aggregate.
*
* @param scenario the scenario
* @param actualEvents the actual events of the aggregate
* @param expectedEvents the expected events of the aggregate
*/
void process(Scenario scenario, List<?> actualEvents, List<?> expectedEvents);
}

private static class DefaultEventsAsserter implements EventsAsserter {

@Override
@SuppressWarnings("java:S4274")
public void process(
@NonNull Scenario scenario,
@NonNull List<?> actualEvents,
@NonNull List<?> expectedEvents
) {
// THEN - check the sizing of actual events with existing given one
assert expectedEvents.size() == actualEvents.size() : String.format(
"%s - the number of actual events (%s) doesn't match the expected ones (%s)",
scenario.getName(),
actualEvents.size(),
expectedEvents.size()
);
// THEN - check actual events according to expected one
var eventIndex = 0;
for (val expectedEvent : expectedEvents) {
val actualEvent = actualEvents.get(eventIndex);
assert expectedEvent.equals(actualEvent) : String.format(
"%s - the actual event (%s) doesn't match the expected one (%s):%n%s%n%s",
scenario.getName(),
eventIndex,
eventIndex,
actualEvent,
expectedEvent
);
eventIndex++;
}
}
}

/**
* The processor to execute custom asserters.
*/
interface AssertersProcessor {
/**
* Process the custom asserters.
*
* @param scenario the scenario
* @param outcome the outcome of the scenario execution
* @param asserters the custom asserters
*/
void process(Scenario scenario, Outcome outcome, List<BiConsumer<Object, List<?>>> asserters);
}

private static class DefaultAssertersProcessor implements AssertersProcessor {

@Override
public void process(
@NonNull Scenario scenario,
@NonNull Outcome outcome,
@NonNull List<BiConsumer<Object, List<?>>> asserters
) {
for (val asserter : asserters) {
asserter.accept(outcome.state, outcome.output.getEvents());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* <p>The module provides a framework to write test scenarios.
* <p>The scenarios are provided by the core to drive and validate the implementation of the side effects.
* The module provides a framework to write test scenarios.
* <p>
* The scenarios are provided by the core to drive and validate the implementation of the side effects.
*/
package io.morin.faggregate.core.validation;
Loading

0 comments on commit a513e68

Please sign in to comment.