In this blog post, I will unpack the design specifics that make software more testable, maintainable, of better quality, and ultimately better equipped to deliver on its value. While we might not always realize it, software is not naturally testable but requires specific design decisions that make it easy for testing and automation to happen at all the right levels.
Testability is the term used to describe how easy it is to write automated tests for the system, class, or method to be tested. We already know that automated tests are crucial for high-quality software; it is, therefore, essential that our code is testable.
To code for testability, you need to understand how test tools interact with a software application and typically what testers will need to test the software system in its entirety. Ensuring those measures are put in place within the design of the software.
There are a lot of measures to consider when it comes to making code more testable, so as a result, I've split this blog post into two sections to make it more digestible and ensure you give each section its due concern. I will look at 5 aspects of testability in this article and then focus on another 5 in the second article.
I will say upfront that designing for testability is not necessarily the fastest way of building an application. A lot of the principles that you will read in this chapter will require a lot more thought and work from a development perspective to build right. However, it's important to note that while we will often only need to write code once, with some levels of maintenance required on a regular basis, tests will be run against the software constantly.
So, even if the development work takes a few weeks extra to complete. With the implementation of these different practices, the payoff is gained long term when you can now increase your test coverage and get it running in a pipeline more seamlessly than had these measures not been in place. Without many of these measures, automation would prove challenging and manual testing will drastically slow down any development effort, so you want to keep the ability to automate code effectively at the center of your code design principles.
Focus on Small Executables
Testability also refers to how easy it is to unit test the code and much like the above design principles, requires the code to be relatively modular and independent in nature. This then allows for each logical element of the code to be easily tested with a set of inputs and the different outputs asserted to ensure that each component behaves as expected.
The trick with this really is in the small part though, as essentially you want any logic tree that is written to have its own set of tests to verify that the logic operates as expected. This may not be possible with every piece of code you are writing and often several decision or logic trees may be required to be placed into an independent module, though it is important to try and keep this at a minimum. The moment there is too much logic contained in a module of code the number of permutations required to test for each possible option greatly increases, as does the room for error.
Knowing that every decision and line of code is well tested gives a high amount of confidence in knowing that it will work as expected. This doesn’t mean that it will necessarily work perfectly though as there is still plenty of room for error at an integration layer. So, your code design needs to consider the need for bigger integration tools that target these API and UI layers as well.
One of the first things most testers will ask of their development team is to ensure that their respective tools can identify certain objects, at either a UI or backend level. This is especially true for UI testing where tools will require a form of object-id to be unique for each UI module on the screen to ensure that the test tool can interact with the object. A lot of developers for the sake of simplifying development will have screens render dynamically with buttons or different objects having no unique identifiers.
This essentially means that the testing tool cannot reliably interact with the object and will require the testers to look to identify other traits like the location of the object on the screen or any text contained within the object to automate its functionality. This tends to make the tests more brittle, and they will likely fail more often due to an object not being properly identified than would otherwise have been the case if the objects were all made uniquely identifiable.
Observability is incredibly important, but I am fully aware that effective test automation needs to be done at a lower level, which is why most of the below points are aimed at improving a codes ability to be unit tested and thereby reducing the need for higher levels of test automation that are slower and trickier to execute.
Dependency injection is a design choice that means rather than the class instantiating the dependency itself, the class asks for the dependency. Have I lost you with this? Well, you’re not alone as I was lost the first time, I heard it too.
Perhaps to explain it better, I will use an illustration. Let’s say you need to make use of a specific tool to perform a certain task. When we are asked to do this task, we find the tool by ourselves and once we have the tool, we use it to perform the task. However, another approach is to say that, while we still need the tool when someone asks us to perform the task, instead of getting it ourselves, we get the tool from the person that wants us to do the task.
This way, we can now instantiate the task class we want to perform and simply pass it a mocked/stubbed version of our tool in the test code. This simple change in the design of the class makes the creation of automated tests easier and, therefore, increases the testability of the code.
More formally, Dependency injection can also be described as a technique where one object supplies the required dependencies of another object.
This simple change in design thinking improves the code in many ways:
It enables us to mock/stub the dependencies in the test code, increasing the productivity of the developer during the testing phase.
It makes all the dependencies more explicit; after all, they all need to be injected (via a constructor, for example).
It affords better separation of concerns: classes now do not need to worry about how to build their dependencies, as they are injected into them.
The class becomes more extensible and can now work with any dependency with little effort.
Separation of concerns
I’ve already mentioned that modules of code should be as independent as possible. To what extent should we take this and exactly how can we design all our complex modules of code to operate as independently as possible? Well, we do this by isolating the separation of concern in what any given module is affected by.
Domain vs infrastructure
The domain is essentially where the core of the system lies, i.e., where all the business rules, logic, entities, services, etc., reside. Whereas Infrastructure relates to all code that handles some infrastructure. For example, pieces of code that handle database queries, web service calls, or file reads and writes. In our examples, all our Data Access Objects are part of what we call infrastructure code.
When domain code and infrastructure code are mixed up together, the system becomes harder to test. For instance, a [piece of code that contains SQL logic as opposed to being dependent on a Data Access Object is increasingly more difficult to work with.
The code becomes less cohesive and not just because it's two languages contained in one function, but because it covers different responsibilities – at a database layer and at a core code layer. This class now requires test cases that cover both responsibilities and makes troubleshooting issues and the maintainability of the code more complicated.
Furthermore, when you look at things from a performance perspective, optimizing code and SQL for performance are two very different skills with two vastly different ways that compilers handle the code and so to make the performance testing and code optimization issues easier, you want to ensure these two layers are separated.
And while I’ve used the database layer as an example here - mostly just because it is perhaps most common – it’s not just about the separation of database and functional code. Things that handle anything to do with infrastructure like drivers or networking infrastructure should also remain separate from the functional code and be left to modules of their own so that they can be tested independently.
Think of the impact of your code
As a developer, you're not just coding for yourself, but always part of a greater application and team and it is, therefore, your responsibility to design code that is not just functional and meeting its purpose but makes the lives of others in the stream around you easier - and to do that, you want to make it more testable.