All tests are not created equal. Some tests are all encompassing; others are more focused. Each approach has its own advantages and disadvantages.
Goals to have when writing tests are to
Unit tests provide a sense of security by making assertions on expected behavior. If all unit tests pass, then we can declare the object/method to be valid. π¦
Letβs look at a quick example of:
If we only test positive numbers, then we can guess the answer using
+
.
However, if a negative number is used, it will have different
behavior than +
. So we must test for both situations.
Even better, letβs test all four positive/negative situations:
for (info in list(
list(x = 50, y = 42, expected = 92),
list(x = -50, y = 42, expected = 8),
list(x = 50, y = -42, expected = 8),
list(x = -50, y = -42, expected = 92)
)) {
testthat::expect_equal(
add_abs(info$x, info$y),
info$expected
)
}
This concept can even be expanded to vectors of values. However, this can lead to a lot of code.
Tests should strive to only confirm behavior that we have control
over. For example, if we are only interested in the behavior of
abs
, then we should not be concerned with the behavior of
+
.
We can assume that +
is working properly and adds two
numbers together. We do not necessarily need to perform a multitude of
nonsense input testing on +
, but it may make sense to see
how abs()
handles a few non-number input values.
testthat::expect_equal(add_abs(1, NA), NA)
testthat::expect_equal(add_abs(1, NULL), numeric(0))
testthat::expect_error(add_abs(1, "string"))
These tests could also be repeated by swapping x
and
y
.
When writing tests, each test can contain many expectations but each expectation should pertain to the test being run.
For example, the two examples above could be included in the same test block:
# File: tests/testthat/test-add_abs-bad.R
test_that("add_abs() works", {
for (info in list(
list(x = 50, y = 42, expected = 92),
list(x = -50, y = 42, expected = 8),
list(x = 50, y = -42, expected = 8),
list(x = -50, y = -42, expected = 92)
)) {
expect_equal(
add_abs(info$x, info$y),
info$expected
)
}
expect_equal(add_abs(1, NA), NA)
expect_equal(add_abs(1, NULL), numeric(0))
expect_error(add_abs(1, "string"))
})
However, itβs better to break them out into two separate tests, each with their own descriptive title.
# File: tests/testthat/test-add_abs-better.R
test_that("add_abs() adds two numbers", {
for (info in list(
list(x = 50, y = 42, expected = 92),
list(x = -50, y = 42, expected = 8),
list(x = 50, y = -42, expected = 8),
list(x = 50, y = -42, expected = -8),
list(x = -50, y = -42, expected = 92)
)) {
expect_equal(
add_abs(info$x, info$y),
info$expected
)
}
})
test_that("add_abs() handles non-numeric input", {
expect_equal(add_abs(1, NA), NA)
expect_equal(add_abs(1, NULL), numeric(0))
expect_error(add_abs(1, "string"))
})
{testthat}
does a great job of displaying output to the
user when thing go wrong. When the first test goes wrong, an error will
be given like:
ββ Failure (Line 9): add_abs() adds two numbers ββββββββββββββββββββββββββββββββ
add_abs(info$x, info$y) (`actual`) not equal to info$expected (`expected`).
`actual`: 8
`expected`: -8
It is great that an error was found, but it is also difficult to
determine which assertion failed. Adding labels to the expectation using
label
and expected.label
allows you to provide
more context about which test failed.
# File: tests/testthat/test-add_abs-label.R
test_that("add_abs() adds two numbers", {
for (info in list(
list(x = 50, y = 42, expected = 92),
list(x = -50, y = 42, expected = 8),
list(x = 50, y = -42, expected = 8),
list(x = 50, y = -42, expected = -8), # <- Failing line
list(x = -50, y = -42, expected = 92)
)) {
expect_equal(
add_abs(info$x, info$y),
info$expected,
label = paste0("x:", info$x, "; y:", info$y),
expected.label = info$expected
)
}
})
#> ββ Failure (Line 9): add_abs() adds two numbers ββββββββββββββββββββββββββββββββ
#> x:50; y:-42 (`actual`) not equal to info$expected (`expected`).
#>
#> `actual`: 8
#> `expected`: -8
Another pattern that provides more context and allows for more tests
is to move the for-loop around the call to test_that()
and
give the test a custom name:
# File: tests/testthat/test-add_abs-label.R
for (info in list(
list(x = 50, y = 42, expected = 92),
list(x = -50, y = 42, expected = 8),
list(x = 50, y = -42, expected = 8),
list(x = 50, y = -42, expected = -8), # <- Failing line
list(x = -50, y = -42, expected = 92)
)) {
test_that(paste0("add_abs() adds two numbers: [", info$x, ", ", info$y, "]"), {
expect_equal(
add_abs(info$x, info$y),
info$expected
)
})
}
#> Test passed π
#> Test passed π
#> Test passed π
#> ββ Failure (Line 9): add_abs() adds two numbers: [50, -42] βββββββββββββββββββββ
#> add_abs(info$x, info$y) (`actual`) not equal to info$expected (`expected`).
#>
#> `actual`: 8
#> `expected`: -8
#> Test passed π₯
This isolates the test even more and does not let an earlier expectation stop testing a later expectation.
{shinytest2}
expectations{shinytest2}
has a handful of built in expectation
methods:
input
/output
names:
AppDriver$expect_unique_names()
: Assert all
input
and output
names are uniqueAppDriver$expect_values()
: Expect all
input
, output
, and export
values
are consistentAppDriver$expect_download()
: Expect a downloaded file
to downloadableAppDriver$expect_text()
: Expect the text content for a
given selector
to be consistentAppDriver$expect_html()
: Expect the HTML content for a
given selector
to be consistentAppDriver$expect_js()
: Expect the JavaScript return
value to be consistentAppDriver$expect_screenshot()
: Expect a screenshot of
the UI to be consistentinput
/output
namesLetβs take a look at AppDriver$expect_unique_names()
.
This method is called (by default) by
AppDriver$new(check_names = TRUE)
and confirms that no
input
or output
HTML names are duplicated. If
duplicate values are found, this results in invalid HTML and possible
failure within Shiny.
One way to help keep input
and output
names
unique is to adopt a naming behavior such as adding _out
to
the end of any output
,
e.g.Β outputs$text_out
.
If you use a dynamic UI and want to reassert the names are still
unique, it is perfectly acceptable to call
AppDriver$expect_unique_names()
after setting any dynamic
UI values.
AppDriver$expect_values()
is the preferred method for
testing in {shinytest2}
. This method tests different
input
, output
, and export
values
provided by the Shiny application.
input
corresponds to the input
values
provided by the Shiny application.output
corresponds to the output
values
provided by the Shiny application.export
corresponds to value that have been _export_ed
by the Shiny application. These values are exported by shiny::exportTestValues()
from within your server
function.When AppDriver$expect_values()
is called, each
input
, output
, and export
value
will be serialized to JSON and saved to a snapshot file
(e.g.Β 001.json
) for followup expectations.
In addition to this value snapshot file, a debug screenshot
will be saved to the snapshot directory with its file name ending in
_.png
(e.g.Β 001_.png
). This screenshot is
useful for knowing what your application looked like when the values
where captured. However, differences in the captured screenshot will
never cause test failures. It is recommended to add
_.new.png
to your .gitignore
file to ignore
any new debug screenshots that have not been accepted.
For typical app testing, this method should cover most of your testing needs. (Remember, try to only test the content you have control over.)
When shiny::downloadButton()
or
shiny::downloadLink()
elements are clicked, a file is
downloaded. To make an expectation on the downloaded file, you can use
AppDriver$expect_download()
.
In addition to the file being saved, a snapshot of the file name being downloaded will be saved if a suggested file name is used.
Two methods are provided as a middle ground between taking a
screenshots and testing Shiny app values:
AppDriver$expect_text()
and
AppDriver$expect_html()
.
AppDriver$expect_text(selector=)
asks the Chrome browser
for the current text contents within the selected elements. This method
is great to test contents that are not input
or
output
values. AppDriver$expect_text()
is not
able to retrieve pseudo elements or values such as the text inside
<text>
or <textarea>
input
elements.
AppDriver$expect_html(selector=)
asks the Chrome browser
for the current DOM structures within the selected elements. This method
is perfect for app authors who are constructing their own DOM structures
and want to verify that they are consistently being produced. Again,
AppDriver$expect_html()
is not able to retrieve pseudo
elements or values such as the text inside <text>
or
<textarea>
input elements.
Finally, both of these methods wrap around
AppDriver$expect_js(script=)
. This method executes a
script
and the return value is saved as a snapshot.
AppDriver$expect_text()
and
AppDriver$expect_html()
are wrappers around
AppDriver$expect_js()
.
Taking a screenshot is the most brittle expectation provided by
{shinytest2}
. AppDriver$expect_screenshot()
should only be used if it is absolutely necessary or if you are willing
to handle many false-positive failures.
There are many ways a screenshot expectation can fail that is unrelated to your code, including:
{bslib}
,
{htmltools}
) changed its DOM structureApp authors who use custom CSS or JavaScript may find this method useful. But it is still strongly recommended to only test the content you have control over to avoid false-positive failures.
When expecting a screenshot, a AppDriver$new(variant=)
must be supplied. The value may be NULL
, but it is
recommended to use shinytest2::platform_variant()
.
It cannot be recommended enough to use shiny::exportTestValues()
to test your Shiny appβs reactive values.
If we make the assumption that package authors create consistent
render methods, then we can test the values provided to render methods
using shiny::exportTestValues()
. Letβs look at an example
of a Shiny app that displays a plot of the first n
rows of
data.
It is recommended to make your render methods contain minimal logic so that the value being rendered can also be exported.
library(shiny)
library(ggplot2)
ui <- fluidPage(
numericInput("n", "Number of rows", 10, 1, nrow(cars)),
plotOutput("plot")
)
server <- function(input, output) {
dt <- reactive({
head(cars, input$n)
})
plot_obj <- reactive({
ggplot(dt(), aes_string("speed", "dist")) + geom_point()
})
output$plot <- renderPlot({
plot_obj()
})
exportTestValues(
dt = dt(),
plot_obj = plot_obj()
)
}
plot_app <- shinyApp(ui = ui, server = server)
plot_app
Both dt
and plot_obj
have been
export
ed.Β This means that they are available when executing
AppDriver$get_values()
, AppDriver$get_value()
,
and AppDriver$expect_values()
.
app <- AppDriver$new(plot_app)
values <- app$get_values()
str(values) # Output has been truncated for printing
#> List of 3
#> $ input :List of 1
#> ..$ n: int 10
#> $ output:List of 1
#> ..$ plot:List of 5
#> .. ..$ src : chr "data:image/png;base64,iVBORw0"| __truncated__
#> .. ..$ width : int 962
#> .. ..$ height : int 400
#> .. ..$ alt : chr "Plot object"
#> .. ..$ coordmap:List of 2 | __truncated__
#> $ export:List of 2
#> ..$ dt :'data.frame': 10 obs. of 2 variables:
#> .. ..$ speed: num [1:10] 4 4 7 7 8 9 10 10 10 11
#> .. ..$ dist : num [1:10] 2 10 4 22 16 10 18 26 34 17
#> ..$ plot_obj:List of 9
#> .. ..$ data :'data.frame': 10 obs. of 2 variables:
#> .. .. ..$ speed: num [1:10] 4 4 7 7 8 9 10 10 10 11
#> .. .. ..$ dist : num [1:10] 2 10 4 22 16 10 18 26 34 17
#> .. ..$ layers :List of 1 | __truncated__
#> .. ..$ scales :Classes 'ScalesList', 'ggproto', 'gg' <ggproto object: Class | __truncated__
#> .. ..$ mapping :List of 2 | __truncated__
#> .. ..$ theme : list() | __truncated__
#> .. ..$ coordinates:Classes 'CoordCartesian', 'Coord', 'ggproto', 'gg' <ggproto | __truncated__
#> .. ..$ facet :Classes 'FacetNull', 'Facet', 'ggproto', 'gg' <ggproto object: Class | __truncated__
#> .. ..$ plot_env :<environment: 0x7fad13bd3d58>
#> .. ..$ labels :List of 2
#> .. .. ..$ x: chr "speed"
#> .. .. ..$ y: chr "dist"
#> .. ..- attr(*, "class")= chr [1:2] "gg" "ggplot"
Plots are notoriously hard to test consistently over time and
different platforms. Similar to
AppDriver$expect_screenshot()
, there are many ways outside
of your code that the plot can produce a change which would fail a
visual test. Some of these include:
When possible, it is recommended to use {ggplot2}
over
base::plot()
as {ggplot2}
is getting closer
and closer to cross platform compatible. {ggplot2}
objects
can also be inspected and compared using {vdiffr}
for cross
platform support.
Snapshot testing makes the test writing minimal and quick. However, this comes with a logical risk.
Snapshots are wonderful at determining if a change has occurred. But for the first execution of the snapshot there is nothing to compare, so the first value is taken as truth even if it is not correct. This allows for the opportunity for invalid values to be saved as truth. Most of the time, it is OK to use snapshots from the beginning of your testing experience, but keeping track of each snapshot file can be tedious when testing multiple applications with multiple variants.
To remedy this possibility, app values can be tested against known
static values. For example, instead of testing the initial value of
dt()
against a snapshot, we can test it against
head(cars, 10)
.
# Snapshot code
app$expect_values(export = "dt")
# Manual expectation code
expect_equal(
app$get_value(export = "dt"),
head(cars, 10)
)
If you have the time to manually inspect the snapshot files
afterwards, using AppDriver
snapshot code provides clean,
minimal code. If snapshot files are unwieldy, you can use
AppDriver
to retrieve values to test against known
values.
Letβs look at how we could write a {shinytest2}
test to
make sure the plot is updated after updating our numeric
input
n
. Assuming the Shiny app code above is
saved in app.R
, we can look at
tests/testthat/test-export.R
below. To successfully run the
code below, be sure to have {vdiffr}
installed.
Since we are not calling AppDriver$expect_screenshot()
or AppDriver$get_screenshot()
, we do not need to use
AppDriver$new(variant=)
.
# File: ./tests/testthat/test-export.R
# App file: ./app.R
library(shinytest2)
test_that("`export`ed `plot_obj` is updated by `n`", {
skip_if_not_installed("vdiffr")
app <- AppDriver$new()
# Verify `dt()` uses first 10 lines of `cars`
n10 <- app$get_value(input = "n")
expect_equal(n10, 10)
# Verify `dt10()` data is first 10 lines of `cars`
dt10 <- app$get_value(export = "dt")
expect_equal(dt10, head(cars, n10))
# Verify `plot_obj()` data is `dt()`
plot_obj_10 <- app$get_value(export = "plot_obj")
expect_equal(plot_obj_10$data, dt10)
# Verify `plot_obj()` is consistent
vdiffr::expect_doppelganger("cars-points-10", plot_obj_10)
## Update `n` to 20
app$set_inputs(n = 20)
# Verify `n` was updated
n20 <- app$get_value(input = "n")
expect_equal(n20, 20)
# Verify `dt()` uses first 20 lines of `cars`
dt20 <- app$get_value(export = "dt")
expect_equal(dt20, head(cars, n20))
# Verify `plot_obj()` data is `dt()`
plot_obj_20 <- app$get_value(export = "plot_obj")
expect_equal(plot_obj_20$data, dt20)
vdiffr::expect_doppelganger("cars-points-20", plot_obj_20)
})
The second half of the test performs similar expectations to the first half of the test. This code can be pulled into a helper function and be written as:
# File: tests/testthat/test-export.R
library(shinytest2)
test_that("`export`ed `plot_obj` is updated by `n`", {
skip_if_not_installed("vdiffr")
app <- AppDriver$new(variant = platform_variant())
expect_n_and_plot <- function(n) {
# Verify `n` input equals `n`
n_val <- app$get_value(input = "n")
expect_equal(n_val, n, expected.label = n)
# Verify `dt()` data is first `n` lines of `cars`
dt <- app$get_value(export = "dt")
expect_equal(dt, head(cars, n), expected.label = paste0("head(cars, ", n, ")"))
# Verify `plot_obj()` data is `dt()`
plot_obj <- app$get_value(export = "plot_obj")
expect_equal(plot_obj$data, dt, info = paste0("n = ", n))
# Verify `plot_obj()` is consistent
vdiffr::expect_doppelganger(paste0("cars-points-", n), plot_obj)
}
expect_n_and_plot(10)
# Update `n` to 20
app$set_inputs(n = 20)
expect_n_and_plot(20)
})
It should be noted that {ggplot2}
objects can not be
serialized to JSON
and should be excluded from the
AppDriver$expect_values()
snapshots.
AppDriver$get_values()
,
AppDriver$get_value()
:
saveRDS()
expect_equal()
) will need to be explicitly stated
from the beginning{ggplot2}
plotsAll AppDriver$expect_*()
should have their snapshots
manually inspected when first created to ensure they contain expected
content.
AppDriver$expect_values()
:
input
, output
, and export
valuesshinytest2::record_app()
{ggplot2}
plots) cannot be serialized to JSONAppDriver$get_value()
and
AppDriver$get_values()
AppDriver$expect_download()
:
shinytest2::record_app()
filename
AppDriver$get_download()
AppDriver$expect_text()
:
selector
AppDriver$expect_html()
, as it
gives more freedom to UI authors to make HTML structure changesselector
AppDriver$get_text()
AppDriver$expect_html()
selector
AppDriver$expect_screenshot()
,
and gives more freedom to UI authorsselector
AppDriver$get_html()
AppDriver$expect_js()
:
AppDriver$expect_text()
and
AppDriver$expect_html()
AppDriver$get_js()
AppDriver$expect_screenshot()
:
"html"
)
for later comparisonAppDriver$get_screenshot()