在Laravel测试用例中模拟一个http请求并解析路由参数

Simulate a http request and parse route parameters in Laravel testcase

我正在尝试创建单元测试来测试某些特定的 类。我使用 app()->make() 实例化 类 进行测试。所以实际上,不需要 HTTP 请求。

但是,一些经过测试的函数需要来自路由参数的信息,以便它们进行调用,例如request()->route()->parameter('info'),这会引发异常:

Call to a member function parameter() on null.

我玩了很多,尝试过类似的东西:

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

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

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

但其中 none 有效...

如何手动初始化路由器并向其提供一些路由参数?或者只是让 request()->route()->parameter() 可用?

更新

@Loek:你没听懂我的意思。基本上,我在做:

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

不涉及“请求”。 request()->route()->parameter() 调用实际上位于我的真实代码中的服务提供者中。这个测试用例专门用来测试那个服务提供者。没有路由会打印该提供程序中方法的返回值。

使用 Laravel phpunit 包装器,您可以让您的测试 class 扩展 TestCase 并使用 visit() 函数。

如果您想更严格(这在单元测试中可能是一件好事),则不推荐使用此方法。

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);
    }
}

我假设您需要 模拟 一个请求而不实际发送它。有了模拟请求,您想要探测它的参数值并开发您的测试用例。

有一种未记录的方法可以做到这一点。你会感到惊讶!

问题

如您所知,Laravel 的 Illuminate\Http\Request class builds upon Symfony\Component\HttpFoundation\Request。上游 class 不允许您以 setRequestUri() 的方式手动设置请求 URI。它根据实际请求 headers 计算出来。别无他法。

好了,废话不多说了。让我们尝试模拟一个请求:

<?php

use Illuminate\Http\Request;

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

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

正如您自己提到的,您会得到:

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

我们需要一个Route

这是为什么?为什么 route() returns null

看看its implementation as well as the implementation of its companion method; getRouteResolver()getRouteResolver() 方法 return 是一个空闭包,然后 route() 调用它,因此 $route 变量将是 null。然后它得到 returned,因此...错误。

在真实的 HTTP 请求上下文中,Laravel sets up its route resolver,所以你不会得到这样的错误。现在您正在模拟请求,您需要自己设置它。让我们看看如何。

<?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'));
    }
}

查看另一个从 Laravel's own RouteCollection class 创建 Route 的示例。

清空参数包

所以,现在您不会收到该错误,因为您实际上有一个路由,请求 object 绑定到它。但它还行不通。如果此时我们 运行 phpunit,我们会得到一个 null 的脸!如果你执行 dd($request->route()) 你会看到即使它设置了 info 参数名称,它的 parameters 数组是空的:

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
}

因此将 ['info' => 5] 传递给 Request 构造函数没有任何效果。让我们看一下 Route class,看看它的 $parameters property 是如何填充的。

当我们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()找出path-specific参数时(在这种情况下我们没有主机参数)。

该方法将请求的解码路径与 Symfony's Symfony\Component\Routing\CompiledRoute 的正则表达式相匹配(您也可以在上面的转储中看到该正则表达式)和 returns 作为路径参数的匹配项。如果路径与模式不匹配(这是我们的情况),它将为空。

/**
 * 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;
}

问题是当没有实际请求时,$request->decodedPath() returns / 与模式不匹配。所以无论如何参数包都会是空的。

欺骗请求 URI

如果您在 Request class 上遵循 decodedPath() 方法,您将深入了解几个方法,最终 return 来自 prepareRequestUri()Symfony\Component\HttpFoundation\Request。在那里,正是在那种方法中,你会找到问题的答案。

它通过探测一堆 HTTP headers 来找出请求 URI。它首先检查 X_ORIGINAL_URL,然后是 X_REWRITE_URL,然后是其他几个,最后是 REQUEST_URI header。您可以将这些 header 中的任何一个设置为实际 spoof 请求 URI 并实现对 http 请求的 minimum 模拟。让我们看看。

<?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'));
    }
}

令你惊讶的是,它打印出 5info 参数的值。

清理

您可能希望将功能提取到辅助 simulateRequest() 方法或 SimulatesRequests 特征中,以便在您的测试用例中使用。

嘲讽

即使完全不可能像上面的方法那样欺骗请求 URI,您也可以部分模拟请求 class 并设置您期望的请求 URI。大致如下:

<?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'));
    }
}

这也打印出 5

由于 route 是作为闭包实现的,您可以直接在路由中访问路由参数,而无需显式调用 parameter('info')。这两个调用returns相同:

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

第二种方法,使模拟 'info' 参数变得非常容易:

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

当然要在你的测试中利用这个方法,你应该在你的 class 测试中注入 Request 对象,而不是通过 request() 使用 Laravel 全局请求对象方法。

我 运行 今天使用 Laravel7 解决了这个问题,这是我解决它的方法,希望它能帮助别人

我正在为中间件编写单元测试,它需要检查一些路由参数,所以我正在做的是创建一个固定请求以将其传递给中间件

        $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;
        });