Laravel Testing with Pest, Part 5: Architecture Tests, CI and Coverage

7 min readUpdated on

Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5

Architecture tests can keep controllers from depending on infrastructure or enforce naming rules, but they complement behavioural tests rather than replace them. Run the focused suite locally and the full suite, static analysis and formatting in CI. Parallel testing shortens feedback once isolation is sound. Treat coverage as a map of unexecuted code, not a target that proves correctness.

Put durable design rules in a dedicated test

Behavioural tests tell us whether a customer can create an order. They do not tell us that the controller stayed a transport boundary while that feature was implemented. That is the useful job for an architecture test: express a small, stable decision that would otherwise be rediscovered in every review.

In this application controllers may use requests, resources and actions. They may not query Eloquent or invoke a vendor client themselves. This is not an abstract preference for layers. It means that a later API, CLI command, or queued job can reuse the order decision without duplicating HTTP work.

php
<?php

declare(strict_types=1);

arch('HTTP controllers are thin')
    ->expect('App\\Http\\Controllers')
    ->toOnlyUse([
        'App\\Http\\Requests',
        'App\\Http\\Resources',
        'App\\Actions',
        'Illuminate',
    ]);

arch('HTTP controllers do not reach persistence')
    ->expect('App\\Http\\Controllers')
    ->not->toUse('Illuminate\\Database')
    ->not->toUse('App\\Models');

arch('actions have an explicit name')
    ->expect('App\\Actions')
    ->toHaveSuffix('Action')
    ->toBeClasses()
    ->not->toBeAbstract();

The exact rule is a local design choice, not a Laravel commandment. An invokable controller can reasonably use an Order type for route model binding. A blanket ban on models would reject legitimate framework code and teach people to evade the suite. Move binding to a request or action where that helps, or record a narrow exception. A rule should make an unwanted change difficult, never make ordinary code unreadable.

Architecture checks also guard dependency direction. If App\\Domain contains order transitions and App\\Infrastructure contains Eloquent repositories and accounting adapters, a domain service may describe a port but must not import an adapter.

php
<?php

declare(strict_types=1);

arch('domain code does not depend on infrastructure')
    ->expect('App\\Domain')
    ->not->toUse('App\\Infrastructure')
    ->not->toUse('Illuminate\\Http')
    ->not->toUse('Illuminate\\Support\\Facades');

arch('infrastructure classes reveal their role')
    ->expect('App\\Infrastructure')
    ->toHaveSuffix(['Repository', 'Client', 'Gateway'])
    ->toBeClasses();

Do not create those namespaces merely to write a clever assertion. A small application with one CreateOrderAction and conventional Eloquent models may have controller, action and model as its real boundary. Add architecture tests after the same layering mistake happens twice, when a package boundary matters, or when the team needs a shared rule. They are neither security checks nor type analysis: policies and feature tests prove access, static analysis finds impossible types, and architecture tests give fast structural feedback.

Group feedback, then make state safe for parallel processes

One huge suite is slow and ambiguous. Label the kinds of evidence without duplicating every example. A developer can run a focused feature group while changing an endpoint; the protected branch still runs all required groups. Browser tests deserve a group because their runtime is heavier and their value is a small number of expensive user journeys.

php
<?php

declare(strict_types=1);

use App\\Models\\User;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;

uses(RefreshDatabase::class)->group('feature')->in('Feature');

test('a customer can submit a support request', function (): void {
    $this->actingAs(User::factory()->create())
        ->post('/support/requests', [
            'subject' => 'Invoice copy',
            'category' => 'billing',
            'message' => 'Please send the invoice for order 1001.',
        ])
        ->assertRedirect();
})->group('browser-contract');

Laravel's --parallel option improves feedback only once every process owns its state: database, cache prefix, filesystem path and every resource it writes. A suite that passes serially and fails in parallel has found a real isolation defect. It has not found a reason to add retries or disable parallelism.

xml
<!-- phpunit.xml -->
<php>
    <env name="APP_ENV" value="testing"/>
    <env name="CACHE_STORE" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="FILESYSTEM_DISK" value="testing"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>
bash
php artisan test --compact
php artisan test --compact --parallel --processes=4

A hard-coded email can collide with a unique index. A test writing reports/latest.pdf races another process. A shared Cache::remember('current-order', ...) key can return another example's value. Generate unique fixtures, prefix keys with an order ID, use Storage::fake(), and create mutable state inside each example.

Parallelism is not CI sharding. Parallelism divides one command on one runner; sharding divides the suite between separate jobs. Use the first when the runner has unused CPU. Add shards only after measuring a consistently slow suite: four jobs that mostly boot Composer and services cost more without giving meaningfully faster evidence.

Let CI collect evidence in the right order

CI should fail first on cheap, deterministic checks, then run behaviour. This workflow keeps formatting and static analysis visible, tests the supported PHP versions, splits the application suite, and leaves the browser contract in its own job.

yaml
name: tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.4', '8.5']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: none
      - uses: ramsey/composer-install@v3
      - run: vendor/bin/pint --test
      - run: vendor/bin/phpstan analyse --memory-limit=1G

  tests:
    needs: quality
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          coverage: none
      - uses: ramsey/composer-install@v3
      - run: php artisan test --compact --parallel --shard=${{ matrix.shard }}/4

  browser-contract:
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
      - uses: ramsey/composer-install@v3
      - run: npm ci
      - run: php artisan test --compact --group=browser-contract

Verify the installed Pest and Laravel command syntax before copying sharding flags into production; runtime support changes. The design does not: every shard must run a disjoint, deterministic slice and branch protection must require every shard. One green shard out of four is not a green suite. Test every PHP version a package promises, but do not create a wide matrix by habit for an application deployed only on one runtime.

Treat coverage as a question

Coverage asks why a branch did not execute. It does not prove that an executed line was asserted meaningfully. Generate an HTML report when investigating a change, rather than making every pull request wait for instrumentation.

bash
XDEBUG_MODE=coverage php artisan test --compact --coverage-html=build/coverage

An uncovered forbidden branch should prompt an HTTP test for a manager from another account. An uncovered insufficient-stock path deserves a domain or feature test. A test whose only purpose is calling Order::getKey() buys a percentage, not safety. Thresholds can make sense for a mature package with a small public API; they are a poor first target for a changing application, migrations, generated code, or a team still learning what its tests prove.

The delivery contract is therefore layered: behaviour tests, a few browser journeys, isolated external boundaries, durable architecture rules, clean CI, and coverage-guided review. A pipeline earns trust when red is specific and reproducible, and green means the important contract really ran.

Before making any of these checks required, let the team see a few failures and agree on the repair path. A controller rule needs an obvious action to move work into; a flaky parallel test needs a reproducible fixture fix; a coverage report needs an owner who can distinguish dead code from missing risk coverage. Otherwise CI becomes a gate people resent rather than a fast, shared review of the delivery contract. Keep the rule, command and failure message close enough that the next contributor can correct the problem without guessing.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.