Unit Testing und Python

Published on December 19, 2018

Unit Testing und Python



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

    Ziele


    Es ist sehr wichtig zu verstehen, warum Sie Unit-Tests anwenden. Spezifische Aktionen werden davon abhängen. Wenn Sie Komponententests falsch verwenden oder damit etwas anderes als 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 eine triviale Regression : Korrigieren Sie etwas im Code, führen Sie Tests durch und stellen Sie fest, dass nichts kaputt 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 einen obligatorischen Komponententest eingeben oder sich einfach mit den Entwicklern über die Verwendung von Komponententests einigen, wirkt sich dies unmittelbar auf den Schreibstil des Codes aus. Es ist nicht möglich, Funktionen in 300 Zeilen mit 50 lokalen Variablen und 15 Parametern zu schreiben, wenn diese Funktionen einem Komponententest unterzogen werden. Darüber hinaus werden dank dieser Tests die Schnittstellen klarer und es treten einige Problembereiche auf. Denn wenn der Code nicht so heiß ist, ist der Test eine Kurve und fällt sofort ins Auge.

    Das dritte Ziel ist es, den Code klarer zu machen.. Angenommen, Sie sind zu einem neuen Projekt gekommen und haben Ihnen 50 MB Quellcode gegeben. Vielleicht kannst du sie einfach nicht herausfinden. Wenn es keine Unit-Tests gibt, ist die einzige Möglichkeit, sich zusätzlich zum Lesen des Quellcodes mit der Arbeit des Codes vertraut zu machen, die „Spear-Methode“. Wenn das System jedoch ziemlich kompliziert ist, kann es lange dauern, bis die erforderlichen Codeteile über die Schnittstelle erreicht sind. Und dank der Unit-Tests können Sie von überall aus sehen, wie der Code ausgeführt wird.

    Das vierte Ziel besteht darin , das Debuggen zu vereinfachen.. Sie haben beispielsweise eine Klasse gefunden und möchten sie debuggen. Wenn anstelle von Komponententests nur Systemtests oder überhaupt keine Tests durchgeführt werden, müssen Sie über die Schnittstelle nur noch an die richtige Stelle gelangen. Ich hatte die Möglichkeit, an einem Projekt teilzunehmen, bei dem es zum Testen einiger Funktionen erforderlich war, einen Benutzer für eine halbe Stunde anzulegen, ihm Geld in Rechnung zu stellen, seinen Status zu ändern, einen Cron auszuführen, damit dieser Status an eine andere Stelle übertragen wird, und dann etwas anderes in der Benutzeroberfläche auszuführen noch ein cron ... Nach einer halben Stunde erschien endlich das Bonusprogramm für diesen User. Und wenn ich Unit-Tests hätte, könnte ich sofort an den richtigen Ort kommen.

    Schließlich ist das wichtigste und sehr abstrakte Ziel, das alle bisherigen Ziele vereint, der Komfort.. Wenn ich Unit-Tests durchführe, habe ich weniger Stress beim Arbeiten mit Code, weil ich verstehe, was passiert. Ich kann eine unbekannte Quelle nehmen, drei Zeilen reparieren, Tests ausführen und sicherstellen, dass der Code wie vorgesehen funktioniert. Und es geht nicht einmal darum, dass die Tests grün sind: Sie können rot sein, aber genau dort, wo ich das 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 eine ganze Menge Bücher und Artikel über Unit-Tests geschrieben wurden, aber die Theorie ist noch sehr unausgereift.

    Wenn Sie jemals Artikel über Komponententests gelesen und versucht haben, das Beschriebene anzuwenden, und es Ihnen nicht gelungen ist, dann liegt der Grund höchstwahrscheinlich in der Unvollkommenheit der Theorie. Das passiert die ganze Zeit. Ich dachte, wie alle Entwickler, dass das Problem in mir lag. Und dann wurde mir klar: Es kann nicht sein, dass ich mich so oft geirrt habe. Und ich entschied, dass es bei Unit-Tests notwendig ist, von meinen eigenen Überlegungen auszugehen und vernünftiger zu handeln.

    Die Standardempfehlung, die Sie in allen Büchern und Artikeln finden: "Sie müssen nicht die Implementierung testen, sondern die Benutzeroberfläche." Immerhin kann sich die Implementierung ändern, die Schnittstelle jedoch nicht. Lassen Sie es uns testen, damit die Tests nicht aus jedem Grund die ganze Zeit fallen. Der Rat scheint gut zu sein, und alles scheint logisch. Wir wissen jedoch sehr gut, dass Sie einige Testwerte auswählen müssen, um etwas zu testen. Normalerweise werden beim Testen einer Funktion die sogenannten Äquivalenzklassen unterschieden: die Menge von Werten, für die sich die Funktion einheitlich verhält. Grob gesagt, auf dem Test für jedes wenn. Um zu wissen, welche Äquivalenzklassen wir haben, ist jedoch eine Implementierung erforderlich. Sie testen es nicht, aber Sie brauchen es, Sie müssen es untersuchen, um zu wissen, welche Testwerte Sie wählen müssen.

    Sprechen Sie mit jedem Tester: Er wird Ihnen sagen, dass er sich beim manuellen Testen immer die Implementierung vorstellt. Er versteht perfekt aus Erfahrung, 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 um 100 Zeichen, da er weiß, dass die Implementierung mit diesen Werten abweichen kann und mit 6 und 7 unwahrscheinlich ist .

    Es ist also nicht klar, wie dem Prinzip „Testen Sie die Schnittstelle, nicht die Implementierung“ zu folgen ist. Sie können nicht nur nehmen, die Augen schließen und einen Test schreiben. Ein Teil dieses Problems ist der Versuch, TDD 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 dieses Prinzip „Test first“. Wir haben kein TDD, und im Zusammenhang mit dem Vorstehenden werden Tests nicht vor dem Erstellen des Codes geschrieben, sondern parallel dazu.

    Ich empfehle definitiv nicht, Tests im Nachhinein zu schreiben. Sie wirken sich schließlich auf die Architektur aus, und wenn sie bereits festgelegt ist, ist es zu spät, sie zu beeinflussen - alles muss neu geschrieben werden. Mit anderen Worten, Code-Testbarkeit ist eine separate Eigenschaft, die der Code ausstatten musser selbst wird nicht so sein. Aus diesem Grund versuchen wir, Tests zusammen mit dem Code zu schreiben. Glauben Sie nicht an Geschichten wie "Lassen Sie uns in drei Monaten ein Projekt schreiben, und dann werden wir in einer Woche alles mit Tests abdecken", das wird niemals passieren.

    Das Wichtigste, was zu verstehen ist, ist, dass Unit-Tests keine Möglichkeit sind, Code zu überprüfen, keine Möglichkeit, seine Richtigkeit zu überprüfen. Dies ist Teil Ihrer Architektur, Ihres Anwendungsdesigns. Wenn Sie mit Unit-Tests arbeiten, ändern Sie Ihre Gewohnheiten. Tests, die nur die Richtigkeit überprüfen, sind eher Abnahmetests. Es wird ein Fehler sein zu glauben, dass Sie später etwas mit Unit-Tests abdecken können oder dass der Code später nicht überprüft werden muss.

    Python-Implementierung


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

    Über xUnit gibt es ein wunderbares Buch "xUnit Test Patterns". Hier erfahren Sie, wie Sie mit Frameworks dieser Familie arbeiten. Der einzige Nachteil des Buches ist seine Größe: Es ist riesig, aber etwa zwei Drittel des Inhalts sind Musterkataloge. Und das erste Drittel des Buches ist einfach wunderbar, dies ist eines der besten Bücher über IT, die ich getroffen habe.

    Ein Komponententest ist ein allgemeiner Code, der eine Art Standardarchitektur aufweist. Alle Unit-Tests bestehen aus drei Schritten: Einrichten, Trainieren und Überprüfen. 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. Das System in den ursprünglichen Zustand zu versetzen, von dem aus Sie es testen möchten, kann sehr schwierig sein. Und der Zustand des Systems kann beliebig komplex sein.

    Zum Zeitpunkt des Aufrufs Ihrer Funktion konnten viele Ereignisse eintreten und eine Million Objekte im Speicher erstellt werden. In allen Komponenten, die sich auf Ihre Software beziehen, befindet sich im Dateisystem, in der Datenbank und in den Caches 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 man auf keinen Fall Dateisysteme, Datenbanken oder andere Komponenten verwenden kann, da dies Ihren Test nicht modular, sondern integrativ macht. Meiner Meinung nach ist dies falsch, da der Integrationstest den Integrationstest durchführt. Wenn Sie einige Komponenten nicht zum Testen verwenden, sondern nur, damit das System funktioniert, ist daran nichts auszusetzen. Ihr Code interagiert mit einer Vielzahl von Computerkomponenten und Betriebssystemen. Das einzige Problem bei der Verwendung des Dateisystems oder der Datenbank ist die Geschwindigkeit.

    Direkt im Code verwenden wir die Abhängigkeitsinjektion. Es ist möglich, anstelle der standardmäßig verwendeten Parameter Parameter an eine Funktion weiterzuleiten. Sie können sogar Links zu Bibliotheken weiterleiten. Sie können statt einer Anforderung auch einen Stub einfügen, damit der Code aus den Tests nicht auf das Netzwerk zugreift. Sie können benutzerdefinierte Protokollfunktionen in Klassenattributen speichern, damit Sie nicht auf die Festplatte schreiben und Zeit sparen.

    Für Stubs verwenden wir das übliche unittest Mock. Es gibt auch eine Patch-Funktion, mit der Sie, anstatt ehrlich Abhängigkeiten einzuführen, einfach sagen können: „In diesem Paket wird dieser Import durch einen anderen ersetzt“. Dies ist praktisch, da Sie nichts senden müssen. Stimmt, dann ist nicht klar, wer und was ersetzt, also vorsichtig verwenden.

    Das Dateisystem ist recht einfach zu fälschen. Es gibt ein Modul io c io.StringIOundio.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 exzellentes tempfile-Modul mit Kontextmanagern für temporäre Dateien, Verzeichnisse, benannte Dateien usw. Tempfile ist ein Supermodul, wenn es Ihnen aus irgendeinem Grund nicht passt.

    C-Datenbank ist 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 genau ich unter Python oder Perl einhalten soll, antworteten sie, dass niemand etwas bereit wisse, und boten an, etwas Eigenes zu schreiben. Ich habe keine Ahnung, wie man einen Emulator schreibt, zum Beispiel PostgreSQL. Ein weiterer Tipp: "Dann nimm SQLite." Dies wird jedoch die Isolation aufheben, da SQLite mit dem Dateisystem zusammenarbeitet. Wenn Sie außerdem so etwas wie MySQL oder PostgreSQL verwenden, funktioniert in SQLite mit Sicherheit nichts. Wenn Sie den Eindruck haben, dass Sie die spezifischen Funktionen bestimmter Produkte nicht nutzen, liegen Sie höchstwahrscheinlich falsch. Sicherlich auch für Kleinigkeiten wie das Arbeiten mit Datteln,

    Infolgedessen wird diese Basis normalerweise verwendet. Die Lösung ist nicht schlecht, nur Sie müssen ein gewisses Maß an Genauigkeit zeigen. Verwenden Sie keine zentralisierte Datenbank, da sich die Tests gegenseitig beschädigen können. Im Idealfall sollte sich die Basis während der Tests von selbst anheben und sich nach dem Testen selbst stoppen.

    Die Situation ist etwas schlimmer, 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 bestimmten Anfangszustand des Systems geben sollte, es sollten einige Daten in der Datenbank sein. Woher sie kommen, ist eine schwierige Frage.

    Der naivste Ansatz, auf den ich gestoßen bin, ist die Verwendung einer Kopie einer echten Basis. Es wurde regelmäßig eine Kopie entnommen, aus der sensible Daten gelöscht wurden. Die Autoren schlussfolgerten, dass echte Daten am besten für Tests geeignet sind. Außerdem ist das Schreiben von Tests für eine Kopie der realen Datenbank eine Qual. Sie wissen nicht, wo sich die Daten befinden. Sie müssen zuerst herausfinden, was Sie testen möchten. Wenn diese Informationen nicht vorhanden sind, ist das, was zu tun ist, unverständlich. Am Ende dieses Projekts beschlossen sie, Tests für das Konto der Wartungsabteilung zu schreiben, die sich "nie ändern" werden. Natürlich änderte sich das nach einiger Zeit.

    Darauf folgt normalerweise eine Entscheidung: „Nehmen wir eine Kopie einer realen Datenbank, kopieren Sie sie und synchronisieren Sie sie nicht mehr. Dann wird es möglich sein, ein bestimmtes Objekt zu binden, zu sehen, 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 die gefälschten Daten manuell eingeben.

    Aber da wir das noch tun werden, bereiten wir sofort einen von Hand gegossenen Sockel vor. Diese Option ähnelt dem, was Django normalerweise als Fixtures bezeichnet: Sie erstellen riesige JSON-Dateien, füllen Testfälle für alle Gelegenheiten, senden sie zu Beginn des Tests an die Basis und auf diese Weise ist alles in Ordnung. Dieser Ansatz hat auch viele Mängel. Daten in einem Haufen gestapelt, ist es nicht klar, welche Art von Test gilt. Niemand kann verstehen, ob die Daten gelöscht wurden oder nicht. Und es gibt auch inkompatible Datenbankzustände: Zum Beispiel benötigt ein Test keine Benutzer in der Datenbank und ein anderer muss vorhanden sein. Diese beiden Zustände können nicht gleichzeitig in derselben Besetzung gespeichert werden. In diesem Fall muss einer der Tests die Datenbank ändern. Und da wir uns noch damit befassen müssen, ist der einfachste Weg, mit einer leeren Datenbank zu beginnen, damit jeder Test die erforderlichen Daten dort ablegt. und am Ende der Prüfung die Basis gelöscht. Der einzige Nachteil dieses Ansatzes ist die Schwierigkeit, Daten in jedem Test zu erstellen. In einem der Projekte, in denen ich gearbeitet habe, um einen Service zu erstellen, mussten 8 Entitäten in verschiedenen Tabellen generiert werden: ein Service auf einem persönlichen Konto, ein persönliches Konto auf einem Kunden, ein Kunde auf einer juristischen Person, eine juristische Person in einer Stadt, ein Kunde in einer Stadt und so weiter. Während Sie nicht alles durch Kette erstellen, sind Sie nicht mit Fremdschlüssel zufrieden, 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). Wir haben zum Beispiel die factory_boy-Bibliothek verwendet, die für Django geeignet ist. Dies ist ein Klon der factory_girl-Bibliothek, die letztes Jahr aus Gründen der politischen Korrektheit in factory_bot umbenannt wurde. Eine solche Bibliothek für Ihr eigenes Framework zu schreiben, kostet nichts. Es basiert auf einer sehr wichtigen Idee: Sie erstellen einmal eine Factory für die Objekte, die Sie generieren möchten, stellen Verbindungen dazu her und teilen dem Benutzer dann mit: „Wenn Sie erstellt werden, nehmen Sie sich einen anderen Namen und generieren Sie die Gruppe selbst mit Hilfe der Group Factory“. Und in der Fabrik ist alles genau das gleiche: Der Name wird auf diese Weise erzeugt, die verwandten Einheiten sind so und so.

    Als Ergebnis ist der Code nur eine letzte Zeile: user = UserFactory(). Der Benutzer wurde erstellt, und Sie können mit ihm arbeiten, da er unter der Haube alles generiert hat, was Sie benötigen. Wenn Sie möchten, können Sie etwas manuell anpassen.

    Um Daten nach dem Testen zu bereinigen, verwenden wir banale Transaktionen. Zu Beginn jedes Tests wird BEGIN ausgeführt, der Test führt eine Aktion mit der Basis aus, und nach dem Test wird ein ROLLBACK ausgeführt. Wenn im Test selbst Transaktionen erforderlich sind, beispielsweise weil der Test der Datenbank etwas Zusätzliches zuweist, ruft er die von uns aufgerufene Methode auf break_db, informiert das Framework darüber, dass die Basis beschädigt wurde, und das Framework setzt sie erneut um. 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 zu erzählen. Hier kann nur ein Appell nach außen, zum Beispiel ins Internet, schief gehen. Wir hatten einige Zeit mit administrativen Problemen zu kämpfen: Wir sagten den Programmierern, wir müssten entweder Funktionen verwenden, die irgendwo hingehen, oder spezielle Flags werfen, damit die Funktionen das nicht tun. Wenn sich der Test auf Unternehmen usw. bezieht, ist dies nicht gut. Als Ergebnis kamen wir zu dem Schluss, dass alles umsonst ist: Wir vergessen ständig, dass eine Funktion eine Funktion aufruft, die eine Funktion aufruft, die zu etcd geht. Daher wurden die Mocks aller Aufrufe zum SetUp der Basisklasse hinzugefügt, d. H., Sie haben alle Aufrufe blockiert, bei denen die Verwendung von Stubs nicht zulässig war.

    Es ist einfach, Stubs mit Patches zu erstellen, die Patches in einem separaten Wörterbuch abzulegen und Zugriff auf alle Tests zu gewähren. Standardmäßig können Tests nicht überall ausgeführt werden, und wenn Sie für einige weiterhin den Zugriff öffnen müssen, können Sie ihn umleiten. Sehr bequem Jenkins wird nachts keine SMS mehr an deine Kunden senden :)

    Überprüfen Sie


    In diesem Stadium verwenden wir aktiv samopisnye Behauptungen, auch einzeilige. Wenn Sie die Existenz einer Datei in einem Test testen, self.assertTrue(file_exists(f)) empfehle ich, anstelle von assert assert zu schreiben not file exists. Im Zusammenhang damit steht holivar: Verwenden Sie CamelCase weiterhin in Namen wie in unittest oder folgen Sie PEP 8? Ich habe keine Antwort. Wenn Sie PEP 8 folgen, ist der Testcode Brei von CamelCase und snake_case. Und wenn Sie CamelCase verwenden, entspricht es nicht PEP 8.

    Und zuletzt. Angenommen, Sie haben einen Code, der etwas testet, und viele Datenvarianten, auf denen dieser Code ausgeführt werden soll. Wenn Sie py.test verwenden, können Sie dort denselben Test mit unterschiedlichen Eingaben ausführen. Wenn Sie py.test nicht haben, können Sie einen solchen Dekorateur verwenden. Ein Tisch wird an den Dekorateur übergeben, und aus einem Test werden mehrere andere, von denen jeder einen der Fälle testet.

    Fazit


    Vertraue Artikeln und Büchern nicht bedingungslos. Wenn Sie denken, dass sie falsch sind, ist es durchaus möglich, dass dies wahr ist.

    Fühlen Sie sich frei, Abhängigkeitstests zu verwenden. Daran ist nichts auszusetzen. Wenn Sie memcached aktiviert 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 zu dem Telegramm-Programmierkanal meines Autors in Python - @pythonetc ein.