stubthat - Stubs for unit testing in R

knitr::opts_chunk$set(collapse = T, comment = "#>")

# loading stubthat
library(stubthat)

Introduction

stubthat package provides stubs for use while unit testing in R. The API is highly inspired by Sinon.js. This package is meant to be used along with testthat and mockr packages, specifically the 'mockr::with_mock' function.

To understand what a stub is and how they are used while unit testing, please take a look at this Stackoverflow question What is a “Stub”?.

Usage

There are three main steps for creating & using a stub of a function -

jedi_or_sith <- function(x) return('No one')
jedi_or_sith_stub <- stub(jedi_or_sith)
jedi_or_sith_stub$withArgs(x = 'Luke')$returns('Jedi')
jedi_or_sith('Luke')
jedi_or_sith_stub$f('Luke')

Use cases

Stubs are generally used in the testing environment. Here is an example:

library(httr) # provides the GET and status_code functions

url_downloader <- function(url) GET(url)

check_api_endpoint_status <- function(url) {
  response <- url_downloader(url)
  response_status <- status_code(response)
  ifelse(response_status == 200, 'up', 'down')
}

This function check_api_endpoint_status should make a GET request (via the url_downloader function) to the specified url (say https://example.com/endpoint) and it should return 'up' if the status code is '200'. Return 'down' otherwise. While testing, it is generally a good idea to avoid making repeated (or any) requests to external sources.

Using stubs (and with_mock from mockr), the above function can be tested without accessing the external source, as shown below:

url_downloader_stub <- stub(url_downloader)
url_downloader_stub$withArgs(url = 'good url')$returns(200)
url_downloader_stub$withArgs(url = 'bad url')$returns(404)

# testthat package provides the expect_equal function
# mockr package provides the with_mock function

check_api_endpoint_status_tester <- function(x) {
  mockr::with_mock(url_downloader = url_downloader_stub$f,
                   check_api_endpoint_status(x))
}

(testthat::expect_equal(check_api_endpoint_status_tester('good url'), 'up'))
#> [1] "up"
(testthat::expect_equal(check_api_endpoint_status_tester('bad url'),  'down'))
#> [1] "down"

Another use case: Consider the outline of a function f1

f1 <- function(...) {

  {...some computation...}

  interim_val <- f2(...)

  {...more computation...}

  return(ans)
}

Here, the function f1 calls f2 within its body. Suppose f2 takes more than few seconds to run (e.g.: Simulations, Model building, etc). Let's assume that the f2 function already has separate tests written to test its validity. As f2 function's validity is ensured, and since it takes a lot of time to finish, it may be better to skip the interim_val <- f2(...) statement in tests for the f1 function. Also, a general expectation from a suite of tests is that they should finish within few minutes (if not seconds). In such a case, using a stub of f2 while testing f1 is desirable.

API

Check if the stub is called with specified arguments

stub$expects(...)

Stub will check the incoming arguments for the specified set of arguments. Throws an error if there is a mismatch.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$expects(a = 2)

stub_of_sum$f(2)
stub_of_sum$f(3)

stub$strictlyExpects(...)

The set of specified arguments should be exactly matched with the set of incoming arguments.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$strictlyExpects(a = 2)
stub_of_sum$f(2)

The above call resulted in the error because the incoming set of arguments was a = 2, b = 1, but the defined set of expected arguments consisted only a = 2.

stub_of_sum$strictlyExpects(a = 2, b = 1)
stub_of_sum$f(2)

stub$onCall(#)$expects(...)

The stub expects the specifed arguments on the nth call.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(3)$expects(a = 2)

stub_of_sum$f(100)
stub_of_sum$f(100)
stub_of_sum$f(100)

stub$onCall(#)$strictlyExpects(...)

The stub expects the exact set of specifed arguments on the nth call.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(3)$strictlyExpects(a = 2, b = 2)

stub_of_sum$f(2)
stub_of_sum$f(2)
stub_of_sum$f(2)

Make the stub return a specified value

stub$returns(...)

Unless otherwise specified, the stub always returns the specified value.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$returns(0)

stub_of_sum$f(2)

stub$onCall(#)$returns(...)

The stub returns the specified value on the nth call.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(2)$returns(0)

stub_of_sum$f(2)
stub_of_sum$f(2)

stub$withArgs(...)$returns(...)

The stub returns the specified value when it is called with the specified arguments.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$withArgs(a = 2)$returns(0)

stub_of_sum$f(1)
stub_of_sum$f(2)

stub$withExactArgs(...)$returns(...)

The stub returns the specified value when it is called with the exact set of specified arguments.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$withExactArgs(a = 2)$returns(0) # won't work because value for b is not defined
stub_of_sum$withExactArgs(a = 2, b = 1)$returns(1)

stub_of_sum$f(1)
stub_of_sum$f(2)

Make the stub throw an error with a specified message

stub$throws('')

Unless otherwise specified, the stub throws an error with the specified message.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$throws('some err msg')

stub_of_sum$f(2)

stub$onCall(#)$throws('')

The stub throws an error on the nth call.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(2)$throws('some err msg')

stub_of_sum$f(0)
stub_of_sum$f(0)

stub$withArgs(...)$throws('')

The stub throws an error when it is called with the specified arguments.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$withArgs(a = 2)$throws('some err msg')

stub_of_sum$f(1)
stub_of_sum$f(2)

stub$withExactArgs(...)$throws('')

The stub returns the specified value when it is called with the exact set of specified arguments.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$withExactArgs(a = 2)$throws('good') # won't work because value for b is not defined
stub_of_sum$withExactArgs(a = 2, b = 1)$throws('nice')

stub_of_sum$f(1)
stub_of_sum$f(2)

Get the number of times the stub has been called

stub$calledTimes()

Using this, one can obtain the number of times, the stub has been called.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

ans <- stub_of_sum$f(3)
ans <- stub_of_sum$f(3)
stub_of_sum$calledTimes()
ans <- stub_of_sum$f(3)
stub_of_sum$calledTimes()

Extra

Convenience functions to reduce repetition of code.

stub$onCall(#)$expects(...)$returns(...)

On nth call, the stub will check for the specified arguments, and if satisfied, returns the specified value.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(1)$expects(a = 1)$returns('good')
stub_of_sum$onCall(3)$expects(a = 3)$returns('nice')

stub_of_sum$f(3)
stub_of_sum$f(3)
stub_of_sum$f(3)

This is same as calling stub$onCall(#)$expects(...) and stub$onCall(#)$returns(...) separately.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(1)$expects(a = 1)
stub_of_sum$onCall(1)$returns('good')
stub_of_sum$onCall(3)$returns('nice')
stub_of_sum$onCall(3)$expects(a = 3)

stub_of_sum$f(3)
stub_of_sum$f(3)
stub_of_sum$f(3)

stub$onCall(#)$strictlyExpects(...)$returns(...)

On nth call, the stub will check for the exact set of specified arguments, and if satisfied, returns the specified value.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(1)$strictlyExpects(a = 3)$returns('good')
stub_of_sum$onCall(3)$strictlyExpects(a = 3, b = 1)$returns('nice')

stub_of_sum$f(3)
stub_of_sum$f(3)
stub_of_sum$f(3)

stub$onCall(#)$expects(...)$throws('')

On nth call, the stub will check for the specified arguments, and if satisfied, throws an error with the specified message.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(1)$expects(a = 1)$throws('good')
stub_of_sum$onCall(3)$expects(a = 3)$throws('nice')

stub_of_sum$f(3)
stub_of_sum$f(3)
stub_of_sum$f(3)

stub$onCall(#)$strictlyExpects(...)$throws('')

On nth call, the stub will check for the exact set of specified arguments, and if satisfied, throws an error with the specified message.

sum <- function(a, b = 1) return(a + b)
stub_of_sum <- stub(sum)

stub_of_sum$onCall(1)$strictlyExpects(a = 3)$throws('good')
stub_of_sum$onCall(3)$strictlyExpects(a = 3, b = 1)$throws('nice')

stub_of_sum$f(3)
stub_of_sum$f(3)
stub_of_sum$f(3)

A note regarding with_mock

testthat::with_mock function is going to be deprecated in a future release of testthat. mockr library's with_mock function is meant to be the replacement for testthat::with_mock. Slight changes will be needed while replacing testthat::with_mock with mockr::with_mock. Refer to mockr's README for more details.

Also, it is no longer possible to mock functions from external packages. If you are doing this, either change the code to avoid such a case or use a wrapper function similar to the url_downloader <- function(url) GET(url) example in this document. To know more about the reasons behind these changes, refer to the following github issues : with_mock interacts badly with the JIT, Prevent with_mock from touching base R packages.



Try the stubthat package in your browser

Any scripts or data that you put into this service are public.

stubthat documentation built on May 1, 2019, 11:16 p.m.