diff --git a/NAMESPACE b/NAMESPACE index 8d29d8f7f..a2bdc1c6d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -119,6 +119,7 @@ export(expect_s3_class) export(expect_s4_class) export(expect_s7_class) export(expect_setequal) +export(expect_shape) export(expect_silent) export(expect_snapshot) export(expect_snapshot_error) diff --git a/NEWS.md b/NEWS.md index 3fa3f7e5d..e89748e3a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ * Fixed an issue preventing compilation from succeeding due to deprecation / removal of `std::uncaught_exception()` (@kevinushey, #2047). * New `skip_unless_r()` to skip running tests on unsuitable versions of R, e.g. `skip_unless_r(">= 4.1.0")` to skip tests that require, say, `...names` (@MichaelChirico, #2022) * `skip_on_os()` gains an option `"emscripten"` of the `os` argument to skip tests on Emscripten (@eitsupi, #2103). +* New expectation, `expect_shape()`, for testing the shape (i.e., the `length()`, `nrow()`, `ncol()`, or `dim()`), all in one place (#1423, @michaelchirico). # testthat 3.2.3 diff --git a/R/expect-length.R b/R/expect-length.R index 0b8d09285..1b21c791f 100644 --- a/R/expect-length.R +++ b/R/expect-length.R @@ -1,6 +1,7 @@ #' Does code return a vector with the specified length? #' -#' @seealso [expect_vector()] to make assertions about the "size" of a vector +#' @seealso [expect_vector()] to make assertions about the "size" of a vector, +#' [expect_shape()] for more general assertions about object "shape". #' @inheritParams expect_that #' @param n Expected length. #' @family expectations @@ -16,11 +17,16 @@ expect_length <- function(object, n) { stopifnot(is.numeric(n), length(n) == 1) act <- quasi_label(enquo(object), arg = "object") + expect_length_impl_(act, n) +} + +expect_length_impl_ <- function(act, n) { act$n <- length(act$val) expect( act$n == n, - sprintf("%s has length %i, not length %i.", act$lab, act$n, n) + sprintf("%s has length %i, not length %i.", act$lab, act$n, n), + trace_env = parent.frame() ) invisible(act$val) diff --git a/R/expect-shape.R b/R/expect-shape.R new file mode 100644 index 000000000..741bd086a --- /dev/null +++ b/R/expect-shape.R @@ -0,0 +1,76 @@ +#' Does code return an object with the specified shape? +#' +#' This is a generalization of [expect_length()] to test the "shape" of +#' more general objects like data.frames, matrices, and arrays. +#' +#' @seealso [expect_length()] to specifically make assertions about the +#' [length()] of a vector. +#' @inheritParams expect_that +#' @param ... Ignored. +#' @param length Expected [length()] of `object`. +#' @param nrow,nrow Expected [nrow()]/[ncol()] of `object`. +#' @param dim Expected [dim()] of `object`. +#' @family expectations +#' @export +#' @examples +#' x <- matrix(1:9, nrow = 3) +#' expect_shape(x, length = 9) +#' expect_shape(x, nrow = 3) +#' expect_shape(x, ncol = 3) +#' expect_shape(x, dim = c(3, 3)) +expect_shape = function(object, ..., length, nrow, ncol, dim) { + check_dots_empty() + check_exclusive(length, nrow, ncol, dim) + act <- quasi_label(enquo(object), arg = "object") + + # Re-use expect_length() to ensure they stay in sync. + if (!missing(length)) { + return(expect_length_impl_(act, length)) + } + # now that we've handled the length argument, revert to usual base function + length <- base::length + + dim_object <- base::dim(object) + if (is.null(dim_object)) { + fail(sprintf("%s has no dimensions.", act$lab)) + } + + if (!missing(nrow)) { + check_number_whole(nrow, allow_na = TRUE) + act$nrow <- dim_object[1L] + + expect( + identical(as.integer(act$nrow), as.integer(nrow)), + sprintf("%s has %i rows, not %i.", act$lab, act$nrow, nrow) + ) + } else if (!missing(ncol)) { + check_number_whole(ncol, allow_na = TRUE) + + if (length(dim_object) == 1L) { + fail(sprintf("%s has only one dimension.", act$lab)) + } + + act$ncol <- dim_object[2L] + + expect( + identical(as.integer(act$ncol), as.integer(ncol)), + sprintf("%s has %i columns, not %i.", act$lab, act$ncol, ncol) + ) + } else { # !missing(dim) + if (!is.numeric(dim) && !is.integer(dim)) { + stop_input_type(dim, "a numeric vector") + } + act$dim <- dim_object + + if (length(act$dim) != length(dim)) { + fail(sprintf("%s has %i dimensions, not %i.", act$lab, length(act$dim), length(dim))) + } + + expect( + identical(as.integer(act$dim), as.integer(dim)), + sprintf("%s has dim (%s), not (%s).", act$lab, toString(act$dim), toString(dim)) + ) + } + + invisible(act$val) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 418d8c7e9..1eac1b26d 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -26,6 +26,7 @@ reference: - subtitle: Vectors contents: - expect_length + - expect_shape - expect_gt - expect_match - expect_named diff --git a/man/comparison-expectations.Rd b/man/comparison-expectations.Rd index 5cbee6931..3a804319a 100644 --- a/man/comparison-expectations.Rd +++ b/man/comparison-expectations.Rd @@ -49,6 +49,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/equality-expectations.Rd b/man/equality-expectations.Rd index e22afd695..25e6d344f 100644 --- a/man/equality-expectations.Rd +++ b/man/equality-expectations.Rd @@ -92,6 +92,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_error.Rd b/man/expect_error.Rd index 50851564b..65a1f51b2 100644 --- a/man/expect_error.Rd +++ b/man/expect_error.Rd @@ -192,6 +192,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_length.Rd b/man/expect_length.Rd index 4e65e8069..8168a72f1 100644 --- a/man/expect_length.Rd +++ b/man/expect_length.Rd @@ -26,7 +26,8 @@ expect_length(1:10, 1) } } \seealso{ -\code{\link[=expect_vector]{expect_vector()}} to make assertions about the "size" of a vector +\code{\link[=expect_vector]{expect_vector()}} to make assertions about the "size" of a vector, +\code{\link[=expect_shape]{expect_shape()}} for more general assertions about object "shape". Other expectations: \code{\link{comparison-expectations}}, @@ -37,6 +38,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_match.Rd b/man/expect_match.Rd index ab0dbdae1..496ad0b32 100644 --- a/man/expect_match.Rd +++ b/man/expect_match.Rd @@ -96,6 +96,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_named.Rd b/man/expect_named.Rd index bfa9ad9fc..f5e879af0 100644 --- a/man/expect_named.Rd +++ b/man/expect_named.Rd @@ -61,6 +61,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_null.Rd b/man/expect_null.Rd index e3b953cce..0dc684f4e 100644 --- a/man/expect_null.Rd +++ b/man/expect_null.Rd @@ -39,6 +39,7 @@ Other expectations: \code{\link{expect_named}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_output.Rd b/man/expect_output.Rd index c7d789f6e..8dd8441d1 100644 --- a/man/expect_output.Rd +++ b/man/expect_output.Rd @@ -73,6 +73,7 @@ Other expectations: \code{\link{expect_named}()}, \code{\link{expect_null}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_reference.Rd b/man/expect_reference.Rd index e5a4b120e..1963c0177 100644 --- a/man/expect_reference.Rd +++ b/man/expect_reference.Rd @@ -49,6 +49,7 @@ Other expectations: \code{\link{expect_named}()}, \code{\link{expect_null}()}, \code{\link{expect_output}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} diff --git a/man/expect_shape.Rd b/man/expect_shape.Rd new file mode 100644 index 000000000..064750fcd --- /dev/null +++ b/man/expect_shape.Rd @@ -0,0 +1,47 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/expect-shape.R +\name{expect_shape} +\alias{expect_shape} +\title{Does code return an object with the specified shape?} +\usage{ +expect_shape(object, ..., length, nrow, ncol, dim) +} +\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{...}{Ignored.} + +\item{length}{Expected \code{\link[=length]{length()}} of \code{object}.} + +\item{nrow}{Expected \code{\link[=nrow]{nrow()}} of \code{object}.} + +\item{ncol}{Expected \code{\link[=ncol]{ncol()}} of \code{object}.} + +\item{dim}{Expected \code{\link[=dim]{dim()}} of \code{object}.} +} +\description{ +This is a generalization of \code{\link[=expect_length]{expect_length()}} to test the "shape" of +more general objects like data.frames, matrices, and arrays. +} +\seealso{ +\code{\link[=expect_length]{expect_length()}} to specifically make assertions about the +\code{\link[=length]{length()}} of a vector. + +Other expectations: +\code{\link{comparison-expectations}}, +\code{\link{equality-expectations}}, +\code{\link{expect_error}()}, +\code{\link{expect_length}()}, +\code{\link{expect_match}()}, +\code{\link{expect_named}()}, +\code{\link{expect_null}()}, +\code{\link{expect_output}()}, +\code{\link{expect_reference}()}, +\code{\link{expect_silent}()}, +\code{\link{inheritance-expectations}}, +\code{\link{logical-expectations}} +} +\concept{expectations} diff --git a/man/expect_silent.Rd b/man/expect_silent.Rd index 69688a2ec..bb92152df 100644 --- a/man/expect_silent.Rd +++ b/man/expect_silent.Rd @@ -41,6 +41,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{inheritance-expectations}}, \code{\link{logical-expectations}} } diff --git a/man/inheritance-expectations.Rd b/man/inheritance-expectations.Rd index e43b0b89e..5305f21fc 100644 --- a/man/inheritance-expectations.Rd +++ b/man/inheritance-expectations.Rd @@ -79,6 +79,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{logical-expectations}} } diff --git a/man/logical-expectations.Rd b/man/logical-expectations.Rd index 7d436b5ec..5dbc779e4 100644 --- a/man/logical-expectations.Rd +++ b/man/logical-expectations.Rd @@ -57,6 +57,7 @@ Other expectations: \code{\link{expect_null}()}, \code{\link{expect_output}()}, \code{\link{expect_reference}()}, +\code{\link{expect_shape}()}, \code{\link{expect_silent}()}, \code{\link{inheritance-expectations}} } diff --git a/tests/testthat/_snaps/expect-shape.md b/tests/testthat/_snaps/expect-shape.md new file mode 100644 index 000000000..edd3354a1 --- /dev/null +++ b/tests/testthat/_snaps/expect-shape.md @@ -0,0 +1,89 @@ +# length compared correctly + + 1 has length 1, not length 2. + +# dim compared correctly + + matrix(nrow = 6, ncol = 3) has dim (6, 3), not (6, 2). + +--- + + matrix(nrow = 6, ncol = 3) has dim (6, 3), not (7, 3). + +--- + + array(dim = 1:3) has 3 dimensions, not 2. + +--- + + array(dim = 1:3) has 3 dimensions, not 4. + +# nrow compared correctly + + matrix(nrow = 5, ncol = 5) has 5 rows, not 6. + +--- + + 1 has no dimensions. + +# ncol compared correctly + + matrix(nrow = 5, ncol = 5) has 5 columns, not 7. + +--- + + array(1) has only one dimension. + +--- + + array(integer()) has only one dimension. + +# NA handling (e.g. dbplyr) + + `x` has NA rows, not 10. + +--- + + `x` has 10 columns, not NA. + +--- + + `x` has dim (NA, 10), not (10, NA). + +# checks inputs arguments, + + Code + expect_shape(1:10) + Condition + Error in `expect_shape()`: + ! One of `length`, `nrow`, `ncol`, or `dim` must be supplied. + Code + expect_shape(1:10, nrow = 1L, ncol = 2L) + Condition + Error in `expect_shape()`: + ! Exactly one of `length`, `nrow`, `ncol`, or `dim` must be supplied. + x `nrow` and `ncol` were supplied together. + Code + expect_shape(1:10, 2) + Condition + Error in `expect_shape()`: + ! `...` must be empty. + x Problematic argument: + * ..1 = 2 + i Did you forget to name an argument? + Code + expect_shape(array(1), nrow = "x") + Condition + Error in `expect_shape()`: + ! `nrow` must be a whole number or `NA`, not the string "x". + Code + expect_shape(array(1), ncol = "x") + Condition + Error in `expect_shape()`: + ! `ncol` must be a whole number or `NA`, not the string "x". + Code + expect_shape(array(1), dim = "x") + Condition + Error in `expect_shape()`: + ! `dim` must be a numeric vector, not the string "x". + diff --git a/tests/testthat/test-expect-shape.R b/tests/testthat/test-expect-shape.R new file mode 100644 index 000000000..6635bcd52 --- /dev/null +++ b/tests/testthat/test-expect-shape.R @@ -0,0 +1,104 @@ +test_that("length compared correctly", { + expect_success(expect_shape(1, length = 1)) + expect_snapshot_failure(expect_shape(1, length = 2)) + expect_success(expect_shape(1:10, length = 10)) + expect_success(expect_shape(letters[1:5], length = 5)) + expect_success(expect_shape(integer(), length = 0)) + + x <- list(1:10, letters) + out <- expect_shape(x, length = 2) + expect_identical(out, x) +}) + +test_that("dim compared correctly", { + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), dim = c(5L, 4L))) + expect_snapshot_failure(expect_shape( + matrix(nrow = 6, ncol = 3), + dim = c(6L, 2L) + )) + expect_snapshot_failure(expect_shape( + matrix(nrow = 6, ncol = 3), + dim = c(7L, 3L) + )) + expect_success(expect_shape(data.frame(1:10, 11:20), dim = c(10, 2))) + expect_success(expect_shape(array(dim = 1:3), dim = 1:3)) + expect_snapshot_failure(expect_shape(array(dim = 1:3), dim = 1:2)) + expect_snapshot_failure(expect_shape(array(dim = 1:3), dim = 1:4)) + expect_success(expect_shape(array(integer()), dim = 0L)) + dd <- c(0L, 0L, 0L, 5L, 0L, 0L, 0L) + expect_success(expect_shape(array(dim = dd), dim = dd)) + + x <- cbind(1:2, 3:4) + out <- expect_shape(x, dim = c(2L, 2L)) + expect_identical(out, x) +}) + +test_that("nrow compared correctly", { + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), nrow = 5L)) + expect_snapshot_failure(expect_shape(matrix(nrow = 5, ncol = 5), nrow = 6L)) + expect_success(expect_shape(data.frame(1:10, 11:20), nrow = 10L)) + expect_snapshot_failure(expect_shape(1, nrow = 1)) + expect_success(expect_shape(array(integer()), nrow = 0L)) + dd <- c(0L, 0L, 0L, 5L, 0L, 0L, 0L) + expect_success(expect_shape(array(dim = dd), nrow = 0L)) + + x <- cbind(1:2, 3:4) + out <- expect_shape(x, dim = c(2L, 2L)) + expect_identical(out, x) +}) + +test_that("ncol compared correctly", { + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), ncol = 4L)) + expect_snapshot_failure(expect_shape(matrix(nrow = 5, ncol = 5), ncol = 7L)) + expect_success(expect_shape(data.frame(1:10, 11:20), ncol = 2L)) + expect_snapshot_failure(expect_shape(array(1), ncol = 1)) + expect_snapshot_failure(expect_shape(array(integer()), ncol = 0L)) + dd <- c(0L, 0L, 0L, 5L, 0L, 0L, 0L) + expect_success(expect_shape(array(dim = dd), ncol = 0L)) + + x <- cbind(1:2, 3:4) + out <- expect_shape(x, dim = c(2L, 2L)) + expect_identical(out, x) +}) + +test_that("uses S3 dim method", { + local_bindings( + dim.testthat_expect_shape = function(x) 1:2, + .env = globalenv() + ) + x <- structure(integer(), class = "testthat_expect_shape") + expect_success(expect_shape(x, dim = 1:2)) +}) + +test_that("NA handling (e.g. dbplyr)", { + local_bindings( + dim.testthat_expect_shape_missing = function(x) c(NA_integer_, 10L), + .env = globalenv() + ) + x <- structure(integer(), class = "testthat_expect_shape_missing") + + expect_success(expect_shape(x, nrow = NA_integer_)) + expect_success(expect_shape(x, ncol = 10L)) + expect_success(expect_shape(x, dim = c(NA_integer_, 10L))) + + expect_snapshot_failure(expect_shape(x, nrow = 10L)) + expect_snapshot_failure(expect_shape(x, ncol = NA_integer_)) + expect_snapshot_failure(expect_shape(x, dim = c(10L, NA_integer_))) +}) + +test_that("uses S4 dim method", { + A <- setClass("ExpectShapeA", slots = c(x = "numeric", y = "numeric")) + setMethod("dim", "ExpectShapeA", function(x) 8:10) + expect_success(expect_shape(A(x = 1:9, y = 3), dim = 8:10)) +}) + +test_that("checks inputs arguments, ", { + expect_snapshot(error = TRUE, { + expect_shape(1:10) + expect_shape(1:10, nrow = 1L, ncol = 2L) + expect_shape(1:10, 2) + expect_shape(array(1), nrow = "x") + expect_shape(array(1), ncol = "x") + expect_shape(array(1), dim = "x") + }) +})