Laravel Pest: Architecture Tests, Mutation Testing, and Type Coverage in CI
#laravel #pest #testing #ci #php

Laravel Pest: Architecture Tests, Mutation Testing, and Type Coverage in CI

4 min read Mohamed Said Mohamed Said

Beyond Feature Tests: Pest as a Quality Gate

Most Laravel teams use Pest for feature and unit tests and stop there. That leaves two powerful capabilities on the table: architecture tests that enforce structural rules, and mutation testing that proves your assertions actually catch bugs. Add type coverage reporting and you have a three-layer quality gate that belongs in every serious CI pipeline.


Architecture Tests

Pest's arch() helper lets you encode conventions as executable rules. No more PR comments about "don't use facades in domain code."

// tests/Architecture/DomainTest.php

arch('domain classes are pure PHP')
    ->expect('App\Domain')
    ->not->toUse(['Illuminate\Support\Facades\DB', 'Illuminate\Support\Facades\Cache'])
    ->not->toExtend('Illuminate\Database\Eloquent\Model');

arch('actions are invokable and final')
    ->expect('App\Actions')
    ->toBeFinal()
    ->toHaveMethod('__invoke');

arch('no debug calls survive')
    ->expect('App')
    ->not->toUse(['dd', 'dump', 'ray', 'var_dump']);

These run in milliseconds and fail the build the moment someone sneaks an Eloquent model into a domain value object. The toUse check is a static analysis pass over the AST — no runtime overhead.

Preset Shortcuts

Pest ships presets for common Laravel conventions:

arch()->preset()->laravel();   // controllers, models, jobs follow framework conventions
arch()->preset()->strict();    // strict types, no unused imports, typed properties
arch()->preset()->security();  // no eval, no exec, no shell_exec

Layer your own rules on top of presets rather than replacing them.


Mutation Testing with Infection

Architecture tests guard structure; mutation testing guards logic. Infection modifies your source code in small ways (mutants) and checks whether your test suite catches each change.

Install it as a dev dependency:

composer require --dev infection/infection infection/codecoverage

A minimal infection.json5 for a Laravel project:

{
  "source": { "directories": ["app/Domain", "app/Actions"] },
  "testFramework": "pest",
  "minMsi": 80,
  "minCoveredMsi": 90,
  "mutators": { "@default": true }
}

minMsi (Mutation Score Indicator) is the percentage of mutants killed. Setting it to 80 means 20 % of logic changes can slip past your tests — tune it upward as coverage matures.

Run it locally:

vendor/bin/infection --threads=4 --show-mutations

A surviving mutant on a pricing calculation is a concrete signal that your test only checks the happy path, not boundary conditions.


Type Coverage

Pest 2.x ships a --type-coverage flag backed by a static analysis pass. It reports the percentage of return types, parameter types, and property types declared across your codebase.

vendor/bin/pest --type-coverage --min=90

Fail the build if coverage drops below your threshold:

// pest.php
covers(\App\Domain\Pricing\PriceCalculator::class);

This is not a replacement for PHPStan or Psalm — it is a fast, CI-friendly gate that catches regressions when someone adds an untyped helper method.


Wiring It All Into CI

A GitHub Actions job that runs all three layers in parallel:

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3', coverage: pcov }
      - run: composer install --no-interaction
      - run: vendor/bin/pest --parallel --coverage --min=80
      - run: vendor/bin/pest --type-coverage --min=90
      - run: vendor/bin/infection --threads=4 --min-msi=80

Keep architecture tests in a separate --group=arch so they run first and fail fast without waiting for the full suite.

describe('arch', function () {
    // ...
})->group('arch');
vendor/bin/pest --group=arch  # fast gate, ~2 s
vendor/bin/pest --parallel    # full suite

Takeaways

  • Architecture tests are zero-runtime structural guards — encode your team's conventions before they become tribal knowledge.
  • Mutation testing reveals tests that pass for the wrong reasons; target domain logic and pricing/calculation code first.
  • Type coverage is a lightweight proxy for codebase health; pair it with PHPStan level 8 for full static analysis.
  • Run architecture tests as a fast first gate; mutation testing is slow and belongs after the green suite.
  • minMsi thresholds should increase over time — treat them like code coverage floors, not ceilings.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does mutation testing replace code coverage metrics?
No — they measure different things. Code coverage tells you which lines were executed; mutation testing tells you whether your assertions would catch a logic change. High coverage with low MSI means your tests run the code but don't verify its behaviour. Use both.
Q02 Are Pest architecture tests the same as PHPStan rules?
They overlap but serve different purposes. Pest arch tests are written in PHP alongside your test suite and enforce project-specific conventions (e.g. no facades in domain code). PHPStan rules are more granular type-level checks. Run both: arch tests for team conventions, PHPStan for type correctness.
Q03 How slow is Infection on a large Laravel codebase?
Infection can be slow because it re-runs the test suite for every mutant. Scope it to high-value directories (domain logic, actions) rather than the whole app, use `--threads` to parallelise, and run it on a nightly CI schedule rather than every push.

Continue reading

More Articles

View all