Now that we've covered the core concepts and the anatomy of a good test, it's time to get hands-on. We'll start at the base of the testing pyramid: unit tests — testing pure functions in task-tracker-api, starting with app/security.py (password hashing and JWT from the Security series).
Why Start Here?
app/security.py contains functions that are IDEAL for a first unit test: no database needed, no HTTP request needed, they just take input and return output.
def hash_password(plain_password: str) -> str: ...
def verify_password(plain_password: str, hashed_password: str) -> bool: ...
def create_access_token(subject: str) -> str: ...
def decode_access_token(token: str) -> Optional[str]: ...
Functions like this are called pure functions (or close to it) — their result depends only on the input given, and they don't touch state outside themselves (except the secret_key from config, which stays consistent). That's what makes them easy to test: no need to "set up a whole world" before calling them.
Installing Pytest and Its Basic Structure
pip install pytest
Pytest has naming conventions it recognizes automatically (called test discovery), with no manual registration needed:
test_*.py or *_test.py.test_.tests/ folder (or whatever folder your config points to).The First Test
# tests/unit/test_security.py
from app.security import hash_password, verify_password
def test_hash_password_is_not_plaintext():
assert hash_password("supersecret123") != "supersecret123"
def test_verify_password_accepts_correct_password():
hashed = hash_password("supersecret123")
assert verify_password("supersecret123", hashed) is True
def test_verify_password_rejects_wrong_password():
hashed = hash_password("supersecret123")
assert verify_password("wrongpassword", hashed) is False
Notice: pytest uses Python's PLAIN assert — not a special method like self.assertEqual(...) in other testing frameworks. This is one reason pytest is so popular: the syntax is as simple as it can be. Behind the scenes, pytest performs assertion rewriting — when an assert fails, pytest automatically shows the details of the left and right values being compared, without us having to write a manual error message.
Run it:
pytest tests/unit/test_security.py -v
tests/unit/test_security.py::test_hash_password_is_not_plaintext PASSED
tests/unit/test_security.py::test_verify_password_accepts_correct_password PASSED
tests/unit/test_security.py::test_verify_password_rejects_wrong_password PASSED
Testing Negative Cases — Just as Important as the Happy Path
A test that only checks "when everything is correct" is half the job. Just as important: testing what happens when input is WRONG or data is TAMPERED with:
def test_decode_access_token_rejects_garbage_token():
assert decode_access_token("not-a-valid-token") is None
def test_decode_access_token_rejects_tampered_token():
token = create_access_token(subject="alice")
header, payload, signature = token.split(".")
# Reversing the signature guarantees the result actually changes —
# see the note below for why that matters.
tampered = f"{header}.{payload}.{signature[::-1]}"
assert decode_access_token(tampered) is None
A true story from behind the scenes of this series: the first version of the second test above only swapped a SINGLE character at the end of the token, instead of reversing the entire signature. That test turned out to be flaky — sometimes passing, sometimes failing, with no code change at all. The cause: base64 (the format JWT uses) has bit redundancy at certain positions, so swapping one character at an "unlucky" position sometimes doesn't actually change the decoded result. This is a real-world lesson about the Repeatable principle from the previous article — the moment you find a test with inconsistent results, suspect the ASSUMPTIONS behind the test, not just the code under test.
Marking Tests
So we can later separate fast tests (unit) from slow ones (integration), we mark them:
import pytest
@pytest.mark.unit
def test_hash_password_is_not_plaintext():
...
Markers are registered in pytest.ini so pytest doesn't warn about an "unknown marker":
[pytest]
markers =
unit: pure unit tests, with no I/O at all (database, network, filesystem)
integration: integration tests, via an HTTP client and a test database
Now we can run just the unit tests:
pytest -m unit -v
tests/unit/test_security.py::test_hash_password_returns_different_hash_each_time PASSED
tests/unit/test_security.py::test_hash_password_is_not_plaintext PASSED
tests/unit/test_security.py::test_verify_password_accepts_correct_password PASSED
tests/unit/test_security.py::test_verify_password_rejects_wrong_password PASSED
tests/unit/test_security.py::test_create_and_decode_access_token_roundtrip PASSED
tests/unit/test_security.py::test_decode_access_token_rejects_garbage_token PASSED
tests/unit/test_security.py::test_decode_access_token_rejects_tampered_token PASSED
======================= 7 passed in 3.37s =======================
These seven tests will eventually sit alongside dozens of integration tests (covered starting in the next article) — and once both exist, you'll feel the difference immediately: pytest -m unit finishes in seconds, perfect to run repeatedly EVERY time you save a code change, while tests that touch a database take noticeably longer.
Summary
test_*.py files and test_* functions, with no manual registration, and uses plain assert.@pytest.mark.unit) make it easy to separate fast tests from slow ones, registered in pytest.ini.