Simulate a http request and parse route parameters in Laravel testcase
Asked Answered
P

6

25

I'm trying to create unit tests to test some specific classes. I use app()->make() to instantiate the classes to test. So actually, no HTTP requests are needed.

However, some of the tested functions need information from the routing parameters so they'll make calls e.g. request()->route()->parameter('info'), and this throws an exception:

Call to a member function parameter() on null.

I've played around a lot and tried something like:

request()->attributes = new \Symfony\Component\HttpFoundation\ParameterBag(['info' => 5]);  

request()->route(['info' => 5]);  

request()->initialize([], [], ['info' => 5], [], [], [], null);

but none of them worked...

How could I manually initialize the router and feed some routing parameters to it? Or simply make request()->route()->parameter() available?

Update

@Loek: You didn't understand me. Basically, I'm doing:

class SomeTest extends TestCase
{
    public function test_info()
    {
        $info = request()->route()->parameter('info');
        $this->assertEquals($info, 'hello_world');
    }
}

No "requests" involved. The request()->route()->parameter() call is actually located in a service provider in my real code. This test case is specifically used to test that service provider. There isn't a route which will print the returning value from the methods in that provider.

Proposition answered 4/1, 2017 at 10:24 Comment(8)
Can you show the code in your service provide that you want to test?Droughty
@RossWilson It's not really relevant to this question. Just for example, it could be a service provider ExpProvider::Info() that returns request()->route()->parameter('info'), and I'd like to test it.Proposition
@RossWilson But there is not such a route like /test/info/{info}. In the unit test, I'd like to call $handler = app()->make(ExProvider::class); $handler->Info();. But before it, how could I set up the router?Proposition
Ah, then you can basically mock your Kernel, create a request (literally new Request(), add some parameters to that request and fire it.Scow
@Scow That would be something like MakesHttpRequest.php: public function call(...) in the Laravel testing framework. And it will "mock" a "request". However there're NO requests in this test case. If you fire the "request", it will be routed to an endpoint and return the response, but apparently it's different from my problem.Proposition
@Scow What all I want is just making the request()->route()->parameter() return an expected value. There is NOT a corresponding endpoint/route available for this test. I want to test it at the Class level rather than Request level.Proposition
Yeah but you need a request to get data out of..Scow
So Laravel unit test actually supports only testing endpoints? Grrr.... That would be ugly...Proposition
L
57

I assume you need to simulate a request without actually dispatching it. With a simulated request in place, you want to probe it for parameter values and develop your testcase.

There's an undocumented way to do this. You'll be surprised!

The problem

As you already know, Laravel's Illuminate\Http\Request class builds upon Symfony\Component\HttpFoundation\Request. The upstream class does not allow you to setup a request URI manually in a setRequestUri() way. It figures it out based on the actual request headers. No other way around.

OK, enough with the chatter. Let's try to simulate a request:

<?php

use Illuminate\Http\Request;

class ExampleTest extends TestCase
{
    public function testBasicExample()
    {
        $request = new Request([], [], ['info' => 5]);

        dd($request->route()->parameter('info'));
    }
}

As you mentioned yourself, you'll get a:

Error: Call to a member function parameter() on null

We need a Route

Why is that? Why route() returns null?

Have a look at its implementation as well as the implementation of its companion method; getRouteResolver(). The getRouteResolver() method returns an empty closure, then route() calls it and so the $route variable will be null. Then it gets returned and thus... the error.

In a real HTTP request context, Laravel sets up its route resolver, so you won't get such errors. Now that you're simulating the request, you need to set up that by yourself. Let's see how.

<?php

use Illuminate\Http\Request;
use Illuminate\Routing\Route;

class ExampleTest extends TestCase
{
    public function testBasicExample()
    {
        $request = new Request([], [], ['info' => 5]);

        $request->setRouteResolver(function () use ($request) {
            return (new Route('GET', 'testing/{info}', []))->bind($request);
        });

        dd($request->route()->parameter('info'));
    }
}

See another example of creating Routes from Laravel's own RouteCollection class.

Empty parameters bag

So, now you won't get that error because you actually have a route with the request object bound to it. But it won't work yet. If we run phpunit at this point, we'll get a null in the face! If you do a dd($request->route()) you'll see that even though it has the info parameter name set up, its parameters array is empty:

Illuminate\Routing\Route {#250
  #uri: "testing/{info}"
  #methods: array:2 [
    0 => "GET"
    1 => "HEAD"
  ]
  #action: array:1 [
    "uses" => null
  ]
  #controller: null
  #defaults: []
  #wheres: []
  #parameters: [] <===================== HERE
  #parameterNames: array:1 [
    0 => "info"
  ]
  #compiled: Symfony\Component\Routing\CompiledRoute {#252
    -variables: array:1 [
      0 => "info"
    ]
    -tokens: array:2 [
      0 => array:4 [
        0 => "variable"
        1 => "/"
        2 => "[^/]++"
        3 => "info"
      ]
      1 => array:2 [
        0 => "text"
        1 => "/testing"
      ]
    ]
    -staticPrefix: "/testing"
    -regex: "#^/testing/(?P<info>[^/]++)$#s"
    -pathVariables: array:1 [
      0 => "info"
    ]
    -hostVariables: []
    -hostRegex: null
    -hostTokens: []
  }
  #router: null
  #container: null
}

So passing that ['info' => 5] to Request constructor has no effect whatsoever. Let's have a look at the Route class and see how its $parameters property is getting populated.

When we bind the request object to the route, the $parameters property gets populated by a subsequent call to the bindParameters() method which in turn calls bindPathParameters() to figure out path-specific parameters (we don't have a host parameter in this case).

That method matches request's decoded path against a regex of Symfony's Symfony\Component\Routing\CompiledRoute (You can see that regex in the above dump as well) and returns the matches which are path parameters. It will be empty if the path doesn't match the pattern (which is our case).

/**
 * Get the parameter matches for the path portion of the URI.
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
protected function bindPathParameters(Request $request)
{
    preg_match($this->compiled->getRegex(), '/'.$request->decodedPath(), $matches);
    return $matches;
}

The problem is that when there's no actual request, that $request->decodedPath() returns / which does not match the pattern. So the parameters bag will be empty, no matter what.

Spoofing the request URI

If you follow that decodedPath() method on the Request class, you'll go deep through a couple of methods which will finally return a value from prepareRequestUri() of Symfony\Component\HttpFoundation\Request. There, exactly in that method, you'll find the answer to your question.

It's figuring out the request URI by probing a bunch of HTTP headers. It first checks for X_ORIGINAL_URL, then X_REWRITE_URL, then a few others and finally for the REQUEST_URI header. You can set either of these headers to actually spoof the request URI and achieve minimum simulation of a http request. Let's see.

<?php

use Illuminate\Http\Request;
use Illuminate\Routing\Route;

class ExampleTest extends TestCase
{
    public function testBasicExample()
    {
        $request = new Request([], [], [], [], [], ['REQUEST_URI' => 'testing/5']);

        $request->setRouteResolver(function () use ($request) {
            return (new Route('GET', 'testing/{info}', []))->bind($request);
        });

        dd($request->route()->parameter('info'));
    }
}

To your surprise, it prints out 5; the value of info parameter.

Cleanup

You might want to extract the functionality to a helper simulateRequest() method, or a SimulatesRequests trait which can be used across your test cases.

Mocking

Even if it was absolutely impossible to spoof the request URI like the approach above, you could partially mock the request class and set your expected request URI. Something along the lines of:

<?php

use Illuminate\Http\Request;
use Illuminate\Routing\Route;

class ExampleTest extends TestCase
{

    public function testBasicExample()
    {
        $requestMock = Mockery::mock(Request::class)
            ->makePartial()
            ->shouldReceive('path')
            ->once()
            ->andReturn('testing/5');

        app()->instance('request', $requestMock->getMock());

        $request = request();

        $request->setRouteResolver(function () use ($request) {
            return (new Route('GET', 'testing/{info}', []))->bind($request);
        });

        dd($request->route()->parameter('info'));
    }
}

This prints out 5 as well.

Liguria answered 14/1, 2017 at 14:13 Comment(5)
Terrific! This is exactly what I wanted! Thanks. Although we later found that Laravel actually didn't do the encapsulation that well so you can make an unrelated what-the-heck $this->call() first, then do other stuff (the singleton objects won't be destructed), this is far more programmatic. We'd switch to this implementation in the future.Proposition
I'm not sure... We noticed it almost by accident.Proposition
really awesome example -- helped me to do some mocking that required a route prefix. much appreciated.Lilywhite
This answer is great! There's a last question / issue I have. I'm trying to test my middleware which expects that $request->route('team') returns an object (as it will thanks to route model binding) - is there a way to "call" / "trigger" the route model binding?Muth
This accepted answer has good explanations and lots of votes, but I found @Cranespud's a much better answer for my tests — short, simple, and works exactly what I need to test.Daugherty
W
7

I ran into this problem today using Laravel7 here is how I solved it, hope it helps somebody

I'm writing unit tests for a middleware, it needs to check for some route parameters, so what I'm doing is creating a fixed request to pass it to the middleware

        $request = Request::create('/api/company/{company}', 'GET');            
        $request->setRouteResolver(function()  use ($company) {
            $stub = $this->createStub(Route::class);
            $stub->expects($this->any())->method('hasParameter')->with('company')->willReturn(true);
            $stub->expects($this->any())->method('parameter')->with('company')->willReturn($company->id); // not $adminUser's company
            return $stub;
        });
Weeds answered 20/5, 2020 at 1:39 Comment(1)
$request = Request::create('/api/company/{company}', 'GET'); worked for me, nice @WeedsDaugherty
B
0

Since route is implemented as a closure, you can access a route parameter directly in the route, without explicitly calling parameter('info'). These two calls returns the same:

$info = $request->route()->parameter('info');
$info = $request->route('info');

The second way, makes mocking the 'info' parameter very easy:

$request = $this->createMock(Request::class);
$request->expects($this->once())->method('route')->willReturn('HelloWorld');
$info = $request->route('info');
$this->assertEquals($info, 'HelloWorld');

Of course to exploit this method in your tests, you should inject the Request object in your class under test, instead of using the Laravel global request object through the request() method.

Babarababassu answered 31/5, 2019 at 14:34 Comment(0)
G
0

If you are using Illuminate\Support\Facades\Request or do not want to make a Mock, use the following code

$request = Request::create(
    'testing/10000230',
    'GET',
    ['id' => 10000230]
);

$this->app->instance(Request::class, $request);

request()->setRouteResolver(function () use ($request) {
    return (new Route(
        'GET',
        'testing/{id}',
        ['id => 10000230]
    ))->bind($request);
});

In my case I needed to mock Request::route('id').

Granicus answered 27/11, 2023 at 13:20 Comment(0)
J
0

I had to test Store request where I had to bind $this->route('category') Looked something like this:

    public function rules(): array
    {
        return [
            'file' => ['required', FileRules::types($this->route('category')->mime_types)],
        ];
    }

Tried several ways but ended up with this:

use App\Http\Requests\Store;
use Illuminate\Routing\Route;

it('checks the request rules.', function () {
    request()->setRouteResolver(function () {
        return (new Route([], '', fn() => true))
            ->bind(request());
    });

    $request = new Store();

    $request->setRouteResolver(function () {
        $route = request()->route();

        $route->parameters['category'] = Category::inRandomOrder()->first();

        return $route;
    });

    dd($request->rules());
});

Hope it helps some one cheers 🥂

Joslyn answered 19/3 at 15:40 Comment(0)
S
-2

Using the Laravel phpunit wrapper, you can let your test class extend TestCase and use the visit() function.

If you want to be stricter (which in unit testing is probably a good thing), this method isn't really recommended.

class UserTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testExample()
    {
        // This is readable but there's a lot of under-the-hood magic
        $this->visit('/home')
             ->see('Welcome')
             ->seePageIs('/home');

        // You can still be explicit and use phpunit functions
        $this->assertTrue(true);
    }
}
Scow answered 4/1, 2017 at 10:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.