Converting testthat Tests to testit

A 13-year-old zero-dependency testing framework for R that just reached v1.0

Yihui Xie 2026-05-15

Back in 2013, I wrote about testing R packages when I first released testit. Thirteen years later, I still believe that unit testing should be nothing more than “tell me if something unexpected happened.” Recently I converted a large testthat test suite to testit, and I thought I’d share a practical guide for anyone considering the same move.

Migration guide

The file structure

testthat testit
tests/testthat.R tests/*.R (any name, e.g., testit.R)
tests/testthat/test-*.R tests/testit/test-*.R
tests/testthat/helper-*.R tests/testit/helper*.R
tests/testthat/_snaps/*.md tests/testit/test-*.md

R runs all .R scripts in tests/ during R CMD check. The filename does not matter—tests/testthat.R is merely a convention that testthat’s tooling creates. testit likewise does not require any specific filename. For example:

# tests/testit.R
library(testit)
test_pkg("pkgname")

You can also split tests into multiple runners, each calling test_pkg() with a different directory:

# tests to run unconditionally under the `core/` dir
library(testit)
test_pkg("pkgname", dir = "core")

# tests under `slow/`; only run when not on CRAN
if (identical(tolower(Sys.getenv("NOT_CRAN")), "true")) {
  test_pkg("pkgname", dir = "slow")
}

# tests under `ci/`; only run when on CI
if (identical(tolower(Sys.getenv("CI")), "true")) {
  test_pkg("pkgname", dir = "ci")
}

This provides a natural way to conditionally skip entire groups of tests (the testit equivalent of skip_*() like skip_on_cran())—simply guard the test_pkg() call with a condition.

The core pattern

testthat:

test_that("description", {
  expect_true(condition)
  expect_equal(a, b)
})

testit:

assert("description", {
  (condition)
  (a == b)
})

Any expression wrapped in () inside assert() is checked—if it evaluates to TRUE (or a vector of all TRUEs), it passes; anything else is a failure. The expression can be any R code: (x > 0), (is.data.frame(df)), (nrow(x) == 10), etc. For approximate numeric comparison, you may use (all.equal(a, b))—it returns TRUE on success or a descriptive string on failure, both of which testit handles correctly. In case of testing exact identity, you may use identical() or the %==% operator in testit (see later).

Assertion mappings

Here is a cheat sheet for translating expect_* calls:

testthat testit
expect_true(x) (x)
expect_false(x) (!x)
expect_equal(a, b) (all.equal(a, b))
expect_equal(a, b, tolerance = t) (all.equal(a, b, tolerance = t))
expect_identical(a, b) (identical(a, b))
expect_null(x) (is.null(x))
expect_length(x, n) (length(x) == n)
expect_s3_class(x, "cls") (inherits(x, "cls"))
expect_gt(a, b) (a > b)
expect_gte(a, b) (a >= b)
expect_lt(a, b) (a < b)
expect_lte(a, b) (a <= b)
expect_named(x, nms) (identical(names(x), nms))
expect_match(x, pat) (grepl(pat, x))
expect_error(expr) (has_error(expr))
expect_error(expr, "msg") (has_error(expr, "msg"))
expect_warning(expr) (has_warning(expr))
expect_warning(expr, "msg") (has_warning(expr, "msg"))
expect_message(expr) (has_message(expr))
expect_no_error(expr) (!has_error(expr))
expect_no_warning(expr) (!has_warning(expr))
expect_no_message(expr) (!has_message(expr))
expect_type(x, "t") (typeof(x) == "t")
expect_setequal(a, b) (setequal(a, b))
expect_in(x, table) (x %in% table)
expect_contains(x, expected) (expected %in% x)
expect_output(expr, pat) (grepl(pat, paste(capture.output(expr), collapse = "\n")))

Most of the translations boil down to “use the base R function directly.” That’s the point.

A note on expect_output(): The capture.output() translation above works, but is ugly to read and maintain. In practice, you may want to use testit’s snapshot tests instead—just put the code in an .md file alongside your test script and let testit compare the output for you. See the “Snapshot tests” section below.

A caveat on expect_equal() vs all.equal(): The mapping above is accurate for the most common case—comparing numeric values, data frames, and lists—where they behave the same with the same default tolerance (sqrt(.Machine$double.eps)). However, there are subtle differences depending on which testthat edition you use:

In short: for data and numbers (the vast majority of test assertions), the mapping is a drop-in replacement. For functions and formulas, you may need check.environment = FALSE.

The %==% operator

testit provides %==% as an alias of identical(). The advantage over calling identical() directly is that when the assertion fails inside assert(), it prints str() for both sides, so you may be able to immediately spot the difference:

assert("example", {
  (1:3 %==% 1:3)
  (c("a", "b") %==% c("a", "b"))
})

If it fails, you’ll see something like:

x (LHS) ==>
 int [1:3] 1 2 3
----------
 int [1:3] 1 2 4
<== (RHS) y

Be cautious about the operator precedence: in R, infix operators like %==% bind tighter than common arithmetic and logical operators such as +, -, *, /, >, <, ==, &, and |, etc. When you use the latter operators in a %==% expression, you need () to guarantee precedence, e.g.,

(1 + 2 %==% 2 + 1)

is interpreted as

(1 + (2 %==% 2) + 1)
# => (1 + TRUE + 1) => (1 + 1 + 1) => (3) => FAIL

and you must group the LHS and RHS explicitly by ():

((1 + 2) %==% (2 + 1))

which may look ugly and confusing, so you may want to compute LHS and RHS before the () test, e.g.,

res = 1 + 2
expected = 2 + 1
(res %==% expected)

Snapshot tests

testthat stores snapshots in tests/testthat/_snaps/. testit uses a simpler approach: just Markdown files like tests/testit/test-name.md alongside the .R test scripts.

## `function_name()` description (optional)

Narratives (optional).

```r
code_to_run()
```

More narratives (optional).

```
expected output here
```

testit runs the R code block and compares its output to the following code block (without the language name r). If they differ, the test fails and shows a diff.

To initialize a snapshot test, you can omit the output block and only include the R source code. When you run the tests (execute the command Rscript tests/*.R, instead of running R CMD check), testit will automatically fill in the output—no need to copy and paste results manually. If you use RStudio, you can click “Run Tests” in the Build pane to initialize and update snapshots (see the “RStudio setup” section below for configuration).

Conditional test execution

testthat has skip_on_cran(), skip_if_not_installed(), etc. testit offers three levels of conditional execution:

Skip an entire test directory—guard the test_pkg() call in a runner script, e.g.,

library(testit)
if (identical(Sys.getenv("NOT_CRAN"), "true")) {
  test_pkg("pkgname", dir = "extended")
}

Skip a single assertion—wrap assert() in a condition, e.g.,

if (requireNamespace("pkg", quietly = TRUE)) assert("uses pkg", {
  ...
})

Skip the rest of a test file—use an early return() in a test file, e.g.,

if (!requireNamespace("pkg", quietly = TRUE)) return()

Since testit files are sourced top-to-bottom, return() skips the rest of the file.

Setup and teardown

testthat’s setup() and teardown() are superseded; the current approach uses withr::defer(..., teardown_env()). With testit, just use normal R patterns:

old <- options(warn = -1)
on.exit(options(old), add = TRUE)

Or place shared setup in helper.R (sourced before test files).

For file cleanup, test_pkg() automatically removes any newly generated files under the test directory after testing completes (controlled by options(testit.cleanup = TRUE), which is the default). This means your tests/ directory stays clean without manual teardown. Have you ever been annoyed by the stray Rplots.pdf in your test folder? You won’t suffer from this problem with testit.

DESCRIPTION changes

- Suggests: testthat (>= 3.0.0)
+ Suggests: testit (>= 1.0)

Remove Config/testthat/edition: * if present.

RStudio setup

If you use RStudio, go to Tools > Project Options > Build Tools and uncheck “Use devtools package functions if available.” With this option unchecked, the “Run Tests” button in the Build pane will run the .R scripts under tests/ directly (i.e., Rscript tests/*.R), which is exactly what testit needs. If you leave devtools enabled, RStudio will try to run tests through devtools::test(), which only looks for tests/testthat/ and calls testthat::test_local()—it will not find or run testit tests at all (you’ll just see “No testing infrastructure found”).

Unfortunately, Positron does not have an equivalent setting—its test command is hardcoded to devtools::test(), so it suffers from the same problem. If you use Positron, you’ll need to run Rscript tests/*.R manually in the terminal.

Sometimes I hear people use popularity as an argument to justify the use of testthat. Personally I don’t find this convincing. I apologize for being a little snarky here, but flu is also “popular”. Part of testthat’s popularity may be self-reinforcing: IDEs like RStudio and Positron hardcode devtools::test() as the test command, which assumes testthat. New users see that their IDE “just works” with testthat and conclude it must be the right choice. Tutorials and templates naturally gravitate toward the same default. The popularity feeds the tooling, and the tooling feeds the popularity. That’s not a technical argument—it’s a network effect. I’m not saying testthat is a bad choice, but I do think it’s worth evaluating testing frameworks on their own merits rather than simply going with the default. There is no free lunch. You gain while you lose, and vice versa.

No matter which editor/IDE you use, R CMD check is always your faithful friend (it just runs tests/*.R) without assuming the testing framework, although it’s much more than running tests.

Why testit over testthat?

After going through the mechanical conversion, let me explain why I think it’s worth the effort.

What about features testthat has that testit doesn’t?

testit v1.0

testit v1.0 has just been released to CRAN. The source code is on GitHub. I want to express my immense gratitude to John Blischak for his thoughtful feedback during the test migration mentioned in the beginning of this post—his suggestions on ergonomics (suppressing noisy error messages from has_error(), adding the filter argument to test_pkg(), requiring library(testit) to be documented clearly, and collecting all test failures instead of stopping at the first) directly shaped the v1.0 release. FWIW, I bumped the version from 13 years’ 0.x to 1.0 not because of breaking changes—there were none (excuse me, how can I break this package?), but just to mark the significantly enhanced usability of this package thanks to John’s suggestions.

In short, testit embodies a philosophy: a test framework should assert conditions and get out of the way. Everything else—mocking, parallelism, reporting, environment management—belongs in separate, purpose-built tools or in base R itself. The result is a testing system that is easy to understand, impossible to misconfigure, and aims to be stable indefinitely.