Skip to content

Commit 29018e0

Browse files
authored
Better srcrefs on failure/error (#1842)
Uses a better strategy based on containment of srcrefs — we first contain to the current testing file, and if possible the current `test_that()` block. That ensures you always get a srcref inside your tests and never to some helper function inside your package (which will be included in the backtrace if needed).
1 parent 4c6bf28 commit 29018e0

19 files changed

+239
-150
lines changed

NEWS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# testthat (development version)
22

3+
* testthat uses an improved algorithm for finding the srcref associated with
4+
an expectation/error/warning/skip. It now looks for the most recent call
5+
that has known source and is found inside the `test_that()` call. This
6+
generally gives more specific locations than the previous approach and
7+
gives much better locations if an error occurs in an exit handler.
8+
39
* Helpers should no longer be run twice.
410

511
* `test_file()` gains a `desc` argument which allows you to run a single

R/expectation.R

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,12 @@ single_letter_summary <- function(x) {
249249
)
250250
}
251251

252-
expectation_location <- function(x) {
253-
if (!inherits(x$srcref, "srcref")) {
254-
"???"
255-
} else {
256-
srcfile <- attr(x$srcref, "srcfile")
257-
filename <- srcfile$filename
258-
if (identical(filename, "")) {
259-
paste0("Line ", x$srcref[1])
260-
} else {
261-
cli::format_inline("{.file {filename}:{x$srcref[1]}:{x$srcref[2]}}")
262-
}
252+
expectation_location <- function(x, prefix = "", suffix = "") {
253+
srcref <- x$srcref
254+
if (!inherits(srcref, "srcref")) {
255+
return("")
263256
}
257+
258+
filename <- attr(srcref, "srcfile")$filename
259+
cli::format_inline("{prefix}{.file {filename}:{srcref[1]}:{srcref[2]}}{suffix}")
264260
}

R/reporter-junit.R

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ JunitReporter <- R6::R6Class("JunitReporter",
111111
)
112112

113113
first_line <- function(x) {
114-
loc <- expectation_location(x)
115-
paste0(strsplit(x$message, split = "\n")[[1]][1], " (", loc, ")")
114+
loc <- expectation_location(x, " (", ")")
115+
paste0(strsplit(x$message, split = "\n")[[1]][1], loc)
116116
}
117117

118118
# add an extra XML child node if not a success

R/reporter-progress.R

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,8 +519,7 @@ issue_header <- function(x, pad = FALSE) {
519519
type <- strpad(type, 7)
520520
}
521521

522-
loc <- expectation_location(x)
523-
paste0(type, " (", loc, "): ", x$test)
522+
paste0(type, expectation_location(x, " (", ")"), ": ", x$test)
524523
}
525524

526525
issue_summary <- function(x, rule = FALSE) {

R/reporter-stop.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ StopReporter <- R6::R6Class("StopReporter",
5353
},
5454
stop_if_needed = function() {
5555
if (self$stop_reporter && self$n_fail > 0) {
56-
abort("Test failed")
56+
abort("Test failed", call = NULL)
5757
}
5858
}
5959
)

R/reporter-summary.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ skip_summary <- function(x, label) {
147147
header <- paste0(label, ". ", x$test)
148148

149149
paste0(
150-
colourise(header, "skip"), " (", expectation_location(x), ") - ", x$message
150+
colourise(header, "skip"), expectation_location(x, " (", ")"), " - ", x$message
151151
)
152152
}
153153

R/source.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ source_file <- function(path,
3838
on.exit(setwd(old_dir), add = TRUE)
3939
}
4040

41-
withr::local_options(testthat_topenv = env)
41+
withr::local_options(testthat_topenv = env, testthat_path = path)
4242
if (wrap) {
4343
invisible(test_code(
4444
test = NULL,

R/srcrefs.R

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
find_expectation_srcref <- function(test_code_frame = NULL, top = caller_env()) {
2+
# It's not possible to give useful srcrefs interactively so don't even try
3+
path <- getOption("testthat_path")
4+
if (is.null(path)) {
5+
return(NULL)
6+
}
7+
8+
# Scope our search to the current file loaded with source_file()
9+
file_srcref <- srcref(srcfile(path), c(1, 1, 1e5, 1e5))
10+
11+
# Now attempt to narrow the scope to a call that leads to test_code(). That's
12+
# usually test_that() but might be describe(), it(), or another wrapper.
13+
testthat_srcref <- find_srcref(
14+
top = test_code_frame,
15+
container = file_srcref
16+
)
17+
18+
# Now we can find the bottom-most call with a srcref that's inside that scope
19+
call_srcref <- find_srcref(
20+
top = top,
21+
bottom = test_code_frame,
22+
container = testthat_srcref %||% file_srcref
23+
)
24+
25+
# If we can't find that we fall back to the test
26+
call_srcref %||% testthat_srcref
27+
}
28+
29+
find_srcref <- function(bottom = NULL,
30+
top = caller_env(),
31+
container = NULL) {
32+
33+
idx <- sys_index(bottom, top)
34+
calls <- sys.calls()[rev(idx)]
35+
36+
for (call in calls) {
37+
srcref <- attr(call, "srcref")
38+
39+
if (!is.null(srcref)) {
40+
if (is.null(container) || srcref_inside(srcref, container)) {
41+
return(srcref)
42+
}
43+
}
44+
}
45+
46+
NULL
47+
}
48+
49+
srcref_inside <- function(needle, haystack) {
50+
stopifnot(inherits(needle, "srcref"), inherits(haystack, "srcref"))
51+
52+
needle_file <- attr(needle, "srcfile")$filename
53+
haystack_file <- attr(haystack, "srcfile")$filename
54+
55+
if (!identical(needle_file, haystack_file)) {
56+
return(FALSE)
57+
}
58+
59+
sign_pair <- function(x, y) {
60+
diff <- y - x
61+
if (diff[1] == 0) sign(diff[2]) else sign(diff[1])
62+
}
63+
64+
sign_pair(needle[1:2], haystack[1:2]) <= 0 &&
65+
sign_pair(needle[3:4], haystack[3:4]) >= 0
66+
}
67+
68+
sys_index <- function(bottom = NULL, top = caller_env()) {
69+
frames <- sys.frames()
70+
if (is.null(bottom)) {
71+
bottom_idx <- 1
72+
} else {
73+
bottom_idx <- Position(function(env) identical(bottom, env), frames)
74+
if (is.na(bottom_idx)) {
75+
abort("Can't find `bottom` on stack")
76+
}
77+
}
78+
79+
top_idx <- Position(function(env) identical(top, env), frames)
80+
if (is.na(top_idx)) {
81+
abort("Can't find `top` on stack")
82+
}
83+
84+
seq2(bottom_idx, top_idx)
85+
}

R/test-that.R

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ test_that <- function(desc, code) {
5959
# `$.Throwable` from the rJava package throws with unknown fields
6060
test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
6161

62+
frame <- caller_env()
6263
reporter <- get_reporter() %||% default_reporter
6364

6465
if (!is.null(test)) {
@@ -71,14 +72,12 @@ test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
7172
# @param debug_end How many frames should be skipped to find the
7273
# last relevant frame call. Only useful for the DebugReporter.
7374
register_expectation <- function(e, debug_end) {
74-
# Find test environment on the stack
75-
start <- eval_bare(quote(base::sys.nframe()), test_env) + 1L
76-
77-
srcref <- e[["srcref"]] %||% find_first_srcref(start)
75+
srcref <- e[["srcref"]] %||% find_expectation_srcref(frame)
7876
e <- as.expectation(e, srcref = srcref)
7977

8078
# Data for the DebugReporter
8179
if (debug_end >= 0) {
80+
start <- eval_bare(quote(base::sys.nframe()), test_env) + 1L
8281
e$start_frame <- start
8382
e$end_frame <- sys.nframe() - debug_end - 1L
8483
}
@@ -89,7 +88,6 @@ test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
8988
reporter$add_result(context = reporter$.context, test = test, result = e)
9089
}
9190

92-
frame <- sys.nframe()
9391
# Any error will be assigned to this variable first
9492
# In case of stack overflow, no further processing (not even a call to
9593
# signalCondition() ) might be possible
@@ -173,15 +171,7 @@ test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
173171
handle_skip <- function(e) {
174172
handled <<- TRUE
175173

176-
if (inherits(e, "skip_empty")) {
177-
# If we get here, `code` has already finished its evaluation.
178-
# Find the srcref in the `test_that()` frame above.
179-
e$srcref <- find_first_srcref(frame - 1)
180-
debug_end <- -1
181-
} else {
182-
debug_end <- 2
183-
}
184-
174+
debug_end <- if (inherits(e, "skip_empty")) -1 else 2
185175
register_expectation(e, debug_end)
186176
signalCondition(e)
187177
}

R/utils.R

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,6 @@ magrittr::`%>%`
44

55
null <- function(...) invisible()
66

7-
# Tools for finding srcrefs -----------------------------------------------
8-
9-
find_first_srcref <- function(start) {
10-
calls <- sys.calls()
11-
calls <- calls[seq2(start, length(calls))]
12-
13-
for (call in calls) {
14-
srcref <- attr(call, "srcref")
15-
if (!is.null(srcref)) {
16-
return(srcref)
17-
}
18-
}
19-
NULL
20-
}
21-
227
escape_regex <- function(x) {
238
chars <- c("*", ".", "?", "^", "+", "$", "|", "(", ")", "[", "]", "{", "}", "\\")
249
gsub(paste0("([\\", paste0(collapse = "\\", chars), "])"), "\\\\\\1", x, perl = TRUE)

0 commit comments

Comments
 (0)