web-dev-qa-db-fra.com

Tests fonctionnels Symfony 2 avec services simulés

J'ai un contrôleur pour lequel je souhaite créer des tests fonctionnels. Ce contrôleur envoie des requêtes HTTP à une API externe via une classe MyApiClient. Je dois simuler cette classe MyApiClient pour pouvoir tester la réponse de mon contrôleur à des réponses données (par exemple, que fera-t-il si la classe MyApiClient renvoie une réponse 500)?.

Je n'ai aucun problème à créer une version fictive de la classe MyApiClient via le modélisateur factice standard de PHPUnit: le problème que je rencontre est que mon contrôleur utilise cet objet pour plusieurs requêtes.

Je fais actuellement ce qui suit dans mon test:

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

Il semble que le conteneur soit en cours de reconstruction lors de la deuxième requête, ce qui provoque l'instanciation de la MyApiClient. La classe MyApiClient est configurée pour être un service via une annotation (à l'aide du bundle JMS DI Extra) et injectée dans une propriété du contrôleur via une annotation.

Si je le pouvais, je divisais chaque requête en son propre test, mais malheureusement, je ne peux pas: je dois adresser une requête au contrôleur via une action GET, puis POST forme il ramène. J'aimerais faire ceci pour deux raisons:

1) Le formulaire utilise la protection CSRF. Par conséquent, si je POST directement sur le formulaire sans utiliser le robot pour le soumettre, le formulaire échoue lors de la vérification CSRF.

2) Le fait de vérifier que le formulaire génère la bonne demande POST lorsqu’il est soumis est un bonus.

Quelqu'un a-t-il des suggestions sur la façon de procéder?

MODIFIER:

Ceci peut être exprimé dans le test unitaire suivant, qui ne dépend d'aucun de mes codes, peut donc être plus clair:

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

Ce test échoue, même si j'appelle $client->getContainer()->set($keyName, new \stdClass()); immédiatement avant le deuxième appel à request()

21
ChrisC

Je pensais que je sauterais ici. Chrisc, je pense que ce que tu veux est ici:

https://github.com/PolishSymfonyCommunity/SymfonyMockerContainer

Je suis d'accord avec votre approche générale, la configuration de ce paramètre dans le conteneur de services en tant que paramètre n'est vraiment pas une bonne approche. L'idée est de pouvoir simuler cela de manière dynamique lors de tests individuels.

8
genexp

Lorsque vous appelez self::createClient(), vous obtenez une instance démarrée du noyau Symfony2. Cela signifie que toute la configuration est analysée et chargée. Lorsque vous envoyez une demande, vous laissez le système faire son travail pour la première fois, n'est-ce pas?

Après la première demande, vous voudrez peut-être vérifier ce qui s'est passé et, par conséquent, le noyau est dans un état où la demande est envoyée, mais il est toujours en cours d'exécution.

Si vous exécutez maintenant une deuxième demande, l'architecture Web exige que le noyau redémarre, car il a déjà exécuté une demande. Ce redémarrage, dans votre code, est exécuté lorsque vous exécutez une demande pour la deuxième fois.

Si vous souhaitez démarrer le noyau et le modifier avant que la demande lui soit envoyée (comme vous le souhaitez), vous devez arrêter l'ancienne instance du noyau et en démarrer une nouvelle.

Vous pouvez le faire simplement en réexécutant self::createClient(). Maintenant, vous devez à nouveau appliquer votre maquette, comme vous l'avez fait la première fois.

Ceci est le code modifié de votre deuxième exemple:

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

Maintenant, vous voudrez peut-être créer une méthode distincte, qui se moque de la nouvelle instance pour vous, de sorte que vous n'ayez pas à copier votre code ...

8
SimonSimCity

Sur la base de la réponse de Mibsen, vous pouvez également configurer cela de manière similaire en développant WebTestCase et en redéfinissant la méthode createClient. Quelque chose dans ce sens:

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

Ensuite, dans le test, vous feriez quelque chose comme:

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

Le comportement que vous rencontrez correspond en réalité à ce que vous rencontriez dans n'importe quel scénario réel, car PHP ne partage rien et reconstruit l'intégralité de la pile à chaque demande. La suite de tests fonctionnels imite ce comportement pour ne pas générer de résultats erronés. Un exemple serait doctrine, qui a un ObjectCache, vous pouvez donc créer des objets, pas les enregistrer dans la base de données, et vos tests réussiraient tous parce qu’ils retirent les objets du cache tout le temps.

Vous pouvez résoudre ce problème de différentes manières:

Créez une vraie classe qui est un TestDouble et émule les résultats que vous attendez de la vraie API. C’est très simple: vous créez une nouvelle variable MyApiClientTestDouble avec la même signature que votre MyApiClient normale et vous ne modifiez que les corps de méthodes si nécessaire.

Dans votre service.yml, vous pourriez bien avoir ceci:

parameters:
  myApiClientClass: Namespace\Of\MyApiClient

service:
  myApiClient:
    class: %myApiClientClass%

Si tel est le cas, vous pouvez facilement remplacer la classe prise en ajoutant ce qui suit à votre fichier config_test.yml:

parameters:
  myApiClientClass: Namespace\Of\MyApiClientTestDouble

Le conteneur de service utilisera maintenant votre TestDouble lors des tests. Si les deux classes ont la même signature, rien de plus n'est nécessaire. Je ne sais pas si ou comment cela fonctionne avec le bundle DI Extras. mais je suppose qu'il y a un moyen.

Ou vous pouvez créer un ApiDouble, implémentant une "vraie" API qui se comporte de la même manière que votre API externe mais renvoie des données de test. Vous devez ensuite créer l'URI de votre API gérée par le conteneur de service (par exemple, l'injection de setter) et créer une variable de paramètres qui pointe vers la bonne API (le test en cas de dev ou test et la valeur réelle en cas d'environnement de production). ).

La troisième méthode est un peu compliquée, mais vous pouvez toujours créer une méthode privée dans vos tests request, qui configure d’abord le conteneur de la bonne manière, puis appelle le client pour faire la demande.

2
Sgoettschkes

Je ne sais pas si vous avez déjà découvert comment résoudre votre problème. Mais voici la solution que j'ai utilisée. C'est également bon pour les autres personnes qui découvrent cela.

Après une longue recherche du problème avec le mocking d'un service entre plusieurs demandes de clients, j'ai trouvé cet article de blog:

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

lyrixx explique comment le noyau s’arrête après chaque demande, rendant le remplacement de service invalide lorsque vous essayez de faire une autre demande.

Pour résoudre ce problème, il crée un AppTestKernel utilisé uniquement pour les tests de fonction.

Cet AppTestKernel étend l'AppKernel et n'applique que quelques gestionnaires pour modifier le noyau: Exemples de code provenant de 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();
    }
}

Lorsque vous devez ensuite remplacer un service dans votre test, appelez le configurateur sur le testAppKernel et appliquez le faux

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

Après avoir suivi ce guide, j'ai eu quelques problèmes pour faire démarrer phpunittest avec le nouvel AppTestKernel.

J'ai découvert que le WebTestCase de Symfonys ( https://github.com/symfony/symfony/blob/master/src/Sundfony/Bundle/FrameworkBundle/Test/WebTestCase.php ) récupère le premier fichier AppKernel qu'il trouve. Donc, un moyen de sortir de cela consiste à changer le nom sur AppTestKernel pour qu'il vienne avant AppKernel ou à remplacer la méthode pour prendre TestKernel à la place.

Ici, je remplace la getKernelClass dans le WebTestCase pour rechercher un * 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;
}

Après cela, vos tests seront chargés avec le nouveau AppTestKernel et vous pourrez simuler des services entre plusieurs demandes de clients.

2
Mibsen

Faites une maquette:

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

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

Remplacez nom_service sur l'objet-maquette:

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

Mon problème était d'utiliser:

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