How can you design your code so that it is testable?
Are you struggling to test code because you did not write it with testing in mind?
If so, you and your team need to design your code with testing in mind.
We all need and want code that is easy to test to get faster, quicker, and cheaper feedback on our changes.
So, here are some tips and techniques to help improve the testability of your code;
First, what do we mean by testability?
A quick google will provide you with lots of definitions, but a simple starting point is; Testability is the degree to which and how easily you can test the software.
If the testability of the software is high, then testing should be easier and more efficient.
In other words, you can think of testability as “how easy is it to test?”
To gauge your software’s testability, consider how efficiently you can test it in terms of these two factors; the number of tests you need to run to be confident it functions as expected and does not exhibit any unexpected issues. And the level or layer at which these tests can be developed and executed.
A larger number of tests or the majority of your tests being at a high level (e.g., UI, E2E, system-level), as opposed to at the unit or integration level, would typically indicate that your code is not very testable.
So, how can we improve the testability of our code?
What are the three main factors?
- Observability – the extent to which we can see that the code functions as expected and not doing anything unexpected.
- Controllability – the extent to which you can control your software. Often by controlling the inputs, state, or data on which each component operates.
- Isolate-ability – the extent to which you can test your code in isolation.
In more detail;
Can you observe the results of any decisions or state changes in your running code?
Code modules or functions that return clear state or status
Code modules or functions that modify state or status but do not return or share anything observable with the calling code
What are you logging?
Effective use of logging levels for different information types, e.g., INFO, WARN, DEBUG, ERROR.
You are only logging errors.
Can you query state, status, or data easily?
State or status is stored or queryable at all times in a DB or via an API call.
State or status is maintained only in the code’s running memory, requiring sophisticated runtime debugging tools to observe it.
Can you efficiently drive different data values into your code?
Easy to call your code component and pass the data you want it to use
Easy to provide data via a defined interface or data source, e.g., an API or DB.
No ability to pass data into your code
Data is provided by a dependency that makes it hard or impossible to control.
Can you quickly change the current state or status of your code?
Easy to call your code component and pass the state you want it to have at any point in time
Easy to provide state via a defined interface or source, e.g., an API or DB
No ability to pass state into your code or to set state before your code is executed
The state is provided by a dependency that makes it hard or impossible to control.
Can you control your inputs?
Easy to change inputs or to use mock, fake, or stubbed inputs, e.g., switch out the source of input between real/live and fake/controlled ones.
Inputs are hardcoded or otherwise unchangeable.
Can you isolate your code components and test them on their own?
Your code component can be easily called or executed on its own. Its’ inputs controlled, outputs observed independently of other code components, such as a unit of code that can be called and passed data or state, and that returns data or state that can be easily verified.
Micro-services tend to be easy to test in isolation.
Code contains many dependencies that are hard/impossible to control, meaning that it has to be run in an environment where the dependencies are met/running.
Code monoliths tend to be hard to test in isolation.
How easy is it for you to mock, fake, or stub out any dependencies for your code?
Easy to inject data via in-memory DB, file, or substitute a URL or other resource pointer for a fake one
Other attributes which tend to impact testability include;
Separation of concerns – the extent to which each code component you want to test has a single, well-defined responsibility.
Can you easily understand or define what each code component does or is what its’ responsibility is?
Clear and typically singular responsibility for a piece of code
The state is changed, or a decision is made based on data or conditions assessed by the code.
Code monoliths or code blocks in which you make multiple decisions or changes of state.
Understandability or readability – how easy is it to understand the intent of the code and how you achieve that intent.
Can you quickly understand what the code is doing?
Clean code, naming helps make intention(s) specific and meaningful, commenting to explain any complex or non-obvious code.
Obfuscated code using short naming that is not explicit or clear. Abstractions that don’t help readability or clarity Enumerations that do not help readability or clarity.
The key takeaway here is to think about how you will test your code BEFORE you write it. Doing so will help you ensure your code is; observable – so that you can see the result, state change, and data that occurs when your code executes.
Controllable – that you can easily control the input data or state for your code to use or work with
Isolatable – that you can easily verify your code in isolation, that you do not need to execute multiple dependencies together to test your code
I often find myself working with teams and code that is was not developed with testability in mind, and this means it is hard to add unit tests without first refactoring the code to make it more testable. It can be hard to know whether or not the code components behave as expected without using or employing sophisticated and often expensive tools or debuggers to observe it or control state or data during execution. Because it is difficult to isolate the code components, you often need to exercise the code and run tests in an integrated or almost production-like environment. Meaning that your feedback cycle is much longer, the time between you making the code change and getting any feedback on whether that change was right or not.