---
title: "Testing — Campaigns for Laravel | Roundly"
description: "Record every write with Campaigns::fake() and assert created, started, cancelled and delivered — or run the real flow on a fake batch."
url: https://roundly-consulting.com/open-source/docs/campaigns-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Campaigns for Laravel](https://roundly-consulting.com/open-source/docs/campaigns-for-laravel.md)

# Testing

Campaigns::fake() swaps a recording fake in behind the facade and the container, so injected managers, the campaigns:cancel command and delivery jobs all see it. It records every write instead of running it — no batch, no queued job, no event, nothing written to your configured store — and keeps its own in-memory store, so find(), all() and campaign()->progress() / recipients() answer from what the test created:

```php
use RoundlyConsulting\Campaigns\Campaign;
use RoundlyConsulting\Campaigns\Facades\Campaigns;

it('sends the spring sale to both subscribers', function (): void {
    $fake = Campaigns::fake();

    $campaign = Campaigns::create('Spring sale', '<p>The spring sale starts today.</p>')
        ->from('news@example.com', 'Example Shop')
        ->to(['ana@example.com', 'ben@example.com'])
        ->dispatch();

    $fake->assertDispatched(fn (Campaign $campaign, array $recipients) => $campaign->subject === 'Spring sale'
        && count($recipients) === 2);
    $fake->assertStarted($campaign);
    $fake->assertNothingCancelled();

    // Answered from the fake's own in-memory store.
    expect(Campaigns::campaign($campaign)->recipients())->toHaveCount(2);
});
```

| Assertion | Passes when |
| --- | --- |
| `assertCreated(?Closure $callback) / assertNothingCreated()` | A campaign was prepared — create()->prepare(), ->dispatch() or Campaigns::prepare(). The callback gets the Campaign and its list<CampaignRecipient>. |
| `assertDispatched(?Closure $callback) / assertNothingDispatched()` | A campaign was created and started. |
| `assertStarted(Campaign\|string\|null $campaign) / assertNothingStarted()` | start() ran — flat, through the handle or via dispatch(). |
| `assertCancelled(Campaign\|string\|null $campaign) / assertNothingCancelled()` | cancel() ran — flat, through the handle or from the command. |
| `assertRecipientProcessed(CampaignRecipient\|string\|null $recipient) / assertNothingProcessed()` | A delivery was recorded — for example by running your job’s handle() under the fake. |
| `assertRecipientFailed(CampaignRecipient\|string\|null $recipient, ?string $error) / assertNothingFailed()` | A failed delivery was recorded. |

Every assertion also works statically (Campaigns::assertCancelled($uuid)). The fake still refuses what the real manager refuses: an unknown uuid, a uuid that is already taken, starting a campaign that isn’t Pending, a recipient of another campaign.

## Testing a delivery job

Build a job from a Campaign and a CampaignRecipient that belongs to it, and handle it directly. withFakeBatch() — from Laravel’s Batchable trait — simulates running inside a batch, including a cancelled one. Under the fake, the delivery is recorded:

```php
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Mail;
use RoundlyConsulting\Campaigns\Campaign;
use RoundlyConsulting\Campaigns\CampaignManager;
use RoundlyConsulting\Campaigns\CampaignRecipient;
use RoundlyConsulting\Campaigns\Facades\Campaigns;
use RoundlyConsulting\Campaigns\Jobs\SendCampaignEmail;

function springSaleJob(): SendCampaignEmail
{
    return new SendCampaignEmail(
        new Campaign(
            uuid: '0b6f4c1e-8d2a-4f0e-9c3b-5a7d2e1f4b6c',
            subject: 'Spring sale',
            content: '<p>The spring sale starts today.</p>',
            fromName: 'Example Shop',
            fromAddress: 'news@example.com',
        ),
        new CampaignRecipient(
            uuid: '5d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6',
            name: 'Ana Horvat',
            reachableAt: 'ana@example.com',
            campaignUuid: '0b6f4c1e-8d2a-4f0e-9c3b-5a7d2e1f4b6c',   // it belongs to this campaign
        ),
    );
}

it('records the delivery', function (): void {
    $fake = Campaigns::fake();

    $job = springSaleJob();
    $job->withFakeBatch();
    $job->handle(resolve(CampaignManager::class));   // the fake; with MAIL_MAILER=array in your test env

    $fake->assertRecipientProcessed($job->recipient);
});

it('sends nothing once the campaign is cancelled', function (): void {
    Mail::fake();

    $job = springSaleJob();
    $job->withFakeBatch(cancelledAt: CarbonImmutable::now());
    $job->handle(resolve(CampaignManager::class));

    Mail::assertNothingOutgoing();
});
```

## Running the real flow

To exercise the real batch flow, skip the fake and bind a fake batch repository — the package resolves BatchRepository from the container, so faking the bus alone isn’t enough. The in-memory store is bound scoped, so every test starts with an empty one:

```php
use Illuminate\Bus\BatchRepository;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Testing\Fakes\BatchRepositoryFake;
use RoundlyConsulting\Campaigns\Enums\CampaignStatus;
use RoundlyConsulting\Campaigns\Facades\Campaigns;
use RoundlyConsulting\Campaigns\Jobs\SendCampaignEmail;

beforeEach(function (): void {
    // The package resolves BatchRepository from the container, so bind the fake there too.
    $batches = new BatchRepositoryFake;
    app()->singleton(BatchRepository::class, fn () => $batches);
    Bus::fake(batchRepository: $batches);
});

it('queues one email job per recipient', function (): void {
    $campaign = Campaigns::create('Spring sale', '<p>The spring sale starts today.</p>')
        ->from('news@example.com', 'Example Shop')
        ->to(['ana@example.com', 'ben@example.com'])
        ->dispatch();

    $batch = Campaigns::campaign($campaign)->batch();

    expect($campaign->progress->status)->toBe(CampaignStatus::Processing)
        ->and($batch->added)->toHaveCount(2)
        ->and($batch->added[0])->toBeInstanceOf(SendCampaignEmail::class)
        ->and(Campaigns::campaign($campaign)->recipients())->toHaveCount(2);
});

it('leaves a prepared campaign pending', function (): void {
    $campaign = Campaigns::create('Spring sale', '<p>The spring sale starts today.</p>')
        ->from('news@example.com')
        ->to('ana@example.com')
        ->prepare();

    expect($campaign->progress->status)->toBe(CampaignStatus::Pending);
});
```

Nothing is sent: the jobs sit in the fake batch, where you can assert on them. Send defaults are read through options-for-laravel, so run the options migration in your test database (RefreshDatabase).

## Tips

- If your tests store send-default options, call Options::flushCache() between tests — options-for-laravel memoises resolved values per process.
- For the database store, run the published campaign migrations and build fixtures with the CampaignRecord and CampaignRecipientRecord factories.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/campaigns-for-laravel.md)
