Extended Example
Combinatorial interaction testing is most useful when testing a complicated function, so let's look at testing code with more branches.
Code Under Test
This code takes any pair of integers, $(i, j)$ and returns a single integer, $z$ that will be unique for every unique $(i,j)$. It's called a Hilbert index, and it's used to provide an ad-hoc clustering of values in a grid. What we see in this code is lots of if-then decisions and some bitshifting. Writing this, I was worried about type stability, interactions among signed and unsigned integers, and errors due to integer size differences.
struct Simple2D{T}
end
function encode_hilbert_zero(::Simple2D{T}, X::Vector{A})::T where {A, T}
x = X[1]
y = X[2]
z = zero(T)
if x == zero(A) && y == zero(A)
return z
end
rmin = convert(Int, floor(log2(max(x, y))) + 1)
w = one(A) << (rmin - 1)
while rmin > 0
z <<= 2
if rmin&1 == 1 # odd
if x < w
if y >= w
# 1
x, y = (y - w, x)
z += one(T)
end # else x, y remain the same.
else
if y < w
x, y = ((w<<1) - x - one(w), w - y - one(w))
# 3
z += T(3)
else
x, y = (y - w, x - w)
z += T(2)
# 2
end
end
else # even
if x < w
if y >= w
# Quadrant 3
x, y = (w - x - one(w), (w << 1) - y - one(w))
z += T(3)
end # else do nothing for quadrant 0.
else
if y < w
# 1
x, y = (y, x-w)
z += one(T)
else
# 2
x, y = (y-w, x-w)
z += T(2)
end
end
end
rmin -= 1
w >>= 1
end
z
end
# Decoding does the opposite of encoding, so it turns a single z into an (i,j).
function decode_hilbert_zero!(::Simple2D{T}, X::Vector{A}, z::T) where {A,T}
r = z & T(3)
x, y = [(zero(A), zero(A)), (zero(A), one(A)), (one(A), one(A)), (one(A), zero(A))][r + 1]
z >>= 2
rmin = 2
w = one(A) << 1
while z > zero(T)
r = z & T(3)
parity = 2 - rmin&1
if rmin & 1 != 0
# Nothing to do for quadrant 0.
if r == 1
x, y = (y, x + w)
elseif r == 2
x, y = (y + w, x + w)
elseif r == 3
x, y = ((w << 1) - x - one(A), w - y - one(A))
end
else
if r == 1
x, y = (y + w, x)
elseif r == 2
x, y = (y + w, x + w)
elseif r == 3
x, y = (w - x - one(A), (w << 1) - y - one(A))
end
end
z >>= 2
rmin += 1
w <<= 1
end
X[1] = x
X[2] = y
end
# These are the same functions with 1-offsets, for use in Julia array indexing.
function encode_hilbert(gg::Simple2D{T}, X::Vector{A}) where {A, T}
encode_hilbert_zero(gg, X .- one(A)) + one(T)
end
function decode_hilbert!(gg::Simple2D{T}, X::Vector{A}, h::T) where {A,T}
decode_hilbert_zero!(gg, X, h - one(T))
X .+= one(A)
end
Combinatorial Test of the Code
The code above is a just two functions, but there is lots of opportunity for problems. Because Julia let's us create a single function to work for multiple types, we effectively have multiple functions to test. How many cases are there, in total?
- The $(i,j)$ can be Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Int128, UInt128 (10).
- The same options are possible for the $z$ value, the Hilbert index (10).
- This function could be called in a 0-based or 1-based way (2).
- The $(i,j)$ could range over different extents. This is a semi-infinite set, but let's choose powers of two, so 4, 8, 16, 32, 64, and trust past that (5).
- This test allows the input vector to be too-large. That's a feature of some Hilbert implementations, and means we test for 2D, 3D, 4D (3).
In all, we could have 3000 test runs. Of those 3000, the all_pairs
function chooses 100 tests that contain every possible pair of arguments. In each test run, the code will do self-consistency checks. It will check that consecutive $z$ values produce contiguous $(i,j)$ values. It will test that decoding is the opposite of encoding. These tests may sound trivial, but four out of the six major papers on Hilbert indices contained code that failed these tests. All were later fixed after publication.
Combinatorial interaction testing of this function caught one major problem. The use of types wasn't consistent within the functions. While there are user-chosen types for input and output, all bitshifting should use simple integers. This testing also reassured the author that unsigned and signed integers would work together for this particular use of bitshifting functions.
using UnitTestDesign
using Random
using Test
rng = Random.MersenneTwister(9790323)
for retrial in 1:5
AxisTypes = shuffle(rng, [Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Int128, UInt128])
IndexTypes = shuffle(rng, [Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Int128, UInt128])
Count= shuffle(rng, [0, 1])
Dims = shuffle(rng, [2, 3, 4])
Bits = shuffle(rng, [2, 3, 4, 5])
test_set = all_pairs(
AxisTypes, IndexTypes, Count, Dims, Bits;
)
for (A, I, C, D, B) in test_set
gg = Simple2D{I}()
if B * D > log2(typemax(I))
continue
end
# Add this because these values aren't in Hilbert order because
# It's an asymmetrical space.
if B * D > log2(typemax(A))
continue
end
last = (one(I) << (B * D)) - one(I) + I(C)
mid = one(I) << (B * D - 1)
few = 5
X = zeros(A, D)
hlarr = vcat(C:min(mid, few), max(mid + 1, last - few):last)
for hl in hlarr
hli = I(hl)
if C == 0
decode_hilbert_zero!(gg, X, hli)
hl2 = encode_hilbert_zero(gg, X)
if hl2 != hli
@show A, I, C, D, B, X
@test hl2 == hli
end
@test typeof(hl2) == typeof(hli)
else
decode_hilbert!(gg, X, hli)
hl3 = encode_hilbert(gg, X)
@test hl3 == hli
@test typeof(hl3) == typeof(hli)
end
end
end
end
One technique you might note above is that the code generates five versions of the test suite. It does this because the combinatorial tests are generated with a deterministic algorithm, so one way to increase test coverage is to shuffle test inputs and regenerate the test suite.