具有依赖性的可测试控制器

use*_*056 19 php registry dependencies design-patterns service-locator

如何解析可测试控制器的依赖关系?

工作原理:URI路由到Controller,Controller可能具有执行某项任务的依赖关系.

<?php

require 'vendor/autoload.php';

/*
 * Registry
 * Singleton
 * Tight coupling
 * Testable?
 */

$request = new Example\Http\Request();

Example\Dependency\Registry::getInstance()->set('request', $request);

$controller = new Example\Controller\RegistryController();

$controller->indexAction();

/*
 * Service Locator
 *
 * Testable? Hard!
 *
 */

$request = new Example\Http\Request();

$serviceLocator = new Example\Dependency\ServiceLocator();

$serviceLocator->set('request', $request);

$controller = new Example\Controller\ServiceLocatorController($serviceLocator);

$controller->indexAction();

/*
 * Poor Man
 *
 * Testable? Yes!
 * Pain in the ass to create with many dependencies, and how do we know specifically what dependencies a controller needs
 * during creation?
 * A solution is the Factory, but you would still need to manually add every dependencies a specific controller needs
 * etc.
 *
 */

$request = new Example\Http\Request();

$controller = new Example\Controller\PoorManController($request);

$controller->indexAction();
Run Code Online (Sandbox Code Playgroud)

这是我对设计模式示例的解释

注册地:

  • 独生子
  • 紧耦合
  • 可测试?没有

服务定位器

  • 可测试?硬/没(?)

穷人迪

  • 可测试
  • 难以维护许多依赖项

注册处

<?php
namespace Example\Dependency;

class Registry
{
    protected $items;

    public static function getInstance()
    {
        static $instance = null;
        if (null === $instance) {
            $instance = new static();
        }

        return $instance;
    }

    public function set($name, $item)
    {
        $this->items[$name] = $item;
    }

    public function get($name)
    {
        return $this->items[$name];
    }
} 
Run Code Online (Sandbox Code Playgroud)

服务定位器

<?php
namespace Example\Dependency;

class ServiceLocator
{
    protected $items;

    public function set($name, $item)
    {
        $this->items[$name] = $item;
    }

    public function get($name)
    {
        return $this->items[$name];
    }
} 
Run Code Online (Sandbox Code Playgroud)

如何解析可测试控制器的依赖关系?

ter*_*ško 19

你在控制器中谈论的依赖是什么?

主要解决方案是:

  • 通过构造函数在控制器中注入服务工厂
  • 使用DI容器直接传递特定服务

我将尝试分别详细描述这两种方法.

注意:所有示例都将省略与视图的交互,授权的处理,处理服务工厂的依赖关系以及其他细节


注射工厂

bootstrap阶段的简化部分,处理开启控制器的东西,看起来有点像这样

$request = //... we do something to initialize and route this 
$resource = $request->getParameter('controller');
$command = $request->getMethod() . $request->getParameter('action');

$factory = new ServiceFactory;
if ( class_exists( $resource ) ) {
    $controller = new $resource( $factory );
    $controller->{$command}( $request );
} else {
    // do something, because requesting non-existing thing
}
Run Code Online (Sandbox Code Playgroud)

该方法简单地通过将不同的工厂作为依赖性传递来提供用于扩展和/或替换模型层相关代码的清楚方式.在控制器中,它看起来像这样:

public function __construct( $factory )
{
    $this->serviceFactory = $factory;
}


public function postLogin( $request ) 
{
    $authentication = $this->serviceFactory->create( 'Authentication' );
    $authentication->login(
        $request->getParameter('username'),
        $request->getParameter('password')
    );
}
Run Code Online (Sandbox Code Playgroud)

这意味着,要测试这个控制器的方法,你必须编写一个单元测试,它模拟$this->serviceFactory创建的实例的内容和传入的值$request.所述mock需要返回一个实例,它可以接受两个参数.

注意:对用户的响应应完全由视图实例处理,因为创建响应是UI逻辑的一部分.请记住,HTTP Location标头也是一种响应形式.

这种控制器的单元测试如下:

public function test_if_Posting_of_Login_Works()
{    
    // setting up mocks for the seam

    $service = $this->getMock( 'Services\Authentication', ['login']);
    $service->expects( $this->once() )
            ->method( 'login' )
            ->with( $this->equalTo('foo'), 
                     $this->equalTo('bar') );

    $factory = $this->getMock( 'ServiceFactory', ['create']);
    $factory->expects( $this->once() )
            ->method( 'create' )
            ->with( $this->equalTo('Authentication'))
            ->will( $this->returnValue( $service ) );

    $request = $this->getMock( 'Request', ['getParameter']);
    $request->expects( $this->exactly(2) )
             ->method( 'getParameter' )
             ->will( $this->onConsecutiveCalls( 'foo', 'bar' ) );

    // test itself

    $instance = new SomeController( $factory );
    $instance->postLogin( $request );

    // done
}
Run Code Online (Sandbox Code Playgroud)

控制器应该是应用程序中最薄的部分.控制器的职责是:获取用户输入,并根据该输入改变模型层的状态(在极少数情况下 - 当前视图).而已.


带DI容器

另一种方法是..好吧..它基本上是复杂的交易(在一个地方减去,在其他地方增加更多).它还传递了一个真正的 DI容器,而不是像疙瘩这样的美化服务定位器.

我的建议:看看Auryn.

DI容器的作用是使用配置文件或反射,它确定要创建的实例的依赖关系.收集所述依赖项.并传入实例的构造函数.

$request = //... we do something to initialize and route this 
$resource = $request->getParameter('controller');
$command = $request->getMethod() . $request->getParameter('action');

$container = new DIContainer;
try {
    $controller = $container->create( $resource );
    $controller->{$command}( $request );
} catch ( FubarException $e ) {
    // do something, because requesting non-existing thing
}
Run Code Online (Sandbox Code Playgroud)

因此,除了抛出异常的能力之外,控制器的引导几乎保持不变.

此外,您应该已经认识到,从一种方法切换到另一种方法主要需要完全重写控制器(以及相关的单元测试).

在这种情况下,控制器的方法看起来像:

private $authenticationService;

#IMPORTANT: if you are using reflection-based DI container,
#then the type-hinting would be MANDATORY
public function __construct( Service\Authentication $authenticationService )
{
    $this->authenticationService = $authenticationService;
}

public function postLogin( $request )
{
    $this->authenticatioService->login(
            $request->getParameter('username'),
            $request->getParameter('password')
    );
}
Run Code Online (Sandbox Code Playgroud)

至于编写测试,在这种情况下,您需要做的只是提供一些模拟隔离并简单验证.但是,在这种情况下,单元测试更简单:

public function test_if_Posting_of_Login_Works()
{    
    // setting up mocks for the seam

    $service = $this->getMock( 'Services\Authentication', ['login']);
    $service->expects( $this->once() )
            ->method( 'login' )
            ->with( $this->equalTo('foo'), 
                     $this->equalTo('bar') );

    $request = $this->getMock( 'Request', ['getParameter']);
    $request->expects( $this->exactly(2) )
             ->method( 'getParameter' )
             ->will( $this->onConsecutiveCalls( 'foo', 'bar' ) );

    // test itself

    $instance = new SomeController( $service );
    $instance->postLogin( $request );

    // done
}
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,在这种情况下,您可以减少一个类来进行模拟.

杂项说明

  • 耦合到名称(在示例中 - "身份验证"):

    正如您可能已经注意到的那样,在两个示例中,您的代码都将与使用的服务名称相关联.即使你使用基于配置的DI容器(因为它可能在symfony中),你仍然会最终定义特定类的名称.

  • DI容器不是魔术:

    在过去的几年里,DI容器的使用有所夸大.它不是一颗银弹.我甚至会说:DI容器与SOLID不兼容.特别是因为它们不适用于接口.您无法在代码中真正使用多态行为,这将由DI容器初始化.

    然后是基于配置的DI的问题.嗯..它很漂亮,而项目很小.但随着项目的增长,配置文件也会增长.您最终可以得到光荣的xml/yaml配置WALL,这只有一个人在项目中可以理解.

    第三个问题是复杂性.好的DI容器制作起来并不简单.如果您使用第三方工具,则会引入其他风险.

  • 依赖项太多:

    如果你的类有太多的依赖关系,那么它不是 DI作为练习的失败.相反,这是一个明确的迹象,表明你的班级做了太多事情.它违反了单一责任原则.

  • 控制器实际上有(某些)逻辑:

    上面使用的示例非常简单,并且通过单个服务与模型层交互.在现实世界中,您的控制器方法包含控制结构(循环,条件,东西).

    最基本的用例是一个控制器,它处理联系表单作为"主题"下拉列表.大多数消息将被定向到与某些CRM通信的服务.但是,如果用户选择"报告错误",则应将该消息传递给差异服务,该服务会自动在错误跟踪器中创建故障单并发送一些通知.

  • 这是PHP单位:

    单元测试的示例是使用PHPUnit框架编写的.如果您正在使用其他框架或手动编写测试,则必须进行一些基本的更改

  • 你将有更多的测试:

    单元测试示例不是控制器方法的整套测试.特别是,当你有非平凡的控制器.

其他材料

有一些..嗯...切向主题.

Brace for: shameless self-promotion

  • 在类似MVC的架构中处理访问控制

    一些框架有一个讨厌的习惯,即在控制器中推送授权检查(不要与"身份验证"......不同主题混淆).除了完全愚蠢的事情之外,它还在控制器中引入了额外的依赖关系(通常是全局范围的).

    还有另一篇文章使用类似的方法来引入非侵入式伐木

  • 讲座清单

    它有点针对那些想要了解MVC的人,但实际上有关于OOP和开发实践的普通教育的材料.我们的想法是,当你完成该列表时,MVC和其他SoC实现只会让你"噢,这有一个名字?我认为这只是常识."

  • 实现模型层

    解释上面描述中那些神奇的"服务"是什么.

  • Pimple是一个服务定位器.**它不解析类的依赖关系**,因为依赖关系是硬编码的.请查看[来源](https://github.com/fabpot/Pimple/blob/master/lib/Pimple.php).Pimple是一个注册表,其中一些包含的实体可以是匿名提供者而不是完整对象,它们可以自己使用.另外,DI容器**不是你"注入"**的东西.如果注入DI容器,它将成为服务定位器. (5认同)
  • @mpm如果将DI容器注入对象,则任何DI容器都是服务定位器.这样做是糟糕的设计,它暴露了面向对象的根本误解.Pimple积极支持并鼓励这种次优的架构.Auryn尽其所能阻止用户这样做,但在一天结束时你不能强迫人们编写好的代码.出于这个原因,我尝试使用注入容器来阻止我所有人 - 这是一个功能太强大的工具,10个PHP开发人员中有9个会错误地使用它. (2认同)

Ton*_*ark 5

我从http://culttt.com/2013/07/15/how-to-structure-testable-controllers-in-laravel-4/尝试过这个

你应该如何构建你的控制器以使其可测试。?

测试您的控制器是构建可靠 Web 应用程序的一个关键方面,但重要的是您只测试应用程序的适当部分。

幸运的是,Laravel 4 使分离控制器的关注点变得非常容易。只要您正确构建了它们,这将使测试您的控制器变得非常简单。

我应该在我的控制器中测试什么?

在我开始介绍如何构建控制器以实现可测试性之前,首先重要的是要了解我们究竟需要测试什么。

正如我在设置你的第一个 Laravel 4 控制器中提到的,控制器应该只关心在模型和视图之间移动数据。您不需要验证数据库是否正在提取正确的数据,只需验证控制器是否调用了正确的方法。因此你的控制器测试永远不应该接触数据库。

这就是我今天要向您展示的内容,因为默认情况下很容易将控制器和模型耦合在一起。坏习惯的例子

为了说明我要避免的内容,以下是 Controller 方法的示例:

public function index()
{
  return User::all();
}
Run Code Online (Sandbox Code Playgroud)

这是一个不好的做法,因为我们无法模拟User::all();,因此相关的测试将被迫访问数据库。

依赖注入来拯救

为了解决这个问题,我们必须将依赖注入到控制器中。依赖注入是您向类传递对象实例的地方,而不是让该对象为其自身创建实例。

通过将依赖项注入控制器,我们可以在测试期间向类传递模拟而不是数据库,而不是实际的数据库对象本身。这意味着我们可以在不接触数据库的情况下测试控制器的功能。

作为一般指南,在任何地方看到一个类正在创建另一个对象的实例,这通常表明可以通过依赖注入更好地处理这种情况。您永远不希望您的对象紧密耦合,因此通过不允许一个类实例化另一个类,您可以防止这种情况发生。

自动分辨率

Laravel 4 有一种很好的处理依赖注入的方式。这意味着您可以在许多情况下完全无需任何配置即可解析类。

这意味着如果你通过构造函数向一个类传递另一个类的实例,Laravel 会自动为你注入该依赖项!

基本上,一切都将在您没有任何配置的情况下工作。

将数据库注入控制器

所以现在您了解了问题和解决方案的理论,我们现在可以修复控制器,使其不与数据库耦合。

如果您还记得上周关于 Laravel 存储库的帖子,您可能已经注意到我已经解决了这个问题。

所以,而不是做:

public function index()
{
  return User::all();
}
Run Code Online (Sandbox Code Playgroud)

我做了:

public function __construct(User $user)
{
  $this->user = $user;
}

/**
 * Display a listing of the resource.
 *
 * @return Response
 */
public function index()
{
  return $this->user->all();
}
Run Code Online (Sandbox Code Playgroud)

创建 UserController 类时,会自动运行 __construct 方法。__construct 方法注入了 User 存储库的一个实例,然后在类的 $this->user 属性上设置该实例。

现在,只要您想在方法中使用数据库,就可以使用 $this->user 实例。

在 Controller 测试中模拟数据库

当您开始编写 Controller 测试时,真正的魔法就会发生。现在您正在将数据库的实例传递给控制器​​,您可以模拟数据库而不是实际访问数据库。这不仅会提高性能,而且您在测试后不会有任何测试数据。

我要做的第一件事是在名为功能的测试目录下创建一个新文件夹。我喜欢将控制器测试视为功能测试,因为我们正在测试传入流量和呈现的视图。

接下来,我将创建一个名为 UserControllerTest.php 的文件并编写以下样板代码:

<?php

class UserControllerTest extends TestCase {

}
Run Code Online (Sandbox Code Playgroud)

嘲讽

如果你还记得我的帖子,什么是测试驱动开发?,我谈到了 Mocks 作为依赖对象的替代品。

为了在 Cribbb 中为测试创建 Mocks,我将使用一个名为 Mockery 的奇妙包。

Mockery 允许您模拟项目中的对象,因此您不必使用真正的依赖项。通过模拟一个对象,你可以告诉 Mockery 你想调用哪个方法以及你想返回什么。

这使您能够隔离您的依赖项,因此您只需进行所需的 Controller 调用即可通过测试。

例如,如果你想在你的数据库对象上调用 all() 方法,而不是实际访问数据库,你可以通过告诉 Mockery 你想调用 all() 方法来模拟调用,它应该返回一个预期值。您不是在测试数据库是否可以返回记录,您只关心是否能够触发方法并处理返回值。

安装 Mockery 像所有优秀的 PHP 包一样,Mockery 可以通过 Composer 安装。

要通过 Composer 安装 Mockery,请将以下行添加到您的 composer.json 文件中:

"require-dev": {
  "mockery/mockery": "dev-master"
}
Run Code Online (Sandbox Code Playgroud)

接下来,安装软件包:

composer install --dev
Run Code Online (Sandbox Code Playgroud)

设置嘲讽

现在要设置 Mockery,我们必须在测试文件中创建几个设置方法:

public function setUp()
{
  parent::setUp();

  $this->mock = $this->mock('Cribbb\Storage\User\UserRepository');
}

public function mock($class)
{
  $mock = Mockery::mock($class);

  $this->app->instance($class, $mock);

  return $mock;
}
Run Code Online (Sandbox Code Playgroud)

setUp()方法在任何测试之前运行。在这里,我们抓取了 的副本UserRepository并创建了一个新的模拟。

mock()方法中,$this->app->instance告诉 Laravel 的 IoC 容器将$mock实例绑定到UserRepository类。这意味着每当 Laravel 想要使用这个类时,它都会使用模拟来代替。编写你的第一个控制器测试

接下来你可以编写你的第一个控制器测试:

public function testIndex()
{
  $this->mock->shouldReceive('all')->once();

  $this->call('GET', 'user');

  $this->assertResponseOk();
}
Run Code Online (Sandbox Code Playgroud)

在这个测试中,我要求模拟all()UserRepository. 然后我使用 GET 请求调用页面,然后我断言响应没问题。

结论

测试控制器不应该像人们想象的那样困难或复杂。只要您隔离依赖项并只测试正确的部分,测试控制器就应该非常简单。

可能这对你有帮助。