--- title: "Automated Testing with GitHub Actions" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Automated Testing with GitHub Actions} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` This vignette shows how to combine `resultcheck`, `testthat`, `renv`, and a GitHub Actions workflow into a fully automated reproducibility pipeline. The goal is to make every push to your repository trigger a test run that fails loudly whenever any analysis result drifts from its committed snapshot — across all three major operating systems. For a real-world example of this pattern in action, see the [IMF replication repository](https://github.com/IMFPaper/IMF). --- ## Why each piece matters | Tool | Role | |------|------| | `resultcheck` | Captures named snapshots of R objects; errors in CI when a snapshot changes | | `testthat` | Test harness that runs the snapshots and reports failures | | `renv` | Locks every package to an exact version so the environment is reproducible | | GitHub Actions | Runs the test suite automatically on push/PR across Windows, macOS, and Linux | Without `renv`, a routine package update could silently change a coefficient or table. Without snapshots, tests would not catch numerical drift. Without multi-OS CI, platform-specific floating-point differences would go unnoticed. --- ## Step 1 — Project layout A minimal project that uses this workflow looks like: ``` myproject/ ├── .Rprofile # auto-activates renv ├── renv.lock # locked package versions (committed) ├── renv/ # renv internals (mostly gitignored) ├── _resultcheck.yml # marks the project root ├── data/ │ └── panel_data.rds ├── code/ │ └── analysis.R # your analysis script ├── tests/_resultcheck_snaps/ # committed snapshot files │ └── analysis/ │ └── main_model.md └── tests/ └── testthat/ └── test-analysis.R ``` The `_resultcheck.yml` file at the root can be empty — its presence is enough for `find_root()` to locate the project: ```yaml # _resultcheck.yml ``` --- ## Step 2 — Initialise renv Inside R, with your project open: ```r install.packages("renv") renv::init() ``` Install the packages your project needs, then snapshot the environment: ```r renv::install(c("resultcheck", "testthat")) # ... install any other packages your analysis uses ... renv::snapshot() ``` Commit both `.Rprofile` and `renv.lock`. The `renv/` folder should be partially ignored according to renv's own `.gitignore` (created automatically by `renv::init()`). --- ## Step 3 — Add snapshots to your analysis script Call `resultcheck::snapshot()` on every object whose value matters for reproducibility. The first time you run the script interactively the snapshot is saved; on all subsequent runs it is compared against the saved version. ```r # code/analysis.R data <- readRDS("data/panel_data.rds") model <- lm(y ~ x1 + x2, data = data) resultcheck::snapshot(model, "main_model") resultcheck::snapshot(data, "panel_data") # ... continue writing outputs ... ``` Run the script interactively once to generate the `.md` snapshot files, review them, then commit them to version control. --- ## Step 4 — Write a testthat test ```r # tests/testthat/test-analysis.R library(testthat) library(resultcheck) test_that("analysis produces stable results", { sandbox <- setup_sandbox("data") on.exit(cleanup_sandbox(sandbox), add = TRUE) # snapshot() inside run_in_sandbox() errors on any mismatch expect_true(run_in_sandbox("code/analysis.R", sandbox)) }) ``` Run locally to confirm everything passes before pushing: ```r testthat::test_dir("tests/testthat") ``` For package examples and quick demos, you can avoid writing into your current project by wrapping code in `resultcheck::with_example({...})`, which creates a temporary project in `tempdir()` and cleans it up automatically. --- ## Step 5 — GitHub Actions workflow Create `.github/workflows/run-tests.yml`. The key ingredients are: * **Matrix strategy** — runs on Windows, macOS, and Ubuntu so platform-specific numerical differences are caught early. * **OS-specific system libraries** — packages such as `ragg`, `xml2`, or `curl` need native libraries on Linux and macOS that are not required on Windows. * **renv cache** — `r-lib/actions/setup-renv@v2` restores the `renv` cache between runs, avoiding re-installing hundreds of packages on every push. ```yaml name: R Tests on: push: branches: [ main ] pull_request: branches: [ main ] workflow_dispatch: jobs: test: runs-on: ${{ matrix.config.os }} name: ${{ matrix.config.os }} strategy: fail-fast: false matrix: config: - {os: windows-latest} - {os: ubuntu-latest} - {os: macos-latest} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} R_KEEP_PKG_SOURCE: yes steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup R uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true # ── Linux system libraries ────────────────────────────────────────── - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ libcurl4-openssl-dev \ libssl-dev \ libxml2-dev \ libfontconfig1-dev \ libharfbuzz-dev \ libfribidi-dev \ libfreetype6-dev \ libpng-dev \ libtiff5-dev \ libjpeg-dev # ── macOS system libraries ─────────────────────────────────────────── - name: Install system dependencies (macOS) if: runner.os == 'macOS' run: | set -euxo pipefail brew update brew install pkg-config libpng cairo freetype harfbuzz fribidi BREW_PREFIX="$(brew --prefix)" echo "SDKROOT=$(xcrun --sdk macosx --show-sdk-path)" >> $GITHUB_ENV echo "PATH=${BREW_PREFIX}/bin:${PATH}" >> $GITHUB_ENV echo "PKG_CONFIG_PATH=${BREW_PREFIX}/lib/pkgconfig:$(brew --prefix libpng)/lib/pkgconfig" >> $GITHUB_ENV mkdir -p ~/.R PNG_CFLAGS="$(pkg-config --cflags libpng)" PNG_LIBS="$(pkg-config --libs libpng)" { echo "CPPFLAGS += -I${BREW_PREFIX}/include" echo "LDFLAGS += -L${BREW_PREFIX}/lib -Wl,-rpath,${BREW_PREFIX}/lib" echo "PKG_CPPFLAGS += ${PNG_CFLAGS}" echo "PKG_LIBS += ${PNG_LIBS}" } >> ~/.R/Makevars # ── Restore renv cache (fast!) ─────────────────────────────────────── - name: Restore renv packages uses: r-lib/actions/setup-renv@v2 with: cache-version: 2 # ── Run tests ──────────────────────────────────────────────────────── - name: Run tests run: Rscript -e "testthat::test_dir('tests/testthat')" - name: Upload test artefacts on failure if: failure() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.config.os }} path: tests/testthat/*.Rout* ``` ### How `setup-renv` caching works `r-lib/actions/setup-renv@v2` reads your `renv.lock` file, computes a cache key from its hash, and restores a previously saved cache of installed packages before calling `renv::restore()`. When the lock file has not changed, all packages are served from the cache and `renv::restore()` completes in seconds rather than minutes. Increment `cache-version` (e.g. from `2` to `3`) whenever you want to force a full re-install — for example after an OS upgrade or when debugging a strange linking error. --- ## Step 6 — Snapshot lifecycle on CI ``` Developer CI runner ───────── ───────── 1. Edit analysis.R 2. Run interactively → snapshots generated / updated 3. Review diffs, accept changes 4. git add tests/_resultcheck_snaps/ git commit && git push 5. Workflow triggered 6. renv::restore() (from cache) 7. testthat::test_dir() └─ run_in_sandbox("code/analysis.R") └─ snapshot() in *testing mode* ✓ matches committed file → pass ✗ differs → FAIL ``` CI never updates snapshots; it only enforces them. To accept a legitimate result change, always re-run the script interactively, confirm the diff, and commit the updated `.md` files. --- ## Handling platform differences When the same computation yields slightly different floating-point values on different operating systems, use the mechanisms described in `vignette("snapshot-tolerance")`: * **`[ignored]` markers** — replace a volatile line in the snapshot file with the literal text `[ignored]`. That line position is skipped on every platform. * **`snapshot.precision`** — add a `precision` key to `_resultcheck.yml` to round all floating-point numbers before comparison: ```yaml # _resultcheck.yml snapshot: precision: 10 ``` Either option lets CI pass on all platforms without losing the safety net on the lines that do matter. --- ## Tips * **Commit `renv.lock` but not `renv/library/`.** The library is rebuilt from the lock file on each runner; only the lock file needs to be in version control. * **Keep snapshots human-readable.** The `.md` files produced by `snapshot()` are plain text and diff well in pull requests — reviewers can see at a glance whether a coefficient changed. * **Pin your R version** in the workflow matrix (e.g. `r-version: '4.4.2'`) if minor R releases have ever changed your numerical results. * **`fail-fast: false`** lets all three OS jobs run to completion even when one fails, giving you a full picture of where the discrepancy occurs. * **`workflow_dispatch`** allows you to trigger the workflow manually from the GitHub Actions UI — useful for debugging without having to push a commit.