Mutationstests in PHP: eine qualitative Messung der Codeabdeckung

Wie bewertet man die Qualität von Tests? Viele verlassen sich auf die beliebteste Metrik, die allen bekannt ist - die Codeabdeckung. Dies ist jedoch eine quantitative und keine qualitative Metrik. Es zeigt, wie viel von Ihrem Code von Tests abgedeckt wird, aber nicht, wie gut diese Tests geschrieben sind.

Eine Möglichkeit, dies herauszufinden, sind Mutationstests. Mit diesem Tool, das geringfügige Änderungen am Quellcode vornimmt und anschließend die Tests erneut ausführt, können Sie nutzlose Tests und eine Abdeckung von geringer Qualität identifizieren.

Beim Badoo PHP Meetup im März sprach ich darüber, wie man Mutationstests für PHP-Code organisiert und auf welche Probleme Sie stoßen könnten. Das Video ist hier verfügbar und für die Textversion willkommen bei cat.



Was ist Mutationstest?


Um zu erklären, was ich meine, zeige ich Ihnen einige Beispiele. Sie sind einfach, stellenweise übertrieben und mögen offensichtlich erscheinen (obwohl reale Beispiele normalerweise recht komplex sind und nicht mit den Augen gesehen werden können).

Betrachten Sie die Situation: Wir haben eine elementare Funktion, die behauptet, ein Erwachsener zu sein, und es gibt einen Test, der sie testet. Der Test hat einen dataProvider, dh er testet zwei Fälle: Alter 17 Jahre und Alter 19 Jahre. Ich denke, es ist für viele von Ihnen offensichtlich, dass isAdult zu 100% abgedeckt ist. Die einzige Zeile. Es wird durch einen Test durchgeführt. Alles ist ganz toll.



Bei näherer Betrachtung zeigt sich jedoch, dass unser Anbieter schlecht geschrieben ist und keine Randbedingungen testet: Das Alter von 18 Jahren als Randbedingung wird nicht getestet. Sie können das> -Zeichen durch> = ersetzen, und der Test erkennt eine solche Änderung nicht.

Ein weiteres Beispiel, etwas komplizierter. Es gibt eine Funktion, die ein einfaches Objekt erstellt, das Setter und Getter enthält. Wir haben drei Felder festgelegt, und es gibt einen Test, der überprüft, ob die Funktion buildPromoBlock das erwartete Objekt wirklich erfasst.



Wenn Sie genau hinschauen, haben wir auch setSomething, das eine Eigenschaft auf true setzt. Aber im Test haben wir keine solche Behauptung. Das heißt, wir können diese Zeile aus buildPromoBlock entfernen - und unser Test wird diese Änderung nicht erfassen. Gleichzeitig haben wir eine 100% ige Abdeckung in der buildPromoBlock-Funktion, da alle drei Zeilen während des Tests ausgeführt wurden.

Diese beiden Beispiele führen uns zu Mutationstests.

Bevor ich den Algorithmus zerlege, werde ich eine kurze Definition geben. Mutationstests sind ein Mechanismus, der es uns ermöglicht, durch geringfügige Änderungen am Code die Handlungen des bösen Pinocchio oder des Junior Vasya nachzuahmen, der gekommen ist und ihn gezielt zu brechen begann, die> Zeichen durch <, = by! = Zu ersetzen und so weiter. Für jede solche Änderung, die wir für gute Zwecke vornehmen, führen wir Tests durch, die die geänderte Zeile abdecken sollen.

Wenn die Tests uns nichts gezeigt haben, wenn sie nicht gefallen sind, sind sie wahrscheinlich nicht effektiv genug. Sie testen keine Grenzfälle, enthalten keine Aussagen: Vielleicht müssen sie verbessert werden. Wenn die Tests fallen, sind sie cool. Sie schützen wirklich vor solchen Veränderungen. Daher ist unser Code schwerer zu brechen.

Lassen Sie uns nun den Algorithmus analysieren. Es ist ganz einfach. Das erste, was wir tun, um Mutationstests durchzuführen, ist, den Quellcode zu nehmen. Als Nächstes erhalten wir Codeabdeckung, um zu wissen, welche Tests für welche Zeichenfolge ausgeführt werden sollen. Danach gehen wir den Quellcode durch und generieren die sogenannten Mutanten.

Eine Mutante ist eine einzelne Codeänderung. Das heißt, wir übernehmen eine bestimmte Funktion, bei der es im Vergleich ein> -Zeichen gab. Wenn wir dieses Vorzeichen in> = - ändern, erhalten wir eine Mutante. Danach führen wir die Tests durch. Hier ist ein Beispiel für eine Mutation (wir haben> durch> = ersetzt):



In diesem Fall werden Mutationen nicht zufällig, sondern nach bestimmten Regeln vorgenommen. Die Mutationstestantwort ist idempotent. Unabhängig davon, wie oft wir Mutationstests mit demselben Code ausführen, werden dieselben Ergebnisse erzielt.

Als letztes führen wir die Tests durch, die die mutierte Linie abdecken. Holen Sie es aus der Abdeckung. Es gibt nicht optimale Tools, die alle Tests steuern. Aber ein gutes Werkzeug wird nur diejenigen vertreiben, die benötigt werden.

Danach werten wir das Ergebnis aus. Tests fielen - dann ist alles in Ordnung. Wenn sie nicht gefallen sind, sind sie nicht sehr effektiv.

Metriken


Welche Metriken geben uns Mutationstests? Es fügt drei weitere zur Codeabdeckung hinzu, über die wir jetzt sprechen werden.

Aber zuerst analysieren wir die Terminologie.



Es gibt das Konzept der getöteten Mutanten: Dies sind die Mutanten, die unsere Tests „genagelt“ haben (dh sie haben sie gefangen).



Es gibt das Konzept der entkommenen Mutante (überlebende Mutanten). Dies sind die Mutanten, die es geschafft haben, eine Bestrafung zu vermeiden (das heißt, die Tests haben sie nicht gefangen).



Und es gibt Konzepte, die Mutanten abdecken - eine Mutante, die von Tests abgedeckt wird, und eine nicht abgedeckte Mutante gegenüber, die von keinem Test abgedeckt wird (d. H. Wir haben Code, es hat Geschäftslogik, wir können ihn ändern, aber keinen einzelnen Test prüft nicht auf Änderungen).

Der Hauptindikator, den uns Mutationstests geben, ist der MSI (Mutation Score Indicator), das Verhältnis der Anzahl der getöteten Mutanten zu ihrer Gesamtzahl.

Der zweite Indikator ist die Abdeckung des Mutationscodes. Es ist nur qualitativ, nicht quantitativ, denn es zeigt, wie viel Geschäftslogik Sie brechen und regelmäßig tun können. Unsere Tests werden abgefangen.

Und die letzte Metrik ist MSI, d. H. Ein weicherer MSI. In diesem Fall berechnen wir den MSI nur für diejenigen Mutanten, die durch Tests abgedeckt wurden.

Probleme mit Mutationstests


Warum hat weniger als die Hälfte der Programmierer von diesem Tool gehört? Warum wird es nicht überall verwendet?

Niedrige Geschwindigkeit


Das erste Problem (eines der Hauptprobleme) ist die Geschwindigkeit der Mutationstests. Wenn wir im Code Dutzende von Mutationsoperatoren haben, selbst für die einfachste Klasse, können wir Hunderte von Mutationen generieren. Für jede Mutation müssen Sie Tests durchführen. Wenn wir beispielsweise 5.000 Unit-Tests haben, die zehn Minuten lang laufen, können Mutationstests Stunden dauern.

Was kann getan werden, um dies auszugleichen? Führen Sie Tests parallel in mehreren Threads aus. Wirf Ströme in mehrere Autos. Es funktioniert.

Der zweite Weg sind inkrementelle Läufe. Es ist nicht erforderlich, jedes Mal Mutationsindikatoren für den gesamten Zweig zu zählen - Sie können Zweigdifferenzen verwenden. Wenn Sie Feature-Brunchs verwenden, können Sie dies ganz einfach tun: Führen Sie Tests nur für die Dateien durch, die sich geändert haben, und sehen Sie, was im Assistenten vor sich geht, vergleichen Sie, analysieren Sie.

Das nächste, was Sie tun können, ist das Tuning von Mutationen. Da Mutationsoperatoren geändert werden können, können Sie bestimmte Regeln festlegen, nach denen sie funktionieren. Anschließend können Sie einige Mutationen stoppen, wenn sie wissentlich zu Problemen führen.

Ein wichtiger Punkt: Mutationstests sind nur für Unit-Tests geeignet. Trotz der Tatsache, dass es für Integrationstests ausgeführt werden kann, ist dies offensichtlich eine fehlgeschlagene Idee, da Integrationstests (wie End-to-End-Tests) viel langsamer ausgeführt werden und viel mehr Code betreffen. Sie werden einfach nie auf die Ergebnisse warten. Grundsätzlich wurde dieser Mechanismus ausschließlich für Unit-Tests erfunden und entwickelt.

Endlose Mutanten


Das zweite Problem, das bei Mutationstests auftreten kann, sind die sogenannten Endlosmutanten. Zum Beispiel gibt es einfachen Code, eine einfache for-Schleife:



Wenn Sie i ++ durch i-- ersetzen, wird der Zyklus unendlich. Ihr Code bleibt lange erhalten. Und Mutationstests erzeugen ziemlich oft solche Mutationen.

Das erste, was Sie tun können, ist die Mutation zu optimieren. Offensichtlich ist es eine sehr schlechte Idee, i ++ in einer for-Schleife in i-- zu ändern: In 99% der Fälle erhalten wir eine Endlosschleife. Daher haben wir dies in unserem Tool verboten.

Das zweite und wichtigste, was Sie vor solchen Problemen schützt, ist das Timeout für den Lauf. Zum Beispiel kann dieselbe PHPUnit einen Timeout-Test durchführen, unabhängig davon, wo sie steckt. PHPUnit über PCNTL legt Rückrufe auf und berechnet die Zeit selbst. Wenn der Test für einen bestimmten Zeitraum fehlschlägt, wird er einfach festgenagelt und ein solcher Fall wird als getötete Mutante betrachtet, da der Code, der die Mutationen generiert hat, vom Test wirklich überprüft wird, wodurch das Problem wirklich erkannt wird und angezeigt wird, dass der Code nicht mehr funktionsfähig ist.

Identische Mutanten


Dieses Problem besteht in der Theorie der Mutationstests. In der Praxis begegnen sie dem nicht sehr oft, aber Sie müssen es wissen.

Betrachten Sie ein klassisches Beispiel, das dies veranschaulicht. Wir haben eine Multiplikation der Variablen A mit -1 und eine Division von A mit -1. Im allgemeinen Fall führen diese Operationen zum gleichen Ergebnis. Wir ändern das Vorzeichen von A. Dementsprechend haben wir eine Mutation, die es zwei Zeichen ermöglicht, sich untereinander zu ändern. Die Logik des Programms durch eine solche Mutation wird nicht verletzt. Tests und sollte es nicht fangen, sollte nicht fallen. Aufgrund solcher identischen Mutanten treten einige Schwierigkeiten auf.

Es gibt keine universelle Lösung - jeder löst dieses Problem auf seine Weise. Vielleicht hilft eine Art mutiertes Registrierungssystem. Wir bei Badoo denken jetzt über etwas Ähnliches nach, wir werden sie nachahmen.

Dies ist eine Theorie. Was ist mit PHP?


Es gibt zwei bekannte Werkzeuge für Mutationstests: Humbug und Infektion. Als ich den Artikel vorbereitete, wollte ich darüber sprechen, welches besser ist, und zu dem Schluss kommen, dass dies eine Infektion ist.
Aber als ich zur Humbug-Seite ging, sah ich dort Folgendes: Humbug erklärte sich zugunsten der Infektion für veraltet. Daher erwies sich ein Teil meines Artikels als bedeutungslos. Infektion ist also ein wirklich gutes Werkzeug. Ich muss mich bei borNfree aus Minsk bedanken , der es geschaffen hat. Er arbeitet wirklich cool. Sie können es direkt aus der Box nehmen, durch den Komponisten legen und starten.

Infektion hat uns sehr gut gefallen. Wir wollten es benutzen. Aber sie konnten aus zwei Gründen nicht. Eine Infektion erfordert eine Codeabdeckung, um Tests für Mutanten korrekt und präzise durchzuführen. Hier haben wir zwei Möglichkeiten. Wir können es direkt zur Laufzeit berechnen (aber wir haben 100.000 Unit-Tests). Oder wir können es für den aktuellen Master berechnen (aber es dauert anderthalb Stunden, auf unserer Wolke von zehn sehr leistungsstarken Maschinen in mehreren Threads aufzubauen). Wenn wir dies bei jedem Mutationslauf tun, wird das Tool wahrscheinlich nicht funktionieren.

Es gibt eine Option, um das fertige zu füttern, aber im PHPUnit-Format handelt es sich um eine Reihe von XML-Dateien. Neben der Tatsache, dass sie wertvolle Informationen enthalten, ziehen sie eine Reihe von Strukturen, einige Klammern und andere Dinge. Ich stellte mir vor, dass unsere Codeabdeckung im Allgemeinen etwa 30 GB wiegt und wir sie über alle Cloud-Computer ziehen müssen, die ständig von der Festplatte gelesen werden. Im Allgemeinen ist die Idee so lala.
Das zweite Problem war noch bedeutender. Wir haben eine wunderbare SoftMocks- Bibliothek. Es ermöglicht uns, mit schwer zu testendem Legacy-Code umzugehen und erfolgreich Tests dafür zu schreiben. Wir nutzen es aktiv und werden es in naher Zukunft nicht ablehnen, obwohl wir neuen Code schreiben, damit wir keine SoftMocks benötigen. Daher ist diese Bibliothek nicht mit Infection kompatibel, da sie fast denselben Ansatz zum Mutieren von Änderungen verwendet.

Wie funktionieren SoftMocks? Sie fangen Dateieinschlüsse ab und ersetzen sie durch modifizierte, dh anstatt Klasse A auszuführen, erstellen SoftMocks Klasse A an einer anderen Stelle und verbinden eine andere anstelle der ursprünglichen. Die Infektion verhält sich genauso, nur funktioniert sie über stream_wrapper_register () , das das Gleiche tut, jedoch auf Systemebene. Infolgedessen können entweder SoftMocks oder Infection für uns funktionieren. Da SoftMocks für unsere Tests erforderlich sind, ist es sehr schwierig, diese beiden Tools zu Freunden zu machen. Dies ist wahrscheinlich möglich, aber in diesem Fall geraten wir so sehr in die Infektion, dass die Bedeutung solcher Änderungen einfach verloren geht.

Um Schwierigkeiten zu überwinden, haben wir unser kleines Instrument geschrieben. Wir haben Mutationsoperatoren von Infection ausgeliehen (sie sind cool geschrieben und sehr einfach zu bedienen). Anstatt Mutationen über stream_wrapper_register () zu starten, führen wir sie über SoftMocks aus, dh wir verwenden unser Tool aus der Box. Unser Toolza ist mit unserem internen Code-Coverage-Service befreundet. Das heißt, bei Bedarf kann eine Datei oder eine Zeile abgedeckt werden, ohne dass alle Tests ausgeführt werden müssen, was sehr schnell geschieht. Es ist jedoch einfach. Wenn Infection über eine Reihe von Tools und Funktionen verfügt (z. B. Start in mehreren Threads), ist dies bei uns nicht der Fall. Wir nutzen jedoch unsere interne Infrastruktur, um diesen Mangel auszugleichen. Zum Beispiel führen wir denselben Testlauf in mehreren Threads durch unsere Cloud aus.

Wie nutzen wir das?

Der erste ist ein manueller Lauf. Dies ist das erste, was zu tun ist. Alle Tests, die Sie schreiben, werden manuell durch Mutationstests überprüft. Es sieht ungefähr so ​​aus:



Ich habe einen Mutationstest für eine Datei durchgeführt. Erhielt das Ergebnis: 16 Mutanten. Von diesen wurden 15 durch Tests getötet und einer fiel mit einem Fehler. Ich habe nicht gesagt, dass Mutationen Todesfälle verursachen können. Wir können leicht etwas ändern: den Rückgabetyp ungültig machen oder etwas anderes. Dies ist möglich, es wird als getötete Mutante angesehen, da unser Test zu fallen beginnt.

Trotzdem unterscheidet Infection solche Mutanten in einer separaten Kategorie, da es sich manchmal lohnt, Fehlern besondere Aufmerksamkeit zu widmen. Es kommt vor, dass etwas Seltsames passiert - und der Mutant wird nicht ganz richtig als getötet angesehen.

Das zweite, was wir verwenden, ist der Bericht über den Master. Einmal am Tag, nachts, wenn unsere Entwicklungsinfrastruktur inaktiv ist, erstellen wir einen Bericht zur Codeabdeckung. Danach machen wir den gleichen Mutationstestbericht. Es sieht so aus:



Wenn Sie sich jemals den Bericht über die Codeabdeckung von PHPUnit angesehen haben, haben Sie wahrscheinlich bemerkt, dass die Benutzeroberfläche ähnlich ist, da wir unser Tool analog erstellt haben. Er berechnete einfach alle Schlüsselindikatoren für eine bestimmte Datei in einem Verzeichnis. Wir haben auch bestimmte Ziele festgelegt (tatsächlich haben wir sie von der Obergrenze genommen und noch nicht eingehalten, da wir noch nicht entschieden haben, welche Ziele von jeder Metrik geleitet werden sollen, aber sie existieren, damit es in Zukunft einfach ist, Berichte zu erstellen).

Und das Letzte, das Wichtigste, was eine Folge der beiden anderen ist. Programmierer sind faule Leute. Ich bin faul: Ich mag es, wenn alles funktioniert und ich muss keine zusätzlichen Gesten machen. Wir haben es so gemacht, dass, wenn ein Entwickler seine eigene Filiale pusht, die Indikatoren seiner Filiale und seines Brunch-Masters automatisch inkrementell gezählt werden.



Zum Beispiel habe ich zwei Dateien ausgeführt und dieses Ergebnis erhalten. Im Master hatte ich 548 Mutanten, 400 wurden getötet. Laut einer anderen Akte - 147 gegen 63. In meinem Zweig nahm die Anzahl der Mutanten in beiden Fällen zu. Aber in der ersten Akte wurde der Mutant festgenagelt, und in der zweiten entkam er. Natürlich ist der MSI-Indikator gefallen. So etwas ermöglicht es sogar Menschen, die keine Zeit verschwenden möchten, Mutationstests mit ihren Händen durchzuführen, zu sehen, was sie schlechter gemacht haben, und darauf zu achten (genau so, wie es Prüfer bei der Codeüberprüfung tun).

Ergebnisse


Es ist immer noch schwierig, Zahlen anzugeben: Wir hatten keinen Indikator, jetzt ist er erschienen, aber es gibt nichts zu vergleichen.

Ich kann sagen, dass Mutationstests psychologische Auswirkungen haben. Wenn Sie anfangen, Ihre Tests durch Mutationstests durchzuführen, beginnen Sie unwillkürlich, bessere Tests zu schreiben, und das Schreiben von Qualitätstests führt unweigerlich zu einer Änderung der Art und Weise, wie Sie Code schreiben. Sie denken, dass Sie alle Fälle abdecken müssen, die Sie brechen können, und beginnen damit bessere Struktur, machen es testbarer.

Dies ist eine ausschließlich subjektive Meinung. Einige meiner Kollegen gaben jedoch ungefähr das gleiche Feedback: Als sie anfingen, ständig Mutationstests in ihrer Arbeit zu verwenden, begannen sie, Tests besser zu schreiben, und viele sagten, dass sie anfingen, Code besser zu schreiben.

Schlussfolgerungen


Die Codeabdeckung ist eine wichtige Metrik, die überwacht werden muss. Dieser Indikator garantiert jedoch nichts: Er bedeutet nicht, dass Sie in Sicherheit sind.

Mutationstests können dazu beitragen, Ihre Komponententests zu verbessern, und die Verfolgung der Codeabdeckung ist sinnvoll. Es gibt bereits ein Tool für PHP. Wenn Sie also ein kleines Projekt ohne Probleme haben, versuchen Sie es noch heute.

Beginnen Sie mindestens, indem Sie einen Mutationstest manuell ausführen. Machen Sie diesen einfachen Schritt und sehen Sie, was es Ihnen gibt. Ich bin sicher, es wird dir gefallen.

Source: https://habr.com/ru/post/de462709/


All Articles