Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/Codeception/Module/Symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 3 additions & 4 deletions src/Codeception/Module/Symfony/CacheTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,12 +47,12 @@ protected function getInternalDomains(): array

$hostRegex = $route->compile()->getHostRegex();
if ($hostRegex !== null && $hostRegex !== '') {
$domains[] = $hostRegex;
$domains[$hostRegex] = true;
}
}

/** @var list<non-empty-string> $domains */
$domains = array_values(array_unique($domains));
$domains = array_keys($domains);
return $this->state['internalDomains'] = $domains;
}

Expand Down
141 changes: 141 additions & 0 deletions src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <?php
* $result = $I->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
* <?php
* $result = $I->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
* <?php
* $result = $I->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
* <?php
* $result = $I->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
* <?php
* $result = $I->runCommand('app:import-users', ['file' => 'broken.csv']);
* $I->assertCommandFailed($result);
* $I->assertStringContainsString('Invalid CSV', $result->getErrorOutput());
* ```
*
* @param array<string, mixed> $input Command arguments and options
* @param list<string> $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.
Expand Down
1 change: 1 addition & 0 deletions src/Codeception/Module/Symfony/DataCollectorName.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
enum DataCollectorName: string
{
case DB = 'db';
case EVENTS = 'events';
case FORM = 'form';
case HTTP_CLIENT = 'http_client';
Expand Down
105 changes: 105 additions & 0 deletions src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <?php
* $I->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.
Expand Down Expand Up @@ -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
* <?php
* $I->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.
Expand Down Expand Up @@ -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<string>
*/
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;
}
}
6 changes: 4 additions & 2 deletions src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 :
Expand All @@ -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
Expand Down
Loading