Test-Generatoren


Ein Test-Generator ist ein Track-spezifisches Stück Software, das die Tests einer Übung automatisch generiert. Dazu wandelt er die JSON-Testfälle der Übung in Tests in der Sprache des Tracks um.

Vorteile

Ein Test-Generator hat unter anderem diese Vorteile:

  1. Übungen lassen sich schneller hinzufügen
  2. Automatisiert die „langweiligen“ Teile beim Hinzufügen einer Übung
  3. Tests lassen sich leicht mit den neuesten kanonischen Daten synchronisieren

Anwendungsfälle

Im Allgemeinen führt man einen Test-Generator aus, um entweder:

  1. die Tests für eine neue Übung zu generieren
  2. die Tests einer bestehenden Übung zu aktualisieren

Tests für eine neue Übung generieren

Wenn du für eine neue Übung einen Test-Generator hinzufügst, kannst du ihre Testdatei(en) generieren. Vorausgesetzt, der Test-Generator selbst ist schon implementiert, ist das Generieren der Tests für die neue Übung (deutlich) weniger Arbeit, als sie von Grund auf zu schreiben.

Tests einer bestehenden Übung aktualisieren

Sobald eine Übung einen Test-Generator hat, kannst du ihn erneut ausführen, um die Übung mit ihren neuesten kanonischen Daten zu aktualisieren bzw. abzugleichen. Wir empfehlen, das regelmäßig zu machen, um zu prüfen, ob es problematische Testfälle gibt, die aktualisiert werden müssen, oder neue Tests, die du aufnehmen möchtest.

Ausgangspunkt

Es gibt zwei mögliche Ausgangspunkte, wenn du einen Test-Generator für eine Übung implementierst:

  1. Die Übung ist neu und hat deshalb noch keine Tests
  2. Die Übung existiert bereits und hat deshalb schon Tests
Caution

Wenn es bereits Tests gibt, implementiere den Test-Generator so, dass die von ihm generierten Tests keine bestehenden Lösungen kaputt machen.

Design

Grob gesagt werden Testdateien auf eine von zwei Arten generiert:

  • Code: Die Testdateien werden (größtenteils) über Code generiert
  • Templates: Die Testdateien werden (größtenteils) mithilfe von Templates generiert

Wir haben festgestellt, dass der codebasierte Ansatz zu recht komplexem Test-Generator-Code führt, während der Template-Ansatz einfacher ist.

Wir empfehlen folgenden Ablauf:

  1. Die kanonischen Daten der Übung lesen
  2. Die Testfälle ausschließen, die in der tests.toml-Datei der Übung als include = false markiert sind
  3. Die kanonischen Daten der Übung in ein Format umwandeln, das in einem Template verwendet werden kann
  4. Die kanonischen Daten der Übung an ein übungsspezifisches Template übergeben

Der entscheidende Vorteil dieses Aufbaus ist, dass jede Übung ihr eigenes Template hat, was:

  • offensichtlich macht, wie die Testdateien generiert werden
  • sie leichter zu debuggen macht
  • es sicher macht, sie zu bearbeiten, ohne zu riskieren, eine andere Übung kaputt zu machen
Caution

Versuche beim Entwerfen des Test-Generators:

  • die Vorverarbeitung der kanonischen Daten im Test-Generator zu minimieren
  • die Kopplung zwischen den Templates zu verringern

Implementierung

Der Test-Generator ist normalerweise (größtenteils) in der Sprache des Tracks geschrieben.

Caution

Du kannst zwar auch andere Sprachen verwenden, aber jede zusätzliche Sprache macht es schwieriger, den Track zu pflegen oder zu ihm beizutragen. Deshalb empfehlen wir, wo möglich die Sprache des Tracks zu verwenden, weil das die Pflege und das Mitwirken einfacher macht.

Formatierung

Wenn dein Track Tools zum Formatieren von Code hat, kannst du sie als Nachbearbeitungsschritt nach dem Rendern deines Templates ausführen.

Kanonische Daten

Die zentrale Datenquelle, mit der der Test-Generator arbeitet, ist die canonical-data.json-Datei einer Übung. Diese Datei ist im Repository exercism/problem-specifications definiert, das gemeinsame Metadaten für viele Übungen von Exercism festlegt.

Caution

Nicht alle Übungen haben eine canonical-data.json-Datei! Wenn nicht, musst du die Tests manuell erstellen, weil es keine Daten gibt, mit denen der Test-Generator arbeiten kann.

Struktur

Kanonische Daten sind in einem JSON-Objekt definiert. Dieses Objekt enthält ein "cases"-Feld, das die Testfälle enthält. Diese Testfälle entsprechen (normalerweise) eins zu eins den Tests in deinem Track.

Jeder Testfall hat ein paar Eigenschaften, wobei die Felder description, property, input value(s) und expected value die wichtigsten sind. Hier ist ein (teilweises) Beispiel der canonical-data.json-Datei der Übung leap:

{
  "exercise": "leap",
  "cases": [
    {
      "uuid": "6466b30d-519c-438e-935d-388224ab5223",
      "description": "year not divisible by 4 in common year",
      "property": "leapYear",
      "input": {
        "year": 2015
      },
      "expected": false
    },
    {
      "uuid": "4fe9b84c-8e65-489e-970b-856d60b8b78e",
      "description": "year divisible by 4, not divisible by 100 in leap year",
      "property": "leapYear",
      "input": {
        "year": 1996
      },
      "expected": true
    }
  ]
}

Die Hauptaufgabe des Test-Generators ist es, diese JSON-Daten in Track-spezifische Tests umzuwandeln. So könnte das obige JSON in Nim-Testcode übersetzt werden:

import unittest
import leap

suite "Leap":
  test "year not divisible by 4 in common year":
    check isLeapYear(2015) == false

  test "year divisible by 4, not divisible by 100 in leap year":
    check isLeapYear(1996) == true

Die Struktur der canonical-data.json-Datei ist gut dokumentiert und es gibt auch eine Definition als JSON-Schema.

Verschachtelung

Manche Übungen verwenden Verschachtelung in ihren kanonischen Daten. Das bedeutet, dass jedes Element in einem cases-Array Folgendes sein kann:

  1. Ein regulärer Testfall (ohne untergeordnete Testfälle)
  2. Eine Gruppierung von Testfällen (ein oder mehrere untergeordnete Testfälle)
Note

Du kannst die Art eines Elements daran erkennen, ob Felder vorhanden sind, die nur für einen Elementtyp vorkommen. Am besten geht das wahrscheinlich über den Schlüssel "cases", der nur in Testfallgruppen vorkommt.

Hier ist ein Beispiel für verschachtelte Testfälle:

{
  "cases": [
    {
      "uuid": "e9c93a78-c536-4750-a336-94583d23fafa",
      "description": "data is retained",
      "property": "data",
      "input": {
        "treeData": ["4"]
      },
      "expected": {
        "data": "4",
        "left": null,
        "right": null
      }
    },
    {
      "description": "insert data at proper node",
      "cases": [
        {
          "uuid": "7a95c9e8-69f6-476a-b0c4-4170cb3f7c91",
          "description": "smaller number at left node",
          "property": "data",
          "input": {
            "treeData": ["4", "2"]
          },
          "expected": {
            "data": "4",
            "left": {
              "data": "2",
              "left": null,
              "right": null
            },
            "right": null
          }
        }
      ]
    }
  ]
}
Caution

Wenn dein Track das Gruppieren von Tests nicht unterstützt, musst du:

  • die cases-Hierarchie durchlaufen bzw. flach machen, sodass am Ende nur die innersten (Blatt-)Testfälle übrig bleiben
  • die Beschreibung des Testfalls mit der oder den Beschreibungen seiner übergeordneten Elemente kombinieren, um einen eindeutigen Testnamen zu erstellen

Eingabe- und Erwartungswerte

Der Inhalt der Testfall-Schlüssel input und expected ist sehr unterschiedlich. In den meisten Fällen sind es skalare Werte (wie Zahlen, boolesche Werte oder Strings) oder einfache Objekte. Gelegentlich findest du aber auch komplexere Werte, die wahrscheinlich etwas Vorverarbeitung erfordern, zum Beispiel Lambdas in Pseudocode, Listen von Operationen, die auf dem Code der Lernenden ausgeführt werden sollen, und mehr.

Szenarien

Testfälle haben ein optionales Feld scenarios. Dieses Feld kann vom Test-Generator verwendet werden, um bestimmte Testfälle gesondert zu behandeln. Der häufigste Anwendungsfall ist, bestimmte Testarten zu ignorieren, zum Beispiel Tests mit dem Szenario "unicode", weil die Sprache deines Tracks Unicode möglicherweise nicht unterstützt.

Die vollständige Liste der Szenarien findest du hier.

canonical-data.json-Dateien lesen

Es gibt mehrere Möglichkeiten, die canonical-data.json-Dateien zu lesen:

  1. Sie direkt aus dem Repository problem-specifications abrufen (z. B. https://raw.githubusercontent.com/exercism/problem-specifications/main/exercises/leap/canonical-data.json).
  2. Das Repository problem-specifications als Git-Submodul zum Track-Repository hinzufügen.
  3. Sie aus dem Cache von configlet lesen. Der Speicherort hängt vom System des Nutzers ab, aber du kannst configlet info -o -v d | head -1 | cut -d " " -f 5 verwenden, um den Speicherort programmatisch zu ermitteln.

Track-spezifische Testfälle

Wenn dein Track zusätzliche, Track-spezifische Testfälle hinzufügen möchte (die es in den kanonischen Daten nicht gibt), kannst du zum Beispiel eine additional-test-cases.json-Datei erstellen, die der Test-Generator dann mit der canonical-data.json-Datei zusammenführen kann, bevor er sie zum Rendern an das Template übergibt.

Templates

Die verwendete Template-Engine ist wahrscheinlich Track-spezifisch. Am besten hältst du deine Templates so einfach wie möglich, also mach dir keine Gedanken über Code-Duplizierung und Ähnliches.

Die Templates selbst bekommen ihre Daten vom Test-Generator; er iteriert über diese Daten, um sie zu rendern.

Note

Um die Templates einfach zu halten, kann es hilfreich sein, auf der Seite des Test-Generators etwas Vorverarbeitung zu betreiben oder ein paar „Filter“ zu definieren, oder welchen Erweiterungsmechanismus deine Templates auch immer unterstützen.

configlet verwenden

configlet ist das wichtigste Werkzeug zur Pflege eines Tracks und kann verwendet werden, um:

  • die Übungsdateien für eine neue Übung zu erstellen: Führe bin/configlet create --practice-exercise <slug> aus
  • die tests.toml-Datei einer bestehenden Übung abzugleichen: Führe bin/configlet sync --tests --update --exercise <slug> aus
  • die kanonischen Daten der Übung auf die Festplatte zu holen (das ist ein Nebeneffekt eines der beiden obigen Befehle)

Damit ist configlet ein großartiges Tool, das du in Kombination mit dem Test-Generator für richtig leistungsstarke Workflows einsetzen kannst.

Kommandozeile

Du möchtest die Verwendung des Test-Generators sowohl einfach als auch leistungsstark machen. Dafür empfehlen wir, eine oder mehrere Skriptdateien zu erstellen.

Note

Du kannst frei wählen, welches Skriptdateiformat am besten zu deinem Track passt. Shell-Skripte und PowerShell-Skripte sind gängige Optionen, die beide gut funktionieren können.

Hier ist ein Beispiel für ein Shell-Skript, das configlet und einen Test-Generator kombiniert, um schnell eine neue Übung aufzusetzen:

bin/fetch-configlet
bin/configlet create --practice-exercise <slug>
path/to/test-generator <slug>

Von Grund auf neu aufbauen

Bevor du anfängst, einen Test-Generator zu bauen, schau dir am besten ein paar bestehende Test-Generatoren an, um ein Gefühl dafür zu bekommen, wie andere Tracks sie umgesetzt haben:

Wenn du Fragen hast, ist das Forum der beste Ort, um sie zu stellen. Auch die Forendiskussionen rund um den Rust und die JavaScript-Test-Generatoren können hilfreich sein.

Minimal Viable Product

Wir empfehlen, den Test-Generator schrittweise aufzubauen und mit einem Minimal Viable Product zu beginnen. Eine absolute Minimalversion würde die canonical-data.json einer Übung lesen und diese Daten einfach an das Template weitergeben.

Konzentriere dich zuerst auf eine einzige Übung, am besten eine einfache wie leap. Erst wenn das funktioniert, solltest du nach und nach weitere Übungen hinzufügen.

Und versuche, den Test-Generator so einfach wie möglich zu halten.

Note

Am besten könnte ein Mitwirkender einfach ein bestehendes Template einfügen bzw. anpassen, ohne verstehen zu müssen, wie der Test-Generator intern funktioniert.

Verwenden oder mitwirken

Wie du einen Test-Generator verwendest oder zu ihm beiträgst, ist Track-spezifisch. Schau nach Anweisungen in der README.md, der CONTRIBUTING.md des Tracks oder im Verzeichnis des Test-Generator-Codes.