Skip to content
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

Feature Enhancement: Minimize duplicated recorded tests #562

Merged
merged 6 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Encoding: UTF-8
Package: covr
Title: Test Coverage for Packages
Version: 3.6.4.9001
Version: 3.6.4.9003
Authors@R: c(
person("Jim", "Hester", email = "[email protected]", role = c("aut", "cre")),
person("Willem", "Ligtenberg", role = "ctb"),
Expand Down
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# covr (development version)

* Prevent `covr.record_tests` option from logging duplicate tests when the same
line of testing code is hit repeatedly, as in a loop. (@dgkf, #528)

* Added support for `klmr/box` modules. This works best with `file_coverage()`. (@radbasa, #491)

# covr 3.6.4
Expand Down
140 changes: 102 additions & 38 deletions R/trace_tests.R
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
#'
#' \item `$<srcref>$tests`: For each srcref count in the coverage object, a
#' `$tests` field is now included which contains a matrix with three columns,
#' "test", "depth" and "i" which specify the test number (corresponding to the
#' index of the test in `attr(,"tests")`, the stack depth into the target
#' code where the trace was executed, and the order of execution for each
#' test.
#' "test", "call", "depth" and "i" which specify the test number
#' (corresponding to the index of the test in `attr(,"tests")`, the number
#' of times the test expression was evaluated to produce the trace hit, the
#' stack depth into the target code where the trace was executed, and the
#' order of execution for each test.
#' }
#'
#' @section Test traces:
Expand Down Expand Up @@ -68,23 +69,23 @@
#' # f(!x)
#' #
#' # $tests
#' # test depth i
#' # [1,] 1 2 4
#' # test call depth i
#' # [1,] 1 1 2 4
Comment on lines -71 to +73
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A change to the structure of the <coverage>[[<key>]]$tests elements.

Adds a call column, which is the number of times the test expression was evaluated before hitting this trace. It's not used within covr, but since the same test now represents multiple calls it is useful for distinguishing test evaluations useful for downstream tooling.

#'
#' # reconstruct the code path of a test by ordering test traces by [,"i"]
#' lapply(cov, `[[`, "tests")
#' # $`source.Ref2326138c55:4:6:4:10:6:10:4:4`
#' # test depth i
#' # [1,] 1 1 2
#' # test call depth i
#' # [1,] 1 1 1 2
#' #
#' # $`source.Ref2326138c55:3:8:3:8:8:8:3:3`
#' # test depth i
#' # [1,] 1 1 1
#' # [2,] 1 2 3
#' # test call depth i
#' # [1,] 1 1 1 1
#' # [2,] 1 1 2 3
#' #
#' # $`source.Ref2326138c55:6:6:6:10:6:10:6:6`
#' # test depth i
#' # [1,] 1 2 4
#' # test call depth i
#' # [1,] 1 1 2 4
#'
#' @name covr.record_tests
NULL
Expand All @@ -110,17 +111,20 @@ count_test <- function(key) {
tests <- .counters[[key]]$tests
n <- NROW(tests$tally)
if (.counters[[key]]$value > n) {
tests$tally <- rbind(tests$tally, matrix(NA_integer_, ncol = 3L, nrow = n))
tests$tally <- rbind(tests$tally, matrix(NA_integer_, ncol = 4L, nrow = n))
}

# test number
tests$.data[[1L]] <- length(.counters$tests)
tests$.data[[1L]] <- .current_test$index

# test call number (for test expressions that are called multiple times)
tests$.data[[2L]] <- .current_test$call_count

# call stack depth when trace is hit
tests$.data[[2L]] <- sys.nframe() - length(.current_test$frames) - n_calls_into_covr + 1L
tests$.data[[3L]] <- sys.nframe() - length(.current_test$frames) - n_calls_into_covr + 1L

# number of traces hit by the test so far
tests$.data[[3L]] <- .current_test$i
tests$.data[[4L]] <- .current_test$i

tests$.value <- .counters[[key]]$value
with(tests, tally[.value,] <- .data)
Expand All @@ -142,15 +146,15 @@ count_test <- function(key) {
#'
new_test_counter <- function(key) {
.counters[[key]]$tests <- new.env(parent = baseenv())
.counters[[key]]$tests$.data <- vector("integer", 3L)
.counters[[key]]$tests$.data <- vector("integer", 4L)
.counters[[key]]$tests$.value <- integer(1L)
.counters[[key]]$tests$tally <- matrix(
NA_integer_,
ncol = 3L,
ncol = 4L,
# initialize with 4 empty rows, only expanded once populated
nrow = 4L,
# cols: test index; call stack depth of covr:::count; execution order index
dimnames = list(c(), c("test", "depth", "i"))
# cols: test index; call index; call stack depth of covr:::count; execution order index
dimnames = list(c(), c("test", "call", "depth", "i"))
)
}

Expand Down Expand Up @@ -213,38 +217,100 @@ update_current_test <- function() {
has_srcref,
.current_test$trace,
right = TRUE,
nomatch = length(exec_frames))]]
nomatch = length(exec_frames)
)]]

# might be NULL if srcrefs aren't kept during building / sourcing
.current_test$src_env <- sys.frame(which = .current_test$last_frame)
.current_test$src_env <- sys.frame(which = .current_test$last_frame - 1L)
Comment on lines -219 to +224
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the calling frame instead of the evaluation frame is one way that this PR cuts down on unnecessary duplication of tests.

.current_test$src_call <- syscalls[[.current_test$last_frame]]
.current_test$srcref <- getSrcref(.current_test$src_call)
.current_test$src <- .current_test$srcref %||% .current_test$src_call

# build test data to store within .counters
test <- list(.current_test$trace)

# only name if srcrefs can be determined
if (inherits(.current_test$src, "srcref")) {
names(test) <- file.path(
dirname(get_source_filename(.current_test$src, normalize = TRUE)),
key(.current_test$src))
}
.current_test$key <- current_test_key()
.current_test$index <- current_test_index()
.current_test$call_count <- current_test_call_count()

# NOTE: r-bugs 18348
# restrict test call lengths to avoid R Rds deserialization limit
# https://bugs.r-project.org/show_bug.cgi?id=18348
max_call_len <- 1e4
call_lengths <- vapply(test[[1L]], length, numeric(1L))
call_lengths <- vapply(.current_test$trace, length, numeric(1L))
if (any(call_lengths > max_call_len)) {
test[[1L]] <- lapply(test[[1L]], truncate_call, limit = max_call_len)
.current_test$trace <- lapply(
.current_test$trace,
truncate_call,
limit = max_call_len
)

warning("A large call was captured as part of a test and will be truncated.")
}

.counters$tests <- append(.counters$tests, test)
.counters$tests[[.current_test$index]] <- .current_test$trace
attr(.counters$tests[[.current_test$index]], "call_count") <- .current_test$call_count
names(.counters$tests)[[.current_test$index]] <- .current_test$key
}

#' Build key for the current test
#'
#' If the current test has a srcref, a unique character key is built from its
#' srcref. Otherwise, an empty string is returned.
#'
#' @return A unique character string if the test call has a srcref, or an empty
#' string otherwise.
#'
#' @keywords internal
current_test_key <- function() {
if (!inherits(.current_test$src, "srcref")) return("")
file.path(
dirname(get_source_filename(.current_test$src, normalize = TRUE)),
key(.current_test$src)
)
}

#' Retrieve the index for the test in `.counters$tests`
#'
#' If the test was encountered before, the index will be the index of the test
#' in the logged tests list. Otherwise, the index will be the next index beyond
#' the length of the tests list.
#'
#' @return An integer index for the test call
#'
#' @keywords internal
current_test_index <- function() {
# check if test has already been encountered and reuse test index
if (inherits(.current_test$src, "srcref")) {
# when tests have srcrefs, we can quickly compare test keys
match(
.current_test$key,
names(.counters$tests),
nomatch = length(.counters$tests) + 1L
)
} else {
# otherwise we compare call stacks
Position(
function(t) identical(t[], .current_test$trace), # t[] to ignore attr
.counters$tests,
right = TRUE,
nomatch = length(.counters$tests) + 1L
)
}
}
Comment on lines +279 to +297
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the current test matches an already logged test, then we reuse that index. Otherwise it's added to a growing list of recorded test expressions.

New tests are distinguished by comparing the srcref "key" to any previous test keys if the test code has known srcrefs, or otherwise looking for identical call stacks.

I was expecting call stack comparisons like this to come with a big performance hit, but it ended up being pretty minor.


#' Retrieve the number of times the test call was called
#'
#' A single test expression might be evaluated many times. Each time the same
#' expression is called, the call count is incremented.
#'
#' @return An integer value representing the number of calls of the current
#' call into the package from the testing suite.
#'
current_test_call_count <- function() {
if (.current_test$index <= length(.counters$tests)) {
attr(.counters$tests[[.current_test$index]], "call_count") + 1L
} else {
1L
}
}

#' Truncate call objects to limit the number of arguments
#'
Expand All @@ -263,8 +329,6 @@ truncate_call <- function(call_obj, limit = 1e4) {
call_obj
}



#' Returns TRUE if we've moved on from test reflected in .current_test
#'
#' Quickly dismiss the need to update the current test if we can. To test if
Expand All @@ -277,7 +341,7 @@ is_current_test_finished <- function() {
is.null(.current_test$src) ||
.current_test$last_frame > sys.nframe() ||
!identical(.current_test$src_call, sys.call(which = .current_test$last_frame)) ||
!identical(.current_test$src_env, sys.frame(which = .current_test$last_frame))
!identical(.current_test$src_env, sys.frame(which = .current_test$last_frame - 1L))
}

#' Is the source bound to the expression
Expand Down
27 changes: 14 additions & 13 deletions man/covr.record_tests.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions man/current_test_call_count.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions man/current_test_index.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions man/current_test_key.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions tests/testthat/test-record_tests.R
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ test_that("covr.record_tests causes test traces to be recorded", {


test_that("covr.record_tests records test indices and depth for each trace", {
expect_equal(ncol(cov_func[[1]]$tests), 3L)
expect_equal(colnames(cov_func[[1]]$tests), c("test", "depth", "i"))
expect_equal(ncol(cov_func[[1]]$tests), 4L)
expect_equal(colnames(cov_func[[1]]$tests), c("test", "call", "depth", "i"))
})


Expand Down Expand Up @@ -205,3 +205,19 @@ test_that("covr.record_tests: safely handles extremely large calls", {
}

})

test_that("covr.record_tests: records multiple calls to the same test expr", {
fcode <- 'f1 <- function(...) "hello, world"; f2 <- function() c(1, 2, 3)'

withr::with_options(c("covr.record_tests" = TRUE), {
cov <- code_coverage(fcode, "for (i in 1:3) with(new.env(), { f1(); f2() })")
})

trace_f1 <- which(vapply(cov, `[[`, character(1L), "functions") == "f1")
expect_equal(cov[[trace_f1]]$tests[, "test"], c(1, 1, 1))
expect_equal(cov[[trace_f1]]$tests[, "call"], c(1, 2, 3))

trace_f2 <- which(vapply(cov, `[[`, character(1L), "functions") == "f2")
expect_equal(cov[[trace_f2]]$tests[, "test"], c(2, 2, 2))
expect_equal(cov[[trace_f2]]$tests[, "call"], c(1, 2, 3))
})
Loading