Skip to content

2. Introduction to Testing

Why is Testing Important?

Software can be fragile. One small change can break something else without warning. Testing brings stability and trust. It confirms that your code works as expected, your bug fixes stick, and that new features don’t undo old ones. There are many reasons for testing software, but some of the top reasons for testing are:

  • Acceptance and Verification: Tests show that your software meets user needs and business goals. You can point to a test and say, “Yes, this works as intended.”
  • Regression Prevention: With automated tests, you’ll know if new code breaks existing functionality. This saves you from introducing the same bug again and again. When you fix a bug, writing a test locks in the fix. If that bug ever shows up again, the test fails and alerts you.
  • Confidence in Code Changes: With good test coverage, refactoring or adding new features feels safer. You don’t have to worry as much about sneaky breakage.

A great test suite isn’t just about catching bugs—it transforms the way you write code. When you have fast, reliable tests, you can experiment freely, refactor fearlessly, and build new features with confidence. Instead of worrying about whether a change might break something unexpected, you get immediate feedback. Green tests? You’re good to go. Red tests? You know exactly where to look. This is especially valuable in a team setting where you might not have written the original code or its tests. With a strong test suite, you don’t have to rely on gut instinct or deep dives into unfamiliar code; the tests will tell you if your changes introduce problems.

Good tests also reinforce clean abstraction layers. When a module or API has solid test coverage, you can trust its interface without constantly checking its implementation. This means you can operate at a higher level, focusing on solving the problem at hand rather than getting bogged down in lower-level details. It’s a massive boost to developer productivity and experience—when the foundational layers are proven to work well with testing, and they "just work" in practice, you can build on top of them with confidence.

Types of Software Tests

Not all tests are created equal, and choosing the right type of test for a given scenario is crucial. A well-balanced test suite includes different types of tests, each serving a specific purpose. Understanding their strengths, limitations, and trade-offs allows you to write intentional tests—tests that prove something meaningful and valuable about your codebase.

The Importance of Intentional Testing

Testing isn't just about writing tests for the sake of it; it's about proving that your code behaves correctly under the conditions that matter most. Before writing a test, ask yourself:

  • What am I trying to prove?
    Are you checking that a function returns the right output? That two components communicate correctly? That your system handles heavy load?
  • Why is this test valuable?
    Does it provide meaningful feedback? Will it catch real-world issues?
  • Where should I set the test’s boundaries?
    Should I isolate a single function, or does the value come from testing multiple pieces together?

When you’re intentional about testing, you avoid redundant or low-value tests and focus on what truly increases confidence in your software.

Common Types of Tests

Each type of test provides different insights into your software. Here’s a breakdown of the most common ones:

  • Unit Tests Unit tests focus on individual functions or methods in isolation. They are fast, reliable, and help confirm that small pieces of logic work correctly. If a unit test fails, you know exactly where the problem is.

    • Strengths: Quick to run, easy to debug, useful for pinpointing issues in logic.
    • Limitations: They only test small pieces of the system and don’t guarantee that components work well together.
    • Best for: Business logic, calculations, pure functions, and methods with clear inputs and outputs.
  • Integration Tests These verify that multiple parts of the system work together as expected. An integration test might check how your API interacts with a service, or how a service interacts with a database, or, generally whether two or more units of code coordinate correctly.

    • Strengths: Helps catch real-world interaction issues that unit tests miss.
    • Limitations: If integration test includes external systems, they are slower than unit tests. Failures can be harder to diagnose since scope is inclusive of many parts of the system.
    • Best for: Dependencies between services, database interactions, API calls, authentication flows, and so on.
  • End-to-End (E2E) Tests E2E tests simulate a user’s experience through the entire system, from frontend clicks to backend responses and database updates. These tests confirm that everything works together in a real-world scenario.

    • Strengths: Provides high confidence that the entire system functions correctly.
    • Limitations: Slow, complex to set up, and brittle—small UI changes can break them.
    • Best for: Critical user flows, such as signups, payments, and login/logout sequences.
  • Performance Tests / Profiling Performance tests, often technically referred to as profiling, measure how fast the system responds and how efficiently it handles requests. These tests help ensure that the software remains performant under normal and peak conditions.

    • Strengths: Helps detect slow response times, memory leaks, and bottlenecks.
    • Limitations: Requires specialized tools and environments to get accurate results.
    • Best for: Response time benchmarks, caching strategies, and database query optimization.
  • Load Tests A type of performance test that determines how well the system handles high levels of traffic. Load tests simulate many users interacting with the system simultaneously to expose potential crashes, slowdowns, race conditions and more.

    • Strengths: Helps anticipate scaling issues before they impact real users.
    • Limitations: Can be expensive and time-consuming to run effectively.
    • Best for: Large-scale web applications, APIs, and cloud services.
  • Security Tests Security tests probe the system for vulnerabilities like SQL injection, cross-site scripting (XSS), and authentication flaws.

    • Strengths: Helps identify potential security risks before attackers do.
    • Limitations: Often requires specialized security expertise and tools.
    • Best for: Web applications, authentication systems, and applications that handle sensitive data.

Making Testing Trade-offs

There’s no one-size-fits-all approach to testing. Each type has trade-offs: unit tests are fast but limited in scope, while E2E tests are comprehensive but slow. Writing too many of the wrong kinds of tests can be as bad as writing none at all.

In this tutorial, we focus on unit and integration tests because they provide a strong balance of speed, reliability, and practical value. They help you validate business logic and confirm that your system components interact correctly without the overhead of full E2E testing.

The key takeaway? Test intentionally. Every test you write should prove something important about your software.

Integration Testing a FastAPI Backend

Integration tests verify that different parts of your system work together correctly. For our FastAPI application, this means testing the complete request flow: from HTTP request handling through routing, dependency injection, and down to service implementation.

Understanding pytest

Before diving into FastAPI testing, let's understand pytest - Python's premier testing framework. pytest uses simple conventions to discover and run tests:

  • Test files must be named test_*.py or *_test.py
  • Test functions must start with test_

The pytest module is installed standard on Microsoft's Dev Container, but it's just pip package you can install and add to requirements.txt, on systems that do not bundle it. You can run pytest from the terminal in several ways:

# Run all tests in current directory and subdirectories
pytest

# Run tests with detailed output
pytest -v

# Run tests in a specific file
pytest test_main.py

# Run a specific test
pytest test_main.py::test_play_route

Basic FastAPI Integration Test Setup

Let's start with a basic integration test for our Rock, Paper, Scissors game. Create a file named test_main.py:

test_main.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_play_route_integration():
    """Test that the /play endpoint handles basic gameplay correctly."""
    response = client.post("/play", json={"user_choice": "rock"})

    # Verify HTTP-level details
    assert response.status_code == 200
    assert response.headers["content-type"] == "application/json"

    # Verify response structure
    data = response.json()
    assert "user_choice" in data
    assert "api_choice" in data
    assert "user_wins" in data
    assert "timestamp" in data

    # Verify data types and constraints
    assert data["user_choice"] == "rock"  # Our input is preserved
    assert data["api_choice"] in ["rock", "paper", "scissors"]
    assert isinstance(data["user_wins"], bool)

This test verifies several integration points:

  1. FastAPI correctly routes the POST request to the /play endpoint
  2. The endpoint successfully deserializes JSON into our GamePlay model
  3. The dependency injection system provides a GameService instance
  4. The service processes the game and returns a valid result
  5. FastAPI successfully serializes the GameResult back to JSON

Notice something this test does not prove: the logic of who wins. This illustrates one of the key benefits and downsides of an integration test versus a unit test: the benefit is you have confidence everything comes together from request-to-response in one test. The downside is it would be very cumbersome to try and fully test logic in this way. Sure, you could write a loop that plays the game enough times and re-encode the winning logic to test a winner, but that's thinking about an integration test at the wrong level of abstraction. That style of test is more suited for a unit test, which we will explore shortly.

Unit Testing

Let's dive into unit testing! While integration tests give us confidence that all the pieces work together, unit tests help us verify that individual components work correctly in isolation. This granular approach makes it easier to pinpoint issues when tests fail and often leads to better designed components.

Typically, you will write unit tests before writing integration tests, but since we will introduce some new techniques for isolating behavior in unit tests, we wanted to start with the bigger picture and then zoom in to emphasize the contrasts before getting into the details.

Unit Testing the Game Service

Let's start by testing the core game logic in our GameService class. Since this service needs to make random choices, we'll use Python's unittest.mock.patch to temporarily replace the random choice behavior during our tests and control it ourselves:

test_services.py
from unittest.mock import MagicMock
from services import GameService
from models import Choice, GamePlay, GameResult
from datetime import datetime, UTC


def create_mock_game_service(choice_to_return: Choice) -> GameService:
    """Create a GameService with a mocked _random_choice method"""
    service = GameService()
    service._random_choice = MagicMock(return_value=choice_to_return)
    return service


def test_game_service_rock_beats_scissors():
    # Create a service that will return scissors
    service = create_mock_game_service(Choice.scissors)
    result = service.play(GamePlay(user_choice=Choice.rock))

    assert result.user_choice == Choice.rock
    assert result.api_choice == Choice.scissors
    assert result.user_wins is True
    service._random_choice.assert_called_once()


def test_game_service_scissors_loses_to_rock():
    service = create_mock_game_service(Choice.rock)
    result = service.play(GamePlay(user_choice=Choice.scissors))

    assert result.user_choice == Choice.scissors
    assert result.api_choice == Choice.rock
    assert result.user_wins is False
    service._random_choice.assert_called_once()


def test_game_service_draw():
    service = create_mock_game_service(Choice.paper)
    result = service.play(GamePlay(user_choice=Choice.paper))

    assert result.user_choice == Choice.paper
    assert result.api_choice == Choice.paper
    assert result.user_wins is False  # Draws count as API wins
    service._random_choice.assert_called_once()


def test_game_service_all_combinations():
    """Test all possible game combinations systematically"""
    # Define all possible combinations and expected results
    test_cases = [
        (Choice.rock, Choice.scissors, True),  # Rock beats scissors
        (Choice.rock, Choice.paper, False),  # Rock loses to paper
        (Choice.rock, Choice.rock, False),  # Rock ties rock (API wins)
        (Choice.paper, Choice.rock, True),  # Paper beats rock
        (Choice.paper, Choice.scissors, False),  # Paper loses to scissors
        (Choice.paper, Choice.paper, False),  # Paper ties paper (API wins)
        (Choice.scissors, Choice.paper, True),  # Scissors beats paper
        (Choice.scissors, Choice.rock, False),  # Scissors loses to rock
        (Choice.scissors, Choice.scissors, False),  # Scissors ties scissors (API wins)
    ]

    for user_choice, api_choice, expected_win in test_cases:
        # Create a service that will return the API choice we want to test
        service = create_mock_game_service(api_choice)
        result = service.play(GamePlay(user_choice=user_choice))

        assert result.user_choice == user_choice
        assert result.api_choice == api_choice
        assert result.user_wins == expected_win, (
            f"Failed when user played {user_choice.value} "
            f"against API's {api_choice.value}"
        )
        service._random_choice.assert_called_once()

Understanding patch.object

The patch.object decorator/context manager is a powerful feature in Python's unittest.mock library that temporarily replaces attributes or methods during testing. Let's break down what's happening:

with patch.object(GameService, '_random_choice', return_value=Choice.scissors):
    service = GameService()
    # ... test code ...

This code:

  1. Temporarily replaces, or patches, the _random_choice method on the GameService class
  2. Any instance of GameService created within the with block will use the patched version
  3. The patched version always returns Choice.scissors (or whatever we specify as the return_value of the method)
  4. When the with block ends, the original method is restored

Patching is particularly useful when testing code that has external dependencies or non-deterministic behavior (like randomization). By patching, we make the behavior predictable during testing while preserving the actual implementation for normal use.

Unit Testing Route Functions

Now let's look at unit testing the FastAPI route functions. These tests focus on the route function itself, isolated from both HTTP concerns and service implementation. We will isolate the routing concerns by calling the functions directly and manually controlling the arguments (FastAPI routes are just plain-old functions, after all!). Additionally, we will isolate the service by mocking it, a technique best seen and explained with some real usage:

test_main_unit.py
    # Verify how the service was used
    mock_service.play.assert_called_once_with(GamePlay(user_choice=Choice.rock))

Understanding MagicMock

MagicMock is a powerful class in Python's unittest.mock library that creates objects that can pretend to be anything. Here's what makes it special:

  1. Automatic Method Creation: MagicMock automatically creates mock methods and attributes as you try to use them. When we access mock_service.play, MagicMock creates a play attribute that is itself a MagicMock. In doing so, as shown, you can also control the value returned by calling this mock method.

  2. Call Tracking: MagicMock records all calls made to it, including:

    • How many times it was called
    • What arguments were used
    • In what order calls occurred
  3. Verification Methods: MagicMock provides methods to verify how it was used:

    • assert_called() - Was it called at all?
    • assert_called_once() - Was it called exactly once?
    • assert_called_with(args) - Was it called with specific arguments?
    • assert_called_once_with(args) - Was it called once with specific arguments?

In our route test, mock_service.play.assert_called_once_with(GamePlay(user_choice=Choice.rock)) proves that:

  1. The route called the service's play method exactly once
  2. It passed exactly the Choice.rock argument
  3. It didn't call any other methods on the service

This verification is valuable because it proves that:

  • The route correctly forwards the user's choice to the service
  • It doesn't call the service multiple times
  • It doesn't modify the choice before passing it to the service
  • It doesn't call any other service methods it shouldn't

Admittedly, given how simple this route function is, unit testing it can feel a bit silly. Indeed, it's far more code to isolate the unit test than the actual implementation itself. The earlier tests proved more valuable things to our system. So, why write unit tests for such simple functions? If you had the integration test we started with, there probably isn't a valid reason to in this case! We didn't really prove anything useful other than more tightly encoding dependencies which already exist in our system. In large enough teams, though, policies of "every public function or method must be tested" is an axiom that helps prevent code bases from slipping into the dangerous territory of being untested.

So, when does unit testing a route function make more sense? Primarily when there is actual logic in the route function. This is common when you are returning a non-200 status code and helps verify expected responses. Or perhaps there's some additional logic the route is doing to pre-process inputs. For our purposes, if a route function just returns a method call and you already have an integration test: don't worry about the unit test.

Different Approaches for Different Needs

Notice we're using different mocking approaches for different parts of our system:

  1. We used patch to:

    • Override some internal method (such as randomization)
    • The patched method is an implementation detail
    • We want to isolate the service's game logic
  2. We used MagicMock to:

    • Verify interaction patterns between route and service
    • Isolate the route from the service that is normally dependency injected
    • We care about the interface of GameService in the unit tests for the route, not implementation. The implementation is tested in GameService unit tests.

This illustrates an important testing principle: choose your testing tools based on what you're trying to prove about your code. Sometimes you need to control behavior (patch), sometimes you need to verify interactions (MagicMock), and sometimes you might need both, which MagicMock can also do.

The Limitations of Tests

While testing is essential for software quality, we must understand its fundamental limitations. Tests provide confidence, not certainty, and even the most comprehensive test suite cannot guarantee bug-free software. The sheer number of possible input combinations, environmental factors, and user behaviors makes complete testing impossible. Tests themselves can be flawed, suffering from false positives that pass when they should fail, or false negatives that fail when they should pass. Perhaps most insidiously, tests might continue passing while no longer validating what they were intended to check due to changing assumptions about system behavior or business rules.

Test-Driven Development and Practical Strategies

Test-Driven Development (TDD) offers a structured approach to address many testing challenges through its Red-Green-Refactor cycle:

  1. Red: Write a failing test that defines the desired behavior
  2. Green: Write just enough code to make the test pass
  3. Refactor: Improve the code while maintaining passing tests

This methodology helps prevent over-engineering while ensuring code meets requirements. Different projects demand different testing approaches - critical systems require comprehensive testing at all levels, while rapid prototypes might focus only on key functionality.

When working with AI tools for testing, treat them as helpful starting points rather than complete solutions. While AI can quickly generate test cases and identify edge cases, human oversight remains crucial for ensuring tests are meaningful and align with project requirements.

The Economics and Culture of Testing

Testing represents a significant investment in time, technical infrastructure, and knowledge. Not every piece of code needs the same level of testing, and not every test provides equal value. Success requires building a culture that values testing while remaining pragmatic about testing efforts.

Summary

Testing is a fundamental skill. If you invest time and energy into learning how to test well it will pay dividends in your career. Throughout this chapter, we've explored how different types of tests—from unit tests that verify individual components to integration tests that ensure systems work together—serve distinct but complementary purposes. We've seen how testing transforms development itself: with a strong test suite, you can refactor confidently, catch regressions early, and build more reliable software. More importantly, we've learned that effective testing isn't about achieving perfect coverage or following rigid rules—it's about writing intentional tests that prove something meaningful about your code. As you apply these testing practices in your work, you'll find yourself writing more maintainable code, catching issues earlier, and delivering features with greater confidence.