Learn Python Series (#38) - Testing Your Code Part 1

in #stem19 hours ago (edited)

Learn Python Series (#38) - Testing Your Code Part 1

python-logo.png

Repository

What will I learn?

  • You will learn why testing matters and what problems it actually solves;
  • the mental model behind automated testing - assertions as executable documentation;
  • how to think about test organization and what makes a good test;
  • the difference between testing for correctness vs testing for regressions;
  • how fixtures work and why they exist instead of simple setup functions.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Python 3(.11+) distribution;
  • The ambition to learn Python programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Python Series):

GitHub Account

https://github.com/realScipio

Learn Python Series (#38) - Testing Your Code Part 1

You write a function. It works. You test it manually - call it a few times, check the output, looks good. Ship it.

Three weeks later, you modify a different part of the code. Suddenly, that function breaks. You didn't touch it directly, but something it depended on changed. The bug makes it to production. A user reports it. You spend an hour debugging what should have been caught in seconds.

This is the problem automated testing solves.

Nota bene: This episode is about understanding WHY we test and HOW to think about tests - not just memorizing pytest syntax. Tests are executable documentation that catches regressions automatically.

The core problem: code changes

Here's the fundamental insight: code that never changes doesn't need tests. If you write a script once, run it once, and delete it, testing is overkill.

But real software changes constantly. You add features. You refactor. You fix bugs. You upgrade dependencies. Every change is an opportunity to break something that previously worked.

Manual testing doesn't scale. After every change, you'd need to manually verify that everything still works. For a codebase with 50 functions, that's 50 things to check. For 500 functions, impossible.

Automated tests make this scaling problem trivial. Write the test once, run it automatically after every change. The test verifies behavior in milliseconds. If it passes, you have confidence. If it fails, you know exactly what broke.

This is why "works on my machine" isn't enough. Your machine today. What about your machine tomorrow, after you've made 20 changes? What about someone else's machine, running a different Python version? Tests provide repeatable verification independent of who runs them or when.

Tests as executable specifications

Think about documentation. You might write: "The add(a, b) function returns the sum of two numbers." This documents the intent, but nobody checks if it's true. If you change the function to multiply instead, the documentation becomes a lie.

A test is documentation that executes:

def test_add_returns_sum():
    assert add(2, 3) == 5

This says: "When I call add(2, 3), I expect 5." If the function changes and returns something else, the test fails immediately. The documentation can't become outdated because it's verified automatically.

This is the mental model: tests are specifications written as code. They describe what the code SHOULD do. The test framework verifies that the code DOES what it should.

The anatomy of a test

Every test follows the same pattern:

1. Arrange: Set up the conditions for the test. Create the data you need, initialize objects, prepare the environment.

2. Act: Execute the code you're testing. Call the function, trigger the event, perform the operation.

3. Assert: Verify the result matches expectations. Check return values, check side effects, check that the world is in the expected state.

This pattern is universal across all testing frameworks and languages. Understanding it helps you structure tests clearly:

def test_divide_positive_numbers():
    # Arrange
    calculator = Calculator()
    
    # Act
    result = calculator.divide(10, 2)
    
    # Assert
    assert result == 5

Sometimes the arrange step is trivial (no setup needed), sometimes the act and assert merge (one line), but the conceptual pattern remains.

Assertions: expectations as code

The assert keyword in Python is simple: if the condition is True, continue; if False, raise an AssertionError. Test frameworks catch that error and report a failure.

assert 2 + 2 == 4

This passes. The condition is True, nothing happens, the test continues.

assert 2 + 2 == 5

This fails. Python raises AssertionError, pytest catches it, marks the test failed, and shows you what went wrong.

The power comes from pytest's introspection. When you write assert result == expected, pytest doesn't just tell you it failed - it shows you the actual values:

E   assert 4 == 5

You see both sides of the comparison. For complex objects, pytest shows detailed differences. This makes debugging failing tests fast.

Nota bene: Assertions are expectations. You're telling the test framework: "I expect this condition to be true. If it's not, something is wrong, and I want to know immediately."

What makes a good test?

Tests can be written badly. A test that's hard to understand, slow to run, or flaky (sometimes passes, sometimes fails for no clear reason) is worse than no test.

Good tests share these properties:

Fast: Tests should run in milliseconds. If your test suite takes minutes, you won't run it frequently. Fast tests enable rapid feedback.

Independent: Each test should stand alone. Test order shouldn't matter. If test B only passes when test A runs first, that's fragile. Use fixtures (explained below) to provide fresh state for each test.

Repeatable: Run the test 100 times, get the same result 100 times. Tests that depend on current time, random numbers, or network availability become flaky.

Self-checking: The test itself determines pass/fail through assertions. You shouldn't need to read output and manually decide if it's correct.

Focused: Each test verifies one specific behavior. If a test has 10 assertions checking unrelated things, failures are hard to debug. Better to have 10 small tests.

Test organization: mirror your code

A common pattern is to mirror your source code structure in your test directory:

myproject/
├── src/myproject/
│   ├── calculator.py
│   └── parser.py
└── tests/
    ├── test_calculator.py
    └── test_parser.py

This makes tests easy to find. Want to test calculator.py? Look in test_calculator.py. This convention is so widespread that pytest automatically discovers and runs any file named test_*.py or *_test.py.

Within each test file, group related tests into classes or simply use descriptive function names. The goal: someone reading the test should understand what behavior is being verified without digging through implementation details.

Fixtures: reusable test setup

The arrange step often involves setup: creating objects, loading data, establishing state. If every test does the same setup, you'll copy-paste code everywhere.

Fixtures solve this. A fixture is a function that provides test data or resources. Tests declare which fixtures they need as parameters, and pytest automatically calls those fixtures and passes the results in.

The mental model: fixtures are dependency injection for tests. Instead of each test creating its own setup, fixtures centralize common setups and pytest wires them together.

Why fixtures instead of simple setup functions? Fixtures support composition (fixtures can use other fixtures), scoping (control how often setup runs), and teardown (cleanup after tests). They're more powerful and more flexible.

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

The test_sum function declares it needs sample_data. Pytest sees this, calls the sample_data fixture, and passes the result to the test. The test doesn't know or care how sample_data is created - it just uses it.

This separation of concerns makes tests cleaner. The test focuses on behavior verification. The fixture handles setup details.

Testing for failure: exceptions expected

Some code is supposed to fail under certain conditions. A divide-by-zero should raise an error. Invalid input should raise ValueError. These aren't bugs - they're expected behavior.

You need to test that these failures happen correctly. The pattern: pytest's raises context manager captures the expected exception and lets you verify it occurred:

def test_divide_by_zero_raises_error():
    with pytest.raises(ValueError):
        divide(10, 0)

This test PASSES if divide(10, 0) raises ValueError. If it doesn't raise anything, or raises a different exception, the test fails.

Think of it as inverting the expectation. Normally you assert a result. Here you assert an exception. Both are specifications of correct behavior.

Parametrized tests: multiple inputs, one logic

You often want to test the same logic with different inputs. Instead of writing 10 nearly-identical test functions, parametrize one test:

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

Pytest runs this test three times, once per parameter set. Each run is reported separately. If one fails, you see exactly which input caused the failure.

This is more than convenience - it's clarity. The parameter list documents all the edge cases you're testing. Someone reading this immediately sees: positive numbers, zeros, negative numbers, mixed signs. The test serves as executable documentation of supported scenarios.

Getting started with pytest

Install pytest (using Poetry from episode #37):

poetry add --group dev pytest

Create a test file starting with test_, write test functions starting with test_, use assert for expectations. Run pytest from your project root. That's the basics.

Pytest discovers tests automatically, runs them, reports results. No boilerplate, no test classes required (though you can use them for organization), no complex configuration needed for simple cases.

The convention-over-configuration philosophy: follow the naming patterns, and everything just works.

Wrapping up

In this episode, we covered the conceptual foundations of automated testing:

  • Why tests exist: catching regressions automatically as code changes
  • Tests as executable specifications that can't become outdated
  • The arrange-act-assert pattern that structures every test
  • Assertions as expectations written in code
  • What makes tests good: fast, independent, repeatable, self-checking, focused
  • Test organization mirroring source code structure
  • Fixtures as dependency injection for reusable test setup
  • Testing expected failures with exception assertions
  • Parametrized tests for multiple inputs with shared logic

Testing isn't about memorizing pytest commands. It's about understanding that software changes, manual verification doesn't scale, and executable specifications provide automated confidence.

If you made it this far, you're doing great. Thanks for your time!

@scipio

Sort:  

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

Thanks again STEMsocial !!! <3