Skip to contents

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] 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")
})

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.