Unit Testing und Python



Mein Name ist Vadim, ich bin ein führender Entwickler bei Mail.Ru Search. Ich werde unsere Erfahrungen mit Unit-Tests teilen. Der Artikel besteht aus drei Teilen: Im ersten Teil werde ich Ihnen sagen, was wir im Allgemeinen mit Hilfe von Unit-Tests erreichen. Der zweite Teil beschreibt die Prinzipien, denen wir folgen. Im dritten Teil erfahren Sie, wie die genannten Prinzipien in Python implementiert werden.

Ziele


Es ist sehr wichtig zu verstehen, warum Sie Unit-Tests anwenden. Konkrete Maßnahmen werden davon abhängen. Wenn Sie die Komponententests falsch verwenden oder mit ihrer Hilfe nicht das tun, was Sie wollten, wird nichts Gutes daraus. Daher ist es sehr wichtig, im Voraus zu verstehen, welche Ziele Sie verfolgen.

In unseren Projekten verfolgen wir mehrere Ziele.

Die erste ist die banale Regression : Um etwas im Code zu reparieren, führen Sie die Tests aus und stellen Sie fest, dass nichts kaputt gegangen ist. Obwohl es in der Tat nicht so einfach ist, wie es sich anhört.

Das zweite Ziel besteht darin, die Auswirkungen der Architektur zu bewerten . Wenn Sie im Projekt obligatorische Komponententests einführen oder sich einfach mit den Entwicklern auf die Verwendung von Komponententests einigen, wirkt sich dies sofort auf den Schreibstil des Codes aus. Es ist unmöglich, Funktionen in 300 Zeilen mit 50 lokalen Variablen und 15 Parametern zu schreiben, wenn diese Funktionen einem Komponententest unterzogen werden. Dank dieser Tests werden die Schnittstellen außerdem verständlicher und es treten einige Problembereiche auf. Wenn der Code nicht so heiß ist, ist der Test eine Kurve und fällt sofort auf.

Das dritte Ziel ist es, den Code klarer zu machen . Angenommen, Sie sind zu einem neuen Projekt gekommen und haben 50 MB Quellcode erhalten. Möglicherweise können Sie sie einfach nicht herausfinden. Wenn es keine Komponententests gibt, ist die einzige Möglichkeit, sich neben dem Lesen der Quelle mit der Arbeit des Codes vertraut zu machen, die „Poke-Methode“. Wenn das System jedoch recht kompliziert ist, kann es viel Zeit in Anspruch nehmen, über die Schnittstelle zu den erforderlichen Codeteilen zu gelangen. Und dank Unit-Tests können Sie sehen, wie der Code von überall ausgeführt wird.

Das vierte Ziel besteht darin , das Debuggen zu vereinfachen . Sie haben beispielsweise eine Klasse gefunden und möchten diese debuggen. Wenn es anstelle von Komponententests nur Systemtests oder gar keine Tests gibt, bleibt es nur, über die Schnittstelle an den richtigen Ort zu gelangen. Ich habe zufällig an einem Projekt teilgenommen, bei dem es zum Testen einiger Funktionen eine halbe Stunde gedauert hat, einen Benutzer zu erstellen, ihm Geld in Rechnung zu stellen, seinen Status zu ändern, eine Art Cron zu starten, sodass dieser Status an einen anderen Ort übertragen wurde, dann auf etwas in der Benutzeroberfläche zu klicken und etwas zu starten ein anderes cron ... Nach einer halben Stunde erschien endlich ein Bonusprogramm für diesen Benutzer. Und wenn ich Unit-Tests hätte, könnte ich sofort an den richtigen Ort kommen.

Schließlich ist Komfort das wichtigste und sehr abstrakte Ziel, das alle vorherigen verbindet. Wenn ich Unit-Tests habe, habe ich weniger Stress beim Arbeiten mit Code, weil ich verstehe, was passiert. Ich kann eine unbekannte Quelle verwenden, drei Zeilen korrigieren, Tests ausführen und sicherstellen, dass der Code wie beabsichtigt funktioniert. Und es ist nicht einmal so, dass die Tests grün sind: Sie können rot sein, aber genau dort, wo ich es erwarte. Das heißt, ich verstehe, wie der Code funktioniert.

Prinzipien


Wenn Sie Ihre Ziele verstehen, können Sie verstehen, was getan werden muss, um sie zu erreichen. Und hier beginnen die Probleme. Tatsache ist, dass viele Bücher und Artikel über Unit-Tests geschrieben wurden, aber die Theorie ist noch sehr unausgereift.

Wenn Sie jemals Artikel über Komponententests gelesen haben, versucht haben, die beschriebenen anzuwenden, und es Ihnen nicht gelungen ist, ist es sehr wahrscheinlich, dass der Grund die Unvollkommenheit der Theorie ist. Das passiert die ganze Zeit. Ich habe, wie alle Entwickler, einmal gedacht, dass das Problem in mir liegt. Und dann wurde ihm klar: Es kann nicht sein, dass ich mich so oft geirrt habe. Und er entschied, dass es beim Testen von Einheiten notwendig war, von seinen eigenen Überlegungen auszugehen, um vernünftiger zu handeln.

Der Standard-Ratschlag, den Sie in allen Büchern und Artikeln finden: „Sie sollten nicht die Implementierung, sondern die Benutzeroberfläche testen“. Schließlich kann sich die Implementierung ändern, die Schnittstelle jedoch nicht. Lassen Sie es uns testen, damit die Tests nicht immer bei jeder Gelegenheit fallen. Der Rat scheint nicht schlecht zu sein, und alles scheint logisch. Aber wir wissen es sehr gut: Um etwas zu testen, müssen Sie einige Testwerte auswählen. Normalerweise werden beim Testen von Funktionen die sogenannten Äquivalenzklassen unterschieden: die Menge von Werten, bei denen sich die Funktion gleichmäßig verhält. Grob gesagt ist der Test für jeden wenn. Um zu wissen, welche Äquivalenzklassen wir haben, ist eine Implementierung erforderlich. Sie testen es nicht, aber Sie brauchen es. Sie sollten es untersuchen, um zu wissen, welche Testwerte Sie auswählen müssen.

Sprechen Sie mit jedem Tester: Er wird Ihnen sagen, dass er sich beim manuellen Testen immer eine Implementierung vorstellt. Aus seiner Erfahrung versteht er perfekt, wo Programmierer normalerweise Fehler machen. Der Tester überprüft nicht alles, indem er zuerst 5, dann 6 und dann 7 eingibt. Er überprüft 5, abc, –7 und die Zahl besteht aus 100 Zeichen, da er weiß, dass die Implementierung für diese Werte unterschiedlich sein kann, für 6 und 7 jedoch unwahrscheinlich ist .

Es ist also nicht klar, wie man dem Prinzip "Testen der Schnittstelle, nicht der Implementierung" folgt. Sie können nicht einfach nehmen, die Augen schließen und einen Test schreiben. TDD versucht, dieses Problem teilweise zu lösen. Die Theorie schlägt vor, Äquivalenzklassen einzeln einzuführen und Tests für sie zu schreiben. Ich habe viele Bücher und Artikel zu diesem Thema gelesen, aber irgendwie bleibt es nicht hängen. Ich stimme jedoch der These zu, dass Tests zuerst geschrieben werden sollten. Wir nennen diesen Haupttest zuerst. Wir haben kein TDD, und im Zusammenhang mit dem oben Gesagten werden Tests nicht geschrieben, bevor der Code erstellt wird, sondern parallel dazu.

Ich empfehle definitiv nicht, Tests rückwirkend zu schreiben. Schließlich beeinflussen sie die Architektur, und wenn sie sich bereits niedergelassen hat, ist es zu spät, sie zu beeinflussen - alles muss neu geschrieben werden. Mit anderen Worten, die Codetestbarkeit ist eine separate Eigenschaft, die der Code verleihen muss , und wird nicht zu einer solchen. Daher versuchen wir, Tests zusammen mit Code zu schreiben. Glauben Sie nicht an Geschichten wie „Lassen Sie uns in drei Monaten ein Projekt schreiben und dann in einer Woche alles mit Tests abdecken“, dies wird niemals passieren.

Das Wichtigste, was Sie verstehen sollten: Unit-Tests sind keine Möglichkeit, den Code zu überprüfen, und keine Möglichkeit, seine Richtigkeit zu überprüfen. Dies ist Teil Ihrer Architektur, des Designs Ihrer Anwendung. Wenn Sie mit Unit-Tests arbeiten, ändern Sie Ihre Gewohnheiten. Tests, die nur die Richtigkeit überprüfen, sind eher Abnahmetests. Es ist ein Fehler zu glauben, dass Sie dann etwas mit Unit-Tests abdecken können oder dass dann der Code nicht überprüft werden muss.

Python-Implementierung


Wir verwenden die Standard-Unittest-Bibliothek aus der xUnit-Familie. Die Geschichte ist folgende: Es gab die SmallTalk-Sprache und darin die SUnit-Bibliothek. Jeder mochte es, sie fingen an, es zu kopieren. Die Bibliothek wurde unter dem Namen Junit nach Java importiert, von dort in C ++ unter dem Namen CppUnit und unter dem Namen RUnit in Ruby (dann wurde sie in RSpec umbenannt). Schließlich wurde die Bibliothek von Java unter dem Namen unittest nach Python „verschoben“. Und sie importierten es so wörtlich, dass sogar CamelCase übrig blieb, obwohl dies nicht PEP 8 entspricht.

Über xUnit gibt es ein wundervolles Buch, "xUnit Test Patterns". Es beschreibt, wie man mit den Rahmenbedingungen dieser Familie arbeitet. Der einzige Nachteil des Buches ist seine Größe: Es ist riesig, aber ungefähr 2/3 des Inhalts sind ein Katalog von Mustern. Und das erste Drittel des Buches ist einfach wunderbar, dies ist eines der besten Bücher über IT, die ich getroffen habe.

Ein Unit-Test ist ein regulärer Code mit einer bestimmten Standardarchitektur. Alle Unit-Tests bestehen aus drei Phasen: Einrichtung, Übung und Überprüfung. Sie bereiten die Daten vor, führen die Tests durch und prüfen, ob alles im richtigen Zustand ist.



Setup


Die schwierigste und interessanteste Etappe. Es kann sehr schwierig sein, das System in den ursprünglichen Zustand zu versetzen, von dem aus Sie es testen möchten. Und der Zustand des Systems kann beliebig komplex sein.

Bis zum Aufruf Ihrer Funktion könnten viele Ereignisse eingetreten sein, eine Million Objekte könnten im Speicher erstellt worden sein. In allen mit Ihrer Software verknüpften Komponenten - im Dateisystem, in der Datenbank, in den Caches - befindet sich bereits etwas, und die Funktion kann nur in dieser Umgebung ausgeführt werden. Und wenn die Umgebung nicht vorbereitet ist, sind die Aktionen der Funktion bedeutungslos.

Normalerweise behauptet jeder, dass Sie in keinem Fall Dateisysteme, Datenbanken oder andere separate Komponenten verwenden können, da dies Ihren Test nicht modular, sondern integrativ macht. Meiner Meinung nach ist dies nicht der Fall, da der Integrationstest durch den Integrationstest durchgeführt wird. Wenn Sie einige Komponenten nicht zur Überprüfung verwenden, sondern nur, damit das System funktioniert, ist daran nichts auszusetzen. Ihr Code interagiert mit vielen Komponenten des Computers und des Betriebssystems. Das einzige Problem bei der Verwendung eines Dateisystems oder einer Datenbank ist die Geschwindigkeit.

Direkt im Code verwenden wir die Abhängigkeitsinjektion . Sie können Parameter anstelle der Standardparameter in die Funktion einfügen. Sie können sogar Links zu Bibliotheken weiterleiten. Oder Sie können einen Stub anstelle einer Anforderung verschieben, damit der Code aus den Tests nicht auf das Netzwerk zugreift. Sie können benutzerdefinierte Protokollierer in den Klassenattributen speichern, um nicht auf die Festplatte zu schreiben und Zeit zu sparen.

Für Stubs verwenden wir das übliche Mock von Unittest. Es gibt auch eine Patch-Funktion, die, anstatt Abhängigkeiten ehrlich zu implementieren, einfach sagt: "In diesem Paket ist dieser Import ein Ersatz für einen anderen." Dies ist praktisch, da Sie nirgendwo etwas werfen müssen. Stimmt, dann ist nicht klar, wer was ersetzt hat, also gehen Sie vorsichtig damit um.

Was das Dateisystem betrifft, so ist das Fälschen ganz einfach. Es gibt ein io-Modul mit io.StringIO und io.BytesIO . Sie können dateiähnliche Objekte erstellen, die nicht auf die Festplatte zugreifen. Aber wenn Ihnen dies plötzlich nicht mehr ausreicht, gibt es ein wunderbares Tempfile-Modul mit Kontextmanagern für temporäre Dateien, Verzeichnisse, benannte Dateien usw. Tempfile ist ein Supermodul, wenn IO aus irgendeinem Grund nicht zu Ihnen passt.

Mit einer Datenbank ist alles komplizierter. Es gibt eine Standardempfehlung: "Verwenden Sie keine echte, sondern eine gefälschte Basis." Ich weiß nichts über dich, aber in meinem Leben habe ich keine einzige gefälschte und ausreichend funktionierende Basis gesehen. Jedes Mal, wenn ich um Rat gefragt wurde, was speziell unter Python oder Perl zu tun ist, antworteten sie, dass niemand etwas bereit wusste, und boten an, etwas Eigenes zu schreiben. Ich kann mir nicht vorstellen, wie man einen Emulator schreiben kann, zum Beispiel PostgreSQL. Ein weiterer Tipp: "Dann holen Sie sich SQLite." Dies wird jedoch die Isolation aufheben, da SQLite mit dem Dateisystem zusammenarbeitet. Wenn Sie beispielsweise MySQL oder PostgreSQL verwenden, funktioniert SQLite wahrscheinlich nicht. Wenn Sie den Eindruck haben, dass Sie die spezifischen Funktionen bestimmter Produkte nicht nutzen, irren Sie sich höchstwahrscheinlich. Selbst für alltägliche Dinge wie das Arbeiten mit Datumsangaben verwenden Sie bestimmte Funktionen, die nur von Ihrem DBMS unterstützt werden.

Infolgedessen verwenden sie normalerweise eine echte Basis. Die Lösung ist nicht schlecht, nur müssen wir ein gewisses Maß an Genauigkeit zeigen. Verwenden Sie keine zentralisierte Datenbank, da Tests untereinander unterbrochen werden können. Idealerweise sollte die Basis selbst während der Tests ansteigen und nach dem Test selbst anhalten.

Eine etwas schlimmere Situation ist, wenn Sie eine lokale Datenbank ausführen müssen, die verwendet wird. Aber die Frage ist, wie werden die Daten dorthin gelangen? Wir haben bereits gesagt, dass es einen Anfangszustand des Systems geben muss, es müssen einige Daten in der Datenbank sein. Woher sie kommen, ist keine leichte Frage.

Der naivste Ansatz, auf den ich gestoßen bin, ist die Verwendung einer Kopie einer echten Datenbank. Es wurde regelmäßig eine Kopie entnommen, aus der vertrauliche Daten gelöscht wurden. Die Autoren argumentierten, dass reale Daten am besten zum Testen geeignet sind. Außerdem ist das Schreiben von Tests für eine Kopie einer realen Datenbank eine Qual. Sie wissen nicht, welche Daten vorhanden sind. Sie müssen zuerst herausfinden, worauf Sie testen möchten. Wenn diese Informationen nicht vorhanden sind, ist unklar, was zu tun ist. Es endete damit, dass sie in diesem Projekt beschlossen, Tests für das Konto der Betriebsabteilung zu schreiben, die sich „niemals ändern werden“. Natürlich hat sie sich nach einiger Zeit verändert.

Darauf folgt normalerweise die Entscheidung: „Machen wir eine Besetzung der realen Basis, kopieren Sie sie und synchronisieren Sie nicht mehr. Dann ist es möglich, an ein bestimmtes Objekt gebunden zu werden, zu beobachten, was dort passiert, und Tests zu schreiben. “ Es stellt sich sofort die Frage: Was passiert, wenn der Datenbank neue Tabellen hinzugefügt werden? Anscheinend müssen Sie gefälschte Daten manuell eingeben.

Da wir dies jedoch trotzdem tun, bereiten wir die Grundbesetzung sofort manuell vor. Diese Option ist der in Django normalerweise als Fixtures bezeichneten Option sehr ähnlich: Sie erstellen riesige JSON-Dateien, laden Testfälle für alle Gelegenheiten hoch, senden sie zu Beginn des Tests an die Datenbank, und bei uns ist alles in Ordnung. Dieser Ansatz hat auch viele Nachteile. Die Daten sind auf einem Haufen gestapelt, es ist nicht klar, auf welchen Test sie sich beziehen. Niemand kann verstehen, ob die Daten gelöscht wurden oder nicht. Und es gibt inkompatible Zustände der Datenbank: Zum Beispiel muss ein Test keine Benutzer in der Datenbank haben und der andere, um sie zu haben. Diese beiden Bedingungen können nicht gleichzeitig in derselben Form gelagert werden. In diesem Fall muss einer der Tests die Datenbank ändern. Und da Sie sich sowieso noch damit befassen müssen, ist es am einfachsten, von einer leeren Datenbank auszugehen, sodass bei jedem Test die erforderlichen Daten dort abgelegt werden und am Ende des Tests die Datenbank gelöscht wird. Der einzige Nachteil dieses Ansatzes ist die Schwierigkeit, Daten in jedem Test zu erstellen. In einem der Projekte, in denen ich gearbeitet habe, war es zum Erstellen eines Dienstes erforderlich, 8 Entitäten in verschiedenen Tabellen zu generieren: einen Dienst auf einem persönlichen Konto, ein persönliches Konto auf einem Kunden, einen Kunden auf einer juristischen Person, eine juristische Person in einer Stadt, einen Kunden in einer Stadt usw. Bis Sie dies alles in einer Kette erstellen, werden Sie keinen Fremdschlüssel erfüllen, nichts funktioniert.

Für solche Situationen gibt es spezielle Bibliotheken, die das Leben erheblich erleichtern. Sie können Hilfswerkzeuge schreiben, die normalerweise als Fabriken bezeichnet werden (nicht mit dem Entwurfsmuster verwechseln). Zum Beispiel haben wir die factory_boy-Bibliothek verwendet, die für Django geeignet ist. Dies ist ein Klon der Bibliothek factory_girl, die letztes Jahr aus Gründen der politischen Korrektheit in factory_bot umbenannt wurde. Das Schreiben einer solchen Bibliothek für Ihr eigenes Framework kostet nichts. Es basiert auf einer sehr wichtigen Idee: Sie erstellen einmal eine Factory für die Objekte, die Sie erzeugen möchten, stellen Verbindungen dafür her und teilen dem Benutzer dann mit: „Wenn Sie erstellt sind, nehmen Sie Ihren nächsten Namen und generieren Sie die Gruppe selbst mithilfe der Gruppenfactory.“ Und in der Fabrik ist alles genau gleich: Generieren Sie den Namen so, verwandte Entitäten so und so.

Infolgedessen bleibt nur eine letzte Zeile im Code: user = UserFactory() . Der Benutzer wurde erstellt, und Sie können mit ihm arbeiten, da er unter der Haube alles generiert hat, was benötigt wird. Wenn Sie möchten, können Sie etwas manuell konfigurieren.

Um die Daten nach dem Testen zu bereinigen, verwenden wir triviale Transaktionen. Zu Beginn jedes Tests wird BEGIN durchgeführt, der Test macht etwas mit der Basis und nach dem Test wird ROLLBACK durchgeführt. Wenn im Test selbst Transaktionen erforderlich sind - zum Beispiel, weil sie etwas break_db für die Datenbank break_db -, ruft sie die von uns aufgerufene Methode break_db , teilt dem Framework mit, dass die Datenbank break_db wurde, und das Framework rollt sie erneut. Es stellt sich langsam heraus, aber da es normalerweise nur sehr wenige Tests gibt, die Transaktionen erfordern, ist alles in Ordnung.

Übung


Über diese Etappe gibt es nichts Besonderes zu erzählen. Das Einzige, was hier schief gehen kann, ist, sich beispielsweise dem Internet zuzuwenden. Einige Zeit hatten wir administrativ damit zu kämpfen: Wir sagten den Programmierern, wir müssten entweder Funktionen eintauchen, die irgendwohin gehen, oder spezielle Flags werfen, damit die Funktionen dies nicht tun. Wenn der Test auf Corporate etcd zugreift, ist dies nicht gut. Als Ergebnis kamen wir zu dem Schluss, dass alles verschwendet wurde: Wir selbst vergessen ständig, dass eine Funktion eine Funktion aufruft, die eine Funktion aufruft, die zu etcd geht. Daher haben wir im setUp der Basisklasse das Moki aller Aufrufe hinzugefügt, dh mit Hilfe von Stubs alle Aufrufe blockiert, wo sie nicht platziert wurden.

Stubs können einfach mit Patchern erstellt, Patcher in ein separates Wörterbuch gestellt und Zugriff auf alle Tests gewährt werden. Standardmäßig können Tests nirgendwo hingehen. Wenn Sie für einige noch Open Access benötigen, können Sie sie umleiten. Sehr bequem. Jenkins sendet nachts keine SMS mehr an Ihre Kunden :)

Überprüfen Sie


In dieser Phase verwenden wir aktiv selbstgeschriebene Aussagen, auch einzeilige. Wenn Sie die Existenz einer Datei im Test testen, empfehle self.assertTrue(file_exists(f)) , anstelle von assert self.assertTrue(file_exists(f)) schreiben, dass assert not file exists . Holivar ist damit verbunden: Soll ich CamelCase weiterhin in Namen verwenden, wie in unittest, oder sollte ich PEP 8 folgen? Ich habe keine Antwort. Wenn Sie PEP 8 folgen, gibt es im Testcode ein Durcheinander von CamelCase und snake_case. Und wenn Sie CamelCase verwenden, entspricht dies nicht PEP 8.

Und der letzte. Angenommen, Sie haben einen Code, der etwas testet, und es gibt viele Datenoptionen, auf denen dieser Code ausgeführt werden muss. Wenn Sie py.test verwenden, können Sie dort denselben Test mit unterschiedlichen Eingabedaten ausführen. Wenn Sie nicht über py.test verfügen, können Sie einen solchen Dekorator verwenden . Ein Tisch wird an den Dekorateur übergeben, und aus einem Test werden mehrere andere, von denen jeder einen der Fälle testet.

Fazit


Vertrauen Sie Artikeln und Büchern nicht unbedingt. Wenn Sie denken, dass sie falsch sind, ist es möglich, dass dies tatsächlich so ist.

Fühlen Sie sich frei, Abhängigkeitstests zu verwenden. Daran ist nichts auszusetzen. Wenn Sie memcached ausgelöst haben, weil Ihr Code ohne memcached nicht normal funktioniert, ist das in Ordnung. Aber es ist besser, wenn möglich darauf zu verzichten.

Achten Sie auf die Fabriken. Dies ist ein sehr interessantes Muster.

PS Ich lade Sie zum Programmieren in Python auf den Telegrammkanal meines Autors ein - @pythonetc.

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


All Articles