the rhino framework

rhino apps are unique because unlike golem and leprechaun, they aren’t R packages. rhino uses box modules to manage add-on package functions, which minimizes dependencies and separates the application’s code into a clear ‘division of labor.’1

Getting started

To create a new rhino application, select Project > New Project > New Directory, and Shiny Application using rhino. The New Project Wizard will require a Directory name and subdirectory and Github Actions CI (selected by default)

(a) New Project Wizard
Figure 1: New rhino app

After clicking Create Project, you’ll see the following output in the **Console*:**

The following package(s) will be updated in the lockfile:

# CRAN ------------------------------------------------------
- renv           [* -> 1.0.3]

# GitHub ----------------------------------------------------
- testthat       [* -> r-lib/testthat@HEAD]

# RSPM ------------------------------------------------------
  
      < < < < < < < OMITTED > > > > > > > >
  
The version of R recorded in the lockfile will be updated:
- R              [* -> 4.3.1]

- Lockfile written to "~/projects/pkgs/rap/renv.lock".
- Project '~/projects/pkgs/rap' loaded. [renv 1.0.3]
 Initialized renv.
 Application structure created.
 Unit tests structure created.
 E2E tests structure created.
 Github Actions CI added.

This initializes the new rhino app by opening the .Rproj file in RStudio. To create a rhino application from the console, use the following:

install.packages("rhino")
rhino::init("/path/to/rhino/app")

rhino files

The initial folder structure for a new rhino app is below:

rap/
├── app
   ├── js
   ├── logic
   ├── main.R
   ├── static
   ├── styles
   └── view
├── app.R
├── brap.Rproj
├── config.yml
├── dependencies.R
├── renv
   ├── activate.R
   ├── library
   └── settings.json
├── renv.lock
├── rhino.yml
└── tests
    ├── cypress
    ├── cypress.json
    └── testthat

12 directories, 10 files

The rhino package website has excellent documentation on their app structure philosophy, and it’s worth reading through this before getting started. I’ll do my best to summarize the application’s files below:

App code

The app/ folder contains the primary folder and files:

app/
  ├── js/
  ├── logic/
  ├── main.R
  ├── static/
  ├── styles/
  └── view/

6 directories, 1 file

The subfolders in app/ contain the following files:

  • app/js/: initially contains a blank index.js script

  • app/logic/: contains utility functions and code independent from Shiny

  • app/static/: stores external resources (like JavaScript files) and is similar to the sub-folders in inst/ from golem and leprechaun

  • app/styles/: holds custom styles (CSS and HTML) in the app/styles/main.css file (which is initially blank)

  • app/view/: will hold all the code used to build the application and relies upon the reactive capabilities of Shiny.

  • app/main.R: contains the primary ui and server code (similar to app_ui and app_server in a golem application)

    • The initial main.R file contains the following code:
    show/hide initial app/main.R
    box::use(
      shiny[bootstrapPage, moduleServer, NS, renderText, tags, textOutput],
    )
    
    #' @export
    ui <- function(id) {
      ns <- NS(id)
      bootstrapPage(
        tags$h3(
          textOutput(ns("message"))
        )
      )
    }
    
    #' @export
    server <- function(id) {
      moduleServer(id, function(input, output, session) {
        output$message <- renderText("Hello!")
      })
    }


box::use()?

If the box syntax looks strange–don’t worry! box is designed to ‘completely replaces the base R library and require functions.’ We’ll cover it more in the Build section below.

app.R will run the application and contains the rhino::app() function:2

# Rhino / shinyApp entrypoint. Do not edit.
rhino::app()

YAML files

New rhino apps begin with two .yml configuration files:

  • config.yml is a YAML file that follows the config package format.

    • This file initially contains two calls to rhinos environment variables:3
    default:
      rhino_log_level: !expr Sys.getenv("RHINO_LOG_LEVEL", "INFO")
      rhino_log_file: !expr Sys.getenv("RHINO_LOG_FILE", NA)
  • rhino.yml is a configuration file that contains options for 1) Sass build settings, and 3) import a legacy application structure to rhino.4

    • the code below showcases the optional arguments (and is not included in the application)
    sass: string               # required | one of: "node", "r"
    legacy_entrypoint: string  # optional | one of: "app_dir", "source", "box_top_level"

dependencies.R

rhino apps manage dependencies with the dependencies.R file and renv package.

  • dependencies.R contains any add-on packages used in the application. As we can see from the note in the comments, dependencies are tracked using packrat in rsconnect.5

    # This file allows packrat (used by rsconnect during deployment)
    # to pick up dependencies.
    library(rhino)
  • The renv/ folder stores the R package versions used in the application. renv.lock contains the packages and R version used in our application.6

    rap/
      ├── renv/
          ├── activate.R
          ├── library/
          ├── sandbox/
          └── settings.dcf
      └── renv.lock
    • We know we’ll be using ggplot2, stringr, and rlang in the app, so we’ll load these packages here:
    rhino::pkg_install(c("ggplot2", "stringr", "rlang"))
    • Adding packages with rhino::pkg_install() will automatically update dependencies.R and renv

The tests/ folder initially contains two sub-folders, cypress/ and testthat/, and the cypress.json file.

tests/
    ├── cypress/
    ├── cypress.json
    └── testthat/
  • tests/cypress/ holds folders for using the Cypress web and component testing framework.7

    tests/
        ├── cypress/
            └── integration/
                └── app.spec.js
        └── cypress.json
  • tests/testthat/ contains the architecture for writing unit tests with testthat, which can be run with the rhino::test_r() helper function:8

    tests/
        └── testthat/
                └── test-main.R

Building a rhino app

Now that we’ve covered the initial file and folder structure of a new rhino application, we’re going to cover how to convert the ‘vanilla’ Shiny app below into the rhino structure. We’ll have to 1) load the movies data, and 2) create/call the scatter_plot() utility function, and 3) convert the contents of app.R into modules.

show/hide monolithlic app.R file
ui <- shiny::fluidPage(
  theme = shinythemes::shinytheme("spacelab"),
  shiny::sidebarLayout(
    shiny::sidebarPanel(
      shiny::selectInput(
        inputId = "y",
        label = "Y-axis:",
        choices = c(
          "IMDB rating" = "imdb_rating",
          "IMDB number of votes" = "imdb_num_votes",
          "Critics Score" = "critics_score",
          "Audience Score" = "audience_score",
          "Runtime" = "runtime"
        ),
        selected = "audience_score"
      ),

      shiny::selectInput(
        inputId = "x",
        label = "X-axis:",
        choices = c(
          "IMDB rating" = "imdb_rating",
          "IMDB number of votes" = "imdb_num_votes",
          "Critics Score" = "critics_score",
          "Audience Score" = "audience_score",
          "Runtime" = "runtime"
        ),
        selected = "critics_score"
      ),

      shiny::selectInput(
        inputId = "z",
        label = "Color by:",
        choices = c(
          "Title Type" = "title_type",
          "Genre" = "genre",
          "MPAA Rating" = "mpaa_rating",
          "Critics Rating" = "critics_rating",
          "Audience Rating" = "audience_rating"
        ),
        selected = "mpaa_rating"
      ),

      shiny::sliderInput(
        inputId = "alpha",
        label = "Alpha:",
        min = 0, max = 1,
        value = 0.4
      ),

      shiny::sliderInput(
        inputId = "size",
        label = "Size:",
        min = 0, max = 5,
        value = 3
      ),

      shiny::textInput(
        inputId = "plot_title",
        label = "Plot title",
        placeholder = "Enter text to be used as plot title"
      ),

      shiny::actionButton(
        inputId = "update_plot_title",
        label = "Update plot title"
      )
    ),

    shiny::mainPanel(
      shiny::br(),
      shiny::p(
        "These data were obtained from",
        shiny::a("IMBD", href = "http://www.imbd.com/"), "and",
        shiny::a("Rotten Tomatoes", href = "https://www.rottentomatoes.com/"), "."
      ),
      shiny::p("The data represent", 
        nrow(movies), 
        "randomly sampled movies released between 1972 to 2014 in the United States."),
      shiny::plotOutput(outputId = "scatterplot"),
      shiny::hr(),
        shiny::p(shiny::em("The code for this shiny application comes from", 
          shiny::a("Building Web Applications with shiny", 
            href = "https://rstudio-education.github.io/shiny-course/"))
          )
    )
  )
)

server <- function(input, output, session) {
  
  new_plot_title <- shiny::reactive({
      tools::toTitleCase(input$plot_title)
    }) |> 
    shiny::bindEvent(input$update_plot_title, 
                     ignoreNULL = FALSE, 
                     ignoreInit = FALSE)
    

  output$scatterplot <- shiny::renderPlot({
    scatter_plot(
        df = movies,
        x_var = input$x,
        y_var = input$y,
        col_var = input$z,
        alpha_var = input$alpha,
        size_var = input$size
      ) + 
      ggplot2::labs(title = new_plot_title()) + 
      ggplot2::theme_minimal() +
      ggplot2::theme(legend.position = "bottom")
  })
}

shiny::shinyApp(ui = ui, server = server)
show/hide scatter_plot.R utility function
scatter_plot <- function(df, x_var, y_var, col_var, alpha_var, size_var) {
    ggplot2::ggplot(data = df,
      ggplot2::aes(x = .data[[x_var]],
          y = .data[[y_var]],
          color = .data[[col_var]])) +
      ggplot2::geom_point(alpha = alpha_var, size = size_var)

}

Download the movies dataset.

box modules

Unlike R packages, rhino applications don’t use the NAMESPACE and DESCRIPTION to manage dependencies. Instead, they use the box modules to explicitly import packages and functions. box is designed for writing “reusable, composable and modular R code

Dependency refresher

In standard Shiny app development, add-on package functions are used with the following steps:

  1. install the package using install.packages('pkg')

  2. run library(pkg), which loads the package namespace ‘and attach[es] it on the search list

If/when the app is converted into an R package, add-on packages are managed by:

  1. including the package in the DESCRIPTION file

  2. using pkg::fun() in code below R/ 9

The methods above might prompt the following questions:

  1. Why do we load and attach the entire package namespace and if we only need a single function during standard (i.e., non-package) R development? 10

  2. Why do we install the entire package in the DESCRIPTION if we’re only accessing a single function below R with pkg::fun() in our R package? 11

This is where box comes in–it’s designed to ‘completely replace the base R library and require functions’.

How box modules work

Below is a quick demonstration of box modules using tidyverse::tidyverse_logo(). If we attempted to use the tidyverse_logo() function without installing or loading the tidyverse meta-package, we see the following error:12

tidyverse_logo()
Error in tidyverse_logo(): could not find function "tidyverse_logo"

This is expected, because even if tidyverse has been installed, it hasn’t been loaded with libaray(tidyverse). box modules allow us to encapsulate and explicitly import packages and functions.13

Below is a quick demonstration of how they work:

1) Create folder

Create a new box module named tidy (which again, is just a folder named tidy)

2) Import

Import the tidyverse_logo() from tidyverse by creating a logo.R file with the following code

3) Use module

Call box::use(tidy/logo) to access the logo object from the tidy module

└──tidy/
    └─logo.R 
#' @export
box::use(
  tidyverse[tidyverse_logo]
)
box::use(
  tidy/logo
)

box::use() creates and accesses box modules. The first call to box::use() in tidy/logo.R places tidyverse_logo() in a tidy module, and the second call to box::use() allows us to use the logo object.

  • Use ls() on logo to return the object(s) it imports:

    ls(logo)
    [1] "tidyverse_logo"

To access the objects within a box module, use the $ operator.

logo$tidyverse_logo()
 __  _    __   .    ⬡           ⬢  . 
 / /_(_)__/ /_ ___  _____ _______ ___ 
/ __/ / _  / // / |/ / -_) __(_-</ -_)
\__/_/\_,_/\_, /|___/\__/_/ /___/\__/ 
       . /___/      ⬡      .       ⬢ 


box modules are self-contained, meaning the tidyverse_logo() function only exists inside the logo module. Explicitly listing the packages and functions we intend to use with box::use() means we no longer need to include calls to install.packages() and library() or require().

  • Note what happens when I try to access the tidyverse_logo() function by itself:

    tidyverse_logo()
    Error in tidyverse_logo(): could not find function "tidyverse_logo"


This was a very brief overview of box, so I highly recommend consulting the box website and vignettes. The rhino website also has a great overview on using box with Shiny apps.14

Modules

Now we’re going to convert contents of app.R into Shiny modules. Shiny modules should be placed in the app/view/ folder. rhino modules are still broken into ui and server functions, and box::use() is called within each function to add the necessary package[function]

  • The first module we’ll create is the app/view/inputs.R module for collecting the user inputs:

    # app/view/inputs.R
    
    # define module functions
    
    #' input values ui
    #' @export
    ui <- function(id) {
      box::use(
        shiny[
          NS, tagList, selectInput, h3,
          sliderInput, textInput
        ],
      )
      ns <- NS(id)
      tagList(
        selectInput(
          inputId = ns("y"),
          label = "Y-axis:",
          choices = c(
            "IMDB rating" = "imdb_rating",
            "IMDB number of votes" = "imdb_num_votes",
            "Critics Score" = "critics_score",
            "Audience Score" = "audience_score",
            "Runtime" = "runtime"
          ),
          selected = "audience_score"
        ),
        selectInput(
          inputId = ns("x"),
          label = "X-axis:",
          choices = c(
            "IMDB rating" = "imdb_rating",
            "IMDB number of votes" = "imdb_num_votes",
            "Critics Score" = "critics_score",
            "Audience Score" = "audience_score",
            "Runtime" = "runtime"
          ),
          selected = "imdb_rating"
        ),
        selectInput(
          inputId = ns("z"),
          label = "Color by:",
          choices = c(
            "Title Type" = "title_type",
            "Genre" = "genre",
            "MPAA Rating" = "mpaa_rating",
            "Critics Rating" = "critics_rating",
            "Audience Rating" = "audience_rating"
          ),
          selected = "mpaa_rating"
        ),
        sliderInput(
          inputId = ns("alpha"),
          label = "Alpha:",
          min = 0, max = 1, step = 0.1,
          value = 0.5
        ),
        sliderInput(
          inputId = ns("size"),
          label = "Size:",
          min = 0, max = 5,
          value = 2
        ),
        textInput(
          inputId = ns("plot_title"),
          label = "Plot title",
          placeholder = "Enter plot title"
        )
      )
    }
    
    #' input values server
    #' @export
    server <- function(id) {
      box::use(
        shiny[moduleServer, reactive],
      )
    
      moduleServer(id, function(input, output, session) {
        return(
          reactive({
            list(
              "x" = input$x,
              "y" = input$y,
              "z" = input$z,
              "alpha" = input$alpha,
              "size" = input$size,
              "plot_title" = input$plot_title
            )
          })
        )
      })
    }
    • The server function in app/view/inputs returns the same reactive list of inputs from the UI.

The app/view/display module contains the code for collecting and rendering the graph.

  • The app/logic/data and app/logic/plot modules are added to app/view/display with box::use():

    # app/view/display.R
    
    # import data and plot modules
    box::use(
      app / logic / data,
      app / logic / plot
    )
  • The ui in app/view/display includes the necessary shiny functions with box::use():

    #' display ui
    #' @export
    ui <- function(id) {
      box::use(
        shiny[NS, tagList, tags, plotOutput]
      )
      ns <- NS(id)
      # use data$movies_data() ----
      tagList(
        tags$br(),
        tags$p(
          "These data were obtained from",
          tags$a("IMBD", href = "http://www.imbd.com/"), "and",
          tags$a("Rotten Tomatoes", href = "https://www.rottentomatoes.com/"), 
          ". The data represent 651 randomly sampled movies released between 
          1972 to 2014 in the United States."
        ),
        tags$hr(),
        plotOutput(outputId = ns("scatterplot")),
        tags$hr(),
        tags$blockquote(
          tags$em(
            tags$h6(
              "The code for this application comes from the ",
              tags$a("Building web applications with Shiny",
                href = "https://rstudio-education.github.io/shiny-course/"
              ),
              "tutorial"
            )
          )
        )
      )
    }
  • The server function adds the ggplot2, shiny, tools, and stringr functions with box::use() for creating the plot output, and imports the movies data with data$movies_data():

    #' display server
    #' @export
    server <- function(id, var_inputs) {
    
      # load 
      box::use(
        ggplot2[labs, theme_minimal, theme],
        shiny[NS, moduleServer, plotOutput, reactive, renderPlot],
        tools[toTitleCase],
        stringr[str_replace_all]
      )
    
      moduleServer(id, function(input, output, session) {
    
        # use data$movies_data() ----
        movies <- data$movies_data()
    
        inputs <- reactive({
          plot_title <- toTitleCase(var_inputs()$plot_title)
          list(
            x = var_inputs()$x,
            y = var_inputs()$y,
            z = var_inputs()$z,
            alpha = var_inputs()$alpha,
            size = var_inputs()$size,
            plot_title = plot_title
          )
        })
    
        output$scatterplot <- renderPlot({
          # use plot$scatter_plot() ----
          plot <- plot$scatter_plot(
            df = movies,
            x_var = inputs()$x,
            y_var = inputs()$y,
            col_var = inputs()$z,
            alpha_var = inputs()$alpha,
            size_var = inputs()$size
          )
          plot +
            labs(
              title = inputs()$plot_title,
              x = str_replace_all(
                toTitleCase(inputs()$x ), "_", " " ),
              y = str_replace_all(
                toTitleCase(inputs()$y), "_", " " )
            ) +
            theme_minimal() +
            theme(legend.position = "bottom")
        })
      })
    }

After composing the module in app/view/display.R, I’ll add testthat tests for the app/logic/ and app/view modules.

Utility functions

The scatter_plot() utility function is stored in app/logic/plot.R. Again, box::use() includes the ggplot2 and rlang functions for scatter_plot():

# contents of app/logic/plot.R

#' scatter plot function
#' @export
scatter_plot <- function(df, x_var, y_var, col_var, alpha_var, size_var) {

box::use(
  ggplot2[ggplot, aes, geom_point],
  rlang[.data]
)
  
  ggplot(
    data = df,
    aes( 
      x = .data[[x_var]],
      y = .data[[y_var]],
      color = .data[[col_var]]
    )
  ) +
    geom_point(alpha = alpha_var, size = size_var)
  
}
1
Add ggplot2 functions
2
Add rlang functions
3
Use ggplot2 functions
4
Use rlang functions

The app/logic/__init__.R file exports the two modules above:15

# Logic: application code independent from Shiny.
# https://go.appsilon.com/rhino-project-structure
#' @export
box::use(
  app / logic / data,
  app / logic / plot
)

Data

Non-Shiny code and functions should be stored in app/logic/. We’ll include a box module for importing the movies data in app/logic/data.R.16

# contents of app/logic/data.R

#' @export
box::use(
  vroom[vroom, cols, col_skip]
)

#' import movies data 
#' @export
movies_data <- function() {
  raw_csv_url <- "https://bit.ly/47FPO6t"
  # from 07_data branch!
  vroom(raw_csv_url,
    col_types = cols(...1 = col_skip()))
}
1
Add vroom functions
2
Call vroom functions

In app/logic/data.R, the necessary vroom functions are included with box::use() to import movies.csv from GitHub.

Launch

After the app/logic/ and app/view/ code has been written and tested, the modules and layout functions can be included in app/main.R.

  • box::use() is used to import the shiny and shinythemes functions:

    # app/main.R
    
    # shiny functions
    box::use(
      shiny[
        NS, fluidPage, sidebarLayout, sidebarPanel, 
        mainPanel, fluidRow, column, tags, icon,
        plotOutput, moduleServer, renderPlot
      ],
      shinythemes[shinytheme]
    )
    
    # import modules
    box::use(
      # load inputs module ----
      app / view / inputs,
      # load display module ----
      app / view / display
    )

The ui() and server() functions in app/main look very similar to the movies_ui() and movies_server() functions, except we access the modules using the $ operator.

  • The ui() function includes both input$ui() and display$ui().

    #' rap ui
    #' @export
    ui <- function(id) {
      ns <- NS(id)
      fluidPage(
        theme = shinytheme("spacelab"),
        sidebarLayout(
          sidebarPanel(
            # use inputs module UI ----
            inputs$ui(ns("vals"))
          ),
          mainPanel(
            fluidRow(
              column(
                width = 12,
                  tags$h3("rap")
                )
            ),
            fluidRow(
              column(
                width = 1,
                offset = 11,
                # example info button ---
                tags$button(
                  id = "help-button",
                  icon("info"),
                  # add 'onclick' after rhino::build_sass()
                  # and rhino::build_js()
                  onclick = "App.showHelp()"
                )
              )
            ),
            fluidRow(
              column(
                width = 12,
                # use display module UI ----
                display$ui(ns("disp"))
              )
            )
          )
        )
      )
    }
  • The server function in app/main.R calls the inputs$server(), collects the input values as selected_vars, and passed these to display$server():

    #' rap server
    #' @export
    server <- function(id) {
      moduleServer(id, function(input, output, session) {
        # use inputs module server ----
        selected_vars <- inputs$server(id = "vals")
        # use display module server ----
        display$server(
          id = "disp",
          var_inputs = selected_vars
        )
      })
    }

After saving all the module files and app/main.R, we can run the app using app.R:

Figure 2: initial rap launch

Unit tests

The unit tests for the box modules in app/logic/ and app/view/ are in the tests/testthat/ folder:

tests/testthat/
        ├── test-data.R
        ├── test-display.R
        ├── test-inputs.R
        └── test-plot.R

1 directory, 4 files

Unit tests with rhino applications are similar to unit tests in R packages, with a few important differences:

  1. We don’t have access to the usethis and devtools functions for creating or running test files, so we’ll need to import the necessary testthat functions with box::use()
  2. To run the tests in rap, we can use the rhino::test_r() function (test_r() will run the unit tests in tests/testthat/)

The first test for app/logic/data.R is below:

# import testthat 
box::use(
  testthat[describe, it, expect_equal, expect_true]
)

# import data module
box::use(
  app / logic / data
)

describe(description = "Feature: Movies Data Dimensions Verification
  As a data analyst,
  I want to ensure the movies data frame has the correct dimensions
  So that I can rely on its structure for further analysis.", 
  code = {
  it(description = "Scenario: Checking the dimensions of the movies data frame
    Given a function to import movies data
    When I call the function to retrieve the movies data
    Then the data frame should have 651 rows and 34 columns
    And the data frame should be of type 'data.frame'", 
    code = {
    # call function to import movies data
    movies <- data$movies_data()
    # test dimensions
    expect_equal(
      object = dim(movies), 
      expected = c(651L, 34L))
    # test class
    expect_true(object = is.data.frame(movies))
  })
})

The test for app/logic/plot.R is below. Note this test imports the app/logic/data and app/logic/plot modules:

# import testthat and ggplot2 function
box::use(
  testthat[describe, it, expect_equal, expect_true],
  ggplot2[is.ggplot]
)
# import data and plot modules
box::use(
  app / logic / data,
  app / logic / plot
)


describe("Feature: Scatter Plot Generation Verification
  As a data analyst,
  I want to ensure that scatter_plot() generates a valid scatter plot
  So that I can use it for visualizing relationships in movies data.", 
  code = {
  it("Scenario: Generating a scatter plot with specified parameters
    Given a function to import movies data
    And a function scatter_plot() from the plot module
    When I call scatter_plot() with movies data
    And specify x_var as 'critics_score'
    And specify y_var as 'imdb_rating'
    And specify col_var as 'mpaa_rating'
    And set alpha_var to 2 / 3
    And set size_var to 2
    Then the function should return a ggplot object with a scatter plot",
    code = {
    # call function to import movies data
    movies <- data$movies_data()
    # test point plot
    expect_true(
      is.ggplot(
          # call scatter_plot() from plot module
          plot$scatter_plot(
            df = movies,
            x_var = 'critics_score', 
            y_var = 'imdb_rating', 
            col_var = 'mpaa_rating', 
            alpha_var = 2 / 3,
            size_var = 2
          )
        )
      )
  })
})

Module tests

We can test the application modules with Shiny’s testServer() function. First we’ll test that the reactive list of inputs is returned from app/view/inputs:

# import testthat and shiny::testServer()
box::use(
  testthat[describe, it, expect_equal],
  shiny[testServer]
)

# import inputs module
box::use(
  app / view / inputs
)

describe("Feature: Server Reactive Values Verification
  As a Shiny app developer,
  I want to ensure that the server function returns a list of reactive values 
  So that I can confirm the server's responsiveness to input changes.", 
  code = {
  it("Scenario: Checking the return values of the server function
    Given a server function inputs$server for handling reactive inputs
    When I create a server object and set reactive input values as:
      | input       | value            |
      | x           | audience_score   |
      | y           | imdb_rating      |
      | z           | mpaa_rating      |
      | alpha       | 0.75             |
      | size        | 3                |
      | plot_title  | Example title    |
    And I compare the returned values from the server
    Then the returned values should match the following list:
      | key        | value            |
      | x          | audience_score   |
      | y          | imdb_rating      |
      | z          | mpaa_rating      |
      | alpha      | 0.75             |
      | size       | 3                |
      | plot_title | Example title    |", 
    code = {
    # create server object
    testServer(app = inputs$server, expr = {
      # create list of output vals
      test_vals <- list(
        x = "audience_score",
        y = "imdb_rating",
        z = "mpaa_rating",
        alpha = 0.75,
        size = 3,
        plot_title = "Example title")
      # change inputs
      session$setInputs(x = "audience_score",
                        y = "imdb_rating",
                        z = "mpaa_rating",
                        alpha = 0.75,
                        size = 3,
                        plot_title = "Example title")
      # test class
      expect_equal(
        object = session$returned(),
        expected = test_vals
      )
   })
  })
})

We’ll also want to make sure the reactive inputs are passed from the app/view/inputs/ module to the app/view/display/ module.

describe("Feature: Server Acceptance of Reactive Values
  As a Shiny app developer,
  I want to verify that the display server can accept a list of reactive values
  So that I can ensure the interactive elements of the app respond as expected.",
  code = {
  it("Scenario: Confirming the server's handling of reactive input values
    Given a server function display$server for processing reactive inputs
    When I test the server with a list of reactive inputs:
      | input      | value            |
      | x          | critics_score    |
      | y          | imdb_rating      |
      | z          | mpaa_rating      |
      | alpha      | 0.5              |
      | size       | 2                |
      | plot_title | Enter Plot Title |
    Then the server should correctly receive and process the reactive inputs
    And the inputs received by the server should match the specified values",
    code = {
    # test inputs to display$server
    testServer(
      app = display$server,
      args = list(
        # include list of reactive inputs
        var_inputs =
          reactive(
            list(
                 x = "critics_score",
                 y = "imdb_rating",
                 z = "mpaa_rating",
                 alpha = 0.5,
                 size = 2,
                 plot_title = "Enter Plot Title"
                )
            )
      ),
      expr = {
        expect_equal(
          # test against input reactive list
          object = inputs(),
          expected = list(
            x = "critics_score",
            y = "imdb_rating",
            z = "mpaa_rating",
            alpha = 0.5,
            size = 2,
            plot_title = "Enter Plot Title"
          )
        )
    })
  })
})

These tests confirm the reactive values are passed between the app/view/inputs and the app/view/display modules.

Running the testthat tests in rap app is slightly different than executing tests in an R package. The standard devtools functions and keyboard shortcuts aren’t available, but rhino comes with a rhino::test_r() helper function to run all the tests in the testthat/ folder:

tests/testthat/
├── test-data.R
├── test-display.R
├── test-inputs.R
├── test-main.R
└── test-plot.R

1 directory, 5 files
rhino::test_r()
✔ | F W  S  OK | Context
✔ |          4 | data [1.3s]                                                                                                               
✔ |          5 | display [1.2s]                                                                                                            
✔ |          2 | inputs                                                                                                                    
✔ |          1 | plot                                                                                                                      

══ Results ═════════════════════════════════════════════════════════════════════
Duration: 3.3 s

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

Adding resources

It’s fairly straightforward to add external resources (i.e., JavaScript, CSS, Sass, etc.) to rhino apps. I’ll use the example from the website to demonstrate because it adds both CSS and JavaScript to the codebase.

  • Update the mainPanel() to include the title, info button, and display module in app/main.R:

    tags$button(
      id = "help-button",
      icon("info"),
      # add 'onclick' after rhino::build_sass()
      # and rhino::build_js()
      onclick = "App.showHelp()"
    )
    • The following CSS is added to app/styles/main.css in the container for the button.
    // app/styles/main.scss
    
    .components-container {
      display: inline-grid;
      grid-template-columns: 1fr 1fr;
      width: 100%;
    
      .component-box {
        padding: 10px;
        margin: 10px;
        box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
      }
    }
    
    h1 {
      text-align: center;
      font-weight: 900;
    }
    
    #help-button {
      position: fixed;
      top: 0;
      right: 0;
      margin: 10px;
    }
    • Run rhino::build_sass() to create the app/static/css/app.min.css file (requires node.js)
    rhino::build_sass()
    added 748 packages in 49s
    build-sass
    sass --no-source-map --style=compressed 
      ../app/styles/main.scss:../app/static/css/app.min.css
    • This tells me app.min.css has been added to in app/static/css/
    app/static/
          └── css
               └── app.min.css
    • Add the following to app/js/index.js:
    export function showHelp() {
    alert('Learn more about shiny frameworks: https://mjfrigaard.github.io/posts/my-rhino-app/');
    }
    • Run rhino::build_js() to build the app/static/js/app.min.js (requires node.js)
    rhino::build_js()
    build-js
    webpack
    
    asset app.min.js 502 bytes [emitted] [minimized] (name: main)
    runtime modules 670 bytes 3 modules
    ../app/js/index.js 126 bytes [built] [code generated]
    webpack 5.69.0 compiled successfully in 1300 ms
    • The output tells me the app.min.js has been created in app/static/js
    app/static/
          └── js
              └── app.min.js

Now when I save everything and click ‘Run App’ in app.R I should see the info button (and message):

Figure 3: Adding .js to app/js/index.js
Figure 4: Adding .js to app/js/index.js ‘on click’

System tests (shinytest2)

System tests can also be written using shinytest2. The tests below come from System tests chapter of Shiny App-Packages.17

Start by installing shinytest2 and shinyvalidate:18

  • This will update the dependencies.R file (and might require a call to renv::snapshot())

    rhino::pkg_install(c("shinytest2", "shinyvalidate"))
  • Create new tests with shinytest2::record_test() the same way you would for a standard Shiny app:

    shinytest2::record_test()
Figure 5: Test recorder

This produces the following in the Console:

{shiny} R stderr ----------- Loading required package: shiny
{shiny} R stderr ----------- Running application in test mode.
{shiny} R stderr ----------- Listening on http://127.0.0.1:5391
 Saving test runner: tests/testthat.R
 Saving test file: tests/testthat/test-shinytest2.R
 Adding 'shinytest2::load_app_env()' to 'tests/testthat/setup-shinytest2.R'
 Modify '/rap/tests/testthat/test-shinytest2.R'
 Running recorded test: tests/testthat/test-shinytest2.R
 | F W  S  OK | Context
 |   2      1 | shinytest2 [11.4s]                                           

─────────────────────────────────────────────────────────────────────────────
Warning (test-shinytest2.R:13:3): {shinytest2} recording: feature-01
Adding new file snapshot: 'tests/testthat/_snaps/feature-01-001_.png'

Warning (test-shinytest2.R:13:3): {shinytest2} recording: feature-01
Adding new file snapshot: 'tests/testthat/_snaps/feature-01-001.json'
─────────────────────────────────────────────────────────────────────────────

══ Results ══════════════════════════════════════════════════════════════════
Duration: 11.8 s

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

Modules in rhino

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")

The shinytest2 tests for moviesApp are below. Note how the nested modules are called from the app Driver (app$set_inputs()):

app$set_inputs(`app-vals-y` = "imdb_num_votes")
app$set_inputs(`app-vals-x` = "critics_score")
app$set_inputs(`app-vals-z` = "genre")
app$set_inputs(`app-vals-alpha` = 0.7)
app$set_inputs(`app-vals-size` = 3)
app$set_inputs(`app-vals-plot_title` = "New plot title")

In rap, the box modules have another level of encapsulation (i.e., vars-y becomes app-vals-y):

It’s important to keep these differences in mind when writing shinytest2 tests.19

BDD system tests

I’ve provided a few shinytest2 example tests for the data visualization user-input features using testthats BDD functions:20

show/hide contents of tests/testthat/test-app-feature-01.R
library(shinytest2)
describe("Feature 1: Scatter plot data visualization dropdowns
           As a film data analyst
           I want to explore variables in the movie review data
           So that I can analyze relationships between movie reivew sources", {
             
  describe("Scenario A: Change dropdown values for plotting
             Given the movie review application is loaded
             When I choose the variable [critics_score] for the x-axis
             And I choose the variable [imdb_num_votes] for the y-axis
             And I choose the variable [genre] for the color", {
    it("Then the scatter plot should show [critics_score] on the x-axis
         And the scatter plot should show [imdb_num_votes] on the y-axis
         And the points on the scatter plot should be colored by [genre]", {
              app <- AppDriver$new(name = "feature-01-senario-a", 
                                     height = 800, width = 1173)
                app$set_inputs(`app-vars-y` = "imdb_num_votes")
                app$set_inputs(`app=vars-x` = "critics_score")
                app$set_inputs(`app-vars-z` = "genre")
                app$expect_values()
       })
   })
             
  describe("Scenario B: Change dropdown values for plotting
              Given the movie review application is loaded
              When I choose the size of the points to be [0.7]
              And I choose the opacity of the points to be [3]
              And I enter '[New plot title]' for the plot title", {
         it("Then 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]'", {
              app <- AppDriver$new(name = "feature-01-senario-b", 
                                     height = 800, width = 1173)
                app$set_inputs(`app-vars-alpha` = 0.7)
                app$set_inputs(`app-vars-size` = 3)
                app$set_inputs(`app-vars-plot_title` = "New plot title")
                app$expect_values()
        })
    })
})

Note that these tests combine testthat’s describe() and it() functions with the Gherkin syntax.

System tests (Cypress)

rhino apps extend the test suite to include the Cypress test framework. The Cypress testing framework relies on node.js. Every machine/setup is a little different, but I use homebrew, so I installed node with the following:

# remove to get a fresh start
brew uninstall node
# update homebrew 
brew update
brew upgrade
# file cleanup
brew cleanup

Install node:

brew install node

Link node (I had to use the --overwrite flag):

brew link --overwrite node
Linking /usr/local/Cellar/node/21.5.0... 112 symlinks created.

Run the post-install:

brew postinstall node
==> Postinstalling node

Verify versions:

node --version
v21.5.0
npm -v
10.2.4

Click & message

The Cypress tests below follow the example from the rhino website. Below are two modules (clicks.R and message.R) that have been included in app/view (as app/view/message.R and app/view/clicks.R).

Both modules contain an actionButton() and textOutput(). Using app/view/clicks or app/view/message also requires adding both modules in the app/main.R file with box::use():

Updated app/main.R

The updated app/main.R file:

show/hide app/main.R
# app/main.R

# shiny functions
box::use(
  shiny[
    NS, fluidPage, sidebarLayout, sidebarPanel, 
    mainPanel, fluidRow, column, tags, icon,
    plotOutput, moduleServer, renderPlot,
    br, hr
  ],
  shinythemes[shinytheme]
)

# import modules ----
box::use(
  app / view / inputs,
  app / view / display,
  app / view / clicks,
  app / view / message,
)

#' rap ui
#' @export
ui <- function(id) {
  ns <- NS(id)
  fluidPage(theme = shinytheme("spacelab"),
    sidebarLayout(
      sidebarPanel(
        inputs$ui(ns("vals"))
        ),
      mainPanel(
        fluidRow(
              clicks$ui(ns("clicks")),
              message$ui(ns("message"))
          ),
        fluidRow(
          column(
            width = 1,
            offset = 11,
            tags$button(id = "help-button",
              icon("info"),
              # add 'onclick' after rhino::build_sass()
              # and rhino::build_js()
              onclick = "App.showHelp()")
          )
        ),
        fluidRow(
            column(
              width = 12,
              tags$h3("rap"),
            display$ui(ns("disp"))
            )
          )
        )
      )
    )
}

#' rap server
#' @export
server <- function(id) {
  moduleServer(id, function(input, output, session) {
    selected_vars <- inputs$server(id = "vals")
    display$server(
      id = "disp",
      var_inputs = selected_vars
    )
  })
  
  moduleServer(id, function(input, output, session) {
    clicks$server("clicks")
    message$server("message")
  })
  
}
1
include app/view/clicks.R and app/view/message.R modules in UI
2
include app/view/clicks.R and app/view/message.R modules in server

The new ‘clicks’ and ‘message’ buttons are visible in the mainPanel():

Running Cypress tests

Cypress tests are stored in the tests/cypress/integration/ folder:21

tests/cypress
└── integration
    └── app.spec.js

2 directories, 1 file

The initial call to rhino::test_e2e() should note if it is your first time using cypress (and might include an update or two).22

> test-e2e
> start-server-and-test run-app http://localhost:3333 run-cypress

1: starting server using command "npm run run-app"
and when url "[ 'http://localhost:3333' ]" is responding with HTTP status code 200
running tests using command "npm run run-cypress"


> run-app
> cd .. && Rscript -e "shiny::runApp(port = 3333)"

Loading required package: shiny

Listening on http://127.0.0.1:3333

> run-cypress
> cypress run --project ../tests

It looks like this is your first time using Cypress: 7.7.0

[STARTED] Task without title.
[TITLE]  Verified Cypress!       /Users/mjfrigaard/Library/Caches/Cypress/7.7.0/Cypress.app
[SUCCESS]  Verified Cypress!       /Users/mjfrigaard/Library/Caches/Cypress/7.7.0/Cypress.app

Opening Cypress...

The initial test run output (from the test stored in tests/cypress/integration/app.spec.js) is below:

hide/view initial tests/cypress/integration/app.spec.js test
  (Run Starting)

  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   Cypress:    7.7.0                                                                              │
   Browser:    Electron 89 (headless)                                                             
   Specs:      1 found (app.spec.js)                                                              
  └────────────────────────────────────────────────────────────────────────────────────────────────┘


────────────────────────────────────────────────────────────────────────────────────────────────────
                                                                                                    
  Running:  app.spec.js                                                                     (1 of 1)

  app
     starts (691ms)


  1 passing (716ms)

New names:
 `` -> `...1`

  (Results)

  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   Tests:        1                                                                                │
   Passing:      1                                                                                │
   Failing:      0                                                                                │
   Pending:      0                                                                                │
   Skipped:      0                                                                                │
   Screenshots:  0                                                                                │
   Video:        true                                                                             │
   Duration:     0 seconds                                                                        │
   Spec Ran:     app.spec.js                                                                      │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘


  (Video)

  -  Started processing:  Compressing to 32 CRF                                                     
  -  Finished processing: /Users/mjfrigaard/projects/apps/sfw/_apps/rap/tests/cypress     (1 second)
                          /videos/app.spec.js.mp4                                                   


Opening `/dev/tty` failed (6): Device not configured
resize:  can`t open terminal /dev/tty
================================================================================

  (Run Finished)

       Spec                                              Tests  Passing  Failing  Pending  Skipped  
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   ✔  app.spec.js                              714ms        1        1        -        -        -
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
      All specs passed!                        714ms        1        1        -        -        -  

The Cypress output lists the test file name as Spec or Specs, and gives us a detailed report of the test result and any artifacts (like videos or images).

tests/cypress
├── integration
   └── app.spec.js
├── screenshots
└── videos
    └── app.spec.js.mp4

4 directories, 2 files

Writing Cypress tests

I’ll include the first test for the app/view/message module from the rhino cypress tutorial in the tests/cypress/integration/ folder:

// tests/cypress/integration/message.cy.js

describe("Show message", () => {
  beforeEach(() => {
    cy.visit("/");
  });

  it("'Show message' button exists", () => {
    cy.get(".message button").should("have.text", "Show message");
  });

});

I love the use of testthat’s BDD functions with cypress, because we can see the feature and scenario described in the test itself.

After running the test with rhino::test_e2e(), we see the output now includes two videos (one for each test):

hide/view tests/cypress/integration/message.cy.js test
  (Run Starting)

  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   Cypress:    7.7.0                                                                              │
   Browser:    Electron 89 (headless)                                                             
   Specs:      2 found (app.spec.js, message.cy.js)                                               
  └────────────────────────────────────────────────────────────────────────────────────────────────┘


────────────────────────────────────────────────────────────────────────────────────────────────────
                                                                                                    
  Running:  message.cy.js                                                                   (2 of 2)


  Show message
     'Show message' button exists (546ms)


  1 passing (612ms)

New names:
 `` -> `...1`

  (Results)

  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   Tests:        1                                                                                │
   Passing:      1                                                                                │
   Failing:      0                                                                                │
   Pending:      0                                                                                │
   Skipped:      0                                                                                │
   Screenshots:  0                                                                                │
   Video:        true                                                                             │
   Duration:     0 seconds                                                                        │
   Spec Ran:     message.cy.js                                                                    │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘


  (Video)

  -  Started processing:  Compressing to 32 CRF                                                     
  -  Finished processing: /Users/mjfrigaard/projects/apps/sfw/_apps/rap/tests/cypress     (1 second)
                          /videos/message.cy.js.mp4                                                 


Opening `/dev/tty` failed (6): Device not configured
resize:  can`t open terminal /dev/tty
================================================================================

  (Run Finished)

       Spec                                              Tests  Passing  Failing  Pending  Skipped  
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   ✔  app.spec.js                              776ms        1        1        -        -        -
  ├────────────────────────────────────────────────────────────────────────────────────────────────┤
   ✔  message.cy.js                            596ms        1        1        -        -        -
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
      All specs passed!                        00:01        2        2        -        -        -  
tests/cypress
├── integration
   ├── app.spec.js
   └── message.cy.js
├── screenshots
└── videos
    ├── app.spec.js.mp4
    └── message.cy.js.mp4

4 directories, 4 files

If we view the contents of tests/cypress/videos/message.cy.js.mp4, we see it opens our app and verifies the message button:

(a) Cypress test recording
Figure 6: Contents of tests/cypress/videos/message.cy.js.mp4

Interactive tests

After adding the second test for the app/view/message module, we’ll run the test using interactive = TRUE, which opens the Cypress app window:

(a) Cypress app
Figure 7: The Cypress appliction

After the application opens, you’ll be able to select the browser (I have Chrome selected)

(a) Run cypress tests
Figure 8: Run the tests in tests/cypress/integration/

Clicking on Run 2 integration specs will launch the application in Chrome. You’ll see the tests loading in the sidebar before they are run.

(a) Chrome from cypress
Figure 9: Open application in Chrome from cypress

Below is a screenshot of our tests listed in the sidebar:

(a) Chrome tests
Figure 10: Tests list

Cypress quickly runs through each test:

(a) Running tests
Figure 11: Running tests in Chrome

And we can see the final result at the menu bar:

(a) Test results
Figure 12: Test results in Chrome

The Cypress documentation is worth reading through, because there are other features I haven’t covered here.

Recap

  • Most of the development takes place in app/logic and app/view (using box modules). The separation of the ‘business logic’ workflow from the ‘app view’ code provides a nice division of labor for each module, and the use of box modules make it easy to move components into the application in app/main.R.

  • New JavaScript or CSS code requires a corresponding rhino (rhino::build_js() or rhino::build_sass()), and requires installing node.js.

    • These functions create output files in app/static/js/app.min.js and app/static/css/app.min.css that are used in the application.
  • Testing with testthat is similar to a standard R package, but using box modules in tests takes some getting used to.

    • Cypress tests are an excellent way to perform end-to-end tests (faster than shinytest2), but requires some overhead in learning the syntax (which combines testthat functions with CSS selectors, etc.).

Footnotes

  1. The code used to build the rhino app is available here.↩︎

  2. rhino has a ‘minimal app.Rphilosophy, and the call to rhino::app() performs multiple operations beyond shiny::runApp()↩︎

  3. Read more about how to use config.yml in the Environments section of the How to: Manage secrets and environments vignette.↩︎

  4. Read more about rhino.yml in the Explanation: Configuring Rhino - rhino.yml vignette.↩︎

  5. dependencies.R is covered in the Manage Dependencies vignette on the package website.↩︎

  6. Read more about getting started with renv.↩︎

  7. Testing with Cypress is also covered in the ‘Use shinttest2 vignette’.↩︎

  8. Read more about rhino unit tests↩︎

  9. Add-on packages can also be included in R packages by adding the @importFrom or @import tags from roxygen2, which writes the NAMESPACE directives↩︎

  10. install.packages() downloads and installs packages from a repo like CRAN or GitHub, and library (or require) loads and attaches the add-on packages to the search list.↩︎

  11. Adding a package to the Imports field in the DESCRIPTION will download/install the add-on package when your package is installed, but not attach it to the search list (the Depends field will install and attach the package).↩︎

  12. library(tidyverse) is typically used to install the core tidyverse packages (ggplot2, dplyr, tidyr, readr, purrr, tibble, stringr, forcats), but this is not advised during package development.↩︎

  13. This is how boxcompletely replaces the base R library and require functions” - box documentation.↩︎

  14. I’d start with “the hierarchy of module environments” vignette. I’ve also created a collection of box module examples in the rbox repo.↩︎

  15. The __init__.R files are covered on the rhino website↩︎

  16. rhino apps come with an app/logic/ folder, which is used to store code for “data manipulation, generating non-interactive plots and graphs, or connecting to an external data source, but outside of definable inputs, it doesn’t interact with or rely on shiny in any way.”↩︎

  17. These system tests are written for a Shiny app in a standard R package.↩︎

  18. These steps are covered in the Appsilon article, ‘How-to: Use shinytest2’.↩︎

  19. This is referenced in the Cypress tutorial when trying to identify the text output from the app/view/message module.↩︎

  20. This test is covered in BDD test templates section of Shiny App-Packages.↩︎

  21. The folder names are slightly different in the Cypress tutorial, but if you replace e2e with integration in the file paths, everything works!↩︎

  22. The rhino documentation also mentions updates, ‘since this is the first time you use one of the functionalities that depend on Node.js, it needs to install all the required libraries. Don’t worry, this is just a one-time step and is done automatically.↩︎