runit-testingdata.tablepackagetestthat

unit tests and checks in package function: do we do checks in both?


I'm a new to R and package development so bear with me. I am writing test cases to keep package is line with standard practices. But I'm confused if I do the checks in testthat, should I not perform if/else checks in the package function?

my_function<-function(dt_genetic, dt_gene, dt_snpBP){

if((is.data.table(dt_genetic) & is.data.table(dt_gene) & is.data.table(dt_snpBP))== FALSE){
stop("data format unacceptable")
}
## similary more checks on column names and such

} ## function ends

In my test-data_integrity.R

## create sample data.table
test_gene_coord<-data.table(GENE=c("ABC","XYG","alpha"),"START"=c(10,200,320),"END"=c(101,250,350))
test_snp_pos<-data.table(SNP=c("SNP1","SNP2","SNP3"),"BP"=c(101,250,350))
test_snp_gene<-data.table(SNP=c("SNP1","SNP2","SNP3"),"GENE"=c("ABC","BRCA1","gamma"))


## check data type

test_that("data types correct works", {
   expect_is(test_data_table,'data.table')
expect_is(test_gene_coord,'data.table')
expect_is(test_snp_pos,'data.table')

expect_is(test_snp_gene,'data.table')
expect_is(test_gene_coord$START, 'numeric')
expect_is(test_gene_coord$END, 'numeric')
expect_is(test_snp_pos$BP, 'numeric')
})

## check column names 

test_that("column names works", {

 expect_named(test_gene_coord, c("GENE","START","END"))
 expect_named(test_snp_pos, c("SNP","BP"))
 expect_named(test_snp_gene, c("SNP","GENE"))

})

when I run devtools::test() all tests are passed, but does it mean that I should not test within my function?

Pardon me if this seems naive but this is confusing as this is completely alien to me.

Edited: data.table if check.


Solution

  • (This is an expansion on my comments on the question. My comments are from a quasi-professional programmer; some of what I say here may be good "in general" but not perfectly complete from a theoretical standpoint.)

    There are many "types" of tests, but I'll focus on distinguishing between "unit-tests" and "assertions". For me, the main difference is that unit-tests are typically run by the developer(s) only, and assertions are run at run-time.

    Assertions

    When you mention adding tests to your function, which to me sounds like assertions: a programmatic statement that an object meets specific property assumptions. This is often necessary when the data is provided by the user or from an external source (database), where the size or quality of the data is previously unknown.

    There are "formal" packages for assertions, including assertthat, assertr, and assertive; while I have little experience with any of them, there is also sufficient support in base R that these aren't strictly required. The most basic method is

    if (!inherits(mtcars, "data.table")) {
      stop("'obj' is not 'data.table'")
    }
    # Error: 'obj' is not 'data.table'
    

    which gives you absolute control at the expense of several lines of code. There's another function which shortens this a little:

    stopifnot(inherits(mtcars, "data.table"))
    # Error: inherits(mtcars, "data.table") is not TRUE
    

    Multiple conditions can be provided, all must be TRUE to pass. (Unlike many R conditionals such as if, this statement must resolve to exactly TRUE: stopifnot(3) does not pass.) In R < 4.0, the error messages were uncontrolled, but starting in R-4.0 one can now name them:

    stopifnot(
      "mtcars not data.frame" = inherits(mtcars, "data.frame"),
      "mtcars data.table error" = inherits(mtcars, "data.table")
    )
    # Error: mtcars data.table error
    

    In some programming languages, these assertions are more declarative/deliberate so that compilation can optimize them out of a production executable. In this sense, they are useful during development, but for production it is assumed that some steps that worked before no longer need validation. I believe there is not an automatic way to do this in R (especially since it is generally not "compiled into an executable"), but one could fashion a function in a way to mimic this behavior:

    myfunc <- function(x, ..., asserts = getOption("run_my_assertions", FALSE)) {
      # this one only runs when the user explicitly says "asserts=TRUE"
      if (asserts) stopifnot("'x' not a data.frame" = inherits(x, "data.frame"))
      # this assertion runs all the time
      stopifnot("'x' not a data.table" = inherits(x, "data.table"))
    }
    

    I have not seen that logic or flow often in R packages.

    Regardless, my assumption of assertions is that those not optimized out (due to compilation or user arguments) execute every time the function runs. This tends to ensure a "safer" flow, and is a good idea especially for less-experienced developers who do not have the experience ("have not been burned enough") to know how many ways certain calls can go wrong.

    Unit Tests

    These are a bit different, both in their purpose and runtime effect.

    First and foremost, unit-tests are not run every time a function is used. They are typically defined in a completely different file, not within the function at all[^1]. They are deliberate sets of calls to your functions, testing/confirming specific behaviors given certain inputs.

    With the testthat package, R scripts (that match certain filename patterns) in the package's ./tests/testthat/ sub-directory will be run on command as unit-tests. (Other unit-test packages exist.) (Unit-tests do not require that they operate on a package; they can be located anywhere, and run on any set of files or directories of files. I'm using a "package" as an example.)

    Side note: it is certainly feasible to include some of the testthat tools within your function for runtime validation as well. For instance, one might replace stopifnot(inherits(x, "data.frame")) with expect_is(x, "data.frame"), and it will fail with non-frames, and pass with all three types of frames tested above. I don't know that this is always the best way to go, and I haven't seen its use in packages I use. (Doesn't mean it isn't there. If you see testthat in a package's "Imports:", then it's possible.)

    The premise here is not validation of runtime objects. The premise is validation of your function's performance given very specific inputs[^2]. For instance, one might define a unit-test to confirm that your function operates equally well on frames of class "data.frame", "tbl_df", and "data.table". (This is not a throw-away unit-test, btw.)

    Consider a meek function that one would presume can work equally well on any data.frame-like object:

    func <- function(x, nm) head(x[nm], n = 2)
    

    To test that this accepts various types, one might simply call it on the console with:

    func(mtcars, "cyl")
    #               cyl
    # Mazda RX4       6
    # Mazda RX4 Wag   6
    

    When a colleague complains that this function isn't working, you might wonder that they're using either the tidyverse (and tibble) or data.table, so you can quickly test on the console:

    func(tibble::as_tibble(mtcars), "cyl")
    # # A tibble: 2 x 1
    #     cyl
    #   <dbl>
    # 1     6
    # 2     6
    func(data.table::as.data.table(mtcars), "cyl")
    # Error in `[.data.table`(x, nm) : 
    #   When i is a data.table (or character vector), the columns to join by must be specified using 'on=' argument (see ?data.table), by keying x (i.e. sorted, and, marked as sorted, see ?setkey), or by sharing column names between x and i (i.e., a natural join). Keyed joins might have further speed benefits on very large data due to x being sorted in RAM.
    

    So now you know where the problem lies (if not yet how to fix it). If you test this "as is" with data.table, one might think to try something like this (obviously wrong) fix:

    func <- function(x, nm) head(x[,..nm], n = 2)
    func(data.table::as.data.table(mtcars), "cyl")
    #    cyl
    # 1:   6
    # 2:   6
    

    While this works, unfortunately it now fails for the other two frame-like objects.

    The answer to this dilemma is to make tests so that when you make a change to your function, if previously-successful property assumptions now change, you will know immediately. Had all three of those tests been incorporated into a unit-test, one might have done something such as

    library(testthat)
    test_that("func works with all frame-like objects", {
      expect_silent(func(mtcars, "cyl"))
      expect_silent(func(tibble::as_tibble(mtcars), "cyl"))
      expect_silent(func(data.table::as.data.table(mtcars), "cyl"))
    })
    # Error: Test failed: 'func works with all frame-like objects'
    

    Given some research, you find one method that you think will satisfy all three frame-like objects:

    func <- function(x, nm) head(subset(x, select = nm), n = 2)
    

    And then run your unit-tests again:

    test_that("func works with all frame-like objects", {
      expect_silent(func(mtcars, "cyl"))
      expect_silent(func(tibble::as_tibble(mtcars), "cyl"))
      expect_silent(func(data.table::as.data.table(mtcars), "cyl"))
    })
    

    (No output ... silence is golden.)

    Similar to many things in programming, there are many opinions on how to organize, fashion, or even when to create these unit-tests. Many of these opinions are right for somebody. One strategy that I tend to start with is this:

    Experience will dictate types of tests to write preemptively before the bugs even come.

    Tests don't always have to be about "no errors", by the way. They can test for a lot of things:

    Some will say that unit-tests are no fun to write, and abhor efforts on them. While I don't disagree that unit-tests are not fun, I have burned myself countless times when making a simple fix to a function inadvertently broke several other things ... and since I deployed the "simple fix" without applicable unit-tests, I just shifted the bug reports from "this title has "NA" in it" to "the app crashes and everybody is angry" (true story).

    For some packages, unit-testing can be done in moments; for others, it may take minutes or hours. Due to complexity in functions, some of my unit-tests deal with "large" data structures, so a single test takes several minutes to reveal its success. Most of my unit-tests are relatively instantaneous with inputs of vectors of length 1 to 3, or frames/matrices with 2-4 rows and/or columns.

    This is by far not a complete document on testing. There are books, tutorials, and countless blogs about different techniques. One good reference is Hadley's book on R Packages, Testing chapter: http://r-pkgs.had.co.nz/tests.html. I like that, but it is far from the only one.

    [^1] Tangentially, I believe that one power the roxygen2 package affords is the convenience of storing a function's documentation in the same file as the function itself. Its proximity "reminds" me to update the docs when I'm working on code. It would be nice if we could determine a sane way to similarly add formal testthat (or similar) unit-tests to the function file itself. I've seen (and at times used) informal unit-tests by including specific code in the roxygen2 @examples section: when the file is rendered to an .Rd file, any errors in the example code will alert me on the console. I know that this technique is sloppy and hasty, and in general I only suggest it when more formal unit-testing will not be done. It does tend to make help documentation a lot more verbose than it needs to be.

    [^2] I said above "given very specific inputs": an alternative is something called "fuzzing", a technique where functions are called with random or invalid input. I believe this is very useful for searching for stack overflow, memory-access, or similar problems that cause a program to crash and/or execute the wrong code. I've not seen this used in R (ymmv).