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

时间:2013-12-19 23:48:00

标签: php design-patterns dependencies registry 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();

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

注册表:

  • 的Singleton
  • 紧耦合
  • 可测试?否

服务定位器

  • 可测试?硬/否(?)

穷人迪

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

注册表

<?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];
    }
} 

服务定位器

<?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];
    }
} 

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

3 个答案:

答案 0 :(得分: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
}

这种方法提供了一种简单的方法来简单地通过传入不同的工厂作为依赖关系来扩展和/或替换模型层相关代码。在控制器中,它看起来像这样:

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


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

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

  

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

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

$request

控制器应该是应用程序的最薄部分。控制器的职责是:接受用户输入,并根据该输入改变模型层的状态(在极少数情况下 - 当前视图)。就是这样。


使用DI容器

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

我的建议:结帐Auryn

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

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
}

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

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

在这种情况下,控制器的方法如下所示:

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

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

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

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

杂项说明

  • 耦合到名称(在示例中 - &#34;身份验证&#34;):

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

  • DI容器不是魔法

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

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

    第三个问题是复杂性。好的DI容器易于制作。如果您使用第三方工具,则会带来额外的风险。

  • 依赖项太多

    如果你的班级有太多的依赖关系,那么 DI的失败就是练习。相反,这是一个明确的指示,你的班级做了太多事情。它违反了Single Responsibility Principle

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

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

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

  • 它的PHP单元

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

  • 您将有更多测试

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

其他材料

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

支持:无耻的自我推销

  • dealing with access control in MVC-like architecture

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

    还有另一篇文章使用类似方法介绍non-invasive logging

  • list of lectures

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

  • implementing model layer

    解释那些神奇的&#34;服务&#34;在上面的描述中。

答案 1 :(得分:4)

我是从http://culttt.com/2013/07/15/how-to-structure-testable-controllers-in-laravel-4/

尝试过的

如何构建控制器以使其可测试。

测试控制器是构建可靠Web应用程序的一个重要方面,但重要的是您只需测试应用程序的相应位。

幸运的是,Laravel 4让你很容易将控制器的问题分开。这使得只要您正确地构建控制器,就可以非常直接地测试控制器。

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

在我开始如何构建控制器的可测试性之前,首先了解我们需要测试的内容非常重要。

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

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

作为一种说明我想避免的方法,这里是一个Controller方法的例子:

public function index()
{
  return User::all();
}

这是一种不好的做法,因为我们无法模拟User::all();,因此相关的测试将被强制命中数据库。

拯救依赖注入

为了解决这个问题,我们必须将依赖注入Controller。依赖注入是将类传递给对象的实例,而不是让该对象为其自身创建实例。

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

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

自动解决

Laravel 4有一种处理Dependancy Injection的漂亮方式。这意味着您可以在许多情况下完全解析类而无需任何配置。

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

基本上,一切都可以在没有任何配置的情况下运行。

将数据库注入控制器

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

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

所以不要这样做:

public function index()
{
  return User::all();
}

我做了:

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

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

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

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

在Controller测试中模拟数据库

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

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

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

<?php

class UserControllerTest extends TestCase {

}

嘲弄Mockery

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

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

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

这使您可以隔离依赖项,以便只进行所需的Controller调用以便测试通过。

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

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

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

"require-dev": {
  "mockery/mockery": "dev-master"
}

接下来,安装包:

composer install --dev

设置Mockery

现在要设置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;
}

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

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

接下来,您可以编写第一个Controller测试:

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

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

  $this->assertResponseOk();
}

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

<强>结论

测试控制器不应该像它那样困难或复杂。只要您隔离依赖项并仅测试正确的位,测试控制器就应该非常直接。

这可能对你有所帮助。

答案 2 :(得分:0)

面向方面编程甚至可以使用Service Locator模式为模拟方法提供解决方案。寻找AspectMock测试框架。

  1. Github:https://github.com/Codeception/AspectMock
  2. Jeffrey Way的视频:http://jeffrey-way.com/blog/2013/07/24/aspectmock-is-pretty-neat/