A Suite You Keep

What to test and what to skip, how to keep the run fast, and how to make a failing test block the merge.

Syntaxphp artisan test --parallel --processes=4 --profile

A suite earns its keep by failing on the day you break something and staying quiet otherwise. That depends less on coverage than on choosing what to assert and how long the run takes.

What to test, what to skip

For each route, cover three things: the happy path, the validation failure, and the authorization refusal. Cover money, dates, rounding, and any rule two people argued about. Every bug gets a test that fails before the fix and passes after, with the issue number in the method name. Skip the framework's own behaviour, plain accessors, and the exact wording of markup — a test that asserts a button's HTML breaks at the next redesign and teaches you nothing.

Speed is a feature

Nobody waits for a slow suite, and an unrun suite is no suite. Laravel 13's phpunit.xml already picks the cheap options: in-memory SQLite, BCRYPT_ROUNDS=4, array cache and session. Beyond that, php artisan test --profile lists your ten slowest tests. composer require brianium/paratest --dev then php artisan test --parallel spreads the run across cores, creating one test database per process with the process token as a suffix; --processes=4 caps it. The WithCachedConfig and WithCachedRoutes traits build config and routes once for the whole run instead of per test. Never sleep in a test: use $this->travelTo() or $this->freezeTime().

Run it where nobody can skip it

Put php artisan test in CI and make the job required to merge. The Laravel skeleton's own .github/workflows/tests.yml is the shape: check out, set up PHP, composer install, copy .env.example, php artisan key:generate, run the tests. --coverage --min=80 enforces a floor, but needs Xdebug or PCOV installed on the runner.

Example

Example · php
<?php
// tests/Feature/SubscriptionExpiryTest.php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Subscription;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class SubscriptionExpiryTest extends TestCase
{
    use RefreshDatabase;

    public function test_a_subscription_expires_the_moment_its_term_ends(): void
    {
        // Fixed clock: this test asserts the same thing in June as in December.
        $this->travelTo(Carbon::parse('2026-03-01 12:00:00'));

        $subscription = Subscription::factory()->create([
            'ends_at' => '2026-03-01 11:59:59',
        ]);

        $this->assertTrue($subscription->hasExpired());
    }

    /**
     * Regression: issue 412. A cancelled subscription whose term had not
     * yet ended was still reported as active, and kept billing.
     */
    public function test_a_cancelled_subscription_is_never_active(): void
    {
        $subscription = Subscription::factory()->create([
            'ends_at' => '2026-12-31 00:00:00',
            'cancelled_at' => '2026-03-01 09:00:00',
        ]);

        $this->assertFalse($subscription->isActive());
    }
}

When to use it

  • Decide which of forty untested routes to cover first.
  • Cut a six-minute suite down by finding the handful of slow tests.
  • Make a failing test block a pull request instead of reaching production.
  • Turn a reported bug into a permanent regression test with the issue number attached.

More examples

The CI workflow

This mirrors the workflow the Laravel skeleton ships. The pdo_sqlite extension is the part people forget: without it the in-memory database configured in phpunit.xml cannot open and every database test errors. Mark this job as a required status check so a red suite blocks the merge instead of being noticed later.

Example · yaml
# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v7

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.5
          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
          coverage: none

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-interaction --no-progress

      - name: Copy environment file
        run: cp .env.example .env

      - name: Generate app key
        run: php artisan key:generate

      - name: Execute tests
        run: php artisan test

Commands worth knowing

Anything the pest or phpunit binaries accept can be passed to php artisan test, which is why --filter and --stop-on-failure work here. Use --profile before optimising anything: the slow tests are usually a handful that hit a real HTTP service or hash a password at full cost.

Example · bash
# Everything, with a readable report.
php artisan test

# One class or one method while you work on it.
php artisan test --filter=SubscriptionExpiryTest

# Stop at the first failure, and only the Feature suite.
php artisan test --testsuite=Feature --stop-on-failure

# Find out what is slow: prints the ten slowest tests.
php artisan test --profile

# Spread across cores (needs brianium/paratest).
php artisan test --parallel --processes=4

# Enforce a coverage floor (needs Xdebug or PCOV).
php artisan test --coverage --min=80

Building config and routes once per run

The application is booted again for every test method, which means every config file is read and every route file is collected again. These traits build each once and keep it in memory for the run. The gain grows with the number of config and route files, so it is most noticeable on large applications with hundreds of tests.

Example · php
<?php
// tests/TestCase.php

declare(strict_types=1);

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\WithCachedConfig;
use Illuminate\Foundation\Testing\WithCachedRoutes;

abstract class TestCase extends BaseTestCase
{
    use WithCachedConfig;
    use WithCachedRoutes;
}

Discussion

  • Be the first to comment on this lesson.