├── src ├── RouteTestable.php ├── Autoload.php ├── RouteTest.php ├── RouteTestingTestCall.php └── RouteResolver.php ├── LICENSE.md ├── rector.php ├── composer.json └── phpstan-baseline.neon /src/RouteTestable.php: -------------------------------------------------------------------------------- 1 | parameters[$binding] = $value; 15 | 16 | return $this; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Autoload.php: -------------------------------------------------------------------------------- 1 | paths([ 19 | __DIR__.'/src', 20 | ]); 21 | 22 | $rectorConfig->sets([ 23 | LevelSetList::UP_TO_PHP_81, 24 | SetList::EARLY_RETURN, 25 | SetList::TYPE_DECLARATION, 26 | ]); 27 | 28 | $rectorConfig->skip([ 29 | ReadOnlyPropertyRector::class, 30 | ClosureToArrowFunctionRector::class, 31 | FirstClassCallableRector::class, 32 | NullToStrictStringFuncCallArgRector::class, 33 | BooleanInIfConditionRuleFixerRector::class, 34 | BooleanInBooleanNotRuleFixerRector::class, 35 | SimplifyUselessVariableRector::class, 36 | ReturnNeverTypeRector::class, 37 | ]); 38 | }; 39 | -------------------------------------------------------------------------------- /src/RouteTest.php: -------------------------------------------------------------------------------- 1 | $this->parameters[$binding] = $closure() 23 | ); 24 | } 25 | 26 | public static function test(TestCall $test, array $assertions): void 27 | { 28 | /** @var TestResponse $testResponse */ 29 | $testResponse = $test 30 | ->defer(function (string $method, string $uri) { 31 | /** @var Route $route */ 32 | $route = collect(RouteFacade::getRoutes() 33 | ->getRoutesByMethod()[$method]) 34 | ->first(fn (Route $route) => $route->uri === $uri); 35 | 36 | if ($route === null) { 37 | $this->markTestIncomplete("Route not found: {$method} {$uri}"); 38 | } 39 | 40 | try { 41 | $this->url = url()->toRoute($route, $this->parameters ?? null, false); 42 | } catch (UrlGenerationException) { 43 | $this->markTestSkipped("Missing parameters for route: {$method} {$uri}"); 44 | } 45 | }) 46 | ->expect(fn (string $method, string $uri) => $this->{$method}($this->url)); 47 | 48 | foreach ($assertions as [$method, $parameters]) { 49 | $testResponse->{$method}(...$parameters); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/pest-plugin-route-testing", 3 | "description": "Make sure all routes in your Laravel app are ok", 4 | "keywords": [ 5 | "php", 6 | "framework", 7 | "pest", 8 | "unit", 9 | "test", 10 | "testing", 11 | "plugin", 12 | "spatie", 13 | "dev" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Niels Vanpachtenbeke", 19 | "email": "niels@spatie.be", 20 | "role": "Developer" 21 | }, 22 | { 23 | "name": "Alex Vanderbist", 24 | "email": "alex@spatie.be", 25 | "role": "Developer" 26 | }, 27 | { 28 | "name": "Freek Van der Herten", 29 | "email": "freek@spatie.be", 30 | "role": "Developer" 31 | } 32 | ], 33 | "require": { 34 | "php": "^8.1", 35 | "nunomaduro/termwind": "^1.15.1|^2.1", 36 | "pestphp/pest": "^2.34.9|^3.0.6|^4.0", 37 | "pestphp/pest-plugin": "^2.1.1|^3.0|^4.0", 38 | "spatie/laravel-package-tools": "^1.16.5" 39 | }, 40 | "require-dev": { 41 | "laravel/pint": "^1.17.3", 42 | "orchestra/testbench": "^8.24|^9.4", 43 | "pestphp/pest-dev-tools": "^2.16|^3.0" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Spatie\\RouteTesting\\": "src/" 48 | }, 49 | "files": [ 50 | "src/Autoload.php" 51 | ] 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "Tests\\": "tests/" 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true, 60 | "config": { 61 | "sort-packages": true, 62 | "preferred-install": "dist", 63 | "allow-plugins": { 64 | "pestphp/pest-plugin": true 65 | } 66 | }, 67 | "scripts": { 68 | "analyse": "./vendor/bin/phpstan analyse", 69 | "baseline": "./vendor/bin/phpstan analyse --generate-baseline", 70 | "lint": "pint", 71 | "rector": "./vendor/bin/rector --dry-run", 72 | "test": "./vendor/bin/pest --compact" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/RouteTestingTestCall.php: -------------------------------------------------------------------------------- 1 | */ 21 | protected array $bindingNames = []; 22 | 23 | /** @var array */ 24 | protected array $assertions = []; 25 | 26 | public function __construct(TestCall $testCall) 27 | { 28 | $this->testCall = $testCall; 29 | 30 | $this->routeResolver = new RouteResolver; 31 | 32 | $this->with($this->routeResolver->getFilteredRouteList()); 33 | 34 | $this->exclude([ 35 | '_ignition', 36 | '_debugbar*', 37 | 'horizon*', 38 | 'pulse*', 39 | 'sanctum*', 40 | ]); 41 | } 42 | 43 | protected function with(array $routes): self 44 | { 45 | $this->testCall->testCaseMethod->datasets = [$routes]; 46 | 47 | return $this; 48 | } 49 | 50 | public function ignoreRoutesWithMissingBindings(): self 51 | { 52 | $this->routeResolver->exceptRoutesWithMissingBindings(); 53 | 54 | $this->with($this->routeResolver->getFilteredRouteList()); 55 | 56 | return $this; 57 | } 58 | 59 | public function setUp(Closure $closure): static 60 | { 61 | $this->testCall->defer($closure); 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * There is some weird Pest magic going on here... We can't create closures in this class. 68 | * Instead, just pass the arguments to a different class where we can create closures. 69 | * It's 3 AM and this took me like 3 days to figure out and I just want to sleep. 70 | */ 71 | public function bind(string $binding, Closure $closure): self 72 | { 73 | RouteTest::bind($binding, $closure); 74 | 75 | $this->bindingNames = array_merge($this->bindingNames, [$binding]); 76 | 77 | $this->routeResolver->bindingNames($this->bindingNames); 78 | 79 | $this->with($this->routeResolver->getFilteredRouteList()); 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * @param array|string[] $path 86 | * @return $this 87 | */ 88 | public function include(array|string ...$path): self 89 | { 90 | $this->routeResolver->paths(Arr::wrap($path)); 91 | 92 | $this->with($this->routeResolver->getFilteredRouteList()); 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * @param string[]|array $path 99 | * @return $this 100 | */ 101 | public function exclude(string|array ...$path): self 102 | { 103 | $this->routeResolver->exceptPaths(Arr::wrap($path)); 104 | 105 | $this->with($this->routeResolver->getFilteredRouteList()); 106 | 107 | return $this; 108 | } 109 | 110 | public function __call($method, $parameters): self 111 | { 112 | // Assertions cannot be chained on the test call yet until the user is done adding bindings and other Pest test methods. 113 | // We'll capture assertions and apply them to the TestResponse later (in the __destruct method). 114 | if (in_array($method, get_class_methods(TestResponse::class)) || $method === 'toMatchSnapshot' || TestResponse::hasMacro($method)) { 115 | $this->assertions[] = [$method, $parameters]; 116 | 117 | return $this; 118 | } 119 | 120 | // Make sure Pest's methods (skip, group, etc...) are still callable. 121 | $this->forwardCallTo($this->testCall, $method, $parameters); 122 | 123 | return $this; 124 | } 125 | 126 | public function __destruct() 127 | { 128 | RouteTest::test($this->testCall, $this->assertions); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/RouteResolver.php: -------------------------------------------------------------------------------- 1 | |null */ 14 | protected ?array $paths = null; 15 | 16 | /** @var array|null */ 17 | protected ?array $exceptPaths = null; 18 | 19 | /** @var array */ 20 | protected array $methods = ['GET']; 21 | 22 | protected bool $exceptRoutesWithMissingBindings = false; 23 | 24 | /** @var array */ 25 | protected array $bindingNames = []; 26 | 27 | /** @var Collection */ 28 | protected Collection $fullRouteList; 29 | 30 | public function __construct() 31 | { 32 | $this->fullRouteList = $this->resolveFullRouteList(); 33 | } 34 | 35 | /** @return Collection */ 36 | protected function resolveFullRouteList(): Collection 37 | { 38 | $command = 'php artisan route:list --json --method=GET'; 39 | 40 | try { 41 | $result = Process::run($command); 42 | 43 | $output = $result->output(); 44 | } catch (Exception) { 45 | $process = SymfonyProcess::fromShellCommandline($command); 46 | 47 | $process->run(); 48 | 49 | $output = $process->getOutput(); 50 | } 51 | 52 | $routes = json_decode($output, true); 53 | 54 | return collect($routes)->flatMap( 55 | fn (array $route) => Str::of($route['method']) 56 | ->explode('|') 57 | ->intersect($this->methods) 58 | ->map(fn ($method) => ['method' => $method, 'uri' => $route['uri']]) 59 | ); 60 | } 61 | 62 | public function paths(array $paths): self 63 | { 64 | $this->paths = array_merge($this->paths ?? [], $paths); 65 | 66 | return $this; 67 | } 68 | 69 | public function exceptPaths(array $paths): self 70 | { 71 | $this->exceptPaths = array_merge($this->exceptPaths ?? [], $paths); 72 | 73 | return $this; 74 | } 75 | 76 | public function bindingNames(array $bindingNames): self 77 | { 78 | $this->bindingNames = $bindingNames; 79 | 80 | return $this; 81 | } 82 | 83 | public function exceptRoutesWithMissingBindings(): self 84 | { 85 | $this->exceptRoutesWithMissingBindings = true; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * @return array> 92 | */ 93 | public function getFilteredRouteList(): array 94 | { 95 | return $this->fullRouteList 96 | ->filter(function (array $route) { 97 | if ($this->paths) { 98 | return collect($this->paths)->contains(fn ($path) => Str::is($path, $route['uri'])); 99 | } 100 | 101 | return true; 102 | }) 103 | ->filter(function (array $route) { 104 | if ($this->exceptPaths) { 105 | return ! collect($this->exceptPaths)->contains(fn ($path) => Str::is($path, $route['uri'])); 106 | } 107 | 108 | return true; 109 | }) 110 | ->when($this->exceptRoutesWithMissingBindings, function (Collection $routes) { 111 | return $routes->filter(function (array $route) { 112 | $uriBindings = $this->getBindingsFromUrl($route['uri']); 113 | 114 | if (count($uriBindings) === 0) { 115 | return true; 116 | } 117 | 118 | return count(array_diff($uriBindings, $this->bindingNames)) === 0; 119 | }); 120 | }) 121 | ->map(fn (array $route) => array_values($route)) 122 | ->toArray(); 123 | } 124 | 125 | protected function getBindingsFromUrl(string $uri): array 126 | { 127 | $pattern = '/{([^}]*)}/'; 128 | 129 | preg_match_all($pattern, $uri, $matches); 130 | 131 | return $matches[1]; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Function Spatie\\\\RouteTesting\\\\routeTesting\\(\\) has no return type specified\\.$#" 5 | count: 1 6 | path: src/Autoload.php 7 | 8 | - 9 | message: "#^Parameter \\#1 \\$testCall of class Spatie\\\\RouteTesting\\\\RouteTestingTestCall constructor expects Pest\\\\PendingCalls\\\\TestCall, Pest\\\\PendingCalls\\\\TestCall\\|Pest\\\\Support\\\\HigherOrderTapProxy given\\.$#" 10 | count: 1 11 | path: src/Autoload.php 12 | 13 | - 14 | message: "#^In method \"Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:resolveFullRouteList\", caught \"Exception\" must be rethrown\\. Either catch a more specific exception or add a \"throw\" clause in the \"catch\" block to propagate the exception\\. More info\\: http\\://bit\\.ly/failloud$#" 15 | count: 1 16 | path: src/RouteResolver.php 17 | 18 | - 19 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:bindingNames\\(\\) has parameter \\$bindingNames with no value type specified in iterable type array\\.$#" 20 | count: 1 21 | path: src/RouteResolver.php 22 | 23 | - 24 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:exceptPaths\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#" 25 | count: 1 26 | path: src/RouteResolver.php 27 | 28 | - 29 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:getBindingsFromUrl\\(\\) return type has no value type specified in iterable type array\\.$#" 30 | count: 1 31 | path: src/RouteResolver.php 32 | 33 | - 34 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:getFilteredRouteList\\(\\) should return array\\\\> but returns array\\\\.$#" 35 | count: 1 36 | path: src/RouteResolver.php 37 | 38 | - 39 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:paths\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#" 40 | count: 1 41 | path: src/RouteResolver.php 42 | 43 | - 44 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteResolver\\:\\:resolveFullRouteList\\(\\) should return Illuminate\\\\Support\\\\Collection\\ but returns Illuminate\\\\Support\\\\Collection\\\\.$#" 45 | count: 1 46 | path: src/RouteResolver.php 47 | 48 | - 49 | message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:flatMap\\(\\) expects callable\\(mixed, int\\|string\\)\\: \\(array\\\\|Illuminate\\\\Support\\\\Collection\\\\), Closure\\(array\\)\\: Illuminate\\\\Support\\\\Collection\\ given\\.$#" 50 | count: 1 51 | path: src/RouteResolver.php 52 | 53 | - 54 | message: "#^Parameter \\#1 \\$value of function collect expects Illuminate\\\\Contracts\\\\Support\\\\Arrayable\\<\\(int\\|string\\), mixed\\>\\|iterable\\<\\(int\\|string\\), mixed\\>\\|null, mixed given\\.$#" 55 | count: 1 56 | path: src/RouteResolver.php 57 | 58 | - 59 | message: "#^Unable to resolve the template type TKey in call to function collect$#" 60 | count: 1 61 | path: src/RouteResolver.php 62 | 63 | - 64 | message: "#^Unable to resolve the template type TValue in call to function collect$#" 65 | count: 1 66 | path: src/RouteResolver.php 67 | 68 | - 69 | message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Routing\\\\UrlGenerator\\:\\:toRoute\\(\\)\\.$#" 70 | count: 1 71 | path: src/RouteTest.php 72 | 73 | - 74 | message: "#^Call to an undefined method Pest\\\\PendingCalls\\\\TestCall\\:\\:defer\\(\\)\\.$#" 75 | count: 1 76 | path: src/RouteTest.php 77 | 78 | - 79 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteTest\\:\\:test\\(\\) has parameter \\$assertions with no value type specified in iterable type array\\.$#" 80 | count: 1 81 | path: src/RouteTest.php 82 | 83 | - 84 | message: "#^PHPDoc tag @var for variable \\$testResponse contains generic class Illuminate\\\\Testing\\\\TestResponse but does not specify its types\\: TResponse$#" 85 | count: 1 86 | path: src/RouteTest.php 87 | 88 | - 89 | message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:first\\(\\) expects \\(callable\\(mixed, int\\|string\\)\\: bool\\)\\|null, Closure\\(Illuminate\\\\Routing\\\\Route\\)\\: bool given\\.$#" 90 | count: 1 91 | path: src/RouteTest.php 92 | 93 | - 94 | message: "#^Unable to resolve the template type TKey in call to function collect$#" 95 | count: 1 96 | path: src/RouteTest.php 97 | 98 | - 99 | message: "#^Unable to resolve the template type TValue in call to function collect$#" 100 | count: 1 101 | path: src/RouteTest.php 102 | 103 | - 104 | message: "#^Undefined variable\\: \\$this$#" 105 | count: 6 106 | path: src/RouteTest.php 107 | 108 | - 109 | message: "#^Variable \\$this on left side of \\?\\? is never defined\\.$#" 110 | count: 1 111 | path: src/RouteTest.php 112 | 113 | - 114 | message: "#^Call to an undefined method Pest\\\\PendingCalls\\\\TestCall\\:\\:defer\\(\\)\\.$#" 115 | count: 1 116 | path: src/RouteTestingTestCall.php 117 | 118 | - 119 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteTestingTestCall\\:\\:__call\\(\\) has parameter \\$method with no type specified\\.$#" 120 | count: 1 121 | path: src/RouteTestingTestCall.php 122 | 123 | - 124 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteTestingTestCall\\:\\:__call\\(\\) has parameter \\$parameters with no type specified\\.$#" 125 | count: 1 126 | path: src/RouteTestingTestCall.php 127 | 128 | - 129 | message: "#^Method Spatie\\\\RouteTesting\\\\RouteTestingTestCall\\:\\:with\\(\\) has parameter \\$routes with no value type specified in iterable type array\\.$#" 130 | count: 1 131 | path: src/RouteTestingTestCall.php 132 | --------------------------------------------------------------------------------