Introducing FuzzyTests
TL;DR: Grab it here : Github repo
Unit testing is painful amirite?
Writing good tests for your code very often means spending twice as much time coding them than on the things you test themselves.
It is good practice though to verify as much as possible that the code you write is valid, especially if that code is going to be public or included in someone else's work.
In my workflow I insist on the notion of ownership :
The bottomline for me is this: if there are several people on a project, I want clearly defined ownership. It's not that I won't fix a bug in someone else's code, just that they own it and therefore have to have a reliable way of testing that my fix works.
Tests solve part of that problem. My code, my tests. If you fix my code, run my tests, I'm fairly confident that you didn't wreck the whole thing. And that I won't have to spend a couple of hours figuring out what it is that you did.
This a a very very very light constraint when you compare it to methodologies like TDD, but it's a required minimum for me.
Plus, it's not that painful, except...
Testing every case
In my personal opinion, the tests that are hardest to do right are the ones that have a very large input range, with a few failure/continuity points.
If, for instance, and completely randomly, of course, you had an application where the tilt of the phone changes the state of the app (locked/unlocked, depending on whether the phone is lying flat-ish on the table or not:
- from -20º to 20º the app is locked
- from 160º to 200º the app is locked
- the rest of the time it's not locked
- All of that modulo 360, of course
So you have a function that takes the current pitch angle, and returns if we should lock or not:
func pitchLock(_ angle: Double) -> Bool {
// ...
}
Does it work? Does it work modulo 360? What would a unit test for that function even look like? A for loop?
I have been looking for a way to do that kind of test for a while, which is why I published HoledRange (now Domains 😇) a while back, as part of my hacks.
What I wanted is to write my tests kind of like this (invalid code on so many levels):
for x in [-1000.0...1000.0].randomSelection {
let unitCircleAngle = x%360.0
if unitCircleAngle >= 340 || unitCircle <= 20 {
XCTAssert(pitchLock(x))
} else if unitCircleAngle >= 160 && unitCircle <= 200 {
XCTAssert(pitchLock(x))
} else {
XCTAssertFalse(pitchLock(x))
}
}
This way of testing, while vaguely valid, leaves so many things flaky:
- how many elements in the random selection?
- how can we make certain values untestable (because we address them somewhere else, for instance)
- what a lot of boilerplate if I have multiple functions to test on the same range of values
- I can't reuse the same value for multiple tests to check function chains
Function builders
I have been fascinated with @_functionBuilder
every since it was announced. While I don't feel enthusiastic about SwiftUI (in french), that way to build elements out of blocks is something I have wanted for years.
Making them is a harrowing experience the first time, but in the end it works!
What I wanted to use as syntax is something like this:
func myPlus(_ a: Int, _ b: Int) -> Int
DomainTests<Int> {
Domain(-10000...10000)
1000000
Test { (a: Int) in
XCTAssert(myPlus(a, 1) == a+1, "Problem with value\(a)")
XCTAssert(myPlus(1, a) == a+1, "Problem with value\(a)")
}
Test { (a: Int) in
let random = Int.random(in: -10000...10000)
XCTAssert(myPlus(a, random) == a+random, "Problem with value\(a)")
XCTAssert(myPlus(random, a) == a+random, "Problem with value\(a)")
}
}.random()
This particular DomainTests
runs 1000000
times over $$D=[-10000;10000]$$ in a random fashion.
Note the Test
builder that takes a function with a parameter that will be in the domain, and the definition that allows to define both the test domain (mandatory) and the number of random iterations (optional).
If you want to test every single value in a domain, the bounding needs to be Strideable
, ie usable in a for-loop.
DomainTests<Int> {
Domain(-10000...10000)
Test { (a: Int) in
XCTAssert(myPlus(a, 1) == a+1, "Problem with value\(a)")
XCTAssert(myPlus(1, a) == a+1, "Problem with value\(a)")
}
Test { (a: Int) in
let random = Int.random(in: -10000...10000)
XCTAssert(myPlus(a, random) == a+random, "Problem with value\(a)")
XCTAssert(myPlus(random, a) == a+random, "Problem with value\(a)")
}
}.full()
Conclusion
A couple of hard working days plus a healthy dose of using that framework personally means this should be ready-ish for production.
If you are a maths-oriented dev and shiver at the idea of untested domains, this is for you 😬