knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE)
library(shiny) library(brochure) library(testthat)
A single page Shiny app has one server function, and shiny::testServer()
drives it. A brochure app has one per page, plus the thing that decides which
of them you get. That last part is new, it is where most of the bugs are, and
it is the easiest of the three to test — no browser, no server, no reactivity.
Three layers, three tools:
| What you are testing | How |
| --- | --- |
| which page answers an url, and with what | call app$httpHandler() |
| which server a session runs | call app$serverFuncSource() |
| what a page computes | testServer(), on a module |
| that it all works in a browser | a browser |
brochureApp() returns a shiny.appobj, and its httpHandler is an ordinary
function from a request to a response. Call it and look at what comes back:
app <- brochureApp( page(href = "/", ui = tagList(h1("Home"))), page(href = "/contact", ui = tagList(h1("Contact"))), redirect(from = "/index.html", to = "/"), content_404 = "Nothing here" ) res <- app$httpHandler(mock_req("/contact")) res$status #> [1] 200
A request is a Rook environment — what httpuv hands the app. It is small enough to write by hand, and four fields are enough to be dispatched:
mock_req <- function(path, method = "GET") { req <- new.env(parent = emptyenv()) req$REQUEST_METHOD <- method req$PATH_INFO <- path req$QUERY_STRING <- "" # What makes the dispatch accept this as a request req$rook.version <- "1.1-0" req }
Keep it in tests/testthat/helper-mock.R and every routing test is two lines:
test_that("each url reaches its own page", { expect_match(app$httpHandler(mock_req("/"))$content, "Home") expect_match(app$httpHandler(mock_req("/contact"))$content, "Contact") }) test_that("an unknown url gets the 404", { res <- app$httpHandler(mock_req("/nope")) expect_equal(res$status, 404) expect_equal(res$content, "Nothing here") }) test_that("the old index redirects home", { res <- app$httpHandler(mock_req("/index.html")) expect_equal(res$status, 301) expect_equal(res$headers$Location, "/") })
This is also where you check the order two hrefs were declared in — the rule that bites silently:
test_that("/who/me is not caught by /who/:id", { app <- brochureApp( page(href = "/who/me", ui = tagList(h1("It's you"))), page(href = "/who/:id", ui = tagList(h1("Someone else"))) ) expect_match(app$httpHandler(mock_req("/who/me"))$content, "It's you") })
A response is a list, so anything a handler put on it is readable. Cookies:
test_that("logging in sets the session cookie", { app <- brochureApp( page( href = "/login", ui = tagList(h1("Logged in")), res_handlers = list(~ set_cookie(.x, "SESSION", "abc")) ) ) res <- app$httpHandler(mock_req("/login")) expect_match(res$headers$`Set-Cookie`, "SESSION=abc") })
A request handler answering on its own:
test_that("the healthcheck answers 200 without Shiny", { expect_equal( app$httpHandler(mock_req("/healthcheck"))$status, 200 ) })
And basepath, which is the one thing you cannot see locally and that breaks
on deployment. Testing it costs a line, and saves the round trip:
test_that("links stay inside the app under a mount", { app <- brochureApp( page(href = "/", ui = tagList(tags$a(href = "/contact", "Contact"))), basepath = "myapp" ) content <- app$httpHandler(mock_req("/myapp/"))$content expect_match(content, 'href="/myapp/contact"', fixed = TRUE) })
The document and the session are two different requests. app$serverFuncSource()
gives you the server function, and it picks the page from the request the
session was opened with — the websocket handshake, which hits
<href>/websocket/:
mock_session <- function(path) { list( request = mock_req(path), sendCustomMessage = function(type, message) NULL ) } test_that("a session runs the server of its own page", { ran <- character() app <- brochureApp( page(href = "/", ui = tagList(), server = function(i, o, s) ran <<- c(ran, "home")), page(href = "/page2", ui = tagList(), server = function(i, o, s) ran <<- c(ran, "page2")) ) app$serverFuncSource()(NULL, NULL, mock_session("/page2/websocket/")) expect_equal(ran, "page2") })
That tells you which server ran, not what it computed. It is worth a test
when two pages could be confused for one another — a parameterised href next
to a static one, or a page answering on POST, whose handshake is a GET like
everyone else's.
Here is the thing to know before you write that test: get_keys() and
get_cookies() both read session$request, and a MockShinySession — what
testServer() gives you — hands out a fresh empty environment every time you
touch it. You cannot put keys or a cookie in it, and it will not let you
replace it either.
Which is a good reason to do what you would want to do anyway: read the url and the cookies at the page boundary, and pass plain values down.
mod_profile_ui <- function(id) { ns <- NS(id) tagList(textOutput(ns("name"))) } mod_profile_server <- function(id, user) { moduleServer(id, function(input, output, session) { output$name <- renderText(sprintf("Hello %s", user)) }) } profile <- function() { page( href = "/who/:id", ui = mod_profile_ui("profile"), server = function(input, output, session) { # The only line that needs a real session mod_profile_server("profile", user = get_keys()$id) } ) }
The page server is now three lines you can read, and everything worth testing takes arguments:
test_that("the profile module greets the user", { testServer(mod_profile_server, args = list(user = "colin"), { expect_equal(output$name, "Hello colin") }) })
testServer() supplies the id itself, so args holds your own arguments
only. From there it is module testing like any other: session$setInputs(),
read output, drive time with session$elapse().
Do the same with a cookie: get_cookies() in the page server,
parse_cookie_string() next to it, and the module below receives the session
identifier — or the user it named — as an argument.
What none of the above sees: a link actually navigating, a plot actually
drawing, server_redirect() actually landing somewhere. That needs a browser,
and pages being real urls, any browser driver does the job — you point it at a
url rather than clicking through one document.
This is how brochure tests itself. inst/simple/ holds a small app, and
tests/playwright/ drives it: a page per file, page.goto("/page2"), an
assertion on what is rendered. tests/testthat/test-playwright.R runs the
suite from testthat and skips it wherever npx or the installed suite is
missing.
Test there what only a browser can answer, and leave the rest above — it runs in milliseconds and tells you exactly which of the three layers broke.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.