Skip to content

Unit Testing

What to Unit Test (and What Not To)

Unit testing is about verifying the correctness of individual units of code — functions, methods, or classes — in isolation. Knowing what to test is just as important as knowing how to test.

What You Should Test

  • Business logic — Calculations, transformations, and decision-making code that implements your domain rules.
  • Edge cases — Boundary values, empty inputs, null values, maximum sizes, and off-by-one scenarios.
  • Error handling — Ensure your code fails gracefully with meaningful error messages.
  • Pure functions — Functions with no side effects are the easiest and most valuable to unit test.
  • Complex conditionals — Code with multiple branches and conditions where bugs love to hide.

What You Should Not Unit Test

  • Trivial code — Simple getters, setters, and pass-through methods provide little value when tested.
  • Third-party libraries — Trust that well-maintained libraries work correctly. Test your usage of them, not the library itself.
  • Configuration — Static configuration values do not need unit tests.
  • Private implementation details — Test behavior through public interfaces. If you refactor internals, tests should still pass.
  • UI layout — Pixel-level rendering is better suited to visual regression testing tools.

Writing Your First Test

Let us start with a simple example across multiple languages. We will test a function that determines whether a year is a leap year.

leap_year.py
def is_leap_year(year):
"""Determine if a given year is a leap year."""
if year <= 0:
raise ValueError("Year must be a positive integer")
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
test_leap_year.py
import pytest
from leap_year import is_leap_year
def test_common_year():
assert is_leap_year(2023) is False
def test_typical_leap_year():
assert is_leap_year(2024) is True
def test_century_year_not_leap():
assert is_leap_year(1900) is False
def test_four_hundred_year_is_leap():
assert is_leap_year(2000) is True
def test_negative_year_raises():
with pytest.raises(ValueError):
is_leap_year(-1)

Test Structure

Every well-organized test follows four phases: Setup, Execution, Assertion, and Teardown.

Setup (Arrange)

Prepare the test’s preconditions. Create objects, initialize state, and set up test data. Many frameworks provide special hooks for setup code that runs before each test.

Execution (Act)

Invoke the behavior under test. This should be a single, focused action.

Assertion (Assert)

Verify the outcome. Compare actual results against expected values.

Teardown (Cleanup)

Restore the environment to a clean state. Close connections, delete temporary files, and reset shared resources. Most frameworks provide hooks for this.

import pytest
from shopping_cart import ShoppingCart, Item
class TestShoppingCart:
def setup_method(self):
"""Setup: Create a fresh cart before each test."""
self.cart = ShoppingCart()
self.apple = Item("Apple", price=1.50)
self.bread = Item("Bread", price=3.00)
def test_add_item_increases_count(self):
"""Execute and Assert."""
self.cart.add(self.apple, quantity=3)
assert self.cart.item_count == 3
def test_total_reflects_quantities(self):
self.cart.add(self.apple, quantity=2)
self.cart.add(self.bread, quantity=1)
assert self.cart.total == 6.00
def teardown_method(self):
"""Teardown: Clean up after each test."""
self.cart.clear()

Naming Conventions

Good test names describe the scenario and expected outcome without requiring the reader to examine the test body. Several conventions exist — pick one and use it consistently across your project.

ConventionExample
test_<behavior>_<scenario>test_calculate_discount_with_negative_price_raises_error
should <behavior> when <scenario>should raise error when price is negative
<method>_<scenario>_<expected>calculateDiscount_negativePrice_throwsException
given_<context>_when_<action>_then_<outcome>given_negative_price_when_calculating_discount_then_raises_error

The best name is the one your team agrees on and applies consistently. Aim for names that read like a sentence and act as documentation.


Assertion Types

Testing frameworks provide a rich set of assertion methods. Choosing the right assertion produces clearer failure messages and more readable tests.

Equality Assertions

The most common assertion — verify that an actual value matches an expected value.

def test_equality_assertions():
assert calculate_total(10, 5) == 15 # Exact equality
assert calculate_total(10.1, 5.2) == pytest.approx(15.3) # Float comparison
assert result != "error" # Inequality

Truthiness Assertions

Check boolean conditions, null values, and existence.

def test_truthiness():
assert is_valid is True # Explicit boolean check
assert user is not None # Not None
assert not errors # Falsy (empty list, 0, None, "")
assert results # Truthy (non-empty)

Exception Assertions

Verify that code throws the expected exceptions for invalid inputs.

def test_exception_assertions():
with pytest.raises(ValueError) as exc_info:
withdraw(-100)
assert "negative" in str(exc_info.value).lower()
with pytest.raises(ZeroDivisionError):
divide(10, 0)

Collection Assertions

Verify properties of lists, sets, maps, and other collections.

def test_collection_assertions():
fruits = ["apple", "banana", "cherry"]
assert len(fruits) == 3
assert "banana" in fruits
assert "grape" not in fruits
assert sorted(fruits) == ["apple", "banana", "cherry"]
assert all(isinstance(f, str) for f in fruits)

Parameterized Tests

When you need to test the same logic with many different inputs, parameterized tests eliminate duplication. Instead of writing ten nearly identical tests, you write one test and feed it a table of inputs and expected outputs.

import pytest
@pytest.mark.parametrize("input_str, expected", [
("hello", "HELLO"),
("Hello World", "HELLO WORLD"),
("", ""),
("123abc", "123ABC"),
("ALREADY UPPER", "ALREADY UPPER"),
])
def test_to_uppercase(input_str, expected):
assert input_str.upper() == expected
# Multiple parameters with IDs for clear test output
@pytest.mark.parametrize("a, b, expected", [
pytest.param(2, 3, 5, id="positive numbers"),
pytest.param(-1, 1, 0, id="negative and positive"),
pytest.param(0, 0, 0, id="both zero"),
pytest.param(-5, -3, -8, id="both negative"),
])
def test_add(a, b, expected):
assert add(a, b) == expected

Test Fixtures

Test fixtures provide shared setup and teardown logic for groups of related tests. They ensure each test starts with a known, consistent state.

import pytest
from database import Database
from user_repository import UserRepository
# conftest.py - shared fixtures available to all test files in the directory
@pytest.fixture
def db():
"""Provide a clean in-memory database for each test."""
database = Database(":memory:")
database.create_tables()
yield database
database.close()
@pytest.fixture
def user_repo(db):
"""Provide a UserRepository backed by the test database."""
return UserRepository(db)
@pytest.fixture
def sample_users(user_repo):
"""Pre-populate the database with sample users."""
users = [
user_repo.create("Alice", "alice@example.com"),
user_repo.create("Bob", "bob@example.com"),
user_repo.create("Charlie", "charlie@example.com"),
]
return users
# test_users.py
def test_find_user_by_email(user_repo, sample_users):
user = user_repo.find_by_email("bob@example.com")
assert user.name == "Bob"
def test_delete_user(user_repo, sample_users):
user_repo.delete(sample_users[0].id)
assert user_repo.count() == 2
# Fixtures with different scopes
@pytest.fixture(scope="module")
def expensive_resource():
"""Created once per test module, shared across tests."""
resource = create_expensive_resource()
yield resource
resource.cleanup()
@pytest.fixture(scope="session")
def global_config():
"""Created once per entire test session."""
return load_test_config()

Test Coverage

Test coverage measures how much of your source code is exercised by your test suite. It is a useful metric, but it must be interpreted carefully.

Types of Coverage

Coverage TypeWhat It MeasuresExample
Line coveragePercentage of lines executedDid the test run this line?
Branch coveragePercentage of decision branches takenWere both the if and else paths tested?
Function coveragePercentage of functions calledWas this function invoked at all?
Path coveragePercentage of possible execution pathsWere all combinations of branches tested?

What Percentage to Aim For

There is no universal “right” coverage number, but here are practical guidelines:

  • 70-80% — A reasonable target for most projects. Covers the important logic without chasing trivial code.
  • 90%+ — Appropriate for critical systems (financial calculations, medical software, security-sensitive code).
  • 100% — Rarely practical or beneficial. Pursuing 100% often leads to brittle tests of implementation details.

Coverage Pitfalls

High coverage does not mean good tests. Consider this function:

def divide(a, b):
return a / b

This test achieves 100% line coverage:

def test_divide():
assert divide(10, 2) == 5

But it misses the critical edge case: divide(10, 0). Coverage tells you what code was executed, not whether the assertions are meaningful. Use coverage as a guide for finding untested code, not as a measure of test quality.

Generating Coverage Reports

Terminal window
# Install coverage tool
pip install pytest-cov
# Run tests with coverage
pytest --cov=mypackage --cov-report=html
# Enforce minimum coverage
pytest --cov=mypackage --cov-fail-under=80

Testing Edge Cases

Edge cases are where bugs hide. A thorough test suite explicitly covers boundary conditions and unusual inputs.

Common Edge Cases to Test

CategoryExamples
Empty inputsEmpty strings, empty lists, null/None, zero
Boundary valuesMinimum, maximum, just inside/outside bounds
Type extremesINT_MAX, INT_MIN, NaN, Infinity, very long strings
Special charactersUnicode, emojis, newlines, tabs, SQL injection strings
ConcurrencySimultaneous access, race conditions, deadlocks
Resource limitsOut of memory, disk full, network timeout

Example: Testing a Password Validator

import pytest
from password_validator import validate_password
class TestPasswordValidator:
"""Edge case testing for password validation."""
# Happy path
def test_valid_password(self):
assert validate_password("Str0ng!Pass") is True
# Length boundaries
def test_too_short(self):
assert validate_password("Ab1!") is False
def test_minimum_length(self):
assert validate_password("Abcde1!x") is True # Exactly 8 chars
def test_maximum_length(self):
assert validate_password("A" * 127 + "1!") is True # 129 chars
def test_exceeds_maximum_length(self):
assert validate_password("A" * 200 + "1!") is False
# Missing character types
def test_no_uppercase(self):
assert validate_password("lowercase1!") is False
def test_no_lowercase(self):
assert validate_password("UPPERCASE1!") is False
def test_no_digit(self):
assert validate_password("NoDigits!!") is False
def test_no_special_char(self):
assert validate_password("NoSpecial1") is False
# Edge cases
def test_empty_string(self):
assert validate_password("") is False
def test_only_spaces(self):
assert validate_password(" ") is False
def test_unicode_characters(self):
assert validate_password("Unicod3!Pass") is True
@pytest.mark.parametrize("password", [
None,
123,
[],
{},
])
def test_non_string_input(self, password):
with pytest.raises(TypeError):
validate_password(password)

Next Steps

Now that you can write effective unit tests, advance to more sophisticated testing techniques: