- 1. Why Test in Python? (vs TypeScript/jest)
- 2. pytest Deep Dive — Every Feature Exhaustive Reference
- 2.1 Test Discovery & File Conventions
- 2.2 Assertions — Python's Built-in assert vs TypeScript expect()
- 2.3 Fixtures — Dependency Injection for Tests
- 2.4 Parametrized Tests — @pytest.mark.parametrize
- 2.5 Markers & Skips — xfail, skip, custom markers
- 2.6 Hooks — pytest_configure, pytest_runtest_*, Custom Behavior
- 2.7 Plugin Architecture & Popular Plugins
- 3. unittest — The Built-in Alternative (Complete Reference)
- 4. Mocking Exhaustive Reference (unittest.mock)
- 5. Coverage Patterns & Configuration
- 6. Test Organization Strategies
- 7. Property-Based Testing with hypothesis
- 8. Performance Comparison: pytest vs unittest vs nose2
- 9. Quizzes (25+ Questions with Answers)
- 10. Exercises (20+ Problems with Solutions)
| Aspect | TypeScript/jest | Python/pytest |
|---|---|---|
| Test runner | jest (bundled with most TS projects) | pytest (third-party but universally adopted — like jest in the TS world!) |
| Assertion library | expect(actual).toBe(expected) built into jest |
assert actual == expected — Python's built-in assert statement! No external library needed. |
| Mocking | jest.fn(), jest.mock() built into framework |
unittest.mock.patch() — separate import but more powerful (mock anything) |
| Async testing | async/await or .resolves/.rejects matchers |
pytest-asyncio plugin for async def tests |
| CLI commands | npx jest |
pytest tests/ |
| Coverage tooling | --coverage built into jest |
pytest-cov plugin (separate package) |
| Snapshot testing | Built into jest (toMatchSnapshot()) |
syrupy, dirty-equals, or instruments as plugins |
| Parallel testing | jest's --maxWorkers |
pytest-xdist plugin (-n auto) |
| Test isolation | Each describe() block is isolated |
Each test function is isolated by default (no shared mutable state) |
| Type checking tests | Full type inference in test code | mypy for type-checking test files separately |
| Fuzzy matching | .toContain(), .toMatch() |
dirty-equals.IgnoreExtraKeys, == with fuzzy packages |
flowchart TD
subgraph TS_TESTING["TypeScript Testing Stack"]
direction TB
T1["npm install --save-dev jest @types/jest"] --> T2["jest.config.js / package.json config"]
T2 --> T3["expect().toBe() / .toEqual() / .rejects.toThrow()"]
T3 --> T4["jest.fn() / jest.mock() / jest.spyOn()"]
T4 --> T5["npx jest --coverage"]
style T1 fill:#3178c6,color:white
style T2 fill:#3178c6,color:white
style T3 fill:#3178c6,color:white
style T4 fill:#3178c6,color:white
style T5 fill:#3178c6,color:white
end
subgraph PY_TESTING["Python Testing Stack"]
direction TB
P1["pip install pytest pytest-cov pytest-asyncio pytest-xdist hypothesis"] --> P2["[tool.pytest] in pyproject.toml"]
P2 --> P3["assert actual == expected (built-in!)"]
P3 --> P4["from unittest.mock import patch, MagicMock"]
P4 --> P5["pytest --cov=src -n auto"]
style P1 fill:#3069bd,color:white
style P2 fill:#3069bd,color:white
style P3 fill:#3069bd,color:white
style P4 fill:#3069bd,color:white
style P5 fill:#3069bd,color:white
end
T1 -.->|"Bundled monolith\nOne package does everything"| P1
T3 -.->|"Chained matchers: expect(x).toBe(y)"| P3
T4 -.->|"jest.fn() / jest.mock()"| P4
T5 -.->|"Built-in coverage flag"| P5
-
pytest is the universal standard for Python testing — like jest in TypeScript. In TypeScript you get jest bundled by default; in Python, install pytest via
pip install pytest. -
Python's
assertis built-in — unlike TypeScript where you need an assertion library (jest.expect or chai), Python hasassertas a language feature! -
unittest.mock.patch() is more powerful than jest.mock() — it manipulates the import system directly, so you can mock anything: functions, classes, modules, attributes, even built-ins like
open(). -
Python testing requires more packages for feature parity with jest (pytest-cov, pytest-asyncio, pytest-xdist, hypothesis) but each is focused and composable.
pytest auto-discovers tests using these rules:
# === File naming (all of these are discovered automatically) ===
test_math.py # test_*.py pattern
math_test.py # *_test.py pattern
tests/test_math.py # in a 'tests/' directory
src/tests/test_api.py # anywhere on sys.path
# === Function/method naming ===
def test_add(): # Discovered because it starts with 'test_'
assert add(1, 2) == 3
class TestMath: # Class starting with 'Test' is discovered
def test_subtract(self): # Method starting with 'test_' is discovered
assert subtract(5, 3) == 2
async def test_async_fetch(self):
pytest.fail("Needs pytest-asyncio plugin")
# === Files/directories skipped by default ===
tests/__pycache__/ # Skipped (Python cache)
tests/.pytest_cache/ # Skipped (pytest's own cache)
tests/conftest.py # NOT a test file, but loaded for fixtures/hooks// === TypeScript/jest: File conventions ===
// Files matching these patterns are discovered automatically:
// - **/__tests__/**/*.ts (Jest default)
// - **/*.test.ts (common convention)
// - **/*.spec.ts (Bazel/TSLint convention)
// jest.config.js
module.exports = {
testMatch: ['**/__tests__/**/*.(ts|tsx)', '**/*.test.ts'], // Glob pattern
testPathIgnorePatterns: ['/node_modules/'], // Skip node_modules
roots: ['<rootDir>/src'], // Search in src/
};
// Usage: npx jest // Run all tests
// Usage: npx jest math.test.ts // Run specific file
// Usage: npx jest -t "adds numbers" // Run tests matching name# === Python/pytest: File conventions ===
# Files matching these patterns are discovered automatically:
import pytest
# pyproject.toml configuration (equivalent to jest.config.js)
# [tool.pytest.ini_options]
# testpaths = ["tests"] # Search in tests/ directory
# python_files = ["test_*.py", "*_test.py"] # File naming patterns
# python_classes = ["Test*"] # Class naming pattern
# python_functions = ["test_*"] # Function naming pattern
# Run all tests: pytest
# Run specific file: pytest tests/test_math.py
# Match by name: pytest -k "add" (matches test names containing 'add')
# Match by marker: pytest -m "slow" (runs only @pytest.mark.slow tests)
# Fail fast on first: pytest --maxfail=1 (stop after N failures)
# Verbosity: pytest -v or pytest -vv (verbose/very verbose output)flowchart TD
A["pytest invoked\n$ pytest"] --> B["Scan for config files\npyproject.toml / pytest.ini / setup.cfg"]
B --> C["Load testpaths from config\ndefaults to current directory"]
C --> D["Find all .py files matching\npython_files patterns (test_*.py, *_test.py)"]
D --> E["For each file:\n1. Import the module\n2. Find classes starting with 'Test'\n3. Find functions/methods starting with 'test_'"]
E --> F["Build test collection\nEach test = test function + fixtures needed"]
F --> G{"Any errors during import?\nImportError, SyntaxError, etc."}
G -->|Yes| H["Report collection error\ntest is NOT discovered"]
G -->|No| I["Execute tests in order\nFixture setup → test body → fixture teardown"]
I --> J["Print results\npassed / failed / skipped / xfailed"]
style A fill:#3069bd,color:white
style B fill:#3069bd,color:white
style D fill:#3069bd,color:white
style F fill:#3069bd,color:white
style I fill:#3069bd,color:white
style J fill:#3069bd,color:white
| Assertion | Python assert |
TypeScript/jest expect() |
|---|---|---|
| Equal values | assert a == b |
expect(a).toBe(b) |
| Equal (deep) | assert a == b (same — Python == is deep) |
expect(a).toEqual(b) |
| Is None | assert a is None |
expect(a).toBeNull() or expect(a).toBeFalsy() |
| Is not None | assert a is not None |
expect(a).toBeDefined() |
| Is truthy | assert bool(a) or assert a |
expect(a).toBeTruthy() |
| Is falsy | assert not a |
expect(a).toBeFalsy() |
| Contains in list/dict | "key" in my_dict / "val" in lst |
expect(my_dict).toHaveProperty("key") |
| List contains | assert "x" in lst |
expect(lst).toContain("x") |
| Raises exception | with pytest.raises(ValueError, match="msg"): func() |
await expect(func()).rejects.toThrow("msg") |
| String match regex | import re; assert re.search(r"\d+", text) |
expect(text).toMatch(/\d+/) |
| Approximate float | assert abs(a - b) < 1e-6 or assert pytest.approx(a, rel=1e-6) == b |
expect(a).toBeCloseTo(b, delta) |
| Instance check | assert isinstance(obj, MyClass) |
expect(obj).toBeInstanceOf(MyClass) |
| Attribute exists | assert hasattr(obj, "attr") |
expect(obj).toHaveProperty("attr") |
| Custom error message | assert a == b, f"Expected {b}, got {a}" |
expect(a).toBe(b) (pytest auto-shows diff!) |
# === Python: Every assertion pattern with examples ===
import pytest
import re
def test_basic_assertions():
# Equality
assert 1 + 1 == 2
assert {"a": 1} == {"a": 1} # Deep equality (same as Object.is for primitives)
# None checks (CRITICAL — never use == None, always use is None)
result = None
assert result is None # Like expect(result).toBeNull()
assert result is not None # Like expect(result).toBeDefined()
# Truthy/falsy
assert bool([1, 2, 3]) # List with items is truthy
assert not [] # Empty list is falsy
# Membership
assert "key" in {"key": "value"} # Dictionary key check
assert "x" in ["a", "b", "x"] # List membership
assert "hello" in "hello world" # Substring check
# String operations
assert "hello".startswith("he")
assert "hello".endswith("ld")
assert "hello".islower()
assert "HELLO".isupper()
# Numeric comparisons
assert 5 > 3
assert 10 >= 10
assert -1 < 0
assert abs(3.14159 - 3.1416) < 1e-4 # Float comparison
# Instance checks
class MyClass: pass
obj = MyClass()
assert isinstance(obj, MyClass)
# Attribute checks
assert hasattr(obj, "__class__")
def test_assertion_errors_with_custom_messages():
"""Python assertions show DIFFS automatically in pytest output!"""
actual = {"name": "Alice", "age": 30}
expected = {"name": "Bob", "age": 30}
# pytest shows a beautiful colored diff:
# E Left: {'name': 'Alice', 'age': 30}
# E Right: {'name': 'Bob', 'age': 30}
assert actual == expected, f"Expected {expected}, got {actual}"
# === TypeScript equivalent for every assertion above ===
/*
// TypeScript/jest equivalents:
expect(1 + 1).toBe(2);
expect({a: 1}).toEqual({a: 1}); // Deep equality
expect(result).toBeNull(); // null check
expect(result).toBeDefined(); // not null/undefined
expect([1, 2, 3]).toBeTruthy();
expect([]).toBeFalsy();
expect(obj).toHaveProperty("key");
expect(arr).toContain("x");
expect(str).toContain("hello");
expect(str).toMatch(/he/);
expect(value).toBeCloseTo(3.1416, 0.001);
expect(obj).toBeInstanceOf(MyClass);
expect(() => func()).toThrow("error message");
// Jest also has:
expect(value).not.toBe(...); // Negated assertions
expect.arrayContaining([...]); // Partial array match
expect.objectContaining({...}); // Partial object match
*/# === Python: Exception assertions with pytest.raises ===
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_exception_assertions():
# Simple exception check
with pytest.raises(ValueError):
divide(1, 0)
# With match pattern (regex!)
with pytest.raises(ValueError, match="Cannot divide"):
divide(1, 0)
# With additional attributes check
err = pytest.raises(TypeError, lambda: int("not-a-number"))
assert "not-a-number" in str(err.value)
# Async exception (with pytest-asyncio)
# async def test_async_exception():
# with pytest.raises(ConnectionError):
# await some_async_function()
# === TypeScript equivalent ===
/*
// TypeScript/jest:
await expect(divide(1, 0)).rejects.toThrow(ValueError);
await expect(divide(1, 0)).rejects.toThrow("Cannot divide by zero");
// Jest also supports regex matchers:
await expect(divide(1, 0)).rejects.toThrow(/Cannot divide/);
*/# === COMPLETE FIXTURE REFERENCE ===
import pytest
import os
# --- Basic fixture (simplest form) ---
@pytest.fixture
def sample_user():
"""Returns a basic user dict."""
return {"name": "Alice", "age": 30, "email": "alice@example.com"}
def test_basic_fixture(sample_user):
assert sample_user["name"] == "Alice"
# --- Fixture with yield (setup/teardown pattern) ---
@pytest.fixture
def temp_config():
"""Fixture that sets up and tears down a config file."""
config_path = "/tmp/test_config.json"
# SETUP: Create the resource
with open(config_path, "w") as f:
f.write('{"debug": true}')
yield config_path # Give the value to the test
# TEARDOWN: Clean up after the test
os.remove(config_path)
# --- Fixture that depends on another fixture ---
@pytest.fixture
def admin_user(sample_user):
"""Derives from sample_user, adding admin role."""
user = sample_user.copy() # Like {...sampleUser} in JS spread
user["role"] = "admin"
return user
def test_admin(admin_user):
assert admin_user["role"] == "admin" # Fixture chain works automatically!
# --- Autouse fixture (runs before every test automatically) ---
@pytest.fixture(autouse=True)
def cleanup_env():
"""Like Jest's beforeEach() that runs for EVERY test in this scope."""
original_env = os.environ.copy() # Save current env vars
yield
os.environ.clear()
os.environ.update(original_env) # Restore after each test
# --- Fixture with scope parameter (lifecycle control) ---
@pytest.fixture(scope="session") # Created once for entire test run (slowest setup)
def slow_database():
"""Expensive to set up — shared across ALL tests in the session!"""
db = create_expensive_db() # Setup only once per pytest invocation
yield db
db.shutdown()
@pytest.fixture(scope="module") # Created once per module (file)
def module_config():
"""Shared within a single test file."""
yield load_config()
@pytest.fixture(scope="class") # Python 3.12+ only! Created once per class
def class_database():
"""Shared within a test class."""
db = create_db()
yield db
@pytest.fixture(scope="function") # Default — created for each test function
def per_test_data():
"""Fresh data for every test (default scope)."""
yield generate_random_data()
# --- Parametrized fixture ---
@pytest.fixture(params=["chrome", "firefox", "safari"])
def browser(request):
"""Runs the test 3 times — once per browser type."""
return create_browser(request.param) # request.param = "chrome" / "firefox" / "safari"
# --- Fixture that returns a factory function ---
@pytest.fixture
def user_factory():
"""Fixture that returns a function for creating users on demand."""
def _create(name, age=18):
return {"name": name, "age": age}
return _create
def test_with_factory(user_factory):
u1 = user_factory("Alice") # Create multiple instances in one test
u2 = user_factory("Bob")
assert len([u1, u2]) == 2flowchart TD
subgraph COLLECT["Test Collection Phase"]
C1["pytest discovers test functions\nthat request fixtures as parameters"] --> C2["Resolves fixture dependency graph\nfixture_a → fixture_b (if a depends on b)"]
C2 --> C3{"What is the scope?"}
C3 -->|session| C4["Create once for entire run"]
C3 -->|module| C5["Create once per test file"]
C3 -->|class| C6["Create once per test class"]
C3 -->|function| C7["Create fresh for each test (DEFAULT)"]
end
subgraph SETUP["Setup Phase (before each test)"]
S1["Run fixture code UP TO the yield statement\nAllocate resources: DB connections, files, mocks..."] --> S2["Yield the resource value\nto the test function as a parameter"]
end
subgraph TEST["Test Execution Phase"]
T1["Test runs with all fixtures available\nas function parameters (dependency injection!)"] --> T2{"All assertions pass?"}
end
subgraph TEARDOWN["Teardown Phase (after each test)"]
T3["Continue fixture code AFTER the yield statement\nRelease resources: close files, shut down services..."] --> T4["Fixture is garbage collected"]
end
C7 -.-> S1
S2 -.-> T1
T2 -->|Yes| T3
T2 -->|No| T3
style C1 fill:#3069bd,color:white
style S1 fill:#e8a53b,color:black
style S2 fill:#e8a53b,color:black
style T1 fill:#4caf50,color:white
style T3 fill:#f44336,color:white
# === Pattern 1: Simple value fixture (like Jest's module-level variable) ===
@pytest.fixture
def counter():
return {"value": 0}
def test_counter(counter):
counter["value"] += 1
assert counter["value"] == 1
# === Pattern 2: Setup/teardown with yield (like Jest's beforeEach/afterEach) ===
@pytest.fixture
def temp_dir(tmp_path):
"""Fixture that creates a temporary directory."""
d = tmp_path / "test_subdir" # tmp_path is a built-in pytest fixture!
d.mkdir()
return d
def test_in_temp_dir(temp_dir):
file = temp_dir / "test.txt"
file.write_text("hello")
assert file.read_text() == "hello"
# === Pattern 3: Session-scoped shared resource (like Jest's once()) ===
@pytest.fixture(scope="session")
def database_pool():
"""Expensive connection pool — created ONCE for entire test suite."""
pool = create_connection_pool(size=10)
yield pool
pool.close()
class TestDatabase:
def test_query_1(self, database_pool): # Both use SAME pool!
results = database_pool.query("SELECT 1")
assert results == [(1,)]
def test_query_2(self, database_pool): # Same pool, no re-setup!
results = database_pool.query("SELECT 2")
assert results == [(2,)]
# === Pattern 4: Fixture with request object (dynamic behavior) ===
@pytest.fixture
def dynamic_config(request):
"""Fixture that reads pytest configuration options at runtime."""
config_value = request.config.getoption("--my-option")
return load_config(config_value)
# Usage: pytest --my-option=production
# === Pattern 5: Generator fixture (complex setup/teardown) ===
@pytest.fixture
def web_server():
"""Start a test server, yield it, then stop it."""
server = start_test_server(port=8765)
base_url = f"http://localhost:{8765}"
# Any exceptions in the test will propagate through the yield:
try:
yield base_url # Give the URL to the test
finally:
server.stop() # Always clean up
# === Pattern 6: Fixtures that return mocks ===
@pytest.fixture
def mock_http_client():
"""Fixture that returns a pre-configured mock."""
from unittest.mock import MagicMock, AsyncMock
client = MagicMock()
client.get = AsyncMock(return_value={"status": "ok"})
client.post = AsyncMock(side_effect=ValueError("POST not allowed"))
return clientimport pytest
# --- Basic parametrize (single parameter) ---
@pytest.mark.parametrize("input_val", [1, 2, 3, -1, 0])
def test_positive(input_val):
assert input_val > -2 # Runs 5 separate test cases
# --- Multiple parameters (tuple unpacking) ---
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-1, -2, -3),
(0, 0, 0),
(100, 200, 300),
(999999, 1, 1000000),
])
def test_add(a, b, expected):
assert add(a, b) == expected
# --- Parameter names and ids for readable output ---
@pytest.mark.parametrize(
"input_val,expected",
[
(2, 4), # id="small_numbers"
(10, 100), # id="medium_numbers"
(-3, 9), # id="negative_number"
],
ids=["small_numbers", "medium_numbers", "negative_number"]
)
def test_square(input_val, expected):
assert square(input_val) == expected
# Test names in output: test_square[small_numbers], test_square[medium_numbers]...
# --- Parametrize with fixtures (fixture as one of the params) ---
@pytest.fixture(params=["alice", "bob", "charlie"])
def username(request):
return request.param
@pytest.mark.parametrize("role", ["admin", "user", "guest"])
def test_user_roles(username, role):
"""Runs 3 × 3 = 9 test cases (all combinations of username × role)."""
user = create_user(username, role)
assert user["name"] == username
assert user["role"] == role
# --- Class-level parametrize ===
class TestCalculator:
@pytest.mark.parametrize("a,b,op,expected", [
(1, 2, "add", 3),
(5, 3, "sub", 2),
(4, 3, "mul", 12),
(10, 2, "div", 5.0),
])
def test_calculate(self, a, b, op, expected):
assert calculate(a, b, op) == expected
# --- Parametrize with fixture objects (not just primitive values) ---
@pytest.fixture(params=[
{"engine": "sqlite"},
{"engine": "postgresql"},
{"engine": "mysql"},
])
def db_config(request):
return request.param
def test_db_connection(db_config):
conn = connect(db_config["engine"])
assert conn.is_open
# --- Nested parametrize (cross-product of two sets) ---
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
@pytest.mark.parametrize("status_code", [200, 201, 400, 404, 500])
def test_http_response(method, status_code):
"""Runs 4 × 5 = 20 test cases."""
response = make_request(method)
assert response.status == status_code
# --- Using pytest.lazy_fixture for lazy evaluation (pytest-lazy-fixture plugin) ===
# Without plugin, use a fixture that returns a factory:
@pytest.fixture
def user_builder():
def build(name="Alice", age=30):
return {"name": name, "age": age}
return build| Feature | TypeScript/jest | Python/pytest |
|---|---|---|
| Single param | it.each([1,2,3])("test %d", n => ...) |
@pytest.mark.parametrize("n", [1,2,3]) |
| Multiple params | it.each([[1,2,3],[-1,-2,-3]])("a+b=%d", (a,b,e) => ...) |
@pytest.mark.parametrize("a,b,expected", [(1,2,3),(-1,-2,-3)]) |
| Custom test names | .each(testCases)("name %s", data => ...) with custom template |
ids=["small", "large"] parameter in decorator |
| Class-level params | describe.each([...])("class", () => { it(...) }) |
@pytest.mark.parametrize on class methods |
| Cross-product params | Two .each() calls or manual combinations |
Two @pytest.mark.parametrize decorators stacked |
import pytest
import sys
# --- Built-in markers ---
# Skip unconditionally
@pytest.mark.skip(reason="Not implemented yet")
def test_not_yet():
pass
# Skip conditionally (like Jest's .skipIf)
@pytest.mark.skipif(sys.platform == "win32", reason="Linux-only feature")
def test_linux_only():
assert True
# Expected to fail (xfail) — counts as XFAIL not FAIL in results
@pytest.mark.xfail(reason="Known bug: issue #123")
def test_known_bug():
assert False # Shows as xfailed, NOT failed!
# xfail with strict mode (if test passes, it becomes XPASS and ERRORs!)
@pytest.mark.xfail(strict=True, reason="Must remain broken until fixed")
def test_must_fail():
pass
# Skip on specific Python version
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+")
def test_match_statement():
match "hello":
case "hello": pass
# --- Custom markers ---
@pytest.mark.slow # Run with: pytest -m slow
def test_slow_operation():
import time; time.sleep(10)
@pytest.mark.integration # Run with: pytest -m integration
def test_db_integration():
pass
@pytest.mark.api # Run with: pytest -m "api and not slow"
def test_api_endpoint():
pass
@pytest.mark.unit # Run specific subsets
def test_unit_logic():
pass
# --- Combining markers (AND / OR logic) ---
@pytest.mark.slow
@pytest.mark.integration
def test_slow_integration(): # Marked with BOTH 'slow' AND 'integration'
pass
# CLI usage:
# pytest -m "slow and not integration" → slow non-integration tests
# pytest -m "api or unit" → api OR unit tests
# pytest -m "not slow" → all except slow
# --- Registering custom markers (prevents warnings!) ---
# In pyproject.toml:
# [tool.pytest.ini_options]
# markers = [
# "slow: marks tests as slow (deselect with '-m \"not slow\"')",
# "integration: marks tests as integration tests",
# "api: marks API-related tests",
# ]
# --- Using markers in hooks for custom behavior ---
def pytest_collection_modifyitems(session, config, items):
"""Hook: runs after test collection, before execution."""
for item in items:
if "slow" in item.keywords:
item.add_marker(pytest.mark.slow) # Programmatically add markers
# --- parametrize marker (special case) ---
@pytest.mark.parametrize(
"input,output",
[([1,2,3], 6), ([], 0), ([5, -1], 4)],
ids=["normal", "empty", "with_negative"]
)
def test_sum(input, output):
assert sum(input) == outputflowchart TD
A["pytest CLI flags\n-m 'slow' OR -m 'api and not slow' OR --strict-markers"] --> B{"Marker expression parsed?"}
B -->|Yes| C["Collect ALL tests in the project"]
C --> D["Filter: keep tests whose markers match expression?"]
D -->|Yes| E["Test is INCLUDED in run"]
D -->|No| F["Test is EXCLUDED (not even shown)"]
E --> G{"Any @pytest.mark.skip if condition?"}
G -->|True| H["Marked as SKIPPED ⊘"]
G -->|False| I{"Any @pytest.mark.xfail?"}
I -->|True| J["Expected to fail — XFAIL if fails, XPASS error if passes"]
I -->|False| K["Run normally — PASS or FAIL ✓/✗"]
style A fill:#3069bd,color:white
style E fill:#4caf50,color:white
style H fill:#ff9800,color:black
style K fill:#e0e0e0,color:black
# conftest.py — Hooks are defined here and automatically picked up by pytest!
# --- Hook: Configuration phase (before test collection) ---
def pytest_configure(config):
"""Register custom markers and set up configuration."""
config.addinivalue_line(
"markers", "slow: mark test as slow (takes >1s)"
)
config.addinivalue_line(
"markers", "integration: requires external services"
)
# --- Hook: Test collection (after collecting, before running) ---
def pytest_collection_modifyitems(session, config, items):
"""Modify the collected test items."""
# Add skip markers dynamically based on conditions
for item in items:
if "integration" in item.name:
item.add_marker(pytest.mark.integration)
# --- Hook: Setup/teardown around each test phase ---
def pytest_runtest_setup(item):
"""Runs BEFORE each test (between 'collection' and 'call' phases)."""
print(f"\n>>> Setting up: {item.name}")
def pytest_runtest_teardown(item, nextitem):
"""Runs AFTER each test (between 'call' and 'logreport' phases)."""
print(f">>> Tearing down: {item.name}")
# --- Hook: Each test phase boundaries ---
def pytest_runtest_logstart(nodeid, location):
"""Called at the start of a test phase (setup/call/teardown)."""
pass
def pytest_runtest_logreport(report):
"""Called after each test phase with the result."""
if report.failed:
print(f"FAILED: {report.nodeid} — {report.longrepr}")
# --- Hook: When a test fails ---
def pytest_exception_interact(node, exc_info):
"""Intercept test exceptions (like Jest's test hooks)."""
tb = exc_info.get_traceback()
print(f"Test {node.name} raised {exc_info.type.__name__}: {exc_info.value}")
# --- Hook: Session lifecycle ---
def pytest_sessionstart(session):
"""Called at the very beginning of the test session."""
print("Test session starting!")
def pytest_sessionfinish(session, exitstatus):
"""Called at the very end of the test session."""
print(f"Test session finished with status: {exitstatus}")
# --- Hook: When generating test IDs ---
def pytest_generate_tests(metafunc):
"""Dynamic fixture generation (like parametrize but programmatic)."""
if "browser" in metafunc.fixturenames:
metafunc.parametrize("browser", ["chrome", "firefox", "safari"])
# --- Hook: Command-line options ---
def pytest_addoption(parser):
"""Add custom CLI options."""
parser.addoption(
"--env",
default="dev",
help="Target environment: dev, staging, prod"
)
# Usage: conftest.py reads it via request.config.getoption("--env")sequenceDiagram
participant P as pytest CLI
participant H as Hooks (conftest.py)
participant T as Test Functions
P->>H: pytest_sessionstart()
P->>H: pytest_configure()
P->>H: pytest_collection() → items collected
P->>H: pytest_collection_modifyitems()
loop For each test file
H->>T: setup fixture chain
H->>H: pytest_runtest_setup()
H->>T: run test body
H->>H: pytest_runtest_logstart(call)
T-->>H: PASS / FAIL / SKIP
H->>H: pytest_runtest_logreport()
H->>H: pytest_runtest_teardown()
H->>H: teardown fixture chain
end
P->>H: pytest_sessionfinish()
| Plugin | pip Install | Purpose | TypeScript Equivalent | Key API |
|---|---|---|---|---|
| pytest-asyncio | pip install pytest-asyncio |
Run async def tests with @pytest.mark.asyncio |
jest's native async support | @pytest.mark.asyncio, async def test_*() |
| pytest-cov | pip install pytest-cov |
Code coverage reports | jest --coverage |
pytest --cov=src --cov-report=html |
| pytest-xdist | pip install pytest-xdist |
Parallel test execution across CPUs | jest --maxWorkers=N |
pytest -n auto |
| pytest-benchmark | pip install pytest-benchmark |
Performance benchmarking | Benchmark.js / vitest/bench | @pytest.mark.benchmark |
| hypothesis | pip install hypothesis |
Property-based testing | fast-check (npm) | @given, @reify, @composite |
| pytest-mock | pip install pytest-mock |
Cleaner mock integration (mocker.patch()) |
jest's built-in mocking | mocker.patch(), mocker.MagicMock |
| pytest-lazy-fixture | pip install pytest-lazy-fixture |
Lazy fixture evaluation (avoid setup overhead) | No direct equivalent | @lazy_fixture |
| pytest-html | pip install pytest-html |
HTML test reports | jest's --json + custom viewer |
pytest --html=report.html |
| pytest-instafail | pip install pytest-instafail |
Show failures immediately during run | No direct equivalent | pytest --instafail |
| pytest-sugar | pip install pytest-sugar |
Beautiful progress bar output | tapir / custom formatters | pytest --sugar |
| pytest-dotenv | pip install pytest-dotenv |
Load .env files for test config | dotenv packages | .env auto-loading in tests |
| pytest-subtests | pip install pytest-subtests |
Sub-tests within a single test (like jest.each inside it) | No direct equivalent | with subtest(...) as sub: |
import pytest
import asyncio
# Configure in pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # All async tests auto-detected (no decorator needed!)
# OR
# asyncio_mode = "strict" # Requires @pytest.mark.asyncio on every async test
# --- Mode 1: Explicit marker ---
@pytest.mark.asyncio
async def test_async_function():
result = await some_async_function()
assert result == "expected"
# --- Mode 2: Async fixture ---
@pytest.fixture
async def db_connection():
conn = await create_db_connection()
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_with_async_fixture(db_connection):
rows = await db_connection.query("SELECT 1")
# --- Mode 3: Auto mode (set asyncio_mode = "auto" in config) ---
# All async def test_*() are automatically marked as async!
# --- Async context fixture ---
@pytest.fixture
async def temp_file(tmp_path):
f = tmp_path / "temp.txt"
await f.write_text("hello")
yield f
await f.unlink()
# --- Event loop scope (shared across tests in a session/module) ---
@pytest.fixture(scope="module")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()# === CLI Usage ===
# Basic coverage:
pytest --cov=my_package # Terminal output
pytest --cov=my_package --cov-report=html # HTML report in htmlcov/
pytest --cov=my_package --cov-report=xml # XML for CI integration (Coveralls, Codecov)
pytest --cov=my_package --cov-branch # Branch coverage
pytest --cov=my_package --cov-fail-under=80 # Fail if coverage < 80%
# In pyproject.toml:
# [tool.pytest.ini_options]
# addopts = "--cov=src --cov-report=term-missing --cov-fail-under=90"# Run tests in parallel using all CPU cores:
pytest -n auto # Use all available CPUs
pytest -n 4 # Use exactly 4 workers
pytest -n logical # Use logical CPUs (hyperthreading-aware)
pytest --dist=load # Load-balance tasks across workers
pytest --dist=worksteal # Steal work from busy workers
# Per-worker output:
pytest -n auto --dist=loadgroup # Keep test classes together per workerimport pytest
@pytest.mark.benchmark(min_time=0.1, max_time=1.0)
def test_sort_performance(benchmark):
"""benchmark() is the fixture provided by pytest-benchmark."""
data = list(range(1000))
result = benchmark(sorted, data) # Runs multiple iterations, reports stats
assert len(result) == 1000
# Output:
# ---------- benchmarks --------------
# test_sort_performance 50.2ms ± 1.2ms (mean ± std. dev., 10 runs)| Criterion | unittest (built-in) | pytest (recommended) |
|---|---|---|
| No pip install | ✅ Standard library | ❌ Requires pip install pytest |
| Readability | self.assertEqual(a, b) |
assert a == b (cleaner!) |
| Fixtures/Setup | setUp(), setUpClass() |
@pytest.fixture (more flexible!) |
| Parametrization | Manual loops | @pytest.mark.parametrize (declarative!) |
| Async support | IsolatedAsyncioTestCase (3.8+) |
pytest-asyncio plugin |
| Mocking | unittest.mock.patch() |
Same, plus mocker.patch() via pytest-mock |
| Plugins | ❌ No plugin ecosystem | ✅ Huge ecosystem |
| Test discovery | Manual class structure | Auto-discovers test_*.py files |
import unittest
import sys
# === Every TestCase method available ===
class TestMathComplete(unittest.TestCase):
"""Full unittest test class — every method explained."""
# --- Class-level hooks (run ONCE before/after all tests in class) ---
@classmethod
def setUpClass(cls):
"""Like Jest's beforeAll() — runs once for entire class."""
cls.expensive_resource = create_expensive_resource()
print(f"setUpClass: {cls.__name__}") # Access via cls
@classmethod
def tearDownClass(cls):
"""Like Jest's afterAll() — runs once after all tests."""
cls.expensive_resource.cleanup()
# --- Per-test hooks (run before/after EACH test method) ---
def setUp(self):
"""Like Jest's beforeEach() — runs before every test method."""
self.shared_value = 0
print(f"setUp: {self._testMethodName}") # Access via self
def tearDown(self):
"""Like Jest's afterEach() — runs after every test method."""
del self.shared_value # Clean up per-test resources
# === Every assertion type in unittest.TestCase ===
# Equality assertions
def test_assertEqual(self):
self.assertEqual(1 + 1, 2) # Like expect(2).toBe(2)
self.assertNotEqual(1 + 1, 3) # Like expect(2).not.toBe(3)
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7) # Float comparison
# Identity assertions
def test_isNone(self):
self.assertIsNone(None) # Like expect(x).toBeNull()
self.assertIsNotNone("hello") # Like expect(x).toBeDefined()
self.assertIs(True, True) # Like expect(x).toBe(true) (identity)
self.assertIsNot("a", "b") # like expect(a).not.toBe(b)
# Boolean assertions
def test_truthy(self):
self.assertTrue(bool([1, 2, 3])) # Like expect(x).toBeTruthy()
self.assertFalse([]) # Like expect(x).toBeFalsy()
# Container assertions
def test_container(self):
self.assertIn("x", ["a", "b", "x"]) # Like expect(arr).toContain(x)
self.assertNotIn("z", ["a", "b", "x"])
self.assertIn("key", {"key": "val"})
self.assertIsInstance([], list) # Like expect(x).toBeInstanceOf(Array)
self.assertNotIsInstance(5, str)
# Exception assertions
def test_exception(self):
with self.assertRaises(ValueError):
raise ValueError("error")
with self.assertRaisesRegex(TypeError, "expected type"):
raise TypeError("expected type")
# Context manager assertions (Python 3.1+)
def test_warns(self):
import warnings
with self.assertWarns(DeprecationWarning):
warnings.warn("deprecated", DeprecationWarning)
with self.assertNoWarnings():
pass
# Skip/xfail in unittest (different from pytest!)
@unittest.skip("Not implemented")
def test_skipped(self):
pass
@unittest.skipIf(sys.platform == "win32", "Linux only")
def test_conditional_skip(self):
pass
@unittest.expectedFailure
def test_expected_failure(self):
self.assertTrue(False) # Will show as XFAIL not FAIL
# === Async unittest (Python 3.8+) ===
class TestAsyncUnittest(unittest.IsolatedAsyncioTestCase):
"""unittest's built-in async test case — no pytest-asyncio needed!"""
async def test_async_assertion(self):
result = await some_async_function()
self.assertEqual(result, "expected")
async def test_async_exception(self):
with self.assertRaises(ConnectionError):
await failing_async_function()
# === Running tests: ===
if __name__ == "__main__":
unittest.main()
# Or: python -m unittest discover -s testsflowchart TD
subgraph TS_PROJECT["TypeScript Project Structure"]
direction TB
T1["src/\n├── math.ts\n└── api.ts"] --> T2["__tests__/\n├── math.test.ts\n└── api.test.ts"]
T3["jest.config.js / jest in package.json\n→ testMatch, roots, coverage thresholds"]
style T1 fill:#3178c6,color:white
style T2 fill:#3178c6,color:white
style T3 fill:#3178c6,color:white
end
subgraph PY_PROJECT["Python Project Structure"]
direction TB
P1["src/\n├── my_package/\n│ ├── __init__.py\n│ ├── math.py\n│ └── api.py"] --> P2["tests/\n├── conftest.py ← shared fixtures & hooks!\n├── test_math.py\n└── test_api.py"]
P4["[tool.pytest]\nin pyproject.toml\n→ addopts, testpaths, markers"]
style P1 fill:#3069bd,color:white
style P2 fill:#3069bd,color:white
style P4 fill:#3069bd,color:white
end
T3 -.->|"jest.config.js configures everything"| P4
- pytest is the dominant framework for Python testing — like jest in TypeScript. More flexible, readable, and powerful than unittest.
- unittest is built into the standard library — no pip install needed, but less readable (
self.assertEqualvsassert). - pytest fixtures use explicit dependency injection (parameters) rather than implicit hooks (
setUp()). - Use pytest for all new projects; use unittest only when third-party packages are unavailable.
# === CRITICAL RULE: Patch where the name is LOOKED UP, not where it's DEFINED! ===
# If file `service.py` does: from database import connect
# → You must patch "service.connect" (where it's imported), NOT "database.connect"!
from unittest.mock import patch, MagicMock, AsyncMock, call, ANY
import asyncio
# --- Pattern 1: Patch as decorator (most common) ---
@patch("my_module.database.connect")
def test_db_connect(mock_connect):
mock_connect.return_value = {"status": "connected"}
result = service.get_connection()
assert result == {"status": "connected"}
mock_connect.assert_called_once()
# --- Pattern 2: Patch as context manager ---
def test_with_context_manager():
with patch("my_module.requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"name": "Alice"}
result = service.fetch_user(1)
assert result["name"] == "Alice"
# --- Pattern 3: Patch object attributes (not just functions) ---
@patch.object(service.UserService, "validate", return_value=True)
def test_validation(mock_validate):
user = service.UserService()
user.create({"name": "Alice"})
mock_validate.assert_called_once_with({"name": "Alice"})
# --- Pattern 4: Patch class instantiation (new instances) ---
@patch("my_module.http.Client")
def test_client_instantiation(mock_client_class):
mock_instance = MagicMock()
mock_instance.get.return_value.json.return_value = {"data": "ok"}
mock_client_class.return_value = mock_instance # Client() → mock_instance
result = service.make_request()
assert result["data"] == "ok"
# --- Pattern 5: Patch built-in functions (like open()) ---
@patch("builtins.open", new_callable=MagicMock)
def test_file_operations(mock_open):
mock_file = MagicMock()
mock_file.__enter__.return_value.read.return_value = '{"key": "value"}'
mock_open.return_value.__enter__.return_value = mock_file
result = load_json("data.json")
assert result["key"] == "value"
# --- Pattern 6: Multiple patches (stacked decorators — order is REVERSE!) ---
@patch("my_module.db.save")
@patch("my_module.api.fetch_user") # This runs FIRST (innermost)
def test_full_flow(mock_fetch, mock_save): # Parameters are in REVERSE order!
mock_fetch.return_value = {"name": "Alice"}
result = service.create_user(1)
assert mock_fetch.called
mock_save.assert_called_once_with(result)
# --- Pattern 7: Patch with return_value chains ---
@patch("my_module.api.Client")
def test_chained_calls(mock_client):
mock_resp = MagicMock()
mock_resp.json.return_value = {"users": [{"name": "Alice"}]}
mock_resp.status_code = 200
mock_instance = MagicMock()
mock_instance.get.return_value = mock_resp
mock_client.return_value = mock_instance
result = service.get_users()
assert len(result["users"]) == 1
# --- Pattern 8: Patch with side_effect for dynamic behavior ---
@patch("my_module.api.get_user")
def test_dynamic_response(mock_get):
def side_effect(user_id):
if user_id == 1:
return {"name": "Alice"}
elif user_id == 2:
raise ValueError("Not found")
else:
return None
mock_get.side_effect = side_effect
# --- Pattern 9: AsyncMock for async functions (Python 3.8+) ---
@patch("my_module.api.fetch_users", new_callable=AsyncMock)
async def test_async_mock(mock_fetch):
mock_fetch.return_value = [{"name": "Alice"}]
result = await service.get_users()
assert result == [{"name": "Alice"}]
# --- Pattern 10: New patch with lambda/inline return_value ===
with patch("my_module.config", new={"DEBUG": True, "ENV": "test"}) as mock_config:
assert mock_config.DEBUG is Trueflowchart TD
A["@patch('module.func') decorator applied"] --> B["pytest discovers test function\ntest_func(mock_name)"]
B --> C["@patch intercepts the call to service.func"]
C --> D["Replaces service.func with a MagicMock instance"]
D --> E["Test runs — calls service.func() which returns mock's return_value"]
E --> F["After test: @patch restores original function\nto module.func"]
F --> G["Assert methods available:\n- assert_called_once_with(args)\n- assert_any_call(args)\n- call_count\n- mock_calls (list of all calls)"]
style A fill:#3069bd,color:white
style C fill:#ff9800,color:black
style D fill:#4caf50,color:white
style G fill:#e0e0e0,color:black
| Mock Type | Purpose | When to Use |
|---|---|---|
MagicMock() |
Full-featured mock with auto-created attributes | General mocking of functions/methods |
AsyncMock() |
Async version of MagicMock (returns awaitable) | Mocking async def functions |
PropertyMock() |
Mock Python properties (@property) |
Mocking @property decorated methods |
Mock() |
Minimal mock (no auto-created sub-mocks) | Performance-critical tests |
NonCallableMagicMock() |
Mock objects that aren't called as functions | Mocking classes/data objects |
from unittest.mock import MagicMock, AsyncMock, PropertyMock, Mock
# === MagicMock: The Swiss Army Knife ===
mock = MagicMock()
# Auto-creates any attribute access (chainable!)
mock.database.connect.return_value = "connected" # mock.database → auto-created MagicMock!
assert mock.database.connect() == "connected" # Returns the configured value
# Configure different return values for sequential calls
mock.get_users.side_effect = [
[{"id": 1}], # First call
[{"id": 2}], # Second call
[], # Third call (empty list)
]
assert mock.get_users() == [{"id": 1}]
assert mock.get_users() == [{"id": 2}]
assert mock.get_users() == []
# === AsyncMock: For async functions ===
async def test_async_mock():
mock_fetch = AsyncMock(return_value={"data": "ok"})
result = await mock_fetch("https://api.example.com")
assert result == {"data": "ok"}
# AsyncMock also has assert_called_once, etc.
mock_fetch.assert_awaited_once_with("https://api.example.com")
# === PropertyMock: Mock @property decorators ===
class Database:
@property
def connection_string(self):
return "postgresql://localhost/db"
db = Database()
# Patch the property
with patch.object(Database, "connection_string", new_callable=PropertyMock) as mock_conn:
mock_conn.return_value = "postgresql://test:test@localhost/testdb"
assert db.connection_string == "postgresql://test:test@localhost/testdb"
# === Mock vs MagicMock difference ===
minimal_mock = Mock() # No auto-created sub-mocks — AttributeError on any attribute access
full_mock = MagicMock() # Auto-creates attributes, returns new MagicMock for each access
# Use Mock when you want strict behavior (catches typos in test code)
# Use MagicMock when you need chainable calls like `mock.db.users.get()`| Pattern | Code | Effect |
|---|---|---|
| Static value | side_effect = ValueError("error") |
Always raise this exception |
| Iterable | side_effect = [1, 2, 3] |
Return successive values until exhausted, then raise StopIteration |
| Callable | side_effect = lambda x: x * 2 |
Call the function with args each time |
| Exception list | side_effect = [ValueError(), TypeError()] |
Raise exceptions in order per call |
| Reset | side_effect = None |
Clear side_effect (returns return_value or MagicMock) |
from unittest.mock import patch, MagicMock
# --- Pattern 1: side_effect with exception ---
@patch("my_module.api.fetch")
def test_side_effect_exception(mock_fetch):
mock_fetch.side_effect = ConnectionError("Connection refused")
with pytest.raises(ConnectionError, match="Connection refused"):
service.fetch_data()
# --- Pattern 2: side_effect with successive values ---
@patch("my_module.service.next_id")
def test_side_effect_values(mock_next_id):
mock_next_id.side_effect = [100, 101, 102] # Each call returns the next value
assert service.next_id() == 100
assert service.next_id() == 101
assert service.next_id() == 102
# After exhausted: raises StopIteration!
# --- Pattern 3: side_effect with callable (dynamic behavior) ---
@patch("my_module.service.calculate_price")
def test_side_effect_callable(mock_calc):
def calc(item):
prices = {"A": 10, "B": 20, "C": 30}
return prices.get(item["name"], 0)
mock_calc.side_effect = calc
assert service.calculate_price({"name": "A"}) == 10
assert service.calculate_price({"name": "B"}) == 20
# --- Pattern 4: side_effect with list of exceptions (simulate progressive failures) ---
@patch("my_module.api.retry_request")
def test_side_effect_exception_list(mock_retry):
mock_retry.side_effect = [
TimeoutError(), # First call → timeout
TimeoutError(), # Second call → timeout
{"data": "success"}, # Third call → success!
]
with pytest.raises(TimeoutError):
service.retry_request() # Raises TimeoutError
with pytest.raises(TimeoutError):
service.retry_request() # Raises TimeoutError again
result = service.retry_request() # Finally returns {"data": "success"}
assert result["data"] == "success"
# --- Pattern 5: side_effect that checks arguments then returns ---
@patch("my_module.api.get_user")
def test_side_effect_with_validation(mock_get):
def get_user(user_id):
if user_id < 0:
raise ValueError("Invalid ID")
return {"id": user_id, "name": f"User {user_id}"}
mock_get.side_effect = get_user
with pytest.raises(ValueError):
service.get_user(-1)
assert service.get_user(5)["name"] == "User 5"| Mock Assertion | Description | TypeScript Equivalent |
|---|---|---|
mock.assert_called() |
Called at least once | expect(mock).toHaveBeenCalled() |
mock.assert_called_once() |
Called exactly once | expect(mock).toHaveBeenCalledTimes(1) |
mock.assert_called_with(*args, **kwargs) |
Last call had these args | expect(mock).toHaveBeenCalledWith(args) |
mock.assert_called_once_with(*args, **kwargs) |
Called once with these args | expect(mock).toHaveBeenCalledTimes(1) AND toHaveBeenCalledWith |
mock.assert_any_call(*args, **kwargs) |
Any call (not just last) had these args | Check mock.mock.calls for any matching |
mock.assert_not_called() |
Never called | expect(mock).not.toHaveBeenCalled() |
mock.call_args |
Last call's args/kwargs as tuple | mock.mock.calls[mock.mock.calls.length - 1] |
mock.call_count |
Number of calls | mock.mock.calls.length |
mock.call_args_list |
List of all calls as Call objects | [mock.mock.calls[0], mock.mock.calls[1], ...] |
mock.method_calls |
Calls to sub-mock methods | N/A (Jest doesn't track sub-calls) |
from unittest.mock import patch, call
@patch("my_module.service.fetch")
def test_mock_assertions(mock_fetch):
# Call the function under test multiple times
service.fetch(1)
service.fetch(2)
service.fetch(3)
# Every assertion method:
mock_fetch.assert_called() # Called at least once ✓
assert mock_fetch.call_count == 3 # Called exactly 3 times
# Last call assertions:
mock_fetch.assert_called_with(3) # LAST call was with (3)
# Any call assertion:
mock_fetch.assert_any_call(1) # SOME call had args (1)
mock_fetch.assert_any_call(2) # SOME call had args (2)
# All calls tracked:
expected_calls = [call(1), call(2), call(3)]
assert mock_fetch.call_args_list == expected_calls
# Never called assertion:
mock_fetch.reset_mock() # Reset all tracking
assert not mock_fetch.called # Now not called
# --- Comparing with exact call sequences (order matters!) ===
@patch("my_module.api.get")
def test_call_order(mock_get):
service.get_user(1)
service.get_post(2)
# Check the EXACT sequence of calls:
mock_get.assert_has_calls([
call("/api/users/1"),
call("/api/posts/2"),
], any_order=False) # If any_order=True, order doesn't matter
# --- Asserting called with ANY matching kwargs (partial match) ===
@patch("my_module.api.post")
def test_partial_kwargs(mock_post):
service.create_user(name="Alice", email="a@b.com", age=30)
# Assert that name="Alice" was in the call (ignore other kwargs!)
from unittest.mock import ANY
mock_post.assert_called_with(
"/api/users",
json={"name": "Alice", "age": ANY, "email": ANY} # ANY matches any value
)
# --- AsyncMock assertions ===
@patch("my_module.api.fetch", new_callable=AsyncMock)
async def test_async_mock_assertions(mock_fetch):
await service.fetch_user(1)
mock_fetch.assert_awaited() # Awaits happened
mock_fetch.assert_awaited_once() # Awaited exactly once
mock_fetch.assert_awaited_with(1) # Last await had args (1,)from unittest.mock import MagicMock, patch
# --- spec: Enforce real object's interface on mock ===
class RealUser:
def get_name(self): return "Alice"
def get_age(self): return 30
# With spec: only allow attributes that exist on the real class!
mock = MagicMock(spec=RealUser)
mock.get_name() # ✅ OK — exists on RealUser
mock.get_age() # ✅ OK
mock.get_email() # ❌ AttributeError! Not on RealUser — catches typos!
# --- spec_set: Like spec but raises on ANY attribute access (even write!) ===
mock_strict = MagicMock(spec_set=RealUser)
mock_strict.get_name() # ✅
mock_strict.fake_attr = "x" # ❌ AttributeError! Can't even set fake attributes!
# Usage in @patch:
@patch("my_module.UserService", spec=True)
def test_with_spec(mock_service):
instance = mock_service()
instance.get_name() # Only allowed methods that exist on real UserServicefrom unittest.mock import patch, MagicMock
# --- Mocking a context manager (async with / with) ===
class FileHandler:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def read(self):
return "data"
@patch("my_module.FileHandler", new_callable=AsyncMock)
async def test_context_manager(mock_fh_class):
mock_instance = AsyncMock()
mock_instance.read.return_value = "mocked data"
mock_fh_class.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
async with FileHandler() as handler:
data = await handler.read()
assert data == "mocked data"
# --- Mocking a chainable API (fluent interface) ===
@patch("my_module.http.Client")
def test_chainable_api(mock_client_class):
mock_resp = MagicMock()
mock_resp.json.return_value = {"users": [{"name": "Alice"}]}
mock_instance = MagicMock()
mock_instance.get.return_value = mock_resp
mock_client_class.return_value = mock_instance
# The real code does: client.get("/users").json()
result = service.get_users() # Inside: client.get("/users").json()
assert result == {"users": [{"name": "Alice"}]}
# --- Mocking with statement (non-async context manager) ===
@patch("my_module.database.connect")
def test_sync_context_manager(mock_connect):
mock_conn = MagicMock()
mock_conn.__enter__ = MagicMock(return_value=mock_conn) # self on __enter__
mock_conn.__exit__ = MagicMock(return_value=False)
mock_connect.return_value = mock_conn
with service.connect_db() as conn:
conn.query("SELECT 1")
mock_connect.assert_called_once()| Feature | TypeScript/jest | Python/pytest + unittest.mock | Notes |
|---|---|---|---|
| Create mock function | const fn = jest.fn() |
from unittest.mock import MagicMock; fn = MagicMock() |
Same concept |
| Set return value | fn.mockReturnValue(42) |
fn.return_value = 42 |
Simple attribute assignment! |
| Set async return value | fn.mockResolvedValue(data) |
fn = AsyncMock(return_value=data) |
Use AsyncMock for async functions |
| Set error to throw | fn.mockImplementation(() => { throw new Error("err") }) |
fn.side_effect = ValueError("err") |
side_effect with exception raises it |
| Mock module | jest.mock("./module") |
@patch("module.func") decorator or context manager |
Python patches at import location |
| Verify called once | expect(fn).toHaveBeenCalledTimes(1) |
fn.assert_called_once() |
Method call vs chained matcher |
| Verify called with args | expect(fn).toHaveBeenCalledWith(x, y) |
fn.assert_called_with(x, y) |
Same concept |
| Verify any call | Check fn.mock.calls array |
fn.assert_any_call(x, y) |
Direct method available in Python! |
| Spy on existing function | jest.spyOn(obj, "method").mockReturnValue(42) |
with patch.object(obj, "method", return_value=42): ... |
Patch object's method in context |
| Restore all mocks | jest.restoreAllMocks() |
Mocks auto-restored by @patch decorator/context manager! | Python's @patch cleans up automatically |
| Mock constructor | const C = jest.fn().mockReturnValue({}) |
@patch("module.Class") → mock.return_value = instance |
Same pattern, different syntax |
| Mock property | Object.defineProperty(obj, "prop", { get: () => 42 }) |
@patch.object(Class, "prop", new_callable=PropertyMock) |
PropertyMock for @property decorators |
| Clear mock state | fn.mockClear() |
fn.reset_mock() |
Same concept |
| Get call arguments | fn.mock.calls[0][0] (first arg of first call) |
fn.call_args_list[0][0] or fn.call_args[0][0] |
Index into call list |
| Mock with side effect function | fn.mockImplementation((x) => x * 2) |
fn.side_effect = lambda x: x * 2 |
Lambda works as side_effect! |
# === Installation ===
pip install pytest-cov
# === Basic Usage ===
pytest --cov=my_package # Terminal output
pytest --cov=my_package --cov-report=html # HTML report → htmlcov/index.html
pytest --cov=my_package --cov-report=xml # XML → coverage.xml (for Coveralls/Codecov)
pytest --cov=my_package --cov-report=json # JSON → coverage.json (for CI/CD)
pytest --cov=my_package --cov-branch # Branch-level coverage
pytest --cov=my_package --cov-fail-under=80 # FAIL if coverage < 80%
# === Targeted Coverage ===
pytest --cov=my_package --cov=tests # Coverage on multiple targets
pytest --cov=my_package --cov-ignore-errors # Don't fail on syntax errors in code
pytest --cov=my_package --cov-skip-covered # Skip already-covered lines# pyproject.toml
[tool.pytest.ini_options]
addopts = [
"--cov=src",
"--cov-report=term-missing", # Show missing lines in terminal
"--cov-report=html:htmlcov", # Generate HTML report
"--cov-report=xml:coverage.xml", # XML for CI integration
"--cov-branch", # Branch coverage
"--cov-fail-under=90", # Fail if below 90%
]
[tool.coverage.run]
source = ["src"] # What to measure
omit = [
"*/tests/*", # Exclude tests from coverage
"*/__init__.py", # Exclude init files
"*/migrations/*", # Exclude migrations
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover", # Lines marked with this won't be required
"def __repr__", # Don't require __repr__ coverage
"if TYPE_CHECKING:", # Type-only imports
"@abstractmethod", # Abstract methods
]
[tool.coverage.html]
directory = "htmlcov" # HTML report output directoryflowchart TD
A["pytest --cov=src runs all tests"] --> B["Coverage.py instruments executed bytecode\ntracks which lines/functions/branches are executed"]
B --> C{"Source files analyzed?"}
C -->|Yes| D["Calculate coverage percentages\nLine %\nBranch %\nFunction %"]
C -->|"No (missing from source)"| E["Mark as NOT measured (gap in report)"]
D --> F["Generate reports:\n- Terminal: term-missing\n- HTML: htmlcov/\n- XML: coverage.xml\n- JSON: coverage.json"]
E --> F
F --> G{"--cov-fail-under threshold met?"}
G -->|Yes| H["Tests PASS with coverage data"]
G -->|No| I["Tests FAIL with coverage gap details"]
style A fill:#3069bd,color:white
style D fill:#4caf50,color:white
style F fill:#ff9800,color:black
style H fill:#4caf50,color:white
style I fill:#f44336,color:white
flowchart TD
subgraph PROJECT["Project Root"]
direction TB
P1["project/\n├── conftest.py ← Session-scoped fixtures\n available to ALL tests in the project!"] --> P2["pytest_configure runs here for entire project"]
end
subgraph APP_LEVEL["app/ directory"]
A1["app/conftest.py ← Module-scoped fixtures\n available to ALL tests under app/"] --> A2["Module-level hooks for app/ tests"]
end
subgraph TESTS_LEVEL["tests/ directory"]
T1["tests/conftest.py ← Test-specific fixtures\n only visible to tests in tests/"] --> T2["Test-level hooks for test discovery"]
end
P2 -.->|"inherited by| A2
A2 -.->|"inherited by| T2
style P1 fill:#3069bd,color:white
style A1 fill:#e8a53b,color:black
style T1 fill:#4caf50,color:white
# ==========================================
# project_root/conftest.py (project-wide)
# ==========================================
# These fixtures/hooks are available to EVERY test in the entire project.
import pytest
@pytest.fixture(scope="session")
def global_config():
"""Created once for the entire test session — shared across all files."""
return {"database_url": "postgresql://test:test@localhost/testdb"}
def pytest_configure(config):
"""Register markers and custom options at project level."""
config.addinivalue_line("markers", "slow: slow tests (>1s)")
config.addinivalue_line("markers", "integration: requires external services")
# ==========================================
# project_root/tests/conftest.py (test suite-wide)
# ==========================================
import pytest
from unittest.mock import MagicMock, AsyncMock
@pytest.fixture
def api_client():
"""Shared HTTP client for all API tests."""
from httpx import AsyncClient
return AsyncClient(base_url="http://localhost:8000")
@pytest.fixture(autouse=True)
async def clear_database(db_connection):
"""Automatically clear DB before every test in this directory."""
await db_connection.execute("DELETE FROM *")
yield
# ==========================================
# project_root/tests/api/conftest.py (API tests only)
# ==========================================
@pytest.fixture
def auth_token():
"""Generate auth token for API tests."""
return create_test_token(role="admin")
@pytest.fixture
def authenticated_client(auth_token, api_client):
"""Client with authentication headers automatically set."""
api_client.headers["Authorization"] = f"Bearer {auth_token}"
return api_client
# ==========================================
# project_root/tests/integration/conftest.py (integration tests only)
# ==========================================
@pytest.fixture(scope="module")
def live_database():
"""Expensive DB setup — shared across ALL integration tests in this module."""
db = start_real_postgres_container()
yield db
db.shutdown()# === Pattern: Fixtures as a class (organized, reusable test helpers) ===
import pytest
from dataclasses import dataclass
from unittest.mock import MagicMock
@dataclass
class UserFixtures:
"""Class of related fixtures — use each fixture individually by name."""
@pytest.fixture
def user_admin(self):
return {"id": 1, "name": "Admin", "role": "admin"}
@pytest.fixture
def user_normal(self):
return {"id": 2, "name": "Normal", "role": "user"}
@pytest.fixture
def user_guest(self):
return {"id": 3, "name": "Guest", "role": "guest"}
# In conftest.py (project-wide):
from .fixtures import UserFixtures
user_fixtures = UserFixtures()
# Now in test files:
def test_admin_can_delete(user_admin):
assert user_admin["role"] == "admin"
def test_guest_cannot_delete(user_guest):
assert user_guest["role"] == "guest"
# === Pattern: Fixtures as a class with setup/teardown ===
class DatabaseFixtures:
@pytest.fixture(scope="module")
def db(self):
conn = create_test_db()
yield conn
conn.close()
@pytest.fixture
def seed_users(self, db):
"""Seeds the database with test users."""
db.execute("INSERT INTO users VALUES (1, 'Alice')")
yield
@pytest.fixture
def seed_posts(self, db):
db.execute("INSERT INTO posts VALUES (1, 'Hello')")
yield
# In conftest.py:
from .fixtures import DatabaseFixtures
db_fixtures = DatabaseFixtures()# === Layout 1: Flat (small projects) ===
project/
├── src/
│ ├── __init__.py
│ ├── math.py
│ └── api.py
├── tests/
│ ├── conftest.py # Shared fixtures for all tests
│ ├── test_math.py # Tests for math.py
│ └── test_api.py # Tests for api.py
# === Layout 2: Mirrored (medium projects — recommended) ===
project/
├── src/
│ ├── __init__.py
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── login.py
│ │ └── register.py
│ └── api/
│ ├── __init__.py
│ └── endpoints.py
├── tests/
│ ├── conftest.py # Project-wide fixtures
│ ├── auth/
│ │ ├── conftest.py # Auth-specific fixtures
│ │ └── test_login.py # Tests mirror src/auth/login.py
│ └── api/
│ ├── conftest.py # API-specific fixtures
│ └── test_endpoints.py
# === Layout 3: Functional separation (large projects) ===
project/
├── src/
├── tests/
│ ├── unit/ # Fast, isolated unit tests
│ │ ├── conftest.py # Unit-level mocks & fixtures
│ │ └── test_math.py
│ ├── integration/ # Tests requiring real services
│ │ ├── conftest.py # Live DB servers, API endpoints
│ │ └── test_api_flow.py
│ ├── e2e/ # End-to-end browser tests
│ │ └── test_login_flow.py
│ └── fixtures/ # Reusable fixture classes
│ └── user_fixtures.py
Property-based testing generates random inputs and checks that certain properties (invariants) hold for ALL inputs — rather than checking specific input/output pairs like traditional testing. In TypeScript, use fast-check (npm). In Python, use hypothesis.
| Concept | TypeScript (fast-check) | Python (hypothesis) |
|---|---|---|
| Property test | fc.property(fc.integer(), fc.string(), ...) |
@given(...) decorator |
| Generate integers | fc.integer() |
st.integers() |
| Generate strings | fc.string() |
st.text() or st.characters() |
| Generate booleans | fc.boolean() |
st.booleans() |
| Generate lists | fc.array(fc.integer()) |
st.lists(st.integers()) |
| Generate dicts | fc.record({key: fc.string(), value: fc.integer()}) |
st.dictionaries(st.text(), st.integers()) |
| Shrinking (find minimal failing input) | Automatic in fast-check | Automatic in hypothesis! |
| Example-based testing | check(...).withExamples(...) |
.example(x, y, ...) on strategy |
from hypothesis import given, settings, assume, strategies as st, Phase
from hypothesis.strategies import just, one_of, builds
import pytest
# --- Basic @given: Property-based test with auto-generated inputs ---
@given(st.integers(), st.integers())
def test_addition_is_commutative(a, b):
"""Property: a + b == b + a for ALL integers (generated automatically!)."""
assert add(a, b) == add(b, a)
# --- @given with multiple strategies ===
@given(
st.lists(st.integers(), min_size=1), # Non-empty list of integers
st.integers(min_value=0) # Non-negative index
)
def test_index_access(lst, idx):
"""Property: indexing a non-empty list at a valid index never crashes."""
assume(idx < len(lst)) # Skip test if condition doesn't hold (don't count as failure!)
_ = lst[idx] # Should not raise IndexError
# --- Common Strategies Reference ===
# Primitive strategies
st.integers(min_value=-1000, max_value=1000) # Integers in range
st.floats(min_value=0.0, max_value=100.0, allow_nan=False) # Floats without NaN
st.booleans() # True/False
st.text(min_size=1, max_size=100) # Strings
st.binary(min_size=0, max_size=1024) # Bytes
# Collection strategies
st.lists(st.integers(), min_size=0, max_size=10) # Lists of integers
st.sets(st.text()) # Sets of strings
st.dictionaries( # Dicts: str → int
keys=st.text(min_size=1),
values=st.integers(),
min_size=1, max_size=5
)
st.tuples(st.integers(), st.text()) # Fixed tuples
# Special strategies
st.just(42) # Always 42 (like a constant generator)
st.one_of(st.integers(), st.floats()) # Choose from multiple strategies
st.none() # Always None
st.sampled_from(["apple", "banana", "cherry"]) # Sample from list (random choice)
# Build custom objects
@dataclass
class User:
name: str
age: int
st.builds(User, name=st.text(), age=st.integers(0, 150))
# --- Settings: Control test behavior ===
@given(st.integers())
@settings(
max_examples=500, # Run 500 examples (default: 100)
verbosity=Verbosity.verbose, # Show every failing example
timeout=60, # Timeout after 60 seconds
phases=[Phase.generate], # Only generate (don't shrink or replay)
deadline=500 # Fail if single example takes >500ms
)
def test_with_settings(x):
assert x * 0 == 0 # Always true
# --- assume(): Skip examples that don't meet condition ===
@given(st.integers(), st.integers())
def test_division_property(a, b):
assume(b != 0) # Skip when b is 0 (don't count as test case)
result = divide(a, b)
assert result * b == a
# --- .example(): Provide specific examples that are ALWAYS tested ===
@given(st.integers())
@settings(max_examples=100)
@example(0) # Always test with 0 first
@example(1)
@example(-1)
@example(2**63 - 1) # Edge case: max int64
def test_edge_cases(x):
assert is_valid_number(x)
# --- @composite: Create custom strategies from other strategies ===
from hypothesis.strategies import composite
@composite
def user_strategy(draw):
"""Custom strategy that generates valid User objects."""
name = draw(st.text(min_size=1, max_size=50))
age = draw(st.integers(0, 150))
email = draw(st.email()) # Hypothesis has built-in email generator!
return {"name": name, "age": age, "email": email}
@given(user_strategy())
def test_user_validity(user):
assert len(user["name"]) > 0
assert 0 <= user["age"] <= 150
assert "@" in user["email"]
# --- Finding the minimal failing input (shrinking) ===
@given(st.integers(min_value=0, max_value=10**6))
def test_square_root(x):
result = square_root_approximation(x)
# hypothesis automatically SHRINKS the failing input to find the minimum!
assert abs(result * result - x) < 1e-6
# --- Using hypothesis with pytest.mark.parametrize ===
@pytest.mark.parametrize("input_val", [1, 2, 3])
@given(st.integers())
def test_combined(input_val, x):
"""Combines explicit params (pytest) with auto-generated inputs (hypothesis)."""
assert process(input_val, x) is not Noneflowchart TD
A["@given(st.integers(), st.text())\nDefines the input strategies"] --> B["hypothesis generates 100 random test cases\n(e.g., (42, 'hello'), (-7, ''), (999999, 'abc')]"]
B --> C["For each generated case:\n1. Run the test function with these inputs\n2. Check all assertions"]
C --> D{"Any assertion fails?"}
D -->|"No (all 100 pass)"| E["✓ Test PASSES — property holds for random samples!"]
D -->|"Yes (case 47 fails: (-5, 'bad'))"| F["hypothesis SHRINKS the failing input\nfinds minimal reproducer: ((-5,) → -5)"]
F --> G["Report:\nFalsifying example: test_func(-5)\nSmallest failing case found!"]
style A fill:#3069bd,color:white
style B fill:#e8a53b,color:black
style E fill:#4caf50,color:white
style G fill:#f44336,color:white
| Framework | Install | Speed (10k tests) | Features | Plugin Ecosystem | Type Support | Recommendation |
|---|---|---|---|---|---|---|
| pytest | pip install pytest |
~5s | ✅⭐⭐⭐ Best | ✅ Largest | ✅ pytest-mypy-plugins | Default choice |
| unittest | Built-in | ~8s | ⭐ Basic only | ❌ Minimal | ✅ typing support | Legacy / no-install needed |
| nose2 | pip install nose2 |
~7s | ⭐⭐ Medium | ⭐ Some | ⭐ Partial | Deprecated (not recommended) |
Verdict: Use pytest for all new projects. It's the fastest-developing, most-feature-rich option with the largest community.
-
Q: What command runs all pytest tests? A:
pytest(orpython -m pytest) -
Q: How does Python's
assertdiffer from TypeScript'sexpect().toBe()? A: Python'sassertis a built-in language statement — no chaining, no library needed. Typeassert x == y. TypeScript'sexpect(x).toBe(y)is a chained matcher API requiring the jest (or chai) library. -
Q: What does
@pytest.fixture(scope="session")do? A: The fixture is created once and shared across the entire test session, not recreated per test. -
Q: Where do you patch when using
@patch("module.func")— at the definition site or import site? A: At the import site (where the name is looked up). Ifservice.pydoesfrom database import connect, patch"service.connect", not"database.connect". -
Q: What is the difference between
MagicMockandMock? A:MagicMockauto-creates sub-mocks for any attribute access (chainable).MockraisesAttributeErroron unknown attributes — use it for strict tests that catch typos. -
Q: How do you run pytest with parallel workers? A:
pip install pytest-xdistthenpytest -n auto -
Q: What does
@pytest.mark.xfaildo vs@pytest.mark.skip? A:xfailmarks a test as expected to fail (shows as XFAIL, not FAIL).skipskips the test entirely (shows as SKIP, doesn't run it). -
Q: How do you mock an async function in pytest? A: Use
AsyncMock:mock_func = AsyncMock(return_value=data)or@patch("path.func", new_callable=AsyncMock). -
Q: What is the difference between
assert_called_withandassert_any_call? A:assert_called_with(x)checks the LAST call had argsx.assert_any_call(x)checks that ANY call (anywhere in history) had argsx. -
Q: How do you configure pytest to require 90% coverage and fail below it? A: In pyproject.toml:
--cov=src --cov-fail-under=90in[tool.pytest.ini_options].addopts -
Q: What does
pytest.mark.parametrize("a,b", [(1,2),(3,4)])do? A: Runs the test twice — once witha=1, b=2and once witha=3, b=4. Each is a separate test case. -
Q: What is the purpose of
conftest.pyat different directory levels? A: Fixtures/hooks defined in a conftest.py are scoped to tests in that directory and subdirectories. Root conftest = project-wide, module-level conftest = directory-specific. -
Q: What does
side_effect = [ValueError(), TypeError()]do on a mock? A: First call raises ValueError, second call raises TypeError, then subsequent calls return MagicMock (empty behavior). -
Q: How do you use
spec_seton a MagicMock? A:MagicMock(spec_set=RealClass)— allows no attribute access or setting that doesn't exist on RealClass. Catches typos in test code at runtime. -
Q: What is property-based testing and which library implements it in Python? A: Property-based testing generates random inputs and checks that properties (invariants) hold for all of them. Library:
hypothesis(pip install hypothesis). TypeScript equivalent:fast-check. -
Q: How do you run only tests marked as @pytest.mark.slow? A:
pytest -m slow -
Q: What's the default scope of a pytest fixture? A:
"function"— created fresh for each test function. -
Q: How does
pytest.raisesdiffer from Jest'sexpect().rejects.toThrow()? A: Same concept — both catch exceptions raised during a block. Python useswith pytest.raises(ValueError): func(). TypeScript usesawait expect(func()).rejects.toThrow(). -
Q: What is
pytest_generate_testshook used for? A: Dynamic fixture generation at collection time — programmatic parametrize logic. -
Q: How do you mock a class constructor (i.e., patch what
ClassName()returns)? A:@patch("module.ClassName")— the decorator's arg gets the MagicMock for the class. Setmock.return_value = fake_instance. -
Q: What happens if you use
python -Oto run your tests? A: Python strips allassertstatements! Assertions won't be evaluated. Always test without the-Oflag, or useif not condition: raise AssertionError(...)for critical checks. -
Q: How do you mock a property (
@property) in Python? A: UsePropertyMock:@patch.object(Class, "prop", new_callable=PropertyMock). -
Q: What is the difference between
return_valueandside_effecton a mock? A:return_valuealways returns the same static value.side_effectcan be a callable (invoked each time), an iterable (successive values), or an exception (raised). -
Q: How do you generate coverage for both source and tests in pytest? A:
pytest --cov=src --cov=tests --cov-report=term-missing -
Q: When would you use
unittestoverpytest? A: Only when you cannot install third-party packages (unittest is built into Python's standard library). For all other cases, pytest is recommended for its flexibility and feature set. -
Q: What does
@given(st.integers(), st.text())do in hypothesis? A: Generates random pairs of (integer, string) inputs across 100 examples by default, testing the function against diverse inputs to find edge cases that manual tests might miss. -
Q: How do you mock a context manager for
with db.session() as session:? A:@patch.object(db.Session, "__enter__", return_value=mock_session)and@patch.object(db.Session, "__exit__", return_value=False). Or simpler:@patch("module.db.Session")and configure the mock's__enter__/__exit__attributes.
📝 Problem — Click to expand
# math_utils.py — Code under test
def add(a, b): return a + b
def subtract(a, b): return a - b
def multiply(a, b): return a * b
def divide(a, b):
if b == 0: raise ValueError("Division by zero")
return a / bTasks:
- Write
test_math_utils.pywith basic tests for each function - Add parametrized tests covering edge cases (zero, negative, large numbers)
- Test the division-by-zero exception
- Run with coverage:
pytest --cov=math_utils
✅ Solution — Click to expand
# test_math_utils.py
import pytest
from math_utils import add, subtract, multiply, divide
class TestAdd:
def test_basic(self):
assert add(1, 2) == 3
@pytest.mark.parametrize("a,b,expected", [
(0, 0, 0),
(-1, -1, -2),
(100, 200, 300),
(999999, 1, 1000000),
])
def test_various_inputs(self, a, b, expected):
assert add(a, b) == expected
class TestDivide:
def test_normal(self):
assert divide(10, 2) == 5.0
def test_zero_division(self):
with pytest.raises(ValueError, match="Division by zero"):
divide(1, 0)📝 Problem — Click to expand
Write a fixture that creates an in-memory SQLite database with a users table, seeds it with test data, and yields the connection. Use it across multiple tests.
✅ Solution — Click to expand
# conftest.py
import pytest
import sqlite3
@pytest.fixture
def db():
"""Create in-memory SQLite database with seed data."""
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')")
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@example.com')")
conn.commit()
yield conn
conn.close()
# test_db.py
def test_get_all_users(db):
rows = db.execute("SELECT * FROM users").fetchall()
assert len(rows) == 2
def test_get_user_name(db):
row = db.execute("SELECT name FROM users WHERE id = 1").fetchone()
assert row[0] == "Alice"📝 Problem — Click to expand
Write a function that calls an external REST API and test it by patching the HTTP client.
✅ Solution — Click to expand
# user_service.py
import requests
def get_user(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
# test_user_service.py
from unittest.mock import patch, MagicMock
from user_service import get_user
@patch("user_service.requests.get")
def test_get_user(mock_get):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_get.return_value = mock_response
result = get_user(1)
assert result["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")📝 Problem — Click to expand
Write tests for a URL validation function that accepts multiple input/output pairs.
✅ Solution — Click to expand
# test_url_validation.py
import pytest
from url_validator import is_valid_url
@pytest.mark.parametrize("url,expected", [
("https://example.com", True),
("http://test.org/path", True),
("not-a-url", False),
("ftp://files.example.com", False), # Only http/https allowed
("https://sub.domain.co.uk/page?query=1", True),
("", False),
])
def test_is_valid_url(url, expected):
assert is_valid_url(url) == expected📝 Problem — Click to expand
Create a conftest.py hierarchy where API tests share an auth_token fixture and database tests share a db_connection fixture.
✅ Solution — Click to expand
# tests/conftest.py (project-wide)
import pytest
@pytest.fixture(scope="session")
def config():
return {"database_url": "sqlite:///:memory:", "api_base": "http://localhost:8000"}
# tests/api/conftest.py (API-specific)
@pytest.fixture
def auth_token():
return "test-jwt-token-12345"
@pytest.fixture
def authenticated_api_client(auth_token, config):
headers = {"Authorization": f"Bearer {auth_token}"}
return {"base_url": config["api_base"], "headers": headers}
# tests/api/test_endpoints.py
def test_get_profile(authenticated_api_client):
client = authenticated_api_client
assert "Authorization" in client["headers"]📝 Problem — Click to expand
Write a test that verifies a retry mechanism works by making a mock return different values on successive calls.
✅ Solution — Click to expand
# service.py
import requests
def fetch_with_retry(url, max_retries=3):
for i in range(max_retries):
try:
response = requests.get(url)
response.raise_for_status()
return response.json()
except requests.RequestException:
if i == max_retries - 1:
raise
return None
# test_service.py
from unittest.mock import patch
from service import fetch_with_retry
@patch("service.requests.get")
def test_fetch_with_retry(mock_get):
# First two calls fail, third succeeds
mock_get.side_effect = [
requests.RequestException(), # 1st attempt: error
requests.RequestException(), # 2nd attempt: error
MagicMock(status_code=200, json=lambda: {"status": "ok"}), # 3rd: success!
]
result = fetch_with_retry("https://api.example.com")
assert result == {"status": "ok"}
assert mock_get.call_count == 3 # Called exactly 3 times📝 Problem — Click to expand
Write an async test that fetches data from a mock endpoint using pytest-asyncio.
✅ Solution — Click to expand
# test_async.py
import pytest
import asyncio
import aiohttp
from unittest.mock import AsyncMock, patch
@patch("aiohttp.ClientSession.get", new_callable=AsyncMock)
@pytest.mark.asyncio
async def test_fetch_user(mock_get):
mock_resp = AsyncMock()
mock_resp.json.return_value = {"name": "Alice"}
mock_resp.status = 200
mock_get.return_value = mock_resp
async with aiohttp.ClientSession() as session:
result = await session.get("/api/users/1")
data = await result.json()
assert data["name"] == "Alice"
mock_get.assert_awaited_once_with("/api/users/1")📝 Problem — Click to expand
Write a property-based test that verifies sorting is commutative and idempotent for any list of integers.
✅ Solution — Click to expand
# test_sorting.py
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_is_idempotent(lst):
"""Sorting a sorted list produces the same result."""
first = sorted(lst)
second = sorted(first)
assert first == second
@given(st.lists(st.integers(), min_size=1))
def test_sorted_contains_same_elements(lst):
"""Sorted list contains exactly the same elements as original."""
s = sorted(lst)
assert set(s) == set(lst)
assert len(s) == len(lst)📝 Problem — Click to expand
Write a test using spec that catches a typo in the mock attribute name.
✅ Solution — Click to expand
# user.py
class User:
def get_name(self): return "Alice"
def get_age(self): return 30
# test_user.py
from unittest.mock import MagicMock, patch
@patch("user.User")
def test_typed_mock(mock_user_class):
instance = mock_user_class(spec=User)
# These work:
instance.get_name() # ✅ OK — exists on User
instance.get_age() # ✅ OK
# This raises AttributeError — typo detected!
with pytest.raises(AttributeError):
instance.get_emal() # ❌ Typo: "emal" vs "email"📝 Problem — Click to expand
Configure pyproject.toml for pytest with coverage that generates HTML, XML, and terminal reports.
✅ Solution — Click to expand
# pyproject.toml
[tool.pytest.ini_options]
addopts = [
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html:htmlcov",
"--cov-report=xml:coverage.xml",
"--cov-branch",
"--cov-fail-under=90",
]
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/__init__.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@abstractmethod",
]📝 Problem — Click to expand
Write a conftest.py hook that logs how long each test takes.
✅ Solution — Click to expand
# conftest.py
import pytest
import time
def pytest_runtest_setup(item):
item._start_time = time.time()
def pytest_runtest_logreport(report):
if report.when == "call" and hasattr(report, "duration"):
duration = time.time() - getattr(report.node, '_start_time', 0)
status = "PASS" if report.passed else ("SKIP" if report.skipped else "FAIL")
print(f"\n[{status}] {report.nodeid} — {duration:.3f}s")📝 Problem — Click to expand
Mock a chained API call like client.users.filter(active=True).get().
✅ Solution — Click to expand
from unittest.mock import patch, MagicMock
@patch("app.service.Client")
def test_chained_api(mock_client_class):
# Configure the chain: users → filter → get
mock_users = MagicMock()
mock_filter = MagicMock()
mock_instance = MagicMock()
mock_instance.users = mock_users # client.users → mock_users
mock_users.filter.return_value = mock_filter # .filter(...) → mock_filter
mock_filter.get.return_value = [{"id": 1}] # .get() → [result]
mock_client_class.return_value = mock_instance
result = app.service.Client().users.filter(active=True).get()
assert result == [{"id": 1}]
mock_users.filter.assert_called_once_with(active=True)
mock_filter.get.assert_called_once()📝 Problem — Click to expand
Create a session-scoped fixture that starts a test server and yields its base URL.
✅ Solution — Click to expand
# conftest.py
import pytest
@pytest.fixture(scope="session")
def test_server():
"""Start server once for entire test session."""
server = start_test_server(port=9876)
base_url = f"http://localhost:9876"
yield base_url
server.shutdown() # Runs only at end of ALL tests
# tests/test_api.py
def test_users_endpoint(test_server):
response = requests.get(f"{test_server}/users")
assert response.status_code == 200
def test_posts_endpoint(test_server):
response = requests.get(f"{test_server}/posts")
assert response.status_code == 200
# Both tests share the SAME server instance — no re-setup!📝 Problem — Click to expand
Create a class of E2E test fixtures that manage user accounts.
✅ Solution — Click to expand
# tests/fixtures/e2e_users.py
import pytest
class E2EUserFixtures:
@pytest.fixture
def admin_user(self):
return self._create_user("admin@test.com", role="admin")
@pytest.fixture
def normal_user(self):
return self._create_user("user@test.com", role="user")
@pytest.fixture
def deleted_user(self):
user = self._create_user("deleted@test.com", role="user")
self._delete_user(user["id"])
yield user
def _create_user(self, email, role):
return {"email": email, "role": role} # Simplified
def _delete_user(self, user_id):
pass # In reality: API call
# In conftest.py:
from tests.fixtures.e2e_users import E2EUserFixtures
e2e_users = E2EUserFixtures()
# In test files:
def test_admin_can_delete_user(admin_user):
assert admin_user["role"] == "admin"
def test_deleted_user_cannot_login(deleted_user):
assert deleted_user["email"] == "deleted@test.com"📝 Problem — Click to expand
Test an async function that awaits multiple async dependencies.
✅ Solution — Click to expand
# app.py
async def process_users():
users = await fetch_users() # async
posts = await fetch_posts() # async
return {"users": users, "posts": posts}
# test_app.py
from unittest.mock import AsyncMock, patch
import asyncio
@patch("app.fetch_users", new_callable=AsyncMock)
@patch("app.fetch_posts", new_callable=AsyncMock)
@pytest.mark.asyncio
async def test_process_users(mock_posts, mock_users):
mock_users.return_value = [{"id": 1}]
mock_posts.return_value = [{"id": "post-1"}]
result = await process_users()
assert result == {"users": [{"id": 1}], "posts": [{"id": "post-1"}]}
mock_users.assert_awaited_once()
mock_posts.assert_awaited_once()📝 Problem — Click to expand
Create a test suite with tests tagged with different markers and verify marker selection works correctly.
✅ Solution — Click to expand
# conftest.py (register the markers)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: slow tests")
config.addinivalue_line("markers", "db: requires database")
config.addinivalue_line("markers": "unit: fast unit tests")
# test_suite.py
import pytest
@pytest.mark.unit
def test_add_simple():
assert add(1, 2) == 3
@pytest.mark.slow
@pytest.mark.db
def test_db_migration():
run_migration()
@pytest.mark.unit
@pytest.mark.slow
def test_complex_algorithm():
complex_compute()
# CLI commands:
# pytest -m "unit and not slow" → only fast unit tests
# pytest -m "db or slow" → db OR slow tests
# pytest -m "not slow" → all except slow
# pytest --strict-markers → fail if any unregistered marker used📝 Problem — Click to expand
Verify that a function makes calls in the exact right order.
✅ Solution — Click to expand
# service.py
def deploy_pipeline():
validate_config()
build_artifact()
push_to_registry()
deploy_to_prod()
# test_service.py
from unittest.mock import patch, call
@patch("service.validate_config")
@patch("service.build_artifact")
@patch("service.push_to_registry")
@patch("service.deploy_to_prod")
def test_deploy_order(mock_deploy, mock_push, mock_build, mock_validate):
service.deploy_pipeline()
# Verify exact call order:
expected_calls = [
call(), # validate_config()
call(), # build_artifact()
call(), # push_to_registry()
call(), # deploy_to_prod()
]
assert mock_validate.call_args_list == expected_calls[:1]
assert mock_build.call_args_list == expected_calls[1:2]
assert mock_push.call_args_list == expected_calls[2:3]
assert mock_deploy.call_args_list == expected_calls[3:]📝 Problem — Click to expand
Generate valid email addresses and verify an email validation function works with all of them.
✅ Solution — Click to expand
from hypothesis import given, strategies as st
@given(st.emails()) # Hypothesis generates VALID emails!
def test_email_validator_accepts_valid_emails(email):
assert is_valid_email(email) is True
@given(st.text(min_size=1))
def test_email_validator_rejects_invalid(tldless):
assume("@" not in tldless) # Skip if it accidentally contains @
assert is_valid_email(tldless) is False
@given(st.emails(), st.integers(0, 1000))
@example("test@example.com") # Always include this specific example
@settings(max_examples=200)
def test_email_length(email, n):
extended = f"{email}.{'x' * n}"
if len(extended) < 320: # Email max length is 320 chars
assert len(extended) >= len(email)📝 Problem — Click to expand
Mock a complete class with multiple async methods, return values, and side effects.
✅ Solution — Click to expand
# payment.py
class PaymentProcessor:
async def validate(self, amount):
if amount <= 0: raise ValueError("Invalid amount")
return True
async def charge(self, user_id, amount):
# ... real logic ...
pass
async def refund(self, transaction_id):
# ... real logic ...
pass
# test_payment.py
from unittest.mock import AsyncMock, patch
import pytest
class TestPaymentProcessor:
@pytest.fixture
def mock_processor(self):
processor = AsyncMock(spec=PaymentProcessor)
processor.validate = AsyncMock(side_effect=lambda a: True if a > 0 else ValueError("Invalid"))
processor.charge = AsyncMock(return_value={"tx_id": "txn-123"})
processor.refund = AsyncMock(return_value={"status": "refunded"})
return processor
@pytest.mark.asyncio
async def test_valid_charge(self, mock_processor):
mock_processor.validate.return_value = True
result = await mock_processor.charge("user-1", 100)
assert result["tx_id"] == "txn-123"
mock_processor.validate.assert_called_with(100)
@pytest.mark.asyncio
async def test_invalid_amount(self, mock_processor):
mock_processor.validate.side_effect = ValueError("Invalid amount")
with pytest.raises(ValueError):
await mock_processor.charge("user-1", -50)📝 Problem — Click to expand
Design a test directory structure for a project with auth, payments, and API layers. Include conftest.py at each level.
✅ Solution — Click to expand
project/
├── src/
│ ├── auth/
│ ├── payments/
│ └── api/
├── tests/
│ ├── conftest.py # Session-scoped: config, event_loop
│ │
│ ├── unit/ # Fast, no external deps
│ │ ├── conftest.py # Unit-level mocks
│ │ ├── test_auth_utils.py
│ │ └── test_payment_calcs.py
│ │
│ ├── integration/ # Real DB/API
│ │ ├── conftest.py # Live containers, fixtures
│ │ ├── test_auth_flow.py
│ │ └── test_payment_flow.py
│ │
│ ├── api/ # API endpoint tests
│ │ ├── conftest.py # Auth tokens, client setup
│ │ └── test_endpoints.py
│ │
│ └── e2e/ # Browser-level
│ ├── conftest.py # Browser driver
│ └── test_full_checkout.py
Each conftest.py provides fixtures scoped to its directory:
# tests/conftest.py (session-scoped, project-wide)
import pytest
@pytest.fixture(scope="session")
def config(): return {"db": "sqlite:///:memory:"}
# tests/unit/conftest.py
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def mock_logger(): return MagicMock()
# tests/integration/conftest.py
import pytest
@pytest.fixture(scope="module")
def postgres_container():
container = start_postgres()
yield container
container.stop()📝 Problem — Click to expand
Mock a method on an existing instance (not the class).
✅ Solution — Click to expand
from unittest.mock import patch, MagicMock
class Service:
def __init__(self):
self.helper = Helper()
def process(self):
return self.helper.transform("input")
class Helper:
def transform(self, data):
return f"transformed:{data}"
# Test with patch.object on the INSTANCE
def test_process_with_mock():
service = Service()
with patch.object(service.helper, "transform", return_value="mocked_output") as mock:
result = service.process()
assert result == "mocked_output"
mock.assert_called_once_with("input")📝 Problem — Click to expand
Test that a function prints the correct output to stdout.
✅ Solution — Click to expand
# utils.py
def greet(name):
print(f"Hello, {name}!")
return f"Greeting sent to {name}"
# test_utils.py
def test_greet_output(capsys):
"""capsys fixture captures stdout/stderr."""
result = greet("Alice")
captured = capsys.readouterr()
assert captured.out == "Hello, Alice!\n"
assert result == "Greeting sent to Alice"
def test_greet_stderr(capsys):
import sys
def error_fn():
print("Error occurred", file=sys.stderr)
error_fn()
captured = capsys.readouterr()
assert captured.err == "Error occurred\n"📝 Problem — Click to expand
Test code that reads os.environ["API_KEY"] without permanently modifying the environment.
✅ Solution — Click to expand
# app.py
import os
def get_api_key():
return os.environ.get("API_KEY", "default-key")
# test_app.py
def test_get_api_key(monkeypatch):
"""monkeypatch fixture safely modifies attributes."""
monkeypatch.setenv("API_KEY", "test-key-123")
assert get_api_key() == "test-key-123"
# After the test, API_KEY is automatically restored!
def test_missing_api_key(monkeypatch):
"""Test behavior when env var is absent."""
monkeypatch.delenv("API_KEY", raising=False)
assert get_api_key() == "default-key"📝 Problem — Click to expand
Create a plugin that adds a --red-green flag: green output for passing tests, red for failing.
✅ Solution — Click to expand
# pytest_redgreen.py (custom plugin)
def pytest_addoption(parser):
parser.addoption(
"--red-green",
action="store_true",
default=False,
help="Color output: green for pass, red for fail"
)
def pytest_configure(config):
if config.getoption("--red-green"):
config.pluginmanager.register(GreenRedReporter())
class GreenRedReporter:
def pytest_terminal_summary(self, terminalreporter):
for key in sorted(terminalreporter.stats):
stat = terminalreporter.stats[key]
color = "green" if key == "passed" else "red" if key == "failed" else None
if color:
msg = f"{key}: {len(stat)}"
# Apply ANSI color codes for terminal output📝 Problem — Click to expand
Write a test for an async context manager that opens/closes a database connection.
✅ Solution — Click to expand
# db_manager.py
from contextlib import asynccontextmanager
@asynccontextmanager
async def db_connection(url):
conn = await create_connection(url) # async open
try:
yield conn
finally:
await conn.close() # guaranteed cleanup
# test_db_manager.py
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_db_connection_closes_on_success():
mock_conn = AsyncMock()
with patch("db_manager.create_connection", new_callable=AsyncMock) as mock_create:
mock_create.return_value = mock_conn
async with db_connection("postgresql://test") as conn:
assert conn is mock_conn
# Connection was closed after the 'with' block
mock_conn.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_db_connection_closes_on_exception():
mock_conn = AsyncMock()
with patch("db_manager.create_connection", new_callable=AsyncMock) as mock_create:
mock_create.return_value = mock_conn
with pytest.raises(ValueError):
async with db_connection("postgresql://test") as conn:
raise ValueError("Test error!")
# Even on exception, connection was closed!
mock_conn.close.assert_awaited_once()📝 Problem — Click to expand
Use pytest-subtests to run multiple sub-checks within a single test function.
✅ Solution — Click to expand
# Requires: pip install pytest-subtests
def test_multiple_user_validations(subtests):
users = [
{"name": "Alice", "age": 30, "valid": True},
{"name": "", "age": -1, "valid": False}, # Invalid name and age
{"name": "Bob", "age": 200, "valid": False}, # Age too high
]
for user in users:
with subtests.test(name=user["name"], age=user["age"]):
errors = validate_user(user)
if user["valid"]:
assert len(errors) == 0
else:
assert len(errors) > 0| Goal | Command |
|---|---|
| Run all tests | pytest |
| Run specific file | pytest tests/test_math.py |
| Match test name pattern | pytest -k "add" |
| Marked tests only | pytest -m slow |
| Stop on first failure | pytest --maxfail=1 |
| With coverage | pytest --cov=src |
| Parallel execution | pytest -n auto |
| Verbose output | pytest -v or -vv |
| HTML report | pytest --html=report.html |
| Only failed tests (rerun) | pytest --lf |
| Run last failed first | pytest --ff |
| Goal | Code |
|---|---|
| Create mock | m = MagicMock() |
| Set return value | m.return_value = 42 |
| Set side effect (callable) | m.side_effect = lambda x: x*2 |
| Set side effect (exception) | m.side_effect = ValueError("err") |
| Assert called once | m.assert_called_once() |
| Assert called with args | m.assert_called_with(1, 2) |
| Assert any call | m.assert_any_call(3) |
| Assert not called | m.assert_not_called() |
| Get call count | m.call_count |
| Get all calls | m.call_args_list |
| Reset mock | m.reset_mock() |
| Async mock | m = AsyncMock(return_value=data) |
| Spec enforcement | MagicMock(spec=RealClass) |
| Scope | Created | Ideal For |
|---|---|---|
function (default) |
Before each test | Per-test data, mocks |
class |
Once per test class | Database connections per class |
module |
Once per file | Expensive setup, config objects |
session |
Once for entire run | Shared server, DB pools |
Next: Module 12 — Async/Await Patterns (asyncio, coroutines, event loop)