From 322ab0bc2235e884c061f96bda09d0b6fdc7d71b Mon Sep 17 00:00:00 2001 From: Aaron Gustavo Nieves <64917965+TavoNiievez@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:11:21 -0500 Subject: [PATCH] Add flash, service-mock, query-count, messenger-transport and console-result assertions Close the small set of gaps that today push a Symfony developer toward a separate, non-Codeception package, in two buckets: assertions Symfony ships in its own Test\* traits that the module had not inherited yet, and capabilities absent from both Symfony core and the Codeception module ecosystem. Symfony ports: - assertSessionHasFlashMessage() (SessionAssertionsTrait), a port of Symfony 8.1's BrowserKit assertion of the same name. Given one or more messages it passes if any of them is present in the type's channel, matching Symfony's SessionHasFlashMessage constraint, and reads the flash bag with peek() so it stays non-destructive. Symfony 5.4 has no FlashBagAwareSessionInterface, so the concrete Session class is accepted as well. - runCommand() plus assertCommandIsSuccessful(), assertCommandFailed(), assertCommandIsInvalid() and assertCommandResultEquals() (ConsoleAssertionsTrait), a port of Symfony 8.1's ConsoleCommandAssertionsTrait. runCommand() returns an ExecutionResult exposing the status code together with the separate standard and error output, which runSymfonyConsoleCommand() cannot. It is guarded with class_exists(ExecutionResult::class), and runSymfonyConsoleCommand() remains the pre-8.1 path. Module-native: - mockService() / unmockService() (ServicesAssertionsTrait) swap a container service for a test double on the existing persistent-service rails, so the replacement survives kernel reboots. The double is built with Codeception Stub, PHPUnit or Mockery; the module only supplies the replacement. - seeNumQueriesIsLessThan() / dontSeeDuplicateQueries() (DoctrineAssertionsTrait) guard against N+1 queries by reading the Doctrine db profiler collector, typed through the new DataCollectorName::DB. symfony/doctrine-bridge is added as a dev dependency to type the collector; it spans Symfony 5.4 to 8.1, so every CI row stays green. - grabMessengerTransport(), seeMessengerTransportContains(), seeMessengerQueueCount() and consumeMessengerMessages() (MessengerAssertionsTrait) inspect and process the real message objects on a Symfony in-memory transport, complementing the existing profiler-based dispatch assertions. seeMessengerQueueCount() counts the pending queue (sent minus acknowledged or rejected), and consumeMessengerMessages() handles queued envelopes through the routable message bus in-process, which works with the in-memory transport where messenger:consume does not. They resolve the transport from the Transport\InMemory namespace, so on older versions they report a symfony/messenger >= 6.3 requirement. No new trait, no new config knob, and nothing added to the Symfony facade except the two new services Part entries. Also stop the functional CI job from installing a second copy of the module into the app: it removed codeception/module-symfony with --no-update and then ran `composer install`, which reinstalls from the app's lockfile, so the module source under test and a stale released copy were both autoloaded. Includes the internal-domain deduplication carried on this branch: build the domain set keyed by host regex instead of array_unique() over a growing list, so grabbing the internal domains is O(N) rather than O(N^2). --- .github/workflows/main.yml | 4 +- composer.json | 1 + src/Codeception/Module/Symfony.php | 2 + src/Codeception/Module/Symfony/CacheTrait.php | 7 +- .../Module/Symfony/ConsoleAssertionsTrait.php | 141 ++++++++++++++ .../Module/Symfony/DataCollectorName.php | 1 + .../Symfony/DoctrineAssertionsTrait.php | 105 +++++++++++ .../Symfony/HttpKernelAssertionsTrait.php | 6 +- .../Symfony/MessengerAssertionsTrait.php | 177 ++++++++++++++++++ .../Symfony/ServicesAssertionsTrait.php | 50 +++++ .../Module/Symfony/SessionAssertionsTrait.php | 74 ++++++++ tests/ConsoleAssertionsTest.php | 54 ++++++ tests/DoctrineAssertionsTest.php | 23 +++ tests/MessengerAssertionsTest.php | 52 +++++ tests/ServicesAssertionsTest.php | 18 ++ tests/SessionAssertionsTest.php | 10 + tests/_app/Command/TestCommand.php | 14 ++ tests/_app/Controller/AppController.php | 7 + tests/_app/Doctrine/DbDataCollector.php | 55 ++++++ tests/_app/TestKernel.php | 8 +- tests/_app/config/routes.php | 1 + tests/_app/config/services.php | 4 + 22 files changed, 805 insertions(+), 9 deletions(-) create mode 100644 tests/_app/Doctrine/DbDataCollector.php diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 87da0ef7..5fdde9d9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -47,8 +47,7 @@ jobs: - name: Prepare Symfony app & run tests run: | - composer -d framework-tests remove codeception/module-symfony --dev --no-update - composer -d framework-tests install --no-progress + composer -d framework-tests remove codeception/module-symfony --dev --no-progress php framework-tests/bin/console doctrine:schema:update --force php framework-tests/bin/console doctrine:fixtures:load --quiet php vendor/bin/codecept run Functional -c framework-tests @@ -58,6 +57,7 @@ jobs: composer require codeception/module-rest:^3.4 --dev git -C framework-tests checkout -- composer.json patch -d framework-tests -p1 --fuzz=3 --no-backup-if-mismatch < framework-tests/resetFormatsAfterRequest_issue_test.patch + composer -d framework-tests remove codeception/module-symfony --dev --no-update composer -d framework-tests update --no-progress php framework-tests/bin/console lexik:jwt:generate-keypair --skip-if-exists php vendor/bin/codecept run Functional -c framework-tests diff --git a/composer.json b/composer.json index 2f274b64..6a340848 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "symfony/console": "^5.4 | ^6.4 | ^7.4 | ^8.1", "symfony/css-selector": "^5.4 | ^6.4 | ^7.4 | ^8.1", "symfony/dependency-injection": "^5.4 | ^6.4 | ^7.4 | ^8.1", + "symfony/doctrine-bridge": "^5.4 | ^6.4 | ^7.4 | ^8.1", "symfony/dom-crawler": "^5.4 | ^6.4 | ^7.4 | ^8.1", "symfony/dotenv": "^5.4 | ^6.4 | ^7.4 | ^8.1", "symfony/error-handler": "^5.4 | ^6.4 | ^7.4 | ^8.1", diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index eecda7f2..099f249f 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -111,8 +111,10 @@ * * * `services`: Includes methods related to the Symfony dependency injection container (DIC): * * grabService + * * mockService * * persistService * * persistPermanentService + * * unmockService * * unpersistService * * See [WebDriver module](https://codeception.com/docs/modules/WebDriver#Loading-Parts-from-other-Modules) diff --git a/src/Codeception/Module/Symfony/CacheTrait.php b/src/Codeception/Module/Symfony/CacheTrait.php index 1c7940fb..a00add40 100644 --- a/src/Codeception/Module/Symfony/CacheTrait.php +++ b/src/Codeception/Module/Symfony/CacheTrait.php @@ -8,8 +8,7 @@ use Symfony\Component\HttpKernel\Profiler\Profile; use function array_key_exists; -use function array_unique; -use function array_values; +use function array_keys; use function is_string; trait CacheTrait @@ -48,12 +47,12 @@ protected function getInternalDomains(): array $hostRegex = $route->compile()->getHostRegex(); if ($hostRegex !== null && $hostRegex !== '') { - $domains[] = $hostRegex; + $domains[$hostRegex] = true; } } /** @var list $domains */ - $domains = array_values(array_unique($domains)); + $domains = array_keys($domains); return $this->state['internalDomains'] = $domains; } diff --git a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php index 9a2def50..f154278b 100644 --- a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php @@ -4,16 +4,157 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Tester\Constraint\CommandFailed; +use Symfony\Component\Console\Tester\Constraint\CommandIsInvalid; +use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful; +use Symfony\Component\Console\Tester\ExecutionResult; use Symfony\Component\HttpKernel\KernelInterface; +use function class_exists; use function is_int; use function sprintf; trait ConsoleAssertionsTrait { + /** + * Asserts that a command run with [`runCommand()`](https://codeception.com/docs/modules/Symfony#runCommand) + * exited with a non-zero (failure) status code. + * + * ```php + * runCommand('app:import-users', ['file' => 'broken.csv']); + * $I->assertCommandFailed($result); + * ``` + */ + public function assertCommandFailed(ExecutionResult $result, string $message = ''): void + { + $this->assertThat($result->statusCode, new CommandFailed(), $message); + } + + /** + * Asserts that a command run with [`runCommand()`](https://codeception.com/docs/modules/Symfony#runCommand) + * exited with the "invalid" status code (`Command::INVALID`, i.e. `2`). + * + * ```php + * runCommand('app:import-users'); + * $I->assertCommandIsInvalid($result); + * ``` + */ + public function assertCommandIsInvalid(ExecutionResult $result, string $message = ''): void + { + $this->assertThat($result->statusCode, new CommandIsInvalid(), $message); + } + + /** + * Asserts that a command run with [`runCommand()`](https://codeception.com/docs/modules/Symfony#runCommand) + * exited successfully (status code `0`). + * + * ```php + * runCommand('app:import-users', ['file' => 'users.csv']); + * $I->assertCommandIsSuccessful($result); + * ``` + */ + public function assertCommandIsSuccessful(ExecutionResult $result, string $message = ''): void + { + $this->assertThat($result->statusCode, new CommandIsSuccessful(), $message); + } + + /** + * Asserts on the parts of an {@see ExecutionResult} you pass: any of the + * status code, stdout, stderr and the combined display. Arguments left `null` + * are not checked. + * + * ```php + * runCommand('app:import-users', ['file' => 'broken.csv']); + * $I->assertCommandResultEquals( + * $result, + * expectedStatusCode: 1, + * expectedErrorOutput: "Invalid CSV\n", + * ); + * ``` + */ + public function assertCommandResultEquals( + ExecutionResult $result, + ?int $expectedStatusCode = null, + ?string $expectedOutput = null, + ?string $expectedErrorOutput = null, + ?string $expectedDisplay = null, + string $message = '' + ): void { + $expected = []; + $actual = []; + + if ($expectedStatusCode !== null) { + $expected['statusCode'] = $expectedStatusCode; + $actual['statusCode'] = $result->statusCode; + } + if ($expectedOutput !== null) { + $expected['output'] = $expectedOutput; + $actual['output'] = $result->getOutput(); + } + if ($expectedErrorOutput !== null) { + $expected['errorOutput'] = $expectedErrorOutput; + $actual['errorOutput'] = $result->getErrorOutput(); + } + if ($expectedDisplay !== null) { + $expected['display'] = $expectedDisplay; + $actual['display'] = $result->getDisplay(); + } + + $this->assertSame($expected, $actual, $message); + } + + /** + * Runs a console command and returns its {@see ExecutionResult}, which exposes + * the exit status code together with stdout, stderr and the combined display as + * separate streams. + * + * Unlike [`runSymfonyConsoleCommand()`](https://codeception.com/docs/modules/Symfony#runSymfonyConsoleCommand), + * which merges stdout and stderr into a single string, this lets you assert on + * the error output in isolation. Pair it with [`assertCommandIsSuccessful()`](https://codeception.com/docs/modules/Symfony#assertCommandIsSuccessful), + * [`assertCommandFailed()`](https://codeception.com/docs/modules/Symfony#assertCommandFailed), + * [`assertCommandIsInvalid()`](https://codeception.com/docs/modules/Symfony#assertCommandIsInvalid) + * or [`assertCommandResultEquals()`](https://codeception.com/docs/modules/Symfony#assertCommandResultEquals). + * + * Requires `symfony/console` 8.1 or higher; on older versions use `runSymfonyConsoleCommand()`. + * + * ```php + * runCommand('app:import-users', ['file' => 'broken.csv']); + * $I->assertCommandFailed($result); + * $I->assertStringContainsString('Invalid CSV', $result->getErrorOutput()); + * ``` + * + * @param array $input Command arguments and options + * @param list $interactiveInputs Inputs for interactive questions + * @param OutputInterface::VERBOSITY_*|null $verbosity + * @param array<\Closure(string): string> $normalizers + */ + public function runCommand( + string $name, + array $input = [], + array $interactiveInputs = [], + ?bool $interactive = null, + ?bool $decorated = null, + ?int $verbosity = null, + array $normalizers = [] + ): ExecutionResult { + if (!class_exists(ExecutionResult::class)) { + Assert::fail('runCommand() requires symfony/console 8.1 or higher; use runSymfonyConsoleCommand() on older versions.'); + } + + $command = (new Application($this->kernel))->find($name); + + return (new CommandTester($command))->run($input, $interactiveInputs, $interactive, $decorated, $verbosity, $normalizers); + } + /** * Run Symfony console command, grab response and return as string. * Recommended to use for functional testing. diff --git a/src/Codeception/Module/Symfony/DataCollectorName.php b/src/Codeception/Module/Symfony/DataCollectorName.php index e7cd5e27..bab7cb7b 100644 --- a/src/Codeception/Module/Symfony/DataCollectorName.php +++ b/src/Codeception/Module/Symfony/DataCollectorName.php @@ -9,6 +9,7 @@ */ enum DataCollectorName: string { + case DB = 'db'; case EVENTS = 'events'; case FORM = 'form'; case HTTP_CLIENT = 'http_client'; diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index e40617e2..59aff3c9 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -6,15 +6,52 @@ use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\Assert; +use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector; +use function array_count_values; +use function array_filter; +use function array_keys; +use function class_exists; +use function count; +use function implode; use function interface_exists; +use function is_array; use function is_object; +use function is_string; use function is_subclass_of; use function json_encode; +use function preg_match; use function sprintf; trait DoctrineAssertionsTrait { + /** + * Asserts that no identical SQL query was executed more than once during the + * last request — a common symptom of an N+1 problem. + * + * Transaction-control statements (`START TRANSACTION`, `COMMIT`, ...) are ignored, + * so legitimately repeated transaction boundaries are not flagged as duplicates. + * + * Reads Doctrine's `db` profiler collector, so it requires `doctrine/doctrine-bundle`. + * + * ```php + * dontSeeDuplicateQueries(); + * ``` + */ + public function dontSeeDuplicateQueries(): void + { + $statements = $this->grabExecutedStatements(__FUNCTION__); + + $duplicates = array_keys(array_filter(array_count_values($statements), static fn(int $count): bool => $count > 1)); + + $this->assertSame( + [], + $duplicates, + sprintf('Expected no duplicate database queries, but found %d: %s', count($duplicates), implode(' | ', $duplicates)) + ); + } + /** * Returns the number of rows that match the given criteria for the * specified Doctrine entity. @@ -71,6 +108,32 @@ public function grabRepository(object|string $entityOrClass): EntityRepository return $em->getRepository($id); } + /** + * Asserts that fewer than the given number of database queries were executed + * during the last request — a ceiling guard against N+1 problems. + * + * Transaction-control statements (`START TRANSACTION`, `COMMIT`, ...) are not + * counted, so the number reflects the application queries only. + * + * Reads Doctrine's `db` profiler collector, so it requires `doctrine/doctrine-bundle`. + * Counts are environment-sensitive, so assert a ceiling rather than an exact number. + * + * ```php + * seeNumQueriesIsLessThan(5); + * ``` + */ + public function seeNumQueriesIsLessThan(int $expectedCount): void + { + $actualCount = count($this->grabExecutedStatements(__FUNCTION__)); + + $this->assertLessThan( + $expectedCount, + $actualCount, + sprintf('Expected fewer than %d database queries, but %d were executed.', $expectedCount, $actualCount) + ); + } + /** * Asserts that a given number of records exists for the entity. * 'id' is the default search parameter. @@ -102,4 +165,46 @@ public function seeNumRecords(int $expectedNum, string $className, array $criter ) ); } + + private function grabDoctrineCollector(string $function): DoctrineDataCollector + { + if (!class_exists(DoctrineDataCollector::class)) { + Assert::fail(sprintf("The '%s' assertion requires the 'doctrine/doctrine-bundle' package.", $function)); + } + + return $this->grabCollector( + DataCollectorName::DB, + $function, + sprintf("The Doctrine 'db' collector is needed to use '%s'. Is DoctrineBundle enabled with the profiler?", $function) + ); + } + + /** + * Flattens the executed SQL of every connection into a single list, dropping + * transaction-control statements so the N+1 guards count application queries only. + * + * @return list + */ + private function grabExecutedStatements(string $function): array + { + $statements = []; + foreach ($this->grabDoctrineCollector($function)->getQueries() as $connectionQueries) { + if (!is_array($connectionQueries)) { + continue; + } + foreach ($connectionQueries as $query) { + $sql = is_array($query) ? ($query['sql'] ?? null) : null; + if (is_string($sql) && !$this->isTransactionStatement($sql)) { + $statements[] = $sql; + } + } + } + + return $statements; + } + + private function isTransactionStatement(string $sql): bool + { + return preg_match('/^\s*("|`)?(START\s+TRANSACTION|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i', $sql) === 1; + } } diff --git a/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php index 721b1a58..265f9fcc 100644 --- a/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php @@ -5,6 +5,7 @@ namespace Codeception\Module\Symfony; use PHPUnit\Framework\Assert; +use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector; use Symfony\Bridge\Twig\DataCollector\TwigDataCollector; use Symfony\Bundle\SecurityBundle\DataCollector\SecurityDataCollector; use Symfony\Component\Form\Extension\DataCollector\FormDataCollector; @@ -32,7 +33,8 @@ abstract protected function getProfile(): ?Profile; * Grab a Symfony Data Collector from the current profile. * * @phpstan-return ( - * $name is DataCollectorName::EVENTS ? EventDataCollector : + * $name is DataCollectorName::DB ? DoctrineDataCollector : + * ($name is DataCollectorName::EVENTS ? EventDataCollector : * ($name is DataCollectorName::FORM ? FormDataCollector : * ($name is DataCollectorName::HTTP_CLIENT ? HttpClientDataCollector : * ($name is DataCollectorName::LOGGER ? LoggerDataCollector : @@ -44,7 +46,7 @@ abstract protected function getProfile(): ?Profile; * ($name is DataCollectorName::MESSENGER ? MessengerDataCollector : * ($name is DataCollectorName::NOTIFIER ? NotificationDataCollector : * DataCollectorInterface - * )))))))))) + * ))))))))))) * ) */ protected function grabCollector(DataCollectorName $name, string $callingFunction = '', ?string $message = null): DataCollectorInterface diff --git a/src/Codeception/Module/Symfony/MessengerAssertionsTrait.php b/src/Codeception/Module/Symfony/MessengerAssertionsTrait.php index 70682cab..68ec5948 100644 --- a/src/Codeception/Module/Symfony/MessengerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MessengerAssertionsTrait.php @@ -4,16 +4,57 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; use Symfony\Component\Messenger\DataCollector\MessengerDataCollector; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp; +use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Messenger\Stamp\TransportMessageIdStamp; +use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; use Symfony\Component\VarDumper\Cloner\Data; use function class_exists; use function count; +use function is_scalar; use function is_string; use function sprintf; trait MessengerAssertionsTrait { + /** + * Processes messages waiting on an in-memory transport by dispatching them + * back through the message bus to their handlers, then acknowledging them. + * + * Use it to assert side effects that happen during handling (an email is sent, + * a row is written, another message is dispatched). + * + * Requires the bus to be routed to a Symfony in-memory transport + * (`MESSENGER_TRANSPORT_DSN=in-memory://`) in the test environment. + * + * ```php + * consumeMessengerMessages('async'); // process one message + * $I->consumeMessengerMessages('async', limit: 5); // process up to five + * ``` + */ + public function consumeMessengerMessages(string $transportName, int $limit = 1): void + { + $transport = $this->grabMessengerTransport($transportName); + $bus = $this->grabMessageBus(); + + $consumed = 0; + foreach ($this->getQueuedEnvelopes($transportName) as $envelope) { + if ($consumed >= $limit) { + break; + } + + $bus->dispatch($envelope->with(new ReceivedStamp($transportName), new ConsumedByWorkerStamp())); + $transport->ack($envelope); + ++$consumed; + } + } + /** * Asserts no message of the given class was dispatched (optionally on a single bus). * @@ -52,6 +93,41 @@ public function grabDispatchedMessageClasses(?string $bus = null): array return $this->getDispatchedMessageClasses(__FUNCTION__, $bus); } + /** + * Grabs a Symfony in-memory transport so you can inspect the real message + * objects it holds via `getSent()`, `getAcknowledged()` and `getRejected()`. + * + * Unlike the profiler-based assertions, this exposes the actual envelopes + * (with their payload and stamps), not a lossy class-name snapshot. + * + * Requires the bus to be routed to a Symfony in-memory transport + * (`MESSENGER_TRANSPORT_DSN=in-memory://`) in the test environment. + * + * ```php + * grabMessengerTransport('async')->getSent()[0]->getMessage(); + * ``` + */ + public function grabMessengerTransport(string $transportName): InMemoryTransport + { + $this->assertTrue( + class_exists(InMemoryTransport::class), + 'The Messenger transport inspection methods require symfony/messenger >= 6.3.' + ); + + $transport = $this->grabService(sprintf('messenger.transport.%s', $transportName)); + + if (!$transport instanceof InMemoryTransport) { + Assert::fail(sprintf( + "The 'messenger.transport.%s' transport is not a Symfony in-memory transport. " + . "Route the bus to 'in-memory://' in your test environment to use this method.", + $transportName, + )); + } + + return $transport; + } + /** * Asserts how many messages were dispatched (optionally on a single bus). * @@ -97,6 +173,57 @@ public function seeMessageDispatched(string $messageClass, ?string $bus = null): ); } + /** + * Asserts how many messages are still waiting on an in-memory transport + * (sent but not yet acknowledged or rejected). + * + * ```php + * seeMessengerQueueCount(1, 'async'); + * ``` + */ + public function seeMessengerQueueCount(int $expectedCount, string $transportName): void + { + $queued = $this->getQueuedEnvelopes($transportName); + + $this->assertCount( + $expectedCount, + $queued, + sprintf( + "Expected %d message(s) queued on the '%s' transport, but found %d.", + $expectedCount, + $transportName, + count($queued), + ), + ); + } + + /** + * Asserts that a message of the given class is waiting on an in-memory transport. + * + * ```php + * seeMessengerTransportContains(SendInvoice::class, 'async'); + * ``` + * + * @param class-string $messageClass + */ + public function seeMessengerTransportContains(string $messageClass, string $transportName): void + { + $found = false; + foreach ($this->getQueuedEnvelopes($transportName) as $envelope) { + if ($envelope->getMessage() instanceof $messageClass) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + sprintf("No '%s' message is queued on the '%s' transport.", $messageClass, $transportName), + ); + } + /** * @return list */ @@ -122,6 +249,56 @@ private function getDispatchedMessageClasses(string $callingFunction, ?string $b return $classes; } + /** + * Returns the envelopes still waiting on the transport, i.e. sent but not yet + * acknowledged or rejected. + * + * @return list + */ + private function getQueuedEnvelopes(string $transportName): array + { + $transport = $this->grabMessengerTransport($transportName); + + $processedIds = []; + foreach ([...$transport->getAcknowledged(), ...$transport->getRejected()] as $envelope) { + $id = $this->envelopeId($envelope); + if ($id !== null) { + $processedIds[$id] = true; + } + } + + $queued = []; + foreach ($transport->getSent() as $envelope) { + $id = $this->envelopeId($envelope); + if ($id === null || !isset($processedIds[$id])) { + $queued[] = $envelope; + } + } + + return $queued; + } + + private function envelopeId(Envelope $envelope): ?string + { + $stamp = $envelope->last(TransportMessageIdStamp::class); + if (!$stamp instanceof TransportMessageIdStamp) { + return null; + } + + $id = $stamp->getId(); + return is_scalar($id) ? (string) $id : null; + } + + private function grabMessageBus(): MessageBusInterface + { + $bus = $this->grabService('messenger.routable_message_bus'); + if (!$bus instanceof MessageBusInterface) { + Assert::fail("The 'messenger.routable_message_bus' service is not a message bus."); + } + + return $bus; + } + private function busSuffix(?string $bus): string { return $bus !== null ? sprintf(" on bus '%s'", $bus) : ''; diff --git a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php index f2fb0765..77c1b3ac 100644 --- a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php @@ -4,6 +4,7 @@ namespace Codeception\Module\Symfony; +use InvalidArgumentException; use PHPUnit\Framework\Assert; trait ServicesAssertionsTrait @@ -54,6 +55,39 @@ public function grabService(string $serviceId): object ); } + /** + * Replaces a service in the container with a test double (mock, stub or fake). + * + * Build the double however you like — Codeception `Stub`, PHPUnit mocks, + * Mockery, or a hand-written fake — then swap it in. The replacement is kept + * as a persistent service, so it survives the kernel reboots that happen + * between requests and stays active for the rest of the test. + * + * Typical uses: stub outbound HTTP, freeze the clock, or replace a collaborator. + * + * ```php + * mockService('http_client', new MockHttpClient($responses)); + * $I->mockService(PaymentGateway::class, $this->makeEmpty(PaymentGateway::class)); + * $I->mockService('clock', new MockClock('2030-01-01')); + * ``` + * + * @part services + * @param non-empty-string $serviceId + */ + public function mockService(string $serviceId, object $replacement): void + { + $this->persistentServices[$serviceId] = $replacement; + $this->updateClientPersistentService($serviceId, $replacement); + + try { + $this->_getContainer()->set($serviceId, $replacement); + } catch (InvalidArgumentException) { + // The current container may have already initialized the service and + // refuse to replace it; the double still takes effect on the next reboot. + } + } + /** * Get service $serviceName and add it to the lists of persistent services. * @@ -77,6 +111,22 @@ public function persistPermanentService(string $serviceName): void $this->doPersistService($serviceName, true); } + /** + * Removes a previously mocked service, restoring the real one on the next kernel reboot. + * + * ```php + * unmockService('http_client'); + * ``` + * + * @part services + * @param non-empty-string $serviceId + */ + public function unmockService(string $serviceId): void + { + $this->unpersistService($serviceId); + } + /** * Remove service $serviceName from the lists of persistent services. * diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 431e72a5..9f27f674 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -4,8 +4,12 @@ namespace Codeception\Module\Symfony; +use BadMethodCallException; use InvalidArgumentException; +use PHPUnit\Framework\Assert; use Symfony\Component\BrowserKit\Cookie; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; +use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionFactoryInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Kernel; @@ -18,8 +22,11 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; +use function array_filter; +use function array_intersect; use function class_exists; use function get_debug_type; +use function is_array; use function is_int; use function is_string; use function serialize; @@ -65,6 +72,73 @@ public function amLoggedInWithToken(TokenInterface $token, string $firewallName $this->getClient()->getCookieJar()->set(new Cookie($session->getName(), $session->getId())); } + /** + * Asserts that the session has a flash message of the given type, optionally + * checking that it contains at least one of the given messages. + * + * This is a port of Symfony 8.1's `assertSessionHasFlashMessage()`: when one + * or more messages are given, the assertion passes if any of them is present + * in the type's channel. The flash bag is read with `peek()`, so the + * assertion is non-destructive and a later `see()` on the rendered page still + * works. + * + * Because templates consume the flash bag while rendering, the followed + * redirect page drains it before the assertion runs. Call + * [`stopFollowingRedirects()`](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) + * before the request so the flash survives. + * + * ```php + * stopFollowingRedirects(); + * $I->amOnPage('/register'); // an action that adds a flash and redirects + * $I->assertSessionHasFlashMessage('success'); + * $I->assertSessionHasFlashMessage('success', 'Your account has been created.'); + * $I->assertSessionHasFlashMessage('notice', ['First notice', 'Second notice']); + * ``` + * + * @param string|list $messages + */ + public function assertSessionHasFlashMessage(string $messageType, string|array $messages = ''): void + { + try { + $request = $this->getClient()->getRequest(); + } catch (BadMethodCallException) { + Assert::fail('You must perform a request before asserting flash messages.'); + } + + $this->assertTrue( + $request->hasSession(), + sprintf("The request has no session, so it cannot hold a '%s' flash message.", $messageType) + ); + + $session = $request->getSession(); + + // Symfony 5.4 has no FlashBagAwareSessionInterface, so the concrete + // Session class is checked as well: it carries the flash bag on every + // supported version. instanceof on a missing class is simply false. + if (!$session instanceof Session && !$session instanceof FlashBagAwareSessionInterface) { + Assert::fail('The session does not have a flash bag.'); + } + + $actualMessages = $session->getFlashBag()->peek($messageType); + + $this->assertNotEmpty( + $actualMessages, + sprintf("The session does not have a '%s' flash message.", $messageType) + ); + + if ($messages === '' || $messages === []) { + return; + } + + $expectedMessages = is_array($messages) ? $messages : [$messages]; + + $this->assertNotEmpty( + array_intersect($expectedMessages, array_filter($actualMessages, 'is_string')), + sprintf("The '%s' flash messages do not contain any of the expected messages.", $messageType) + ); + } + /** * Assert that a session attribute does not exist, or is not equal to the passed value. * diff --git a/tests/ConsoleAssertionsTest.php b/tests/ConsoleAssertionsTest.php index 03d477b4..f695f5db 100644 --- a/tests/ConsoleAssertionsTest.php +++ b/tests/ConsoleAssertionsTest.php @@ -5,12 +5,59 @@ namespace Tests; use Codeception\Module\Symfony\ConsoleAssertionsTrait; +use Symfony\Component\Console\Tester\ExecutionResult; use Tests\Support\CodeceptTestCase; +use function class_exists; + final class ConsoleAssertionsTest extends CodeceptTestCase { use ConsoleAssertionsTrait; + public function testAssertCommandFailed(): void + { + $this->requireExecutionResult(); + + $result = $this->runCommand('app:test-command', ['--fail' => true]); + + $this->assertCommandFailed($result); + $this->assertStringContainsString('Something went wrong', $result->getErrorOutput()); + $this->assertStringNotContainsString('Something went wrong', $result->getOutput()); + } + + public function testAssertCommandIsInvalid(): void + { + $this->requireExecutionResult(); + + $result = $this->runCommand('app:test-command', ['--invalid' => true]); + + $this->assertCommandIsInvalid($result); + } + + public function testAssertCommandResultEquals(): void + { + $this->requireExecutionResult(); + + $result = $this->runCommand('app:test-command', ['--fail' => true]); + + $this->assertCommandResultEquals( + $result, + expectedStatusCode: 1, + expectedErrorOutput: 'Something went wrong', + ); + } + + public function testRunCommand(): void + { + $this->requireExecutionResult(); + + $result = $this->runCommand('app:test-command'); + + $this->assertCommandIsSuccessful($result); + $this->assertSame(0, $result->statusCode); + $this->assertStringContainsString('No option', $result->getOutput()); + } + public function testRunSymfonyConsoleCommand(): void { $this->assertStringContainsString('No option', $this->runSymfonyConsoleCommand('app:test-command')); @@ -18,4 +65,11 @@ public function testRunSymfonyConsoleCommand(): void $this->assertStringContainsString('Option selected', $this->runSymfonyConsoleCommand('app:test-command', ['-o' => true])); $this->assertSame('', $this->runSymfonyConsoleCommand('app:test-command', ['-q'])); } + + private function requireExecutionResult(): void + { + if (!class_exists(ExecutionResult::class)) { + $this->markTestSkipped('symfony/console 8.1 or higher is required for runCommand().'); + } + } } diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php index 95ebc496..a23f2a6f 100644 --- a/tests/DoctrineAssertionsTest.php +++ b/tests/DoctrineAssertionsTest.php @@ -5,6 +5,7 @@ namespace Tests; use Codeception\Module\Symfony\DoctrineAssertionsTrait; +use PHPUnit\Framework\AssertionFailedError; use Tests\App\Entity\User; use Tests\App\Repository\UserRepository; use Tests\App\Repository\UserRepositoryInterface; @@ -14,6 +15,21 @@ final class DoctrineAssertionsTest extends CodeceptTestCase { use DoctrineAssertionsTrait; + public function testDontSeeDuplicateQueries(): void + { + $this->client->request('GET', '/'); + + $this->dontSeeDuplicateQueries(); + } + + public function testDontSeeDuplicateQueriesDetectsDuplicates(): void + { + $this->client->request('GET', '/?duplicateQueries=1'); + + $this->expectException(AssertionFailedError::class); + $this->dontSeeDuplicateQueries(); + } + public function testGrabNumRecords(): void { $this->assertSame(1, $this->grabNumRecords(User::class)); @@ -27,6 +43,13 @@ public function testGrabRepository(): void $this->assertInstanceOf(UserRepository::class, $this->grabRepository(UserRepositoryInterface::class)); } + public function testSeeNumQueriesIsLessThan(): void + { + $this->client->request('GET', '/'); + + $this->seeNumQueriesIsLessThan(3); + } + public function testSeeNumRecords(): void { $this->seeNumRecords(1, User::class); diff --git a/tests/MessengerAssertionsTest.php b/tests/MessengerAssertionsTest.php index 05cabf40..749ea7fa 100644 --- a/tests/MessengerAssertionsTest.php +++ b/tests/MessengerAssertionsTest.php @@ -6,13 +6,65 @@ use Codeception\Module\Symfony\MessengerAssertionsTrait; use stdClass; +use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; use Tests\App\Message\TestMessage; +use Tests\App\MessageHandler\TestMessageHandler; use Tests\Support\CodeceptTestCase; +use function class_exists; + final class MessengerAssertionsTest extends CodeceptTestCase { use MessengerAssertionsTrait; + public function testConsumeMessengerMessages(): void + { + $this->requireInMemoryTransport(); + $this->client->request('GET', '/dispatch-message'); + $this->seeMessengerQueueCount(1, 'async'); + + $this->consumeMessengerMessages('async'); + + $this->seeMessengerQueueCount(0, 'async'); + + $handler = $this->grabService(TestMessageHandler::class); + $this->assertContains('Hello from Messenger', $handler->handled); + } + + public function testGrabMessengerTransport(): void + { + $this->requireInMemoryTransport(); + $this->client->request('GET', '/dispatch-message'); + + $sent = $this->grabMessengerTransport('async')->getSent(); + + $this->assertCount(1, $sent); + $this->assertInstanceOf(TestMessage::class, $sent[0]->getMessage()); + } + + public function testSeeMessengerQueueCount(): void + { + $this->requireInMemoryTransport(); + $this->client->request('GET', '/dispatch-message'); + + $this->seeMessengerQueueCount(1, 'async'); + } + + public function testSeeMessengerTransportContains(): void + { + $this->requireInMemoryTransport(); + $this->client->request('GET', '/dispatch-message'); + + $this->seeMessengerTransportContains(TestMessage::class, 'async'); + } + + private function requireInMemoryTransport(): void + { + if (!class_exists(InMemoryTransport::class)) { + $this->markTestSkipped('symfony/messenger 6.3 or higher (in-memory transport) is required.'); + } + } + public function testSeeDispatchedMessageCount(): void { $this->client->request('GET', '/dispatch-message'); diff --git a/tests/ServicesAssertionsTest.php b/tests/ServicesAssertionsTest.php index aed7c5ea..45c657b1 100644 --- a/tests/ServicesAssertionsTest.php +++ b/tests/ServicesAssertionsTest.php @@ -5,6 +5,8 @@ namespace Tests; use Codeception\Module\Symfony\ServicesAssertionsTrait; +use stdClass; +use Tests\App\HttpClient\MockResponseFactory; use Tests\Support\CodeceptTestCase; final class ServicesAssertionsTest extends CodeceptTestCase @@ -16,6 +18,15 @@ public function testGrabService(): void $this->assertIsObject($this->grabService('security.helper')); } + public function testMockService(): void + { + $double = new stdClass(); + $this->mockService(MockResponseFactory::class, $double); + + $this->assertSame($double, $this->grabService(MockResponseFactory::class)); + $this->assertArrayHasKey(MockResponseFactory::class, $this->persistentServices); + } + public function testPersistService(): void { $this->persistService('router'); @@ -29,6 +40,13 @@ public function testPersistPermanentService(): void $this->assertArrayHasKey('router', $this->persistentServices); } + public function testUnmockService(): void + { + $this->mockService(MockResponseFactory::class, new stdClass()); + $this->unmockService(MockResponseFactory::class); + $this->assertArrayNotHasKey(MockResponseFactory::class, $this->persistentServices); + } + public function testUnpersistService(): void { $this->persistService('router'); diff --git a/tests/SessionAssertionsTest.php b/tests/SessionAssertionsTest.php index 289f6951..64e20829 100644 --- a/tests/SessionAssertionsTest.php +++ b/tests/SessionAssertionsTest.php @@ -30,6 +30,16 @@ public function testAmLoggedInWithToken(): void $this->assertStringContainsString('You are in the Dashboard!', $this->client->getResponse()->getContent()); } + public function testAssertSessionHasFlashMessage(): void + { + $this->client->followRedirects(false); + $this->client->request('GET', '/set-flash'); + + $this->assertSessionHasFlashMessage('success'); + $this->assertSessionHasFlashMessage('success', 'Welcome aboard!'); + $this->assertSessionHasFlashMessage('success', ['Another message.', 'Welcome aboard!']); + } + public function testDontSeeInSession(): void { $this->client->request('GET', '/'); diff --git a/tests/_app/Command/TestCommand.php b/tests/_app/Command/TestCommand.php index d4f41814..70a993d3 100644 --- a/tests/_app/Command/TestCommand.php +++ b/tests/_app/Command/TestCommand.php @@ -8,6 +8,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -17,12 +18,25 @@ final class TestCommand extends Command protected function configure(): void { $this->addOption('opt', 'o', InputOption::VALUE_NONE, 'Option'); + $this->addOption('fail', null, InputOption::VALUE_NONE, 'Exit with a failure status and write to stderr'); + $this->addOption('invalid', null, InputOption::VALUE_NONE, 'Exit with the invalid status code'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + if ($input->getOption('fail')) { + $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $errorOutput->write('Something went wrong'); + + return Command::FAILURE; + } + + if ($input->getOption('invalid')) { + return Command::INVALID; + } + if ($input->getOption('opt')) { $io->text('Option selected'); } else { diff --git a/tests/_app/Controller/AppController.php b/tests/_app/Controller/AppController.php index 9ff23287..875655c6 100644 --- a/tests/_app/Controller/AppController.php +++ b/tests/_app/Controller/AppController.php @@ -192,6 +192,13 @@ public function sendMessage(MessageMailer $mailer): Response return new Response('Message sent'); } + public function setFlash(Request $request): RedirectResponse + { + $request->getSession()->getFlashBag()->add('success', 'Welcome aboard!'); + + return new RedirectResponse('/'); + } + public function testPage(Environment $twig): Response { return new Response($twig->render('test_page.html.twig')); diff --git a/tests/_app/Doctrine/DbDataCollector.php b/tests/_app/Doctrine/DbDataCollector.php new file mode 100644 index 00000000..cad48a99 --- /dev/null +++ b/tests/_app/Doctrine/DbDataCollector.php @@ -0,0 +1,55 @@ + 'START TRANSACTION', 'executionMS' => 0.1], + ['sql' => 'SELECT * FROM user', 'executionMS' => 0.1], + ['sql' => 'COMMIT', 'executionMS' => 0.1], + ['sql' => 'START TRANSACTION', 'executionMS' => 0.1], + ['sql' => 'SELECT id FROM product WHERE category_id = 1', 'executionMS' => 0.1], + ['sql' => 'COMMIT', 'executionMS' => 0.1], + ]; + + if ($request->query->getBoolean('duplicateQueries')) { + $queries[] = ['sql' => 'START TRANSACTION', 'executionMS' => 0.1]; + $queries[] = ['sql' => 'SELECT * FROM user', 'executionMS' => 0.1]; + $queries[] = ['sql' => 'COMMIT', 'executionMS' => 0.1]; + } + + $this->data = [ + 'queries' => ['default' => $queries], + 'connections' => ['default'], + 'managers' => ['default' => 'default'], + ]; + } + + public function reset(): void + { + $this->data = []; + } +} diff --git a/tests/_app/TestKernel.php b/tests/_app/TestKernel.php index d60eda30..e90cc07c 100644 --- a/tests/_app/TestKernel.php +++ b/tests/_app/TestKernel.php @@ -15,6 +15,7 @@ use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Tests\App\Message\TestMessage; class TestKernel extends BaseKernel { @@ -60,7 +61,12 @@ private function configureExtensions(ContainerConfigurator $container): void 'validation' => ['enabled' => true], 'form' => ['enabled' => true], 'notifier' => ['chatter_transports' => ['async' => 'null://null'], 'texter_transports' => ['sms' => 'null://null']], - 'messenger' => ['default_bus' => 'messenger.bus.default', 'buses' => ['messenger.bus.default' => []]], + 'messenger' => [ + 'default_bus' => 'messenger.bus.default', + 'buses' => ['messenger.bus.default' => []], + 'transports' => ['async' => 'in-memory://'], + 'routing' => [TestMessage::class => 'async'], + ], ]); $container->extension('twig', ['default_path' => __DIR__ . '/templates', 'debug' => true]); diff --git a/tests/_app/config/routes.php b/tests/_app/config/routes.php index 55fabb6f..92fea633 100644 --- a/tests/_app/config/routes.php +++ b/tests/_app/config/routes.php @@ -25,6 +25,7 @@ $routes->add('sample', '/sample')->controller(AppController::class . '::sample'); $routes->add('send_email', '/send-email')->controller(AppController::class . '::sendEmail'); $routes->add('send_message', '/send-message')->controller(AppController::class . '::sendMessage'); + $routes->add('set_flash', '/set-flash')->controller(AppController::class . '::setFlash'); $routes->add('test_page', '/test_page')->controller(AppController::class . '::testPage'); $routes->add('unprocessable_entity', '/unprocessable_entity')->controller(AppController::class . '::unprocessableEntity'); }; diff --git a/tests/_app/config/services.php b/tests/_app/config/services.php index 0c5cf534..50715ebe 100644 --- a/tests/_app/config/services.php +++ b/tests/_app/config/services.php @@ -17,6 +17,7 @@ use Symfony\Component\Notifier\EventListener\NotificationLoggerListener; use Tests\App\Command\TestCommand; use Tests\App\Controller\AppController; +use Tests\App\Doctrine\DbDataCollector; use Tests\App\Doctrine\DoctrineSetup; use Tests\App\Entity\User; use Tests\App\Event\TestEvent; @@ -42,6 +43,9 @@ $services->set(AppController::class); $services->set(TestCommand::class)->tag('console.command', ['command' => 'app:test-command']); + $services->set(DbDataCollector::class) + ->tag('data_collector', ['id' => 'db', 'template' => '@WebProfiler/Collector/db.html.twig', 'priority' => 250]); + $services->set('doctrine.orm.entity_manager', EntityManagerInterface::class) ->factory([DoctrineSetup::class, 'createEntityManager']); $services->alias('doctrine.orm.default_entity_manager', 'doctrine.orm.entity_manager')->public();