diff --git a/DESCRIPTION b/DESCRIPTION index f826669aa7..fcbfcf3a47 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -171,7 +171,7 @@ Collate: 'snapshot.R' 'tar.R' 'test-export.R' - 'test-module.R' + 'test-server.R' 'test.R' 'update-input.R' RoxygenNote: 7.1.0 diff --git a/NAMESPACE b/NAMESPACE index f2cb9d0171..46739ab746 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -263,7 +263,6 @@ export(tagHasAttribute) export(tagList) export(tagSetChildren) export(tags) -export(testModule) export(testServer) export(textAreaInput) export(textInput) @@ -354,4 +353,3 @@ importFrom(htmltools,validateCssUnit) importFrom(htmltools,withTags) importFrom(promises,"%...!%") importFrom(promises,"%...>%") -importFrom(withr,with_options) diff --git a/R/mock-session.R b/R/mock-session.R index a90b060f2e..04c97f55ff 100644 --- a/R/mock-session.R +++ b/R/mock-session.R @@ -69,6 +69,13 @@ extract <- function(promise) { stop("Single-bracket indexing of mockclientdata is not allowed.") } +#' @noRd +mapNames <- function(func, ...) { + vals <- list(...) + names(vals) <- vapply(names(vals), func, character(1)) + vals +} + #' Mock Shiny Session #' #' @description @@ -83,6 +90,8 @@ MockShinySession <- R6Class( public = list( #' @field env The environment associated with the session. env = NULL, + #' @field returned The value returned by the module. + returned = NULL, #' @field singletons Hardcoded as empty. Needed for rendering HTML (i.e. renderUI) singletons = character(0), #' @field clientData Mock client data that always returns a size for plots @@ -371,10 +380,10 @@ MockShinySession <- R6Class( #' @param export Not used #' @param format Not used getTestSnapshotUrl = function(input=TRUE, output=TRUE, export=TRUE, format="json") {}, - #' @description Returns the given id prefixed by `mock-session-`. + #' @description Returns the given id prefixed by this namespace's id. #' @param id The id to modify. ns = function(id) { - paste0("mock-session-", id) # TODO: does this need to be more complex/intelligent? + NS(private$nsPrefix, id) }, #' @description Trigger a reactive flush right now. flushReact = function(){ @@ -388,8 +397,30 @@ MockShinySession <- R6Class( self, input = .createReactiveValues(private$.input, readonly = TRUE, ns = ns), output = structure(.createOutputWriter(self, ns = ns), class = "shinyoutput"), - makeScope = function(namespace) self$makeScope(ns(namespace)) + makeScope = function(namespace) self$makeScope(ns(namespace)), + ns = function(namespace) ns(namespace), + setInputs = function(...) do.call(self$setInputs, mapNames(ns, ...)) ) + }, + #' @description Set the environment associated with a testServer() call. + #' @param env The environment to retain. + setEnv = function(env) { + self$env <- env + }, + #' @description Set the value returned by the module call and proactively flush. + #' @param value The value returned from the module + setReturned = function(value) { + self$returned <- value + private$flush() + value + }, + #' @description Get the value returned by the module call. + getReturned = function() self$returned, + #' @description Return a distinct character identifier for use as a proxy + #' namespace. + genId = function() { + private$idCounter <- private$idCounter + 1 + paste0("proxy", private$idCounter) } ), private = list( @@ -400,7 +431,8 @@ MockShinySession <- R6Class( timer = NULL, closed = FALSE, outs = list(), - returnedVal = NULL, + nsPrefix = "mock-session", + idCounter = 0, flush = function(){ isolate(private$flushCBs$invoke(..stacktraceon = TRUE)) @@ -410,18 +442,6 @@ MockShinySession <- R6Class( } ), active = list( - # If assigning to `returned`, proactively flush - #' @field returned The value returned from the module - returned = function(value){ - if(missing(value)){ - return(private$returnedVal) - } - # When you assign to returned, that implies that you just ran - # the module. So we should proactively flush. We have to do this - # here since flush is private. - private$returnedVal <- value - private$flush() - }, #' @field request An empty environment where the request should be. The request isn't meaningfully mocked currently. request = function(value) { if (!missing(value)){ diff --git a/R/modules.R b/R/modules.R index f76587fece..aa12a9e3c9 100644 --- a/R/modules.R +++ b/R/modules.R @@ -36,7 +36,6 @@ createSessionProxy <- function(parentSession, ...) { `[[<-.session_proxy` <- `$<-.session_proxy` - #' Shiny modules #' #' Shiny's module feature lets you break complicated UI and server logic into @@ -132,6 +131,12 @@ createSessionProxy <- function(parentSession, ...) { #' #' @export moduleServer <- function(id, module, session = getDefaultReactiveDomain()) { + if (inherits(session, "MockShinySession")) { + body(module) <- rlang::expr({ + session$setEnv(base::environment()) + session$setReturned({ !!!body(module) }) + }) + } callModule(module, id, session = session) } diff --git a/R/test-module.R b/R/test-module.R deleted file mode 100644 index 0e17f05a35..0000000000 --- a/R/test-module.R +++ /dev/null @@ -1,169 +0,0 @@ - - -#' Integration testing for Shiny modules or server functions -#' -#' Offer a way to test the reactive interactions in Shiny --- either in Shiny -#' modules or in the server portion of a Shiny application. For more -#' information, visit [the Shiny Dev Center article on integration -#' testing](https://shiny.rstudio.com/articles/integration-testing.html). -#' @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 -#' values created inside of the module. -#' @param ... Additional arguments to pass to the module function. These -#' arguments are processed with [rlang::list2()] and so are -#' _[dynamic][rlang::dyn-dots]_. -#' @return The result of evaluating `expr`. -#' @include mock-session.R -#' @rdname testModule -#' @examples -#' module <- function(input, output, session, multiplier = 2, prefix = "I am ") { -#' myreactive <- reactive({ -#' input$x * multiplier -#' }) -#' output$txt <- renderText({ -#' paste0(prefix, myreactive()) -#' }) -#' } -#' -#' # Basic Usage -#' # ----------- -#' testModule(module, { -#' session$setInputs(x = 1) -#' # You're also free to use third-party -#' # testing packages like testthat: -#' # expect_equal(myreactive(), 2) -#' stopifnot(myreactive() == 2) -#' stopifnot(output$txt == "I am 2") -#' -#' session$setInputs(x = 2) -#' stopifnot(myreactive() == 4) -#' stopifnot(output$txt == "I am 4") -#' # Any additional arguments, below, are passed along to the module. -#' }, multiplier = 2) -#' -#' # Advanced Usage -#' # -------------- -#' multiplier_arg_name = "multiplier" -#' more_args <- list(prefix = "I am ") -#' testModule(module, { -#' session$setInputs(x = 1) -#' stopifnot(myreactive() == 2) -#' stopifnot(output$txt == "I am 2") -#' # !!/:= and !!! from rlang are used below to splice computed arguments -#' # into the testModule() argument list. -#' }, !!multiplier_arg_name := 2, !!!more_args) -#' @export -testModule <- function(module, expr, ...) { - .testModule( - module, - quosure = rlang::enquo(expr), - dots = rlang::list2(...), - env = rlang::caller_env() - ) -} - -#' @noRd -#' @importFrom withr with_options -.testModule <- function(module, quosure, dots, env) { - # Modify the module function locally by inserting `session$env <- - # environment()` at the beginning of its body. The dynamic environment of the - # module function is saved so that it may be referenced after the module - # function has returned. The saved dynamic environment is the basis for the - # `data` argument of tidy_eval() when used below to evaluate `quosure`, the - # test code expression. - body(module) <- rlang::expr({ - session$env <- base::environment() - !!!body(module) - }) - - session <- MockShinySession$new() - on.exit(if (!session$isClosed()) session$close()) - args <- append(dots, list(input = session$input, output = session$output, session = session)) - - isolate( - withReactiveDomain( - session, - withr::with_options(list(`shiny.allowoutputreads`=TRUE), { - # Assigning to `$returned` causes a flush to happen automatically. - session$returned <- do.call(module, args) - }) - ) - ) - - # Evaluate `quosure` in a reactive context, and in the provided `env`, but - # with `env` masked by a shallow view of `session$env`, the environment that - # was saved when the module function was invoked. flush is not needed before - # entering the loop because the first expr executed is `{`. - isolate({ - withReactiveDomain( - session, - withr::with_options(list(`shiny.allowoutputreads`=TRUE), { - rlang::eval_tidy( - quosure, - data = rlang::as_data_mask(as.list(session$env)), - env = env - ) - }) - ) - }) -} - -#' Test an app's server-side logic -#' @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. -#' @rdname testModule -#' @export -testServer <- function(expr, appDir=NULL) { - if (is.null(appDir)){ - appDir <- findApp() - } - - app <- shinyAppDir(appDir) - message("Testing application found in: ", appDir) - server <- app$serverFuncSource() - - origwd <- getwd() - setwd(appDir) - on.exit({ setwd(origwd) }, add=TRUE) - - # Add `session` argument if not present - fn_formals <- formals(server) - if (! "session" %in% names(fn_formals)) { - fn_formals$session <- bquote() - formals(server) <- fn_formals - } - - # Test the server function almost as if it were a module. `dots` is empty - # because server functions never take additional arguments. - .testModule( - server, - quosure = rlang::enquo(expr), - dots = list(), - env = rlang::caller_env() - ) -} - -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/R/test-server.R b/R/test-server.R new file mode 100644 index 0000000000..41f8dfcc78 --- /dev/null +++ b/R/test-server.R @@ -0,0 +1,119 @@ +# Create a "data mask" suitable for passing to rlang::eval_tidy. Bindings in +# `env` and bindings in the parent of `env` are merged into a single named list. +# Bindings in `env` take precedence over bindings in the parent of `env`. +#' @noRd +makeMask <- function(env) { + stopifnot(length(rlang::env_parents(env)) > 1) + child <- as.list(env) + parent <- as.list(rlang::env_parent(env)) + parent_only <- setdiff(names(parent), names(child)) + append(child, parent[parent_only]) +} + +#' @noRd +isModuleServer <- function(x) { + is.function(x) && names(formals(x))[1] == "id" +} + +#' Reactive testing for Shiny server functions and modules +#' +#' A way to test the reactive interactions in Shiny applications. Reactive +#' interactions are defined in the server function of applications and in +#' modules. +#' @param app The path to an application or module to test. In addition to +#' paths, applications may be represented by any object suitable for coercion +#' to an `appObj` by `as.shiny.appobj`. Application server functions must +#' include a `session` argument in order to be tested. +#' @param expr Test code containing expectations. The test expression will run +#' in the server function environment, meaning that the parameters of the +#' server function (e.g. `input`, `output`, and `session`) will be available +#' along with any other values created inside of the server function. +#' @param ... Additional arguments to pass to the module function. These +#' arguments are processed with [rlang::list2()] and so are +#' _[dynamic][rlang::dyn-dots]_. If `app` is a module, and no `id` argument is +#' provided, one will be generated and supplied automatically. +#' @return The result of evaluating `expr`. +#' @include mock-session.R +#' @rdname testServer +#' @examples +#' server <- function(id, multiplier = 2, prefix = "I am ") { +#' moduleServer(id, function(input, output, session) { +#' myreactive <- reactive({ +#' input$x * multiplier +#' }) +#' output$txt <- renderText({ +#' paste0(prefix, myreactive()) +#' }) +#' }) +#' } +#' +#' testServer(server, { +#' session$setInputs(x = 1) +#' # You're also free to use third-party +#' # testing packages like testthat: +#' # expect_equal(myreactive(), 2) +#' stopifnot(myreactive() == 2) +#' stopifnot(output$txt == "I am 2") +#' +#' session$setInputs(x = 2) +#' stopifnot(myreactive() == 4) +#' stopifnot(output$txt == "I am 4") +#' # Any additional arguments, below, are passed along to the module. +#' }, multiplier = 2) +#' @export +testServer <- function(app, expr, ...) { + + args <- rlang::list2(...) + + session <- getDefaultReactiveDomain() + + if (inherits(session, "MockShinySession")) + stop("Test expressions may not call testServer()") + if (inherits(session, "session_proxy") + && inherits(get("parent", envir = session), "MockShinySession")) + stop("Modules may not call testServer()") + + session <- MockShinySession$new() + on.exit(if (!session$isClosed()) session$close()) + + if (isModuleServer(app)) { + if (!("id" %in% names(args))) + args[["id"]] <- session$genId() + } else { + appobj <- as.shiny.appobj(app) + server <- appobj$serverFuncSource() + if (! "session" %in% names(formals(server))) + stop("Tested application server functions must declare input, output, and session arguments.") + body(server) <- rlang::expr({ + session$setEnv(base::environment()) + !!!body(server) + }) + app <- function() { + session$setReturned(server(input = session$input, output = session$output, session = session)) + } + if (length(args)) + message("Discarding unused arguments to server function") + } + + isolate( + withReactiveDomain( + session, + withr::with_options(list(`shiny.allowoutputreads` = TRUE), { + rlang::exec(app, !!!args) + }) + ) + ) + + stopifnot(all(c("input", "output", "session") %in% ls(session$env))) + + quosure <- rlang::enquo(expr) + + isolate( + withReactiveDomain( + session, + withr::with_options(list(`shiny.allowoutputreads` = TRUE), { + rlang::eval_tidy(quosure, makeMask(session$env), rlang::caller_env()) + }) + ) + ) +} diff --git a/man/MockShinySession.Rd b/man/MockShinySession.Rd index b5df4e90e9..93fc2ec3ba 100644 --- a/man/MockShinySession.Rd +++ b/man/MockShinySession.Rd @@ -21,6 +21,8 @@ s$setInputs(x=1, y=2) \describe{ \item{\code{env}}{The environment associated with the session.} +\item{\code{returned}}{The value returned by the module.} + \item{\code{singletons}}{Hardcoded as empty. Needed for rendering HTML (i.e. renderUI)} \item{\code{clientData}}{Mock client data that always returns a size for plots} @@ -38,8 +40,6 @@ s$setInputs(x=1, y=2) \section{Active bindings}{ \if{html}{\out{
}} \describe{ -\item{\code{returned}}{The value returned from the module} - \item{\code{request}}{An empty environment where the request should be. The request isn't meaningfully mocked currently.} } \if{html}{\out{
}} @@ -83,6 +83,10 @@ s$setInputs(x=1, y=2) \item \href{#method-ns}{\code{MockShinySession$ns()}} \item \href{#method-flushReact}{\code{MockShinySession$flushReact()}} \item \href{#method-makeScope}{\code{MockShinySession$makeScope()}} +\item \href{#method-setEnv}{\code{MockShinySession$setEnv()}} +\item \href{#method-setReturned}{\code{MockShinySession$setReturned()}} +\item \href{#method-getReturned}{\code{MockShinySession$getReturned()}} +\item \href{#method-genId}{\code{MockShinySession$genId()}} \item \href{#method-clone}{\code{MockShinySession$clone()}} } } @@ -627,7 +631,7 @@ No-op \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-ns}{}}} \subsection{Method \code{ns()}}{ -Returns the given id prefixed by \verb{mock-session-}. +Returns the given id prefixed by this namespace's id. \subsection{Usage}{ \if{html}{\out{
}}\preformatted{MockShinySession$ns(id)}\if{html}{\out{
}} } @@ -666,6 +670,61 @@ Create and return a namespace-specific session proxy. } \if{html}{\out{}} } +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-setEnv}{}}} +\subsection{Method \code{setEnv()}}{ +Set the environment associated with a testServer() call. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$setEnv(env)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{env}}{The environment to retain.} +} +\if{html}{\out{
}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-setReturned}{}}} +\subsection{Method \code{setReturned()}}{ +Set the value returned by the module call and proactively flush. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$setReturned(value)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{value}}{The value returned from the module} +} +\if{html}{\out{
}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-getReturned}{}}} +\subsection{Method \code{getReturned()}}{ +Get the value returned by the module call. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$getReturned()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-genId}{}}} +\subsection{Method \code{genId()}}{ +Return a distinct character identifier for use as a proxy +namespace. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$genId()}\if{html}{\out{
}} +} + } \if{html}{\out{
}} \if{html}{\out{}} diff --git a/man/testModule.Rd b/man/testModule.Rd deleted file mode 100644 index 8723bf148c..0000000000 --- a/man/testModule.Rd +++ /dev/null @@ -1,73 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/test-module.R -\name{testModule} -\alias{testModule} -\alias{testServer} -\title{Integration testing for Shiny modules or server functions} -\usage{ -testModule(module, expr, ...) - -testServer(expr, appDir = NULL) -} -\arguments{ -\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. -\code{input}, \code{output}, and \code{session}) will be available along with any other -values created inside of the module.} - -\item{...}{Additional arguments to pass to the module function. These -arguments are processed with \code{\link[rlang:list2]{rlang::list2()}} and so are -\emph{\link[rlang:dyn-dots]{dynamic}}.} - -\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.} -} -\value{ -The result of evaluating \code{expr}. -} -\description{ -Offer a way to test the reactive interactions in Shiny --- either in Shiny -modules or in the server portion of a Shiny application. For more -information, visit \href{https://shiny.rstudio.com/articles/integration-testing.html}{the Shiny Dev Center article on integration testing}. -} -\examples{ -module <- function(input, output, session, multiplier = 2, prefix = "I am ") { - myreactive <- reactive({ - input$x * multiplier - }) - output$txt <- renderText({ - paste0(prefix, myreactive()) - }) -} - -# Basic Usage -# ----------- -testModule(module, { - session$setInputs(x = 1) - # You're also free to use third-party - # testing packages like testthat: - # expect_equal(myreactive(), 2) - stopifnot(myreactive() == 2) - stopifnot(output$txt == "I am 2") - - session$setInputs(x = 2) - stopifnot(myreactive() == 4) - stopifnot(output$txt == "I am 4") - # Any additional arguments, below, are passed along to the module. -}, multiplier = 2) - -# Advanced Usage -# -------------- -multiplier_arg_name = "multiplier" -more_args <- list(prefix = "I am ") -testModule(module, { - session$setInputs(x = 1) - stopifnot(myreactive() == 2) - stopifnot(output$txt == "I am 2") - # !!/:= and !!! from rlang are used below to splice computed arguments - # into the testModule() argument list. -}, !!multiplier_arg_name := 2, !!!more_args) -} diff --git a/man/testServer.Rd b/man/testServer.Rd new file mode 100644 index 0000000000..5973b07fee --- /dev/null +++ b/man/testServer.Rd @@ -0,0 +1,58 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/test-server.R +\name{testServer} +\alias{testServer} +\title{Reactive testing for Shiny server functions and modules} +\usage{ +testServer(app, expr, ...) +} +\arguments{ +\item{app}{The path to an application or module to test. In addition to +paths, applications may be represented by any object suitable for coercion +to an \code{appObj} by \code{as.shiny.appobj}. Application server functions must +include a \code{session} argument in order to be tested.} + +\item{expr}{Test code containing expectations. The test expression will run +in the server function environment, meaning that the parameters of the +server function (e.g. \code{input}, \code{output}, and \code{session}) will be available +along with any other values created inside of the server function.} + +\item{...}{Additional arguments to pass to the module function. These +arguments are processed with \code{\link[rlang:list2]{rlang::list2()}} and so are +\emph{\link[rlang:dyn-dots]{dynamic}}. If \code{app} is a module, and no \code{id} argument is +provided, one will be generated and supplied automatically.} +} +\value{ +The result of evaluating \code{expr}. +} +\description{ +A way to test the reactive interactions in Shiny applications. Reactive +interactions are defined in the server function of applications and in +modules. +} +\examples{ +server <- function(id, multiplier = 2, prefix = "I am ") { + moduleServer(id, function(input, output, session) { + myreactive <- reactive({ + input$x * multiplier + }) + output$txt <- renderText({ + paste0(prefix, myreactive()) + }) + }) +} + +testServer(server, { + session$setInputs(x = 1) + # You're also free to use third-party + # testing packages like testthat: + # expect_equal(myreactive(), 2) + stopifnot(myreactive() == 2) + stopifnot(output$txt == "I am 2") + + session$setInputs(x = 2) + stopifnot(myreactive() == 4) + stopifnot(output$txt == "I am 4") + # Any additional arguments, below, are passed along to the module. +}, multiplier = 2) +} diff --git a/tests/test-modules/06_tabsets/app.R b/tests/test-modules/06_tabsets/app.R index 2b3ff527b0..1cc0bd3299 100644 --- a/tests/test-modules/06_tabsets/app.R +++ b/tests/test-modules/06_tabsets/app.R @@ -48,7 +48,7 @@ ui <- fluidPage( ) # Define server logic for random distribution app ---- -server <- function(input, output) { +server <- function(input, output, session) { # Reactive expression to generate the requested distribution ---- # This is called whenever the inputs change. The output functions diff --git a/tests/test-modules/server_r/server.R b/tests/test-modules/server_r/server.R index 9ec0e5dd22..335c9ed9a5 100644 --- a/tests/test-modules/server_r/server.R +++ b/tests/test-modules/server_r/server.R @@ -1,7 +1,7 @@ library(shiny) # Define server logic for random distribution app ---- -function(input, output) { +function(input, output, session) { # Reactive expression to generate the requested distribution ---- # This is called whenever the inputs change. The output functions diff --git a/tests/testthat/test-test-module.R b/tests/testthat/test-test-module.R deleted file mode 100644 index a542b38a07..0000000000 --- a/tests/testthat/test-test-module.R +++ /dev/null @@ -1,738 +0,0 @@ -context("testModule") - -library(promises) -library(future) -plan(multisession) - -test_that("testModule passes dots", { - module <- function(input, output, session, someArg) { - expect_false(missing(someArg)) - expect_equal(someArg, 123) - } - testModule(module, {}, someArg = 123) -}) - -test_that("testModule passes dynamic dots", { - module <- function(input, output, session, someArg) { - expect_false(missing(someArg)) - expect_equal(someArg, 123) - } - - # Test with !!! to splice in a whole named list constructed with base::list() - moreArgs <- list(someArg = 123) - testModule(module, {}, !!!moreArgs) - - # Test with !!/:= to splice in an argument name - argName <- "someArg" - testModule(module, {}, !!argName := 123) -}) - -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(){ rnorm(1) }, function(){ - isolate(rv$x <- rv$x + 1) - rnorm(1) - }) - - 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("testModule works with nested modules", { - outerModule <- function(input, output, session) { - r1 <- reactive({ input$x + 1}) - r2 <- callModule(innerModule, "innerModule", r1) - output$someVar <- renderText(r2()) - } - - innerModule <- function(input, output, session, r) { - reactive(paste("a value:", r())) - } - - testModule(outerModule, { - session$setInputs(x = 1) - expect_equal(output$someVar, "a value: 2") - }) -}) - -test_that("testModule calls can be nested", { - outerModule <- function(input, output, session) { - doubled <- reactive({ input$x * 2 }) - innerModule <- function(input, output, session) { - quadrupled <- reactive({ doubled() * 2 }) - } - } - - testModule(outerModule, { - session$setInputs(x = 1) - expect_equal(doubled(), 2) - testModule(innerModule, { - expect_equal(quadrupled(), 4) - }) - }) -}) - -test_that("testModule returns a meaningful result", { - result <- testModule(function(input, output, session) { - reactive({ input$x * 2 }) - }, { - session$setInputs(x = 2) - session$returned() - }) - expect_equal(result, 4) -}) - -test_that("assigning an output in a module function with a non-function errors", { - module <- function(input, output, session) { - output$someVar <- 123 - } - - expect_error(testModule(module, {}), "^Unexpected") -}) - -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("..", "test-modules", "06_tabsets")) - - # 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", { - # If global is defined at the top of app.R outside of the server function. - testServer({ - expect_equal(get("global", session$env), 123) - }, appDir=test_path("..", "test-modules", "06_tabsets")) -}) - -test_that("testModule allows lexical environment access through session$env", { - m <- local({ - a_var <- 123 - function(input, output, session) { - b_var <- 321 - } - }) - expect_false(exists("a_var", inherits = FALSE)) - testModule(m, { - expect_equal(b_var, 321) - expect_equal(get("a_var", session$env), 123) - }) -}) - -test_that("Module shadowing can be mitigated with unquote", { - i <- 0 - inc <- function() i <<- i+1 - - m <- local({ - function(input, output, session) { - inc <- function() stop("I should never be called") - } - }) - - testModule(m, { - expect_is(inc, "function") - expect_false(identical(inc, !!inc)) - !!inc() - }) - - expect_equal(i, 1) -}) - -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 - - path <- normalizePath(path, mustWork = FALSE) - - 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) - 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. - f <- fa() - expect_equal(normalizePath(f, mustWork = FALSE), cd) - expect_equal(calls, 3) # Two for current dir and hit on the first in the parent -}) diff --git a/tests/testthat/test-test-server-app.R b/tests/testthat/test-test-server-app.R new file mode 100644 index 0000000000..bed5c6a36c --- /dev/null +++ b/tests/testthat/test-test-server-app.R @@ -0,0 +1,31 @@ +context("testServer app") + +library(shiny) +library(testthat) + +test_that("testServer works with dir app", { + # app.R + testServer(test_path("..", "test-modules", "06_tabsets"), { + session$setInputs(dist="norm", n=5) + expect_length(d(), 5) + + session$setInputs(dist="unif", n=6) + expect_length(d(), 6) + }) + + # server.R + testServer(test_path("..", "test-modules", "server_r"), { + session$setInputs(dist="norm", n=5) + expect_length(d(), 5) + + session$setInputs(dist="unif", n=6) + expect_length(d(), 6) + }) +}) + +test_that("testServer works when referencing external globals", { + # If global is defined at the top of app.R outside of the server function. + testServer(test_path("..", "test-modules", "06_tabsets"), { + expect_equal(get("global", session$env), 123) + }) +}) diff --git a/tests/testthat/test-test-server-nesting.R b/tests/testthat/test-test-server-nesting.R new file mode 100644 index 0000000000..c2383afc9a --- /dev/null +++ b/tests/testthat/test-test-server-nesting.R @@ -0,0 +1,89 @@ +context("testServer nesting") + +library(shiny) +library(testthat) + +test_that("Nested modules", { + child <- function(id) { + moduleServer(id, function(input, output, session) { + output$txt <- renderText("bar") + }) + } + + parent <- function(id) { + moduleServer(id, function(input, output, session) { + output$txt <- renderText("foo") + child("child-id") + }) + } + + testServer(parent, { + expect_equal(output$txt, "foo") + }, id = "parent-id") + +}) + +test_that("Lack of ID", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + output$txt <- renderText(session$ns("x")) + }) + } + + testServer(module, { + expect_equal(output$txt, "foo-x") + }, id = "foo") +}) + +test_that("testServer works with nested module servers", { + outerModule <- function(id) { + moduleServer(id, function(input, output, session) { + r1 <- reactive({ input$x + 1}) + r2 <- innerModule("inner", r1) + output$someVar <- renderText(r2()) + }) + } + + innerModule <- function(id, r) { + moduleServer(id, function(input, output, session) { + reactive(paste("a value:", r())) + }) + } + + testServer(outerModule, { + session$setInputs(x = 1) + expect_equal(output$someVar, "a value: 2") + }, id = "foo") +}) + +test_that("testServer calls do not nest in module functions", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + x <- 1 + testServer(function(id) { + moduleServer(id, function(input, output, session) { + y <- x + 1 + }) + }) + }) + } + + expect_error(testServer(module, {}), regexp = "Modules may not call testServer()") +}) + +test_that("testServer calls do not nest in test exprs", { + module <- function(id) { + x <- 1 + moduleServer(id, function(input, output, session) { + inner <- function(id) { + moduleServer(id, function(input, output, session) { + y <- x + 1 + }) + } + }) + } + + expect_error(testServer(module, { + testServer(inner, {}) + }), regexp = "Test expressions may not call testServer()") +}) diff --git a/tests/testthat/test-test-server-scope.R b/tests/testthat/test-test-server-scope.R new file mode 100644 index 0000000000..94c6c32f71 --- /dev/null +++ b/tests/testthat/test-test-server-scope.R @@ -0,0 +1,82 @@ +context("testServer scope") + +library(shiny) +library(testthat) + +test_that("Variables outside of the module are inaccessible", { + module <- local({ + outside <- 123 + function(id, x) { + y <- x+1 + moduleServer(id, function(input, output, session) { + z <- y+1 + }) + } + }, envir = new.env(parent = globalenv())) + + testServer(module, { + expect_equal(x, 0) + expect_equal(y, 1) + expect_equal(z, 2) + expect_equal(exists("outside"), FALSE) + }, x = 0) +}) + +test_that("Variables outside the testServer() have correct visibility", { + module <- local({ + function(id, x) { + moduleServer(id, function(input, output, session) { + y <- 1 + }) + } + }, envir = new.env(parent = globalenv())) + + x <- 99 + z <- 123 + + testServer(module, { + expect_equal(x, 0) + expect_equal(y, 1) + expect_equal(z, 123) + }, x = 0) +}) + +test_that("testServer allows lexical environment access through session$env", { + module <- local({ + a_var <- 123 + function(id) { + moduleServer(id, function(input, output, session) { + b_var <- 321 + }) + } + }) + + expect_false(exists("a_var", inherits = FALSE)) + + testServer(module, { + expect_equal(b_var, 321) + expect_equal(get("a_var", session$env, inherits = TRUE), 123) + expect_false(exists("a_var", inherits = FALSE)) + }) +}) + +test_that("Shadowing can be mitigated with unquote", { + i <- 0 + inc <- function() i <<- i+1 + + module <- local({ + function(id) { + moduleServer(id, function(input, output, session) { + inc <- function() stop("I should never be called") + }) + } + }, envir = globalenv()) + + testServer(module, { + expect_is(inc, "function") + expect_false(identical(inc, !!inc)) + !!inc() + }) + + expect_equal(i, 1) +}) diff --git a/tests/testthat/test-test-server.R b/tests/testthat/test-test-server.R new file mode 100644 index 0000000000..23866c4898 --- /dev/null +++ b/tests/testthat/test-test-server.R @@ -0,0 +1,664 @@ +context("testServer") + +library(shiny) +library(testthat) +library(future) + +test_that("testServer passes dots", { + module <- function(id, someArg) { + expect_false(missing(someArg)) + moduleServer(id, function(input, output, session) { + expect_equal(someArg, 123) + }) + } + testServer(module, {}, someArg = 123) +}) + +test_that("testServer passes dynamic dots", { + module <- function(id, someArg) { + expect_false(missing(someArg)) + moduleServer(id, function(input, output, session) { + expect_equal(someArg, 123) + }) + } + + # Test with !!! to splice in a whole named list constructed with base::list() + moreArgs <- list(someArg = 123) + testServer(module, {}, !!!moreArgs) + + # Test with !!/:= to splice in an argument name + argName <- "someArg" + testServer(module, {}, !!argName := 123) +}) + +test_that("testServer handles observers", { + module <- function(id) { + moduleServer(id, 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) + }) + }) + } + + testServer(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(id) { + moduleServer(id, function(input, output, session) { + }) + } + + testServer(module, { + session$setInputs(x = 0) + expect_error({ input$x <- 1 }) + expect_error({ input$y <- 1 }) + }) +}) + +test_that("testServer handles more complex expressions", { + module <- function(id) { + moduleServer(id, function(input, output, session){ + output$txt <- renderText({ + input$x + }) + }) + } + + testServer(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("testServer handles reactiveVal", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + x <- reactiveVal(0) + observe({ + x(input$y + input$z) + }) + }) + } + + testServer(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("testServer handles reactives with complex dependency tree", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + x <- reactiveValues(x=1) + r <- reactive({ + x$x + input$a + input$b + }) + r2 <- reactive({ + r() + input$c + }) + }) + } + + testServer(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("testServer handles reactivePoll", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + rv <- reactiveValues(x = 0) + rp <- reactivePoll(50, session, function(){ rnorm(1) }, function(){ + isolate(rv$x <- rv$x + 1) + rnorm(1) + }) + + observe({rp()}) + }) + } + + testServer(module, { + expect_equal(rv$x, 1) + + for (i in 1:4){ + session$elapse(50) + } + + expect_equal(rv$x, 5) + }) +}) + +test_that("testServer handles reactiveTimer", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + rv <- reactiveValues(x = 0) + + rp <- reactiveTimer(50) + observe({ + rp() + isolate(rv$x <- rv$x + 1) + }) + }) + } + + testServer(module, { + expect_equal(rv$x, 1) + + session$elapse(200) + + expect_equal(rv$x, 5) + }) +}) + +test_that("testServer handles debounce/throttle", { + module <- function(id) { + moduleServer(id, 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) + }) + }) + } + + testServer(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("testServer 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(id) { + moduleServer(id, function(input, output, session) { + rv <- reactiveValues(x=0) + rp <- reactiveTimer(50) + output$txt <- renderText({ + rp() + isolate(rv$x <- rv$x + 1) + }) + }) + } + + testServer(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("testServer works with async", { + module <- function(id) { + moduleServer(id, 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" + }) + }) + } + + testServer(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(id) { + moduleServer(id, function(input, output, session) { + output$txt1 <- renderText({ + future({ + Sys.sleep(1) + 1 + }) + }) + + output$txt2 <- renderText({ + future({ + Sys.sleep(1) + 2 + }) + }) + }) + } + + testServer(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(id) { + moduleServer(id, 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() + }) + }) + } + + testServer(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("testServer handles modules with additional arguments", { + module <- function(id, arg1, arg2) { + moduleServer(id, function(input, output, session){ + output$txt1 <- renderText({ + arg1 + }) + + output$txt2 <- renderText({ + arg2 + }) + + output$inp <- renderText({ + input$x + }) + }) + } + + testServer(module, { + expect_equal(output$txt1, "val1") + expect_equal(output$txt2, "val2") + }, arg1="val1", arg2="val2") +}) + +test_that("testServer 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(id) { + moduleServer(id, function(input, output, session){ + output$dy <- dygraphs::renderDygraph({ + dygraphs::dygraph(data.frame(outcome=0:5, year=2000:2005)) + }) + }) + } + + testServer(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("testServer captures renderUI", { + module <- function(id) { + moduleServer(id, function(input, output, session){ + output$ui <- renderUI({ + tags$a(href="https://rstudio.com", "hello!") + }) + }) + } + + testServer(module, { + expect_equal(output$ui$deps, list()) + expect_equal(as.character(output$ui$html), "hello!") + }) +}) + +test_that("testServer captures base graphics outputs", { + module <- function(id) { + moduleServer(id, function(input, output, session){ + output$fixed <- renderPlot({ + plot(1,1) + }, width=300, height=350) + + output$dynamic <- renderPlot({ + plot(1,1) + }) + }) + } + + testServer(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("testServer captures ggplot2 outputs", { + if (!requireNamespace("ggplot2")){ + testthat::skip("ggplot2 not available") + } + + module <- function(id) { + moduleServer(id, 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) + }) + }) + } + + testServer(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("testServer exposes the returned value from the module", { + module <- function(id) { + moduleServer(id, function(input, output, session){ + reactive({ + return(input$a + input$b) + }) + }) + } + + testServer(module, { + session$setInputs(a=1, b=2) + expect_equal(session$getReturned()(), 3) + + # And retains reactivity + session$setInputs(a=2) + expect_equal(session$getReturned()(), 4) + }) +}) + +test_that("testServer handles synchronous errors", { + module <- function(id) { + moduleServer(id, function(input, output, session, arg1, arg2){ + output$err <- renderText({ + stop("my error") + }) + + output$safe <- renderText({ + stop(safeError("my safe error")) + }) + }) + } + + testServer(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-existent output gives an informative message", { + module <- function(id) { + moduleServer(id, function(input, output, session){}) + } + + testServer(module, { + expect_error(output$dontexist, "hasn't been defined yet: output\\$server1-dontexist") + }, id = "server1") + + testServer(module, { + expect_error(output$dontexist, "hasn't been defined yet: output\\$.*-dontexist") + }) +}) + +test_that("testServer returns a meaningful result", { + result <- testServer(function(id) { + moduleServer(id, function(input, output, session) { + reactive({ input$x * 2 }) + }) + }, { + session$setInputs(x = 2) + session$getReturned()() + }) + expect_equal(result, 4) +}) + +test_that("assigning an output in a module function with a non-function errors", { + module <- function(id) { + moduleServer(id, function(input, output, session) { + output$someVar <- 123 + + }) + } + + expect_error(testServer(module, {}), "^Unexpected") +}) + +test_that("testServer handles invalidateLater", { + module <- function(id) { + moduleServer(id, 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) + } + }) + }) + } + + testServer(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(id) { + moduleServer(id, function(input, output, session){}) + } + + testServer(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(id) { + moduleServer(id, 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 + }) + }) + } + + testServer(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) + + }) +}) diff --git a/tools/documentation/pkgdown.yml b/tools/documentation/pkgdown.yml index ec06d351c2..3ba7816b82 100644 --- a/tools/documentation/pkgdown.yml +++ b/tools/documentation/pkgdown.yml @@ -217,5 +217,5 @@ reference: desc: Functions intended for testing of Shiny components contents: - runTests - - testModule + - testServer - MockShinySession