The dataset ran one body against each bad status. A test that cannot fail when the feature breaks is noise. So is a test that fails when you rename a private method and nothing else changes. This series stops at the behaviour.
Test-driven Laravel · Lesson 3
What not to test
Video
01 Leave the framework alone
Do not assert that belongsTo() returns a relation. Do not assert the SQL. Do not call a private method through reflection. Do not copy every validation rule into a second test that instantiates the form request: the HTTP test already posted the empty form.
Assert the columns the outcome depends on. issued_on matters when the test is about the date. created_at does not, unless the test froze time in order to check it.
One behaviour per test. The happy path, the validation error, the other team, and the queued mail are four tests. A single test that does all four fails for one reason and you have to read the whole method to find which reason.
Do not hit the network, the real disk, or the database the site is using. The fakes and RefreshDatabase exist so the suite can run without them.
02 A test of the framework fails when you upgrade, not when you break the invoice
Asserting that a factory can create a model, with no further claim, is an example. Laravel already has that test. Yours should fail when issuing an invoice stops working.
it('does not need to prove the framework saves a model', function () {
$invoice = Invoice::factory()->create(['number' => 'INV-1042']);
expect($invoice->number)->toBe('INV-1042');
});That test passes for as long as Eloquent and the factory work. Delete it. The HTTP test that posts INV-1042 and reads the row back already covers the number, and it fails when the controller stops writing it. The same rule retires a test that only calls $invoice->team and expects a BelongsTo instance. The authorisation test already proved the team is the one that matters.
The test worth keeping is the one that would go red when the controller stops issuing the invoice. A private method extracted from that controller does not earn a test of its own. The HTTP test still posts the same number and still reads the same row. Renaming the private method leaves the test green, which is what you want. That is the suite staying pointed at the invoice, not at the shape of the class.
it('stores the posted invoice number', 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,
]);
});Recap
You can delete a test that only proves the framework still saves a model.
- Assert the columns the outcome depends on.
- Do not assert SQL,
belongsTo(), or a private method. - The HTTP test that posts INV-1042 already covers the number.
Check yourself · 3 questions
Question 1 of 3A test creates an invoice with a factory and expects the number back, and never sends a request. When does it fail?
Where the series leaves you
The finished suite for issuing an invoice:
- tests/Feature/IssueInvoiceTest.php issues one for the signed-in team.
- tests/Feature/GuestInvoiceTest.php sends a guest to login.
- tests/Feature/InvoiceValidationTest.php rejects an empty number and stores nothing.
- tests/Feature/InvoiceAuthorisationTest.php returns 403 for another team.
- tests/Feature/InvoiceOwnerTest.php lets the owner through.
- tests/Feature/InvoiceMailTest.php asserts the mailable was queued.
- tests/Feature/InvoicePaymentTest.php asserts the payment job was pushed.
- tests/Feature/InvoicePdfTest.php asserts the PDF was stored.
- tests/Feature/InvoiceTimestampTest.php pins sent_at.
- tests/Feature/InvoiceStatusTest.php runs each bad status from one dataset.
The framework, the SQL, and the private methods are deliberately not in that list.