1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
|
#' 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 A server function (i.e. a function with `input`, `output`,
#' and `session`), or a module function (i.e. a function with first
#' argument `id` that calls [moduleServer()].
#'
#' You can also provide an app, a path an app, or anything that
#' [`as.shiny.appobj()`] can handle.
#' @param expr Test code containing expectations. The objects from inside the
#' server function environment will be made available in the environment of
#' the test expression (this is done using a data mask with
#' [rlang::eval_tidy()]). This includes the parameters of the server function
#' (e.g. `input`, `output`, and `session`), along with any other values
#' created inside of the server function.
#' @param args Additional arguments to pass to the module function. If `app` is
#' a module, and no `id` argument is provided, one will be generated and
#' supplied automatically.
#' @param session The [`MockShinySession`] object to use as the [reactive
#' domain][shiny::domains]. The same session object is used as the domain both
#' during invocation of the server or module under test and during evaluation
#' of `expr`.
#' @include mock-session.R
#' @rdname testServer
#' @examples
#' # Testing a server function ----------------------------------------------
#' server <- function(input, output, session) {
#' x <- reactive(input$a * input$b)
#' }
#'
#' testServer(server, {
#' session$setInputs(a = 2, b = 3)
#' stopifnot(x() == 6)
#' })
#'
#'
#' # Testing a module --------------------------------------------------------
#' myModuleServer <- 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(myModuleServer, args = list(multiplier = 2), {
#' 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.
#' })
#' @export
testServer <- function(app = NULL, expr, args = list(), session = MockShinySession$new()) {
require(shiny)
if (!is.null(getDefaultReactiveDomain()))
stop("testServer() is for use only within tests and may not indirectly call itself.")
on.exit(if (!session$isClosed()) session$close(), add = TRUE)
quosure <- rlang::enquo(expr)
if (isModuleServer(app)) {
if (!("id" %in% names(args)))
args[["id"]] <- session$genId()
# app is presumed to be a module, and modules may take additional arguments,
# so splice in any args.
withMockContext(session, rlang::exec(app, !!!args))
# If app is a module, then we must use both the module function's immediate
# environment and also its enclosing environment to construct the mask.
parent_clone <- rlang::env_clone(parent.env(session$env))
clone <- rlang::env_clone(session$env, parent_clone)
mask <- rlang::new_data_mask(clone, parent_clone)
withMockContext(session, rlang::eval_tidy(quosure, mask, rlang::caller_env()))
return(invisible())
}
if (is.null(app)) {
path <- findEnclosingApp(".")
app <- shinyAppDir(path)
} else if (isServer(app)) {
app <- shinyApp(fluidPage(), app)
} else {
app <- as.shiny.appobj(app)
}
if (!is.null(app$onStart))
app$onStart()
if (!is.null(app$onStop))
on.exit(app$onStop(), add = TRUE)
server <- app$serverFuncSource()
if (!"session" %in% names(formals(server)))
stop("Tested application server functions must declare input, output, and session arguments.")
if (length(args))
stop("Arguments were provided to a server function.")
body(server) <- rlang::expr({
session$setEnv(base::environment())
!!body(server)
})
withMockContext(session,
server(input = session$input, output = session$output, session = session)
)
# # If app is a server, we use only the server function's immediate
# # environment to construct the mask.
mask <- rlang::new_data_mask(rlang::env_clone(session$env))
withMockContext(session, {
rlang::eval_tidy(quosure, mask, rlang::caller_env())
})
invisible()
}
withMockContext <- function(session, expr) {
isolate(
withReactiveDomain(session, {
withr::with_options(list(`shiny.allowoutputreads` = TRUE), {
# Sets a cache for renderCachedPlot() with cache = "app" to use.
shinyOptions("cache" = session$appcache)
expr
})
})
)
}
# Helpers -----------------------------------------------------------------
isModuleServer <- function(x) {
is.function(x) && names(formals(x))[[1]] == "id"
}
isServer <- function(x) {
if (!is.function(x)) {
return(FALSE)
}
if (length(formals(x)) < 3) {
return(FALSE)
}
identical(names(formals(x))[1:3], c("input", "output", "session"))
}
|