Shiny-Apps mit testthat (unit-) testen

In diesem Blog-Beitrag wird erklärt, wie man eine Shiny-App mithilfe der Pakete shinytest und testthat testen kann. Grundlegende Vorkenntnisse über Shiny-Apps und das Prinzip von Unit-Tests mit dem Paket testthat sind nützlich, werden hier aber nicht vorausgesetzt.

Beispiel einer Shiny-App

Für den hier vorgestellten Test sind die Pakete shiny (aktuell: 1.1.0), testthat (1.3.0) und shinytest (2.0.0) erforderlich. Falls nötig können sie bequem mit install.packages() installiert werden.

Unten ist ein minimales Beispiel einer Shiny-App (app.R), die getestet werden soll. Die App hat nur einen einzigen numerischen Input und einen Text-Output. Die eingegebene Zahl n wird quadriert und das Ergebnis wird als Text angezeigt.

library(shiny)

ui <- fluidRow(title = 'Minimal App',
               numericInput("num_input", "Bitte geben Sie eine Zahl n ein:", 0),
               textOutput('text_out')
               )

server <- function(input, output, session) {
  result <- reactive(input$num_input^2)
  output$text_out <- renderText(
    paste("Das Quadrat der Zahl n lautet: n² =", result())
    )
}

shinyApp(ui, server)

Und so sieht die App aus:

Was ist shinytest?

Das Paket shinytest ermöglicht das automatische Testen einer Shiny-App. Dabei wird sowohl das “Aussehen” der App, sowie ihr “interner” Zustand zu einem bestimmten Zeitpunkt im Programmablauf untersucht. Mithilfe eines interaktiven User-Interfaces lassen sich Snapshots (genauer gesagt Referenz-Snapshots) sowie eine Test-Datei erstellen. Die Test-Datei beinhaltet den Code, der für spätere Wiederherstellung der Snapshots erforderlich ist. Bei jedem weiteren Test werden neue Snapshots erstellt und mit den Referenz-Snapshots verglichen, um unerwartetes Verhalten der Shiny-App automatisch zu detektieren. Mehr zu dem normalen Workflow mit shinytest ist hier zu finden. In diesem Blog-Beitrag wird allerdings ein anderer Umgang mit dem Testen beschrieben; Nämlich das gemeinsame Testen mittels shinytest und testtthat [*].

[*] Nicht hier besprochen wird die Funktion expect_pass (vgl. ?expect_pass), die einfach alle shinytest-Snapshots vergleicht und über Abweichungen informiert. Das erlaubt zwar schnelle Vergleiche, aber ist weniger detailliert und spezifisch als der hier vorgestellte Ansatz.

Shiny-Apps mit shinytest & testthat testen

shinytest verfügt über die Klasse ShinyDriver (vgl. ?ShinyDriver), die beim Erstellen eines neuen Objekts die Shiny-App in einer neuen R-Session sowie eine Instanz von PhantomJS öffnet und diese automatisch mit der Shiny-App verbindet. PhantomJS ist ein Headless Webbrowser, der durch JavaScript bedient werden kann. Das ShinyDriver-Objekt ist mit verschiedenen Methoden ausgerüstet, die vor allem das Auslesen bzw. Eingeben von Werten für verschiedene Variablen in der shiny-App ermöglichen. Man kann also den Input-Variablen beliebige Werte gezielt, “manuell” (ohne das übliche User-Interface der Shiny-App) zuweisen und dann die Ausgabe-Variablen auslesen.

Beispiel eines Tests

In folgendem Test wird die Variable num_input auf 30 gesetzt und dann getestet, ob die Variable text_out zum String “Das Quadrat der Zahl n lautet: n² = 900” wird. Mehr zum Testen mit testthat ist hier zu finden.

library(shinytest)
library(testthat)

context("Test Shiny-App")

# shiny-App und PhantomJS öffnen
app <- ShinyDriver$new("<Pfad zu app.R>")

test_that("Output wird korrekt berechnet", {
  # num_input auf 30 setzen 
  app$setInputs(num_input = 30)
  # text_out auslesen
  output <- app$getValue(name = "text_out")
  # testen
  expect_equal(output, "Das Quadrat der Zahl n lautet: n² = 900")  
})

# Shiny-App stoppen
app$stop()

Mit den Erwartungsfunktionen des Pakets testthat ist es also bequem möglich, die Funktionalitäten der Shiny-App zu testen. Ein Vorteil dabei ist auch, dass beim Aufrufen von devtools::test() sowohl die Tests der Shiny-App als auch die weiteren Unit-Tests mitberücksichtigt werden.

Tiefere Einsichten – Exportierte Variablen und HTML-Widgets

Innerhalb der server-Funktion von Shiny kann man zudem neue Variablen (außer der üblichen Inputs und Outputs) extra definieren. Diese sind dann von shinytest-Seite “sichtbar” und erlauben eine detailliertere Untersuchung der App-Abläufe. Als Beispiel für die oben gezeigte Shiny-App kann man die Liste aller eingegebenen Zahlen n speichern und als Variable inputs_list exportieren (bitte siehe den Code unten).

Für verschiedene HTML-Widgets bietet sich die Methode findElement mit der XPath zu verwenden app$findElement(xpath = "Hier das XPATH"). Wenn man beispielsweise Benachrichtigungen mit der Funktion showNotification() in der Shiny-App zeigt, kann man sie mit xpath = "//*[@id=\"shiny-notification-panel\"]" identifizieren und entsprechend testen.

Im Folgenden wird gezeigt, wie man in der Shiny-App eine Variable exportiert und Benachrichtigungen mittels showNotification() Benachrichtigungen darstellt:

library(shiny)

# gleiches ui wie oben
ui <- fluidRow(title = 'Minimal App',
               numericInput("num_input", "Bitte geben Sie eine Zahl n ein:", 0),
               textOutput('text_out')
               )

server <- function(input, output, session) {
  result <- reactive(input$num_input ^ 2)
  output$text_out <- renderText(
    paste("Das Quadrat der Zahl n lautet: n² =", result())
    )
  # Initialisierung der exportierten Liste
  inputs_list <- c()
  observeEvent(input$num_input, {
    # neue Eingabe wird zu inputs_list zusammengefügt
    inputs_list <<- c(inputs_list, input$num_input)
    # Benachrichtigung zeigen
    showNotification(HTML(result()), duration = NULL)
    })
  # inputs_list exportieren
  exportTestValues(inputs_list = {inputs_list})
}

shinyApp(ui, server)

Ein Test kann dann beispielsweise wie folgt aussehen:

library(shinytest)
library(testthat)

context("Test Shiny-App")

# shiny-App und PhantomJS öffnen
app <- ShinyDriver$new("<Pfad zu app.R>")

test_that("inputs_list wird korrekt exportiert", {
  # Mehrere Eingaben
  app$setInputs(num_input = 1)
  app$setInputs(num_input = 7)
  app$setInputs(num_input = 42)

  # exportierte Variable inputs_list auslesen
  exported_list <- app$getAllValues()$export$inputs_list

  # testen (0 war der initiale Wert)
  expect_equal(exported_list, c(0, 1, 7, 42))  
})

test_that("Benachrichtignungen beinhalten korrekten Text", {
  # HTML-Widget mit der XPath identifizieren
  popup <- app$findElement(xpath = "//*[@id=\"shiny-notification-panel\"]")

  # Benachrichtigungstext testen
  testthat::expect_equal(popup$getText(), "×\n0\n×\n1\n×\n49\n×\n1764")
})

# shiny-App stoppen
app$stop()