Free lesson · Python Basic to Advance
Why Test
Why Test
What Testing Is
A test is code that runs other code and checks the result against an expected outcome. Testing is the practice of writing those checks systematically, so that changes to a codebase can be verified automatically rather than by manual inspection.
def add(a, b):
return a + b
# A simple test
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
test_add()
print("all checks passed")
This code shows the essential form of a test. The function under test is add. The test function calls add with known inputs and asserts the result matches expectations. When all assertions hold, the test passes silently. When one fails, it raises an AssertionError with line context.
Tests are a feedback loop. They tell you whether code behaves as intended - now, and after every future change.
What Testing Buys
Tests serve several purposes that compound over time:
- Regression detection - catch bugs introduced by changes
- Documentation - tests show how code is meant to be used
- Design pressure - code that's hard to test is often code that's hard to use
- Refactoring confidence - change implementation without changing behavior, verified
- Specification - the test names and assertions describe the contract
A small change in a function you wrote six months ago can break code elsewhere. Without tests, that break may not appear until production. With tests, it appears the moment the change is saved.
The Cost-Benefit Trade
Testing has a real cost - time spent writing tests, time spent maintaining them, code that exists only to verify other code. The benefit compounds over the codebase's lifetime.
For one-off scripts that solve a problem and are discarded, the cost-benefit usually favors no tests. For code that lives, gets read, gets changed, and gets relied on by others - the calculus inverts.
# Throwaway script - probably skip tests
def parse_my_one_csv():
with open("today.csv") as f:
return [line.split(",") for line in f]
# Library code - tests are essential
def parse_csv(path, delimiter=",", skip_header=True):
"""Parse a CSV file with configurable options."""
# ...
This code contrasts contexts. The throwaway script will run once. The library function will be called by other code in different contexts. The library function carries an implicit contract that tests can verify and document. The throwaway script has no future users to protect.
Categories of Tests
Tests come in layers, each verifying different scope:
- Unit tests - verify one function, method, or class in isolation
- Integration tests - verify components working together (database, network, file system)
- End-to-end tests - verify the full system as a user would experience it
- Performance tests - verify that code stays fast enough
- Property tests - verify properties that should hold for many inputs (Topic 23.8)
A common heuristic: many unit tests, fewer integration tests, even fewer end-to-end tests. The pyramid metaphor - broad base of fast unit tests, narrow tip of slow full-system tests - reflects the trade-off between coverage and execution time.
Test-First vs Test-After
Two workflows:
- Test-after - write the code, then write tests for it
- Test-driven (TDD) - write the test first, watch it fail, write the minimum code to pass, refactor
TDD is a discipline that shapes the design: code written to pass an immediate test tends to be small, well-bounded, and verifiable. Test-after is more common in practice and works fine for most code. The choice is largely cultural.
# TDD workflow - example sequence
# 1. Write the test (fails)
def test_double():
assert double(3) == 6
# 2. Write minimal code (passes)
def double(n):
return n * 2
# 3. Add a more demanding test
def test_double_handles_negatives():
assert double(-2) == -4
# 4. Refactor if needed (still passes)
This code shows the TDD cycle. Start with a failing test. Write the minimum code to pass. Add another test demanding more. Refactor when the implementation reveals improvements. The discipline keeps code small and focused on demonstrated requirements rather than imagined ones.
What Makes a Good Test
A few properties distinguish strong tests from weak ones:
- Independent - runs in isolation; order of tests doesn't matter
- Repeatable - same result every run; no flakiness
- Fast - milliseconds per test, not seconds
- Focused - tests one behavior at a time
- Self-validating - passes or fails; no manual inspection needed
- Readable - the test name and body describe the behavior tested
# Weak test - vague, dependent on output format
def test_user():
user = create_user("alice")
print(user) # Manual inspection needed
# Strong test - focused, self-validating
def test_create_user_sets_name():
user = create_user("alice")
assert user.name == "alice"
def test_create_user_assigns_id():
user = create_user("alice")
assert user.id is not None
This code contrasts test quality. The weak version requires reading output to know if it worked. The strong version asserts specific properties - the test passes silently or fails loudly with a clear message. Each focused test has one reason to fail, making debugging much faster.
The AAA Pattern
A common structure for tests - Arrange, Act, Assert:
def test_account_deposit():
# Arrange - set up the state
account = Account(balance=100)
# Act - perform the operation
account.deposit(50)
# Assert - verify the outcome
assert account.balance == 150
This code uses the AAA pattern. Arrange sets up the preconditions. Act performs the operation being tested. Assert verifies the result. The three-phase structure makes tests easy to read and maintain. Blank lines or comments often separate the phases for clarity.
Following AAA makes tests easier to scan. A reader can identify the setup, the action, and the verification at a glance.
Test Failure Messages
Good tests give clear failure messages. Poor tests give cryptic ones:
# Weak failure message
def test_calculation():
result = compute(10)
assert result == 42
# When this fails:
# AssertionError
# Strong failure message
def test_calculation():
result = compute(10)
assert result == 42, f"compute(10) returned {result}, expected 42"
# When this fails:
# AssertionError: compute(10) returned 37, expected 42
This code shows assertion messages. A plain assert gives no context on failure. Adding a message after the comma includes diagnostic information - the actual value, the expected value, the input that produced it. Topic 23.3 covers pytest's automatic introspection that makes messages clear without manual formatting.
pytest (Topic 23.3) introspects assertions automatically - so the manual message is often unnecessary. With unittest or plain assert, explicit messages help.
When Tests Fail
A failing test should be informative: name describes the behavior, traceback shows the line, assertion shows the values. The goal is for a developer reading the failure to understand the problem in seconds, not minutes.
When a test fails:
- Read the test name first - it usually describes what's broken
- Read the assertion - see expected vs actual
- Trace the values back - what setup produced this state
- Fix the code, not the test - unless the test was wrong
The reflex to "make the test green" by relaxing the assertion is a trap. The test exists to enforce behavior; weakening it weakens the protection.
Common Anti-Patterns
A few patterns to avoid:
- Tests that don't fail when the code is broken - meaningless
- Tests that depend on each other - order-dependent and fragile
- Tests that test the framework, not the code - wasted effort
- One test asserting many unrelated things - hard to diagnose failures
- Tests that mock everything - they verify the mocks, not real behavior
- Tests with magic numbers -
assert result == 42without explanation
# Anti-pattern - many unrelated assertions
def test_user():
user = create_user("alice")
assert user.name == "alice"
assert user.id is not None
assert user.created_at is not None
user.set_email("alice@example.com")
assert user.email == "alice@example.com"
user.activate()
assert user.is_active
# Better - focused tests, easier to debug
def test_create_user_sets_name(): ...
def test_create_user_assigns_id(): ...
def test_set_email_updates_email(): ...
def test_activate_makes_user_active(): ...
This code contrasts patterns. The first version mixes multiple behaviors into one test - when it fails, the line tells you what broke but not which behavior. The second version separates each behavior. Failures pinpoint exactly which method or property is broken.
The Python Testing Ecosystem
Python has two major testing tools and several supporting libraries:
unittest- standard library; xUnit-style, class-based (Topic 23.2)pytest- third-party, more popular for new code; function-based, rich fixtures (Topic 23.3)unittest.mock- mocking, included in standard library (Topic 23.6)coverage.py- measure which lines are executed by tests (Topic 23.7)hypothesis- property-based testing (Topic 23.8)
For new code, pytest is usually the right choice. unittest remains common in older codebases and the standard library itself.
Tests Run Automatically
The point of tests is to run them often - ideally on every change. Mechanisms:
- Manual - run before committing
- Pre-commit hooks - block commits that fail tests
- Continuous integration (CI) - run tests on every push to a shared repository
- Pull request gates - block merges until tests pass
A test suite that runs in seconds gets run often. One that takes ten minutes gets skipped, then ignored, then forgotten. Fast tests are not a nicety - they're a requirement for the practice to work.
Interview Relevance
Interview: Testing fundamentals are expected:
- What tests are and why they exist
- Unit vs integration vs end-to-end
- The AAA pattern
- What makes a test fast, independent, repeatable
unittestvspytest(most new code usespytest)- Test-driven development as a discipline
A common interview moment: a candidate dismisses tests as "no time for that on this project." The right framing is that tests pay back the cost on the first regression they catch - which usually happens within days.
Another moment: explaining why a test that tests everything in one function is a problem. The right answer involves diagnosability when it fails.
Module 30 covers Python interview preparation.
Common Pitfalls
Warning: Tests that pass without exercising the code. Often happens with empty assertions or mocks that hide reality. Verify the test fails when the code is wrong.
One test per behavior, ignored. Tests that bundle many assertions hide which behavior actually failed.
Tests depending on order. Creates fragility. Each test should run in isolation.
Skipping tests because "the change is small." Most regressions come from small changes.
Testing only the happy path. Edge cases - empty input, large input, error conditions - are where bugs live.
Tests that take too long to run. Slow tests get skipped. Keep unit tests in milliseconds.
Treating tests as second-class code. Test code lives as long as production code; it needs the same care.
Try It
Exercise: Practice with the testing mindset:
- Pick a function from your code. Write three tests for it - one happy path, two edge cases.
- Run your tests. Modify the function to break it deliberately. Verify the tests catch the break.
- Refactor a test that bundles multiple assertions into multiple focused tests.
- Pair a test name with the behavior it verifies. Make the name describe the contract.
- Time your test suite. Identify the slowest tests and consider why they're slow.
- For a piece of code without tests, write the test you wish existed before changing it.