Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

moduleServer/testServer overhaul #2807

Merged
merged 39 commits into from
Apr 8, 2020
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a943d95
make sure the server is only run if the example is interactive
schloerke Mar 12, 2020
3861357
get travis to pass for now
schloerke Mar 12, 2020
88374ec
Update modules.R
alandipert Mar 12, 2020
2d324c7
Add S3 class to MockShinySession
alandipert Mar 12, 2020
a577b1e
un-skip test
schloerke Mar 12, 2020
e17f416
Update roxygen level
schloerke Mar 12, 2020
f6e1718
Merge remote-tracking branch 'origin/master' into serverModule-testMo…
alandipert Mar 19, 2020
b005799
Add back many working/converted tests
alandipert Mar 23, 2020
c4852cb
Desired environment semantics are working
alandipert Mar 25, 2020
ec2c9ec
Split up and rename various tests
alandipert Mar 27, 2020
0023418
More test reorg
alandipert Mar 30, 2020
bb4aaa2
Bring back scope tests
alandipert Mar 31, 2020
dd9e034
More test progress
alandipert Mar 31, 2020
9d13cb6
test-module.R => test-server.R
alandipert Mar 31, 2020
65233cd
First passing app dir test for testServer overhaul
alandipert Mar 31, 2020
cf9ab1c
appobj coercion works
alandipert Mar 31, 2020
58b4585
Doc and test updates
alandipert Apr 1, 2020
5475ec4
document
alandipert Apr 1, 2020
7e3deb5
document
alandipert Apr 1, 2020
7f80bfd
document
alandipert Apr 1, 2020
78da4c7
Merge remote-tracking branch 'origin/master' into serverModule-testMo…
alandipert Apr 1, 2020
828567e
Add failing proxy-related and ns() related tests
alandipert Apr 1, 2020
e0ed443
WIP mock session scoped proxy
alandipert Apr 2, 2020
953de73
nested module tests pass now, many others fail %-)
alandipert Apr 2, 2020
90f5318
fix one failing test
alandipert Apr 3, 2020
70edcd6
Getting there
alandipert Apr 3, 2020
3ca8b10
Tests pass \o/
alandipert Apr 6, 2020
1d9a6ea
getEnv() => env, docs
alandipert Apr 6, 2020
a2dd97c
Merge remote-tracking branch 'origin/master' into serverModule-testMo…
alandipert Apr 6, 2020
9d8a6d0
Document new R6 methods
alandipert Apr 7, 2020
286f125
document
alandipert Apr 7, 2020
bc2aa71
Use vapply in mapNames()
alandipert Apr 8, 2020
c0c02d2
Remove unused variable
alandipert Apr 8, 2020
2f8227e
Un-inline assignment
alandipert Apr 8, 2020
214d721
Move session$env sanity check out of makeMask and into testServer
alandipert Apr 8, 2020
7e2ffab
Use base versions of a couple rlang::env_* functions
alandipert Apr 8, 2020
fc6f535
Clarify testServer lexenv assertions
alandipert Apr 8, 2020
bac7299
Remove strings from expect_error
alandipert Apr 8, 2020
f4e3e5b
server => module
alandipert Apr 8, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,6 @@ export(tagHasAttribute)
export(tagList)
export(tagSetChildren)
export(tags)
export(testModule)
export(testServer)
export(textAreaInput)
export(textInput)
Expand Down Expand Up @@ -354,4 +353,3 @@ importFrom(htmltools,validateCssUnit)
importFrom(htmltools,withTags)
importFrom(promises,"%...!%")
importFrom(promises,"%...>%")
importFrom(withr,with_options)
22 changes: 9 additions & 13 deletions R/mock-session.R
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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
Expand Down Expand Up @@ -390,6 +392,13 @@ MockShinySession <- R6Class(
output = structure(.createOutputWriter(self, ns = ns), class = "shinyoutput"),
makeScope = function(namespace) self$makeScope(ns(namespace))
)
},
# If assigning to `returned`, proactively flush
#' @param value The value returned from the module
setReturned = function(value) {
self$returned <- value
private$flush()
value
}
),
private = list(
Expand All @@ -400,7 +409,6 @@ MockShinySession <- R6Class(
timer = NULL,
closed = FALSE,
outs = list(),
returnedVal = NULL,

flush = function(){
isolate(private$flushCBs$invoke(..stacktraceon = TRUE))
Expand All @@ -410,18 +418,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)){
Expand Down
6 changes: 5 additions & 1 deletion R/modules.R
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ createSessionProxy <- function(parentSession, ...) {
#'
#' @export
moduleServer <- function(id, module, session = getDefaultReactiveDomain()) {
callModule(module, id, session = session)
if (inherits(session, "MockShinySession")) {
testCallModule(module, id, session)
} else {
callModule(module, id, session = session)
}
}


Expand Down
169 changes: 0 additions & 169 deletions R/test-module.R

This file was deleted.

138 changes: 138 additions & 0 deletions R/test-server.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#' @noRd
testCallModule <- function(module, id, session) {
# TODO alan Figure out what to do with id here, necessary for nested usage?
body(module) <- rlang::expr({
session$env <- base::environment()
!!!body(module)
})

session$setReturned(do.call(module, list(
input = session$input,
output = session$output,
session = session
)))
}

# 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.
alandipert marked this conversation as resolved.
Show resolved Hide resolved
# Bindings in `env` take precedence over bindings in the parent of `env`.
#' @noRd
makeMask <- function(env) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think of it, this function seems like a natural one to make recursive. Something like this. (Note that this isn't well-tested):

makeMask <- function(env, depth = 1, append_list = NULL) {
  # (Note: sorted=FALSE can be significantly faster than TRUE)
  obj_names <- ls(env, all.names = TRUE, sorted = FALSE)
  obj_names <- setdiff(obj_names, names(append_list))
  obj_list <- c(append_list, mget(obj_names, envir = env))

  if (depth <= 1) {
    obj_list
  } else {
    makeMask(parent.env(env), depth - 1, obj_list)
  }
}

stopifnot(length(rlang::env_parents(env)) > 1)
stopifnot(all(c("input", "output", "session") %in% ls(env)))
alandipert marked this conversation as resolved.
Show resolved Hide resolved
child <- as.list(env)
alandipert marked this conversation as resolved.
Show resolved Hide resolved
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"
}

#' @noRd
coercableToAppObj <- function(x) {
!is.null(getS3method("as.shiny.appobj", class(x), optional = TRUE))
alandipert marked this conversation as resolved.
Show resolved Hide resolved
}

#' 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]_.
#' @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())
#' })
#' })
#' }
#'
#' # Basic Usage
#' # -----------
#' 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)
#'
#' # Advanced Usage
#' # --------------
#' multiplier_arg_name = "multiplier"
#' more_args <- list(prefix = "I am ")
#' testServer(server, {
#' 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
testServer <- function(app, expr, ...) {

session <- MockShinySession$new()
on.exit(if (!session$isClosed()) session$close())

if (coercableToAppObj(app)) {
alandipert marked this conversation as resolved.
Show resolved Hide resolved
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$env <- base::environment()
!!!body(server)
})
app <- function() {
session$setReturned(server(input = session$input, output = session$output, session = session))
}
} else if (!isModuleServer(app)) {
stop("app argument must be a module function or coercable by as.shiny.appobj")
}


isolate(
withReactiveDomain(
session,
withr::with_options(list(`shiny.allowoutputreads` = TRUE), {
rlang::exec(app, ...)
})
)
)

quosure <- rlang::enquo(expr)
isolate(
withReactiveDomain(
session,
withr::with_options(list(`shiny.allowoutputreads`=TRUE), {
rlang::eval_tidy(quosure, makeMask(session$env), rlang::caller_env())
})
)
)
}
Loading