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 suggestion / idea] parameterized tests ? #111

Open
eddelbuettel opened this issue Dec 27, 2022 · 10 comments
Open

[feature suggestion / idea] parameterized tests ? #111

eddelbuettel opened this issue Dec 27, 2022 · 10 comments

Comments

@eddelbuettel
Copy link
Contributor

eddelbuettel commented Dec 27, 2022

Would it be useful to borrow some ideas from other languages and test framework to automagically loop over sets of parameters? Python's has this 'parametrize' for pytest which has been pointed out as useful. Is that something we can borrow?

@eddelbuettel
Copy link
Contributor Author

Also: https://github.com/google/patrick which seems to do this for testthat

@michaelquinn32
Copy link

In principle, it shouldn't be a problem to create a fork of patrick to work with tinytest instead of testthat; let's call it tinypatrick. Generally speaking, patrick is a very simple package, generating test cases within a loop. It doesn't seem like tinytest use that concept, which is fine, but we would need to understand

  • What happens when assertions run in a loop
  • If failures should early exit
  • If failures still print meaningful values
  • What the most meaningful level of a loop might be; should we introduce something like a test case or should it happen at the file level

Feel free to explore if you've got the time. Otherwise, I might be able to put something together soon.

Best wishes,
Michael

@eddelbuettel
Copy link
Contributor Author

Thanks so much for piping in here!

tinytest works quite well via extensions, I once did one myself (ttdo) a while back. And I had forgotten too (but kicked the can a little) that @vincentarelbundock had put something in to allow mulitple extensions at once. And while I have to admit that I have not yet looked in detail into patrick, I noticed that it is short-ish in its main function which is encouraging :)

R being R, and allowing for so many tricks computing on the table of which @markvanderloo has deployed quite a few I have the feeling that is both something we all should do, and could. Then again, takes someone with an itch to scratch and a bit of time...

I'll let @markvanderloo answer the mechanics. There is something truly clever going on which is describes in a pair of papers at his arXiv page. Maybe we find a way to "shimmy" the implicit loop in there.

@markvanderloo
Copy link
Owner

markvanderloo commented Dec 29, 2022

I think that you can already do much of this with little effort because it is possible to program over tests.

For example, you can already do this in tinytest:

addOne <- function(n) n + 1

inputs <- 1:5
outputs <- 2:6
mapply(expect_equal, addOne(inputs), outputs)

Or, you can explicitly loop over tests, as for example in this test file from the wand package. Here, Bob creates a list of input-output pairs and loops over that.

But I might be misunderstanding the question here, please tell me if I do.

@eddelbuettel
Copy link
Contributor Author

Yes, I also explicitly loop in some test scripts. I think the idea would be to show / document / help. The mapply example is compelling, question is about generalizing to non-scalar / non-simple results. However, as mapply can consume lists maybe this is moot ... or just a question of a new section in the vignette?

@markvanderloo
Copy link
Owner

Yes, it would be a good idea to document 'parameterized tests' in the vignette. Perhaps in the 'tinytest by example' vignette. (I also should move those to simplermarkdown)

@eddelbuettel
Copy link
Contributor Author

One problem with the mapply example is that the feedback in case of error is a little convoluted. If I replace one of the output values with NA I get a fairly convoluted call stack:

$ tt.r -f param.R     ## tt.r is a simple littler wrapper, using run_test_file with -f file
param.R.......................    5 tests 1 fails 52ms
----- FAILED[data]: param.R<8--8>
 call| mapply(expect_equal, addOne(inputs), outputs)
 call| -->c("(function (...) ", "{", "    out <- fun(...)", "    if (inherits(out, \"tinytest\")) {", "        attr(out, \"file\") <- env$file", "        attr(out, \"fst\") <- env$fst", "        attr(out, \"lst\") <- env$lst", "        attr(out, \"call\") <- env$call", "        attr(out, \"trace\") <- sys.calls()", "        if (!is.na(out) && env$lst - env$fst >= 3) ", "            attr(out, \"call\") <- match.call(fun)", "        env$add(out)", "        attr(out, \"env\") <- env", "    }", "    out", "})(dots[[1]][[5]], dots[[2]][[5]])"
 call| )
 diff| Expected 'NA', got '5'
 
Showing 1 out of 5 results: 1 fails, 4 passes (52ms)
$ 

Maybe that's an auxiliary problem and one that's not easy to avoid

@eddelbuettel
Copy link
Contributor Author

Next question: Michael has a nice and compact 'trigonometrics' example on the README.md of patrick (and in the docs). Foregoing the tribble, we can build a simple list of (a)lists:

ll <- list(alist("sin", sin(pi/4), 1/sqrt(2)),       
           alist("cos", cos(pi/4), 1/sqrt(2)),        
           alist("tan", tan(pi/4), 1)) 

A very pedestrian of way of working with this is looping and evaluating:

eval_expect_equal <- function(ll) {
    for (l in ll) {
        v <- eval(l[[2]])
        w <- eval(l[[3]])
        val <- expect_equal(v, w, info=l[[1]])
    }
}
eval_expect_equal(ll)

which gets us the usual aggregation:

$ tt.r -f /tmp/ttdo-prs/param.R 
param.R.......................    3 tests OK 41ms
All ok, 3 results (41ms)
$

Can you think of simpler / nicer / better way to unrool that we could 'hide' in a helper function (to which we may pass the predicate, expect_equal, too?

@michaelquinn32
Copy link

This is pretty similar to the interface in patrick. We should be able to do basically the same thing, without tidy* helpers.

with_parameters <- function(assertions, ..., .calling_env = parent.frame()) {
  params <- list(...)
  results <- vector("list", length(params))
  for (i in seq_along(params)) {
    results[[i]] <- eval(
      substitute(assertions),
      params[[i]],
      enclos = .calling_env
    )
  }
  results
}

x <- 3

with_parameters(
  {
    tinytest::expect_equal(expr, numeric_value, info = info)
  },
  sin = list(expr = sin(pi / 4) - x, numeric_value = 1 / sqrt(2) - 3, info = "sin"),
  cos = list(expr = cos(pi / 4), numeric_value = 1 / sqrt(2), info = "cos"),
  tan = list(expr = tan(pi / 4), numeric_value = 1, info = "tan")
)

#> [[1]]
#> ----- PASSED      : <-->
#>   call| eval(substitute(assertions), params[[i]], enclos = .calling_env)
#> info| sin 
#> 
#> [[2]]
#> ----- PASSED      : <-->
#>   call| eval(substitute(assertions), params[[i]], enclos = .calling_env)
#> info| cos 
#> 
#> [[3]]
#> ----- PASSED      : <-->
#>   call| eval(substitute(assertions), params[[i]], enclos = .calling_env)
#> info| tan  

This doesn't play especially nice with the current expression parsing in tinytest, but I think it's something that could be fixed.

@eddelbuettel
Copy link
Contributor Author

I should putz around more "on the language". I suspected I could help myself with a reference to parent.env. This is gettting somewhere -- very nice second step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants