TDD - Test Driven Development - is first and foremost a way to reduce the TCO of Software Development
by Jerome Kehrli
Posted on Saturday Jan 18, 2020 at 11:23PM in Agile
Test Driven Development is a development practice from eXtreme Programming which combines test-first development where you write a test before you write just enough production code to fulfill that test and refactoring.
TDD aims to improve the productivity and quality of software development. It consists in jointly building the software and its suite of non-regression tests.
The principle of TDD is as follows:
- write a failing test,
- write code for the test to work,
- refactor the written code,
and start all over again.
Instead of writing functional code first and then the testing code afterwards (if one writes it at all), one instead writes the test code before the functional code.
In addition, one does so in tiny small steps - write one single test and a small bit of corresponding functional code at a time. A programmer taking a TDD approach shall refuse to write a new function until there is first a test that fails - or even doesn't compile - because that function isn't present. In fact, one shall refuse to add even a single line of code until a test exists for it. Once the test is in place one then does the work required to ensure that the test suite now passes (the new code may break several existing tests as well as the new one).
This sounds simple in principle, but when one is first learning to take a TDD approach, it does definitely require great discipline because it's easy to "slip" and write functional code without first writing or extending a new test.
In theory, the method requires the involvement of two different developers, one writing the tests, then other one writing the code. This avoids subjectivity issues. Kent Beck has more than a lot of examples of why and how TDD and pair programming fit eXtremely well together.
Now in practice, most of the time one single developer tends to write tests and the corresponding code all alone by himself which enforces the integrity of a new functionalities in a largely collaborative project.
There are multiple perspective in considering what is actually TDD.
For some it's about specification and not validation. In other words, it's one way to think through the requirements or design before one writes the functional code (implying that TDD is both an important agile requirements and an agile design technique). These considers that TDD is first and foremost a design technique.
Another view is that TDD is a programming technique streamlining the development process.
TDD is sometimes perceived as a way to improve quality of software deliverables, sometimes as a way to achieve better design and sometimes many other things.
I myself believe that TDD is all of this but most importantly a way to significantly reduce the "Total Cost of Ownership (TCO)" of software development projects, especially when long-term maintenance and evolution is to be considered.
The Total Cost of Ownership (TCO) of enterprise software is the sum of all direct and indirect costs incurred by that software, where the development, for in-house developped software, is obviously the biggest contributor. Understanding and forecasting the TCO and is a critical part of the Return on Investment (ROI) calculation.
This article is an in depth presentation of my views on TDD and an attempt to illustrate my perspective on why TDD is first and foremost a way to get control back on large Software Development Projects and significantly reduce their TCO.
- 1. So what is TDD exactly ?
- 2. Improving Design
- 3. Reducing TCO
- 4. An example to illustrate the TCO reduction
- 5. Conclusion / Take Aways
1. So what is TDD exactly ?
1.1 Principle of TDD
The principle of TDD is simple: when one wants to develop a new feature, one starts by writing the test that assesses how it shall work. In the next step, the functional code is developed so that the test is validated. And nothing more!
Focusing on functionalities avoids writing code without meeting a requirement satisfied by a validated test.
The principle then consists in working in small iterative cycles consisting of:
- writing the minimum possible code to pass the test;
- enriching the test base with a new test;
- rewriting the minimum code to pass the test;
- and so on...
This practice mostly comes from Kent Beck, one of the signatories of the Agile Manifesto. It encourages a simple, clean and sound design of software products and makes the developer more confident in the ability of his code to do what he wants correctly, without hiding a few bugs.
Let's take a closer look at the different stages of the TDD cycle.
- Write a test. The first thing to do when one wants to implement a new feature is to write a test. It involves understanding the functionality that one has to develop beforehand, which is a very good thing.
Execute the test(s). Then one has to run the test that he just wrote. In practice, the new test is executed, along with all those already existing. This implies that they must be very quick to execute, otherwise too much time is wasted waiting for feedback. Some IDEs even push it to the extent of running the tests continuously during the development, in order to have an even faster response.
The test must fail, since no code has been written to make it pass. In general, it doesn't event compile, because the method / class doesn't even exist.
- Write the code. Then, one writes the strictly minimum functional code required to make the test pass and nothing more. If the written code is not perfect yet, or makes the test inelegantly, it doesn't really matter for now.
- Execute the test(s) again. The developer then re-runs all the tests ane makes sure that they run successfuly and that everything is working fine.
- Refactoring. In this phase, one shall improve the code he has written. This helps to see if it may be simplified, written better, made generic, factorized, etc. One shall get rid of duplications, rename variables if some are not utmost meaningful, as well as methods, classes, etc., so that the code is clean, simple and clearly expresses its intentions. One shall separate responsibilities, maybe extract some design patterns, etc.
Following this virtuous development approaches enforces single responsibilities, separation of concerns, important code coverage, etc. and comes with multiple benefits described in the next section.
What to do when a bug manages to make it through to production?
When TDD is properly applied, it makes it simply impossible for the vaste majority of bugs to make it through to production.
However, some very tricky corner case situations may be difficult to assess with automated tests and as a consequence, even when TDD is applied by the book, it may happen that a bug passes through the cracks and is discovered late in production, sometimes after months, when the specific situation triggering the bug occurs.
This is an interesting situation and is worth discussing: what shall happen with TDD whenever a bug still manages to make it through to production?
Long story short: whenever a bug is spot in production, the resolution of the bug shall follow TDD as well:
- First, implement a new unit test or integration test that reproduces the bug. The test shall fail at first, since the bug exists.
- Then do whatever it takes to have the test passing.
This method shall simply always be respected whenever a bug that passes through the cracks is encountered. Eventually, these bug resolution tests will form the most important assets in the non-regression tests suite.
A note about 100% functional code coverage
When following the TDD methodology, one shall target 100% coverage of the functional code with automated tests, both in terms of Lines of Code and Conditions Branches.
This does not mean that sonar with its default configuration - or other code coverage measuring tools - shall necessarily report 100% coverage.
In practice, some boilertplate code doesn't neet to be tested. It's note considered as functional code.
For instance in Java, some exception catch blocks - that may be mandatory for the code to compile but that simply can't happen in practice because of the impossibility of the functional code to enter the specific branch triggering the exception - shall not be tested. Testing those would be a waste of time.
On the other hand, if the exception catch block corresponds to a specific exceptional business situation that can happen in practice, it has to have a proper unit test assessing it and it shall be covered.
Most of the time, the code coverage computation tool or the quality assessment tool - such as sonar - shall be properly configured to exclude from the coverage computation the blocks of code that would be a waste of time to test.
In practice that is rarely the case and the reported coverage never reaches 100%. This is not a problem as long as the functional code coverage reaches nearly 100%.
For this reason, there is an important distinction between the functional code and the whole code.
The essential point is that the functional code - the business meaningful conditions - are covered 100%. The technical boilerplate code doesn't need to be covered 100%.
1.2 Advantages of TDD over tests after or even no tests
Implementing automated tests - mostly unit tests and some integration tests - is a formidable development tool.
Even without TDD, automated tests provide significant benefits:
- In software development, the written functional code has to be tested continuously as it is written. Without automated tests, one needs to test the code on a live running application to assess its behaviour. In addition, when some misbehaviour is happening, one is left with the debugger to figure what is going wrong. There is no less efficient mean to figure what some code is doing than using a debugger (even though sometimes it's the only way). Implementing unit tests assessing some conditions capturing what the code under testing is doing is a much simpler way. Automated tests enable to understand the code behaviour in a simple and unitary way. Again, going through the debugger once is a while to understand why an assessment fails is still required, but to a much lesser extent.
Automated tests form a formidable non-regression tests suite. Instead of testing the live running application over and over again to search for regressions, on simply re-runs the whole automated test suite and, if it succeeds, one can be confident that no regression have been introduced by some maintenance or evolution.
This non-regression benefit also helps during the development process. Running the existing tests indicates whether the last change breaks something in the existing code base.
- Bugs passing through the cracks and making it to production are significantly reduced. Unit testing and integration testing provides a much larger coverage of code both in terms of lines of codes and condition branches over manual testing. On large software products, there is simply no way manual testing can compete with automated tests, regardless of the size, the complexity and the maturity of the test plan.
- Developers are getting more productive since they get confident in changing and evolving the code. When programming, the bigger the codebase gets, the harder it gets to move further or to change the code because it's easier to mess up. When one has automated tests, they become the safety net, allowing one to see what the mistake are, where they are, and how they affects the system. It helps identify errors in a really short period of time. These tests give developers very fast feedback when something breaks.
- Automated tests, mostly unit tests, form a very good form of detailed specification and documentation. This not only streamlines the development process but helps re-understanding the code quickly when it has to be maintained, sometimes several months or even years after its initial development.
TDD, or driving the software development with the tests, brings additional benefits over writing the tests after:
- TDD is about getting feedback. Some define TDD as being a mental model (discipline) which relies on a very short feedback loop at the code level. Getting short and frequent feedback about what the code is doing streamlines the whole development process. Quick and small feedback is much easier to understand than late and large feedback. TDD gives the ability to think more about simplicity, focusing on writing only the code strictly necessary to pass an assumption. In that sense, with TDD one challenges the wrong assumptions as early as possible and one identifies errors and problems very quickly.
The number of bugs passing through the cracks are reduced even further with TDD.
TDD enables to reach an almost exhaustive coverage of the code both in terms of Lines of Code and Condition branches.
- The increase in code coverage by tests also makes it much more straightforward to proceed with large refactorings. And without refactoring ability, one's screwed to ensure best possible design since, unless one is a genius, achieving the best possible design relies mostly on his ability to refactor (improve the design)
- TDD is ultimately about design. One is forced to write small class focused about one concern and small methods dedicated to one responsibility. TDD enforces SOLID design rules (see below). TDD enforces clean, simple and sound design since it simply makes it difficult not to say impossible to write convoluted code with TDD. One of the principal reasons behind this is that writing the tests first requires one to really consider what do he wants from the code a the very beginning.
- All of this leads to significant reduction of the TCO (Total Cost of Ownership) of Software Development
1.3 Different types of tests
There is a distinction between automated tests and unit tests. Not all automated tests are necessarily unit tests and while TDD is mostly about unit testing, other types of tests also make a lot of sense and bring value when embracing TDD.
There are basically three types of automated tests:
- Unit tests: are meant to test individual modules of an application in isolation (without any interaction with dependencies) to confirm that an individual piece of code - typically a method - is doing things right.
- Integration tests: are meant to check if different modules are working fine when combined together as a group and that their interactions is producing the expected results.
- Functional tests: are meant to test a slice of functionality in the system (may interact with dependencies) to confirm that the code is doing the right things.
Functional tests are comparable to integration tests in some way, with the difference that they are intended to ensure the sound behaviour of the entire application's functionality with all the code running together and deployed in a realistic way, nearly somehow a super integration test.
Unit tests considers checking a single component of the system whereas functional tests are intended to assess the conformity of a whole feature with its specifications (such as the user story and its acceptance criterias).
Functional tests are intended as a safety net and form a good way to automate acceptance tests. Working with the Product Owner, the Product Managers or Business Experts, developers can formalize acceptance criterias and automate acceptance tests in the form of functional tests. This is actually the best possible way to implicate business experts in assessing the product quality.
Unit tests alone are not sufficient since it's nearly impossible to achieve near to 100% coverage of the functional code with unit tests. For instance, assessing behaviour related to interactions between modules is by design impossible with unit tests.
This is where integration tests kick in. Integrations tests are meant to cover and assess behaviour that unit tests are not targeting by assessing the well behaviour of the different units when working with each others.
These different kind of tests don't have the same level of complexity and the same target in terms of coverage. Yet they have a large overlap of scope which can be represented as follows:
All these tests shall be operated in a consistent way, using maven for instance if maven is the chosen tool to build and package the software.
In terms of technology though, while unit tests and integration tests usually share a common base, this is not necessarily the case for automated tests which often rely on a completely different technical stack.
The technologies quite widely used for these different kinds of tests can be reprsented as follows :
Again, while TDD is mostly about unit testing, and depending on the approach as well as the stage of the development, some other types of tests are fully part of the TDD scope.
Eventually, all these tests together form the non-regression test suite.
For instance while unit tests are intended to tests a method (or other forms of units) specifically (perhaps under multiple conditions), integration tests are intended to while functional tests are expected to tests end to end features and mimim user behaviour on the software.
1.4 Styles of TDD
There are actually two quite opposed approaches when applying TDD on a large software development project, the Inside-Out approach and the Outside-In approach:
1.4.1 Inside-Out TDD (Bottom Up / Detroit School / Classic Approach)
The first approach is Inside-Out TDD, which is sometimes called the "Detroit School" of TDD, or bottom-up, or even classical approach. With Inside-Out TDD, one starts by writing tests and implementation for small aspects of the system. The aim is to grow the design through a process of refactoring and generalizing the codebase as tests become increasingly specific.
Although all developers should be mindful of the bigger picture, Inside-Out TDD enables developers to focus on one thing at a time. Every component (i.e. an individual module or single class) is created one after the other and pile up until the whole application is built up.
One one hand, individual components written this way could be deemed somewhat worthless until they are connected together by higher level components and working together. Also wiring the system together at a late stage may constitute a higher risk in terms of overall design consistency. On the other hand, focusing on one component at a time helps parallelize development work efficiently within a team and refactoring is here to ensure the overall design when the components starts to pile up.
The main characteristics of the Inside-Out approach are as follows:
- Emergent Design happens during the refactoring phase.
- Very often tests are state-based tests.
- During the refactoring phase, the unit under test may grow to multiple classes.
- Mocks are rarely used, unless when isolating external systems.
- No or little up-front design considerations are made except for breaking it in small features. Overall design emerges from code and is improved with refactoring..
- Inside-Out TDD is often used in conjunction with the 4 Rules of Simple Design.
Its advantages are often considered as being the following:
- It's a great way to avoid over-engineering.
- Easier to understand and adopt due to state-based tests and little design up-front.
- Good for exploration, when one knows what the input and desired output are but one doesn't really know how the implementation looks like at the early stage.
- Great for cases where one can't rely on a domain expert or domain language (data transformation, algorithms, etc.)
Of course, it suffers from some commonly accepted drawbacks:
- Exposing state for tests purpose only.
- Refactoring phases are normally bigger and more complexe when compared to Outside-In approach (more on that below).
- Unit under test becomes bigger than a class when classes emerge during the refactoring phase. This is fine when we look at that test in isolation but as classes emerge, they evolved and are extended significantly as they are being reused by other parts of the application. As these other classes evolve, they often break completely unrelated tests, since the tests use their real implementation instead of mocks.
- The refactoring step (improvement of the design) is often skipped or not done properly by inexperienced practitioners, leading to a cycle that looks more like RED-GREEN-RED-GREEN-...-RED-GREEN-MASSIVE AND YET NOT EFFICIENT REFACTORING.
- Due to its exploratory nature, some classes under test are created according to the "I think I'll need this class with this interface (public methods)", making them not fit well when connected to the rest of the system and requiring further more complex refactorings.
- Can be slow and wasteful since quite often one already knows that one cannot have so many responsibilities in the class under test. The classicist advice is to wait for the refactoring phase to fix the design, only relying on concrete evidence to extract other classes. Although this is good for novices, this is often somewhat a waste of time for more experienced developers.
An illustration of the inside-out method could be as follows:
1.4.2 Outside-In TDD (Top Down / London School / Mockist Approach)
Second approach is Outside-In TDD, which is sometimes called "London School" of TDD, or Top-Down or even Mockist Approach. Using this approach, development begins at the very top of the system's architecture and is grown downwards. The aim is to progressively implement increasing functionality of lower levels, one layer of the system at a time.
As a result, a reliance on mocks is required to simulate the functionality of lower level components.
Outside-In TDD lends itself well to having a definable route through the system from the very start, even if some (most if not all at first) parts are initially mocked.
The tests are based upon user-requested scenarios (or user stories with proper acceptance criteria well defined), and components are wired together from the beginning. This allows a fluent API to emerge and integration is performed from the very start of development.
By focusing on a complete flow through the system from the start, knowledge of how different parts of the system interact with each other is required from the very beginning. A little time to come up with a proper architecture of the system and even a rough design of every layer and functional blocks is required.
As required components emerge, they are mocked or stubbed out, which allows their detail to be deferred until later, when their time comes. This approach means that the developer needs to know how to test interactions up front, either through a mocking framework or by writing their own test doubles. The developer will then loop back, providing the real implementation of the mocked or stubbed components through new unit tests as the development moves forward.
The main characteristics of the Outside-In approach are as follows:
- Different from the classicist, Outside-In TDD prescribes a direction in which we start test-driving our code: from outside (first class to receive an external request) to the inside (classes that will contain single pieces of behaviour that satisfy the feature being implemented).
- One normally starts with an acceptance test which verifies if the feature as a whole works. The acceptance test also serves as a guide for the implementation as it progresses to lower layers.
- With a failing acceptance test informing why the feature is not yet complete (no data returned, no message sent to a queue, no data stored in a database, etc.), one starts writing unit tests. The first class to be tested is the class handling an external request (a controller, queue listener, event handler, the entry point for a component, etc.)
- As one already knows that the entire application won't be built in a single class, one makes some assumptions of which type of collaborators the class under test will need. One then writes tests that verify the collaboration between the class under test and its collaborators.
- Collaborators are identified according to all the things the class under test needs to do when its public method is invoked. Collaborators names and methods should come from the domain language (nouns and verbs).
- Once a class is tested, one picks the first collaborator (which was created with no implementation) and test-drive its behaviour, following the same approach one used for the previous class. This is why Outside-In is called this way: one starts from classes that are closer to the input of the system (outside) and move towards the inside of the application as more collaborators are identified.
- Design starts in the red phase, while writing the tests.
- Tests are rather about collaboration and behaviour, and only little about state.
- Design is refined during the refactoring phase.
- Each collaborator and its public methods are always created to serve an existing client class, making the code read very well.
It's advantages are often considered as being the following:
- Since most classes are designed to serve the client calling code, the design is client-centric. This is not only better conceptually but also helps enforcing the domain language when naming methods.
- Refactoring phases are much smaller, when compared to the classicist approach.
- Promotes a better encapsulation since usually less state is exposed for test purposes only.
- More aligned to the original ideas of Object Oriented Programming: tests are about objects sending messages to other objects instead of checking their state.
- More suitable for business applications, where names and verbs can be extracted from user stories and acceptance criteria (domain model, domain language).
Of course, it also suffers from some commonly accepted drawbacks:
- The architecture needs to be defined up-front and a significant design work needs to be done as well before starting to work on the first feature.
- Much harder for novices to adopt since a higher level of design skill is necessary.
- Developers don't get feedback from code in order to create collaborators. They need to visualize collaborators while writing the test.
- May lead to over-engineering due to premature type (collaborators) creation.
- Less suitable for exploratory work or behaviour that is not specified in a user story (data transformation, algorithms, etc).
- Bad design skills may lead to an explosion of mocks.
- Behavioural tests are harder to write than state tests.
- Knowledge of Domain Driven Design and other design techniques, including 4 Rules of Simple Design (SOLID, see below), are required while writing tests.
- Doesn't enforce simple and clean design as much as the classical approach (the emergent design is weaken)
An illustration of the Outside-In method could be as follows:
At the end of the day, every development team shall ask itself what it is more comfortable with, what makes more sense considering the product management and development organization around it, the maturity of the software architects and their ability to come up with a design first or a good breaking down in functionality.
One method is not better than the other one even though, again, Inside-Out fits better exploratory work and technical software while Outside-In works better for Business Applications and large software development projects.
But at the end of the day it's more related to the culture of the software develpment team and different cultures might prefer one or the other.
2. Improving Design
2.1 Design by testing and initial design
TDD is eventually a tool to help us design faster, first because of the necessity to write testable code, and second by easing refactoring.
The fact that one needs to write code that fullfills a unit test, magically forces to write code with simple, clear and sound design.
There really is some kind of magic in this which is interesting to explain a little.
Whenever one starts by writing some code and perhaps only then write a few unit tests (most of the time only for the methods that are easy to test this way), the testability of the code is not a key concern and it's likely that significant portions of it won't be testable using unit tests.
For the sake of solely testing and test coverage, integration tests can help a little of course, but as far as design is concerned, they don't.
Even when one tries to write code with testability in mind, ending up with code that is really only a well-thought collection of single-responsibility classes and methods each doing one and only one clearly identified functional action is really hard, not to say impossible.
With TDD, one writes the unit test first.
Writing a unit test that tests and assesses a single and unique clearly identified behaviour (or responsibility) is natural to everyone, even junior developers. When code is implemented by strictly following a logic of "making this unit test pass", it naturally and logically ends up in being a collection of single concern methods and class.
This really happens magically because it simply becomes natural to write the code this way. Implementing a unit test that would require a very convoluted code to make it pass is nearly impossible.
This is the magic part in TDD.
Interestingly, following TDD, even very junior developers end up with an initial design that is way simpler, cleaner and sound than what experienced developers could do without TDD.
Specifically, TDD is especially good at ensuring Low Coupling between different modules, different classes, etc. by forcing to think and design interactions and dependencies very carefully.
But that is not all in TDD that related to design, the next section is even more important.
2.2 Emergent Design
The next level of clarity and simplicity is then achieved with refactoring, which TDD makes easy and natural, thanks to the way it promotes nearly 100% functional code coverage both in terms of lines of code and condition branches.
TDD is a design help tool. The quality of the design one gets out of TDD depends largely on the capacity of the developer to use refactoring to Design Patterns, or refactoring to SOLID principles.
We say that the developer makes the design emerge using continuous refactoring. Applying TDD without doing constant refactoring is missing half of the job and will often lead to systems not being designed as good as they could / should be.
TDD is always associated with this important notion of "emergent design". In agile, one often builds the software incrementaly, feature by feature. So one can't know right from the start what fine design will be required, it will evolve / emerge as the development moves forward. So any time one adds a new piece of functionality, one does some refactoring to improve the design of the application. It's continuous / incremental design. That's why TDD is key in agile development processes.
Doing a lot of design upfront (BDUF = Big Design Up Front) is not incompatible with TDD though, on the contrary. There is nothing wrong with starting a piece of sofware while having the design already in mind. TDD will then enable one to put that design in place quickly. And in the case the design one thought about was wrong, TDD will allow one to refactor it nicely and safely. Again, it's just a tool, it's there to help one develop his ideas faster and design stuff safely and faster.
Now RDUF - Rough Design Up Front - probably makes more sense when embracing TDD.
When using Outside-In TDD, the RDUF is a strong requirement along with a proper pre-identification of the architecture of the software product.
In every case, one should never try to do emergent design without being willing to do some constant refactoring, they both go together and it does really require a lot of discipline
2.3 Design principles to identify refactoring opportunities
In the world of Agile projects and Agile design, several principles shall be respected to help the code keep a clean, simple and sound design.
First, the SOLID principles:
- Single responsibility principle (SRP) : A class should have one and only one reason to change, meaning that a class should only have one job, one single respnsibility. Note that the same should apply to a method, a package, one could consider even a whole application, with different levels of abstraction of course.
- Open-closed Principle (OCP) : Objects or components should be open for extension, but closed for modification. Open for extension means that we should be able to add new features or components to the application without breaking existing code. Closed for modification means that we should not be able to introduce breaking changes to existing functionality, because that would force one to refactor a lot of existing code.
Liskov Substitution Principle (LSP) : Every subclass / derived class should be substitutable for its base / parent class. In other words, a subclass should override the parent class methods in a way that does not break functionality from a client's point of view.
The LSP stats that whenever one is tempted to introduce some inheritance between classes but would break this principle in doing so, then one should consider composition instead of inheritance.
At the end of the day, it's about answering the question "Is that X really an Y ?" and if the answer is positive, then inheritance between X and Y can be used. For instance, the answer to "Is a cat really an animal ? is clearly yes. But the answer to the question "Is than appartment really a room ?" is clearly negative even though they share some common properties - such as size, volume, number of light switches, etc. - some common methods - such as Enter, Leave, etc. This is a good indication that Apartment should not inherit from Room, an appartment should own a collection of rooms. Now The Composite pattern would help factorize the common stuff.
- Dependency Inversion Principle (DIP) : Entities must depend on abstractions not on concretions. It states that the high level module must not depend on the low level module, but they should depend on abstractions.
Then, some common sense principles:
- YAGNI - You ain't gonna need it : Don't implement today something that is not strictly required today. When one thinks of some cool feature and one's tempted to implement it because it may one day be required, one shall simply never implement it today, rather implement it that one day, when the need is confirmed, not today. This way, if eventually it's not required, one doesn't loose the amount of time needed to develop it.
- DRY - Don't repeat yourself : Use sonar or else to identify every piece of duplicated code or feature and factorize the it to eliminate the duplication. Take the opportunity to identify the duplicated code responsibility and introduce a new proper abstraction.
- KISS - Keep It Simple and Stupid : Keep design as simple as possible. This sound easy ... but it's not. Actually coming up with the simplest possible design is much harder and requires a lot more thoughts than settling for the first idea that comes in mind.
- Design Patterns : Introduce Design Patterns when identifying an opportunity for it.
3. Reducing TCO
3.1 Implementing Automated tests
Automated tests reduce maintenance costs significantly since they:
- form a formidable form of documentation aimed at understanding the code much faster when it needs to be maintained and/or evoluted, sometimes months or years after the initial development,
- prevent from long sessions of manual tests to assess the behaviour of the application on edge cases,
- prevent from deploying the code over and over again in a live running application to test it as the development is ongoing,
- prevent from relying exclusively on the debugger to understand misbehaviour. Using the debugger is highly inefficient. Unit and Integration tests don't entirely anihilate the needs to use the debugger once in a while, but significantly reduce this need,
- finally, unit tests (as well as integration tests) prevents a lot of bugs from passing through the cracks and making it to production, being discovered weeks or months later when a specific conditions occurs and making the business users as well as the developers loose a lot of time to figure and fix.
All these benefits that reduce the TCO comes out of the box when one reaches a good coverage of the lines of code and the condition branches with automated tests.
A good coverage of the code with unit tests means reaching 80% of lines of code and condition branches covered.
The 80/20 rule states the following : "If 100 days would be required to cover 100% of the functional code in lines and condition branches coverage, then it's likely that only 20 days are required to cover 80% of them and the remaining 20% to be covered require the additional 80 days. This overwhelming investment is not worth it and one is better of limiting the invested development effort to the 20 days to cover 80%. of the code".
3.2 Embracing TDD
Implementing unit tests after the code suffers from two important drawbacks:
- It doesn't enforce, simple, clean and sound design. The emergent design approach - relying on testability of the code and refactoring - is not enforced whenever one writes tests after the code.
- Because of the previous problem, covering (nearly) 100% of the functional code with tests is overkill and one limits to the 80/20 rule.
- Since the design is not as simple, clean and sound as it shall be, long term maintenance and evolution of the code will be more expensive that when the design is as good as it can be with
These drawbacks have direct consequences on the TCO.
Hence the reason for Tests Driven Development.
- Because a unit test is written first and the code written after limited to each and every line of code required for the unit test to run successfully, the functional code coverage both in terms of lines coverage and condition branches reaches (almost) 100%.
- The code is forced to be simple and clean because it has to fullfill the specification of a single unit test. When following TDD, it's impossible to write convoluted code since it would have been impossible to implement a unit test for this convoluted code first.
- Thanks to the nearly 100% functional code coverage by automated tests and to the simple design, refactoring is not only always possible but also simple and straightforward.
These advantages have direct benefits on the maintenance cost since:
- the need to spend long hours re-understanding the code over and over again every time it needs to be maintained is significantly reduced futher, benefiting from the simple, clean and sound design enforced by TDD,
- the need to use a debugger to figure and understand misbehaviour the code, thanks to both the documentation that the test form and the simple and clean design, is almost eliminated,
- the need to deploy the code in a live running application to test is reduced significantly further.
TDD is really about embracing unit and other formats of automated testing to the next level which benefits first and foremost to the TCO.
The next section will illustrate this statement with an example.
4. An example to illustrate the TCO reduction
4.1 Illustration Example
Let's take an example as an illustration of the gain in TCO when coding with TDD.
This example assume that some code must be developed, representing some 10 days of work.
This code will be developed following 3 methods:
- A. No automated tests whatsoever
- B. Automated tests implemented after the code
- C. Strict TDD following Bottom-Up approach
For the sake of illustrating the potential TCO gain with TDD, the code will experience some maintenance after a few weeks (next maintenance) and then a major evolution (further evolution) after a few months.
In details, the development and maintenance tasks in the illustration scenario are as follows:
Initial development : initial development of the feature down to production rollout
- Development Time : this is the initial development time of the first version of the feature
- Debugging Time : this is the debugging at development time, on the live running application to polish the behaviour
- Manual Testing time : this is the manual testing of the application at development time on the live running application
- Pre and Post-Production Debugging Time : this is the additional debugging just before and after the features enters production, most of the time required when the test coverage is not good.
After a few weeks maintenance : few weeks after, a small set of changes are required
- Next maintenance re-understanding time : time lost on re-understanding the code and doing the small changes
- Next maintenance Debugging time : time lost again in debugging the code to figure and assess its behaviour
After a few months evolution : few months after, an important evolution is required.
- Further evolution re-understanding time : time lost on re-understanding the code
- Further evolution implementation time : time required to implement the evolution
- Further evolution manual testing : time required to tests the evolution manually
- Further evolution non-regression testing : time required to re-test the feature as a whole and assessing the evolution didn't break anything.
This is a simplification over what could be a real development and maintenance scenario of course since it leaves out all aspects that are not relevant to compare the different methods such as Documentation, Acceptance testing by business users or the product owner, etc.
Each and every approach listed above is discussed hereunder in terms of advantages and drawbacks related to costs.
4.2 No automated tests whatsoever (A)
The costs for the different steps above of the software component development and maintenance lifecycle in the case of the "no tests" method are as follows:
We can see that the actual coding cost ("Development Time" and "Further evolution implementation time") is only a tiny part of the whole development and maintenance lifecycle.
Debugging, manual testing and struggling to understand the software again after a few months represents a significant portion of the whole TCO.
4.3 Automated tests implemented after the code (B)
The costs for the different steps above of the software component development and maintenance lifecycle in the case of the "test after" method are as follows:
The actual coding time becomes a lot more significative compared to the other activities (debugging, manual testing, etc.) which are significantly reduces thanks to the introduction of automated tests. Their cumulated cost remains significant though.
4.4 Strict TDD following Bottom-Up approach (C)
The costs for the different steps above of the software component development and maintenance lifecycle in the case of the "test before" (TDD) method are as follows:
We can see that most of the TCO is related to coding activity, either writing tests or the functional code.
Other activities are reduced to marginal levels.
4.5 How do these methods compare with each others in regards to TCO?
We can now compare how these methods compare together in termd of TCO.
Let's first explain how these different method diverge from each others on each and every task of our scenario :
|No automated tests||Tests implemented after code||TDD|
|Development Time||Not implementing any automated tests makes the "purely" development part of the process quicker indeed. There is much less code to be written. However this illusional gain will need to be paid later on. In addition, the need to deploy the application to run live tests after every block of code implemented reduces the gain. One is better off assessing the code with unit tests instead of with a running application.||Implementing tests after writing the business logic code is better than nothing, it prevents from the need to test the code within the live running application. It comes with a little additional cost, the need to write these unit tests. The problem here is mostly that impementing tests after the code doesn't benefit from the first advantage of TDD which is ensuring a clear, simple and sound design. In addition, when writing the tests after the code, one struggles to have a good code coverage. In most-if-not-all cases the code coverage is way below what is achieved with TDD. This will be paid later on.||With TDD, tests are implemented first. This forces the code to have a clear, simple and sound design and to be utmost testable. This comes with an additional cost over tests implemented after. However, the testing, the maintainance and the future evolution of the code will tremendously benefit from the almost exhaustive coverage of the code by automated tests and the simple design.|
|Debugging Time||Without unit tests, one is left with a debugger to spot the misbehaviour of the code. Debugging a running application to figure what the code is doing and where the problems are form the worst possible way to develop software.||The unit tests are preventing from loosing so much time with a debugger here. However, the poor coverage makes it so that one still needs to rely quite a lot on the debugger to figure the interactions between the different parts of the code and understand misbehaviours.||The almost exhaustive coverage by unit tests makes it almost entirely useless to debug the running application to figure mishbehaviours and side effects. Everything, including edge cases, is properly covered by unit tests and the debugger is only required very rarely to understand tricky code parts.|
|Manual Testing time||Debugging is one thing, but the worst aspect without unit tests is that one needs to tests the whole behaviour of the code manually. And this is where it can get quite tricky, sometimes several minutes of manipulations are required on the UI of the running application to put in place the conditions required to test a specific edge case of the business logic.||Tests prevents manual testing to some extend only when they are not exhaustive. Most of the time when tests are implemented after the code, edge cases are not covered and as such manual testing is still required quite extensively to assess the behaviour of the code on edge cases.||This is one of the most striking advantages of TDD. The almost exhaustive coverage of condition branches and code by automated tests reduces significantly the need for manual testing. Just the tricky integration aspects remain to be tested manually.|
|Pre and Post-Production Debugging Time||Without integration tests, most of the time when the application is prepared for production and/or integrated in a realistic production environment for the first time, a whole new range of corner cases appear and require a new set of very lengthy debugging session, not to mention the need to reproduce the production environment on the developer's computer first. In addition, after the production roll-out, specific conditions triggering new bugs will most certainly occur and make busines users as well as developers loose a lot of time to figure and fix.||Unfortunately, the poor coverage of condition branches as well as the lack of good integration tests reproducing different production situations most of the time prevent from benefitting from the advantages of the tests. When tests are implemented after the code, most of the time an important level of debugging under the specific conditions of the production environment is still required. Nevertheless, automated tests prevents the majority of bugs to pass through the cracks and make through post-production rollout.||Unit and integration tests can easily reproduce the whole range of specificities of the possibles conditions around the code being tested and assessed. Especially with intgeration tests, developers have the possibility to reproduce different production conditions and assess the well behaviour of the code under these specific conditions. This prevents most-if-not-all of the production debugging nightmare.|
|Next maintenance re-understanding time||Unit and integration tests form a formidable form of documentation for the code. Without any of these, whenever a developer needs to apply a maintenance on some piece of code after a few months, he needs first to dedicate the required amount of time to understand the code all over again.||With unit and integration tests, the developer benefits from a surprisingly good form of documentation to understand the code very quickly and be fast in a position where he can apply the maintenance changes. However, writing the tests after the code doesn't enforce the clean, simple and sound design that TDD brings. As such, without TDD, some time is still lost due to the need to understand sometimes quite convoluted code.||With TDD, the developer doesn't only benefit from the exhaustive automated tests forming a good documentation, he also benefits from the fact that TDD enforces clean, simple and sound design and can underdstand the code produced this way much faster.|
|Next maintenance Debugging Time||Without tests, the need to debug the code over and over again at every maintenance kicks in. Deploying the code in a live application is the only way to figure and understand it's misbehaviours.||The unit tests are preventing from loosing so much time with a debugger here. However, the poor coverage doesn't entirely prevent from its usage to understand some tricky part of the code or some complex interactions and side effects.||The almost exhaustive coverage by automated tests makes it almost entirely useless to debug the running application to figure mishbehaviours and side effects. This is especially important when maintaining the code or evoluting it months or years after it's been initially written. Finally, with TDD the proper reaction whenever a bug is encountered is implement a unit or integration test that reproduces the bug and assess the wrong behaviour and then fixing the failing tests. This is a much more efficient way of fixing a bug than debugging.|
|Further evolution re-understanding time||Same as above. Without unit tests documenting the behaviour, one is left with reading the code itself to figure what it does.||Same as above, the developer benefits from unit tests to understand and assess the expected behaviour of the code which comes with a significant gain of time when needed to maintain or evolve the code sometimes several months after the initial development.||TDD comes with better and more tests, making the whole process even more efficient. In addition, the enforcement of a simple, clean and sound design makes the code itself much more readable which comes with a great increase of TCO gains.|
|Further evolution implementation time||Once all the required time to understand the code all over again is invested, the developer can proceed with implementing the evolution. Not writing any tests is again quicker of course. But that gain is an illusion and without tests a lot of time will be lost further on the process.||Writing the tests after the development does take some additional development time, of course. But for all the reasons already presented, this time lost will be regained with huge benefits further on the process. Now again writing the tests after the code doesn't lead neither to an optimal code coverage nor to the best possible design which will have consequences later.||Here as well again the development of the tests will require some additional coding time but the other acitivites will be significantly reduced thanks to these almost exhaustive automated tests suite, not to mention the simple design which makes the whole evolution process easier.|
|Further evolution manual testing||Same as above. Without unit tests, one is left with manual testing of every aspect of the feature and all corner cases on the live running application. This takes a lot of time and more importantly this has to be done over and over again everytime the feature evolves.||Again, writing unit tests after the code is better than nothing of course. But doing so, one struggles to come up with a sufficient coverage of the code with automated tests. And in this case at least some level of manual testing of the feature and egde cases is required.||Again, with an almost exhaustive coverage of code both in terms of lines of code and in terms of condition branches, the need for manual testing is significantly reduced. Only specific integration concerns and very rare border cases need to be assessed on the live running application. And most of the time when a glitch is discovered, it comes from a lack of prevision of some corner cases, almost never from a bug passing through.|
|Further evolution non-regression testing||This is perhaps the biggest problem from which a software development project not leveraging on automated tests will suffer. Without a proper suite of automated tests to assess the non-regression of the software, one is left with manually testing almost the whole application each and every time some code is changed. This comes with the most amazing hidden cost and is the price to pay when not investing on automated tests at the development time.||Automated tests, even when written after the code, form a formidable protection against regressions. Most of the manual testing needed against regressions is prevented by the suite of automated tests. In case the coverage of the functional code is not 100%, which is the case most of the time when tests are written after the code, a little level of manual testing is still required.||This is another one of the most striking benefits from TDD: the fact that the test suite form a formidable non-regression testing approach. With an almost exhaustive coverage of the code with automated tests, non-regression testing boils down to simply running these tests.|
As a consequence, the TCO in terms of required man/days diverge quite a lot between the three different approaches:
The scenario above gives us the following figures in terms of man/days required for every approach:
- 37 M/D for the approach without any test
- 30 M/D for the approach with tests written after the code
- 26 M/D for the TDD approach
Which represents the following difference
- 20% TCO gain when working with a consistent suite of automated tests over the no tests approach
- 10% addition gains with TDD over the tests written after approach.
This represents a 30% reduction of TCO when embracing TDD over an approach without a comprehensive suite of automated tests.
On software development projects requiring millions of dollars of investment, this represents a more than significant gain.
5. Conclusion / Take Aways
I believe that the most important take away when reading an article about TDD is that TDD is eventually the only way to recover some level of mastery on software development processes.
Please allow me to develop this statement.
Software Engineering forms a very specific and peculiar domain in the engineering business. Let's compare its situation with Civil Engineering for instance. We are building bridges for litterally several thousands of years. Today, even a 10 years old child can have a basic understanding of how a bridge shall be built, some pilars should be anchored in the ground and a deck shall be layed on top of these, etc.
Everyone is able to figure what would be the trivial steps when building a bridge.
A software product is something completely different. Due to its very abstract nature, building large software products is very hazardous. In contrary to other engineering domains, it's nearly impossible to estimate the required effort to develop a large software component and the reality shifts simply always from the plan.
And this is not even accounting debugging, maintenance and evolutions.
Without TDD, whenever a development team believe it's quite close to completing the project is most of the time also the very moment it just starts figuring the tons of bugs that will need to be solved and the tremedous amount of work that actually still remains to be done.
TDD is a way to get the control back.
TDD enables to reduce significantly maintenance and evolution costs and at the same time master the software development process. With TDD, the implemented code is most of the time almost production ready from a functional perspective and pre-production debugging sessions are largely reduced.
But more importantly TDD enables to smoothing the future evolutions of the software product by significantly improving its design and providing an exhaustive set of non-regression tests out of the box.
At the end of the day, this significant reduction of the TCO is the most important aspect of TDD. The pressure to deliver should never dictate whether one uses TDD or not. The time gained at development time when skipping automated tests is an illusion. Eventually much more time will be lost without tests. And then again TDD is not only about tests...