-
Notifications
You must be signed in to change notification settings - Fork 339
Improved composition #2250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improved composition #2250
Conversation
`expect_named()` and `expect_output()` need to always return the input value, even if they use some subexpectation. To make this work, expectation components now only ever fail (never pass) and return TRUE or FALSE. Fixes `expect_named()` and `expect_output()` now return different outputs Fixes #2246
This comment was marked as outdated.
This comment was marked as outdated.
@DavisVaughan can you please take a fairly deep look at this? I think it's probably easier to look at the flow of the new versions, rather than inspecting the diffs. Assuming that this looks good to you, I'll merge this PR and then rewrite all the expectations to follow this new pattern. I do think separating the return value (which is now always It also leads to a less confusing failure when you have a sequence of expectations. library(testthat)
test_that("", {
1:10 |>
expect_type("character") |>
expect_length(10)
})
# BEFORE
#> ── Failure: ────────────
#> Expected `1:10` to have type "character".
#> Actual type: "integer"
#> ── Failure: ────────────
#> Expected `expect_type(1:10, "character")` to have length 10.
#> Actual length: 3.
#> Error:
#> ! Test failed with 2 failures and 0 successes.
# AFTER
#> ── Failure: ────────────
#> Expected `1:10` to have type "character".
#> Actual type: "integer"
#> Error:
#> ! Test failed with 1 failure and 1 success. |
I updated a few more — early returns now feel pretty suboptimal, so I only used them when it really saves a bunch of indenting. (But even there it might be better to create a helper function) |
I've convinced myself that this is a good idea so I'm converting all expectations. @DavisVaughan it would still be super useful for you to take a look at few expectations and critique the style. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this all looks great, with the main outcomes being
-
We learned
pass()
andfail()
should be used purely for their side effects. In practice, this makes their usages very symmetric looking, which is very appealing to read. -
We learned (nearly) all expectations should return
invisible(act$val)
. This makes the return value orthogonal frompass()
andfail()
which feels very correct to me.
My main comments in the code would be:
-
I like seeing
pass()
in the if branch andfail()
in the else branch (unless it is a series of fail, fail, fail, pass) and there are a lot of places we don't do that. Up to you. -
Consider what side effecty helpers like
expect_compare_()
should return. I'd argue nothing - they are extensions ofpass()
andfail()
and should be used purely for their side effects. Could even be renamedpass_fail_compare()
to make this clear. Main argument against this wasexpect_message()
withexpect_condition_matching_()
but I do not think you should let that 1 case stop you
@@ -1,5 +1,6 @@ | |||
# testthat (development version) | |||
|
|||
* Expectations now consistently return the value of the first argument, regardless of whether the expectation succeeds or fails. The primary exception are `expect_message()` and friends which will return the condition. This shouldn't affect existing tests, but will make failures clearer when you chain together multiple expectations (#2246). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* Expectations now consistently return the value of the first argument, regardless of whether the expectation succeeds or fails. The primary exception are `expect_message()` and friends which will return the condition. This shouldn't affect existing tests, but will make failures clearer when you chain together multiple expectations (#2246). | |
* Expectations now consistently return the value of the first argument, regardless of whether the expectation succeeds or fails. The primary exceptions are `expect_message()` and friends which will return the condition. This shouldn't affect existing tests, but will make failures clearer when you chain together multiple expectations (#2246). |
call = trace_env | ||
) | ||
} | ||
if (!isTRUE(cmp)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm totally with you on no longer wanting to do an early return anymore
Like, I think seeing an if/else construct where one branch ends in a pass()
and one branch ends in a fail()
could be a defining feature of all of these expectations.
In other words, I prefer this version of expect_compare_()
much more
expect_compare_ <- function(
operator = c("<", "<=", ">", ">="),
act,
exp,
trace_env = caller_env()
) {
operator <- match.arg(operator)
op <- match.fun(operator)
actual_op <- switch(operator, "<" = ">=", "<=" = ">", ">" = "<=", ">=" = "<")
cmp <- op(act$val, exp$val)
if (length(cmp) != 1 || !is.logical(cmp)) {
cli::cli_abort(
"Result of comparison must be a single logical value.",
call = trace_env
)
}
if (isTRUE(cmp)) {
pass()
} else {
diff <- act$val - exp$val
msg_exp <- sprintf("Expected %s %s %s.", act$lab, operator, exp$lab)
digits <- max(
digits(act$val),
digits(exp$val),
min_digits(act$val, exp$val)
)
msg_act <- sprintf(
"Actual comparison: %s %s %s",
num_exact(act$val, digits),
actual_op,
num_exact(exp$val, digits)
)
if (is.na(diff)) {
msg_diff <- NULL
} else {
msg_diff <- sprintf(
"Difference: %s %s 0",
num_exact(diff, digits),
actual_op
)
}
fail(c(msg_exp, msg_act, msg_diff), trace_env = trace_env)
}
invisible(act$val)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was thinking that instead of an early return, pulling out a helper to generate the failure message would be the way to go.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea everything except the fail(), i.e. this bit
diff <- act$val - exp$val
msg_exp <- sprintf("Expected %s %s %s.", act$lab, operator, exp$lab)
digits <- max(
digits(act$val),
digits(exp$val),
min_digits(act$val, exp$val)
)
msg_act <- sprintf(
"Actual comparison: %s %s %s",
num_exact(act$val, digits),
actual_op,
num_exact(exp$val, digits)
)
if (is.na(diff)) {
msg_diff <- NULL
} else {
msg_diff <- sprintf(
"Difference: %s %s 0",
num_exact(diff, digits),
actual_op
)
}
if (!is.null(msg)) { | ||
return(fail(msg, info = info, trace = act$cap[["trace"]])) | ||
fail(msg, info = info, trace = act$cap[["trace"]]) | ||
} else { | ||
pass() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My brain really wants
if (is.null(msg)) {
pass()
} else {
fail(msg, info = info, trace = act$cap[["trace"]])
}
i.e. always pass()
first then fail()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm actually starting to think it pass()
should always be last because if there are multiple conditions that need to be satisfied in order to pass, you want to check each of them in turn.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like seeing pass() in the if branch and fail() in the else branch (unless it is a series of fail, fail, fail, pass) and there are a lot of places we don't do that. Up to you.
yea i noted that as my exception here
return(fail(msg, info = info)) | ||
fail(msg, info = info) | ||
} else { | ||
pass() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flip pass fail order?
return(fail(msg)) | ||
)) | ||
} else { | ||
pass() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flip pass fail order?
) | ||
return(fail(msg)) | ||
} | ||
pass() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty elegant fail, fail, fail, pass fallthrough
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is why!
pass() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expect_file_unchanged_()
is a good example of what I was saying where I thought these functions could be used for their side effects but not their return values. i.e. caller is in charge of returning invisible(act$val)
which feels right to me
#' @param message Check that the failure message matches this regexp. | ||
#' @param ... Other arguments passed on to [expect_match()]. | ||
#' @export | ||
expect_success <- function(expr) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this supposed to return? And expect_failure()
? Nothing I guess? There's not really an actual
input?
check_dots_empty() | ||
check_exclusive(nrow, ncol, dim) | ||
act <- quasi_label(enquo(object)) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expect_shape()
is a place where it would be worth considering extracting out expect_shape_()
that is purely about the side effect part.
It may make the early returns simpler because you don't have to worry about consistently returning invisible(act$val)
in 3 separate places
Something like
expect_shape = function(object, ..., nrow, ncol, dim) {
check_dots_empty()
check_exclusive(nrow, ncol, dim)
act <- quasi_label(enquo(object))
expect_shape_(act, ...)
invisible(act$val)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tried this (since I think it was worth a shot) but I don't think it nets out as much of an improvement — it simplifies the early returns but you now have to pass trace_env
to fail()
and call
to check_...
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But I eliminated the early returns another way.
#' act_n <- length(act$val) | ||
#' if (act_n != n) { | ||
#' msg <- sprintf("%s has length %i, not length %i.", act$lab, act_n, n) | ||
#' return(fail(msg)) | ||
#' fail(sprintf("%s has length %i, not length %i.", act$lab, act_n, n)) | ||
#' } else { | ||
#' pass() | ||
#' } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For an example, I think it is probably better practice to show the pass()
first then the fail()
?
I can't quite describe why that feels better to me, but it does. Happy path first?
expect_named()
andexpect_output()
need to always return the input value, even if they use some subexpectation.To make this work, expectation components now only ever fail (never pass) and return TRUE or FALSE.
Fixes
expect_named()
andexpect_output()
now return different outputsFixes #2246