11.3 Testing Your App
What to test:
business logic
user interface
reactive connections
application load
11.3.1 Testing the business logic
Good practice:
business logic (non-reactive) is separated from interactive logic
application is inside a package
Standard package development tools are available to you
testthat
devtools
library(testthat)
function() 42
meaning_of_life <-
describe("The meaning of life", {
it("is always 42", {
expect_equal(meaning_of_life(), 42)
}) })
## Test passed 🥇
11.3.2 shiny::testServer
For testing reactive updates to server-side values
library(shiny)
# Given the following module
function(id, multiplier = 10) {
computation_module_server <-moduleServer(id, function(input, output, session) {
NS(id)
ns <- reactiveValues(
r <-value = NULL
)observeEvent(input$selector, {
$value <- input$selector * multiplier
r
})
})
}
describe("'value'", {
it("updates to 'multiplier' * 'selector'", {
# with default multiplier
testServer(computation_module_server, {
$setInputs(selector = 1)
sessionexpect_equal(r$value, 10)
$setInputs(selector = 2)
sessionexpect_equal(r$value, 20)
})# setting a non-default multiplier
testServer(computation_module_server, args = list(multiplier = 15), {
$setInputs(selector = 3)
sessionexpect_equal(r$value, 45)
})
}) })
## Test passed 🎉
11.3.3 Testing the interactive logic
Several options available for testing UI and interactivity
11.3.3.1 Puppeteer
Mimics a session on the app
- Puppeteer
- NodeJS module
- Google Chrome headless session
npm install puppeteer
Chrome extension
- Headless recorder
- Website
- Creates scripts for puppeteer and playwright
- This records button clicks and text input
- Note: you have to hit TAB after text input or it won’t be recorded
[Interactive]
- Load Hexmake website
- Load Headless Recorder “Basic Usage”
- Click “Headless Recorder” Icon in Chrome extensions
- Click the BIG RED BUTTON
- Click “Manage Name” on hexmake page
- Change Name to “dupree” then hit TAB
- Stop the recording
- Copy the code to the clipboard
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto('https://connect.thinkr.fr/hexmake/')
await page.setViewport({ width: 898, height: 926 })
await page.waitForSelector('.row > .col > .rounded > details:nth-child(3) > summary')
await page.click('.row > .col > .rounded > details:nth-child(3) > summary')
await page.waitForSelector('#main_ui_1-left_ui_1-pkg_name_ui_1-package')
await page.click('#main_ui_1-left_ui_1-pkg_name_ui_1-package')
await page.type('#main_ui_1-left_ui_1-pkg_name_ui_1-package', 'dupree')
await browser.close()
# rerun the script
node ./examples/ch11/my_puppeteer_script.js
/home/russ/github/bookclub-epgs/examples/ch11/my_puppeteer_script.js:2
const browser = await puppeteer.launch()
^^^^^
SyntaxError: await is only valid in async functions and the top level bodies of modules
at Object.compileFunction (node:vm:353:18)
at wrapSafe (node:internal/modules/cjs/loader:1039:15)
at Module._compile (node:internal/modules/cjs/loader:1073:27)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1138:10)
at Module.load (node:internal/modules/cjs/loader:989:32)
at Function.Module._load (node:internal/modules/cjs/loader:829:14)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:76:12)
This didn’t actually work. Comparing to the book example, and some other puppeteer examples, we see it should probably have looked like this:
const puppeteer = require('puppeteer');
async () => {
(const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto('https://connect.thinkr.fr/hexmake/')
await page.setViewport({ width: 898, height: 926 })
await page.waitForSelector('.row > .col > .rounded > details:nth-child(3) > summary')
await page.click('.row > .col > .rounded > details:nth-child(3) > summary')
await page.waitForSelector('#main_ui_1-left_ui_1-pkg_name_ui_1-package')
await page.click('#main_ui_1-left_ui_1-pkg_name_ui_1-package')
await page.type('#main_ui_1-left_ui_1-pkg_name_ui_1-package', 'dupree')
await browser.close()
})()
node ./examples/ch11/my_fixed_puppeteer_script.js
[Interactive]
- Try changing the script:
- use a non-existing selector and see the script fails
- use
puppeteer.lauch({ headless: false });
11.3.3.2 {crrri} and {crrry}
That’s fine, but do we really want to have a node.js env in all our shiny projects?
- {crrri}
- {chromote}
- {crrry} - puppeteer like, tailored for {shiny}
Why the strange names?
- they use the “Chrome remote interface”
# to install {crrry}
::install_github("ColinFay/crrry") remotes
# Creating a new test instance
crrry::CrrryOnPage$new(
test <-# Using the `find_chrome()` function to guess where the
# Chrome bin is on our machine
chrome_bin = pagedown::find_chrome(),
# Launching Chrome on a random available port on our machine
# Note that you will need httpuv >= 1.5.2 if you want to use
# this function
chrome_port = httpuv::randomPort(),
# Specifying the page we want to connect to
url = "https://connect.thinkr.fr/hexmake/",
# Do everything on the terminal, with no window open
headless = TRUE
)
# We'll wait for the application to be ready to accept inputs
$wait_for_shiny_ready() test
A simple test:
- we input a package name
- then check that the updated package name matches our input name
"dupree"
new_pkg <- "main_ui_1-left_ui_1-pkg_name_ui_1-package"
pkg_name_selector <- glue::glue("$('#{pkg_name_selector}').attr('value')")
js_get_pkg <-
$shiny_set_input(pkg_name_selector, new_pkg)
test$wait_for_shiny_ready()
test
# This (seemingly trivial) test fails:
# - test$shiny_set_input doesn't change the 'visible' input
# - but, it triggers reactive changes on the server side
expect_equal(
$call_js(js_get_pkg)[["result"]][["value"]],
testexpected = new_pkg
)
That test failed (test$set_shiny_input
sets the inputs that are passed to the server).
We can use javascript / jquery syntax to set the UI-attached values.
"dupree"
new_pkg <- "main_ui_1-left_ui_1-pkg_name_ui_1-package"
pkg_name_selector <- glue::glue("$('#{pkg_name_selector}').attr('value')")
js_get_pkg <- glue::glue("$('#{pkg_name_selector}').attr('value', '{new_pkg}')")
js_set_pkg <-
$call_js(js_set_pkg)
test$wait_for_shiny_ready()
test
expect_equal(
$call_js(js_get_pkg)[["result"]][["value"]],
testexpected = new_pkg
)
$stop() test
Available methods:
- call_js()
- shiny_set_input()
- wait_for()
- click_on_id