Appendix E — Mocks and snapshots

Published

2024-09-11

I’ve created the shinypak R package In an effort to make each section accessible and easy to follow:

Install shinypak using pak (or remotes):

# install.packages('pak')
pak::pak('mjfrigaard/shinypak')

Review the chapters in each section:

library(shinypak)
list_apps(regex = '^A')
## # A tibble: 1 × 2
##   branch              last_updated       
##   <chr>               <dttm>             
## 1 A.E_mocks-snapshots 2024-02-13 12:48:31

Launch the app with launch()

launch(app = "A.E_mocks-snapshots")

Download the app with get()

get_app(app = "A.E_mocks-snapshots")

E.1 Snapshots

If we want to create a graph snapshot test, the vdiffr package allows us to perform a ‘visual unit test’ by capturing the expected output as an .svg file that we can compare with future versions.

Launch app with the shinypak package:

launch('A.E-mocks-snapshots')

The expect_doppelganger() function from vdiffr is designed specifically to work with ‘graphical plots’.

vdiffr::expect_doppelganger(
      title = "name of graph", 
      fig = # ...code to create graph...
  )

Another option for using snapshots for testing is the expect_snapshot_file() function 1 but expect_doppelganger() is probably the better option for comparing graph outputs.

E.1.1 Testing graph outputs

The Feature for the initial graph output from scatter_plot() might look like:

testthat::describe(
  "Feature: Scatter Plot Configuration in Movie Review Application
    As a user who accesses the movie review application,
    I want the initial scatter plot pre-configured with variables and aesthetics,
    So that I can immediately see a meaningful visualization.", code = {
    
})

Combining scenarios in the same test file is helpful if we’re trying to keep a 1:1 between the test/testthat/ file names and file names in R/.2

testthat::it(
  "Scenario: Create scatter plot
      Given I have launched the movie review exploration app,
      When the scatter plot renders,
      Then the points on the x axis should represent 'Ratings'
      And the points on the y axis should represent 'Length'
      And the points should be colored by 'MPAA' rating
      And the opacity of the points should be set to '0.5'
      And the size of the points should be set to '2'
      And the plot title should be set to 'Enter plot title'",
  code = {
    
    test_logger(
      start = "snap scatter_plot()", 
      msg = "initial x,y,z,size,alpha")

    scatter_inputs <- list(
      x = "imdb_rating",
      y = "audience_score",
      z = "mpaa_rating",
      alpha = 0.5,
      size = 2,
      plot_title = "Enter plot title"
    )

    vdiffr::expect_doppelganger(
      title = "Initial x y z axes",
      fig = scatter_plot(movies,
        x_var = scatter_inputs$x,
        y_var = scatter_inputs$y,
        col_var = scatter_inputs$z,
        alpha_var = scatter_inputs$alpha,
        size_var = scatter_inputs$size
      ) +
        ggplot2::labs(
          title = scatter_inputs$plot_title,
          x = stringr::str_replace_all(
            tools::toTitleCase(
              scatter_inputs$x
            ), "_", " "
          ),
          y = stringr::str_replace_all(
            tools::toTitleCase(
              scatter_inputs$y
            ), "_", " "
          )
        ) +
        ggplot2::theme_minimal() +
        ggplot2::theme(legend.position = "bottom")
    )

    test_logger(
      end = "snap scatter_plot()", 
      msg = "initial x,y,z,size,alpha")
    
  }
)
1
Test scope
2
Log start
3
Initial movies variable inputs for x, y, and z from UI
4
Snapshot with initial values
5
Log end

Test results also return the output from test_logger() with the context I’ve added on what’s being tested.

E.1.2 Snapshots

We also see a warning when the snapshot has been saved in the tests/testthat/_snaps/ folder the first time the test is run:

── Warning (test-scatter_plot.R:124:9): 
      Scenario: Create scatter plot
          Given I have launched the movie review exploration app,
          When the scatter plot renders,
          Then the points on the x axis should represent 'Ratings'
          And the points on the y axis should represent 'Length'
          And the points should be colored by 'MPAA' rating
          And the size of the points should be set to '2'
          And the opacity of the points should be set to '0.5' ──
Adding new file snapshot: 'tests/testthat/_snaps/initial-x-y-z-axes.svg'

── Warning (test-scatter_plot.R:186:7): 
      Scenario: Change x, y, color values for plotting
        When I launch the Scatter Plot Data Visualization
        And I select the variable 'Audience Score' for the x-axis
        And I select the variable 'IMDB Rating' for the y-axis
        And I select the variable 'Critics Rating' for the color
        Then the scatter plot should show 'Audience Score' on the x-axis
        And the scatter plot should show 'IMDB Rating' on the y-axis
        And the points on the scatter plot should be colored by 'Critics Rating' ──
Adding new file snapshot: 'tests/testthat/_snaps/updated-x-y-color.svg'
[ FAIL 0 | WARN 2 | SKIP 0 | PASS 2 ]

On subsequent runs, this warning will disappear (as long as there are no changes to the .svg files).

INFO [2023-10-27 10:58:25] [ START snap scatter_plot() = initial x,y,z,size,alpha]
[ FAIL 0 | WARN 1 | SKIP 0 | PASS 3 ]
INFO [2023-10-27 10:58:25] [ END snap scatter_plot() = initial x,y,z,size,alpha]

E.1.3 Comparing graph objects

Below is the output from diffobj::diffObj() comparing our custom plotting function (scatter_plot()) against a graph built with analogous ggplot2 code:

ggp_graph <- ggplot2::ggplot(mtcars, 
              ggplot2::aes(x = mpg, y = disp)) + 
              ggplot2::geom_point(
                ggplot2::aes(color = cyl), 
                             alpha = 0.5, 
                             size = 3)
  
app_graph <- scatter_plot(mtcars, 
                  x_var = "mpg", 
                  y_var = "disp", 
                  col_var = "cyl", 
                  alpha_var = 0.5, 
                  size_var = 3)

diffobj::diffObj(ggp_graph, app_graph)

diffobj::diffObj() on graph outputs
Figure E.1: Graph objects are difficult to use as test objects

The output shows us all the potential points of failure when comparing complex objects like graphs (despite the actual outputs appearing identical), so it’s best to limit the number of ‘visual unit tests’ unless they’re absolutely necessary.

I’ve included additional snapshot tests (test-text_logo.R) in the A.E-mocks-snapshots branch of sap:

tests/testthat/
    ├── test-scatter_plot.R
    └── test-text_logo.R

E.2 Mocks

Test code may rely on external systems, behavior, functions, or objects. To ensure that our unit tests remain fast and focused solely on the functional requirement being tested, it’s important to minimize these external dependencies.

The mocking functions can be used to substitute functions by emulating their behavior within the test scope (in BDD terms, mocks are creating the Given conditions).3

Launch app with the shinypak package:

launch('A.E-mocks-snapshots')

E.2.1 Example: mocking add-on functions

We’ll use local_mocked_bindings() from testthat to mock the behavior of rlang::is_installed().4 Instead of real-time computations, mocks return predefined responses to given inputs. Consider the check_installed() function below:

check_installed <- function(package) {
  if (is_installed(package)) {
    return(invisible())
  } else {
    stop("Please install '{package}' before continuing")
  }
}

Below is a feature description for check_installed() and two scenarios for each expected behavior:

Feature: Checking if an R package is installed

  Scenario: Checking an installed package
    Given the R package 'base' is installed
    When I call the `check_installed()` function with 'base'
    Then the function should return without any error

  Scenario: Checking an uninstalled package
    Given the R package 'foo' is not installed
    When I call the `check_installed()` function with 'foo'
    Then the function should raise an error with the message
      `Please install 'nonexistent_package' before continuing`

The check_installed() shouldn’t be confused with rlang::check_installed(), which checks if a package is installed, and if it isn’t, prompts the user install the package using pak::pkg_install().

Lets review how is_installed() behaves with installed and missing packages:

rlang::is_installed('foo')
## [1] FALSE
rlang::is_installed('base')
## [1] TRUE

The version of check_installed() in sap will check if a package is installed and return invisible() if it is (which, when assigned to an object, evaluates to NULL):

check_installed('base')
x <- check_installed('base')
x
## NULL

If the package is not installed, check_installed() prints an error message:

check_installed('foo')
## Error in check_installed("foo"): Please install '{package}' before continuing

To use mocking with is_installed(), we’ll use the following syntax:

local_mocked_bindings(
  {local function} = function(...) {value}
)

In this case, {local function} is is_installed() from rlang, and we want to test the two possible {value}s (TRUE/FALSE).

In the first test, we’ll use expect_error() to confirm that the error message is returned for an uninstalled package by using local_mocked_bindings() and setting the is_installed() value to FALSE:

describe("Feature: Checking if an R package is installed", {
  
  test_that(
    "Scenario: Checking an uninstalled package
        Given the R package 'foo' is not installed
        When I call the `check_installed()` function with 'foo'
        Then the function should raise an error with the message
        `Please install 'nonexistent_package' before continuing`", {
          
    test_logger(start = "mock is_installed", msg = "FALSE")
    local_mocked_bindings(is_installed = function(package) FALSE)
    expect_error(object = check_installed("foo"))
    test_logger(end = "mock is_installed", msg = "FALSE")
    
  })
  
})
1
Log test start and end
2
Set {value} to FALSE
3
Pass a package we know is not installed

To test installed packages, we’ll confirm check_installed('foo') with expect_invisible():

describe("Feature: Checking if an R package is installed", {
  
  test_that(
    "Scenario: Checking an installed package
        Given the R package 'base' is installed
        When I call the `check_installed()` function with 'base'
        Then the function should return without any error", {
          
    test_logger(start = "mock is_installed", msg = "TRUE")
    local_mocked_bindings(is_installed = function(package) TRUE)
    expect_invisible(check_installed("base"))
    test_logger(end = "mock is_installed", msg = "TRUE")
    
  })
})
1
Log test start and end
2
Set {value} to TRUE
3
Pass a package we know is installed

The output from the tests above is provided below:

[ FAIL 0 | WARN 0 | SKIP 0 | PASS 0 ]
INFO [2023-10-08 22:59:43] [ START mock is_installed = FALSE]
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 1 ]
INFO [2023-10-08 22:59:43] [ END mock is_installed = FALSE]

INFO [2023-10-08 22:59:43] [ START mock is_installed = TRUE]
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]
INFO [2023-10-08 22:59:43] [ END mock is_installed = TRUE]

E.2.2 Notes on mocking

The roxygen2 documentation for check_installed() uses the @importFrom tag to import is_installed and add it to the sap namespace (using explicit namespacing alone won’t work):

#' Check if package is installed
#' 
#' @description
#' An example function for demonstrating how to use `testthat`'s
#' mocking functions.
#' 
#' @param package string, name of package
#'
#' @return invisible 
#'
#' @importFrom rlang is_installed
#'
#' @export
#'
#' @examples
#' check_installed("foo")
#' check_installed("base")
1
Fortunately we already included rlang in our DESCRIPTION file for .data in scatter_plot()
Recap: test snapshots & mocks

Snapshots

vdiffr: create graph snapshots with the expect_doppelganger() function from vdiffr

As stated before, snapshots are brittle and can produce false negatives test failures (i.e., due to inconsequential changes in the graph) when comparing a new graph to the baseline image.

Test mocks

Using testthat’s mocking functions allow us to craft unit tests that evaluate a single, specific behavior. Read more about mocking functions on the testthat webite.

Please open an issue on GitHub


  1. Follow the expect_snapshot_file() example from the testthat documentation↩︎

  2. matching files names between R/ and tests/testthat/ keeps our code organized and ensures the devtools::test_coverage_active_file() function works.↩︎

  3. Test mocking functions are a relatively new addition to testthat. Read more in the recent updates to testthat↩︎

  4. This example comes from the package development masterclass workshop at posit::conf(2023).↩︎