Testing a Laravel API: What Is Actually Worth Testing
Testing a Laravel API: What Is Actually Worth Testing

A suite of 340 tests, 71 per cent coverage, green on every commit. And a bug that let a member of one organisation read another organisation’s time entries by changing a number in the URL.
Every model accessor had a test. Not one test had ever made a request as the wrong user. That is the whole problem with measuring a suite by its size: it tells you how much was tested, never whether the tested things were the ones that could hurt you.

Feature tests beat unit tests for a CRUD API
Look at what a single endpoint actually is. A route, middleware, authentication, a Form Request, a policy, a query scoped to a tenant, an API resource, a status code. Eight things, and seven of them are joins between components.
A unit test of the controller with the request and the model mocked exercises the controller. The bugs are almost never in the controller. They are in the middleware that was not applied to that route group, the policy that was never registered, the resource that serialises a column it should not, the scope that was forgotten.
<?php
public function test_a_member_can_log_time(): void
{
$this->actingAs($this->member)
->postJson('/api/time-entries', [
'project_id' => $this->project->id,
'started_at' => '2026-09-15T09:00:00+05:30',
'duration' => 3600,
])
->assertCreated()
->assertJsonPath('data.duration', 3600);
$this->assertDatabaseHas('time_entries', [
'organization_id' => $this->org->id,
'user_id' => $this->member->id,
'duration' => 3600,
]);
}
Twelve lines, and they prove the route exists, the middleware lets an authenticated member through, the rules accept a good payload, the policy allows it, the tenant key was set from the session, the response shape is what the frontend consumes, and the row landed with the right owner. No mock could tell you all of that, and any of those seven can break independently.
Unit tests still earn their place, just not here. They belong on pure logic with branches: invoice rounding, detecting overlapping time entries, working out which working days fall in a leave request, a date-range helper that has to survive month ends. That code has many inputs and no dependencies, which is precisely what a unit test is good at and precisely what a feature test is a clumsy way to reach.
The four tests every endpoint deserves
Not a maximum, a floor. Almost every endpoint we have shipped a bug in was missing one of these four, and it was rarely the first.
1. The happy path
The right user sends a valid payload and gets the right result. Assert the status, one or two fields of the response body, and the row in the database. Asserting the whole JSON body is tempting and makes the test break every time somebody adds a field, which trains people to update assertions without reading them.
2. Bad input
Post something malformed and assert a 422 with the specific field errors. This is the test that catches a rule being dropped during a refactor, and it takes three lines.
<?php
public function test_duration_must_be_positive(): void
{
$this->actingAs($this->member)
->postJson('/api/time-entries', ['duration' => -3600])
->assertStatus(422)
->assertJsonValidationErrors(['duration', 'project_id']);
}
3. The wrong role
A member tries to do an owner’s action and gets a 403. Every permission you have implemented is one if away from being deleted by somebody refactoring a policy, and nothing else in the system will complain.
4. Somebody else's data
The one that would have caught our bug. A fully valid, fully authenticated request for a resource belonging to another organisation.
<?php
public function test_a_member_cannot_read_another_orgs_entry(): void
{
$theirs = TimeEntry::factory()->for(Organization::factory())->create();
$this->actingAs($this->member)
->getJson("/api/time-entries/{$theirs->id}")
->assertNotFound();
}
Note the assertion: 404, not 403. A 403 confirms that the record exists, which tells an attacker enumerating ids exactly where the real data is. For another tenant’s resource, the honest answer is that it does not exist as far as this user is concerned. Pick one convention and assert it everywhere, because inconsistency here is itself the leak.
Tenant isolation is not a feature, it is the product
If you sell software to more than one company, one customer seeing another customer’s data is the failure you do not recover from. It does not need to be dramatic. A forgotten where on one endpoint is enough, and it will be an endpoint somebody added in a hurry.
Two defences. A global scope on every tenant-owned model, so the filter is applied by default rather than remembered. And a test that walks the endpoints rather than trusting that each one was individually reviewed.
<?php
public static function tenantEndpoints(): array
{
return [
'show entry' => ['GET', '/api/time-entries/{id}'],
'update entry' => ['PATCH', '/api/time-entries/{id}'],
'delete entry' => ['DELETE', '/api/time-entries/{id}'],
'show project' => ['GET', '/api/projects/{id}'],
'show invoice' => ['GET', '/api/invoices/{id}'],
];
}
#[DataProvider('tenantEndpoints')]
public function test_endpoints_do_not_leak_across_tenants(
string $verb, string $template
): void {
$other = $this->makeOtherTenantRecordFor($template);
$this->actingAs($this->member)
->json($verb, str_replace('{id}', $other->id, $template))
->assertNotFound();
}
Adding a row to that array when you add an endpoint takes ten seconds, and the day somebody writes a controller that queries by id alone, the suite says so by name. We added this after the incident. It found two more endpoints with the same hole on the first run.
The list-shaped version matters too: create ten entries in another organisation, hit the index endpoint, and assert the response contains none of them. A detail endpoint and a list endpoint fail this in different ways.

Factories and the setup that keeps tests readable
Tests get abandoned when they are hard to read, and the usual cause is thirty lines of arrangement before the two lines that matter.
<?php
protected function setUp(): void
{
parent::setUp();
$this->org = Organization::factory()->create();
$this->owner = User::factory()->owner()->for($this->org)->create();
$this->member = User::factory()->member()->for($this->org)->create();
$this->project = Project::factory()->for($this->org)->create();
}
Named factory states carry the meaning: owner(), member(), onTrial(), overdue(). A reader sees intent instead of a column being set to 2. Relationship helpers like for() and has() keep the tenant wiring in one place, which is also where you fix it when the schema changes.
The rule that keeps this from turning into its own maze: anything a reader must know to understand the assertion belongs in the test body. Shared scaffolding that is always the same can live in setUp. The moment a test depends on the member having a specific rate or the project being archived, put that line in the test, even though it is one more line.
SQLite in memory versus real MySQL
In-memory SQLite takes a suite from ninety seconds to eight. That difference is not a convenience, it changes behaviour, and it is worth having. It also lies to you in specific ways, and you need to know which.
- It does not truncate or reject on length. MySQL in strict mode rejects a 300-character string in a
varchar(255). SQLite stores it happily, so the test passes and production throws. - Group-by behaves differently. MySQL with
ONLY_FULL_GROUP_BYrejects queries SQLite runs without complaint. Report queries are the usual victim. - No FULLTEXT. Any search built on
MATCH ... AGAINSTcannot be tested at all on SQLite. - JSON functions and column types differ in both syntax and behaviour, which matters if you keep settings in a JSON column.
- Foreign keys are off unless you turn them on, so a test can insert an orphan row and never learn that production would not have allowed it.
- LIKE is case-insensitive in MySQL and case-sensitive in SQLite for anything beyond ASCII, which turns into a test that passes locally and fails in CI or the other way round.
- Raw SQL is where it really hurts. Any
selectRawwith a MySQL date function is simply a different language.
What works in practice: SQLite for the fast local loop, and the same suite against real MySQL in CI on every push, on the same major version as production. You get the tight feedback cycle where you need it and the truth before anything merges. When a test genuinely cannot run on SQLite, mark it and skip it locally rather than weakening it.

Test the thing you are afraid of
A coverage percentage is a measure of what was executed, not of what was checked. It is entirely possible to reach 80 per cent by testing accessors and still have nothing standing between a customer and somebody else’s invoices. Chasing the number produces exactly that suite, because the easy code is the code that raises the number fastest.
Better questions to sort the backlog with.
- What would embarrass us in front of a customer? Wrong totals on an invoice. Another company’s data. An email sent to the wrong address.
- What costs money when it is wrong? Anything touching billable hours, rates, plan limits or payment status.
- What is irreversible? Deletes, retention pruning, bulk updates, anything that writes to a customer’s calendar or inbox.
- What broke last quarter? The best predictor of the next bug is the last one, in the same file.
- What runs at 3am with nobody watching? Scheduled jobs fail silently for weeks. They deserve tests more than the screens people look at daily.
- What involves a timezone or a month boundary? These are wrong more often than any other category, and they are wrong quietly.
The discipline that builds a good suite without a plan: every production bug gets a failing test before the fix. It costs twenty minutes, it proves you understood the bug rather than moved it, and over a year the suite grows dense exactly where your application is actually fragile. That is a far better distribution than one produced by aiming at a percentage.
A suite that runs in two seconds
This sounds like a vanity metric and is not. Ninety seconds means you run the tests before you push. Two seconds means you run them on save. The second one changes what you are willing to do: you will refactor a payment path at 5pm on a Friday if the feedback is instant, and you will not if it is not.
- Turn down bcrypt in the test environment. Password hashing at the production cost factor dominates a suite that creates users. Four rounds in
config/hashing.phpfor testing routinely halves the total time. - Migrate once, not per test.
RefreshDatabasewraps each test in a transaction after migrating once, which is what you want. Re-running migrations per test is what makes a suite crawl. - Seed less. A shared seeder that creates two hundred rows for every test is paid for by every test. Create what the test needs.
- Fake everything that leaves the process.
Http::fake(),Mail::fake(),Queue::fake(),Storage::fake(). A real HTTP call in a test is slow, flaky, and eventually a bill. - Never sleep in a test. Travel in time instead with
$this->travelTo(). - Run in parallel.
php artisan test --parallelacross four cores, once the suite is clean enough to survive it.
Our API suite is a little over 600 tests and finishes in nine seconds on SQLite in parallel, and about two minutes against MySQL in CI. The nine seconds is what people actually experience, and it is the reason the tests get run.

The parts that are not a request and a response
An API is rarely only endpoints. Work gets pushed onto a queue, a command runs overnight, a webhook arrives from a payment gateway. These are the least tested parts of most applications and the ones that fail without anybody noticing.
For an endpoint that dispatches work, test the two halves separately. The request test asserts that the job was dispatched with the right payload; the job test asserts what the job does.
<?php
Queue::fake();
$this->actingAs($this->owner)
->postJson('/api/invoices/12/send')
->assertOk();
Queue::assertPushed(SendInvoiceEmail::class, function ($job) {
return $job->invoice->id === 12;
});
Then a second test that constructs the job and calls handle() directly, with mail faked. Split this way, a failure tells you which half broke. A single test that runs the queue synchronously tells you only that something in a chain of four things is wrong.
- Test the failure branch of a job, not only the success. What happens on the third retry? Does
failed()notify anybody? That code is written once and never run until the night it matters. - Test scheduled commands by calling them with
$this->artisan('entries:prune')and asserting on the data. Separately, one test that asserts the schedule actually registers the command — a task that is not registered is invisible forever. - Test webhook handlers as untrusted input: a bad signature must be rejected, a duplicate delivery must not charge twice, an unknown event type must not throw.
- Fake external HTTP with sequences so you can test a timeout and a 500 from the gateway, not just the response that arrives when everything is well.
Flaky tests are worse than missing ones
A test that fails one run in twenty teaches people to press the button again. Once that habit exists, a real failure gets the same treatment, and the suite has stopped working while still looking green most of the time.
Four causes account for nearly all of it.
- Real time. A test that builds “this month” from
now()passes for 28 days and fails on the 31st, or at 00:05 IST when the server thinks it is still yesterday in UTC. Freeze time in every test that touches a date. - Order dependence. One test leaving a row, a config value or a static property behind for the next. It passes locally in the usual order and fails in parallel.
RefreshDatabasecovers the database; it does not cover your singletons. - Random factory data. A faker-generated name that occasionally contains a quote, or a random number that is occasionally zero. If a value matters to the assertion, write it down.
- Shared external state. Two parallel processes on one test database, or a cache driver that is not
arrayin the test environment.
Treat a flaky test as a broken test. Fix it the same day or delete it, because a suite people have learned to re-run is not protecting anything.
What we deliberately do not test
- Framework behaviour. Eloquent saves records. That is Laravel’s test to write, not yours.
- Getters, setters and simple casts. They raise coverage and catch nothing.
- Blade markup. Asserting that a div has a class is a test that fails on every design change and never finds a bug.
- Third-party SDK internals. Fake the boundary and test your handling of its responses, including the failures.
- Private methods. If a private method needs its own test, it wants to be a class.
On Monday morning
- Pick your three most dangerous endpoints — the ones touching money, deletion or another customer’s data — and write the four tests for each. Half a day, and it is the highest-value half day in this article.
- Write the cross-tenant test today, even for one endpoint. Then add the data provider and fill the list as you go.
- Turn bcrypt down to four rounds in the testing config and time the suite before and after.
- Add MySQL to CI if you are on SQLite locally, running the same suite on the same commit.
- Open your bug tracker and write a failing test for the last three production bugs. If a test still fails, you never actually fixed it.
- Stop reporting the coverage number in stand-up. Report which dangerous paths now have tests instead.
A test suite is not a quality certificate. It is a list of things you decided were worth being certain about. Write that list on purpose and 200 tests will protect you better than 340 that were chosen by whatever was easy to reach.

