09 · Advanced Testing with testthat¶
Level 2 covered basic testthat expectations. Real code under test
usually touches something you don't want a test suite actually hitting —
a network call, a paid API, the filesystem — and needs to clean up after
itself regardless of whether the test passes or fails. This module covers
mocking with mockery, fixtures with withr, and skip() for tests that
can't always run.
The problem: code that calls something untestable¶
fetch_price <- function(ticker) {
# in real life: an HTTP call to a paid market-data API
stop("network not available in tests")
}
get_discounted_price <- function(ticker, discount) {
price <- fetch_price(ticker)
price * (1 - discount)
}
Testing get_discounted_price() directly would require a real network
call to fetch_price() on every test run — slow, flaky, and potentially
costly. The logic actually worth testing (the discount math) doesn't
depend on how the price was fetched, only on what fetch_price()
returned.
Mocking with mockery¶
test_that("get_discounted_price applies discount to mocked fetch", {
m <- mock(100)
stub(get_discounted_price, "fetch_price", m)
result <- get_discounted_price("AAPL", 0.1)
expect_equal(result, 90)
expect_called(m, 1)
expect_args(m, 1, "AAPL")
})
mock(100) creates a fake function that returns 100 every time it's
called, regardless of its arguments. stub(get_discounted_price,
"fetch_price", m) temporarily replaces fetch_price() as called from
inside get_discounted_price() with that mock, for the duration of this
one test — the real fetch_price() (and its stop()) is never reached.
expect_called(m, 1) and expect_args(m, 1, "AAPL") then verify not just
that the discount math was right, but that fetch_price() was called
exactly once, with the ticker you expected — asserting on how a
function was used, not only what it returned.
Fixtures with automatic cleanup¶
A common testing need is a piece of test data — a temp file, a temp directory, an environment variable — that must exist for the test and be cleaned up afterward, even if the test fails partway through:
test_that("uses a temp file fixture that cleans up automatically", {
tf <- withr::local_tempfile(lines = c("a,b", "1,2"))
df <- read.csv(tf)
expect_equal(df$a, 1)
})
withr::local_tempfile() creates a temp file, writes the given lines to
it, and schedules its deletion for when the current test exits — pass
or fail, the file is gone before the next test runs. This is the general
withr pattern (local_tempdir(), local_envvar(), local_dir(), and
more): each local_*() function sets something up and registers its own
teardown, so you never write a manual cleanup step that a failed
assertion could skip over.
Trap: manually creating a temp file with tempfile() +
file.create() and cleaning it up with unlink() at the end of the test
body looks fine until an expect_*() earlier in the same test fails — a
failed expectation stops execution of that test block immediately, and a
manual cleanup line written after the assertions never runs, leaking
temp files across test runs. withr's local_*() functions register
their cleanup to run regardless of how the test exits, which is why they
are the safer default over manual setup/teardown.
Skipping tests that can't always run¶
══ Skipped ═══════════════════════════════════════
1. skipped example - Reason: demonstration of skip()
skip() marks a test as intentionally not run, with a reason recorded in
the output — distinct from a passing or failing test. Real uses include
skip_if_not_installed("some_optional_pkg"), skip_on_cran() for tests
that need network access CRAN's check machinery won't have, or
skip_if(Sys.getenv("API_KEY") == "") for tests that need a real
credential not available in every environment. A skipped test is visible
in the report as skipped, which is the honest signal — silently deleting
a test that can't always run hides the fact that coverage has a gap.
Cheat sheet¶
| Task | Tool |
|---|---|
| Replace a function with a fake return value inside the function under test | mockery::stub(fn, "dependency", mock(value)) |
| Assert a mock was called a specific number of times | expect_called(m, n) |
| Assert what arguments a mock was called with | expect_args(m, call_number, ...) |
| Create a temp file that auto-cleans after the test | withr::local_tempfile(lines = ...) |
| Create a temp dir that auto-cleans after the test | withr::local_tempdir() |
| Temporarily set an env var for one test | withr::local_envvar(VAR = "value") |
| Mark a test as intentionally not run, with a reason | skip("reason") |
| Skip only when a package isn't installed | skip_if_not_installed("pkgname") |
| Skip only in CRAN's automated checks | skip_on_cran() |
How It Actually Works¶
Mocking in testthat (via local_mocked_bindings() or the older
with_mock()) works by temporarily rebinding a name inside a package's
namespace environment for the duration of a test — because R resolves a
function call by looking the name up in the calling function's enclosing
environment chain, swapping what that name points to (even just for one
test's scope, restored automatically afterward) makes every call to that
function inside the code under test transparently use the mock instead,
with no need to change the code being tested at all.
Code coverage tools (covr) work by instrumenting your functions'
byte-compiled or parsed expressions with tracing hooks before running
your test suite — each expression gets wrapped so that executing it also
increments a hit counter recorded against that specific line — then runs
the full test suite once and reports which lines' counters stayed at
zero. This is a real execution trace, not static analysis: a line can be
syntactically reachable but still show as uncovered if no test path
actually executes it, which is the whole point of a coverage report.
Exercise¶
- Take a function that calls another function you don't want to
actually invoke in tests (a
send_email(), acharge_card(), afetch_price()-style call) and write a test usingmockery::stub()andmock()that verifies the calling function's own logic without triggering the real dependency. - Write a test with a
withr::local_tempfile()fixture, deliberately put a failingexpect_equal()before the file would be used, and confirm (by checkingtempdir()afterward) that the file was still cleaned up despite the failure. - Add a
skip_if_not_installed("mockery")guard to a test file to see the message it produces, then removemockeryfrom consideration and re-run to see the test reported as skipped rather than failed.