Unit Tests — Concept & Examples
What They Test
One function, one method, one class. No database, no API calls, no browser. Code calls code, result is checked. That's it.
Function → [input] → Unit test → assert result
Why They Are the Foundation
| Property | Value |
|---|---|
| Speed | 0.001–0.01 s per test |
| Count | Thousands on a real project |
| Feedback | Instant — developer runs before committing |
| Tool | pytest |
Fast feedback is the entire point. A developer changes the password validation logic, runs the tests, sees a failure in 2 seconds — not after deploy, not after manual QA.
What to Test at Unit Level
- Business logic (discounts, calculations, rules)
- Validation (input checks, error messages)
- Data transformations (formatters, parsers, serializers)
- Edge cases (empty input, None, zero, max value)
- Error paths (expected exceptions with correct messages)
Example: Password Validator
import pytest
def validate_password(password: str) -> list[str]:
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not any(char.isupper() for char in password):
errors.append("one uppercase letter")
if not any(char.isdigit() for char in password):
errors.append("one digit")
if not any(char in "!@#$%^&*" for char in password):
errors.append("one special character")
return errors
@pytest.mark.unit
class TestValidatePassword:
def test_valid_password(self) -> None:
assert validate_password("Str0ng!Pass") == []
def test_too_short(self) -> None:
assert "at least 8 characters" in validate_password("Ab1!")
def test_no_uppercase(self) -> None:
assert "one uppercase letter" in validate_password("weak1!pass")
def test_no_digit(self) -> None:
assert "one digit" in validate_password("NoDigits!Here")
def test_no_special_char(self) -> None:
assert "one special character" in validate_password("NoSpecial1")
def test_empty_string_returns_all_errors(self) -> None:
assert len(validate_password("")) == 4
Six tests, under a second. Covers valid case, each rule separately, and worst-case input.
Toolchain Requirements
Without these enforced in CI, unit test suites quietly rot:
| Tool | Purpose |
|---|---|
coverage |
Track which lines are covered; reject PRs below threshold |
ruff |
Lint — catch style issues and common bugs |
mypy |
Type checking — catch type mismatches before runtime |
# Run with coverage
uv run pytest --cov=src --cov-report=term-missing tests/unit/
# Enforce threshold
uv run pytest --cov=src --cov-fail-under=95 tests/unit/
Pytest Marks for Layer Separation
# conftest.py
import pytest
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "unit: unit tests — no I/O")
config.addinivalue_line("markers", "integration: integration tests — real services")
config.addinivalue_line("markers", "e2e: end-to-end browser tests")
Run only unit tests in the fast feedback loop:
uv run pytest -m unit # only unit
uv run pytest -m "not e2e" # everything except E2E
Isolation Contract
A unit test must not: - connect to a database - make HTTP requests - read from the filesystem (unless testing I/O code specifically) - depend on environment variables being set
If it does any of these, it is an integration test — slow, fragile, and failing on CI
because the dev's local Postgres isn't running in the container.
Use mocks (unittest.mock, pytest-mock) to isolate external dependencies at this level.