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();