├── .github └── workflows │ └── main.yml ├── LICENSE ├── composer.json └── src └── Codeception ├── Lib └── Connector │ └── Symfony.php └── Module ├── Symfony.php └── Symfony ├── BrowserAssertionsTrait.php ├── ConsoleAssertionsTrait.php ├── DoctrineAssertionsTrait.php ├── DomCrawlerAssertionsTrait.php ├── EventsAssertionsTrait.php ├── FormAssertionsTrait.php ├── HttpClientAssertionsTrait.php ├── LoggerAssertionsTrait.php ├── MailerAssertionsTrait.php ├── MimeAssertionsTrait.php ├── ParameterAssertionsTrait.php ├── RouterAssertionsTrait.php ├── SecurityAssertionsTrait.php ├── ServicesAssertionsTrait.php ├── SessionAssertionsTrait.php ├── TimeAssertionsTrait.php ├── TranslationAssertionsTrait.php ├── TwigAssertionsTrait.php └── ValidatorAssertionsTrait.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | php: [8.2, 8.3, 8.4] 10 | symfony: ["5.4.*", "6.4.*", "6.4wApi", "7.2.*"] 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php }} 20 | tools: composer:v2 21 | extensions: ctype, iconv, intl, json, mbstring, pdo, pdo_sqlite 22 | coverage: none 23 | 24 | - name: Set Symfony version reference 25 | env: 26 | MATRIX_SYMFONY: ${{ matrix.symfony }} 27 | run: | 28 | if [[ "$MATRIX_SYMFONY" == *'*' ]]; then 29 | echo "SF_REF=${MATRIX_SYMFONY%.*}" >> "$GITHUB_ENV" 30 | else 31 | echo "SF_REF=$MATRIX_SYMFONY" >> "$GITHUB_ENV" 32 | fi 33 | 34 | - name: Set Composer Symfony constraint 35 | env: 36 | MATRIX_SYMFONY: ${{ matrix.symfony }} 37 | run: | 38 | if [[ "$MATRIX_SYMFONY" == "6.4wApi" ]]; then 39 | echo "COMP_SYMFONY=6.4.*" >> "$GITHUB_ENV" 40 | else 41 | echo "COMP_SYMFONY=$MATRIX_SYMFONY" >> "$GITHUB_ENV" 42 | fi 43 | 44 | - name: Checkout Symfony ${{ env.SF_REF }} sample 45 | uses: actions/checkout@v4 46 | with: 47 | repository: Codeception/symfony-module-tests 48 | path: framework-tests 49 | ref: ${{ env.SF_REF }} 50 | 51 | - name: Get composer cache directory 52 | id: composer-cache 53 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 54 | 55 | - name: Cache Composer dependencies 56 | uses: actions/cache@v3 57 | with: 58 | path: ${{ steps.composer-cache.outputs.dir }} 59 | key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json', 'composer.lock') }} 60 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-composer- 61 | 62 | - name: Install PHPUnit 10 63 | run: composer require --dev --no-update "phpunit/phpunit=^10.0" 64 | 65 | - name: Install dependencies 66 | env: 67 | MATRIX_SYMFONY: ${{ matrix.symfony }} 68 | run: | 69 | composer require symfony/finder=${{ env.COMP_SYMFONY }} --no-update 70 | composer require symfony/yaml=${{ env.COMP_SYMFONY }} --no-update 71 | composer require symfony/console=${{ env.COMP_SYMFONY }} --no-update 72 | composer require symfony/event-dispatcher=${{ env.COMP_SYMFONY }} --no-update 73 | composer require symfony/css-selector=${{ env.COMP_SYMFONY }} --no-update 74 | composer require symfony/dom-crawler=${{ env.COMP_SYMFONY }} --no-update 75 | composer require symfony/browser-kit=${{ env.COMP_SYMFONY }} --no-update 76 | composer require vlucas/phpdotenv --no-update 77 | composer require codeception/module-asserts="3.*" --no-update 78 | composer require codeception/module-doctrine="3.*" --no-update 79 | 80 | if [[ "$MATRIX_SYMFONY" == "6.4wApi" ]]; then 81 | composer require codeception/module-rest="3.*" --no-update 82 | fi 83 | 84 | composer update --prefer-dist --no-progress --no-dev 85 | 86 | - name: Validate Composer files 87 | run: composer validate --strict 88 | working-directory: framework-tests 89 | 90 | - name: Install PHPUnit in framework-tests 91 | run: composer require --dev --no-update "phpunit/phpunit=^10.0" 92 | working-directory: framework-tests 93 | 94 | - name: Prepare Symfony sample 95 | run: | 96 | composer remove codeception/codeception codeception/module-asserts codeception/module-doctrine codeception/lib-innerbrowser codeception/module-symfony --dev --no-update 97 | composer update --no-progress 98 | working-directory: framework-tests 99 | 100 | - name: Setup Database 101 | run: | 102 | php bin/console doctrine:schema:update --force 103 | php bin/console doctrine:fixtures:load --quiet 104 | working-directory: framework-tests 105 | 106 | - name: Generate JWT keypair 107 | if: ${{ matrix.symfony == '6.4wApi' }} 108 | run: php bin/console lexik:jwt:generate-keypair --skip-if-exists 109 | working-directory: framework-tests 110 | 111 | - name: Run tests 112 | run: | 113 | php vendor/bin/codecept build -c framework-tests 114 | php vendor/bin/codecept run Functional -c framework-tests 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2024 Michael Bodnarchuk and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codeception/module-symfony", 3 | "description": "Codeception module for Symfony framework", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "codeception", 8 | "functional testing", 9 | "symfony" 10 | ], 11 | "homepage": "https://codeception.com/", 12 | "support": { 13 | "docs": "https://codeception.com/docs/modules/Symfony" 14 | }, 15 | "authors": [ 16 | { 17 | "name": "Michael Bodnarchuk" 18 | }, 19 | { 20 | "name": "Gustavo Nieves", 21 | "homepage": "https://medium.com/@ganieves" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2", 26 | "ext-json": "*", 27 | "codeception/codeception": "^5.3", 28 | "codeception/lib-innerbrowser": "^3.1 | ^4.0" 29 | }, 30 | "require-dev": { 31 | "codeception/module-asserts": "^3.0", 32 | "codeception/module-doctrine": "^3.1", 33 | "doctrine/orm": "^2.20", 34 | "symfony/browser-kit": "^5.4 | ^6.4 | ^7.2", 35 | "symfony/cache": "^5.4 | ^6.4 | ^7.2", 36 | "symfony/config": "^5.4 | ^6.4 | ^7.2", 37 | "symfony/dependency-injection": "^5.4 | ^6.4 | ^7.2", 38 | "symfony/dom-crawler": "^5.4 | ^6.4 | ^7.2", 39 | "symfony/dotenv": "^5.4 | ^6.4 | ^7.2", 40 | "symfony/error-handler": "^5.4 | ^6.4 | ^7.2", 41 | "symfony/filesystem": "^5.4 | ^6.4 | ^7.2", 42 | "symfony/form": "^5.4 | ^6.4 | ^7.2", 43 | "symfony/framework-bundle": "^5.4 | ^6.4 | ^7.2", 44 | "symfony/http-client": "^5.4 | ^6.4 | ^7.2", 45 | "symfony/http-foundation": "^5.4 | ^6.4 | ^7.2", 46 | "symfony/http-kernel": "^5.4 | ^6.4 | ^7.2", 47 | "symfony/mailer": "^5.4 | ^6.4 | ^7.2", 48 | "symfony/mime": "^5.4 | ^6.4 | ^7.2", 49 | "symfony/notifier": "^5.4 | ^6.4 | ^7.2", 50 | "symfony/options-resolver": "^5.4 | ^6.4 | ^7.2", 51 | "symfony/property-access": "^5.4 | ^6.4 | ^7.2", 52 | "symfony/property-info": "^5.4 | ^6.4 | ^7.2", 53 | "symfony/routing": "^5.4 | ^6.4 | ^7.2", 54 | "symfony/security-bundle": "^5.4 | ^6.4 | ^7.2", 55 | "symfony/security-core": "^5.4 | ^6.4 | ^7.2", 56 | "symfony/security-csrf": "^5.4 | ^6.4 | ^7.2", 57 | "symfony/security-http": "^5.4 | ^6.4 | ^7.2", 58 | "symfony/translation": "^5.4 | ^6.4 | ^7.2", 59 | "symfony/twig-bundle": "^5.4 | ^6.4 | ^7.2", 60 | "symfony/validator": "^5.4 | ^6.4 | ^7.2", 61 | "symfony/var-exporter": "^5.4 | ^6.4 | ^7.2", 62 | "vlucas/phpdotenv": "^4.2 | ^5.4" 63 | }, 64 | "suggest": { 65 | "codeception/module-asserts": "Include traditional PHPUnit assertions in your tests", 66 | "symfony/web-profiler-bundle": "Tool that gives information about the execution of requests" 67 | }, 68 | "autoload": { 69 | "classmap": ["src/"] 70 | }, 71 | "config": { 72 | "classmap-authoritative": true, 73 | "sort-packages": true 74 | }, 75 | "minimum-stability": "RC" 76 | } 77 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Connector/Symfony.php: -------------------------------------------------------------------------------- 1 | followRedirects(); 33 | $this->container = $this->getContainer(); 34 | $this->rebootKernel(); 35 | } 36 | 37 | /** @param Request $request */ 38 | protected function doRequest(object $request): Response 39 | { 40 | if ($this->rebootable) { 41 | if ($this->hasPerformedRequest) { 42 | $this->rebootKernel(); 43 | } else { 44 | $this->hasPerformedRequest = true; 45 | } 46 | } 47 | 48 | return parent::doRequest($request); 49 | } 50 | 51 | /** 52 | * Reboots the kernel. 53 | * 54 | * Services from the list of persistent services 55 | * are updated from service container before kernel shutdown 56 | * and injected into newly initialized container after kernel boot. 57 | */ 58 | public function rebootKernel(): void 59 | { 60 | if ($this->container) { 61 | foreach (array_keys($this->persistentServices) as $serviceName) { 62 | if ($service = $this->getService($serviceName)) { 63 | $this->persistentServices[$serviceName] = $service; 64 | } 65 | } 66 | } 67 | 68 | $this->persistDoctrineConnections(); 69 | $this->ensureKernelShutdown(); 70 | $this->kernel->boot(); 71 | $this->container = $this->getContainer(); 72 | 73 | foreach ($this->persistentServices as $serviceName => $service) { 74 | try { 75 | $this->container->set($serviceName, $service); 76 | } catch (InvalidArgumentException $e) { 77 | codecept_debug("[Symfony] Can't set persistent service {$serviceName}: " . $e->getMessage()); 78 | } 79 | } 80 | 81 | if ($profiler = $this->getProfiler()) { 82 | $profiler->enable(); 83 | } 84 | } 85 | 86 | protected function ensureKernelShutdown(): void 87 | { 88 | $this->kernel->boot(); 89 | $this->kernel->shutdown(); 90 | } 91 | 92 | private function getContainer(): ?ContainerInterface 93 | { 94 | /** @var ContainerInterface $container */ 95 | $container = $this->kernel->getContainer(); 96 | return $container->has('test.service_container') 97 | ? $container->get('test.service_container') 98 | : $container; 99 | } 100 | 101 | private function getProfiler(): ?Profiler 102 | { 103 | return $this->container->has('profiler') 104 | ? $this->container->get('profiler') 105 | : null; 106 | } 107 | 108 | private function getService(string $serviceName): ?object 109 | { 110 | return $this->container->has($serviceName) 111 | ? $this->container->get($serviceName) 112 | : null; 113 | } 114 | 115 | private function persistDoctrineConnections(): void 116 | { 117 | if (!$this->container->hasParameter('doctrine.connections')) { 118 | return; 119 | } 120 | 121 | if ($this->container instanceof TestContainer) { 122 | $reflectedTestContainer = new ReflectionMethod($this->container, 'getPublicContainer'); 123 | $reflectedTestContainer->setAccessible(true); 124 | $publicContainer = $reflectedTestContainer->invoke($this->container); 125 | } else { 126 | $publicContainer = $this->container; 127 | } 128 | 129 | $reflectedContainer = new ReflectionClass($publicContainer); 130 | $reflectionTarget = $reflectedContainer->hasProperty('parameters') ? $publicContainer : $publicContainer->getParameterBag(); 131 | 132 | $reflectedParameters = new ReflectionProperty($reflectionTarget, 'parameters'); 133 | $reflectedParameters->setAccessible(true); 134 | $parameters = $reflectedParameters->getValue($reflectionTarget); 135 | unset($parameters['doctrine.connections']); 136 | $reflectedParameters->setValue($reflectionTarget, $parameters); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony.php: -------------------------------------------------------------------------------- 1 | grabService(...)`](#grabService) 69 | * * Use Doctrine to test against the database: `$I->seeInRepository(...)` - see [Doctrine Module](https://codeception.com/docs/modules/Doctrine) 70 | * * Assert that emails would have been sent: [`$I->seeEmailIsSent()`](#seeEmailIsSent) 71 | * * Tests are wrapped into Doctrine transaction to speed them up. 72 | * * Symfony Router can be cached between requests to speed up testing. 73 | * 74 | * ## Demo Project 75 | * 76 | * 77 | * 78 | * ## Config 79 | * 80 | * ### Symfony 5.4 or higher 81 | * 82 | * * `app_path`: 'src' - Specify custom path to your app dir, where the kernel interface is located. 83 | * * `environment`: 'local' - Environment used for load kernel 84 | * * `kernel_class`: 'App\Kernel' - Kernel class name 85 | * * `em_service`: 'doctrine.orm.entity_manager' - Use the stated EntityManager to pair with Doctrine Module. 86 | * * `debug`: true - Turn on/off [debug mode](https://codeception.com/docs/Debugging) 87 | * * `cache_router`: 'false' - Enable router caching between tests in order to [increase performance](http://lakion.com/blog/how-did-we-speed-up-sylius-behat-suite-with-blackfire) (can have an impact on ajax requests sending via '$I->sendAjaxPostRequest()') 88 | * * `rebootable_client`: 'true' - Reboot client's kernel before each request 89 | * * `guard`: 'false' - Enable custom authentication system with guard (only for Symfony 5.4) 90 | * * `bootstrap`: 'false' - Enable the test environment setup with the tests/bootstrap.php file if it exists or with Symfony DotEnv otherwise. If false, it does nothing. 91 | * * `authenticator`: 'false' - Reboot client's kernel before each request (only for Symfony 6.0 or higher) 92 | * 93 | * #### Sample `Functional.suite.yml` 94 | * 95 | * modules: 96 | * enabled: 97 | * - Symfony: 98 | * app_path: 'src' 99 | * environment: 'test' 100 | * 101 | * 102 | * ## Public Properties 103 | * 104 | * * kernel - HttpKernel instance 105 | * * client - current Crawler instance 106 | * 107 | * ## Parts 108 | * 109 | * * `services`: Includes methods related to the Symfony dependency injection container (DIC): 110 | * * grabService 111 | * * persistService 112 | * * persistPermanentService 113 | * * unpersistService 114 | * 115 | * See [WebDriver module](https://codeception.com/docs/modules/WebDriver#Loading-Parts-from-other-Modules) 116 | * for general information on how to load parts of a framework module. 117 | * 118 | * Usage example: 119 | * 120 | * ```yaml 121 | * actor: AcceptanceTester 122 | * modules: 123 | * enabled: 124 | * - Symfony: 125 | * part: services 126 | * - Doctrine: 127 | * depends: Symfony 128 | * - WebDriver: 129 | * url: http://example.com 130 | * browser: firefox 131 | * ``` 132 | * 133 | * If you're using Symfony with Eloquent ORM (instead of Doctrine), you can load the [`ORM` part of Laravel module](https://codeception.com/docs/modules/Laravel#Parts) 134 | * in addition to Symfony module. 135 | * 136 | */ 137 | class Symfony extends Framework implements DoctrineProvider, PartedModule 138 | { 139 | use BrowserAssertionsTrait; 140 | use ConsoleAssertionsTrait; 141 | use DoctrineAssertionsTrait; 142 | use DomCrawlerAssertionsTrait; 143 | use EventsAssertionsTrait; 144 | use FormAssertionsTrait; 145 | use HttpClientAssertionsTrait; 146 | use LoggerAssertionsTrait; 147 | use MailerAssertionsTrait; 148 | use MimeAssertionsTrait; 149 | use ParameterAssertionsTrait; 150 | use RouterAssertionsTrait; 151 | use SecurityAssertionsTrait; 152 | use ServicesAssertionsTrait; 153 | use SessionAssertionsTrait; 154 | use TranslationAssertionsTrait; 155 | use TimeAssertionsTrait; 156 | use TwigAssertionsTrait; 157 | use ValidatorAssertionsTrait; 158 | 159 | public Kernel $kernel; 160 | 161 | /** 162 | * @var SymfonyConnector 163 | */ 164 | public ?AbstractBrowser $client = null; 165 | 166 | /** 167 | * @var array 168 | */ 169 | public array $config = [ 170 | 'app_path' => 'app', 171 | 'kernel_class' => 'App\Kernel', 172 | 'environment' => 'test', 173 | 'debug' => true, 174 | 'cache_router' => false, 175 | 'em_service' => 'doctrine.orm.entity_manager', 176 | 'rebootable_client' => true, 177 | 'authenticator' => false, 178 | 'bootstrap' => false, 179 | 'guard' => false 180 | ]; 181 | 182 | protected ?string $kernelClass = null; 183 | /** 184 | * Services that should be persistent permanently for all tests 185 | */ 186 | protected array $permanentServices = []; 187 | /** 188 | * Services that should be persistent during test execution between kernel reboots 189 | */ 190 | protected array $persistentServices = []; 191 | 192 | /** 193 | * @return string[] 194 | */ 195 | public function _parts(): array 196 | { 197 | return ['services']; 198 | } 199 | 200 | public function _initialize(): void 201 | { 202 | $this->kernelClass = $this->getKernelClass(); 203 | $this->setXdebugMaxNestingLevel(200); 204 | $this->kernel = new $this->kernelClass($this->config['environment'], $this->config['debug']); 205 | if ($this->config['bootstrap']) { 206 | $this->bootstrapEnvironment(); 207 | } 208 | $this->kernel->boot(); 209 | if ($this->config['cache_router']) { 210 | $this->persistPermanentService('router'); 211 | } 212 | } 213 | 214 | /** 215 | * Initialize new client instance before each test 216 | */ 217 | public function _before(TestInterface $test): void 218 | { 219 | $this->persistentServices = array_merge($this->persistentServices, $this->permanentServices); 220 | $this->client = new SymfonyConnector($this->kernel, $this->persistentServices, $this->config['rebootable_client']); 221 | } 222 | 223 | /** 224 | * Update permanent services after each test 225 | */ 226 | public function _after(TestInterface $test): void 227 | { 228 | foreach (array_keys($this->permanentServices) as $serviceName) { 229 | $this->permanentServices[$serviceName] = $this->grabService($serviceName); 230 | } 231 | parent::_after($test); 232 | } 233 | 234 | protected function onReconfigure(array $settings = []): void 235 | { 236 | parent::_beforeSuite($settings); 237 | $this->_initialize(); 238 | } 239 | 240 | /** 241 | * Retrieve Entity Manager. 242 | * 243 | * EM service is retrieved once and then that instance returned on each call 244 | */ 245 | public function _getEntityManager(): EntityManagerInterface 246 | { 247 | if ($this->kernel === null) { 248 | $this->fail('Symfony module is not loaded'); 249 | } 250 | 251 | $emService = $this->config['em_service']; 252 | if (!isset($this->permanentServices[$emService])) { 253 | $this->persistPermanentService($emService); 254 | $container = $this->_getContainer(); 255 | $services = ['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection']; 256 | foreach ($services as $service) { 257 | if ($container->has($service)) { 258 | $this->persistPermanentService($service); 259 | } 260 | } 261 | } 262 | 263 | return $this->permanentServices[$emService]; 264 | } 265 | 266 | public function _getContainer(): ContainerInterface 267 | { 268 | $container = $this->kernel->getContainer(); 269 | 270 | return $container->has('test.service_container') ? $container->get('test.service_container') : $container; 271 | } 272 | 273 | protected function getClient(): SymfonyConnector 274 | { 275 | return $this->client ?: $this->fail('Client is not initialized'); 276 | } 277 | 278 | /** 279 | * Attempts to guess the kernel location. 280 | * When the Kernel is located, the file is required. 281 | * 282 | * @return string The Kernel class name 283 | * @throws ModuleRequireException|ReflectionException 284 | */ 285 | protected function getKernelClass(): string 286 | { 287 | $path = codecept_root_dir() . $this->config['app_path']; 288 | if (!file_exists($path)) { 289 | throw new ModuleRequireException( 290 | self::class, 291 | "Can't load Kernel from {$path}.\n" 292 | . 'Directory does not exist. Set `app_path` in your suite configuration to a valid application path.' 293 | ); 294 | } 295 | 296 | $this->requireAdditionalAutoloader(); 297 | 298 | $finder = new Finder(); 299 | $results = iterator_to_array($finder->name('*Kernel.php')->depth('0')->in($path)); 300 | if ($results === []) { 301 | throw new ModuleRequireException( 302 | self::class, 303 | "File with Kernel class was not found at {$path}.\n" 304 | . 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' 305 | ); 306 | } 307 | 308 | $kernelClass = $this->config['kernel_class']; 309 | $filesRealPath = array_map(static function ($file) { 310 | require_once $file; 311 | return $file->getRealPath(); 312 | }, $results); 313 | 314 | if (class_exists($kernelClass)) { 315 | $reflectionClass = new ReflectionClass($kernelClass); 316 | if (in_array($reflectionClass->getFileName(), $filesRealPath, true)) { 317 | return $kernelClass; 318 | } 319 | } 320 | 321 | throw new ModuleRequireException( 322 | self::class, 323 | "Kernel class was not found.\n" 324 | . 'Specify directory where file with Kernel class for your application is located with `kernel_class` parameter.' 325 | ); 326 | } 327 | 328 | protected function getProfile(): ?Profile 329 | { 330 | /** @var Profiler $profiler */ 331 | $profiler = $this->getService('profiler'); 332 | try { 333 | return $profiler?->loadProfileFromResponse($this->getClient()->getResponse()); 334 | } catch (BadMethodCallException) { 335 | $this->fail('You must perform a request before using this method.'); 336 | } catch (Exception $e) { 337 | $this->fail($e->getMessage()); 338 | } 339 | 340 | return null; 341 | } 342 | 343 | /** 344 | * Grabs a Symfony Data Collector 345 | */ 346 | protected function grabCollector(string $collector, string $function, ?string $message = null): DataCollectorInterface 347 | { 348 | $profile = $this->getProfile(); 349 | if ($profile === null) { 350 | $this->fail(sprintf("The Profile is needed to use the '%s' function.", $function)); 351 | } 352 | if (!$profile->hasCollector($collector)) { 353 | $this->fail($message ?: "The '{$collector}' collector is needed to use the '{$function}' function."); 354 | } 355 | 356 | return $profile->getCollector($collector); 357 | } 358 | 359 | /** 360 | * Set the data that will be displayed when running a test with the `--debug` flag 361 | * 362 | * @param mixed $url 363 | */ 364 | protected function debugResponse($url): void 365 | { 366 | parent::debugResponse($url); 367 | if ($profile = $this->getProfile()) { 368 | $collectors = [ 369 | 'security' => 'debugSecurityData', 370 | 'mailer' => 'debugMailerData', 371 | 'time' => 'debugTimeData', 372 | ]; 373 | foreach ($collectors as $collector => $method) { 374 | if ($profile->hasCollector($collector)) { 375 | $this->$method($profile->getCollector($collector)); 376 | } 377 | } 378 | } 379 | } 380 | 381 | /** 382 | * Returns a list of recognized domain names. 383 | */ 384 | protected function getInternalDomains(): array 385 | { 386 | $internalDomains = []; 387 | $router = $this->grabRouterService(); 388 | $routes = $router->getRouteCollection(); 389 | 390 | foreach ($routes as $route) { 391 | if ($route->getHost() !== null) { 392 | $compiledRoute = $route->compile(); 393 | if ($compiledRoute->getHostRegex() !== null) { 394 | $internalDomains[] = $compiledRoute->getHostRegex(); 395 | } 396 | } 397 | } 398 | 399 | return array_unique($internalDomains); 400 | } 401 | 402 | private function setXdebugMaxNestingLevel(int $maxNestingLevel): void 403 | { 404 | if (ini_get('xdebug.max_nesting_level') < $maxNestingLevel) { 405 | ini_set('xdebug.max_nesting_level', (string)$maxNestingLevel); 406 | } 407 | } 408 | 409 | private function bootstrapEnvironment(): void 410 | { 411 | $bootstrapFile = $this->kernel->getProjectDir() . '/tests/bootstrap.php'; 412 | if (file_exists($bootstrapFile)) { 413 | require_once $bootstrapFile; 414 | } else { 415 | if (!method_exists(Dotenv::class, 'bootEnv')) { 416 | throw new LogicException( 417 | "Symfony DotEnv is missing. Try running 'composer require symfony/dotenv'\n" . 418 | "If you can't install DotEnv add your env files to the 'params' key in codeception.yml\n" . 419 | "or update your symfony/framework-bundle recipe by running:\n" . 420 | 'composer recipes:install symfony/framework-bundle --force' 421 | ); 422 | } 423 | $_ENV['APP_ENV'] = $this->config['environment']; 424 | (new Dotenv())->bootEnv('.env'); 425 | } 426 | } 427 | 428 | private function debugSecurityData(SecurityDataCollector $security): void 429 | { 430 | if ($security->isAuthenticated()) { 431 | $roles = $security->getRoles(); 432 | $rolesString = implode(',', $roles instanceof Data ? $roles->getValue() : $roles); 433 | $userInfo = $security->getUser() . ' [' . $rolesString . ']'; 434 | } else { 435 | $userInfo = 'Anonymous'; 436 | } 437 | $this->debugSection('User', $userInfo); 438 | } 439 | 440 | private function debugMailerData(MessageDataCollector $mailerCollector): void 441 | { 442 | $this->debugSection('Emails', count($mailerCollector->getEvents()->getMessages()) . ' sent'); 443 | } 444 | 445 | private function debugTimeData(TimeDataCollector $timeCollector): void 446 | { 447 | $this->debugSection('Time', number_format($timeCollector->getDuration(), 2) . ' ms'); 448 | } 449 | 450 | /** 451 | * Ensures autoloader loading of additional directories. 452 | * It is only required for CI jobs to run correctly. 453 | */ 454 | private function requireAdditionalAutoloader(): void 455 | { 456 | $autoLoader = codecept_root_dir() . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php'; 457 | if (file_exists($autoLoader)) { 458 | require_once $autoLoader; 459 | } 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/BrowserAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | assertBrowserCookieValueSame('cookie_name', 'expected_value'); 32 | * ``` 33 | */ 34 | public function assertBrowserCookieValueSame(string $name, string $expectedValue, bool $raw = false, string $path = '/', ?string $domain = null, string $message = ''): void 35 | { 36 | $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); 37 | $this->assertThatForClient(new BrowserCookieValueSame($name, $expectedValue, $raw, $path, $domain), $message); 38 | } 39 | 40 | /** 41 | * Asserts that the test client has the specified cookie set. 42 | * This indicates that the cookie was set by any response during the test. 43 | * 44 | * ``` 45 | * assertBrowserHasCookie('cookie_name'); 47 | * ``` 48 | */ 49 | public function assertBrowserHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void 50 | { 51 | $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); 52 | } 53 | 54 | /** 55 | * Asserts that the test client does not have the specified cookie set. 56 | * This indicates that the cookie was not set by any response during the test. 57 | * 58 | * ```php 59 | * assertBrowserNotHasCookie('cookie_name'); 61 | * ``` 62 | */ 63 | public function assertBrowserNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void 64 | { 65 | $this->assertThatForClient(new LogicalNot(new BrowserHasCookie($name, $path, $domain)), $message); 66 | } 67 | 68 | /** 69 | * Asserts that the specified request attribute matches the expected value. 70 | * 71 | * ```php 72 | * assertRequestAttributeValueSame('attribute_name', 'expected_value'); 74 | * ``` 75 | */ 76 | public function assertRequestAttributeValueSame(string $name, string $expectedValue, string $message = ''): void 77 | { 78 | $this->assertThat($this->getClient()->getRequest(), new RequestAttributeValueSame($name, $expectedValue), $message); 79 | } 80 | 81 | /** 82 | * Asserts that the specified response cookie is present and matches the expected value. 83 | * 84 | * ```php 85 | * assertResponseCookieValueSame('cookie_name', 'expected_value'); 87 | * ``` 88 | */ 89 | public function assertResponseCookieValueSame(string $name, string $expectedValue, string $path = '/', ?string $domain = null, string $message = ''): void 90 | { 91 | $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); 92 | $this->assertThatForResponse(new ResponseCookieValueSame($name, $expectedValue, $path, $domain), $message); 93 | } 94 | 95 | /** 96 | * Asserts that the response format matches the expected format. This checks the format returned by the `Response::getFormat()` method. 97 | * 98 | * ```php 99 | * assertResponseFormatSame('json'); 101 | * ``` 102 | */ 103 | public function assertResponseFormatSame(?string $expectedFormat, string $message = ''): void 104 | { 105 | $this->assertThatForResponse(new ResponseFormatSame($this->getClient()->getRequest(), $expectedFormat), $message); 106 | } 107 | 108 | /** 109 | * Asserts that the specified cookie is present in the response. Optionally, it can check for a specific cookie path or domain. 110 | * 111 | * ```php 112 | * assertResponseHasCookie('cookie_name'); 114 | * ``` 115 | */ 116 | public function assertResponseHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void 117 | { 118 | $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); 119 | } 120 | 121 | /** 122 | * Asserts that the specified header is available in the response. 123 | * For example, use `assertResponseHasHeader('content-type');`. 124 | * 125 | * ```php 126 | * assertResponseHasHeader('content-type'); 128 | * ``` 129 | */ 130 | public function assertResponseHasHeader(string $headerName, string $message = ''): void 131 | { 132 | $this->assertThatForResponse(new ResponseHasHeader($headerName), $message); 133 | } 134 | 135 | /** 136 | * Asserts that the specified header does not contain the expected value in the response. 137 | * For example, use `assertResponseHeaderNotSame('content-type', 'application/octet-stream');`. 138 | * 139 | * ```php 140 | * assertResponseHeaderNotSame('content-type', 'application/json'); 142 | * ``` 143 | */ 144 | public function assertResponseHeaderNotSame(string $headerName, string $expectedValue, string $message = ''): void 145 | { 146 | $this->assertThatForResponse(new LogicalNot(new ResponseHeaderSame($headerName, $expectedValue)), $message); 147 | } 148 | 149 | /** 150 | * Asserts that the specified header contains the expected value in the response. 151 | * For example, use `assertResponseHeaderSame('content-type', 'application/octet-stream');`. 152 | * 153 | * ```php 154 | * assertResponseHeaderSame('content-type', 'application/json'); 156 | * ``` 157 | */ 158 | public function assertResponseHeaderSame(string $headerName, string $expectedValue, string $message = ''): void 159 | { 160 | $this->assertThatForResponse(new ResponseHeaderSame($headerName, $expectedValue), $message); 161 | } 162 | 163 | /** 164 | * Asserts that the response was successful (HTTP status code is in the 2xx range). 165 | * 166 | * ```php 167 | * assertResponseIsSuccessful(); 169 | * ``` 170 | */ 171 | public function assertResponseIsSuccessful(string $message = '', bool $verbose = true): void 172 | { 173 | $this->assertThatForResponse(new ResponseIsSuccessful($verbose), $message); 174 | } 175 | 176 | /** 177 | * Asserts that the response is unprocessable (HTTP status code is 422). 178 | * 179 | * ```php 180 | * assertResponseIsUnprocessable(); 182 | * ``` 183 | */ 184 | public function assertResponseIsUnprocessable(string $message = '', bool $verbose = true): void 185 | { 186 | $this->assertThatForResponse(new ResponseIsUnprocessable($verbose), $message); 187 | } 188 | 189 | /** 190 | * Asserts that the specified cookie is not present in the response. Optionally, it can check for a specific cookie path or domain. 191 | * 192 | * ```php 193 | * assertResponseNotHasCookie('cookie_name'); 195 | * ``` 196 | */ 197 | public function assertResponseNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void 198 | { 199 | $this->assertThatForResponse(new LogicalNot(new ResponseHasCookie($name, $path, $domain)), $message); 200 | } 201 | 202 | /** 203 | * Asserts that the specified header is not available in the response. 204 | * 205 | * ```php 206 | * assertResponseNotHasHeader('content-type'); 208 | * ``` 209 | */ 210 | public function assertResponseNotHasHeader(string $headerName, string $message = ''): void 211 | { 212 | $this->assertThatForResponse(new LogicalNot(new ResponseHasHeader($headerName)), $message); 213 | } 214 | 215 | /** 216 | * Asserts that the response is a redirect. Optionally, you can check the target location and status code. 217 | * The expected location can be either an absolute or a relative path. 218 | * 219 | * ```php 220 | * assertResponseRedirects('/login', 302); 223 | * ``` 224 | */ 225 | public function assertResponseRedirects(?string $expectedLocation = null, ?int $expectedCode = null, string $message = '', bool $verbose = true): void 226 | { 227 | $this->assertThatForResponse(new ResponseIsRedirected($verbose), $message); 228 | 229 | if ($expectedLocation) { 230 | $constraint = class_exists(ResponseHeaderLocationSame::class) 231 | ? new ResponseHeaderLocationSame($this->getClient()->getRequest(), $expectedLocation) 232 | : new ResponseHeaderSame('Location', $expectedLocation); 233 | $this->assertThatForResponse($constraint, $message); 234 | } 235 | 236 | if ($expectedCode) { 237 | $this->assertThatForResponse(new ResponseStatusCodeSame($expectedCode), $message); 238 | } 239 | } 240 | 241 | /** 242 | * Asserts that the response status code matches the expected code. 243 | * 244 | * ```php 245 | * assertResponseStatusCodeSame(200); 247 | * ``` 248 | */ 249 | public function assertResponseStatusCodeSame(int $expectedCode, string $message = '', bool $verbose = true): void 250 | { 251 | $this->assertThatForResponse(new ResponseStatusCodeSame($expectedCode, $verbose), $message); 252 | } 253 | 254 | /** 255 | * Asserts the request matches the given route and optionally route parameters. 256 | * 257 | * ```php 258 | * assertRouteSame('profile', ['id' => 123]); 260 | * ``` 261 | * 262 | * @param array $parameters 263 | */ 264 | public function assertRouteSame(string $expectedRoute, array $parameters = [], string $message = ''): void 265 | { 266 | $request = $this->getClient()->getRequest(); 267 | $this->assertThat($request, new RequestAttributeValueSame('_route', $expectedRoute)); 268 | 269 | foreach ($parameters as $key => $value) { 270 | $this->assertThat($request, new RequestAttributeValueSame($key, (string)$value), $message); 271 | } 272 | } 273 | 274 | /** 275 | * Reboots the client's kernel. 276 | * Can be used to manually reboot the kernel when 'rebootable_client' is set to false. 277 | * 278 | * ```php 279 | * rebootClientKernel(); 284 | * 285 | * // Perform other requests 286 | * 287 | * ``` 288 | */ 289 | public function rebootClientKernel(): void 290 | { 291 | $this->getClient()->rebootKernel(); 292 | } 293 | 294 | /** 295 | * Verifies that a page is available. 296 | * By default, it checks the current page. Specify the `$url` parameter to change the page being checked. 297 | * 298 | * ```php 299 | * amOnPage('/dashboard'); 301 | * $I->seePageIsAvailable(); 302 | * 303 | * $I->seePageIsAvailable('/dashboard'); // Same as above 304 | * ``` 305 | * 306 | * @param string|null $url The URL of the page to check. If null, the current page is checked. 307 | */ 308 | public function seePageIsAvailable(?string $url = null): void 309 | { 310 | if ($url !== null) { 311 | $this->amOnPage($url); 312 | $this->seeInCurrentUrl($url); 313 | } 314 | 315 | $this->assertResponseIsSuccessful(); 316 | } 317 | 318 | /** 319 | * Navigates to a page and verifies that it redirects to another page. 320 | * 321 | * ```php 322 | * seePageRedirectsTo('/admin', '/login'); 324 | * ``` 325 | */ 326 | public function seePageRedirectsTo(string $page, string $redirectsTo): void 327 | { 328 | $client = $this->getClient(); 329 | $client->followRedirects(false); 330 | $this->amOnPage($page); 331 | 332 | $this->assertTrue( 333 | $client->getResponse()->isRedirection(), 334 | 'The response is not a redirection.' 335 | ); 336 | 337 | $client->followRedirect(); 338 | $this->seeInCurrentUrl($redirectsTo); 339 | } 340 | 341 | /** 342 | * Submits a form by specifying the form name only once. 343 | * 344 | * Use this function instead of [`$I->submitForm()`](#submitForm) to avoid repeating the form name in the field selectors. 345 | * If you have customized the names of the field selectors, use `$I->submitForm()` for full control. 346 | * 347 | * ```php 348 | * submitSymfonyForm('login_form', [ 350 | * '[email]' => 'john_doe@example.com', 351 | * '[password]' => 'secretForest' 352 | * ]); 353 | * ``` 354 | * 355 | * @param string $name The `name` attribute of the `
`. You cannot use an array as a selector here. 356 | * @param array $fields The form fields to submit. 357 | */ 358 | public function submitSymfonyForm(string $name, array $fields): void 359 | { 360 | $selector = sprintf('form[name=%s]', $name); 361 | 362 | $params = []; 363 | foreach ($fields as $key => $value) { 364 | $fixedKey = sprintf('%s%s', $name, $key); 365 | $params[$fixedKey] = $value; 366 | } 367 | 368 | $button = sprintf('%s_submit', $name); 369 | 370 | $this->submitForm($selector, $params, $button); 371 | } 372 | 373 | protected function assertThatForClient(Constraint $constraint, string $message = ''): void 374 | { 375 | $this->assertThat($this->getClient(), $constraint, $message); 376 | } 377 | 378 | protected function assertThatForResponse(Constraint $constraint, string $message = ''): void 379 | { 380 | $this->assertThat($this->getClient()->getResponse(), $constraint, $message); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | runSymfonyConsoleCommand('hello:world', ['arg' => 'argValue', 'opt1' => 'optValue'], ['input']); 24 | * ``` 25 | * 26 | * @param string $command The console command to execute. 27 | * @param array $parameters Arguments and options passed to the command 28 | * @param list $consoleInputs Inputs for interactive questions. 29 | * @param int $expectedExitCode Expected exit code. 30 | * @return string Console output (stdout). 31 | */ 32 | public function runSymfonyConsoleCommand( 33 | string $command, 34 | array $parameters = [], 35 | array $consoleInputs = [], 36 | int $expectedExitCode = 0 37 | ): string { 38 | $kernel = $this->grabKernelService(); 39 | $application = new Application($kernel); 40 | $consoleCommand = $application->find($command); 41 | $commandTester = new CommandTester($consoleCommand); 42 | $commandTester->setInputs($consoleInputs); 43 | 44 | $input = ['command' => $command] + $parameters; 45 | $options = $this->configureOptions($parameters); 46 | $exitCode = $commandTester->execute($input, $options); 47 | $output = $commandTester->getDisplay(); 48 | 49 | $this->assertSame( 50 | $expectedExitCode, 51 | $exitCode, 52 | sprintf('Command exited with %d instead of expected %d. Output: %s', $exitCode, $expectedExitCode, $output) 53 | ); 54 | 55 | return $output; 56 | } 57 | 58 | /** 59 | * @param array $parameters 60 | * @return array Options array supported by CommandTester. 61 | */ 62 | private function configureOptions(array $parameters): array 63 | { 64 | $options = []; 65 | 66 | if (in_array('--ansi', $parameters, true)) { 67 | $options['decorated'] = true; 68 | } elseif (in_array('--no-ansi', $parameters, true)) { 69 | $options['decorated'] = false; 70 | } 71 | 72 | if (in_array('--no-interaction', $parameters, true) || in_array('-n', $parameters, true)) { 73 | $options['interactive'] = false; 74 | } 75 | 76 | if (in_array('--quiet', $parameters, true) || in_array('-q', $parameters, true)) { 77 | $options['verbosity'] = OutputInterface::VERBOSITY_QUIET; 78 | $options['interactive'] = false; 79 | } 80 | 81 | if (in_array('-vvv', $parameters, true) 82 | || in_array('--verbose=3', $parameters, true) 83 | || (isset($parameters['--verbose']) && $parameters['--verbose'] === 3) 84 | ) { 85 | $options['verbosity'] = OutputInterface::VERBOSITY_DEBUG; 86 | } elseif (in_array('-vv', $parameters, true) 87 | || in_array('--verbose=2', $parameters, true) 88 | || (isset($parameters['--verbose']) && $parameters['--verbose'] === 2) 89 | ) { 90 | $options['verbosity'] = OutputInterface::VERBOSITY_VERY_VERBOSE; 91 | } elseif (in_array('-v', $parameters, true) 92 | || in_array('--verbose=1', $parameters, true) 93 | || in_array('--verbose', $parameters, true) 94 | || (isset($parameters['--verbose']) && $parameters['--verbose'] === 1) 95 | ) { 96 | $options['verbosity'] = OutputInterface::VERBOSITY_VERBOSE; 97 | } 98 | 99 | return $options; 100 | } 101 | 102 | protected function grabKernelService(): KernelInterface 103 | { 104 | return $this->grabService('kernel'); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | grabNumRecords('User::class', ['name' => 'davert']); 25 | * ``` 26 | * 27 | * @param string $entityClass The entity class 28 | * @param array $criteria Optional query criteria 29 | */ 30 | public function grabNumRecords(string $entityClass, array $criteria = []): int 31 | { 32 | $em = $this->_getEntityManager(); 33 | $repository = $em->getRepository($entityClass); 34 | 35 | if (empty($criteria)) { 36 | return (int)$repository->createQueryBuilder('a') 37 | ->select('count(a.id)') 38 | ->getQuery() 39 | ->getSingleScalarResult(); 40 | } 41 | 42 | return $repository->count($criteria); 43 | } 44 | 45 | /** 46 | * Grab a Doctrine entity repository. 47 | * Works with objects, entities, repositories, and repository interfaces. 48 | * 49 | * ```php 50 | * grabRepository($user); 52 | * $I->grabRepository(User::class); 53 | * $I->grabRepository(UserRepository::class); 54 | * $I->grabRepository(UserRepositoryInterface::class); 55 | * ``` 56 | */ 57 | public function grabRepository(object|string $mixed): ?EntityRepository 58 | { 59 | $entityRepoClass = EntityRepository::class; 60 | $isNotARepo = function () use ($mixed): void { 61 | $this->fail( 62 | sprintf("'%s' is not an entity repository", $mixed) 63 | ); 64 | }; 65 | $getRepo = function () use ($mixed, $entityRepoClass, $isNotARepo): ?EntityRepository { 66 | if (!$repo = $this->grabService($mixed)) return null; 67 | 68 | /** @var EntityRepository $repo */ 69 | if (!$repo instanceof $entityRepoClass) { 70 | $isNotARepo(); 71 | return null; 72 | } 73 | 74 | return $repo; 75 | }; 76 | 77 | if (is_object($mixed)) { 78 | $mixed = $mixed::class; 79 | } 80 | 81 | if (interface_exists($mixed)) { 82 | return $getRepo(); 83 | } 84 | 85 | if (!is_string($mixed) || !class_exists($mixed)) { 86 | $isNotARepo(); 87 | return null; 88 | } 89 | 90 | if (is_subclass_of($mixed, $entityRepoClass)) { 91 | return $getRepo(); 92 | } 93 | 94 | $em = $this->_getEntityManager(); 95 | if ($em->getMetadataFactory()->isTransient($mixed)) { 96 | $isNotARepo(); 97 | return null; 98 | } 99 | 100 | return $em->getRepository($mixed); 101 | } 102 | 103 | /** 104 | * Checks that number of given records were found in database. 105 | * 'id' is the default search parameter. 106 | * 107 | * ```php 108 | * seeNumRecords(1, User::class, ['name' => 'davert']); 110 | * $I->seeNumRecords(80, User::class); 111 | * ``` 112 | * 113 | * @param int $expectedNum Expected number of records 114 | * @param string $className A doctrine entity 115 | * @param array $criteria Optional query criteria 116 | */ 117 | public function seeNumRecords(int $expectedNum, string $className, array $criteria = []): void 118 | { 119 | $currentNum = $this->grabNumRecords($className, $criteria); 120 | 121 | $this->assertSame( 122 | $expectedNum, 123 | $currentNum, 124 | sprintf( 125 | 'The number of found %s (%d) does not match expected number %d with %s', 126 | $className, $currentNum, $expectedNum, json_encode($criteria, JSON_THROW_ON_ERROR) 127 | ) 128 | ); 129 | } 130 | } -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | assertCheckboxChecked('agree_terms'); 22 | * ``` 23 | */ 24 | public function assertCheckboxChecked(string $fieldName, string $message = ''): void 25 | { 26 | $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked"), $message); 27 | } 28 | 29 | /** 30 | * Asserts that the checkbox with the given name is not checked. 31 | * 32 | * ```php 33 | * assertCheckboxNotChecked('subscribe'); 35 | * ``` 36 | */ 37 | public function assertCheckboxNotChecked(string $fieldName, string $message = ''): void 38 | { 39 | $this->assertThatCrawler( 40 | new LogicalNot( 41 | new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked") 42 | ), $message 43 | ); 44 | } 45 | 46 | /** 47 | * Asserts that the value of the form input with the given name does not equal the expected value. 48 | * 49 | * ```php 50 | * assertInputValueNotSame('username', 'admin'); 52 | * ``` 53 | */ 54 | public function assertInputValueNotSame(string $fieldName, string $expectedValue, string $message = ''): void 55 | { 56 | $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); 57 | $this->assertThatCrawler( 58 | new LogicalNot( 59 | new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue) 60 | ), $message 61 | ); 62 | } 63 | 64 | /** 65 | * Asserts that the value of the form input with the given name equals the expected value. 66 | * 67 | * ```php 68 | * assertInputValueSame('username', 'johndoe'); 70 | * ``` 71 | */ 72 | public function assertInputValueSame(string $fieldName, string $expectedValue, string $message = ''): void 73 | { 74 | $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); 75 | $this->assertThatCrawler( 76 | new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue), 77 | $message 78 | ); 79 | } 80 | 81 | /** 82 | * Asserts that the `` element contains the given title. 83 | * 84 | * ```php 85 | * <?php 86 | * $I->assertPageTitleContains('Welcome'); 87 | * ``` 88 | */ 89 | public function assertPageTitleContains(string $expectedTitle, string $message = ''): void 90 | { 91 | $this->assertSelectorTextContains('title', $expectedTitle, $message); 92 | } 93 | 94 | /** 95 | * Asserts that the `<title>` element equals the given title. 96 | * 97 | * ```php 98 | * <?php 99 | * $I->assertPageTitleSame('Home Page'); 100 | * ``` 101 | */ 102 | public function assertPageTitleSame(string $expectedTitle, string $message = ''): void 103 | { 104 | $this->assertSelectorTextSame('title', $expectedTitle, $message); 105 | } 106 | 107 | /** 108 | * Asserts that the given selector matches at least one element in the response. 109 | * 110 | * ```php 111 | * <?php 112 | * $I->assertSelectorExists('.main-content'); 113 | * ``` 114 | */ 115 | public function assertSelectorExists(string $selector, string $message = ''): void 116 | { 117 | $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); 118 | } 119 | 120 | /** 121 | * Asserts that the given selector does not match at least one element in the response. 122 | * 123 | * ```php 124 | * <?php 125 | * $I->assertSelectorNotExists('.error'); 126 | * ``` 127 | */ 128 | public function assertSelectorNotExists(string $selector, string $message = ''): void 129 | { 130 | $this->assertThatCrawler(new LogicalNot(new CrawlerSelectorExists($selector)), $message); 131 | } 132 | 133 | /** 134 | * Asserts that the first element matching the given selector contains the expected text. 135 | * 136 | * ```php 137 | * <?php 138 | * $I->assertSelectorTextContains('h1', 'Dashboard'); 139 | * ``` 140 | */ 141 | public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void 142 | { 143 | $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); 144 | $this->assertThatCrawler(new CrawlerSelectorTextContains($selector, $text), $message); 145 | } 146 | 147 | /** 148 | * Asserts that the first element matching the given selector does not contain the expected text. 149 | * 150 | * ```php 151 | * <?php 152 | * $I->assertSelectorTextNotContains('p', 'error'); 153 | * ``` 154 | */ 155 | public function assertSelectorTextNotContains(string $selector, string $text, string $message = ''): void 156 | { 157 | $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); 158 | $this->assertThatCrawler(new LogicalNot(new CrawlerSelectorTextContains($selector, $text)), $message); 159 | } 160 | 161 | /** 162 | * Asserts that the text of the first element matching the given selector equals the expected text. 163 | * 164 | * ```php 165 | * <?php 166 | * $I->assertSelectorTextSame('h1', 'Dashboard'); 167 | * ``` 168 | */ 169 | public function assertSelectorTextSame(string $selector, string $text, string $message = ''): void 170 | { 171 | $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); 172 | $this->assertThatCrawler(new CrawlerSelectorTextSame($selector, $text), $message); 173 | } 174 | 175 | protected function assertThatCrawler(Constraint $constraint, string $message): void 176 | { 177 | $this->assertThat($this->getClient()->getCrawler(), $constraint, $message); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/EventsAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\HttpKernel\DataCollector\EventDataCollector; 8 | use function is_array; 9 | use function is_object; 10 | 11 | trait EventsAssertionsTrait 12 | { 13 | /** 14 | * Verifies that there were no events during the test. 15 | * Both regular and orphan events are checked. 16 | * 17 | * ```php 18 | * <?php 19 | * $I->dontSeeEvent(); 20 | * $I->dontSeeEvent('App\MyEvent'); 21 | * $I->dontSeeEvent(['App\MyEvent', 'App\MyOtherEvent']); 22 | * ``` 23 | * 24 | * @param string|string[]|null $expected 25 | */ 26 | public function dontSeeEvent(array|string|null $expected = null): void 27 | { 28 | $actualEvents = [...array_column($this->getCalledListeners(), 'event')]; 29 | $actual = [$this->getOrphanedEvents(), $actualEvents]; 30 | $this->assertEventTriggered(false, $expected, $actual); 31 | } 32 | 33 | /** 34 | * Verifies that one or more event listeners were not called during the test. 35 | * 36 | * ```php 37 | * <?php 38 | * $I->dontSeeEventListenerIsCalled('App\MyEventListener'); 39 | * $I->dontSeeEventListenerIsCalled(['App\MyEventListener', 'App\MyOtherEventListener']); 40 | * $I->dontSeeEventListenerIsCalled('App\MyEventListener', 'my.event); 41 | * $I->dontSeeEventListenerIsCalled('App\MyEventListener', ['my.event', 'my.other.event']); 42 | * ``` 43 | * 44 | * @param class-string|class-string[] $expected 45 | * @param string|string[] $events 46 | */ 47 | public function dontSeeEventListenerIsCalled(array|object|string $expected, array|string $events = []): void 48 | { 49 | $this->assertListenerCalled(false, $expected, $events); 50 | } 51 | 52 | /** 53 | * Verifies that one or more event listeners were not called during the test. 54 | * 55 | * ```php 56 | * <?php 57 | * $I->dontSeeEventTriggered('App\MyEvent'); 58 | * $I->dontSeeEventTriggered(new App\Events\MyEvent()); 59 | * $I->dontSeeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); 60 | * ``` 61 | * 62 | * @param object|string|string[] $expected 63 | * @deprecated Use `dontSeeEventListenerIsCalled` instead. 64 | */ 65 | public function dontSeeEventTriggered(array|object|string $expected): void 66 | { 67 | trigger_error( 68 | 'dontSeeEventTriggered is deprecated, please use dontSeeEventListenerIsCalled instead', 69 | E_USER_DEPRECATED 70 | ); 71 | $this->dontSeeEventListenerIsCalled($expected); 72 | } 73 | 74 | /** 75 | * Verifies that there were no orphan events during the test. 76 | * 77 | * An orphan event is an event that was triggered by manually executing the 78 | * [`dispatch()`](https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event) method 79 | * of the EventDispatcher but was not handled by any listener after it was dispatched. 80 | * 81 | * ```php 82 | * <?php 83 | * $I->dontSeeOrphanEvent(); 84 | * $I->dontSeeOrphanEvent('App\MyEvent'); 85 | * $I->dontSeeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); 86 | * ``` 87 | * 88 | * @param string|string[] $expected 89 | */ 90 | public function dontSeeOrphanEvent(array|string|null $expected = null): void 91 | { 92 | $actual = [$this->getOrphanedEvents()]; 93 | $this->assertEventTriggered(false, $expected, $actual); 94 | } 95 | 96 | /** 97 | * Verifies that one or more events were dispatched during the test. 98 | * Both regular and orphan events are checked. 99 | * 100 | * If you need to verify that expected event is not orphan, 101 | * add `dontSeeOrphanEvent` call. 102 | * 103 | * ```php 104 | * <?php 105 | * $I->seeEvent('App\MyEvent'); 106 | * $I->seeEvent(['App\MyEvent', 'App\MyOtherEvent']); 107 | * ``` 108 | * 109 | * @param string|string[] $expected 110 | */ 111 | public function seeEvent(array|string $expected): void 112 | { 113 | $actualEvents = [...array_column($this->getCalledListeners(), 'event')]; 114 | $actual = [$this->getOrphanedEvents(), $actualEvents]; 115 | $this->assertEventTriggered(true, $expected, $actual); 116 | } 117 | 118 | /** 119 | * Verifies that one or more event listeners were called during the test. 120 | * 121 | * ```php 122 | * <?php 123 | * $I->seeEventListenerIsCalled('App\MyEventListener'); 124 | * $I->seeEventListenerIsCalled(['App\MyEventListener', 'App\MyOtherEventListener']); 125 | * $I->seeEventListenerIsCalled('App\MyEventListener', 'my.event); 126 | * $I->seeEventListenerIsCalled('App\MyEventListener', ['my.event', 'my.other.event']); 127 | * ``` 128 | * 129 | * @param class-string|class-string[] $expected 130 | * @param string|string[] $events 131 | */ 132 | public function seeEventListenerIsCalled(array|object|string $expected, array|string $events = []): void 133 | { 134 | $this->assertListenerCalled(true, $expected, $events); 135 | } 136 | 137 | /** 138 | * Verifies that one or more event listeners were called during the test. 139 | * 140 | * ```php 141 | * <?php 142 | * $I->seeEventTriggered('App\MyEvent'); 143 | * $I->seeEventTriggered(new App\Events\MyEvent()); 144 | * $I->seeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); 145 | * ``` 146 | * 147 | * @param object|string|string[] $expected 148 | * @deprecated Use `seeEventListenerIsCalled` instead. 149 | */ 150 | public function seeEventTriggered(array|object|string $expected): void 151 | { 152 | trigger_error( 153 | 'seeEventTriggered is deprecated, please use seeEventListenerIsCalled instead', 154 | E_USER_DEPRECATED 155 | ); 156 | $this->seeEventListenerIsCalled($expected); 157 | } 158 | 159 | /** 160 | * Verifies that one or more orphan events were dispatched during the test. 161 | * 162 | * An orphan event is an event that was triggered by manually executing the 163 | * [`dispatch()`](https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event) method 164 | * of the EventDispatcher but was not handled by any listener after it was dispatched. 165 | * 166 | * ```php 167 | * <?php 168 | * $I->seeOrphanEvent('App\MyEvent'); 169 | * $I->seeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); 170 | * ``` 171 | * 172 | * @param string|string[] $expected 173 | */ 174 | public function seeOrphanEvent(array|string $expected): void 175 | { 176 | $actual = [$this->getOrphanedEvents()]; 177 | $this->assertEventTriggered(true, $expected, $actual); 178 | } 179 | 180 | protected function getCalledListeners(): array 181 | { 182 | $eventCollector = $this->grabEventCollector(__FUNCTION__); 183 | $calledListeners = $eventCollector->getCalledListeners($this->getDefaultDispatcher()); 184 | return [...$calledListeners->getValue(true)]; 185 | } 186 | 187 | protected function getOrphanedEvents(): array 188 | { 189 | $eventCollector = $this->grabEventCollector(__FUNCTION__); 190 | $orphanedEvents = $eventCollector->getOrphanedEvents($this->getDefaultDispatcher()); 191 | return [...$orphanedEvents->getValue(true)]; 192 | } 193 | 194 | protected function assertEventTriggered(bool $assertTrue, array|object|string|null $expected, array $actual): void 195 | { 196 | $actualEvents = array_merge(...$actual); 197 | 198 | if ($assertTrue) $this->assertNotEmpty($actualEvents, 'No event was triggered'); 199 | if ($expected === null) { 200 | $this->assertEmpty($actualEvents); 201 | return; 202 | } 203 | 204 | $expected = is_object($expected) ? $expected::class : $expected; 205 | foreach ((array)$expected as $expectedEvent) { 206 | $expectedEvent = is_object($expectedEvent) ? $expectedEvent::class : $expectedEvent; 207 | $eventTriggered = in_array($expectedEvent, $actualEvents); 208 | 209 | $message = $assertTrue 210 | ? "The '{$expectedEvent}' event did not trigger" 211 | : "The '{$expectedEvent}' event triggered"; 212 | $this->assertSame($assertTrue, $eventTriggered, $message); 213 | } 214 | } 215 | 216 | protected function assertListenerCalled(bool $assertTrue, array|object|string $expectedListeners, array|object|string $expectedEvents): void 217 | { 218 | $expectedListeners = is_array($expectedListeners) ? $expectedListeners : [$expectedListeners]; 219 | $expectedEvents = is_array($expectedEvents) ? $expectedEvents : [$expectedEvents]; 220 | 221 | if (empty($expectedEvents)) { 222 | $expectedEvents = [null]; 223 | } elseif (count($expectedListeners) > 1) { 224 | $this->fail('You cannot check for events when using multiple listeners. Make multiple assertions instead.'); 225 | } 226 | 227 | $actualEvents = $this->getCalledListeners(); 228 | if ($assertTrue && empty($actualEvents)) { 229 | $this->fail('No event listener was called'); 230 | } 231 | 232 | foreach ($expectedListeners as $expectedListener) { 233 | $expectedListener = is_object($expectedListener) ? $expectedListener::class : $expectedListener; 234 | 235 | foreach ($expectedEvents as $expectedEvent) { 236 | $listenerCalled = $this->listenerWasCalled($expectedListener, $expectedEvent, $actualEvents); 237 | $message = "The '{$expectedListener}' listener was called" 238 | . ($expectedEvent ? " for the '{$expectedEvent}' event" : ''); 239 | $this->assertSame($assertTrue, $listenerCalled, $message); 240 | } 241 | } 242 | } 243 | 244 | private function listenerWasCalled(string $expectedListener, ?string $expectedEvent, array $actualEvents): bool 245 | { 246 | foreach ($actualEvents as $actualEvent) { 247 | if ( 248 | isset($actualEvent['pretty'], $actualEvent['event']) 249 | && str_starts_with($actualEvent['pretty'], $expectedListener) 250 | && ($expectedEvent === null || $actualEvent['event'] === $expectedEvent) 251 | ) { 252 | return true; 253 | } 254 | } 255 | return false; 256 | } 257 | 258 | protected function getDefaultDispatcher(): string 259 | { 260 | return 'event_dispatcher'; 261 | } 262 | 263 | protected function grabEventCollector(string $function): EventDataCollector 264 | { 265 | return $this->grabCollector('events', $function); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/FormAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\Form\Extension\DataCollector\FormDataCollector; 8 | use function array_key_exists; 9 | use function in_array; 10 | use function is_int; 11 | use function sprintf; 12 | 13 | trait FormAssertionsTrait 14 | { 15 | /** 16 | * Asserts that value of the field of the first form matching the given selector does equal the expected value. 17 | * 18 | * ```php 19 | * <?php 20 | * $I->assertFormValue('#loginForm', 'username', 'john_doe'); 21 | * ``` 22 | */ 23 | public function assertFormValue(string $formSelector, string $fieldName, string $value, string $message = ''): void 24 | { 25 | $node = $this->getCLient()->getCrawler()->filter($formSelector); 26 | $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); 27 | $values = $node->form()->getValues(); 28 | $this->assertArrayHasKey($fieldName, $values, $message ?: sprintf('Field "%s" not found in form "%s".', $fieldName, $formSelector)); 29 | $this->assertSame($value, $values[$fieldName]); 30 | } 31 | 32 | /** 33 | * Asserts that the field of the first form matching the given selector does not have a value. 34 | * 35 | * ```php 36 | * <?php 37 | * $I->assertNoFormValue('#registrationForm', 'middle_name'); 38 | * ``` 39 | */ 40 | public function assertNoFormValue(string $formSelector, string $fieldName, string $message = ''): void 41 | { 42 | $node = $this->getCLient()->getCrawler()->filter($formSelector); 43 | $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); 44 | $values = $node->form()->getValues(); 45 | $this->assertArrayNotHasKey($fieldName, $values, $message ?: sprintf('Field "%s" has a value in form "%s".', $fieldName, $formSelector)); 46 | } 47 | 48 | /** 49 | * Verifies that there are no errors bound to the submitted form. 50 | * 51 | * ```php 52 | * <?php 53 | * $I->dontSeeFormErrors(); 54 | * ``` 55 | */ 56 | public function dontSeeFormErrors(): void 57 | { 58 | $formCollector = $this->grabFormCollector(__FUNCTION__); 59 | 60 | $errors = (int)$formCollector->getData()->offsetGet('nb_errors'); 61 | 62 | $this->assertSame( 63 | 0, 64 | $errors, 65 | 'Expecting that the form does not have errors, but there were!' 66 | ); 67 | } 68 | 69 | /** 70 | * Verifies that a form field has an error. 71 | * You can specify the expected error message as second parameter. 72 | * 73 | * ```php 74 | * <?php 75 | * $I->seeFormErrorMessage('username'); 76 | * $I->seeFormErrorMessage('username', 'Username is empty'); 77 | * ``` 78 | */ 79 | public function seeFormErrorMessage(string $field, ?string $message = null): void 80 | { 81 | $formCollector = $this->grabFormCollector(__FUNCTION__); 82 | 83 | if (!$forms = $formCollector->getData()->getValue(true)['forms']) { 84 | $this->fail('No forms found on the current page.'); 85 | } 86 | 87 | $fields = []; 88 | $errors = []; 89 | 90 | foreach ($forms as $form) { 91 | foreach ($form['children'] as $child) { 92 | $fieldName = $child['name']; 93 | $fields[] = $fieldName; 94 | 95 | if (!array_key_exists('errors', $child)) { 96 | continue; 97 | } 98 | 99 | foreach ($child['errors'] as $error) { 100 | $errors[$fieldName] = $error['message']; 101 | } 102 | } 103 | } 104 | 105 | if (!in_array($field, $fields)) { 106 | $this->fail("The field '{$field}' does not exist in the form."); 107 | } 108 | 109 | if (!array_key_exists($field, $errors)) { 110 | $this->fail("No form error message for field '{$field}'."); 111 | } 112 | 113 | if (!$message) { 114 | return; 115 | } 116 | 117 | $this->assertStringContainsString( 118 | $message, 119 | $errors[$field], 120 | sprintf( 121 | "There is an error message for the field '%s', but it does not match the expected message.", 122 | $field 123 | ) 124 | ); 125 | } 126 | 127 | /** 128 | * Verifies that multiple fields on a form have errors. 129 | * 130 | * If you only specify the name of the fields, this method will 131 | * verify that the field contains at least one error of any type: 132 | * 133 | * ```php 134 | * <?php 135 | * $I->seeFormErrorMessages(['telephone', 'address']); 136 | * ``` 137 | * 138 | * If you want to specify the error messages, you can do so 139 | * by sending an associative array instead, with the key being 140 | * the name of the field and the error message the value. 141 | * This method will validate that the expected error message 142 | * is contained in the actual error message, that is, 143 | * you can specify either the entire error message or just a part of it: 144 | * 145 | * ```php 146 | * <?php 147 | * $I->seeFormErrorMessages([ 148 | * 'address' => 'The address is too long', 149 | * 'telephone' => 'too short', // the full error message is 'The telephone is too short' 150 | * ]); 151 | * ``` 152 | * 153 | * If you don't want to specify the error message for some fields, 154 | * you can pass `null` as value instead of the message string, 155 | * or you can directly omit the value of that field. If that is the case, 156 | * it will be validated that that field has at least one error of any type: 157 | * 158 | * ```php 159 | * <?php 160 | * $I->seeFormErrorMessages([ 161 | * 'telephone' => 'too short', 162 | * 'address' => null, 163 | * 'postal code', 164 | * ]); 165 | * ``` 166 | * 167 | * @param string[] $expectedErrors 168 | */ 169 | public function seeFormErrorMessages(array $expectedErrors): void 170 | { 171 | foreach ($expectedErrors as $field => $message) { 172 | if (is_int($field)) { 173 | $this->seeFormErrorMessage($message); 174 | } else { 175 | $this->seeFormErrorMessage($field, $message); 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Verifies that there are one or more errors bound to the submitted form. 182 | * 183 | * ```php 184 | * <?php 185 | * $I->seeFormHasErrors(); 186 | * ``` 187 | */ 188 | public function seeFormHasErrors(): void 189 | { 190 | $formCollector = $this->grabFormCollector(__FUNCTION__); 191 | 192 | $this->assertGreaterThan( 193 | 0, 194 | $formCollector->getData()->offsetGet('nb_errors'), 195 | 'Expecting that the form has errors, but there were none!' 196 | ); 197 | } 198 | 199 | protected function grabFormCollector(string $function): FormDataCollector 200 | { 201 | return $this->grabCollector('form', $function); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; 8 | use function array_key_exists; 9 | use function is_string; 10 | 11 | trait HttpClientAssertionsTrait 12 | { 13 | /** 14 | * Asserts that the given URL has been called using, if specified, the given method body and headers. 15 | * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. 16 | * (It will succeed if the request has been called multiple times.) 17 | * 18 | * ```php 19 | * <?php 20 | * $I->assertHttpClientRequest( 21 | * 'https://example.com/api', 22 | * 'POST', 23 | * '{"data": "value"}', 24 | * ['Authorization' => 'Bearer token'] 25 | * ); 26 | * ``` 27 | */ 28 | public function assertHttpClientRequest(string $expectedUrl, string $expectedMethod = 'GET', string|array|null $expectedBody = null, array $expectedHeaders = [], string $httpClientId = 'http_client'): void 29 | { 30 | $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); 31 | $expectedRequestHasBeenFound = false; 32 | 33 | if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { 34 | $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); 35 | } 36 | 37 | foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { 38 | if (($expectedUrl !== $trace['info']['url'] && $expectedUrl !== $trace['url']) 39 | || $expectedMethod !== $trace['method'] 40 | ) { 41 | continue; 42 | } 43 | 44 | if (null !== $expectedBody) { 45 | $actualBody = null; 46 | 47 | if (null !== $trace['options']['body'] && null === $trace['options']['json']) { 48 | $actualBody = is_string($trace['options']['body']) ? $trace['options']['body'] : $trace['options']['body']->getValue(true); 49 | } 50 | 51 | if (null === $trace['options']['body'] && null !== $trace['options']['json']) { 52 | $actualBody = $trace['options']['json']->getValue(true); 53 | } 54 | 55 | if (!$actualBody) { 56 | continue; 57 | } 58 | 59 | if ($expectedBody === $actualBody) { 60 | $expectedRequestHasBeenFound = true; 61 | 62 | if (!$expectedHeaders) { 63 | break; 64 | } 65 | } 66 | } 67 | 68 | if ($expectedHeaders) { 69 | $actualHeaders = $trace['options']['headers'] ?? []; 70 | 71 | foreach ($actualHeaders as $headerKey => $actualHeader) { 72 | if (array_key_exists($headerKey, $expectedHeaders) 73 | && $expectedHeaders[$headerKey] === $actualHeader->getValue(true) 74 | ) { 75 | $expectedRequestHasBeenFound = true; 76 | break 2; 77 | } 78 | } 79 | } 80 | 81 | $expectedRequestHasBeenFound = true; 82 | break; 83 | } 84 | 85 | $this->assertTrue($expectedRequestHasBeenFound, 'The expected request has not been called: "' . $expectedMethod . '" - "' . $expectedUrl . '"'); 86 | } 87 | 88 | /** 89 | * Asserts that the given number of requests has been made on the HttpClient. 90 | * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. 91 | * 92 | * ```php 93 | * <?php 94 | * $I->assertHttpClientRequestCount(3); 95 | * ``` 96 | */ 97 | public function assertHttpClientRequestCount(int $count, string $httpClientId = 'http_client'): void 98 | { 99 | $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); 100 | 101 | $this->assertCount($count, $httpClientCollector->getClients()[$httpClientId]['traces']); 102 | } 103 | 104 | /** 105 | * Asserts that the given URL has not been called using GET or the specified method. 106 | * By default, it will check on the HttpClient, but a HttpClient id can be specified. 107 | * 108 | * ```php 109 | * <?php 110 | * $I->assertNotHttpClientRequest('https://example.com/unexpected', 'GET'); 111 | * ``` 112 | */ 113 | public function assertNotHttpClientRequest(string $unexpectedUrl, string $expectedMethod = 'GET', string $httpClientId = 'http_client'): void 114 | { 115 | $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); 116 | $unexpectedUrlHasBeenFound = false; 117 | 118 | if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { 119 | $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); 120 | } 121 | 122 | foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { 123 | if (($unexpectedUrl === $trace['info']['url'] || $unexpectedUrl === $trace['url']) 124 | && $expectedMethod === $trace['method'] 125 | ) { 126 | $unexpectedUrlHasBeenFound = true; 127 | break; 128 | } 129 | } 130 | 131 | $this->assertFalse($unexpectedUrlHasBeenFound, sprintf('Unexpected URL called: "%s" - "%s"', $expectedMethod, $unexpectedUrl)); 132 | } 133 | 134 | protected function grabHttpClientCollector(string $function): HttpClientDataCollector 135 | { 136 | return $this->grabCollector('http_client', $function); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/LoggerAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\HttpKernel\DataCollector\LoggerDataCollector; 8 | use Symfony\Component\VarDumper\Cloner\Data; 9 | use function sprintf; 10 | 11 | trait LoggerAssertionsTrait 12 | { 13 | /** 14 | * Asserts that there are no deprecation messages in Symfony's log. 15 | * 16 | * ```php 17 | * <?php 18 | * $I->amOnPage('/home'); 19 | * $I->dontSeeDeprecations(); 20 | * ``` 21 | * 22 | * @param string $message Optional custom failure message. 23 | */ 24 | public function dontSeeDeprecations(string $message = ''): void 25 | { 26 | $loggerCollector = $this->grabLoggerCollector(__FUNCTION__); 27 | $logs = $loggerCollector->getProcessedLogs(); 28 | 29 | $foundDeprecations = []; 30 | 31 | foreach ($logs as $log) { 32 | if (isset($log['type']) && $log['type'] === 'deprecation') { 33 | $msg = $log['message']; 34 | if ($msg instanceof Data) { 35 | $msg = $msg->getValue(true); 36 | } 37 | if (!is_string($msg)) { 38 | $msg = (string)$msg; 39 | } 40 | $foundDeprecations[] = $msg; 41 | } 42 | } 43 | 44 | $errorMessage = $message ?: sprintf( 45 | "Found %d deprecation message%s in the log:\n%s", 46 | count($foundDeprecations), 47 | count($foundDeprecations) > 1 ? 's' : '', 48 | implode("\n", array_map(static function ($msg) { 49 | return " - " . $msg; 50 | }, $foundDeprecations)) 51 | ); 52 | 53 | $this->assertEmpty($foundDeprecations, $errorMessage); 54 | } 55 | 56 | protected function grabLoggerCollector(string $function): LoggerDataCollector 57 | { 58 | return $this->grabCollector('logger', $function); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/MailerAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use PHPUnit\Framework\Constraint\LogicalNot; 8 | use Symfony\Component\Mailer\Event\MessageEvent; 9 | use Symfony\Component\Mailer\Event\MessageEvents; 10 | use Symfony\Component\Mailer\EventListener\MessageLoggerListener; 11 | use Symfony\Component\Mailer\Test\Constraint as MailerConstraint; 12 | use Symfony\Component\Mime\Email; 13 | 14 | trait MailerAssertionsTrait 15 | { 16 | /** 17 | * Asserts that the expected number of emails was sent. 18 | * 19 | * ```php 20 | * <?php 21 | * $I->assertEmailCount(2, 'smtp'); 22 | * ``` 23 | */ 24 | public function assertEmailCount(int $count, ?string $transport = null, string $message = ''): void 25 | { 26 | $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport), $message); 27 | } 28 | 29 | /** 30 | * Asserts that the given mailer event is not queued. 31 | * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. 32 | * 33 | * ```php 34 | * <?php 35 | * $event = $I->getMailerEvent(); 36 | * $I->assertEmailIsNotQueued($event); 37 | * ``` 38 | */ 39 | public function assertEmailIsNotQueued(MessageEvent $event, string $message = ''): void 40 | { 41 | $this->assertThat($event, new LogicalNot(new MailerConstraint\EmailIsQueued()), $message); 42 | } 43 | 44 | /** 45 | * Asserts that the given mailer event is queued. 46 | * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. 47 | * 48 | * ```php 49 | * <?php 50 | * $event = $I->getMailerEvent(); 51 | * $I->assertEmailIsQueued($event); 52 | * ``` 53 | */ 54 | public function assertEmailIsQueued(MessageEvent $event, string $message = ''): void 55 | { 56 | $this->assertThat($event, new MailerConstraint\EmailIsQueued(), $message); 57 | } 58 | 59 | /** 60 | * Asserts that the expected number of emails was queued (e.g. using the Messenger component). 61 | * 62 | * ```php 63 | * <?php 64 | * $I->assertQueuedEmailCount(1, 'smtp'); 65 | * ``` 66 | */ 67 | public function assertQueuedEmailCount(int $count, ?string $transport = null, string $message = ''): void 68 | { 69 | $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport, true), $message); 70 | } 71 | 72 | /** 73 | * Checks that no email was sent. 74 | * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: 75 | * If your app performs an HTTP redirect, you need to suppress it using [stopFollowingRedirects()](#stopFollowingRedirects) first; otherwise this check will *always* pass. 76 | * 77 | * ```php 78 | * <?php 79 | * $I->dontSeeEmailIsSent(); 80 | * ``` 81 | */ 82 | public function dontSeeEmailIsSent(): void 83 | { 84 | $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount(0)); 85 | } 86 | 87 | /** 88 | * Returns the last sent email. 89 | * The function is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: 90 | * If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](#stopFollowingRedirects) first. 91 | * See also: [grabSentEmails()](https://codeception.com/docs/modules/Symfony#grabSentEmails) 92 | * 93 | * ```php 94 | * <?php 95 | * $email = $I->grabLastSentEmail(); 96 | * $address = $email->getTo()[0]; 97 | * $I->assertSame('john_doe@example.com', $address->getAddress()); 98 | * ``` 99 | */ 100 | public function grabLastSentEmail(): ?Email 101 | { 102 | /** @var Email[] $emails */ 103 | $emails = $this->getMessageMailerEvents()->getMessages(); 104 | $lastEmail = end($emails); 105 | 106 | return $lastEmail ?: null; 107 | } 108 | 109 | /** 110 | * Returns an array of all sent emails. 111 | * The function is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: 112 | * If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](#stopFollowingRedirects) first. 113 | * See also: [grabLastSentEmail()](https://codeception.com/docs/modules/Symfony#grabLastSentEmail) 114 | * 115 | * ```php 116 | * <?php 117 | * $emails = $I->grabSentEmails(); 118 | * ``` 119 | * 120 | * @return \Symfony\Component\Mime\Email[] 121 | */ 122 | public function grabSentEmails(): array 123 | { 124 | return $this->getMessageMailerEvents()->getMessages(); 125 | } 126 | 127 | /** 128 | * Checks if the given number of emails was sent (default `$expectedCount`: 1). 129 | * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: 130 | * If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](#stopFollowingRedirects) first. 131 | * 132 | * Limitation: 133 | * If your mail is sent in a Symfony console command and you start that command in your test with [$I->runShellCommand()](https://codeception.com/docs/modules/Cli#runShellCommand), 134 | * Codeception will not notice it. 135 | * As a more professional alternative, we recommend Mailpit (see [Addons](https://codeception.com/addons)), which also lets you test the content of the mail. 136 | * 137 | * ```php 138 | * <?php 139 | * $I->seeEmailIsSent(2); 140 | * ``` 141 | * 142 | * @param int $expectedCount The expected number of emails sent 143 | */ 144 | public function seeEmailIsSent(int $expectedCount = 1): void 145 | { 146 | $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($expectedCount)); 147 | } 148 | 149 | /** 150 | * Returns the mailer event at the specified index. 151 | * 152 | * ```php 153 | * <?php 154 | * $event = $I->getMailerEvent(); 155 | * ``` 156 | */ 157 | public function getMailerEvent(int $index = 0, ?string $transport = null): ?MessageEvent 158 | { 159 | $mailerEvents = $this->getMessageMailerEvents(); 160 | $events = $mailerEvents->getEvents($transport); 161 | return $events[$index] ?? null; 162 | } 163 | 164 | protected function getMessageMailerEvents(): MessageEvents 165 | { 166 | if ($mailer = $this->getService('mailer.message_logger_listener')) { 167 | /** @var MessageLoggerListener $mailer */ 168 | return $mailer->getEvents(); 169 | } 170 | if ($mailer = $this->getService('mailer.logger_message_listener')) { 171 | /** @var MessageLoggerListener $mailer */ 172 | return $mailer->getEvents(); 173 | } 174 | $this->fail("Emails can't be tested without Symfony Mailer service."); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/MimeAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use PHPUnit\Framework\Assert; 8 | use PHPUnit\Framework\Constraint\LogicalNot; 9 | use Symfony\Component\Mime\Email; 10 | use Symfony\Component\Mime\Test\Constraint as MimeConstraint; 11 | 12 | trait MimeAssertionsTrait 13 | { 14 | /** 15 | * Verify that an email contains addresses with a [header](https://datatracker.ietf.org/doc/html/rfc4021) 16 | * `$headerName` and its expected value `$expectedValue`. 17 | * If the Email object is not specified, the last email sent is used instead. 18 | * 19 | * ```php 20 | * <?php 21 | * $I->assertEmailAddressContains('To', 'jane_doe@example.com'); 22 | * ``` 23 | */ 24 | public function assertEmailAddressContains(string $headerName, string $expectedValue, ?Email $email = null): void 25 | { 26 | $email = $this->verifyEmailObject($email, __FUNCTION__); 27 | $this->assertThat($email, new MimeConstraint\EmailAddressContains($headerName, $expectedValue)); 28 | } 29 | 30 | /** 31 | * Verify that an email has sent the specified number `$count` of attachments. 32 | * If the Email object is not specified, the last email sent is used instead. 33 | * 34 | * ```php 35 | * <?php 36 | * $I->assertEmailAttachmentCount(1); 37 | * ``` 38 | */ 39 | public function assertEmailAttachmentCount(int $count, ?Email $email = null): void 40 | { 41 | $email = $this->verifyEmailObject($email, __FUNCTION__); 42 | $this->assertThat($email, new MimeConstraint\EmailAttachmentCount($count)); 43 | } 44 | 45 | /** 46 | * Verify that an email has a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`. 47 | * If the Email object is not specified, the last email sent is used instead. 48 | * 49 | * ```php 50 | * <?php 51 | * $I->assertEmailHasHeader('Bcc'); 52 | * ``` 53 | */ 54 | public function assertEmailHasHeader(string $headerName, ?Email $email = null): void 55 | { 56 | $email = $this->verifyEmailObject($email, __FUNCTION__); 57 | $this->assertThat($email, new MimeConstraint\EmailHasHeader($headerName)); 58 | } 59 | 60 | /** 61 | * Verify that the [header](https://datatracker.ietf.org/doc/html/rfc4021) 62 | * `$headerName` of an email is not the expected one `$expectedValue`. 63 | * If the Email object is not specified, the last email sent is used instead. 64 | * 65 | * ```php 66 | * <?php 67 | * $I->assertEmailHeaderNotSame('To', 'john_doe@gmail.com'); 68 | * ``` 69 | */ 70 | public function assertEmailHeaderNotSame(string $headerName, string $expectedValue, ?Email $email = null): void 71 | { 72 | $email = $this->verifyEmailObject($email, __FUNCTION__); 73 | $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHeaderSame($headerName, $expectedValue))); 74 | } 75 | 76 | /** 77 | * Verify that the [header](https://datatracker.ietf.org/doc/html/rfc4021) 78 | * `$headerName` of an email is the same as expected `$expectedValue`. 79 | * If the Email object is not specified, the last email sent is used instead. 80 | * 81 | * ```php 82 | * <?php 83 | * $I->assertEmailHeaderSame('To', 'jane_doe@gmail.com'); 84 | * ``` 85 | */ 86 | public function assertEmailHeaderSame(string $headerName, string $expectedValue, ?Email $email = null): void 87 | { 88 | $email = $this->verifyEmailObject($email, __FUNCTION__); 89 | $this->assertThat($email, new MimeConstraint\EmailHeaderSame($headerName, $expectedValue)); 90 | } 91 | 92 | /** 93 | * Verify that the HTML body of an email contains `$text`. 94 | * If the Email object is not specified, the last email sent is used instead. 95 | * 96 | * ```php 97 | * <?php 98 | * $I->assertEmailHtmlBodyContains('Successful registration'); 99 | * ``` 100 | */ 101 | public function assertEmailHtmlBodyContains(string $text, ?Email $email = null): void 102 | { 103 | $email = $this->verifyEmailObject($email, __FUNCTION__); 104 | $this->assertThat($email, new MimeConstraint\EmailHtmlBodyContains($text)); 105 | } 106 | 107 | /** 108 | * Verify that the HTML body of an email does not contain a text `$text`. 109 | * If the Email object is not specified, the last email sent is used instead. 110 | * 111 | * ```php 112 | * <?php 113 | * $I->assertEmailHtmlBodyNotContains('userpassword'); 114 | * ``` 115 | */ 116 | public function assertEmailHtmlBodyNotContains(string $text, ?Email $email = null): void 117 | { 118 | $email = $this->verifyEmailObject($email, __FUNCTION__); 119 | $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHtmlBodyContains($text))); 120 | } 121 | 122 | /** 123 | * Verify that an email does not have a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`. 124 | * If the Email object is not specified, the last email sent is used instead. 125 | * 126 | * ```php 127 | * <?php 128 | * $I->assertEmailNotHasHeader('Bcc'); 129 | * ``` 130 | */ 131 | public function assertEmailNotHasHeader(string $headerName, ?Email $email = null): void 132 | { 133 | $email = $this->verifyEmailObject($email, __FUNCTION__); 134 | $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHasHeader($headerName))); 135 | } 136 | 137 | /** 138 | * Verify the text body of an email contains a `$text`. 139 | * If the Email object is not specified, the last email sent is used instead. 140 | * 141 | * ```php 142 | * <?php 143 | * $I->assertEmailTextBodyContains('Example text body'); 144 | * ``` 145 | */ 146 | public function assertEmailTextBodyContains(string $text, ?Email $email = null): void 147 | { 148 | $email = $this->verifyEmailObject($email, __FUNCTION__); 149 | $this->assertThat($email, new MimeConstraint\EmailTextBodyContains($text)); 150 | } 151 | 152 | /** 153 | * Verify that the text body of an email does not contain a `$text`. 154 | * If the Email object is not specified, the last email sent is used instead. 155 | * 156 | * ```php 157 | * <?php 158 | * $I->assertEmailTextBodyNotContains('My secret text body'); 159 | * ``` 160 | */ 161 | public function assertEmailTextBodyNotContains(string $text, ?Email $email = null): void 162 | { 163 | $email = $this->verifyEmailObject($email, __FUNCTION__); 164 | $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailTextBodyContains($text))); 165 | } 166 | 167 | /** 168 | * Returns the last email sent if $email is null. If no email has been sent it fails. 169 | */ 170 | private function verifyEmailObject(?Email $email, string $function): Email 171 | { 172 | $email = $email ?: $this->grabLastSentEmail(); 173 | $errorMsgTemplate = "There is no email to verify. An Email object was not specified when invoking '%s' and the application has not sent one."; 174 | return $email ?? Assert::fail( 175 | sprintf($errorMsgTemplate, $function) 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/ParameterAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; 8 | use UnitEnum; 9 | 10 | trait ParameterAssertionsTrait 11 | { 12 | /** 13 | * Grabs a Symfony parameter 14 | * 15 | * ```php 16 | * <?php 17 | * $I->grabParameter('app.business_name'); 18 | * ``` 19 | * This only works for explicitly set parameters (just using `bind` for Symfony's dependency injection is not enough). 20 | */ 21 | public function grabParameter(string $parameterName): array|bool|string|int|float|UnitEnum|null 22 | { 23 | $parameterBag = $this->grabParameterBagService(); 24 | return $parameterBag->get($parameterName); 25 | } 26 | 27 | protected function grabParameterBagService(): ParameterBagInterface 28 | { 29 | return $this->grabService('parameter_bag'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/RouterAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\Routing\Exception\ResourceNotFoundException; 8 | use Symfony\Component\Routing\Route; 9 | use Symfony\Component\Routing\RouterInterface; 10 | use function array_intersect_assoc; 11 | use function explode; 12 | use function sprintf; 13 | 14 | trait RouterAssertionsTrait 15 | { 16 | /** 17 | * Opens web page by action name 18 | * 19 | * ```php 20 | * <?php 21 | * $I->amOnAction('PostController::index'); 22 | * $I->amOnAction('HomeController'); 23 | * $I->amOnAction('ArticleController', ['slug' => 'lorem-ipsum']); 24 | * ``` 25 | */ 26 | public function amOnAction(string $action, array $params = []): void 27 | { 28 | $router = $this->grabRouterService(); 29 | $routes = $router->getRouteCollection()->getIterator(); 30 | 31 | /** @var Route $route */ 32 | foreach ($routes as $route) { 33 | $controller = $route->getDefault('_controller'); 34 | if (str_ends_with((string) $controller, $action)) { 35 | $resource = $router->match($route->getPath()); 36 | $url = $router->generate( 37 | $resource['_route'], 38 | $params 39 | ); 40 | $this->amOnPage($url); 41 | return; 42 | } 43 | } 44 | } 45 | 46 | /** 47 | * Opens web page using route name and parameters. 48 | * 49 | * ```php 50 | * <?php 51 | * $I->amOnRoute('posts.create'); 52 | * $I->amOnRoute('posts.show', ['id' => 34]); 53 | * ``` 54 | */ 55 | public function amOnRoute(string $routeName, array $params = []): void 56 | { 57 | $router = $this->grabRouterService(); 58 | if ($router->getRouteCollection()->get($routeName) === null) { 59 | $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); 60 | } 61 | 62 | $url = $router->generate($routeName, $params); 63 | $this->amOnPage($url); 64 | } 65 | 66 | /** 67 | * Invalidate previously cached routes. 68 | */ 69 | public function invalidateCachedRouter(): void 70 | { 71 | $this->unpersistService('router'); 72 | } 73 | 74 | /** 75 | * Checks that current page matches action 76 | * 77 | * ```php 78 | * <?php 79 | * $I->seeCurrentActionIs('PostController::index'); 80 | * $I->seeCurrentActionIs('HomeController'); 81 | * ``` 82 | */ 83 | public function seeCurrentActionIs(string $action): void 84 | { 85 | $router = $this->grabRouterService(); 86 | $routes = $router->getRouteCollection()->getIterator(); 87 | 88 | /** @var Route $route */ 89 | foreach ($routes as $route) { 90 | $controller = $route->getDefault('_controller'); 91 | if (str_ends_with((string) $controller, $action)) { 92 | $request = $this->getClient()->getRequest(); 93 | $currentActionFqcn = $request->attributes->get('_controller'); 94 | 95 | $this->assertStringEndsWith($action, $currentActionFqcn, "Current action is '{$currentActionFqcn}'."); 96 | return; 97 | } 98 | } 99 | 100 | $this->fail("Action '{$action}' does not exist"); 101 | } 102 | 103 | /** 104 | * Checks that current url matches route. 105 | * 106 | * ```php 107 | * <?php 108 | * $I->seeCurrentRouteIs('posts.index'); 109 | * $I->seeCurrentRouteIs('posts.show', ['id' => 8]); 110 | * ``` 111 | */ 112 | public function seeCurrentRouteIs(string $routeName, array $params = []): void 113 | { 114 | $router = $this->grabRouterService(); 115 | if ($router->getRouteCollection()->get($routeName) === null) { 116 | $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); 117 | } 118 | 119 | $uri = explode('?', $this->grabFromCurrentUrl())[0]; 120 | $uri = explode('#', $uri)[0]; 121 | $match = []; 122 | try { 123 | $match = $router->match($uri); 124 | } catch (ResourceNotFoundException) { 125 | $this->fail(sprintf('The "%s" url does not match with any route', $uri)); 126 | } 127 | 128 | $expected = ['_route' => $routeName, ...$params]; 129 | $intersection = array_intersect_assoc($expected, $match); 130 | 131 | $this->assertSame($expected, $intersection); 132 | } 133 | 134 | /** 135 | * Checks that current url matches route. 136 | * Unlike seeCurrentRouteIs, this can matches without exact route parameters 137 | * 138 | * ```php 139 | * <?php 140 | * $I->seeInCurrentRoute('my_blog_pages'); 141 | * ``` 142 | */ 143 | public function seeInCurrentRoute(string $routeName): void 144 | { 145 | $router = $this->grabRouterService(); 146 | if ($router->getRouteCollection()->get($routeName) === null) { 147 | $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); 148 | } 149 | 150 | $uri = explode('?', $this->grabFromCurrentUrl())[0]; 151 | $uri = explode('#', $uri)[0]; 152 | $matchedRouteName = ''; 153 | try { 154 | $matchedRouteName = (string)$router->match($uri)['_route']; 155 | } catch (ResourceNotFoundException) { 156 | $this->fail(sprintf('The "%s" url does not match with any route', $uri)); 157 | } 158 | 159 | $this->assertSame($matchedRouteName, $routeName); 160 | } 161 | 162 | protected function grabRouterService(): RouterInterface 163 | { 164 | return $this->grabService('router'); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/SecurityAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Bundle\SecurityBundle\Security; 8 | use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; 9 | use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; 10 | use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; 11 | use Symfony\Component\Security\Core\Security as LegacySecurity; 12 | use Symfony\Component\Security\Core\User\UserInterface; 13 | use function sprintf; 14 | 15 | trait SecurityAssertionsTrait 16 | { 17 | /** 18 | * Check that user is not authenticated. 19 | * 20 | * ```php 21 | * <?php 22 | * $I->dontSeeAuthentication(); 23 | * ``` 24 | */ 25 | public function dontSeeAuthentication(): void 26 | { 27 | $security = $this->grabSecurityService(); 28 | 29 | $this->assertFalse( 30 | $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), 31 | 'There is an user authenticated' 32 | ); 33 | } 34 | 35 | /** 36 | * Check that user is not authenticated with the 'remember me' option. 37 | * 38 | * ```php 39 | * <?php 40 | * $I->dontSeeRememberedAuthentication(); 41 | * ``` 42 | */ 43 | public function dontSeeRememberedAuthentication(): void 44 | { 45 | $security = $this->grabSecurityService(); 46 | 47 | $hasRememberMeCookie = $this->client->getCookieJar()->get('REMEMBERME'); 48 | $hasRememberMeRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); 49 | 50 | $isRemembered = $hasRememberMeCookie && $hasRememberMeRole; 51 | $this->assertFalse( 52 | $isRemembered, 53 | 'User does have remembered authentication' 54 | ); 55 | } 56 | 57 | /** 58 | * Checks that a user is authenticated. 59 | * 60 | * ```php 61 | * <?php 62 | * $I->seeAuthentication(); 63 | * ``` 64 | */ 65 | public function seeAuthentication(): void 66 | { 67 | $security = $this->grabSecurityService(); 68 | 69 | if (!$security->getUser()) { 70 | $this->fail('There is no user in session'); 71 | } 72 | 73 | $this->assertTrue( 74 | $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), 75 | 'There is no authenticated user' 76 | ); 77 | } 78 | 79 | /** 80 | * Checks that a user is authenticated with the 'remember me' option. 81 | * 82 | * ```php 83 | * <?php 84 | * $I->seeRememberedAuthentication(); 85 | * ``` 86 | */ 87 | public function seeRememberedAuthentication(): void 88 | { 89 | $security = $this->grabSecurityService(); 90 | 91 | if ($security->getUser() === null) { 92 | $this->fail('There is no user in session'); 93 | } 94 | 95 | $hasRememberMeCookie = $this->client->getCookieJar()->get('REMEMBERME'); 96 | $hasRememberMeRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); 97 | 98 | $isRemembered = $hasRememberMeCookie && $hasRememberMeRole; 99 | $this->assertTrue( 100 | $isRemembered, 101 | 'User does not have remembered authentication' 102 | ); 103 | } 104 | 105 | /** 106 | * Check that the current user has a role 107 | * 108 | * ```php 109 | * <?php 110 | * $I->seeUserHasRole('ROLE_ADMIN'); 111 | * ``` 112 | */ 113 | public function seeUserHasRole(string $role): void 114 | { 115 | $security = $this->grabSecurityService(); 116 | 117 | if (!$user = $security->getUser()) { 118 | $this->fail('There is no user in session'); 119 | } 120 | 121 | $userIdentifier = method_exists($user, 'getUserIdentifier') ? 122 | $user->getUserIdentifier() : 123 | $user->getUsername(); 124 | 125 | $this->assertTrue( 126 | $security->isGranted($role), 127 | sprintf( 128 | 'User %s has no role %s', 129 | $userIdentifier, 130 | $role 131 | ) 132 | ); 133 | } 134 | 135 | /** 136 | * Verifies that the current user has multiple roles 137 | * 138 | * ```php 139 | * <?php 140 | * $I->seeUserHasRoles(['ROLE_USER', 'ROLE_ADMIN']); 141 | * ``` 142 | * 143 | * @param string[] $roles 144 | */ 145 | public function seeUserHasRoles(array $roles): void 146 | { 147 | foreach ($roles as $role) { 148 | $this->seeUserHasRole($role); 149 | } 150 | } 151 | 152 | /** 153 | * Checks that the user's password would not benefit from rehashing. 154 | * If the user is not provided it is taken from the current session. 155 | * 156 | * You might use this function after performing tasks like registering a user or submitting a password update form. 157 | * 158 | * ```php 159 | * <?php 160 | * $I->seeUserPasswordDoesNotNeedRehash(); 161 | * $I->seeUserPasswordDoesNotNeedRehash($user); 162 | * ``` 163 | * 164 | * @param UserInterface|null $user 165 | */ 166 | public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): void 167 | { 168 | if ($user === null) { 169 | $security = $this->grabSecurityService(); 170 | if (!$user = $security->getUser()) { 171 | $this->fail('No user found to validate'); 172 | } 173 | } 174 | 175 | $hasher = $this->grabPasswordHasherService(); 176 | 177 | $this->assertFalse($hasher->needsRehash($user), 'User password needs rehash'); 178 | } 179 | 180 | protected function grabSecurityService(): Security|LegacySecurity 181 | { 182 | return $this->grabService('security.helper'); 183 | } 184 | 185 | protected function grabPasswordHasherService(): UserPasswordHasherInterface|UserPasswordEncoderInterface 186 | { 187 | $hasher = $this->getService('security.password_hasher') ?: $this->getService('security.password_encoder'); 188 | 189 | if ($hasher === null) { 190 | $this->fail('Password hasher service could not be found.'); 191 | } 192 | 193 | return $hasher; 194 | } 195 | } -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/ServicesAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Codeception\Lib\Connector\Symfony as SymfonyConnector; 8 | use PHPUnit\Framework\Assert; 9 | 10 | trait ServicesAssertionsTrait 11 | { 12 | /** 13 | * Grabs a service from the Symfony dependency injection container (DIC). 14 | * In the "test" environment, Symfony uses a special `test.service_container`. 15 | * See the "[Public Versus Private Services](https://symfony.com/doc/current/service_container/alias_private.html#marking-services-as-public-private)" documentation. 16 | * Services that aren't injected anywhere in your app, need to be defined as `public` to be accessible by Codeception. 17 | * 18 | * ```php 19 | * <?php 20 | * $em = $I->grabService('doctrine'); 21 | * ``` 22 | * 23 | * @part services 24 | */ 25 | public function grabService(string $serviceId): object 26 | { 27 | if (!$service = $this->getService($serviceId)) { 28 | Assert::fail("Service `{$serviceId}` is required by Codeception, but not loaded by Symfony. Possible solutions:\n 29 | In your `config/packages/framework.php`/`.yaml`, set `test` to `true` (when in test environment), see https://symfony.com/doc/current/reference/configuration/framework.html#test\n 30 | If you're still getting this message, you're not using that service in your app, so Symfony isn't loading it at all.\n 31 | Solution: Set it to `public` in your `config/services.php`/`.yaml`, see https://symfony.com/doc/current/service_container/alias_private.html#marking-services-as-public-private\n"); 32 | } 33 | 34 | return $service; 35 | } 36 | 37 | /** 38 | * Get service $serviceName and add it to the lists of persistent services. 39 | * 40 | * @part services 41 | */ 42 | public function persistService(string $serviceName): void 43 | { 44 | $service = $this->grabService($serviceName); 45 | $this->persistentServices[$serviceName] = $service; 46 | if ($this->client instanceof SymfonyConnector) { 47 | $this->client->persistentServices[$serviceName] = $service; 48 | } 49 | } 50 | 51 | /** 52 | * Get service $serviceName and add it to the lists of persistent services, 53 | * making that service persistent between tests. 54 | * 55 | * @part services 56 | */ 57 | public function persistPermanentService(string $serviceName): void 58 | { 59 | $service = $this->grabService($serviceName); 60 | $this->persistentServices[$serviceName] = $service; 61 | $this->permanentServices[$serviceName] = $service; 62 | if ($this->client instanceof SymfonyConnector) { 63 | $this->client->persistentServices[$serviceName] = $service; 64 | } 65 | } 66 | 67 | /** 68 | * Remove service $serviceName from the lists of persistent services. 69 | * 70 | * @part services 71 | */ 72 | public function unpersistService(string $serviceName): void 73 | { 74 | unset($this->persistentServices[$serviceName]); 75 | unset($this->permanentServices[$serviceName]); 76 | 77 | if ($this->client instanceof SymfonyConnector) { 78 | unset($this->client->persistentServices[$serviceName]); 79 | } 80 | } 81 | 82 | protected function getService(string $serviceId): ?object 83 | { 84 | $container = $this->_getContainer(); 85 | if ($container->has($serviceId)) { 86 | return $container->get($serviceId); 87 | } 88 | 89 | return null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/SessionAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\BrowserKit\Cookie; 8 | use Symfony\Component\HttpFoundation\Session\SessionInterface; 9 | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; 10 | use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; 11 | use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; 12 | use Symfony\Component\Security\Core\User\UserInterface; 13 | use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; 14 | use Symfony\Component\Security\Guard\Token\GuardTokenInterface; 15 | use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; 16 | use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; 17 | use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; 18 | use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; 19 | use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; 20 | use function is_int; 21 | use function serialize; 22 | 23 | trait SessionAssertionsTrait 24 | { 25 | /** 26 | * Login with the given user object. 27 | * The `$user` object must have a persistent identifier. 28 | * If you have more than one firewall or firewall context, you can specify the desired one as a parameter. 29 | * 30 | * ```php 31 | * <?php 32 | * $user = $I->grabEntityFromRepository(User::class, [ 33 | * 'email' => 'john_doe@example.com' 34 | * ]); 35 | * $I->amLoggedInAs($user); 36 | * ``` 37 | */ 38 | public function amLoggedInAs(UserInterface $user, string $firewallName = 'main', ?string $firewallContext = null): void 39 | { 40 | $token = $this->createAuthenticationToken($user, $firewallName); 41 | $this->loginWithToken($token, $firewallName, $firewallContext); 42 | } 43 | 44 | public function amLoggedInWithToken(TokenInterface $token, string $firewallName = 'main', ?string $firewallContext = null): void 45 | { 46 | $this->loginWithToken($token, $firewallName, $firewallContext); 47 | } 48 | 49 | protected function loginWithToken(TokenInterface $token, string $firewallName, ?string $firewallContext): void 50 | { 51 | $this->getTokenStorage()->setToken($token); 52 | 53 | $session = $this->getCurrentSession(); 54 | $sessionKey = $firewallContext ? "_security_{$firewallContext}" : "_security_{$firewallName}"; 55 | $session->set($sessionKey, serialize($token)); 56 | $session->save(); 57 | 58 | $cookie = new Cookie($session->getName(), $session->getId()); 59 | $this->client->getCookieJar()->set($cookie); 60 | } 61 | 62 | /** 63 | * Assert that a session attribute does not exist, or is not equal to the passed value. 64 | * 65 | * ```php 66 | * <?php 67 | * $I->dontSeeInSession('attribute'); 68 | * $I->dontSeeInSession('attribute', 'value'); 69 | * ``` 70 | */ 71 | public function dontSeeInSession(string $attribute, mixed $value = null): void 72 | { 73 | $session = $this->getCurrentSession(); 74 | 75 | $attributeExists = $session->has($attribute); 76 | $this->assertFalse($attributeExists, "Session attribute '{$attribute}' exists."); 77 | 78 | if (null !== $value) { 79 | $this->assertNotSame($value, $session->get($attribute)); 80 | } 81 | } 82 | 83 | /** 84 | * Go to the configured logout url (by default: `/logout`). 85 | * This method includes redirection to the destination page configured after logout. 86 | * 87 | * See the Symfony documentation on ['Logging Out'](https://symfony.com/doc/current/security.html#logging-out). 88 | */ 89 | public function goToLogoutPath(): void 90 | { 91 | $logoutPath = $this->getLogoutUrlGenerator()->getLogoutPath(); 92 | $this->amOnPage($logoutPath); 93 | } 94 | 95 | /** 96 | * Alias method for [`logoutProgrammatically()`](https://codeception.com/docs/modules/Symfony#logoutProgrammatically) 97 | * 98 | * ```php 99 | * <?php 100 | * $I->logout(); 101 | * ``` 102 | */ 103 | public function logout(): void 104 | { 105 | $this->logoutProgrammatically(); 106 | } 107 | 108 | /** 109 | * Invalidates the current user's session and expires the session cookies. 110 | * This method does not include any redirects after logging out. 111 | * 112 | * ```php 113 | * <?php 114 | * $I->logoutProgrammatically(); 115 | * ``` 116 | */ 117 | public function logoutProgrammatically(): void 118 | { 119 | if ($tokenStorage = $this->getTokenStorage()) { 120 | $tokenStorage->setToken(); 121 | } 122 | 123 | $session = $this->getCurrentSession(); 124 | $sessionName = $session->getName(); 125 | $session->invalidate(); 126 | 127 | $cookieJar = $this->client->getCookieJar(); 128 | $cookiesToExpire = ['MOCKSESSID', 'REMEMBERME', $sessionName]; 129 | foreach ($cookieJar->all() as $cookie) { 130 | $cookieName = $cookie->getName(); 131 | if (in_array($cookieName, $cookiesToExpire, true)) { 132 | $cookieJar->expire($cookieName); 133 | } 134 | } 135 | 136 | $cookieJar->flushExpiredCookies(); 137 | } 138 | 139 | /** 140 | * Assert that a session attribute exists. 141 | * 142 | * ```php 143 | * <?php 144 | * $I->seeInSession('attribute'); 145 | * $I->seeInSession('attribute', 'value'); 146 | * ``` 147 | */ 148 | public function seeInSession(string $attribute, mixed $value = null): void 149 | { 150 | $session = $this->getCurrentSession(); 151 | 152 | $attributeExists = $session->has($attribute); 153 | $this->assertTrue($attributeExists, "No session attribute with name '{$attribute}'"); 154 | 155 | if (null !== $value) { 156 | $this->assertSame($value, $session->get($attribute)); 157 | } 158 | } 159 | 160 | /** 161 | * Assert that the session has a given list of values. 162 | * 163 | * ```php 164 | * <?php 165 | * $I->seeSessionHasValues(['key1', 'key2']); 166 | * $I->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']); 167 | * ``` 168 | */ 169 | public function seeSessionHasValues(array $bindings): void 170 | { 171 | foreach ($bindings as $key => $value) { 172 | if (is_int($key)) { 173 | $this->seeInSession($value); 174 | } else { 175 | $this->seeInSession($key, $value); 176 | } 177 | } 178 | } 179 | 180 | protected function getTokenStorage(): ?TokenStorageInterface 181 | { 182 | return $this->getService('security.token_storage'); 183 | } 184 | 185 | protected function getLogoutUrlGenerator(): ?LogoutUrlGenerator 186 | { 187 | return $this->getService('security.logout_url_generator'); 188 | } 189 | 190 | protected function getAuthenticator(): ?AuthenticatorInterface 191 | { 192 | return $this->getService(AuthenticatorInterface::class); 193 | } 194 | 195 | protected function getCurrentSession(): SessionInterface 196 | { 197 | $container = $this->_getContainer(); 198 | 199 | if ($this->getSymfonyMajorVersion() < 6 || $container->has('session')) { 200 | return $container->get('session'); 201 | } 202 | 203 | $session = $container->get('session.factory')->createSession(); 204 | $container->set('session', $session); 205 | 206 | return $session; 207 | } 208 | 209 | protected function getSymfonyMajorVersion(): int 210 | { 211 | return $this->kernel::MAJOR_VERSION; 212 | } 213 | 214 | /** 215 | * @return TokenInterface|GuardTokenInterface 216 | */ 217 | protected function createAuthenticationToken(UserInterface $user, string $firewallName) 218 | { 219 | $roles = $user->getRoles(); 220 | if ($this->getSymfonyMajorVersion() < 6) { 221 | return $this->config['guard'] 222 | ? new PostAuthenticationGuardToken($user, $firewallName, $roles) 223 | : new UsernamePasswordToken($user, null, $firewallName, $roles); 224 | } 225 | 226 | if ($this->config['authenticator']) { 227 | if ($authenticator = $this->getAuthenticator()) { 228 | $passport = new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), fn () => $user)); 229 | return $authenticator->createToken($passport, $firewallName); 230 | } 231 | return new PostAuthenticationToken($user, $firewallName, $roles); 232 | } 233 | return new UsernamePasswordToken($user, $firewallName, $roles); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/TimeAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\HttpKernel\DataCollector\TimeDataCollector; 8 | use function round; 9 | use function sprintf; 10 | 11 | trait TimeAssertionsTrait 12 | { 13 | /** 14 | * Asserts that the time a request lasted is less than expected. 15 | * 16 | * If the page performed an HTTP redirect, only the time of the last request will be taken into account. 17 | * You can modify this behavior using [stopFollowingRedirects()](https://codeception.com/docs/modules/Symfony#stopFollowingRedirects) first. 18 | * 19 | * Also, note that using code coverage can significantly increase the time it takes to resolve a request, 20 | * which could lead to unreliable results when used together. 21 | * 22 | * It is recommended to set [`rebootable_client`](https://codeception.com/docs/modules/Symfony#Config) to `true` (=default), 23 | * cause otherwise this assertion gives false results if you access multiple pages in a row, or if your app performs a redirect. 24 | * 25 | * @param int|float $expectedMilliseconds The expected time in milliseconds 26 | */ 27 | public function seeRequestTimeIsLessThan(int|float $expectedMilliseconds): void 28 | { 29 | $expectedMilliseconds = round($expectedMilliseconds, 2); 30 | 31 | $timeCollector = $this->grabTimeCollector(__FUNCTION__); 32 | 33 | $actualMilliseconds = round($timeCollector->getDuration(), 2); 34 | 35 | $this->assertLessThan( 36 | $expectedMilliseconds, 37 | $actualMilliseconds, 38 | sprintf( 39 | 'The request duration was expected to be less than %d ms, but it was actually %d ms.', 40 | $expectedMilliseconds, 41 | $actualMilliseconds 42 | ) 43 | ); 44 | } 45 | 46 | protected function grabTimeCollector(string $function): TimeDataCollector 47 | { 48 | return $this->grabCollector('time', $function); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/TranslationAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\Translation\DataCollector\TranslationDataCollector; 8 | use Symfony\Component\VarDumper\Cloner\Data; 9 | 10 | trait TranslationAssertionsTrait 11 | { 12 | /** 13 | * Asserts that no fallback translations were found. 14 | * 15 | * ```php 16 | * <?php 17 | * $I->dontSeeFallbackTranslations(); 18 | * ``` 19 | */ 20 | public function dontSeeFallbackTranslations(): void 21 | { 22 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 23 | $fallbacks = $translationCollector->getCountFallbacks(); 24 | 25 | $this->assertSame( 26 | $fallbacks, 27 | 0, 28 | "Expected no fallback translations, but found {$fallbacks}." 29 | ); 30 | } 31 | 32 | /** 33 | * Asserts that no missing translations were found. 34 | * 35 | * ```php 36 | * <?php 37 | * $I->dontSeeMissingTranslations(); 38 | * ``` 39 | */ 40 | public function dontSeeMissingTranslations(): void 41 | { 42 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 43 | $missings = $translationCollector->getCountMissings(); 44 | 45 | $this->assertSame( 46 | $missings, 47 | 0, 48 | "Expected no missing translations, but found {$missings}." 49 | ); 50 | } 51 | 52 | /** 53 | * Grabs the count of defined translations. 54 | * 55 | * ```php 56 | * <?php 57 | * $count = $I->grabDefinedTranslations(); 58 | * ``` 59 | * 60 | * @return int The count of defined translations. 61 | */ 62 | public function grabDefinedTranslationsCount(): int 63 | { 64 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 65 | return $translationCollector->getCountDefines(); 66 | } 67 | 68 | /** 69 | * Asserts that there are no missing translations and no fallback translations. 70 | * 71 | * ```php 72 | * <?php 73 | * $I->seeAllTranslationsDefined(); 74 | * ``` 75 | */ 76 | public function seeAllTranslationsDefined(): void 77 | { 78 | $this->dontSeeMissingTranslations(); 79 | $this->dontSeeFallbackTranslations(); 80 | } 81 | 82 | /** 83 | * Asserts that the default locale is the expected one. 84 | * 85 | * ```php 86 | * <?php 87 | * $I->seeDefaultLocaleIs('en'); 88 | * ``` 89 | * 90 | * @param string $expectedLocale The expected default locale 91 | */ 92 | public function seeDefaultLocaleIs(string $expectedLocale): void 93 | { 94 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 95 | $locale = $translationCollector->getLocale(); 96 | 97 | $this->assertSame( 98 | $expectedLocale, 99 | $locale, 100 | "Expected default locale '{$expectedLocale}', but found '{$locale}'." 101 | ); 102 | } 103 | 104 | /** 105 | * Asserts that the fallback locales match the expected ones. 106 | * 107 | * ```php 108 | * <?php 109 | * $I->seeFallbackLocalesAre(['es', 'fr']); 110 | * ``` 111 | * 112 | * @param string[] $expectedLocales The expected fallback locales 113 | */ 114 | public function seeFallbackLocalesAre(array $expectedLocales): void 115 | { 116 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 117 | $fallbackLocales = $translationCollector->getFallbackLocales(); 118 | 119 | if ($fallbackLocales instanceof Data) { 120 | $fallbackLocales = $fallbackLocales->getValue(true); 121 | } 122 | 123 | $this->assertSame( 124 | $expectedLocales, 125 | $fallbackLocales, 126 | "Fallback locales do not match expected." 127 | ); 128 | } 129 | 130 | /** 131 | * Asserts that the count of fallback translations is less than the given limit. 132 | * 133 | * ```php 134 | * <?php 135 | * $I->seeFallbackTranslationsCountLessThan(10); 136 | * ``` 137 | * 138 | * @param int $limit Maximum count of fallback translations 139 | */ 140 | public function seeFallbackTranslationsCountLessThan(int $limit): void 141 | { 142 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 143 | $fallbacks = $translationCollector->getCountFallbacks(); 144 | 145 | $this->assertLessThan( 146 | $limit, 147 | $fallbacks, 148 | "Expected fewer than {$limit} fallback translations, but found {$fallbacks}." 149 | ); 150 | } 151 | 152 | /** 153 | * Asserts that the count of missing translations is less than the given limit. 154 | * 155 | * ```php 156 | * <?php 157 | * $I->seeMissingTranslationsCountLessThan(5); 158 | * ``` 159 | * 160 | * @param int $limit Maximum count of missing translations 161 | */ 162 | public function seeMissingTranslationsCountLessThan(int $limit): void 163 | { 164 | $translationCollector = $this->grabTranslationCollector(__FUNCTION__); 165 | $missings = $translationCollector->getCountMissings(); 166 | 167 | $this->assertLessThan( 168 | $limit, 169 | $missings, 170 | "Expected fewer than {$limit} missing translations, but found {$missings}." 171 | ); 172 | } 173 | 174 | protected function grabTranslationCollector(string $function): TranslationDataCollector 175 | { 176 | return $this->grabCollector('translation', $function); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/TwigAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Bridge\Twig\DataCollector\TwigDataCollector; 8 | use function array_key_first; 9 | 10 | trait TwigAssertionsTrait 11 | { 12 | /** 13 | * Asserts that a template was not rendered in the response. 14 | * 15 | * ```php 16 | * <?php 17 | * $I->dontSeeRenderedTemplate('home.html.twig'); 18 | * ``` 19 | */ 20 | public function dontSeeRenderedTemplate(string $template): void 21 | { 22 | $twigCollector = $this->grabTwigCollector(__FUNCTION__); 23 | 24 | $templates = $twigCollector->getTemplates(); 25 | 26 | $this->assertArrayNotHasKey( 27 | $template, 28 | $templates, 29 | "Template {$template} was rendered." 30 | ); 31 | } 32 | 33 | /** 34 | * Asserts that the current template matches the expected template. 35 | * 36 | * ```php 37 | * <?php 38 | * $I->seeCurrentTemplateIs('home.html.twig'); 39 | * ``` 40 | */ 41 | public function seeCurrentTemplateIs(string $expectedTemplate): void 42 | { 43 | $twigCollector = $this->grabTwigCollector(__FUNCTION__); 44 | 45 | $templates = $twigCollector->getTemplates(); 46 | $actualTemplate = empty($templates) ? 'N/A' : (string) array_key_first($templates); 47 | 48 | $this->assertSame( 49 | $expectedTemplate, 50 | $actualTemplate, 51 | "Actual template {$actualTemplate} does not match expected template {$expectedTemplate}." 52 | ); 53 | } 54 | 55 | /** 56 | * Asserts that a template was rendered in the response. 57 | * That includes templates built with [inheritance](https://twig.symfony.com/doc/3.x/templates.html#template-inheritance). 58 | * 59 | * ```php 60 | * <?php 61 | * $I->seeRenderedTemplate('home.html.twig'); 62 | * $I->seeRenderedTemplate('layout.html.twig'); 63 | * ``` 64 | */ 65 | public function seeRenderedTemplate(string $template): void 66 | { 67 | $twigCollector = $this->grabTwigCollector(__FUNCTION__); 68 | 69 | $templates = $twigCollector->getTemplates(); 70 | 71 | $this->assertArrayHasKey( 72 | $template, 73 | $templates, 74 | "Template {$template} was not rendered." 75 | ); 76 | } 77 | 78 | protected function grabTwigCollector(string $function): TwigDataCollector 79 | { 80 | return $this->grabCollector('twig', $function); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Codeception\Module\Symfony; 6 | 7 | use Symfony\Component\Validator\ConstraintViolationInterface; 8 | use Symfony\Component\Validator\Validator\ValidatorInterface; 9 | 10 | trait ValidatorAssertionsTrait 11 | { 12 | /** 13 | * Asserts that the given subject fails validation. 14 | * This assertion does not concern the exact number of violations. 15 | * 16 | * ```php 17 | * <?php 18 | * $I->dontSeeViolatedConstraint($subject); 19 | * $I->dontSeeViolatedConstraint($subject, 'propertyName'); 20 | * $I->dontSeeViolatedConstraint($subject, 'propertyName', 'Symfony\Validator\ConstraintClass'); 21 | * ``` 22 | */ 23 | public function dontSeeViolatedConstraint(object $subject, ?string $propertyPath = null, ?string $constraint = null): void 24 | { 25 | $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); 26 | $this->assertCount(0, $violations, 'Constraint violations found.'); 27 | } 28 | 29 | /** 30 | * Asserts that the given subject passes validation. 31 | * This assertion does not concern the exact number of violations. 32 | * 33 | * ```php 34 | * <?php 35 | * $I->seeViolatedConstraint($subject); 36 | * $I->seeViolatedConstraint($subject, 'propertyName'); 37 | * $I->seeViolatedConstraint($subject, 'propertyName', 'Symfony\Validator\ConstraintClass'); 38 | * ``` 39 | */ 40 | public function seeViolatedConstraint(object $subject, ?string $propertyPath = null, ?string $constraint = null): void 41 | { 42 | $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); 43 | $this->assertNotCount(0, $violations, 'No constraint violations found.'); 44 | } 45 | 46 | /** 47 | * Asserts the exact number of violations for the given subject. 48 | * 49 | * ```php 50 | * <?php 51 | * $I->seeViolatedConstraintsCount(3, $subject); 52 | * $I->seeViolatedConstraintsCount(2, $subject, 'propertyName'); 53 | * ``` 54 | */ 55 | public function seeViolatedConstraintsCount(int $expected, object $subject, ?string $propertyPath = null, ?string $constraint = null): void 56 | { 57 | $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); 58 | $this->assertCount($expected, $violations); 59 | } 60 | 61 | /** 62 | * Asserts that a constraint violation message or a part of it is present in the subject's violations. 63 | * 64 | * ```php 65 | * <?php 66 | * $I->seeViolatedConstraintMessage('too short', $user, 'address'); 67 | * ``` 68 | */ 69 | public function seeViolatedConstraintMessage(string $expected, object $subject, string $propertyPath): void 70 | { 71 | $violations = $this->getViolationsForSubject($subject, $propertyPath); 72 | $containsExpected = false; 73 | foreach ($violations as $violation) { 74 | if ($violation->getPropertyPath() === $propertyPath && str_contains((string)$violation->getMessage(), $expected)) { 75 | $containsExpected = true; 76 | break; 77 | } 78 | } 79 | 80 | $this->assertTrue($containsExpected, 'The violation messages do not contain: ' . $expected); 81 | } 82 | 83 | /** @return ConstraintViolationInterface[] */ 84 | protected function getViolationsForSubject(object $subject, ?string $propertyPath = null, ?string $constraint = null): array 85 | { 86 | $validator = $this->getValidatorService(); 87 | $violations = $propertyPath ? $validator->validateProperty($subject, $propertyPath) : $validator->validate($subject); 88 | 89 | $violations = iterator_to_array($violations); 90 | 91 | if ($constraint !== null) { 92 | return (array)array_filter( 93 | $violations, 94 | static fn(ConstraintViolationInterface $violation): bool => get_class((object)$violation->getConstraint()) === $constraint && 95 | ($propertyPath === null || $violation->getPropertyPath() === $propertyPath) 96 | ); 97 | } 98 | 99 | return $violations; 100 | } 101 | 102 | protected function getValidatorService(): ValidatorInterface 103 | { 104 | return $this->grabService(ValidatorInterface::class); 105 | } 106 | } 107 | --------------------------------------------------------------------------------