Layer 5 Session 32 Testing

Setting Up pytest

We install and configure pytest, then convert Session 31's manual assert-based checks into real, discoverable, well-organized pytest tests.

assert result == expected

A real tool for running and reporting on those checks.

Estimated time: 35โ€“40 minutes  ยท  Pre-quiz โ†’ Concept โ†’ Lab โ†’ Commit โ†’ Post-quiz

1. Learning Objective

By the end of this session you will be able to:

  • Install pytest and understand its file/function naming conventions for test discovery
  • Write a test function pytest can automatically discover and run
  • Run the test suite from the command line and read its output
  • Compare a failing pytest assertion's output to a bare assert's output
  • Organize tests into a dedicated tests/ directory, separate from application code

2. Pre-Coding Quiz

Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ€” that is the point. You need 4/5 to proceed to the lab.

Question 1 of 5

For pytest to automatically discover a test function without any extra configuration, what must be true about its name and the file it lives in?

pytest's default discovery convention looks for files matching test_*.py or *_test.py, and within them, functions starting with test_. Following this convention means you never need to manually register a test.
Question 2 of 5

How do you actually run the discovered tests from the command line?

Simply running pytest in the project directory automatically discovers and runs every test file/function matching its naming convention โ€” no manual listing required.
Question 3 of 5

When a plain assert k.population == 55000000 fails inside a pytest test, what does pytest's output show you that a bare Python assert (Session 31) does not?

This is one of pytest's most useful features: it introspects a failing assert and shows you the actual runtime values involved, giving a much clearer picture of what went wrong than a bare assert's generic AssertionError.
Question 4 of 5

Why organize tests into a separate tests/ directory instead of mixing test files in with application code?

Just like Session 10's module-splitting discipline, a dedicated tests/ directory keeps concerns separated and makes it immediately obvious, to anyone browsing the project, where the tests live versus where the actual application logic lives.
Question 5 of 5

What is the pytest equivalent of Session 31's manual test_grow_population_rejects_negative(), which checked that calling grow_population(-5) raised ValueError?

pytest.raises(ExceptionType) is a context manager specifically for asserting that a block of code raises a given exception โ€” replacing Session 31's manual try/except/assert False pattern with something more concise and pytest-native.

3. The Concept โ€” pytest Fundamentals

BARE ASSERTAssertionError(no detail)PYTEST ASSERTassert 55000000== 56000000

A bare assert just says "failed." pytest shows you the actual values on both sides, automatically.

Installing pytest

pytest is installed like any third-party package, typically with pip.

# In your terminal (not in a .py file):
# pip install pytest

Test discovery conventions

pytest automatically finds and runs tests that follow its naming conventions โ€” no manual registration required, unlike Session 31's hand-called test functions.

# tests/test_country.py  โ€” file name starts with test_
from country import Country  # importing the real application module

def test_summary_format():   # function name starts with test_
    k = Country(name="Kenya", region="Africa", population=54000000)
    assert k.summary() == "Kenya (Africa): pop. 54,000,000"

Running the suite

A single command runs every discovered test and reports a summary โ€” pass/fail counts and details on any failures.

# In your terminal:
# pytest
#
# Example output:
# ===== test session starts =====
# collected 3 items
#
# tests/test_country.py ...                                    [100%]
#
# ===== 3 passed in 0.02s =====

Readable failure output

When a plain assert fails inside a pytest test, pytest shows you the actual values on both sides โ€” far more informative than a bare AssertionError.

def test_grow_population_increases_value():
    k = Country(name="Kenya", region="Africa", population=54000000)
    k.grow_population(1000000)
    assert k.population == 56000000  # deliberately wrong, to see pytest's failure output

# pytest shows something like:
#     assert 55000000 == 56000000
# โ€” pytest introspected the actual values automatically, no manual message needed

Testing for raised exceptions with pytest.raises

pytest provides a dedicated, cleaner way to assert that code raises a specific exception, replacing Session 31's manual try/except/assert False.

import pytest

def test_grow_population_rejects_negative():
    k = Country(name="Kenya", region="Africa", population=54000000)
    with pytest.raises(ValueError):
        k.grow_population(-5)
    # if grow_population does NOT raise ValueError, this test fails automatically

4. Lab

Lab objective: Set up a proper project structure with a tests/ directory, convert Session 31's manual tests to pytest, and run the suite.

What you will build

A file called country.py (the real module) and tests/test_country.py.

Step-by-step instructions

1

Create country.py as a real, importable module

# country.py
class Country:
    def __init__(self, name, region, population):
        self.name = name
        self.region = region
        self.population = population

    def summary(self):
        return f"{self.name} ({self.region}): pop. {self.population:,}"

    def set_population(self, value):
        if value < 0:
            raise ValueError(f"population must be non-negative, got {value}")
        self.population = value

    def grow_population(self, amount):
        if amount < 0:
            raise ValueError(f"amount must be non-negative, got {amount}")
        self.population += amount
2

Create the tests/ directory and test_country.py

# tests/test_country.py
import pytest
from country import Country

def test_summary_format():
    k = Country(name="Kenya", region="Africa", population=54000000)
    assert k.summary() == "Kenya (Africa): pop. 54,000,000"

def test_grow_population_increases_value():
    k = Country(name="Kenya", region="Africa", population=54000000)
    k.grow_population(1000000)
    assert k.population == 55000000
3

Add exception tests using pytest.raises

def test_grow_population_rejects_negative():
    k = Country(name="Kenya", region="Africa", population=54000000)
    with pytest.raises(ValueError):
        k.grow_population(-5)

def test_set_population_rejects_negative():
    k = Country(name="Kenya", region="Africa", population=54000000)
    with pytest.raises(ValueError):
        k.set_population(-1)
4

Run pytest from the project root and confirm all tests pass

Run the pytest command and read the summary output.

# pytest
5

Deliberately break one test to see pytest's failure output, then fix it

Change an expected value to something wrong, run pytest again, read the detailed failure output, then revert it.


5. Expected Files Changed

FileActionWhy
country.py Created The real, importable Country module (no longer duplicated inline in a lab script).
tests/test_country.py Created A proper pytest test suite for Country, organized in a dedicated tests/ directory.
docs/sessions/session-32/index.html Created This session document.
If you find yourself editing any other file, stop. This session touches exactly 3 files.

6. Commit Checkpoint

Once the lab is complete and you can explain every line, make this exact commit:

git add country.py tests/test_country.py docs/sessions/session-32/index.html
git commit -m "session-32: set up pytest with a real tests/ directory"
Do not commit until you can answer out loud: "What naming convention did I follow to make pytest automatically discover these tests without any manual registration?"

7. Code Review Checklist

Go through your code line by line and check each item:


8. Post-Coding Quiz

Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.

Question 1 of 5

For pytest to automatically discover a test function without any extra configuration, what must be true about its name and the file it lives in?

pytest's default discovery convention looks for files matching test_*.py or *_test.py, and within them, functions starting with test_. Following this convention means you never need to manually register a test.
Question 2 of 5

How do you actually run the discovered tests from the command line?

Simply running pytest in the project directory automatically discovers and runs every test file/function matching its naming convention โ€” no manual listing required.
Question 3 of 5

When a plain assert k.population == 55000000 fails inside a pytest test, what does pytest's output show you that a bare Python assert (Session 31) does not?

This is one of pytest's most useful features: it introspects a failing assert and shows you the actual runtime values involved, giving a much clearer picture of what went wrong than a bare assert's generic AssertionError.
Question 4 of 5

Why organize tests into a separate tests/ directory instead of mixing test files in with application code?

Just like Session 10's module-splitting discipline, a dedicated tests/ directory keeps concerns separated and makes it immediately obvious, to anyone browsing the project, where the tests live versus where the actual application logic lives.
Question 5 of 5

What is the pytest equivalent of Session 31's manual test_grow_population_rejects_negative(), which checked that calling grow_population(-5) raised ValueError?

pytest.raises(ExceptionType) is a context manager specifically for asserting that a block of code raises a given exception โ€” replacing Session 31's manual try/except/assert False pattern with something more concise and pytest-native.

9. Reflection Questions

Think through these after the post-quiz. No right answer โ€” they are for discussion.

  1. Compare the effort of writing test_grow_population_rejects_negative() with pytest.raises() versus Session 31's manual try/except/assert False version. Which communicates intent more clearly to a reader?
  2. Why does pytest need file and function names to follow a convention, instead of you manually telling it which functions are tests?
  3. What did the deliberately-broken test's failure output show you that made it easy to identify the problem?
  4. How does organizing tests/ separately from country.py reflect the same reasoning as splitting country_data.py from explorer.py back in Session 10?

10. What Breaks If This Knowledge Is Missing?

  • Forgetting to run tests: Even a good test suite is useless if no one remembers to run it before shipping a change โ€” this is a process discipline this session sets up the tooling for, but does not solve by itself (CI automation solves it, but is out of scope for this curriculum).
  • Testing props/output specifically (Session 33): This session set up the tooling; the next two sessions dive into what specifically to assert on โ€” return values and object state โ€” building real test coverage across the whole project.
  • Confident refactoring (Layer 6): A real, runnable pytest suite is the safety net that makes Layer 6's architecture refactoring sessions safe to do boldly instead of nervously.

11. What We Learned

Python concept mastered: pytest fundamentals โ€” installation, test discovery conventions, running the suite, readable failure output, and pytest.raises for exception testing.

Unlocks: The project now has a real, professional testing setup โ€” the foundation for every remaining test-writing session in Layer 5.

Next session: Session 33 โ€” Testing Functions and Return Values. We dig into what specifically to assert on for functions and methods that return values, covering edge cases systematically.