Adventures in testing go randomness
In a recent feature I have worked on the need for using randomness has risen. This feature had to most basic requirement of rolling a number which helps decides a simple yes/no route.
After completing the required changes the need to unit test the new and affected code has risen, On first look, this seemed like a quick hack, as rand.Rand accepts a source for generating randomness. But alas, after running writing and running the unit tests, something seemed wrong.
Since I can’t share the original code, and as it will probably bloat this post as well, I’ll share some code examples.
Here is an example of a function which might resemble what I wanted to test-
func DoRandomCalculation(rand *rand.Rand) int64 {
return int64(rand.Intn(5) * 5)
}
And here is an example of a unit test attempting to test such a function -
func TestDoRandomCalculation(t *testing.T) {
type args struct {
rand *rand.Rand
}
tests := []struct {
name string
args args
want int64
}{
{"When zero is rolled, zero is returned", args{rand.New(&PredictableRandomSource{mockedRandomValue: 0})}, 0},
{"When one is rolled, five is returned", args{rand.New(&PredictableRandomSource{mockedRandomValue: 1})}, 5},
{"When two is rolled, ten is returned", args{rand.New(&PredictableRandomSource{mockedRandomValue: 2})}, 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DoRandomCalculation(tt.args.rand); got != tt.want {
t.Errorf("DoRandomCalculation() = %v, want %v", got, tt.want)
}
})
}
}
Where PredictableRandomSource
has the following implementation -
type PredictableRandomSource struct {
mockedRandomValue int64
}
func (p *PredictableRandomSource) Int63() int64 {
return p.mockedRandomValue
}
func (p *PredictableRandomSource) Seed(seed int64) {
//do nothing
}
Running the unit test as-is will produce the following output -
=== RUN TestDoRandomCalculation/When_zero_is_rolled,_zero_is_returned
=== RUN TestDoRandomCalculation/When_one_is_rolled,_five_is_returned
random_test.go:28: DoRandomCalculation() = 0, want 5
=== RUN TestDoRandomCalculation/When_two_is_rolled,_ten_is_returned
random_test.go:28: DoRandomCalculation() = 0, want 10
--- FAIL: TestDoRandomCalculation (0.00s)
--- PASS: TestDoRandomCalculation/When_zero_is_rolled,_zero_is_returned (0.00s)
--- FAIL: TestDoRandomCalculation/When_one_is_rolled,_five_is_returned (0.00s)
--- FAIL: TestDoRandomCalculation/When_two_is_rolled,_ten_is_returned (0.00s)
So what is going on here ?
Poking inside the actual random implementation inside go, I observed the following culprit -
func (r *Rand) Int31() int32 { return int32(r.Int63() >> 32) }
it seems that Intn here is doing a “binary shift” to make our “random” int64 fit an int, so if we were to mimic our second unit test, this will translate to “1 » 32”, here is short and fast way to test this theory -
func main() {
fmt.Printf("the output of the binary shift is %v", 1>>32)
}
this, not surprisingly, outputs the output of the binary shift is 0
.
So now that we know why our unit tests are failing, we can probably get away by using an 64 bit variant of Intn - Int63n
.
Replacing the above DoRandomCalculation
implementation with it proves it by making all our tests pass as expected.
But this is not the end of the adventure, as the post started - our basic need is a yes/no, random answer.
Poking around inside the implementation of Int(63)n has made me think whether it’s a good idea to actually use Intn, as it might loop looking for our target random number.
Maybe it will be faster to check for a range of numbers, i.e. is the random number larger than half of math.MaxInt
?
bench time.
Let’s start with the closed group (int(63)n) version -
func BenchmarkInt64ClosedGroup(b *testing.B) {
r := rand.New(rand.NewSource(85166485))
for i := 0; i < b.N; i++ {
_ = r.Intn(2)
}
}
Has produced on my machine -
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkInt64ClosedGroup
BenchmarkInt64ClosedGroup-16 225519601 5.637 ns/op
PASS
And for the proposed, simpler and faster version -
func BenchmarkInt64WithSimpleRange(b *testing.B) {
r := rand.New(rand.NewSource(85166485))
for i := 0; i < b.N; i++ {
_ = r.Int63() > (math.MaxInt64 / 2)
}
}
got us -
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkInt64WithSimpleRange
BenchmarkInt64WithSimpleRange-16 399359359 2.864 ns/op
PASS
To summarize, just as suspected, for our use case, avoiding the closed group (Int(63)n) variant for a simple yes/no question, it will probably be faster to simply ask for a larger random number.