6 Use vcr (& webmockr)
In this chapter we aim at adding HTTP testing infrastructure to exemplighratia2 using vcr (& webmockr).
Corresponding pull request to exemplighratia2. Feel free to fork the repository to experiment yourself!
6.1 Setup
Before working on all this, we need to install vcr.
First, we need to run usethis::use_package("vcr", type = "Suggests") (in the exemplighratia directory) to add vcr as a dependency to DESCRIPTION, under Suggests just like testthat.
Then we need to create a helper file using usethis::use_test_helper("vcr").
A new file is created under tests/testthat/helper-vcr.R.
When testthat runs tests, files whose name start with “helper” are always run first.
They are also loaded by devtools::load_all(), so the vcr setup is loaded when developing and testing interactively.
See the table in the R-hub blog post “Helper code and files for your testthat tests”.
We have to tweak the vcr setup for our needs.
We need to ensure that we set up a fake API key when there is no API token around. Why? Because if you remember well, the code of our function
gh_organizations()checks for the presence of a token. With mock responses around, we don’t need a token but we still need to fool our own package in contexts where there is no token (e.g. in continuous integration checks for a fork of a GitHub repository).We do not need to tweak the configuration to prevent our API from leaking, because with vcr 2.0.0 and above, the
Authorizationheader is never recorded.
Below is the updated setup file saved under tests/testthat/helper-vcr.R.
vcr_dir <- vcr::vcr_test_path("_vcr")
if (!gh::gh_token_exists()) {
if (dir.exists(vcr_dir)) {
# Fake API token to fool our package
Sys.setenv("GITHUB_PAT" = "foobar")
} else {
# If there's no mock files nor API token, impossible to run tests
stop("No API key nor cassettes, tests cannot be run.", call. = FALSE)
}
}So this was just setup, now on to adapting our tests!
6.2 Actual testing
The most important function will be vcr::local_cassette("cassette-informative-and-unique-name") which tells vcr to create a mock file to store all API responses for API calls occurring in the current test (what’s inside test_that()).
The vcr::local_cassette() function might remind you of the withr::local_ functions or of rlang::local_options().
Let’s tweak the test for gh_api_status, it now becomes
test_that("gh_api_status() works", {
vcr::local_cassette("gh_api_status")
status <- gh_api_status()
expect_type(status, "character")
})If you had used vcr::use_cassette() instead of the newer vcr::local_cassette(), you might notice the code feels less nested!
If we run this test, for instance through devtools::test_active_file(),
- the first time, vcr creates a cassette (mock file) under
tests/testthat/_vcr/gh_api_status.ymlwhere it stores the API response It contains all the information related to requests and responses, headers included.
http_interactions:
- request:
method: GET
uri: https://kctbh9vrtdwd.statuspage.io/api/v2/components.json
response:
status: 200
headers:
content-type: application/json; charset=utf-8
date: Tue, 09 Sep 2025 10:09:28 GMT
x-download-options: noopen
x-permitted-cross-domain-policies: none
referrer-policy: strict-origin-when-cross-origin
x-statuspage-version: b27f95704d5835d5f1a3ccc341503d3f7f22f502
strict-transport-security: max-age=259200
x-statuspage-skip-logging: 'true'
access-control-allow-origin: '*'
cache-control: max-age=3, public
x-pollinator-metadata-service: status-page-web-pages
etag: W/"81603fa0d180d761ce3b3c3c02d68741"
x-runtime: '0.049065'
server: AtlassianEdge
accept-ranges: bytes
x-content-type-options: nosniff
x-xss-protection: 1; mode=block
atl-traceid: daa0d6503446464e9748ed3a8634ead0
atl-request-id: daa0d650-3446-464e-9748-ed3a8634ead0
report-to: '{"endpoints": [{"url": "https://dz8aopenkvv6s.cloudfront.net"}],
"group": "endpoint-1", "include_subdomains": true, "max_age": 600}'
nel: '{"failure_fraction": 0.001, "include_subdomains": true, "max_age": 600,
"report_to": "endpoint-1"}'
content-encoding: br
server-timing: atl-edge;dur=129,atl-edge-internal;dur=5,atl-edge-upstream;dur=125,atl-edge-pop;desc="aws-us-east-1"
vary: Accept,Accept-Encoding
x-cache: Miss from cloudfront
via: 1.1 dd239fa6f06e5b3ae1437460dbc3d6a6.cloudfront.net (CloudFront)
x-amz-cf-pop: MRS53-P3
x-amz-cf-id: K63D5rR4HMl9WRamA6Yzh8R6SuF2vfDOuh8W-bgc1kUo-xGdrVZp9g==
body:
string: '{"page":{"id":"kctbh9vrtdwd","name":"GitHub","url":"https://www.githubstatus.com","time_zone":"Etc/UTC","updated_at":"2025-09-09T10:00:59.457Z"},"components":[{"id":"8l4ygp009s5s","name":"Git
Operations","status":"operational","created_at":"2017-01-31T20:05:05.370Z","updated_at":"2025-08-21T06:58:30.069Z","position":1,"description":"Performance
of git clones, pulls, pushes, and associated operations","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"4230lsnqdsld","name":"Webhooks","status":"operational","created_at":"2019-11-13T18:00:24.256Z","updated_at":"2025-08-05T16:14:01.686Z","position":2,"description":"Real
time HTTP callbacks of user-generated and system events","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"0l2p9nhqnxpd","name":"Visit
www.githubstatus.com for more information","status":"operational","created_at":"2018-12-05T19:39:40.838Z","updated_at":"2025-03-19T05:00:21.309Z","position":3,"description":null,"showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"brv1bkgrwx7q","name":"API
Requests","status":"operational","created_at":"2017-01-31T20:01:46.621Z","updated_at":"2025-09-04T20:25:47.243Z","position":4,"description":"Requests
for GitHub APIs","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"kr09ddfgbfsf","name":"Issues","status":"operational","created_at":"2017-01-31T20:01:46.638Z","updated_at":"2025-08-27T21:27:47.854Z","position":5,"description":"Requests
for Issues on GitHub.com","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"hhtssxt0f5v2","name":"Pull
Requests","status":"operational","created_at":"2020-09-02T15:39:06.329Z","updated_at":"2025-08-12T17:56:13.209Z","position":6,"description":"Requests
for Pull Requests on GitHub.com","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"br0l2tvcx85d","name":"Actions","status":"operational","created_at":"2019-11-13T18:02:19.432Z","updated_at":"2025-08-21T18:13:02.491Z","position":7,"description":"Workflows,
Compute and Orchestration for GitHub Actions","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"st3j38cctv9l","name":"Packages","status":"operational","created_at":"2019-11-13T18:02:40.064Z","updated_at":"2025-08-14T18:37:12.106Z","position":8,"description":"API
requests and webhook delivery for GitHub Packages","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"vg70hn9s2tyj","name":"Pages","status":"operational","created_at":"2017-01-31T20:04:33.923Z","updated_at":"2025-06-17T20:03:36.357Z","position":9,"description":"Frontend
application and API servers for Pages builds","showcase":false,"start_date":null,"group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"h2ftsgbw7kmk","name":"Codespaces","status":"operational","created_at":"2021-08-11T16:02:09.505Z","updated_at":"2025-07-21T09:47:56.207Z","position":10,"description":"Orchestration
and Compute for GitHub Codespaces","showcase":false,"start_date":"2021-08-11","group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false},{"id":"pjmpxvq2cmr2","name":"Copilot","status":"operational","created_at":"2022-06-21T16:04:33.017Z","updated_at":"2025-08-27T21:36:28.924Z","position":11,"description":null,"showcase":false,"start_date":"2022-06-21","group_id":null,"page_id":"kctbh9vrtdwd","group":false,"only_show_if_degraded":false}]}'
recorded_at: 2025-09-09 10:09:28
recorded_with: VCR-vcr/2.0.0- all the times after that, unless we delete the cassette (mock file), vcr simply uses the cassette instead of actually calling the API.
Let’s tweak our other test, of gh_organizations().
Here things get more exciting or complicated, as we also set out to adding a test of the error behavior.
This inspired us to change error behavior a bit with a slightly more specific error message i.e. httr::stop_for_status(response) became httr::stop_for_status(response, task = "get data from the API, oops").
The test file tests/testthat/test-organizations.R is now:
test_that("gh_organizations works", {
vcr::local_cassette("gh_organizations")
orgs <- gh_organizations()
expect_type(orgs, "character")
})
test_that("gh_organizations errors when the API doesn't behave", {
webmockr::enable()
stub <- webmockr::stub_request(
"get",
"https://api.github.com/organizations?since=1"
)
webmockr::to_return(stub, status = 502)
expect_snapshot(error = TRUE, gh_organizations())
webmockr::disable()
})The first test is similar to what we did for gh_api_status().
In the second test there is more to unpack.
- We enable the use of webmockr at the beginning with
webmockr::enable(). Why webmockr? Because it can help mock a failure scenario. - We explicitly write that a request to
https://api.github.com/organizations?since=1should return a status of 502.
stub <- webmockr::stub_request("get", "https://api.github.com/organizations?since=1")
webmockr::to_return(stub, status = 502)- We then test for the error message with
expect_snapshot(error = TRUE, gh_organizations()). - We disable webmockr with
webmockr::disable().
Instead of using webmockr for creating a fake API eror, we could have
- recorded a normal cassette;
- edited it to replace the status code.
Read pros and cons of this approach in the vcr vignette Why and how edit your vcr cassettes?, especially if you don’t find the webmockr approach enjoyable.
Without the HTTP testing infrastructure, testing for behavior of the package in case of API errors would be more difficult. However, we could resort to testthat’s mocking tools.
Regarding our secret API token, the first time we run the test file, vcr creates a cassette where we do not see our token.
6.3 Also testing for real interactions
What if the API responses change? Hopefully we’d notice that thanks to following API news. However, sometimes web APIs change without any notice. Therefore it is important to run tests against the real web service once in a while.
The vcr package provides various methods to turn vcr use on and off to allow real requests i.e. ignoring mock files.
See ?vcr::lightswitch.
In the case of exemplighratia2, we added a GitHub Actions workflow that will run on schedule once a week, for which one of the build has vcr turned off via the VCR_TURN_OFF environment variable.
We chose to have one build with vcr turned on and otherwise the same configuration to make it easier to assess what broke in case of failure (if both builds fail, the web API is probably not the culprit).
Compared to continuous integration builds where vcr is turned on, this one build needs to have access to a GITHUB_PAT secret environment variable. Furthermore, it is slower.
One could imagine other strategies:
- Always having one continuous integration build with vcr turned off but skipping it in contexts where there isn’t any token (pull requests from forks for instance?);
- Only running tests with vcr turned off locally once in a while.
6.4 Summary
- We set up vcr usage in our package exemplighratia2 by registering vcr as a dependency and creating a file to protect our secret API key and to fool our own package that needs an API token.
- Inside
test_that()blocks, we callvcr::local_cassette(<cassette-name>)and ran the tests a first time to generate mock files that hold all information about the API interactions. - In one of the tests, we used webmockr to create an environment where only fake requests are allowed. We defined that the request that
gh_organizations()makes should get a 502 status. We were therefore able to test for the error messagegh_organizations()returns in such cases.
Now, how do we make sure this works?
- Turn off wifi, run the tests again. It works! Turn on wifi again.
- Open .Renviron (
usethis::edit_r_environ()), edit “GITHUB_PAT” into “byeGITHUB_PAT”, re-start R, run the tests again. It works! Fix your “GITHUB_PAT” token in .Renviron. (to store your credentials, you should actually use gitcreds for Git credentials, and keyring for other credentials).
So we now have tests that no longer rely on an internet connection nor on having API credentials.
We also added a continuous integration workflow for having a build using real interactions once every week, as it is important to regularly make sure the package still works against the latest API responses.
For the full list of changes applied to exemplighratia in this chapter, see the pull request diff on GitHub.
How do we get there with other packages? Let’s try httptest in the next chapter!
6.5 PS: Where to put use_cassette()
Where do we put the vcr::use_cassette() call?
Well, as written in the manual page of that function, There’s a few ways to get correct line numbers for failed tests and one way to not get correct line numbers:
What’s correct?
- Wrapping the whole
testthat::test_that()call (do not do that if your test contains for instance `skip_on_cran()``);
vcr::use_cassette("thing", {
testthat::test_that("thing", {
lala <- get_foo()
expect_true(lala)
})
})- Wrapping a few lines inside
testthat::test_that()excluding the expectationsexpect_blabla()
testthat::test_that("thing", {
vcr::use_cassette("thing", {
lala <- get_foo()
})
expect_true(lala)
})What’s incorrect?
testthat::test_that("thing", {
vcr::use_cassette("thing", {
lala <- get_foo()
expect_true(lala)
})
})We used the solution of only wrapping the lines containing API calls in vcr::use_cassette(), but it is up to you to choose what you prefer.