Test Utilities Guide
This guide provides an overview of the test utilities available for testing aggregates, verifying recorded events, and handling exceptions during aggregate actions.
Overview
The test-utils
module is designed to make it easier to test aggregate behavior by providing methods for loading aggregate instances with histories, asserting events, and handling exceptions thrown during aggregate operations.
import { assert, GivenAggregateRoot } from "@nulltype/modddel/test-utils";
Available Functions
assert
: Asserts that a condition or result is met.GivenAggregate
: Initializes an aggregate with either a new instance or an existing event history for testing.
Assertions
The assert
function provides a way to verify that specific conditions are met during the execution of aggregate operations. If an assertion fails, an error is thrown with a detailed message.
await assert(
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.addItem("item-1", 1);
})
.toRecordEvents(["ItemAdded"])
);
GivenAggregate
The GivenAggregate
is a factory function that allows you to either create a new instance of an aggregate or load an existing one from event history and/or snapshots. This utility ensures that your aggregate tests start from a known state, whether that state is new or reconstructed from historical events.
GivenAggregate(MyAggregateDefinition).withNewInstance("my-aggregate-id");
// or
GivenAggregate(MyAggregateDefinition).withHitory(
"my-aggregate-id",
eventHistory
);
Methods
withNewInstance(id: string)
: Creates a new aggregate instance with the specified id. This method is useful when testing commands on a fresh aggregate.withHistory(id: string, events: HistoryEvent[], snapshot?: ISnapshot)
: Loads an aggregate instance with a specified event history, and optionally a snapshot. This is particularly useful when you want to test an aggregate that has a non-trivial past and validate its behavior in that context.
INFO
The HistoryEvent
is a minimalistic representation of an event. It's a "tuple", an array with two elements. The first is the event name. The second is event payload. The utils will convert it to proper events.
GivenAggregate(MyAggregateDefinition).withInstanceHistory("aggregate-id", [
["Event1", { data: "value" }],
["Event2", { data: "other value" }],
]);
Both methods—whether you're creating a new instance or loading an instance from history—return an API that includes the expect
method. The expect
method allows you to define actions that you want to test and provides the instance as an argument in the callback. From there, you can chain various assertions, such as verifying that certain events were recorded or checking for errors.
expect
Both withNewInstance
and withHistory
return an API that exposes the expect method. This method allows you to perform actions on the aggregate instance (passed as an argument) and then chain assertions.
The signature of the expect method looks like this:
expect(callback: (instance: AggregateInstance<StateT, ActionsT, EventsT>) => Promise<void> | void);
- Callback Argument: The callback receives the aggregate instance, allowing you to perform actions on it (like calling actions).
- Return Value: After defining actions, you can chain methods like
toRecordEvents
ortoThrow
to verify expected outcomes.
Example:
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.addItem("item-1", 1);
});
Assertion Methods: toThrow
and toRecordEvents
When testing event-sourced aggregates using the GivenAggregate
utility, two key assertion methods—toThrow
and toRecordEvents
—allow you to verify the outcomes of commands executed on an aggregate. These methods are chained after the expect
method and help ensure your domain logic behaves as expected.
toThrow
: Verifying Error Handling
The toThrow
method is used to assert that a action executed on an aggregate instance throws an expected error. This is particularly useful for testing invalid actions, ensuring that the aggregate enforces its business rules and constraints properly.
Usage
The toThrow
method can handle various types of expectations for errors:
- Exact Error Message: You can assert that a specific error message is thrown.
- Regular Expression: Use a regular expression to assert that the error message matches a specific pattern.
- Custom Function: Define a custom function to check for a specific condition in the thrown error. This function must return true if the thrown error is what we expected or false otherwise.
Method Signature
toThrow(expected: string | RegExp | ((error: Error) => AssertResult)): Assertion
Example: Asserting Exact Error Message
await assert(
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.removeItem("nonexistent-item", 1);
})
.toThrow("Cannot remove items not present in cart")
);
In this example:
- The command
removeItem
is expected to throw an error because the item doesn't exist in the cart. - The assertion verifies that the error message matches
"Cannot remove items not present in cart"
exactly.
Example: Using a Regular Expression
await assert(
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.removeItem("nonexistent-item", 1);
})
.toThrow(/not present in cart/i)
);
Here, the toThrow
method checks if the error message contains the phrase "not present in cart"
, using a regular expression.
Example: Custom Function for Error Validation
await assert(
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.removeItem("nonexistent-item", 1);
})
.toThrow((error) => error.message.includes('not present'));
);
This example uses a custom function to validate that the error message includes the word "not present". This approach offers flexibility for complex validation logic.
toRecordEvents
: Verifying Recorded Events
The toRecordEvents
method is used to assert that specific events were recorded by the aggregate after executing a command. This method ensures that the aggregate's event-sourcing logic is working correctly and that the right events are emitted as a result of domain actions.
Usage
toRecordEvents
can be used in two ways:
- Exact Event Names: Assert that the events recorded match a specific list of event names.
- Custom Function: Provide a function to validate the recorded events based on more complex logic (e.g., checking event payloads).
Method signature
toRecordEvents(events: (keyof EventsT)[] | ((events: Readonly<AggregateEvent<EventsT>[]>) => AssertResult)): Assertion
events
: The expected events, which can be:- An array of event names (matching the names of the expected events).
- A function that receives the recorded events and returns true the events is exactly what we want.
await assert(
GivenAggregate(ShoppingCart)
.withNewInstance("cart-1")
.expect((instance) => {
instance.addItem("item-1", 1);
})
.toRecordEvents((events) => {
return events.length === 1 && events[0].payload.id === "item-1";
})
);
Conclusion
The test-utils
module provides a rich set of utilities to test the behavior of aggregates, manage event histories, and verify event sourcing workflows. By combining the GivenAggregate
, expect
, toThrow
, toRecordEvents
, you can build robust tests that simulate real-world scenarios, ensuring that your aggregates behave as expected.