Testing Non-Package Shiny Apps

Part 2 (series): Using testthat with Shiny outside of a package

shiny
testing
Author

Martin Frigaard

Published

May 7, 2023

packages
library(testthat)
library(ggplot2)
library(dplyr)
library(shiny)
library(vdiffr)
library(shinytest2)
Updates to series

This series on testing has been updated with recent changes in testthat, shinytest2, and other packages to improve testing.

This is the second post in a series on testing Shiny applications. I’ll cover testing Shiny module server functions using the testhat package outside of an R package structure. The noap branch of the sapkgs.

Testing module server functions

This post covers how shiny::testServer() works using a simple Shiny application. The code for abcApp() is an RStudio project (i.e., there is a noap.Rproj file in the parent folder), but it’s not part of an R package. Developing shiny applications as R packages is highly recommended, but it’s possible to begin writing unit tests before your application is a fully developed shiny app-package.

For more information regarding performing tests outside of the package environment, see this issue on GitHub.

ABC App

testthat is designed to perform unit tests in R packages, but not all Shiny apps begin as R packages. The Shiny application we’ll be using for this demonstration has been written using Shiny modules and a single utility function.

The standalone application function (launchApp()) is stored in app.R, the modules are contained in modules.R, and the single utility function is stored in utils.R:

├── README.md
├── app.R
├── modules.R
├── sapkgs.Rproj
├── tests/
│   ├── testthat/
│   │   ├── test-mod_abc_server.R
│   │   └── test-num_super_script.R
│   └── testthat.R
└── utils.R
  • The tests/ folder contains the following:

    tests
    ├── testthat
    │   ├── test-mod_abc_server.R
    │   └── test-num_super_script.R
    └── testthat.R
    • tests/ has a testthat.R ‘test runner’ file
    • New test files should be placed in tests/testthat/ (see example test-mod_abc_server.R below):

UI module function

In this small example app, both ui and server modules are stored in the modules.R file.

  • UI module:

    mod_abc_ui() (example ui module function)
    # ui module
    mod_abc_ui <- function(id) {
      ns <- NS(id)
      tagList(
        column(
          width = 3,
          offset = 2,
          numericInput(
            inputId = ns("num"),
            label = "Alphabet Number",
            value = 5,
            min = 1,
            max = 26
          )
        ),
        column(
          width = 6,
          br(),
          uiOutput(
            outputId = ns("txt")
          ),
          verbatimTextOutput(ns("out"))
        )
      )
    }

Server module function

The counterpart to mod_abc_ui() is mod_abc_server():

  • Server module:

    mod_abc_server() (example server module function)
    # server module
    mod_abc_server <- function(id) {
      moduleServer(id, function(input, output, session) {
        # reactive
        letter <- reactive({
          LETTERS[input$num]
        })
        # super script
        sup_scrpt <- reactive({
          num_super_script(x = input$num)
        })
        # output
        output$txt <- renderUI({
          HTML(
            paste0(
              em(
                "The ", code(input$num), code(sup_scrpt()),
                " letter in the alphabet is: ", code(letter())
              )
            )
          )
        })
        output$out <- renderPrint({
          HTML(
            paste0(
              em(
                "The ", code(input$num), code(sup_scrpt()),
                " letter in the alphabet is: ", code(letter())
              )
            )
          )
        })
      })
    }

Module utility function

The mod_abc_server() function uses the num_super_script() function stored in utils.R:

  • Utility function:

    num_super_script() (example utility function)
    # utility function
    num_super_script <- function(x) {
          num <- as.numeric(x)
          if (num < 0) {
            stop("not a valid number")
          } else if (num > 26) {
            stop("not a valid number")
          } else if (num == 0) {
            super_script <- ""
          } else if (num == 1 | num == 21) {
            super_script <- "st"
          } else if (num == 2 | num == 22) {
            super_script <- "nd"
          } else if (num == 3 | num == 23) {
            super_script <- "rd"
          } else {
            super_script <- "th"
          }
        return(super_script)
    }

Standalone app function

  • Standalone app functions include a call to shiny::shinyApp():

    launch() (example app with modules)
    launchApp <- function() {
      shinyApp(
        ui = fluidPage(
          h2("ABC App"),
          fluidRow(
            mod_abc_ui("x")
          )
        ),
        server = function(input, output, session) {
          mod_abc_server("x")
        }
      )
    }
    launchApp()
    • The call to shiny::shinyApp() is placed inside the launchApp() function

    • The ui argument is wrapped in shiny::fluidPage() with the ui module function (mod_abc_ui()) placed inside fluidRow()

    • The server argument includes the standard function(input, output, session) and the module server companion function–mod_abc_server()–with a matching id arguments

Because launchApp() is not part of a package, shiny and testthat are loaded and the modules and utility function are sourced in the top of the app.R file.

source utils.R and modules.R in app.R
# packages --------------------------------------------------------
library(shiny)
library(testthat)

# utils ------------------------------------------------------------------
source("utils.R")

# modules ------------------------------------------------------------------
source("modules.R")

Using testServer()

In the test-mod_abc_server.R file, I’ll add testServer() and include the module server function as the first argument:

  • app is the module server function (mod_abc_server) or any shiny.appobj

    app = mod_abc_server
    testServer(app = mod_abc_server, {
    
    })

Testing input$s

  • The first test I’ll add will check the initial value of input$num

    • I’ll also include a custom message with cat()
    test initial value with custom message
    testServer(mod_abc_server, {
      # Test initial value
      testthat::expect_equal(input$num, NULL)
      cat("\n Test 1 initial input$num = NULL: ", is.null(input$num), "\n")
    })

testServer() allows me to set new input values with session$setInputs()

  • Use session$setInputs() to set input$num to 3

    • Test 2 confirms input$num has changed (we’ll also add another custom message with cat())
    setInputs() and test inputs
    testServer(mod_abc_server, {
      # set inputs
      session$setInputs(num = 3)
      # Test set inputs
      testthat::expect_equal(input$num, 3)
      cat("\n Test 2 setInputs(num = 3):", input$num, "\n")
    })

Testing reactive values

The module’s reactive values are also available to in testServer().

  • Test 3 adds a test for sup_scrpt() (given the changed value of input$num)

    • The expected value is what I’m expecting num_super_script() to return:
    Check sup_scrpt() reactive value with expect_equal()
    testServer(mod_abc_server, {
      # Test super script
      testthat::expect_equal(object = sup_scrpt(), expected = "rd")
      cat("\n Test 3 sup_scrpt(): = 'rd':", sup_scrpt(), "\n")
    })
    • For completeness we’ll add a test for letter()
    Check letter() reactive value with expect_equal()
    testServer(mod_abc_server, {
      # Test letter
      expect_equal(object = letter(), expected = "C")
      cat("\n Test 4 letter() = C:", letter(), "\n")
    })

Testing output$s

The module output values are also available as output$<value>.

  • The final test will verify this object is a list and print the results to the Console

    • Output tests can verify that output$txt has been updated with input$num:
    Check module output values
    testServer(mod_abc_server, {
      # Test output
      expect_true(is.list(output$txt))
      print(output$txt)
    })
    • Finally, I’ll run the tests with test_file():
    test_file(path = "/path/to/app/tests/testthat/")
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 1 ]
     Test 1 initial input$num = NULL:  TRUE 
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]
     Test 2 setInputs(num = 3): 3 
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 3 ]
     Test 3 sup_scrpt(): = 'rd': rd 
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4 ]
     Test 4 letter() = C: C 
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 5 ]$html
    <em>
      The 
      <code>3</code>
      <code>rd</code>
       letter in the alphabet is: 
      <code>C</code>
    </em>

The results show the tests passed! Now I am confident inputs, reactive values (sup_scrpt() & letter()), outputs behave as expected.

Recap

The example above provides a workflow for using testServer() with testthat outside a package environment. The checklist below summarizes the steps required to test your application’s module server functions:

  1. Create test files (in tests/testthat/)

  2. Verify inputs with session$setInputs(inputId = <value>)

  3. Test reactive values by referring to them as you would in the module server

  4. Test outputs using output$<value> to check changes to the inputs and reactives

This concludes running tests on noap. Ideally, Shiny applications are developed as an R package (which I’ll cover in future posts), but now you know how to perform tests if this isn’t the case. The files for this demonstration are located here..

For a more comprehensive review of testing, check out the chapters on testing in R packages and Mastering Shiny.