So hören Sie auf, sich Sorgen zu machen, und beginnen, eigenschaftsbasierte Tests zu schreiben

In letzter Zeit gibt es immer häufiger Verweise auf ein bestimmtes magisches Werkzeug - Testen basierend auf Eigenschaften (eigenschaftsbasiertes Testen, wenn Sie englische Literatur googeln müssen). Die meisten Artikel zu diesem Thema sprechen darüber, was für ein cooler Ansatz es ist. In einem elementaren Beispiel zeigen sie, wie man einen solchen Test mit einem bestimmten Framework schreibt. Bestenfalls schlagen sie mehrere gemeinsame Eigenschaften vor, und ... das ist alles. Dann versucht der verblüffte und begeisterte Leser, all dies in die Praxis umzusetzen, und beruht auf der Tatsache, dass die Eigenschaften irgendwie nicht erfunden sind. Und leider gibt es sich oft dem hin. In diesem Artikel werde ich versuchen, etwas anders zu priorisieren. Trotzdem beginne ich mit einem mehr oder weniger konkreten Beispiel, um zu erklären, um welche Art von Tier es sich handelt. Aber ein Beispiel ist hoffentlich nicht ganz typisch für Artikel dieser Art. Dann werde ich versuchen, einige der mit diesem Ansatz verbundenen Probleme zu analysieren und zu analysieren, wie sie gelöst werden können. Und im Folgenden - Eigenschaften, Eigenschaften und nur Eigenschaften, mit Beispielen, wo sie verschoben werden können. Interessant?

Testen der Schlüsselwertspeicherung in drei kurzen Tests


Nehmen wir also aus irgendeinem Grund an, wir müssen eine Art Schlüsselwertspeicher implementieren. Es kann ein Wörterbuch sein, das auf einer Hash-Tabelle basiert, oder es kann auf einem Baum basieren, es kann vollständig im Speicher gespeichert sein oder mit einer Festplatte arbeiten - das ist uns egal. Die Hauptsache ist, dass es eine Schnittstelle haben sollte, die es Ihnen ermöglicht:

  • Wert per Schlüssel schreiben
  • Überprüfen Sie, ob ein Eintrag mit dem gewünschten Schlüssel vorhanden ist
  • Wert mit Schlüssel lesen
  • Holen Sie sich eine Liste der aufgezeichneten Elemente
  • Holen Sie sich eine Kopie des Repositorys

Im klassischen beispielbasierten Ansatz würde ein typischer Test ungefähr so ​​aussehen:

storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42 

Oder so:

 storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73 

Und im Allgemeinen können und müssen solche Tests etwas mehr als Dofiga geschrieben werden. Je komplizierter die interne Implementierung ist, desto größer ist außerdem die Wahrscheinlichkeit, dass etwas übersehen wird. Kurz gesagt, ein langer, langwieriger und oft undankbarer Job. Wie schön wäre es, es jemandem zu schieben! Lassen Sie den Computer beispielsweise Testfälle für uns generieren. Versuchen Sie zunächst Folgendes:

 storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value 

Dies ist der erste eigenschaftsbasierte Test. Es sieht fast genauso aus wie das traditionelle, obwohl bereits ein kleiner Bonus auffällt - es werden keine Werte von der Decke genommen, stattdessen verwenden wir Funktionen, die beliebige Schlüssel und Werte zurückgeben. Es gibt noch einen weiteren, viel schwerwiegenderen Vorteil: Er kann viele, viele Male und mit verschiedenen Eingabedaten ausgeführt werden, um den Vertrag zu überprüfen. Wenn Sie versuchen, dem leeren Speicher ein Element hinzuzufügen, wird es dort tatsächlich hinzugefügt. Okay, das ist alles schön und gut, aber bisher ist es im Vergleich zum traditionellen Ansatz nicht sehr nützlich. Versuchen wir, einen weiteren Test hinzuzufügen:

 storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy) 

Anstatt den leeren Speicher zu übernehmen, generieren wir hier mit einigen Daten willkürlich und prüfen, ob die Kopie mit dem Original identisch ist. Ja, der Generator muss mit einer möglicherweise fehlerhaften öffentlichen API geschrieben werden, aber in der Regel ist dies keine so schwierige Aufgabe. Wenn die Implementierung schwerwiegende Fehler enthält, ist die Wahrscheinlichkeit hoch, dass die Stürze während des Generierungsprozesses beginnen. Dies kann daher auch als eine Art Bonus-Rauchtest angesehen werden. Jetzt können wir sicher sein, dass alles, was der Generator liefern konnte, korrekt kopiert werden kann. Und dank des ersten Tests wissen wir sicher, dass der Generator Speicher mit mindestens einem Element erstellen kann. Zeit für den nächsten Test! Gleichzeitig verwenden wir den Generator wieder:

 storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup) 

Wir nehmen einen beliebigen Speicherplatz und prüfen, ob wir dort ein weiteres Element hinzufügen können. Der Generator kann also ein Repository mit zwei Elementen erstellen. Und Sie können auch ein Element hinzufügen. Und so weiter (ich erinnere mich sofort an so etwas wie mathematische Induktion). Die drei geschriebenen Tests und der Generator ermöglichen es daher, zuverlässig zu überprüfen, ob dem Repository eine beliebige Anzahl verschiedener Elemente hinzugefügt werden kann. Nur drei kurze Tests! Das ist im Grunde die ganze Idee von eigenschaftsbasierten Tests:

  • Wir finden Eigenschaften
  • Überprüfen der Eigenschaften auf einem Haufen verschiedener Daten
  • Gewinn!

Übrigens widerspricht dieser Ansatz nicht den Prinzipien von TDD - Tests können auf die gleiche Weise vor dem Code geschrieben werden (zumindest persönlich mache ich das normalerweise). Es ist eine andere Sache, dass es viel schwieriger sein kann, einen solchen Test grün zu machen als herkömmlich, aber wenn er schließlich erfolgreich bestanden wird, werden wir sicher sein, dass der Code wirklich einem bestimmten Teil des Vertrags entspricht.

Das ist alles schön und gut, aber ...


Bei aller Attraktivität eines eigenschaftsbasierten Testansatzes gibt es eine Reihe von Problemen. In diesem Teil werde ich versuchen, die häufigsten zu erkennen. Abgesehen von den Problemen mit der tatsächlichen Komplexität der Suche nach nützlichen Eigenschaften (auf die ich im nächsten Abschnitt zurückkommen werde), ist meiner Meinung nach das größte Problem für Anfänger oft das falsche Vertrauen in eine gute Abdeckung. In der Tat haben wir mehrere Tests geschrieben, die Hunderte von Testfällen generieren - was könnte schief gehen? Wenn Sie sich das Beispiel aus dem vorherigen Teil ansehen, gibt es tatsächlich viele Dinge. Zunächst einmal geben die schriftlichen Tests keine Garantie dafür, dass storage.copy () wirklich eine „tiefe“ Kopie erstellt und nicht nur den Zeiger kopiert. Ein weiteres Loch - es gibt keine normale Überprüfung, ob der Schlüssel im Speicher False zurückgibt, wenn sich der gesuchte Schlüssel nicht im Geschäft befindet. Und die Liste geht weiter. Nun, eines meiner Lieblingsbeispiele - sagen wir, wir schreiben eine Sortierung, und aus irgendeinem Grund denken wir, dass ein Test, der die Reihenfolge der Elemente überprüft, ausreicht:

 input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:])) 

Und eine solche Implementierung wird perfekt verlaufen

 def sort(input): return [1, 2, 3] 

Ich hoffe, die Moral hier ist klar.

Das nächste Problem, das in gewissem Sinne als Konsequenz der beiden vorherigen Probleme bezeichnet werden kann, besteht darin, dass die Verwendung von eigenschaftsbasierten Tests oft sehr schwierig ist, um eine wirklich vollständige Abdeckung zu erreichen. Aber meiner Meinung nach ist dies sehr einfach zu lösen - Sie müssen nicht nur Tests schreiben, die auf Eigenschaften basieren, niemand hat traditionelle Tests abgebrochen. Darüber hinaus sind die Menschen so arrangiert, dass es für sie viel einfacher ist, Dinge mit konkreten Beispielen zu verstehen, was auch für die Verwendung beider Ansätze spricht. Im Allgemeinen habe ich für mich ungefähr den folgenden Algorithmus entwickelt - um einige sehr einfache traditionelle Tests zu schreiben, idealerweise, damit sie als Beispiel dafür dienen können, wie die API verwendet werden soll. Sobald das Gefühl bestand, dass Tests zur Dokumentation ausreichen, aber noch lange nicht vollständig abgedeckt sind, fügen Sie Tests hinzu, die auf Eigenschaften basieren.

Nun zur Frage der Frameworks, was von ihnen zu erwarten ist und warum sie überhaupt benötigt werden - schließlich verbietet niemand mit Ihren Händen, einen Test in einem Zyklus zu fahren, was zu einer Zufälligkeit im Inneren führt und das Leben genießt. In der Tat wird die Freude bis zum ersten Testfall sein, und es ist gut, wenn vor Ort und nicht in einigen CI. Erstens, da eigenschaftsbasierte Tests randomisiert sind, benötigen Sie auf jeden Fall eine Möglichkeit, einen abgelegten Fall zuverlässig zu reproduzieren, und jedes Framework mit Selbstachtung ermöglicht es Ihnen, dies zu tun. Die gängigsten Ansätze sind die Ausgabe eines bestimmten Startwerts an die Konsole, den Sie manuell im Testläufer abtasten und den abgelegten Fall zuverlässig abspielen können (praktisch zum Debuggen), oder einen Cache auf der Festplatte mit "schlechten" Seiten erstellen, der beim Teststart automatisch zuerst überprüft wird ( hilft bei der Wiederholbarkeit in CI). Ein weiterer wichtiger Aspekt ist die Datenminimierung (Schrumpfung in ausländischen Quellen). Da die Daten zufällig generiert werden, ist dies eine völlig unechte Chance, mit einem Container mit 1000 Elementen in einen fallenden Testfall zu geraten, was immer noch ein „Vergnügen“ beim Debuggen ist. Daher wenden gute Frameworks nach dem Auffinden eines Feylyaschy-Falls eine Reihe von Heuristiken an, um zu versuchen, einen kompakteren Satz von Eingabedaten zu finden, der den Test dennoch weiterhin zum Absturz bringt. Und schließlich - oft ist die Hälfte der Testfunktionalität ein Eingabedatengenerator. Das Vorhandensein integrierter Generatoren und Grundelemente, mit denen Sie schnell komplexere Generatoren aus einfachen Generatoren erstellen können, hilft ebenfalls sehr.

Gelegentlich wird auch kritisiert, dass es zu viele Logiktests gibt, die auf Eigenschaften basieren. Dies wird jedoch in der Regel von Beispielen im Stil von begleitet

 data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut) 

In der Tat ist es durchaus üblich (für Anfänger) Antipattern, tun Sie dies nicht! Es ist viel besser, einen solchen Test in zwei verschiedene zu unterteilen und entweder unangemessene Eingabedaten zu überspringen (in vielen Frameworks gibt es sogar spezielle Tools dafür), wenn die Chance gering ist, auf sie zuzugreifen, oder spezialisiertere Generatoren zu verwenden, die sofort nur geeignete Daten erzeugen. Das Ergebnis sollte so etwas wie sein

 data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut) 

und

 data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut) 

Nützliche Eigenschaften und ihre Lebensräume


Okay, was ist es nützlich für Tests basierend auf Eigenschaften, es scheint klar, die Hauptfallen wurden behoben ... obwohl nein, die Hauptsache ist immer noch nicht klar - woher kommen diese Eigenschaften? Versuchen wir zu suchen.

Zumindest nicht fallen


Am einfachsten ist es, beliebige Daten in das zu testende System zu verschieben und sicherzustellen, dass es nicht abstürzt. Tatsächlich ist dies eine ganz andere Richtung mit dem modischen Namen Fuzzing, für den es spezielle Werkzeuge gibt (zum Beispiel AFL alias American Fuzzy Lop), aber mit etwas Dehnung kann es als Sonderfall des Testens basierend auf Eigenschaften angesehen werden, und wenn überhaupt keine Ideen im Sinn sind Wenn es nicht klettert, können Sie damit beginnen. In der Regel sind solche Tests jedoch in der Regel selten sinnvoll, da potenzielle Tropfen bei der Überprüfung anderer Eigenschaften in der Regel sehr gut zur Geltung kommen. Der Hauptgrund, warum ich diese „Eigenschaft“ erwähne, besteht darin, den Leser auf Fuzzers und insbesondere AFL (es gibt viele englischsprachige Artikel zu diesem Thema) zu verweisen, um das Bild zu vervollständigen.

Orakel testen


Eine der langweiligsten Eigenschaften, aber in der Tat eine sehr mächtige Sache, die viel häufiger verwendet werden kann, als es scheint. Die Idee ist, dass es manchmal zwei Codeteile gibt, die dasselbe tun, aber auf unterschiedliche Weise. Und dann können Sie insbesondere nicht verstehen, beliebige Eingabedaten zu generieren, diese in beide Optionen zu verschieben und zu überprüfen, ob die Ergebnisse übereinstimmen. Das am häufigsten genannte Anwendungsbeispiel ist das Schreiben einer optimierten Version einer Funktion, um eine langsame, aber einfache Option zu belassen und Tests dagegen durchzuführen.

 input = arbitrary_list() assert quick_sort(input) == bubble_sort(input) 

Die Anwendbarkeit dieser Eigenschaft ist jedoch nicht darauf beschränkt. Beispielsweise stellt sich sehr oft heraus, dass die von dem zu testenden System implementierte Funktionalität eine Obermenge von bereits implementierten Funktionen ist, häufig sogar in der Standard-Sprachbibliothek. Insbesondere kann normalerweise der größte Teil der Funktionalität eines Schlüsselwertspeichers (im Speicher oder auf der Festplatte, basierend auf Bäumen, Hash-Tabellen oder einigen exotischeren Datenstrukturen wie Merkle Patricia Tree) mit einem Standard-Standardwörterbuch getestet werden. Testen aller Arten von CRUDs - auch dort.

Eine weitere interessante Anwendung, die ich persönlich verwendet habe - manchmal können bei der Implementierung eines numerischen Modells eines Systems bestimmte Fälle analytisch berechnet und die Simulationsergebnisse mit ihnen verglichen werden. Wenn Sie in diesem Fall versuchen, völlig beliebige Daten in die Eingabe zu verschieben, fallen die Tests in der Regel trotz der korrekten Implementierung aufgrund der begrenzten Genauigkeit (und dementsprechend der Anwendbarkeit) der numerischen Lösungen immer noch ab, aber während des Reparaturprozesses durch Auferlegung von Einschränkungen für die generierten Eingabedaten gelten dieselben Einschränkungen bekannt werden.

Anforderungen und Invarianten


Die Hauptidee dabei ist, dass die Anforderungen selbst häufig so formuliert werden, dass sie einfach als Eigenschaften verwendet werden können. In einigen Artikeln zu solchen Themen werden Invarianten separat hervorgehoben, aber meiner Meinung nach ist die Grenze hier zu instabil, da die meisten dieser Invarianten direkte Konsequenzen von Anforderungen sind, sodass ich wahrscheinlich alles auf einen Haufen werfen werde.

Eine kleine Liste von Beispielen aus verschiedenen Bereichen, die zur Überprüfung von Eigenschaften geeignet sind:

  • Das Klassenfeld muss einen zuvor zugewiesenen Wert haben (Getter-Setter).
  • Das Repository sollte in der Lage sein, ein zuvor aufgezeichnetes Element zu lesen
  • Das Hinzufügen eines zuvor nicht vorhandenen Elements zum Repository wirkt sich nicht auf zuvor hinzugefügte Elemente aus
  • In vielen Wörterbüchern können mehrere verschiedene Elemente mit demselben Schlüssel nicht gespeichert werden
  • ausgeglichene Baumhöhe sollte nicht mehr sein K cdotlog(N)wo N- Anzahl der aufgezeichneten Elemente
  • Das Sortierergebnis ist eine Liste der bestellten Artikel
  • Das Base64-Codierungsergebnis sollte nur Base64-Zeichen enthalten
  • Der Routenbildungsalgorithmus sollte eine Folge zulässiger Bewegungen zurückgeben, die von Punkt A nach Punkt B führen
  • für alle Punkte der konstruierten Isolinien sollte erfüllt sein f(x,y)=const
  • Der Algorithmus zur Überprüfung der elektronischen Signatur sollte True zurückgeben, wenn die Signatur echt ist, andernfalls False
  • Infolge der Orthonormalisierung müssen alle Vektoren in der Basis eine Einheitslänge und keine gegenseitigen Skalarprodukte aufweisen
  • Vektorübertragungs- und Rotationsoperationen dürfen ihre Länge nicht ändern

Grundsätzlich könnte man sagen, dass alles vollständig ist, der Artikel vollständig ist, Testorakel verwenden oder nach Eigenschaften in den Anforderungen suchen, aber es gibt einige interessantere „Sonderfälle“, auf die ich separat hinweisen möchte.

Induktions- und Zustandstests


Manchmal muss man etwas mit einem Zustand testen. In diesem Fall der einfachste Weg:

  • Schreiben Sie einen Test, der die Richtigkeit des Ausgangszustands überprüft (z. B. dass der gerade erstellte Container leer ist).
  • Schreiben Sie einen Generator, der das System mithilfe einer Reihe von Zufallsoperationen in einen beliebigen Zustand versetzt
  • Schreiben Sie Tests für alle Operationen, wobei Sie das Ergebnis des Generators als Ausgangszustand verwenden

Sehr ähnlich zur mathematischen Induktion:

  • Aussage beweisen 1
  • beweise Aussage N + 1 unter der Annahme, dass Aussage N wahr ist

Eine andere Methode (manchmal etwas mehr Informationen darüber, wo es kaputt gegangen ist) besteht darin, eine akzeptable Folge von Ereignissen zu generieren, diese auf das zu testende System anzuwenden und die Eigenschaften nach jedem Schritt zu überprüfen.

Hin und her


Wenn plötzlich einige Funktionen für die direkte und umgekehrte Konvertierung einiger Daten getestet werden mussten, haben Sie großes Glück:

 input = arbitrary_data() assert decode(encode(input)) == input 

Ideal zum Testen:

  • Serialisierung-Deserialisierung
  • Verschlüsselung Entschlüsselung
  • Kodierung-Dekodierung
  • transformiere die Basismatrix in Quaternion und umgekehrt
  • direkte und inverse Koordinatentransformation
  • direkte und inverse Fourier-Transformation

Ein besonderer, aber interessanter Fall ist die Inversion:

 input = arbitrary_data() assert invert(invert(input)) == input 

Ein auffälliges Beispiel ist die Inversion oder Transposition einer Matrix.

Idempotenz


Einige Vorgänge ändern das Ergebnis der wiederholten Verwendung nicht. Typische Beispiele:

  • Sortieren
  • jede Normalisierung von Vektoren und Basen
  • erneutes Hinzufügen eines vorhandenen Elements zu einem Satz oder Wörterbuch
  • Erneutes Aufzeichnen derselben Daten in einer Eigenschaft des Objekts
  • Daten in kanonische Form umwandeln (Leerzeichen in JSON führen beispielsweise zu einem einheitlichen Stil)

Die Idempotenz kann auch zum Testen der Serialisierung-Deserialisierung verwendet werden, wenn die übliche Eingabemethode Decodierung (Codierung (Eingabe)) == aufgrund unterschiedlicher möglicher Darstellungen für äquivalente Eingabedaten nicht geeignet ist (wiederum zusätzliche Leerzeichen in einigen JSON):

 def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input) 

Verschiedene Wege, ein Ergebnis


Hier läuft die Idee darauf hinaus, die Tatsache auszunutzen, dass es manchmal mehrere Möglichkeiten gibt, dasselbe zu tun. Dies mag wie ein Sonderfall des Testorakels erscheinen, ist aber in Wirklichkeit nicht ganz so. Das einfachste Beispiel ist die Verwendung der Kommutativität einiger Operationen:

 a = arbitrary_value() b = arbitrary_value() assert a + b == b + a 

Es mag trivial erscheinen, aber dies ist eine großartige Möglichkeit, um zu testen:

  • Addition und Multiplikation von Zahlen in einer nicht standardmäßigen Darstellung (bigint, rational, das ist alles)
  • "Addition" von Punkten auf elliptischen Kurven in endlichen Feldern (Hallo, Kryptographie!)
  • Vereinigung von Mengen (die im Inneren völlig nicht triviale Datenstrukturen haben können)

Darüber hinaus hat das Hinzufügen von Elementen zum Wörterbuch dieselbe Eigenschaft:

 A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B 

Die Option ist komplizierter - ich habe lange darüber nachgedacht, wie man sie in Worten beschreibt, aber mir fällt nur eine mathematische Notation ein. Im Allgemeinen sind solche Transformationen üblich f(x)für die das Eigentum gilt f(x+y)=f(x) cdotf(y)und sowohl das Argument als auch das Ergebnis der Funktion sind nicht unbedingt nur eine Zahl, sondern Operationen +und  cdot- nur einige binäre Operationen an diesen Objekten. Was Sie damit testen können:

  • Addition und Multiplikation aller Arten von seltsamen Zahlen, Vektoren, Matrizen, Quaternionen ( a cdot(x+y)=a cdotx+a cdoty)
  • lineare Operatoren, insbesondere alle Arten von Integralen, Differentialen, Faltungen, digitalen Filtern, Fourier-Transformationen usw. ( F[x+y]=F[x]+F[y])
  • Operationen an identischen Objekten in verschiedenen Darstellungen, zum Beispiel

    • M(qa cdotqb)=M(qa) cdotM(qb)wo qaund qbSind einzelne Quaternionen und M(q)- Operation zur Umwandlung eines Quaternions in eine äquivalente Basismatrix
    • F[a circb]=F[a] cdotF[b]wo aund bSind Signale  circ- Faltung  cdot- Multiplikation und F- Fourier-Transformation


Ein Beispiel für eine etwas „gewöhnlichere“ Aufgabe: Um einen kniffligen Algorithmus zum Zusammenführen von Wörterbüchern zu testen, können Sie Folgendes tun:

 a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b) 

Anstelle einer Schlussfolgerung


Das ist im Grunde alles, was ich in diesem Artikel erzählen wollte. Ich hoffe, es war interessant und ein bisschen mehr Leute werden anfangen, all dies in die Praxis umzusetzen. Um die Aufgabe ein wenig zu vereinfachen, gebe ich Ihnen eine Liste von Frameworks mit unterschiedlichem Gültigkeitsgrad für verschiedene Sprachen:


Und natürlich ein besonderer Dank an die Leute, die einst wundervolle Artikel geschrieben haben, dank derer ich vor ein paar Jahren von diesem Ansatz erfahren habe, aufgehört habe, mir Sorgen zu machen, und angefangen habe, Tests basierend auf Eigenschaften zu schreiben:

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


All Articles