Bryce Mecum

Testing R API Packages

2020/08/05
Tagged: software r github testing

I recently needed to test an R package at work destined for CRAN that wraps an API and ran into a situation where:

  1. I wanted only unit tests to run when CRAN checks the package. A package with tests that run on CRAN and depend on web services such as APIs are bound to cause your package to fail CRAN’s checks eventually which is a pain for both CRAN and you.
  2. I wanted to check the package across a variety of platforms and R versions in a typical build matrix fashion.
  3. I wanted to run a full integration test suite somewhere other than my machine in order to ensure the integration tests work in a clean environment.

I settled on GitHub Actions because it’s integrated with GitHub itself (which is really nice) and there are already great resources such as Jim Hester’s talk and helpful utilities such as usethis:use_github_actions() which makes it easy to get started.

The setup requires creating two GitHub Actions workflows:

  1. One that runs R CMD CHECK across a build matrix of platforms and R versions to ensure the package works for others. This runs just unit tests (i.e., those that don’t depend on external access to an API).
  2. Another that runs the full integration test suite. This will use a Docker container to spin up a fresh instance of the API I’m testing which is super easy with GitHub Actions.

Before setting up both workflows, I needed a way to skip a test if it’s an integration test (i.e., depended on having access to the API). I use testthat for my tests so I defined a helper in ./tests/setup-rt.R (rt is my package name here) which makes my helper available to all tests:

# Skip helper to control whether integration tests are run or not
skip_unless_integration <- function() {
  if (Sys.getenv("RT_INTEGRATION") != TRUE) {
    skip("Skipping integration test. Set RT_INTEGRATION to TRUE to run all tests.")
  }
}

This is the basis for a convention in my package where the full test suite is only run when the environmental variable RT_INTEGRATION is set to TRUE which I can control with GitHub Actions. With this setup, any test which requires access to the API gets skipped both on CRAN and when running the test suite locally when I prepend the following two lines to a test:

test_that("we can get properties of a ticket", {
  testthat::skip_on_cran()
  skip_unless_integration()

  # The rest of the test
})

With this test helper and testthat::skip_on_cran(), I can control which tests are run on CRAN and which tests are run when I have GitHub Actions run the full test suite depending on whether I include both, one, or none of them.

Now we need to pair this with the two workflows I mentioned above. These go in a .github folder at the top level of the package:

.github
└── workflows
    ├── ci.yml      # Build matrix
    └── tests.yml   # Integration tests

1 directory, 2 files

The first, ci.yml is a workflow that effectively runs R CMD CHECK on a variety of platforms and R versions (a build matrix):

on: [push, pull_request]

name: CI

jobs:
  CI:
    runs-on: $

    strategy:
      fail-fast: false
      matrix:
        config:
          - { os: windows-latest, r: "3.6", args: "--no-manual" }
          - { os: windows-latest, r: "4.0", args: "--no-manual" }
          - { os: macOS-latest, r: "3.6" }
          - { os: macOS-latest, r: "4.0" }
          - { os: macOS-latest, r: "devel", args: "--no-manual" }
          - { os: ubuntu-18.04, r: "3.5", args: "--no-manual" }
          - { os: ubuntu-18.04, r: "3.6", args: "--no-manual" }
          - { os: ubuntu-18.04, r: "4.0", args: "--no-manual" }
    env:
      R_REMOTES_NO_ERRORS_FROM_WARNINGS: true

    steps:
      - uses: actions/checkout@v1

      - uses: r-lib/actions/setup-r@master
        with:
          r-version: $

      - uses: r-lib/actions/setup-pandoc@master

      - uses: r-lib/actions/setup-tinytex@master
        if: contains(matrix.config.args, 'no-manual') == false

      - name: Cache R packages
        uses: actions/cache@v1
        if: runner.os != 'Windows'
        with:
          path: $
          key: $-r-$-$

      - name: Install system dependencies
        if: runner.os == 'Linux'
        env:
          RHUB_PLATFORM: linux-x86_64-ubuntu-gcc
        run: |
          Rscript -e "install.packages('remotes')" -e "remotes::install_github('r-hub/sysreqs')"
          sysreqs=$(Rscript -e "cat(sysreqs::sysreq_commands('DESCRIPTION'))")
          sudo -s eval "$sysreqs"

      - name: Install dependencies
        run: |
          install.packages("remotes")
          remotes::install_deps(dependencies = TRUE)
          remotes::install_cran('rcmdcheck')
        shell: Rscript {0}

      - name: Check
        run: Rscript -e "rcmdcheck::rcmdcheck(args = '$', error_on = 'warning', check_dir = 'check')"

      - name: Upload check results
        if: failure()
        uses: actions/upload-artifact@master
        with:
          name: $-r$-results
          path: check

The second, tests.yml runs the full test suite, which includes integration tests:

on: [push, pull_request]

name: Tests

jobs:
  CI:
    services:
      rt:
        image: netsandbox/request-tracker
        ports:
          - 80:80

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v1

      - uses: r-lib/actions/setup-r@master

      - name: Cache R packages
        uses: actions/cache@v1
        with:
          path: $
          key: $

      - name: Install system dependencies
        env:
          RHUB_PLATFORM: linux-x86_64-ubuntu-gcc
        run: |
          Rscript -e "install.packages('remotes')" -e "remotes::install_github('r-hub/sysreqs')"
          sysreqs=$(Rscript -e "cat(sysreqs::sysreq_commands('DESCRIPTION'))")
          sudo -s eval "$sysreqs"

      - name: Install dependencies
        run: |
          install.packages("remotes")
          remotes::install_deps(dependencies = TRUE)
          remotes::install_cran('rcmdcheck')
        shell: Rscript {0}

      - name: Check
        run: Rscript -e "rcmdcheck::rcmdcheck(args = \"--no-manual\", error_on = 'warning', check_dir = 'check')"

      - name: Upload check results
        if: failure()
        uses: actions/upload-artifact@master
        with:
          name: results
          path: check

Hopefully this pattern is useful to others. So far, I’ve found this setup works well and the hosting all of this on GitHub Actions also works well.