Testing Fundamentals
Automated tests are executable specifications of correct behavior — this topic covers the core vocabulary and mental models (unit, integration, end-to-end tests, isolation, assertions) that every Python testing tool builds on.
Testing is the practice of writing code that automatically checks whether other code behaves correctly, so regressions are caught by running a suite instead of by a human noticing something broke in production. Before diving into a specific tool (the standard library's unittest, or the widely-used third-party pytest), it's worth understanding the vocabulary and design principles that every testing approach shares: what a test actually verifies, the different granularities of tests, why isolation matters, and what makes an assertion meaningful. This topic lays that foundation; the dedicated unittest topic covers the standard library's specific API.
What Is Automated Testing
An automated test is a small program that runs a piece of your code with known inputs and checks that the output (or resulting state, or raised exception) matches what's expected. A test SUITE is a collection of such tests, runnable together (often on every commit, via CI) to give fast, repeatable confidence that a codebase still behaves as intended after a change — replacing (or drastically reducing) manual, ad hoc verification.
The value of a test comes from three properties: it's automatic (no human needs to remember to run it or judge the result by eye), it's repeatable (the same test gives the same result every time, given the same code), and it fails loudly and specifically when something breaks (pinpointing what's wrong, not just that something is wrong).
Why Testing Matters
Tests catch regressions — bugs introduced by a change that breaks previously-working behavior — often before the code ever reaches a user. They document expected behavior in a form that stays accurate (unlike comments, a test that lies about behavior simply fails), enable confident refactoring (a comprehensive suite tells you immediately if a rewrite changed behavior it shouldn't have), and speed up debugging by narrowing down exactly which unit of code misbehaves. The upfront cost of writing tests is real, but it is consistently cheaper than the compounding cost of manually re-verifying behavior by hand every time code changes.
Unit Tests
A unit test verifies the smallest testable piece of code in isolation — typically a single function or method — with everything it depends on either genuinely simple or replaced by a test double (see Test Isolation below). Unit tests should be fast (milliseconds, so hundreds or thousands can run in seconds), numerous (most of a healthy test suite is unit tests), and tightly focused on one behavior per test.
This directly tests one function's behavior with no external dependencies involved — the defining trait of a unit test.
def add(a: int, b: int) -> int:
return a + b
# A unit test in plain Python (conceptually -- see the unittest topic for the real API)
def test_add_returns_sum():
result = add(2, 3)
assert result == 5, f"expected 5, got {result}"
test_add_returns_sum()
print("unit test passed")Integration Tests
An integration test verifies that two or more units work correctly TOGETHER — a function that calls another module, a class that talks to a real (often test-configured) database, a service layer that combines several components. Integration tests catch bugs that unit tests structurally cannot: mismatched assumptions at the seams between components, even when each component is individually correct in isolation. They are naturally slower and fewer in number than unit tests, since they exercise more real machinery (actual databases, file systems, or network calls, sometimes against local test instances).
This exercises save_user against a real (if in-memory, for speed) SQLite database, verifying the interaction between the function and actual SQL, not just the function's logic in isolation.
import sqlite3
def save_user(conn: sqlite3.Connection, name: str) -> int:
cursor = conn.execute("INSERT INTO users (name) VALUES (?)", (name,))
conn.commit()
return cursor.lastrowid
# An integration test: exercises save_user AND a real (in-memory) database together
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
user_id = save_user(conn, "Ada")
row = conn.execute("SELECT name FROM users WHERE id = ?", (user_id,)).fetchone()
assert row[0] == "Ada"
print("integration test passed")End-to-End (E2E) Tests
An end-to-end test exercises a complete user-facing workflow through the full, real system — for a web application, this typically means driving an actual browser against a running server and a real (or realistic) database, verifying that a whole feature works from the outside exactly as a user would experience it. E2E tests give the highest confidence that the system genuinely works as a whole, but are the slowest, most brittle (small unrelated changes elsewhere can break them), and most expensive to write and maintain — so they're used sparingly, for critical user journeys, not to cover every code path.
The Testing Pyramid
A widely-used mental model — the 'testing pyramid' — recommends a large base of fast unit tests, a smaller middle layer of integration tests, and a thin top layer of end-to-end tests. The intuition: unit tests are cheap and fast, so write many; E2E tests are slow, brittle, and expensive, so write only enough to cover the truly critical paths. A suite inverted from this shape (many slow E2E tests, few unit tests) tends to be slow to run and painful to maintain, even though every individual test might be well-written.
/\
/E2E\ few, slow, high-confidence, brittle
/------\
/ Integ. \ some, moderate speed
/------------\
/ Unit Tests \ many, fast, cheap, focused
/------------------\Test Cases and Test Structure
A test case is one specific scenario being verified — one set of inputs and the expected outcome. Well-structured test cases commonly follow the Arrange-Act-Assert (AAA) pattern: ARRANGE the necessary setup/inputs, ACT by calling the code under test, then ASSERT that the result matches expectations. Keeping these three phases visually distinct (even with blank lines or comments) makes tests far easier to read and debug when they fail.
def test_discount_applies_correctly():
# Arrange
price = 100.0
discount_percent = 20
# Act
final_price = price * (1 - discount_percent / 100)
# Assert
assert final_price == 80.0Assertions
An assertion is the actual check inside a test: a statement that some condition must be true, or the test fails. Python's built-in assert statement (assert condition, "message") is the simplest form — it raises AssertionError with the given message if condition is falsy. Real testing frameworks build on this idea with richer assertion helpers (assertEqual, assertRaises in unittest; a rewritten assert with detailed introspection in pytest) that produce much more informative failure output than a bare assert.
Bare assert statements are stripped from bytecode when Python runs with the -O (optimize) flag, so production code should never rely on assert for real runtime validation — it's appropriate for tests (which are never run with -O) and for debugging invariants, not for enforcing business logic or security checks.
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("cannot divide by zero")
return a / b
# A basic assertion
assert divide(10, 2) == 5.0
# Asserting that an exception is raised is also a common assertion shape
try:
divide(1, 0)
assert False, "expected ValueError but none was raised"
except ValueError:
pass # expectedTest Isolation
Test isolation means each test runs independently: it doesn't depend on another test having run first, doesn't leave behind state that affects later tests, and produces the same result regardless of execution order. Isolation failures are a leading cause of flaky tests — tests that pass or fail unpredictably depending on what ran before them, or on execution order/parallelism, which erodes trust in the whole suite. Isolation is typically achieved by giving each test its own fresh fixtures (fresh objects, a fresh in-memory database, temp directories) rather than sharing mutable state across tests, and by using setup/teardown hooks (covered in depth in the unittest topic) to reset shared resources between tests.
# NOT isolated: tests share and mutate a module-level list
shared_list = []
def test_append_not_isolated():
shared_list.append(1)
assert shared_list == [1] # passes alone, but fails if another test ran first
# Isolated: each test builds its own fresh data
def test_append_isolated():
local_list = []
local_list.append(1)
assert local_list == [1] # always passes, regardless of test orderTest Doubles: Fakes, Stubs, and Mocks (Overview)
To keep unit tests fast and isolated, real dependencies (a database, a network API, the current time, randomness) are often replaced with a simplified stand-in called a test double. Common categories: a STUB returns canned answers for specific calls; a FAKE is a lightweight working implementation (like an in-memory dict standing in for a real database); a MOCK records how it was called so the test can assert on the interaction itself (e.g. 'was send_email called exactly once with this address'). The standard library's unittest.mock module (covered in the unittest topic) provides tools for all of these; this is only a conceptual overview here to keep the scope on testing fundamentals broadly.
What Makes a Good Test
A good test is: FAST (so it can run constantly without friction), DETERMINISTIC (the same code always produces the same pass/fail result — no dependence on real time, real randomness, or network availability unless that's specifically what's being tested), FOCUSED (tests one behavior, so a failure immediately tells you what broke), and INDEPENDENT (isolated from other tests, per above). A test that is slow, flaky, or tests five unrelated things at once actively erodes confidence in the suite over time, even if it's technically correct.
Practical Usage
In practice: write unit tests as you write the code they cover (or, in test-driven development, before it), covering the core logic and edge cases of each function/method. Add integration tests at the boundaries where components genuinely need to cooperate correctly (database access layers, API clients). Reserve end-to-end tests for a small number of the most critical user-facing workflows. Run the fast unit-test layer on every save/commit, and the slower layers on a schedule (CI pipeline) rather than constantly during local development.
Key Takeaways
Unit tests verify one piece of code in isolation and should be fast and numerous; integration tests verify components working together; end-to-end tests verify whole user workflows through the real system.
The testing pyramid recommends many unit tests, fewer integration tests, and a thin top layer of E2E tests, favoring speed and maintainability.
Arrange-Act-Assert is a simple, effective structure for individual test cases.
An assertion checks that a condition holds and fails the test (via AssertionError) if it doesn't; bare assert is stripped under Python's -O flag, so it's for tests/debugging, not production logic.
Test isolation — each test independent of execution order and of other tests' side effects — prevents flaky, unreliable suites.
Test doubles (stubs, fakes, mocks) replace real dependencies to keep unit tests fast and deterministic.
A good test is fast, deterministic, focused on one behavior, and independent of other tests.
results = [] def test_one(): results.append("a") assert results == ["a"] # fails if test_two ran first def test_two(): results.append("b") assert results == ["b"] # fails if test_one ran first def test_one(): results = [] results.append("a") assert results == ["a"] def test_two(): results = [] results.append("b") assert results == ["b"]
Sharing mutable state across tests breaks isolation; each test's outcome ends up depending on execution order, producing flaky, order-dependent failures. Give each test its own fresh local state.
def test_user_workflow(): user = create_user() assert user.name == "Ada" order = create_order(user) assert order.total == 0 invoice = create_invoice(order) assert invoice.status == "draft" # one failure here hides what actually broke def test_create_user_sets_name(): user = create_user() assert user.name == "Ada" def test_create_order_starts_at_zero_total(): order = create_order(create_user()) assert order.total == 0 def test_create_invoice_starts_as_draft(): invoice = create_invoice(create_order(create_user())) assert invoice.status == "draft"
A single test asserting many unrelated things makes failures ambiguous -- you can't tell which behavior actually broke from the failure alone. Split into focused tests, one behavior each.
def test_recent_signup(): import datetime user = create_user(signup_date=datetime.date.today()) # depends on real 'today' assert user.is_recent() # passes today, may silently break behavior later import datetime def test_recent_signup(): fixed_date = datetime.date(2026, 1, 1) user = create_user(signup_date=fixed_date) assert user.is_recent(reference_date=fixed_date)
A test that depends on the real current date/time (or other non-deterministic input) is not fully deterministic -- its pass/fail behavior can drift as time passes. Fix such inputs explicitly instead of relying on the real clock.
What is the defining difference between a unit test and an integration test?
A unit test verifies the smallest testable piece of code (usually one function/method) in isolation, typically with dependencies simplified or replaced. An integration test verifies that two or more real components work correctly together, exercising the actual seams between them.
Why does the testing pyramid recommend many unit tests but only a few end-to-end tests?
Unit tests are fast and cheap to write and run, so a suite can afford (and benefits from) many of them. End-to-end tests exercise the full real system and are comparatively slow, brittle, and expensive to write and maintain, so they're reserved for a small number of the most critical user workflows rather than broad coverage.
What does the Arrange-Act-Assert pattern mean, and why is separating these phases useful?
Arrange sets up the inputs/state a test needs, Act invokes the code under test, and Assert checks the result. Keeping the phases visually distinct makes a test's intent and its failure point immediately clear when reading or debugging it.
Why is a bare assert statement unsuitable for enforcing business logic or security checks in production code?
Bare assert statements are stripped from the bytecode entirely when Python runs with the -O (optimize) flag, so any check written only as assert can silently vanish in an optimized run -- it should be reserved for tests (never run with -O) and debugging invariants, not for logic that must always execute.
What does 'test isolation' mean, and what commonly goes wrong when it's missing?
Test isolation means each test is independent of other tests and of execution order -- it doesn't rely on state left behind by a previous test. Missing isolation commonly causes flaky tests: ones that pass or fail unpredictably depending on what ran before them or on parallel execution order.
What are the three general categories of test double mentioned, and what does each one do differently?
A stub returns canned answers for specific calls; a fake is a lightweight but genuinely working implementation (like an in-memory database); a mock records how it was called so a test can assert on the interaction itself, not just the return value.
Name the four qualities of a 'good' test discussed and briefly explain why each matters.
Fast (so it can run constantly without friction), deterministic (same code always gives the same pass/fail, independent of real time/randomness/network), focused (tests one behavior so failures are unambiguous), and independent (isolated from other tests and execution order) -- together they keep a suite trustworthy and cheap to run often.
Write a function is_palindrome(s: str) -> bool and three focused unit tests for it (a true case, a false case, and an edge case like an empty string), using plain assert following the Arrange-Act-Assert structure.
def is_palindrome(s: str) -> bool: return s == s[::-1] def test_is_palindrome_true_case(): # Arrange word = "racecar" # Act result = is_palindrome(word) # Assert assert result is True def test_is_palindrome_false_case(): word = "hello" result = is_palindrome(word) assert result is False def test_is_palindrome_empty_string(): word = "" result = is_palindrome(word) assert result is True # an empty string reads the same forwards and backwards test_is_palindrome_true_case() test_is_palindrome_false_case() test_is_palindrome_empty_string() print("all tests passed")
Each test is focused on exactly one scenario (true case, false case, edge case), follows Arrange-Act-Assert, and is independent of the others -- running them in any order or in isolation gives the same result.
Does test_b() pass when run right after test_a()? What does this reveal about the test suite?
No -- it fails. After test_a() runs, call_log is ['a']; test_b() appends 'b', making call_log == ['a', 'b'], which does not equal ['b']. This reveals a test-isolation bug: the tests share mutable state (call_log) across calls, so their outcome depends on execution order -- exactly the kind of flakiness isolation is meant to prevent.
Write a function safe_divide(a, b) that raises ValueError on division by zero, plus a unit test asserting that the correct exception is raised for that case.
def safe_divide(a: float, b: float) -> float: if b == 0: raise ValueError("cannot divide by zero") return a / b def test_safe_divide_raises_on_zero(): try: safe_divide(1, 0) except ValueError: pass # expected else: assert False, "expected ValueError to be raised" test_safe_divide_raises_on_zero() print("test passed")
The test calls the function inside a try/except, treating the caught ValueError as the expected (passing) outcome and using else: assert False to fail the test explicitly if no exception was raised at all -- this is the manual shape that unittest's assertRaises and pytest's pytest.raises automate.
call_log = []
def record_call(name):
call_log.append(name)
def test_a():
record_call("a")
assert call_log == ["a"]
def test_b():
record_call("b")
assert call_log == ["b"]
test_a()
test_b()