This series builds the tests for issuing an invoice. A feature test sends a request and asserts what a person would notice: a redirect, a saved row, a forbidden response. The framework’s own internals stay out of the assertion.
Each lesson is a page. Mark it as read when you have tried the idea, then use Next. The last lesson points at the next series, and this overview stays one link away.
it('issues an invoice for the signed-in team', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/invoices', [
'number' => 'INV-1042',
'issued_on' => '2026-10-22',
])
->assertRedirect();
$this->assertDatabaseHas('invoices', [
'number' => 'INV-1042',
'team_id' => $user->current_team_id,
]);
});The arrange is one user. The act is the post. The assert is the row on that user’s team. The lessons below take that shape apart: the database, the factory, the fakes, and the tests that are not worth writing.
01 The lessons
- What a feature test is for. Check one thing a person can do, through the same HTTP door they use.
- Arrange, act, assert. Set the world up, do one thing, then say what changed.
- Refreshing the database. Give each test an empty database, and keep that database off the one the site is serving.
- Factories that read like the domain. Name the interesting invoice, instead of repeating a column array in every test.
- Acting as a user. Sign a team member in without going through the login form.
- HTTP tests for happy paths. Follow the redirect, then assert the row the action promised.
- Validation errors. Name the fields that failed, and assert that no invoice was stored.
- Authorisation. Expect 403 when the signed-in team does not own the invoice.
- Faking the mail. Assert the mailable was queued, without sending it.
- Faking the queue. Assert the job was pushed, and leave
handle()unrun. - Faking storage and HTTP. Keep the PDF off the real disk, and fail the test if the code calls an unexpected host.
- Freezing time. Pin
now()so a timestamp assertion does not depend on the clock. - Datasets. Run one test body against each bad status.
- What not to test. Leave the framework, the SQL, and the private methods alone.