install.packages("remotes")
::install_github(
remotes"https://github.com/mjfrigaard/sapkgs",
ref = "mstsap"
)
This is the fourth post in a series on testing Shiny applications. The previous posts have covered using BDD in unit tests, testing apps outside of an R package structure, and testing module server functions. In this post, we’ll be covering testing Shiny applications using testthat
and shinytest2
.
App-Package Contents
In the previous post, we stored the modules and applications from the Shiny modules chapter of Mastering Shiny in the mstsap
branch of sapkgs
, which you can install using the code below:
The msst2ap
branch of sapkgs
contains shinytest2
tests for the Shiny apps in mstsap
(hence the name: Mastering Shiny shinytest2 app-package).
You can install msst2ap
using the following:
install.packages("remotes")
::install_github(
remotes"https://github.com/mjfrigaard/sapkgs",
ref = "msst2ap"
)
The beauty of R packages is that we can use the modules we developed in the previous mstsap
branch as a dependency for msst2ap
by adding this package to the Depends
field of the DESCRIPTION
file (this will automatically attach the mstsap
to the search list when msst2ap
is loaded).
::load_all() devtools
ℹ Loading msst2ap Loading required package: mstsap
I’ve stored development versions of the applications in the inst/dev/
folder of mstsap
:
inst/
└── dev/
├── datasetApp/
│ ├── DESCRIPTION
│ ├── app.R
│ └── modules.R
├── selectDataVarApp/
│ ├── DESCRIPTION
│ ├── README.md
│ ├── app.R
│ └── modules.R
└── selectVarApp/
├── DESCRIPTION
├── README.md
├── app.R
├── modules.R └── utils.R
Using system.file()
The apps stored in the inst/dev/
directory of mstsap
can be passed to the app_dir
argument of AppDriver$new()
with system.file()
:
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app $view()
app })
The first things we’ll check with our test is the window size we’ve provided and the system.file()
path to the datasetApp
in mstsap
:
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app expect_equal(
object = app$get_window_size(),
expected = list(width = 800L, height = 600L))
expect_equal(
object = app$get_dir(),
expected = app_pth)
})
Setting inputs
Next we’ll change dataset-dataset
input from ability.cov
to attitude
using app$set_inputs()
(Note that this uses the module notation above (i.e., "id-inputId"
):
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app # previous tests omitted
$set_inputs(`dataset-dataset` = "attitude")
app })
If you can see both windows, you’ll see the application values change in the Chromium browser:
Checking inputs
In the previous test, we used the expect_values()
to capture a list of all the app values (input
, output
, export
). We can also capture these values in a list inside the test by including a call to app$get_values()
and assigning the output to app_values
.
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app # previous tests omitted
$set_inputs(`dataset-dataset` = "attitude")
app<- app$get_values()
app_values })
app_values
has a similar structure to the .json
snapshot covered above (i.e., with input
, output
, and export
):
str(app_values)
List of 3
$ input :List of 1
..$ dataset-dataset: chr "attitude"
$ output:List of 2
..$ data: chr "<table class = 'table shiny-table table- spacing-s' style = 'width:auto;'>\n<thead> <tr"..
..$ vals: chr "$`dataset-dataset`\n[1] \"attitude\"\n" $ export: Named list()
We can use waldo::compare()
to verify the input
in app_values
to verify the value that we changed with app$set_inputs()
::compare(
waldox = app_values$input$`dataset-dataset`,
y = "attitude"
)
✔ No differences
waldo::compare()
can easily be adapted to a new test expectation:
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app # previous tests omitted
$set_inputs(`dataset-dataset` = "attitude")
app<- app$get_values()
app_values expect_equal(
object = app_values$input$`dataset-dataset`,
expected = "attitude"
) })
At the end of the test, I’ll add a call app$stop()
to close the Chromium app.
test_that("mstsap::datasetApp", {
<- system.file("dev", "datasetApp", package = "mstsap")
app_pth <- AppDriver$new(app_dir = app_pth, height = 600, width = 800)
app expect_equal(
object = app$get_window_size(),
expected = list(width = 800L, height = 600L))
expect_equal(
object = app$get_dir(),
expected = app_pth)
$set_inputs(`dataset-dataset` = "attitude")
app<- app$get_values()
app_values expect_equal(
object = app_values$input$`dataset-dataset`,
expected = "attitude")
$stop()
app })
Exporting test values
The shinytest2
documentation repeatedly1 recommends2 exporting test values from Shiny applications. We’ll use the application stored in inst/dev/selectVarApp/
to explore exporting test values.
The application in the inst/dev/selectVarApp/
folder of mstsap
includes a call to exportTestValues()
and the test.mode
option set to TRUE
in the call to shinyApp()
.3
<- function(input, output, session) {
server <- datasetServer("data")
data <- selectVarServer("var", data, filter = filter)
var
$out <- renderTable(head(var()))
output
$vals <- renderPrint({
output<- reactiveValuesToList(input,
x all.names = TRUE
)print(x)
})
exportTestValues(
var = var(),
data = data()
) }
The test for this application contains the same system.file()
call to create the AppDriver
object:
test_that("mstsap::selectVarApp", {
<- system.file("dev", "selectVarApp", package = "mstsap")
app_pth <- AppDriver$new(app_pth, height = 1200, width = 1000)
app })
After entering app$view()
in the Console, the application opens in the Chromium headless browser again:
$view() app
We can see selectVarApp
has been launched in showcase mode, so the README
and code files are displayed in the UI.
app$view()
selectVarApp()
application with app$view()
In our test file, we’ll use app$set_values()
to change the $`data-dataset`
and $`var-var`
inputs:
test_that("mstsap::selectVarApp", {
<- system.file("dev", "selectVarApp", package = "mstsap")
app_pth <- AppDriver$new(app_pth, height = 1200, width = 1000)
app
$set_inputs(`data-dataset` = "mtcars")
app })
- 1
-
Change
$`data-dataset`
tomtcars
We’ll also change the variable input from mpg
to wt
and verify the output in the UI:
test_that("mstsap::selectVarApp", {
<- AppDriver$new(
app system.file("dev", "selectVarApp",
package = "mstsap"
),height = 1200,
width = 1000
)$set_inputs(`data-dataset` = "mtcars")
app$set_inputs(`var-var` = "wt")
app })
- 1
-
Change
$`data-dataset`
tomtcars
- 2
-
Change
$`var-var`
towt
The printed reactiveValuesToList()
is updated UI when the selectInput()
changes:
data-dataset
selectVarApp()
after setting data-dataset
and var-var
with app$set_inputs()
Getting values
We’ll use app$get_values()
to store the exported input
, output
, and export
test values in app_values
:
test_that("mstsap::selectVarApp", {
<- AppDriver$new(
app system.file("dev", "selectVarApp",
package = "mstsap"
),height = 1200,
width = 1000
)
$set_inputs(`data-dataset` = "mtcars")
app$set_inputs(`var-var` = "wt")
app
<- app$get_values()
app_values })
- 1
-
Change
$`data-dataset`
tomtcars
- 2
-
Change
$`var-var`
towt
- 3
-
Assign to
app_values
list
app_values
is a list (similar to the .json
snapshot file), but now we’ve explicitly exported values from the server in selectVarApp()
:
names(app_values$export)
[1] "data" "var"
Expectations
We can use app_values
to verify the structure of each exported object:
data
should be adata.frame()
::expect_true(
testthatobject = is.data.frame(app_values$export$data)
)
var
should be a numeric vector:
expect_true(
object = is.numeric(app_values$export$var)
)
Once again, we end the test with a call to app$stop()
.
show/hide mstsap::selectVarApp test
test_that("mstsap::selectVarApp", {
<- system.file("dev", "selectVarApp", package = "mstsap")
app_pth <- AppDriver$new(app_pth, height = 1200, width = 1000)
app # app$view()
expect_equal(app$get_window_size(),
list(width = 1000L, height = 1200L))
expect_equal(app$get_dir(), app_pth)
$set_inputs(`data-dataset` = "mtcars")
app$set_inputs(`var-var` = "wt")
app<- app$get_values()
app_values expect_true(
object = is.data.frame(app_values$export$data))
expect_true(
object = is.numeric(app_values$export$var))
$stop()
app })
Testing complex outputs
The msst2ap
branch has the histogramApp()
from Mastering Shiny in inst/dev/histogramApp/
, and a ggplot2
version of the histogramApp()
in the inst/dev/ggHistApp/
folder (view contents here):
inst
└── dev
├── ggHistApp
│ ├── DESCRIPTION
│ ├── R
│ │ └── modules.R
│ ├── README.md
│ └── app.R
└── histogramApp
├── DESCRIPTION
├── R
│ └── modules.R
├── README.md
└── app.R
6 directories, 8 files
histogramApp()
ggHistApp()
histogramApp()
vs. ggHistApp()
Testing reactive values
The module server functions in histogramApp()
return two values: data
and x
:
show/hide values in msst2ap::histogramApp() server
<- function(input, output, session) {
server <- datasetServer("data")
data <- selectVarServer("var", data)
x histogramServer("hist", x)
# remaining code omitted
}
data
is returned reactive from datasetServer()
and becomes an input parameter for selectVarServer()
, and x
is the returned reactive.
Both of these are reactive values, but they aren’t treated like returned values from the reactive()
function (i.e., they don’t have parentheses). These are passed in the server as reactive expressions, which we can confirm using exportTestValues()
:
show/hide msst2ap::histogramApp() server
<- function(input, output, session) {
server <- datasetServer("data")
data <- selectVarServer("var", data)
x histogramServer("hist", x)
# remaining code omitted
exportTestValues(
data = data,
x = x
) }
- 1
-
We’ve also added
options(shiny.testmode = TRUE)
to the top of theapp.R
file.
In the test for histogramApp()
, we’ll create the app with AppDriver$new()
and change the three inputs using app$set_inputs()
:
show/hide msst2ap::histogramApp() server
test_that("{shinytest2} recording: histogramApp", {
<- system.file("dev", "histogramApp",
app_pth package = "msst2ap")
<- AppDriver$new(app_pth, height = 750, width = 1200)
app $set_inputs(`data-dataset` = "attitude")
app$set_inputs(`var-var` = "privileges")
app$set_inputs(`hist-bins` = 15)
app<- app$get_values()
app_values names(app_values)
})
[1] "data" "x"
We’ll test is these are reactive functions by combining rlang::is_function()
and shiny::is.reactive()
:
show/hide msst2ap::histogramApp() values
test_that("{shinytest2} recording: histogramApp", {
<- system.file("dev", "histogramApp",
app_pth package = "msst2ap")
<- AppDriver$new(app_pth, height = 750, width = 1200)
app $set_inputs(`data-dataset` = "attitude")
app$set_inputs(`var-var` = "privileges")
app$set_inputs(`hist-bins` = 15)
app<- app$get_values()
app_values # names(app_values)
expect_equal(
::is_function(app_values$export$data),
rlang::is.reactive(app_values$export$data))
shinyexpect_equal(
::is_function(app_values$export$x),
rlang::is.reactive(app_values$export$x))
shiny })
Using app logs
shinytest2
also has the handy get_logs()
that allows us to check the logs for specific functionality. After changing the three inputs with set_inputs()
, we can check the output to see these actions were included in the logs:
show/hide msst2ap::histogramApp() logs
test_that("{shinytest2} recording: histogramApp", {
<- system.file("dev", "histogramApp",
app_pth package = "msst2ap")
<- AppDriver$new(app_pth, height = 750, width = 1200)
app $set_inputs(`data-dataset` = "attitude")
app$set_inputs(`var-var` = "privileges")
app$set_inputs(`hist-bins` = 15)
app<- app$get_logs()
app_logs <- subset(app_logs,
ds_msg == "Setting inputs: 'data-dataset'")
message expect_equal(nrow(ds_msg), 1L)
<- subset(app_logs,
var_msg == "Setting inputs: 'var-var'")
message expect_equal(nrow(var_msg), 1L)
<- subset(app_logs,
hist_msg == "Setting inputs: 'hist-bins'")
message expect_equal(nrow(hist_msg), 1L)
})
- 1
- Create app logs
- 2
-
Create and test dataset
- 3
-
Create and test variable
- 4
- Create and test bins
Logs can also be passed from the test to the application using log_message()
.
Verify initial input
s
The ggHistApp()
app is similar to histogramApp()
, but instead of passing a reactive vector to hist()
, ggHistServer()
passes a reactive one-column data.frame
(x()
) to the ggplot2
functions. We’ll add exportTestValues()
to a development version of ggHistServer()
in inst/dev/
: 4
show/hide ggHistServer()
<- function(id, x, title = reactive("Histogram")) {
ggHistServer stopifnot(is.reactive(x))
stopifnot(is.reactive(title))
moduleServer(id, function(input, output, session) {
<- reactive({
gg2_plot ::ggplot(
ggplot2mapping =
::aes(purrr::as_vector(x()))
ggplot2+
) ::geom_histogram(bins = input$bins) +
ggplot2::labs(
ggplot2title = paste0(title(), " [bins = ", input$bins, "]"),
y = "Count",
x = names(x())
+
) ::theme_minimal()
ggplot2
})
observe({
$hist <- renderPlot({gg2_plot()}, res = 124)
output|>
}) bindEvent(c(x(), title(), input$bins))
exportTestValues(
bins = input$bins,
x = x(),
title = title()
)
# remaining code omitted
}) }
- 1
-
Build
ggplot2
graph - 2
-
Render plot
- 3
-
Export bins,
x()
andtitle()
The version of ggHistServer()
above replaces the ggHistServer()
used in the standalone app function).5 The remaining modules from mstsap
are explicitly namespaced. The code below identifies the location of each module in ggHistApp()
: 6
show/hide ggHistApp()
<- function() {
ggHistApp <- fluidPage(
ui sidebarLayout(
sidebarPanel(
::datasetInput("data", is.data.frame),
mstsap::selectVarInput("var"),
mstsap
),mainPanel(
histogramOutput("hist"),
code("app vals"),
verbatimTextOutput("vals")
)
)
)
<- function(input, output, session) {
server <- mstsap::datasetServer("data")
data <- ggSelectVarServer("var", data)
x ggHistServer("hist", x)
$vals <- renderPrint({
output<- reactiveValuesToList(input,
x all.names = TRUE)
print(x, width = 30, max.levels = NULL)},
width = 30)
}
exportTestValues(
x = x(),
data = data(),
react_x = x,
react_data = data
)
shinyApp(ui, server)
}ggHistApp()
- 1
-
From
R/histogramOutput.R
- 2
-
From
R/ggSelectVarServer.R
- 3
-
From
inst/dev/ggHistApp/R/modules.R
- 4
- Exported test values
In the test-shinytest2-ggHistApp.R
test file, I’ll verify the vdiffr
package is installed, then create the AppDriver
object with a call to system.file()
and set the height
and width
:
test_that("{shinytest2}: ggHistApp", {
skip_if_not_installed("vdiffr")
<- system.file("dev", "ggHistApp",
app_pth package = "msst2ap")
<- AppDriver$new(app_pth,
app height = 750, width = 1200)
})
The first expectations in the example test the default input
values with app$get_value(input = )
:
# initial values----
# verify initial values
<- app$get_value(input = 'data-dataset')
init_ds <- app$get_value(input = 'var-var')
init_var # is the variable from the dataset?
expect_true(
%in% names(get(init_ds, "package:datasets"))
init_var
)<- app$get_value(input = 'hist-bins')
init_bins expect_true(is.numeric(init_bins))
Set and verify export
Next, we check chamging the input values with app$set_values(id-inputId)
:
# set values ----
# dataset
$set_inputs(`data-dataset` = 'mtcars')
app<- app$get_value(input = "data-dataset")
new_data # Verify new data
expect_equal(object = new_data,
expected = "mtcars")
$set_inputs(`var-var` = 'disp')
app<- app$get_value(input = "var-var")
new_var # Verify new variable
expect_equal(object = new_var,
expected = "disp")
Verify export
s
Finally, we’ll test the exported values by creating app$get_values()$export
and checking it’s contents:
# Exported objects ----
<- app$get_values()$export
exp_values # check reactives/functions
expect_true(shiny::is.reactive(exp_values$react_data))
expect_true(shiny::is.reactive(exp_values$react_x))
expect_true(rlang::is_function(exp_values$react_data))
expect_true(rlang::is_function(exp_values$react_x))
# check data
expect_true(is.data.frame(exp_values$data))
expect_true(ncol(exp_values$x) == 1)
expect_equal(exp_values$data, mtcars)
expect_equal(exp_values$x, mtcars['disp'])
Verify plot with vdiffr
Now we verify the plot with the exported plot_obj
(in the hist
module) with expect_doppelganger()
from the vdiffr
package.
# Verify plot ----
<- app$get_value(output = "hist-hist")
gg2_plot expect_equal(gg2_plot$alt, "Plot object")
::expect_doppelganger(
vdiffrtitle = "mtcars_disp_plot",
fig = ggplot2::ggplot(data = exp_values$data,
mapping =
::aes(x = disp)
ggplot2+
) ::geom_histogram(bins = exp_values$`hist-bins`) +
ggplot2::labs(
ggplot2title = paste0(exp_values$`hist-title`,
" [bins = ",
$`hist-bins`, "]"),
exp_valuesy = "Count",
x = names(exp_values$x)
+
) ::theme_minimal()
ggplot2 )
- 1
-
Check the rendered plot object
- 2
-
ggHistApp()
renders aggplot2
graph, which makes it easier to demonstrate this example of checking a plot from theshinytest2
package website.
Set, get, expect
The process above is repeated with new values passed to app$set_inputs()
and verified with app$get_values()
:
The data-dataset
, var-var
, and hist-bins
are updated again with new values, exported with exportTestValues()
and stored in exp_values
. The new plot is then verified again with expect_doppelganger()
:
# SET ----
$set_inputs(`data-dataset` = "USArrests")
app$set_inputs(`var-var` = 'UrbanPop')
app$set_inputs(`hist-bins` = 15)
app# GET ----
<- app$get_values()$export
exp_values ## EXPECT ----
::expect_doppelganger(
vdiffrtitle = "usaarrests_plot",
fig = ggplot2::ggplot(data = exp_values$data,
mapping =
::aes(x = UrbanPop)
ggplot2+
) ::geom_histogram(bins = exp_values$`hist-bins`) +
ggplot2::labs(
ggplot2title = paste0(exp_values$`hist-title`,
" [bins = ",
$`hist-bins`, "]"),
exp_valuesy = "Count",
x = names(exp_values$x)
+
) ::theme_minimal()
ggplot2 )
The final snapshot files are stroed in tests/testthat/_snaps/
:
tests/testthat/_snaps/
└── shinytest2-ggHistApp
├── mtcars-disp-plot.svg
├── sleep-extra-plot.svg
└── usaarrests-plot.svg
2 directories, 3 files
Results
The final results of devtools::test()
in msst2ap
are below:
::test() devtools
ℹ Testing msst2ap
Loading required package: shiny
✔ | F W S OK | Context
✔ | 3 | shinytest2-datasetApp [3.7s]
✔ | 16 | shinytest2-ggHistApp [11.4s]
✔ | 5 | shinytest2-histogramApp [3.8s]
✔ | 4 | shinytest2-selectVarApp [2.4s]
══ Results ═══════════════════
Duration: 21.3 s
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 28 ]
Recap
This post has covered creating tests with testthat
and shinytest2
for an app-package containing a Shiny application. In general, shinytest2
is designed for end-to-end testing of Shiny applications. System tests (or regression testing) can capture the state of a Shiny app (input, output, and exported values) during user interactions and compare them with a previous state (i.e., snapshots). As we can see, shinytest2
makes it easier to test specific app behaviors and set expectations iteratively with the AppDriver
.
shinytest2
tests can also simulate user interaction in a way that testServer()
tests can’t, such as waiting for reactive outputs to update after the input changes, clicking on action buttons, etc. shinytest2
can also be resource-intensive, so it’s recommended to write these tests after you’ve completed the standard testthat
unit tests and testServer()
tests.
Footnotes
“In some cases, it’s useful to snapshot some bits of internal state of an application – state that’s not reflected directly in the inputs or outputs. This can be done by exporting values.” -
shinytest2
documentation↩︎“It cannot be recommended enough to use
exportTestValues()
to test your Shiny app’s reactive values.” -shinytest2
documentation↩︎This section replicates these test examples from
shinytest2
using theggHistApp()
.↩︎This version is loaded from a
inst/dev/histogramApp/R/modules.R
file.↩︎This version is loaded from a
inst/dev/histogramApp/R/app.R
file.↩︎