Unit Tests - Umgang mit Abhängigkeiten

7

Dies kann als Folge des Rückrufs von Testing Hooks angesehen werden .

Das Problem: Ich möchte eine Klasse testen, die eine neue Instanz einer My_Noticeaußerhalb des Plugins definierten Klasse erstellt (nennen wir sie das "Haupt-Plugin").

Mein Unit-Test weiß nichts darüber, My_Noticeda er in einer Bibliothek eines Drittanbieters definiert ist (genauer gesagt in einem anderen Plugin). Daher habe ich folgende Optionen (soweit ich weiß):

  1. Stub die My_NoticeKlasse: schwer zu pflegen
  2. Fügen Sie die benötigten Dateien aus der Bibliothek eines Drittanbieters hinzu: Dies funktioniert möglicherweise, aber ich mache meine Tests weniger isoliert
  3. Dynamisches Stubben der Klasse: Ich bin mir nicht sicher, ob dies überhaupt möglich ist, aber es wäre sehr ähnlich, eine Klasse zu verspotten, außer dass sie auch die Definition derselben erstellen sollte, damit die Klasse, die ich teste, sie instanziieren kann.

Die Antwort von @gmazzap weist darauf hin, dass wir diese Art von Abhängigkeiten vermeiden sollten, dem stimme ich voll und ganz zu. Und die Idee, Stub-Klassen zu erstellen, scheint mir nicht gut zu sein: Ich würde lieber den Code des "Main Plugins" mit allen Konsequenzen einfügen.

Ich sehe jedoch nicht, wie ich es anders machen kann.

Hier ist ein Beispiel für den Code, den ich testen möchte:

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

Grundsätzlich hat diese Klasse eine Methode, an die gebunden wird activated_plugin. Diese Methode erstellt eine Instanz einer "Benachrichtigungs" -Klasse, die irgendwo von der My_Noticesan den Konstruktor übergebenen Instanz gespeichert wird .

Der Konstruktor der My_NoticeKlasse erhält zwei grundlegende Argumente (eine UID und eine "Gruppe") und erhält einige Eigenschaften (beachten Sie, dass das gleiche Problem bei der My_Admin_Notice_ActionKlasse auftritt).

Wie könnte ich die My_NoticeKlasse zu einer injizierten Abhängigkeit machen?

Natürlich könnte ich ein assoziatives Array verwenden, eine Aktion aufrufen, die vom "Haupt-Plugin" verknüpft wird und dieses Array in die Argumente der Klasse übersetzt, aber für mich sieht es nicht sauber aus.

Andrea Sciamanna
quelle
Das Problem ist wahrscheinlich die Art und Weise, wie Sie Ihren Code strukturiert haben, und nicht die Implementierung von Ansatz A oder B, aber generische Fragen zum Testen von Einheiten wie diese werden bei SO wahrscheinlich besser gestellt. Ich sehe ehrlich gesagt kein Problem beim Testen Ihres Codes, kann also nicht einmal verstehen, mit welchem ​​Problem Sie konfrontiert sind. Übrigens, nur weil eine Antwort 30 positive Stimmen erhalten hat, heißt das nicht, dass dies der einzig gültige Weg ist, Dinge zu tun
Mark Kaplun,
@ MarkKaplun Ich habe einige Klarstellungen hinzugefügt, was das Problem beim Testen dieser Klasse ist. Ich hoffe das macht mehr Sinn. Ich habe die Frage hier als Folge der ursprünglichen Frage hinzugefügt, die sich im selben Netzwerk befindet. Da das Problem dem in der ursprünglichen Frage gestellten sehr ähnlich ist, bin ich mir nicht 100% sicher, ob ich es bei SO verschieben muss, aber jede Anleitung ist sehr willkommen!
Andrea Sciamanna
1
Das war eine andere Zeit, in der die Regeln hier anders waren, und die Antwort ist einfach nicht gut. Obwohl jedes Wort dort wahr ist und ich damit einverstanden bin, ist der Sinn des Schreibens eines Plugins für WordPress darin zu integrieren. Wenn Sie also isoliert testen, erhalten Sie nur sehr wenig, insbesondere wenn Ihr Code wie der hier gezeigte relativ trivial ist. Das isolierte Testen ist großartig, aber das Testen sollte auch nützlich und nicht nur rein sein.
Mark Kaplun

Antworten:

7

Ist eine Objektinjektion wirklich notwendig?

Um isoliert vollständig testbar zu sein, sollte Code vermeiden, Klassen direkt zu instanziieren und alle Objekte, die benötigt werden, in Objekte einzufügen.

Es gibt jedoch mindestens zwei Ausnahmen von dieser Regel:

  1. Die Klasse ist eine Sprachklasse, z ArrayObject
  2. Die Klasse ist ein richtiges "Wertobjekt" .

Keine Notwendigkeit, die Injektion für Kernobjekte zu erzwingen

Der erste Fall ist leicht zu erklären: Es ist etwas, das in die Sprache eingebettet ist, also nehmen Sie einfach an, dass es funktioniert. Andernfalls sollten Sie auch alle PHP-Kernfunktionen oder -Anweisungen wie return… testen.

Keine Notwendigkeit, die Injektion für das Wertobjekt zu erzwingen

Der zweite Fall bezieht sich auf die Art eines Wertobjekts. Eigentlich:

  • es ist unveränderlich
  • es gibt keine mögliche polymorphe Alternative
  • Per Definition ist eine Wertobjektinstanz nicht von einer anderen zu unterscheiden, die dieselben Konstruktorargumente hat

Dies bedeutet, dass ein Wertobjekt als eigenständiger Typ angesehen werden kann, genau wie beispielsweise eine Zeichenfolge.

Wenn ein Code dies tut, ist $myEmail = '[email protected]'niemand besorgt darüber, diese Zeichenfolge zu verspotten, und auf die gleiche Weise sollte niemand besorgt darüber sein, eine Zeile wie zu verspotten new Email('[email protected]')(vorausgesetzt, dies Emailist unveränderlich und möglicherweise final).

Denn was ich aus Ihrem Code erraten kann, My_Admin_Notice_Actionist ein guter Kandidat, um ein Wertobjekt zu sein / zu werden. Aber ich kann nicht sicher sein, ohne den Code zu sehen.

Ich kann nicht dasselbe sagen My_Notice, aber das ist nur eine andere Vermutung.

Wenn eine Injektion erforderlich ist…

Falls die Klasse, die in einer anderen Klasse instanziiert wird, nicht einer der beiden oben genannten Fälle ist, ist es sicherlich besser, sie zu injizieren.

Wie im Beispiel in OP benötigt die zu erstellende Klasse jedoch Argumente, die vom Kontext abhängen.

In diesem Fall gibt es keine "eine" Antwort, sondern unterschiedliche Ansätze, die je nach Fall alle gültig sein können.

Instanziierung im Client-Code

Ein einfacher Ansatz besteht darin, "Objektcode" von "Clientcode" zu trennen. Wobei Client-Code der Code ist, der Objekte verwendet.

Auf diese Weise können Sie Objektcode mithilfe von Komponententests testen und Client-Codetests Funktions- / Integrationstests überlassen, bei denen Sie sich nicht um Isolation kümmern müssen.

In Ihrem Fall wird es so etwas wie:

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

Ich habe einige Vermutungen zu Ihrem Code angestellt und Methoden und Klassen geschrieben, die möglicherweise nicht existieren (wie My_Admin_Notices_Message), aber der Punkt hier ist, dass der obige Abschluss den gesamten Client-Code enthält, der zum Instanziieren und "Verwenden" der Objekte erforderlich ist. Sie können Ihre Objekte dann isoliert testen, da keines dieser Objekte andere Objekte instanziieren muss, aber alle die erforderlichen Instanzen im Konstruktor oder als Methodenparameter erhalten.

Vereinfachen Sie den Client-Code mit Fabriken

Der obige Ansatz funktioniert möglicherweise gut für kleine Plugins (oder einen kleinen Teil eines Plugins, der vom Rest des Plugins isoliert werden kann), aber für größere Codebasen, wenn Sie nur diesen Ansatz verwenden, können Sie unter anderem mit großem Client-Code in Closures enden Dinge, ist sehr schwer zu testen und zu warten.

In diesen Fällen können Ihnen Fabriken helfen. Fabriken sind Objekte mit dem alleinigen Umfang, andere Objekte zu kreten. Meistens ist es gut, bestimmte Fabriken für Objekte desselben Typs zu haben (Implementierung derselben Schnittstelle).

Bei Fabriken könnte der obige Code folgendermaßen aussehen:

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

Der gesamte Instanziierungscode befindet sich in Fabriken. Sie können Fabriken weiterhin isoliert testen, da Sie testen müssen, ob sie bei richtigen Argumenten erwartete Klassen erzeugen (oder bei falschen Argumenten erwartete Fehler erzeugen).

Und dennoch können Sie alle anderen Objekte isoliert testen, da keine Objekte Instanzen erstellen müssen. In Fakten befindet sich der Instanziierungscode ausschließlich in Fabriken.

Denken Sie natürlich daran, dass Wertobjekte keine Fabriken benötigen. Es wäre, als würden Sie Fabriken für Zeichenfolgen erstellen.

Was ist, wenn ich den Code nicht ändern kann?

Stubs

Manchmal ist es aus verschiedenen Gründen nicht möglich, den Code zu ändern, der andere Objekte instanziiert. ZB ist Code von Drittanbietern, Abwärtskompatibilität und so weiter.

In diesen Fällen können Sie einige Stubs schreiben, wenn es möglich ist, die Tests auszuführen, ohne die zu instanziierenden Klassen zu laden.

Nehmen wir an, Sie haben einen Code, der Folgendes tut:

class Foo {

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

Wenn Sie Tests ausführen können, ohne die SomethingKlasse zu laden , können Sie eine benutzerdefinierte SomethingKlasse nur zum Testen schreiben (einen "Stub").

Es ist immer besser, Stubs sehr einfach zu halten, z.

class Something{

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

Wenn Tests ausgeführt werden, können Sie die Datei laden, die diesen Stub für die SomethingKlasse enthält (z. B. aus Tests setUp()). Wenn die zu testende Klasse a instanziiert new Something, testen Sie sie isoliert, da die Einrichtung sehr einfach ist und Sie sie erstellen können Ein Weg, der von Natur aus das tut, was Sie erwarten.

Natürlich ist dies nicht sehr einfach zu warten, aber wenn man bedenkt, dass man normalerweise keinen Code von Drittanbietern testet, muss man dies selten tun.

Manchmal ist dies jedoch hilfreich, um isolierten Plugins / Themes-Code zu testen, der WordPress-Objekte instanziiert (z WP_Post. B. ).

Spottüberladung

Mit Mockery(einer Bibliothek, die Tools für PHP-Unit-Tests bereitstellt) können Sie sogar vermeiden, diese Stubs zu schreiben. Mit Mockery ist "Instance Mock" (auch bekannt als "Overload") möglich, die Erstellung neuer Instanzen abzufangen und durch ein Mock zu ersetzen. Dieser Artikel erklärt ziemlich gut, wie es geht.

Wenn die Klasse geladen ist ...

Wenn der zu testende Code starke Abhängigkeiten aufweist (Klassen mithilfe von instanziieren new) und es keine Möglichkeit gibt, Tests zu laden, ohne die zu instanziierende Klasse zu laden, besteht nur eine sehr geringe Wahrscheinlichkeit, ihn isoliert zu testen, ohne den Code zu berühren (oder eine Abstraktion zu schreiben) Schicht darum herum).

Beachten Sie jedoch, dass die Test-Bootstrap-Datei häufig als erstes geladen wird. Es gibt also mindestens zwei Fälle, in denen Sie das Laden Ihrer Stubs erzwingen können:

  1. Code verwendet einen Autoloader. Wenn Sie in diesem Fall die Stubs vor dem Laden des Autoloaders laden, wird die "echte" Klasse nie geladen, da bei newVerwendung die Klasse bereits gefunden und der Autoloader nicht ausgelöst wird.

  2. Code prüft class_existsvor dem Definieren / Laden der Klasse. In diesem Fall wird durch das Laden der Stubs als Erstes verhindert, dass die "echte" Klasse geladen wird.

Der knifflige letzte Ausweg

Wenn alles andere fehlschlägt, können Sie noch etwas tun, um harte Abhängigkeiten zu testen.

Sehr oft werden harte Abhängigkeiten als private Variablen gespeichert.

class Foo {

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

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

In solchen Fällen können Sie die harten Abhängigkeiten nach dem Erstellen der Instanz durch einen Mock / Stub ersetzen .

Dies liegt daran, dass sogar privateEigenschaften in PHP (ziemlich) einfach ersetzt werden können. Eigentlich in mehr als einer Hinsicht.

Ohne auf Details einzugehen, kann ich sagen, dass die Verschlussbindung dazu verwendet werden kann. Ich habe sogar eine Bibliothek namens "Andrew" geschrieben , die für den Bereich verwendet werden kann.

Mit Andrew (und Mockery) zum Testen der Fooobigen Klasse können Sie Folgendes tun:

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