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.