Skip to main content

Test-Driven Development (TDD) - Writing Code That's Correct by Design

· 6 min read
Serhii Hrekov
software engineer, creator, artist, programmer, projects founder

Not only Shift-Left mantra matters

The mantra of "Shift Left" has echoed through the halls of software development for years now, and for good reason. The core idea is simple yet powerful: move testing, quality, and security considerations as early as possible in the development lifecycle. It's about catching problems when they're small and cheap to fix, not when they're gargantuan headaches in production. But as a lead engineer who's seen a few projects through the trenches, I've come to realize that "Shift Left" isn't the destination; it's the starting point.

To truly build robust, maintainable, and high-quality software, especially in a dynamic language like Python, we need to embrace a set of development paradigms that not only support but enhance the "Shift Left" philosophy. These aren't just buzzwords; they are methodologies that, when adopted, fundamentally change how we approach writing code for the better.

Test-Driven Development (TDD): Writing Code That's Correct by Design

At its heart, Test-Driven Development (TDD) is a discipline that inverts the traditional "write code, then test it" model. With TDD, you write a failing test before you write the production code to make it pass. It’s a simple cycle:

  1. Red: Write a test for a small piece of functionality you want to add. Run it, and watch it fail. This is crucial; it proves that the test is working and that the functionality doesn't already exist.
  2. Green: Write the absolute minimum amount of code necessary to make the test pass. Don't worry about elegance or optimization at this stage. Just get it to green.
  3. Refactor: Now that you have a passing test as a safety net, you can clean up your code, remove duplication, and improve its structure without changing its external behavior.

Why it matters beyond "Shift Left": TDD is the embodiment of "Shift Left" at the micro-level. It forces you to think about requirements and design before you write a single line of implementation. This leads to a more modular, decoupled, and well-documented codebase. The tests themselves become a form of living documentation, precisely describing how each component is intended to behave.

A glimpse of TDD in Python with pytest:

Let's imagine we're building a simple e-commerce system and need a function to calculate the total price of a cart.

Our TDD journey would start with a test file, say test_cart.py:

import pytest
from cart import calculate_cart_total

def test_empty_cart_total_is_zero():
assert calculate_cart_total([]) == 0

def test_single_item_cart_total():
cart = [{"name": "Laptop", "price": 1200}]
assert calculate_cart_total(cart) == 1200

def test_multiple_item_cart_total():
cart = [
{"name": "Laptop", "price": 1200},
{"name": "Mouse", "price": 25}
]
assert calculate_cart_total(cart) == 1225

Running pytest at this point would result in failures because we haven't implemented calculate_cart_total yet. Now, we write the simplest code to make the tests pass in cart.py:

def calculate_cart_total(cart):
total = 0
for item in cart:
total += item["price"]
return total

Running pytest again should give us all green. Now we can refactor if needed, confident that our tests will catch any regressions.

Behavior-Driven Development (BDD): Bridging the Gap Between Business and Code

While TDD is fantastic for developers, it doesn't always translate well to business stakeholders. This is where Behavior-Driven Development (BDD) comes in. BDD is an extension of TDD that focuses on the behavior of the system from the user's perspective. It uses a natural language syntax, often Gherkin, to write scenarios that are understandable by everyone on the team, from product managers to QA engineers to developers.

Why it matters beyond "Shift Left": BDD fosters a shared understanding of what needs to be built. It encourages collaboration and ensures that the software being developed meets the actual business requirements. By writing these behavioral scenarios upfront, we're not just shifting testing left; we're shifting the conversation about requirements left.

A taste of BDD in Python with behave:

A BDD scenario for our e-commerce site might look like this in a file named cart.feature:

Feature: Shopping Cart Total Calculation

Scenario: Adding items to the cart
Given a user has an empty shopping cart
When the user adds a "Laptop" with a price of 1200
And the user adds a "Mouse" with a price of 25
Then the shopping cart total should be 1225

Then, in a Python file, you'd write the "step definitions" that behave will execute:

from behave import given, when, then

@given('a user has an empty shopping cart')
def step_given_empty_cart(context):
context.cart = []

@when('the user adds a "{item_name}" with a price of {price:d}')
def step_when_add_item(context, item_name, price):
context.cart.append({"name": item_name, "price": price})

@then('the shopping cart total should be {total:d}')
def step_then_check_total(context, total):
from cart import calculate_cart_total
assert calculate_cart_total(context.cart) == total

Continuous Integration and Continuous Deployment (CI/CD): The Automation Backbone

TDD and BDD provide a solid foundation of tests. But what good are they if they're not run consistently? This is where Continuous Integration (CI) and Continuous Deployment (CD) come into play.

  • Continuous Integration (CI): The practice of frequently merging all developers' working copies to a shared mainline. Each integration is then verified by an automated build and automated tests.
  • Continuous Deployment (CD): The practice of automatically deploying every change that passes the automated tests to a production-like environment, and ultimately to production.

Why it matters beyond "Shift Left": CI/CD is the engine that powers the "Shift Left" philosophy at scale. It creates a rapid feedback loop, allowing teams to catch integration issues, bugs, and security vulnerabilities with every commit. It removes the manual, error-prone process of builds and deployments, freeing up developers to focus on what they do best: writing code.

A simple CI/CD pipeline for a Python project using GitHub Actions:

In your project repository, you would create a file at .github/workflows/ci.yml:

name: Python CI

on: [push, pull_request]

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: |
pytest

This simple configuration will automatically run your pytest suite on every push and pull request across multiple Python versions.

The Bigger Picture: A Culture of Quality

"Shift Left" is more than just a set of practices; it's a cultural shift. It's about instilling a sense of shared responsibility for quality across the entire team. TDD, BDD, and CI/CD are not just tools; they are enablers of this culture.

By embracing these paradigms, we move beyond simply finding bugs early. We start to prevent them altogether. We build a safety net that allows us to refactor with confidence, to add new features without breaking existing ones, and to ultimately deliver better software, faster. As a Python developer, in a world of dynamic typing and rapid iteration, this safety net is not just a nice-to-have; it's a necessity.