10 Meta-testing

Meta-testing is the activity aiming to test a function while providing no data to test it.

In a R context, it means being able to

  1. discover function signature
  2. infer data type for each argument
  3. generate data set to be used for each argument
  4. run the function with generated data sets
  5. give back some summary statistics about discoveries of various test run achieved

If you try it by hand, you will probably succeed, because the second point will be managed directly by your brain. If you try it by a program, type inference is much trickier, because any argument in R could be of any type. Generally, you need the documentation and explanations to restrict the scope of possible types. That’s where using wyz.code.meta-testing will ease your work and bring instrumentation to get results in a more reliable and quicker way.

10.1 Traditional way of testing

By testing, I mean hand testing and operating scope and limits discovery.

10.1.1 Empirical approach

If you opt for an empirial testing of function cos, then you will enter command lines for each case you want to test. If you need many of them, it could become really boring very fast. It also disturbates you from the analysis of the results.

Moreover, in this approach you are nearly forced to open the black box cos and to get knowledge about it. That’s where it hurts, as working under predictable time in those conditions is really difficult.

10.1.2 More industrial approach

Going a little bit further than previous, you just use loops and scripts to ease replay. Let’s test base function cos in this way.

And now, we have to unravel the arguments to find which ones are generating warnings or errors, to identify the ones that are accepted. Note that this trial is a gentle trial. It does not try for example to provide raw type or data frame or matrix or a function as argument.

Again, you will have to get knowledge about the function to be able to provide expected parameter types and values. You also would have to decipher the R documentation related to the function. Some documentations are easy and clear, some others much more difficult to understand and interpret.

10.2 Meta testing

10.2.1 Meta testing approach

Let’s test function op_cos using wyz.code.meta-testing.

The general procedure to follow is

  1. create a wrapper function if your function is not offensive programming instrumented
  2. get some knowlegde about the function signature complexity,
  3. fire massive tests, and get synthesis results

10.2.2 Create a wrapper function

Any R function can be classified as offensive programming compliant or not. Second case is indeed much more common and will be encountered more often.

In such a case, use offensiveProgrammingWrapFunction or function opwf to generate a new R function that will be offensive programming compliant.

You have to provide semantic argument names to this function to be able to generate correctly the wrapping function. Once done, type inference is now driven by semantic argument names.

Let’s see an example, considering function cos from base package.

You may wonder what are the difference between the standard R signature and this one? They share same number of arguments, just the name changed. Yes, but as the name is now a semantic name, it can be managed by a factory See FunctionParameterTypeFactory in wyz.code.offensiveProgramming for more details to generate data to match the parameters.

10.2.3 Getting some complexity knowledge

This is achieved using function computeArgumentsCombination.

Here there exist only one signature for function op_cos and so for function cos.

Note that function computeArgumentsCombination can be used with any R function. It does not requires offensive programming instrumentated function as argument.

10.2.4 Firing massive tests

This is achieved using function exploreSignatures.

The second argument passed to the function is just a type restriction to be enforced when generating data for testing purposes. Here I asked for integer real and complex mathematical types these are different from R integer, double and complex as they cannot take value NA. Type restrictions are only considered for polymorphic arguments reminder: the ones that ends with an underscore.

Results tell you that same signature brings various results. Here, 6 tests succeeded and 6 failed.

10.2.4.1 Result analysis

All execution errors provided the same error message.

First diagnostic result, errors are tied to usage of non numeric values.

It is clear that the issue is tied to using a list as value to an imperative argument. Looking closer, on success, you see that only vectors provided results.

Secon diagnostic results, list should not be used as input parameter.

Now, you can conclude that cos function

  1. accepts as input vectors of integers, reals and complex
  2. passing a list as argument brings an error with the shown message.

To be complete, note that

  1. as I enforced mathematical arguments, values NA, NaN and Inf are no more possible values for test. This match the mathematic function cosinus and not the R function cos. This is an important point, know what scope you want to test, not just what function you want to test
  2. I was expecting the cosinus of a complex number to compute the cosinus of the argument of the complex number, normalized by its modulus. Was expecting a to function. That is clearly not the case as output are complex numbers. That is a to function. Explanation of the behavior is that the function cosine is extended to complex numbers as explained here. If you read R documentation of cos function, you would not get any relevant information about this.

10.3 A more complex example

Let’s now use function append from base package.

10.3.1 Create a wrapper function

As you know, we need first to create the offensive programming wrapper function.

As you can see, parameter substitution is also achieved in code for default arguments.

10.3.2 Getting some complexity knowledge

How complex is it to test this function?

There are two call signatures, one without default parameter, one with. Let’s test them.

10.3.3 Firing massive tests

10.4 An example using ellipsis

Let’s now use function sum from base package.

10.4.1 Create a wrapper function

As you know, we need first to create the offensive programming wrapper function.

As you can see, parameter substitution is also achieved in code for default arguments.

10.4.2 Getting some complexity knowledge

How complex is it to test this function?

There are eight call signatures, four without default parameter, four with. By default, ellipsis is replaced by 0 to three arguments. That’s why, first signature is empty, and the total is 8. Let’s test them.

10.4.3 Firing massive tests

From the 32 test runs, 8 passed, 24 failed. As we gave no restriction types for ellipsis, it has been replaced by any kind of types, and in particular some that cannot fit a sum. Let’s restrict the types to uses and run again same kind of test.

Much better. Still 32 tests, now 20 passed, 12 failed. Why?

10.5 A tricky example

Let’s now use function kronecker from base package.

10.5.2 Getting some complexity knowledge

How complex is it to test this function?

There are sixteen call signatures for which meta testing must generate data.

10.5.3 Firing massive tests

Let’s try brut force analysis first.

This fails as there exist no data generation function provided for array. We have to provide one by recording it into the data factory. To do so, here is the procedure to follow

  1. create the draw function that will generate the data type you need and that is not yet recorded into the data factory
  2. test it unitary and ensure result is of good type
  3. record new types into the data factory
  4. make your factory findable
  5. fire tests
# step 1 - create the draw function - wrong way
wrong_draw_integer_array <- function(n, replace_b_1 = TRUE) {
  m <- n + sample(0:3, 1)
  matrix(seq(1, n * m), byrow = TRUE, nrow = n, 
         dimnames = list(paste('row_', 1:n), paste('col_', 1:m)))
} 
# wrong because it does not respect argument names that must be
# n_i_1 and replace_b_1

# step 2 - test draw function
a1 <- wrong_draw_integer_array(2)

# step 3 - record new type into data factory - failure here
df <- retrieveDataFactory()
df$addSuffix('a', 'array', wrong_draw_integer_array)
#> [1] FALSE

# step 1 - create the draw factory - right way
draw_integer_array <- function(n_i_1, replace_b_1 = TRUE) {
  m <- n_i_1 + sample(0:3, 1)
  matrix(seq(1, n_i_1 * m), byrow = TRUE, nrow = n_i_1, 
         dimnames = list(paste('row_', 1:n_i_1), paste('col_', 1:m)))
}

# step 1 - create draw function 
draw_function <- function(n_i_1, replace_b_1 = TRUE) { list(`*`, `+`, `-`)[[sample(1:3, 1)]]}

# step - 2 test good practice verifies your functions behave correctly on a single example
a1 <- draw_integer_array(2)
a2 <- draw_integer_array(3)
f <- draw_function(1)
kronecker(a1, a2, f, TRUE)
#>               col_ 1:col_ 1 col_ 1:col_ 2 col_ 1:col_ 3 col_ 1:col_ 4
#> row_ 1:row_ 1             1             2             3             4
#> row_ 1:row_ 2             6             7             8             9
#> row_ 1:row_ 3            11            12            13            14
#> row_ 2:row_ 1             6            12            18            24
#> row_ 2:row_ 2            36            42            48            54
#> row_ 2:row_ 3            66            72            78            84
#>               col_ 1:col_ 5 col_ 2:col_ 1 col_ 2:col_ 2 col_ 2:col_ 3
#> row_ 1:row_ 1             5             2             4             6
#> row_ 1:row_ 2            10            12            14            16
#> row_ 1:row_ 3            15            22            24            26
#> row_ 2:row_ 1            30             7            14            21
#> row_ 2:row_ 2            60            42            49            56
#> row_ 2:row_ 3            90            77            84            91
#>               col_ 2:col_ 4 col_ 2:col_ 5 col_ 3:col_ 1 col_ 3:col_ 2
#> row_ 1:row_ 1             8            10             3             6
#> row_ 1:row_ 2            18            20            18            21
#> row_ 1:row_ 3            28            30            33            36
#> row_ 2:row_ 1            28            35             8            16
#> row_ 2:row_ 2            63            70            48            56
#> row_ 2:row_ 3            98           105            88            96
#>               col_ 3:col_ 3 col_ 3:col_ 4 col_ 3:col_ 5 col_ 4:col_ 1
#> row_ 1:row_ 1             9            12            15             4
#> row_ 1:row_ 2            24            27            30            24
#> row_ 1:row_ 3            39            42            45            44
#> row_ 2:row_ 1            24            32            40             9
#> row_ 2:row_ 2            64            72            80            54
#> row_ 2:row_ 3           104           112           120            99
#>               col_ 4:col_ 2 col_ 4:col_ 3 col_ 4:col_ 4 col_ 4:col_ 5
#> row_ 1:row_ 1             8            12            16            20
#> row_ 1:row_ 2            28            32            36            40
#> row_ 1:row_ 3            48            52            56            60
#> row_ 2:row_ 1            18            27            36            45
#> row_ 2:row_ 2            63            72            81            90
#> row_ 2:row_ 3           108           117           126           135
#>               col_ 5:col_ 1 col_ 5:col_ 2 col_ 5:col_ 3 col_ 5:col_ 4
#> row_ 1:row_ 1             5            10            15            20
#> row_ 1:row_ 2            30            35            40            45
#> row_ 1:row_ 3            55            60            65            70
#> row_ 2:row_ 1            10            20            30            40
#> row_ 2:row_ 2            60            70            80            90
#> row_ 2:row_ 3           110           120           130           140
#>               col_ 5:col_ 5
#> row_ 1:row_ 1            25
#> row_ 1:row_ 2            50
#> row_ 1:row_ 3            75
#> row_ 2:row_ 1            50
#> row_ 2:row_ 2           100
#> row_ 2:row_ 3           150

# step 3 - record new data types into data factory - success here
df$addSuffix('a', 'array', draw_integer_array)
#> [1] TRUE
df$addSuffix('f', 'function', draw_function)
#> [1] TRUE

# step 4 -  make your factory findable
options(op_mt_data_factory = df)

# step 5 - fire tests - up to 768 contexts managed in one shot
es <- exploreSignatures(op_kronecker)

10.5.3.1 Result analysis

Computed results helps greatly analysis.

Here number of successful test is already very significant, although overcame by number of failure tests.

Patterns for success and failure are the same. So, issue should logically come from data types.

Analysis of signatures brings more insight as all the error signatures implies ellipsis. Such result is great as it provides very quickly a good hint about the root cause.

10.6 Pitfalls to avoid

Some common and well-known pitfalls are

  1. When using opwf function, make sure you provide the argument names in the right order. Examine created function signature prior going further. Make sure it fits the desired definition you look for.
  2. DataFactory changes remain invisible to processing as long as you do not set the option op_mt_data_factory with the name of the R variable that holds the DataFactory you want to use. This is often forgotten.