Skip to content

Commit 9c84c68

Browse files
authored
feat(event-bus): add event bus testing utilities (#1103)
1 parent c45ab5a commit 9c84c68

File tree

5 files changed

+369
-0
lines changed

5 files changed

+369
-0
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Tempest\EventBus\Testing;
4+
5+
use BackedEnum;
6+
use Closure;
7+
use PHPUnit\Framework\Assert;
8+
use PHPUnit\Framework\ExpectationFailedException;
9+
use PHPUnit\Framework\GeneratorNotSupportedException;
10+
use Tempest\Container\Container;
11+
use Tempest\EventBus\EventBus;
12+
use Tempest\EventBus\EventBusConfig;
13+
use UnitEnum;
14+
15+
final class EventBusTester
16+
{
17+
private FakeEventBus $fakeEventBus;
18+
19+
public function __construct(
20+
private readonly Container $container,
21+
) {}
22+
23+
/**
24+
* Prevents the registered event handlers from being called.
25+
*/
26+
public function preventEventHandling(): self
27+
{
28+
$this->fakeEventBus = new FakeEventBus($this->container->get(EventBusConfig::class));
29+
$this->container->singleton(EventBus::class, $this->fakeEventBus);
30+
31+
return $this;
32+
}
33+
34+
/**
35+
* Asserts that the given `$event` has been dispatched.
36+
*
37+
* @param null|Closure $callback A callback accepting the event instance. The assertion fails if the callback returns `false`.
38+
* @param null|int $count If specified, the assertion fails if the event has been dispatched a different amount of times.
39+
*/
40+
public function assertDispatched(string|object $event, ?Closure $callback = null, ?int $count = null): self
41+
{
42+
$this->assertFaked();
43+
44+
Assert::assertNotNull(
45+
actual: $dispatches = $this->findDispatches($event),
46+
message: 'The event was not dispatched.',
47+
);
48+
49+
if ($count !== null) {
50+
Assert::assertCount($count, $dispatches, 'The number of dispatches does not match.');
51+
}
52+
53+
if ($callback !== null) {
54+
foreach ($dispatches as $dispatch) {
55+
Assert::assertNotFalse($callback($dispatch), 'The callback failed.');
56+
}
57+
}
58+
59+
return $this;
60+
}
61+
62+
/**
63+
* Asserts that the specified `$event` has not been dispatched.
64+
*/
65+
public function assertNotDispatched(string|object $event): self
66+
{
67+
$this->assertFaked();
68+
69+
Assert::assertEmpty($this->findDispatches($event), 'The event was dispatched.');
70+
71+
return $this;
72+
}
73+
74+
/**
75+
* Asserts that the specified `$event` is being listened to.
76+
*
77+
* @param null|int $count If specified, the assertion fails if there are a different amount of listeners.
78+
*/
79+
public function assertListeningTo(string $event, ?int $count = null): self
80+
{
81+
$this->assertFaked();
82+
83+
Assert::assertNotEmpty(
84+
actual: $handlers = $this->findHandlersFor($event),
85+
message: 'The event is not being listened to.',
86+
);
87+
88+
if ($count !== null) {
89+
Assert::assertSame($count, count($handlers), 'The number of handlers does not match.');
90+
}
91+
92+
return $this;
93+
}
94+
95+
private function findDispatches(string|object $event): array
96+
{
97+
return array_filter($this->fakeEventBus->dispatched, function (string|object $dispatched) use ($event) {
98+
if ($dispatched === $event) {
99+
return true;
100+
}
101+
102+
if (class_exists($event) && $dispatched instanceof $event) {
103+
return true;
104+
}
105+
106+
return false;
107+
});
108+
}
109+
110+
/** @return array<\Tempest\EventBus\CallableEventHandler> */
111+
private function findHandlersFor(string|object $event): array
112+
{
113+
$eventName = match (true) {
114+
$event instanceof BackedEnum => $event->value,
115+
$event instanceof UnitEnum => $event->name,
116+
is_string($event) => $event,
117+
default => $event::class,
118+
};
119+
120+
return $this->fakeEventBus->eventBusConfig->handlers[$eventName] ?? [];
121+
}
122+
123+
private function assertFaked(): self
124+
{
125+
Assert::assertTrue(isset($this->fakeEventBus), 'Asserting against the event bus require the `preventEventHandling()` method to be called first.');
126+
127+
return $this;
128+
}
129+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Tempest\EventBus\Testing;
4+
5+
use Closure;
6+
use Tempest\EventBus\EventBus;
7+
use Tempest\EventBus\EventBusConfig;
8+
9+
final class FakeEventBus implements EventBus
10+
{
11+
public array $dispatched = [];
12+
13+
public function __construct(
14+
public EventBusConfig $eventBusConfig,
15+
) {}
16+
17+
public function listen(string|object $event, Closure $handler): void
18+
{
19+
$this->eventBusConfig->addClosureHandler($event, $handler);
20+
}
21+
22+
public function dispatch(string|object $event): void
23+
{
24+
$this->dispatched[] = $event;
25+
}
26+
}

src/Tempest/Framework/Testing/IntegrationTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Tempest\Core\FrameworkKernel;
1515
use Tempest\Core\Kernel;
1616
use Tempest\Database\Migrations\MigrationManager;
17+
use Tempest\EventBus\Testing\EventBusTester;
1718
use Tempest\Framework\Testing\Http\HttpRouterTester;
1819
use Tempest\Http\Method;
1920
use Tempest\Router\GenericRequest;
@@ -42,6 +43,8 @@ abstract class IntegrationTest extends TestCase
4243

4344
protected ViteTester $vite;
4445

46+
protected EventBusTester $eventBus;
47+
4548
protected function setUp(): void
4649
{
4750
parent::setUp();
@@ -63,6 +66,8 @@ protected function setUp(): void
6366
$this->vite = $this->container->get(ViteTester::class);
6467
$this->vite->preventTagResolution();
6568

69+
$this->eventBus = $this->container->get(EventBusTester::class);
70+
6671
$request = new GenericRequest(Method::GET, '/', []);
6772
$this->container->singleton(Request::class, fn () => $request);
6873
$this->container->singleton(GenericRequest::class, fn () => $request);
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\EventBus;
6+
7+
use LogicException;
8+
use PHPUnit\Framework\ExpectationFailedException;
9+
use Tempest\EventBus\EventBus;
10+
use Tempest\EventBus\Testing\FakeEventBus;
11+
use Tests\Tempest\Integration\EventBus\Fixtures\FakeEvent;
12+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
13+
14+
/**
15+
* @internal
16+
*/
17+
final class EventBusTesterTest extends FrameworkIntegrationTestCase
18+
{
19+
public function test_fake(): void
20+
{
21+
$this->eventBus->preventEventHandling();
22+
23+
$this->assertInstanceOf(FakeEventBus::class, $this->container->get(EventBus::class));
24+
}
25+
26+
public function test_assertion_on_real_event_bus(): void
27+
{
28+
$this->expectException(ExpectationFailedException::class);
29+
$this->expectExceptionMessage('Asserting against the event bus require the `preventEventHandling()` method to be called first.');
30+
31+
$this->eventBus->assertDispatched('event-bus-fake-event');
32+
}
33+
34+
public function test_assert_dispatched(): void
35+
{
36+
$this->eventBus->preventEventHandling();
37+
38+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
39+
$this->eventBus->assertDispatched('event-bus-fake-event');
40+
41+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
42+
$this->eventBus->assertDispatched(FakeEvent::class);
43+
}
44+
45+
public function test_assert_dispatched_with_callback(): void
46+
{
47+
$this->eventBus->preventEventHandling();
48+
49+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
50+
$this->eventBus->assertDispatched('event-bus-fake-event', function (string $event) {
51+
return $event === 'event-bus-fake-event';
52+
});
53+
54+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
55+
$this->eventBus->assertDispatched(FakeEvent::class, function (FakeEvent $event) {
56+
return $event->value === 'foo';
57+
});
58+
}
59+
60+
public function test_assert_dispatched_with_count(): void
61+
{
62+
$this->eventBus->preventEventHandling();
63+
64+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
65+
$this->eventBus->assertDispatched('event-bus-fake-event', count: 1);
66+
67+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
68+
$this->eventBus->assertDispatched('event-bus-fake-event', count: 2);
69+
70+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
71+
$this->eventBus->assertDispatched(FakeEvent::class, count: 1);
72+
73+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
74+
$this->eventBus->assertDispatched(FakeEvent::class, count: 2);
75+
76+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('baz'));
77+
$this->eventBus->assertDispatched(FakeEvent::class, count: 3);
78+
}
79+
80+
public function test_assert_dispatched_with_count_failure(): void
81+
{
82+
$this->expectException(ExpectationFailedException::class);
83+
$this->expectExceptionMessage('The number of dispatches does not match');
84+
85+
$this->eventBus->preventEventHandling();
86+
87+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
88+
$this->eventBus->assertDispatched('event-bus-fake-event', count: 2);
89+
}
90+
91+
public function test_assert_dispatched_with_callback_failure(): void
92+
{
93+
$this->expectException(ExpectationFailedException::class);
94+
$this->expectExceptionMessage('The callback failed');
95+
96+
$this->eventBus->preventEventHandling();
97+
98+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
99+
$this->eventBus->assertDispatched('event-bus-fake-event', function (string $event) {
100+
return $event !== 'event-bus-fake-event';
101+
});
102+
}
103+
104+
public function test_assert_dispatched_object_with_callback_failure(): void
105+
{
106+
$this->expectException(ExpectationFailedException::class);
107+
$this->expectExceptionMessage('The callback failed');
108+
109+
$this->eventBus->preventEventHandling();
110+
111+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
112+
$this->eventBus->assertDispatched(FakeEvent::class, function (FakeEvent $event) {
113+
return $event->value === 'foobar';
114+
});
115+
}
116+
117+
public function test_assert_not_dispatched(): void
118+
{
119+
$this->eventBus->preventEventHandling();
120+
121+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
122+
$this->eventBus->assertNotDispatched('this-was-not-dispatched');
123+
}
124+
125+
public function test_assert_not_dispatched_failure(): void
126+
{
127+
$this->expectException(ExpectationFailedException::class);
128+
$this->expectExceptionMessage('The event was dispatched');
129+
130+
$this->eventBus->preventEventHandling();
131+
132+
$this->container->get(EventBus::class)->dispatch('event-bus-fake-event');
133+
$this->eventBus->assertNotDispatched('event-bus-fake-event');
134+
}
135+
136+
public function test_assert_not_dispatched_object_failure(): void
137+
{
138+
$this->expectException(ExpectationFailedException::class);
139+
$this->expectExceptionMessage('The event was dispatched');
140+
141+
$this->eventBus->preventEventHandling();
142+
143+
$this->container->get(EventBus::class)->dispatch(new FakeEvent('foo'));
144+
$this->eventBus->assertNotDispatched(FakeEvent::class);
145+
}
146+
147+
public function test_assert_listening_to(): void
148+
{
149+
$this->eventBus->preventEventHandling();
150+
151+
$this->container->get(EventBus::class)->listen(FakeEvent::class, function (FakeEvent $_): never {
152+
throw new LogicException('This should not be called');
153+
});
154+
155+
$this->eventBus->assertListeningTo(FakeEvent::class);
156+
$this->eventBus->assertListeningTo(FakeEvent::class);
157+
}
158+
159+
public function test_assert_listening_to_count(): void
160+
{
161+
$this->eventBus->preventEventHandling();
162+
163+
$this->container->get(EventBus::class)->listen(FakeEvent::class, function (FakeEvent $_): never {
164+
throw new LogicException('This should not be called');
165+
});
166+
167+
$this->eventBus->assertListeningTo(FakeEvent::class, count: 1);
168+
169+
$this->container->get(EventBus::class)->listen(FakeEvent::class, function (FakeEvent $_): never {
170+
throw new LogicException('This should not be called');
171+
});
172+
173+
$this->eventBus->assertListeningTo(FakeEvent::class, count: 2);
174+
}
175+
176+
public function test_assert_listening_to_failure(): void
177+
{
178+
$this->expectException(ExpectationFailedException::class);
179+
$this->expectExceptionMessage('The event is not being listened to');
180+
181+
$this->eventBus->preventEventHandling();
182+
183+
$this->eventBus->assertListeningTo(FakeEvent::class);
184+
}
185+
186+
public function test_assert_listening_to_count_failure(): void
187+
{
188+
$this->expectException(ExpectationFailedException::class);
189+
$this->expectExceptionMessage('The number of handlers does not match');
190+
191+
$this->eventBus->preventEventHandling();
192+
193+
$this->container->get(EventBus::class)->listen(FakeEvent::class, function (FakeEvent $_): never {
194+
throw new LogicException('This should not be called');
195+
});
196+
197+
$this->eventBus->assertListeningTo(FakeEvent::class, count: 2);
198+
}
199+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\EventBus\Fixtures;
4+
5+
final readonly class FakeEvent
6+
{
7+
public function __construct(
8+
public string $value,
9+
) {}
10+
}

0 commit comments

Comments
 (0)