From 263f8a8e7d2a9880dbe59b1c1e7598e62325f5a6 Mon Sep 17 00:00:00 2001 From: trestletech Date: Thu, 24 Oct 2019 10:07:23 -0500 Subject: [PATCH 01/12] Introduce integration testing functionality --- DESCRIPTION | 1 + NAMESPACE | 2 + R/test-module.R | 118 ++++++ inst/_pkgdown.yml | 5 + man/testModule.Rd | 27 ++ man/testServer.Rd | 18 + tests/testthat/test-test-module.R | 618 ++++++++++++++++++++++++++++++ vignettes/integration-testing.Rmd | 286 ++++++++++++++ 8 files changed, 1075 insertions(+) create mode 100644 R/test-module.R create mode 100644 man/testModule.Rd create mode 100644 man/testServer.Rd create mode 100644 tests/testthat/test-test-module.R create mode 100644 vignettes/integration-testing.Rmd diff --git a/DESCRIPTION b/DESCRIPTION index 1f4078f538..119f346332 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -164,6 +164,7 @@ Collate: 'snapshot.R' 'tar.R' 'test-export.R' + 'test-module.R' 'update-input.R' RoxygenNote: 6.1.1 Encoding: UTF-8 diff --git a/NAMESPACE b/NAMESPACE index 2a731204a9..edfe71714c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -260,6 +260,8 @@ export(tagHasAttribute) export(tagList) export(tagSetChildren) export(tags) +export(testModule) +export(testServer) export(textAreaInput) export(textInput) export(textOutput) diff --git a/R/test-module.R b/R/test-module.R new file mode 100644 index 0000000000..0601c5fae4 --- /dev/null +++ b/R/test-module.R @@ -0,0 +1,118 @@ + + +#' Test a shiny module +#' @param module The module under test +#' @param expr Test code containing expectations. The test expression will run +#' in the module's environment, meaning that the module's parameters (e.g. +#' `input`, `output`, and `session`) will be available along with any other +#' values created inside of the module. +#' @param args A list of arguments to pass into the module beyond `input`, +#' `output`, and `session`. +#' @param initialState A list describing the initial values for `input`. If no +#' initial state is given, `input` will initialize as an empty list. +#' @param ... Additional named arguments to be passed on to the module function. +#' @include mock-session.R +#' @export +testModule <- function(module, expr, args, ...) { + expr <- substitute(expr) + .testModule(module, expr, args, ...) +} + +.testModule <- function(module, expr, args, ...) { + # Capture the environment from the module + # Inserts `session$env <- environment()` at the top of the function + fn_body <- body(module) + fn_body[seq(3, length(fn_body)+1)] <- fn_body[seq(2, length(fn_body))] + fn_body[[2]] <- quote(session$env <- environment()) + body(module) <- fn_body + + # Substitute expr for later evaluation + if (!is.call(expr)){ + expr <- substitute(expr) + } + + # Create a mock session + session <- MockShinySession$new() + + # Parse the additional arguments + args <- list(...) + args[["input"]] <- session$input + args[["output"]] <- session$output + args[["session"]] <- session + + # Initialize the module + isolate( + withReactiveDomain( + session, + withr::with_options(list(`shiny.allowoutputreads`=TRUE), { + # Remember that invoking this module implicitly assigns to `session$env` + # Also, assigning to `$returned` will cause a flush to happen automatically. + session$returned <- do.call(module, args) + }) + ) + ) + + # Run the test expression in a reactive context and in the module's environment. + # We don't need to flush before entering the loop because the first expr that we execute is `{`. + # So we'll already flush before we get to the good stuff. + isolate({ + withReactiveDomain( + session, + withr::with_options(list(`shiny.allowoutputreads`=TRUE), { + eval(expr, session$env) + }) + ) + }) + + if (!session$isClosed()){ + session$close() + } +} + +#' Test an app's server-side logic +#' @param expr Test code containing expectations +#' @param appdir The directory root of the Shiny application. If `NULL`, this function +#' will work up the directory hierarchy --- starting with the current directory --- +#' looking for a directory that contains an `app.R` or `server.R` file. +#' @export +testServer <- function(expr, appDir=NULL) { + if (is.null(appDir)){ + appDir <- findApp() + } + + app <- shinyAppDir(appDir) + server <- app$serverFuncSource() + + # Add `session` argument if not present + fn_formals <- formals(server) + if (! "session" %in% names(fn_formals)) { + fn_formals$session <- bquote() + formals(server) <- fn_formals + } + + s3 <<- server + # Now test the server as we would a module + .testModule(server, expr=substitute(expr)) +} + +findApp <- function(startDir="."){ + dir <- normalizePath(startDir) + + # The loop will either return or stop() itself. + while (TRUE){ + if(file.exists.ci(file.path(dir, "app.R")) || file.exists.ci(file.path(dir, "server.R"))){ + return(dir) + } + + # Move up a directory + origDir <- dir + dir <- dirname(dir) + + # Testing for "root" path can be tricky. OSs differ and on Windows, network shares + # might have a \\ prefix. Easier to just see if we got stuck and abort. + if (dir == origDir){ + # We can go no further. + stop("No shiny app was found in ", startDir, " or any of its parent directories") + } + } +} diff --git a/inst/_pkgdown.yml b/inst/_pkgdown.yml index 1c7bb7ff3c..0911ffb631 100644 --- a/inst/_pkgdown.yml +++ b/inst/_pkgdown.yml @@ -215,3 +215,8 @@ reference: contents: - shinyApp - maskReactiveContext + - title: Testing + desc: Functions intended for testing of Shiny components + contents: + - testModule + - testServer diff --git a/man/testModule.Rd b/man/testModule.Rd new file mode 100644 index 0000000000..1df5a56b0f --- /dev/null +++ b/man/testModule.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/test-module.R +\name{testModule} +\alias{testModule} +\title{Test a shiny module} +\usage{ +testModule(module, expr, args, ...) +} +\arguments{ +\item{module}{The module under test} + +\item{expr}{Test code containing expectations. The test expression will run +in the module's environment, meaning that the module's parameters (e.g. +\code{input}, \code{output}, and \code{session}) will be available along with any other +values created inside of the module.} + +\item{args}{A list of arguments to pass into the module beyond \code{input}, +\code{output}, and \code{session}.} + +\item{...}{Additional named arguments to be passed on to the module function.} + +\item{initialState}{A list describing the initial values for \code{input}. If no +initial state is given, \code{input} will initialize as an empty list.} +} +\description{ +Test a shiny module +} diff --git a/man/testServer.Rd b/man/testServer.Rd new file mode 100644 index 0000000000..65d3660f72 --- /dev/null +++ b/man/testServer.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/test-module.R +\name{testServer} +\alias{testServer} +\title{Test an app's server-side logic} +\usage{ +testServer(expr, appDir = NULL) +} +\arguments{ +\item{expr}{Test code containing expectations} + +\item{appdir}{The directory root of the Shiny application. If \code{NULL}, this function +will work up the directory hierarchy --- starting with the current directory --- +looking for a directory that contains an \code{app.R} or \code{server.R} file.} +} +\description{ +Test an app's server-side logic +} diff --git a/tests/testthat/test-test-module.R b/tests/testthat/test-test-module.R new file mode 100644 index 0000000000..79a7de5f1d --- /dev/null +++ b/tests/testthat/test-test-module.R @@ -0,0 +1,618 @@ +context("testModule") + +library(promises) +library(future) +plan(multisession) + +test_that("testModule handles observers", { + module <- function(input, output, session) { + rv <- reactiveValues(x = 0, y = 0) + observe({ + rv$x <- input$x * 2 + }) + observe({ + rv$y <- rv$x + }) + output$txt <- renderText({ + paste0("Value: ", rv$x) + }) + } + + testModule(module, { + session$setInputs(x=1) + expect_equal(rv$y, 2) + expect_equal(rv$x, 2) + expect_equal(output$txt, "Value: 2") + + session$setInputs(x=2) + expect_equal(rv$x, 4) + expect_equal(rv$y, 4) + expect_equal(output$txt, "Value: 4") + }) +}) + +test_that("inputs aren't directly assignable", { + module <- function(input, output, session) { + } + + testModule(module, { + session$setInputs(x = 0) + expect_error({ input$x <- 1 }, "Attempted to assign value to a read-only") + expect_error({ input$y <- 1 }, "Attempted to assign value to a read-only") + }) +}) + +test_that("testModule handles more complex expressions", { + module <- function(input, output, session){ + output$txt <- renderText({ + input$x + }) + } + + testModule(module, { + for (i in 1:5){ + session$setInputs(x=i) + expect_equal(output$txt, as.character(i)) + } + expect_equal(output$txt, "5") + + if(TRUE){ + session$setInputs(x="abc") + expect_equal(output$txt, "abc") + } + }) +}) + +test_that("testModule handles reactiveVal", { + module <- function(input, output, session) { + x <- reactiveVal(0) + observe({ + x(input$y + input$z) + }) + } + + testModule(module, { + session$setInputs(y=1, z=2) + + expect_equal(x(), 3) + + session$setInputs(z=3) + expect_equal(x(), 4) + + session$setInputs(y=5) + expect_equal(x(), 8) + }) +}) + +test_that("testModule handles reactives with complex dependency tree", { + module <- function(input, output, session) { + x <- reactiveValues(x=1) + r <- reactive({ + x$x + input$a + input$b + }) + r2 <- reactive({ + r() + input$c + }) + } + + testModule(module, { + session$setInputs(a=1, b=2, c=3) + expect_equal(r(), 4) + expect_equal(r2(), 7) + + session$setInputs(a=2) + expect_equal(r(), 5) + expect_equal(r2(), 8) + + session$setInputs(b=0) + expect_equal(r2(), 6) + expect_equal(r(), 3) + + session$setInputs(c=4) + expect_equal(r(), 3) + expect_equal(r2(), 7) + }) +}) + +test_that("testModule handles reactivePoll", { + module <- function(input, output, session) { + rv <- reactiveValues(x = 0) + rp <- reactivePoll(50, session, function(){ as.numeric(Sys.time()) }, function(){ + isolate(rv$x <- rv$x + 1) + as.numeric(Sys.time()) + }) + + observe({rp()}) + } + + testModule(module, { + expect_equal(rv$x, 1) + + for (i in 1:4){ + session$elapse(50) + } + + expect_equal(rv$x, 5) + }) +}) + +test_that("testModule handles reactiveTimer", { + module <- function(input, output, session) { + rv <- reactiveValues(x = 0) + + rp <- reactiveTimer(50) + observe({ + rp() + isolate(rv$x <- rv$x + 1) + }) + } + + testModule(module, { + expect_equal(rv$x, 1) + + session$elapse(200) + + expect_equal(rv$x, 5) + }) +}) + +test_that("testModule handles debounce/throttle", { + module <- function(input, output, session) { + rv <- reactiveValues(t = 0, d = 0) + react <- reactive({ + input$y + }) + rt <- throttle(react, 100) + rd <- debounce(react, 100) + + observe({ + rt() # Invalidate this block on the timer + isolate(rv$t <- rv$t + 1) + }) + + observe({ + rd() + isolate(rv$d <- rv$d + 1) + }) + } + + testModule(module, { + session$setInputs(y = TRUE) + expect_equal(rv$d, 1) + for (i in 2:5){ + session$setInputs(y = FALSE) + session$elapse(51) + session$setInputs(y = TRUE) + expect_equal(rv$t, i-1) + session$elapse(51) # TODO: we usually don't have to pad by a ms, but here we do. Investigate. + expect_equal(rv$t, i) + } + # Never sufficient time to debounce. Not incremented + expect_equal(rv$d, 1) + session$elapse(50) + + # Now that 100ms has passed since the last update, debounce should have triggered + expect_equal(rv$d, 2) + }) +}) + +test_that("testModule wraps output in an observer", { + testthat::skip("I'm not sure of a great way to test this without timers.") + # And honestly it's so foundational in what we're doing now that it might not be necessary to test? + + + module <- function(input, output, session) { + rv <- reactiveValues(x=0) + rp <- reactiveTimer(50) + output$txt <- renderText({ + rp() + isolate(rv$x <- rv$x + 1) + }) + } + + testModule(module, { + session$setInputs(x=1) + # Timers only tick if they're being observed. If the output weren't being + # wrapped in an observer, we'd see the value of rv$x initialize to zero and + # only increment when we evaluated the output. e.g.: + # + # expect_equal(rv$x, 0) + # Sys.sleep(1) + # expect_equal(rv$x, 0) + # output$txt() + # expect_equal(rv$x, 1) + + expect_equal(rv$x, 1) + expect_equal(output$txt, "1") + Sys.sleep(.05) + Sys.sleep(.05) + expect_gt(rv$x, 1) + expect_equal(output$txt, as.character(rv$x)) + }) + + # FIXME: + # - Do we want the output to be accessible natively, or some $get() on the output? If we do a get() we could + # do more helpful spy-type things around exec count. + # - plots and such? +}) + +test_that("testModule works with async", { + module <- function(input, output, session) { + output$txt <- renderText({ + val <- input$x + future({ val }) + }) + + output$error <- renderText({ + future({ stop("error here") }) + }) + + output$sync <- renderText({ + # No promises here + "abc" + }) + } + + testModule(module, { + session$setInputs(x=1) + expect_equal(output$txt, "1") + expect_equal(output$sync, "abc") + + # Error gets thrown repeatedly + expect_error(output$error, "error here") + expect_error(output$error, "error here") + + # Responds reactively + session$setInputs(x=2) + expect_equal(output$txt, "2") + # Error still thrown + expect_error(output$error, "error here") + }) +}) + +test_that("testModule works with multiple promises in parallel", { + module <- function(input, output, session) { + output$txt1 <- renderText({ + future({ + Sys.sleep(1) + 1 + }) + }) + + output$txt2 <- renderText({ + future({ + Sys.sleep(1) + 2 + }) + }) + } + + testModule(module, { + # As we enter this test code, the promises will still be running in the background. + # We'll need to give them ~2s (plus overhead) to complete + startMS <- as.numeric(Sys.time()) * 1000 + expect_equal(output$txt1, "1") # This first call will block waiting for the promise to return + expect_equal(output$txt2, "2") + expect_equal(output$txt2, "2") # Now that we have the values, access should not incur a 1s delay. + expect_equal(output$txt1, "1") + expect_equal(output$txt1, "1") + expect_equal(output$txt2, "2") + endMS <- as.numeric(Sys.time()) * 1000 + + # We'll pad quite a bit because promises can introduce some lag. But the point we're trying + # to prove is that we're not hitting a 1s delay for each output access, which = 6000ms. If we're + # under that, then things are likely working. + expect_lt(endMS - startMS, 4000) + }) +}) + +test_that("testModule handles async errors", { + module <- function(input, output, session, arg1, arg2){ + output$err <- renderText({ + future({ "my error"}) %...>% + stop() %...>% + print() # Extra steps after the error + }) + + output$safe <- renderText({ + future({ safeError("my safe error") }) %...>% + stop() + }) + } + + testModule(module, { + expect_error(output$err, "my error") + # TODO: helper for safe errors so users don't have to learn "shiny.custom.error"? + expect_error(output$safe, "my safe error", class="shiny.custom.error") + }) +}) + +test_that("testModule handles modules with additional arguments", { + module <- function(input, output, session, arg1, arg2){ + output$txt1 <- renderText({ + arg1 + }) + + output$txt2 <- renderText({ + arg2 + }) + + output$inp <- renderText({ + input$x + }) + } + + testModule(module, { + expect_equal(output$txt1, "val1") + expect_equal(output$txt2, "val2") + }, arg1="val1", arg2="val2") +}) + +test_that("testModule captures htmlwidgets", { + # TODO: use a simple built-in htmlwidget instead of something complex like dygraph + if (!requireNamespace("dygraphs")){ + testthat::skip("dygraphs not available to test htmlwidgets") + } + + if (!requireNamespace("jsonlite")){ + testthat::skip("jsonlite not available to test htmlwidgets") + } + + module <- function(input, output, session){ + output$dy <- dygraphs::renderDygraph({ + dygraphs::dygraph(data.frame(outcome=0:5, year=2000:2005)) + }) + } + + testModule(module, { + # Really, this test should be specific to each htmlwidget. Here, we don't want to bind ourselves + # to the current JSON structure of dygraphs, so we'll just check one element to see that the raw + # JSON was exposed and is accessible in tests. + d <- jsonlite::fromJSON(output$dy)$x$data + expect_equal(d[1,], 0:5) + expect_equal(d[2,], 2000:2005) + }) +}) + +test_that("testModule captures renderUI", { + module <- function(input, output, session){ + output$ui <- renderUI({ + tags$a(href="https://rstudio.com", "hello!") + }) + } + + testModule(module, { + expect_equal(output$ui$deps, list()) + expect_equal(as.character(output$ui$html), "hello!") + }) +}) + +test_that("testModule captures base graphics outputs", { + module <- function(input, output, session){ + output$fixed <- renderPlot({ + plot(1,1) + }, width=300, height=350) + + output$dynamic <- renderPlot({ + plot(1,1) + }) + } + + testModule(module, { + # We aren't yet able to create reproducible graphics, so this test is intentionally pretty + # limited. + expect_equal(output$fixed$width, 300) + expect_equal(output$fixed$height, 350) + expect_match(output$fixed$src, "^data:image/png;base64,") + + # Ensure that the plot defaults to a reasonable size. + expect_equal(output$dynamic$width, 600) + expect_equal(output$dynamic$height, 400) + expect_match(output$dynamic$src, "^data:image/png;base64,") + + # TODO: how do you customize automatically inferred plot sizes? + # session$setPlotMeta("dynamic", width=600, height=300) ? + }) +}) + +test_that("testModule captures ggplot2 outputs", { + if (!requireNamespace("ggplot2")){ + testthat::skip("ggplot2 not available") + } + + module <- function(input, output, session){ + output$fixed <- renderPlot({ + ggplot2::qplot(iris$Sepal.Length, iris$Sepal.Width) + }, width=300, height=350) + + output$dynamic <- renderPlot({ + ggplot2::qplot(iris$Sepal.Length, iris$Sepal.Width) + }) + } + + testModule(module, { + expect_equal(output$fixed$width, 300) + expect_equal(output$fixed$height, 350) + expect_match(output$fixed$src, "^data:image/png;base64,") + + # Ensure that the plot defaults to a reasonable size. + expect_equal(output$dynamic$width, 600) + expect_equal(output$dynamic$height, 400) + expect_match(output$dynamic$src, "^data:image/png;base64,") + }) +}) + +test_that("testModule exposes the returned value from the module", { + module <- function(input, output, session){ + reactive({ + return(input$a + input$b) + }) + } + + testModule(module, { + session$setInputs(a=1, b=2) + expect_equal(session$returned(), 3) + + # And retains reactivity + session$setInputs(a=2) + expect_equal(session$returned(), 4) + }) +}) + +test_that("testModule handles synchronous errors", { + module <- function(input, output, session, arg1, arg2){ + output$err <- renderText({ + stop("my error") + }) + + output$safe <- renderText({ + stop(safeError("my safe error")) + }) + } + + testModule(module, { + expect_error(output$err, "my error") + # TODO: helper for safe errors so users don't have to learn "shiny.custom.error"? + expect_error(output$safe, "my safe error", class="shiny.custom.error") + }) +}) + +test_that("accessing a non-existant output gives an informative message", { + module <- function(input, output, session){} + + testModule(module, { + expect_error(output$dontexist, "hasn't been defined yet: output\\$dontexist") + }) +}) + +test_that("testServer works", { + # app.R + testServer({ + session$setInputs(dist="norm", n=5) + expect_length(d(), 5) + + session$setInputs(dist="unif", n=6) + expect_length(d(), 6) + }, appDir=test_path("../../inst/examples/06_tabsets")) + + # TODO: test with server.R +}) + +test_that("testServer works when referencing external globals", { + # If global is defined at the top of app.R outside of the server function. + testthat::skip("NYI") +}) + +test_that("testModule handles invalidateLater", { + module <- function(input, output, session) { + rv <- reactiveValues(x = 0) + observe({ + isolate(rv$x <- rv$x + 1) + # We're only testing one invalidation + if (isolate(rv$x) <= 1){ + invalidateLater(50) + } + }) + } + + testModule(module, { + # Should have run once + expect_equal(rv$x, 1) + + session$elapse(49) + expect_equal(rv$x, 1) + + session$elapse(1) + # Should have been incremented now + expect_equal(rv$x, 2) + }) +}) + +test_that("session ended handlers work", { + module <- function(input, output, session){} + + testModule(module, { + rv <- reactiveValues(closed = FALSE) + session$onEnded(function(){ + rv$closed <- TRUE + }) + + expect_equal(session$isEnded(), FALSE) + expect_equal(session$isClosed(), FALSE) + expect_false(rv$closed, FALSE) + + session$close() + + expect_equal(session$isEnded(), TRUE) + expect_equal(session$isClosed(), TRUE) + expect_false(rv$closed, TRUE) + }) +}) + +test_that("session flush handlers work", { + module <- function(input, output, session) { + rv <- reactiveValues(x = 0, flushCounter = 0, flushedCounter = 0, + flushOnceCounter = 0, flushedOnceCounter = 0) + + onFlush(function(){rv$flushCounter <- rv$flushCounter + 1}, once=FALSE) + onFlushed(function(){rv$flushedCounter <- rv$flushedCounter + 1}, once=FALSE) + onFlushed(function(){rv$flushOnceCounter <- rv$flushOnceCounter + 1}, once=TRUE) + onFlushed(function(){rv$flushedOnceCounter <- rv$flushedOnceCounter + 1}, once=TRUE) + + observe({ + rv$x <- input$x * 2 + }) + } + + testModule(module, { + session$setInputs(x=1) + expect_equal(rv$x, 2) + # We're not concerned with the exact values here -- only that they increase + fc <- rv$flushCounter + fdc <- rv$flushedCounter + + session$setInputs(x=2) + expect_gt(rv$flushCounter, fc) + expect_gt(rv$flushedCounter, fdc) + + # These should have only run once + expect_equal(rv$flushOnceCounter, 1) + expect_equal(rv$flushedOnceCounter, 1) + + }) +}) + +test_that("findApp errors with no app", { + calls <- 0 + nothingExists <- function(path){ + calls <<- calls + 1 + FALSE + } + fa <- rewire(findApp, file.exists.ci=nothingExists) + expect_error( + expect_warning(fa("/some/path/here"), "No such file or directory"), # since we just made up a path + "No shiny app was found in ") + expect_equal(calls, 4 * 2) # Checks here, path, some, and / -- looking for app.R and server.R for each +}) + +test_that("findApp works with app in current or parent dir", { + calls <- 0 + cd <- normalizePath(".") + mockExists <- function(path){ + # Only TRUE if looking for server.R or app.R in current Dir + calls <<- calls + 1 + + appPath <- file.path(cd, "app.R") + serverPath <- file.path(cd, "server.R") + return(path %in% c(appPath, serverPath)) + } + fa <- rewire(findApp, file.exists.ci=mockExists) + expect_equal(fa(), cd) + expect_equal(calls, 1) # Should get a hit on the first call and stop + + # Reset and point to the parent dir + calls <- 0 + cd <- normalizePath("../") # TODO: won't work if running tests in the root dir. + expect_equal(fa(), cd) + expect_equal(calls, 3) # Two for current dir and hit on the first in the parent +}) diff --git a/vignettes/integration-testing.Rmd b/vignettes/integration-testing.Rmd new file mode 100644 index 0000000000..3aab82f869 --- /dev/null +++ b/vignettes/integration-testing.Rmd @@ -0,0 +1,286 @@ +--- +title: "Integration Testing in Shiny" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Your Vignette Title} + %\VignetteEncoding{UTF-8} + %\VignetteEngine{knitr::rmarkdown} +editor_options: + chunk_output_type: console +--- + + ```{r setup, include=FALSE} +knitr::opts_chunk$set(echo = TRUE) +``` + +## Introduction to Inspecting Modules + +First, we'll define a simple Shiny module: + +```{r} +library(shiny) +module <- function(input, output, session) { + rv <- reactiveValues(x = 0) + observe({ + rv$x <- input$x * 2 + }) + output$txt <- renderText({ + paste0("Value: ", rv$x) + }) +} +``` + +This module + + - depends on one input (`x`), + - has an intermediate, internal `reactiveValues` (`rv`) which updates reactively, + - and updates an output (`txt`) reactively. + +It would be nice to write tests that confirm that the module behaves the way we expect. We can do so using the `testModule` function. + +```{r} +testModule(module, { + cat("Initially, input$x is NULL, right?", is.null(input$x), "\n") + + # Give input$x a value. + session$setInputs(x = 1) + + cat("Now that x is set to 1, rv$x is: ", rv$x, "\n") + cat("\tand output$txt is: ", output$txt, "\n") + # Now update input$x to a new value + session$setInputs(x = 2) + + cat("After updating x to 2, rv$x is: ", rv$x, "\n") + cat("\tand output$txt is: ", output$txt, "\n") +}) +``` + +There are a few things to notice in this example. + +First, the test expression provided here assumes the existence of some variables -- specifically, `input`, `output`, and `r`. This is safe because the test code provided to `testModule` is run in the module's environment. This means that any parameters passed in to your module (such as `input`, `output`, and `session`) are readily available, as are any intermediate objects or reactives that you define in the module (such as `r`). + +Second, you'll need to give values to any inputs that you want to be defined; by default, they're all `NULL`. We do that using the `session$setInputs()` method. The `session` object used in `testModule` differs from the real `session` object Shiny uses; this allows us to tailor it to be more suitable for testing purposes by modifying or creating new methods such as `setInputs()`. + +Last, you're likely used to assigning to `output`, but here we're reading from `output$txt` in order to check its value. When running inside `testModule`, you can simply reference an output and it will give the value produced by the `render` function. + +## Automated Tests + +Realistically, we don't want to just print the values for manual inspection; we'll want to leverage them in automated tests. That way, we'll be able to build up a collection of tests that we can run against our module in the future to confirm that it always behaves correctly. You can use whatever testing framework you'd like (or none a all!), but we'll use the `expect_*` functions from the testthat package in this example. + +```{r} +# Bring in testthat just for its expectations +suppressWarnings(library(testthat)) +testModule(module, { + session$setInputs(x = 1) + expect_equal(rv$x, 2) + expect_equal(output$txt, "Value: 2") + session$setInputs(x = 2) + expect_equal(rv$x, 4) + expect_equal(output$txt, "Value: 4") +}) +``` + +If there's no error, then we know our tests ran successfully. If there were a bug, we'd see an error printed. For example: + +```{r} +tryCatch({ + testModule(module, { + session$setInputs(x = 1) + + # This expectation will fail + expect_equal(rv$x, 99) + }) +}, error=function(e){ + print("There was an error!") + print(e) +}) +``` + +## Promises + +`testModule` can handle promises inside of render functions. + +```{r} +library(promises) +library(future) +plan(multisession) +module <- function(input, output, session){ + output$async <- renderText({ + # Stash the value since you can't do reactivity inside of a promise. See +# https://rstudio.github.io/promises/articles/shiny.html#shiny-specific-caveats-and-limitations +t <- input$times + +# A promise chain that repeats the letter A and then collapses it into a string. +future({ rep("A", times=t) }) %...>% + paste(collapse="") +}) +} +testModule(module, { + session$setInputs(times = 3) + expect_equal(output$async, "AAA") + + session$setInputs(times = 5) + expect_equal(output$async, "AAAAA") +}) +``` + +As you can see, no special precautions were required for a `render` function that uses promises. Behind-the-scenes, the code in `testModule` will block when trying to read from an `output` that returned a promise. This allows you to interact with the outputs in your tests as if they were synchronous. + +TODO: What about internal reactives that are promise-based? We don't do anything special for them... + +## Modules with additional inputs + +`testModule` can also handle modules that accept additional arguments such as this one. + +```{r} +module <- function(input, output, session, arg1, arg2){ + output$txt1 <- renderText({ arg1 }) + + output$txt2 <- renderText({ arg2 }) +} +``` + +Additional arguments should be passed after the test expression as named parameters. + +```{r} +testModule(module, { + expect_equal(output$txt1, "val1") + expect_equal(output$txt2, "val2") +}, arg1="val1", arg2="val2") +``` + +## Accessing a module's returned value + +Some modules return reactive data as an output. For such modules, it can be helpful to test the returned value, as well. The returned value from the module is made available as a property on the mock `session` object as demonstrated in this example. + +```{r} +module <- function(input, output, session){ + reactive({ + return(input$a + input$b) + }) +} +testModule(module, { + session$setInputs(a = 1, b = 2) + expect_equal(session$returned(), 3) + # And retains reactivity + session$setInputs(a = 2) + expect_equal(session$returned(), 4) +}) +``` + +## Timer and Polling + +Testing behavior that relies on timing is notoriously difficult. Modules will behave differently on different machines and under different conditions. In order to make testing with time more deterministic, `testModule` uses simulated time that you control, rather than the actual computer time. Let's look at what happens when you try to use "real" time in your testing. + +```{r} +module <- function(input, output, session){ + rv <- reactiveValues(x=0) + + observe({ + invalidateLater(100) + isolate(rv$x <- rv$x + 1) + }) +} +testModule(module, { + expect_equal(rv$x, 1) # The observer runs once at initialization + + Sys.sleep(1) # Sleep for a second + + expect_equal(rv$x, 1) # The value hasn't changed +}) +``` + +This behavior may be surprising. It seems like `rv$x` should have been incremented 10 times (or perhaps 9, due to computational overhead). But in truth, it hasn't changed at all. This is because `testModule` doesn't consider the actual time on your computer -- only its simulated understanding of time. + +In order to cause `testModule` to progress through time, instead of `Sys.sleep`, we'll use `session$elapse` -- another method that exists only on our mocked session object. Using the same module object as above... + +```{r} +testModule(module, { + expect_equal(rv$x, 1) # The observer runs once at initialization + + session$elapse(100) # Simulate the passing of 100ms + + expect_equal(rv$x, 2) # The observer was invalidated and the value updated! + + # You can even simulate multiple events in a single elapse + session$elapse(300) + expect_equal(rv$x, 5) +}) +``` + +As you can see, using `session$elapse` caused `testModule` to recognize that (simulted) time had passed which triggered the reactivity as we'd expect. This approach allows you to deterministically control time in your tests while avoiding expensive pauses that would slow down your tests. Using this approach, this test can complete in only a fraction of the 100ms that it simulates. + +## Complex Outputs (plots, htmlwidgets) + +**Work in progress** -- We intend to add more helpers to make it easier to inspect and validate the raw HTML/JSON content. But for now, validating the output is an exercise left to the user. + +Thus far, we've seen how to validate simple outputs like numeric or text values. Real Shiny modules applications often use more complex outputs such as plots or htmlwidgets. Validating the correctness of these is not as simple, but is doable. + +You can access the data for even complex outputs in `testModule`, but the structure of the output may initially be foreign to you. + +```{r} +module <- function(input, output, session){ + output$plot <- renderPlot({ + df <- data.frame(length = iris$Petal.Length, width = iris$Petal.Width) + plot(df) + }) +} +testModule(module, { + print(str(output$plot)) +}) +``` + +As you can see, there are a lot of internal details that go into a plot. Behind-the-scenes, these are all the details that Shiny will use to correctly display a plot in a user's browser. You don't need to learn about all of these properties -- and they're all subject to change. + +In terms of your testing strategy, you shouldn't bother yourself with "is Shiny generating the correct structure so that the plot will generate in the browser?" That's a question that the Shiny package itself needs to answer (and one for which we have our own tests). The goal for your tests should be to ask: "is the code that I wrote producing the plot I want?" There are two components to that question: + + 1. Does the plot generate without producing an error? + 2. Is the plot visually correct? + + `testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a shinytest test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. + +For htmlwidgets, you can adopt a similar strategy. The goal is not to confirm that the htmlwidget's render function is behaving properly -- but rather that the data that you intend to render is indeed getting rendered properly. + +We could modify the above example to better represent this approach. + +```{r} +module <- function(input, output, session){ + # Move any complex logic into a separate reactive which can be tested comprehensively + plotData <- reactive({ + data.frame(length = iris$Petal.Length, width = iris$Petal.Width) + }) + + # And leave the `render` function to be as simple as possible to lessen the need for + # integration tests. + output$plot <- renderPlot({ + plot(plotData()) + }) +} +testModule(module, { + # Confirm that the data reactive is behaving as expected + expect_equal(nrow(plotData()), 150) + expect_equal(ncol(plotData()), 2) + expect_equal(colnames(plotData()), c("length", "width")) + + # And now the plot function is so simple that there's not much need for + # automated testing. If we did wish to evaluate the plot visually, we could + # do so using the shinytest package. + output$plot # Just confirming that the plot can be accessed without an error +}) +``` + +You could adopt a similar strategy with other plots or htmlwidgets: move the complexity into reactives that can be tested, and leave the complex `render` functions as simple as possible. + +## Testing Shiny Applications + +In addition to testing Shiny modules, you can also test Shiny applications. The `testServer` function will automatically extract the server portion given an application's directory and you can test it just like you do any other module. + +```{r} +appdir <- system.file("examples/06_tabsets", package="shiny") +testServer({ + session$setInputs(dist="norm", n=10) + expect_equal(length(d()), 10) +}, appdir) +``` + +As you can see, the test expression can be run for Shiny servers just like it was run for modules. From 42f6adb7fa855964bf944a46e4096b2f29400cb5 Mon Sep 17 00:00:00 2001 From: trestletech Date: Thu, 24 Oct 2019 10:20:54 -0500 Subject: [PATCH 02/12] Handle Joe's feedback. --- R/test-module.R | 2 +- vignettes/integration-testing.Rmd | 37 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/R/test-module.R b/R/test-module.R index 0601c5fae4..4336a5bb60 100644 --- a/R/test-module.R +++ b/R/test-module.R @@ -59,7 +59,7 @@ testModule <- function(module, expr, args, ...) { withReactiveDomain( session, withr::with_options(list(`shiny.allowoutputreads`=TRUE), { - eval(expr, session$env) + eval(expr, new.env(parent=session$env)) }) ) }) diff --git a/vignettes/integration-testing.Rmd b/vignettes/integration-testing.Rmd index 3aab82f869..d865ffe810 100644 --- a/vignettes/integration-testing.Rmd +++ b/vignettes/integration-testing.Rmd @@ -57,7 +57,7 @@ testModule(module, { There are a few things to notice in this example. -First, the test expression provided here assumes the existence of some variables -- specifically, `input`, `output`, and `r`. This is safe because the test code provided to `testModule` is run in the module's environment. This means that any parameters passed in to your module (such as `input`, `output`, and `session`) are readily available, as are any intermediate objects or reactives that you define in the module (such as `r`). +First, the test expression provided here assumes the existence of some variables -- specifically, `input`, `output`, and `r`. This is safe because the test code provided to `testModule` is run in a child of the module's environment. This means that any parameters passed in to your module (such as `input`, `output`, and `session`) are readily available, as are any intermediate objects or reactives that you define in the module (such as `r`). However, because it's a child environment, your test code is less likely to accidentally modify anything in the module itself. Second, you'll need to give values to any inputs that you want to be defined; by default, they're all `NULL`. We do that using the `session$setInputs()` method. The `session` object used in `testModule` differs from the real `session` object Shiny uses; this allows us to tailor it to be more suitable for testing purposes by modifying or creating new methods such as `setInputs()`. @@ -237,7 +237,7 @@ In terms of your testing strategy, you shouldn't bother yourself with "is Shiny 1. Does the plot generate without producing an error? 2. Is the plot visually correct? - `testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a shinytest test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. +`testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a shinytest test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. For htmlwidgets, you can adopt a similar strategy. The goal is not to confirm that the htmlwidget's render function is behaving properly -- but rather that the data that you intend to render is indeed getting rendered properly. @@ -284,3 +284,36 @@ testServer({ ``` As you can see, the test expression can be run for Shiny servers just like it was run for modules. + +## Flushing Reactives + +Reactivity differs from imperative programming in that the processing required to update reactives can be deferred and batched together. While this is a boon for the computational speed of a reactive system, it does create some ambiguity about *when* the reactives should be processed or "flushed". + +`testModule` will do its best to automatically "flush" the reactives at the right time. There are two triggers that will cause a reactive flush: + +1. Calling `session$setInputs()` - After setting the updated inputs, the reactives will be flushed. +2. Calling `session$elapse()` - After the scheduled callbacks are executed, reactives will be flushed. + +However, there may be other times that a Shiny module author might want to trigger a reactive flush. For instance, you might want to flush the reactives after updating an element in a `reactiveValues` in your module like this one. + +```{r} +module <- function(input, output, session){ + rv <- reactiveValues(a=1) + output$txt <- renderText({ + rv$a + }) +} + +testModule(module, { + expect_equal(output$txt, "1") + + rv$a <- 2 + # testModule has no innate knowledge of our `rv` variable so we'll need to manually + # force a flush of the reactives. + session$flushReact() + + expect_equal(output$txt, "2") +}) +``` + +As you can see, we can use `session$flushReact()` to trigger a reactive flush at any point we'd like. In this example, `testModule` knows nothing about our `rv` variable. Therefore if we want to observe reactive changes that occur after manually updating this variable, we'd need to explicitly flush the reactives. From f47b151458cd4b8766b4ca4ed32afe85f74dff23 Mon Sep 17 00:00:00 2001 From: trestletech Date: Thu, 24 Oct 2019 10:24:09 -0500 Subject: [PATCH 03/12] Test improvements for Windows and make CHECK pass. --- .gitignore | 1 + DESCRIPTION | 8 ++- NAMESPACE | 1 + R/test-module.R | 7 +-- man/testModule.Rd | 3 - man/testServer.Rd | 2 +- tests/test-modules/06_tabsets/app.R | 92 ++++++++++++++++++++++++++++ tests/test-modules/server_r/server.R | 44 +++++++++++++ tests/test-modules/server_r/ui.R | 46 ++++++++++++++ tests/testthat/test-test-module.R | 26 +++++--- 10 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 tests/test-modules/06_tabsets/app.R create mode 100644 tests/test-modules/server_r/server.R create mode 100644 tests/test-modules/server_r/ui.R diff --git a/.gitignore b/.gitignore index 2ebe2acaa0..0ae34fa080 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ shinyapps/ README.html .*.Rnb.cached tools/yarn-error.log +vignettes/*.R diff --git a/DESCRIPTION b/DESCRIPTION index 119f346332..f4d1b00952 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -78,7 +78,8 @@ Imports: tools, crayon, rlang (>= 0.4.0), - fastmap (>= 1.0.0) + fastmap (>= 1.0.0), + withr Suggests: datasets, Cairo (>= 1.5-5), @@ -89,7 +90,9 @@ Suggests: ggplot2, reactlog (>= 1.0.0), magrittr, - yaml + yaml, + future, + dygraphs URL: http://shiny.rstudio.com BugReports: https://github.com/rstudio/shiny/issues Collate: @@ -169,3 +172,4 @@ Collate: RoxygenNote: 6.1.1 Encoding: UTF-8 Roxygen: list(markdown = TRUE) +VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index edfe71714c..57b50ddd56 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -313,3 +313,4 @@ importFrom(grDevices,dev.cur) importFrom(grDevices,dev.set) importFrom(promises,"%...!%") importFrom(promises,"%...>%") +importFrom(withr,with_options) diff --git a/R/test-module.R b/R/test-module.R index 4336a5bb60..6c587f4126 100644 --- a/R/test-module.R +++ b/R/test-module.R @@ -8,8 +8,6 @@ #' values created inside of the module. #' @param args A list of arguments to pass into the module beyond `input`, #' `output`, and `session`. -#' @param initialState A list describing the initial values for `input`. If no -#' initial state is given, `input` will initialize as an empty list. #' @param ... Additional named arguments to be passed on to the module function. #' @include mock-session.R #' @export @@ -18,6 +16,8 @@ testModule <- function(module, expr, args, ...) { .testModule(module, expr, args, ...) } +#' @noRd +#' @importFrom withr with_options .testModule <- function(module, expr, args, ...) { # Capture the environment from the module # Inserts `session$env <- environment()` at the top of the function @@ -71,7 +71,7 @@ testModule <- function(module, expr, args, ...) { #' Test an app's server-side logic #' @param expr Test code containing expectations -#' @param appdir The directory root of the Shiny application. If `NULL`, this function +#' @param appDir The directory root of the Shiny application. If `NULL`, this function #' will work up the directory hierarchy --- starting with the current directory --- #' looking for a directory that contains an `app.R` or `server.R` file. #' @export @@ -90,7 +90,6 @@ testServer <- function(expr, appDir=NULL) { formals(server) <- fn_formals } - s3 <<- server # Now test the server as we would a module .testModule(server, expr=substitute(expr)) } diff --git a/man/testModule.Rd b/man/testModule.Rd index 1df5a56b0f..af548f3dba 100644 --- a/man/testModule.Rd +++ b/man/testModule.Rd @@ -18,9 +18,6 @@ values created inside of the module.} \code{output}, and \code{session}.} \item{...}{Additional named arguments to be passed on to the module function.} - -\item{initialState}{A list describing the initial values for \code{input}. If no -initial state is given, \code{input} will initialize as an empty list.} } \description{ Test a shiny module diff --git a/man/testServer.Rd b/man/testServer.Rd index 65d3660f72..05df862dd2 100644 --- a/man/testServer.Rd +++ b/man/testServer.Rd @@ -9,7 +9,7 @@ testServer(expr, appDir = NULL) \arguments{ \item{expr}{Test code containing expectations} -\item{appdir}{The directory root of the Shiny application. If \code{NULL}, this function +\item{appDir}{The directory root of the Shiny application. If \code{NULL}, this function will work up the directory hierarchy --- starting with the current directory --- looking for a directory that contains an \code{app.R} or \code{server.R} file.} } diff --git a/tests/test-modules/06_tabsets/app.R b/tests/test-modules/06_tabsets/app.R new file mode 100644 index 0000000000..e1b89d6644 --- /dev/null +++ b/tests/test-modules/06_tabsets/app.R @@ -0,0 +1,92 @@ +library(shiny) + +# Define UI for random distribution app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Tabsets"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Select the random distribution type ---- + radioButtons("dist", "Distribution type:", + c("Normal" = "norm", + "Uniform" = "unif", + "Log-normal" = "lnorm", + "Exponential" = "exp")), + + # br() element to introduce extra vertical spacing ---- + br(), + + # Input: Slider for the number of observations to generate ---- + sliderInput("n", + "Number of observations:", + value = 500, + min = 1, + max = 1000) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Tabset w/ plot, summary, and table ---- + tabsetPanel(type = "tabs", + tabPanel("Plot", plotOutput("plot")), + tabPanel("Summary", verbatimTextOutput("summary")), + tabPanel("Table", tableOutput("table")) + ) + + ) + ) +) + +# Define server logic for random distribution app ---- +server <- function(input, output) { + + # Reactive expression to generate the requested distribution ---- + # This is called whenever the inputs change. The output functions + # defined below then use the value computed from this expression + d <- reactive({ + dist <- switch(input$dist, + norm = rnorm, + unif = runif, + lnorm = rlnorm, + exp = rexp, + rnorm) + + dist(input$n) + }) + + # Generate a plot of the data ---- + # Also uses the inputs to build the plot label. Note that the + # dependencies on the inputs and the data reactive expression are + # both tracked, and all expressions are called in the sequence + # implied by the dependency graph. + output$plot <- renderPlot({ + dist <- input$dist + n <- input$n + + hist(d(), + main = paste("r", dist, "(", n, ")", sep = ""), + col = "#75AADB", border = "white") + }) + + # Generate a summary of the data ---- + output$summary <- renderPrint({ + summary(d()) + }) + + # Generate an HTML table view of the data ---- + output$table <- renderTable({ + d() + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/tests/test-modules/server_r/server.R b/tests/test-modules/server_r/server.R new file mode 100644 index 0000000000..9ec0e5dd22 --- /dev/null +++ b/tests/test-modules/server_r/server.R @@ -0,0 +1,44 @@ +library(shiny) + +# Define server logic for random distribution app ---- +function(input, output) { + + # Reactive expression to generate the requested distribution ---- + # This is called whenever the inputs change. The output functions + # defined below then use the value computed from this expression + d <- reactive({ + dist <- switch(input$dist, + norm = rnorm, + unif = runif, + lnorm = rlnorm, + exp = rexp, + rnorm) + + dist(input$n) + }) + + # Generate a plot of the data ---- + # Also uses the inputs to build the plot label. Note that the + # dependencies on the inputs and the data reactive expression are + # both tracked, and all expressions are called in the sequence + # implied by the dependency graph. + output$plot <- renderPlot({ + dist <- input$dist + n <- input$n + + hist(d(), + main = paste("r", dist, "(", n, ")", sep = ""), + col = "#75AADB", border = "white") + }) + + # Generate a summary of the data ---- + output$summary <- renderPrint({ + summary(d()) + }) + + # Generate an HTML table view of the data ---- + output$table <- renderTable({ + d() + }) + +} diff --git a/tests/test-modules/server_r/ui.R b/tests/test-modules/server_r/ui.R new file mode 100644 index 0000000000..d447d0d46a --- /dev/null +++ b/tests/test-modules/server_r/ui.R @@ -0,0 +1,46 @@ +library(shiny) + +# Define UI for random distribution app ---- +fluidPage( + + # App title ---- + titlePanel("Tabsets"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Select the random distribution type ---- + radioButtons("dist", "Distribution type:", + c("Normal" = "norm", + "Uniform" = "unif", + "Log-normal" = "lnorm", + "Exponential" = "exp")), + + # br() element to introduce extra vertical spacing ---- + br(), + + # Input: Slider for the number of observations to generate ---- + sliderInput("n", + "Number of observations:", + value = 500, + min = 1, + max = 1000) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Tabset w/ plot, summary, and table ---- + tabsetPanel(type = "tabs", + tabPanel("Plot", plotOutput("plot")), + tabPanel("Summary", verbatimTextOutput("summary")), + tabPanel("Table", tableOutput("table")) + ) + + ) + ) +) diff --git a/tests/testthat/test-test-module.R b/tests/testthat/test-test-module.R index 79a7de5f1d..11f1ee82e7 100644 --- a/tests/testthat/test-test-module.R +++ b/tests/testthat/test-test-module.R @@ -117,9 +117,9 @@ test_that("testModule handles reactives with complex dependency tree", { test_that("testModule handles reactivePoll", { module <- function(input, output, session) { rv <- reactiveValues(x = 0) - rp <- reactivePoll(50, session, function(){ as.numeric(Sys.time()) }, function(){ + rp <- reactivePoll(50, session, function(){ rnorm(1) }, function(){ isolate(rv$x <- rv$x + 1) - as.numeric(Sys.time()) + rnorm(1) }) observe({rp()}) @@ -493,9 +493,16 @@ test_that("testServer works", { session$setInputs(dist="unif", n=6) expect_length(d(), 6) - }, appDir=test_path("../../inst/examples/06_tabsets")) + }, appDir=test_path("..", "test-modules", "06_tabsets")) - # TODO: test with server.R + # server.R + testServer({ + session$setInputs(dist="norm", n=5) + expect_length(d(), 5) + + session$setInputs(dist="unif", n=6) + expect_length(d(), 6) + }, appDir=test_path("..", "test-modules", "server_r")) }) test_that("testServer works when referencing external globals", { @@ -602,8 +609,10 @@ test_that("findApp works with app in current or parent dir", { # Only TRUE if looking for server.R or app.R in current Dir calls <<- calls + 1 - appPath <- file.path(cd, "app.R") - serverPath <- file.path(cd, "server.R") + path <- normalizePath(path) + + appPath <- normalizePath(file.path(cd, "app.R")) + serverPath <- normalizePath(file.path(cd, "server.R")) return(path %in% c(appPath, serverPath)) } fa <- rewire(findApp, file.exists.ci=mockExists) @@ -612,7 +621,8 @@ test_that("findApp works with app in current or parent dir", { # Reset and point to the parent dir calls <- 0 - cd <- normalizePath("../") # TODO: won't work if running tests in the root dir. - expect_equal(fa(), cd) + cd <- normalizePath("..") # TODO: won't work if running tests in the root dir. + f <- fa() + expect_equal(normalizePath(f), cd) expect_equal(calls, 3) # Two for current dir and hit on the first in the parent }) From 5105ecb14810d3e5d3b0a57364fe5a00489f1737 Mon Sep 17 00:00:00 2001 From: trestletech Date: Thu, 24 Oct 2019 14:46:54 -0500 Subject: [PATCH 04/12] Cleaning up the vignette --- vignettes/integration-testing.Rmd | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/vignettes/integration-testing.Rmd b/vignettes/integration-testing.Rmd index d865ffe810..dd82d3026a 100644 --- a/vignettes/integration-testing.Rmd +++ b/vignettes/integration-testing.Rmd @@ -65,7 +65,7 @@ Last, you're likely used to assigning to `output`, but here we're reading from ` ## Automated Tests -Realistically, we don't want to just print the values for manual inspection; we'll want to leverage them in automated tests. That way, we'll be able to build up a collection of tests that we can run against our module in the future to confirm that it always behaves correctly. You can use whatever testing framework you'd like (or none a all!), but we'll use the `expect_*` functions from the testthat package in this example. +Realistically, we don't want to just print the values for manual inspection; we'll want to leverage them in automated tests. That way, we'll be able to build up a collection of tests that we can run against our module in the future to confirm that it always behaves correctly. You can use whatever testing framework you'd like (or none at all!), but we'll use the `expect_*` functions from the testthat package in this example. ```{r} # Bring in testthat just for its expectations @@ -214,7 +214,7 @@ As you can see, using `session$elapse` caused `testModule` to recognize that (si **Work in progress** -- We intend to add more helpers to make it easier to inspect and validate the raw HTML/JSON content. But for now, validating the output is an exercise left to the user. -Thus far, we've seen how to validate simple outputs like numeric or text values. Real Shiny modules applications often use more complex outputs such as plots or htmlwidgets. Validating the correctness of these is not as simple, but is doable. +Thus far, we've seen how to validate simple outputs like numeric or text values. Real Shiny modules and applications often use more complex outputs such as plots or htmlwidgets. Validating the correctness of these is not as simple, but is doable. You can access the data for even complex outputs in `testModule`, but the structure of the output may initially be foreign to you. @@ -232,14 +232,14 @@ testModule(module, { As you can see, there are a lot of internal details that go into a plot. Behind-the-scenes, these are all the details that Shiny will use to correctly display a plot in a user's browser. You don't need to learn about all of these properties -- and they're all subject to change. -In terms of your testing strategy, you shouldn't bother yourself with "is Shiny generating the correct structure so that the plot will generate in the browser?" That's a question that the Shiny package itself needs to answer (and one for which we have our own tests). The goal for your tests should be to ask: "is the code that I wrote producing the plot I want?" There are two components to that question: +In terms of your testing strategy, you shouldn't bother yourself with "is Shiny generating the correct structure so that the plot will render in the browser?" That's a question that the Shiny package itself needs to answer (and one for which we have our own tests). The goal for your tests should be to ask: "is the code that I wrote producing the plot I want?" There are two components to that question: 1. Does the plot generate without producing an error? 2. Is the plot visually correct? -`testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a shinytest test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. +`testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a [shinytest](https://rstudio.github.io/shinytest/articles/shinytest.html) test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. -For htmlwidgets, you can adopt a similar strategy. The goal is not to confirm that the htmlwidget's render function is behaving properly -- but rather that the data that you intend to render is indeed getting rendered properly. +For htmlwidgets, you can adopt a similar strategy. The goal is not to confirm that the htmlwidget's render function is behaving properly -- but rather that the data that you intend to render is indeed getting passed through. We could modify the above example to better represent this approach. @@ -269,7 +269,7 @@ testModule(module, { }) ``` -You could adopt a similar strategy with other plots or htmlwidgets: move the complexity into reactives that can be tested, and leave the complex `render` functions as simple as possible. +You could adopt a similar strategy with other plots or htmlwidgets: move the complexity into reactives that can be tested, and leave the `render` functions as simple as possible. ## Testing Shiny Applications @@ -308,8 +308,12 @@ testModule(module, { expect_equal(output$txt, "1") rv$a <- 2 - # testModule has no innate knowledge of our `rv` variable so we'll need to manually - # force a flush of the reactives. + + # testModule has no innate knowledge of our `rv` variable; therefore, it + # hasn't been updated + expect_equal(output$txt, "1") + + # We'll need to manually force a flush of the reactives. session$flushReact() expect_equal(output$txt, "2") From 799c5ac662088aa20ceae09ece022364fde8ca70 Mon Sep 17 00:00:00 2001 From: trestletech Date: Fri, 25 Oct 2019 16:20:10 -0500 Subject: [PATCH 05/12] Clean up test warnings --- tests/testthat/test-test-module.R | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/testthat/test-test-module.R b/tests/testthat/test-test-module.R index 11f1ee82e7..83a6f2d949 100644 --- a/tests/testthat/test-test-module.R +++ b/tests/testthat/test-test-module.R @@ -609,10 +609,10 @@ test_that("findApp works with app in current or parent dir", { # Only TRUE if looking for server.R or app.R in current Dir calls <<- calls + 1 - path <- normalizePath(path) + path <- normalizePath(path, mustWork = FALSE) - appPath <- normalizePath(file.path(cd, "app.R")) - serverPath <- normalizePath(file.path(cd, "server.R")) + appPath <- normalizePath(file.path(cd, "app.R"), mustWork = FALSE) + serverPath <- normalizePath(file.path(cd, "server.R"), mustWork = FALSE) return(path %in% c(appPath, serverPath)) } fa <- rewire(findApp, file.exists.ci=mockExists) @@ -623,6 +623,6 @@ test_that("findApp works with app in current or parent dir", { calls <- 0 cd <- normalizePath("..") # TODO: won't work if running tests in the root dir. f <- fa() - expect_equal(normalizePath(f), cd) + expect_equal(normalizePath(f, mustWork = FALSE), cd) expect_equal(calls, 3) # Two for current dir and hit on the first in the parent }) From 5a74e369ceddbb2b7d628d242755efd738402e20 Mon Sep 17 00:00:00 2001 From: trestletech Date: Fri, 25 Oct 2019 16:23:16 -0500 Subject: [PATCH 06/12] Implement missing test. --- tests/test-modules/06_tabsets/app.R | 2 ++ tests/testthat/test-test-module.R | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test-modules/06_tabsets/app.R b/tests/test-modules/06_tabsets/app.R index e1b89d6644..2b3ff527b0 100644 --- a/tests/test-modules/06_tabsets/app.R +++ b/tests/test-modules/06_tabsets/app.R @@ -1,5 +1,7 @@ library(shiny) +global <- 123 + # Define UI for random distribution app ---- ui <- fluidPage( diff --git a/tests/testthat/test-test-module.R b/tests/testthat/test-test-module.R index 83a6f2d949..c42bc06ced 100644 --- a/tests/testthat/test-test-module.R +++ b/tests/testthat/test-test-module.R @@ -507,7 +507,9 @@ test_that("testServer works", { test_that("testServer works when referencing external globals", { # If global is defined at the top of app.R outside of the server function. - testthat::skip("NYI") + testServer({ + expect_equal(global, 123) + }, appDir=test_path("..", "test-modules", "06_tabsets")) }) test_that("testModule handles invalidateLater", { From 0776f71ca39a52550cc30c5937cb5d93bc964d9e Mon Sep 17 00:00:00 2001 From: trestletech Date: Fri, 25 Oct 2019 16:27:25 -0500 Subject: [PATCH 07/12] Export session --- NAMESPACE | 1 + R/mock-session.R | 1 + 2 files changed, 2 insertions(+) diff --git a/NAMESPACE b/NAMESPACE index 57b50ddd56..8908145a9a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export("conditionStackTrace<-") export(..stacktraceoff..) export(..stacktraceon..) export(HTML) +export(MockShinySession) export(NS) export(Progress) export(a) diff --git a/R/mock-session.R b/R/mock-session.R index 0c4c6966c2..11f9fe7b7a 100644 --- a/R/mock-session.R +++ b/R/mock-session.R @@ -70,6 +70,7 @@ extract <- function(promise) { } #' @include timer.R +#' @export MockShinySession <- R6Class( 'MockShinySession', portable = FALSE, From 0cad13b3a3714f0ac1dba6984384bcabfd644880 Mon Sep 17 00:00:00 2001 From: trestletech Date: Fri, 25 Oct 2019 16:47:10 -0500 Subject: [PATCH 08/12] Placeholder docs for MockShinySession (More to come in subsequent PR) --- R/mock-session.R | 1 + man/MockShinySession.Rd | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 man/MockShinySession.Rd diff --git a/R/mock-session.R b/R/mock-session.R index 11f9fe7b7a..fb640284c9 100644 --- a/R/mock-session.R +++ b/R/mock-session.R @@ -69,6 +69,7 @@ extract <- function(promise) { stop("Single-bracket indexing of mockclientdata is not allowed.") } +#' Mock Shiny Session #' @include timer.R #' @export MockShinySession <- R6Class( diff --git a/man/MockShinySession.Rd b/man/MockShinySession.Rd new file mode 100644 index 0000000000..9494b79c08 --- /dev/null +++ b/man/MockShinySession.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mock-session.R +\docType{data} +\name{MockShinySession} +\alias{MockShinySession} +\title{Mock Shiny Session} +\format{An object of class \code{R6ClassGenerator} of length 24.} +\usage{ +MockShinySession +} +\description{ +Mock Shiny Session +} +\keyword{datasets} From 0e34221cacafa50c978a3ab740b739212a1ad6ea Mon Sep 17 00:00:00 2001 From: trestletech Date: Fri, 25 Oct 2019 16:54:10 -0500 Subject: [PATCH 09/12] How do I still get paid to do this? --- inst/_pkgdown.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/inst/_pkgdown.yml b/inst/_pkgdown.yml index 3c9c58d742..4586088a4c 100644 --- a/inst/_pkgdown.yml +++ b/inst/_pkgdown.yml @@ -218,3 +218,4 @@ reference: contents: - testModule - testServer + - MockShinySession From 959dc7ffd4c9e12a1e1623b4ec85a83455b5f395 Mon Sep 17 00:00:00 2001 From: trestletech Date: Mon, 28 Oct 2019 22:57:30 -0400 Subject: [PATCH 10/12] PR feedback --- R/test-module.R | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/R/test-module.R b/R/test-module.R index 6c587f4126..24b3ba4b80 100644 --- a/R/test-module.R +++ b/R/test-module.R @@ -1,7 +1,7 @@ #' Test a shiny module -#' @param module The module under test +#' @param module The module to test #' @param expr Test code containing expectations. The test expression will run #' in the module's environment, meaning that the module's parameters (e.g. #' `input`, `output`, and `session`) will be available along with any other @@ -26,19 +26,11 @@ testModule <- function(module, expr, args, ...) { fn_body[[2]] <- quote(session$env <- environment()) body(module) <- fn_body - # Substitute expr for later evaluation - if (!is.call(expr)){ - expr <- substitute(expr) - } - # Create a mock session session <- MockShinySession$new() # Parse the additional arguments - args <- list(...) - args[["input"]] <- session$input - args[["output"]] <- session$output - args[["session"]] <- session + args <- list(..., input = session$input, output = session$output, session = session) # Initialize the module isolate( From 1f4a3c4fd2172a1a78961197183cf02d7b9a2255 Mon Sep 17 00:00:00 2001 From: trestletech Date: Mon, 28 Oct 2019 23:14:25 -0400 Subject: [PATCH 11/12] Regenerate docs --- man/testModule.Rd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/testModule.Rd b/man/testModule.Rd index af548f3dba..9963c1993c 100644 --- a/man/testModule.Rd +++ b/man/testModule.Rd @@ -7,7 +7,7 @@ testModule(module, expr, args, ...) } \arguments{ -\item{module}{The module under test} +\item{module}{The module to test} \item{expr}{Test code containing expectations. The test expression will run in the module's environment, meaning that the module's parameters (e.g. From 5fbaa26d05902e13e9f29fccd718e65cbcbb5878 Mon Sep 17 00:00:00 2001 From: trestletech Date: Wed, 30 Oct 2019 11:29:58 -0400 Subject: [PATCH 12/12] Remove vignette. --- .gitignore | 1 - DESCRIPTION | 1 - vignettes/integration-testing.Rmd | 323 ------------------------------ 3 files changed, 325 deletions(-) delete mode 100644 vignettes/integration-testing.Rmd diff --git a/.gitignore b/.gitignore index 0ae34fa080..2ebe2acaa0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ shinyapps/ README.html .*.Rnb.cached tools/yarn-error.log -vignettes/*.R diff --git a/DESCRIPTION b/DESCRIPTION index f4d1b00952..007a714bd8 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -172,4 +172,3 @@ Collate: RoxygenNote: 6.1.1 Encoding: UTF-8 Roxygen: list(markdown = TRUE) -VignetteBuilder: knitr diff --git a/vignettes/integration-testing.Rmd b/vignettes/integration-testing.Rmd deleted file mode 100644 index dd82d3026a..0000000000 --- a/vignettes/integration-testing.Rmd +++ /dev/null @@ -1,323 +0,0 @@ ---- -title: "Integration Testing in Shiny" -output: rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Your Vignette Title} - %\VignetteEncoding{UTF-8} - %\VignetteEngine{knitr::rmarkdown} -editor_options: - chunk_output_type: console ---- - - ```{r setup, include=FALSE} -knitr::opts_chunk$set(echo = TRUE) -``` - -## Introduction to Inspecting Modules - -First, we'll define a simple Shiny module: - -```{r} -library(shiny) -module <- function(input, output, session) { - rv <- reactiveValues(x = 0) - observe({ - rv$x <- input$x * 2 - }) - output$txt <- renderText({ - paste0("Value: ", rv$x) - }) -} -``` - -This module - - - depends on one input (`x`), - - has an intermediate, internal `reactiveValues` (`rv`) which updates reactively, - - and updates an output (`txt`) reactively. - -It would be nice to write tests that confirm that the module behaves the way we expect. We can do so using the `testModule` function. - -```{r} -testModule(module, { - cat("Initially, input$x is NULL, right?", is.null(input$x), "\n") - - # Give input$x a value. - session$setInputs(x = 1) - - cat("Now that x is set to 1, rv$x is: ", rv$x, "\n") - cat("\tand output$txt is: ", output$txt, "\n") - # Now update input$x to a new value - session$setInputs(x = 2) - - cat("After updating x to 2, rv$x is: ", rv$x, "\n") - cat("\tand output$txt is: ", output$txt, "\n") -}) -``` - -There are a few things to notice in this example. - -First, the test expression provided here assumes the existence of some variables -- specifically, `input`, `output`, and `r`. This is safe because the test code provided to `testModule` is run in a child of the module's environment. This means that any parameters passed in to your module (such as `input`, `output`, and `session`) are readily available, as are any intermediate objects or reactives that you define in the module (such as `r`). However, because it's a child environment, your test code is less likely to accidentally modify anything in the module itself. - -Second, you'll need to give values to any inputs that you want to be defined; by default, they're all `NULL`. We do that using the `session$setInputs()` method. The `session` object used in `testModule` differs from the real `session` object Shiny uses; this allows us to tailor it to be more suitable for testing purposes by modifying or creating new methods such as `setInputs()`. - -Last, you're likely used to assigning to `output`, but here we're reading from `output$txt` in order to check its value. When running inside `testModule`, you can simply reference an output and it will give the value produced by the `render` function. - -## Automated Tests - -Realistically, we don't want to just print the values for manual inspection; we'll want to leverage them in automated tests. That way, we'll be able to build up a collection of tests that we can run against our module in the future to confirm that it always behaves correctly. You can use whatever testing framework you'd like (or none at all!), but we'll use the `expect_*` functions from the testthat package in this example. - -```{r} -# Bring in testthat just for its expectations -suppressWarnings(library(testthat)) -testModule(module, { - session$setInputs(x = 1) - expect_equal(rv$x, 2) - expect_equal(output$txt, "Value: 2") - session$setInputs(x = 2) - expect_equal(rv$x, 4) - expect_equal(output$txt, "Value: 4") -}) -``` - -If there's no error, then we know our tests ran successfully. If there were a bug, we'd see an error printed. For example: - -```{r} -tryCatch({ - testModule(module, { - session$setInputs(x = 1) - - # This expectation will fail - expect_equal(rv$x, 99) - }) -}, error=function(e){ - print("There was an error!") - print(e) -}) -``` - -## Promises - -`testModule` can handle promises inside of render functions. - -```{r} -library(promises) -library(future) -plan(multisession) -module <- function(input, output, session){ - output$async <- renderText({ - # Stash the value since you can't do reactivity inside of a promise. See -# https://rstudio.github.io/promises/articles/shiny.html#shiny-specific-caveats-and-limitations -t <- input$times - -# A promise chain that repeats the letter A and then collapses it into a string. -future({ rep("A", times=t) }) %...>% - paste(collapse="") -}) -} -testModule(module, { - session$setInputs(times = 3) - expect_equal(output$async, "AAA") - - session$setInputs(times = 5) - expect_equal(output$async, "AAAAA") -}) -``` - -As you can see, no special precautions were required for a `render` function that uses promises. Behind-the-scenes, the code in `testModule` will block when trying to read from an `output` that returned a promise. This allows you to interact with the outputs in your tests as if they were synchronous. - -TODO: What about internal reactives that are promise-based? We don't do anything special for them... - -## Modules with additional inputs - -`testModule` can also handle modules that accept additional arguments such as this one. - -```{r} -module <- function(input, output, session, arg1, arg2){ - output$txt1 <- renderText({ arg1 }) - - output$txt2 <- renderText({ arg2 }) -} -``` - -Additional arguments should be passed after the test expression as named parameters. - -```{r} -testModule(module, { - expect_equal(output$txt1, "val1") - expect_equal(output$txt2, "val2") -}, arg1="val1", arg2="val2") -``` - -## Accessing a module's returned value - -Some modules return reactive data as an output. For such modules, it can be helpful to test the returned value, as well. The returned value from the module is made available as a property on the mock `session` object as demonstrated in this example. - -```{r} -module <- function(input, output, session){ - reactive({ - return(input$a + input$b) - }) -} -testModule(module, { - session$setInputs(a = 1, b = 2) - expect_equal(session$returned(), 3) - # And retains reactivity - session$setInputs(a = 2) - expect_equal(session$returned(), 4) -}) -``` - -## Timer and Polling - -Testing behavior that relies on timing is notoriously difficult. Modules will behave differently on different machines and under different conditions. In order to make testing with time more deterministic, `testModule` uses simulated time that you control, rather than the actual computer time. Let's look at what happens when you try to use "real" time in your testing. - -```{r} -module <- function(input, output, session){ - rv <- reactiveValues(x=0) - - observe({ - invalidateLater(100) - isolate(rv$x <- rv$x + 1) - }) -} -testModule(module, { - expect_equal(rv$x, 1) # The observer runs once at initialization - - Sys.sleep(1) # Sleep for a second - - expect_equal(rv$x, 1) # The value hasn't changed -}) -``` - -This behavior may be surprising. It seems like `rv$x` should have been incremented 10 times (or perhaps 9, due to computational overhead). But in truth, it hasn't changed at all. This is because `testModule` doesn't consider the actual time on your computer -- only its simulated understanding of time. - -In order to cause `testModule` to progress through time, instead of `Sys.sleep`, we'll use `session$elapse` -- another method that exists only on our mocked session object. Using the same module object as above... - -```{r} -testModule(module, { - expect_equal(rv$x, 1) # The observer runs once at initialization - - session$elapse(100) # Simulate the passing of 100ms - - expect_equal(rv$x, 2) # The observer was invalidated and the value updated! - - # You can even simulate multiple events in a single elapse - session$elapse(300) - expect_equal(rv$x, 5) -}) -``` - -As you can see, using `session$elapse` caused `testModule` to recognize that (simulted) time had passed which triggered the reactivity as we'd expect. This approach allows you to deterministically control time in your tests while avoiding expensive pauses that would slow down your tests. Using this approach, this test can complete in only a fraction of the 100ms that it simulates. - -## Complex Outputs (plots, htmlwidgets) - -**Work in progress** -- We intend to add more helpers to make it easier to inspect and validate the raw HTML/JSON content. But for now, validating the output is an exercise left to the user. - -Thus far, we've seen how to validate simple outputs like numeric or text values. Real Shiny modules and applications often use more complex outputs such as plots or htmlwidgets. Validating the correctness of these is not as simple, but is doable. - -You can access the data for even complex outputs in `testModule`, but the structure of the output may initially be foreign to you. - -```{r} -module <- function(input, output, session){ - output$plot <- renderPlot({ - df <- data.frame(length = iris$Petal.Length, width = iris$Petal.Width) - plot(df) - }) -} -testModule(module, { - print(str(output$plot)) -}) -``` - -As you can see, there are a lot of internal details that go into a plot. Behind-the-scenes, these are all the details that Shiny will use to correctly display a plot in a user's browser. You don't need to learn about all of these properties -- and they're all subject to change. - -In terms of your testing strategy, you shouldn't bother yourself with "is Shiny generating the correct structure so that the plot will render in the browser?" That's a question that the Shiny package itself needs to answer (and one for which we have our own tests). The goal for your tests should be to ask: "is the code that I wrote producing the plot I want?" There are two components to that question: - - 1. Does the plot generate without producing an error? - 2. Is the plot visually correct? - -`testModule` is great for assessing the first component here. By merely referencing `output$plot` in your test, you'll confirm that the plot was generated without an error. The second component is better suited for a [shinytest](https://rstudio.github.io/shinytest/articles/shinytest.html) test which actually loads the Shiny app in a headless browser and confirms that the content visually appears the same as it did previously. Doing this kind of test in `testModule` would be complex and may not be reliable as graphics devices differ slightly from platform to platform; i.e. the exact bits in the `src` field of your plot will not necessarily be reproducible between different versions of R or different operating systems. - -For htmlwidgets, you can adopt a similar strategy. The goal is not to confirm that the htmlwidget's render function is behaving properly -- but rather that the data that you intend to render is indeed getting passed through. - -We could modify the above example to better represent this approach. - -```{r} -module <- function(input, output, session){ - # Move any complex logic into a separate reactive which can be tested comprehensively - plotData <- reactive({ - data.frame(length = iris$Petal.Length, width = iris$Petal.Width) - }) - - # And leave the `render` function to be as simple as possible to lessen the need for - # integration tests. - output$plot <- renderPlot({ - plot(plotData()) - }) -} -testModule(module, { - # Confirm that the data reactive is behaving as expected - expect_equal(nrow(plotData()), 150) - expect_equal(ncol(plotData()), 2) - expect_equal(colnames(plotData()), c("length", "width")) - - # And now the plot function is so simple that there's not much need for - # automated testing. If we did wish to evaluate the plot visually, we could - # do so using the shinytest package. - output$plot # Just confirming that the plot can be accessed without an error -}) -``` - -You could adopt a similar strategy with other plots or htmlwidgets: move the complexity into reactives that can be tested, and leave the `render` functions as simple as possible. - -## Testing Shiny Applications - -In addition to testing Shiny modules, you can also test Shiny applications. The `testServer` function will automatically extract the server portion given an application's directory and you can test it just like you do any other module. - -```{r} -appdir <- system.file("examples/06_tabsets", package="shiny") -testServer({ - session$setInputs(dist="norm", n=10) - expect_equal(length(d()), 10) -}, appdir) -``` - -As you can see, the test expression can be run for Shiny servers just like it was run for modules. - -## Flushing Reactives - -Reactivity differs from imperative programming in that the processing required to update reactives can be deferred and batched together. While this is a boon for the computational speed of a reactive system, it does create some ambiguity about *when* the reactives should be processed or "flushed". - -`testModule` will do its best to automatically "flush" the reactives at the right time. There are two triggers that will cause a reactive flush: - -1. Calling `session$setInputs()` - After setting the updated inputs, the reactives will be flushed. -2. Calling `session$elapse()` - After the scheduled callbacks are executed, reactives will be flushed. - -However, there may be other times that a Shiny module author might want to trigger a reactive flush. For instance, you might want to flush the reactives after updating an element in a `reactiveValues` in your module like this one. - -```{r} -module <- function(input, output, session){ - rv <- reactiveValues(a=1) - output$txt <- renderText({ - rv$a - }) -} - -testModule(module, { - expect_equal(output$txt, "1") - - rv$a <- 2 - - # testModule has no innate knowledge of our `rv` variable; therefore, it - # hasn't been updated - expect_equal(output$txt, "1") - - # We'll need to manually force a flush of the reactives. - session$flushReact() - - expect_equal(output$txt, "2") -}) -``` - -As you can see, we can use `session$flushReact()` to trigger a reactive flush at any point we'd like. In this example, `testModule` knows nothing about our `rv` variable. Therefore if we want to observe reactive changes that occur after manually updating this variable, we'd need to explicitly flush the reactives.