My Take on TDD
In this article, I wanna talk about my experience with Test Driven Development (TDD) and its implementation on software engineering projects.
Disclaimer: since it’s based on my experience, this article can’t be really correct so take this article a bit of grain of salt.
What is Test-Driven Development?
Well, basically test-driven development is we write the test code first before writing the actual implementation. The purpose of TDD is to make our code more robust and cleaner. The TDD itself has cyclic three phases:
- RED, in this phase, we create a test code for the implementation that will handle every possible scenario that could happen. We can also code the mock objects that are required for the testing in this phase.
- GREEN, in this phase, we create the implementation code that will pass the test created before.
- REFACTOR, after the implementation has passed the test, we can restructure the implementation to make it cleaner, more optimized, or more maintainable but still doesn’t break the correct logic.
Rethinking About The Test Metric
Some say that the good test is the one that fulfills 100% code coverage. But I don’t think that’s necessarily true. Code coverage only tells us the number of codes that are covered or called by the tests. Therefore, we can achieve this 100% coverage by creating assertion-free test code such that it executes all lines of implementation codes. That is why the code coverage itself doesn’t accurately represent how good or proper the test really is. Another problem is some codes can’t be tested because that code doesn’t contain our own implementation (for example code that’s generated from a certain script). We can overcome that problem by putting that kind of code into the coverage exception although that doesn't guarantee 100% coverage. Martin Fowler said that around 80–90s coverage should enough if we really make the proper tests. I can say that the tests that we made really do well if your code rarely creates bugs in the production and you don’t hesitate to change the code. So the main point is, create tests that cover all possible scenarios in all of ours implementation codes, put the untestable code as the exception for the coverage & don't stick too much on the code coverage target. Additionally don’t too stick too much on the pipeline badge result like if I made implementation and push it to the repository don’t expect that the pipeline badge would also be green because then again the reality is, weird stuff or human error like typo can also happen in the progress.
TDD is Investment in Software Development
Test-Driven Development isn’t that easy. You have to invest your mind & your time in that matter like planning the scenario, learn libraries for the testing before writing the test. Weird stuff can also happen like the test on the local is passed but when I push it to the CI, the test failed instead due to the dependencies, mismatch between the mocking and the implementation, and other miscellaneous causes. TDD also actually slower than when we do the implementation first. Now the good thing is, TDD makes our code more cleaner since we plan the scenario first so the redundant code can be minimized. TDD can make our code first so we don’t have to fear when we have to change code especially when we use agile methodology. Therefore TDD makes our code more maintainable and robust. TDD also ensures the integration process runs more smoothly. Like investing, it takes a lot of time and knowledge but there is a huge payoff in the end.
How I Use Test Driven Development in Software Engineering Project
After that long talk about the theory and my thoughts on TDD let’s jump into the implementation. In this example, I applied TDD on a method on the user repository which fetches the user database entity based on the user’s ID. Anyway, the code is coded in Go.
In this phase, I created a negative and positive test for each that method.
As you see, those two test cases use mocking on the SQL. I’ll talk about the mocking later on.
As you can see on the positive test (line 1–16), the test method starts with some dummy database setup since we can’t use the real database on testing. Once it’s all set, next thing is to insert a row on the dummy database using mock row . This will be useful for the mocking for the positive testing. The next thing is on line 9 is the mock part. Basically, the part is whenever there is a query like “SELECT FROM ‘users’” being called then it will return a database row which is a row that is previously inserted using mock row function. Since the inserted rows only have one row it’ll return one entity. Next is the method calling part (line 10–11). GetByID receives one integer called ID. This ID is used to search the user’s id in the database. In this part, I called the method with 1 as ID so it’ll search user that has 1 as ID because on mock row, I inserted a user that has 1 as ID. Finally, the validation part (line 12–16) will make sure that the user that is returned is the same as the user we really want to fetch (in this context, the intended user is a user that has 1 as ID) and no error returned.
The negative test is pretty much the same except there is no mockRows calling since I wanted to simulate an empty database and query mocking part (line 22). Instead of return a proper entity, it’ll return an error to signify that the specified user is not found in the database. The validation part(line 25–26) will make sure that an error is returned and the specified user isn’t fetched because when there is an error occurred, the method should return an error and the specified shouldn’t be fetched.
Tl;dr I created a positive test for the scenario when the thing goes as it’s intended to be (the specified user is found in the database) and a negative test to test the method behaviour whenever an error is occurred(ex: the specified user is not found in the database). I think that two kind of scenarios has already covered all possible scenarios that could happen on the method calling so these two tests are enough for me.
Here in this phase, we create an implementation that will pass the created tests.
In that phase, I created an implementation for the method. The method basically fetches user that has matching user id. That method returns the corresponding entity and zero error (or null) if the specified user is found on the database and return null & error if an error occurred instead.
In this phase, we can refactor or restructure the code that looks neater or more optimized than before. Worry to break the logic? Don’t worry, we have test code on our back to ensure the change doesn’t break the correct logic.
In refactor phase, I added the comment on the implementation since the linter requires to put a comment on every method on implementation except the private method. Here the GetByID is a public method (in go, a public method is signified by uppercase on the first letter while a private method is signified by lowercase on the first letter). I don’t put much comment on it since the method is already self-explanatory. The change is on the comment so it absolutely doesn’t affect the logic of the implementation.
It takes a lot of time for getting used to the TDD paradigm. I’m still trying to get used to it until now. But that investment is surely worth spending on especially when we work on large or team projects since we can make our code more sustainable in the future. But if it’s just a fun project or some quick implementation, I don’t think you have to do TDD. TDD should be applied to a project that involves collaboration or a project where sustainability should be put into consideration.
That’s all folks about TDD from me. See you in the next post & have a good day!