├── .github └── workflows │ └── run-checks-tests.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Annotation │ ├── DataSet.php │ ├── Parameter.php │ └── Skipped.php ├── Auth │ ├── AuthInterface.php │ ├── BasicHttpAuth.php │ └── NoAuth.php ├── Exception │ └── RouteNameNotFoundException.php ├── HttpSmokeTestCase.php ├── RequestDataSet.php ├── RequestDataSetConfig.php ├── RequestDataSetGenerator.php ├── RequestDataSetGeneratorFactory.php ├── RouteConfig.php ├── RouteConfigCustomizer.php ├── RouteInfo.php ├── RouterAdapter │ ├── RouterAdapterInterface.php │ └── SymfonyRouterAdapter.php └── Test │ └── TestController.php └── tests └── Unit ├── RequestDataSetGeneratorTest.php └── RouterAdapter └── SymfonyRouterAdapterTest.php /.github/workflows/run-checks-tests.yaml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | concurrency: 3 | group: ${{ github.ref }} 4 | cancel-in-progress: true 5 | name: "Checks and tests" 6 | jobs: 7 | checks-and-tests: 8 | name: Run checks and tests in PHP ${{ matrix.php-versions }} ${{ matrix.composer-prefered-dependencies }} 9 | runs-on: ubuntu-22.04 10 | strategy: 11 | matrix: 12 | php-versions: ['8.3'] 13 | composer-preferred-dependencies: ['--prefer-lowest', ''] 14 | fail-fast: false 15 | steps: 16 | - name: Sleep for 15 seconds to ensure that split packages has been promoted to packagist.org 17 | run: sleep 15s 18 | shell: bash 19 | - name: GIT checkout branch - ${{ github.ref }} 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.ref }} 23 | - name: Install PHP, extensions and tools 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php-versions }} 27 | extensions: bcmath, gd, intl, pdo_pgsql, redis, pgsql, zip 28 | tools: composer 29 | - name: Install Composer dependencies 30 | run: composer update --optimize-autoloader --no-interaction ${{ matrix.composer-preferred-dependencies }} 31 | - name: Run PHPUnit 32 | run: php vendor/bin/phpunit tests 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.php_cs.cache 3 | /composer.lock 4 | /vendor 5 | /.phpunit.cache 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your contributions to Shopsys HTTP Smoke Testing package. 4 | Together we are making Shopsys Platform better. 5 | 6 | This repository is READ-ONLY. 7 | If you want to [report issues](https://github.com/shopsys/shopsys/issues/new) and/or send [pull requests](https://github.com/shopsys/shopsys/compare), 8 | please use the main [Shopsys repository](https://github.com/shopsys/shopsys). 9 | 10 | Please check our [Contribution Guide](https://github.com/shopsys/shopsys/blob/HEAD/CONTRIBUTING.md) before contributing. 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2023 Shopsys s.r.o., http://www.shopsys.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 11 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 12 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 13 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 14 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopsys HTTP Smoke Testing 2 | 3 | [![Downloads](https://img.shields.io/packagist/dt/shopsys/http-smoke-testing.svg)](https://packagist.org/packages/shopsys/http-smoke-testing) 4 | 5 | This package enables you to do simple HTTP smoke testing of your Symfony application. 6 | 7 | Basically, it generates a HTTP request for every page (controller action) provided by the application router and then asserts that the returned HTTP response code is correct. 8 | 9 | While this is not a very sophisticated check, it can answer the essential question _"does it run?"_. 10 | It prevents you from triggering _500 Server Error_ on some seemingly unrelated page when you are doing changes in shared code. 11 | Moreover, after initial configuration it is almost maintenance-free as it checks any new routes automatically. 12 | 13 | This repository is maintained by [shopsys/shopsys] monorepo, information about changes is in its `CHANGELOG` file. 14 | 15 | ## Installation 16 | 17 | Add the package to `require-dev` in your application: 18 | 19 | ``` 20 | composer require --dev shopsys/http-smoke-testing 21 | ``` 22 | 23 | This package internally uses [PHPUnit](https://phpunit.de/) to run the tests. 24 | That means that you need to setup your `phpunit.xml` properly. 25 | Fortunately, Symfony comes with example configuration. 26 | Renaming the `phpunit.xml.dist` in your project root (or `app/phpunit.xml.dist` on Symfony 2) should be sufficient. 27 | 28 | _Note: If you did not find the file in your project check out the example in [Symfony Standard Edition](https://github.com/symfony/symfony-standard)._ 29 | 30 | ## Usage 31 | 32 | Create [new PHPUnit test](https://phpunit.de/manual/current/en/writing-tests-for-phpunit.html) extending [`\Shopsys\HttpSmokeTesting\HttpSmokeTestCase`](./src/HttpSmokeTestCase.php) class and implement `customizeRouteConfigs` method. 33 | 34 | You can run your new test by: 35 | 36 | ``` 37 | php vendor/bin/phpunit tests/AppBundle/Smoke/SmokeTest.php 38 | ``` 39 | 40 | (or `php bin/phpunit -c app/phpunit.xml src/AppBundle/Tests/Smoke/SmokeTest.php` on Symfony 2) 41 | 42 | **Warning: This package checks all routes by making real requests.** 43 | **It is important not to execute it on production data.** 44 | **You may unknowingly delete or modify your data or real requests on 3rd party services.** 45 | Even if you implement some way of protecting the application from side-effect (eg. database transaction wrapping) you should never execute tests on production data. 46 | 47 | ### Example test class 48 | 49 | ```php 50 | namespace Tests\AppBundle\Smoke; 51 | 52 | use Shopsys\HttpSmokeTesting\Auth\BasicHttpAuth; 53 | use Shopsys\HttpSmokeTesting\HttpSmokeTestCase; 54 | use Shopsys\HttpSmokeTesting\RouteConfig; 55 | use Shopsys\HttpSmokeTesting\RouteConfigCustomizer; 56 | use Shopsys\HttpSmokeTesting\RouteInfo; 57 | use Symfony\Component\HttpFoundation\Request; 58 | 59 | class SmokeTest extends HttpSmokeTestCase { 60 | /** 61 | * @param \Shopsys\HttpSmokeTesting\RouteConfigCustomizer $routeConfigCustomizer 62 | */ 63 | protected function customizeRouteConfigs(RouteConfigCustomizer $routeConfigCustomizer) 64 | { 65 | $routeConfigCustomizer 66 | ->customize(function (RouteConfig $config, RouteInfo $info) { 67 | // This function will be called on every RouteConfig provided by RouterAdapter 68 | if ($info->getRouteName()[0] === '_') { 69 | // You can use RouteConfig to change expected behavior or skip testing particular routes 70 | $config->skipRoute('Route name is prefixed with "_" meaning internal route.'); 71 | } 72 | }) 73 | ->customizeByRouteName('acme_demo_secured_hello', function (RouteConfig $config, RouteInfo $info) { 74 | // You can customize RouteConfig to use authentication for secured routes 75 | $config->changeDefaultRequestDataSet('Log in as "user".') 76 | ->setAuth(new BasicHttpAuth('user', 'userpass')); 77 | }); 78 | } 79 | 80 | /** 81 | * @param \Symfony\Component\HttpFoundation\Request $request 82 | * @return \Symfony\Component\HttpFoundation\Response 83 | */ 84 | protected function handleRequest(Request $request) 85 | { 86 | $entityManager = self::$kernel->getContainer()->get('doctrine.orm.entity_manager'); 87 | 88 | // Enclose request handling in rolled-back database transaction to prevent side-effects 89 | $entityManager->beginTransaction(); 90 | $response = parent::handleRequest($request); 91 | $entityManager->rollback(); 92 | 93 | return $response; 94 | } 95 | } 96 | ``` 97 | 98 | ## Documentation 99 | 100 | By default the test makes request to every route without using any authentication or providing any parameters and expects the response to have HTTP status code _200 OK_. 101 | 102 | To change this behavior you must implement method `customizeRouteConfigs(RouteConfigCustomizer $routeConfigCustomizer)` in your test. 103 | 104 | [`RouteConfigCustomizer`](./src/RouteConfigCustomizer.php) provides two methods for customizing individual route requests: 105 | 106 | - `customize` accepts callback `function (RouteConfig $config, RouteInfo $info) {...}` as the only argument. 107 | This is called with each [`RouteConfig`](./src/RouteConfig.php) along with [`RouteInfo`](./src/RouteInfo.php) collected from your router. 108 | This method is useful when you want to define general rules for multiple routes (eg. skip all routes with name starting with underscore). 109 | - `customizeByRouteName` accepts a single route name or an array of route names as the first argument and same callback as `customize` as the second argument. 110 | This is called with each [`RouteConfig`](./src/RouteConfig.php) along with [`RouteInfo`](./src/RouteInfo.php) with matching route name. 111 | If matching route config is not found a [`RouteNameNotFoundException`](./src/Exception/RouteNameNotFoundException.php) is thrown. 112 | This method is useful when you want to define rules for specific routes (eg. logging in to some secured route). 113 | 114 | In your customizing callback you can call three methods on [`RouteConfig`](./src/RouteConfig.php) to change the tested behavior: 115 | 116 | - `skipRoute` can be called to skip this route during test. 117 | - `changeDefaultRequestDataSet` is the main method for configuring routes. 118 | It returns [`RequestDataSet`](./src/RequestDataSet.php) object offering the setters needed to change the actual behavior: 119 | - `setExpectedStatusCode` changes the expected response HTTP status code that will be asserted. 120 | - `setAuth` changes the authentication method for the route. 121 | (Use [`NoAuth`](./src/Auth/NoAuth.php) for anonymous access, [`BasicHttpAuth`](./src/Auth/BasicHttpAuth.php) for logging in via basic http headers 122 | or implement your own method using [`AuthInterface`](./src/Auth/AuthInterface.php).) 123 | - `setParameter` specifies value of a route parameter by name. 124 | - `addCallDuringTestExecution` adds a callback `function (RequestDataSet $requestDataSet, ContainerInterface $container) { ... }` to be called before test execution. 125 | (Useful for code that needs to access the same instance of container as the test method, eg. adding CSRF token as a route parameter) 126 | - `addExtraRequestDataSet` can be used to test more requests on the same route (eg. test a secured route as both logged in and anonymous user). 127 | Returns [`RequestDataSet`](./src/RequestDataSet.php) that you can use the same way as the result from `changeDefaultRequestDataSet`. 128 | All configured options will extend the values from default request data set (even when you change the default [`RequestDataSet`](./src/RequestDataSet.php) after you add the extra [`RequestDataSet`](./src/RequestDataSet.php)). 129 | 130 | _Note: All three methods of [`RouteConfigCustomizer`](./src/RouteConfigCustomizer.php) accept `string $debugNote` as an argument._ 131 | _It is useful for describing the reasons of your configuration change because it may help you with debugging when the test fails._ 132 | 133 | Additionally you can override these methods in your implementation of [`HttpSmokeTestCase`](./src/HttpSmokeTestCase.php) to further change the test behavior: 134 | 135 | - `setUp` to change the way your kernel is booted (eg. boot it with different options). 136 | - `getRouterAdapter` to change the object responsible for collecting routes from your application and generating urls. 137 | - `createRequest` if you have specific needs about the way `Request` is created from [`RequestDataSet`](./src/RequestDataSet.php). 138 | - `handleRequest` to customize handling `Request` in your application (eg. you can wrap it in database transaction to roll it back into original state). 139 | 140 | ### Annotations 141 | 142 | To make smoke test configuration a little easier, you can use the annotations: 143 | 144 | #### DataSet 145 | 146 | Used for setting expected status code based on provided paramteters. 147 | 148 | ``` 149 | @DataSet(statusCode=404, parameters={ 150 | @Parameter(name="name", value="Batman") 151 | }) 152 | ``` 153 | 154 | - arguments: 155 | - `parameters` _(optional)_ 156 | - `statusCode` _(optional, default = `200`)_ 157 | 158 | #### Parameter 159 | 160 | Parameter defines value for specified parameter. 161 | 162 | ``` 163 | @Parameter(name="name", value="Batman") 164 | ``` 165 | 166 | - arguments: 167 | - `name` _(required)_ 168 | - `value` _(required)_ 169 | 170 | #### Skipped 171 | 172 | Mark test as skipped 173 | 174 | ``` 175 | @Skipped() 176 | ``` 177 | 178 | You can add them directly to your controller methods. See the example in [`Shopsys\HttpSmokeTesting\Test\TestController`](./src/Test/TestController.php). 179 | 180 | _Note: You should avoid using annotations with configuring via `changeDefaultRequestDataSet()` on same route. It may result in unexpected behavior._ 181 | 182 | ## Troubleshooting 183 | 184 | ### Tests do not fail on non-existing route 185 | 186 | PHPUnit by default does not fail on warnings. Setting `failOnWarning="true"` in `phpunit.xml` fixes this problem. 187 | 188 | ## Contributing 189 | 190 | Thank you for your contributions to Shopsys HTTP Smoke Testing package. 191 | Together we are making Shopsys Platform better. 192 | 193 | This repository is READ-ONLY. 194 | If you want to [report issues](https://github.com/shopsys/shopsys/issues/new) and/or send [pull requests](https://github.com/shopsys/shopsys/compare), 195 | please use the main [Shopsys repository](https://github.com/shopsys/shopsys). 196 | 197 | Please check our [Contribution Guide](https://github.com/shopsys/shopsys/blob/HEAD/CONTRIBUTING.md) before contributing. 198 | 199 | ## Support 200 | 201 | What to do when you are in troubles or need some help? 202 | The best way is to join our [Slack](https://join.slack.com/t/shopsysframework/shared_invite/zt-11wx9au4g-e5pXei73UJydHRQ7nVApAQ). 203 | 204 | If you want to [report issues](https://github.com/shopsys/shopsys/issues/new), please use the main [Shopsys repository](https://github.com/shopsys/shopsys). 205 | 206 | [shopsys/shopsys]: (https://github.com/shopsys/shopsys) 207 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopsys/http-smoke-testing", 3 | "type": "library", 4 | "description": "HTTP smoke test case for testing all configured routes in your Symfony project", 5 | "keywords": ["smoke testing", "testing", "Symfony", "routing", "PHPUnit"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Shopsys", 10 | "homepage": "https://www.shopsys.com/" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "Shopsys\\HttpSmokeTesting\\": "src/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "Tests\\HttpSmokeTesting\\": "tests/" 21 | } 22 | }, 23 | "require": { 24 | "php": "^8.3", 25 | "phpunit/phpunit": "^11.2.1", 26 | "symfony/framework-bundle": "^6.4", 27 | "symfony/http-foundation": "^6.4", 28 | "symfony/dependency-injection": "^6.4", 29 | "symfony/routing": "^6.4", 30 | "doctrine/annotations": "^1.10.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Annotation/DataSet.php: -------------------------------------------------------------------------------- 1 | username = $username; 22 | $this->password = $password; 23 | } 24 | 25 | /** 26 | * @param \Symfony\Component\HttpFoundation\Request $request 27 | */ 28 | public function authenticateRequest(Request $request) 29 | { 30 | $request->server->set('PHP_AUTH_USER', $this->username); 31 | 32 | if ($this->password !== null) { 33 | $request->server->set('PHP_AUTH_PW', $this->password); 34 | } 35 | 36 | $request->headers->add($request->server->getHeaders()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Auth/NoAuth.php: -------------------------------------------------------------------------------- 1 | static::APP_ENV, 37 | 'debug' => static::APP_DEBUG, 38 | ]); 39 | } 40 | 41 | /** 42 | * The main test method for smoke testing of all routes in your application. 43 | * 44 | * You must configure the provided RequestDataSets by implementing customizeRouteConfigs method. 45 | * If you need custom behavior for creating or handling requests in your application you should override the 46 | * createRequest or handleRequest method. 47 | * 48 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 49 | */ 50 | #[DataProvider('httpResponseTestDataProvider')] 51 | final public function testHttpResponse(RequestDataSet $requestDataSet) 52 | { 53 | if ($requestDataSet->isSkipped()) { 54 | $message = sprintf('Test for route "%s" was skipped.', $requestDataSet->getRouteName()); 55 | $this->markTestSkipped($this->getMessageWithDebugNotes($requestDataSet, $message)); 56 | } 57 | 58 | $request = $this->createRequest($requestDataSet); 59 | 60 | $requestDataSet->executeCallsDuringTestExecution(static::$kernel->getContainer()); 61 | 62 | $request->attributes->add($requestDataSet->getParameters()); 63 | 64 | $response = $this->handleRequest($request); 65 | 66 | $this->assertResponse($response, $requestDataSet); 67 | } 68 | 69 | /** 70 | * Data provider for the testHttpResponse method. 71 | * 72 | * This method gets all RouteInfo objects provided by RouterAdapter. It then passes them into 73 | * customizeRouteConfigs() method for customization and returns the resulting RequestDataSet objects. 74 | * 75 | * @return \Shopsys\HttpSmokeTesting\RequestDataSet[][] 76 | */ 77 | public static function httpResponseTestDataProvider() 78 | { 79 | static::boot(); 80 | 81 | /** @var \Shopsys\FrameworkBundle\Component\Domain\Domain $domain */ 82 | $domain = static::$kernel->getContainer()->get(Domain::class); 83 | $domain->switchDomainById(Domain::FIRST_DOMAIN_ID); 84 | 85 | $requestDataSetGeneratorFactory = new RequestDataSetGeneratorFactory(); 86 | /** @var \Shopsys\HttpSmokeTesting\RequestDataSetGenerator[] $requestDataSetGenerators */ 87 | $requestDataSetGenerators = []; 88 | 89 | $allRouteInfo = static::getRouterAdapter()->getAllRouteInfo(); 90 | 91 | foreach ($allRouteInfo as $routeInfo) { 92 | $requestDataSetGenerators[] = $requestDataSetGeneratorFactory->create($routeInfo); 93 | } 94 | 95 | $routeConfigCustomizer = new RouteConfigCustomizer($requestDataSetGenerators); 96 | 97 | static::customizeRouteConfigs($routeConfigCustomizer); 98 | 99 | $requestDataSets = []; 100 | 101 | foreach ($requestDataSetGenerators as $requestDataSetGenerator) { 102 | $requestDataSets = array_merge($requestDataSets, $requestDataSetGenerator->generateRequestDataSets()); 103 | } 104 | 105 | return array_map( 106 | function (RequestDataSet $requestDataSet) { 107 | return [$requestDataSet]; 108 | }, 109 | $requestDataSets, 110 | ); 111 | } 112 | 113 | /** 114 | * @return \Shopsys\HttpSmokeTesting\RouterAdapter\RouterAdapterInterface 115 | */ 116 | protected static function getRouterAdapter() 117 | { 118 | $router = static::$kernel->getContainer()->get('router'); 119 | 120 | return new SymfonyRouterAdapter($router); 121 | } 122 | 123 | /** 124 | * This method must be implemented to customize and configure the test cases for individual routes 125 | * 126 | * @param \Shopsys\HttpSmokeTesting\RouteConfigCustomizer $routeConfigCustomizer 127 | */ 128 | abstract protected static function customizeRouteConfigs(RouteConfigCustomizer $routeConfigCustomizer); 129 | 130 | /** 131 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 132 | * @return \Symfony\Component\HttpFoundation\Request 133 | */ 134 | protected static function createRequest(RequestDataSet $requestDataSet) 135 | { 136 | $uri = static::getRouterAdapter()->generateUri($requestDataSet); 137 | 138 | $request = Request::create($uri); 139 | /** @var \Symfony\Component\HttpFoundation\Session\SessionFactory $sessionFactory */ 140 | $sessionFactory = static::$kernel->getContainer()->get('test.service_container')->get('session.factory'); 141 | /** @var \Symfony\Component\HttpFoundation\RequestStack $requestStack */ 142 | $requestStack = static::$kernel->getContainer()->get(RequestStack::class); 143 | 144 | $session = $sessionFactory->createSession(); 145 | $request->setSession($session); 146 | 147 | $requestDataSet->getAuth() 148 | ->authenticateRequest($request); 149 | 150 | $requestStack->push($request); 151 | 152 | return $request; 153 | } 154 | 155 | /** 156 | * @param \Symfony\Component\HttpFoundation\Request $request 157 | * @return \Symfony\Component\HttpFoundation\Response 158 | */ 159 | protected function handleRequest(Request $request) 160 | { 161 | return static::$kernel->handle($request); 162 | } 163 | 164 | /** 165 | * @param \Symfony\Component\HttpFoundation\Response $response 166 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 167 | */ 168 | protected function assertResponse(Response $response, RequestDataSet $requestDataSet) 169 | { 170 | $failMessage = sprintf( 171 | 'Failed asserting that status code %d for route "%s" is identical to expected %d', 172 | $response->getStatusCode(), 173 | $requestDataSet->getRouteName(), 174 | $requestDataSet->getExpectedStatusCode(), 175 | ); 176 | $this->assertSame( 177 | $requestDataSet->getExpectedStatusCode(), 178 | $response->getStatusCode(), 179 | $this->getMessageWithDebugNotes($requestDataSet, $failMessage), 180 | ); 181 | } 182 | 183 | /** 184 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 185 | * @param string $message 186 | * @return string 187 | */ 188 | protected function getMessageWithDebugNotes(RequestDataSet $requestDataSet, $message) 189 | { 190 | if (count($requestDataSet->getDebugNotes()) > 0) { 191 | $indentedDebugNotes = array_map(function ($debugNote) { 192 | return "\n" . ' - ' . $debugNote; 193 | }, $requestDataSet->getDebugNotes()); 194 | $message .= "\n" . 'Notes for this data set:' . implode($indentedDebugNotes); 195 | } 196 | 197 | return $message; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/RequestDataSet.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $parameters; 27 | 28 | /** 29 | * @var string[] 30 | */ 31 | private array $debugNotes; 32 | 33 | /** 34 | * @var callable[] 35 | */ 36 | private array $callsDuringTestExecution; 37 | 38 | /** 39 | * @param string $routeName 40 | */ 41 | public function __construct($routeName) 42 | { 43 | $this->routeName = $routeName; 44 | $this->skipped = false; 45 | $this->parameters = []; 46 | $this->debugNotes = []; 47 | $this->callsDuringTestExecution = []; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getRouteName() 54 | { 55 | return $this->routeName; 56 | } 57 | 58 | /** 59 | * @return bool 60 | */ 61 | public function isSkipped() 62 | { 63 | return $this->skipped; 64 | } 65 | 66 | /** 67 | * @return \Shopsys\HttpSmokeTesting\Auth\AuthInterface 68 | */ 69 | public function getAuth() 70 | { 71 | if ($this->auth === null) { 72 | return new NoAuth(); 73 | } 74 | 75 | return $this->auth; 76 | } 77 | 78 | /** 79 | * @return int 80 | */ 81 | public function getExpectedStatusCode() 82 | { 83 | if ($this->expectedStatusCode === null) { 84 | return self::DEFAULT_EXPECTED_STATUS_CODE; 85 | } 86 | 87 | return $this->expectedStatusCode; 88 | } 89 | 90 | /** 91 | * @return array 92 | */ 93 | public function getParameters() 94 | { 95 | return $this->parameters; 96 | } 97 | 98 | /** 99 | * @return string[] 100 | */ 101 | public function getDebugNotes() 102 | { 103 | return $this->debugNotes; 104 | } 105 | 106 | /** 107 | * @param \Symfony\Component\DependencyInjection\ContainerInterface $container 108 | * @return $this 109 | */ 110 | public function executeCallsDuringTestExecution(ContainerInterface $container) 111 | { 112 | foreach ($this->callsDuringTestExecution as $customization) { 113 | $customization($this, $container); 114 | } 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * @return $this 121 | */ 122 | public function skip() 123 | { 124 | $this->skipped = true; 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * @param \Shopsys\HttpSmokeTesting\Auth\AuthInterface $auth 131 | * @return $this 132 | */ 133 | public function setAuth(AuthInterface $auth) 134 | { 135 | $this->auth = $auth; 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * @param int $code 142 | * @return $this 143 | */ 144 | public function setExpectedStatusCode($code) 145 | { 146 | $this->expectedStatusCode = $code; 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * @param string $name 153 | * @param mixed $value 154 | * @return $this 155 | */ 156 | public function setParameter($name, $value) 157 | { 158 | $this->parameters[$name] = $value; 159 | 160 | return $this; 161 | } 162 | 163 | /** 164 | * @param string $debugNote 165 | * @return $this 166 | */ 167 | public function addDebugNote($debugNote) 168 | { 169 | $this->debugNotes[] = $debugNote; 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Provided $callback will be called with instance of this and ContainerInterface as arguments 176 | * 177 | * Useful for code that needs to access the same instance of container as the test method. 178 | * 179 | * @param callable $callback 180 | * @return $this 181 | */ 182 | public function addCallDuringTestExecution($callback) 183 | { 184 | $this->callsDuringTestExecution[] = $callback; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * Merges values from specified $requestDataSet into this instance. 191 | * 192 | * It is used to merge extra RequestDataSet into default RequestDataSet. 193 | * Values that were not specified in $requestDataSet have no effect on result. 194 | * 195 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 196 | * @return $this 197 | */ 198 | public function mergeExtraValuesFrom(self $requestDataSet) 199 | { 200 | if ($requestDataSet->auth !== null) { 201 | $this->setAuth($requestDataSet->getAuth()); 202 | } 203 | 204 | if ($requestDataSet->expectedStatusCode !== null) { 205 | $this->setExpectedStatusCode($requestDataSet->getExpectedStatusCode()); 206 | } 207 | 208 | foreach ($requestDataSet->getParameters() as $name => $value) { 209 | $this->setParameter($name, $value); 210 | } 211 | 212 | foreach ($requestDataSet->getDebugNotes() as $debugNote) { 213 | $this->addDebugNote($debugNote); 214 | } 215 | 216 | foreach ($requestDataSet->callsDuringTestExecution as $callback) { 217 | $this->addCallDuringTestExecution($callback); 218 | } 219 | 220 | return $this; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/RequestDataSetConfig.php: -------------------------------------------------------------------------------- 1 | defaultRequestDataSet = new RequestDataSet($this->routeInfo->getRouteName()); 25 | $this->extraRequestDataSets = []; 26 | } 27 | 28 | public function fulfillRequestFromAnnotations(): void 29 | { 30 | foreach ($this->routeInfo->getAnnotations() as $index => $annotation) { 31 | if ($annotation instanceof Skipped) { 32 | $this->defaultRequestDataSet->skip(); 33 | } elseif ($annotation instanceof DataSet) { 34 | $this->fulfillRequestDataSetFromAnnotation($this->getRequestDataSetForIteration($index), $annotation); 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * @param int $index 41 | * @return \Shopsys\HttpSmokeTesting\RequestDataSet 42 | */ 43 | private function getRequestDataSetForIteration(int $index): RequestDataSet 44 | { 45 | if ($index === 0) { 46 | return $this->defaultRequestDataSet; 47 | } 48 | 49 | return $this->addExtraRequestDataSet(); 50 | } 51 | 52 | /** 53 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 54 | * @param \Shopsys\HttpSmokeTesting\Annotation\DataSet $annotation 55 | */ 56 | private function fulfillRequestDataSetFromAnnotation(RequestDataSet $requestDataSet, DataSet $annotation): void 57 | { 58 | if ($annotation->statusCode) { 59 | $requestDataSet->setExpectedStatusCode($annotation->statusCode); 60 | } 61 | 62 | foreach ($annotation->parameters as $parameter) { 63 | $requestDataSet->setParameter($parameter->name, $parameter->value); 64 | } 65 | } 66 | 67 | /** 68 | * @return \Shopsys\HttpSmokeTesting\RouteInfo 69 | */ 70 | public function getRouteInfo() 71 | { 72 | return $this->routeInfo; 73 | } 74 | 75 | /** 76 | * @return \Shopsys\HttpSmokeTesting\RequestDataSet[] 77 | */ 78 | public function generateRequestDataSets() 79 | { 80 | $requestDataSets = [clone $this->defaultRequestDataSet]; 81 | 82 | foreach ($this->extraRequestDataSets as $extraRequestDataSet) { 83 | $defaultRequestDataSetClone = clone $this->defaultRequestDataSet; 84 | $requestDataSets[] = $defaultRequestDataSetClone->mergeExtraValuesFrom($extraRequestDataSet); 85 | } 86 | 87 | return $requestDataSets; 88 | } 89 | 90 | /** 91 | * @param string|null $debugNote 92 | * @return \Shopsys\HttpSmokeTesting\RequestDataSetGenerator 93 | */ 94 | public function skipRoute($debugNote = null) 95 | { 96 | $this->defaultRequestDataSet->skip(); 97 | 98 | if ($debugNote !== null) { 99 | $this->defaultRequestDataSet->addDebugNote('Skipped test case: ' . $debugNote); 100 | } 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * @param string|null $debugNote 107 | * @return \Shopsys\HttpSmokeTesting\RequestDataSet 108 | */ 109 | public function changeDefaultRequestDataSet($debugNote = null) 110 | { 111 | $requestDataSet = $this->defaultRequestDataSet; 112 | 113 | if ($debugNote !== null) { 114 | $requestDataSet->addDebugNote($debugNote); 115 | } 116 | 117 | return $requestDataSet; 118 | } 119 | 120 | /** 121 | * @param string|null $debugNote 122 | * @return \Shopsys\HttpSmokeTesting\RequestDataSet 123 | */ 124 | public function addExtraRequestDataSet($debugNote = null) 125 | { 126 | $requestDataSet = new RequestDataSet($this->routeInfo->getRouteName()); 127 | $this->extraRequestDataSets[] = $requestDataSet; 128 | 129 | if ($debugNote !== null) { 130 | $requestDataSet->addDebugNote('Extra test case: ' . $debugNote); 131 | } 132 | 133 | return $requestDataSet; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/RequestDataSetGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | fulfillRequestFromAnnotations(); 17 | 18 | return $requestDataSetGenerator; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/RouteConfig.php: -------------------------------------------------------------------------------- 1 | requestDataSetGenerators as $requestDataSetGenerator) { 29 | $callback($requestDataSetGenerator, $requestDataSetGenerator->getRouteInfo()); 30 | } 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Provided $callback will be called with RouteConfig and RouteInfo that matches by route name as arguments 37 | * 38 | * @see \Shopsys\HttpSmokeTesting\RouteConfig 39 | * @see \Shopsys\HttpSmokeTesting\RouteInfo 40 | * @param string|string[] $routeName 41 | * @param callable $callback 42 | * @return \Shopsys\HttpSmokeTesting\RouteConfigCustomizer 43 | */ 44 | public function customizeByRouteName($routeName, $callback) 45 | { 46 | $routeNames = (array)$routeName; 47 | $foundRouteNames = []; 48 | 49 | foreach ($this->requestDataSetGenerators as $requestDataSetGenerator) { 50 | $routeInfo = $requestDataSetGenerator->getRouteInfo(); 51 | 52 | if (!in_array($routeInfo->getRouteName(), $routeNames, true)) { 53 | continue; 54 | } 55 | 56 | $callback($requestDataSetGenerator, $routeInfo); 57 | $foundRouteNames[] = $routeInfo->getRouteName(); 58 | } 59 | 60 | $notFoundRouteNames = array_diff($routeNames, $foundRouteNames); 61 | 62 | if (count($notFoundRouteNames) > 0) { 63 | throw new RouteNameNotFoundException($notFoundRouteNames); 64 | } 65 | 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/RouteInfo.php: -------------------------------------------------------------------------------- 1 | routeName = $routeName; 21 | } 22 | 23 | /** 24 | * @return string 25 | */ 26 | public function getRouteName() 27 | { 28 | return $this->routeName; 29 | } 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function getRoutePath() 35 | { 36 | return $this->route->getPath(); 37 | } 38 | 39 | /** 40 | * @return string 41 | */ 42 | public function getRouteCondition() 43 | { 44 | return $this->route->getCondition(); 45 | } 46 | 47 | /** 48 | * @param string $method 49 | * @return bool 50 | */ 51 | public function isHttpMethodAllowed($method) 52 | { 53 | $methods = $this->route->getMethods(); 54 | 55 | return count($methods) === 0 || in_array(strtoupper($method), $methods, true); 56 | } 57 | 58 | /** 59 | * @param string $name 60 | * @return bool 61 | */ 62 | public function isRouteParameterRequired($name) 63 | { 64 | return !$this->route->hasDefault($name) && in_array($name, $this->getRouteParameterNames(), true); 65 | } 66 | 67 | /** 68 | * @return string[] 69 | */ 70 | public function getRouteParameterNames() 71 | { 72 | $compiledRoute = $this->route->compile(); 73 | 74 | return $compiledRoute->getVariables(); 75 | } 76 | 77 | /** 78 | * @return array 79 | */ 80 | public function getAnnotations(): array 81 | { 82 | return $this->annotations; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/RouterAdapter/RouterAdapterInterface.php: -------------------------------------------------------------------------------- 1 | annotationsReader = new AnnotationReader(); 30 | } 31 | 32 | /** 33 | * @return \Shopsys\HttpSmokeTesting\RouteInfo[] 34 | */ 35 | public function getAllRouteInfo(): array 36 | { 37 | $allRouteInfo = []; 38 | 39 | foreach ($this->router->getRouteCollection() as $routeName => $route) { 40 | $allRouteInfo[] = new RouteInfo($routeName, $route, $this->extractAnnotationsForRoute($route)); 41 | } 42 | 43 | return $allRouteInfo; 44 | } 45 | 46 | /** 47 | * @param \Symfony\Component\Routing\Route $route 48 | * @return array 49 | */ 50 | private function extractAnnotationsForRoute(Route $route): array 51 | { 52 | if ($route->hasDefault('_controller')) { 53 | return $this->extractAnnotationForController($route->getDefault('_controller')); 54 | } 55 | 56 | return []; 57 | } 58 | 59 | /** 60 | * @param string $controller 61 | * @return array 62 | */ 63 | private function extractAnnotationForController(string $controller): array 64 | { 65 | try { 66 | $reflectionMethod = new ReflectionMethod($controller); 67 | } catch (ReflectionException $e) { 68 | return []; 69 | } 70 | 71 | return $this->getControllerMethodAnnotations($reflectionMethod); 72 | } 73 | 74 | /** 75 | * @param \ReflectionMethod $reflectionMethod 76 | * @return array 77 | */ 78 | private function getControllerMethodAnnotations(ReflectionMethod $reflectionMethod): array 79 | { 80 | $annotations = []; 81 | 82 | foreach ($this->annotationsReader->getMethodAnnotations($reflectionMethod) as $annotation) { 83 | if ($annotation instanceof DataSet || $annotation instanceof Skipped) { 84 | $annotations[] = $annotation; 85 | } 86 | } 87 | 88 | return $annotations; 89 | } 90 | 91 | /** 92 | * @param \Shopsys\HttpSmokeTesting\RequestDataSet $requestDataSet 93 | * @return string|null 94 | */ 95 | public function generateUri(RequestDataSet $requestDataSet): ?string 96 | { 97 | return $this->router->generate($requestDataSet->getRouteName(), $requestDataSet->getParameters()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Test/TestController.php: -------------------------------------------------------------------------------- 1 | createRequestDataSetGenerator('test_route_path', 'test_route_name'); 20 | 21 | $requestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 22 | 23 | self::assertCount(1, $requestDataSets); 24 | } 25 | 26 | public function testGeneratorCanAddExtraRequestDataSet() 27 | { 28 | $requestDataSetGenerator = $this->createRequestDataSetGenerator('test_route_path', 'test_route_name'); 29 | 30 | $requestDataSetGenerator->addExtraRequestDataSet(); 31 | $requestDataSetGenerator->addExtraRequestDataSet(); 32 | $requestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 33 | 34 | self::assertCount(3, $requestDataSets); 35 | } 36 | 37 | public function testGeneratorGeneratesUniqueInstancesOfEqualRequestDataSet() 38 | { 39 | $requestDataSetGenerator = $this->createRequestDataSetGenerator('test_route_path', 'test_route_name'); 40 | 41 | $firstRequestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 42 | $secondRequestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 43 | 44 | self::assertEquals($firstRequestDataSets[0], $secondRequestDataSets[0]); 45 | self::assertNotSame($firstRequestDataSets[0], $secondRequestDataSets[0]); 46 | } 47 | 48 | /** 49 | * @param string $routePath 50 | * @param string $routeName 51 | * @param array $annotations 52 | * @return \Shopsys\HttpSmokeTesting\RequestDataSetGenerator 53 | */ 54 | private function createRequestDataSetGenerator($routePath, $routeName, array $annotations = []) 55 | { 56 | $route = new Route($routePath); 57 | $routeInfo = new RouteInfo($routeName, $route, $annotations); 58 | $requestDataSetGeneratorFactory = new RequestDataSetGeneratorFactory(); 59 | 60 | return $requestDataSetGeneratorFactory->create($routeInfo); 61 | } 62 | 63 | /** 64 | * @param \Shopsys\HttpSmokeTesting\Annotation\DataSet $dataSet 65 | * @param int $statusCode 66 | * @param array $parameters 67 | */ 68 | #[DataProvider('getDataSets')] 69 | public function testGeneratorGenerateRequestDataSetFromDataSetAnnotation( 70 | DataSet $dataSet, 71 | int $statusCode, 72 | array $parameters, 73 | ) { 74 | $requestDataSetGenerator = $this->createRequestDataSetGenerator( 75 | 'test_route_path', 76 | 'test_route_name', 77 | [$dataSet], 78 | ); 79 | $requestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 80 | 81 | self::assertCount(1, $requestDataSets); 82 | self::assertSame($statusCode, $requestDataSets[0]->getExpectedStatusCode()); 83 | self::assertEquals($parameters, $requestDataSets[0]->getParameters()); 84 | } 85 | 86 | public function testGeneratorGeneratesRequestDataSetsFromDataSetAnnotations() 87 | { 88 | $parameter1 = new Parameter(); 89 | $parameter1->name = 'name'; 90 | $parameter1->value = 'Batman'; 91 | 92 | $parameter2 = new Parameter(); 93 | $parameter2->name = 'name'; 94 | $parameter2->value = 'World'; 95 | 96 | $dataSet1 = new DataSet(); 97 | $dataSet1->parameters = [$parameter1]; 98 | 99 | $dataSet2 = new DataSet(); 100 | $dataSet2->parameters = [$parameter2]; 101 | $dataSet2->statusCode = 404; 102 | 103 | $annotations = [ 104 | $dataSet1, 105 | $dataSet2, 106 | ]; 107 | 108 | $requestDataSetGenerator = $this->createRequestDataSetGenerator( 109 | 'test_route_path', 110 | 'test_route_name', 111 | $annotations, 112 | ); 113 | 114 | $requestDataSets = $requestDataSetGenerator->generateRequestDataSets(); 115 | 116 | self::assertCount(2, $requestDataSets); 117 | 118 | self::assertEquals(['name' => 'Batman'], $requestDataSets[0]->getParameters()); 119 | self::assertEquals(['name' => 'World'], $requestDataSets[1]->getParameters()); 120 | self::assertSame(404, $requestDataSets[1]->getExpectedStatusCode()); 121 | } 122 | 123 | /** 124 | * @return array 125 | */ 126 | public static function getDataSets(): array 127 | { 128 | $parameter1 = new Parameter(); 129 | $parameter1->name = 'name'; 130 | $parameter1->value = 'Batman'; 131 | 132 | $parameter2 = new Parameter(); 133 | $parameter2->name = 'foo'; 134 | $parameter2->value = 'Bar'; 135 | 136 | $dataSet1 = new DataSet(); 137 | $dataSet1->parameters = [$parameter1]; 138 | 139 | $dataSet2 = new DataSet(); 140 | $dataSet2->parameters = [$parameter1, $parameter2]; 141 | $dataSet2->statusCode = 404; 142 | 143 | $dataSet3 = new DataSet(); 144 | 145 | $dataSet4 = new DataSet(); 146 | $dataSet4->statusCode = 302; 147 | 148 | $dataSet5 = new DataSet(); 149 | $dataSet5->statusCode = 500; 150 | $dataSet5->parameters = []; 151 | 152 | return [ 153 | [$dataSet1, 200, ['name' => 'Batman']], 154 | [$dataSet2, 404, ['name' => 'Batman', 'foo' => 'Bar']], 155 | [$dataSet3, 200, []], 156 | [$dataSet4, 302, []], 157 | [$dataSet5, 500, []], 158 | ]; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/Unit/RouterAdapter/SymfonyRouterAdapterTest.php: -------------------------------------------------------------------------------- 1 | getAllRouteInfo(); 29 | self::assertCount(3, $routeInfos); 30 | 31 | $route1 = $routeInfos[0]; 32 | self::assertInstanceOf(RouteInfo::class, $route1); 33 | self::assertSame('/hello/{name}', $route1->getRoutePath()); 34 | self::assertCount(2, $route1->getAnnotations()); 35 | 36 | $route1DataSet1 = $route1->getAnnotations()[0]; 37 | $route1DataSet2 = $route1->getAnnotations()[1]; 38 | self::assertInstanceOf(DataSet::class, $route1DataSet1); 39 | self::assertInstanceOf(DataSet::class, $route1DataSet2); 40 | self::assertSame(404, $route1DataSet2->statusCode); 41 | self::assertCount(1, $route1DataSet2->parameters); 42 | self::assertInstanceOf(Parameter::class, $route1DataSet2->parameters[0]); 43 | 44 | $route2 = $routeInfos[1]; 45 | self::assertInstanceOf(RouteInfo::class, $route2); 46 | self::assertSame('/test', $route2->getRoutePath()); 47 | self::assertCount(1, $route2->getAnnotations()); 48 | 49 | $route2DataSet1 = $route2->getAnnotations()[0]; 50 | self::assertInstanceOf(DataSet::class, $route2DataSet1); 51 | 52 | self::assertCount(1, $route2DataSet1->parameters); 53 | $route2DataSetParameter = $route2DataSet1->parameters[0]; 54 | self::assertInstanceOf(Parameter::class, $route2DataSetParameter); 55 | self::assertSame('myName', $route2DataSetParameter->name); 56 | self::assertSame('Batman', $route2DataSetParameter->value); 57 | 58 | $route3 = $routeInfos[2]; 59 | self::assertInstanceOf(RouteInfo::class, $route3); 60 | self::assertSame('/untested', $route3->getRoutePath()); 61 | self::assertCount(1, $route3->getAnnotations()); 62 | self::assertInstanceOf(Skipped::class, $route3->getAnnotations()[0]); 63 | } 64 | } 65 | --------------------------------------------------------------------------------