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