24 votes

Tests fonctionnels Symfony 2 avec services fictifs

J'ai un contrôleur pour lequel j'aimerais créer des tests fonctionnels. Ce contrôleur effectue des requêtes HTTP vers une API externe via une interface de type MyApiClient classe. J'ai besoin de faire une simulation de cette MyApiClient afin de pouvoir tester la façon dont mon contrôleur réagit à des réponses données (par exemple, ce qu'il fera si la classe MyApiClient renvoie une réponse 500).

Je n'ai aucun problème à créer une version simulée de l'interface utilisateur. MyApiClient via l'outil standard de construction de maquettes de PHPUnit : Le problème que j'ai est de faire en sorte que mon contrôleur utilise cet objet pour plus d'une requête.

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 train d'être reconstruit lorsque la deuxième demande est faite, ce qui provoque l'apparition de l'erreur d'affichage. MyApiClient pour être à nouveau instancié. Le site MyApiClient est configurée pour être un service via une annotation (en utilisant le JMS DI Extra Bundle) et injectée dans une propriété du contrôleur via une annotation.

Si je le pouvais, je séparerais chaque requête dans son propre test pour contourner ce problème, mais malheureusement je ne le peux pas : je dois faire une requête au contrôleur via une action GET et ensuite POST le formulaire qu'il ramène. J'aimerais faire cela pour deux raisons :

1) Le formulaire utilise une protection CSRF, donc si j'envoie un POST directement au formulaire sans utiliser le crawler pour le soumettre, le formulaire échoue la vérification CSRF.

2) Tester que le formulaire génère la bonne requête POST lorsqu'il est soumis est un bonus.

Quelqu'un a-t-il des suggestions sur la manière de procéder ?

EDIT :

Ceci peut être exprimé dans le test unitaire suivant qui ne dépend d'aucun de mes codes, donc peut ê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()

9voto

SimonSimCity Points 1403

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 maintenant une requête, vous laissez le système faire son travail pour la première fois, n'est-ce pas ?

Après la première demande, vous pouvez vouloir vérifier ce qui s'est passé, et donc, le noyau est dans un état, où la demande est envoyée, mais il est toujours en cours d'exécution.

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

Si vous voulez démarrer le noyau et le modifier avant que la requête ne 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 en réexécutant simplement self::createClient() . Maintenant, vous devez à nouveau appliquer votre simulacre, comme vous l'avez fait la première fois.

Voici 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 pouvez créer une méthode distincte, qui simule la nouvelle instance pour vous, de sorte que vous n'avez pas à copier votre code ...

8voto

genexp Points 317

J'ai pensé que je devais intervenir 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, mais configurer ceci dans le conteneur de service comme un paramètre n'est vraiment pas une bonne approche. L'idée générale est d'être en mesure de simuler cela dynamiquement pendant les tests individuels.

2voto

Sgoettschkes Points 5963

Le comportement que vous observez est en fait celui que vous observeriez dans n'importe quel scénario réel, car PHP ne partage rien et reconstruit toute la pile à chaque requête. La suite de tests fonctionnels imite ce comportement pour ne pas générer de résultats erronés. Un exemple serait la doctrine, qui a un ObjectCache, donc vous pourriez créer des objets, ne pas les sauvegarder dans la base de données et vos tests passeraient tous parce que les objets sont retirés du cache en permanence.

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

Créez une classe réelle qui est un TestDouble et émule les résultats que vous attendez de l'API réelle. C'est en fait très simple : vous créez une nouvelle classe MyApiClientTestDouble avec la même signature que votre MyApiClient et il suffit de modifier les corps de la méthode lorsque cela est nécessaire.

Dans votre service.yml, vous avez peut-être ceci :

parameters:
  myApiClientClass: Namespace\Of\MyApiClient

service:
  myApiClient:
    class: %myApiClientClass%

Si c'est le cas, vous pouvez facilement modifier la classe utilisée en ajoutant ce qui suit à votre config_test.yml :

parameters:
  myApiClientClass: Namespace\Of\MyApiClientTestDouble

Maintenant le conteneur de service utilisera 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 DI Extras Bundle, mais je suppose qu'il y a un moyen.

Vous pouvez également créer un ApiDouble, mettant en œuvre une API "réelle" qui se comporte de la même manière que votre API externe, mais qui renvoie des données de test. Vous feriez alors en sorte que l'URI de votre API soit géré par le conteneur de service (par exemple, injection de setter) et créeriez une variable de paramètres qui pointe vers la bonne API (celle de test en cas de dev ou de test et la vraie 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 effectuer la demande.

2voto

Mibsen Points 91

Je ne sais pas si vous avez trouvé le moyen de résoudre votre problème. Mais voici la solution que j'ai utilisée. C'est également utile pour les autres personnes qui rencontrent ce problème.

Après une longue recherche sur le problème de l'adaptation 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 parle de la façon dont le noyau s'arrête après chaque demande, rendant le service superposé 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 applique seulement quelques handlers pour modifier le Kernel : Exemples de code tirés du blogue de Lyrixx.

<?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, vous appelez le setter de testAppKernel et appliquez le mock

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 le phpunittest avec le nouveau AppTestKernel.

J'ai découvert que les symfonys WebTestCase ( https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/Test/WebTestCase.php ) Prend le premier fichier AppKernel qu'il trouve. Donc une façon de s'en sortir est de changer le nom de l'AppTestKernel pour qu'il passe avant l'AppKernel ou de surcharger la méthode pour prendre le TestKernel.

Ici, je remplace le getKernelClass dans le WebTestCase pour chercher 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 se chargeront avec le nouveau AppTestKernel et vous serez en mesure de simuler des services entre plusieurs demandes de clients.

2voto

Jeff Shillitto Points 136

En vous basant sur la réponse de Mibsen, vous pouvez également mettre en place une méthode similaire en étendant le WebTestCase et en surchargeant la méthode createClient. Quelque chose comme ça :

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

        .....

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X