Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel
1# Testing Signals
2
3Testing your Signals ensures they behave correctly before deploying to production. Signal provides tools for both manual and automated testing.
4
5## Quick Testing with Artisan
6
7The fastest way to test a Signal is with the `signal:test` command.
8
9### Test a Signal
10
11```bash
12php artisan signal:test NewPostSignal
13```
14
15This runs your Signal with sample event data and displays the output.
16
17### What It Does
18
191. Creates a sample `SignalEvent` matching your Signal's filters
202. Calls your Signal's `handle()` method
213. Displays output, logs, and any errors
224. Shows execution time
23
24### Example Output
25
26```
27Testing Signal: App\Signals\NewPostSignal
28
29Creating sample commit event for collection: app.bsky.feed.post
30━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31
32Event Details:
33 DID: did:plc:test123
34 Collection: app.bsky.feed.post
35 Operation: create
36 Text: Sample post for testing
37
38━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
40Processing event...
41✓ Signal processed successfully
42
43Execution time: 12ms
44```
45
46### Limitations
47
48- Uses sample data (not real events)
49- Doesn't test filtering logic comprehensively
50- Can't test queue behavior
51- Limited to basic scenarios
52
53For comprehensive testing, write automated tests.
54
55## Unit Testing
56
57Test your Signals in isolation.
58
59### Basic Test Structure
60
61```php
62<?php
63
64namespace Tests\Unit\Signals;
65
66use App\Signals\NewPostSignal;
67use SocialDept\AtpSignals\Events\CommitEvent;
68use SocialDept\AtpSignals\Events\SignalEvent;
69use Tests\TestCase;
70
71class NewPostSignalTest extends TestCase
72{
73 /** @test */
74 public function it_handles_new_posts()
75 {
76 $signal = new NewPostSignal();
77
78 $event = new SignalEvent(
79 did: 'did:plc:test123',
80 timeUs: time() * 1000000,
81 kind: 'commit',
82 commit: new CommitEvent(
83 rev: 'test',
84 operation: 'create',
85 collection: 'app.bsky.feed.post',
86 rkey: 'test123',
87 record: (object) [
88 'text' => 'Hello World!',
89 'createdAt' => now()->toIso8601String(),
90 ],
91 ),
92 );
93
94 $signal->handle($event);
95
96 // Assert expected behavior
97 $this->assertDatabaseHas('posts', [
98 'text' => 'Hello World!',
99 ]);
100 }
101}
102```
103
104### Testing Event Types
105
106Verify your Signal listens for correct event types:
107
108```php
109/** @test */
110public function it_listens_for_commit_events()
111{
112 $signal = new NewPostSignal();
113
114 $eventTypes = $signal->eventTypes();
115
116 $this->assertContains('commit', $eventTypes);
117}
118```
119
120### Testing Filters
121
122Verify collection filtering:
123
124```php
125/** @test */
126public function it_filters_to_posts_only()
127{
128 $signal = new NewPostSignal();
129
130 $collections = $signal->collections();
131
132 $this->assertEquals(['app.bsky.feed.post'], $collections);
133}
134```
135
136### Testing Operation Filtering
137
138```php
139/** @test */
140public function it_only_handles_creates()
141{
142 $signal = new NewPostSignal();
143
144 $operations = $signal->operations();
145
146 $this->assertEquals([SignalCommitOperation::Create], $operations);
147}
148```
149
150### Testing Custom Filtering
151
152```php
153/** @test */
154public function it_filters_posts_with_images()
155{
156 $signal = new ImagePostSignal();
157
158 // Event with image
159 $eventWithImage = $this->createEvent([
160 'text' => 'Check this out!',
161 'embed' => (object) ['type' => 'image'],
162 ]);
163
164 $this->assertTrue($signal->shouldHandle($eventWithImage));
165
166 // Event without image
167 $eventWithoutImage = $this->createEvent([
168 'text' => 'Just text',
169 ]);
170
171 $this->assertFalse($signal->shouldHandle($eventWithoutImage));
172}
173```
174
175## Feature Testing
176
177Test Signals in the context of your application.
178
179### Test with Database
180
181```php
182<?php
183
184namespace Tests\Feature\Signals;
185
186use App\Models\Post;
187use App\Signals\StorePostSignal;
188use Illuminate\Foundation\Testing\RefreshDatabase;
189use SocialDept\AtpSignals\Events\CommitEvent;
190use SocialDept\AtpSignals\Events\SignalEvent;
191use Tests\TestCase;
192
193class StorePostSignalTest extends TestCase
194{
195 use RefreshDatabase;
196
197 /** @test */
198 public function it_stores_posts_in_database()
199 {
200 $signal = new StorePostSignal();
201
202 $event = new SignalEvent(
203 did: 'did:plc:test123',
204 timeUs: time() * 1000000,
205 kind: 'commit',
206 commit: new CommitEvent(
207 rev: 'abc',
208 operation: 'create',
209 collection: 'app.bsky.feed.post',
210 rkey: 'test',
211 record: (object) [
212 'text' => 'Test post',
213 'createdAt' => now()->toIso8601String(),
214 ],
215 ),
216 );
217
218 $signal->handle($event);
219
220 $this->assertDatabaseHas('posts', [
221 'did' => 'did:plc:test123',
222 'text' => 'Test post',
223 ]);
224 }
225
226 /** @test */
227 public function it_updates_existing_posts()
228 {
229 Post::create([
230 'did' => 'did:plc:test123',
231 'rkey' => 'test',
232 'text' => 'Old text',
233 ]);
234
235 $signal = new StorePostSignal();
236
237 $event = $this->createUpdateEvent([
238 'text' => 'New text',
239 ]);
240
241 $signal->handle($event);
242
243 $this->assertDatabaseHas('posts', [
244 'did' => 'did:plc:test123',
245 'text' => 'New text',
246 ]);
247
248 $this->assertEquals(1, Post::count());
249 }
250
251 /** @test */
252 public function it_deletes_posts()
253 {
254 Post::create([
255 'did' => 'did:plc:test123',
256 'rkey' => 'test',
257 'text' => 'Test post',
258 ]);
259
260 $signal = new StorePostSignal();
261
262 $event = $this->createDeleteEvent();
263
264 $signal->handle($event);
265
266 $this->assertDatabaseMissing('posts', [
267 'did' => 'did:plc:test123',
268 'rkey' => 'test',
269 ]);
270 }
271}
272```
273
274### Test with External APIs
275
276```php
277use Illuminate\Support\Facades\Http;
278
279/** @test */
280public function it_sends_notifications()
281{
282 Http::fake();
283
284 $signal = new NotificationSignal();
285
286 $event = $this->createEvent();
287
288 $signal->handle($event);
289
290 Http::assertSent(function ($request) {
291 return $request->url() === 'https://api.example.com/notify' &&
292 $request['text'] === 'New post created';
293 });
294}
295```
296
297## Testing Queued Signals
298
299Test Signals that use queues.
300
301### Test Queue Dispatch
302
303```php
304use Illuminate\Support\Facades\Queue;
305
306/** @test */
307public function it_queues_events()
308{
309 Queue::fake();
310
311 $signal = new QueuedSignal();
312
313 $this->assertTrue($signal->shouldQueue());
314
315 // In production, this would queue
316 // For testing, we verify the intent
317}
318```
319
320### Test with Sync Queue
321
322Process queued jobs synchronously in tests:
323
324```php
325/** @test */
326public function it_processes_queued_events()
327{
328 // Use sync queue for immediate processing
329 config(['queue.default' => 'sync']);
330
331 $signal = new QueuedSignal();
332
333 $event = $this->createEvent();
334
335 $signal->handle($event);
336
337 // Assert side effects happened
338 $this->assertDatabaseHas('posts', [...]);
339}
340```
341
342### Test Queue Configuration
343
344```php
345/** @test */
346public function it_uses_high_priority_queue()
347{
348 $signal = new HighPrioritySignal();
349
350 $this->assertTrue($signal->shouldQueue());
351 $this->assertEquals('high-priority', $signal->queue());
352}
353
354/** @test */
355public function it_uses_redis_connection()
356{
357 $signal = new RedisQueueSignal();
358
359 $this->assertEquals('redis', $signal->queueConnection());
360}
361```
362
363## Testing Failure Handling
364
365Test how your Signal handles errors.
366
367### Test Failed Method
368
369```php
370use Illuminate\Support\Facades\Log;
371
372/** @test */
373public function it_logs_failures()
374{
375 Log::spy();
376
377 $signal = new FailureHandlingSignal();
378
379 $event = $this->createEvent();
380 $exception = new \Exception('Something went wrong');
381
382 $signal->failed($event, $exception);
383
384 Log::shouldHaveReceived('error')
385 ->with('Signal failed', \Mockery::any());
386}
387```
388
389### Test Exception Handling
390
391```php
392/** @test */
393public function it_handles_invalid_data_gracefully()
394{
395 $signal = new RobustSignal();
396
397 $event = new SignalEvent(
398 did: 'did:plc:test',
399 timeUs: time() * 1000000,
400 kind: 'commit',
401 commit: new CommitEvent(
402 rev: 'test',
403 operation: 'create',
404 collection: 'app.bsky.feed.post',
405 rkey: 'test',
406 record: (object) [], // Missing required fields
407 ),
408 );
409
410 // Should not throw
411 $signal->handle($event);
412
413 // Should handle gracefully (e.g., log and skip)
414 $this->assertDatabaseCount('posts', 0);
415}
416```
417
418## Test Helpers
419
420Create reusable helpers for common test scenarios.
421
422### Event Factory Helper
423
424```php
425trait CreatesSignalEvents
426{
427 protected function createCommitEvent(array $overrides = []): SignalEvent
428 {
429 $defaults = [
430 'did' => 'did:plc:test123',
431 'timeUs' => time() * 1000000,
432 'kind' => 'commit',
433 'commit' => new CommitEvent(
434 rev: 'test',
435 operation: $overrides['operation'] ?? 'create',
436 collection: $overrides['collection'] ?? 'app.bsky.feed.post',
437 rkey: $overrides['rkey'] ?? 'test',
438 record: (object) array_merge([
439 'text' => 'Test post',
440 'createdAt' => now()->toIso8601String(),
441 ], $overrides['record'] ?? []),
442 ),
443 ];
444
445 return new SignalEvent(...$defaults);
446 }
447
448 protected function createPostEvent(array $record = []): SignalEvent
449 {
450 return $this->createCommitEvent([
451 'collection' => 'app.bsky.feed.post',
452 'record' => $record,
453 ]);
454 }
455
456 protected function createLikeEvent(array $record = []): SignalEvent
457 {
458 return $this->createCommitEvent([
459 'collection' => 'app.bsky.feed.like',
460 'record' => array_merge([
461 'subject' => (object) [
462 'uri' => 'at://did:plc:test/app.bsky.feed.post/test',
463 'cid' => 'bafytest',
464 ],
465 'createdAt' => now()->toIso8601String(),
466 ], $record),
467 ]);
468 }
469
470 protected function createFollowEvent(array $record = []): SignalEvent
471 {
472 return $this->createCommitEvent([
473 'collection' => 'app.bsky.graph.follow',
474 'record' => array_merge([
475 'subject' => 'did:plc:target',
476 'createdAt' => now()->toIso8601String(),
477 ], $record),
478 ]);
479 }
480}
481```
482
483Use in tests:
484
485```php
486class MySignalTest extends TestCase
487{
488 use CreatesSignalEvents;
489
490 /** @test */
491 public function it_handles_posts()
492 {
493 $event = $this->createPostEvent([
494 'text' => 'Custom text',
495 ]);
496
497 // Test with event
498 }
499}
500```
501
502### Signal Factory Helper
503
504```php
505trait CreatesSignals
506{
507 protected function createSignal(string $class, array $config = [])
508 {
509 $signal = new $class();
510
511 // Override configuration for testing
512 foreach ($config as $method => $value) {
513 $signal->{$method} = $value;
514 }
515
516 return $signal;
517 }
518}
519```
520
521## Testing Best Practices
522
523### Use Descriptive Test Names
524
525```php
526// Good
527/** @test */
528public function it_stores_posts_with_valid_data()
529
530/** @test */
531public function it_skips_posts_without_text()
532
533/** @test */
534public function it_handles_duplicate_posts_gracefully()
535
536// Less descriptive
537/** @test */
538public function test_handle()
539```
540
541### Test Edge Cases
542
543```php
544/** @test */
545public function it_handles_empty_text()
546{
547 $event = $this->createPostEvent(['text' => '']);
548 // Test behavior
549}
550
551/** @test */
552public function it_handles_very_long_text()
553{
554 $event = $this->createPostEvent(['text' => str_repeat('a', 10000)]);
555 // Test behavior
556}
557
558/** @test */
559public function it_handles_missing_created_at()
560{
561 $event = $this->createPostEvent(['createdAt' => null]);
562 // Test behavior
563}
564```
565
566### Test All Operations
567
568```php
569/** @test */
570public function it_handles_creates()
571{
572 $event = $this->createEvent(['operation' => 'create']);
573 // Test
574}
575
576/** @test */
577public function it_handles_updates()
578{
579 $event = $this->createEvent(['operation' => 'update']);
580 // Test
581}
582
583/** @test */
584public function it_handles_deletes()
585{
586 $event = $this->createEvent(['operation' => 'delete']);
587 // Test
588}
589```
590
591### Mock External Dependencies
592
593```php
594/** @test */
595public function it_calls_external_api()
596{
597 Http::fake([
598 'api.example.com/*' => Http::response(['success' => true]),
599 ]);
600
601 $signal = new ApiSignal();
602 $event = $this->createEvent();
603
604 $signal->handle($event);
605
606 Http::assertSent(function ($request) {
607 return $request->url() === 'https://api.example.com/endpoint';
608 });
609}
610```
611
612### Test Database State
613
614```php
615use Illuminate\Foundation\Testing\RefreshDatabase;
616
617class DatabaseSignalTest extends TestCase
618{
619 use RefreshDatabase;
620
621 /** @test */
622 public function it_creates_records()
623 {
624 // Fresh database for each test
625 }
626}
627```
628
629## Continuous Integration
630
631Run tests automatically on every commit.
632
633### GitHub Actions
634
635```yaml
636# .github/workflows/tests.yml
637name: Tests
638
639on: [push, pull_request]
640
641jobs:
642 test:
643 runs-on: ubuntu-latest
644
645 steps:
646 - uses: actions/checkout@v2
647
648 - name: Setup PHP
649 uses: shivammathur/setup-php@v2
650 with:
651 php-version: 8.2
652
653 - name: Install Dependencies
654 run: composer install
655
656 - name: Run Tests
657 run: php artisan test
658```
659
660### Run Signal-Specific Tests
661
662```bash
663# Run all Signal tests
664php artisan test --testsuite=Signals
665
666# Run specific test file
667php artisan test tests/Unit/Signals/NewPostSignalTest.php
668
669# Run with coverage
670php artisan test --coverage
671```
672
673## Debugging Tests
674
675### Enable Debug Output
676
677```php
678/** @test */
679public function it_processes_events()
680{
681 $signal = new NewPostSignal();
682
683 $event = $this->createEvent();
684
685 dump($event); // Output event data
686
687 $signal->handle($event);
688
689 dump(Post::all()); // Output results
690}
691```
692
693### Use dd() to Stop Execution
694
695```php
696/** @test */
697public function it_processes_events()
698{
699 $event = $this->createEvent();
700
701 dd($event); // Dump and die
702
703 // This won't run
704}
705```
706
707### Check Logs
708
709```php
710/** @test */
711public function it_logs_processing()
712{
713 Log::spy();
714
715 $signal = new LoggingSignal();
716 $event = $this->createEvent();
717
718 $signal->handle($event);
719
720 Log::shouldHaveReceived('info')->once();
721}
722```
723
724## Next Steps
725
726- **[See real-world examples →](examples.md)** - Learn from production test patterns
727- **[Review queue integration →](queues.md)** - Test queued Signals
728- **[Review signals documentation →](signals.md)** - Understand Signal structure