The point of a test suite is not to prove that code exists. It is to make valuable change safer. A practical Laravel testing strategy chooses the cheapest test boundary that can catch each important failure, while keeping enough integration coverage to trust the framework, database, and wiring.

List risks before test types

Payments, permissions, pricing, data migration, and subscription state deserve more attention than simple accessors. Start with the failures that would cost money, corrupt data, or break user trust. Coverage reports can reveal forgotten files, but a percentage does not know which behaviour matters.

Use unit tests for independent rules

A value object, pricing policy, or state machine can run without booting Laravel. These tests are fast and produce precise failures. If one unit requires a dozen mocks, the code may have too many responsibilities or the test boundary may be wrong.

public function test_an_expired_coupon_cannot_discount_an_order(): void
{
    $coupon = Coupon::expired(code: 'OLD20');

    $this->assertFalse($coupon->canApplyTo(OrderTotal::from(50_00)));
}

Feature tests are a strong Laravel default

Feature tests can exercise routing, middleware, Form Requests, policies, Eloquent, and Resources together. For an important endpoint, cover the successful workflow, validation failure, unauthorized actor, forbidden resource, and a meaningful business conflict.

Assert response semantics and durable effects. Avoid asserting every internal service call; that couples the test to implementation and makes safe refactoring noisy.

Use the production database engine where behaviour matters

SQLite differs from MySQL or PostgreSQL in constraints, collation, JSON, locking, and SQL. Queries, scopes, transactions, and indexes deserve integration tests against the production engine. Docker makes that practical in CI.

Factories should express relevant state. Named states such as paid() or forTenant() make intent clearer than a large anonymous array in every test.

Test external services at two levels

Daily tests should fake HTTP success, timeout, rate limiting, malformed responses, and permanent failures. The adapter that maps a vendor API into application concepts is the useful boundary. A separate scheduled contract test against the sandbox catches provider changes and incorrect assumptions.

Do not fake the whole queue

At an HTTP boundary, assert that the correct job was dispatched. Then test the job’s business effect and retry behaviour separately. If every test stops at Queue::fake(), jobs can be dispatched successfully and still fail whenever a worker runs them.

Recognise brittle tests

  • They assert call order rather than outcome.
  • They depend on uncontrolled time, randomness, or network.
  • One test covers several unrelated behaviours.
  • Setup creates far more data than the scenario needs.
  • The name does not describe the rule being protected.

Keep CI feedback short

Parallelise independent tests, measure the slowest cases, cache dependencies, and use transaction-based reset where appropriate. Browser tests are valuable for a few critical journeys, but expensive as the foundation of the suite. The usual pyramid has many fast rule and feature tests, fewer contract tests, and a small number of end-to-end paths.

Approach legacy code with characterisation tests

Capture current inputs and outputs before refactoring, even when the behaviour is not ideal. The first goal is a safety net; improvement comes next. This is especially useful during a legacy PHP migration.

A Laravel code audit can identify the highest-risk workflows and turn them into a testing roadmap before large structural changes begin.

Frequently asked questions

Should Laravel tests use a real database?

Tests for queries, constraints, scopes, and transactions should. Pure domain rules can remain fast and database-free.

When is mocking harmful?

When the mock repeats implementation details or replaces the framework and database behavior you actually need confidence in.