Implementing event sourcing: testing aggregates
Earlier this year we released v2 of laravel-event-sourcing. This package is probably the easiest way to getting started with event sourcing in Laravel. A significant feature of v2 was the addition of aggregates.
Today we released another new version of the package that adds test methods. These methods allow you to verify if the aggregate behaves correctly. In this post, I'll show you an example and explain how the test methods are implemented.
These test methods were inspired by the awesome testing methods Frank De Jonge made in his Eventsauce package.
Testing an aggregate
An aggregate is a class that decides to record events based on past events. To know more about their general purpose and the idea behind them, read this section on using aggregates to make decisions-based-on-the-past. In the remainder of the post, we're going to assume that you know how to work with aggregates.
Imagine you have an AccountAggregateRoot
that handles adding and subtract an amount for a bank account. The account has a limit of -$5000.
use Spatie\EventProjector\AggregateRoot;
class AccountAggregateRoot extends AggregateRoot
{
/** @var int */
private $balance = 0;
/** @var int */
private $accountLimit = -5000;
public function createAccount(string $name, string $userId)
{
$this->recordThat(new AccountCreated($name, $userId));
return $this;
}
public function addMoney(int $amount)
{
$this->recordThat(new MoneyAdded($amount));
return $this;
}
protected function applyMoneyAdded(MoneyAdded $event)
{
$this->balance += $event->amount;
}
public function subtractMoney(int $amount)
{
$this->hasSufficientFundsToSubtractAmount($amount)
? $this->recordThat(new AccountLimitHit($amount))
: $this->recordThat(new MoneySubtracted($amount));
}
protected function applyMoneySubtracted(MoneySubtracted $event)
{
$this->balance -= $event->amount;
}
private function hasSufficientFundsToSubtractAmount(int $amount): bool
{
return $this->balance - $amount >= $this->accountLimit;
}
}
Let's now test that rule that an account cannot go beyond its limit.
// in a PHPUnit test
/** @test */
public function it_can_subtract_money()
{
AccountAggregateRoot::fake()
->given(new SubtractMoney(4999))
->when(function (AccountAggregate $accountAggregate) {
$accountAggregate->subtractMoney(1);
})
->assertRecorded(new MoneySubtracted(1))
->assertNotRecorded(AccountLimitHit::class);
}
/** @test */
public function it_will_not_make_subtractions_that_would_go_below_the_account_limit()
{
AccountAggregateRoot::fake()
->given(new SubtractMoney(4999))
->when(function (AccountAggregate $accountAggregate) {
$accountAggregate->subtractMoney(2);
})
->assertRecorded(new AccountLimitHit(2))
->assertNotRecorded(MoneySubtracted::class);
}
You could write the above test a bit shorter. The given events can be passed to the fake
method. You're also not required to use the when
function.
/** @test */
public function it_will_not_make_subtractions_that_would_go_below_the_account_limit()
{
AccountAggregateRoot::fake(new SubtractMoney(4999))
->subtractMoney(2)
->assertRecorded(new AccountLimitHit(2))
->assertNotRecorded(MoneySubtracted::class);
}
Implementing aggregate test methods
Before starting implementing these test methods, I thought it was going to be a bit daunting. It turns out, it was not that hard.
To create an aggregate, you need to let a class extend our Spatie\EventProjector\AggregateRoot
class.
class AccountAggregateRoot extends AggregateRoot {}
To keep the implementation of AggregateRoot
clean, I wanted to avoid adding assertions on that class itself. The only method I added was fake
. Calling this class will return a new FakeAggregateRoot
instance in which the aggregate under test (in our example AccountAggregateRoot
) is encapsulated.
Here is the implementation take from AggregateRoot
.
/**
* @param \Spatie\EventProjector\ShouldBeStored|\Spatie\EventProjector\ShouldBeStored[] $events
*
* @return $this
*/
public static function fake($events = []): FakeAggregateRoot
{
$events = Arr::wrap($events);
return (new FakeAggregateRoot(app(static::class)))->given($events);
}
In the FakeAggregateRoot
all the test methods like given
, when
, assertRecorded
and so on live.
Let's take a look at the simplest one: assertNothingRecorded
.
public function assertNothingRecorded()
{
PHPUnit\Framework\Assert::assertCount(0, $this->aggregateRoot->getRecordedEvents());
return $this;
}
In the method above, we fetch the recorded events of the encapsulated aggregate root. A PHPUnit assertion is used to determine if that array is empty. If it is not, the PHPUnit test will fail.
If you're interested, you can take a look at the implementation of the other test methods.
In closing
I hope you've enjoyed this little tour of the new test methods. Previously I've written two more posts about the implementation details of the package: here's how the aggregates are implemented and here's a post on how the developer experience was improved.
To know more about the package in general read the introductionary post or the documentation.
Be sure to also check out this big list of PHP & Laravel packages our team has made previously.
What are your thoughts on "Implementing event sourcing: testing aggregates"?