diff --git a/README.md b/README.md index 42b96b3..e95d2b8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Add the coverage extension to your `phpunit.xml`: | `strip_prefixes` | No | `[]` | Comma-separated prefixes to strip from request paths (e.g., `/api`) | | `specs` | No | `front` | Comma-separated spec names for coverage tracking | | `output_file` | No | — | File path to write Markdown coverage report (relative paths resolve from `getcwd()`) | +| `console_output` | No | `default` | Console output mode: `default`, `all`, or `uncovered_only` (overridden by `OPENAPI_CONSOLE_OUTPUT` env var) | *Not required if you call `OpenApiSpecLoader::configure()` manually. @@ -163,7 +164,11 @@ For Laravel, set the `max_errors` key in `config/openapi-contract-testing.php`. ## Coverage Report -After running tests, the PHPUnit extension prints a coverage report: +After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `OPENAPI_CONSOLE_OUTPUT` environment variable). + +### `default` mode (default) + +Shows covered endpoints individually and uncovered as a count: ``` OpenAPI Contract Test Coverage @@ -179,6 +184,56 @@ Covered: Uncovered: 41 endpoints ``` +### `all` mode + +Shows both covered and uncovered endpoints individually: + +``` +OpenAPI Contract Test Coverage +================================================== + +[front] 12/45 endpoints (26.7%) +-------------------------------------------------- +Covered: + ✓ GET /v1/pets + ✓ POST /v1/pets + ✓ GET /v1/pets/{petId} + ✓ DELETE /v1/pets/{petId} +Uncovered: + ✗ PUT /v1/pets/{petId} + ✗ GET /v1/owners + ... +``` + +### `uncovered_only` mode + +Shows uncovered endpoints individually and covered as a count — useful for large APIs where you want to focus on missing coverage: + +``` +OpenAPI Contract Test Coverage +================================================== + +[front] 12/45 endpoints (26.7%) +-------------------------------------------------- +Covered: 12 endpoints +Uncovered: + ✗ PUT /v1/pets/{petId} + ✗ GET /v1/owners + ... +``` + +You can set the mode via `phpunit.xml`: + +```xml + +``` + +Or via environment variable (takes priority over `phpunit.xml`): + +```bash +OPENAPI_CONSOLE_OUTPUT=uncovered_only vendor/bin/phpunit +``` + ## CI Integration ### GitHub Actions Step Summary @@ -201,6 +256,15 @@ Use the `output_file` parameter to write a Markdown report to a file. This is us ``` +You can also use the `OPENAPI_CONSOLE_OUTPUT` environment variable in CI to show uncovered endpoints in the job log: + +```yaml +- name: Run tests (show uncovered endpoints) + run: vendor/bin/phpunit + env: + OPENAPI_CONSOLE_OUTPUT: uncovered_only +``` + Example GitHub Actions workflow step to post the report as a PR comment: ```yaml diff --git a/src/PHPUnit/ConsoleCoverageRenderer.php b/src/PHPUnit/ConsoleCoverageRenderer.php new file mode 100644 index 0000000..e39f8ce --- /dev/null +++ b/src/PHPUnit/ConsoleCoverageRenderer.php @@ -0,0 +1,94 @@ + $results + */ + public static function render(array $results, ConsoleOutput $consoleOutput = ConsoleOutput::DEFAULT): string + { + if ($results === []) { + return ''; + } + + $output = "\n\n"; + $output .= "OpenAPI Contract Test Coverage\n"; + $output .= str_repeat('=', 50) . "\n"; + + foreach ($results as $spec => $result) { + $percentage = self::percentage($result['coveredCount'], $result['total']); + + $output .= "\n[{$spec}] {$result['coveredCount']}/{$result['total']} endpoints ({$percentage}%)\n"; + $output .= str_repeat('-', 50) . "\n"; + + $output .= self::renderCovered($result, $consoleOutput); + $output .= self::renderUncovered($result, $consoleOutput); + } + + $output .= "\n"; + + return $output; + } + + /** + * @param CoverageResult $result + */ + private static function renderCovered(array $result, ConsoleOutput $consoleOutput): string + { + if ($result['covered'] === []) { + return ''; + } + + if ($consoleOutput === ConsoleOutput::UNCOVERED_ONLY) { + return "Covered: {$result['coveredCount']} endpoints\n"; + } + + $output = "Covered:\n"; + + foreach ($result['covered'] as $endpoint) { + $output .= " ✓ {$endpoint}\n"; + } + + return $output; + } + + /** + * @param CoverageResult $result + */ + private static function renderUncovered(array $result, ConsoleOutput $consoleOutput): string + { + if ($result['uncovered'] === []) { + return ''; + } + + $uncoveredCount = count($result['uncovered']); + + if ($consoleOutput === ConsoleOutput::DEFAULT) { + return "Uncovered: {$uncoveredCount} endpoints\n"; + } + + $output = "Uncovered:\n"; + + foreach ($result['uncovered'] as $endpoint) { + $output .= " ✗ {$endpoint}\n"; + } + + return $output; + } + + private static function percentage(int $covered, int $total): float|int + { + return $total > 0 ? round($covered / $total * 100, 1) : 0; + } +} diff --git a/src/PHPUnit/ConsoleOutput.php b/src/PHPUnit/ConsoleOutput.php new file mode 100644 index 0000000..aca7096 --- /dev/null +++ b/src/PHPUnit/ConsoleOutput.php @@ -0,0 +1,51 @@ + parameter > DEFAULT. + */ + public static function resolve(?string $parameterValue): self + { + $envValue = getenv('OPENAPI_CONSOLE_OUTPUT'); + + if ($envValue !== false && trim($envValue) !== '') { + $resolved = self::tryFrom(mb_strtolower(trim($envValue))); + + if ($resolved === null) { + fwrite(STDERR, "[OpenAPI Coverage] WARNING: Invalid OPENAPI_CONSOLE_OUTPUT value '{$envValue}'. Valid values: default, all, uncovered_only. Falling back to 'default'.\n"); + } + + return $resolved ?? self::DEFAULT; + } + + if ($parameterValue !== null && trim($parameterValue) !== '') { + $resolved = self::tryFrom(mb_strtolower(trim($parameterValue))); + + if ($resolved === null) { + fwrite(STDERR, "[OpenAPI Coverage] WARNING: Invalid console_output parameter '{$parameterValue}'. Valid values: default, all, uncovered_only. Falling back to 'default'.\n"); + } + + return $resolved ?? self::DEFAULT; + } + + return self::DEFAULT; + } +} diff --git a/src/PHPUnit/OpenApiCoverageExtension.php b/src/PHPUnit/OpenApiCoverageExtension.php index 662ea8a..a995ad6 100644 --- a/src/PHPUnit/OpenApiCoverageExtension.php +++ b/src/PHPUnit/OpenApiCoverageExtension.php @@ -23,8 +23,6 @@ use function fwrite; use function getcwd; use function getenv; -use function round; -use function str_repeat; use function str_starts_with; final class OpenApiCoverageExtension implements Extension @@ -59,15 +57,20 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete } } + $consoleOutput = ConsoleOutput::resolve( + $parameters->has('console_output') ? $parameters->get('console_output') : null, + ); + $githubSummaryPath = getenv('GITHUB_STEP_SUMMARY') ?: null; - $facade->registerSubscriber(new class ($specs, $outputFile, $githubSummaryPath) implements ExecutionFinishedSubscriber { + $facade->registerSubscriber(new class ($specs, $outputFile, $consoleOutput, $githubSummaryPath) implements ExecutionFinishedSubscriber { /** * @param string[] $specs */ public function __construct( private readonly array $specs, private readonly ?string $outputFile, + private readonly ConsoleOutput $consoleOutput, private readonly ?string $githubSummaryPath, ) {} @@ -80,7 +83,7 @@ public function notify(ExecutionFinished $event): void return; } - $this->printReport($results); + echo ConsoleCoverageRenderer::render($results, $this->consoleOutput); if ($this->outputFile !== null || $this->githubSummaryPath !== null) { $this->writeMarkdownReport($results); @@ -122,39 +125,6 @@ private function computeAllResults(): array return $results; } - /** - * @param array $results - */ - private function printReport(array $results): void - { - echo "\n\n"; - echo "OpenAPI Contract Test Coverage\n"; - echo str_repeat('=', 50) . "\n"; - - foreach ($results as $spec => $result) { - $percentage = self::percentage($result['coveredCount'], $result['total']); - - echo "\n[{$spec}] {$result['coveredCount']}/{$result['total']} endpoints ({$percentage}%)\n"; - echo str_repeat('-', 50) . "\n"; - - if ($result['covered'] !== []) { - echo "Covered:\n"; - - foreach ($result['covered'] as $endpoint) { - echo " ✓ {$endpoint}\n"; - } - } - - $uncoveredCount = $result['total'] - $result['coveredCount']; - - if ($uncoveredCount > 0) { - echo "Uncovered: {$uncoveredCount} endpoints\n"; - } - } - - echo "\n"; - } - /** * @param array $results */ @@ -178,11 +148,6 @@ private function writeMarkdownReport(array $results): void } } } - - private static function percentage(int $covered, int $total): float|int - { - return $total > 0 ? round($covered / $total * 100, 1) : 0; - } }); } } diff --git a/tests/Unit/ConsoleCoverageRendererTest.php b/tests/Unit/ConsoleCoverageRendererTest.php new file mode 100644 index 0000000..6967c0a --- /dev/null +++ b/tests/Unit/ConsoleCoverageRendererTest.php @@ -0,0 +1,299 @@ +assertSame('', ConsoleCoverageRenderer::render([])); + } + + #[Test] + public function render_default_mode_shows_covered_list_and_uncovered_count(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('OpenAPI Contract Test Coverage', $output); + $this->assertStringContainsString('[front] 2/4 endpoints (50%)', $output); + $this->assertStringContainsString('Covered:', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringContainsString(' ✓ POST /v1/pets', $output); + $this->assertStringContainsString('Uncovered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✗', $output); + } + + #[Test] + public function render_all_mode_shows_both_covered_and_uncovered_lists(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('Covered:', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringContainsString(' ✓ POST /v1/pets', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ DELETE /v1/pets/{petId}', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets/{petId}', $output); + $this->assertStringNotContainsString('Uncovered: 2 endpoints', $output); + } + + #[Test] + public function render_uncovered_only_mode_shows_uncovered_list_and_covered_count(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('Covered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✓', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ DELETE /v1/pets/{petId}', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets/{petId}', $output); + } + + #[Test] + public function render_full_coverage_default_mode_shows_no_uncovered_section(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 2/2 endpoints (100%)', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_full_coverage_all_mode_shows_no_uncovered_section(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_zero_coverage_default_mode_shows_uncovered_count(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered:', $output); + $this->assertStringContainsString('Uncovered: 2 endpoints', $output); + } + + #[Test] + public function render_zero_coverage_uncovered_only_mode_shows_uncovered_list(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered:', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets', $output); + $this->assertStringContainsString(' ✗ POST /v1/pets', $output); + } + + #[Test] + public function render_multiple_specs(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 1, + ], + 'admin' => [ + 'covered' => ['GET /v1/users'], + 'uncovered' => [], + 'total' => 1, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 1/2 endpoints (50%)', $output); + $this->assertStringContainsString('[admin] 1/1 endpoints (100%)', $output); + } + + #[Test] + public function render_defaults_to_default_mode(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 1, + ], + ]; + + $withExplicitDefault = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + $withImplicitDefault = ConsoleCoverageRenderer::render($results); + + $this->assertSame($withExplicitDefault, $withImplicitDefault); + } + + #[Test] + public function render_header_and_separators_are_present(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => [], + 'total' => 1, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results); + + $this->assertStringContainsString('OpenAPI Contract Test Coverage', $output); + $this->assertStringContainsString('==================================================', $output); + $this->assertStringContainsString('--------------------------------------------------', $output); + } + + #[Test] + public function render_zero_coverage_all_mode_shows_uncovered_list_only(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets', $output); + } + + #[Test] + public function render_full_coverage_uncovered_only_mode_shows_covered_count_only(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('Covered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✓', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_percentage_rounds_to_one_decimal_place(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets', 'DELETE /v1/pets/{petId}'], + 'total' => 3, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results); + + $this->assertStringContainsString('[front] 1/3 endpoints (33.3%)', $output); + } + + #[Test] + public function render_spec_with_zero_endpoints(): void + { + $results = [ + 'empty' => [ + 'covered' => [], + 'uncovered' => [], + 'total' => 0, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('[empty] 0/0 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } +} diff --git a/tests/Unit/ConsoleOutputTest.php b/tests/Unit/ConsoleOutputTest.php new file mode 100644 index 0000000..6883157 --- /dev/null +++ b/tests/Unit/ConsoleOutputTest.php @@ -0,0 +1,135 @@ +assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_returns_default_when_parameter_is_empty_string(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('')); + } + + #[Test] + public function resolve_returns_default_when_parameter_is_whitespace(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve(' ')); + } + + #[Test] + public function resolve_returns_all_from_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_returns_uncovered_only_from_parameter(): void + { + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('uncovered_only')); + } + + #[Test] + public function resolve_returns_default_from_parameter(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('default')); + } + + #[Test] + public function resolve_is_case_insensitive_for_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('ALL')); + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('All')); + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('UNCOVERED_ONLY')); + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('Uncovered_Only')); + } + + #[Test] + public function resolve_trims_whitespace_from_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(' all ')); + } + + #[Test] + public function resolve_returns_default_for_invalid_parameter(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('invalid')); + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('covered_only')); + } + + #[Test] + public function resolve_env_overrides_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=uncovered_only'); + + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_env_is_case_insensitive(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=ALL'); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_env_trims_whitespace(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT= all '); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_invalid_env_falls_back_to_default(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=invalid'); + + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_empty_env_uses_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT='); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_whitespace_only_env_uses_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT= '); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } +}