From 1be70852c456e601a4181db25d30e52bb40ef967 Mon Sep 17 00:00:00 2001 From: sidux Date: Tue, 3 Feb 2026 14:30:57 +0100 Subject: [PATCH 1/4] feat(phpunit): remove dependency on internal classes, support paratest --- composer.json | 7 +- config/constants.php | 5 - config/phpstan.neon | 9 +- src/Command/ExecutePlanCommand.php | 321 +++++++++--- src/Config/Loader/PlanConfigLoader.php | 27 +- .../Loader/OpenApiDefinitionLoader.php | 6 +- src/Runner/PHPUnit/AbstractPhpUnitRunner.php | 215 ++++++++ src/Runner/PHPUnit/PhpUnitRunner.php | 18 + src/Runner/ParaTest/ParaTestRunner.php | 32 ++ src/Runner/TestRunner.php | 33 ++ src/Test/Plan.php | 459 ++++++++---------- src/Test/Suite.php | 261 ---------- src/Test/TestCase.php | 54 +-- src/Util/Assert.php | 5 - src/Util/Normalizer/PsrRequestNormalizer.php | 8 + src/Util/Normalizer/PsrResponseNormalizer.php | 8 + src/Util/Object_.php | 2 +- src/Util/TestCase/Printer/DefaultPrinter.php | 26 - src/Util/TestCase/Printer/TestDoxPrinter.php | 22 - src/api-tester | 13 +- tests/Test/PlanTest.php | 6 +- 21 files changed, 799 insertions(+), 738 deletions(-) delete mode 100644 config/constants.php create mode 100644 src/Runner/PHPUnit/AbstractPhpUnitRunner.php create mode 100644 src/Runner/PHPUnit/PhpUnitRunner.php create mode 100644 src/Runner/ParaTest/ParaTestRunner.php create mode 100644 src/Runner/TestRunner.php delete mode 100644 src/Test/Suite.php delete mode 100644 src/Util/TestCase/Printer/DefaultPrinter.php delete mode 100644 src/Util/TestCase/Printer/TestDoxPrinter.php diff --git a/composer.json b/composer.json index c822a4a5..a7e2da57 100644 --- a/composer.json +++ b/composer.json @@ -31,10 +31,7 @@ "psr-4": { "APITester\\": "src/", "APITester\\Symfony\\Component\\PropertyInfo\\": "lib/property-info/" - }, - "files": [ - "config/constants.php" - ] + } }, "autoload-dev": { "psr-4": { @@ -70,6 +67,7 @@ "symfony/finder": "^5.0 || ^6.0 || ^7.0", "symfony/http-client": "^5.0 || ^6.0 || ^7.0", "symfony/http-kernel": "^5.0 || ^6.0 || ^7.0", + "symfony/process": "^5.0 || ^6.0 || ^7.0", "symfony/property-access": "^5.0 || ^6.0 || ^7.0", "symfony/property-info": "^5.0 || ^6.0 || ^7.0", "symfony/psr-http-message-bridge": "^1.2 || ^2.1.2 || ^6.0 || ^7.0", @@ -77,6 +75,7 @@ "symfony/yaml": "^5.0 || ^6.0 || ^7.0" }, "require-dev": { + "brianium/paratest": "^6.0", "ergebnis/composer-normalize": "^2.25", "ergebnis/phpstan-rules": "^1.0", "korbeil/phpstan-generic-rules": "^1.0", diff --git a/config/constants.php b/config/constants.php deleted file mode 100644 index 11e32fe8..00000000 --- a/config/constants.php +++ /dev/null @@ -1,5 +0,0 @@ - + */ + private array $passThroughOptionNames = []; + private InputInterface $input; private OutputInterface $output; @@ -34,10 +42,6 @@ protected function initialize(InputInterface $input, OutputInterface $output): v } /** - * @throws DefinitionLoaderNotFoundException - * @throws DefinitionLoadingException - * @throws InvalidPreparatorConfigException - * @throws RequesterNotFoundException * @throws ConfigurationException * @throws SuiteNotFoundException */ @@ -46,13 +50,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->printInfo(); $this->validateOptions(); - $testPlan = $this->initPlan(); + $configPath = (string) $this->input->getOption('config'); + $suiteName = (string) $this->input->getOption('suite'); + $processes = $this->getProcesses(); - return (int) !$testPlan->execute( - Config\Loader\PlanConfigLoader::load((string) $this->input->getOption('config')), - (string) $this->input->getOption('suite'), - $input->getOptions() - ); + $setBaseline = $this->input->getOption('set-baseline') !== false; + $needsBaselineUpdate = $setBaseline + || $this->input->getOption('update-baseline') !== false; + if ($needsBaselineUpdate && $processes > 1) { + throw new \InvalidArgumentException( + 'Baseline update is not supported with --processes>1. Run without --processes.' + ); + } + + $planConfig = Config\Loader\PlanConfigLoader::load($configPath); + $plan = new Plan(); + $plan->setLogger(new ConsoleLogger($this->output)); + $suiteConfig = $plan->getSuiteConfig($planConfig, $suiteName); + + $runner = $this->createRunner($processes); + $runnerOptions = $this->buildRunnerOptions(); + $testFile = $runner->createRunnerFile($suiteConfig, $configPath, $suiteName, $runnerOptions); + + if ($setBaseline) { + $baselineFile = $suiteConfig->getFilters()->getBaseline(); + if (file_exists($baselineFile)) { + unlink($baselineFile); + } + } + + $passThroughOptions = $this->getPassThroughOptions(); + $junitFile = $this->resolveJUnitFile($needsBaselineUpdate); + $isAutoJunit = $junitFile !== null && !is_string($this->input->getOption('log-junit')); + if ($junitFile !== null) { + $passThroughOptions['log-junit'] = $junitFile; + } + + $exitCode = $runner->run($passThroughOptions, $suiteConfig, $this->output->write(...), $testFile); + + if ($needsBaselineUpdate && $junitFile !== null) { + $this->updateBaseline($suiteConfig, $junitFile); + } + + if ($isAutoJunit) { + unlink($junitFile); + } + + $runner->cleanupRunnerFile($testFile); + + return $exitCode; } protected function configure(): void @@ -72,40 +118,11 @@ protected function configure(): void 'suite name to run', ) ->addOption( - 'testdox', - null, - InputOption::VALUE_NONE, - 'testdox print format' - ) - ->addOption( - 'coverage-php', - null, - InputOption::VALUE_OPTIONAL, - 'coverage export to php format' - ) - ->addOption( - 'coverage-clover', - null, - InputOption::VALUE_OPTIONAL, - 'coverage export to clover format' - ) - ->addOption( - 'coverage-html', - null, + 'processes', + 'p', InputOption::VALUE_OPTIONAL, - 'coverage export to html format' - ) - ->addOption( - 'coverage-text', - null, - InputOption::VALUE_OPTIONAL, - 'coverage export to html format' - ) - ->addOption( - 'coverage-cobertura', - null, - InputOption::VALUE_OPTIONAL, - 'coverage export to html format' + 'Run tests in parallel using ParaTest (requires brianium/paratest).', + 1 ) ->addOption( 'set-baseline', @@ -132,62 +149,103 @@ protected function configure(): void 'only execute tests from the baseline' ) ->addOption( - 'filter', + 'part', null, InputOption::VALUE_OPTIONAL, - 'Filter which tests to run' + 'Partition tests into groups and run only one of them, ex: --part=1/3' ) ->addOption( + 'operation-id', + null, + InputOption::VALUE_OPTIONAL, + 'takes an operation-id to load from api definition' + ) + ; + + $this + ->addPassThroughOption('testdox', null, InputOption::VALUE_NONE, 'testdox print format') + ->addPassThroughOption('coverage-php', null, InputOption::VALUE_OPTIONAL, 'coverage export to php format') + ->addPassThroughOption( + 'coverage-clover', + null, + InputOption::VALUE_OPTIONAL, + 'coverage export to clover format' + ) + ->addPassThroughOption('coverage-html', null, InputOption::VALUE_OPTIONAL, 'coverage export to html format') + ->addPassThroughOption('coverage-text', null, InputOption::VALUE_OPTIONAL, 'coverage export to text format') + ->addPassThroughOption( + 'coverage-cobertura', + null, + InputOption::VALUE_OPTIONAL, + 'coverage export to cobertura format' + ) + ->addPassThroughOption('filter', null, InputOption::VALUE_OPTIONAL, 'Filter which tests to run') + ->addPassThroughOption( 'log-junit', null, InputOption::VALUE_OPTIONAL, 'Log test execution in JUnit XML format to file' ) - ->addOption( + ->addPassThroughOption( 'log-teamcity', null, InputOption::VALUE_OPTIONAL, - 'Log test execution in JUnit XML format to file' + 'Log test execution in TeamCity format to file' ) - ->addOption( + ->addPassThroughOption( 'testdox-html', null, InputOption::VALUE_OPTIONAL, 'Write agile documentation in HTML format to file' ) - ->addOption( + ->addPassThroughOption( 'testdox-text', null, InputOption::VALUE_OPTIONAL, 'Write agile documentation in Text format to file' ) - ->addOption( + ->addPassThroughOption( 'testdox-xml', null, InputOption::VALUE_OPTIONAL, 'Write agile documentation in XML format to file' ) - ->addOption( - 'part', - null, - InputOption::VALUE_OPTIONAL, - 'Partition tests into groups and run only one of them, ex: --part=1/3' - ) - ->addOption( - 'operation-id', - null, - InputOption::VALUE_OPTIONAL, - 'takes an operation-id to load from api definition' - ) ; } - private function initPlan(): Plan + private function addPassThroughOption( + string $name, + ?string $shortcut, + ?int $mode, + string $description, + mixed $default = null + ): static { + $this->passThroughOptionNames[] = $name; + $this->addOption($name, $shortcut, $mode, $description, $default); + + return $this; + } + + /** + * @return array + */ + private function getPassThroughOptions(): array + { + $options = []; + foreach ($this->passThroughOptionNames as $name) { + $options[$name] = $this->input->getOption($name); + } + + return $options; + } + + private function createRunner(int $processes): TestRunner { - $testPlan = new Plan(); - $testPlan->setLogger(new ConsoleLogger($this->output)); + if ($processes > 1) { + return new ParaTestRunner($processes); + } - return $testPlan; + return new PhpUnitRunner(); } private function printInfo(): void @@ -208,14 +266,131 @@ private function printInfo(): void private function validateOptions(): void { + $this->getProcesses(); + if ($this->input->getOption('part') !== null) { $part = explode('/', (string) $this->input->getOption('part')); - if (\count($part) !== 2) { + if (\count($part) !== 2 || $part[0] > $part[1] || $part[1] <= 0) { throw new \InvalidArgumentException('The part option must be in the format x/y where y > 0 and x <= y'); } - if ($part[0] > $part[1] || $part[1] <= 0) { - throw new \InvalidArgumentException('The part option must be in the format x/y where y > 0 and x <= y'); + } + } + + private function getProcesses(): int + { + $processes = filter_var( + $this->input->getOption('processes'), + FILTER_VALIDATE_INT, + [ + 'options' => [ + 'min_range' => 1, + ], + ] + ); + if ($processes === false) { + throw new \InvalidArgumentException('The processes option must be >= 1'); + } + + return $processes; + } + + private function resolveJUnitFile(bool $needsBaselineUpdate): ?string + { + $junit = $this->input->getOption('log-junit'); + if (is_string($junit) && $junit !== '') { + return $junit; + } + + if (!$needsBaselineUpdate) { + return null; + } + + $file = tempnam(sys_get_temp_dir(), 'api-tester-'); + if ($file === false) { + throw new \RuntimeException('Could not create a temporary JUnit file.'); + } + + return $file; + } + + /** + * @return array + */ + private function buildRunnerOptions(): array + { + $runnerOptions = []; + $part = $this->input->getOption('part'); + if ($part !== null) { + $runnerOptions['part'] = (string) $part; + } + $operationId = $this->input->getOption('operation-id'); + if ($operationId !== null) { + $runnerOptions['operation-id'] = (string) $operationId; + } + if ($this->input->getOption('ignore-baseline') !== false) { + $runnerOptions['ignore-baseline'] = true; + } + if ($this->input->getOption('only-baseline') !== false) { + $runnerOptions['only-baseline'] = true; + $runnerOptions['ignore-baseline'] = true; + } + + return $runnerOptions; + } + + private function updateBaseline(Config\Suite $suiteConfig, string $junitFile): void + { + if (!is_file($junitFile)) { + $this->output->writeln("JUnit file not found: {$junitFile}"); + + return; + } + + $failed = $this->extractFailedTestCaseNames($junitFile); + $exclude = array_map(static fn (string $name): array => [ + 'testcase.name' => $name, + ], $failed); + + $suiteConfig->getFilters()->writeBaseline($exclude); + } + + /** + * @return list + */ + private function extractFailedTestCaseNames(string $junitFile): array + { + $doc = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $doc->load($junitFile); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ($loaded !== true) { + return []; + } + + $failed = []; + + foreach ($doc->getElementsByTagName('testcase') as $testcase) { + if (!$testcase instanceof \DOMElement) { + continue; + } + + $name = $testcase->getAttribute('name'); + $pattern = '/^testApi with data set "(?.*)"$/'; + if (preg_match($pattern, $name, $m) !== 1) { + continue; } + + $hasFailure = $testcase->getElementsByTagName('failure')->length > 0 + || $testcase->getElementsByTagName('error')->length > 0; + if (!$hasFailure) { + continue; + } + + $failed[] = $m['name']; } + + /** @var list */ + return array_values(array_unique($failed)); } } diff --git a/src/Config/Loader/PlanConfigLoader.php b/src/Config/Loader/PlanConfigLoader.php index 0a510d60..cd204589 100644 --- a/src/Config/Loader/PlanConfigLoader.php +++ b/src/Config/Loader/PlanConfigLoader.php @@ -6,6 +6,7 @@ use APITester\Config\Exception\ConfigurationException; use APITester\Config\Plan; +use APITester\Util\Path; use APITester\Util\Yaml; use Symfony\Component\Dotenv\Dotenv; @@ -31,20 +32,26 @@ public static function load(string $path): Plan private static function process(string $content): string { $dotenv = new Dotenv(); - $dotenv->loadEnv(PROJECT_DIR . '/env/.env'); - $patterns = []; - $replacements = []; - if (preg_match_all('/%env\((.+?)\)%/i', $content, $matches) > 0) { - foreach ($matches[1] as $var) { + + $envFile = Path::getBasePath() . '/env/.env'; + if (is_file($envFile)) { + $dotenv->loadEnv($envFile); + } + + $processed = preg_replace_callback( + '/%env\((.+?)\)%/i', + static function (array $matches): string { + $var = $matches[1]; $env = $_ENV[$var] ?? null; if ($env === null) { throw new ConfigurationException("Environment variable '{$var}' is not defined."); } - $patterns[] = "/%env\\({$var}\\)%/i"; - $replacements[] = $env; - } - } - return (string) preg_replace($patterns, $replacements, $content); + return (string) $env; + }, + $content + ); + + return $processed ?? $content; } } diff --git a/src/Definition/Loader/OpenApiDefinitionLoader.php b/src/Definition/Loader/OpenApiDefinitionLoader.php index 8aff352f..09569375 100644 --- a/src/Definition/Loader/OpenApiDefinitionLoader.php +++ b/src/Definition/Loader/OpenApiDefinitionLoader.php @@ -117,7 +117,11 @@ private function getOperations(array $paths, array $securitySchemes, array $filt /** @var RequestBody $requestBody */ $requestBody = $operation->requestBody; $responses = $operation->responses; - $requirements = $this->getSecurityRequirementsScopes($operation->security ?? []); + $security = $operation->security ?? []; + if ($security instanceof \cebe\openapi\spec\SecurityRequirements) { + $security = $security->getRequirements() ?? []; + } + $requirements = $this->getSecurityRequirementsScopes($security); $operations->add( Operation::create( diff --git a/src/Runner/PHPUnit/AbstractPhpUnitRunner.php b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php new file mode 100644 index 00000000..db52fb7c --- /dev/null +++ b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php @@ -0,0 +1,215 @@ + $runnerOptions + */ + final public function createRunnerFile( + Config\Suite $suiteConfig, + string $configPath, + string $suiteName, + array $runnerOptions + ): string { + $testCaseClass = Object_::validateClass( + ltrim($suiteConfig->getTestCaseClass(), '\\'), + PhpUnitTestCase::class + ); + + $dir = sys_get_temp_dir() . '/api-tester-' . bin2hex(random_bytes(8)); + if (!mkdir($dir) && !is_dir($dir)) { + throw new \RuntimeException('Could not create a temporary directory for the runner file.'); + } + $file = $dir . '/ApiTesterRunnerTest.php'; + + $parent = '\\' . $testCaseClass; + $configExport = var_export($configPath, true); + $suiteExport = var_export($suiteName, true); + $optionsExport = var_export($runnerOptions, true); + + $content = <<<'PHP' + + */ + public static function apiTestCases(): iterable + { + $config = PlanConfigLoader::load(self::CONFIG_PATH); + $plan = new Plan(); + + foreach ($plan->getTestCases($config, self::SUITE_NAME, self::OPTIONS) as $testCase) { + yield $testCase->getName() => [$testCase]; + } + } + + /** + * @param array $data + * @param int|string $dataName + */ + public function __construct(?string $name = null, array $data = [], $dataName = '') + { + if ($name !== null && $data === [] && $dataName === '') { + $pattern = '/^(?[^ ]+) with data set "(?.*)"$/'; + if (preg_match($pattern, $name, $m) === 1) { + $name = $m['method']; + $dataName = $m['dataName']; + $data = [null]; + } + } + + parent::__construct($name, $data, $dataName); + } + + /** + * @dataProvider apiTestCases + */ + public function testApi(TestCase $testCase): void + { + $kernel = null; + if (method_exists($this, 'getKernel')) { + $candidate = $this->getKernel(); + if ($candidate instanceof HttpKernelInterface) { + $kernel = $candidate; + } + } + + $testCase->test($kernel); + } + } + PHP; + + $content = str_replace( + ['__APITESTER_PARENT__', '__APITESTER_CONFIG__', '__APITESTER_SUITE__', '__APITESTER_OPTIONS__'], + [$parent, $configExport, $suiteExport, $optionsExport], + $content + ); + + file_put_contents($file, ltrim($content)); + + return $file; + } + + final public function cleanupRunnerFile(string $testFile): void + { + if (is_file($testFile)) { + unlink($testFile); + } + + $dir = \dirname($testFile); + if (is_dir($dir) && str_starts_with(basename($dir), 'api-tester-')) { + rmdir($dir); + } + } + + /** + * @param array $passThroughOptions + * @param callable(string): void $writeOutput + */ + final public function run( + array $passThroughOptions, + Config\Suite $suiteConfig, + callable $writeOutput, + string $testFile + ): int { + $binary = $this->findBinary(); + + if (!is_file($testFile)) { + throw new \RuntimeException("APITester runner file not found at '{$testFile}'."); + } + + $arguments = $this->buildArguments($passThroughOptions, $suiteConfig); + + $command = array_merge( + [PHP_BINARY, $binary], + $this->getRunnerSpecificArgs($testFile), + $arguments, + [$testFile] + ); + + $process = new Process($command, Path::getBasePath()); + $process->setTimeout(null); + $process->run(static fn (string $_type, string $buffer) => $writeOutput($buffer)); + + return $process->getExitCode() ?? 1; + } + + abstract protected function getBinaryName(): string; + + /** + * @return list + */ + abstract protected function getRunnerSpecificArgs(string $testFile): array; + + private function findBinary(): string + { + $basePath = Path::getBasePath(); + $name = $this->getBinaryName(); + + foreach ([$basePath . '/vendor/bin/' . $name, $basePath . '/bin/' . $name] as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + throw new \RuntimeException( + "{$name} binary not found. Looked in '{$basePath}/vendor/bin/{$name}' and '{$basePath}/bin/{$name}'." + ); + } + + /** + * @param array $options + * + * @return list + */ + private function buildArguments(array $options, Config\Suite $suiteConfig): array + { + $args = ['--colors=always']; + + $phpunitConfig = $suiteConfig->getPhpunitConfig(); + if ($phpunitConfig !== null) { + $args[] = '--configuration=' . $phpunitConfig; + } + + foreach ($options as $key => $value) { + if ($value === null || $value === false) { + continue; + } + if ($value === true) { + $args[] = "--{$key}"; + continue; + } + if (!\is_scalar($value)) { + throw new \InvalidArgumentException('Options must be scalar'); + } + + $args[] = "--{$key}={$value}"; + } + + return $args; + } +} diff --git a/src/Runner/PHPUnit/PhpUnitRunner.php b/src/Runner/PHPUnit/PhpUnitRunner.php new file mode 100644 index 00000000..013ab566 --- /dev/null +++ b/src/Runner/PHPUnit/PhpUnitRunner.php @@ -0,0 +1,18 @@ + + */ + protected function getRunnerSpecificArgs(string $testFile): array + { + return [ + '--runner=WrapperRunner', + '--processes=' . $this->processes, + '--bootstrap=' . $testFile, + ]; + } +} diff --git a/src/Runner/TestRunner.php b/src/Runner/TestRunner.php new file mode 100644 index 00000000..4f3b2e97 --- /dev/null +++ b/src/Runner/TestRunner.php @@ -0,0 +1,33 @@ + $runnerOptions + */ + public function createRunnerFile( + Config\Suite $suiteConfig, + string $configPath, + string $suiteName, + array $runnerOptions + ): string; + + public function cleanupRunnerFile(string $testFile): void; + + /** + * @param array $passThroughOptions Options forwarded to the test runner CLI + * @param callable(string): void $writeOutput + */ + public function run( + array $passThroughOptions, + Config\Suite $suiteConfig, + callable $writeOutput, + string $testFile + ): int; +} diff --git a/src/Test/Plan.php b/src/Test/Plan.php index 0d039eb3..3dc169e5 100644 --- a/src/Test/Plan.php +++ b/src/Test/Plan.php @@ -9,23 +9,20 @@ use APITester\Authenticator\Exception\AuthenticationLoadingException; use APITester\Config; use APITester\Definition\Api; +use APITester\Definition\Collection\Operations; use APITester\Definition\Collection\Tokens; use APITester\Definition\Loader\DefinitionLoader; use APITester\Definition\Loader\Exception\DefinitionLoaderNotFoundException; use APITester\Definition\Loader\Exception\DefinitionLoadingException; +use APITester\Definition\Operation; use APITester\Preparator\Exception\InvalidPreparatorConfigException; +use APITester\Preparator\Exception\PreparatorLoadingException; use APITester\Preparator\TestCasesPreparator; use APITester\Requester\Exception\RequesterNotFoundException; use APITester\Requester\Requester; use APITester\Test\Exception\SuiteNotFoundException; use APITester\Util\Object_; -use APITester\Util\TestCase\Printer\DefaultPrinter; -use APITester\Util\TestCase\Printer\TestDoxPrinter; -use PHPUnit\Framework\TestResult; -use PHPUnit\TextUI\CliArguments\Builder; -use PHPUnit\TextUI\CliArguments\Mapper; -use PHPUnit\TextUI\TestRunner; -use PHPUnit\TextUI\XmlConfiguration\Loader; +use Illuminate\Support\Collection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -33,21 +30,6 @@ final class Plan { - private const NON_PHPUNIT_OPTIONS = [ - 'config', - 'quiet', - 'ansi', - 'no-ansi', - 'no-interaction', - 'suite', - 'set-baseline', - 'update-baseline', - 'ignore-baseline', - 'only-baseline', - 'part', - 'operation-id', - ]; - private readonly Authenticator $authenticator; /** @@ -65,12 +47,7 @@ final class Plan */ private readonly array $requesters; - /** - * @var array - */ - private array $results = []; - - private readonly TestRunner $runner; + private LoggerInterface $logger; /** * @param TestCasesPreparator[] $preparators @@ -82,16 +59,13 @@ public function __construct( ?array $requesters = null, ?array $definitionLoaders = null, Authenticator $authenticator = null, - private LoggerInterface $logger = new NullLogger() + ?LoggerInterface $logger = null ) { - if (!\defined('PROJECT_DIR')) { - \define('PROJECT_DIR', \dirname(__DIR__, 2)); - } + $this->logger = $logger ?? new NullLogger(); $this->preparators = $preparators ?? Object_::getImplementations(TestCasesPreparator::class); $this->requesters = $requesters ?? Object_::getImplementationsClasses(Requester::class); $this->definitionLoaders = $definitionLoaders ?? Object_::getImplementations(DefinitionLoader::class); $this->authenticator = $authenticator ?? new Authenticator(); - $this->runner = new TestRunner(); } /** @@ -99,183 +73,68 @@ public function __construct( * * @throws DefinitionLoaderNotFoundException * @throws DefinitionLoadingException - * @throws RequesterNotFoundException * @throws InvalidPreparatorConfigException + * @throws RequesterNotFoundException * @throws SuiteNotFoundException + * + * @return TestCase[] */ - public function execute( - Config\Plan $testPlanConfig, - string $suiteName = '', - array $options = [] - ): bool { + public function getTestCases(Config\Plan $testPlanConfig, string $suiteName = '', array $options = []): array + { + $suiteConfig = $this->getSuiteConfig($testPlanConfig, $suiteName); + $bootstrap = $testPlanConfig->getBootstrap(); if ($bootstrap !== null) { require_once $bootstrap; } - $suites = $testPlanConfig->getSuites(); - $suites = $this->selectSuite($suiteName, $suites); - foreach ($suites as $suiteConfig) { - if (!empty($options['set-baseline'])) { - $this->resetBaseLine($suiteConfig); - } - $testSuite = $this->prepareSuite($suiteConfig, $options); - if (!empty($options['ignore-baseline'])) { - $testSuite->setIgnoreBaseLine(true); - } - if (!empty($options['only-baseline'])) { - $testSuite->setOnlyBaseLine(true); - $testSuite->setIgnoreBaseLine(true); - } - $this->runSuite($suiteConfig, $testSuite, $options); - if (!empty($options['update-baseline']) || !empty($options['set-baseline'])) { - $this->updateBaseLine($suiteConfig); - } - break; - } - - return $this->isSuccessful(); - } - /** - * @return array - */ - public function getResults(): array - { - return $this->results; - } + $kernel = $this->loadSymfonyKernel($suiteConfig); + $definition = $this->loadApiDefinition($suiteConfig, $options); + $requester = $this->loadRequester( + $suiteConfig->getRequester(), + $suiteConfig->getBaseUrl() ?? $definition->getUrl(), + $kernel + ); + $tokens = $this->authenticate($suiteConfig, $definition, $requester); + $preparators = $this->loadPreparators($suiteConfig->getPreparators(), $tokens); - public function setLogger(LoggerInterface $logger): void - { - $this->logger = $logger; + return $this->prepareTestCases($suiteConfig, $definition, $preparators, $requester, $options); } /** - * @param array $suites - * * @throws SuiteNotFoundException - * - * @return iterable */ - private function selectSuite(string $suiteName, array $suites): iterable + public function getSuiteConfig(Config\Plan $testPlanConfig, string $suiteName = ''): Config\Suite { + $suites = $testPlanConfig->getSuites(); if ($suiteName !== '') { + /** @var Collection $indexSuites */ $indexSuites = collect($suites) ->keyBy('name') ; - if ($indexSuites->has($suiteName)) { - $suites = $indexSuites->where('name', $suiteName); - } else { + if (!$indexSuites->has($suiteName)) { throw new SuiteNotFoundException(); } - } - return $suites; - } + /** @var Config\Suite $suite */ + $suite = $indexSuites->get($suiteName); - private function resetBaseLine(Config\Suite $suiteConfig): void - { - $baselineFile = $suiteConfig - ->getFilters() - ->getBaseline() - ; - if (file_exists($suiteConfig->getFilters()->getBaseline())) { - unlink($baselineFile); + return $suite; } - } - - /** - * @param array $options - * - * @throws DefinitionLoaderNotFoundException - * @throws DefinitionLoadingException - * @throws InvalidPreparatorConfigException - * @throws RequesterNotFoundException - * - * @return Suite<\PHPUnit\Framework\TestCase, HttpKernelInterface> - */ - private function prepareSuite(Config\Suite $suiteConfig, array $options = []): Suite - { - $testCaseClass = Object_::validateClass( - $suiteConfig->getTestCaseClass(), - \PHPUnit\Framework\TestCase::class - ); - $kernel = $this->loadSymfonyKernel($suiteConfig, $testCaseClass); - $definition = $this->loadApiDefinition($suiteConfig, $options); - $requester = $this->loadRequester( - $suiteConfig->getRequester(), - $suiteConfig->getBaseUrl() ?? $definition->getUrl(), - $kernel - ); - $tokens = $this->authenticate($suiteConfig, $definition, $requester); - $preparators = $this->loadPreparators($suiteConfig->getPreparators(), $tokens); - $testSuite = new Suite( - $suiteConfig->getName(), - $definition, - $preparators, - $requester, - $suiteConfig->getFilters(), - $this->logger, - $testCaseClass, - ); - $testSuite->setBeforeTestCaseCallbacks($suiteConfig->getBeforeTestCaseCallbacks()); - $testSuite->setAfterTestCaseCallbacks($suiteConfig->getAfterTestCaseCallbacks()); - - return $testSuite; - } - - /** - * @param Suite<\PHPUnit\Framework\TestCase, HttpKernelInterface> $testSuite - * @param array $options - */ - private function runSuite(Config\Suite $suiteConfig, Suite $testSuite, array $options): void - { - $part = $options['part'] ?? null; - $testSuite->setPart($part !== null ? (string) $part : null); - $this->results[$suiteConfig->getName()] = $this->runner->run( - $testSuite, - $this->getPhpUnitArguments($options, $suiteConfig), - [], - false - ); - restore_exception_handler(); - } - private function updateBaseLine(Config\Suite $suiteConfig): void - { - $exclude = []; - foreach ($this->results as $result) { - foreach (array_merge($result->failures(), $result->errors()) as $failure) { - /** @var TestCase|null $testCase */ - $testCase = $failure->failedTest(); - if ($testCase === null) { - continue; - } - $exclude[] = [ - 'testcase.name' => $testCase->getName(), - ]; - } + if (\count($suites) === 0) { + throw new SuiteNotFoundException(); } - $suiteConfig - ->getFilters() - ->writeBaseline($exclude) - ; + + return $suites[0]; } - private function isSuccessful(): bool + public function setLogger(LoggerInterface $logger): void { - foreach ($this->results as $suiteResult) { - if ($suiteResult->failureCount() > 0 || $suiteResult->errorCount() > 0) { - return false; - } - } - - return true; + $this->logger = $logger; } - /** - * @param class-string<\PHPUnit\Framework\TestCase> $testCaseClass - */ - private function loadSymfonyKernel(Config\Suite $suiteConfig, string $testCaseClass): ?Kernel + private function loadSymfonyKernel(Config\Suite $suiteConfig): ?Kernel { $kernel = null; if ($suiteConfig->getSymfonyKernelClass() !== null) { @@ -283,11 +142,7 @@ private function loadSymfonyKernel(Config\Suite $suiteConfig, string $testCaseCl $suiteConfig->getSymfonyKernelClass(), HttpKernelInterface::class ); - if (method_exists($testCaseClass, 'getKernel')) { - $kernel = $this->getTestCaseKernel($testCaseClass, $kernelClass); - } else { - $kernel = $this->bootSymfonyKernel($kernelClass); - } + $kernel = $this->bootSymfonyKernel($kernelClass); } return $kernel; @@ -383,56 +238,6 @@ private function loadPreparators(array $preparators, Tokens $tokens): array return $configuredPreparators; } - /** - * @param array $options - * - * @return string[] - */ - private function getPhpUnitArguments(array $options, Config\Suite $suiteConfig): array - { - $options = $this->getPhpUnitOptions($options); - $arguments = (new Builder())->fromParameters($options, []); - $arguments = (new Mapper())->mapToLegacyArray($arguments); - - $phpunitConfig = $suiteConfig->getPhpunitConfig(); - if ($phpunitConfig !== null) { - $arguments['configurationObject'] = (new Loader())->load($phpunitConfig); - } - - return $arguments; - } - - /** - * @param class-string $testCaseClass - * @param class-string $kernelClass - */ - private function getTestCaseKernel(string $testCaseClass, string $kernelClass): Kernel - { - $className = 'TestCaseKernelProvider'; - if (!class_exists('TestCaseKernelProvider')) { - $code = <<resetDatabase(); - if (method_exists(\$this, 'bootKernel')) - \$this->bootKernel(); - } - public function getTestCaseKernel() { - return \$this->getKernel(); - } - } - CODE_SAMPLE; - eval($code); - } - $className = '\\' . $className; - $kernelProvider = new $className(); - - return $kernelProvider->getTestCaseKernel(); - } - /** * @param class-string $symfonyKernelClass */ @@ -462,41 +267,167 @@ private function getConfiguredLoader(string $format): DefinitionLoader } /** - * @param array $options + * @param TestCasesPreparator[] $preparators + * @param array $options * - * @return array + * @return TestCase[] */ - private function getPhpUnitOptions(array $options): array - { - $options['colors'] = 'always'; - if (!isset($options['verbose']) || $options['verbose'] === false) { - $options['printer'] = ($options['testdox'] ?? false) === true ? TestDoxPrinter::class : DefaultPrinter::class; + private function prepareTestCases( + Config\Suite $suiteConfig, + Api $api, + array $preparators, + Requester $requester, + array $options = [] + ): array { + $ignoreBaseline = !empty($options['ignore-baseline']); + $onlyBaseline = !empty($options['only-baseline']); + $part = isset($options['part']) ? (string) $options['part'] : null; + + /** @var Collection $allTests */ + $allTests = collect(); + + foreach ($preparators as $preparator) { + $preparator->setLogger($this->logger); + $preparator->setSchemaValidationBaseline($suiteConfig->getFilters()->getSchemaValidationBaseline()); + + $operations = $api->getOperations() + ->map(static fn (Operation $op) => $op->setPreparator($preparator::getName())) + ; + + try { + $operations = $this->filterOperation($suiteConfig, $operations); + $tests = $preparator->doPrepare($operations); + + if (!$ignoreBaseline) { + $tests = $this->filterTestCases($suiteConfig, $tests); + } + if ($onlyBaseline) { + $tests = $this->filterOnlyTestCases($suiteConfig, $tests); + } + + foreach ($tests as $testCase) { + $testCase->setRequester($requester); + $testCase->setLogger($this->logger); + $testCase->setBeforeCallbacks($suiteConfig->getBeforeTestCaseCallbacks()); + $testCase->setAfterCallbacks($suiteConfig->getAfterTestCaseCallbacks()); + $testCase->setSpecification($api->getSpecification()); + $allTests->add($testCase); + } + } catch (PreparatorLoadingException $e) { + $this->logger->error($e->getMessage()); + } + } + + /** @var TestCase[] $sorted */ + $sorted = $allTests + ->sortBy(static fn (TestCase $testCase) => $testCase->getOperation() ?? '') + ->values() + ->toArray() + ; + + if ($part === null) { + return $sorted; } - $options = array_filter( - $options, - static fn ($key) => !\in_array($key, self::NON_PHPUNIT_OPTIONS, true), - ARRAY_FILTER_USE_KEY + + $filtered = []; + $total = \count($sorted); + foreach ($sorted as $index => $testCase) { + if ($this->indexInPart($part, $index, $total)) { + $filtered[] = $testCase; + } + } + + return $filtered; + } + + private function filterOperation(Config\Suite $suiteConfig, Operations $operations): Operations + { + return $operations->filter( + static fn (Operation $operation) => $suiteConfig->getFilters() + ->includes($operation) ); + } - return array_filter( - array_map( - static function (string $key, $value) { - if ($value === null) { - return null; - } - if ($value === true) { - return "--{$key}"; - } - - if (!\is_scalar($value)) { - throw new \InvalidArgumentException('Options must be scalar'); - } - - return $value !== false ? "--{$key}={$value}" : null; - }, - array_keys($options), - array_values($options), - ) + /** + * @param iterable $tests + * + * @return iterable + */ + private function filterTestCases(Config\Suite $suiteConfig, iterable $tests): iterable + { + $excludedTests = array_column( + $this->toTestCaseFilter($suiteConfig->getFilters()->getBaseLineExclude()), + 'name' + ); + + return collect($tests)->filter(static fn (TestCase $test) => !\in_array( + $test->getName(), + $excludedTests, + true + )); + } + + /** + * @param iterable $tests + * + * @return iterable + */ + private function filterOnlyTestCases(Config\Suite $suiteConfig, iterable $tests): iterable + { + $includedTests = array_column( + $this->toTestCaseFilter($suiteConfig->getFilters()->getBaseLineExclude()), + 'name' ); + + return collect($tests)->filter(static fn (TestCase $test) => \in_array( + $test->getName(), + $includedTests, + true + )); + } + + /** + * @param array> $filter + * + * @return array> + */ + private function toTestCaseFilter(array $filter): array + { + /** @var array> */ + return collect($filter) + ->map( + static fn ($value) => collect($value) + ->filter(static fn ($value, $key) => str_starts_with($key, 'testcase.')) + ->mapWithKeys( + static fn ($value, $key) => [ + str_replace('testcase.', '', $key) => $value, + ] + ) + ) + ->filter() + ->toArray() + ; + } + + private function indexInPart(?string $part, int $index, int $total): bool + { + if ($part === null) { + return true; + } + + [$partIndex, $partsCount] = explode('/', $part); + + $partIndex = (int) $partIndex; + $partsCount = (int) $partsCount; + + if ($partsCount > 0 && $index <= $total) { + $span = (int) ceil($total / $partsCount); + $from = $span * ($partIndex - 1); + $to = $span * $partIndex; + + return $from <= $index && $index < $to; + } + + return false; } } diff --git a/src/Test/Suite.php b/src/Test/Suite.php deleted file mode 100644 index 54198980..00000000 --- a/src/Test/Suite.php +++ /dev/null @@ -1,261 +0,0 @@ - $preparators - * @param class-string $testCaseClass - */ - public function __construct( - string $title, - private readonly Api $api, - private readonly array $preparators, - private Requester $requester, - private readonly Filters $filters = new Filters([], []), - private LoggerInterface $logger = new NullLogger(), - private readonly string $testCaseClass = \PHPUnit\Framework\TestCase::class - ) { - parent::__construct('', $title); - $this->title = $title; - } - - public function run(TestResult $result = null): TestResult - { - $this->prepareTestCases(); - - return parent::run($result); - } - - public function getName(): string - { - return $this->title; - } - - public function setRequester(Requester $requester): void - { - $this->requester = $requester; - } - - public function setLogger(LoggerInterface $logger): void - { - $this->logger = $logger; - } - - /** - * @param array> $filter - * - * @return array> - */ - public function toTestCaseFilter(array $filter): array - { - /** @var array> */ - return collect($filter) - ->map( - static fn ($value) => collect($value) - ->filter(static fn ($value, $key) => str_starts_with( - $key, - 'testcase.' - )) - ->mapWithKeys( - static fn ($value, $key) => [ - str_replace('testcase.', '', $key) => $value, - ] - ) - ) - ->filter() - ->toArray() - ; - } - - /** - * @param \Closure[] $callbacks - */ - public function setBeforeTestCaseCallbacks(array $callbacks): void - { - $this->beforeTestCaseCallbacks = $callbacks; - } - - /** - * @param \Closure[] $callbacks - */ - public function setAfterTestCaseCallbacks(array $callbacks): void - { - $this->afterTestCaseCallbacks = $callbacks; - } - - public function setIgnoreBaseLine(bool $ignoreBaseLine): void - { - $this->ignoreBaseLine = $ignoreBaseLine; - } - - public function setOnlyBaseLine(bool $onlyBaseLine): void - { - $this->onlyBaseLine = $onlyBaseLine; - } - - public function setPart(?string $part): void - { - $this->part = $part; - } - - private function prepareTestCases(): void - { - /** @var Collection $allTests */ - $allTests = collect(); - foreach ($this->preparators as $preparator) { - $preparator->setLogger($this->logger); - $preparator->setSchemaValidationBaseline($this->filters->getSchemaValidationBaseline()); - $operations = $this->api->getOperations() - ->map( - static fn (Operation $op) => $op->setPreparator($preparator::getName()) - ) - ; - try { - $operations = $this->filterOperation($operations); - $tests = $preparator->doPrepare($operations); - if (!$this->ignoreBaseLine) { - $tests = $this->filterTestCases($tests); - } - if ($this->onlyBaseLine) { - $tests = $this->filterOnlyTestCases($tests); - } - foreach ($tests as $testCase) { - $testCase->setRequester($this->requester); - $testCase->setLogger($this->logger); - $testCase->setBeforeCallbacks($this->beforeTestCaseCallbacks); - $testCase->setAfterCallbacks($this->afterTestCaseCallbacks); - $testCase->setSpecification($this->api->getSpecification()); - $allTests->add($testCase); - } - } catch (PreparatorLoadingException $e) { - $this->logger->error($e->getMessage()); - } - } - - $allTests - ->sortBy('operation') - ->values() - ->filter(fn (TestCase $testCase, int $index) => $this->indexInPart( - $this->part, - $index, - $allTests->count(), - )) - ->each( - fn (TestCase $testCase) => $this->addTest( - $testCase->toPhpUnitTestCase( - $this->testCaseClass, - ) - ) - ) - ; - } - - private function filterOperation(Operations $operations): Operations - { - return $operations->filter(fn (Operation $operation) => $this->filters->includes($operation)); - } - - /** - * @param iterable $tests - * - * @return iterable - */ - private function filterTestCases(iterable $tests): iterable - { - $excludedTests = array_column( - $this->toTestCaseFilter($this->filters->getBaseLineExclude()), - 'name' - ); - - return collect($tests)->filter(static fn (TestCase $test) => !\in_array( - $test->getName(), - $excludedTests, - true - )); - } - - /** - * @param iterable $tests - * - * @return iterable - */ - private function filterOnlyTestCases(iterable $tests): iterable - { - $includedTests = array_column( - $this->toTestCaseFilter($this->filters->getBaseLineExclude()), - 'name' - ); - - return collect($tests)->filter(static fn (TestCase $test) => \in_array( - $test->getName(), - $includedTests, - true - )); - } - - private function indexInPart(?string $part, int $index, int $total): bool - { - if ($part === null) { - return true; - } - - [$partIndex, $partsCount] = explode('/', $part); - - $partIndex = (int) $partIndex; - $partsCount = (int) $partsCount; - - if ($partsCount > 0 && $index <= $total) { - $span = (int) ceil($total / $partsCount); - $from = $span * ($partIndex - 1); - $to = $span * $partIndex; - - return $from <= $index && $index < $to; - } - - return false; - } -} diff --git a/src/Test/TestCase.php b/src/Test/TestCase.php index 2afb8d96..5739c7ae 100644 --- a/src/Test/TestCase.php +++ b/src/Test/TestCase.php @@ -28,7 +28,6 @@ use OpenClassrooms\OpenAPIValidation\Schema\Exception\SchemaMismatch; use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -166,7 +165,7 @@ public function assert(): void ResponseExample::fromPsrResponse($this->response), $this->excludedFields ); - } catch (ExpectationFailedException $e) { + } catch (\Throwable $e) { $this->log(LogLevel::NOTICE); throw $e; } @@ -204,22 +203,6 @@ public function setRequester(Requester $requester): void $this->requester = $requester; } - /** - * @template T of \PHPUnit\Framework\TestCase - * - * @param class-string $testCaseClass - * - * @return T - */ - public function toPhpUnitTestCase(string $testCaseClass): \PHPUnit\Framework\TestCase - { - $className = '\ApiTestCase'; - $testCaseName = $this->getName(); - $this->declareTestCaseClass($className, $testCaseClass); - - return new $className($this, $testCaseName); - } - public function withRequestBody(Body $request): self { $request = $this->request->withBody(Stream::create($request->getStringExample())); @@ -339,41 +322,6 @@ private function log(string $logLevel): void $this->logger->log($logLevel, $message); } - private function declareTestCaseClass(string $name, string $parent): void - { - if (!class_exists($name)) { - $name = str_replace('\\', '', $name); - $code = <<name = \$name; - \$this->testCase = \$testCase; - } - public function getName(bool \$withDataSet = true): string - { - return \$this->name; - } - public function getMetadata(): array - { - return \$this->testCase->getMetadata(); - } - public function test(): void - { - \$kernel = null; - if (method_exists(\$this, 'getKernel')) { - \$kernel = \$this->getKernel(); - } - \$this->testCase->test(\$kernel); - } - } - CODE_SAMPLE; - eval($code); - } - } - /** * @throws InvalidResponseSchemaException */ diff --git a/src/Util/Assert.php b/src/Util/Assert.php index cf3aa0cc..99c17dc1 100644 --- a/src/Util/Assert.php +++ b/src/Util/Assert.php @@ -8,7 +8,6 @@ use APITester\Util\Normalizer\PsrRequestNormalizer; use APITester\Util\Normalizer\PsrResponseNormalizer; use PHPUnit\Framework\Assert as BaseAssert; -use PHPUnit\Framework\ExpectationFailedException; use SebastianBergmann\RecursionContext\InvalidArgumentException; use Symfony\Component\PropertyAccess\PropertyAccessorBuilder; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -33,7 +32,6 @@ final class Assert * @param array $exclude * * @throws InvalidArgumentException - * @throws ExpectationFailedException */ public static function objectsEqual( iterable|object $expected, @@ -83,9 +81,6 @@ public static function same($expected, $actual, string $message = ''): void BaseAssert::assertSame($expected, $actual, $message); } - /** - * @throws ExpectationFailedException - */ public static function true(mixed $actual, string $message = ''): void { BaseAssert::assertTrue($actual, $message); diff --git a/src/Util/Normalizer/PsrRequestNormalizer.php b/src/Util/Normalizer/PsrRequestNormalizer.php index 68c1e93b..8113a7aa 100644 --- a/src/Util/Normalizer/PsrRequestNormalizer.php +++ b/src/Util/Normalizer/PsrRequestNormalizer.php @@ -11,12 +11,17 @@ final class PsrRequestNormalizer implements NormalizerInterface { + /** + * @param array $context + */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof Request; } /** + * @param array $context + * * @return array{'method': string, * 'url': string, * 'body': string|array, @@ -51,6 +56,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $result; } + /** + * @return array + */ public function getSupportedTypes(?string $format): array { return [ diff --git a/src/Util/Normalizer/PsrResponseNormalizer.php b/src/Util/Normalizer/PsrResponseNormalizer.php index 2e3139e7..c68f7623 100644 --- a/src/Util/Normalizer/PsrResponseNormalizer.php +++ b/src/Util/Normalizer/PsrResponseNormalizer.php @@ -11,12 +11,17 @@ final class PsrResponseNormalizer implements NormalizerInterface { + /** + * @param array $context + */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof Response; } /** + * @param array $context + * * @return array{ * 'body': string|array, * 'status': int, @@ -50,6 +55,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $result; } + /** + * @return array + */ public function getSupportedTypes(?string $format): array { return [ diff --git a/src/Util/Object_.php b/src/Util/Object_.php index 128ace09..6f97cef7 100644 --- a/src/Util/Object_.php +++ b/src/Util/Object_.php @@ -82,7 +82,7 @@ public static function getImplementations(string $interface): array public static function getSubTypesOf(string $interface): array { $finder = new Finder(); - $finder->in(PROJECT_DIR . '/src') + $finder->in(\dirname(__DIR__)) ->files() ->name('*.php') ; diff --git a/src/Util/TestCase/Printer/DefaultPrinter.php b/src/Util/TestCase/Printer/DefaultPrinter.php deleted file mode 100644 index ef7eed44..00000000 --- a/src/Util/TestCase/Printer/DefaultPrinter.php +++ /dev/null @@ -1,26 +0,0 @@ -write( - sprintf( - "\n%d) %s\n", - $count, - str_replace('ApiTestCase::', '', $defect->getTestName()) - ) - ); - } -} diff --git a/src/Util/TestCase/Printer/TestDoxPrinter.php b/src/Util/TestCase/Printer/TestDoxPrinter.php deleted file mode 100644 index 6006fb1f..00000000 --- a/src/Util/TestCase/Printer/TestDoxPrinter.php +++ /dev/null @@ -1,22 +0,0 @@ -getName() : ''; - } -} diff --git a/src/api-tester b/src/api-tester index b5819408..98d8593c 100755 --- a/src/api-tester +++ b/src/api-tester @@ -8,17 +8,24 @@ $autoload = [ __DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../autoload.php', ]; + +$loaded = false; foreach ($autoload as $file) { if (is_file($file)) { - require $file; + require_once $file; + $loaded = true; + break; } } +if (!$loaded) { + fwrite(STDERR, "Could not find Composer autoload.php.\n"); + exit(1); +} + use APITester\Command\ExecutePlanCommand; -use PHPUnit\Util\TestDox\CliTestDoxPrinter; use Symfony\Component\Console\Application; -new CliTestDoxPrinter(); //hack should find a solution to autoload $application = new Application('api-tester', '0.1'); $application->add(new ExecutePlanCommand()); $application->setDefaultCommand('launch', true); diff --git a/tests/Test/PlanTest.php b/tests/Test/PlanTest.php index 68b35588..605c71bd 100644 --- a/tests/Test/PlanTest.php +++ b/tests/Test/PlanTest.php @@ -33,12 +33,14 @@ public function testPetStore(): void { $this->expectException(SuiteNotFoundException::class); $config = PlanConfigLoader::load(FixturesLocation::CONFIG_OPENAPI); - $this->testPlan->execute($config, 'petstore'); + $this->testPlan->getSuiteConfig($config, 'petstore'); } public function testOC(): void { $config = PlanConfigLoader::load(FixturesLocation::CONFIG_OPENAPI); - $this->testPlan->execute($config, 'oc'); + + $suite = $this->testPlan->getSuiteConfig($config, 'oc'); + self::assertSame('oc', $suite->getName()); } } From b8fad46e15fe606571d199056ebc9e200563c184 Mon Sep 17 00:00:00 2001 From: sidux Date: Wed, 4 Feb 2026 15:21:33 +0100 Subject: [PATCH 2/4] feat(devenv): add sdz link commands --- devenv.nix | 12 ++++++++++++ src/Runner/PHPUnit/AbstractPhpUnitRunner.php | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/devenv.nix b/devenv.nix index 9a4c1352..a0be3e15 100644 --- a/devenv.nix +++ b/devenv.nix @@ -17,4 +17,16 @@ date.timezone = "Europe/Paris" ''; }; + + scripts.sdz-link.exec = '' + cd ../SdZv4 || exit 1 + composer config repositories.api-tester-local '{"type": "path", "url": "../APITester", "options": {"symlink": true}}' --json + composer update openclassrooms/api-tester --no-interaction --ignore-platform-reqs + ''; + + scripts.sdz-unlink.exec = '' + cd ../SdZv4 || exit 1 + composer config --unset repositories.api-tester-local + composer update openclassrooms/api-tester --no-interaction --ignore-platform-reqs + ''; } diff --git a/src/Runner/PHPUnit/AbstractPhpUnitRunner.php b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php index db52fb7c..45a32668 100644 --- a/src/Runner/PHPUnit/AbstractPhpUnitRunner.php +++ b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php @@ -27,8 +27,8 @@ final public function createRunnerFile( PhpUnitTestCase::class ); - $dir = sys_get_temp_dir() . '/api-tester-' . bin2hex(random_bytes(8)); - if (!mkdir($dir) && !is_dir($dir)) { + $dir = sys_get_temp_dir() . '/api-tester-runner'; + if (!is_dir($dir) && !mkdir($dir)) { throw new \RuntimeException('Could not create a temporary directory for the runner file.'); } $file = $dir . '/ApiTesterRunnerTest.php'; From d501a2a2461c584e15c7e0dd145deb5564857ad4 Mon Sep 17 00:00:00 2001 From: sidux Date: Wed, 4 Feb 2026 16:16:00 +0100 Subject: [PATCH 3/4] fix(requester): fix -vvv log, simplify requester url resolve --- src/Command/ExecutePlanCommand.php | 12 ++++++---- src/Requester/HttpAsyncRequester.php | 20 ++++------------ src/Requester/HttpDumpRequester.php | 5 +++- src/Requester/Requester.php | 23 +++++++++++++++++- src/Requester/SymfonyKernelRequester.php | 25 ++++++-------------- src/Runner/PHPUnit/AbstractPhpUnitRunner.php | 7 +++++- src/Test/TestCase.php | 4 ++-- 7 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/Command/ExecutePlanCommand.php b/src/Command/ExecutePlanCommand.php index ff6b231d..64ac7d59 100644 --- a/src/Command/ExecutePlanCommand.php +++ b/src/Command/ExecutePlanCommand.php @@ -73,7 +73,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $testFile = $runner->createRunnerFile($suiteConfig, $configPath, $suiteName, $runnerOptions); if ($setBaseline) { - $baselineFile = $suiteConfig->getFilters()->getBaseline(); + $baselineFile = $suiteConfig->getFilters() + ->getBaseline(); if (file_exists($baselineFile)) { unlink($baselineFile); } @@ -334,6 +335,7 @@ private function buildRunnerOptions(): array $runnerOptions['only-baseline'] = true; $runnerOptions['ignore-baseline'] = true; } + $runnerOptions['verbosity'] = $this->output->getVerbosity(); return $runnerOptions; } @@ -351,7 +353,8 @@ private function updateBaseline(Config\Suite $suiteConfig, string $junitFile): v 'testcase.name' => $name, ], $failed); - $suiteConfig->getFilters()->writeBaseline($exclude); + $suiteConfig->getFilters() + ->writeBaseline($exclude); } /** @@ -381,8 +384,9 @@ private function extractFailedTestCaseNames(string $junitFile): array continue; } - $hasFailure = $testcase->getElementsByTagName('failure')->length > 0 - || $testcase->getElementsByTagName('error')->length > 0; + $failures = $testcase->getElementsByTagName('failure'); + $errors = $testcase->getElementsByTagName('error'); + $hasFailure = $failures->length > 0 || $errors->length > 0; if (!$hasFailure) { continue; } diff --git a/src/Requester/HttpAsyncRequester.php b/src/Requester/HttpAsyncRequester.php index 1764bbf3..4592c10a 100644 --- a/src/Requester/HttpAsyncRequester.php +++ b/src/Requester/HttpAsyncRequester.php @@ -4,15 +4,12 @@ namespace APITester\Requester; -use Nyholm\Psr7\Uri; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpClient\HttplugClient; final class HttpAsyncRequester extends Requester { - private string $baseUri; - /** * @var ResponseInterface[] */ @@ -27,7 +24,7 @@ final class HttpAsyncRequester extends Requester public function __construct(string $baseUri = '') { - $this->baseUri = rtrim($baseUri, '/'); + $this->setBaseUri($baseUri); } public static function getName(): string @@ -38,10 +35,13 @@ public static function getName(): string /** * @inheritDoc */ - public function request(RequestInterface $request, string $id): void + public function request(RequestInterface $request, string $id): RequestInterface { + $request = $this->resolveUri($request); $this->launched = false; $this->requests[$id] = $request; + + return $request; } public function getResponse(string $id): ResponseInterface @@ -54,20 +54,10 @@ public function getResponse(string $id): ResponseInterface return $this->responses[$id]; } - public function setBaseUri(string $baseUri): void - { - $this->baseUri = $baseUri; - } - private function call(): void { $httpClient = new HttplugClient(); foreach ($this->requests as $id => $request) { - $request = $request->withUri( - str_contains((string) $request->getUri(), 'https://') ? $request->getUri() : new Uri( - trim($this->baseUri, '/') . '/' . trim((string) $request->getUri(), '/') - ) - ); try { $httpClient ->sendAsyncRequest($request) diff --git a/src/Requester/HttpDumpRequester.php b/src/Requester/HttpDumpRequester.php index 4cdb68c8..08b27c42 100644 --- a/src/Requester/HttpDumpRequester.php +++ b/src/Requester/HttpDumpRequester.php @@ -16,14 +16,17 @@ final class HttpDumpRequester extends Requester */ private array $responses = []; - public function request(RequestInterface $request, string $id): void + public function request(RequestInterface $request, string $id): RequestInterface { + $request = $this->resolveUri($request); $httpDump = $this->requestToHttp($request, $id); echo "\n" . $httpDump . "\n"; $response = new Response($request->getMethod() === 'POST' ? 201 : 200); $this->responses[$id] = $response; + + return $request; } public function getResponse(string $id): ResponseInterface diff --git a/src/Requester/Requester.php b/src/Requester/Requester.php index 1de165a3..997e67b3 100644 --- a/src/Requester/Requester.php +++ b/src/Requester/Requester.php @@ -17,15 +17,36 @@ abstract class Requester */ private array $vars = []; + private string $baseUri = ''; + abstract public static function getName(): string; /** * @throws ClientExceptionInterface */ - abstract public function request(RequestInterface $request, string $id): void; + abstract public function request(RequestInterface $request, string $id): RequestInterface; abstract public function getResponse(string $id): ResponseInterface; + final public function getBaseUri(): string + { + return $this->baseUri; + } + + final public function setBaseUri(string $baseUri): void + { + $this->baseUri = rtrim($baseUri, '/'); + } + + final public function resolveUri(RequestInterface $request): RequestInterface + { + if ($this->baseUri !== '' && !str_starts_with((string) $request->getUri(), 'http')) { + return $request->withUri(new Uri($this->baseUri . $request->getUri())); + } + + return $request; + } + protected function fillRequestVars(RequestInterface $request): void { foreach ($request->getHeaders() as $name => $header) { diff --git a/src/Requester/SymfonyKernelRequester.php b/src/Requester/SymfonyKernelRequester.php index b17657f9..87ec4d01 100644 --- a/src/Requester/SymfonyKernelRequester.php +++ b/src/Requester/SymfonyKernelRequester.php @@ -7,7 +7,6 @@ use APITester\Util\Json; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\ServerRequest; -use Nyholm\Psr7\Uri; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -18,8 +17,6 @@ final class SymfonyKernelRequester extends Requester { - private string $baseUri; - private HttpKernelInterface $kernel; /** @@ -29,28 +26,25 @@ final class SymfonyKernelRequester extends Requester public function __construct(string $baseUri = '') { - $this->baseUri = rtrim($baseUri, '/'); + $this->setBaseUri($baseUri); } /** * @inheritDoc */ - public function request(RequestInterface $request, string $id): void + public function request(RequestInterface $request, string $id): RequestInterface { - if (!str_starts_with((string) $request->getUri(), 'http')) { - $request = $request->withUri(new Uri(trim($this->baseUri . $request->getUri()))); - } + $request = $this->resolveUri($request); try { - $request = $this->psrToSymfonyRequest($request); - $response = $this->kernel->handle($request); - // $this->kernel->terminate($request, $response); + $sfRequest = $this->psrToSymfonyRequest($request); + $response = $this->kernel->handle($sfRequest); $this->responses[$id] = $this->symfonyToPsrResponse($response); } catch (\Throwable $e) { - // print_r($e->getTrace()[0]); - // throw new \RuntimeException($e->getMessage(), $e->getCode(), $e); $response = new Response(Json::encode($e), 500); $this->responses[$id] = $this->symfonyToPsrResponse($response); } + + return $request; } public function getResponse(string $id): ResponseInterface @@ -63,11 +57,6 @@ public static function getName(): string return 'symfony-kernel'; } - public function setBaseUri(string $baseUri): void - { - $this->baseUri = $baseUri; - } - public function setKernel(HttpKernelInterface $kernel): void { $this->kernel = $kernel; diff --git a/src/Runner/PHPUnit/AbstractPhpUnitRunner.php b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php index 45a32668..2090a0c0 100644 --- a/src/Runner/PHPUnit/AbstractPhpUnitRunner.php +++ b/src/Runner/PHPUnit/AbstractPhpUnitRunner.php @@ -46,6 +46,8 @@ final public function createRunnerFile( use APITester\Config\Loader\PlanConfigLoader; use APITester\Test\Plan; use APITester\Test\TestCase; + use Symfony\Component\Console\Logger\ConsoleLogger; + use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\HttpKernel\HttpKernelInterface; final class ApiTesterRunnerTest extends __APITESTER_PARENT__ @@ -62,6 +64,9 @@ public static function apiTestCases(): iterable $config = PlanConfigLoader::load(self::CONFIG_PATH); $plan = new Plan(); + $verbosity = self::OPTIONS['verbosity'] ?? 32; + $plan->setLogger(new ConsoleLogger(new ConsoleOutput($verbosity))); + foreach ($plan->getTestCases($config, self::SUITE_NAME, self::OPTIONS) as $testCase) { yield $testCase->getName() => [$testCase]; } @@ -127,7 +132,7 @@ final public function cleanupRunnerFile(string $testFile): void } /** - * @param array $passThroughOptions + * @param array $passThroughOptions * @param callable(string): void $writeOutput */ final public function run( diff --git a/src/Test/TestCase.php b/src/Test/TestCase.php index 5739c7ae..ed79b226 100644 --- a/src/Test/TestCase.php +++ b/src/Test/TestCase.php @@ -142,7 +142,7 @@ public function prepare(): void ($callback)(); } $this->startedAt = Carbon::now(); - $this->requester->request($this->request, $this->id); + $this->request = $this->requester->request($this->request, $this->id); $this->finishedAt = Carbon::now(); foreach ($this->afterCallbacks as $callback) { ($callback)(); @@ -318,7 +318,7 @@ private function log(string $logLevel): void 'request' => Serializer::normalize($this->request), 'response' => Serializer::normalize($this->response), 'expected' => Serializer::normalize($this->operationExample->getResponse(), $this->excludedFields), - ], JSON_PRETTY_PRINT); + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $this->logger->log($logLevel, $message); } From 39ec2cdc928494b9e95eac503d9ef138db146bbb Mon Sep 17 00:00:00 2001 From: sidux Date: Wed, 4 Feb 2026 20:33:31 +0100 Subject: [PATCH 4/4] feat(tests): add some coverage --- src/Config/Loader/PlanConfigLoader.php | 3 + tests/Config/FiltersTest.php | 200 ++++++++++++++++++ tests/Config/Loader/PlanConfigLoaderTest.php | 58 +++++ tests/Requester/RequesterTest.php | 93 ++++++++ .../PHPUnit/AbstractPhpUnitRunnerTest.php | 159 ++++++++++++++ tests/Test/ResultTest.php | 58 +++++ tests/Util/JsonTest.php | 85 ++++++++ .../Normalizer/PsrRequestNormalizerTest.php | 68 ++++++ .../Normalizer/PsrResponseNormalizerTest.php | 67 ++++++ 9 files changed, 791 insertions(+) create mode 100644 tests/Config/FiltersTest.php create mode 100644 tests/Config/Loader/PlanConfigLoaderTest.php create mode 100644 tests/Requester/RequesterTest.php create mode 100644 tests/Runner/PHPUnit/AbstractPhpUnitRunnerTest.php create mode 100644 tests/Test/ResultTest.php create mode 100644 tests/Util/JsonTest.php create mode 100644 tests/Util/Normalizer/PsrRequestNormalizerTest.php create mode 100644 tests/Util/Normalizer/PsrResponseNormalizerTest.php diff --git a/src/Config/Loader/PlanConfigLoader.php b/src/Config/Loader/PlanConfigLoader.php index cd204589..9da9aae6 100644 --- a/src/Config/Loader/PlanConfigLoader.php +++ b/src/Config/Loader/PlanConfigLoader.php @@ -17,6 +17,9 @@ final class PlanConfigLoader */ public static function load(string $path): Plan { + if (!is_file($path)) { + throw new ConfigurationException("File '{$path}' does not exist."); + } $content = file_get_contents($path); if ($content === false) { throw new ConfigurationException("Could not load file '{$path}'"); diff --git a/tests/Config/FiltersTest.php b/tests/Config/FiltersTest.php new file mode 100644 index 00000000..ea7e903c --- /dev/null +++ b/tests/Config/FiltersTest.php @@ -0,0 +1,200 @@ +tempFile !== null && file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + public function testDefaultConstructorHasEmptyArrays(): void + { + $filters = new Filters(); + + static::assertSame([], $filters->getInclude()); + static::assertSame([], $filters->getExclude()); + } + + public function testAddIncludeAppendsRules(): void + { + $filters = new Filters(); + $filters->addInclude([ + [ + 'id' => 'foo', + ], + ]); + $filters->addInclude([ + [ + 'id' => 'bar', + ], + ]); + + static::assertCount(2, $filters->getInclude()); + static::assertSame('foo', $filters->getInclude()[0]['id']); + static::assertSame('bar', $filters->getInclude()[1]['id']); + } + + public function testAddExcludeAppendsRules(): void + { + $filters = new Filters(); + $filters->addExclude([ + [ + 'method' => 'DELETE', + ], + ]); + + static::assertCount(1, $filters->getExclude()); + } + + public function testIncludesWithEmptyFiltersReturnsTrue(): void + { + $filters = new Filters(); + $object = $this->createFilterable([ + 'id' => 'anything', + ]); + + static::assertTrue($filters->includes($object)); + } + + public function testIncludesWithMatchingIncludeRule(): void + { + $filters = new Filters([ + [ + 'id' => 'foo', + ], + ]); + $matching = $this->createFilterable([ + 'id' => 'foo', + ]); + $nonMatching = $this->createFilterable([ + 'id' => 'bar', + ]); + + static::assertTrue($filters->includes($matching)); + static::assertFalse($filters->includes($nonMatching)); + } + + public function testIncludesWithExcludeRule(): void + { + $filters = new Filters(null, [ + [ + 'id' => 'excluded', + ], + ]); + $excluded = $this->createFilterable([ + 'id' => 'excluded', + ]); + $included = $this->createFilterable([ + 'id' => 'other', + ]); + + static::assertFalse($filters->includes($excluded)); + static::assertTrue($filters->includes($included)); + } + + public function testHandleTagsNotProducesNotEqual(): void + { + $ref = new \ReflectionMethod(Filters::class, 'handleTags'); + $ref->setAccessible(true); + $filters = new Filters(); + + /** @var array{0: string, 1: string|int|null} $result */ + $result = $ref->invoke($filters, new TaggedValue('NOT', 'DELETE')); + + static::assertSame('!=', $result[0]); + static::assertSame('DELETE', $result[1]); + } + + public function testHandleTagsInProducesContains(): void + { + $ref = new \ReflectionMethod(Filters::class, 'handleTags'); + $ref->setAccessible(true); + $filters = new Filters(); + + /** @var array{0: string, 1: string|int|null} $result */ + $result = $ref->invoke($filters, new TaggedValue('IN', 'pet')); + + static::assertSame('contains', $result[0]); + static::assertSame('pet', $result[1]); + } + + public function testHandleTagsNullStringConvertsToNull(): void + { + $ref = new \ReflectionMethod(Filters::class, 'handleTags'); + $ref->setAccessible(true); + $filters = new Filters(); + + /** @var array{0: string, 1: string|int|null} $result */ + $result = $ref->invoke($filters, 'null'); + + static::assertSame('=', $result[0]); + static::assertNull($result[1]); + } + + public function testWriteBaselineAndGetBaselineExcludeRoundTrip(): void + { + $this->tempFile = tempnam(sys_get_temp_dir(), 'api-tester-test-') . '.yaml'; + $filters = new Filters(null, null, $this->tempFile); + + $exclude = [ + [ + 'id' => 'test_1', + ], + [ + 'id' => 'test_2', + ], + ]; + $filters->writeBaseline($exclude); + + $result = $filters->getBaseLineExclude(); + + static::assertCount(2, $result); + static::assertSame('test_1', $result[0]['id']); + static::assertSame('test_2', $result[1]['id']); + } + + /** + * @param array $props + */ + private function createFilterable(array $props): Filterable + { + return new class($props) implements Filterable { + /** + * @param array $props + */ + public function __construct( + private readonly array $props + ) { + } + + public function has(string $prop, $value, string $operator = '='): bool + { + if (!array_key_exists($prop, $this->props)) { + return false; + } + + $propValue = $this->props[$prop]; + + return match ($operator) { + '=' => $propValue === $value, + '!=' => $propValue !== $value, + 'contains' => is_string($propValue) && is_string($value) && str_contains($propValue, $value), + default => false, + }; + } + }; + } +} diff --git a/tests/Config/Loader/PlanConfigLoaderTest.php b/tests/Config/Loader/PlanConfigLoaderTest.php new file mode 100644 index 00000000..9395e940 --- /dev/null +++ b/tests/Config/Loader/PlanConfigLoaderTest.php @@ -0,0 +1,58 @@ +getSuites()); + static::assertSame('oc', $plan->getSuites()[0]->getName()); + } + + public function testLoadThrowsForNonExistentFile(): void + { + $this->expectException(ConfigurationException::class); + + PlanConfigLoader::load('/non/existent/file.yaml'); + } + + public function testEnvVarSubstitution(): void + { + $_ENV['TEST_API_VAR'] = 'replaced_value'; + + try { + $content = '%env(TEST_API_VAR)%'; + $ref = new \ReflectionClass(PlanConfigLoader::class); + $method = $ref->getMethod('process'); + + $result = $method->invoke(null, $content); + + static::assertSame('replaced_value', $result); + } finally { + unset($_ENV['TEST_API_VAR']); + } + } + + public function testMissingEnvVarThrowsConfigurationException(): void + { + unset($_ENV['NONEXISTENT_VAR_FOR_TEST']); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage("'NONEXISTENT_VAR_FOR_TEST'"); + + $ref = new \ReflectionClass(PlanConfigLoader::class); + $method = $ref->getMethod('process'); + + $method->invoke(null, '%env(NONEXISTENT_VAR_FOR_TEST)%'); + } +} diff --git a/tests/Requester/RequesterTest.php b/tests/Requester/RequesterTest.php new file mode 100644 index 00000000..94230e08 --- /dev/null +++ b/tests/Requester/RequesterTest.php @@ -0,0 +1,93 @@ +setBaseUri('https://example.com/api/'); + + static::assertSame('https://example.com/api', $requester->getBaseUri()); + } + + public function testSetBaseUriTrimsMultipleTrailingSlashes(): void + { + $requester = new HttpDumpRequester(); + $requester->setBaseUri('https://example.com///'); + + static::assertSame('https://example.com', $requester->getBaseUri()); + } + + public function testResolveUriPrependsBaseUriForRelativePath(): void + { + $requester = new HttpDumpRequester(); + $requester->setBaseUri('https://example.com/api'); + + $request = new Request('GET', '/users/1'); + $resolved = $requester->resolveUri($request); + + static::assertSame('https://example.com/api/users/1', (string) $resolved->getUri()); + } + + public function testResolveUriDoesNotPrependForAbsoluteUri(): void + { + $requester = new HttpDumpRequester(); + $requester->setBaseUri('https://example.com/api'); + + $request = new Request('GET', 'https://other.com/resource'); + $resolved = $requester->resolveUri($request); + + static::assertSame('https://other.com/resource', (string) $resolved->getUri()); + } + + public function testResolveUriWithEmptyBaseUriReturnsUnchanged(): void + { + $requester = new HttpDumpRequester(); + + $request = new Request('GET', '/users'); + $resolved = $requester->resolveUri($request); + + static::assertSame('/users', (string) $resolved->getUri()); + } + + /** + * @dataProvider fillVarsProvider + */ + public function testFillVarsReplacesPlaceholders(string $subject, string $expected): void + { + $requester = new HttpDumpRequester(); + + $varsRef = new \ReflectionProperty(Requester::class, 'vars'); + $varsRef->setAccessible(true); + $varsRef->setValue($requester, [ + 'name' => 'John', + 'id' => '42', + ]); + + $fillVarsRef = new \ReflectionMethod(Requester::class, 'fillVars'); + $fillVarsRef->setAccessible(true); + + $result = $fillVarsRef->invoke($requester, $subject); + + static::assertSame($expected, $result); + } + + /** + * @return iterable + */ + public function fillVarsProvider(): iterable + { + yield 'single placeholder' => ['/users/{id}', '/users/42']; + yield 'multiple placeholders' => ['{name} has id {id}', 'John has id 42']; + yield 'no placeholders' => ['plain text', 'plain text']; + } +} diff --git a/tests/Runner/PHPUnit/AbstractPhpUnitRunnerTest.php b/tests/Runner/PHPUnit/AbstractPhpUnitRunnerTest.php new file mode 100644 index 00000000..2aafb1c0 --- /dev/null +++ b/tests/Runner/PHPUnit/AbstractPhpUnitRunnerTest.php @@ -0,0 +1,159 @@ +generatedFile !== null) { + $runner = new PhpUnitRunner(); + $runner->cleanupRunnerFile($this->generatedFile); + $this->generatedFile = null; + } + } + + public function testCreateRunnerFileGeneratesValidPhpFile(): void + { + $runner = new PhpUnitRunner(); + $suite = $this->createSuite(); + + $this->generatedFile = $runner->createRunnerFile($suite, '/tmp/config.yaml', 'my-suite', [ + 'verbosity' => 32, + ]); + + static::assertFileExists($this->generatedFile); + static::assertStringEndsWith('.php', $this->generatedFile); + + $content = file_get_contents($this->generatedFile); + static::assertIsString($content); + static::assertStringStartsWith('createSuite(); + + $this->generatedFile = $runner->createRunnerFile( + $suite, + '/path/to/config.yaml', + 'test-suite', + [ + 'debug' => true, + ] + ); + + $content = file_get_contents($this->generatedFile); + static::assertIsString($content); + + static::assertStringContainsString('/path/to/config.yaml', $content); + static::assertStringContainsString('test-suite', $content); + static::assertStringContainsString('ApiTesterRunnerTest', $content); + } + + public function testCleanupRunnerFileDeletesFile(): void + { + $runner = new PhpUnitRunner(); + $suite = $this->createSuite(); + + $file = $runner->createRunnerFile($suite, '/tmp/config.yaml', 'suite', []); + + static::assertFileExists($file); + + $runner->cleanupRunnerFile($file); + + static::assertFileDoesNotExist($file); + $this->generatedFile = null; + } + + /** + * @dataProvider buildArgumentsProvider + * + * @param array $options + * @param list $expectedContains + * @param list $expectedNotContains + */ + public function testBuildArguments( + array $options, + ?string $phpunitConfig, + array $expectedContains, + array $expectedNotContains + ): void { + $ref = new \ReflectionClass(PhpUnitRunner::class); + $method = $ref->getMethod('buildArguments'); + $method->setAccessible(true); + + $suite = $this->createSuite(); + if ($phpunitConfig !== null) { + $suite->setPhpunitConfig($phpunitConfig); + } + + $runner = new PhpUnitRunner(); + /** @var list $args */ + $args = $method->invoke($runner, $options, $suite); + + foreach ($expectedContains as $expected) { + static::assertContains($expected, $args); + } + + foreach ($expectedNotContains as $notExpected) { + static::assertNotContains($notExpected, $args); + } + } + + /** + * @return iterable, 1: ?string, 2: list, 3: list}> + */ + public function buildArgumentsProvider(): iterable + { + yield 'boolean true produces flag' => [ + [ + 'verbose' => true, + ], + null, + ['--verbose', '--colors=always'], + [], + ]; + + yield 'null and false are skipped' => [ + [ + 'skip-me' => null, + 'also-skip' => false, + ], + null, + ['--colors=always'], + ['--skip-me', '--also-skip'], + ]; + + yield 'scalar produces key=value' => [ + [ + 'filter' => 'MyTest', + ], + null, + ['--filter=MyTest', '--colors=always'], + [], + ]; + + yield 'phpunit config is included' => [ + [], + 'custom-phpunit.xml', + ['--configuration=custom-phpunit.xml', '--colors=always'], + [], + ]; + } + + private function createSuite(): Suite + { + return new Suite('test', new Definition('tests/Fixtures/OpenAPI/petstore.yaml', 'openapi')); + } +} diff --git a/tests/Test/ResultTest.php b/tests/Test/ResultTest.php new file mode 100644 index 00000000..d3839df6 --- /dev/null +++ b/tests/Test/ResultTest.php @@ -0,0 +1,58 @@ +hasSucceeded()); + static::assertSame('success', $result->getStatus()); + } + + public function testSuccessDefaultMessage(): void + { + $result = Result::success(); + + static::assertSame('Succeeded.', (string) $result); + } + + public function testFailedFactory(): void + { + $result = Result::failed('Something broke', 'ERR'); + + static::assertFalse($result->hasSucceeded()); + static::assertSame('failed', $result->getStatus()); + } + + public function testToStringWithCode(): void + { + $result = Result::failed('not found', '404'); + + static::assertSame('404: not found', (string) $result); + } + + public function testToStringWithoutCode(): void + { + $result = Result::failed('not found'); + + static::assertSame('not found', (string) $result); + } + + public function testJsonSerializeReturnsCorrectShape(): void + { + $result = Result::success('done', 'OK'); + $serialized = $result->jsonSerialize(); + + static::assertSame('OK', $serialized['code']); + static::assertSame('done', $serialized['message']); + static::assertCount(2, $serialized); + } +} diff --git a/tests/Util/JsonTest.php b/tests/Util/JsonTest.php new file mode 100644 index 00000000..8c52fb96 --- /dev/null +++ b/tests/Util/JsonTest.php @@ -0,0 +1,85 @@ + + */ + public function isJsonProvider(): iterable + { + yield 'valid object' => ['{"key":"value"}', true]; + yield 'valid array' => ['[1,2,3]', true]; + yield 'invalid string' => ['not json', false]; + yield 'empty string' => ['', false]; + } + + public function testDecodeReturnsAssociativeArray(): void + { + $result = Json::decode('{"foo":"bar","num":42}'); + + static::assertSame([ + 'foo' => 'bar', + 'num' => 42, + ], $result); + } + + public function testDecodeThrowsOnInvalidInput(): void + { + $this->expectException(\JsonException::class); + + Json::decode('invalid'); + } + + public function testDecodeAsObjectReturnsObject(): void + { + $result = Json::decodeAsObject('{"foo":"bar"}'); + + static::assertInstanceOf(\stdClass::class, $result); + static::assertSame('bar', $result->foo); + } + + public function testPrettifyReformatsCompactJson(): void + { + $compact = '{"a":1,"b":2}'; + $pretty = Json::prettify($compact); + + static::assertStringContainsString("\n", $pretty); + static::assertSame(json_encode([ + 'a' => 1, + 'b' => 2, + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), $pretty); + } + + public function testEncodeIncludesThrowOnError(): void + { + $result = Json::encode([ + 'key' => 'value', + ]); + + static::assertSame('{"key":"value"}', $result); + } + + public function testEncodeMergesExtraFlags(): void + { + $result = Json::encode([ + 'a' => 1, + ], JSON_PRETTY_PRINT); + + static::assertStringContainsString("\n", $result); + } +} diff --git a/tests/Util/Normalizer/PsrRequestNormalizerTest.php b/tests/Util/Normalizer/PsrRequestNormalizerTest.php new file mode 100644 index 00000000..7b7ae537 --- /dev/null +++ b/tests/Util/Normalizer/PsrRequestNormalizerTest.php @@ -0,0 +1,68 @@ +normalizer = new PsrRequestNormalizer(); + } + + public function testSupportsNormalizationForRequest(): void + { + static::assertTrue($this->normalizer->supportsNormalization(new Request('GET', '/'))); + } + + public function testSupportsNormalizationReturnsFalseForOther(): void + { + static::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeExtractsFields(): void + { + $request = new Request('POST', 'https://example.com/api', [ + 'Accept' => 'text/plain', + ], 'raw body'); + + $result = $this->normalizer->normalize($request); + + static::assertSame('POST', $result['method']); + static::assertSame('https://example.com/api', $result['url']); + static::assertSame('raw body', $result['body']); + static::assertArrayHasKey('headers', $result); + } + + public function testNormalizeDecodesJsonBody(): void + { + $body = '{"key":"value"}'; + $request = new Request('POST', '/api', [], $body); + + $result = $this->normalizer->normalize($request); + + static::assertSame([ + 'key' => 'value', + ], $result['body']); + } + + public function testNormalizeIgnoresAttributes(): void + { + $request = new Request('GET', '/api'); + + $result = $this->normalizer->normalize($request, null, [ + AbstractNormalizer::IGNORED_ATTRIBUTES => ['headers', 'body'], + ]); + + static::assertArrayNotHasKey('headers', $result); + static::assertArrayNotHasKey('body', $result); + } +} diff --git a/tests/Util/Normalizer/PsrResponseNormalizerTest.php b/tests/Util/Normalizer/PsrResponseNormalizerTest.php new file mode 100644 index 00000000..c37e7f75 --- /dev/null +++ b/tests/Util/Normalizer/PsrResponseNormalizerTest.php @@ -0,0 +1,67 @@ +normalizer = new PsrResponseNormalizer(); + } + + public function testSupportsNormalizationForResponse(): void + { + static::assertTrue($this->normalizer->supportsNormalization(new Response())); + } + + public function testSupportsNormalizationReturnsFalseForOther(): void + { + static::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeExtractsFields(): void + { + $response = new Response(201, [ + 'X-Custom' => 'val', + ], 'plain body'); + + $result = $this->normalizer->normalize($response); + + static::assertSame(201, $result['status']); + static::assertSame('plain body', $result['body']); + static::assertArrayHasKey('headers', $result); + } + + public function testNormalizeDecodesJsonBody(): void + { + $body = '{"ok":true}'; + $response = new Response(200, [], $body); + + $result = $this->normalizer->normalize($response); + + static::assertSame([ + 'ok' => true, + ], $result['body']); + } + + public function testNormalizeIgnoresAttributes(): void + { + $response = new Response(200, [], 'body'); + + $result = $this->normalizer->normalize($response, null, [ + AbstractNormalizer::IGNORED_ATTRIBUTES => ['headers'], + ]); + + static::assertArrayNotHasKey('headers', $result); + static::assertArrayHasKey('status', $result); + } +}