diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..b36231f --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,48 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Check Docs" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + +jobs: + checkdocs: + name: "Check Docs" + + runs-on: ${{ matrix.operating-system }} + + strategy: + matrix: + dependencies: + - "locked" + php-version: + - "8.4" + operating-system: + - "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v6 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.37.2" + with: + coverage: none + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1, opcache.enable_cli=1 + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "extract php code" + run: "bin/docs-extract-php-code" + + - name: "lint php" + run: "php -l docs_php/*.php" + + - name: "docs code style" + run: "vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit" \ No newline at end of file diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..7cb425e --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,22 @@ +name: Publish docs + +on: + push: + branches: + - "[0-9]+.[0-9]+.x" + release: + types: + - published + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Trigger workflow in other repo + run: | + curl -L -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + https://api.github.com/repos/patchlevel/patchlevel.dev/actions/workflows/prod-deployment.yaml/dispatches \ + -d '{"ref":"main"}' \ No newline at end of file diff --git a/Makefile b/Makefile index 82e9974..1826969 100644 --- a/Makefile +++ b/Makefile @@ -42,3 +42,25 @@ test: phpunit .PHONY: dev dev: static test ## run dev tools + +.PHONY: docs +docs: docs-extract-php docs-php-lint docs-phpcs docs-inject-php + +.PHONY: docs-extract-php +docs-extract-php: + bin/docs-extract-php-code + +.PHONY: docs-inject-php +docs-inject-php: + bin/docs-inject-php-code + +.PHONY: docs-format ## format docs +docs-format: docs-phpcs docs-inject-php + +.PHONY: docs-php-lint ## lint docs code +docs-php-lint: docs-extract-php + php -l docs_php/*.php | grep 'Parse error: ' || true + +.PHONY: docs-phpcs +docs-phpcs: docs-extract-php + vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit || true diff --git a/README.md b/README.md index 7272a31..baa20c1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,17 @@ [![Latest Stable Version](https://poser.pugx.org/patchlevel/event-sourcing-phpunit/v)](//packagist.org/packages/patchlevel/event-sourcing-phpunit) [![License](https://poser.pugx.org/patchlevel/event-sourcing-phpunit/license)](//packagist.org/packages/patchlevel/event-sourcing-phpunit) -# Testing utilities +# Event Sourcing PHPUnit -With this library you can ease the testing for your [event-sourcing](https://github.com/patchlevel/event-sourcing) -project when using PHPUnit. It comes with utilities for aggregates and subscribers. +"Test your event-sourcing aggregates and subscribers with a clear given / when / then notation." + +## Features + +* A [given / when / then test case](https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-aggregates) for aggregate behaviour +* A `when` that also [dispatches commands](https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-aggregates) through your `#[Handle]` methods +* [Aggregate state assertions](https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-aggregates) with closures +* A [subscriber utility](https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-subscribers) for setup, run and teardown +* and much more... ## Installation @@ -13,308 +20,18 @@ project when using PHPUnit. It comes with utilities for aggregates and subscribe composer require --dev patchlevel/event-sourcing-phpunit ``` -## Testing Aggregates - -There is a special `TestCase` for aggregate tests which you can extend from. Extending from `AggregateRootTestCase` -enables you to use the given / when / then notation. This makes it very clear what the test is doing. When extending the -class you will need to implement a method which provides the FQCN of the aggregate you want to test. - -```php -final class ProfileTest extends AggregateRootTestCase -{ - protected function aggregateClass(): string - { - return Profile::class; - } -} -``` - -When this is done, you already can start testing your behaviour. For example testing that a event is recorded. - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->given( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - ) - ->when(static fn (Profile $profile) => $profile->visitProfile(ProfileId::fromString('2'))) - ->then(new ProfileVisited(ProfileId::fromString('2'))); - } -} -``` - -You can also provide multiple given events and expect multiple events: - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->given( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - new ProfileVisited(ProfileId::fromString('2')), - ) - ->when( - static function (Profile $profile) { - $profile->visitProfile(ProfileId::fromString('3')); - $profile->visitProfile(ProfileId::fromString('4')); - } - ) - ->then( - new ProfileVisited(ProfileId::fromString('3')), - new ProfileVisited(ProfileId::fromString('4')), - ); - } -} -``` - -You can also test the creation of the aggregate: - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->when(static fn () => Profile::createProfile(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))) - ->then(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))); - } -} -``` - -And expect an exception and the message of it: - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->given( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - ) - ->when(static fn (Profile $profile) => $profile->throwException()) - ->expectsException(ProfileError::class) - ->expectsExceptionMessage('throwing so that you can catch it!'); - } -} -``` - -### Asserting aggregate state - -You can pass closures to `then()` to assert on the aggregate's state after the events have been applied. This is useful -when your aggregate exposes state via public properties or getters that are set in `apply` methods. Closures receive the -aggregate instance and are executed after the event assertion. You can mix closures and expected events freely — event -order is preserved regardless of callback placement. - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->given( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - ) - ->when(static fn (Profile $profile) => $profile->visitProfile(ProfileId::fromString('2'))) - ->then( - new ProfileVisited(ProfileId::fromString('2')), - static fn (Profile $profile) => self::assertSame('1', $profile->id()->toString()), - ); - } -} -``` - -> [!NOTE] -> When `then()` receives only closures and no event objects, it strictly asserts that zero events were emitted. - -### Using Commandbus like syntax - -When using the command bus and the `#[Handle]` attributes in your aggregate you can also provide the command directly -for the `when` method. - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->when(new CreateProfile(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))) - ->then(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))); - } -} -``` - -If more parameters than the command is needed, these can also be provided as additional parameters for `when`. In this -example the we need a string which will be directly passed to the event. - -```php -final class ProfileTest extends AggregateRootTestCase -{ - // protected function aggregateClass(): string; - - public function testBehaviour(): void - { - $this - ->given( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - ) - ->when(new VisitProfile(ProfileId::fromString('2')), 'Extra Parameter / Dependency') - ->then(new ProfileVisited(ProfileId::fromString('2'), 'Extra Parameter / Dependency')); - } -} -``` - -## Testing Subscriber - -For testing a subscriber there is a utility class which you can use. Using `SubscriberUtilities` will provide you a -bunch of dx features which makes the testing easier. First, you will need to provide the utility class the subscriptions -you will want to test, this is done when initializing the class. After that, you can call these 3 methods: -`executeSetup`, `executeRun` and `executeTeardown`. These methods will be calling the right methods which are defined -via the attributes. For our example we are taking as simplified subscriber: - -```php -use Patchlevel\EventSourcing\Attribute\Setup; -use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Attribute\Subscriber; -use Patchlevel\EventSourcing\Attribute\Teardown; - -#[Subscriber('profile_subscriber', RunMode::FromBeginning)] -final class ProfileSubscriber -{ - public int $called = 0; - - #[Subscribe(ProfileCreated::class)] - public function run(): void - { - $this->called++; - } - - #[Setup] - public function setup(): void - { - $this->called++; - } - - #[Teardown] - public function teardown(): void - { - $this->called++; - } -} -``` +## Documentation -With this, we can now write our test for it: +* Latest [Docs](https://patchlevel.dev/docs/event-sourcing-phpunit/latest) +* Related [Blog](https://patchlevel.dev/blog) -```php -use Patchlevel\EventSourcing\Attribute\Subscriber; -use Patchlevel\EventSourcing\Subscription\RunMode; -use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; +## Integration -final class ProfileSubscriberTest extends TestCase -{ - use SubscriberUtilities; +* [event-sourcing](https://github.com/patchlevel/event-sourcing) - public function testProfileCreated(): void - { - $subscriber = new ProfileSubscriber(/* inject deps, if needed */); +## Contributing - $util = new SubscriberUtilities($subscriber); - $util->executeSetup(); - $util->executeRun( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ) - ); - $util->executeTeardown(); +We are open to contributions as long as they are in line with +our [BC-Policy](https://patchlevel.dev/our-backward-compatibility-promise). - self::assertSame(3, $subscriber->count); - } -} -``` - -This Util class can be used for integration or unit tests. - -You can also pass `Message` instances with additional headers to the `executeRun` method. This allows testing -subscribers that rely on additional parameters like header information: - - -```php -use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Attribute\Subscriber; -use DateTimeImmutable; - -#[Subscriber('profile_subscriber', RunMode::FromBeginning)] -final class ProfileSubscriber -{ - #[Subscribe(ProfileCreated::class)] - public function run(ProfileCreated $event, DateTimeImmutable $recordedOn): void - { - } -} -``` - -Add any headers you want in the test: - -```php -use Patchlevel\EventSourcing\Attribute\Subscriber; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; -use Patchlevel\EventSourcing\Subscription\RunMode; -use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; -use DateTimeImmutable; - -final class ProfileSubscriberTest extends TestCase -{ - use SubscriberUtilities; - - public function testProfileCreated(): void - { - /* Setup and Teardown as before */ - - $util->executeRun( - Message::createWithHeaders( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('hq@patchlevel.de'), - ), - [new RecordedOnHeader(new DateTimeImmutable('now'))], - ) - ); - - /* Your assertions */ - } -} -``` +Also note that the `composer.lock` is always generated with the newest supported PHP version as this is the version our tools run in the CI. diff --git a/bin/docs-extract-php-code b/bin/docs-extract-php-code new file mode 100755 index 0000000..d50b642 --- /dev/null +++ b/bin/docs-extract-php-code @@ -0,0 +1,53 @@ +#!/usr/bin/env php +addExtension(new MarkdownRendererExtension()); + +$parser = new MarkdownParser($environment); + +$targetDir = __DIR__ . '/../docs_php'; + +if (file_exists($targetDir)) { + exec('rm -rf ' . $targetDir); +} + +mkdir($targetDir); + +$finder = new Symfony\Component\Finder\Finder(); +$finder->files()->in(__DIR__ . '/../docs')->name('*.md'); + +foreach ($finder as $file) { + $fileName = pathinfo($file->getBasename(), PATHINFO_FILENAME); + + $content = file_get_contents($file->getPathname()); + $document = $parser->parse($content); + + $result = (new Query()) + ->where(Query::type(FencedCode::class)) + ->findAll($document); + + /** + * @var FencedCode $node + */ + foreach ($result as $i => $node) { + if ($node->getInfo() !== 'php') { + continue; + } + + $source = sprintf('%s:%s', $file->getRealPath(), $node->getStartLine()); + + $code = "getLiteral(); + + $targetPath = $targetDir . '/' . $fileName . '_' . $i . '.php'; + file_put_contents($targetPath, $code); + } +} diff --git a/bin/docs-inject-php-code b/bin/docs-inject-php-code new file mode 100755 index 0000000..e4e81ea --- /dev/null +++ b/bin/docs-inject-php-code @@ -0,0 +1,70 @@ +#!/usr/bin/env php +addExtension(new MarkdownRendererExtension()); + +$parser = new MarkdownParser($environment); +$markdownRenderer = new MarkdownRenderer($environment); + +$targetDir = __DIR__ . '/../docs_php'; + +if (!file_exists($targetDir)) { + exit(1); +} + +$finder = new Symfony\Component\Finder\Finder(); +$finder->files()->in(__DIR__ . '/../docs')->name('*.md'); + +foreach ($finder as $file) { + $fileName = pathinfo($file->getBasename(), PATHINFO_FILENAME); + + $content = file_get_contents($file->getPathname()); + $document = $parser->parse($content); + + $result = (new Query()) + ->where(Query::type(FencedCode::class)) + ->findAll($document); + + /** + * @var FencedCode $node + */ + foreach ($result as $i => $node) { + if ($node->getInfo() !== 'php') { + $node->setLiteral(trim($node->getLiteral())); + continue; + } + + $targetPath = $targetDir . '/' . $fileName . '_' . $i . '.php'; + + if (!file_exists($targetPath)) { + $node->setLiteral(trim($node->getLiteral())); + continue; + } + + $code = file_get_contents($targetPath); + + $lines = explode("\n", $code); + array_splice($lines, 0, 2); + $code = implode("\n", $lines); + + $node->setLiteral(trim($code)); + } + + file_put_contents($file->getPathname(), $markdownRenderer->renderDocument($document)); +} + +if (file_exists($targetDir)) { + exec('rm -rf ' . $targetDir); +} \ No newline at end of file diff --git a/composer.json b/composer.json index 2f3c85b..0952935 100644 --- a/composer.json +++ b/composer.json @@ -2,14 +2,15 @@ "name": "patchlevel/event-sourcing-phpunit", "type": "library", "license": "MIT", - "description": "PHPUnit testing utilities for patchlevel/event-sourcing", + "description": "Test your event-sourcing aggregates and subscribers with a clear given / when / then notation", "keywords": [ + "patchlevel", "event-sourcing", "ddd", "phpunit", "testing" ], - "homepage": "https://event-sourcing.patchlevel.io", + "homepage": "https://patchlevel.dev/docs/event-sourcing-phpunit/latest", "authors": [ { "name": "Daniel Badura", @@ -28,7 +29,8 @@ "require-dev": { "infection/infection": "^0.32.0", "patchlevel/coding-standard": "^1.3.0", - "phpstan/phpstan": "^2.1.0" + "phpstan/phpstan": "^2.1.0", + "wnx/commonmark-markdown-renderer": "^1.6" }, "config": { "preferred-install": { diff --git a/composer.lock b/composer.lock index 3fcc267..cba60c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bca9b7c68f388f406a298db7e3098b96", + "content-hash": "742a1772d5223d1cc85cdcc69dac0679", "packages": [ { "name": "brick/math", @@ -4579,6 +4579,81 @@ ], "time": "2026-05-06T08:26:05+00:00" }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, { "name": "fidry/cpu-core-counter", "version": "1.3.0", @@ -5085,6 +5160,195 @@ }, "time": "2026-06-05T14:05:24+00:00" }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, { "name": "marc-mabe/php-enum", "version": "v4.7.2", @@ -5158,6 +5422,164 @@ }, "time": "2025-09-14T11:18:39+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, { "name": "ondram/ci-detector", "version": "4.2.0", @@ -5891,6 +6313,90 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/process", "version": "v8.1.0", @@ -6164,6 +6670,67 @@ "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, "time": "2026-05-20T13:07:01+00:00" + }, + { + "name": "wnx/commonmark-markdown-renderer", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/stefanzweifel/commonmark-markdown-renderer.git", + "reference": "3a283076abd1a1ed043940f9be43cd35470cd0d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stefanzweifel/commonmark-markdown-renderer/zipball/3a283076abd1a1ed043940f9be43cd35470cd0d4", + "reference": "3a283076abd1a1ed043940f9be43cd35470cd0d4", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0", + "rector/rector": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Wnx\\CommonmarkMarkdownRenderer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stefan Zweifel", + "email": "stefan@stefanzweifel.dev", + "role": "Developer" + } + ], + "description": "Render Markdown AST back to Markdown.", + "homepage": "https://github.com/stefanzweifel/commonmark-markdown-renderer", + "keywords": [ + "commonmark-markdown-renderer", + "markdown", + "renderer", + "wnx" + ], + "support": { + "issues": "https://github.com/stefanzweifel/commonmark-markdown-renderer/issues", + "source": "https://github.com/stefanzweifel/commonmark-markdown-renderer/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://github.com/stefanzweifel", + "type": "github" + } + ], + "time": "2025-11-23T20:14:14+00:00" } ], "aliases": [], diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..5abceb2 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,175 @@ +# Getting Started + +This guide walks you through testing a small profile domain end to end. You start with an aggregate test in the given / when / then style, then drive a subscriber through its lifecycle. The example assumes you already have an [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) aggregate in place. + +## Installation + +Install the package as a development dependency: + +```bash +composer require --dev patchlevel/event-sourcing-phpunit +``` +## The example domain + +The whole guide uses a `Profile` aggregate that can be created and visited. It records a `ProfileCreated` event on creation and a `ProfileVisited` event whenever someone visits it. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\Id; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Id] + private ProfileId $id; + private Email $email; + private int $visits = 0; + + public function id(): ProfileId + { + return $this->id; + } + + public static function create(ProfileId $id, Email $email): self + { + $self = new self(); + $self->recordThat(new ProfileCreated($id, $email)); + + return $self; + } + + public function visit(ProfileId $visitorId): void + { + $this->recordThat(new ProfileVisited($visitorId)); + } + + #[Apply(ProfileCreated::class)] + #[Apply(ProfileVisited::class)] + protected function applyEvent(ProfileCreated|ProfileVisited $event): void + { + if ($event instanceof ProfileCreated) { + $this->id = $event->profileId; + $this->email = $event->email; + + return; + } + + $this->visits++; + } +} +``` +:::note +Aggregates, events and the `#[Apply]` attribute come from the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library, not from this package. +::: + +## Write your first aggregate test + +Extend [`AggregateRootTestCase`](testing-aggregates.md) and tell it which aggregate you are testing by implementing `aggregateClass()`. From there you describe the past with `given()`, trigger behaviour with `when()` and assert the recorded events with `then()`. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testVisit(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ) + ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) + ->then(new ProfileVisited(ProfileId::fromString('2'))); + } +} +``` +## Test the creation + +When there is no history yet, skip `given()` and let `when()` create the aggregate. Return the new aggregate from the closure so the test case can collect its events. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testCreate(): void + { + $this + ->when(static fn () => Profile::create( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )) + ->then(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )); + } +} +``` +:::tip +There are more ways to drive an aggregate, including a command bus aware `when()`. See [testing aggregates](testing-aggregates.md) for the full picture. +::: + +## Test a subscriber + +To test a subscriber, hand it to [`SubscriberUtilities`](testing-subscribers.md) and call the lifecycle methods. The utility resolves the `#[Setup]`, `#[Subscribe]` and `#[Teardown]` methods from the attributes for you. + +```php +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; +use Patchlevel\EventSourcing\Subscription\RunMode; +use PHPUnit\Framework\TestCase; + +#[Subscriber('profile_counter', RunMode::FromBeginning)] +final class ProfileCounter +{ + public int $count = 0; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(): void + { + $this->count++; + } +} + +final class ProfileCounterTest extends TestCase +{ + public function testProfileCreated(): void + { + $subscriber = new ProfileCounter(); + + $util = new SubscriberUtilities($subscriber); + $util->executeRun( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ); + + self::assertSame(1, $subscriber->count); + } +} +``` +## Result + +You now have a fast unit test suite for your aggregates and subscribers, written in a notation that reads like the behaviour it verifies. No database, no message bus and no container are involved. + +## Learn more + +* [How to test aggregates](testing-aggregates.md) +* [How to test subscribers](testing-subscribers.md) diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..4e590a1 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,24 @@ +# Event Sourcing PHPUnit + +Testing utilities that make it easy to test your [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) code with PHPUnit. It ships a given / when / then test case for aggregates and a helper that drives subscribers through their lifecycle, so your tests describe behaviour instead of wiring. + +## Features + +* A [given / when / then test case](testing-aggregates.md) for aggregate behaviour +* A `when` that also [dispatches commands](testing-aggregates.md) through your `#[Handle]` methods +* [Aggregate state assertions](testing-aggregates.md) with closures +* A [subscriber utility](testing-subscribers.md) for setup, run and teardown +* and much more... + +## Installation + +```bash +composer require --dev patchlevel/event-sourcing-phpunit +``` +## Integration + +* [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) + +:::tip +New here? Follow the [getting started](getting-started.md) guide to write your first test. +::: diff --git a/docs/project.json b/docs/project.json new file mode 100644 index 0000000..be50991 --- /dev/null +++ b/docs/project.json @@ -0,0 +1,8 @@ +{ + "navigation": [ + { "title": "Introduction", "file": "introduction.md" }, + { "title": "Getting Started", "file": "getting-started.md" }, + { "title": "Testing Aggregates", "file": "testing-aggregates.md" }, + { "title": "Testing Subscribers", "file": "testing-subscribers.md" } + ] +} diff --git a/docs/testing-aggregates.md b/docs/testing-aggregates.md new file mode 100644 index 0000000..6b5368c --- /dev/null +++ b/docs/testing-aggregates.md @@ -0,0 +1,256 @@ +# Testing Aggregates + +Aggregates hold your domain behaviour, so they deserve focused tests. The `AggregateRootTestCase` gives you a given / when / then notation that makes each test read like a small specification: the history that happened, the action you trigger and the events you expect in return. + +## The test case + +Extend `AggregateRootTestCase` and implement `aggregateClass()` to return the fully qualified class name of the aggregate under test. The test case uses it to rebuild the aggregate from your given events and to find command handlers. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } +} +``` +## Given, when, then + +`given()` takes the events that already happened, `when()` triggers behaviour on the rebuilt aggregate and `then()` asserts the events that were recorded. The closure passed to `when()` receives the aggregate instance. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testVisit(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ) + ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) + ->then(new ProfileVisited(ProfileId::fromString('2'))); + } +} +``` +:::note +The expected events are compared with `assertEquals`, so value objects inside your events are matched by value, not by identity. +::: + +## Multiple events + +You can pass several events to both `given()` and `then()`. The order of the expected events must match the order in which they were recorded. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testMultipleVisits(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + new ProfileVisited(ProfileId::fromString('2')), + ) + ->when(static function (Profile $profile): void { + $profile->visit(ProfileId::fromString('3')); + $profile->visit(ProfileId::fromString('4')); + }) + ->then( + new ProfileVisited(ProfileId::fromString('3')), + new ProfileVisited(ProfileId::fromString('4')), + ); + } +} +``` +## Testing creation + +When the aggregate does not exist yet, omit `given()` and return the freshly created aggregate from the `when()` closure. The test case collects the events from the returned aggregate. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testCreate(): void + { + $this + ->when(static fn () => Profile::create( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )) + ->then(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )); + } +} +``` +:::warning +Return the aggregate only when there are no given events. Combining `given()` with a closure that also returns an aggregate raises an `AggregateAlreadySet` error, because the test case would not know which instance to use. +::: + +## Expecting exceptions + +Use `expectsException()` and `expectsExceptionMessage()` to assert that an action fails. You can use either one on its own or both together. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testThrowsOnInvalidAction(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ) + ->when(static fn (Profile $profile) => $profile->throwException()) + ->expectsException(ProfileError::class) + ->expectsExceptionMessage('throwing so that you can catch it!'); + } +} +``` +:::note +`expectsExceptionMessage()` matches if the thrown message is or contains the given string, so you can assert on a meaningful fragment instead of the full text. +::: + +## Asserting aggregate state + +Sometimes you want to check the aggregate's state, not only its events. Pass a closure to `then()` and it receives the aggregate after all events have been applied. You can mix expected events and closures freely, the event order is preserved regardless of where the closures sit. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testStateAfterVisit(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ) + ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) + ->then( + new ProfileVisited(ProfileId::fromString('2')), + static fn (Profile $profile) => self::assertSame('1', $profile->id()->toString()), + ); + } +} +``` +:::warning +When `then()` receives only closures and no event objects, it asserts that zero events were recorded. Always list the events you expect alongside your state assertions. +::: + +## Command bus syntax + +If your aggregate handles commands with the `#[Handle]` attribute, you can pass the command object straight to `when()`. The test case finds the matching handler and invokes it, whether it is a static factory or an instance method. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testCreateViaCommand(): void + { + $this + ->when(new CreateProfile( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )) + ->then(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + )); + } +} +``` +When the handler needs more than the command, pass the extra arguments after the command. They are forwarded to the handler method in order, which is handy for injecting dependencies or values. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; + +final class ProfileTest extends AggregateRootTestCase +{ + protected function aggregateClass(): string + { + return Profile::class; + } + + public function testVisitViaCommandWithToken(): void + { + $this + ->given( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ) + ->when(new VisitProfile(ProfileId::fromString('2')), 'a-token') + ->then(new ProfileVisited(ProfileId::fromString('2'), 'a-token')); + } +} +``` +:::tip +The command bus and the `#[Handle]` attribute are part of the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. More about the [command bus](https://patchlevel.dev/docs/event-sourcing/latest) lives in its documentation. +::: + +## Common errors + +The test case guards against incomplete tests with dedicated errors, all extending `AggregateTestError`: + +* `NoWhenProvided` is raised when a test forgets to call `when()`, because nothing would actually be exercised. +* `NoAggregateCreated` is raised when neither given events nor a returned aggregate produced an instance to assert on. +* `AggregateAlreadySet` is raised when given events and a returned aggregate are combined. + +## Learn more + +* [How to test subscribers](testing-subscribers.md) +* [How to get started](getting-started.md) diff --git a/docs/testing-subscribers.md b/docs/testing-subscribers.md new file mode 100644 index 0000000..bd19b86 --- /dev/null +++ b/docs/testing-subscribers.md @@ -0,0 +1,173 @@ +# Testing Subscribers + +Subscribers react to events to build projections, send notifications or run other side effects. `SubscriberUtilities` lets you drive a subscriber through its lifecycle without a running subscription engine, so you can unit test the reaction to a single event in isolation. + +## The utility + +Create a `SubscriberUtilities` instance with the subscriber you want to test. It reads the `#[Setup]`, `#[Subscribe]` and `#[Teardown]` attributes and exposes one method per lifecycle step: `executeSetup()`, `executeRun()` and `executeTeardown()`. Each method returns the utility, so you can chain the calls. + +```php +use Patchlevel\EventSourcing\Attribute\Setup; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Attribute\Teardown; +use Patchlevel\EventSourcing\Subscription\RunMode; + +#[Subscriber('profile_counter', RunMode::FromBeginning)] +final class ProfileCounter +{ + public int $count = 0; + + #[Setup] + public function setup(): void + { + $this->count = 0; + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(): void + { + $this->count++; + } + + #[Teardown] + public function teardown(): void + { + $this->count = 0; + } +} +``` +:::note +The subscriber attributes come from the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. This package only invokes the methods they mark. +::: + +## Running the lifecycle + +Pass the subscriber to the utility and call the steps you want to verify. `executeRun()` accepts one or more events and forwards each to the matching `#[Subscribe]` methods. + +```php +use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; +use PHPUnit\Framework\TestCase; + +final class ProfileCounterTest extends TestCase +{ + public function testProfileCreated(): void + { + $subscriber = new ProfileCounter(); + + $util = new SubscriberUtilities($subscriber); + $util->executeSetup(); + $util->executeRun( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + ); + $util->executeTeardown(); + + self::assertSame(0, $subscriber->count); + } +} +``` +:::note +Each step is optional. If a subscriber has no setup or teardown method, `executeSetup()` and `executeTeardown()` simply do nothing, so you can call only the steps your test cares about. +::: + +## Passing messages with headers + +A subscribe method can ask for header values such as the recording time. To provide them, wrap the event in a `Message` and add the headers you need, then pass the message to `executeRun()`. Plain events are wrapped in a message automatically. + +```php +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\RunMode; + +#[Subscriber('profile_log', RunMode::FromBeginning)] +final class ProfileLog +{ + public DateTimeImmutable|null $lastSeen = null; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event, DateTimeImmutable $recordedOn): void + { + $this->lastSeen = $recordedOn; + } +} +``` +Build the message with the matching header in your test: + +```php +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; +use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; +use PHPUnit\Framework\TestCase; + +final class ProfileLogTest extends TestCase +{ + public function testRecordsTimestamp(): void + { + $recordedOn = new DateTimeImmutable('2026-06-14 12:00:00'); + $subscriber = new ProfileLog(); + + $util = new SubscriberUtilities($subscriber); + $util->executeRun( + Message::createWithHeaders( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), + [new RecordedOnHeader($recordedOn)], + ), + ); + + self::assertEquals($recordedOn, $subscriber->lastSeen); + } +} +``` +## Testing multiple subscribers + +Pass an array of subscribers to test them together against the same events. Every lifecycle call is dispatched to all of them, which is useful when several projections react to one event. + +```php +$util = new SubscriberUtilities([ + new ProfileCounter(), + new ProfileLog(), +]); +$util->executeRun( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hq@patchlevel.de'), + ), +); +``` +:::tip +For subscribers that need extra constructor dependencies, build them yourself before handing them to the utility, just like any other object under test. + +```php +$subscriber = new ProfileNotifier($mailerMock); +$util = new SubscriberUtilities($subscriber); +``` +::: + +## Custom argument resolvers + +If your subscribe methods rely on custom arguments, pass your own argument resolvers as the third constructor argument. The second argument is the metadata factory, which defaults to the attribute based factory. + +```php +use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; +use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; + +$util = new SubscriberUtilities( + new ProfileCounter(), + new AttributeSubscriberMetadataFactory(), + [new MyCustomArgumentResolver()], +); +``` +:::note +Metadata factories and argument resolvers are defined by the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. You only need them when your subscribers go beyond the default event and header arguments. +::: + +## Learn more + +* [How to test aggregates](testing-aggregates.md) +* [How to get started](getting-started.md)