testparity / parity
Structural parity and code coverage validation — pluggable rules, framework agnostic.
Requires
- php: ^8.4
- laravel-zero/framework: ^12.0.2
- symfony/yaml: ^8.0
Requires (Dev)
- humbug/box: ^4.7
- laravel/pint: ^1.25.1
- mockery/mockery: ^1.6.12
- pestphp/pest: ^3.8.4|^4.1.2
README
Parity
Structural parity and code coverage validation for any project. Parity ensures application files have belonging tests, verifies coverage ownership, and catches files whose direct tests are weaker than global coverage suggests.
Global coverage answers "did the test suite touch enough code?" Parity answers "did the test that owns this file cover enough of this file?"
Install
composer global require testparity/parity
Parity requires PHP 8.4 or newer.
Make sure Composer's global bin directory is in your PATH:
# Add to your shell profile (~/.zshrc, ~/.bashrc, etc.) export PATH="$HOME/.composer/vendor/bin:$PATH"
Quick Start
# In your project root parity init # Creates parity.yaml parity check # Validate an existing coverage artifact parity test # Generate per-test coverage, then validate it # Options parity check --format=json # JSON output for CI parity check --config=path/to.yaml # Custom config path parity test --no-check # Generate reports without the follow-up check
parity check only reads existing coverage reports. When a runner cannot emit per-test attribution, parity test runs each expected test file individually, normalizes its coverage artifact, writes one report per test, and runs parity check against those reports by default.
parity test executes the configured test.command through the system shell. Placeholder values are escaped, but the command template is trusted executable configuration; review parity.yaml before running it locally or in privileged CI.
coverage_xml: [.parity/per-test, parity-coverage.json, coverage-xml, clover.xml, cobertura.xml]
coverage_xml can be a single path or an ordered list. Parity uses the first existing path, so teams can prefer attribution-rich reports and fall back to portable reports in CI:
| Format | Best for | Matched coverage |
|---|---|---|
| Parity per-test directory | Attribution generated by parity test from any supported single-test artifact |
Yes |
| Parity JSON | Language-neutral per-test attribution from custom converters | Yes |
| PHPUnit XML directory | PHP, PHPUnit, Pest, and line-level test attribution | Yes |
| Clover XML | PHP, JavaScript, TypeScript, and common coverage tools | No |
| Cobertura XML | Rust, Python, Go, JavaScript, TypeScript, JVM, and CI coverage tools | No |
Development
composer install ./vendor/bin/pint --test XDEBUG_MODE=coverage ./vendor/bin/pest --coverage-xml=coverage-xml --coverage-clover=clover.xml --colors=never php parity check --format=json composer validate --strict
This repository dogfoods Parity through the root parity.yaml. The self-check expects PHPUnit XML coverage in coverage-xml/, so generate coverage before running php parity check.
Build the PHAR manually with:
./vendor/bin/box compile --no-interaction php parity.phar --version
Samples
Public sample repositories demonstrate Parity across PHP, Laravel, TypeScript, AdonisJS, Rust, PHPUnit, Pest, Jest, Mocha, Vitest, and Cargo. Each sample installs the public Packagist package in GitHub Actions, runs parity check, and documents the same proof shape: global coverage can be 80% while the owning test only covers 40% of one file.
| Sample | Language/framework | Test runner | Repository |
|---|---|---|---|
| PHP | Plain PHP | Fixture coverage | https://github.com/testparity/php-sample |
| Laravel | Laravel-style PHP | Fixture coverage | https://github.com/testparity/laravel-sample |
| TypeScript | TypeScript utility | Fixture coverage | https://github.com/testparity/typescript-sample |
| AdonisJS | AdonisJS-style TypeScript | Fixture coverage | https://github.com/testparity/adonisjs-sample |
| Rust | Plain Rust | Fixture coverage | https://github.com/testparity/rust-sample |
| Cargo | Cargo project | Cargo | https://github.com/testparity/cargo-sample |
| PHPUnit | PHP | PHPUnit | https://github.com/testparity/phpunit-sample |
| Pest | PHP | Pest | https://github.com/testparity/pest-sample |
| Jest | JavaScript | Jest | https://github.com/testparity/jest-sample |
| Mocha | JavaScript | Mocha + NYC | https://github.com/testparity/mocha-sample |
| Vitest | TypeScript | Vitest | https://github.com/testparity/vitest-sample |
See docs/SAMPLES.md, docs/WHY-GLOBAL-COVERAGE-LIES.md, and docs/PARITY-COVERAGE-JSON.md.
The local samples/ directory contains the original fixtures. Run them from this package root:
php parity check --config=samples/php/parity.yaml php parity check --config=samples/laravel/parity.yaml php parity check --config=samples/vite/parity.yaml php parity check --config=samples/adonisjs/parity.yaml php parity check --config=samples/rust/parity.yaml php parity check --config=samples/phpunit/parity.yaml php parity check --config=samples/pest/parity.yaml php parity check --config=samples/jest/parity.yaml php parity check --config=samples/mocha/parity.yaml php parity check --config=samples/vitest/parity.yaml php parity check --config=samples/cargo/parity.yaml
Specs and Docs
Public specs are indexed in specs/README.md. Feature docs live in docs/, with the complete code/config/plugin/output reference in docs/REFERENCE.md. The VitePress website lives in ../parity-website during this workspace phase and mirrors the full specs tree under /specs/.
Configuration
All behavior is driven by parity.yaml. Parity is designed to be framework and language agnostic — settings control how files are discovered, named, and validated.
Settings
settings: # Namespace roots: directory prefix -> namespace prefix namespace_roots: app: App tests: Tests # File discovery source_extension: ".php" # .ts, .py, .go, etc. test_suffix: "Test" # Spec, _test, .test, etc. test_extension: ".php" # Defaults to source_extension # Identifier format namespace_separator: "\\" # . for Java/Python, / for Go
Coverage
# Coverage file(s): first existing path is used # Supports Parity JSON, PHPUnit XML, Clover XML, and Cobertura XML coverage_xml: [.parity/per-test, parity-coverage.json, coverage-xml, clover.xml, cobertura.xml] # Global thresholds min_coverage: 80 min_coverage_global: 80
When the configured coverage format cannot identify the owning test, configure isolated execution:
test: command: "./vendor/bin/pest {test_abs} --coverage-clover={coverage}" coverage: ".parity/tmp/{slug}.xml" reports: ".parity/per-test" timeout: 300
parity test supports {test}, {test_abs}, {coverage}, {slug}, and {project_root} placeholders. It discovers the same belonging tests as parity check, stages a complete report set, and checks it unless --no-check is supplied. Each isolated runner defaults to a 300-second timeout; --timeout overrides the config.
Parity preserves the last complete report set when a runner fails or times out, and removes each temporary runner artifact after the attempt. It refuses destructive, symlinked, or overlapping report/coverage targets and will not replace existing coverage artifacts outside .parity/ unless Parity created and marked them.
Structure with Rules
Each structure maps a source directory to a test directory and applies rules:
structure: - name: "Unit Services" paths: source: "app/Services" test: "tests/Unit/Services" rules: - enforce-coverage-link - minimum-coverage: min: 80 - matched-coverage: min: 60 file_map: # Override test path for specific files "Auth/LoginService.php": "Auth/LoginServiceTest.php"
Built-in Rules
Rules are pluggable and registered in the container as parity.rules.{name}.
| Rule | Column | Parameters | Description |
|---|---|---|---|
test-exists |
∃ |
none | Test file exists (auto-added) |
enforce-coverage-link |
Link |
linkers, attribute |
Test declares ->covers() (Pest) or #[CoversClass] (PHPUnit) |
minimum-coverage |
Cov |
min (required) |
Per-file coverage meets threshold |
matched-coverage |
Match |
min (optional) |
Coverage from matching test file only |
coverage-attribution |
# + Other |
none | Shows test count and non-matching test count |
Rule Parameters
Each rule declares a parameters() method that returns validation rules:
// MinimumCoverageRule public function parameters(): array { return ['min' => 'required|numeric|min:0|max:100']; }
Plugins
Parity discovers custom rules from three locations:
| Source | Path | Format |
|---|---|---|
| Project-local | .parity/plugins/*.php |
PHP file returning RuleInterface |
| Global user | ~/.parity/plugins/*.php |
PHP file returning RuleInterface |
| Composer package | extra.parity.rules in composer.json |
Array of class FQCNs |
File Plugin
Create a PHP file that returns a RuleInterface (or array of them):
// .parity/plugins/naming-convention.php return new class implements \App\Rules\RuleInterface { public function name(): string { return 'naming-convention'; } public function parameters(): array { return ['pattern' => 'sometimes|string']; } public function evaluate(\App\Rules\RuleContext $context, array $params): \App\Rules\RuleResult { ... } public function columnHeader(): ?string { return 'Name'; } public function formatCell(\App\Rules\RuleResult $result): string { ... } public function isEnforced(): bool { return true; } };
Composer Plugin Package
Publish a composer package with rules declared in composer.json:
{
"name": "acme/parity-rules",
"extra": {
"parity": {
"rules": [
"Acme\\Parity\\NamingConventionRule"
]
}
}
}
Then use any plugin rule in parity.yaml:
rules: - naming-convention: pattern: "{ClassName}Test.php"
Coverage Linkers
Coverage link detection supports multiple strategies via CoverageLinkerInterface:
| Linker | Detects | Example |
|---|---|---|
pest-covers |
Pest method chains | ->covers(Foo::class) |
php-attribute |
PHP 8 attributes | #[CoversClass(Foo::class)] |
Linkers auto-detect based on file content (Pest vs PHPUnit). Override with:
rules: - enforce-coverage-link: linkers: [pest-covers] # Only check Pest-style
Legacy Config
The old format (source_path, test_path, enforce_attribute, min_coverage) is still fully supported:
structure: - name: "Unit Actions" source_path: "app/Actions" test_path: "tests/Unit/Actions" enforce_attribute: 'PHPUnit\Framework\Attributes\CoversClass' min_coverage: 90
Architecture
parity.yaml (config)
|
v
Settings (resolved config DTO)
|
v
CheckCommand (orchestrator)
|
+-- RuleRegistry (resolves rules from config)
| |
| +-- TestExistsRule
| +-- EnforceCoverageLinkRule --> CoverageLinkerRegistry
| +-- MinimumCoverageRule
| +-- MatchedCoverageRule
| +-- CoverageAttributionRule
|
+-- CoverageReader / PhpUnitXmlCoverageReader
+-- NamespaceHelper (configurable path<->FQCN)
+-- ParityChecker (file-level validation)
License
MIT