--- title: "Robust testing" output: rmarkdown::html_vignette editor_options: chunk_output_type: console vignette: > %\VignetteIndexEntry{Robust testing} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, eval = FALSE) ``` 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 - Confirm the expected behavior - Assert as little unnecessary information - Write clear, direct tests # Expectations 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: ``` r add_abs <- function(x, y) { abs(x + y) } ``` #### Confirm the expected behavior If we only test positive numbers, then we can guess the answer using `+`. ``` r x <- 50; y <- 42 testthat::expect_equal(add_abs(x, y), x + y) ``` However, if a negative number is used, it will have different behavior than `+`. So we must test for both situations. ``` r x <- 50; y <- -42 testthat::expect_equal(add_abs(x, y), x + y) ``` Even better, let's test all four positive/negative situations: ``` 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 = 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. #### Assert as little unnecessary information 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. ``` r 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`. #### Write clear, direct tests 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: ``` r # 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. ``` r # 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. ``` r # 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: ``` r # 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 unique - Shiny value expectations: - `AppDriver$expect_values()`: Expect all `input`, `output`, and `export` values are consistent - `AppDriver$expect_download()`: Expect a downloaded file to downloadable - UI expectations: - `AppDriver$expect_text()`: Expect the text content for a given `selector` to be consistent - `AppDriver$expect_html()`: Expect the HTML content for a given `selector` to be consistent - `AppDriver$expect_js()`: Expect the JavaScript return value to be consistent - UI visual expectations: - `AppDriver$expect_screenshot()`: Expect a screenshot of the UI to be consistent ## `input`/`output` names Let'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. ## Shiny 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()`](https://shiny.rstudio.com/reference/shiny/latest/exportTestValues.html) 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.) ### Downloads 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. ## UI expectations 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 `` or `