Chapter 11: Testing Your Code

Testing proves your code works. Catch bugs before users do.

Installing pytest

pytest is a third-party package. Install with pip:

python -m pip install --user pytest

Verify installation:

pytest --version

Testing a Function

The Function to Test

# network.py
def get_connection_string(host, port, protocol='tcp'):
    """Return formatted connection string."""
    return f"{protocol}://{host}:{port}"

Writing a Test

# test_network.py
from network import get_connection_string

def test_basic_connection():
    """Test basic connection string."""
    result = get_connection_string('10.0.1.10', 443)
    assert result == 'tcp://10.0.1.10:443'

Key rules:

  • Filename starts with test_

  • Function name starts with test_

  • Use assert to verify results

Running Tests

pytest

Output for passing test:

========================= test session starts =========================
collected 1 item

test_network.py .                                                [100%]
========================== 1 passed in 0.01s ==========================

Single dot = one passing test.

A Failing Test

If we break the function:

========================= test session starts =========================
collected 1 item

test_network.py F                                                [100%]
============================== FAILURES ===============================
________________________ test_basic_connection ________________________

    def test_basic_connection():
        """Test basic connection string."""
        result = get_connection_string('10.0.1.10', 443)
>       assert result == 'tcp://10.0.1.10:443'
E       AssertionError: assert 'tcp:10.0.1.10:443' == 'tcp://10.0.1.10:443'

test_network.py:6: AssertionError
========================== 1 failed in 0.04s ==========================

The F indicates failure. Output shows exactly what failed and why.

Responding to Failures

When a test fails:

  1. Don’t change the test (unless it’s wrong)

  2. Fix the code that broke

  3. Run tests again

Tests define correct behavior. Fix code to match.

Multiple Tests

# test_network.py
from network import get_connection_string

def test_basic_connection():
    """Test with default protocol."""
    result = get_connection_string('10.0.1.10', 443)
    assert result == 'tcp://10.0.1.10:443'

def test_custom_protocol():
    """Test with explicit protocol."""
    result = get_connection_string('10.0.1.10', 443, 'https')
    assert result == 'https://10.0.1.10:443'

def test_different_port():
    """Test different port number."""
    result = get_connection_string('db-01', 5432)
    assert result == 'tcp://db-01:5432'
test_network.py ...                                              [100%]
========================== 3 passed in 0.01s ==========================

Three dots = three passing tests.

Assertion Types

# Equality
assert result == expected
assert result != wrong_value

# Boolean
assert is_valid
assert not is_error

# Membership
assert 'error' in log_message
assert server not in blocked_list

# Type checking
assert isinstance(result, dict)

Testing a Class

# server.py
class Server:
    def __init__(self, hostname, ip):
        self.hostname = hostname
        self.ip = ip
        self.status = 'stopped'

    def start(self):
        self.status = 'running'

    def stop(self):
        self.status = 'stopped'
# test_server.py
from server import Server

def test_initial_status():
    """New servers start stopped."""
    srv = Server('web-01', '10.0.1.10')
    assert srv.status == 'stopped'

def test_start():
    """Start changes status to running."""
    srv = Server('web-01', '10.0.1.10')
    srv.start()
    assert srv.status == 'running'

def test_stop():
    """Stop changes status to stopped."""
    srv = Server('web-01', '10.0.1.10')
    srv.start()
    srv.stop()
    assert srv.status == 'stopped'

Fixtures

Fixtures provide shared setup. Avoid repeating code.

Without Fixture

def test_start():
    srv = Server('web-01', '10.0.1.10')  # repeated
    srv.start()
    assert srv.status == 'running'

def test_stop():
    srv = Server('web-01', '10.0.1.10')  # repeated
    srv.start()
    srv.stop()
    assert srv.status == 'stopped'

With Fixture

import pytest
from server import Server

@pytest.fixture
def web_server():
    """Provide a Server instance for tests."""
    return Server('web-01', '10.0.1.10')

def test_initial_status(web_server):
    """New servers start stopped."""
    assert web_server.status == 'stopped'

def test_start(web_server):
    """Start changes status to running."""
    web_server.start()
    assert web_server.status == 'running'

def test_stop(web_server):
    """Stop changes status to stopped."""
    web_server.start()
    web_server.stop()
    assert web_server.status == 'stopped'
  • @pytest.fixture decorator marks the function

  • Test functions receive fixture by parameter name

  • Each test gets a fresh instance

Test Organization

Project Structure

project/
├── network.py
├── server.py
└── tests/
    ├── test_network.py
    └── test_server.py

Run all tests:

pytest

Run specific file:

pytest tests/test_server.py

Run specific test:

pytest tests/test_server.py::test_start

Naming Conventions

  • Test files: test_*.py or *_test.py

  • Test functions: test_*

  • Test classes: Test*

Descriptive Names

# Good - describes what's being tested
def test_connection_string_with_default_protocol():
def test_server_starts_in_stopped_state():
def test_invalid_port_raises_error():

# Bad - vague
def test_connection():
def test_server():
def test_port():

Testing Patterns

Testing for Exceptions

import pytest

def test_invalid_port_raises_error():
    with pytest.raises(ValueError):
        create_connection('host', -1)

Parameterized Tests

Test multiple inputs with one function:

import pytest

@pytest.mark.parametrize("host,port,expected", [
    ('web-01', 80, 'tcp://web-01:80'),
    ('db-01', 5432, 'tcp://db-01:5432'),
    ('cache', 6379, 'tcp://cache:6379'),
])
def test_connection_strings(host, port, expected):
    result = get_connection_string(host, port)
    assert result == expected

When to Test

  • Test critical behaviors first

  • Test edge cases (empty input, zero, negative)

  • Test error conditions

  • Add tests when fixing bugs (prevent regression)

You don’t need 100% coverage. Test what matters.

Quick Reference

Command Purpose

pytest

Run all tests

pytest file.py

Run tests in file

pytest -v

Verbose output

pytest -x

Stop on first failure

pytest --tb=short

Shorter tracebacks

Pattern Code

Assert equal

assert a == b

Assert true

assert condition

Assert in

assert x in collection

Fixture

@pytest.fixture

Expect exception

with pytest.raises(Error):

Exercises

11-1. City Function

Write format_city(city, country) returning "City, Country". Write tests for it.

11-2. Server Class

Test your Server class from Chapter 9. Test initial state and methods.

11-3. Fixture Practice

Create a fixture that provides a configured server. Use it in 3 tests.

11-4. Edge Cases

Write tests for: empty strings, very long strings, special characters.

11-5. Error Handling

Write a function that raises ValueError for invalid input. Test that it raises correctly.

Summary

  • pytest discovers and runs test files/functions starting with test_

  • assert verifies expected outcomes

  • Failing tests show exactly what went wrong

  • Fix code, not tests, when tests fail

  • Fixtures provide shared setup with @pytest.fixture

  • Run specific tests: pytest file.py::test_name

  • Test critical behaviors and edge cases

Part I complete. Next: Building projects.