Testing interactive functions

I’m a huge fan of unit tests, but it’s tricky to test interactive functions where the user needs to enter input before the function can progress. I used to test them manually, which is incredibly tedious and time-consuming. So I ended up not testing interative functions very thoroughly. I found a post on Stack Overflow with a useful answer by znk. I’ve expanded their answer into a full example of a unit test for an interactive function.

library(testthat)

Set up the function

Your function needs to use readLines to get interactive input and take an argument for the connection (con). The default value for the connection should be the same as its default value for readLines, which is stdin() (the terminal). You can’t use readline, which only supports connection to the terminal.

This function displays a prompt and a list of valid options. If your response isn’t in the list, it will repeat the prompt until it is.

ask_opts <- function(prompt, opts = NULL, con = stdin()) {
  # display prompt and options
  optlist <- paste(opts, collapse = "|")
  prompt_opt <- paste0(prompt, " [", optlist, "]\n")
  cat(prompt_opt)
  input <- readLines(con = con, n = 1)
  
  # repeat if input is not in opts
  if (!is.null(opts) & !input %in% opts) {
    input <- ask_opts(prompt, opts, con)
  }

  input
}

Set up the Test

You need to create a file containing the input you want to send to the function, one input per line. I want to answer the first time with something not in the option list, then the second time with something that is in the option list.

# set up interactive answers
f <- file()
lines <- c("echidna", "ferret")
ans <- paste(lines, collapse = "\n")
write(ans, f)

Then run your interactive function, setting the connection to your file. Run it inside capture_output_lines if you want to test the prompts and not just the output. Close the file when you are done with it.

prompt <- "What is your favourite animal?"
opts <- c("cat", "dog", "ferret")

output_prompts <- capture_output_lines({
  result <- ask_opts(prompt, opts, f)
})

close(f) # close the file

Now you can run your tests

txt <- "What is your favourite animal? [cat|dog|ferret]"
expect_equal(result, "ferret")
expect_equal(output_prompts, rep(txt, 2))

Without a new argument

What if you don’t want to change the arguments to your function to add a connection? You can set the connection in the options and test for it in the function, defaulting to stdin(). For example:

ask_opts <- function(prompt, opts = NULL) {
  # set up connection, default to stdin() if not set
  con <- getOption("ask_opts.con", stdin())
  
  # display prompt and options
  optlist <- paste(opts, collapse = "|")
  prompt_opt <- paste0(prompt, " [", optlist, "]\n")
  cat(prompt_opt)
  input <- readLines(con = con, n = 1)
  
  # repeat if input is not in opts
  if (!is.null(opts) & !input %in% opts) {
    input <- ask_opts(prompt, opts)
  }

  input
}

Then you just need to set this option before you run the interactive function in your testing environment. Make sure to reset it to stdin() when you’re done.

test_that("interactive", {
  # set up interactive answers
  f <- file()
  lines <- c("maybe", "y")
  ans <- paste(lines, collapse = "\n")
  write(ans, f)
  
  options("ask_opts.con" = f) # set connection option 
  
  # run interactive function
  prompt <- "Was this helpful?"
  opts <- c("y", "n")
  
  output_prompts <- capture_output_lines({
    result <- ask_opts(prompt, opts)
  })
  
  close(f) # close the file
  options("ask_opts.con" = stdin()) # reset connection option
  
  # tests
  txt <- "Was this helpful? [y|n]"
  expect_equal(result, "y")
  expect_equal(output_prompts, rep(txt, 2))
})