Home / Courses / Testing for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 3 of 9

Hands-On: Your First Unit Test with Pytest

Pytest's install and basic conventions, writing your first unit test for pure functions in app/security.py, testing negative cases, and marking tests so fast and slow ones can be run separately.

July 10, 2026

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 files must be named test_*.py or *_test.py.
  • Test functions inside them must start with test_.
  • Pytest automatically finds files and functions matching this pattern, throughout the 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

  • Unit tests are the best fit for pure functions — no database, network, or external state needed.
  • Pytest automatically detects test_*.py files and test_* functions, with no manual registration, and uses plain assert.
  • Test negative cases, not just the happy path — and if you find a test with inconsistent results (flaky), suspect the assumptions behind the test.
  • Markers (@pytest.mark.unit) make it easy to separate fast tests from slow ones, registered in pytest.ini.
  • Topics

    TestingPytest