使用模拟服务进行Symfony 2功能测试

时间:2013-03-11 14:52:54

标签: symfony dependency-injection phpunit

我有一个控制器,我想为其创建功能测试。该控制器通过MyApiClient类向外部API发出HTTP请求。我需要模拟这个MyApiClient类,所以我可以测试我的控制器如何响应给定的响应(例如,如果MyApiClient类返回500响应,它会怎么做。)

通过标准的PHPUnit mockbuilder创建MyApiClient类的模拟版本没有问题:我遇到的问题是让我的控制器将此对象用于多个请求。

我目前正在测试中执行以下操作:

class ApplicationControllerTest extends WebTestCase
{

    public function testSomething()
    {
        $client = static::createClient();

        $apiClient = $this->getMockMyApiClient();

        $client->getContainer()->set('myapiclient', $apiClient);

        $client->request('GET', '/my/url/here');

        // Some assertions: Mocked API client returns 500 as expected.

        $client->request('GET', '/my/url/here');

        // Some assertions: Mocked API client is not used: Actual MyApiClient instance is being used instead.
    }

    protected function getMockMyApiClient()
    {
        $client = $this->getMockBuilder('Namespace\Of\MyApiClient')
            ->setMethods(array('doSomething'))
            ->getMock();

        $client->expects($this->any())
            ->method('doSomething')
            ->will($this->returnValue(500));

        return $apiClient;
    }
}

似乎在第二次请求时正在重建容器,导致MyApiClient再次被实例化。 MyApiClient类通过注释(使用JMS DI Extra Bundle)配置为服务,并通过注释注入控制器的属性。

我将每个请求拆分到自己的测试中,如果可以的话,可以解决这个问题,但不幸的是我不能:我需要通过GET操作向控制器发出请求,然后POST它带来的表单背部。我想这样做有两个原因:

1)表单使用CSRF保护,因此如果我直接在不使用爬虫提交的情况下直接发布到表单,表单将无法通过CSRF检查。

2)测试表单在提交时生成正确的POST请求是一种奖励。

有没有人对如何做到这一点有任何建议?

修改

这可以在以下单元测试中表示,该测试不依赖于我的任何代码,因此可能更清楚:

public function testAMockServiceCanBeAccessedByMultipleRequests()
{
    $client = static::createClient();

    // Set the container to contain an instance of stdClass at key 'testing123'.
    $keyName = 'testing123';
    $client->getContainer()->set($keyName, new \stdClass());

    // Check our object is still set on the container.
    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName))); // Passes.

    $client->request('GET', '/any/url/');

    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName))); // Passes.

    $client->request('GET', '/any/url/');

    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName))); // Fails.
}

即使我在第二次拨打$client->getContainer()->set($keyName, new \stdClass());

之前立即拨打request(),此测试也会失败

7 个答案:

答案 0 :(得分:8)

我以为我会跳进这里。克里斯克,我想你想要的是:

https://github.com/PolishSymfonyCommunity/SymfonyMockerContainer

我同意您的一般方法,在服务容器中将其配置为参数确实不是一个好方法。整个想法是能够在单个测试运行期间动态模拟它。

答案 1 :(得分:8)

当您调用self::createClient()时,您将获得Symfony2内核的启动实例。这意味着,解析并加载所有配置。当现在发送请求时,你让系统第一次完成它的工作,对吗?

在第一次请求之后,您可能想要检查发生了什么,因此,内核处于发送请求的状态,但它仍在运行。

如果您现在运行第二个请求,则Web架构要求内核重新启动,因为它已经运行了请求。当您第二次执行请求时,将执行代码中的重新启动。

如果要在发送请求之前启动内核并对其进行修改(如您所愿),则必须关闭旧内核实例并启动新内核实例。

只需重新运行self::createClient()即可。现在你再次必须像第一次那样应用你的模拟。

这是您的第二个示例的修改代码:

public function testAMockServiceCanBeAccessedByMultipleRequests()
{
    $keyName = 'testing123';

    $client = static::createClient();
    $client->getContainer()->set($keyName, new \stdClass());

    // Check our object is still set on the container.
    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName)));

    $client->request('GET', '/any/url/');

    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName)));

    # addded these two lines here:
    $client = static::createClient();
    $client->getContainer()->set($keyName, new \stdClass());

    $client->request('GET', '/any/url/');

    $this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName)));
}

现在您可能想要创建一个单独的方法,为您模拟新鲜实例,这样您就不必复制代码......

答案 2 :(得分:2)

您遇到的行为实际上是您在任何真实场景中所经历的行为,因为PHP无需任何共享,并在每个请求上重建整个堆栈。功能测试套件模仿此行为不会产生错误的结果。一个例子是doctrine,它有一个ObjectCache,因此您可以创建对象,而不是将它们保存到数据库中,并且您的测试都会通过,因为它始终将对象从缓存中取出。

您可以通过不同方式解决此问题:

创建一个真正的类,它是一个TestDouble,并模拟您期望从真实API获得的结果。这实际上非常简单:您创建一个与普通MyApiClientTestDouble具有相同签名的新MyApiClient,只需更改所需的方法主体。

在你的service.yml中,你可能有这个:

parameters:
  myApiClientClass: Namespace\Of\MyApiClient

service:
  myApiClient:
    class: %myApiClientClass%

如果是这种情况,您可以通过在config_test.yml中添加以下内容来轻松覆盖所采用的类:

parameters:
  myApiClientClass: Namespace\Of\MyApiClientTestDouble

现在,服务容器将在测试时使用您的TestDouble。如果两个类具有相同的签名,则不需要其他任何内容。我不知道它是否或如何与DI Extras Bundle一起使用。但我想有办法。

或者你可以创建一个ApiDouble,实现一个“真正的”API,其行为方式与外部API相同但返回测试数据。然后,您将通过服务容器处理API的URI(例如,setter注入)并创建一个指向正确API的参数变量(在开发或测试的情况下为测试版,在生产环境的情况下为真实的API) )。

第三种方式有点hacky,但你总是可以在你的测试request中创建一个私有方法,它首先以正确的方式设置容器,然后调用客户端发出请求。

答案 3 :(得分:2)

我不知道你是否曾经发现如何解决你的问题。但这是我使用的解决方案。这对其他发现这一点的人也有好处。

经过长时间搜索多个客户端请求之间模拟服务的问题后,我找到了这篇博文:

http://blog.lyrixx.info/2013/04/12/symfony2-how-to-mock-services-during-functional-tests.html

lyrixx讨论当你尝试发出另一个请求时,每次请求后内核如何关闭,使服务覆盖无效。

为了解决这个问题,他创建了一个仅用于功能测试的AppTestKernel。

这个AppTestKernel扩展了AppKernel,只应用了一些处理程序来修改内核: lyrixx blogpost的代码示例。

<?php

// app/AppTestKernel.php

require_once __DIR__.'/AppKernel.php';

class AppTestKernel extends AppKernel
{
    private $kernelModifier = null;

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

        if ($kernelModifier = $this->kernelModifier) {
            $kernelModifier($this);
            $this->kernelModifier = null;
        };
    }

    public function setKernelModifier(\Closure $kernelModifier)
    {
        $this->kernelModifier = $kernelModifier;

        // We force the kernel to shutdown to be sure the next request will boot it
        $this->shutdown();
    }
}

当您需要覆盖测试中的服务时,请在testAppKernel上调用setter并应用模拟

class TwitterTest extends WebTestCase
{
    public function testTwitter()
    {
        $twitter = $this->getMock('Twitter');
        // Configure your mock here.
        static::$kernel->setKernelModifier(function($kernel) use ($twitter) {
            $kernel->getContainer()->set('my_bundle.twitter', $twitter);
        });

        $this->client->request('GET', '/fetch/twitter'));

        $this->assertSame(200, $this->client->getResponse()->getStatusCode());
    }
}

在遵循本指南后,我遇到了一些问题,让phpunittest启动时使用新的AppTestKernel。

我发现symfonys WebTestCase(https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/Test/WebTestCase.php) 获取它找到的第一个AppKernel文件。因此,摆脱这种情况的一种方法是将AppTestKernel上的名称更改为在AppKernel之前或覆盖取代TestKernel的方法

这里我重写WebTestCase中的getKernelClass以查找* TestKernel.php

    protected static function getKernelClass()
  {
            $dir = isset($_SERVER['KERNEL_DIR']) ? $_SERVER['KERNEL_DIR'] : static::getPhpUnitXmlDir();

    $finder = new Finder();
    $finder->name('*TestKernel.php')->depth(0)->in($dir);
    $results = iterator_to_array($finder);
    if (!count($results)) {
        throw new \RuntimeException('Either set KERNEL_DIR in your phpunit.xml according to http://symfony.com/doc/current/book/testing.html#your-first-functional-test or override the WebTestCase::createKernel() method.');
    }

    $file = current($results);

    $class = $file->getBasename('.php');

    require_once $file;

    return $class;
}

在此之后,您的测试将加载新的AppTestKernel,您将能够在多个客户端请求之间模拟服务。

答案 4 :(得分:2)

根据Mibsen的回答,你也可以通过扩展WebTestCase并覆盖createClient方法以类似的方式设置它。这些方面的东西:

class MyTestCase extends WebTestCase
{
    private static $kernelModifier = null;

    /**
     * Set a Closure to modify the Kernel
     */
    public function setKernelModifier(\Closure $kernelModifier)
    {
        self::$kernelModifier = $kernelModifier;

        $this->ensureKernelShutdown();
    }

    /**
     * Override the createClient method in WebTestCase to invoke the kernelModifier
     */
    protected static function createClient(array $options = [], array $server = [])
    {
        static::bootKernel($options);

        if ($kernelModifier = self::$kernelModifier) {
            $kernelModifier->__invoke();
            self::$kernelModifier = null;
        };

        $client = static::$kernel->getContainer()->get('test.client');
        $client->setServerParameters($server);

        return $client;
    }
}

然后在测试中你会做类似的事情:

class ApplicationControllerTest extends MyTestCase
{
    public function testSomething()
    {
        $apiClient = $this->getMockMyApiClient();

        $this->setKernelModifier(function () use ($apiClient) {
            static::$kernel->getContainer()->set('myapiclient', $apiClient);
        });

        $client = static::createClient();

        .....

答案 5 :(得分:0)

制作模拟:

$mock = $this->getMockBuilder($className)
             ->disableOriginalConstructor()
             ->getMock();

$mock->method($method)->willReturn($return);

替换mock-object上的service_name:

$client = static::createClient()
$client->getContainer()->set('service_name', $mock);

我的问题是使用:

self::$kernel->getContainer();

答案 6 :(得分:0)

我在Symfony 4.4中也遇到了同样的问题。

阅读后 Create mocks in api functional testing with Symfony 我找到了解决方法-self::ensureKernelShutdown()

...    
$client->request('GET', '/any/url/');
$this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName))); // Passes.

self::ensureKernelShutdown()

$client->request('GET', '/any/url/');
$this->assertEquals('stdClass', get_class($client->getContainer()->get($keyName))); // Passes.
...