Python Unit Testing (unittest/pytest)

Unit testing means writing small, automated programs that check whether individual pieces of your code — usually a single function or method — behave the way you expect. Instead of manually running your program and eyeballing the output every time you change something, you write tests once and rerun them in seconds, forever. Python ships with a built-in testing framework called unittest, and the wider community has largely standardized on a third-party library called pytest for its simpler syntax and richer features. This lesson covers both, because you will encounter unittest-style tests in older and enterprise codebases, and pytest in almost every modern Python project.

Overview: What Unit Testing Is and How It Works

A unit test exercises one small unit of behavior — typically a function or method — in isolation, feeds it known inputs, and asserts that the output or resulting state matches what you expect. If the assertion fails, the test framework reports a failure with a traceback showing exactly what was expected versus what actually happened. Run enough of these tests together and you get a safety net: whenever you refactor or add a feature, you rerun the whole suite and immediately know if you broke something, often before you even open a browser or REPL.

Both unittest and pytest follow the same underlying model, often called the “AAA” pattern: Arrange (set up the inputs and any objects you need), Act (call the code under test), and Assert (check the result). Under the hood, a test runner is responsible for three things: discovering test code (scanning files and modules for functions or classes that look like tests), executing each test in isolation, and reporting results — which tests passed, which failed, and why. When a test fails, the framework doesn’t stop the whole suite; it catches the AssertionError (or any other exception) raised inside that one test, records it, and moves on to the next test. This is why one broken function doesn’t hide failures elsewhere in your codebase.

unittest is modeled on Java’s JUnit and uses an object-oriented, “xUnit” style: you subclass unittest.TestCase and write methods whose names start with test. The base class supplies dozens of assert* methods (assertEqual, assertTrue, assertRaises, and so on) that produce clear failure messages. pytest, by contrast, lets you write plain functions and use Python’s built-in assert statement directly — pytest rewrites the bytecode of your test module at collection time so that a failing assert a == b prints both values automatically, without you needing special assertion methods. Because pytest tests are plain functions, they’re less boilerplate-heavy, and pytest also runs unittest.TestCase-based tests without modification, so you can mix both styles in one project.

Syntax

A minimal unittest test module has this shape:

import unittest

class TestSomething(unittest.TestCase):
    def test_case_name(self):
        self.assertEqual(actual, expected)

if __name__ == "__main__":
    unittest.main()

A minimal pytest test module looks like this instead:

def test_case_name():
    assert actual == expected

Key rules that both frameworks rely on for discovery:

  • Test files are usually named test_*.py or *_test.py.
  • In unittest, test classes must subclass unittest.TestCase, and test methods must start with the literal prefix test (e.g. test_addition) — a helper method named check_addition will simply never be collected or run.
  • In pytest, any top-level function whose name starts with test_ is treated as a test — no base class required.

The table below summarizes the most common unittest.TestCase assertion methods and their pytest-style equivalents:

unittest method Checks pytest equivalent
assertEqual(a, b) a == b assert a == b
assertNotEqual(a, b) a != b assert a != b
assertTrue(x) / assertFalse(x) bool(x) is True/False assert x / assert not x
assertIsNone(x) x is None assert x is None
assertIn(a, b) a in b assert a in b
assertRaises(Err) code raises Err pytest.raises(Err)
assertAlmostEqual(a, b) floats nearly equal a == pytest.approx(b)

Examples

Example 1: A basic unittest test case

import unittest


def calculate_discount(price: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return price - (price * percent / 100)


class TestCalculateDiscount(unittest.TestCase):
    def test_normal_discount(self):
        self.assertEqual(calculate_discount(100, 20), 80.0)

    def test_zero_percent(self):
        self.assertEqual(calculate_discount(50, 0), 50.0)

    def test_invalid_percent_raises(self):
        with self.assertRaises(ValueError):
            calculate_discount(100, 150)


if __name__ == "__main__":
    unittest.main()

Output (running python3 test_discount.py):

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Each dot represents one passing test. unittest.main() creates a TestLoader, discovers every test* method on TestCalculateDiscount, runs them each in a fresh instance of the class (so state never leaks between tests), and prints a summary. Run the same file with python3 test_discount.py -v to see each test name and its individual result instead of dots.

Example 2: The same tests written with pytest

import pytest


def calculate_discount(price: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return price - (price * percent / 100)


def test_normal_discount():
    assert calculate_discount(100, 20) == 80.0


def test_zero_percent():
    assert calculate_discount(50, 0) == 50.0


def test_invalid_percent_raises():
    with pytest.raises(ValueError):
        calculate_discount(100, 150)


@pytest.fixture
def sample_prices():
    return [10.0, 25.0, 100.0]


def test_discount_on_multiple_prices(sample_prices):
    discounted = [calculate_discount(p, 10) for p in sample_prices]
    assert discounted == [9.0, 22.5, 90.0]

Output (running pytest -v test_discount.py):

test_discount.py::test_normal_discount PASSED
test_discount.py::test_zero_percent PASSED
test_discount.py::test_invalid_percent_raises PASSED
test_discount.py::test_discount_on_multiple_prices PASSED

============================== 4 passed in 0.01s ==============================

Notice test_discount_on_multiple_prices takes a parameter named sample_prices that matches the name of the function decorated with @pytest.fixture. This is dependency injection: before running the test, pytest calls the fixture function, and whatever it returns is passed in as the argument. Fixtures are pytest's mechanism for supplying reusable setup data (or objects, database connections, temporary files) to any test that asks for them by parameter name.

Example 3: Mocking a dependency with unittest.mock

Real code often depends on things that are slow, unreliable, or external — network calls, databases, the current time. To keep unit tests fast and deterministic, you replace those dependencies with mock objects using the built-in unittest.mock module (which works fine inside pytest tests too).

import unittest
from unittest.mock import Mock


class WeatherClient:
    def __init__(self, api):
        self.api = api

    def get_temperature(self, city: str) -> float:
        data = self.api.fetch(city)
        return data["temp_celsius"]


class TestWeatherClient(unittest.TestCase):
    def setUp(self):
        self.mock_api = Mock()
        self.client = WeatherClient(self.mock_api)

    def test_get_temperature_returns_value_from_api(self):
        self.mock_api.fetch.return_value = {"temp_celsius": 21.5}

        result = self.client.get_temperature("Berlin")

        self.assertEqual(result, 21.5)
        self.mock_api.fetch.assert_called_once_with("Berlin")

    def test_get_temperature_propagates_missing_key(self):
        self.mock_api.fetch.return_value = {}

        with self.assertRaises(KeyError):
            self.client.get_temperature("Berlin")


if __name__ == "__main__":
    unittest.main()

Output (running python3 test_weather.py):

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

setUp() runs automatically before every test method, giving each test a brand-new Mock() and WeatherClient instance so tests can't accidentally affect each other. Mock() creates a stand-in object where any attribute access (like .fetch) automatically becomes another mock; setting .return_value tells it what to hand back when called. assert_called_once_with("Berlin") then verifies not just the output, but that the code under test called the dependency correctly. Note that no real network or weather API is ever touched — that's the whole point of mocking.

How Test Discovery and Execution Work Under the Hood

When you run python -m unittest or pytest with no arguments, the framework walks the current directory tree looking for files matching its discovery pattern (test*.py for unittest, test_*.py/*_test.py for pytest by default). It imports each matching module, then inspects it: unittest's TestLoader looks for subclasses of TestCase and, within each, methods whose names begin with test; pytest's collector looks for top-level functions named test_* as well as any TestCase subclasses (for compatibility). Collected tests are wrapped into a test suite and handed to a runner, which executes each one, catching any exception raised during the run. An AssertionError is reported as a failure (the assertion was false), while any other exception — a TypeError or KeyError you didn't expect — is reported as an error, a useful distinction when triaging a red test suite. Finally, the process exits with status code 0 if everything passed and non-zero otherwise, which is exactly what lets CI systems like GitHub Actions treat "tests failed" as a build failure.

Common Mistakes

Mistake 1: Forgetting the required test-name prefix

unittest silently ignores any method that doesn't start with test, so a typo means the test never runs at all — no error, no warning, just zero coverage.

import unittest


class TestMath(unittest.TestCase):
    def check_addition(self):
        self.assertEqual(1 + 1, 2)


if __name__ == "__main__":
    unittest.main()

Output:

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

It reports OK because zero failures occurred among zero tests run — a dangerously misleading result. The fix is simply naming the method with the test_ prefix:

import unittest


class TestMath(unittest.TestCase):
    def test_addition(self):
        self.assertEqual(1 + 1, 2)


if __name__ == "__main__":
    unittest.main()

Output:

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Mistake 2: Comparing floats with exact equality

Floating-point numbers are stored in binary, so decimal values like 0.1 can't always be represented exactly, and simple arithmetic can produce tiny rounding errors.

def test_float_addition():
    assert 0.1 + 0.2 == 0.3

Run under pytest, this fails with something like assert 0.30000000000000004 == 0.3, even though the math is "correct" to a human. The fix is to compare with a tolerance, using pytest.approx or the standard library's math.isclose:

import math


def test_float_addition():
    assert math.isclose(0.1 + 0.2, 0.3)

This passes because math.isclose checks that the two values are within a small relative tolerance of each other rather than requiring bit-for-bit equality.

Best Practices

  • Name tests descriptively, e.g. test_get_temperature_raises_on_missing_key, so a failure report tells you what broke without opening the file.
  • Keep each test focused on one behavior; a test that asserts five unrelated things makes failures hard to diagnose.
  • Use setUp/tearDown (unittest) or fixtures (pytest) to avoid duplicating setup code, and to guarantee each test starts from a clean state.
  • Mock external dependencies (APIs, databases, the filesystem, the clock) so your unit tests stay fast, deterministic, and runnable offline.
  • Test edge cases and error paths — empty inputs, negative numbers, exceptions — not just the "happy path".
  • Run your test suite automatically in CI on every push so regressions are caught before merging, not after deployment.
  • Prefer pytest for new projects: it requires less boilerplate, gives richer failure output, and still runs any legacy unittest code unmodified.
  • Avoid testing implementation details (private helper functions, internal variable names); test the public behavior so refactors don't force you to rewrite your tests.

Practice Exercises

  1. Write a function is_palindrome(text: str) -> bool that ignores case and spaces, then write at least three unittest test methods covering a true palindrome, a false case, and an empty string.
  2. Write a pytest fixture that returns a list of dictionaries representing users (each with name and age keys), and a test function that uses the fixture to assert the average age is computed correctly by a function you write.
  3. Write a class BankAccount with a withdraw(amount) method that raises ValueError if the amount exceeds the balance. Using unittest.mock, write a test verifying that a logging dependency's .record() method is called exactly once whenever a withdrawal succeeds.

Summary

  • Unit tests automatically verify that individual functions or methods behave as expected, giving you a safety net when refactoring.
  • unittest is built into Python and uses classes subclassing TestCase with assert* methods; test methods must start with test or they are silently skipped.
  • pytest is a popular third-party framework using plain functions and the built-in assert statement, plus powerful fixtures for reusable setup.
  • Test runners discover, execute, and report on tests, distinguishing failures (assertion errors) from errors (unexpected exceptions).
  • unittest.mock.Mock lets you replace slow or unreliable dependencies with controllable stand-ins so tests stay fast and deterministic.
  • Never compare floating-point numbers with ==; use math.isclose or pytest.approx instead.