Software Testing: Design Code with Testing in Mind
A best practice when writing new code is to design and structure the code from the ground up with testing in mind. Questions such as “how are we going to test the code?” should be considered early on. This blog post aims to explain why this is. Furthermore, the blog post gives some best practices to follow in order to make testing of the code easier.
In software development it might be lucrative to first solve a technical problem and to only afterwards consider how to write tests for the program. However, if testing was not considered during the design phase, it can lead to having to choose between two sub-optimal options: either you have to (partially) re-design to enable proper testing or testing the program is hard.
If testing was not considered during design, you might have to choose between one of the following:
• Re-design the product to enable testing
• Testing the code is expensive
How do you design your code so that testing would be as easy as possible? Following normal best practices such as using clear abstractions, separation of concerns and dependency injections go a long way. When you have small parts of code that can be tested in isolation, it is easier to achieve meaningful tests. Firstly, the tests test exactly the small part they are meant to test. Furthermore, the tests fail only if the exact code breaks. Lastly, when testing a small amount of code it is typically far easier to test both error- and corner-cases.
To create testable code, use:
• Clear abstractions
• Separation of concerns
• Dependency injections
Clear abstractions
Let’s extend upon what we mean by clear abstractions. Consider you need to write a class that interacts with a database. Now, if your interactions are properly generalized, it should not matter what the actual database implementation is. This can help with code maintainability, refactoring and extendability, as you can upgrade or even change the database implementation completely. However, what is essential for testing, is that you can replace the database implementation with a mocked implementation.
The image above is something typical one can find in programming books about Object Oriented Programming (OOP). As “My Class” only interfaces with “Abstract Database”, it should not matter what the actual database implementation is.
While clear abstractions made it easy to change the production database from PostgreSQL to MySQL or MariaDB, it has also made it easy to change the database in testing into a mocked database.
Separation of concerns
Closely related to clear abstractions we have separation of concerns. The former is about abstracting the interfaces while the latter is about abstracting the functionality itself. Most software is rather complex and the best way to manage complexity is to split it up into smaller pieces. It is easier to understand with an example.
Let’s say we are building a car. The car needs an engine, steering wheel and tires among other things. If we design them all together, we have to manage a far greater complexity than we have to, if we design everything separately.
The internet stack, or the IP stack, is prime example of a highly successful separation of concerns. Each protocol implements a single layer. In general, each protocol can be designed and tested in isolation of all the other protocols. Therefore, both design and testing is far easier.
In the ISO OSI model, each layer takes care of a single function.
Separation of concerns is beneficial also in regards to testing. Since we have split our product into smaller parts, we can test these parts in isolation. As we test in isolation, when tests fail we know exactly where to look.
Dependency injection
Generally, dependency injection is needed to enable mocking. Consider the pseudo-code
MyClass::constructor() { this->database = new DatabaseConnection("dbserver.testing.company.com") }
In this example, the constructor of MyClass
will open a new database connection to a live database. This requires a live database to exist, not only in production, but in testing as well. Furthermore, the testing database needs to include suitable data for the tests to run and pass.
Now, instead of having MyClass
own the database connection, we can dependency inject it. Consider the following pseudo-code
MyClass::constructor(DatabaseConnectionInterface db_connection) { this->database = db_connection }
This code does not open a new database connection. Instead, it has to be done elsewhere in the code. But, for testing this means we can write a mocked database and pass it to the function as a test parameter, and no live database is required for testing.
Summary
In this blog post we advised to consider testing already during product design. If testing is left out and considered only afterwards, it can lead to cumbersome testing or partial re-design of the product. Furthermore, we discussed how using clear abstractions, separation of concerns and dependency injection helps making testing easier.
This article is part of Omoroi’s blog series on Software Testing. You can reach us at blog@omoroi.fi or discuss in this LinkedIn thread.