wake-up-neo.net

Symfony 2-Funktionstests mit verspotteten Diensten

Ich habe einen Controller, für den ich Funktionstests erstellen möchte. Dieser Controller sendet HTTP-Anforderungen über eine MyApiClient-Klasse an eine externe API. Ich muss diese MyApiClient-Klasse verspotten, damit ich testen kann, wie mein Controller auf bestimmte Antworten reagiert (z. B. was passiert, wenn die MyApiClient-Klasse eine 500-Antwort zurückgibt).

Ich habe keine Probleme damit, eine verspottete Version der Klasse MyApiClient mit dem Standard-Mockbuilder von PHPUnit zu erstellen: Das Problem besteht darin, dass mein Controller dieses Objekt für mehr als eine Anforderung verwendet.

In meinem Test mache ich derzeit Folgendes:

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

Es sieht so aus, als würde der Container bei der zweiten Anforderung neu erstellt und die MyApiClient erneut instanziiert. Die Klasse MyApiClient ist so konfiguriert, dass sie über eine Annotation (mithilfe des JMS DI-Zusatzbündels) ein Service ist und über eine Annotation in eine Eigenschaft des Controllers eingefügt wird.

Ich würde jede Anforderung in einen eigenen Test aufteilen, um dies zu umgehen, wenn ich könnte, aber leider kann ich nicht: Ich muss eine Anforderung über eine GET-Aktion an den Controller senden und dann POST den Form bringt es zurück. Ich möchte dies aus zwei Gründen tun:

1) Das Formular verwendet CSRF-Schutz. Wenn ich also nur POST direkt an das Formular sende, ohne den Crawler zu verwenden, besteht das Formular die CSRF-Prüfung nicht.

2) Es ist ein Bonus, zu testen, ob das Formular beim Absenden die richtige POST -Anforderung generiert.

Hat jemand irgendwelche Vorschläge, wie man das macht?

BEARBEITEN:

Dies kann im folgenden Komponententest ausgedrückt werden, der von keinem meiner Codes abhängt.

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

Dieser Test schlägt fehl, auch wenn ich $client->getContainer()->set($keyName, new \stdClass()); unmittelbar vor dem zweiten Aufruf von request() anrufe.

21
ChrisC

Ich dachte, ich würde hier reinspringen. Chrisc, ich denke was du willst ist hier:

https://github.com/PolishSymfonyCommunity/SymfonyMockerContainer

Ich stimme Ihrem allgemeinen Ansatz zu, dies im Service-Container als Parameter zu konfigurieren, ist wirklich kein guter Ansatz. Die ganze Idee ist, dies bei einzelnen Testläufen dynamisch nachahmen zu können.

8
genexp

Wenn Sie self::createClient() aufrufen, erhalten Sie eine gebootete Instanz des Symfony2-Kernels. Das heißt, die gesamte Konfiguration wird analysiert und geladen. Wenn Sie jetzt eine Anfrage senden, lassen Sie das System dies zum ersten Mal tun, oder?

Nach der ersten Anforderung möchten Sie möglicherweise überprüfen, was vor sich ging. Daher befindet sich der Kernel in einem Zustand, in dem die Anforderung gesendet wird, sie jedoch noch ausgeführt wird.

Wenn Sie jetzt eine zweite Anforderung ausführen, muss der Kernel aufgrund der Webarchitektur neu gestartet werden, da bereits eine Anforderung ausgeführt wurde. Dieser Neustart wird in Ihrem Code ausgeführt, wenn Sie eine Anforderung zum zweiten Mal ausführen.

Wenn Sie den Kernel booten und ändern möchten, bevor die Anfrage an ihn gesendet wird (wie Sie möchten), müssen Sie die alte Kernel-Instanz herunterfahren und eine neue booten.

Sie können dies tun, indem Sie self::createClient() erneut ausführen. Jetzt musst du wieder deinen Schein anwenden, wie du es das erste Mal getan hast.

Dies ist der geänderte Code Ihres zweiten Beispiels:

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

Jetzt möchten Sie vielleicht eine separate Methode erstellen, die die neue Instanz für Sie verspottet, damit Sie Ihren Code nicht kopieren müssen ...

8
SimonSimCity

Basierend auf der Antwort von Mibsen können Sie dies auch auf ähnliche Weise einrichten, indem Sie den WebTestCase erweitern und die createClient-Methode überschreiben. Etwas in diese Richtung:

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

Dann würden Sie im Test etwas tun wie:

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

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

        $client = static::createClient();

        .....
2
Jeff Shillitto

Das Verhalten, das Sie beobachten, ist tatsächlich das, was Sie in einem realen Szenario erleben würden, da PHP nichts gemeinsam nutzt und bei jeder Anforderung den gesamten Stapel neu erstellt. Die Funktionstestsuite ahmt dieses Verhalten nach, um keine falschen Ergebnisse zu erzielen. Ein Beispiel wäre doctrine, das über einen ObjectCache verfügt, sodass Sie Objekte erstellen und nicht in der Datenbank speichern können. Alle Tests würden bestanden, da die Objekte die ganze Zeit aus dem Cache entfernt werden.

Sie können dieses Problem auf verschiedene Arten lösen:

Erstellen Sie eine echte Klasse, die ein TestDouble ist und die Ergebnisse emuliert, die Sie von der echten API erwarten würden. Das ist eigentlich ganz einfach: Sie erstellen eine neue MyApiClientTestDouble mit derselben Signatur wie Ihre normale MyApiClient und ändern bei Bedarf nur die Methodentexte.

In Ihrer service.yml haben Sie möglicherweise Folgendes:

parameters:
  myApiClientClass: Namespace\Of\MyApiClient

service:
  myApiClient:
    class: %myApiClientClass%

In diesem Fall können Sie leicht überschreiben, welche Klasse verwendet wird, indem Sie Ihrer config_test.yml Folgendes hinzufügen:

parameters:
  myApiClientClass: Namespace\Of\MyApiClientTestDouble

Jetzt verwendet der Service-Container beim Testen Ihr TestDouble. Wenn beide Klassen dieselbe Signatur haben, wird nichts mehr benötigt. Ich weiß nicht, ob oder wie das mit dem DI Extras Bundle funktioniert. Aber ich denke, es gibt einen Weg.

Sie können auch ein ApiDouble erstellen und eine "echte" API implementieren, die sich genauso verhält wie Ihre externe API, jedoch Testdaten zurückgibt. Sie würden dann den URI Ihrer API vom Service-Container verarbeiten lassen (z. B. Setter-Injection) und eine Parametervariable erstellen, die auf die richtige API verweist (die Test-API bei dev oder test und die Real-API bei der Produktionsumgebung) ).

Der dritte Weg ist ein bisschen hacky, aber Sie können in Ihren Tests immer eine private Methode erstellen, request, die zuerst den Container richtig einrichtet und dann den Client aufruft, um die Anfrage zu stellen.

2
Sgoettschkes

Ich weiß nicht, ob Sie jemals herausgefunden haben, wie Sie Ihr Problem beheben können. Aber hier ist die Lösung, die ich verwendet habe. Dies ist auch gut für andere Leute, die dies finden.

Nach einer langen Suche nach dem Problem mit dem Verspotten eines Dienstes zwischen mehreren Client-Anfragen fand ich diesen Blog-Beitrag:

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

in lyrixx wird erläutert, wie der Kernel nach jeder Anforderung heruntergefahren wird und der Dienst außer Kraft gesetzt wird, wenn Sie versuchen, eine andere Anforderung zu stellen.

Um dies zu beheben, erstellt er einen AppTestKernel, der nur für die Funktionstests verwendet wird.

Dieser AppTestKernel erweitert den AppKernel und wendet nur einige Handler an, um den Kernel zu ändern: Codebeispiele aus dem 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();
    }
}

Wenn Sie dann einen Dienst in Ihrem Test überschreiben müssen, rufen Sie den Setter im testAppKernel auf und wenden den Mock an

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

Nachdem ich dieser Anleitung gefolgt war, hatte ich einige Probleme, den phpunittest mit dem neuen AppTestKernel zum Starten zu bringen.

Ich fand heraus, dass der symfonys WebTestCase ( https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/Test/WebTestCase.php ) die erste gefundene AppKernel-Datei übernimmt. Eine Möglichkeit, dies zu vermeiden, besteht darin, den Namen im AppTestKernel so zu ändern, dass er vor dem AppKernel angezeigt wird, oder die Methode zu überschreiben, mit der stattdessen der TestKernel verwendet wird

Hier überschreibe ich die getKernelClass im WebTestCase, um nach einer * TestKernel.php zu suchen

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

Danach werden Ihre Tests mit dem neuen AppTestKernel geladen und Sie können Dienste zwischen mehreren Clientanforderungen nachahmen.

2
Mibsen

Machen Sie eine Verspottung:

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

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

Ersetzen Sie service_name auf dem Mock-Objekt:

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

Mein Problem war zu benutzen:

self::$kernel->getContainer();
0
Lebnik