web-dev-qa-db-fra.com

Tests unitaires - Gestion des dépendances

Cela peut être considéré comme un corollaire de Test du rappel des crochets .

Le problème: je veux tester une classe qui crée une nouvelle instance d'une classe My_Notice définie en dehors du plugin (appelons-le le "plugin principal").

Mon test unitaire ne connaît rien de My_Notice car il est défini dans une bibliothèque tierce (un autre plugin, pour être précis). Par conséquent, j'ai ces options (autant que je sache):

  1. Remplacez la classe My_Notice: difficile à maintenir
  2. Incluez les fichiers nécessaires de la bibliothèque tierce partie: cela peut fonctionner, mais je simplifie mes tests
  3. Dynamiquement stub la classe: pas sûr si c'est même possible, mais ce serait très similaire à se moquer d'une classe, sauf qu'il devrait également créer la définition de la même, de sorte que la classe que je teste sera en mesure de l'instancier.

La réponse de @gmazzap souligne que nous devrions éviter de créer ce type de dépendances, ce que je suis totalement d'accord. Et l'idée de créer des classes de stub ne me semble pas bonne: je préfère inclure le code du "Main Plugin", avec toutes les conséquences).

Cependant, je ne vois pas comment faire autrement.

Voici un exemple du code que j'essaie de tester:

class My_Admin_Notices_Handler {
    public function __construct( My_Notices $admin_notices ) {
        $this->admin_notices = $admin_notices;
    }

    /**
     * This will be hooked to the `activated_plugin` action
     *
     * @param string $plugin
     * @param bool   $network_wide
     */
    public function activated_plugin( $plugin, $network_wide ) {
        $this->add_notice( 'plugin', 'activated', $plugin );
    }

    /**
     * @param string $type
     * @param string $action
     * @param string $plugin
     */
    private function add_notice( $type, $action, $plugin ) {
        $message = '';
        if ( 'activated' === $action ) {
            if ( 'plugin' === $type ) {
                $message = __( '%1s Some message for plugin(s)', 'my-test-domain' );
            }
            if ( 'theme' === $type ) {
                $message = __( '%1s Some message for the theme', 'my-test-domain' );
            }
        }
        if ( 'updated' === $action && ( 'plugin' === $type || 'theme' === $type ) ) {
            $message = __( '%1s Another message for updated theme or plugin(s)', 'my-test-domain' );
        }

        if ( $message ) {
            $notice          = new My_Notice( $plugin, 'wpml-st-string-scan' );
            $notice->text    = $message;
            $notice->actions = array(
                new My_Admin_Notice_Action( __( 'Scan now', 'my-test-domain' ), '#' ),
                new My_Admin_Notice_Action( __( 'Skip', 'my-test-domain' ), '#', true ),
            );
            $this->admin_notices->add_notice( $notice );
        }
    }
}

En gros, cette classe a une méthode qui sera connectée à activated_plugin. Cette méthode construit une instance d'une classe "notification", qui sera stockée quelque part par l'instance My_Notices transmise au constructeur.

Le constructeur de la classe My_Notice reçoit deux arguments de base (un UID et un "groupe") et obtient certaines propriétés définies (sachant que le même problème concerne la classe My_Admin_Notice_Action).

Comment pourrais-je faire de la classe My_Notice une dépendance injectée?

Bien sûr, je pourrais utiliser un tableau associatif, appeler une action, qui est accrochée par le "plug-in principal" et qui traduit ce tableau dans les arguments de la classe, mais cela ne me semble pas clair.

7
Andrea Sciamanna

L'injection d'objet est-elle vraiment nécessaire?

Pour être entièrement testable de manière isolée, le code doit éviter d'instancier directement les classes et d'injecter tout objet nécessaire à l'intérieur des objets.

Cependant, il y a au moins deux exceptions à cette règle:

  1. La classe est une classe de langue, par exemple ArrayObject
  2. La classe est un "objet de valeur" approprié .

Pas besoin de forcer l'injection pour les objets de base

Le premier cas est facile à expliquer: il s’agit d’un élément intégré dans le langage, de sorte que vous supposez que cela fonctionne. Sinon, vous devriez également tester toutes les fonctions ou instructions PHP telles que return

Pas besoin de forcer l'injection pour l'objet de valeur

Le second cas est lié à la nature d'un objet de valeur. Dans les faits:

  • c'est immuable
  • il n'y a pas d'alternative polymorphe possible
  • par définition, une instance d'objet de valeur est identique à une autre qui a les mêmes arguments de constructeur

Cela signifie qu'un objet de valeur peut être considéré comme un type immuable, tout comme une chaîne, par exemple.

Si un code ne $myEmail = '[email protected]', personne ne craint de se moquer de cette chaîne, et de la même manière, personne ne devrait se préoccuper de se moquer d'une ligne comme new Email('[email protected]') (en supposant que Email est immuable et éventuellement final).

Pour ce que je peux deviner de votre code, My_Admin_Notice_Action est un bon candidat pour être/devenir un objet de valeur. Mais, ne peut pas être sûr sans voir le code.

Je ne peux pas en dire autant de My_Notice, mais ce n'est qu'une autre hypothèse.

Si l'injection est nécessaire…

Si la classe qui est instanciée dans une autre classe n’est pas l’un des deux cas ci-dessus, il est sûrement préférable de l’injecter.

Cependant, comme dans l'exemple de OP, la classe à construire a besoin d'arguments qui dépendent du contexte.

Dans ce cas, il n’ya pas de réponse "un", mais différentes approches peuvent être valables selon les cas.

Instanciation dans le code client

Une approche simple consiste à séparer le "code des objets" du "code client". Où code client est le code que utilise des objets.

De cette façon, vous pouvez tester le code des objets à l'aide de tests unitaires et laisser les tests de code client aux tests fonctionnels/d'intégration, pour lesquels vous n'avez pas à vous soucier de l'isolement.

Dans votre cas, ce sera quelque chose comme:

add_action( 'activate_plugin', function( $plugin, $network_wide ) {

   $message = My_Admin_Notices_Message( 'plugin', 'activated' );

   if ( $message->has_text() ) {

      $notice = new My_Notice( $plugin, $message, 'wpml-st-string-scan' );
      $notice->add_action( new My_Admin_Notice_Action( 'Scan now', '#' ) );
      $notice->add_action( new My_Admin_Notice_Action( 'Skip', '#', true ) );

      $notices = new My_Notices();
      $notices->add_notice( $notice );

      $handler = new My_Admin_Notices_Handler( $notices );
      $handler->handle_notices();
   }

}, 10, 2);

J'ai fait quelques suppositions sur votre code et écrit des méthodes et des classes qui n'existent peut-être pas (comme My_Admin_Notices_Message), mais le point ici est que la fermeture ci-dessus contient tout le code client nécessaire pour instancier et "utiliser" les objets. Vous pouvez ensuite tester vos objets de manière isolée car aucun de ces objets n'a besoin d'instancier d'autres objets, mais ils reçoivent tous les instances nécessaires dans le constructeur ou en tant que méthodes param.

Simplifier le code client avec les usines

L’approche ci-dessus peut bien fonctionner pour de petits plugins (ou une petite partie d’un plugin qui peut être isolée du reste), mais pour des bases de code plus grandes, en utilisant uniquement cette approche, vous pouvez vous retrouver avec un gros code client dans des fermetures qui, entre autres, est très difficile à tester et à maintenir.

Dans ces cas, les usines peuvent vous aider. Les usines sont des objets dans le seul but de créer d'autres objets. La plupart du temps, il est bon d'avoir des usines spécifiques pour des objets du même type (implémentant la même interface).

Avec les usines, le code ci-dessus pourrait ressembler à ceci:

add_action( 'activate_plugin', function( $plugin, $network_wide ) {

   $notice = $notice_factory->create_for( 'plugin', 'activated' );

   if ( $notice instanceof My_Notice_Interface ) {
      $handler_factory = new My_Admin_Notices_Handler_Factory();
      $handler = $handler_factory->build_for_notices( [ $notice ] );
      $handler->handle_notices();
   }

}, 10, 2);

Tout le code d'instanciation est en usine. Vous pouvez toujours tester les fabriques de manière isolée, car vous devez vérifier que si les arguments corrects sont utilisés, elles produisent les classes attendues (ou que les arguments incorrects sont à l'origine des erreurs attendues).

Et pourtant, vous pouvez tester tous les autres objets de manière isolée car aucun objet n'a besoin de créer d'instances. En réalité, le code d'instanciation est entièrement dans des usines.

Bien sûr, rappelez-vous que les objets de valeur n'ont pas besoin d'usines ... ce serait comme créer des usines pour des chaînes ...

Et si je ne peux pas changer le code?

Stubs

Parfois, il n'est pas possible de changer le code qui instancie d'autres objets, pour différentes raisons. Par exemple. le code est une tierce partie, une compatibilité ascendante, etc.

Dans ces cas, s'il est possible d'exécuter les tests sans charger les classes en cours d'instanciation, vous pouvez écrire des stubs.

Supposons que vous avez un code qui fait:

class Foo {

   public function run_something() {
     $something = new Something();
     $something->run();
   }
}

Si vous êtes en mesure d'exécuter des tests sans charger la classe Something, vous pouvez écrire une classe Something personnalisée uniquement à des fins de test (un "stub").

Il est toujours préférable de garder les talons très simples, par exemple:

class Something{

   public function run() {
     return 'I ran'.
   }
}

Lorsque les tests sont exécutés, vous pouvez charger le fichier contenant ce stub pour la classe Something (par exemple, à partir de tests setUp()). Lorsque la classe sous tests instanciera un new Something, vous le testerez de manière isolée, car la configuration est très simple et créez-le d'une manière qui, par sa conception, répond à vos attentes.

Bien sûr, ce n’est pas très simple à maintenir, mais étant donné que normalement, vous n’effectuez pas de tests unitaires avec du code tiers, vous devez rarement le faire.

Parfois, cependant, cela est utile pour tester dans le code de plugins/thèmes d’isolation qui instancie des objets WordPress (par exemple, WP_Post).

Surcharge de la moquerie

En utilisant Mockery (une bibliothèque qui fournit des outils pour les tests unitaires PHP), vous pouvez même éviter d'écrire ces stubs. Avec Mockery "instance mock" (ou "surcharge") est possible d'intercepter la création de nouvelles instances et de la remplacer par une maquette. Cet article explique assez bien comment le faire.

Quand la classe est chargée ...

Si le code à tester a des dépendances difficiles (instancier des classes avec new) et qu’il n’ya aucune possibilité de charger des tests sans charger la classe qui va être instanciée, il y a très peu de chances de le tester seul sans toucher au code (ou écrire un script). couche d'abstraction autour de lui).

Cependant, notez que le fichier de démarrage de test est souvent chargé en tant que première chose absolue. Il y a donc au moins deux cas dans lesquels vous pouvez forcer le chargement de vos talons:

  1. Le code utilise un autochargeur. Dans ce cas, si vous chargez les stubs avant l'autoloader, la classe "réelle" n'est jamais chargée, car lorsque new est utilisé, la classe est déjà trouvée et l'autoloader n'est pas déclenché.

  2. Le code vérifie class_exists avant de définir/charger la classe. Dans ce cas, charger les stubs en premier lieu empêchera le chargement de la "vraie" classe.

Le dernier recours délicat

Lorsque tout le reste échoue, vous pouvez également tester des dépendances difficiles.

Très souvent, les dépendances difficiles sont stockées en tant que variables privées.

class Foo {

   public function __construct() {
     $this->something = new Something();
   }

   public function run_something() {
     return $this->something->run();
   }
}

dans ce cas, vous pouvez remplacer les dépendances difficiles par un faux/stub after l'instance est créée.

Ceci parce que même les propriétés private peuvent être (assez) remplacées facilement en PHP. À plus d'un titre, en fait.

Sans entrer dans les détails, je peux dire que la reliure de fermeture peut être utilisée à cette fin. J'ai même écrit une bibliothèque appelée "Andrew" qui peut être utilisée pour la portée.

En utilisant Andrew (et Mockery) pour tester la classe Foo ci-dessus, vous pouvez effectuer les opérations suivantes:

public function test_run_something() {

    $foo = new Foo();

    $something_mock = Mockery::mock( 'Something' );
    $something_mock
        ->shouldReceive('run')
        ->once()
        ->withNoArgs()
        ->andReturn('I ran.');

    // changing proxy properties will change private properties of proxied object
    $proxy = new Andrew\Proxy( $foo );
    $proxy->something = $something_mock;

    $this->assertSame( 'I ran.', $foo->run_something() );
}
6
gmazzap