diff --git a/NAMESPACE b/NAMESPACE index 8a3200163..7a1af9fe4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -110,6 +110,7 @@ export(expect_reference) export(expect_s3_class) export(expect_s4_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 23cedc936..c15c47fd3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,8 @@ # testthat (development version) +* New expectation, `expect_shape()`, for testing the shape (i.e., the `length()`, + `nrow()` and/or `ncol()`, or `dim()`, all in one place (#1423, @michaelchirico). + # testthat 3.1.0 ## Snapshot tests diff --git a/R/expect-length.R b/R/expect-length.R index 0b8d09285..a64643dea 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 @@ -25,3 +26,80 @@ expect_length <- function(object, n) { invisible(act$val) } + +#' Does code return an object with the specified shape? +#' +#' By "shape", we mean an object's [dim()], or, for one-dimensional objects, +#' it's [length()]. Thus this is an extension of [expect_length()] to more +#' general objects like [data.frame()], [matrix()], and [array()]. +#' To wit, first, the object's `dim()` is checked. If non-`NULL`, it is compared +#' to `shape` (or one/both of `nrow`, `ncol`, if they are supplied, in which +#' case they take precedence). If `dim(object)` is `NULL`, `length(object)` +#' is compared to `shape`. +#' +#' @seealso [expect_length()] to specifically make assertions about the +#' [length()] of a vector. +#' @inheritParams expect_that +#' @param shape Expected shape, an integer vector. +#' @param nrow Expected number of rows, numeric. +#' @param ncol Expected number of columns, numeric. +#' @family expectations +#' @export +#' @examples +expect_shape = function(object, shape, nrow, ncol) { + stopifnot( + missing(shape) || is.numeric(shape), + missing(nrow) || is.numeric(nrow), + missing(ncol) || is.numeric(ncol) + ) + + dim_object <- dim(object) + if (is.null(dim_object)) { + if (missing(shape)) { + stop("`shape` must be provided for one-dimensional inputs") + } + return(expect_length(object, shape)) + } + + act <- quasi_label(enquo(object), arg = "object") + + if (missing(nrow) && missing(ncol)) { + # testing dim + if (missing(shape)) { + stop("`shape` must be provided if `nrow` and `ncol` are not") + } + act$shape <- dim_object + + expect( + isTRUE(all.equal(act$shape, shape)), + sprintf("%s has shape (%s), not (%s).", act$lab, toString(act$shape), toString(shape)) + ) + } else if (missing(nrow) && !missing(ncol)) { + # testing only ncol + act$ncol <- dim_object[2L] + + expect( + act$ncol == ncol, + sprintf("%s has %i columns, not %i.", act$lab, act$ncol, ncol) + ) + } else if (!missing(nrow) && missing(ncol)) { + # testing only nrow + act$nrow <- dim_object[1L] + + expect( + act$nrow == nrow, + sprintf("%s has %i rows, not %i.", act$lab, act$nrow, nrow) + ) + } else { + # testing both nrow & ncol (useful, e.g., for testing dim(.)[1:2] for arrays + act$nrow <- dim_object[1L] + act$ncol <- dim_object[2L] + + expect( + act$nrow == nrow && act$ncol == ncol, + sprintf("%s has %i rows and %i columns, not %i rows and %i columns", act$lab, act$nrow, act$ncol, nrow, ncol) + ) + } + + return(act$val) +} diff --git a/man/expect_shape.Rd b/man/expect_shape.Rd new file mode 100644 index 000000000..e59b827b8 --- /dev/null +++ b/man/expect_shape.Rd @@ -0,0 +1,46 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/expect-length.R +\name{expect_shape} +\alias{expect_shape} +\title{Does code return an object with the specified shape?} +\usage{ +expect_shape(object, shape, nrow, ncol) +} +\arguments{ +\item{object}{Object to test. + + Supports limited unquoting to make it easier to generate readable failures + within a function or for loop. See [quasi_label] for more details.} + +\item{shape}{Expected shape, an integer vector.} +\item{nrow}{Expected number of rows, numeric.} +\item{ncol}{Expected number of columns, numeric.} +} +\description{ +By "shape", we mean an object's [dim()], or, for one-dimensional objects, + it's [length()]. Thus this is an extension of [expect_length()] to more + general objects like [data.frame()], [matrix()], and [array()]. +To wit, first, the object's `dim()` is checked. If non-`NULL`, it is compared + to `shape` (or one/both of `nrow`, `ncol`, if they are supplied, in which + case they take precedence). If `dim(object)` is `NULL`, `length(object)` + is compared to `shape`. +} +\seealso{ +[expect_length()] to specifically make assertions about the + [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/tests/testthat/test-expect-shape.R b/tests/testthat/test-expect-shape.R new file mode 100644 index 000000000..bee3e4908 --- /dev/null +++ b/tests/testthat/test-expect-shape.R @@ -0,0 +1,51 @@ +test_that("shape computed correctly", { + # equivalent to expect_length + expect_success(expect_shape(1, 1)) + expect_failure(expect_shape(1, 2), "has length 1, not length 2.") + expect_success(expect_shape(1:10, 10)) + expect_success(expect_shape(letters[1:5], 5)) + + # testing dim() + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), c(5L, 4L))) + expect_failure(expect_shape(matrix(nrow = 6, ncol = 3), c(6L, 2L))) + expect_failure(expect_shape(matrix(nrow = 6, ncol = 3), c(7L, 3L))) + expect_success(expect_shape(data.frame(1:10, 11:20), c(10, 2))) + expect_success(expect_shape(array(dim = 1:3), 1:3)) + + # testing nrow= + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), nrow = 5L)) + expect_failure(expect_shape(matrix(nrow = 5, ncol = 5), nrow = 6L)) + expect_success(expect_shape(data.frame(1:10, 11:20), nrow = 10L)) + + # testing ncol= + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), ncol = 4L)) + expect_failure(expect_shape(matrix(nrow = 5, ncol = 5), ncol = 7L)) + expect_success(expect_shape(data.frame(1:10, 11:20), ncol = 2L)) + + # testing nrow= and ncol= + expect_success(expect_shape(matrix(nrow = 5, ncol = 4), nrow = 5L, ncol = 4L)) + expect_failure(expect_shape(matrix(nrow = 5, ncol = 5), nrow = 6L, ncol = 5L)) + expect_success(expect_shape(data.frame(1:10, 11:20), nrow = 10L, ncol = 2L)) + expect_success(expect_shape(array(dim = 5:7), nrow = 5L, ncol = 6L)) + + # precedence of manual nrow/ncol over shape + expect_success(expect_shape(matrix(nrow = 7, ncol = 10), 1:2, nrow = 7L)) + expect_success(expect_shape(matrix(nrow = 7, ncol = 10), 1:2, ncol = 10L)) +}) + +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), 8:10)) +}) + +test_that("returns input", { + x <- list(1:10, letters) + out <- expect_shape(x, 2) + expect_identical(out, x) +}) + +test_that("at least one argument is required", { + expect_error(expect_shape(1:10), "`shape` must be provided for one-dimensional inputs", fixed = TRUE) + expect_error(expect_shape(cbind(1:2)), "`shape` must be provided if `nrow` and `ncol` are not") +})