Package | Version | Title | Description |
---|---|---|---|
rhino | 1.11.0 | A Framework for Enterprise Shiny Applications | A framework that supports creating and extending enterprise Shiny applications using best practices. |
box | 1.2.0 | Write Reusable, Composable and Modular R Code | A modern module system for R. Organise code into hierarchical, composable, reusable modules, and use it effortlessly across projects via a flexible, declarative dependency loading syntax. |
The rhino
framework
rhino
takes a completely novel approach to Shiny applications. Unlike golem
and leprechaun
, rhino
apps aren’t R packages. Instead, rhino
structures the application into box
modules, which minimizes dependencies and separates the code into a clear ‘division of labor.’ rhino
has utilities for easily adding JavaScript and SCSS, and comes with end-to-end testing.1
Package versions
The versions of rhino
and box
used in this example are below:
Getting started
To create a new rhino
application in RStudio , 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)
After clicking Create Project, rhino will set up our new app (and you should see something like the following output in the Console):
Installing dependencies:
- Linking packages into the project library ... Done!
- Resolving missing dependencies ...
# Installing packages -------------------------------
- Installing treesitter ... OK [linked from cache] - Installing treesitter.r ... OK [linked from cache]
Updating renv
lockfile:
The following package(s) will be updated in the lockfile:
# CRAN --------------------------------------------------
- backports [* -> 1.5.0]
- box [* -> 1.2.0]
- box.linters [* -> 0.10.5]
- box.lsp [* -> 0.1.3]
- codetools [* -> 0.2-20]
- config [* -> 0.3.2]
- lintr [* -> 3.2.0]
- R.cache [* -> 0.16.0]
- R.methodsS3 [* -> 1.8.2]
- rex [* -> 1.2.1]
- styler [* -> 1.10.3]
- xmlparsedata [* -> 1.0.5]
# RSPM --------------------------------------------------
- jsonlite [* -> 1.9.1]
- logger [* -> 0.4.0]
- R.oo [* -> 1.27.0]
- R.utils [* -> 2.13.0]
- renv [* -> 1.1.4]
- rhino [* -> 1.10.1]
- stringi [* -> 1.8.4]
- xfun [* -> 0.51]
# pm ----------------------------------------------------
- base64enc [* -> 0.1-3]
- brio [* -> 1.1.5]
- bslib [* -> 0.9.0]
- cachem [* -> 1.1.0]
- callr [* -> 3.7.6]
- cli [* -> 3.6.4]
- commonmark [* -> 1.9.5]
- crayon [* -> 1.5.3]
- desc [* -> 1.4.3]
- diffobj [* -> 0.3.5]
- digest [* -> 0.6.37]
- evaluate [* -> 1.0.3]
- fastmap [* -> 1.2.0]
- fontawesome [* -> 0.5.3]
- fs [* -> 1.6.5]
- glue [* -> 1.8.0]
- highr [* -> 0.11]
- htmltools [* -> 0.5.8.1]
- httpuv [* -> 1.6.15]
- jquerylib [* -> 0.1.4]
- knitr [* -> 1.50]
- later [* -> 1.4.1]
- lazyeval [* -> 0.2.2]
- lifecycle [* -> 1.0.4]
- magrittr [* -> 2.0.3]
- memoise [* -> 2.0.1]
- mime [* -> 0.13]
- pkgbuild [* -> 1.4.7]
- pkgload [* -> 1.4.0]
- praise [* -> 1.0.0]
- processx [* -> 3.8.6]
- promises [* -> 1.3.2]
- ps [* -> 1.9.0]
- purrr [* -> 1.0.4]
- R6 [* -> 2.6.1]
- rappdirs [* -> 0.3.3]
- Rcpp [* -> 1.0.14]
- rlang [* -> 1.1.5]
- rprojroot [* -> 2.0.4]
- rstudioapi [* -> 0.17.1]
- sass [* -> 0.4.9]
- shiny [* -> 1.10.0]
- sourcetools [* -> 0.1.7-1]
- stringr [* -> 1.5.1]
- testthat [* -> 3.2.3]
- treesitter [* -> 0.1.0]
- treesitter.r [* -> 1.1.0]
- vctrs [* -> 0.6.5]
- waldo [* -> 0.6.1]
- withr [* -> 3.0.2]
- xml2 [* -> 1.3.8]
- xtable [* -> 1.8-4]
- yaml [* -> 2.3.10]
The version of R recorded in the lockfile will be updated:
- R [* -> 4.4.2]
- Lockfile written to "~/projects/dev/rap/renv.lock". - Project '~/projects/dev/rap' loaded. [renv 1.1.4]
Application structure, tests, and CI/CD:
✔ 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 . If you’re using Positron
To create a
rhino
application from the console, use the following:
install.packages("rhino")
::init("/path/to/rhino/app") rhino
rhino
files
The initial folder structure for a new rhino
app is below:
rap/
├── app
│ ├── js/
│ │ └── index.js
│ ├── logic/
│ │ └── __init__.R
│ ├── main.R
│ ├── static/
│ │ └── favicon.ico
│ ├── styles/
│ │ └── main.scss
│ └── view/
│ └── __init__.R
├── app.R
├── config.yml
├── dependencies.R
├── rap.Rproj
├── renv
│ ├── activate.R
│ ├── library/
│ └── settings.json
├── renv.lock
├── rhino.yml
└── tests
├── cypress
│ └── e2e
│ └── app.cy.js
├── cypress.config.js
├── testthat
│ └── test-main.R └── testthat.R
The rhino
package website has excellent documentation on their approach and philosophy to app development, 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
│ └── index.js
├── logic
│ └── __init__.R
├── main.R
├── static
│ └── favicon.ico
├── styles
│ └── main.scss
└── view
└── __init__.R
6 directories, 6 files
The subfolders in app/
contain the following files:
app/js/
initially contains a blank index.js
script.
app/js
└── index.js
1 directory, 1 file
app/logic/
contains utility functions and code independent from Shiny.
app/logic/
└── __init__.R
1 directory, 1 file
app/static/
stores external resources (like JavaScript files) and is similar to the sub-folders in inst/
from golem
and leprechaun
.
app/static/
└── favicon.ico
1 directory, 1 file
app/styles/
holds custom styles (CSS and HTML) in the app/styles/main.css
file (which is initially blank).
app/styles/
└── main.scss
1 directory, 1 file
app/view/
will hold all the code used to build the application and relies upon the reactive capabilities of Shiny.
app/view/
└── __init__.R
1 directory, 1 file
app/main.R
contains the primary ui
and server
code (similar to app_ui
and app_server
in a golem
application).
app/
└── main.R
1 directory, 1 file
The initial main.R
file contains the following code:
show/hide initial app/main.R
::use(
box
shiny[bootstrapPage, div, moduleServer, NS, renderUI, tags, uiOutput],
)
#' @export
<- function(id) {
ui <- NS(id)
ns bootstrapPage(
uiOutput(ns("message"))
)
}
#' @export
<- function(id) {
server moduleServer(id, function(input, output, session) {
$message <- renderUI({
outputdiv(
style = "display: flex; justify-content: center; align-items: center; height: 100vh;",
$h1(
tags$a("Check out Rhino docs!", href = "https://appsilon.github.io/rhino/")
tags
)
)
})
}) }
app.R
will run the application and contains the rhino::app()
function:2
# Rhino / shinyApp entrypoint. Do not edit.
::app() rhino
YAML files
New rhino
apps begin with two .yml
configuration files:
config.yml
is a YAML file that follows the config
package format and initially contains two calls to rhino
s 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
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
is used during deployment (as we can see from the comments) and contains any add-on packages used in the application.5
# This file allows packrat (used by rsconnect
# during deployment) to pick up dependencies.
library(rhino)
library(treesitter)
library(treesitter.r)
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:
::pkg_install(c("ggplot2", "stringr", "rlang")) rhino
Adding packages with rhino::pkg_install()
will automatically update dependencies.R
and renv
.
Tests
The tests/
folder initially contains two sub-folders, cypress/
and testthat/
, and the cypress.json
file.
tests
├── cypress
│ └── e2e
│ └── app.cy.js
├── cypress.config.js
└── testthat
└── test-main.R
4 directories, 3 files
tests/cypress/
holds folders for using the Cypress web and component testing framework.7
tests/cypress
└── e2e
└── app.cy.js
2 directories, 1 file
Development
Now that we’ve covered the initial file and folder structure of a new rhino
application, we’re going to cover how to convert our movie review Shiny app 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.
I’ve included the original application files below:
show/hide monolithlic app.R file
<- shiny::fluidPage(
ui theme = shinythemes::shinytheme("spacelab"),
::sidebarLayout(
shiny::sidebarPanel(
shiny::selectInput(
shinyinputId = "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(
shinyinputId = "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"
),
::selectInput(
shinyinputId = "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(
shinyinputId = "alpha",
label = "Alpha:",
min = 0, max = 1,
value = 0.4
),
::sliderInput(
shinyinputId = "size",
label = "Size:",
min = 0, max = 5,
value = 3
),
::textInput(
shinyinputId = "plot_title",
label = "Plot title",
placeholder = "Enter text to be used as plot title"
),
::actionButton(
shinyinputId = "update_plot_title",
label = "Update plot title"
)
),
::mainPanel(
shiny::br(),
shiny::p(
shiny"These data were obtained from",
::a("IMBD", href = "http://www.imbd.com/"), "and",
shiny::a("Rotten Tomatoes", href = "https://www.rottentomatoes.com/"), "."
shiny
),::p("The data represent",
shinynrow(movies),
"randomly sampled movies released between 1972 to 2014 in the United States."),
::plotOutput(outputId = "scatterplot"),
shiny::hr(),
shiny::p(shiny::em("The code for this shiny application comes from",
shiny::a("Building Web Applications with shiny",
shinyhref = "https://rstudio-education.github.io/shiny-course/"))
)
)
)
)
<- function(input, output, session) {
server
<- shiny::reactive({
new_plot_title ::toTitleCase(input$plot_title)
tools|>
}) ::bindEvent(input$update_plot_title,
shinyignoreNULL = FALSE,
ignoreInit = FALSE)
$scatterplot <- shiny::renderPlot({
outputscatter_plot(
df = movies,
x_var = input$x,
y_var = input$y,
col_var = input$z,
alpha_var = input$alpha,
size_var = input$size
+
) ::labs(title = new_plot_title()) +
ggplot2::theme_minimal() +
ggplot2::theme(legend.position = "bottom")
ggplot2
})
}
::shinyApp(ui = ui, server = server) shiny
show/hide scatter_plot.R utility function
<- function(df, x_var, y_var, col_var, alpha_var, size_var) {
scatter_plot ::ggplot(data = df,
ggplot2::aes(x = .data[[x_var]],
ggplot2y = .data[[y_var]],
color = .data[[col_var]])) +
::geom_point(alpha = alpha_var, size = size_var)
ggplot2
}
Download the movies
dataset.
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:
install the package using
install.packages('pkg')
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:
including the package in the
DESCRIPTION
fileusing
pkg::fun()
in code belowR/
9
The methods above might prompt the following questions:
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
Why do we install the entire package in the
DESCRIPTION
if we’re only accessing a single function below R withpkg::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:
Create folder: Create a new box
module named tidy
(which again, is just a folder named tidy
)
└──tidy/ └─logo.R
Import: import the tidyverse_logo()
from tidyverse
by creating a logo.R
file with the following code
#' @export
::use(
box
tidyverse[tidyverse_logo] )
Use module: call box::use(tidy/logo)
to access the logo
object from the tidy
module
::use(
box/logo
tidy )
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.
$tidyverse_logo() 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
, and I highly recommend consulting the box
website and vignettes. The rhino
website also has a great overview on using box
with Shiny apps.14
Utility functions
We’ll start developing rap
by creating a box
module for our plotting utility function, scatter_plot()
. Recall that the app/logic
folder contains the non-Shiny code and functions:15
app/logic/plot.R
app/logic/
├── __init__.R
└── plot.R
1 directory, 2 files
In app/logic/plot.R
, we’ll include the ggplot2
and rlang
functions used in scatter_plot()
with box::use()
:
# contents of app/logic/plot.R
#' scatter plot function
#' @export
<- function(df, x_var, y_var, col_var, alpha_var, size_var) {
scatter_plot
::use(
box
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
Data
We’ll also include a box
module for importing the movies
data in app/logic/data.R
. This module will read the movies data from a .csv
file stored in a previous branch of the sap
package.16
# contents of app/logic/data.R
#' @export
::use(
box
vroom[vroom, cols, col_skip]
)
#' import movies data
#' @export
<- function() {
movies_data <- "https://tinyurl.com/5cdmpuzy"
raw_csv_url 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.
Now that we have modules for our utility function and data, we want to make them available to our Shiny (box
) modules. We can do this in the app/logic/__init__.R
file:
# Logic: application code independent from Shiny.
# https://go.appsilon.com/rhino-project-structure
#' @export
::use(
box/ logic / data,
app / logic / plot
app )
Including the folder paths in box::use()
in app/logic/__init__.R
will export the plot
and data
modules we created above:17
Now we can use use app/logic/data
and app/logic/plot
in our Shiny (box) modules below.
Modules
To convert contents of our monolithic app.R
file into Shiny (box
) modules, we’ll start by creating files for each module in the app/view/
folder.
First we’ll create the app/view/var_inputs
module for collecting the user variable inputs:
app/view/var_inputs.R
app/view/
├── __init__.R
└── var_inputs.R
1 directory, 2 files
rhino
modules are still broken into ui
and server
functions, and box::use()
is called within each function to add the necessary package[function]
show/hide app/view/var_inputs.R
## app/view/var_inputs.R
# define module functions
#' variable input values ui
#' @export
<- function(id) {
ui ::use(
box
shiny[
NS, tagList, selectInput
],
)<- NS(id)
ns 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"
)
)
}
#' variable input values server
#' @export
<- function(id) {
server ::use(
box
shiny[moduleServer, reactive],
)
moduleServer(id, function(input, output, session) {
return(
reactive({
list(
"x" = input$x,
"y" = input$y,
"z" = input$z
)
})
)
}) }
The server
function in app/view/var_inputs
returns a reactive list of column names from the UI.
We’ll also create aes_inputs
module for collecting the graph ‘aesthetics’ inputs:
app/view/aes_inputs.R
app/view/
├── __init__.R
├── aes_inputs.R
└── var_inputs.R
1 directory, 3 files
Note that each module explicitly lists the functions it uses in the box::use()
section:
show/hide app/view/aes_inputs.R
# app/view/aes_inputs.R
# define module functions
#' aesthetic input values ui
#' @export
<- function(id) {
ui ::use(
box
shiny[
NS, tagList,
sliderInput, textInput
],
)<- NS(id)
ns tagList(
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"
)
)
}
#' aesthetic input values server
#' @export
<- function(id) {
server ::use(
box
shiny[moduleServer, reactive],
)
moduleServer(id, function(input, output, session) {
return(
reactive({
list(
"alpha" = input$alpha,
"size" = input$size,
"plot_title" = input$plot_title
)
})
)
}) }
The final display
module contains the code for collecting the variable and aesthetic inputs and rendering the graph.
app/view/display
├── __init__.R
├── aes_inputs.R
├── display
└── var_inputs.R
1 directory, 4 files
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
::use(
box/ logic / data,
app / logic / plot
app )
The ui
in app/view/display
includes the necessary shiny
functions with box::use()
:
#' display ui
#' @export
<- function(id) {
ui ::use(
box
shiny[NS, tagList, tags, plotOutput]
)<- NS(id)
ns # use data$movies_data() ----
tagList(
$br(),
tags$p(
tags"These data were obtained from",
$a("IMBD", href = "http://www.imbd.com/"), "and",
tags$a("Rotten Tomatoes", href = "https://www.rottentomatoes.com/"),
tags". The data represent 651 randomly sampled movies released between
1972 to 2014 in the United States."
),$hr(),
tagsplotOutput(outputId = ns("scatterplot")),
$hr(),
tags$blockquote(
tags$em(
tags$h6(
tags"The code for this application comes from the ",
$a("Building web applications with Shiny",
tagshref = "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
<- function(id, var_inputs) {
server
# load
::use(
box
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() ----
<- data$movies_data()
movies
<- reactive({
inputs <- toTitleCase(var_inputs()$plot_title)
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
)
})
$scatterplot <- renderPlot({
output# use plot$scatter_plot() ----
<- plot$scatter_plot(
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 creating the app/view/display.R
module, I’ll add testthat
tests for the app/logic/
and app/view
modules.
Unit tests
The unit tests for the box
modules in app/logic/
and app/view/
are in the tests/testthat/
folder:
tests/testthat/
tests/testthat/
├── test-data.R
└── test-plot.R
1 directory, 2 files
Unit tests with rhino
applications are similar to unit tests in R packages, with a few important differences:
We don’t have access to the
usethis
anddevtools
functions for creating or running test files, so we’ll need to import the necessarytestthat
functions withbox::use()
To run the tests in
rap
, we can use therhino::test_r()
function (test_r()
will run the unit tests intests/testthat/
)
The first test for app/logic/data.R
is below:
# import testthat
::use(
box
testthat[describe, it, expect_equal, expect_true]
)
# import data module
::use(
box/ logic / data
app
)
describe("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("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
<- data$movies_data()
movies # 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
::use(
box
testthat[describe, it, expect_equal, expect_true],
ggplot2[is.ggplot]
)# import data and plot modules
::use(
box/ logic / data,
app / logic / plot
app
)
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
<- data$movies_data()
movies # test point plot
expect_true(
is.ggplot(
# call scatter_plot() from plot module
$scatter_plot(
plotdf = movies,
x_var = 'critics_score',
y_var = 'imdb_rating',
col_var = 'mpaa_rating',
alpha_var = 2 / 3,
size_var = 2
)
)
)
}) })
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:
::test_r() rhino
══ Results ════════════════════════════
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 4 ]
> rhino::test_r()
✔ | F W S OK | Context
✔ | 2 | data
✔ | 1 | main
✔ | 1 | plot
══ Results ════════════════════════════ [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4 ]
Module tests
We can test the application modules with Shiny’s testServer()
function. We’ll test that the reactive list of inputs is returned from app/view/var_inputs
and app/view/aes_inputs
:
show/hide tests/testthat/test-var_inputs.R
# import testthat and shiny's testServer()
::use(
box
testthat[describe, it, expect_equal],
shiny[testServer]
)
# import inputs module
::use(
box/ view / var_inputs
app
)
describe("Feature: Server Reactive Values Verification
As a Shiny app developer,
I want to ensure the var_inputs module 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 var_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 |
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 |",
code = {
# create server object
testServer(app = var_inputs$server, expr = {
# create list of output vals
<- list(
test_vals x = "audience_score",
y = "imdb_rating",
z = "mpaa_rating")
# change inputs
$setInputs(x = "audience_score",
sessiony = "imdb_rating",
z = "mpaa_rating")
# test class
expect_equal(
object = session$returned(),
expected = test_vals
)
})
}) })
show/hide tests/testthat/test-aes_inputs.R
# import testthat and shiny's testServer()
::use(
box
testthat[describe, it, expect_equal],
shiny[testServer]
)
# import inputs module
::use(
box/ view / aes_inputs
app
)
describe("Feature: Server Reactive Values Verification
As a Shiny app developer,
I want to ensure the aes_inputs module 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 aes_inputs$server for handling reactive inputs
When I create a server object and set reactive input values as:
| alpha | 0.5 |
| size | 2 |
| plot_title | Enter Plot Title |
And I compare the returned values from the server
Then the returned values should match the following list:
| alpha | 0.5 |
| size | 2 |
| plot_title | Enter Plot Title |",
code = {
# create server object
testServer(app = aes_inputs$server, expr = {
# create list of output vals
<- list(
test_vals alpha = 0.5,
size = 2,
plot_title = "Enter Plot Title")
# change inputs
$setInputs(alpha = 0.5,
sessionsize = 2,
plot_title = "Enter Plot 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.
# import testthat and shiny's testServer()
::use(
box
testthat[describe, it, expect_equal],
shiny[testServer]
)
# import inputs module
::use(
box/ view / display,
app / view / var_inputs,
app / view / aes_inputs
app
)
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 | '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(
var_inputs =
reactive(
list(
x = "critics_score",
y = "imdb_rating",
z = "mpaa_rating"
)
),aes_inputs =
reactive(
list(
alpha = 0.5,
size = 2,
plot_title = "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 = "Plot Title"
)
)
})
}) })
tests/testthat/
tests/testthat/
├── test-data.R
├── test-display.R
├── test-inputs.R
├── test-main.R
└── test-plot.R
1 directory, 5 files
::test_r() rhino
✔ | F W S OK | Context
✔ | 1 | aes_inputs
✔ | 2 | data
✔ | 1 | display
✔ | 1 | plot
✔ | 1 | var_inputs
══ Results ═══════════════════════════ [ FAIL 0 | WARN 0 | SKIP 0 | PASS 6 ]
These tests confirm the reactive values are passed between the app/view/inputs
and the app/view/display
modules.
Launch
After the app/logic/
and app/view/
code has been written and tested, we can add the modules and layout functions to app/main.R
:
# app/main.R
# shiny functions
::use(
box
shiny[
NS, tags, icon, moduleServer
],# bslib functions
bslib[
page_fillable, layout_sidebar, sidebar,
card, card_header, card_body, bs_theme
]
)
# import modules ----
::use(
box## load inputs module
/ view / var_inputs,
app / view / aes_inputs,
app ## load display module
/ view / display
app )
The ui()
and server()
functions in app/main
look very similar to golem
and leprechaun
UI and server functions, except we access the modules using the $
operator.
The ui()
function includes var_inputs$ui()
, aes_inputs$ui()
and display$ui()
.
#' rap ui
#' @export
<- function(id) {
ui <- NS(id)
ns page_fillable(
# theme
theme = bs_theme(
bg = "#202123",
fg = "#B8BCC2",
primary = "#EA80FC",
secondary = "#48DAC6",
base_font = c("Proxima Nova", "sans-serif"),
code_font = c("Hack", "monospace"),
heading_font = c("Optima", "sans-serif")
),layout_sidebar(
sidebar = sidebar(
$ui(ns("vals")),
var_inputs$ui(ns("aes"))
aes_inputs
),card(
full_screen = TRUE,
card_body(
$ui(ns("disp"))
display
)
)
)
) }
The help-button
will be covered below in the rhino::build_sass()
and rhino::build_js()
.
The server
function in app/main.R
calls the var_inputs$server()
and aes_inputs$server()
, collects the input values as selected_vars
and selected_aes
, then passes these to display$server()
:
#' rap server
#' @export
<- function(id) {
server moduleServer(id, function(input, output, session) {
# collect variable inputs module server ----
<- var_inputs$server(id = "vals")
selected_vars # collect aesthetic inputs module server ----
<- aes_inputs$server(id = "aes")
selected_aes
# pass to display module server ----
$server(
displayid = "disp",
var_inputs = selected_vars,
aes_inputs = selected_aes
)
}) }
After saving all the module files and app/main.R
, we can run the app using app.R
:
Adding resources
It’s fairly straightforward to add external resources (i.e., JavaScript, CSS, Sass, etc.) to rhino
apps. We’ll cover the examples from the website to demonstrate adding both CSS and JavaScript to the rap
codebase. Be sure to download and install Node.js.
Update the card_header()
to include the title, info button, and display module in app/main.R
:
Help button
card_header(
$button(id = "help-button",
tagsicon("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.css
// 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 #00000033,
0 6px 20px 0 #00000030;
}
}
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)
::build_sass() rhino
ℹ Initializing Node.js directory...
ℹ Installing Node.js packages with npm...
added 761 packages in 23s
> 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)
::build_js() rhino
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):
Update main.R
test
The message from our info-button has changed, so we’ll want to update the test to include the correct string match:
::use(
box
shiny[testServer],
testthat[expect_true, test_that],
)::use(
box/main[server],
app
)
test_that("main server works", {
testServer(server, {
expect_true(
grepl(x = output$message$html,
pattern = "Learn more about shiny frameworks: https://mjfrigaard.github.io/posts/my-rhino-app/"))
}) })
System tests (shinytest2
)
System tests can also be written using shinytest2
. The tests below come from System tests chapter of Shiny App-Packages.18
Start by installing shinytest2
and shinyvalidate
:19
::pkg_install(c("shinytest2", "shinyvalidate")) rhino
Create new tests with shinytest2::record_test()
the same way you would for a standard Shiny app:
::record_test() shinytest2
This produces the following in the Console:
Loading required package: shiny
Listening on http://127.0.0.1:5800
{shiny} R stderr ----------- Loading required package: shiny
{shiny} R stderr ----------- Running application in test mode.
{shiny} R stderr -----------
{shiny} R stderr ----------- Listening on http://127.0.0.1:6435
• 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
• Running recorded test: tests/testthat/test-shinytest2.R
Loading required package: testthat
✔ | F W S OK | Context
✔ | 2 1 | shinytest2 [11.9s]
────────────────────────────────────────────────────────────────────────────────
Warning (test-shinytest2.R:11:3): {shinytest2} recording: rap
Adding new file snapshot: 'tests/testthat/_snaps/rap-001_.png'
Warning (test-shinytest2.R:11:3): {shinytest2} recording: rap
Adding new file snapshot: 'tests/testthat/_snaps/rap-001.json'
────────────────────────────────────────────────────────────────────────────────
══ Results ═════════════════════════════════════════════════════════════════════
Duration: 12.0 s
[ FAIL 0 | WARN 2 | SKIP 0 | PASS 1 ]
rhino
Shiny modules
When setting inputs with app$set_inputs()
in sap
, the variable inputId
s are nested inside the module namespaces:
$set_inputs(`vars-y` = "imdb_num_votes")
app$set_inputs(`vars-x` = "critics_score")
app$set_inputs(`vars-z` = "genre") app
In rap
, the box
modules have another level of encapsulation (i.e., vars-y
becomes app-vals-y
):
$set_inputs(`app-vals-y` = "imdb_num_votes")
app$set_inputs(`app-vals-x` = "critics_score")
app$set_inputs(`app-vals-z` = "genre") app
It’s important to keep these differences in mind when writing shinytest2
tests.20
BDD system tests
I’ve provided a few shinytest2
example tests for the data visualization user-input features using testthat
s BDD functions:21
show/hide contents of tests/testthat/test-rap-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]", {
<- AppDriver$new(name = "feature-01-senario-a",
app height = 800, width = 1173)
$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()
app
})
})
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]'", {
<- AppDriver$new(name = "feature-01-senario-b",
app height = 800, width = 1173)
$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()
app
})
}) })
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
v22.14.0
npm -v
10.9.2
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()
:
show/hide app/view/message.R
# app/view/message.R
::use(
box
shiny[actionButton, div,
moduleServer, NS, renderText,
req, textOutput],
)
#' @export
<- function(id) {
ui <- NS(id)
ns div(
class = "message",
actionButton(
ns("show_message"),
"Show message"
),textOutput(ns("message_text"))
)
}
#' @export
<- function(id) {
server moduleServer(id, function(input, output, session) {
$message_text <- renderText({
outputreq(input$show_message)
"This is a message"
})
}) }
show/hide app/view/clicks.R
# app/view/clicks.R
::use(
box
shiny[actionButton, div,
moduleServer, NS,
renderText, textOutput]
)
#' @export
<- function(id) {
ui <- NS(id)
ns div(
class = "clicks",
actionButton(
ns("click"),
"Click me!"
),textOutput(ns("counter"))
)
}
#' @export
<- function(id) {
server moduleServer(id, function(input, output, session) {
$counter <- renderText(input$click)
output
}) }
Updated app/main.R
Using app/view/clicks
and app/view/message
also requires adding the modules to the app/main.R
file with box::use()
:
show/hide app/main.R
# app/main.R
# shiny functions
::use(
box
shiny[
NS, span, tags, icon, moduleServer
],# bslib functions
bslib[
page_fillable, layout_sidebar, sidebar,
card, card_header, card_body, bs_theme
]
)
# import modules ----
::use(
box## load inputs module
/ view / var_inputs,
app / view / aes_inputs,
app ## load display module
/ view / display,
app ## load clicks module
/ view / clicks,
app ## load message module
/ view / message
app
)
#' rap ui
#' @export
<- function(id) {
ui <- NS(id)
ns page_fillable(
# theme
theme = bs_theme(
bg = "#202123",
fg = "#B8BCC2",
# accent colors (e.g., hyperlink, button, etc)
primary = "#EA80FC",
secondary = "#48DAC6",
# fonts
base_font = c("Proxima Nova", "sans-serif"),
code_font = c("Hack", "monospace"),
heading_font = c("Optima", "sans-serif")
),layout_sidebar(
sidebar = sidebar(
# input modules
$ui(ns("vals")),
var_inputs$ui(ns("aes"))
aes_inputs
),card(
full_screen = TRUE,
card_header(
$button(id = "help-button",
tagsicon("info"),
# add 'onclick' after rhino::build_sass()
# and rhino::build_js()
onclick = "App.showHelp()"),
),card_body(
span(
# use clicks
$ui(ns("clicks")),
clicks# use message
$ui(ns("message")),
message
),$ui(ns("disp"))
display
)
)
)
)
}
#' rap server
#' @export
<- function(id) {
server
moduleServer(id, function(input, output, session) {
# collect variable inputs module server ----
<- var_inputs$server(id = "vals")
selected_vars # collect aesthetic inputs module server ----
<- aes_inputs$server(id = "aes")
selected_aes
# pass to display module server ----
$server(
displayid = "disp",
var_inputs = selected_vars,
aes_inputs = selected_aes
)
})
moduleServer(id, function(input, output, session) {
$server("clicks")
clicks$server("message")
message
})
}
- 1
-
include
app/view/clicks.R
andapp/view/message.R
modules in UI
- 2
-
include
app/view/clicks.R
andapp/view/message.R
modules in server
The new ‘clicks’ and ‘message’ buttons are visible when we launch our app:
Running Cypress tests
Cypress tests are stored in the tests/cypress/e2e/
folder:
tests
└── cypress
└── e2e └── app.cy.js
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() rhino
> 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: 13.6.2
[STARTED] Task without title.
[TITLE] Verified Cypress! /Users/mjfrigaard/Library/Caches/Cypress/13.6.2/Cypress.app
[SUCCESS] Verified Cypress! /Users/mjfrigaard/Library/Caches/Cypress/13.6.2/Cypress.app
Opening Cypress...
DevTools listening on ws://127.0.0.1:51925/devtools/browser/a47246ab-14bd-4c51-ad24-fec911eeb2d8
Opening `/dev/tty` failed (6): Device not configured
resize: can't open terminal /dev/tty
================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 13.6.2 │
│ Browser: Electron 114 (headless) │
│ Node Version: v23.11.0 (/usr/local/Cellar/node/23.11.0/bin/node) │
│ Specs: 1 found (app.cy.js) │
│ Searched: cypress/e2e/**/*.cy.{js,jsx,ts,tsx} │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: app.cy.js (1 of 1)
app
✓ starts (855ms)
1 passing (900ms)
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: 0 seconds │
│ Spec Ran: app.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Opening `/dev/tty` failed (6): Device not configured
resize: can't open terminal /dev/tty
================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ app.cy.js 903ms 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘ ✔ All specs passed! 903ms 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).
Writing Cypress tests
I’ll include the first test for the app/view/message
module from the rhino
cypress tutorial in the tests/cypress/e2e/
folder:
// tests/cypress/integration/message.cy.js
describe("Show message", () => {
beforeEach(() => {
.visit("/");
cy;
})
it("'Show message' button exists", () => {
.get(".message button").should("have.text", "Show message");
cy;
})
; })
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 adding the test to tests/cypress/
and running the test with rhino::test_e2e()
, we see the following output:
> 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
DevTools listening on ws://127.0.0.1:54091/devtools/browser/d35cc929-720b-4c07-aaa9-b49eb4af56f9
Opening `/dev/tty` failed (6): Device not configured
resize: can't open terminal /dev/tty
================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 13.6.2 │
│ Browser: Electron 114 (headless) │
│ Node Version: v23.11.0 (/usr/local/Cellar/node/23.11.0/bin/node) │
│ Specs: 2 found (app.cy.js, message.cy.js) │
│ Searched: cypress/e2e/**/*.cy.{js,jsx,ts,tsx} │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: app.cy.js (1 of 2)
app
✓ starts (865ms)
1 passing (912ms)
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: 0 seconds │
│ Spec Ran: app.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: message.cy.js (2 of 2)
Show message
✓ 'Show message' button exists (990ms)
✓ 'Show message' button shows the message' (1112ms)
2 passing (2s)
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 2 │
│ Passing: 2 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: 2 seconds │
│ Spec Ran: message.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
Opening `/dev/tty` failed (6): Device not configured
resize: can't open terminal /dev/tty
================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ app.cy.js 914ms 1 1 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ message.cy.js 00:02 2 2 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! 00:03 3 3 - - -
Execution halted
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:
::test_e2e(interactive = TRUE) rhino
After the application opens, you’ll be able to select the browser (I have Chrome selected).
After clicking on Start E2E Testing in Chromium, Cypress will launch the application in Chromium. You’ll see the tests in the sidebar:
Select a test to open it and click on Run All Tests:
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
andapp/view
(usingbox
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 ofbox
modules make it easy to move components into the application inapp/main.R
.New JavaScript or CSS code requires a corresponding
rhino
(rhino::build_js()
orrhino::build_sass()
), and requires installing node.js.- These functions create output files in
app/static/js/app.min.js
andapp/static/css/app.min.css
that are used in the application.
- These functions create output files in
Testing with
testthat
is similar to a standard R package, but usingbox
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 combinestestthat
functions with CSS selectors, etc.).
- Cypress tests are an excellent way to perform end-to-end tests (faster than
Footnotes
rhino
has a ‘minimalapp.R
’ philosophy, and the call torhino::app()
performs multiple operations beyondshiny::runApp()
↩︎Read more about how to use
config.yml
in the Environments section of the How to: Manage secrets and environments vignette.↩︎Read more about
rhino.yml
in the Explanation: Configuring Rhino - rhino.yml vignette. The code below showcases the optional arguments (and is not included in the application).↩︎dependencies.R
is covered in the Manage Dependencies vignette on the package website.↩︎Read more about getting started with
renv
.↩︎Testing with Cypress is also covered in the ‘Use
shinttest2
vignette’.↩︎Read more about
rhino
unit tests↩︎Add-on packages can also be included in R packages by adding the
@importFrom
or@import
tags fromroxygen2
, which writes theNAMESPACE
directives↩︎install.packages()
downloads and installs packages from a repo like CRAN or GitHub, andlibrary
(orrequire
) loads and attaches the add-on packages to the search list.↩︎Adding a package to the
Imports
field in theDESCRIPTION
will download/install the add-on package when your package is installed, but not attach it to the search list (theDepends
field will install and attach the package).↩︎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.↩︎This is how
box
“completely replaces the base Rlibrary
andrequire
functions” -box
documentation.↩︎I’d start with “the hierarchy of module environments” vignette. I’ve also created a collection of
box
module examples in therbox
repo.↩︎rhino
apps come with anapp/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.”↩︎The
movies.csv
file comes from the07_data
branch of sap.↩︎The
__init__.R
files are covered on therhino
website↩︎These system tests are written for a Shiny app in a standard R package.↩︎
These steps are covered in the Appsilon article, ‘How-to: Use shinytest2’.↩︎
This is referenced in the Cypress tutorial when trying to identify the text output from the
app/view/message
module.↩︎This test is covered in BDD test templates section of Shiny App-Packages.↩︎
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.’↩︎