Elixir bietet uns eine einfache Möglichkeit, Testdaten übergreifend zu nutzen. Mit sogenannten Kontexten können wir eine Startbasis an Daten definieren, die unsere Tests komplett oder in Teilen verwenden können. Wir lernen das Konstrukt setup kennen. Neben Beispieldaten können wir auch Funktionsaufrufe mit Seiteneffekten im Setup ausführen, z. B. Löschen von vorherigen Testerzeugnissen auf der Festplatte oder Starten von externen Diensten.

Wer neu in Elixir ist, kann mit Test-ABC mit Elixir eine kurze Einführung in die Elixir-Test-Welt bekommen.

Bevor es los geht

Wer schon ein bestehendes Projekt hat, kann dieses Kapitel überspringen. Wir erstellen uns zuerst eine Spielwiese. Dabei verwenden wir Elixir in Version 1.8 auf Erlang 21, wobei die Versionen keine große Bedeutung für unsere Tests haben werden. Wie man Elixir & Co schnell installieren kann, haben wir bereits in Mit Nix raus aus der Versionshölle gesehen. Nun legen wir in einem Verzeichnis mit mix new fehlerfrei ein Projekt mit dem Namen Fehlerfrei an. Mix erstellt uns einige hilfreiche Dinge, wie z. B. auch eine Projekt-Readme oder die Gitignore-Datei.

Basis für Tests

In einführenden Beispielen lernt man immer nur einfache 3-Zeiler-Tests kennen. In realen Anwendung kommt man schnell zu dem Punkt, an dem ein Test die letzten herausfordernden 10% einer Funktion oder eines Programms testen soll. Die ersten 90% über 10 Tests hinweg sind dabei meistens gleich. Möchte man das ewige Beispiel eines Onlineshops bemühen, braucht man zum Testen für Funktionalität wie Übersicht der Produkte, Produkte in den Warenkorb legen, Kauf abschließen als registrierter Benutzer oder Benutzerkonto löschen einiges an Vorarbeit. Auf jeden Fall schließt dies eine Reihe an Produkten und ein Benutzerkonto als Datenobjekte ein. Dafür definieren wir uns zwei zusammengesetzte Datentypen, Product und Account:

  defmodule Account do
    @enforce_keys [:email]
    defstruct email: nil,
      password: "",
      address: ""

    @type t() :: %__MODULE__{
      email: String.t() | nil,
      password: String.t(),
      address: String.t()
    }
  end

  defmodule Product do
    @enforce_keys [:id, :price]
    defstruct id: nil,
      title: "",
      description: "",
      price: 0,
      available: 0

    @type t() :: %__MODULE__{
      id: String.t(),
      title: String.t(),
      description: String.t(),
      price: float(),
      available: non_neg_integer()
    }
  end

Diese Definitionen hätten wir normalerweise einzeln in eigenen Dateien gepackt, wir können diese aber auch direkt vor dem Modul Fehlerfrei in lib/fehlerfrei.ex definieren. Mit defstruct definieren wir den Datentyp. @type t() legt eine Typsignatur für %__MODULE__{} fest, also für %Account{} bzw. %Product{}. Damit erstellen wir uns jetzt Beispieldaten. Da wir bei Tests in einem Modul sind, könnten wir diese Definitionen von Testdaten einfach außerhalb der Tests definieren, würden dann aber schnell die Übersicht verlieren. Elixir bietet uns daher die Möglichkeit, einen Test-Kontext aufzubauen. Wir definieren uns am Anfang ein setup, z. B. mit Datenobjekten als Structs. In unserem Beispiel legen wir Instanzen von Produkten und Benutzerkonten fest:

 setup do
    pRasen = %Product{id: "10000815",
                      title: "Rasenmäher",
                      description: "Mit Turbofunktion",
                      price: 399.99,
                      available: 10}
    pKabel = %Product{id: "10000816",
                      title: "Verlängerungskabel",
                      description: "10 Meter",
                      price: 24.99,
                      available: 0}

    aMargo = %Account{email: "margo.musterman@example.com",
                      password: "00e3261a6e0d79c329445acd540fb2b07187a0dcf6017065c8814010283ac67f",
                      address: "Margo Mustermann, Hauptstraße 12, 01234 Musterhausen"}

    aRobin = %Account{email: "robin.mustermann@example.com",
                      password: "54d03b8c1bea08ef8896747edc304ff22fbe71a9d764ef9a3ee7b1a4ea60a622",
                      address: ""}

    {:ok,

     pRasen: pRasen,
     pKabel: pKabel,

     aMargo: aMargo,
     aRobin: aRobin
    }
  end

Am Ende von setup steht der Rückgabewert. Das ist ein Tupel mit :ok und z. B. einer Keyword-Liste (die eckigen Klammern werden hier oft weg gelassen, zum besseren Verständnis: {:ok, [a1: objekt1, ...]}). Dieser Wert wird jedem Test als Parameter übergeben. In unseren Tests binden wir diesen übergebenen Kontext an eine Variable, z. B. an context im folgendem Testfall, der die zuvor definierte Funktion change_availability überprüft:

  @doc "Change availability of a products."
  @spec change_availability(%Product{}, integer()) :: %Product{}
  def change_availability(%Product{} = p, new_amount) do
    %Product{p | available: p.available + new_amount}
  end

  test "new products were delivered", context do
    pRasenNew = change_availability(context.pRasen, 99)
    assert pRasenNew.available == 109
  end

In change_availability benutzen wir die Pipe-Syntax (|) innerhalb eines Structs, mit der man einen vorhandenen Struct in seinen Feldern verändern kann.

Für einen weiteren Test betrachten wir nur die beiden Benutzerkonten. Wir müssen nicht zwangsweise den ganzen übergebenen Kontext im Test verfügbar machen. Mithilfe von Pattern Matching, können wir einzelne Teile des Kontexts direkt an Variablen binden und andere überhaupt nicht verwenden.

  @doc "Check if a account has a non-empty address"
  @spec has_address?(%Account{}) :: boolean()
  def has_address?(%Account{address: ""}), do: false
  def has_address?(%Account{}), do: true

  test "the account has_address? functionality", %{aMargo: account_with_address,
                                                   aRobin: account_without_address} do
    assert has_address?(account_with_address)
    refute has_address?(account_without_address)
  end

Kontexte staffeln, ändern, …

Oft kommt es vor, dass man für eine Reihe an Tests eine minimal geänderte Grundlage braucht. Man könnte alle Objekte im zuvor kennengelernten Setup duplizieren und dann abändern. Dadurch würde man viel Doppelung bekommen und viel Übersicht verlieren. Mit Hilfe von describe können wir Tests zu einem Block zusammenfassen. Dies ist oft schon für eine bessere Übersicht sinnvoll. Innerhalb von describe können wir dann ein Setup anhand von Funktionen definieren, die den Kontext erstellen, verändern, erweitern oder reduzieren.

Dafür brauchen wir eine Funktion, die einen Kontext entgegennimmt und wieder zurückgibt. Wir definieren uns zwei Funktionen: Eine, die die Produkte auf Verfügbarkeit 0 setzt und eine, die eine Liste mit allen Produkten dem Kontext hinzufügt:

  # Make all products unavailable
  defp context_products_unavailable(%{pRasen: pRasen, pKabel: pKabel} = context) do
    %{context |
      pRasen: Map.put(pRasen, :available, 0),
      pKabel: Map.put(pKabel, :available, 0)}
  end

  # Put all products in a list
  defp context_put_products_to_list(%{pRasen: pRasen, pKabel: pKabel} = context) do
    Map.merge(
      context,
      %{all_products: [pRasen, pKabel]})
    # This duplicates the data in the context, we don't delete context.pRasen & context.pKabel
  end

Im ersten Beispiel verwenden wir erneut die Pipe-Syntax, um pRasen und pKabel zu überschreiben. In context_put_products_to_list vereinen wir den alten Kontext mit einer Map, die unser neues Feld enthält. Wir möchten die folgenden zwei Implementierungen testen:

  @doc "Check if we can buy a product"
  @spec buyable?(%Product{}) :: boolean()
  def buyable?(%Product{available: n, price: s}) do
    n > 0 and s > 0
  end

  @doc "Returns list of products we need to redorder"
  @spec need_reorder([%Product{}]) :: [%Product{}]
  def need_reorder(products) do
    Enum.filter(products, fn p -> p.available <= 0 end)
  end

Wir können uns jetzt zwei getrennte Gruppen mit describe definieren: Tests mit nicht verfügbaren Produkten bekommen als Setup einen Kontext, der durch context_products_unavailable und context_put_products_to_list geschleift wurde. Tests mit verfügbaren Produkten verwenden hingegen den Kontext nur bearbeitet durch die Funktion, welche die Produkte in eine Liste packt.

  describe "functionality with no available products:" do
    setup [:context_products_unavailable, :context_put_products_to_list]

    test "can't buy unavailable products", %{pRasen: pRasen,
                                             pKabel: pKabel} do
      refute buyable?(pRasen)
      refute buyable?(pKabel)
    end

    test "list products which are not available", %{all_products: products} do
      assert need_reorder(products) == products
    end
  end

  describe "functionality with some available products:" do
    setup [:context_put_products_to_list]

    test "list products which are not available", %{all_products: products,
                                                    pKabel: pKabel} do
      assert need_reorder(products) == [pKabel]
    end
  end

Die bei describe angegebenen Titel werden an den eigentlichen Testtitel vorangestellt (siehe mix test --trace). Mit dem Aufruf von setup am Anfang des describe-Blocks geben wir eine Liste von Funktionsnamen als Atome an. Durch diese Funktion wird der globale Kontext von links nach rechts durchgereicht, bevor er einem Testfall übergeben wird.

Fazit

Mit setup können wir Beispieldaten oder andere Vorarbeiten übersichtlich zu Kontexten struktuieren. Für jeden Testfall wird dieser Kontext neu erstellt und wie wir oben gesehen haben, ganz oder teilweise mittels Pattern Matching verfügbar gemacht. Mit Funktionen, die einen Kontext konsumieren und einen Kontext zurück geben, können wir mit Hilfe von describe Tests gruppieren und mit beliebigen Kontext-Funktionen die Beispieldaten modifizieren. Darüber hinaus kann man sich viele weitere Anwendungsfälle für ein Test-Setup vorstellen. Eine Idee wäre, eine externe Datenbank zu löschen und inital mit Daten zu befüllen. Jeder Test hätte dadurch einen festen Datenbestand und funktioniert unabhängig von anderen Tests. In diesem Fall muss aber darauf geachtet werden, dass die Tests nicht parallel ausgeführt werden, was der Standardeinstellung entspricht.