Developing & Testing Your Shiny Application

Martin Frigaard (Atorus)

Introduction


Why are you here?



Comfortable building shiny apps, not as comfortable with R packages


Encountered frameworks (golem, leprechaun, or rhino) and not sure which one to use


Comfortable building R packages, but want to build more app-packages

Agenda


Shiny stuff

  • Shiny

  • Packages

  • Development

Package stuff

  • Documentation

  • Dependencies

  • Data

App-package stuff

  • Launch

  • External files

  • Testing

Shiny

  • The learning modules in this course also serve as ‘prerequisites’ for many of the topics covered here

New Shiny app


  • Shiny apps only require two files (README.md is optional)

  • Boilerplate app code in app.R
moviesApp/
├── README.md
├── app.R
└── moviesApp.Rproj

1 directory, 3 files

Shiny code


Slightly more developed:

  • Updates to app.R

  • Utility function (utils.R)

moviesApp/
├── README.md
├── app.R
├── movies.RData
├── moviesApp.Rproj
└── utils.R

1 directory, 5 files

Shiny project folders


Shiny app project folders:

  • R/

  • www/


moviesApp/
├── DESCRIPTION
├── R
   ├── mod_scatter_display.R
   ├── mod_var_input.R
   └── utils.R
├── README.md
├── app.R
├── movies.RData
├── moviesApp.Rproj
└── www
    └── shiny.png

3 directories, 9 files

Shiny loadSupport()


Shiny automatically sources the files in the R/ folder and serves the external resources in www/

  • loads any top-level supporting .R files in the R/ directory adjacent to the app.R/server.R/ui.R files - Shiny loadSupport()

DESCRIPTION files


The DESCRIPTION file can be used to control the DisplayMode (i.e., Showcase)

Type: shiny
Title: movies app
Author: John Smith
DisplayMode: Showcase

DESCRIPTION fields



The Package, Version, License, Description, Title, Author, and Maintainer fields are mandatory.’ - Writing R Extensions

Package: moviesApp
Title: movies app
Version: 0.0.0.9000
Author: John Smith [aut, cre]
Maintainer: John Smith <John.Smith@email.io>
Description: A movie-review shiny application.
License: GPL-3

.Rproj files


The .Rproj file is a plain text configuration file for the Posit Workbench IDE.

moviesApp/
├── DESCRIPTION
├── R
   ├── mod_scatter_display.R
   ├── mod_var_input.R
   └── utils.R
├── README.md
├── app.R
├── movies.RData
├── moviesApp.Rproj
└── www
    └── shiny.png

3 directories, 9 files

Shiny project .Rproj fields


Shiny apps built with the New Project Wizard have the following fields:

Version: 1.0

RestoreWorkspace: Default
SaveWorkspace: Default
AlwaysSaveHistory: Default

EnableCodeIndexing: Yes
UseSpacesForTab: Yes
NumSpacesForTab: 2
Encoding: UTF-8

RnwWeave: Sweave
LaTeX: XeLaTeX

R Package (Project build tools)


These are accessible under:

Tools > Project Options… > Build Tools


Change Project build tools to Package

R Package .Rproj fields



These add the following fields to the .Rproj file:

BuildType: Package
PackageUseDevtools: Yes
PackageInstallArgs: --no-multiarch --with-keep.source
PackageRoxygenize: rd,collate,namespace

Creating new app-packages


Create new Shiny app-packages (or convert Shiny projects) with usethis::create_package()


Assuming it’s being called from the desired location of your new app-package:


usethis::create_package(path = getwd())

This creates an app similar to the version we saw in main:

newApp/
  ├── .Rbuildignore
  ├── .Rproj.user/
  ├── .gitignore
  ├── DESCRIPTION
  ├── NAMESPACE
  ├── R/
  └── newApp.Rproj

Converting Shiny apps (fields)


Many of the DESCRIPTION fields inevitably require revision


We can handle this during the creation process with the fields argument

fields = list(Package = 'moviesApp',
       Version = '0.0.0.9000',
       Title = 'movies app',
       Description = 'A movie-review Shiny application.',
       "Authors@R" = NULL,
       Author = utils::person(
          given = "John", 
          family = "Smith", 
          email = "John.Smith@email.io", 
          role = c("aut", "cre")),
        Maintainer = utils::person(
          given = "John", 
          family = "Smith",
          email = "John.Smith@email.io"),
        License = "GPL-3")

Converting Shiny apps (IDE options)


check_name: verifies your Shiny app-package name is valid for CRAN


open: can be set to FALSE if you don’t need Posit Workbench to open in a new session

usethis::create_package(path = getwd(),
  fields = list(Package = 'moviesApp',
         Version = '0.0.0.9000',
         Title = 'movies app',
         Description = 'A movie-review Shiny application.',
         "Authors@R" = NULL,
         Author = utils::person(
            given = "John", 
            family = "Smith", 
            email = "John.Smith@email.io", 
            role = c("aut", "cre")),
          Maintainer = utils::person(
            given = "John", 
            family = "Smith",
            email = "John.Smith@email.io"),
          License = "GPL-3"),
  check_name = FALSE, 
  open = FALSE)

Development with devtools


load_all() is the development function you’ll use the most during package development


document() creates the help files and writes the NAMESPACE


install() installs a local version of your app-package

Development: load_all()


load_all() removes friction from the development workflow and eliminates the temptation to use workarounds that often lead to mistakes around namespace and dependency management’ - Benefits of load_all(), R Packages, 2ed


devtools::load_all()
 Loading moviesApp


Load the package when anything changes in the R/ folder.

Development: document()


document() writes dependencies into the NAMESPACE and creates the help files in the man/ folder


devtools::document()
First time using roxygen2. Upgrading automatically...
Setting `RoxygenNote` to "7.2.3"
Warning message:
roxygen2 requires Encoding: "UTF-8"
 Current encoding is NA 


Updated DESCRIPTION fields

RoxygenNote: 7.2.3
Encoding: UTF-8

Development: document()



Future calls to document() will result in the following output:


devtools::document()
==> devtools::document(roclets = c('rd', 'collate', 'namespace'))

 Updating moviesApp
  documentation
 Loading moviesApp
Documentation completed

Development: install()


Install a package after the initial setup, after major changes to the code, documentation, or dependencies, and before committing or sharing.


devtools::install()
==> R CMD INSTALL --preclean --no-multiarch --with-keep.source moviesApp

* installing to library ‘/path/to/pkg/R-4.2/x86_64-apple-darwin17.0’
* installing *source* package ‘moviesApp’ ...
** using staged installation
** R
** byte-compile and prepare package for lazy loading
No man pages found in package  ‘moviesApp’ 
** help
*** installing help indices
** building package indices
** testing if installed package can be loaded from temporary location
** testing if installed package can be loaded from final location
** testing if installed package keeps a record of temporary installation path
* DONE (moviesApp)

Development: check()?


devtools::check() performs a series of checks to ensure a package meets the standards set by CRAN


Consider check() like ‘quality control’ for:

  • Documentation
  • NAMESPACE dependencies
  • Unnecessary or non-standard folders and files
  • etc.

R Packages recommends using check() often, but I agree with the advice in Mastering Shiny on using check() with app-packages,

“I don’t recommend that you [call check()] the first time, the second time, or even the third time you try out the package structure. Instead, I recommend that you get familiar with the basic structure and workflow before you take the next step to make a fully compliant package.”

Ready

BREAK!

R Packages

Recap: Shiny stuff


  • DESCRIPTION file and fields

  • .Rproj files and fields

  • devtools functions (load_all(), document(), install())

Documentation: roxygen2


  1. Each function needs:
    • @title, @description, @param(s), @return, and @examples (if applicable)
  2. Regularly load and document to preview the help files
    • load_all() & document()
  1. Application functions

    • Link modules with @seealso
    • Group functions with @family
  2. Use Markdown for code, emphasis, hyperlinks, etc.

  3. Include any additional information in a new @section

Documentation: roxygen2 & document()


man
├── mod_scatter_display_server.Rd
├── mod_scatter_display_ui.Rd
├── mod_var_input_server.Rd
├── mod_var_input_ui.Rd
├── movies_app.Rd
├── movies_server.Rd
├── movies_ui.Rd
└── scatter_plot.Rd

1 directory, 8 files

Dependencies


What happens when we load, document, and install moviesApp, then try to launch the standalone app function?


devtools::load_all()
devtools::document()
devtools::install()


library(moviesApp)
movies_app()
Error in movies_app() : could not find function "movies_app"

Dependencies: exports


How can I export movies_app() from moviesApp?


Exporting functions:

  • Use @export tag from roxygen2

  • Name argument is optional

  • Run document() to write NAMESPACE

# Generated by roxygen2: do not edit by hand

export(movies_app)
export(scatter_plot)


Dependencies: imports


Add the package name to the Imports field in the DESCRIPTION

usethis::use_package('pkg')

Use a ‘fully qualified variable reference’ in the code below R/

pkg::fun()

Use @importFrom if an object can’t be imported with :: (i.e., an operator)

#' @importFrom rlang .data

Use the @import tag if your code uses a lot of functions from a package

#' @import shiny

Dependencies: imports cont.

  • document() writes the NAMESPACE (not the DESCRIPTION)


  • All packages in NAMESPACE must be in DESCRIPTION

I highly recommend reading Confusion about imports in R packages, 2ed

Dependencies: recap


Updates to NAMESPACE

# Generated by roxygen2: do not edit by hand

export(movies_app)
export(scatter_plot)
import(shiny)
importFrom(rlang,.data)


Updates to DESCRIPTION

Imports: 
    ggplot2,
    rlang,
    shiny,
    shinythemes,
    stringr

Data


There are three locations for data in your app-package:



data/

data-raw/

inst/extdata/

Data: package data


data/

  • Contains package data
  • Accessible via namespace (i.e., pkg::data) or data() function
  • Add data files to data with usethis::use_data()
data
  ├── movies.RData
  └── movies.rda

1 directory, 2 files

Data: raw data files


data-raw/

  • Scripts and files used to create data in data/
  • Create data-raw files with usethis::use_data_raw()
  • For example, the scripts used to create movies.RData might look like:


data-raw
  ├── all.csv
  ├── movies.csv
  ├── pull_rotten_data.R
  ├── rotten.R
  └── scrape_boxoffice.R

1 directory, 5 files

Data: external data files


inst/extdata/

  • inst/extdata can be used for external datasets in other file formats (.csv, .sas7bdat, .txt, .xlsx, etc)
inst/
└── extdata
    └── movies.fst

2 directories, 1 file
  • Import these files with the system.file() file path accessor function
fst::read_fst(path = 
      system.file("extdata/", "movies.fst", 
                  package = "moviesApp")
    )

Using system.file()


system.file() gives us access to the contents of our installed package

This is your package.

├── DESCRIPTION
├── NAMESPACE
├── R
├── README.md
├── app.R
├── data
├── inst
   └── extdata
       └── movies.fst
├── man
├── moviesApp.Rproj
└── www
    └── shiny.png

This is your package, installed

├── DESCRIPTION
├── INDEX
├── Meta
├── NAMESPACE
├── R
├── data
├── extdata
   └── movies.fst
├── help
└── html

The contents of the inst/ subdirectory will be copied recursively to the installation directory.” - Writing R extensions, Package subdirectories

Ready

BREAK!

App-packages

The next section covers practices specific to app-packages.


  • Launching apps

    • app.R

    • Standalone app function

  • External resources

    • www/

    • App UI function arguments

    • dev/ and prod/ apps

Launching apps


There are two common methods for launching Shiny apps:


A standalone app function:


moviesApp/
  └── R/
      └── movies_app.R

An app.R file:


moviesApp/
  └── app.R

Launching apps: movies_app()


What should go in the standalone app function?


Check for interactive()

if (interactive()) { 
    # define interactive behavior
  } else {
    # define non-interactive behavior
  }

Include argument for Shiny options

movies_app <- function(options = list()) {
    shinyApp(
      ui = movies_ui(), 
      server = movies_server,
      options = options
    )
}

Launching apps: display_type()


The display_type() is a helper function that controls where the application is launched.


Has options for viewer pane, window, and browser.


Prints option to the Console

display_type <- function(run = "w") {
  if (run == "p") {
    options(shiny.launch.browser = .rs.invokeShinyPaneViewer)
  } else if (run == "b") {
    options(shiny.launch.browser = .rs.invokeShinyWindowExternal) 
  } else if (run == "w") {
    options(shiny.launch.browser = .rs.invokeShinyWindowViewer) 
  } else {
    options(shiny.launch.browser = NULL)
  }
    shinyViewerType <- getOption('shiny.launch.browser') |> 
                        attributes() |> 
                        unlist() |> 
                        unname()
    cli::cli_alert_info("shinyViewerType set to {shinyViewerType}")
}

Launching apps: movies_app()


Include display_type() if movies_app() is interactive


Include default run argument to your preference

movies_app <- function(options = list(), run = "p") {
  if (interactive()) {
    display_type(run = run)
  }
    shinyApp(
      ui = movies_ui(),
      server = movies_server,
      options = options
    )
}

Launching apps: app.R


What should go in app.R?


Turn off loadSupport()


Load package





Include Shiny options

withr::with_options(new = list(shiny.autoload.r = FALSE), code = {
  if (!interactive()) {
    sink(stderr(), type = "output")
    tryCatch(
      expr = {
        library(moviesApp)
      },
      error = function(e) {
        pkgload::load_all()
      }
    )
  } else {
    pkgload::load_all()
  }
    moviesApp::movies_app(
      options = list(test.mode = TRUE), run = 'p')
})

External resources


We have external files referenced in our UI (i.e., in www)


To include in our app-package, we need to move www/ (and contents) into inst/

Previous location:

├── inst/
│   └── extdata
│       └── movies.fst
└── www/
    └── shiny.png

Current location:

inst/
  ├── extdata/
  │   └── movies.fst
  └── www/
      └── shiny.png


After loading, documenting, and installing, we can access shiny.png with system.file()

External files: www/


Adding external files (formerly in www)


  • In the app UI function, include a call to addResourcePath()
movies_ui <- function() {
  addResourcePath(prefix = , 
                  directoryPath = )
  tagList() # Additional UI code
}


  • Use system.file() to add the www path
directoryPath = system.file('www', 
                    package = 'moviesApp')


  • Reference the file (without inst/)
img(src = "www/shiny.png")

External files: bslib


inst/ can store alternate images and resources


  • Add image to inst/www
inst
 └── www
      ├── bootstrap.png
      └── shiny.png
  
2 directories, 2 files


  • In UI, addResourcePath() has already added www/
img("www/bootstrap.png")
  • Add bslib argument to movies_ui()
movies_app(options = list(test.mode = TRUE), 
  run = 'p', 
  bslib = TRUE)

External files: dev


We can also store entire apps in inst/


inst/dev/:

  • app.R (app file)
  • imdb.png (image file)
  • tidy_movies.fst (data)


inst/dev
├── app.R
├── imdb.png
└── tidy_movies.fst

1 directory, 3 files
  • Functions from R/ are accessible in inst/dev/app.R
moviesApp::mod_var_input_server("vars")
  • Write an alternate standalone app function
ggp2_movies_app(
  options = list(test.mode = FALSE), 
  run = 'p')

External files: prod


Apps can be deployed from a dedicated inst/ folder


In inst/prod/app/app.R

shinyApp(
  ui = moviesApp::movies_ui(bslib = TRUE), 
  server = moviesApp::movies_server)


inst/
  └── prod/
       └── app/
            └── app.R
          
3 directories, 1 file
  • in app.R, use shinyAppDir() and system.file() to return the app object from prod/app/app.R
shinyAppDir(appDir = system.file("prod/app", 
                            package = "moviesApp"))

Ready

BREAK!

Tests


Recap: Package stuff

  • roxygen2 & dependencies

  • Data

  • Launching apps

  • External resources

The following sections will cover:

  1. Setting up your test suite

  2. Test fixtures and helpers

  3. Testing modules and system tests

Test suite


Setting up your testthat test suite:


usethis::use_testthat(3)

3 is ‘3rd edition’

  • In the DESCRIPTION file, testthat (>= 3.0.0) is listed under Suggests
  • Config/testthat/edition: 3 is also listed in the DESCRIPTION to specify the testthat edition
  • A new tests/ folder is created, with a testthat/ subfolder
  • The tests/testthat/testthat.R file is created

New tests


For every .R file in R/


R/
└── scatter_plot.R

1 directory, 1 file
use_test("scatter_plot")
tests/
 └── testthat/
      └── test-scatter_plot.R

1 directory, 1 file


…create a test file in tests/testthat/

Test files


test- files

 Writing 'tests/testthat/test-scatter_plot.R'
 Modify 'tests/testthat/test-scatter_plot.R'


test_that() tests

test_that(desc = "multiplication works", code = { 
 
})


expect_ations

expect_equal( 
  object = 2 * 2,  
  expected = 4 
  ) 

Running tests


Build pane

test()

Ctrl/Cmd + Shift + T

test_active_file()

Ctrl/Cmd + T

test_coverage_active_file()

Ctrl/Cmd + Shift + R

App specifications


Applications should have some version of the following specifications:


User specifications

Capture the needs and expectations of the end-user

Features

Describe the high-level capabilities of the application

Functional requirements

The testable, specific behaviors (i.e., inputs and outputs)

Test specifications


A traceability matrix is a table that ‘traces’ the user specifications to features and functional requirements (and the tests they give rise to)


User Specifications Features Requirements Tests
US1: Shiny App Scatter Plot Data Visualization for Movie Review Explorer F1.1: IMDB and Rotten Tomatoes data with continuous (i.e., scores) and categorical (i.e., mpaa) variables. FR 1.1: The app should display movie review data from IMDB and Rotten Tomatoes containing both continuous and categorical variables stored in a tabular format. ?

Behavior-driven development tests


In Behavior-driven development (BDD), requirements are written plain language ‘feature files’ using a series of keywords:


Feature: < High-level description of capability >

  Background: < Steps or conditions that exist before each scenario >
    Given < Initial context or preconditions for the scenario >
    
  Scenario: < Steps outlining a concrete example to illustrate the feature >
    When
    And
    Then

Describe features & background


The Feature and Background can be included in nested testthat::describe() blocks


testthat::describe(
  "Feature: < High-level description of capability >
      As a 
      I want 
      So that", code = {
  
    testthat::describe(
      "Background: < Preconditions >
         Given < Initial context for scenario >", code = { 
    
  })
})

Write a test for it


For Scenarios, each Then keyword should have a corresponding testthat::it() or testthat::test_that()


testthat::it("
  Scenario: < Steps outlining a concrete example to illustrate the feature >
    Given ...
    When ...
    Then", 
  code = {
    # test code 
    testthat::expect_equal(
      object = x, 
      expected = y)
})

Test fixtures


Test fixtures can be anything used to create repeatable test conditions (data, file paths, functions, etc.)


  • Fixtures provide a consistent, well-defined test environment

  • Are removed/destroyed when the test is executed


tests/
  ├── testthat/
     └── fixtures/                                         
  └── testthat.R

Feature description for fixture


We can use nested describe() functions to document the Feature and Background


testthat::describe(
    "Feature: Scatter plot data visualization
       As a film data analyst
       I want to explore movie review data from IMDB.com
       So that I can analyze relationships between movie reivew metrics",
  code = {
  testthat::describe(
    "Background:
       Given I have data with IMDB movie reviews
       And the data contains continuous variables like 'rating'
       And the data contains categorical variables like 'mpaa'",
    code = {
         
      })
    })

Give a concrete scenario


Illustrate the test with clear Given, When, Then steps


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 size of the points should be set to '2'
      And the opacity of the points should be set to '0.5'", 
    code = {
      
  })

Example fixture: data


tests
├── testthat
   ├── fixtures
   │   └── make-tidy_ggp2_movies.R
   └── test-scatter_plot.R
└── testthat.R
  • make-tidy_ggp2_movies: code used to create a ‘tidy’ version of the ggplot2movies::movies data


tests
├── testthat
   ├── fixtures
   │   ├── make-tidy_ggp2_movies.R
   │   └── tidy_ggp2_movies.rds
   └── test-scatter_plot.R
└── testthat.R
  • tidy_ggp2_movies.rds the output dataset

Write a fixture test


Write a test using the static data test fixture


  ggp2_scatter_inputs <- list(x = "rating",
                              y = "length",
                              z = "mpaa",
                              alpha = 0.75,
                              size = 3,
                              plot_title = "Enter plot title")
  tidy_ggp2_movies <- readRDS(test_path("fixtures",
                              "tidy_ggp2_movies.rds"))
    app_graph <- scatter_plot(tidy_ggp2_movies,
      x_var = ggp2_scatter_inputs$x,
      y_var = ggp2_scatter_inputs$y,
      col_var = ggp2_scatter_inputs$z,
      alpha_var = ggp2_scatter_inputs$alpha,
      size_var = ggp2_scatter_inputs$size)
    expect_true(ggplot2::is.ggplot(app_graph))

Test helpers


Helper files are a mighty weapon in the battle to eliminate code floating around at the top-level of test files.Testthat helper files, R Packages, 2ed


tests/
  ├── testthat/
     ├── fixtures/
     │   ├── make-tidy_ggp2_movies.R
     │   └── tidy_ggp2_movies.rds
     ├── helper.R
     └── test-scatter_plot.R
  └── testthat.R

Store test helpers in tests/testthat/helper.R files.

Example test helper


Consider the inputs passed to the scatter_plot() function in the previous test:


ggp2_scatter_inputs <- list(x = "rating",
                            y = "length",
                            z = "mpaa",
                            alpha = 0.75,
                            size = 3,
                            plot_title = "Enter plot title")
var_inputs <- function() {
 list(x = "rating",
      y = "length",
      z = "mpaa",
      alpha = 0.75,
      size = 3,
      plot_title = "Enter plot title")
}

We could write var_inputs() to store these values in a list

Example test helper


This would allow us to use var_inputs() with the same ‘reactive syntax’ we use in the module server function


str(var_inputs())
## List of 6
##  $ x         : chr "rating"
##  $ y         : chr "length"
##  $ z         : chr "mpaa"
##  $ alpha     : num 0.75
##  $ size      : num 3
##  $ plot_title: chr "Enter plot title"
app_graph <- scatter_plot(
  tidy_ggp2_movies,
  x_var = var_inputs()$x,
  y_var = var_inputs()$y,
  col_var = var_inputs()$z,
  alpha_var = var_inputs()$alpha,
  size_var = var_inputs()$size)

expect_true(ggplot2::is.ggplot(app_graph))


This removes duplicated code, but it’s not clear for the reader what var_inputs() contains or where it comes from

Test helpers & the DRY principle


If you have repeated code in your tests, consider the following questions below before creating a helper function:


Does the code help explain what behavior is being tested?


Would a helper make it harder to debug the test when it fails?


It’s more important that test code is obvious than DRY, because it’s more likely you’ll be dealing with this test when it fails (and you’re not likely to remember why all the top-level code is there)

Better test helper


make_ggp2_inputs() creates inputs for the scatter_plot() utility function


make_ggp2_inputs <- function() {
  glue::glue_collapse("list(x = 'rating',
     y = 'length',
     z = 'mpaa',
     alpha = 0.75,
     size = 3,
     plot_title = 'Enter plot title'
     )"
  )
}

This reduces the number of keystrokes per test, but doesn’t obscure the source of the values.

Test output


The logger package is great for verbose test output.


  • test_logger() can be used to log when a test starts and ends
test_logger(start = "Test number", 
  msg = "Short description of functional requirement")

# test code (fixtures, helpers, etc.)
# expectations

test_logger(end = "Test number", 
  msg = "Short description of functional requirement")


Store common test utilities in R/:

R/
└── testthat.R

Testing modules


We want tests for specific module behaviors (i.e., communicating or transferring values)


“Are the user inputs from the variable input module being passed to scatter-plot display module?”


movies_server <- function(input, output, session) {

      selected_vars <- mod_var_input_server("vars")

      mod_scatter_display_server("plot", var_inputs = selected_vars)
      
}

Module behaviors


What scenarios call for a snapshot test?


  testthat::it("Scenario: Scatter plot initial x, y, color values 
                   Given the movie review application is loaded
                   When I view the initial scatter plot
                   Then the scatter plot should show 'IMDB Rating' on the x-axis
                   And the scatter plot should show 'Audience Score' on the y-axis
                   And the points on the scatter plot 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'", code = {
  testthat::it("Scenario: Change x, y, color values for plotting
                  Given the movie review application is loaded
                  When I choose the variable 'IMDB number of votes' for the x-axis
                  And I choose the variable 'Critics Score' for the y-axis
                  And I choose the variable 'Genre' for the color
                  Then the scatter plot should show 'IMDB number of votes' on the x-axis
                  And the scatter plot should show 'Critics Score' on the y-axis
                  And the points on the scatter plot should be colored by 'Genre'", code = {

Testing modules: returned values



Values from mod_var_input_ui() are passed to setInputs()


Compare with session$returned()

shiny::testServer(app = mod_var_input_server, expr = {
      # set inputs
      session$setInputs(y = "imdb_rating",
                        x = "audience_score",
                        z = "mpaa_rating",
                        alpha = 0.5,
                        size = 2,
                        plot_title = "Enter plot title")
      # test against inputs in mod_var_input_ui()
      testthat::expect_equal(object = session$returned(),
        expected = list(y = "imdb_rating",
                        x = "audience_score",
                        z = "mpaa_rating",
                        alpha = 0.5,
                        size = 2,
                        plot_title = "Enter plot title"))
})

Testing modules: returned values


We know the values are being returned from mod_var_input_server()


“how can we test if the reactive values (selected_vars) are passed into mod_scatter_display_server()?”


movies_server <- function(input, output, session) {

      selected_vars <- mod_var_input_server("vars")

      mod_scatter_display_server("plot", var_inputs = selected_vars)
      
}

Testing modules: reactive inputs



If we pass the values from the variable input module to args = list(), we get an error:

shiny::testServer(app = mod_scatter_display_server,
  args = list(var_inputs = 
        list(x = "critics_score",
            y = "imdb_rating",
            z = "mpaa_rating",
            alpha = 0.5,
            size = 2,
            plot_title = "Enter Plot Title")), expr = {
    testthat::expect_equal(object = inputs(),
      expected = list(x = "critics_score",
                      y = "imdb_rating",
                      z = "mpaa_rating",
                      alpha = 0.5,
                      size = 2,
                      plot_title = "Enter Plot Title"))
})


[ FAIL 1 | WARN 0 | SKIP 0 | PASS 0 ]

── Error (test-mod_scatter_display.R:29:13): ──
Error in `var_inputs()`: could not find function "var_inputs"

Testing modules: reactive inputs


We can fix this by simulating how selected_vars is used in testServer()


If we wrap the values in reactive() and pass them as var_inputs to args, this simulates the reactive values in selected_vars


inputs() is the object used to create the graph in the call to renderPlot()

shiny::testServer(app = mod_scatter_display_server,
  args = list(var_inputs = 
      shiny::reactive(
        list(x = "critics_score",
            y = "imdb_rating",
            z = "mpaa_rating",
            alpha = 0.5,
            size = 2,
            plot_title = "Enter Plot Title"))), expr = {
    testthat::expect_equal(object = inputs(),
      expected = list(x = "critics_score",
                      y = "imdb_rating",
                      z = "mpaa_rating",
                      alpha = 0.5,
                      size = 2,
                      plot_title = "Enter Plot Title"))
})

Testing modules: how does this work?


If pause execution with the debugger, we can see the difference:

Browse[1]> selected_vars
reactive({
    list(
      y = input$y, 
      x = input$x, 
      z = input$z, 
      alpha = input$alpha, 
      size = input$size, 
      plot_title = input$plot_title
      )
})
Browse[1]> selected_vars()
$y
[1] "audience_score"

$x
[1] "imdb_rating"

$z
[1] "mpaa_rating"

$alpha
[1] 0.5

$size
[1] 2

$plot_title
[1] ""

Testing modules: reactive inputs


We can check other input values by passing them to var_inputs in args = list()


These values simulate alternate user inputs being passed from the variable input module (as selected_vars)

shiny::testServer(app = mod_scatter_display_server,
  args = list(var_inputs =
      shiny::reactive(
        list(x = "critics_score",
             y = "imdb_num_votes",
             z = "genre",
             alpha = 0.75,
             size = 3,
             plot_title = "New plot title"))), expr = {
})

Testing modules: outputs


In terms of your testing strategy, you shouldn’t bother yourself with ‘is Shiny generating the correct structure so that the plot will render in the browser?’ That’s a question that the Shiny package itself needs to answer (and one for which we have our own tests).

The goal for your tests should be to ask ‘is the code that I wrote producing the plot I want?’ There are two components to that question:

  1. Does the plot generate without producing an error?
  2. Is the plot visually correct?

testServer is great for assessing the first component here. - Server Function Testing, Shiny Documentation

Testing modules: verifying outputs


The outputs in the display module are handled by the render*() functions, so we need to verfiy ‘does the plot generate without producing an error?’


If we know how to subset the object rendered with output$scatterplot, we can test this for directly

testthat::expect_equal(object = output$scatterplot[["alt"]],
                       expected = "Plot object")

We can also build the graph in the display module and confirm with is.ggplot()



…or we can do a sanity check by passing plot to print()

  plot <- scatter_plot(movies,
    x_var = inputs()$x,
    y_var = inputs()$y,
    col_var = inputs()$z,
    alpha_var = inputs()$alpha,
    size_var = inputs()$size) +
  ggplot2::labs(title = inputs()$plot_title,
    x = stringr::str_replace_all(tools::toTitleCase(
      inputs()$x), "_", " "),
    y = stringr::str_replace_all(tools::toTitleCase(
      inputs()$y), "_", " ")) +
  ggplot2::theme_minimal() +
  ggplot2::theme(legend.position = "bottom")
  testthat::expect_true(ggplot2::is.ggplot(plot))

System tests with shinytest2


Setting up shinytest2:


Install shinytest2 and run use_shinytest2()

install.packages('shinytest2')
library(shinytest2)
shinytest2::use_shinytest2()
  • Adding shinytest2::load_app_env() to tests/testthat/setup-shinytest2.R

  • Adding *_.new.png to .gitignore

  • Adding _\\.new\\.png$ to .Rbuildignore

  • Setting active project to /path/to/moviesApp

  • Adding shinytest2 to Suggests field in DESCRIPTION

  • Setting active project to <no active project>

shinytest2 setup


shinytest2 installation checklist:

Verify you can create a new session with:

library(chromote)
b <- ChromoteSession$new()
b$view()

shinytest2: recording tests


Ideally we’d have one test recording per feature.


Feature 1: Scatter plot data visualization dropdowns
     As a film data analyst
     I want to explore continuous and categorical variables in the movie review data
     So that I can analyze relationships between movie reivew metrics

shinytest2: recording tests


If we’ve been writing BDD scenarios, the tests are relatively easy to record:


  Scenario: Change dropdown values for plotting
     Given the movie review application is loaded
     When I choose the variable 'IMDB number of votes' for the x-axis
     And I choose the variable 'Critics Score' for the y-axis
     And I choose the variable 'Genre' for the color
     And I choose the size of the points to be 3
     And I choose the opacity of the points to be 0.7

shinytest2: recording features


shinytest2::record_test()

shinytest2: test contents


Test file is saved in tests/testthat/test-shinytest2.R



The test file contains the contents of our scenario.

library(shinytest2)

test_that("{shinytest2} recording: moviesApp-feature-01", {
  app <- AppDriver$new(name = "moviesApp-feature-01", 
                       height = 800, width = 1173)
  app$set_inputs(`vars-y` = "imdb_num_votes")
  app$set_inputs(`vars-x` = "critics_score")
  app$set_inputs(`vars-z` = "genre")
  app$set_inputs(`vars-alpha` = 0.7)
  app$set_inputs(`vars-size` = 3)
  app$set_inputs(`vars-plot_title` = "New plot title")
  app$expect_values()
})


   Then the scatter plot should show 'IMDB number of votes' on the x-axis
   And the scatter plot should show 'Critics Score' on the y-axis
   And the points on the scatter plot should be colored by 'Genre'
   And the size of the points on the scatter plot should be 3
   And the opacity of the points on the scatter plot should be 0.7
   And the title of the plot should be 'New Plot Title'

shinytest2: writing tests


We can create system tests using the feature and scenario descriptions, BDD functions, and AppDriver$new()


testthat::describe("Feature 1: Scatter plot data visualization dropdowns
     ...", code = {
  testthat::it("Scenario: Change dropdown values for plotting
                 Given ...
                 When ...
                 Then ...", {
    app <- AppDriver$new(name = "moviesApp-feature-01", height = 800, width = 1173)
    app$set_inputs(`vars-y` = "imdb_num_votes")
    app$set_inputs(`vars-x` = "critics_score")
    app$set_inputs(`vars-z` = "genre")
    app$set_inputs(`vars-alpha` = 0.7)
    app$set_inputs(`vars-size` = 3)
    app$set_inputs(`vars-plot_title` = "New plot title")
    app$expect_values()
  })
})

Recap

Shiny App-Packages

  • DESCRIPTION file and fields

  • .Rproj files and fields

  • devtools function workflow

  • roxygen2 & dependencies

  • Data


  • Launching apps

  • External resources

  • BDD tests, fixtures & helpers

  • Testing modules & system tests