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