Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel
Testing Signals#
Testing your Signals ensures they behave correctly before deploying to production. Signal provides tools for both manual and automated testing.
Quick Testing with Artisan#
The fastest way to test a Signal is with the signal:test command.
Test a Signal#
php artisan signal:test NewPostSignal
This runs your Signal with sample event data and displays the output.
What It Does#
- Creates a sample
SignalEventmatching your Signal's filters - Calls your Signal's
handle()method - Displays output, logs, and any errors
- Shows execution time
Example Output#
Testing Signal: App\Signals\NewPostSignal
Creating sample commit event for collection: app.bsky.feed.post
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Event Details:
DID: did:plc:test123
Collection: app.bsky.feed.post
Operation: create
Text: Sample post for testing
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Processing event...
✓ Signal processed successfully
Execution time: 12ms
Limitations#
- Uses sample data (not real events)
- Doesn't test filtering logic comprehensively
- Can't test queue behavior
- Limited to basic scenarios
For comprehensive testing, write automated tests.
Unit Testing#
Test your Signals in isolation.
Basic Test Structure#
<?php
namespace Tests\Unit\Signals;
use App\Signals\NewPostSignal;
use SocialDept\AtpSignals\Events\CommitEvent;
use SocialDept\AtpSignals\Events\SignalEvent;
use Tests\TestCase;
class NewPostSignalTest extends TestCase
{
/** @test */
public function it_handles_new_posts()
{
$signal = new NewPostSignal();
$event = new SignalEvent(
did: 'did:plc:test123',
timeUs: time() * 1000000,
kind: 'commit',
commit: new CommitEvent(
rev: 'test',
operation: 'create',
collection: 'app.bsky.feed.post',
rkey: 'test123',
record: (object) [
'text' => 'Hello World!',
'createdAt' => now()->toIso8601String(),
],
),
);
$signal->handle($event);
// Assert expected behavior
$this->assertDatabaseHas('posts', [
'text' => 'Hello World!',
]);
}
}
Testing Event Types#
Verify your Signal listens for correct event types:
/** @test */
public function it_listens_for_commit_events()
{
$signal = new NewPostSignal();
$eventTypes = $signal->eventTypes();
$this->assertContains('commit', $eventTypes);
}
Testing Filters#
Verify collection filtering:
/** @test */
public function it_filters_to_posts_only()
{
$signal = new NewPostSignal();
$collections = $signal->collections();
$this->assertEquals(['app.bsky.feed.post'], $collections);
}
Testing Operation Filtering#
/** @test */
public function it_only_handles_creates()
{
$signal = new NewPostSignal();
$operations = $signal->operations();
$this->assertEquals([SignalCommitOperation::Create], $operations);
}
Testing Custom Filtering#
/** @test */
public function it_filters_posts_with_images()
{
$signal = new ImagePostSignal();
// Event with image
$eventWithImage = $this->createEvent([
'text' => 'Check this out!',
'embed' => (object) ['type' => 'image'],
]);
$this->assertTrue($signal->shouldHandle($eventWithImage));
// Event without image
$eventWithoutImage = $this->createEvent([
'text' => 'Just text',
]);
$this->assertFalse($signal->shouldHandle($eventWithoutImage));
}
Feature Testing#
Test Signals in the context of your application.
Test with Database#
<?php
namespace Tests\Feature\Signals;
use App\Models\Post;
use App\Signals\StorePostSignal;
use Illuminate\Foundation\Testing\RefreshDatabase;
use SocialDept\AtpSignals\Events\CommitEvent;
use SocialDept\AtpSignals\Events\SignalEvent;
use Tests\TestCase;
class StorePostSignalTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_stores_posts_in_database()
{
$signal = new StorePostSignal();
$event = new SignalEvent(
did: 'did:plc:test123',
timeUs: time() * 1000000,
kind: 'commit',
commit: new CommitEvent(
rev: 'abc',
operation: 'create',
collection: 'app.bsky.feed.post',
rkey: 'test',
record: (object) [
'text' => 'Test post',
'createdAt' => now()->toIso8601String(),
],
),
);
$signal->handle($event);
$this->assertDatabaseHas('posts', [
'did' => 'did:plc:test123',
'text' => 'Test post',
]);
}
/** @test */
public function it_updates_existing_posts()
{
Post::create([
'did' => 'did:plc:test123',
'rkey' => 'test',
'text' => 'Old text',
]);
$signal = new StorePostSignal();
$event = $this->createUpdateEvent([
'text' => 'New text',
]);
$signal->handle($event);
$this->assertDatabaseHas('posts', [
'did' => 'did:plc:test123',
'text' => 'New text',
]);
$this->assertEquals(1, Post::count());
}
/** @test */
public function it_deletes_posts()
{
Post::create([
'did' => 'did:plc:test123',
'rkey' => 'test',
'text' => 'Test post',
]);
$signal = new StorePostSignal();
$event = $this->createDeleteEvent();
$signal->handle($event);
$this->assertDatabaseMissing('posts', [
'did' => 'did:plc:test123',
'rkey' => 'test',
]);
}
}
Test with External APIs#
use Illuminate\Support\Facades\Http;
/** @test */
public function it_sends_notifications()
{
Http::fake();
$signal = new NotificationSignal();
$event = $this->createEvent();
$signal->handle($event);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/notify' &&
$request['text'] === 'New post created';
});
}
Testing Queued Signals#
Test Signals that use queues.
Test Queue Dispatch#
use Illuminate\Support\Facades\Queue;
/** @test */
public function it_queues_events()
{
Queue::fake();
$signal = new QueuedSignal();
$this->assertTrue($signal->shouldQueue());
// In production, this would queue
// For testing, we verify the intent
}
Test with Sync Queue#
Process queued jobs synchronously in tests:
/** @test */
public function it_processes_queued_events()
{
// Use sync queue for immediate processing
config(['queue.default' => 'sync']);
$signal = new QueuedSignal();
$event = $this->createEvent();
$signal->handle($event);
// Assert side effects happened
$this->assertDatabaseHas('posts', [...]);
}
Test Queue Configuration#
/** @test */
public function it_uses_high_priority_queue()
{
$signal = new HighPrioritySignal();
$this->assertTrue($signal->shouldQueue());
$this->assertEquals('high-priority', $signal->queue());
}
/** @test */
public function it_uses_redis_connection()
{
$signal = new RedisQueueSignal();
$this->assertEquals('redis', $signal->queueConnection());
}
Testing Failure Handling#
Test how your Signal handles errors.
Test Failed Method#
use Illuminate\Support\Facades\Log;
/** @test */
public function it_logs_failures()
{
Log::spy();
$signal = new FailureHandlingSignal();
$event = $this->createEvent();
$exception = new \Exception('Something went wrong');
$signal->failed($event, $exception);
Log::shouldHaveReceived('error')
->with('Signal failed', \Mockery::any());
}
Test Exception Handling#
/** @test */
public function it_handles_invalid_data_gracefully()
{
$signal = new RobustSignal();
$event = new SignalEvent(
did: 'did:plc:test',
timeUs: time() * 1000000,
kind: 'commit',
commit: new CommitEvent(
rev: 'test',
operation: 'create',
collection: 'app.bsky.feed.post',
rkey: 'test',
record: (object) [], // Missing required fields
),
);
// Should not throw
$signal->handle($event);
// Should handle gracefully (e.g., log and skip)
$this->assertDatabaseCount('posts', 0);
}
Test Helpers#
Create reusable helpers for common test scenarios.
Event Factory Helper#
trait CreatesSignalEvents
{
protected function createCommitEvent(array $overrides = []): SignalEvent
{
$defaults = [
'did' => 'did:plc:test123',
'timeUs' => time() * 1000000,
'kind' => 'commit',
'commit' => new CommitEvent(
rev: 'test',
operation: $overrides['operation'] ?? 'create',
collection: $overrides['collection'] ?? 'app.bsky.feed.post',
rkey: $overrides['rkey'] ?? 'test',
record: (object) array_merge([
'text' => 'Test post',
'createdAt' => now()->toIso8601String(),
], $overrides['record'] ?? []),
),
];
return new SignalEvent(...$defaults);
}
protected function createPostEvent(array $record = []): SignalEvent
{
return $this->createCommitEvent([
'collection' => 'app.bsky.feed.post',
'record' => $record,
]);
}
protected function createLikeEvent(array $record = []): SignalEvent
{
return $this->createCommitEvent([
'collection' => 'app.bsky.feed.like',
'record' => array_merge([
'subject' => (object) [
'uri' => 'at://did:plc:test/app.bsky.feed.post/test',
'cid' => 'bafytest',
],
'createdAt' => now()->toIso8601String(),
], $record),
]);
}
protected function createFollowEvent(array $record = []): SignalEvent
{
return $this->createCommitEvent([
'collection' => 'app.bsky.graph.follow',
'record' => array_merge([
'subject' => 'did:plc:target',
'createdAt' => now()->toIso8601String(),
], $record),
]);
}
}
Use in tests:
class MySignalTest extends TestCase
{
use CreatesSignalEvents;
/** @test */
public function it_handles_posts()
{
$event = $this->createPostEvent([
'text' => 'Custom text',
]);
// Test with event
}
}
Signal Factory Helper#
trait CreatesSignals
{
protected function createSignal(string $class, array $config = [])
{
$signal = new $class();
// Override configuration for testing
foreach ($config as $method => $value) {
$signal->{$method} = $value;
}
return $signal;
}
}
Testing Best Practices#
Use Descriptive Test Names#
// Good
/** @test */
public function it_stores_posts_with_valid_data()
/** @test */
public function it_skips_posts_without_text()
/** @test */
public function it_handles_duplicate_posts_gracefully()
// Less descriptive
/** @test */
public function test_handle()
Test Edge Cases#
/** @test */
public function it_handles_empty_text()
{
$event = $this->createPostEvent(['text' => '']);
// Test behavior
}
/** @test */
public function it_handles_very_long_text()
{
$event = $this->createPostEvent(['text' => str_repeat('a', 10000)]);
// Test behavior
}
/** @test */
public function it_handles_missing_created_at()
{
$event = $this->createPostEvent(['createdAt' => null]);
// Test behavior
}
Test All Operations#
/** @test */
public function it_handles_creates()
{
$event = $this->createEvent(['operation' => 'create']);
// Test
}
/** @test */
public function it_handles_updates()
{
$event = $this->createEvent(['operation' => 'update']);
// Test
}
/** @test */
public function it_handles_deletes()
{
$event = $this->createEvent(['operation' => 'delete']);
// Test
}
Mock External Dependencies#
/** @test */
public function it_calls_external_api()
{
Http::fake([
'api.example.com/*' => Http::response(['success' => true]),
]);
$signal = new ApiSignal();
$event = $this->createEvent();
$signal->handle($event);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/endpoint';
});
}
Test Database State#
use Illuminate\Foundation\Testing\RefreshDatabase;
class DatabaseSignalTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_creates_records()
{
// Fresh database for each test
}
}
Continuous Integration#
Run tests automatically on every commit.
GitHub Actions#
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
- name: Install Dependencies
run: composer install
- name: Run Tests
run: php artisan test
Run Signal-Specific Tests#
# Run all Signal tests
php artisan test --testsuite=Signals
# Run specific test file
php artisan test tests/Unit/Signals/NewPostSignalTest.php
# Run with coverage
php artisan test --coverage
Debugging Tests#
Enable Debug Output#
/** @test */
public function it_processes_events()
{
$signal = new NewPostSignal();
$event = $this->createEvent();
dump($event); // Output event data
$signal->handle($event);
dump(Post::all()); // Output results
}
Use dd() to Stop Execution#
/** @test */
public function it_processes_events()
{
$event = $this->createEvent();
dd($event); // Dump and die
// This won't run
}
Check Logs#
/** @test */
public function it_logs_processing()
{
Log::spy();
$signal = new LoggingSignal();
$event = $this->createEvent();
$signal->handle($event);
Log::shouldHaveReceived('info')->once();
}
Next Steps#
- See real-world examples → - Learn from production test patterns
- Review queue integration → - Test queued Signals
- Review signals documentation → - Understand Signal structure