TDD: The Counterintuitive Programming Approach That Led to Better Code
Hands-on first experience using Test-Driven-Development approach on the project
As a software developer constantly on the lookout for methods to refine my coding process, I recently embarked on an intriguing journey with Test-Driven Development (TDD), an approach I initially found counterintuitive. However, as I delved deeper, I discovered its innate genius, and it ultimately transformed not only the quality of my code but also how I approached programming.
It’s a testament to the saying, “Slow is smooth, and smooth is fast
Understanding TDD
Test-Driven Development (TDD) is a software development methodology where tests are written before the actual code. This may sound backwards at first, especially for those of us accustomed to writing tests after the code, if at all. TDD follows a simple but powerful cycle: Red-Green-Refactor.
- Red: Write a failing test.
- Green: Write minimal code to make the test pass.
- Refactor: Improve the code while keeping the tests green.
This methodology ensures each line of code you write is covered by tests, leading to more robust, reliable software.
First Experience with TDD
My initial experience with TDD came when I was working on a project to develop a restaurant reservation system. The first requirement was to allow users to create a reservation.
Red Phase
I started by writing a test for this requirement. I was testing the data_seeding
apps from my django project, the app that to automate data seeding using custom django-admin command (refer to this article for more detail explanation). It felt strange to write a test for a non-existent function, but I went ahead anyway. Let’s code !
# file: data_seed/tests.py
from io import StringIO
from django.test import TestCase
from django.core.management import call_command
from django.test import TestCase
from data_seed.models import DummyShip
class TestCase(TestCase):
def test_mycommand_POSITIVE(self):
" Test my custom command."
args = ['./importfile.csv']
opts = {}
out = StringIO()
call_command('import_data', *args, **opts, stdout=out)
self.assertIn('Data imported successfully', out.getvalue())
def test_mycommand_NEGATIVE(self):
" Test my custom command."
args = ['./non-exists.csv']
opts = {}
out = StringIO()
call_command('import_data', *args, **opts, stdout=out)
self.assertIn('Error importing data', out.getvalue())
# test DummyShip model
def test_dummy_ship_model(self):
ship = DummyShip.objects.create(
ship_name='Test Ship',
ship_total_vessels=100
)
self.assertEqual(ship.ship_name, 'Test Ship')
self.assertEqual(ship.ship_total_vessels, 100)
def test_str_method(self):
dummy_ship = DummyShip(ship_name='Test Ship', ship_total_vessels=5)
self.assertEqual(str(dummy_ship), 'Test Ship')
The provided code imports the necessary modules and functions for creating tests in Django, including TestCase
from django.test
and call_command
from django.core.management
. It also imports the DummyShip
model from a local module named data_seed.models
.
The script contains a test case class TestCase
, which inherits from Django's TestCase
class. Within this class, several methods are defined, each corresponding to a specific test.
- test_mycommand_POSITIVE(): This function tests a custom command
import_data
that presumably imports data from a file (importfile.csv
). Usingcall_command
, the test executes the command and captures its output. The test then checks if the string 'Data imported successfully' is in the output, indicating a successful data import. - test_mycommand_NEGATIVE(): This test is similar to the previous one but uses a non-existent file (
non-exists.csv
). The expectation here is that the command will fail and return an error message 'Error importing data'. - test_dummy_ship_model(): This function tests the
DummyShip
model. It creates an instance ofDummyShip
with a specific ship name and total vessels, and then checks if the created instance's attributes match the expected values. - test_str_method(): This test checks the
__str__
method of theDummyShip
model. It creates an instance ofDummyShip
and then checks if its string representation is the expected ship name.
In all these tests, the assertIn
and assertEqual
methods are used to check if the test conditions are met. If the condition in the assert statement is True
, the test passes; otherwise, it fails. As expected, the test failed, marking the ‘Red’ phase of the TDD cycle because we don’t have the implementation yet.
Green Phase
Next, I wrote just enough code to make the test pass, marking the ‘Green’ phase.
from django.core.management.base import BaseCommand
from csv import reader
from data_seed.models import DummyShip
from vessel_systems.models import VesselSystems
from vessels.models import Vessel, VesselClass, Rules
from users.models import User
from work_orders.models import WorkOrder
from work_orders_detail.models import WorkOrderDetail
class Command(BaseCommand):
help = "Seed data from CSV files"
def add_arguments(self, parser):
parser.add_argument('file_path', type=str, help='Path to the CSV file')
def handle(self, *args, **kwargs):
file_path = kwargs['file_path']
try:
chief_engineer = User.objects.create_user(username='chief_engineer', password='password', is_chief_engineer=True)
vessel_owner = User.objects.create_user(username='owner', password='password', is_vessel_owner=True)
system_jayakarta1 = VesselSystems.objects.create()
rule_jayakarta = Rules.objects.create(name='Rule Jayakarta')
class_jayakarta = VesselClass.objects.create(name='Class Jayakarta', rule=rule_jayakarta)
vessel_obj = Vessel.objects.create(
name='Jayakarta I',
vessel_owner=vessel_owner,
vessel_class=class_jayakarta,
chief_engineer=chief_engineer,
vessel_systems=system_jayakarta1,
)
work_order_main_engine = WorkOrder.objects.create(
id = 1,
work_category = 'Main Engine',
total_cost = 450,
vessel_systems = system_jayakarta1,
)
work_order_control = WorkOrder.objects.create(
id = 2,
work_category = 'Control',
total_cost = 60,
vessel_systems = system_jayakarta1,
)
with open(file_path, 'r') as csvfile:
csv_reader = reader(csvfile, delimiter=',')
next(csv_reader) # skip header row
work_details= [
WorkOrderDetail(
spare_part="",
maintenance_desc=row[0],
interval_hours=row[1],
work_days=row[2],
number=0,
running_hour_ref=row[3],
work_orders=work_order_main_engine,
cost=row[4],
) for row in csv_reader
]
WorkOrderDetail.objects.bulk_create(work_details)
self.stdout.write(self.style.SUCCESS('Data imported successfully'))
except Exception as e:
self.stdout.write(self.style.ERROR('Error importing data'))
self.stdout.write(self.style.ERROR(str(e)))
The provided Python script is a Django management command. It’s designed to read data from a CSV file and seed that data into my database. Here’s a brief explanation of each part of the script:
- Command Class Definition: The script defines a new management command
Command
that inherits from Django'sBaseCommand
. A management command is a command-line utility you can create as part of your Django application. - add_arguments() method: This method is used to specify the command-line arguments for this command. In this case, it’s expecting one argument: the file path to the CSV file you want to import.
- handle() method: This method contains the main logic of the command. It’s executed when you run the command.
- It begins by extracting the
file_path
argument. - Then, it creates a database transaction. This means that all the database changes inside this block will either all succeed, or if there’s an exception, they will all be rolled back. This ensures the consistency of your database.
- It creates several instances of different models, including
User
,VesselSystems
,Rules
,VesselClass
,Vessel
, andWorkOrder
. - It opens the CSV file at the provided file path, reads its content line by line, skipping the first header row.
- For each row, it creates a
WorkOrderDetail
instance (without saving it to the database yet), with fields populated from the CSV data. - Once all
WorkOrderDetail
instances are created, it usesbulk_create
to save them all to the database at once, which is more efficient than saving them one by one. - If everything goes well, it prints a success message. Otheriwise it prints the error message
The code was not perfect, but it was enough to fulfill the requirement and pass the test. It was a rewarding feeling to see the previously failing test now passing.
Refactor Phase
Then came the ‘Refactor’ phase. I revisited the code I had written, tidying it up and making improvements. This phase gave me a safety net to clean up the code without fear of breaking anything. As we can see from the last code, our database seeding didn’t have a safety procedure fot the transaction while we are making the bulk insert. This could caused a further error if we have problems in the middle of the transaction meanwhile the inserted data it’s still there. Let’s add the rollback procedure for our database to overcome the problem:
from django.core.management.base import BaseCommand
from csv import reader
from data_seed.models import DummyShip
from vessel_systems.models import VesselSystems
from vessels.models import Vessel, VesselClass, Rules
from users.models import User
from work_orders.models import WorkOrder
from work_orders_detail.models import WorkOrderDetail
from django.db import DatabaseError, transaction
class Command(BaseCommand):
help = "Seed data from CSV files"
def add_arguments(self, parser):
parser.add_argument('file_path', type=str, help='Path to the CSV file')
def handle(self, *args, **kwargs):
file_path = kwargs['file_path']
try:
with transaction.atomic():
chief_engineer = User.objects.create_user(username='chief_engineer', password='password', is_chief_engineer=True)
vessel_owner = User.objects.create_user(username='owner', password='password', is_vessel_owner=True)
system_jayakarta1 = VesselSystems.objects.create()
rule_jayakarta = Rules.objects.create(name='Rule Jayakarta')
class_jayakarta = VesselClass.objects.create(name='Class Jayakarta', rule=rule_jayakarta)
vessel_obj = Vessel.objects.create(
name='Jayakarta I',
vessel_owner=vessel_owner,
vessel_class=class_jayakarta,
chief_engineer=chief_engineer,
vessel_systems=system_jayakarta1,
)
work_order_main_engine = WorkOrder.objects.create(
id = 1,
work_category = 'Main Engine',
total_cost = 450,
vessel_systems = system_jayakarta1,
)
work_order_control = WorkOrder.objects.create(
id = 2,
work_category = 'Control',
total_cost = 60,
vessel_systems = system_jayakarta1,
)
with open(file_path, 'r') as csvfile:
csv_reader = reader(csvfile, delimiter=',')
next(csv_reader) # skip header row
work_details= [
WorkOrderDetail(
spare_part="",
maintenance_desc=row[0],
interval_hours=row[1],
work_days=row[2],
number=0,
running_hour_ref=row[3],
work_orders=work_order_main_engine,
cost=row[4],
) for row in csv_reader
]
WorkOrderDetail.objects.bulk_create(work_details)
self.stdout.write(self.style.SUCCESS('Data imported successfully'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error importing data {e}'))
self.stdout.write(self.style.WARNING('Database rollbacked successfully!'))
Note that now we have transaction.atomic()
that becomes our guard for our failing database transaction. I made sure to rerun the test after each change to ensure it was still passing, and it did!
I found this Red-Green-Refactor cycle to be a paradigm shift. It felt slow at first, writing tests before the actual code, but as I repeated the cycle for each new feature (updating reservations, deleting reservations), I realized I was spending less time debugging and more time building features.
The Outcome
Using TDD, I noticed a significant improvement in my code quality. Each line of code was tested, making the application robust and largely bug-free. The tests also provided excellent documentation for each function, making it easier to understand what each part of the system was supposed to do.
Moreover, TDD made me think more about the design and functionality of my code before I even started coding. This helped me break down complex features into smaller, more manageable parts, leading to better code structure and organization.
Conclusion
My hands-on experience with TDD was transformative. What initially seemed counterintuitive turned out to be a powerful approach that has now become an integral part of my coding process. TDD has not only made me a better programmer but also given me the confidence to tackle more complex projects. It’s a testament to the saying, “Slow is smooth, and smooth is fast,” underscoring the fact that taking time to think through and test our code can lead to better, more reliable software.