TDD without Unit Tests
Many years ago I was walking through the office when I passed a colleague talking to our manager. I overheard him as he was putting forward the case for using Ruby - "The great thing about dynamic[ly typed] languages is that they force you to write tests", he said. Because our manager was already committed to the importance of high test coverage, the idea of putting natural pressure on developers to write tests was probably quite appealing.
I didn't have suitably developed thoughts on the subject at the time, and so kept on walking. But that snippet of conversation had immediately struck me as illogical. How does a dynamic language force us to write tests? How could a language characteristic that provides less correctness lead us to a state of being more correct? And why was the mere presence of tests considered such a desirable goal?
Forcing us to write tests
There is a simple answer to the first question. Dynamic languages force us to write tests because the type system doesn't find problems until it is too late.
Let's explore how a dynamic language like Ruby "forces us to write tests". Consider a glass that may be stirred (but not shaken – this is just MVP).
class Glass
def initialize(drink)
@drink = drink
end
def stir
@drink.agitate
end
end
The class implements the action of stir
by delegating to @drink.agitate
.
This can logically fail if @drink
is not what we expect. For example, if it cannot be
agitated:
Glass.new(42).stir # => NoMethodError
Or it can be agitated, but not in the way we expect:
class Wasp
def agitate
raise "stings your face"
end
end
Glass.new(Wasp.new).stir # => swolen face
The very existence of this simple class requires that we unit test all classes in our application, just in case a Glass is used inappropriately.
A staticly typed language does this rudimentary and laborious testing for us. Using Scala we might write:
class Glass(drink: Drink) {
def stir = drink.agitate
}
Here, a Glass is always a Glass and a Drink is always a Drink. It doesn't make sense to test anything
here, as the compiler will ensure all initialisations of Glass are made with Drinks (so long as we
are committed to programming
without using null references, and that Drinks always support the agitate
method. The potential problem with agitating Wasps still exists, but the programmer would first have
to explicitly declare that Wasp extends Drink
.
The value of tests
What is it that tests do for our programs? All kinds of tests act as constraints to ensure that what happens at runtime is limited to a known set of possibilities. They define what is out of bounds for our application and assert that this is indeed the case.
There are degrees of sophistication in the application of tests. At a nascent stage we may write code we think is correct, try it out and observe the results. Our observation is the assertion. For the beginner this has the advantage of being intuitive and accessible. But it is a technique with significant drawbacks: it's not repeatable; subject to human error; and destined to be forgotten or avoided in a rush to deploy.
The next stage is to apply unit testing and an automated test execution phase. With this technique, the program is constrained by other programs that explicitly explore and assert the boundaries of units of work. The benefit with this approach is repeatability. Although it introduces an up-front cost of creating the tests, it provides efficiency over the long term by shortening the ongoing cost of test execution with each change.
My colleague and manager understood these two phases and probably considered them to be the full story. Perhaps they considered unit testing to be the optimal method for making applications behave correctly. But it is not so.
Static type systems are an additional tool we can use to constrain the behaviour of applications. The more advanced a language's type system is, the more detailed and expressive the potential constraints can be. And the less you need explicit unit tests. Like unit tests, the information expressed in a type is a constraint upon the otherwise limitless boundaries of the program. This constraint is asserted by the compiler as static type checking is performed which, unlike unit tests, cannot be disabled.
"Types don't free you from writing tests!"
Yes they do. You need tests only to the extent that you do not have types.
— Rúnar Óli
(@runarorama) November 15,
2013
Beyond constraint, towards design
Tests don't just help us constrain the system. They also help us to design it.
Test-Driven Development (TDD) is a well-documented process of writing tests before writing the production code those tests apply to. The constraints from these tests guide the developer towards writing code with more modular and malleable designs. In this way, TDD is foremost a design tool, rather than a testing tool. The residual tests are merely a happy by-product of the software design process and are retained as regression tests.
But TDD does not necessarily mean writing unit tests, as we have seen that static types play a strong testing role. TDD is adequately performed by compiler-checked properties in the cycle of red/green/refactor as it is by hand-written xUnit treatment. The process is the same as always: change the code from the outside-in. As you work inwards, some code is certain to break because the types are changing. This is evidence of the compiler testing your code for free.
Replacing tests with types does not reduce the effort needed to design effective solutions. On the contrary, a reasonable amount of time is still required to understand and model the domain. What we do save is the sometimes tedious boilerplate work of actually writing the tests. Where the type system does not encapsulate the required logic, only then do we need to write unit tests. The more sophisticated the type system, the less that explicit testing is required.
Performing TDD in dynamic languages takes more effort than in static ones because it requires developers to write unit tests, but doesn't require any less domain modelling or correctness. It is also easier to skip the TDD process in dynamic languages than in static ones, because in dynamic languages the types can be ignored right up until the runtime event that exposes a bug.
Why consider dynamic languages
The perceived benefit of avoiding types in a language is that it allows you to express your programs without having to think too hard about the types you are modelling. Logically, the types exist, ("you couldn't write useful code otherwise") but the programmer doesn't require the rigour of thinking about them because the execution environment will not hold the programmer to account.
However, the moment a program reaches even moderate complexity, the types will need to be asserted to ensure correct behaviour. In a language with weak or dynamic typing, this must be done by writing tests explicitly. This comes at a cost over the medium to long term.
Dynamically typed languages allow us to write potentially incorrect programs quickly. They have their place in rapid prototyping. To write more sophisticated software in dynamic languages you need to ensure that effort is continuously spent on writing tests. Strongly typed languages force you to design your programs correctly by implicitly applying the rigour of testing to your development process.
In summary, we rely on tests to help us constrain our software. But tests are not the only way to do this, nor are they the most efficient. In truth, it is the inverse of the original assertion that holds true. It is statically typed languages that force us to "write tests", more so than dynamically typed ones do, because the tests are built into the language.