Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
laravel validationlaravel feature testseloquent relationshipslaravel queue

Laravel Testing: Why 1,800 Passing Tests Still Ship Bugs

Laravel testing that passes in CI can still ship real bugs. Learn where test suites lie and how to close the gap between green tests and working software.

Sep 28, 2026 · 6 min read

A recent dev.to post described a suite of roughly 1,800 tests that passed in CI, followed by 14 bugs discovered the first time someone actually used the application. That ratio is not rare. It is the normal failure mode of a suite that tests code in isolation from how users reach it. Laravel testing done well catches those bugs before deploy; done badly, it produces a green pipeline that means almost nothing. This guide covers the specific gaps that cause passing tests to miss real defects, and how to close each one in a Laravel codebase.

Key takeaway

Most false-green suites share the same defects: mocked or faked too much, no HTTP layer exercised, seeded fixtures that match nothing real, and integration points like queues and third-party APIs stubbed out entirely. Fixing the suite matters more than adding more tests.

Why do tests pass while the app is broken?

A unit test verifies that a function returns what the author assumed it should. The 14 bugs in that post lived in the assumptions, not the functions. Typical categories:

  • Unvalidated input paths. The service method handles a well-formed array. The route never enforces that the payload is well-formed before it arrives.
  • Relationship loading mistakes. Code that works with a single model in a test fails under lazy loading or missing eager loading in production (see Fix Eloquent N+1 in Laravel).
  • Environment drift. Different PHP versions, driver differences between SQLite in tests and PostgreSQL in production, and config cached locally but not in CI.
  • Async behavior. A laravel queue job is dispatched in tests with the sync driver, so any serialization or database-state bug inside the job never surfaces.
  • Human-only paths. Double submits, expired sessions, back-button navigation, browser autofill. No automated suite covers these unless you deliberately write for them.

Stop mocking the framework away

The single biggest cause of false confidence in Laravel testing is over-mocking. If your test mocks the repository, the validator, and the event dispatcher, you have tested your mock setup, not your application.

Prefer feature tests that go through the full HTTP stack. Laravel's testing layer makes this cheap:

public function test_store_rejects_missing_title(): void{    $response = $this->postJson('/api/articles', [        'body' => 'Valid body text',    ]);     $response->assertStatus(422)        ->assertJsonValidationErrors(['title']);     $this->assertDatabaseCount('articles', 0);}

This one test covers routing, middleware, form request laravel validation, persistence, and the response contract. A unit test on the controller method covers none of those boundaries. The HTTP tests documentation lists the full assertion API; assertJsonValidationErrors and assertDatabaseCount alone catch a large share of the integration bugs that unit suites miss.

Keep unit tests for pure logic: edit-distance calculations, date math, pricing rules. Everything that touches Eloquent, HTTP, or the container should be tested through the real stack.

Test validation boundaries, not just happy paths

Validation bugs are the most common category in post-mortems of this kind. A form request that validates in one route but is bypassed on another endpoint (an internal update route, an import command, a queued job re-processing a payload) creates divergent behavior that no single test catches.

Two practices fix this:

  1. Test every entry point to a mutation. If Article::update() can be reached via web route, API route, console command, and queue job, each path needs at least one test proving invalid input is rejected there.
  2. Assert the negative case explicitly. A test that only asserts assertStatus(200) on valid input proves nothing about what happens with a string where an integer belongs. Add one invalid-payload test per form request, covering the field most likely to be malformed.

For deeper coverage of rule design and conditional validation, see Laravel Validation: Rules, Custom Checks & Best Practices.

Make your test database resemble production

Running tests on SQLite while production runs PostgreSQL is a known source of silent divergence. JSON casting, case-insensitive collation, and constraint behavior differ between engines. Where possible, run the suite against the production driver in a container:

# phpunit.xml<env name="DB_CONNECTION" value="pgsql"/><env name="DB_HOST" value="127.0.0.1"/><env name="DB_DATABASE" value="app_test"/>

Pair this with a RefreshDatabase or DatabaseTransactions strategy appropriate to your suite size, and seed with factories whose data distribution resembles reality. If every test user has all three possible roles and every article has exactly two comments, your tests will pass on data shapes production never produces.

Run one CI job against SQLite for speed and a nightly job against PostgreSQL for parity. The nightly run catches driver-specific failures without slowing every pull request.

Laravel testing for queues and async paths

With the default test configuration, queued jobs execute synchronously, hiding serialization bugs and database-state races. Instead, assert the dispatch and then run the job:

Queue::fake(); $this->postJson('/api/articles', $payload)->assertStatus(201); Queue::assertPushed(ProcessArticle::class); // Run the real job through its real handler:ProcessArticle::dispatchSync($article = Article::first()); $this->assertDatabaseHas('article_tags', [    'article_id' => $article->id,]);

dispatchSync executes the job's handler in-process, exercising the actual work without a worker. This pattern catches the classic failure where a job receives a model ID, the row has been deleted by the time it runs, and the job crashes or silently no-ops. For job reliability patterns including retries and failure handling, see Laravel Queue Best Practices for Reliable Background Jobs.

Do your Eloquent tests match real access patterns?

Many bugs in production come from eloquent relationships accessed differently than in tests. A test that loads a model directly and calls a relationship bypasses the controller-side eager loading the real request path uses. Two checks close this gap:

  • Prevent lazy loading in tests. Add Model::preventLazyLoading(true) in your base TestCase. Any relationship accessed without eager loading now throws, turning an N+1 or missing-relation bug into a test failure. Laravel supports this natively via Model::shouldBeStrict() in non-production environments.
  • Test through the endpoint. Assert the JSON response contains the related data the frontend actually consumes, not just that the model exists.
protected function setUp(): void{    parent::setUp();    Model::shouldBeStrict(! app()->isProduction());}

Write the tests the 14 bugs would have failed

After any production bug, do not just fix it. Write the test that would have caught it, through the same entry point the user hit. Over a few months this converts your suite from a collection of author assumptions into a behavioral specification. Track the ratio of feature tests to unit tests; a healthy Laravel suite leans heavily toward feature tests because the framework's value, routing, validation, Eloquent, queues, lives at those boundaries.

Verification is straightforward: pick three recently shipped bugs, check whether the current suite would fail on the pre-fix code, and note which boundary the missing test needed to cross. That tells you exactly where your laravel testing strategy is blind.

FAQ

How much test coverage is enough in Laravel?

Coverage percentage is a weak signal. A suite with 90% coverage can still miss every integration boundary. Aim for full coverage of mutation entry points (routes, commands, jobs) and pure logic, and accept lower coverage on generated or trivial code.

Should I use SQLite or the production database driver for tests?

SQLite for fast everyday runs, the production driver for a slower parity suite. If your schema uses PostgreSQL-specific features such as partial indexes or JSONB operators, the parity suite is not optional.

Are Pest or PHPUnit required for feature tests?

No. Laravel's HTTP test assertions work identically under both. Choose based on team preference; the structural advice here applies either way.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Sep 26, 2026 · 6 min read

    Eloquent Relationships: Eager Loading Deep Dive

    How Laravel eager loading actually works, when with() stops helping, and how to detect and fix relationship queries that quietly multiply per row.

  • Sep 10, 2026 · 6 min read

    Fix Eloquent N+1 in Laravel: Query Problems at Scale

    Eloquent N+1 problems quietly destroy Laravel performance. Learn to detect, fix, and prevent them with eager loading, chunking, and query auditing in production.

On this page

  • Why do tests pass while the app is broken?
  • Stop mocking the framework away
  • Test validation boundaries, not just happy paths
  • Make your test database resemble production
  • Laravel testing for queues and async paths
  • Do your Eloquent tests match real access patterns?
  • Write the tests the 14 bugs would have failed
  • FAQ
  • How much test coverage is enough in Laravel?
  • Should I use SQLite or the production database driver for tests?
  • Are Pest or PHPUnit required for feature tests?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies