DEV Community

Cover image for The Adventures of Blink S2e4: TDD
Ben Link
Ben Link

Posted on

The Adventures of Blink S2e4: TDD

Hey friends, welcome to the latest Adventure of Blink!

Here's what we've done so far in season 2:

  • Learned about using Docker Desktop
  • Built a MongoDB Docker Container and created an initialization script to run on startup
  • Created a Python Flask API for our database with 2 endpoints: /add and /random
  • Used Docker-Compose to orchestrate our API and Database startup

You can find the code we've written thus far on my GitHub. You'll find a separate branch for each week's pro

Today we're going to start the build of the actual hangman game in Python. Let's dig in, shall we?

TL/DR: YouTube

Don't feel like reading? Just watch! (Please like, subscribe, etc, so that you won't miss out on future episodes!)

Shouldn't we have some prerequisites???

You might expect me to immediately go start up our API and Database. After all, we'll need data from them in order to create a game board, right? Well... not really!

This week's adventure is going to involve a Three-Letter Acronym: TDD, which stands for "Test-Driven Development".

TDD, Defined

Test-Driven Development is a programming technique where you start by writing tests that explain what your code is supposed to do. These tests will fail, of course, because you haven't written the code yet that they're supposed to test. Then you write the code for your app like normal, until you pass the tests you wrote. Passing all your tests is sort of your "definition of Done" -> you have to keep writing code until your tests pass.

Why people don't like it

When I've discussed TDD with developers who haven't done it before (or even with some who have!), I usually hear the following complaints about the practice:

  • Writing the tests first feels awkward. I believe this is because we aren't used to thinking all the way through our goals when we start coding. TDD is like creating guardrails for your application; you define these tests to know what actually constrains the way your application should work. If you only have a vague idea of your intended design... that could definitely make it hard to build those guardrails at the outset!

  • It's going to slow me down. I think this complaint stems from the way we were often taught to code. I remember in my hobbyist days (and even in my school days!) that building something rarely involved thinking deeply about its design - it was often "hey here's the problem, aaand we're off to the races writing code!". We programmers can be an impatient bunch, and we often have a lot of domain knowledge already in our heads that we haven't thought out, which tricks us into thinking we have the design already complete.

  • I don't like writing tests. Ok, I get it. Relatively few of us received formal training about testing our code, and the practice doesn't always feel like a great deal of progress toward our end goal. But this is about being a responsible software engineer! Writing tests for your program makes your code more maintainable, not only by you but also by your teammates... or even people who come to the team long after you're gone.

Why it's worth it

  • It encourages automated tests. Seriously, if you've ever worked on a codebase with a good test suite, it is NICE. You get fast feedback on whether a change you made is going to be safe, and if you're unclear about what something's supposed to do, the test doubles as documentation!

  • It encourages you to think rather than write. We get ahead of ourselves and start building before we think sometimes. Designing a test for code that isn't yet written will make you stop and scratch your head a little... in a very good way. Should I build it this way? Am I creating a problem for myself later? Have I thought about the weird edge cases?

  • You'll find that you write less code. It's easy to sit down and start coding on the fly and ending up way down a rabbit-trail solving a problem you don't need to solve. But if I've written tests to be my guardrails, I have a clear finish line: when all the tests pass, I should have a working module. I don't need to do any extra coding beyond that point.

Building the structure of the Python App

Before we write tests, let's lay out the structure of our codebase.
In our project root, we'll create the following structure:

hangman/
│
├── hangman/                      # Core Python package
│   ├── __init__.py               # Makes the folder a package
│   ├── game.py                   # Main game logic (class or functions for Hangman)
│   ├── utils.py                  # Utility functions (e.g., phrase selection, formatting)
│   ├── exceptions.py             # (Optional) Custom exceptions for error handling
│   └── config.py                 # (Optional) Configurations like max attempts, phrase list, etc.
│
├── tests/                        # Test suite for TDD using pytest
│   ├── __init__.py               # Makes the folder a package
│   ├── test_game.py              # Tests for the main game logic (functions/classes in game.py)
│   ├── test_utils.py             # Tests for utility functions
│   └── test_config.py            # (Optional) Tests for configuration behavior
│
├── main.py                       # Entry point to run the Hangman game locally
├── README.md                     # Project documentation
└── requirements.txt              # Dependencies (if any, like pytest)
Enter fullscreen mode Exit fullscreen mode

If you've done much work in Python, you probably don't find the structure too surprising... this is pretty standard stuff.

The key things to think about:

  • /tests/: Parallel to the application code is a tests folder, which has a corresponding test file for every python file in the app. Our ideal goal is to have a test that runs every line of code in our project - but we'll talk more about that later.
  • README.md: If you haven't worked much in GitHub or a similar platform, you might not even think to include this - but it's a critical component of your documentation as a developer! Here's where you put the notes so that your teammates (or the people who maintain this thing when you've moved on) can figure out how to engage with the codebase.
  • requirements.txt: We talked a little about this last week with the Flask code - you need to track your dependencies and document them for others who might want to pull this code and run it locally.

Time to test!

Now that you've got the basic structure of the application in place, it's time to write some tests! We're going to start our example with hangman/game.py as the code under test, so the file we'll be working in is tests/test_game.py.

I'm going to fast-forward this part and just write some tests to work from as an example, and then add comments to talk through it.

# The first thing we do is import pytest, and 
# then we grab the Hangman class that we anticipate
# writing in game.py.
import pytest
from hangman.game import Hangman

# Annotating a method as a fixture allows you to 
# create shared setup and teardown code that all 
# of your tests can use.
@pytest.fixture
def new_game():
    # Our new_game fixture creates a hangman board with 
    # the word "PYTHON" as its content.
    return Hangman("PYTHON")  # Example game phrase

# Our first test defines the initial state of the game.
# We list our expectations here and verify that the new
# object meets those expectations.
def test_initial_game_state(new_game):
    """Test the initial state of the game."""
    # Create a new game
    game = new_game
    # Check the values we expect the game to have for these variables
    assert game.remaining_lives == 6  # Assuming 6 lives to start
    assert game.display_word == "______"  # Hidden word
    assert game.guessed_letters == []  # No guesses yet
    assert game.is_over is False  # Game isn't over

# Our next test validates what happens when you select
# a letter that DOES appear in the puzzle.
def test_correct_guess(new_game):
    """Test guessing a correct letter."""
    game = new_game
    # Use the game's "guess" method to pick the letter 'P'
    result = game.guess("P")
    # Validate that the method returns the right value and
    # also that it updates the game appropriately.
    assert result is True
    assert game.display_word == "P_____"  # 'P' revealed
    assert game.guessed_letters == ["P"]

# Next we should validate what happens if the letter is NOT
# present in the puzzle
def test_incorrect_guess(new_game):
    """Test guessing an incorrect letter."""
    game = new_game
    result = game.guess("X")
    assert result is False
    assert game.remaining_lives == 5  # Lose a life
    assert game.guessed_letters == ["X"]

# Testing a single incorrect guess is a start, but we 
# should ensure that multiple incorrect guesses update the game
# correctly, too
def test_multiple_incorrect_guesses(new_game):
    """Test multiple incorrect guesses."""
    game = new_game
    game.guess("X")
    game.guess("Z")
    assert game.remaining_lives == 4  # Lost two lives
    assert "X" in game.guessed_letters
    assert "Z" in game.guessed_letters

# We should validate that the "game is won" behavior
def test_game_won(new_game):
    """Test the game is won after correct guesses."""
    game = new_game
    game.guess("P")
    game.guess("Y")
    game.guess("T")
    game.guess("H")
    game.guess("O")
    game.guess("N")
    assert game.is_won() is True
    assert game.is_over is True

# And of course, we should validate that you can lose the game too!
def test_game_lost(new_game):
    """Test the game is lost after too many incorrect guesses."""
    game = new_game
    for guess in ["A", "B", "C", "D", "E", "F"]:
        game.guess(guess)
    assert game.remaining_lives == 0
    assert game.is_over is True
    assert game.is_won() is False

# Finally, we need to ensure that duplicate guesses of a letter
# are ignored by the game state.
def test_duplicate_guess(new_game):
    """Test that duplicate guesses do not affect lives or state."""
    game = new_game
    game.guess("P")
    game.guess("P")  # Duplicate guess
    assert game.remaining_lives == 6  # Lives shouldn't change
    assert game.guessed_letters == ["P"]  # No duplicate in guessed letters
Enter fullscreen mode Exit fullscreen mode

Running our tests

Now that we have some tests, we need to be able to run them. Of course, the first hurdle we have to overcome is that we need to install the dependencies!

Dependency Management

First, set up a virtual environment using venv:

python -m venv .venv
source .venv/bin/activate     # On Mac/Linux
source .venv/Scripts/activate # On Windows
Enter fullscreen mode Exit fullscreen mode

Once we're in our venv, we can install pytest:

pip install pytest

and then we should save our dependency needs with the following:

pip freeze > requirements.txt

Now we're ready to run our tests:

#> pytest

========================================================================================== test session starts ===========================================================================================
platform darwin -- Python 3.12.4, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/ben/Documents/s2-hangman/hangman
collected 0 items / 1 error                                                                                                                                                                              

================================================================================================= ERRORS =================================================================================================
__________________________________________________________________________________ ERROR collecting tests/test_game.py ___________________________________________________________________________________
ImportError while importing test module '/Users/ben/Documents/s2-hangman/hangman/tests/test_game.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests/test_game.py:5: in <module>
    from hangman.game import Hangman
E   ImportError: cannot import name 'Hangman' from 'hangman.game' (/Users/ben/Documents/s2-hangman/hangman/hangman/game.py)
======================================================================================== short test summary info =========================================================================================
ERROR tests/test_game.py
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
============================================================================================ 1 error in 0.05s ============================================================================================
Enter fullscreen mode Exit fullscreen mode

Not much to look at, is there? We have tests, but no actual code... the point of this exercise is to demonstrate that we expect our tests to fail. This is how TDD works: you write the tests, they fail, and you write code until they pass.

Our error shows us our first task: to create a Hangman class in game.py.

class Hangman:
    def __init__(self, word):
        pass
Enter fullscreen mode Exit fullscreen mode

Once we do that, we can re-run our tests...

#> pytest

...
FAILED tests/test_game.py::test_initial_game_state - AttributeError: 'Hangman' object has no attribute 'remaining_lives'
FAILED tests/test_game.py::test_correct_guess - AttributeError: 'Hangman' object has no attribute 'guess'
FAILED tests/test_game.py::test_incorrect_guess - AttributeError: 'Hangman' object has no attribute 'guess'
FAILED tests/test_game.py::test_multiple_incorrect_guesses - AttributeError: 'Hangman' object has no attribute 'guess'
FAILED tests/test_game.py::test_game_won - AttributeError: 'Hangman' object has no attribute 'guess'
FAILED tests/test_game.py::test_game_lost - AttributeError: 'Hangman' object has no attribute 'guess'
FAILED tests/test_game.py::test_duplicate_guess - AttributeError: 'Hangman' object has no attribute 'guess'
...
Enter fullscreen mode Exit fullscreen mode

We now see new errors, don't we? This becomes our development workflow... run the tests, see what fails, write code to fix the failures. We depend on the existence of tests to describe how the application should work, and that guides us to write the code needed to pass the test.

Aside from providing constraints for the behavior of the app, this has another side effect: it leads us to write the minimal amount of code required to pass the tests. Since we're constantly trying to get to the point where the tests pass, we're not encouraged to go off track.

Here's the resulting code that I used to pass the first test:

'''Hangman game functions'''

import re

class Hangman:
    def __init__(self, word):
        '''Create a new Hangman game where [word] is the word or phrase the player is trying to guess.'''
        self._remaining_lives = 6
        self._guessed_letters = []
        self._is_over = False
        self._is_won = False
        self._word = word.upper()

        self._display_word = self._calculate_display_word()

    @property
    def remaining_lives(self):
        return self._remaining_lives

    @property
    def guessed_letters(self):
        return self._guessed_letters

    @property
    def game_over(self):
        return self._is_over

    @property
    def game_won(self):
        return self._is_won

    @property
    def get_word(self):
        return self._word

    @property
    def get_display_word(self):
        return self._display_word

    def _calculate_display_word(self):
        '''Starting with a series of underscores that represent the letters in a word/phrase, fill in the blanks with the letters that have already been guessed.'''
        return re.sub(r'[a-zA-Z0-9]', '_', self._word)
Enter fullscreen mode Exit fullscreen mode

I put in a little extra work to make my variables protected within the class and thus only accessible via properties, but that's the cool part of TDD: you still pick the implementation you want, as long as it meets the guidelines of your tests.

Moving forward

It's not my intent to code the whole app in painstaking detail this season - merely to demonstrate how the technique works to get us where we're going.

To that end, I'm going to do a little fast-forwarding behind the scenes, and release most of the app sort of "between episodes". This way we can move directly on to the next technique, but if you're a completionist like me you still get to see the code work! 😬

Top comments (0)