diff --git a/README.md b/README.md index f364e9d..1094a6b 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,17 @@ Total: 60 tests Your suite is getting heavier. ``` -Pyrameter is a PHPUnit extension that reports the shape of your test suite after PHPUnit runs. It classifies each executed test as `unit`, `functional`, `integration`, or `e2e`, compares the totals with your target shape, and can fail CI when the suite drifts too far. - -It works from the classes and namespaces used by your test files, so you can define what counts as "heavy" in your project instead of relying on test directory names. +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 Pyrameter as a dev dependency: +1. Install with Composer: ```bash composer require --dev boundwize/pyrameter ``` -Register the extension in `phpunit.xml`: +2. Register the extension in `phpunit.xml`: ```xml @@ -70,17 +68,44 @@ Register the extension in `phpunit.xml`: ``` -Run PHPUnit: +3. Run PHPUnit as usual: ```bash vendor/bin/phpunit ``` -Without extra configuration, Pyrameter uses its default rules and target shape. If a `pyrameter.php` file exists in the current working directory, it is loaded automatically. +This uses the default rules and target shape. ## Configure -Create `pyrameter.php` when you want to tune classification rules, target percentages, or CI behavior: +### 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: + +```php +return PyrameterConfig::defaults() + ->usesClass(App\Search\ExternalSearch::class, TestKind::Integration); +``` + +Define the complete configuration yourself: + +```php +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 15], integration: ['max' => 7], e2e: ['max' => 2], - ); + ) + ->failOnViolation(); ``` -`PyrameterConfig::defaults()` includes rules for common database usage (including CodeIgniter database tests), -cache, filesystem, Symfony and CodeIgniter controller functional tests, Panther, and WebDriver usage. -CodeIgniter tests using both `ControllerTestTrait` and `DatabaseTestTrait` remain functional; database-only tests -are integration tests. +Rules can match a class or trait, a namespace prefix, or a function: -Use `PyrameterConfig::create()` instead when you want to start with no rules or targets and define everything yourself: +| Rule | Example | +| --- | --- | +| `usesClass()` | `->usesClass(PDO::class, TestKind::Integration)` | +| `usesNamespace()` | `->usesNamespace('App\Tests\Browser\\', TestKind::E2E)` | +| `usesFunction()` | `->usesFunction('file_put_contents', TestKind::Integration)` | -```php -usesClass(PDO::class, TestKind::Integration) - ->usesNamespace('App\Controller\\', TestKind::Functional) - ->usesNamespace('App\Browser\\', TestKind::E2E) - ->usesFunction('file_put_contents', TestKind::Integration) - ->targetShape( - unit: ['min' => 70], - functional: ['max' => 20], - integration: ['max' => 8], - e2e: ['max' => 2], - ); + ->usesClass( + InteractsWithDatabase::class, + TestKind::Integration, + unless: [MakesHttpRequests::class], + ) + ->usesClass(MakesHttpRequests::class, TestKind::Functional); ``` -With `create()`, only the rules you add are used for heavier classifications; all other executed tests stay `unit`. +| Traits used by the test | Result | +| --- | --- | +| `InteractsWithDatabase` | `integration` | +| `MakesHttpRequests` | `functional` | +| Both traits | `functional` | + +The optional `unless` argument is also available on `usesNamespace()` and `usesFunction()`. + +The equivalent CodeIgniter exception is already included in `defaults()`: -Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Names can be configured with or without a leading backslash. +```php +return PyrameterConfig::defaults(); +``` + +Tests that use only `DatabaseTestTrait` are classified as `integration`; tests that also use `ControllerTestTrait` remain `functional`. -To load a config file from another path, pass the `config` parameter to the PHPUnit extension: +Load a configuration file from another path: ```xml @@ -146,28 +181,28 @@ To load a config file from another path, pass the `config` parameter to the PHPU ## Classification -Pyrameter scans the consumed classes, namespaces, and function calls in each test file: - | Usage | Kind | | --- | --- | -| No configured heavy usage | `unit` | +| No matching heavier rule | `unit` | | Framework test runtime | `functional` | | Database, cache, queue, filesystem, or external boundary | `integration` | | Browser driver usage | `e2e` | -When multiple rules match, the heaviest kind wins. Mocked heavy dependencies stay `unit` unless the test file also consumes a class or namespace you configured as heavier. - -Counts follow PHPUnit's executed test count, so data-provider datasets are counted separately. +- The heaviest matching rule wins. +- Mocked dependencies do not trigger a rule by themselves. +- Data-provider datasets are counted separately. ## Targets and CI -Targets are percentages. Missing `min` means `0`; missing `max` means `100`. - -By default, Pyrameter is report-only. To fail PHPUnit when the target shape is violated, enable `failOnViolation()`: - ```php return PyrameterConfig::defaults() + ->targetShape( + unit: ['min' => 70], + functional: ['max' => 20], + integration: ['max' => 8], + e2e: ['max' => 2], + ) ->failOnViolation(); ``` -Pyrameter is a pressure gauge for suite shape, not a perfect taxonomy judge. Tune the rules to match how your team defines unit, functional, integration, and e2e tests. +Targets are percentages. An omitted `min` defaults to `0`; an omitted `max` defaults to `100`. Without `failOnViolation()`, Pyrameter only reports violations. diff --git a/src/Config/PyrameterConfig.php b/src/Config/PyrameterConfig.php index 7446ba8..7ed5560 100644 --- a/src/Config/PyrameterConfig.php +++ b/src/Config/PyrameterConfig.php @@ -111,15 +111,14 @@ public static function defaults(): self ->usesNamespace('Predis\\', TestKind::Integration) ->usesNamespace('Symfony\Bundle\FrameworkBundle\Test\\', TestKind::Functional) ->usesClass(ControllerTestTrait::class, TestKind::Functional) + ->usesClass( + DatabaseTestTrait::class, + TestKind::Integration, + unless: [ControllerTestTrait::class], + ) ->usesNamespace('Symfony\Component\Panther\\', TestKind::E2E) ->usesNamespace('Facebook\WebDriver\\', TestKind::E2E); - $pyrameterConfig->usageRules[] = new UsageRule( - DatabaseTestTrait::class, - TestKind::Integration, - unless: [ControllerTestTrait::class], - ); - foreach (self::FILE_OPERATION_FUNCTIONS as $functionName) { $pyrameterConfig->usesFunction($functionName, TestKind::Integration); } @@ -134,24 +133,40 @@ functional: ['max' => 18], /** * @param class-string $className + * @param list $unless */ - public function usesClass(string $className, TestKind $testKind): self + public function usesClass(string $className, TestKind $testKind, array $unless = []): self { - $this->usageRules[] = new UsageRule(ltrim($className, '\\'), $testKind); + $this->usageRules[] = new UsageRule(ltrim($className, '\\'), $testKind, unless: $unless); return $this; } - public function usesNamespace(string $namespace, TestKind $testKind): self + /** + * @param list $unless + */ + public function usesNamespace(string $namespace, TestKind $testKind, array $unless = []): self { - $this->usageRules[] = new UsageRule(rtrim(ltrim($namespace, '\\'), '\\') . '\\', $testKind); + $this->usageRules[] = new UsageRule( + rtrim(ltrim($namespace, '\\'), '\\') . '\\', + $testKind, + unless: $unless, + ); return $this; } - public function usesFunction(string $functionName, TestKind $testKind): self + /** + * @param list $unless + */ + public function usesFunction(string $functionName, TestKind $testKind, array $unless = []): self { - $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind, UsageType::Function); + $this->usageRules[] = new UsageRule( + ltrim($functionName, '\\'), + $testKind, + usageType: UsageType::Function, + unless: $unless, + ); return $this; } diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index 0c97ea2..2132059 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -95,4 +95,22 @@ public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:\FILE_GET_CONTENTS'])); } + + public function testUsesClassCanBeSuppressedByAnotherConsumedClass(): void + { + $pyrameterConfig = PyrameterConfig::create() + ->usesClass( + PDO::class, + TestKind::Integration, + unless: [self::class], + ) + ->usesClass(self::class, TestKind::Functional); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame( + TestKind::Functional, + $usageClassifier->classify([PDO::class, self::class]), + ); + $this->assertSame(TestKind::Integration, $usageClassifier->classify([PDO::class])); + } }