In this tutorial, we will write tests that are going to check the behavior of a running Rhino application using Cypress.
Before going further, you need to install:
rhino
(surprised?) - install.packages("rhino")
First, we need to build a simple Rhino application that is going to serve as a playground for our tests.
To do that, create a new project using either RStudio wizard or
rhino::init("EndToEndTests")
function. For more details, check
Create an initial application
section in the basic Rhino tutorial.
Once you have the initial application, let's add some content that we can test.
Create two modules - clicks.R
and message.R
- in app/view
directory:
# app/view/clicks.R box::use( shiny[actionButton, div, moduleServer, NS, renderText, textOutput], ) #' @export ui <- function(id) { ns <- NS(id) div( class = "clicks", actionButton( ns("click"), "Click me!" ), textOutput(ns("counter")) ) } #' @export server <- function(id) { moduleServer(id, function(input, output, session) { output$counter <- renderText(input$click) }) }
# app/view/message.R box::use( shiny[actionButton, div, moduleServer, NS, renderText, req, textOutput], ) #' @export ui <- function(id) { ns <- NS(id) div( class = "message", actionButton( ns("show_message"), "Show message" ), textOutput(ns("message_text")) ) } #' @export server <- function(id) { moduleServer(id, function(input, output, session) { output$message_text <- renderText({ req(input$show_message) "This is a message" }) }) }
Now, we need to add those modules to app/main.R
:
# app/main.R box::use( shiny[column, fluidPage, fluidRow, moduleServer, NS], ) box::use( app/view/clicks, app/view/message, ) #' @export ui <- function(id) { ns <- NS(id) fluidPage( fluidRow( column( width = 6, clicks$ui(ns("clicks")) ), column( width = 6, message$ui(ns("message")) ) ) ) } #' @export server <- function(id) { moduleServer(id, function(input, output, session) { clicks$server("clicks") message$server("message") }) }
If you now run the application, you should see something similar to this:
There are two functionalities that we are going to test:
If you check the project structure, you will find tests/cypress/
directory
with an initial test in tests/cypress/e2e/app.cy.js
ready to use.
It contains a simple test that just starts the application. If your app can start, it will pass, no matter if it crashes a few seconds later.
Now, if you call rhino::test_e2e()
the test will be run, but before that, 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.
Once everything is installed, you will see the output of the test:
There is one more thing worth noticing. End-to-end tests have an interactive mode,
where you can see Cypress interacting with your application.
To use it, simply run tests with one additional argument: rhino::test_e2e(interactive = TRUE)
Since we have just one simple test, there will be not much there, but soon this will change.
We are about to write our first end-to-end test. The goal is to check if clicking the button with "Show message" label will show the message.
The first thing to do is to create a test file: tests/cypress/e2e/message.cy.js
.
Inside, provide a structure and description for tests using describe
and it
:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { it("'Show message' button exists", () => { }); it("'Show message' button shows the message", () => { }); });
As you can see, we are going to write two tests - the first one to check if the button exists and the second to check if it works properly and shows the message.
Now, let's make sure that in the beginning, Cypress will open the application.
To achieve that, add beforeEach
statement at the beginning:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { beforeEach(() => { cy.visit("/"); }); it("'Show message' button exists", () => { }); it("'Show message' button shows the message'", () => { }); });
Now before each test (each it
) Cypress will go to the application root URL.
Note that you don't need to provide the full address - it is already preconfigured
and you don't need to worry about it.
The test is going to use two Cypress commands - get
and should
.
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 build a CSS selector that will point Cypress to the correct button. This can be done using the part of the browser Developer Tools, usually called (depending on which browser you use) Inspector, Explorer, or Elements. You can learn more about Developer Tools available in your browser for example from this article.
Now, run the application (shiny::runApp()
in the R console or a button in RStudio),
open it in the browser, right-click on the "Show message" button, and select Inspect.
Now you should be able to see the HTML structure of the webpage with the button highlighted.
From this view, you can see that we can ask for example for a button
(HTML tag)
inside an element of class message
. Let's pass this selector to Cypress get
command:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { beforeEach(() => { cy.visit("/"); }); it("'Show message' button exists", () => { cy.get(".message button"); }); it("'Show message' button shows the message'", () => { }); });
The next step is to check if this button has a proper label:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { beforeEach(() => { cy.visit("/"); }); it("'Show message' button exists", () => { cy.get(".message button").should("have.text", "Show message"); }); it("'Show message' button shows the message'", () => { }); });
If you run tests (rhino::test_e2e()
), you will see that it passes - the button is there:
Ok, we are sure that the button exists, now it's time to click it and check the outcome.
Cypress comes with a command called click
(who would expect that?!).
Use it in the second test to click the button:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { beforeEach(() => { cy.visit("/"); }); it("'Show message' button exists", () => { cy.get(".message button").should("have.text", "Show message"); }); it("'Show message' button shows the message'", () => { cy.get(".message button").click(); }); });
Now, we just need to check if the message is there and has proper content.
We will do it the same as we did with the button - we will look for it using
the CSS selector and check if it contains the expected text.
This time we will use the id. If you check the element in the browser
(the same way as we did with the button), you will see that
the id of the message is "app-message-message_text". This shows, that in Rhino,
everything is a Shiny module, even the outermost UI and server part.
"app" in this selector is the namespace that identifies this outer module.
"message" is the namespace part added when we called our message.R
module
and "message_text" is the id of our textOutput
.
Ok, let's use it and check if the message element has a correct text inside:
// tests/cypress/e2e/message.cy.js describe("Show message", () => { beforeEach(() => { cy.visit("/"); }); it("'Show message' button exists", () => { cy.get(".message button").should("have.text", "Show message"); }); it("'Show message' button shows the message'", () => { cy.get(".message button").click(); cy.get("#app-message-message_text").should("have.text", "This is a message"); }); });
Run tests again and you should see that again, all passed:
As previously, we need to start with creating a file and providing the test structure.
Create tests/cypress/e2e/clicks.cy.js
and fill it with the skeleton
similar to the first test file:
// tests/cypress/e2e/clicks.cy.js describe("Counting clicks", () => { beforeEach(() => { cy.visit("/"); }); it("has a 'Click me!' button", () => { }); it("counts clicks", () => { }); });
First, we want to make sure the button exists. We will follow the example from the message module:
// tests/cypress/e2e/clicks.cy.js describe("Counting clicks", () => { beforeEach(() => { cy.visit("/"); }); it("has a 'Click me!' button", () => { cy.get(".clicks button").should("have.text", "Click me!"); }); it("counts clicks", () => { }); });
Now, if you run tests, you will see the new ones. And they should pass.
The next step is to check if clicking the button affects the counter and if it shows the correct value. To do that, we will simulate 5 clicks and check if the counter is set to 5.
Since the button will be clicked several times,
we will use an alias.
To do that, we will utilize another Cypress command: as
:
// tests/cypress/e2e/clicks.cy.js describe("Counting clicks", () => { beforeEach(() => { cy.visit("/"); }); it("has a 'Click me!' button", () => { cy.get(".clicks button").should("have.text", "Click me!"); }); it("counts clicks", () => { cy.get(".clicks button").as("button"); }); });
Now, we can access the button using @button
. So let's click it 5 times:
// tests/cypress/e2e/clicks.cy.js describe("Counting clicks", () => { beforeEach(() => { cy.visit("/"); }); it("has a 'Click me!' button", () => { cy.get(".clicks button").should("have.text", "Click me!"); }); it("counts clicks", () => { cy.get(".clicks button").as("button"); for (let i = 0; i < 5; i++) { cy.get("@button").click(); } }); });
Finally, we need to check if the counter has the correct value
(you already know how to look for its id to pass to get
command):
// tests/cypress/e2e/clicks.cy.js describe("Counting clicks", () => { beforeEach(() => { cy.visit("/"); }); it("has a 'Click me!' button", () => { cy.get(".clicks button").should("have.text", "Click me!"); }); it("counts clicks", () => { cy.get(".clicks button").as("button"); for (let i = 0; i < 5; i++) { cy.get("@button").click(); } cy.get("#app-clicks-counter").should("have.text", "5"); }); });
Now, run your tests. You should see something similar to this (you might have noticed that now clicking test took more time than previously - that's because the clicking finally happened):
If you run tests using the interactive mode (rhino::test_e2e(interactive = TRUE)
), you will be able to
check each click as a separate action!
Let's now think about a scenario, when, during the development of the application some new feature has been introduced. This feature works fine, but unfortunately, it added some unexpected changes to other features.
We will simulate this by a small modification in the app/view/clicks.R
- we will add 1 to the counter:
# app/view/clicks.R box::use( shiny[actionButton, div, moduleServer, NS, renderText, textOutput], ) #' @export ui <- function(id) { ns <- NS(id) div( class = "clicks", actionButton( ns("click"), "Click me!" ), textOutput(ns("counter")) ) } #' @export server <- function(id) { moduleServer(id, function(input, output, session) { output$counter <- renderText(input$click + 1) }) }
If you now run tests, you will see that the counting one failed:
This way we spotted the (unexpected) 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 modified.
But here comes a question: Do you need to run those tests each time you make a change?
Fortunately, this can be automated! Rhino comes with a setup for GitHub Actions will run those tests for you and inform you if there is a problem. You can even block merging changes that don't pass those automated checks.
What do you need to do to get all of this?
If you are using GitHub - literally nothing! Your Rhino application
comes with a GitHub Actions configuration (.github/workflows/rhino-test.yml
)
that includes end-to-end tests!
Adding end-to-end tests to your application can ensure the quality and help catch bugs early in the process of development. Rhino comes with a powerful setup that utilizes Cypress to check the behavior of your application.
But what if you want to use another solution: shinytest2
?
You can do that without any problems, shinytest2
works out of the box in Rhino applications!
You can learn more here.
And if you want to learn about the differences between Cypress and shinytest2
,
check this blogpost.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.