Static Data Trap. All Tests Passed. But Why is the Output Wrong?

Code coverage is not enough

Code coverage is one of those metrics which can easily give you a false sense of security. In real life, code achieving 100% code coverage is often not possible, but that is the least of the problems with using code coverage as a metric of how well-tested some code is.

There are some parts of the code and logic that code coverage tools simply cannot cover. Even if you have achieved 100% code coverage for some code, you might still have serious flaws in that code.

Every branch is taken, every line is green, all your tests are passing. There is nothing left to do. Right?

Wrong?

Probably one of the simplest examples that can show this problem is converting a decimal integer number to some other base:

const BaseChars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; function IntToBase(Value, Base: UInt32): string; begin Result := ''; while Value > 0 do begin Result := (BaseChars[1 + (Value mod Base)]) + Result; Value := Value div Base; end; if Result = '' then Result := '0'; end;

To achieve 100% code coverage for the above code, we need only two test cases:

Value  Base  Expected Result
0 16 0
205 16 CD

It is beyond obvious that a mere two test cases cannot possibly constitute a proper set of test cases for such a function. This is one of the reasons code coverage is an extremely weak measure for adequate testing. It can only tell us when we do have some omissions in our test suite, and show us code which was not tested at all, but it cannot tell us how good our set of test cases is, and whether we have adequately covered a particular functionality with tests.

The blind spot of traditional testing methodologies

Full code coverage commonly gives us less than white-box testing methodologies could. In this particular situation, test cases which give us 100% code coverage are almost the same we would get with white-box methodologies. The only missing, non-critical, test case is the one in which a while loop would only have a single iteration.

Adding that one would extend our test set to:

Value  Base  Expected Result
0 16 0
1 16 1
205 16 CD

To get a more exhaustive set of test cases we need to reach to the black-box testing methodologies, like equivalence partitioning and boundary value analysis.

For the Value parameter, those give us two partitions from which we can take our boundary values:

Value Partitition  Category
0 valid
1 .. 4294967295  valid

For the Base parameter, we get the following partitions:

Value Partitition  Category
0 invalid
1 invalid
2 .. 36 valid
37 .. 4294967295  invalid

Using those we can extend our initial test cases and get the following set of tests:

Value  Base  Expected Result
0 16 0
1 16 1
4294967295  16 FFFFFFFF
10 2 1010
35 36 Z
1 0 division by zero
1 1 infinite loop
36 37 range error

Above set of test cases exercise the functionality of the IntToBase function more thoroughly, and they show the function fails on basic input validation, if we pass an invalid base value. In the context of testing, the most problematic is the infinite loop, which will hang the test suite.

Based on the previous analysis we might add an additional range check for the Base parameter input. But we might well have written the code like that on purpose, because we don't expect that any code calling the function will use invalid input values, and for performance reasons, we don't want to introduce additional verification in the code at this level.

But the real problem with our minimal test suite lies elsewhere. It is hidden in the BaseChars constant. Yes, we used that constant when determining valid and invalid input for the Base parameter, but our test cases forgot to cover another, extremely important aspect. This constant has direct impact on the correctness of the integer-to-string conversion. Changes in the constant—for instance, a simple typo where some characters are swapped and are not in the proper order—will give us incorrect results for values which contain that wrong character in their converted output.

Our set of test cases can only catch those if the BaseChars has incorrect values for 0, 1, F, or Z characters. For all other 32 characters, we can change values in the constant and our tests would still pass.

Even if we had included an additional set of test cases, beyond the minimal set shown above, they will most likely not cover all characters defined in the constant, unless we have intentionally written them to do so.

If your code relies on static data, where a change in that data changes the functionality of the code, then even with 100% code coverage, and even with test cases provided by other testing methodologies, you might still have serious flaws lurking in that code.

Code coverage tools can only measure the execution of instructions, but they cannot measure whether all the data behind some declared constant has been exercised during testing.

Considering the impact lookup tables and other kinds of constants can have on code correctness, we need to make sure our unit tests fully cover such scenarios, too.

The only way to properly test code which relies on static data is to make an exhaustive set of test cases which will cover every piece of the data. If any part of the data changes, the tests must catch the change and fail.

This is especially important in the AI era, where writing code and tests has been often delegated to the AI. An AI can more easily introduce accidental changes in places a person would not touch. If AI-made changes are not properly monitored, you can easily end up with bugs in previously working code, which will not be caught in testing.

Even when you instruct AI to write an exhaustive suite of tests, it may still fail to properly detect a potential issue with static data. One of the problems with AI-written tests—even in scenarios where AI will actually try to exercise the whole static dataset—is that it will simply take a copy of the original data and use it for test verification. (Yes, people will do that kind of thing, too.)

If the original data had correct values, then such tests will be able to catch future changes and related issues, but if the original data was flawed, then those tests will be flawed, too.

Testing functionality which uses static data is one of those situations where more care is always needed, regardless of who actually writes the tests.

Comments

Popular posts from this blog

Delphi 13.1 Update: Windows Arm64EC compiler

Delphi 13 Florence: 64-bit IDE debugging experience

Using atomic operations to achieve thread safety