Testable Code
Testing has quickly become a standard in the software industry. Writing testable code is quite simple when following a few simple guidelines. This post aims at providing you with a framework for tackling that issue.
The SOLID principle plays a big role when it comes to writing testable code. It is an important cornerstone for writing high quality and maintainable code. Together with Dependency Injection it lays the foundation of testability. The following principles comprise the base recipe
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
- Dependency Injection
The following sections explain the principles and how they apply to testability, together with examples.
Single Responsibility
The Single Responsibility Principle (SRP) states that every component of a system should have one and only one responsibility. Or as Robert C. Martin elegantly phrases it
A class should have only one reason to change
An example of this could be
package export
type ExportSvc interface {
Serialize(interface{}) (string, err)
WriteToFile(string) err
}
type Exp struct {}
//Exp -> extends interface
The Exp struct violates the SRP, because there are two scenarios that require a change in code. Number one is the serialization mechanism. Maybe our API will accept json instead of yaml. And number two is the access to the file system. Supposedly we switch from write to disk to Amazon S3.
Increasingly complex systems to design and maintain imply an increasing number of reasons for change. SRP makes sure that these changes have a small footprint on our codebase. Increased testability of our code is thus reached by narrowing the scope and therefore the complexity of both our tests and their subjects.
Open-Closed
The Open-Closed principle (OCP) states, according to it’s creator, Bertran Meyer
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
Being open for extension means that the behaviour of a given component can be extended. In other words, the behaviour is not set in stone or written in such a manner that extending functionality infers copying of code.
When a component is called closed for modification it means that the public API of this component is fixed. Furthermore, no changes to the code are to be made.
Typically violations of this principle are verbose switch case statements or if-elseif structures. The following example illustrates a clear violation
package main
type Product struct{
Type string
//...
}
type PriceSvc struct{}
func (_ *PriceSvc) GetVAT(p *Product) uint {
switch p.Type {
case 'food':
return 70
case 'other':
return 0
default:
return 80
}
}
Immagine this code is inside of a library you’re using. Now in order to extend the behaviour of the GetVAT method by another type, let’s say ‘furniture’, the programmer has to either modify the original code or overwrite it completely. When overwriting the code she potentially also has to write test cases for each scenario.
The example could be fixed by introducing a simple mapping table type -> VAT. When adding new product types, another entry can be inserted to this table, leaving it open for extension. Furthermore, instead of writing a test case per product type, a single more generic test can be written instead.
Software systems are subject to a good deal of change. The OCP, much like the SRP, helps us to reduce the impact change has on our system. In practice the two principles go hand in hand, since having complex paths in your code, a violation of OCP, normally also implies a violation of the SRP.
Liskov Substitution
The Liskov Substitution Principle (LSP) states that if a type S is a subtype of type T, then S must include all the same behaviours as T. This has profound implications on inheritance in object oriented programming languages. The principle infers that function signatures stay equivalent in subtypes, as well as exceptions that are thrown.
LSP has way more implications, that won’t be subject of this post. It is a broad topic that warrants an article on it’s own.
Interface Segregation
According to the Interface Segregation Principle (ISP), clients ought not be forced to depend on unused methods. In other words, keep your interfaces small and segregated.
Violations of this principle are numerous and most often also infer a transgression against the SRP. Here the same example as for the SRP
package main
type ExportSvc interface {
Serialize(interface{}) (string, err)
WriteToFile(string) err
}
A possible violation of this is that a client might only need to depend on one the Serialize function. Using this interface, she’s also aware of the WriteToFile function which creates an unnecessary dependency. Splitting the interface into two separate interafaces would solve the violation, yet still violates the SRP.
When writing tests, ISP enables us to create smaller mock objects of the dependencies we’re injecting. Thus decreasing the lines of code and the complexity.
Dependency Inversion
Many programmers hardwire business layers with the utility layers, making it harder to replace single components in a system. The Dependency Inversion Principle (DIP) is a way to solve that problem. It states
High level components should not depend upon low level components. Both should depend upon abstractions.
This basically means that instead of leveraging concrete classes, we should always rely on interfaces. Since our modules are loosely coupled, the impact of this inversion principle is kept relatively low. Go, compared to other programming languages, makes it easy to comply with the DIP, since interfaces are implemented implicitly.
Examples of this principles are trivial and hence omitted. When testing, this principle allows us to inject minimalistic classes that correspond to the interface, rather than laboriously overwriting functionality and overloading our component tests.
Dependency Injection
Dependency Injection (DI) is not part of the SOLID principles, yet it has a significant impact on our design, and hence also our tests. Following DI, the programmer injects the dependencies, rather than instantiating them on the fly. This leads to the encapsulation of the construction logic, as well as the flexible replacability of the dependencies.
The following example shows a violation of the DI practice
type Dep1 interface{}
type Dep2 interface{}
type Svc struct {
dep1 *Dep1
dep2 *Dep2
}
func NewSvc() *Svc {
return &Svc{
dep1: &Dep1{},
dep2: &Dep2{},
}
}
If one were to replace, for example, Dep1 with another implementation, the construction code would have to be altered. To satisfy DI we do the following
type Dep1 interface{}
type Dep2 interface{}
type Svc struct {
dep1 *Dep1
dep2 *Dep2
}
func NewSvc(dep1 *Dep1, dep2 *Dep2) *Svc {
return &Svc{
dep1: dep1,
dep2: dep2,
}
}
This subtle change empowers the caller to provide the dependencies. Together with the DIP, this leads to the complete independence of the implementation of a class. Take a second to appreciate the value we’ve just gained.
To create unit tests, we have to remove all the dependencies from our component to be able to test in isolation. DI, together with the DIP, allows us to do exactly that, thus it is vital to our design.
Conclusion
The above stated principles all aim at reducing effort and complexity to write tests, as well as to maintain them. Here in short a few best practices to follow
- Follow the five SOLID principles
- Avoid global variables and functions
- Inject dependencies, instead of constructing them or leveraging static or global methods
All these principles take time to adjust to, yet they ultimately lead to higher quality systems, making them a good investment.