Skip to content

Repository files navigation

easy-test

tests

easy-test overview: independent Pest groups, per-module coverage, Docker, and database safety

Run Pest test groups one-by-one instead of the whole suite in a single command: easier debugging, independent per-module coverage, clearer CI output, and one obviously-named group per failure instead of one giant red run.

A Laravel package rewrite of a battle-tested CI bash script, with the coverage map, execution settings, and Docker execution all driven by config/easy-test.php instead of a separate PHP file and shell flags.

Requirements

Supported
PHP 8.3, 8.4
Laravel 12.x, 13.x
Pest 3.x, 4.x
Coverage driver PCOV (preferred) or Xdebug

Install

The package is not published on Packagist, so register the repository once in the consuming project's composer.json:

{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/husseinzaher/easy-test.git" }
    ]
}

Then pick a channel:

# newest stable release
composer require zaher/easy-test --dev

# pin to the 1.x stable line
composer require "zaher/easy-test:^1.0" --dev

# tip of the development branch
composer require "zaher/easy-test:dev-develop" --dev

php artisan vendor:publish --tag=easy-test-config

This publishes config/easy-test.php.

Stable and dev channels

Constraint Resolves to
* or @stable newest stable tag
^1.0 newest 1.x stable tag
@dev allows dev versions — but see the note below, a stable tag still wins
^1.0@dev 1.x stable tags and 1.0.x-dev
dev-main tip of main — the current release line plus fixes
dev-develop tip of develop — the next unreleased minor
1.1.x-dev same as dev-develop, via the alias below

@dev alone will not put you on a branch. It only permits unstable versions; with "prefer-stable": true Composer still picks the newest satisfying stable tag. Verified against this repo: @dev resolves to v1.0.2, while dev-develop resolves to the branch tip. To actually track a branch, name it — dev-develop or dev-main.

The branches carry version aliases so a dev install still satisfies a numeric constraint:

"extra": {
    "branch-alias": {
        "dev-develop": "1.1.x-dev",
        "dev-main": "1.0.x-dev"
    }
}

Releases are cut on main and tagged (v1.0.2, …); day-to-day work lands on develop. Every tag is a signed, verified annotated tag.

Two things worth knowing about @dev:

  • The @dev suffix is a per-package stability flag. A consuming project keeps "minimum-stability": "stable" and "prefer-stable": true — only this package comes from a branch, everything else stays stable.
  • Composer reads branch-alias from the composer.json on that branch, so a branch only advertises its alias once the change is merged into it.

Quick start

  1. Map each Pest group to the directory whose coverage it should measure:
// config/easy-test.php
'groups' => [
    ['name' => 'admin', 'path' => 'Modules/Admin/'],
    ['name' => 'country', 'path' => 'Modules/Country/'],
    ['name' => 'app', 'path' => 'app/'],
],
  1. Set your testing database guard (see Database guard):
EASY_TEST_DB_GUARD_DATABASE_PREFIX=arm_backend_testing
  1. Make sure your phpunit.xml declares no coverage source paths (see PHPUnit configuration).

  2. Run:

php artisan easy-test:run
php artisan easy-test:run --group=admin,country
php artisan easy-test:doctor

Commands

easy-test:run

Option Description
--group= Comma-separated Pest groups to run (default: every discovered group)
--exclude-groups= Comma-separated groups fully excluded from this run (merged with config exclude_groups)
--min= Minimum coverage percentage (overrides coverage.minimum)
--processes= Parallel process count (overrides execution.processes / auto-detected CPU cores)
--driver= auto|pcov|xdebug (overrides coverage.driver)
--retry= Retry attempts after the first (overrides execution.retry)
--no-parallel Run without --parallel, --processes, or --recreate-databases
--retry-without-parallel Drop parallel mode on retry attempts (default)
--retry-with-parallel Keep parallel mode enabled on retry attempts
--slow-threshold= Seconds a group or step may take before the timing report flags it (overrides reports.slow_group_threshold; 0 disables)
--debug Print extended diagnostics before and after failures
--doctor Validate prerequisites and exit (equivalent to easy-test:doctor)
--docker / --no-docker Force-enable/disable Docker execution for this run only
--docker-driver= Override the configured Docker driver for this run only
--no-db-guard Skip the testing-database guard for this run only

easy-test:doctor

Validates PHP/Composer/Pest/PHPUnit availability, the coverage map, the coverage driver, the database guard config, and (if enabled) Docker reachability — without running any tests. Same Docker override options as easy-test:run.

PHPUnit configuration (phpunit.xml)

easy-test runs one Pest process per group and passes that group's coverage directory as an explicit --coverage-filter. Your phpunit.xml must therefore not declare any coverage source paths of its own. A <source> include list is applied on top of the per-group filter and widens every group back to the whole codebase — each module then reports roughly the same number, and the per-module threshold stops meaning anything.

Use this as the unit-test configuration in a project that runs easy-test:run:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".cache/phpunit"
         failOnWarning="true"
         failOnRisky="true"
         failOnEmptyTestSuite="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
            <directory>Modules/*/tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
            <directory>Modules/*/tests/Feature</directory>
        </testsuite>
    </testsuites>

    <!--
        No <source> and no <coverage> element on purpose.

        easy-test passes the coverage filter per group, one group at a time.
        Declaring include paths here would override that and make every
        group's coverage number identical and meaningless.
    -->

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_CONNECTION" value="pgsql"/>
        <env name="DB_DATABASE" value="arm_backend_testing"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="MAIL_MAILER" value="array"/>
    </php>
</phpunit>

If your phpunit.xml currently contains a block like the Laravel default below, delete it — that is the block that breaks per-module coverage:

<!-- Remove this when using easy-test -->
<source>
    <include>
        <directory>app</directory>
    </include>
</source>

If you need to exclude a directory from coverage (e.g. generated code, migrations), that's still fine — <exclude> only narrows the per-group filter easy-test already passes, it doesn't widen it back to the whole codebase the way <include> does:

<source>
    <exclude>
        <directory>app/Console/Commands</directory>
    </exclude>
</source>

Every test also needs an explicit group tag, since an untagged test lands in Pest's default group and fails the location pre-flight:

// Modules/Country/tests/Feature/CountryApiTest.php
it('lists countries', function () {
    // ...
})->group('country');

Or tag a whole directory once in tests/Pest.php:

pest()->group('country')->in(__DIR__.'/../Modules/Country/tests');

Configuration reference (config/easy-test.php)

groups and modules_roots

groups is the runner contract: every Pest ->group('name') tag must appear here with a coverage path, and every module directory under a configured modules root must be represented by exactly one entry. Both directions are validated before any test runs:

  • a module directory with no matching entry → Coverage Map Module Error
  • a mapped entry with zero discovered tests → Coverage Map Untested Module Error
  • a discovered/requested group missing from the map → Coverage Map Group Error

modules_roots accepts a string or a list, so modules can live in more than one place. Every immediate subdirectory of each root counts as one module:

'modules_roots' => 'Modules',                              // one root
'modules_roots' => ['Modules', 'Packages', 'src/Domains'], // several

With the second form, Modules/Country/, Packages/Billing/ and src/Domains/Shipping/ all need entries:

'groups' => [
    ['name' => 'country',  'path' => 'Modules/Country/'],
    ['name' => 'billing',  'path' => 'Packages/Billing/'],
    ['name' => 'shipping', 'path' => 'src/Domains/Shipping/'],
    ['name' => 'app',      'path' => 'app/'],
],

The recommended group name is always the snake_cased directory name, whichever root it sits in — so two modules with the same directory name under different roots would recommend the same group name; give one of them a distinct name.

Set modules_roots to [] (or null) to skip the module-directory scan entirely, for non-modular projects. The older singular modules_root key is still read as a fallback.

exclude_groups

Groups here are exempt from both coverage-map checks above and are skipped even if explicitly passed to --group — use this for a work-in-progress module with no tests yet.

conventions

The default rule is strict: a group's tests must live inside that group's own mapped coverage directory. conventions is the escape hatch for code that is not in a module — typically app/, whose tests conventionally live in the shared top-level tests/.

  • app_directories — coverage paths allowed to keep their tests in a shared test directory
  • root_tests_dirs — the shared test directories those paths may use

Both accept a string or a list, so this works for any layout:

// Laravel default
'conventions' => [
    'app_directories' => 'app',
    'root_tests_dirs' => 'tests',
],

// Several non-module roots, split test folders
'conventions' => [
    'app_directories' => ['app', 'src', 'support'],
    'root_tests_dirs' => ['tests', 'testing/integration'],
],

Set either to [] to drop the exemption and require every group's tests to sit inside its own mapped directory. The older singular app_directory / root_tests_dir keys are still read as a fallback.

Test files are located by asking Pest, so the directory a test class lives in does not have to match its namespace casing — Tests\Feature\FooTest resolves to tests/Feature/FooTest.php, and a module using Modules/Admin/Tests/ (capital T) works too.

coverage / execution

Coverage minimum/driver and parallel/retry/memory settings. execution.processes left null auto-detects CPU cores — through the configured Docker driver, so it reflects the container's core count, not the host's.

expected_versions

Optional PHP/Laravel/Pest/PHPUnit versions this setup was validated against. Drift only warns — it never blocks a run — since a version bump can change output formats or exit-code behavior without anything actually being broken.

database_guard

Fail-fast safety check, on by default, that runs before any test group executes:

'database_guard' => [
    'enabled' => true,
    'environment' => 'testing',
    'connection' => env('EASY_TEST_DB_GUARD_CONNECTION'), // required - e.g. 'pgsql'
    'database_prefix' => env('EASY_TEST_DB_GUARD_DATABASE_PREFIX'), // e.g. 'arm_backend_testing'
],

It refuses to run unless app()->environment() === 'testing' and database.connections.<connection>.database starts with database_prefix. Disable with database_guard.enabled = false or --no-db-guard for a single run.

For defense-in-depth (e.g. a suite that swaps connections mid-run), add the same check inside your own TestCase:

use Zaher\EasyTest\Concerns\EnforcesTestingDatabase;

class TestCase extends BaseTestCase
{
    use EnforcesTestingDatabase;
}

The trait's setUpEnforcesTestingDatabase() runs automatically - Laravel's base TestCase::setUp() calls it via the same setUp{TraitName}() convention RefreshDatabase and WithFaker use, so there is nothing to call manually.

docker

Every php/composer/pest/artisan command this package runs is wrapped according to docker.driver when docker.enabled is true:

Driver Wraps commands as
native (default) (no wrapping — runs on the host)
docker-compose docker compose -f <compose_file> exec -T [-e KEY=VAL ...] <service> ...
sail ./vendor/bin/sail ...
docker-exec docker exec -T [-e KEY=VAL ...] [-w <workdir>] <container> ...
'docker' => [
    'enabled' => env('EASY_TEST_DOCKER', false),
    'driver' => env('EASY_TEST_DOCKER_DRIVER', 'docker-compose'),
    'compose_file' => 'docker-compose.yml',
    'service' => 'app',
    'container' => null,
    'workdir' => null,
],

The sail driver cannot forward arbitrary -e environment variables (coverage driver mode, memory limit) into the container — Sail's own compose file must already forward them. Prefer docker-compose or docker-exec when the coverage driver needs to reliably switch per run.

reports

cache_dir (default .cache, relative to the app base path unless absolute) receives per-run reports under test-groups-<runId>/ plus two stable "legacy" files published after every complete run: coverage-report.txt and report.json.

run_id (default: the runner's PID) distinguishes concurrent runs writing into the same cache_dir — set it to the CI job id when several jobs share one workspace. github_summary (default on) appends a Markdown table to $GITHUB_STEP_SUMMARY when running under GitHub Actions.

Which build produced a run

Every command prints the installed version of the package: under the banner on easy-test:run, as the first line of easy-test:doctor, as the easy-test Version row of the configuration dashboard, and as easy_test_version in report.json plus a line in the GitHub Actions summary.

Label Means
v1.0.2 that release tag
dev-main@d87829a a branch install, at that commit — dev-main alone identifies nothing
dev-main@d87829a (linked) a symlinked Composer path repository: editing the checkout changes the next run
source / source@d87829a no version recorded (running from the package's own checkout)

A coverage number is only comparable against another run of the same runner, and a locally linked checkout otherwise looks exactly like a released tag — which is the whole reason this is printed rather than left to be looked up.

Console report layout

Every table the runner prints goes through one house style: a titled box, section headings inside the box, right-aligned numbers, colour-coded coverage and status, a TOTAL row, and a hard cap on how wide a single column may grow.

That last part matters more than it sounds. A plain table sizes each column to its widest cell, so the loaded-extensions row — 300+ characters on a normal PHP build — stretches the whole table far past any terminal width and makes every other row unreadable. Capped, that one value wraps and the table stays inside 100 columns:

┌─────────────────────┬─────────────────── Configuration ──────────────────────────────────────────┐
│ Setting             │ Value                                                                      │
├─────────────────────┴────────────────────────────────────────────────────────────────────────────┤
│ Coverage                                                                                         │
│ Coverage Driver     │ pcov                                                                       │
│ Minimum Coverage    │ 80%                                                                        │
│ Loaded Extensions   │ bcmath, Core, ctype, curl, date, dom, excimer, exif, fileinfo, filter, gd, │
│                     │ hash, iconv, intl, json, libxml, mbstring, mysqlnd, openssl, pcntl, pcov,  │
│                     │ pcre, PDO, pdo_pgsql, pdo_sqlite, Phar, posix, random, readline, redis,    │
│                     │ Reflection, session, SimpleXML, sodium, SPL, sqlite3, standard, tokenizer, │
│                     │ xdebug, xml, xmlreader, xmlwriter, Zend OPcache, zip, zlib                 │
├─────────────────────┴────────────────────────────────────────────────────────────────────────────┤
│ Execution                                                                                        │
│ Memory Limit        │ 256M                                                                       │
│ ...                                                                                              │

The configuration dashboard is grouped into Environment, Coverage, Execution and Scope, because when a run misbehaves the question is always about one of those areas.

Pre-flight speed (execution.discovery_concurrency)

The grouped-test-location check has to ask Pest which files belong to each group, and that needs two listings per group — neither can be dropped:

Listing Finds
--list-tests Pest closure tests (--list-test-files reports these as eval()'d code)
--list-test-files class-based PHPUnit tests

Each listing is a full Pest boot, so a 20-group project runs 42 boots. One after another on a large Laravel app, that is minutes of a CI job spent waiting. The listings are read-only, so they go out as one concurrent batch instead:

'execution' => [
    // null = auto (CPU cores, capped at 8), 1 = serial
    'discovery_concurrency' => env('EASY_TEST_DISCOVERY_CONCURRENCY'),
],

Measured on this repo with 12 real Pest boots: 11.10s serial → 2.23s at concurrency 8 (5.0× faster), identical results. The auto value is capped at 8 even on a 32-core machine — each boot is a whole framework boot, and a CI container swamped with them gets slower, not faster. Set it to 1 to go back to serial, or lower it if your runner is memory-constrained.

The Step Timing table reports this phase as Check grouped test locations, so the effect is visible in the run's own report.

Timing

Every run is timed twice over, and both timings end up in the final report.

Per group — the report table carries two columns: Duration is what Pest printed for the group's last attempt, and Time is the wall clock this runner measured around the whole group, so coverage generation, database recreation and retry attempts are included instead of invisible. Each group log ends with the same number (Elapsed (wall clock): 2m 25.3s).

Per step — a Step Timing table lists every phase in the order it happened, with its share of the run: resolving the coverage driver, detecting CPU cores, checking modules against the coverage map, discovering Pest groups, checking grouped test locations, then one row per group, plus the run total. A pre-flight scan that walks a large tree can cost more than a module's tests, and nothing else in the output would show that.

Anything at or over reports.slow_group_threshold (default 120 seconds, i.e. 2 minutes, env EASY_TEST_SLOW_GROUP_THRESHOLD, per-run --slow-threshold=) is highlighted in the tables and listed again under Slow Groups / Slow Steps below the summary — a long CI log gets scanned, not read. Set the threshold to 0 to switch the highlighting off.

┌───────────┬──────────┬───────┬─── Test Groups ───────┬───────────────┬──────────┐
│ Group     │ Coverage │ Tests │ Assertions │ Duration │          Time │ Status   │
├───────────┼──────────┼───────┼────────────┼──────────┼───────────────┼──────────┤
│ app       │   88.50% │    31 │         78 │   12.40s │         18.2s │ PASS     │
│ admin     │   91.00% │    31 │         78 │   2m 30s │  3m 7.4s SLOW │ PASS     │
│ country   │    ERROR │    31 │         78 │    4.21s │          9.6s │ FAIL     │
│ warehouse │   80.10% │    31 │         78 │   5m 02s │ 5m 40.8s SLOW │ PASS     │
├───────────┼──────────┼───────┼────────────┼──────────┼───────────────┼──────────┤
│ TOTAL     │   86.53% │   124 │        312 │          │        9m 16s │ 1/4 FAIL │
└───────────┴──────────┴───────┴────────────┴──────────┴───────────────┴──────────┘

┌───┬────────────────────────────── Step Timing ─────────────┬────────────────────┐
│ # │ Step                                   │          Time │ Share              │
├───┼────────────────────────────────────────┼───────────────┼────────────────────┤
│ 1 │ Resolve coverage driver                │          3.2s │ ░░░░░░░░░░░░  0.5% │
│ 2 │ Detect CPU cores                       │          0.8s │ ░░░░░░░░░░░░  0.1% │
│ 3 │ Check modules against the coverage map │   2m 21s SLOW │ ██░░░░░░░░░░ 20.1% │
│ 4 │ Discover Pest groups                   │            7s │ ░░░░░░░░░░░░    1% │
│ 5 │ Check grouped test locations           │            0s │ ░░░░░░░░░░░░    0% │
│ 6 │ Run group: app                         │         18.2s │ ░░░░░░░░░░░░  2.6% │
│ 7 │ Run group: admin                       │  3m 7.4s SLOW │ ███░░░░░░░░░ 26.8% │
│ 8 │ Run group: country                     │          9.6s │ ░░░░░░░░░░░░  1.4% │
│ 9 │ Run group: warehouse                   │ 5m 40.8s SLOW │ ██████░░░░░░ 48.7% │
├───┼────────────────────────────────────────┼───────────────┼────────────────────┤
│   │ Total run                              │       11m 40s │ ████████████  100% │
└───┴────────────────────────────────────────┴───────────────┴────────────────────┘

┌────────────── Run Summary ──────────────┐
│ Metric           │                Value │
├──────────────────┴──────────────────────┤
│ Result                                  │
│ Groups           │                    4 │
│ Passed           │                    3 │
│ Failed           │                    1 │
│ Tests            │                  124 │
│ Assertions       │                  312 │
├──────────────────┴──────────────────────┤
│ Coverage                                │
│ Average Coverage │               86.53% │
│ Coverage Driver  │                 pcov │
├──────────────────┴──────────────────────┤
│ Timing                                  │
│ Test Groups Time │               9m 16s │
│ Total Run Time   │              11m 40s │
│ Slowest Group    │ warehouse (5m 40.8s) │
└──────────────────┴──────────────────────┘

   WARN  Slow Groups (over 2m).

 - warehouse: 5m 40.8s (Pest duration 5m 02s)
 - admin: 3m 7.4s (Pest duration 2m 30s)

   WARN  Slow Steps (over 2m).

 - Check modules against the coverage map: 2m 21s

   ERROR  Failed Groups.

 - country

The persisted reports carry the same data: coverage-report.txt gains a measured-time field (group|coverage|tests|assertions|duration|elapsed|status), and report.json gains elapsed / elapsed_seconds (whole run), groups_elapsed_seconds, slow_threshold_seconds, slow_groups, a slow + elapsed pair per group, and a steps array. The GitHub Actions summary marks slow rows with ⚠️ and adds a Step timing table.

Contributing

The package's own suite runs on Pest with Orchestra Testbench:

composer install
composer test

.github/workflows/tests.yml runs the same suite on every push and pull request against main/develop, across PHP 8.3/8.4 × Laravel 12/13 (Testbench 10/11), with PCOV installed so CI exercises the same coverage driver easy-test:run prefers.

Tests under tests/Unit run without booting Laravel — the Support classes take an explicit $basePath instead of calling base_path() — so they stay fast and side-effect free. tests/Feature gets a booted Testbench app.

Edge cases handled

Ported from the original CI script, plus a few new ones from the Docker/config rewrite:

  • Coverage driver resolution & verification — auto picks PCOV over Xdebug; whichever is requested is verified by actually evaluating ini_get()/extension_loaded() inside the resolved command (through the Docker wrapper), not just checking it's installed. A silently-broken coverage setup fails loudly instead of producing a green run with no coverage data.
  • Coverage-map drift both directions — new module with no map entry, and mapped module with no tests, both hard-fail before any group runs.
  • Grouped test location drift — a test tagged with a group but living outside its mapped directory, or living inside the mapped directory but not tagged with the group, or left in Pest's untagged "default" group — all reported per-file and fail the run.
  • Pest parallel's known false-failure exit code — when parallel mode returns non-zero even though every test passed and coverage is above --min, the run retries once without --parallel to confirm the real result, instead of either trusting a possibly-wrong failure or silently swallowing it.
  • Configuration errors never retry — a missing coverage filter, an unloaded extension, or a bad CLI value fails immediately; retrying a broken setup only wastes CI minutes on a guaranteed repeat failure.
  • Coverage-threshold failures are distinguished from test failures in the failure report (different root cause, different fix).
  • Dependency version drift (PHP/Laravel/Pest/PHPUnit) warns without blocking, since this runner encodes workarounds tied to specific versions.
  • Timing that survives a retry — a group's measured time covers every attempt plus coverage generation, so a group that only passes on its second attempt reports what it really cost the job, not what Pest printed for the winning attempt. The elapsed line is appended to the log only after it has been parsed, since Pest's duration patterns would otherwise match it.
  • Concurrent invocations are serialized with a lock file under the cache dir, so two overlapping runs (e.g. a flaky CI re-trigger) can't truncate each other's report.
  • Testing-database guard fails the entire run before any group executes if the app isn't in the expected environment or the target database doesn't look like a test database — see Database guard.
  • Docker-aware system detection — PHP/Laravel/Pest/PHPUnit versions, loaded extensions, and CPU core count are all probed through the configured Docker driver, so auto coverage-driver detection and process-count auto-detection reflect the container, not the host, when Docker mode is on.
  • Process output capture never silently drops data — if the log file can't be written, the run fails loudly instead of producing an incomplete report.

About

Laravel testing toolkit for isolated Pest groups, per-module coverage, parallel execution, retries, Docker, and database safety.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages