A factory isn't a fixture. It's documentation of what a valid record looks like, written as code instead of prose.
// api-infrastructure src/Database/Factories/BearerFactory.php
final class BearerFactory extends Factory
{
protected $model = Bearer::class;
public function definition(): array
{
return [
'token' => Str::random(32),
'description' => $this->faker->sentence(),
'domains' => null,
'settings' => [],
'expires_at' => null,
];
}
public function expired(): self
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subDay(),
]);
}
}
definition() is the baseline: a valid, unexpired, unrestricted bearer token, exactly the record you'd want by default in a test that isn't about tokens at all. The expired() state method is where factories earn the word "intent." Compare Bearer::factory()->expired()->create() to hand-building the same record with Bearer::create(['token' => Str::random(32), 'expires_at' => now()->subDay(), ...]) inline in every test that needs one. The factory version reads like English. The inline version makes every test author re-derive what "expired" means, and when the Bearer model gains a new required column, every one of those inline calls breaks at once instead of one factory definition absorbing the change.
This is why LicenseControllerTest.php calls Bearer::factory() eighteen separate times in one file, six of them chained with ->expired(), without anyone re-explaining what that state means. The name is the explanation.
Testing failure, not just success
A test suite that only proves the happy path works has proven that your API works when nothing goes wrong, which is the one condition you don't need proof for. The interesting bugs live in what happens when Statamic is slow, wrong, or gone.
// tests/Feature/LicenseControllerTest.php
test('treats a 404 from Statamic as a successful no-op delete', function () {
Http::swap(new Factory);
Http::fake(fn () => Http::response(['message' => 'Not found'], 404));
$api = new StatamicAPI(Http::baseUrl('https://example.test')->acceptJson());
// Already-deleted key: returns null and does NOT throw.
expect($api->deleteLicense('already-gone'))->toBeNull();
});
test('rethrows non-404 Statamic delete failures so the job can retry', function () {
Http::swap(new Factory);
Http::fake(fn () => Http::response(['message' => 'Server error'], 500));
$api = new StatamicAPI(Http::baseUrl('https://example.test')->acceptJson());
expect(fn () => $api->deleteLicense('boom'))->toThrow(RequestException::class);
});
These two tests look almost identical and mean opposite things, which is exactly the point. A 404 from a delete call means the thing you wanted gone is already gone, so StatamicAPI::deleteLicense() treats it as success. A 500 means Statamic broke, so the same method rethrows and lets the queued job's retry policy handle it. Collapse those into one "handles errors gracefully" test and you'd never notice if someone accidentally swapped the two behaviors. Write them separately and the distinction becomes part of the spec, permanently, whether or not anyone remembers the reasoning six months from now.
Every endpoint deserves this treatment for its own failure modes: expired tokens, revoked tokens, malformed upstream payloads, validation failures on every required field, not just one. If you've only tested the version where everything goes right, you haven't tested the API. You've tested a demo.