boundwize / pyrameter
PHPUnit extension that measures the shape of your test pyramid.
Fund package maintenance!
Requires
- php: ^8.2
- nikic/php-parser: ^5.0
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- boundwize/structarmed: ^0.13.4
- laminas/laminas-coding-standard: ^3.1
- phpstan/phpstan: ^2.2
- rector/rector: ^2.4
README
Keep your PHPUnit test suite shaped like a pyramid.
vendor/bin/phpunit
........................
=========
Pyrameter
=========
Shape: Integration Mountain
Result: Violated ⚠
▲ E2E ✓
▄▄▄▄▄ Integration ✗
▄▄▄▄▄▄▄▄▄ Functional ✓
▄▄▄▄▄▄▄▄▄▄▄▄▄ Unit ✗
+=============+=======+========+============+
| KIND | TESTS | ACTUAL | TARGET |
+=============+=======+========+============+
| Unit | 39 | 65.0% | >= 70.0% ✗ |
+-------------+-------+--------+------------+
| Functional | 10 | 16.7% | <= 18.0% ✓ |
+-------------+-------+--------+------------+
| Integration | 10 | 16.7% | <= 8.0% ✗ |
+-------------+-------+--------+------------+
| E2E | 1 | 1.6% | <= 2.0% ✓ |
+-------------+-------+--------+------------+
Total: 60 tests
Your suite is getting heavier.
Pyrameter classifies executed tests as unit, functional, integration, or e2e based on the code they use, then compares the totals with your target shape.
Quick start
Install with Composer:
composer require --dev boundwize/pyrameter
Register the extension in phpunit.xml:
<extensions> <bootstrap class="Boundwize\Pyrameter\Extension"/> </extensions>
Run PHPUnit as usual:
vendor/bin/phpunit
This uses the default rules and target shape.
Configure
Default or empty configuration
Choose the starting point before adding rules:
| Start with | Behavior |
|---|---|
PyrameterConfig::defaults() |
Starts with built-in rules and the default target shape. The rules cover common database, cache, and filesystem usage; Symfony and CodeIgniter functional tests; and Panther and WebDriver browser tests. Classification methods add rules; targetShape() replaces the targets. |
PyrameterConfig::create() |
Starts with no rules or targets. Only rules you add can classify tests as heavier than unit. |
Extend the built-in configuration:
return PyrameterConfig::defaults() ->usesClass(App\Search\ExternalSearch::class, TestKind::Integration);
Define the complete configuration yourself:
return PyrameterConfig::create() ->usesClass(PDO::class, TestKind::Integration) ->targetShape( unit: ['min' => 80], integration: ['max' => 20], );
A complete pyrameter.php can combine rules, targets, and CI behavior:
<?php declare(strict_types=1); use Boundwize\Pyrameter\Config\PyrameterConfig; use Boundwize\Pyrameter\TestKind; return PyrameterConfig::defaults() ->usesClass(App\Analyser\Analyser::class, TestKind::Integration) ->usesNamespace('App\Tests\Browser\\', TestKind::E2E) ->usesFunction('app_writes_to_disk', TestKind::Integration) ->targetShape( unit: ['min' => 75], functional: ['max' => 15], integration: ['max' => 7], e2e: ['max' => 2], ) ->failOnViolation();
Rules can match a class or trait, a namespace prefix, or a function:
| Rule | Example |
|---|---|
usesClass() |
->usesClass(PDO::class, TestKind::Integration) |
usesNamespace() |
->usesNamespace('App\Tests\Browser\\', TestKind::E2E) |
usesFunction() |
->usesFunction('file_put_contents', TestKind::Integration) |
Rule exceptions
Use unless to ignore a rule when the test also consumes another class or trait:
use App\Tests\Concerns\InteractsWithDatabase; use App\Tests\Concerns\MakesHttpRequests; return PyrameterConfig::create() ->usesClass( InteractsWithDatabase::class, TestKind::Integration, unless: [MakesHttpRequests::class], ) ->usesClass(MakesHttpRequests::class, TestKind::Functional);
| Traits used by the test | Result |
|---|---|
InteractsWithDatabase |
integration |
MakesHttpRequests |
functional |
| Both traits | functional |
The optional unless argument is also available on usesNamespace() and usesFunction(). Its values always identify classes or traits, regardless of the rule type. For example, a filesystem function rule can be suppressed when a test uses a virtual-filesystem trait:
use App\Tests\Concerns\UsesVirtualFilesystem; return PyrameterConfig::create() ->usesFunction( 'file_put_contents', TestKind::Integration, unless: [UsesVirtualFilesystem::class], ) ->usesClass(UsesVirtualFilesystem::class, TestKind::Functional);
A test that calls file_put_contents() and uses UsesVirtualFilesystem is classified as functional.
The equivalent CodeIgniter exception is already included in defaults():
return PyrameterConfig::defaults();
Tests that use only DatabaseTestTrait are classified as integration; tests that also use ControllerTestTrait remain functional.
Load a configuration file from another path:
<extensions> <bootstrap class="Boundwize\Pyrameter\Extension"> <parameter name="config" value="config/pyrameter.php"/> </bootstrap> </extensions>
Classification
| Usage | Kind |
|---|---|
| No matching heavier rule | unit |
| Framework test runtime | functional |
| Database, cache, queue, filesystem, or external boundary | integration |
| Browser driver usage | e2e |
- The heaviest matching rule wins.
- Mocked dependencies do not trigger a rule by themselves.
- Data-provider datasets are counted separately.
Targets and CI
return PyrameterConfig::defaults() ->targetShape( unit: ['min' => 70], functional: ['max' => 20], integration: ['max' => 8], e2e: ['max' => 2], ) ->failOnViolation();
Targets are percentages. An omitted min defaults to 0; an omitted max defaults to 100. Without failOnViolation(), Pyrameter only reports violations.
