A Philosophy of Testing 7: Code for Testing

Testing is a Critical Requirement

All too often, folks think of testing as something separate from the code — you write the code, and only afterwards do you test it. Test automation is often treated as menial work, to be written by less-skilled engineers after the fact. That is pernicious nonsense, and I’d like to encourage you to stay far away from that sort of thinking. (In particular, if you think it’s a menial task, you probably aren’t testing mindfully.)

  • Be structured in such a way as to allow the test harness to fake and instrument the various subsystems as necessary.
  • Provide “hooks” that allow the tests to deeply understand what is going on in the running code, again as necessary.

Structuring for Tests: zio-test

The ZIO framework has a tendency to be rather all-or-nothing, and isn’t everyone’s cup of tea. But it and its bespoke test framework provide several good simple examples of how to design your system for testability.

  • Clock, which lets you fetch the current time and schedule events.
  • Console, which handles console I/O.
  • Random, which lets you obtain random numbers and strings.
  • System, which provides access to environment variables and properties.
  • TestClock, which lets you jump the time forward or pin it to a specific time.
  • TestConsole, which lets you specify what console input will be provided, and gives you access to the any output being sent to the system console by the program.
  • TestRandom, which lets you specify the exact random values that will be generated.
  • TestSystem, which lets you provide test values for the environment variables.

Structuring for Tests: RqClient

Building your code for testability doesn’t have to be limited to simple things like random numbers — pretty much every aspect of your interactions with the external world can and should be designed to be testable.

TestHooks

Less obviously, there are times when you simply need your code to be more transparent to the test harness — where the simple “send some input, check the output” isn’t good enough.

  • What happens is a side-effect. For example, a call to the service results in a change to an in-memory value.
  • Nothing happens, and that’s good. For example, this call is redundant with the current state, and we intentionally don’t want to change anything.
trait TestHooks {
def event(evt: => Any): Unit
}
class TestHooksImpl() extends TestHooks {
def event(evt: => Any): Unit = {}
}
class TestTimeTestHooks() extends TestHooks {
var eventsRaised: List[Any]
def event(evt: => Any): Unit = synchronized {
eventsRaised = evt :: eventsRaised
}
}
case class InputDiscarded(name: String)
...
if (needToDoSomethingTo(name)) {
goDoTheThingTo(name)
} else {
testHooks.event(InputDiscarded(name))
}
callThatShouldNotDoAnythingWith(name)
eventually {
assert(testHooks.eventsRaised.exists(_ == InputDiscarded(name)))
}

Summary

When you think of testability as a requirement of your application, not just an after-the-fact checkbox, it changes the way you think about structuring your application. You want to code so that everything outside the application proper (including basic environmental factors like the time) can be easily controlled by the test harness. And you want to connect the application and test harness via lightweight mechanisms such as TestHooks, so that the code can provide the tests with the insight that they need.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Mark "Justin" Waks

Mark "Justin" Waks

Lifelong programmer and software architect, specializing in online social tools and (nowadays) Scala. Architect of Querki (“leading the small data revolution”).