--- title: "Testing a brochure app" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Testing a brochure app} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE) ``` ```{r} 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 | ## 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: ```{r} 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: ```{r} 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: ```{r} 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: ```{r} 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: ```{r} 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: ```{r} 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: ```{r} 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 `/websocket/`: ```{r} 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. ```{r} 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: ```{r} 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.