19.2. Unit Testing
Table of Contents
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:
- A single function
- A single method of a class
- Occasionally a very small class
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:
- A function that validates user input
- A function that calculates taxes or discounts
- A method that hashes a password string
- A function that parses configuration values from environment variables
Less ideal as a unit:
- “Create user in database and send email and log a message” all in one function
- “Handle the entire HTTP request” function that touches many layers
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:
| Property | Description |
|---|---|
| Fast | Run in milliseconds, so you can run them all the time |
| Isolated | Do not depend on database, network, or other tests |
| Deterministic | Always give the same result for the same code and input |
| Focused | Test one behavior or scenario at a time |
| Readable | Easy 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:
- code file:
math_utils.py - test file:
test_math_utils.py
Below are examples in Python, since this course uses Python for backend development.
Example: Testing a pure function
math_utils.py:
def add(a: int, b: int) -> int:
return a + b
test_math_utils.py:
def test_add_two_positive_numbers():
result = add(2, 3)
assert result == 5
def test_add_with_zero():
result = add(7, 0)
assert result == 7Each test:
- Calls the function with specific inputs
- Asserts that the output is what you expect
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:
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:
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:
passYou 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:
- Arrange: Set up data and objects
- Act: Call the function or method you want to test
- Assert: Check the outcome
Example in AAA style
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.0Writing 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:
- Empty strings
- Very large numbers
- Negative numbers
- Boundary values (exactly 0, exactly 1, exactly the maximum allowed)
- None values (when allowed)
- Unusual but valid characters (like unicode)
Example: Input validation helper
validators.py:
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:
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 FalseThese 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:
- A clock or current time
- Random numbers
- Configuration from the environment
Even these can make tests flaky or hard to reproduce.
Example: Function that uses “now”
time_utils.py:
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.
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:
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 TrueYou 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:
- Same input, same output
- No side effects
Functions with side effects are harder to test:
- They write to a database
- They send an email
- They make an HTTP call
For unit tests, there are two common strategies:
- Refactor to separate pure logic from side effects
- Mock the side effect in tests, so you do not actually perform it
Example: Refactor to test pure logic
Bad design in one function:
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:
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.
| Scenario | Type of test (typically) |
|---|---|
| Test a pure function that adds numbers | Unit test |
| Test a function that parses a JSON string | Unit test |
| Test a function that reads a real file | Integration / system detail |
| Test a repository function that hits a real DB | Integration test |
| Test an API endpoint using a real HTTP server | Integration or end‑to‑end test |
If your test touches:
- Real network
- Real database
- Real filesystem
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:
app/
services/
discounts.py
validators.py
tests/
unit/
test_discounts.py
test_validators.py
integration/
test_api_endpoints.pyYou separate unit and integration tests into different folders.
Naming tests
Good names describe the behavior, not just the function:
| Bad name | Better name |
|---|---|
test_discount_vip | test_calculate_discount_for_vip_user |
test_invalid | test_is_valid_username_rejects_empty_string |
test_edge | test_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:
- Business rules
- Discount calculation
- Free shipping logic
- Inventory checks
- Input validation logic
- Email and password validation
- Length limits
- Allowed characters
- Utility functions
- String helpers
- Timestamp / token helpers (with injected time)
These parts often have tricky rules and many edge cases. Unit tests are perfect here.
You can cover:
- Database access with integration tests
- Full endpoints with API tests
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:
- 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.
- Writing tests that are too big
- Testing half of your application in a single test.
- Split into smaller behavioral checks.
- Relying on external systems
- Real databases, external APIs, actual file IO, real current time.
- Use pure logic, dependency injection, or mocking for unit tests.
- 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:
- Testing small, isolated pieces of code
- Keeping tests fast, deterministic, and focused
- Covering normal cases and edge cases
- Treating tests as executable documentation of your business rules
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
KAHIBARO