Unit Testing mit R

Markus Berroth Blog

„All code is guilty until proven innocent.“

Testing ist ein wichtiger Teil in der Entwicklung von stabilem R Code. Testing stellt sicher, dass der Code wie beabsichtigt funktioniert. Allerdings ist dies ein zusätzlicher Schritt im bisherigen Workflow. Oft besteht der übliche "Testing"-Workflow in R darin, nach dem Schreiben einer neuen Funktion, diese zuerst informell in der Konsole zu testen und zu schauen, ob der Code wie angestrebt funktioniert. Dieser Beitrag soll aufzeigen, wie man mit Hilfe des testthat-Packages strukturierte Unit-Tests schreibt.

Motivation für Unit Testing

  • Geringere Anzahl an Bugs: Dadurch, dass das Verhalten des Codes an zwei Stellen festgehalten wird – einmal im Code selbst und einmal in den Tests – kann man sicherstellen, dass er wie beabsichtigt funktioniert und dadurch im besten Fall keine Fehler im Code sind. Inbesondere kann es nützlich sein, im Falle eines behobenen Bugs im Anschluss einen entsprechenden Test zu schreiben, welcher den Bug identifiziert hätte. Dies stellt sicher, dass wenn man sich nach einiger Zeit dem Code widmet, nicht einen alten Fehler erneut hinzufügt.
  • Bessere Code-Struktur: Für sinnvolle Tests ist es wichtig, dass der Code übersichtlich gestaltet ist. Damit dieser gut getestet werden kann, ist es hilfreich, anstelle von einer komplexen, verschachtelten Funktion, den Code in mehrere, simplere Funktionen aufzuteilen. Hierdurch wird die Fehleranfälligkeit zusätzlich verringert.
  • Robuster Code: Da die komplette Funktionalität des Codes schon einmal überprüft worden ist, kann man einfacher größere Änderungen am Code vornehmen ohne in diesen (unabsichtlich) Fehler einzubauen. Dies ist vor allem hilfreich, wenn man zu einem späteren Zeitpunkt denkt, dass es einen effizienteren Weg gibt, um dies zu bewerkstelligen, aber einen zuvor berücksichtigten Randfall vergisst.

Package: testthat

Ein fantastisches Paket in R für Unit-Testing ist das testthat-Paket von Hadley Wickham. Kennt man sich mit Testing aus anderen Programmiersprachen aus, wird man feststellen, dass es einige signifikante Unterschiede gibt. Dies liegt zum größten Teil daran, dass es sich bei R stärker um eine funktionale Programmiersprache handelt, als um eine objektorientierte Programmiersprache. Daher macht es wenig Sinn, Tests um Objekte und Methoden zu bauen, anstatt um Funktionen.
Eine Alternative zu testthat ist das RUnit-Paket, wobei einer der Vorteile von testthat ist, dass es aktiv weiterentwickelt wird.
Der generelle Testaufbau mit testthat ist, dass mehrere zusammenhängende Expectations in ein test_that-Statement zusammengefügt werden, welches wiederrum in ein context-File gruppiert wird.

Workflow mit Beispielen

Im Folgenden werden wir die Funktion im Skript quadratic_function.R testen, welche die Nullstellen einer quadratischen Gleichung berechnet. Dieses Skript, sowie die Tests sind ebenfalls auf unserer Github-Seite zu finden.

quadratic_equation <- function(a, b, c)
{
  if (a == 0)
    stop("Leading term cannot be zero")
  # Calculate determinant
  d <- b * b - 4 * a * c
  
  # Calculate real roots
  if (d < 0)
    rr <- c()
  else if (d == 0)
    rr <- c(-b / (2 * a))
  else
    rr <- c((-b - sqrt(d)) / (2 * a), 
            (-b + sqrt(d)) / (2 * a))
  
  return(rr)
}

Zu Beginn eines Test-Skriptes wird das jeweilige R-Skript, welches die zu testenden Funktionen enthält, geladen.

source("quadratic_function.R")

Der Name des Test-Skripts muss mit test beginnen und ist strukturell das höchste Element im Testing. Jedes File sollte einen context()Aufruf beinhalten, welches eine kurze Beschreibung über den Inhalt zur Verfügung stellt. Hierbei sollte man beachten, dass man ein gesundes Mittelmaß für den Umfang eines jeden Files findet. Es ist schlecht, wenn die gesamten Tests für ein komplexes Paket sich in einem File befinden, aber gleichermaßen, wenn jeder Test sein eigenes File hat. Oft ist es eine gute Idee, dass jede komplexe Funktion sein eigenes File besitzt.

Expectations

Expectations stellen das kleinste Element dar und beschreiben, was das erwartete Ergebnis einer Berechnung ist, beispielsweise die richtige Klasse oder Wert. Expectations sind Funktionen, die mit expect_ beginnen.

# Expectations
calculated_root <- quadratic_equation(1, 7, 10)

expect_is(calculated_root, "numeric")
expect_length(calculated_root, 2)
expect_lt(calculated_root[1], calculated_root[2])

Es gibt verschiedene, vordefinierte expect_-Funktionen, welche unterschiedliche Bedingungen überprüfen. Beispielsweise überprüft die erste Expectation ob der zurückgegebenen Werte numerisch ist, die zweite ob zwei Wurzeln zurückgegeben werden und die dritte, ob die erste berechnete Wurzel kleiner als die zweite ist. Es besteht ebenfalls die Möglichkeit, mit expect() eigene Expectations zu schreiben, falls eine Expectation häufiger verwendet wird oder mit expect_true() einfache True/Falls Bedingungen zu überprüfen. Letzteres sollte allerdings nur verwendet werden, falls es keine vordefinierte Expectation gibt, da diese eine bessere Fehlerbeschreibung beinhalten als expect_true(). Außerdem sollte beachtet werden, dass expect_that() veraltet ist und nicht mehr benutzt werden sollte.

Tests

Ein Test verbindet mehrere Expectation um beispielsweiße die Ausgabe einer simplen Funktion, eine Reihe von möglichen Eingabewerten einer komplexeren Funktion oder stark verbundene Funktionalitäten von mehreren unterschiedlichen Funktionen zu testen. Daher wird diese Art von Tests Unit-Tests genannt, da sie eine Einheit (Unit) der Funktionalität überprüfen.
Ein neuer Test wird mit Hilfe der test_that()-Funktion kreiert und beinhaltet den Testname sowie einen Codeblock. Hierbei sollte der Testname den Satz "Test that…" beenden. Außerdem sollte die Beschreibung informativ gehalten werden, damit ein möglichen Fehler zügig gefunden werden kann auch für den Fall, dass man längere Zeit nicht damit gearbeitet hat. Zusätzlich sollte ein Test einen nicht zu großen Bereich an Expectations abdecken, was eine schnelle Lokalisierung des Fehlers ermöglicht.

# Beispiel test_that
calculated_root <- quadratic_equation(1, 7, 10)

expect_is(calculated_root, "numeric")
expect_length(calculated_root, 2)
expect_lt( calculated_root[1], calculated_root[2])

In diesem Beispiel haben wir die obigen Expectations in einen neuen Test zusammengefast, welcher testet, ob die Funktion distinkte Werte zurückgibt.

Einzelne Testfiles können mit test_file() aufgerufen werden und mehrere Tests, deren Dateinamen mit test_ beginnen und sich im gleichen Ordner befinden mit test_dir(). Dies ermöglicht im Gegensatz zu einem einfachen source(), dass die weiteren Tests ebenfalls durchlaufen, falls ein vorheriger abbricht.
Bevor wir die Tests einmal durchlaufen lassen, habe ich noch weitere hinzugefügt, unter anderem einen Test welcher absichtlich nicht passiert. Dieser testet ob die Funktion eine Warnung ausgibt, falls a = 0 gesetzt wurde. Allerdings wurde in der Funktion zuvor eine error-Ausnahme definiert statt einer warning. Über die Handhabung von Ausnahmen dreht sich auch mein vorheriger Blogbeitrag.
Die Ausgabe für die Tests sieht folgendermaßen aus:

unit test output

Ein grüner Punkt bedeutet, dass der jeweilige Test erfolgreich bestanden wurde. Eine rote Zahl hingegen bedeutet, dass der Test nicht bestanden wurde. Darunter befinden sich zusätzliche Informationen, wie beispielweise welcher Test nicht bestanden wurde. Daher ist es wichtig, diese eindeutig zu benennen und nicht zu umfangreich zu gestalten.

good code or shity unit test

Was sollte getestet werden?

Es ist schwer, die richtige Balance zu finden, wenn es um das Schreiben von Tests geht. Auf der einen Seite verhindern diese, dass man unbeabsichtigt etwas im Code verändert, jedoch müssen bei einer gewollten Änderung ebenfalls alle betroffenen Tests angepasst werden.
Einige hilfreiche Punkte hierfür sind:

  • Teste lieber das äußere Interface einer Funktion, als den inneren Code, da dadurch die Flexibilität beibehalten wird, diesen später zu ändern.
  • Schreibe für jede Funktionalität deiner Funktionen nur einen einzelnen Test. Dadurch muss später, falls sich diese verändert, nur ein Test angepasst werden.
  • Konzentriere dich darauf, komplizierten Code mit vielen Abhängigkeiten zu testen und Randfälle statt dem Offensichtlichen. Jeder hat wahrscheinlich schon einmal mehrere Stunden mit Debugging verbracht, nur um einen simplen Fehler zu finden.
  • Schreibe nach jedem erfolgreich gefixten Bug einen Test.
Über den Autor
Markus Berroth

Markus Berroth

I am a data scientist at STATWORX and I love creating novel knowledge from data. In my time off, I am always open for a weekend trip.

ABOUT US


STATWORX
is a consulting company for data science, statistics, machine learning and artificial intelligence located in Frankfurt, Zurich and Vienna. Sign up for our NEWSLETTER and receive reads and treats from the world of data science and AI. If you have questions or suggestions, please write us an e-mail addressed to blog(at)statworx.com.