From 24a6b53b1941059b5352aa0232bacb6f22dc90ab Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 27 Sep 2022 11:06:10 -0500 Subject: [PATCH] Implement expect_no_warnings() and friends (#1690) Fixes #1679 --- NAMESPACE | 4 + NEWS.md | 4 + R/expect-condition.R | 12 +- R/expect-no-condition.R | 127 +++++++++++++++++++ _pkgdown.yml | 1 + man/expect_error.Rd | 15 ++- man/expect_no_error.Rd | 64 ++++++++++ tests/testthat/_snaps/expect-no-condition.md | 35 +++++ tests/testthat/test-expect-no-condition.R | 26 ++++ 9 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 R/expect-no-condition.R create mode 100644 man/expect_no_error.Rd create mode 100644 tests/testthat/_snaps/expect-no-condition.md create mode 100644 tests/testthat/test-expect-no-condition.R diff --git a/NAMESPACE b/NAMESPACE index 39ea9fb24..6aed6190e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -102,7 +102,11 @@ export(expect_match) export(expect_message) export(expect_more_than) export(expect_named) +export(expect_no_condition) +export(expect_no_error) export(expect_no_match) +export(expect_no_message) +export(expect_no_warning) export(expect_null) export(expect_output) export(expect_output_file) diff --git a/NEWS.md b/NEWS.md index 6bf44607b..c73388f20 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # testthat (development version) +* New experimental `expect_no_error()`, `expect_no_warning()`, + `expect_no_message()`, and `expect_no_condition()` for asserting + the code runs without an error, warning, message, or condition (#1679). + * Fixed a warning in R >=4.2.0 on Windows that occurred when using the C++ testing infrastructure that testthat provides (#1672). diff --git a/R/expect-condition.R b/R/expect-condition.R index 0c86deba0..8bd4695e2 100644 --- a/R/expect-condition.R +++ b/R/expect-condition.R @@ -42,7 +42,14 @@ #' error message. #' * If `NULL`, the default, asserts that there should be an error, #' but doesn't test for a specific value. -#' * If `NA`, asserts that there should be no errors. +#' * If `NA`, asserts that there should be no errors, but we now recommend +#' using [expect_no_error()] and friends instead. +#' +#' Note that you should only use `message` with errors/warnings/messages +#' that you generate. Avoid tests that rely on the specific text generated by +#' another package since this can easily change. If you do need to test text +#' generated by another package, either protect the test with `skip_on_cran()` +#' or use `expect_snapshot()`. #' @inheritDotParams expect_match -object -regexp -info -label -all #' @param class Instead of supplying a regular expression, you can also supply #' a class name. This is useful for "classed" conditions. @@ -51,6 +58,9 @@ #' @param all *DEPRECATED* If you need to test multiple warnings/messages #' you now need to use multiple calls to `expect_message()`/ #' `expect_warning()` +#' @seealso [expect_no_error()], `expect_no_warning()`, +#' `expect_no_message()`, and `expect_no_condition()` to assert +#' that code runs without errors/warnings/messages/conditions. #' @return If `regexp = NA`, the value of the first argument; otherwise #' the captured condition. #' @examples diff --git a/R/expect-no-condition.R b/R/expect-no-condition.R new file mode 100644 index 000000000..9095c3823 --- /dev/null +++ b/R/expect-no-condition.R @@ -0,0 +1,127 @@ +#' Does code run without error, warning, message, or other condition? +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' These expectations are the opposite of [expect_error()], +#' `expect_warning()`, `expect_message()`, and `expect_condition()`. They +#' assert the absence of an error, warning, or message, respectively. +#' +#' @inheritParams expect_error +#' @param message,class The default, `message = NULL, class = NULL`, +#' will fail if there is any error/warning/message/condition. +#' +#' If many cases, particularly when testing warnings and message, you will +#' want to be more specific about the condition you are hoping **not** to see, +#' i.e. the condition that motivated you to write the test. Similar to +#' `expect_error()` and friends, you can specify the `message` (a regular +#' expression that the message of the condition must match) and/or the +#' `class` (a class the condition must inherit from). This ensures that +#' the message/warnings you don't want never recur, while allowing new +#' messages/warnings to bubble up for you to deal with. +#' +#' Note that you should only use `message` with errors/warnings/messages +#' that you generate, or that base R generates (which tend to be stable). +#' Avoid tests that rely on the specific text generated by another package +#' since this can easily change. If you do need to test text generated by +#' another package, either protect the test with `skip_on_cran()` or +#' use `expect_snapshot()`. +#' @inheritParams rlang::args_dots_empty +#' @export +#' @examples +#' expect_no_warning(1 + 1) +#' +#' foo <- function(x) { +#' warning("This is a problem!") +#' } +#' +#' # warning doesn't match so bubbles up: +#' expect_no_warning(foo(), message = "bananas") +#' +#' # warning does match so causes a failure: +#' try(expect_no_warning(foo(), message = "problem")) +expect_no_error <- function(object, + ..., + message = NULL, + class = NULL) { + check_dots_empty() + expect_no_("error", {{ object }}, ..., regexp = message, class = class) +} + + +#' @export +#' @rdname expect_no_error +expect_no_warning <- function(object, + ..., + message = NULL, + class = NULL + ) { + check_dots_empty() + expect_no_("warning", {{ object }}, ..., regexp = message, class = class) +} + +#' @export +#' @rdname expect_no_error +expect_no_message <- function(object, + ..., + message = NULL, + class = NULL + ) { + check_dots_empty() + expect_no_("messsage", {{ object }}, ..., regexp = message, class = class) +} + +#' @export +#' @rdname expect_no_error +expect_no_condition <- function(object, + ..., + message = NULL, + class = NULL + ) { + check_dots_empty() + expect_no_("condition", {{ object }}, ..., regexp = message, class = class) +} + + +expect_no_ <- function(base_class, + object, + regexp = NULL, + class = NULL, + ..., + error_call = caller_env()) { + + check_dots_used(action = warn, call = error_call) + matcher <- cnd_matcher(class %||% base_class, regexp, ...) + + capture <- function(code) { + try_fetch( + code, + !!base_class := function(cnd) { + if (!matcher(cnd)) { + return(zap()) + } + + expected <- paste0( + "Expected ", quo_label(enquo(object)), " to run without any ", base_class, "s", + if (!is.null(class)) paste0(" of class '", class, "'"), + if (!is.null(regexp)) paste0(" matching pattern '", regexp, "'"), + "." + ) + actual <- paste0( + "Actually got a <", class(cnd)[[1]], ">:\n", + indent_lines(rlang::cnd_message(cnd, prefix = TRUE)) + ) + message <- format_error_bullets(c(expected, i = actual)) + fail(message, trace_env = error_call) + } + ) + } + + act <- quasi_capture(enquo(object), NULL, capture) + succeed() + invisible(act$val) +} + +indent_lines <- function(x) { + paste0(" ", gsub("\n", "\n ", x)) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 2e4149abb..e690bb625 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -34,6 +34,7 @@ reference: - subtitle: Side-effects contents: - expect_error + - expect_no_error - expect_invisible - expect_output - expect_silent diff --git a/man/expect_error.Rd b/man/expect_error.Rd index 1c5a3e966..50851564b 100644 --- a/man/expect_error.Rd +++ b/man/expect_error.Rd @@ -61,8 +61,15 @@ within a function or for loop. See \link{quasi_label} for more details.} error message. \item If \code{NULL}, the default, asserts that there should be an error, but doesn't test for a specific value. -\item If \code{NA}, asserts that there should be no errors. -}} +\item If \code{NA}, asserts that there should be no errors, but we now recommend +using \code{\link[=expect_no_error]{expect_no_error()}} and friends instead. +} + +Note that you should only use \code{message} with errors/warnings/messages +that you generate. Avoid tests that rely on the specific text generated by +another package since this can easily change. If you do need to test text +generated by another package, either protect the test with \code{skip_on_cran()} +or use \code{expect_snapshot()}.} \item{class}{Instead of supplying a regular expression, you can also supply a class name. This is useful for "classed" conditions.} @@ -172,6 +179,10 @@ expect_message(f(-1), "already negative") expect_message(f(1), NA) } \seealso{ +\code{\link[=expect_no_error]{expect_no_error()}}, \code{expect_no_warning()}, +\code{expect_no_message()}, and \code{expect_no_condition()} to assert +that code runs without errors/warnings/messages/conditions. + Other expectations: \code{\link{comparison-expectations}}, \code{\link{equality-expectations}}, diff --git a/man/expect_no_error.Rd b/man/expect_no_error.Rd new file mode 100644 index 000000000..c1113bdf9 --- /dev/null +++ b/man/expect_no_error.Rd @@ -0,0 +1,64 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/expect-no-condition.R +\name{expect_no_error} +\alias{expect_no_error} +\alias{expect_no_warning} +\alias{expect_no_message} +\alias{expect_no_condition} +\title{Does code run without error, warning, message, or other condition?} +\usage{ +expect_no_error(object, ..., message = NULL, class = NULL) + +expect_no_warning(object, ..., message = NULL, class = NULL) + +expect_no_message(object, ..., message = NULL, class = NULL) + +expect_no_condition(object, ..., message = NULL, class = NULL) +} +\arguments{ +\item{object}{Object to test. + +Supports limited unquoting to make it easier to generate readable failures +within a function or for loop. See \link{quasi_label} for more details.} + +\item{...}{These dots are for future extensions and must be empty.} + +\item{message, class}{The default, \verb{message = NULL, class = NULL}, +will fail if there is any error/warning/message/condition. + +If many cases, particularly when testing warnings and message, you will +want to be more specific about the condition you are hoping \strong{not} to see, +i.e. the condition that motivated you to write the test. Similar to +\code{expect_error()} and friends, you can specify the \code{message} (a regular +expression that the message of the condition must match) and/or the +\code{class} (a class the condition must inherit from). This ensures that +the message/warnings you don't want never recur, while allowing new +messages/warnings to bubble up for you to deal with. + +Note that you should only use \code{message} with errors/warnings/messages +that you generate, or that base R generates (which tend to be stable). +Avoid tests that rely on the specific text generated by another package +since this can easily change. If you do need to test text generated by +another package, either protect the test with \code{skip_on_cran()} or +use \code{expect_snapshot()}.} +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +These expectations are the opposite of \code{\link[=expect_error]{expect_error()}}, +\code{expect_warning()}, \code{expect_message()}, and \code{expect_condition()}. They +assert the absence of an error, warning, or message, respectively. +} +\examples{ +expect_no_warning(1 + 1) + +foo <- function(x) { + warning("This is a problem!") +} + +# warning doesn't match so bubbles up: +expect_no_warning(foo(), message = "bananas") + +# warning does match so causes a failure: +try(expect_no_warning(foo(), message = "problem")) +} diff --git a/tests/testthat/_snaps/expect-no-condition.md b/tests/testthat/_snaps/expect-no-condition.md new file mode 100644 index 000000000..17221e744 --- /dev/null +++ b/tests/testthat/_snaps/expect-no-condition.md @@ -0,0 +1,35 @@ +# matched conditions give informative message + + Code + expect_no_warning(foo()) + Condition + Error: + ! Expected `foo()` to run without any warnings. + i Actually got a : + Warning: + This is a problem! + Code + expect_no_warning(foo(), message = "problem") + Condition + Error: + ! Expected `foo()` to run without any warnings matching pattern 'problem'. + i Actually got a : + Warning: + This is a problem! + Code + expect_no_warning(foo(), class = "test") + Condition + Error: + ! Expected `foo()` to run without any warnings of class 'test'. + i Actually got a : + Warning: + This is a problem! + Code + expect_no_warning(foo(), message = "problem", class = "test") + Condition + Error: + ! Expected `foo()` to run without any warnings of class 'test' matching pattern 'problem'. + i Actually got a : + Warning: + This is a problem! + diff --git a/tests/testthat/test-expect-no-condition.R b/tests/testthat/test-expect-no-condition.R new file mode 100644 index 000000000..1d793094a --- /dev/null +++ b/tests/testthat/test-expect-no-condition.R @@ -0,0 +1,26 @@ +test_that("unmatched conditions bubble up", { + expect_error(expect_no_error(abort("foo"), message = "bar"), "foo") + expect_warning(expect_no_warning(warn("foo"), message = "bar"), "foo") + expect_message(expect_no_message(inform("foo"), message = "bar"), "foo") + expect_condition(expect_no_condition(signal("foo", "x"), message = "bar"), "foo") +}) + +test_that("only matches conditions of specified type", { + foo <- function() { + warn("This is a problem!", class = "test") + } + expect_warning(expect_no_error(foo(), class = "test"), class = "test") +}) + +test_that("matched conditions give informative message", { + foo <- function() { + warn("This is a problem!", class = "test") + } + + expect_snapshot(error = TRUE, { + expect_no_warning(foo()) + expect_no_warning(foo(), message = "problem") + expect_no_warning(foo(), class = "test") + expect_no_warning(foo(), message = "problem", class = "test") + }) +})