Skip to content
This repository was archived by the owner on Nov 5, 2025. It is now read-only.

Commit e26717c

Browse files
Implement custom reporting for event-based testing (requires snapshot of PHPUnit 12.3)
1 parent c0bfccc commit e26717c

File tree

11 files changed

+4082
-2127
lines changed

11 files changed

+4082
-2127
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
/.php-cs-fixer.cache
55
/.phpunit.cache
66
/vendor
7+
/build
78
/projections

phpunit.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,10 @@
5353
<php>
5454
<const name="TEST_WEB_SERVER_BASE_URL" value="http://127.0.0.1:8080"/>
5555
</php>
56+
57+
<extensions>
58+
<bootstrap class="example\framework\event\test\extension\Extension">
59+
<parameter name="target" value="build/events.md"/>
60+
</bootstrap>
61+
</extensions>
5662
</phpunit>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php declare(strict_types=1);
2+
namespace example\caledonia\application;
3+
4+
use example\caledonia\domain\Good;
5+
use example\caledonia\domain\Price;
6+
use example\caledonia\domain\PurchaseGoodCommand;
7+
use example\framework\event\EventTestCase;
8+
use PHPUnit\Framework\Attributes\CoversClass;
9+
use PHPUnit\Framework\Attributes\Medium;
10+
use PHPUnit\Framework\Attributes\TestDox;
11+
12+
#[CoversClass(ProcessingPurchaseGoodCommandProcessor::class)]
13+
#[Medium]
14+
#[TestDox('ProcessingPurchaseGoodCommandProcessor')]
15+
final class PurchaseGoodCommandProcessorTest extends EventTestCase
16+
{
17+
#[TestDox('A GoodPurchasedEvent and a PriceChangedEvent are emitted when the price changes after a good is purchased')]
18+
public function testEmitsGoodPurchasedAndPriceChangedEvents(): void
19+
{
20+
$this->given(
21+
$this->goodPurchased(Good::Bread, Price::from(10), 1),
22+
$this->goodPurchased(Good::Bread, Price::from(11), 1),
23+
);
24+
25+
$this->when(new PurchaseGoodCommand(Good::Bread, 1));
26+
27+
$this->then(
28+
$this->goodPurchased(Good::Bread, Price::from(11), 1),
29+
$this->priceChanged(Good::Bread, Price::from(11), Price::from(12)),
30+
);
31+
}
32+
33+
private function when(PurchaseGoodCommand $command): void
34+
{
35+
$processor = new ProcessingPurchaseGoodCommandProcessor($this->emitter(), $this->sourcer());
36+
37+
$processor->process($command);
38+
39+
$this->recordWhen($command);
40+
}
41+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php declare(strict_types=1);
2+
namespace example\caledonia\application;
3+
4+
use example\caledonia\domain\Good;
5+
use example\caledonia\domain\Price;
6+
use example\caledonia\domain\SellGoodCommand;
7+
use example\framework\event\EventTestCase;
8+
use PHPUnit\Framework\Attributes\CoversClass;
9+
use PHPUnit\Framework\Attributes\Medium;
10+
use PHPUnit\Framework\Attributes\TestDox;
11+
12+
#[CoversClass(ProcessingSellGoodCommandProcessor::class)]
13+
#[Medium]
14+
#[TestDox('ProcessingPurchaseSellCommandProcessor')]
15+
final class SellGoodCommandProcessorTest extends EventTestCase
16+
{
17+
#[TestDox('A GoodSoldEvent and a PriceChangedEvent are emitted when the price changes after a good is sold')]
18+
public function testEmitsGoodPurchasedAndPriceChangedEvents(): void
19+
{
20+
$this->given();
21+
22+
$this->when(new SellGoodCommand(Good::Bread, 1));
23+
24+
$this->then(
25+
$this->goodSold(Good::Bread, Price::from(10), 1),
26+
$this->priceChanged(Good::Bread, Price::from(10), Price::from(9)),
27+
);
28+
}
29+
30+
private function when(SellGoodCommand $command): void
31+
{
32+
$processor = new ProcessingSellGoodCommandProcessor($this->emitter(), $this->sourcer());
33+
34+
$processor->process($command);
35+
36+
$this->recordWhen($command);
37+
}
38+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types=1);
2+
namespace example\framework\event;
3+
4+
final class CollectingEventDispatcher implements EventDispatcher
5+
{
6+
/**
7+
* @var list<Event>
8+
*/
9+
private array $events = [];
10+
11+
public function dispatch(Event $event): void
12+
{
13+
$this->events[] = $event;
14+
}
15+
16+
public function events(): EventCollection
17+
{
18+
return EventCollection::fromArray($this->events);
19+
}
20+
}

tests/src/EventTestCase.php

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php declare(strict_types=1);
2+
namespace example\framework\event;
3+
4+
use const PHP_EOL;
5+
use function array_keys;
6+
use function array_values;
7+
use function assert;
8+
use example\caledonia\application\DispatchingEventEmitter;
9+
use example\caledonia\application\EventEmitter;
10+
use example\caledonia\application\MarketEventSourcer;
11+
use example\caledonia\application\MarketSourcer;
12+
use example\caledonia\domain\Command;
13+
use example\caledonia\domain\Good;
14+
use example\caledonia\domain\GoodPurchasedEvent;
15+
use example\caledonia\domain\GoodSoldEvent;
16+
use example\caledonia\domain\Price;
17+
use example\caledonia\domain\PriceChangedEvent;
18+
use example\framework\library\RandomUuidGenerator;
19+
use PHPUnit\Framework\MockObject\Stub;
20+
use PHPUnit\Framework\TestCase;
21+
22+
abstract class EventTestCase extends TestCase
23+
{
24+
private EventReader&Stub $reader;
25+
private MarketEventSourcer $sourcer;
26+
private DispatchingEventEmitter $emitter;
27+
private CollectingEventDispatcher $dispatcher;
28+
private string $documentation;
29+
30+
final protected function setUp(): void
31+
{
32+
$this->reader = $this->createStub(EventReader::class);
33+
$this->sourcer = new MarketEventSourcer($this->reader);
34+
$this->dispatcher = new CollectingEventDispatcher;
35+
36+
$this->emitter = new DispatchingEventEmitter(
37+
$this->dispatcher,
38+
new RandomUuidGenerator,
39+
);
40+
}
41+
42+
final protected function given(Event ...$events): void
43+
{
44+
$events = EventCollection::fromArray(array_values($events));
45+
46+
$this
47+
->reader
48+
->method('topic')
49+
->willReturn($events);
50+
51+
$this->documentation = 'Given:' . PHP_EOL . PHP_EOL;
52+
53+
foreach ($events as $event) {
54+
$this->documentation .= ' - ' . $event->asString() . PHP_EOL;
55+
}
56+
57+
$this->documentation .= PHP_EOL;
58+
}
59+
60+
final protected function recordWhen(Command $command): void
61+
{
62+
$this->documentation .= 'When:' . PHP_EOL . PHP_EOL;
63+
$this->documentation .= ' - ' . $command->asString() . PHP_EOL . PHP_EOL;
64+
}
65+
66+
final protected function then(Event ...$events): void
67+
{
68+
$events = EventCollection::fromArray(array_values($events));
69+
70+
$expected = $events->asArray();
71+
$actual = $this->dispatcher->events()->asArray();
72+
73+
$this->assertSameSize($expected, $actual);
74+
75+
foreach (array_keys($expected) as $key) {
76+
assert(isset($expected[$key]));
77+
assert(isset($actual[$key]));
78+
79+
$this->assertEventObjectsAreEqualExceptForUuid($expected[$key], $actual[$key]);
80+
}
81+
82+
$this->documentation .= 'Then:' . PHP_EOL . PHP_EOL;
83+
84+
foreach ($events as $event) {
85+
$this->documentation .= ' - ' . $event->asString() . PHP_EOL;
86+
}
87+
88+
$this->provideAdditionalInformation($this->documentation);
89+
}
90+
91+
/**
92+
* @param positive-int $amount
93+
*/
94+
final protected function goodPurchased(Good $good, Price $price, int $amount): GoodPurchasedEvent
95+
{
96+
return new GoodPurchasedEvent(
97+
(new RandomUuidGenerator)->generate(),
98+
$good,
99+
$price,
100+
$amount,
101+
);
102+
}
103+
104+
/**
105+
* @param positive-int $amount
106+
*/
107+
final protected function goodSold(Good $good, Price $price, int $amount): GoodSoldEvent
108+
{
109+
return new GoodSoldEvent(
110+
(new RandomUuidGenerator)->generate(),
111+
$good,
112+
$price,
113+
$amount,
114+
);
115+
}
116+
117+
final protected function priceChanged(Good $good, Price $old, Price $new): PriceChangedEvent
118+
{
119+
return new PriceChangedEvent(
120+
(new RandomUuidGenerator)->generate(),
121+
$good,
122+
$old,
123+
$new,
124+
);
125+
}
126+
127+
final protected function emitter(): EventEmitter
128+
{
129+
return $this->emitter;
130+
}
131+
132+
final protected function sourcer(): MarketSourcer
133+
{
134+
return $this->sourcer;
135+
}
136+
137+
private function assertEventObjectsAreEqualExceptForUuid(Event $expected, Event $actual): void
138+
{
139+
$this->assertInstanceOf($expected::class, $actual);
140+
141+
$this->assertArrayIsEqualToArrayIgnoringListOfKeys(
142+
(array) $expected,
143+
(array) $actual,
144+
["\0example\\framework\\event\\Event\0id"],
145+
);
146+
}
147+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php declare(strict_types=1);
2+
namespace example\framework\event\test\extension;
3+
4+
use PHPUnit\Event\Test\AdditionalInformationProvided as TestProvidedAdditionalInformation;
5+
use PHPUnit\Event\Test\AdditionalInformationProvidedSubscriber as TestProvidedAdditionalInformationSubscriber;
6+
7+
/**
8+
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
9+
*
10+
* @internal This class is not covered by the backward compatibility promise for PHPUnit
11+
*/
12+
final readonly class AdditionalInformationProvidedSubscriber extends Subscriber implements TestProvidedAdditionalInformationSubscriber
13+
{
14+
public function notify(TestProvidedAdditionalInformation $event): void
15+
{
16+
$this->extension()->testProvidedAdditionalInformation($event);
17+
}
18+
}

tests/src/extension/Extension.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php declare(strict_types=1);
2+
namespace example\framework\event\test\extension;
3+
4+
use const PHP_EOL;
5+
use function assert;
6+
use function dirname;
7+
use function file_put_contents;
8+
use function is_dir;
9+
use function is_file;
10+
use function ksort;
11+
use function mkdir;
12+
use function unlink;
13+
use PHPUnit\Event\Test\AdditionalInformationProvided;
14+
use PHPUnit\Runner\Extension\Extension as ExtensionInterface;
15+
use PHPUnit\Runner\Extension\Facade as ExtensionFacade;
16+
use PHPUnit\Runner\Extension\ParameterCollection;
17+
use PHPUnit\TextUI\Configuration\Configuration;
18+
19+
final class Extension implements ExtensionInterface
20+
{
21+
/**
22+
* @var non-empty-string
23+
*/
24+
private string $target;
25+
26+
/**
27+
* @var array<string, list<array{testdox: string, specification: string}>>
28+
*/
29+
private array $specification = [];
30+
31+
public function bootstrap(Configuration $configuration, ExtensionFacade $facade, ParameterCollection $parameters): void
32+
{
33+
$target = '/tmp/events.md';
34+
35+
if ($parameters->has('target')) {
36+
$target = $parameters->get('target');
37+
}
38+
39+
assert($target !== '');
40+
41+
$this->target = $target;
42+
43+
$this->prepareLogfile($this->target);
44+
45+
$facade->registerSubscribers(
46+
new AdditionalInformationProvidedSubscriber($this),
47+
new TestRunnerExecutionFinishedSubscriber($this),
48+
);
49+
}
50+
51+
public function testProvidedAdditionalInformation(AdditionalInformationProvided $event): void
52+
{
53+
if (!isset($this->specification[$event->test()->testDox()->prettifiedClassName()])) {
54+
$this->specification[$event->test()->testDox()->prettifiedClassName()] = [];
55+
}
56+
57+
$this->specification[$event->test()->testDox()->prettifiedClassName()][] = [
58+
'testdox' => $event->test()->testDox()->prettifiedMethodName(),
59+
'specification' => $event->additionalInformation(),
60+
];
61+
}
62+
63+
public function flush(): void
64+
{
65+
ksort($this->specification);
66+
67+
$buffer = '';
68+
69+
foreach ($this->specification as $class => $methods) {
70+
$buffer .= '# ' . $class . PHP_EOL . PHP_EOL;
71+
72+
foreach ($methods as $method) {
73+
$buffer .= '## ' . $method['testdox'] . PHP_EOL . PHP_EOL;
74+
$buffer .= $method['specification'] . PHP_EOL . PHP_EOL;
75+
}
76+
}
77+
78+
file_put_contents($this->target, $buffer);
79+
}
80+
81+
private function prepareLogfile(string $target): void
82+
{
83+
if (is_file($target)) {
84+
unlink($target);
85+
86+
return;
87+
}
88+
89+
if (!is_dir(dirname($target))) {
90+
@mkdir(dirname($target), 0o777, true);
91+
}
92+
}
93+
}

tests/src/extension/Subscriber.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types=1);
2+
namespace example\framework\event\test\extension;
3+
4+
abstract readonly class Subscriber
5+
{
6+
private Extension $extension;
7+
8+
public function __construct(Extension $extension)
9+
{
10+
$this->extension = $extension;
11+
}
12+
13+
final protected function extension(): Extension
14+
{
15+
return $this->extension;
16+
}
17+
}

0 commit comments

Comments
 (0)