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

Introduce integration testing framework #2682

Merged
merged 13 commits into from
Oct 30, 2019
8 changes: 6 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ Imports:
tools,
crayon,
rlang (>= 0.4.0),
fastmap (>= 1.0.0)
fastmap (>= 1.0.0),
withr
Suggests:
datasets,
Cairo (>= 1.5-5),
Expand All @@ -89,7 +90,9 @@ Suggests:
ggplot2,
reactlog (>= 1.0.0),
magrittr,
yaml
yaml,
future,
Copy link
Collaborator

Choose a reason for hiding this comment

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

These packages aren't needed anymore, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They're still used in test-test-module, actually.

dygraphs
URL: http://shiny.rstudio.com
BugReports: https://github.com/rstudio/shiny/issues
Collate:
Expand Down Expand Up @@ -164,6 +167,7 @@ Collate:
'snapshot.R'
'tar.R'
'test-export.R'
'test-module.R'
'update-input.R'
RoxygenNote: 6.1.1
Encoding: UTF-8
Expand Down
4 changes: 4 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export("conditionStackTrace<-")
export(..stacktraceoff..)
export(..stacktraceon..)
export(HTML)
export(MockShinySession)
export(NS)
export(Progress)
export(a)
Expand Down Expand Up @@ -260,6 +261,8 @@ export(tagHasAttribute)
export(tagList)
export(tagSetChildren)
export(tags)
export(testModule)
export(testServer)
export(textAreaInput)
export(textInput)
export(textOutput)
Expand Down Expand Up @@ -311,3 +314,4 @@ importFrom(grDevices,dev.cur)
importFrom(grDevices,dev.set)
importFrom(promises,"%...!%")
importFrom(promises,"%...>%")
importFrom(withr,with_options)
2 changes: 2 additions & 0 deletions R/mock-session.R
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ extract <- function(promise) {
stop("Single-bracket indexing of mockclientdata is not allowed.")
}

#' Mock Shiny Session
#' @include timer.R
#' @export
MockShinySession <- R6Class(
'MockShinySession',
portable = FALSE,
Expand Down
109 changes: 109 additions & 0 deletions R/test-module.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@


#' Test a shiny module
#' @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 args A list of arguments to pass into the module beyond `input`,
#' `output`, and `session`.
#' @param ... Additional named arguments to be passed on to the module function.
#' @include mock-session.R
#' @export
testModule <- function(module, expr, args, ...) {
expr <- substitute(expr)
.testModule(module, expr, args, ...)
}

#' @noRd
#' @importFrom withr with_options
.testModule <- function(module, expr, args, ...) {
# Capture the environment from the module
# Inserts `session$env <- environment()` at the top of the function
fn_body <- body(module)
fn_body[seq(3, length(fn_body)+1)] <- fn_body[seq(2, length(fn_body))]
fn_body[[2]] <- quote(session$env <- environment())
body(module) <- fn_body

# Create a mock session
session <- MockShinySession$new()

# Parse the additional arguments
args <- list(..., input = session$input, output = session$output, session = session)

# Initialize the module
isolate(
withReactiveDomain(
session,
withr::with_options(list(`shiny.allowoutputreads`=TRUE), {
# Remember that invoking this module implicitly assigns to `session$env`
# Also, assigning to `$returned` will cause a flush to happen automatically.
session$returned <- do.call(module, args)
})
)
)

# Run the test expression in a reactive context and in the module's environment.
# We don't need to flush before entering the loop because the first expr that we execute is `{`.
# So we'll already flush before we get to the good stuff.
isolate({
withReactiveDomain(
session,
withr::with_options(list(`shiny.allowoutputreads`=TRUE), {
eval(expr, new.env(parent=session$env))
})
)
})

if (!session$isClosed()){
session$close()
}
}

#' Test an app's server-side logic
#' @param expr Test code containing expectations
#' @param appDir The directory root of the Shiny application. If `NULL`, this function
#' will work up the directory hierarchy --- starting with the current directory ---
#' looking for a directory that contains an `app.R` or `server.R` file.
#' @export
testServer <- function(expr, appDir=NULL) {
if (is.null(appDir)){
appDir <- findApp()
}

app <- shinyAppDir(appDir)
server <- app$serverFuncSource()

# Add `session` argument if not present
fn_formals <- formals(server)
if (! "session" %in% names(fn_formals)) {
fn_formals$session <- bquote()
formals(server) <- fn_formals
}

# Now test the server as we would a module
.testModule(server, expr=substitute(expr))
}

findApp <- function(startDir="."){
dir <- normalizePath(startDir)

# The loop will either return or stop() itself.
while (TRUE){
if(file.exists.ci(file.path(dir, "app.R")) || file.exists.ci(file.path(dir, "server.R"))){
return(dir)
}

# Move up a directory
origDir <- dir
dir <- dirname(dir)

# Testing for "root" path can be tricky. OSs differ and on Windows, network shares
# might have a \\ prefix. Easier to just see if we got stuck and abort.
if (dir == origDir){
# We can go no further.
stop("No shiny app was found in ", startDir, " or any of its parent directories")
}
}
}
6 changes: 6 additions & 0 deletions inst/_pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,9 @@ reference:
contents:
- shinyApp
- maskReactiveContext
- title: Testing
desc: Functions intended for testing of Shiny components
contents:
- testModule
- testServer
- MockShinySession
14 changes: 14 additions & 0 deletions man/MockShinySession.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions man/testModule.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions man/testServer.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions tests/test-modules/06_tabsets/app.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
library(shiny)

global <- 123

# Define UI for random distribution app ----
ui <- fluidPage(

# App title ----
titlePanel("Tabsets"),

# Sidebar layout with input and output definitions ----
sidebarLayout(

# Sidebar panel for inputs ----
sidebarPanel(

# Input: Select the random distribution type ----
radioButtons("dist", "Distribution type:",
c("Normal" = "norm",
"Uniform" = "unif",
"Log-normal" = "lnorm",
"Exponential" = "exp")),

# br() element to introduce extra vertical spacing ----
br(),

# Input: Slider for the number of observations to generate ----
sliderInput("n",
"Number of observations:",
value = 500,
min = 1,
max = 1000)

),

# Main panel for displaying outputs ----
mainPanel(

# Output: Tabset w/ plot, summary, and table ----
tabsetPanel(type = "tabs",
tabPanel("Plot", plotOutput("plot")),
tabPanel("Summary", verbatimTextOutput("summary")),
tabPanel("Table", tableOutput("table"))
)

)
)
)

# Define server logic for random distribution app ----
server <- function(input, output) {

# Reactive expression to generate the requested distribution ----
# This is called whenever the inputs change. The output functions
# defined below then use the value computed from this expression
d <- reactive({
dist <- switch(input$dist,
norm = rnorm,
unif = runif,
lnorm = rlnorm,
exp = rexp,
rnorm)

dist(input$n)
})

# Generate a plot of the data ----
# Also uses the inputs to build the plot label. Note that the
# dependencies on the inputs and the data reactive expression are
# both tracked, and all expressions are called in the sequence
# implied by the dependency graph.
output$plot <- renderPlot({
dist <- input$dist
n <- input$n

hist(d(),
main = paste("r", dist, "(", n, ")", sep = ""),
col = "#75AADB", border = "white")
})

# Generate a summary of the data ----
output$summary <- renderPrint({
summary(d())
})

# Generate an HTML table view of the data ----
output$table <- renderTable({
d()
})

}

# Create Shiny app ----
shinyApp(ui, server)
44 changes: 44 additions & 0 deletions tests/test-modules/server_r/server.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
library(shiny)

# Define server logic for random distribution app ----
function(input, output) {

# Reactive expression to generate the requested distribution ----
# This is called whenever the inputs change. The output functions
# defined below then use the value computed from this expression
d <- reactive({
dist <- switch(input$dist,
norm = rnorm,
unif = runif,
lnorm = rlnorm,
exp = rexp,
rnorm)

dist(input$n)
})

# Generate a plot of the data ----
# Also uses the inputs to build the plot label. Note that the
# dependencies on the inputs and the data reactive expression are
# both tracked, and all expressions are called in the sequence
# implied by the dependency graph.
output$plot <- renderPlot({
dist <- input$dist
n <- input$n

hist(d(),
main = paste("r", dist, "(", n, ")", sep = ""),
col = "#75AADB", border = "white")
})

# Generate a summary of the data ----
output$summary <- renderPrint({
summary(d())
})

# Generate an HTML table view of the data ----
output$table <- renderTable({
d()
})

}
Loading