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 |
The routing
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] 200A 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")
})Everything else the response carries
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)
})Which server a session runs
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.
What a page computes
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.
In a browser
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.
