DEV Community

Freek Van der Herten
Freek Van der Herten

Posted on • Originally published at freek.dev on

★ 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.

Top comments (0)