The basic idea of testing with inverse functions is simple. We have two functions f and its inverse function f-1. If we apply f to an input value and then take the result and apply it to f-1 this should result in the input value again: f-1(f(x)) = x.
This test pattern is applicable for all kinds of functions for example compression and encryption. In the business application domain create and delete are examples of operations that can be tested this way. Another example are do and undo operations of command objects. In both examples, doing and undoing an action leaves the state of the world unchanged.
There are some constraints on the applicability of the test pattern. The function f has to have a inversion function so it is a bijective function and one of the functions f or f-1 have to be tested. Otherwise the intermediary result could be invalid. For example, if you test a create and a delete operation together, the inverse function test passes if both operations do nothing.
The example used to illustrate the inverse function testing is a simple square function.
public double square(double a) { double square = a * a; return square; }
The inverse function test can be implemented with concrete values. We use the Math.sqrt() implementation as the inverse function.
@Test public void squareConcreteValues() { double a = 2; assertEquals(square(Math.sqrt(a)), a, precission); }
This is okay but defining input values manually is not very productive, not readable and has not sufficient test coverage. You can instead employ some computing power to generate the values using Quickcheck.
Firstly, the square function is not bijective as square(-x) = square(x). This is a fact we did not express in the example with the concrete values. It simply omitted this fact. To fix this the result is compared to the absolute value of x. Secondly, the function will overflow and test output like this:
java.lang.AssertionError: expected:<1.6482368012418589E307> but was:<Infinity>
is typical.
@Test public void squareWithOverflows() { for(double a : someDoubles()) { assertEquals(abs(a), Math.sqrt(square(a)), a * precission); } }
Again, this aspect was not expressed in the concrete test. Even if you were not aware of the problem the failing test points to the overflow problem. This is a nice example how Quickcheck can help you to find bugs you did not anticipate. I admit that this is a simple example but give it a try. You'll see that you run into all kinds of problems you did not think of. You have to break your software to know it.
Now we have to fix the overflow problem. Depending on the situation it can be easier to find and test a valid implementation that is more restrictive than theoretically possible but suffices your requirements. This is the trade-off between effort now and potential applicability later.
For this example it is easy to find all legal input arguments. It is the largest double value that can be squared.
@Test public void squareWithBounds() { double max = Math.nextAfter(Math.sqrt(Double.MAX_VALUE), 0.0); for (double a : someDoubles(-max, max)) { assertEquals(abs(a), Math.sqrt(square(a)), a * precission); } }
To finish this example let’s write the test for the illegal input arguments as well. All square arguments that cause an overflow are invalid and should cause an IllegalArgumentException. The invalidArguments double generator defines all invalid values. These are the values greater than the largest valid value max and smaller than the smallest allowed value -max.
@Test public void squareInvalidArguments() { double max = Math.sqrt(Double.MAX_VALUE); double firstInvalid = Math.nextUp(max); Generator<Double> invalidArguments = oneOf(doubles(-Double.MAX_VALUE, -firstInvalid)) .add(doubles(firstInvalid, Double.MAX_VALUE)); for (double a : someEnsureValues(asList(firstInvalid, -firstInvalid), invalidArguments)) { try{ square(a); fail(); }catch(IllegalArgumentException e){ } } }
The implemenation passing the tests is:
public double square(double a) { double square = a * a; if (Double.isInfinite(square)) { throw new IllegalArgumentException() }; return square; }
Testing with inversion functions can solve the dilemma of writing tests without repeating the production code. If you repeat the code in the test you have an additional check that you wrote the code down correctly. You have to repeat an error in both places to introduce a bug. This is all you can get out of such tests. If you test with an inverse function the test and implementation do not share code. This kind of test has the potential to find conceptual problems in your code. (Actually, the reality is not black and white. You can have a test that implements the same operation with a simpler test implementation. This implementation verifies that the more complex production version works. This is the idea of the analogous function test pattern. I will come to later.)
If the inverse function pattern is applicable it can save you a lot of effort. For example the result of an operation can be very costly to verify like encryption functions. The exact representation may not be of interest for the given domain or change frequently leading to high maintenance costs with concrete test values. If you can define valid input values and have an tested inverse function the test effort is to setup the input value generator. The nice side-effect is that you test that the functions you think are inverse functions really are inverse.
No comments:
Post a Comment