How I Decide What to Test in Laravel
I start with what a test should prove, then choose the boundary that can prove it. This is how I separate Unit, Integration, Feature and browser tests without making every test do the same job.
On this page
A test that passes isn't automatically a useful test. I want to know what would have to break for it to fail.
If I can change the result the user depends on and the test stays green, I've probably asserted the wrong thing. If I can reorganize the implementation without changing the result and the test fails, I may have tied it too closely to the code's current shape.
That's where I start: what should this test prove? The folder comes after that decision.
Want the test boundaries and checklist at a glance? Download the companion slides (PDF, 8 pages).
Choose the Boundary First
I separate most of my application tests into Unit, Integration and Feature suites. The distinction is about how the test enters the code and what it exercises.
A pure calculation is different from an action saving related records. Calling that action directly is different again from submitting a request through the application's route, middleware and validation.
Three boundaries I separate: pure logic, application code working with its collaborators, and application entry points. Laravel defaults to Unit and Feature; the separate Integration suite is my convention. The card's order examples are hypothetical contracts, not tests from this site.
Open the full-size testing reference card.
Browser and architecture tests sit outside this drawing. I'll come back to those, because an HTTP response can't answer every question about the page someone actually uses.
Unit Tests Stay Small
My Unit tests don't boot Laravel. I construct the object or call the function with ordinary inputs and assert the result or the exception.
The card's discount example is deliberately simple: 1,000 cents with ten percent off produces 900 cents. That proves one calculation. It says nothing about whether an order saves, whether the current user can apply the discount, or whether the checkout page displays it correctly.
Those aren't shortcomings in that test. They're different questions.
I don't mock half the application just to put a test in Unit. If the behavior depends on the database, the container or a framework integration, I move to a boundary that can exercise those things honestly.
On this site, one small Unit test checks that contact data retains its typed fields through serialization. It uses synthetic values and constructs the data object directly. No request, database or mail service is involved.
Integration Tests Let the Parts Work Together
My Integration suite calls application code directly and exercises the collaborators that matter to its contract.
The contact action on this site is a useful example. It saves an inquiry and queues the email work in the same database transaction. The integration test uses the test database and deliberately fails the queue-job insert.
Then it checks that neither the inquiry nor the queued job remains. It retries the action and checks that one inquiry and one job exist.
That's a specific promise: a failure at the handoff doesn't leave an inquiry stranded without its email job. A mock that simply says “the queue method was called” couldn't prove the rollback. The test needs the database interaction because the database interaction is the behavior under test.
This still doesn't prove that a worker sends the email or that the contact form accepts valid input. Those need their own checks. I don't ask one test to stand in for the whole feature.
The separate suite helps me find tests with this shape. Laravel doesn't require it. If a project's established Feature suite already includes these tests, I wouldn't reorganize everything just to match my folders.
Feature Tests Enter Through the Application
My Feature tests exercise an entry point: an HTTP request, a command, or an interactive component's server-side behavior.
For this site's blog, the visibility tests create published, draft and future-dated posts. They request the public listing and check that the published title appears while the other two don't. They also request individual posts and check that drafts and future posts return 404.
That covers two ways someone might reach private content: finding it in the listing and requesting its URL directly. Checking only the listing would leave the second question unanswered.
The card's forbidden-update example makes a similar distinction. For an illustrative order endpoint, a signed-in user without permission gets a 403 response, and the order stays unchanged. Both assertions matter. A denied response would be little comfort if the application had already saved the unauthorized change.
The status code belongs to the contract. An app that deliberately returns 404 for a forbidden record needs a test for that decision instead. I don't copy 403 into every denial test because it happens to be on the card.
Assert the Result That Matters
I write the test name as a sentence about behavior. It should tell me what promise broke without making me open the implementation first.
“Rolls back the inquiry when its email job cannot be queued” tells me more than “tests the contact action.” It also gives the assertions a job: show that rollback happened.
I like a clear setup, one operation, and assertions about its result. The setup should make the meaningful difference easy to spot. A scheduled post needs a future publication date; it doesn't need a page of unrelated attributes.
For a write, that might mean checking the stored state. For a read, it might mean checking both what appears and what must stay out. For a failed validation request, I want the expected errors and any promised preservation of the user's input.
I avoid asserting every available field by habit. Each assertion should protect something the application relies on. More lines aren't necessarily more confidence.
Fakes Have a Job Too
I use isolated systems and suitable fakes to keep tests repeatable. The test suite shouldn't email customers or make an unexpected request to a live service.
This site's Laravel test base blocks stray outbound HTTP requests. Its normal test environment uses an in-memory SQLite database, and external-service credentials are blank. A test that needs an outbound response has to arrange one explicitly.
But a fake changes what the test can prove. A queue fake can show that code requested a job. It doesn't run that job. A mail fake can show that a message was requested; it doesn't prove a provider delivered it.
I decide where to replace the dependency by looking at the question. The contact rollback test needs a real insert into the test queue table. A request test whose concern is validation may reasonably fake the work that would happen afterward.
I also watch the database boundary. A test passing on SQLite doesn't establish the behavior of another database's locks or constraints. If an app depends on a different production database, that behavior needs verification against that database too.
Use the Browser for Browser Questions
A response can contain a mobile menu button while the button doesn't work. It can contain an image URL while the image fails to load. It can return valid markup while the article overflows the phone's viewport.
Those are browser questions.
This site's browser tests check that public routes stay within the mobile viewport. They also open the mobile menu, dismiss it with Escape, and follow the Writing link. That is behavior an ordinary HTTP assertion can't exercise.
I keep server-side permissions, validation and persistence at their appropriate boundaries. I use browser tests for the interaction, rendering and JavaScript that require a browser. Running every possible case through the browser would add cost without answering a new question.
Architecture tests have another job: enforcing source conventions, such as which classes may depend on which layers. They can protect the structure I've agreed to maintain. They don't prove that a user can complete an operation.
What I Don't Test Twice
I don't need the same calculation repeated through every layer just because every layer is available.
A focused test can cover a rule's meaningful cases. A higher-level test can then prove the entry point uses that rule and produces the expected outcome. I add another case when it can catch a different defect, not because I want the test count to look healthier.
I also don't mock private implementation details to make a refactor harder. Renaming a helper or splitting a method shouldn't break a behavior test if the promised result is unchanged. Source-level conventions belong in architecture checks when they are intentional project rules.
There are tradeoffs. A broad request test gives confidence that the pieces are connected, but its failure may take more work to diagnose. A small test is easier to understand, but can miss wiring. I want both where they answer distinct questions.
Start With the Failure You Care About
When I'm fixing a bug, I want a test that reproduces it before I change the code. If it already passes, I haven't captured the failure yet.
The red-green-refactor cycle gives me a useful discipline: see the test fail for the intended reason, make the behavior correct, then improve the implementation while keeping it correct. Adam Wathan's Test-Driven Laravel course covers that approach to building an application. Its framework examples are older; I use current documentation for the APIs.
For the next test, I start with five questions:
- What observable promise am I protecting?
- Which boundary can actually prove it?
- What failure or forbidden case would matter?
- Which real collaborators do I need, and what should I isolate?
- Could the behavior break while this test still passes?
I use Pest, but the syntax isn't the main decision. The useful part is being able to explain what the test proves, and what it leaves for another check.
The Laravel testing documentation covers the framework's starting points. My extra Integration suite is an organizational choice. The goal is a suite that catches the failures I care about and lets me change working code without fighting it.
Written by Jeffrey Davidson
Laravel developer and software architect sharing practical lessons from building and maintaining real applications.
More about JeffreyContinue reading
How I Handle Authorization in Laravel
I separate who a user is from what they're allowed to do. Gates and policies organize the permission rules, but the server still has to enforce them before the operation runs.
How I Use Laravel Queues
I use queues to move work out of the request, but dispatching a job is only the handoff. I still need a worker, a safe retry, and a way to know whether the work finished.
How I Follow a Request Through Laravel
I follow a request from Laravel's entry point to the route handler and back through middleware. Knowing where it can stop gives me a better place to start debugging.
