Automating End-to-End Cypress Tests in Rhino: A Guide to Seamless UI Testing for Shiny Apps
<b>No one wants to break an application!</b> And part of the process of developing a quality R/Shiny dashboard is making sure that new features or bug fixes do not create new problems.
This can be done by <b>testing the application</b>, something that developers perform manually for the task at hand, but overtime this process becomes too time consuming and inconsistent when looking at all the components of an application.
Here is where automated tests enter the picture. These can range from small-scall unit tests that verify expected output from function calls or include interface tests that simulate user interaction with the application.
<h2>End-to-End UI Testing a Rhino Shiny app</h2>
In this article, we will focus on user interface (UI) testing and how we can take advantage of the Rhino framework to seamlessly create <b>end-to-end tests</b> <i>(e2e)</i> for an R/Shiny application <b>using Cypress</b>.
<b>Rhino provides the infrastructure</b> to create enterprise ready applications and includes ready to use end-to-end tests via the <b>Cypress </b>testing framework. Cypress is used to test modern applications in the browser and is not specific to Shiny. For Shiny-specific testing, consider <a href="https://appsilon.com/shinytest2-vs-cypress-e2e-testing/" target="_blank" rel="noopener">shinytest2</a>.
This article will show an example of how to work with Cypress. We can start by setting up the environment by installing R and Node.js <i>(needed for Cypress)</i>. This tutorial uses <a href="https://posit.co/downloads/" target="_blank" rel="noopener">RStudio IDE</a> for demonstration purposes, but it is an optional requirement.
TOC:
<ul><li><a href="#nodejs">R and Node.JS</a></li><li><a href="#rhino">🦏 Setting Up Rhino</a></li><li><a href="#css-sass">🎨 Make It Look Blue: CSS/SASS on Shiny</a></li><li><a href="#first-test">✏️ Preparing for the First Cypress Test</a></li><li><a href="#writing-test">✏️ Writing the First Cypress Test</a></li><li><a href="#new-features">🏗 Building A Shiny Dashboard with New Features</a></li><li><a href="#testing-features">✏️ Writing Tests for New Shiny App Features</a></li><li><a href="#message-test">🗒️ Writing a Message Test</a></li><li><a href="#click-test">🗒️ Writing a Click Test</a></li><li><a href="#map-test">🗒️ Writing a Map Interaction Test</a></li><li><a href="#changes">🟡 What Happens When the App Changes?</a></li><li><a href="#ci">🔄 Continuous Integration</a></li><li><a href="#map-test">🗒️ Writing a Map Interaction Test</a></li></ul>
[video width="784" height="500" webm="https://wordpress.appsilon.com/wp-content/uploads/2023/06/cypress-running-interactively-with-all-tests-in-rhino-r-shiny-app.webm" loop="true" autoplay="true"][/video]
<hr />
<h2 id="nodejs">R and Node.JS</h2>
R can be installed from the <a href="https://cloud.r-project.org/" target="_blank" rel="noreferrer noopener">r-project.org</a> following the instructions on their download page.
Node.JS is needed to run Cypress tests and the instructions for the different <a href="https://nodejs.org/en/download/" target="_blank" rel="noreferrer noopener">operating systems can be found here</a> (Windows, MacOS, Linux and Docker).
To test if Node.JS is correctly installed we can call the command below on the R / RStudio console to check the installed version:
<pre><code>
> system(“node -v”)
</code></pre>
ℹ️ Tip for Linux Users: When using the node version manager (NVM) you should be aware of a known issue with RStudio and <a href="https://appsilon.github.io/rhino/articles/explanation/node-js-javascript-and-sass-tools.html#node-installation-via-nvm" target="_blank" rel="noopener">how to mitigate it</a>.
<h2 id="rhino">🦏 Setting Up Rhino</h2>
Once the environment is ready, change the working directory to an empty folder or start a new project on RStudio, and initialize Rhino.
<pre><code>
> install.packages(“rhino”)
> rhino::init()
</code></pre>
The boilerplate code will add a few files and directories to the working directory:
<ul><li>renv environment to manage installed packages and versions</li><li>Github actions for continuous integration <i>(including for e2e tests)</i></li><li>Shiny folder structure on app/ directory</li><li><b>Test folder for e2e </b>and unit tests</li></ul>
The boilerplate Shiny application is ready to be run and will show a default dashboard. We will replace main.R with a minimal example that renders “Hello!” via the `renderText()` Shiny function. We will do this by replacing the `app/main.R` with the code below:
<pre><code>
# app/main.R
<br>box::use(
shiny[h3, textOutput, renderText, fluidPage, fluidRow, moduleServer, NS],
)
<br>#' @export
ui <- function(id) {
ns <- NS(id)
fluidPage(fluidRow(h3(class = "title", textOutput(ns("header")))))
}
<br>#' @export
server <- function(id) {
moduleServer(id, function(input, output, session) {
output$header <- renderText("Hello!")
})
}
</code></pre>
When running the Shiny application with the default Shiny command we will be greeted:
<pre><code>
> shiny::runApp()
</code></pre>
<img class="wp-image-19600 size-full" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b183c93260f451effab_boilerplate-rhino-r-shiny-dashboard.webp" alt="Boilerplate Rhino dashboard" width="852" height="162" /> Boilerplate Rhino dashboard
<h2 id="css-sass">🎨 Make It Look Blue: CSS/SASS on Shiny</h2>
Let’s make the application look better than the default blank style by adding a snippet of CSS/SASS on <code>`app/styles/main.scss` </code>and build the CSS file. Besides an integration with Cypress e2e tests, Rhino also boasts a seamless integration with SASS.
<pre><code>
body {
margin: 0;
background-color: #15354a;
padding: 1em;
<br> .container-fluid {
border-radius: 0.5em;
max-width: 1200px;
background-color: white;
<br> .row {
padding: 1em;
}
}
}
</code></pre>
Build the CSS for the dashboard using the command below:
<pre><code>
> rhino::buil_sass()
> shiny::runApp()
<br># The browser page might need to be reloaded to refresh the cache</code></pre>
<img class="size-full wp-image-19598" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b18281264fb1e19edc3_boilerplate-rhino-dashboard-with-simple-styling.webp" alt="Boilerplate Rhino dashboard with simple styling" width="852" height="162" /> Boilerplate Rhino dashboard with simple styling
<h2 id="first-test">✏️ Preparing for the First Cypress Test</h2>
As we can see from the previous screenshot, the R/Shiny dashboard only shows a simple ‘hello' message. We can start by testing this via Cypress to understand how to create simple tests.
The tests in Cypress are written in JavaScript and are based on the <code>“cy”</code> object. It is very intuitive to replicate a user’s manual action. It mostly revolves around looking for elements, checking their content and properties, whilst performing actions.
For the first test, we want to check if it contains the title showing <code>“Hello!”</code> text. We can modify the <code>`app.spec.js`</code> file located at <code>`tests/cypress/integration`</code> and prepare by creating an empty test with a descriptive title.
<pre><code>
describe('app', () => {
# ...
<br> it('Hello text appears', () => { })
})
</code></pre>
Let’s make sure that in the beginning, Cypress will open the dashboard. To achieve that, add a <code>`beforeEach()`</code> statement at the top of the test so that before each test (each <code>“it”</code>) Cypress will go to the application root URL.
<pre><code>
describe('app', () => {
beforeEach(() => {
cy.visit('/')
})
it('Hello text appears', () => { })
})
</code></pre>
ℹ️ Tip: You don’t need to provide the full address, as the base URL is already pre-configured by Rhino <i>(see the </i><i><code>`tests/cypress.json`</code></i><i> configuration file)</i>
<h2 id="writing-test">✏️ Writing the First Cypress Test</h2>
The test is going to use two Cypress commands: <code>`get()`</code> and <code>`should()`</code>. The first one will look for an element using a given CSS selector and the second will check if the selected element has the expected property.
First, we need to use a CSS selector that will point Cypress to the correct button. This can be done using the browser’s ‘Developer Tools’, that can be called ‘Inspector’, ‘Explorer’, or ‘Elements’ <i>(depending on the browser)</i>. We can learn more about ‘Developer Tools’ available in the browser from <a href="https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools" target="_blank" rel="noreferrer noopener">this article by Mozilla</a>.
The following steps can be generally performed to find a CSS selector:
<ul><li style="font-weight: 400;" aria-level="1">Run the application<code> `shiny::runApp()</code>`</li><li style="font-weight: 400;" aria-level="1">Open it in the browser</li><li style="font-weight: 400;" aria-level="1">Right-click on the<code> “Hello!”</code> text, and select <code>Inspect</code></li></ul>
[video width="792" height="500" webm="https://wordpress.appsilon.com/wp-content/uploads/2023/06/html-elements-and-css-selector-for-shiny-hello-string.webm" loop="true" autoplay="true"][/video]
We should be able to see the HTML structure of the webpage with the text highlighted. The selected element is an HTML <code>H3</code> tag with a <code>DIV</code> tag inside. We can use any CSS selector that allows to select the element, such as the class of the <code>H3</code> or the id of the <code>DIV.</code>
Let’s pass two ways to select the text to Cypress’ <code>`get()`</code> function (using the <code>H3</code> tag with the <code>“title”</code> class as well as the class id of the Shiny element).
<pre><code>
describe('app', () => {
beforeEach(() => {
cy.visit('/')
})
<br> it('starts', () => {})
<br> it('hello text appears', () => {
cy.get("h3.title").should("contain", "Hello")
cy.get("#app-header").should("contain", "Hello")
})
})
</code></pre>
This example shows two different methods of looking for the text, with the second being more specific by searching the element for the exact id.
<pre><code>
> rhino::test_e2e()</code></pre>
[video width="738" height="286" webm="https://wordpress.appsilon.com/wp-content/uploads/2023/06/console-result-of-cypress-successful-tests.webm" loop="true" autoplay="true"][/video]
ℹ️ Tip: If you interrupt the <code>`rhino::test_e2e()`</code> the first time it runs and it shows errors the second time, then delete the hidden folder named `.rhino` on the root directory.
ℹ️ Tip: Running the tests with<code> `interactive = TRUE`</code> allows you to inspect the tests using the Cypress interface <i>(see the first GIF on the article</i>
<h2 id="new-features">🏗 Building A Shiny Dashboard with New Features</h2>
We are building a simple dashboard with 3 main features <i>(and later create additional tests)</i>:
<ul><li style="font-weight: 400;" aria-level="1">Message that only shows after clicking a button</li><li style="font-weight: 400;" aria-level="1">Counter that is increased every time a button is clicked</li><li style="font-weight: 400;" aria-level="1">Map that adds random location markers with each counter click</li></ul>
Let’s take advantage of modules in Shiny and create one for each of the features in the <code>`app/view/`</code> directory (<code>`clicks.R`</code>,<code> `map.R`</code> and<code> `message.R`</code>). We also need to update `main.R` to include the new modules.
<pre><code>
# app/view/message.R
box::use(
shiny[actionButton, div, moduleServer, NS, renderText, req, textOutput],
)
<br>#' @export
ui <- function(id) {
ns <- NS(id)
<br> div(
class = "message",
actionButton(
ns("show_message"),
"Show message"
),
textOutput(ns("message_text"))
)
}
<br>#' @export
server <- function(id) {
moduleServer(id, function(input, output, session) {
output$message_text <- renderText({
req(input$show_message)
"This is a message"
})
})
}
</code></pre>
<pre><code>
# app/view/clicks.R
box::use(
shiny[actionButton, div, moduleServer, NS, renderText, textOutput, reactive],
)
<br>#' @export
ui <- function(id) {
ns <- NS(id)
div(
class = "clicks",
actionButton(ns("click"), "Click me!"),
textOutput(ns("counter"))
)
}
<br>#' @export
server <- function(id) {
moduleServer(id, function(input, output, session) {
output$counter <- renderText(input$click)
return(reactive(input$click))
})
}
</code></pre>
<pre><code>
# app/view/map.R
box::use(
shiny[div, moduleServer, NS, reactive, observe, bindEvent],
leaflet[
leafletOutput, renderLeaflet, leaflet, addProviderTiles, providers,
providerTileOptions, addMarkers, leafletProxy, setView, fitBounds
],
stats[rnorm],
)
<br>#' @export
ui <- function(id) {
ns <- NS(id)
div(class = "maps", leafletOutput(ns("map_random")))
}
<br>#' @export
server <- function(id, input_click) {
moduleServer(id, function(input, output, session) {
output$map_random <- renderLeaflet({ leaflet() |>
fitBounds(lng1 = -11.16, lat1 = 34.9, lng2 = 22.4, lat2 = 58) |>
addProviderTiles(
providers$Stamen.TonerLite,
options = providerTileOptions(noWrap = TRUE)
)
})
<br> observe({
points <- cbind(rnorm(1, mean = 10.6, sd = 10), rnorm(1, mean = 49.1, sd = 3)) leafletProxy("map_random") |> addMarkers(data = points)
}) |>
bindEvent(input_click())
})
}
</code></pre>
<pre><code>
# app/main.R
box::use(
shiny[h3, textOutput, renderText, column, fluidPage, fluidRow, moduleServer, NS],
)
<br>box::use(
app/view/clicks,
app/view/message,
app/view/map,
)
<br>#' @export
ui <- function(id) {
ns <- NS(id)
fluidPage(
fluidRow(h3(class = "title", textOutput(ns("header")))),
fluidRow(
column(width = 6, clicks$ui(ns("clicks"))),
column(width = 6, message$ui(ns("message")))
),
fluidRow(map$ui(ns("map")))
)
}
<br>#' @export
server <- function(id) {
moduleServer(id, function(input, output, session) {
output$header <- renderText("Hello!")
input_click <- clicks$server("clicks")
message$server("message")
map$server("map", input_click)
})
}
</code></pre>
<h2>🗺️ Install Leaflet <i>(requirement for map module)</i></h2>
We need to install the “leaflet” library before running the application. This can be done via renv:
<pre><code>
> renv::install("leaflet")</code></pre>
The library should be added to the <code>`dependencies.R`</code> file in the root directory.
<pre><code>
# This file allows packrat (used by rsconnect during deployment) to pick up dependencies.
library(rhino)
library(leaflet)
</code></pre>
<h2>🎴 Running the Shiny Application</h2>
We can check how the new modules look by running the dashboard via:
<pre><code>
> shiny::runApp()</code></pre>
<video width="100%" height="auto" src="https://wordpress.appsilon.com/wp-content/uploads/2023/06/shiny-app-demo-with-3-modules-implemented.webm" loop="true" autoplay="true" controls="true"></video>
<h2 id="testing-features">✏️ Writing Tests for New Shiny App Features</h2>
We are going to test the new features by checking:
<ul><li style="font-weight: 400;" aria-level="1">The initial message is not visible <i>(unless clicked)</i></li><li style="font-weight: 400;" aria-level="1">The buttons exist and are visible <i>(“Click me!” and “Show message”)</i></li><li style="font-weight: 400;" aria-level="1">Counter starts at 0</li><li style="font-weight: 400;" aria-level="1">Every time the button on the left is clicked it will:<ul><li style="font-weight: 400;" aria-level="2">Increase the counter</li><li style="font-weight: 400;" aria-level="2">Add new marker to map</li></ul>
</li>
</ul>
We can start with `message` tests by creating a new file for the specification <code>`message.spec.js`</code>.
<h3 id="message-test">🗒️ Writing a Message Test</h3>
The first thing to do is to create a test file:<code> `tests/cypress/integration/message.spec.js`</code> with a similar structure as the simple example above. It should include the boilerplate for the tests (the <code>“it”</code>) and indicate to Cypress that it should visit the root page before running each test.
<pre><code>
# tests/cypress/integration/message.spec.js
describe("Show message", () => {
beforeEach(() => {
cy.visit('/')
})
<br> it("'Show message' button exists", () => { });
<br> it("'Show message' button shows the message", () => { });
});
</code></pre>
The <code>“‘Show message' button exists”</code> workflow should start by finding the button using a CSS selector and then check:
<ul><li style="font-weight: 400;" aria-level="1">Button is visible</li><li style="font-weight: 400;" aria-level="1">Button has the expected content</li></ul>
We will use the <code>`should(“be.visible”)`</code> and <code>`should(“have.text”, “<some text>”)`</code> to perform these checks.
The second test “‘Show message’ button shows the message” will need to click the button using Cypress’ <code>`click()`</code> method and lookup if the message appears. We will also check that the message is not visible before clicking.
<pre><code>
# tests/cypress/integration/message.spec.js
describe("Show message", () => {
beforeEach(() => {
cy.visit('/')
})
<br> it("'Show message' button exists", () => {
// check if button exists and has correct label
cy.get(".message button")
.should("be.visible")
.should("have.text", "Show message");
});
<br> it("'Show message' button shows the message", () => {
// ensure that the message is not visible without clicking the button
cy.get("#app-message-message_text").should("not.be.visible");
<br> // click on message button to display message
cy.get(".message button").click();
cy.get("#app-message-message_text")
.should("be.visible")
.should("have.text", "This is a message");
});
});
</code></pre>
<img class="size-full wp-image-19604" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b19ee976c4ea98eb216_cypress-message-after-specification-test.webp" alt="" width="806" height="178" /> Message from Cypress after running the message specification tests
<h3 id="click-test">🗒️ Writing a Click Test</h3>
Next we will create the specification for the “click” counter functionality, by creating the file <code>`tests/cypress/integration/click.spec.js`</code>.
This workflow will look at 3 parts of the interface:
<ol><li style="font-weight: 400;" aria-level="1">The button is visible and has the expected content</li><li style="font-weight: 400;" aria-level="1">The initial value for the counter is 0 <i>(zero)</i></li><li style="font-weight: 400;" aria-level="1">When clicking the button it should verify that the counter on the dashboard increases<ul><li style="font-weight: 400;" aria-level="2">We will test with an arbitrary number of clicks</li></ul></li></ol>
We will skip the boilerplate code with 3 empty tests and share the code. Note that there is a click on the message button, just to make sure that clicks on other buttons don’t affect the counter.
<pre><code>
# tests/cypress/integration/clicks.spec.js
describe("Counting clicks", () => {
beforeEach(() => {
cy.visit("/");
});
<br> it("Has a 'Click me!' button", () => {
cy.get(".clicks button")
.should("have.text", "Click me!")
.should("be.visible");
});
<br> it("Counter starts at zero", () => {
cy.get("#app-clicks-counter")
.should("have.text", "0")
.should("be.visible");
})
<br> it("Counter increases with clicks", () => {
cy.get(".clicks button").as("button");
<br> for (let i = 0; i < 10; i++)
cy.get("@button").click();
<br> cy.get(".message button").click();
<br> cy.get("#app-clicks-counter").should("have.text", "10");
});
});
</code></pre>
When running the tests in interactive mode, we can observe the test running automatically and we can also inspect each step.
<video width="100%" height="auto" src="https://wordpress.appsilon.com/wp-content/uploads/2023/06/interactive-cypress-click-test-in-shiny.webm" loop="true" autoplay="true" controls="true"></video>
<h3 id="map-test">🗒️ Writing a Map Interaction Test</h3>
The map specification will be similar to the click, but instead of looking for the counter content it should check the number of markers on the map.
We can start by adding a few markers on the map and inspecting one of them to find a valid CSS selector. We can use the “<code>leaflet-marker-icon</code>” class and count the number of markers.
<video width="100%" height="auto" src="https://wordpress.appsilon.com/wp-content/uploads/2023/06/valid-class-for-location-marker-demo.webm" loop="true" autoplay="true" controls="true"></video>
We will create a specification in <code>`tests/cypress/integration/map.spec.js`</code> with a test that clicks on the button creating a bunch of markers and then counts the number of markers that exist on the map. As with the other specification, we need to visit the root page before each test.
<pre><code>
# tests/cypress/integration/map.spec.js
describe("Counting map markers", () => {
beforeEach(() => {
cy.visit("/");
});
<br> it("Has no markers", () => {
cy.get("#app-map-map_random .leaflet-marker-icon").should('have.length', 0)
});
<br> it("Each click adds a marker", () => {
cy.get(".clicks button").as("button");
<br> for (let i = 0; i < 100; i++)
cy.get("@button").click();
<br> // Clicking on show message shouldn't add or remove any markers
cy.get("#app-message-show_message").click()
<br> cy.get("#app-map-map_random .leaflet-marker-icon")
.should('have.length', 100);
});
});
</code></pre>
The tests use the “<code>have.length</code>” property of the “<code>should()</code>” method to check the number of markers on the map. We also check if there are no markers before clicking on the counter.
<img class="size-full wp-image-19608" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b1aee976c4ea98eb45c_cypress-message-map-test-specs.webp" alt="" width="801" height="190" /> Message from Cypress after running the map test specifications. Note that it takes around 8 seconds to finish the test, as the interface needs to click on the button 100 times. And then it will wait for the markers to have the correct length for a default timeout.
<h2>ℹ️ How Long Does Cypress Wait for Elements?</h2>
Cypress will wait for a default of 4000 ms between commands and this timeout can be adjusted globally for a specific test or to a single command.
The example below shows 3 different methods to change the timeout to 10 seconds:
<pre><code>
// change the overall configuration
Cypress.config('defaultCommandTimeout', 10000);
<br>// Change for a single test
it('Should do something', { defaultCommandTimeout: 10000 }, () => {
// ...
})
<br>// Change for a single command
cy.get('ELEMENT', { timeout: 10000 })</code></pre>
<h2 id="changes">🟡 What Happens When the App Changes?</h2>
Let’s now think about a scenario in which, during the development of the application, a new feature has been introduced. This feature works fine; however, it has unfortunately introduced some unexpected changes to other features.
We will simulate this by a small modification in the <code>`app/view/clicks.R`</code>and add 1 to the counter:
<pre><code>
# ...
server <- function(id) {
moduleServer(id, function(input, output, session) {
output$counter <- renderText(input$click + 1)
return(reactive(input$click))
})
}
</code></pre>
If you now run tests, you will see that the two tests fail, the one that verifies the initial counter value, and the test that counts the number of clicks.
<img class="size-full wp-image-19606" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b1c67ce14432ee3d2f2_cypress-message-after-unsuccessful-test.webp" alt="Message from Cypress after running the unsuccessful tests comparing the expected result with the actual result." width="846" height="610" /> Message from Cypress after running the unsuccessful tests comparing the expected result with the actual result.
This way we can spot the <i>(unexpected)</i> change in the application behavior in an automated way. Without end-to-end tests, this could go unnoticed, since the app still works. There are no errors, it is just the logic that has been changed in a way we didn’t want it to be.
<h2 id="ci">🔄 Continuous Integration</h2>
Here’s a question for you: <b>Do we need to run those tests each time there’s a new feature or fix?</b>
Answer:<b> Yes, you should.</b>
Fortunately, this can be automated! Rhino comes with a setup for<a href="https://github.com/features/actions" target="_blank" rel="noreferrer noopener"> GitHub Actions</a> which will run those tests and inform you if there is a problem.
ℹ️ Tip: You can even block merging changes that don’t pass those automated checks.
What do we need to do to get all of this? When using GitHub.. nothing! The Rhino application comes with a GitHub Actions configuration <code>`.github/workflows/rhino-test.yml`</code> that includes end-to-end tests!
<img class="size-full wp-image-19616" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01b1d87923e0adc158828_linter-test.webp" alt="Screenshot of the Github Action running." width="1534" height="874" /> Screenshot of the Github Action running
<h2>Cypress Testing in Rhino-Built Shiny Apps</h2>
Adding end-to-end tests to your application can ensure the quality and help catch bugs early in the process of development. Ultimately, this can save you time and resources (not to mention headaches!). Rhino comes with a powerful setup that utilizes <a href="https://www.cypress.io/" target="_blank" rel="noreferrer noopener">Cypress</a> to check the behavior of your application.
But what if you want to use another solution: shinytest2?
You can do that! shinytest2 works out of the box in Rhino applications! You can learn more on the Rhino tutorials page.
And feel free to explore our blog to learn about the differences between Cypress and shinytest2.