Skip to contents

paparazzi tests check what a user sees in a real browser. A test runs the same actions a screenshot or recording would, and its expectations wait for each step’s result before the next one starts.

Test the action and its result

Inside testthat::test_that(), every pz_expect_*() call is a testthat expectation, and a failure fails the test. Outside testthat, a failure is an error of class paparazzi_expectation_failure that reports the target, the last value seen and how long it waited. A passing expectation returns the context invisibly, so it sits in the middle of a chain.

Open the page with pz_local_page() so it closes when the test ends, pass or fail. Pages opened from an app path stop their server too.

library(paparazzi)

testthat::test_that("adding a task inserts its title first", {
  page <- pz_local_page(pz_example("tasks"), width = 1000, height = 720)
  page |>
    pz_act_type("Repot the fern", target = "#task-title") |>
    pz_act_click("#add-task") |>
    pz_expect_text(
      "Repot the fern",
      target = pz_loc(".task-title", which = "first"),
      match = "exact"
    ) |>
    pz_expect_count(8, target = ".task")
})

The click waits until the Add button can be clicked; the text expectation waits for the save to finish. Those are two different conditions, so expect each step’s result before starting the next.

Choose the expectation that states the behavior

Choose the expectation that states the behavior precisely:

Intended result Expectation
DOM element appears or is removed pz_expect_exists() with not as needed
All matched elements are shown pz_expect_visible()
No match is visible pz_expect_hidden()
Collection has a size or range pz_expect_count(n) or min / max
Displayed text changes pz_expect_text()
Input value changes pz_expect_value()
CSS class marks state pz_expect_class()
Checkbox or radio is checked pz_expect_checked()
Control is enabled pz_expect_enabled()
Focus or viewport placement matters pz_expect_focused(), pz_expect_in_viewport()
Navigation reaches the destination pz_expect_url(), pz_expect_title()
App-specific DOM predicate holds pz_expect_js()

Most element expectations need at least one match, and every match must pass; pz_expect_exists() needs just one. To check one row, target it with has_text or within. pz_expect_hidden() also passes when the element is absent, so pair it with pz_expect_exists() when presence matters.

testthat::test_that("help starts hidden and can be opened", {
  page <- pz_local_page(pz_example("tasks"))
  page |>
    pz_expect_exists(target = "#help") |>
    pz_expect_hidden(target = "#help") |>
    pz_act_click("#toggle-help") |>
    pz_expect_visible(target = "#help")
})

Match text and values

pz_expect_text() collapses whitespace on both sides. match = "contains" (the default) checks for a substring, "exact" for equality and "regex" for an R regular expression. One expected string applies to every match; a vector needs exactly that many matches and compares them in document order.

testthat::test_that("text entry updates the input value", {
  page <- pz_local_page(pz_example("tasks"))
  page |>
    pz_act_type("Buy milk", target = "#task-title") |>
    pz_expect_value("Buy milk", target = "#task-title", match = "exact")
  testthat::expect_identical(pz_get_value(page, target = "#task-title"), "Buy milk")
})

Getters return plain values and end the chain, so compare them with ordinary testthat expectations. pz_get_count() reads once and can return zero; use pz_expect_count() when the count is about to change.

Wait on conditions, not time

Expectations retry until they pass or time out. timeout = NULL uses the page’s default, set with pz_open(timeout = ) or through pz_local_page(). timeout = 0 checks once, for when an earlier step already waited.

When there’s no particular value to expect, wait on a condition instead: pz_wait_for_shiny_idle() for reactive work, pz_wait_for_stable() for settled layout or text, or pz_wait_for_js() for a JavaScript predicate. pz_wait() is a fixed wait that keeps the browser running, recordings included.

pz_with_page(pz_example("tasks"), function(page) {
  page |>
    pz_wait_for_js("document.readyState === 'complete'") |>
    pz_wait_for_stable(target = ".task-list", prop = "rect", for_ms = 200) |>
    pz_expect_count(7, target = ".task", timeout = 0)
})

The default page timeout is ten seconds. For a slow app, raise the timeout rather than adding fixed pauses.

Test navigation between documents

Serve test pages over HTTP so links and history behave as they do on a real site. This example writes two small pages joined by a link, then tests the click, the destination and the way back. It needs httpuv. The function owns the server and the test owns the page.

check_navigation <- function() {
  site <- tempfile("paparazzi-site-")
  dir.create(site)
  withr::defer(unlink(site, recursive = TRUE))
  writeLines(
    '<title>Start</title><a id="next" href="next.html">Continue</a>',
    file.path(site, "index.html")
  )
  writeLines('<title>Next</title><h1>Arrived</h1>', file.path(site, "next.html"))
  server <- pz_serve_static(site)
  withr::defer(server$stop())

  testthat::test_that("the link opens the next document", {
    page <- pz_local_page(server)
    page |>
      pz_act_click("#next") |>
      pz_wait_for_navigation() |>
      pz_expect_url("/next.html", match = "contains") |>
      pz_expect_text("Arrived", target = "h1", match = "exact") |>
      pz_nav_back() |>
      pz_expect_title("Start", match = "exact")
  })
}
check_navigation()

Navigation resets scopes to the root, so find the destination’s elements again (see Locators and scopes).

Inspect state when a test fails

pz_inspect() prints the page’s state, and getters return specific values to R. A screenshot with an explicit path saves evidence without opening a viewer, and the chain continues.

pz_with_page(pz_example("tasks"), function(page) {
  page |> pz_expect_visible(target = "#new-task")
  pz_inspect(page)
  pz_get_elements(page, target = "#new-task input")
  pz_get_attr(page, "type", target = "#task-title")
  pz_get_style(page, "display", target = "#new-task")
  page |> pz_screenshot(tempfile(fileext = ".png"), frame = "#new-task")
})

For checks no built-in expectation covers, pz_expect_js() takes a JavaScript function that receives each matched element and returns true or false. At the root, the target defaults to the page body.

pz_with_page(pz_example("tasks"), function(page) {
  page |>
    pz_expect_js("el => el.querySelectorAll('.task').length === 7")
})

Browser tests need Chrome or Chromium where chromote can find it. Open a new page in each test so tests don’t share state. A shared Shiny server can save startup time while each page still gets its own app session (see Shiny).