A few months ago, my team was working on building an application offering online coupon codes to the masses. An important discovery was made during the project. The development team realized that a small negligent change made in a ‘not so critical’ piece of code was capable to break certain, sometimes important application parts. Loss in business would be inevitable, if preventive, timely action was not taken.
A detailed study of application's code base revealed that business logic was tightly coupled to the framework in use. As code was scattered across files, distinguishing between business logic and framework code was difficult. The MVC provided by the framework was misinterpreted as a wrapper for all business logic. Multiple hands having multiple styles of coding only made the situation worse. This rendered the code base highly unstable. An identified way to ensure application’s stability was to test code base programmatically.
Test Driven Development
My team came up with a plan - Let's move all business logic into classes, as per SOLID principles. Let’s test these classes right from the beginning. Enter Test Driven Development (TDD).
TDD is an approach to development that emphasizes on testing code even before the code exists. This is also known as test-first. Unlike traditional development, tests drive functional development in TDD.
1. Do not write production code unless you have a failing test case.
2. Make minimum required changes/additions to production code (just for the test to pass).
3. Repeat above steps till functionality is complete.
It is a good practice to keep individual tests as small as possible. That is, practically testing just one thing in a single test, as stated in the Single Responsibility Principle.
Applying TDD on the Project
We started writing unit tests. Soon we realized that unit tests were not enough. This was because unit tests did not test the databases. Unit tests mainly test business logic by mocking any external dependencies including data inputs. Unit tests did not account for errors in data. But we had to make sure the data was correct too. And so, we started writing Integration, API and CLI tests in addition to Unit tests. With this setup, there was no way something could go wrong without getting noticed.
As a part of CI & CD, we ran all the different tests. We wrote a hook that would not let the version control commit happen unless all the tests pass. This hook also ensured coding standards are met, code is optimized and is error free. This way, if some of the tests failed, we immediately knew we broke something in the current commit.
After a year or so of development, the application was 100% test covered and highly stable. As the process matured, development efforts reduced. But the test suite had grown rapidly. Running all the tests was now taking much longer.
Where TDD worked
TDD molded the functional code such that all scenarios, negative and positive were foreseen and the bug count was reduced considerably. The focus was on writing minimum code necessary to pass tests. This ensured that designs were cleaner and clearer, as compared to applying other methods.
TDD led to a deeper and earlier understanding of product requirements. It ensured test code effectiveness, and helped maintain a continuous focus on software quality. It revealed a self-documenting nature of the tests. So, by reading the tests, a developer would know what should be expected of the functional code.
TDD reduced debugging efforts. When a certain test failed, it was easier to point to the exact line in the functional code that had failed. It particularly helped while refactoring, or merging code. Developers in TDD teams will be able to narrate many such incidents where the tests become saviors.
TDD takes time to learn and adapt. Initially, manual testing felt easier, while TDD was time consuming. Ultimately as the team matured, development, debugging and refactoring time reduced considerably.
As the tests started growing, we had to employ an external service solely to replicate the production server environment and isolate the running of tests. This might not be possible for all teams.
Writing complex code while following TDD was difficult. It sometimes felt like the tests only added more complexity. But being unable to test a piece of code, usually points to flaws at the architecture level in the application. Flexibility (in altering architecture, etc) goes a long way in such cases.
Recommendations for applying TDD to your Project
TDD, in a way, dictates how the application’s architecture should be. If you want to write code that is testable, you must revisit all the principles and design patterns that exist. For example, a piece of code that instantiates a dependent class all by itself is not testable. So, you might want to inject this class as a dependency. To get the instance of the class to be injected, you might want to consider a factory design pattern.
It is best to know the SOLID Principles of development. A sound knowledge of design patterns is also recommended to make TDD easier. Usually developers would be reluctant to change architecture to test a small piece of code. Although this is a good direction to move in, teams might think otherwise. Initial reluctance in adapting TDD is a common feature across teams.
The prerequisites for TDD are listed below:
1. A team well trained in TDD.
2. A testing framework.
3. A defined CI/CD, to run the tests.
4. Ample time.
TDD is a great way to increase reliability and improve code quality. It is a good start to improving your application’s architecture. Developers that follow TDD will swear by it, but it has its downsides. Whether TDD is good for a project or not is best decided by the team. But choosing TDD implies change and moving in the right direction. By change, we mean a whole lot of change!