Testing Habit - Inject determinism
This is an area where testing habits deeply overlap with software architecture. So buckle up đ.
As a rule, when thereâs some kind of randomness, or non-deterministic behavior, I try to inject that behavior. âInjectionâ can be an opaque, âfancyâ, overloaded term. In this case, I just mean âpass it in so we can control it.â
There are a couple of benefits from setting up the code this way. One major benefit is, as you may have guessed, your tests are more stable. If youâve ever dealt with flaky tests, this may strike a chord. But there are architectural gains, too. By isolating the things that change for different reasons, we can more easily swap out behavior, and insulate ourselves from dependencies.
Examples
For this one, it feels prudent to provide examples in dynamic & statically-typed languages, since there are slightly different techniques for managing the injection.
Dynamically-typed language example
In duck-typed/dynamic languages like Ruby, Python, or JavaScript, you can get away with passing in an object that âquacksâ like the role you need.
Letâs start with a simple class representing a Die we can roll.
class Die
def initialize(side_count:)
@side_count = side_count
end
def sides = (1..@side_count).to_a
def roll = sides.sample
end
This works well.
You can specify how many sides your die has and then roll it and get a new, random value every time.
But how would you test it?
The #sides method is straightforward.
Itâs deterministic, and always returns the same thing.
The #roll method is different, though.
It will return something different every time.
One way to handle this would be to do some kind of property based testing.
That is, call the roll method many times and assert that it, say, always returns a number between 1-6.
That might look something like this:
class DieTest < Minitest::Test
def test_roll_returns_numbers_in_range
die = Die.new(side_count: 6)
100_000.times do
assert_includes 1..6, die.roll
end
end
end
There are also frameworks that can make this cleaner, or easier to read, or more clearly declare your intent.
But thereâs another way thatâs more explicit.
What if we had control of the randomness?
If youâre not familiar, the #sample method actually allows you to specify which random number generator you use.
With this knowledge, you can do something like this:
class Die
def initialize(side_count:, random: Random)
@side_count = side_count
@random = random
end
def sides = (1..@side_count).to_a
def roll = sides.sample(random: @random)
end
Go ahead and try it! This should behave exactly the same. But, now, weâve introduced a really neat seam into our code. Now we can write a deterministic test for this:
class Unrandom < Data.define(:stream)
def rand(*)
item = stream.first
stream.rotate!
item
end
end
class DieTest < Minitest::Test
def test_roll_returns_random_numbers
# the `sample` method uses `rand` to determine the next index in the array to return
# so we pass in index values to the `Unrandom` constructor to get die faces
random = Unrandom.new(stream: [3, 0, 2])
die = Die.new(side_count: 6, random:)
rolls = [die.roll, die.roll, die.roll]
assert_equal [4, 1, 3], rolls
end
end
So we create a new, non-random class; one we can completely control, but one that fits the interface of Random.
We then âinjectâ a new instance of our non-random class in place of our random generator.
From there, itâs simply a matter of implementing the interface of Random#rand such that we can guarantee the output we want.
This turns our non-deterministic test into a plain, boring, input/output test.
With this, we can completely eliminate variance that could cause flakiness.
Further, we leave ourselves a seam to introduce different behavior.
Statically-typed language example
In statically/nominally-typed languages, you must make the role explicit, or wrap the underlying collaborator to match the interface you need.
Letâs start with the same core example, this time though, in Kotlin.
class Die(val sideCount: Int) {
fun sides(): List<Int> = (1..sideCount).toList()
fun roll(): Int = sides.random()
}
With this, we have the same conundrum, but luckily, random() also supports injecting the randomness.
We do however, have to do a little more work.
Because the type system ânominallyâ declares it needs an instance of Random, we have to match that interface.
This may be a little different in your language of choice, but in Kotlin, they provide an abstract Random base class we can extend.
class ImmediateRandom(val numbers: List<Int>): Random() {
private var index = 0
override fun nextInt(): Int {
return numbers[index++ % numbers.size]
}
}
Side note: I chose the naming of âimmediateâ to distinguish this random from one that actually does ârandomnessâ.
I believe I originally saw this naming in a project that used a dependency injection framework, but Iâve since lost the context.
This could just as easily be named something like: LiteralRandom, StaticRandom, or FakeRandom.
From here, almost the exact same recipe applies.
First, we update our Die to accept inject determinism.
class Die(val sideCount: Int, val random: Random = Random.Default) {
fun sides(): List<Int> = (1..sideCount).toList()
fun roll(): Int = sides.random(random)
}
Then, our test can construct a ImmediateRandom with the expected output stream and it âjust works.â
@Test
fun `roll returns random numbers`() {
val random = ImmediateRandom(listOf(3, 0, 2))
val die = Die(6, random)
val rolls = listOf(die.roll(), die.roll(), die.roll())
assertEqual(listOf(4, 1, 3), rolls)
}
Itâs interesting, that the standard library designers chose to allow injecting behavior into these random methods, donât ya think?
Time
The concept of time so regularly interferes with our test suites, it feels worthwhile to call it out on its own. In particular, tests depending on time are prone to flakiness, which can largely be mitigated with the technique of âinjecting determinism.â
One place that this can be particularly difficult (and applicable) is in frontend applications where youâre regularly dealing with user time zones.
For this example, weâll look at how one might do dependency injection with React to allow injecting determinism. Letâs begin with a simple React component that renders the userâs current date.
const CurrentDateTime = () => {
const date = new Date();
const humanTime = date.toLocaleString(); // this could really be any arbitrary formatting
return <time datetime={date.toISOString()}>{humanTime}</time>
}
Imagine we want to test this component because the output format weâre using is actually quite tricky.
One way to handle this would be âstop time,â using something like the jest libraryâs fake timers.
This is, technically, one way of âinjecting determinismâ by overriding a system dependency.
However, we can be more explicit through more classical injection.
In this simple example, the obvious solution is best: pass in the date we want to render through a prop.
const CurrentDateTime = ({ date = new Date() }) => {
const humanTime = date.toLocaleString(); // this could really be any arbitrary formatting
return <time datetime={date.toISOString()}>{humanTime}</time>
}
Letâs stretch the example a little, though ⌠for science.
Suppose passing the date wasnât possible (for whatever reason), and you still need to verify the formatting is happening.
Another way to do this might be leverage the Context API to provide dependencies.
But what dependency to provide?
Well, youâd likely need to figure out what makes sense for your application, but one such implementation Iâve found useful on several projects is a Clock.
This class lets us provide a more intention revealing API than the built-in JS Date class, without reaching for a full blown library for time management.
Though, Iâll say, having a class like this makes swapping to a time library later much easier.
class WallClock {
now() {
return new Date();
}
parseDateTime(iso) {
return new Date(iso);
}
formatIsoToLocal(iso) {
return this.parseDateTime(iso).toLocaleString();
}
}
const ClockContext = createContext(new WallClock());
export const useClock = useContext(ClockContext);
Then, somewhere high up in your component tree, you can provide the default Clock and use that everywhere.
export const App = () => {
return(
<ClockContext.Provider>
<Router>
// etc...
</Router>
</ClockContext.Provider>
)
}
Our component needs a small adjustment to useContext.
const CurrentDateTime = ({ iso }) => {
const clock = useClock();
const humanTime = clock.formatIsoToLocal(iso);
return <time datetime={iso}>{humanTime}</time>
}
With this in place, our tests then can wrap the component under test in a fake implementation of the clock that provides more deterministic behavior.
// this may, or may not, live in a test file
class CuckooClock {
formatIsoToLocal(iso) {
return "user's timezone str";
}
}
describe(CurrentDateTime, () => {
it("renders the ISO string in the user's timezone", () => {
render(
<ClockContext.Provider value={new CuckooClock()}>
<CurrentDateTime iso="2021-03-02T00:00:00Z" />
</ClockContext.Provider>
)
expect(screen.getByRole('time').textContent).toEqual("user's timezone str");
})
})
And just like that, weâve decoupled our component from a direct dependency on the ugliness of the JS Date class and improved the resiliency of our test suite.
Additionally, by injecting the time dependency, weâve made our component more general purpose.
It not only renders the CurrentDateTime, but actually any DateTime in the application/locale format.
Another side point - arguably, this is the same as using a jest.Mock (or .fn(), etc), and using one here wouldnât hurt in this case.
But using a hand-rolled, more explicit Fake can provide other benefits.
Thereâll be another post later to talk about that point. đ
Conclusion
By injecting dependencies into our tests, we can transform non-deterministic, flaky suites into reliable, stable ones. This practice not only improves our test code, but also leads to more robust, modular architectures that are easier to maintain and grow. Whether youâre working with random numbers, time, the network, or any other form of non-determinism, the concept of injection is a powerful tool in your arsenal.