KAHIBARO
Discord Login Register

19.2. Unit Testing

Why Unit Testing Matters

Unit testing is about testing the smallest pieces of your code, usually individual functions or methods, in isolation. The goal is to prove that each “unit” behaves exactly as you expect.

You already saw why testing matters in the parent chapter. Here we focus specifically on unit tests, how they look, and how to think about them as a backend developer.

Unit tests will not test your full API, database, or external services. Those belong to other types of tests such as integration or end‑to‑end tests.

Key idea: A unit test checks a small piece of code in isolation, fast and deterministically.
It should not depend on a real database, network, filesystem, or other external systems.


What Is a “Unit”?

A "unit" is usually:

The exact definition depends on your project, but the spirit is the same: small, focused, and easy to reason about.

Examples of units

In backend code, good units to test include:

Less ideal as a unit:

When a function does too many things, unit testing becomes harder. That is usually a sign you should refactor the code.


Characteristics of Good Unit Tests

Good unit tests generally have these properties:

PropertyDescription
FastRun in milliseconds, so you can run them all the time
IsolatedDo not depend on database, network, or other tests
DeterministicAlways give the same result for the same code and input
FocusedTest one behavior or scenario at a time
ReadableEasy to understand what is being tested and why

Rule: If your “unit” test is slow or flaky, it probably depends on something external. Consider mocking or refactoring.


Simple Unit Test Examples

You will typically write unit tests in a separate test file, for example:

Below are examples in Python, since this course uses Python for backend development.

Example: Testing a pure function

math_utils.py:

python
def add(a: int, b: int) -> int:
    return a + b

test_math_utils.py:

python
def test_add_two_positive_numbers():
    result = add(2, 3)
    assert result == 5
def test_add_with_zero():
    result = add(7, 0)
    assert result == 7

Each test:

There is no database, network, or filesystem involved. These are classic unit tests.

Example: Testing business logic

Imagine some backend business logic that calculates a discount:

discounts.py:

python
def calculate_discount(price: float, user_is_vip: bool) -> float:
    if price < 0:
        raise ValueError("Price cannot be negative")
    discount_rate = 0.2 if user_is_vip else 0.05
    return price * (1 - discount_rate)

test_discounts.py:

python
import math
def test_calculate_discount_for_vip_user():
    result = calculate_discount(100.0, user_is_vip=True)
    assert math.isclose(result, 80.0)
def test_calculate_discount_for_regular_user():
    result = calculate_discount(100.0, user_is_vip=False)
    assert math.isclose(result, 95.0)
def test_calculate_discount_raises_for_negative_price():
    try:
        calculate_discount(-10.0, user_is_vip=False)
        assert False, "Expected ValueError"
    except ValueError:
        pass

You test both normal cases (VIP and regular user) and error handling (negative price).


The AAA Pattern: Arrange, Act, Assert

Unit tests often follow the AAA pattern:

  1. Arrange: Set up data and objects
  2. Act: Call the function or method you want to test
  3. Assert: Check the outcome

Example in AAA style

python
def test_calculate_discount_for_vip_user():
    # Arrange
    price = 200.0
    user_is_vip = True
    # Act
    result = calculate_discount(price, user_is_vip)
    # Assert
    assert result == 160.0

Writing tests with AAA keeps them clear and structured.


Testing Edge Cases

Unit tests are especially good at covering edge cases. These are inputs that are rare or tricky.

Examples for backend logic:

Example: Input validation helper

validators.py:

python
def is_valid_username(username: str) -> bool:
    if not username:
        return False
    if len(username) < 3 or len(username) > 20:
        return False
    if not username.isalnum():
        return False
    return True

test_validators.py:

python
def test_valid_username():
    assert is_valid_username("alice123") is True
def test_empty_username_is_invalid():
    assert is_valid_username("") is False
def test_too_short_username_is_invalid():
    assert is_valid_username("ab") is False
def test_too_long_username_is_invalid():
    assert is_valid_username("a" * 21) is False
def test_username_with_special_characters_is_invalid():
    assert is_valid_username("alice!") is False

These tests describe the rules of what is valid or invalid, and serve as executable documentation.


Isolating Units with Simple Stubs or Mocks

Even in unit tests, sometimes a function depends on something external. For example:

Even these can make tests flaky or hard to reproduce.

Example: Function that uses “now”

time_utils.py:

python
from datetime import datetime, timedelta, timezone
def is_token_expired(issued_at: datetime, ttl_seconds: int) -> bool:
    now = datetime.now(timezone.utc)
    return now > issued_at + timedelta(seconds=ttl_seconds)

This is tricky to unit test because datetime.now changes all the time.

A simple pattern to improve testability is dependency injection: pass in what you need.

python
from datetime import datetime, timedelta, timezone
from typing import Callable
def is_token_expired(
    issued_at: datetime,
    ttl_seconds: int,
    now_func: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> bool:
    now = now_func()
    return now > issued_at + timedelta(seconds=ttl_seconds)

Now you can test with a "fake" clock:

test_time_utils.py:

python
from datetime import datetime, timedelta, timezone
def test_token_not_expired_yet():
    issued_at = datetime(2023, 1, 1, tzinfo=timezone.utc)
    ttl = 60
    def fake_now():
        return issued_at + timedelta(seconds=30)
    assert is_token_expired(issued_at, ttl, now_func=fake_now) is False
def test_token_expired():
    issued_at = datetime(2023, 1, 1, tzinfo=timezone.utc)
    ttl = 60
    def fake_now():
        return issued_at + timedelta(seconds=120)
    assert is_token_expired(issued_at, ttl, now_func=fake_now) is True

You did not touch a real clock. The test is deterministic and fast.


Pure Functions vs Functions with Side Effects

Unit testing is easiest when you have pure functions:

Functions with side effects are harder to test:

For unit tests, there are two common strategies:

  1. Refactor to separate pure logic from side effects
  2. Mock the side effect in tests, so you do not actually perform it

Example: Refactor to test pure logic

Bad design in one function:

python
def register_user(email: str, password: str):
    # 1. Validate email and password
    # 2. Hash password
    # 3. Insert into database
    # 4. Send welcome email
    ...

Better design, splitting logic:

python
def validate_registration(email: str, password: str) -> None:
    # only validate, no side effects
    ...
def hash_password(password: str) -> str:
    ...
def register_user_in_db(email: str, password_hash: str):
    ...
def send_welcome_email(email: str):
    ...
def register_user(email: str, password: str):
    validate_registration(email, password)
    password_hash = hash_password(password)
    user = register_user_in_db(email, password_hash)
    send_welcome_email(email)
    return user

Then you can unit test validate_registration and hash_password easily, while register_user may need mocking or be covered by higher-level tests.


When Is a Test a Unit Test vs Integration Test?

Sometimes it is not obvious.

ScenarioType of test (typically)
Test a pure function that adds numbersUnit test
Test a function that parses a JSON stringUnit test
Test a function that reads a real fileIntegration / system detail
Test a repository function that hits a real DBIntegration test
Test an API endpoint using a real HTTP serverIntegration or end‑to‑end test

If your test touches:

it is usually not a unit test anymore.

The parent chapter and later chapters will cover integration and API testing in more detail, so here we focus mainly on the pure, isolated case.


Naming and Organizing Unit Tests

Clear structure makes your test suite maintainable.

File and folder structure

A typical Python project might have:

text
app/
    services/
        discounts.py
        validators.py
tests/
    unit/
        test_discounts.py
        test_validators.py
    integration/
        test_api_endpoints.py

You separate unit and integration tests into different folders.

Naming tests

Good names describe the behavior, not just the function:

Bad nameBetter name
test_discount_viptest_calculate_discount_for_vip_user
test_invalidtest_is_valid_username_rejects_empty_string
test_edgetest_calculate_discount_raises_for_negative_price

A good test name reads like a sentence and tells you what went wrong if it fails.


What to Unit Test First in a Backend Project

As a backend beginner, focus your unit tests on:

These parts often have tricky rules and many edge cases. Unit tests are perfect here.

You can cover:

So do not worry if every function is not unit tested. Target the parts that benefit most.


Common Mistakes in Unit Testing

Avoid these typical problems:

  1. Testing implementation details instead of behavior
    • Example: Asserting that a function calls another internal function, instead of checking the final result.
    • Behavior is more stable than implementation.
  2. Writing tests that are too big
    • Testing half of your application in a single test.
    • Split into smaller behavioral checks.
  3. Relying on external systems
    • Real databases, external APIs, actual file IO, real current time.
    • Use pure logic, dependency injection, or mocking for unit tests.
  4. Not running tests often
    • Unit tests are meant to be run after small code changes.
    • If they are slow, they are likely not true unit tests.

Summary

Unit testing is about:

As a backend developer, think of unit tests as the foundation of your test suite. They give you confidence that your core logic behaves correctly, which then supports higher-level integration and API tests later in the course.

Views: 6

Comments

Please login to add a comment.

Don't have an account? Register now!