DocsHub
Testing

Testing with unittest & pytest

Learn how to write and run tests for your Python code using unittest and pytest.

Testing with unittest & pytest

Writing code is one thing. Knowing your code actually works — and keeps working when you change things — is another. That is what testing is for.

A test is code that checks your code. You write a function, then you write another function that calls it with known inputs and verifies the outputs are what you expect. If something breaks later, your tests catch it immediately.

Without tests, every change you make is a gamble. With tests, you have a safety net.


Why testing matters

Say you have this function:

def calculate_discount(price, percent):
    return price - (price * percent / 100)

It works today. Three months later, someone changes it to fix a bug and accidentally breaks the rounding. Without a test, you might not notice until a customer complains. With a test:

assert calculate_discount(1000, 10) == 900.0

The moment it breaks, you know. Immediately. Before it reaches production.


Two testing tools

Python gives you two main options:

unittestpytest
Comes with PythonYes — built inNo — install with pip
StyleClass-basedFunction-based
VerbosityMore boilerplateMinimal
OutputBasicDetailed and readable
Used inLegacy codebasesModern projects

Start with pytest for new projects. Learn unittest because you will encounter it in existing codebases.


The code we will test

Throughout this file we will test this module — save it as bank.py:

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.balance += amount
        self.transactions.append(("deposit", amount))
        return self.balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self.balance:
            raise ValueError("Insufficient funds.")
        self.balance -= amount
        self.transactions.append(("withdrawal", amount))
        return self.balance

    def get_transaction_count(self):
        return len(self.transactions)

unittest — built-in testing

Writing tests

Create a file called test_bank.py:

import unittest
from bank import BankAccount

class TestBankAccount(unittest.TestCase):

    def setUp(self):
        """Runs before every test — fresh account each time."""
        self.account = BankAccount("Ali", balance=1000.0)

    def test_initial_balance(self):
        """Account starts with the correct balance."""
        self.assertEqual(self.account.balance, 1000.0)

    def test_deposit_increases_balance(self):
        """Depositing money increases the balance."""
        self.account.deposit(500)
        self.assertEqual(self.account.balance, 1500.0)

    def test_withdraw_decreases_balance(self):
        """Withdrawing money decreases the balance."""
        self.account.withdraw(200)
        self.assertEqual(self.account.balance, 800.0)

    def test_deposit_negative_raises_error(self):
        """Depositing a negative amount raises ValueError."""
        with self.assertRaises(ValueError):
            self.account.deposit(-100)

    def test_withdraw_more_than_balance_raises_error(self):
        """Withdrawing more than balance raises ValueError."""
        with self.assertRaises(ValueError):
            self.account.withdraw(5000)

    def test_transaction_count(self):
        """Transaction count updates correctly."""
        self.account.deposit(100)
        self.account.withdraw(50)
        self.assertEqual(self.account.get_transaction_count(), 2)

    def tearDown(self):
        """Runs after every test — cleanup if needed."""
        pass


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

Running unittest tests

python3 -m unittest test_bank.py

Output:

......
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Each . is a passing test. An F means a failure. An E means an error.

Run with more detail:

python3 -m unittest test_bank.py -v

Output:

test_deposit_increases_balance ... ok
test_deposit_negative_raises_error ... ok
test_initial_balance ... ok
test_transaction_count ... ok
test_withdraw_decreases_balance ... ok
test_withdraw_more_than_balance_raises_error ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

unittest assertions

These are the most common self.assert* methods:

MethodChecks
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)x is truthy
assertFalse(x)x is falsy
assertIsNone(x)x is None
assertIsNotNone(x)x is not None
assertIn(a, b)a in b
assertNotIn(a, b)a not in b
assertRaises(Error)Code raises that error
assertGreater(a, b)a > b
assertLess(a, b)a < b
assertAlmostEqual(a, b)a ≈ b (for floats)

Use assertAlmostEqual when comparing floats — never assertEqual. Floating point math is imprecise, so 0.1 + 0.2 == 0.3 is False in Python. assertAlmostEqual(0.1 + 0.2, 0.3) handles this correctly.


pytest — the modern standard

Install it:

pip install pytest

Writing pytest tests

pytest is simpler — no classes required, no special assertion methods. Just functions that start with test_ and use plain assert:

# test_bank_pytest.py
import pytest
from bank import BankAccount


def test_initial_balance():
    account = BankAccount("Ali", balance=1000.0)
    assert account.balance == 1000.0


def test_deposit_increases_balance():
    account = BankAccount("Ali", balance=1000.0)
    account.deposit(500)
    assert account.balance == 1500.0


def test_withdraw_decreases_balance():
    account = BankAccount("Ali", balance=1000.0)
    account.withdraw(200)
    assert account.balance == 800.0


def test_deposit_negative_raises_error():
    account = BankAccount("Ali", balance=1000.0)
    with pytest.raises(ValueError):
        account.deposit(-100)


def test_withdraw_more_than_balance_raises_error():
    account = BankAccount("Ali", balance=1000.0)
    with pytest.raises(ValueError):
        account.withdraw(5000)


def test_transaction_count():
    account = BankAccount("Ali", balance=1000.0)
    account.deposit(100)
    account.withdraw(50)
    assert account.get_transaction_count() == 2

Running pytest

pytest

pytest automatically finds any file starting with test_ and any function starting with test_.

Output:

========================= test session starts ==========================
collected 6 items

test_bank_pytest.py ......                                       [100%]

========================== 6 passed in 0.02s ===========================

More detail:

pytest -v

Output:

========================= test session starts ==========================
collected 6 items

test_bank_pytest.py::test_initial_balance PASSED               [ 16%]
test_bank_pytest.py::test_deposit_increases_balance PASSED     [ 33%]
test_bank_pytest.py::test_withdraw_decreases_balance PASSED    [ 50%]
test_bank_pytest.py::test_deposit_negative_raises_error PASSED [ 66%]
test_bank_pytest.py::test_withdraw_more_than_balance_raises_error PASSED [ 83%]
test_bank_pytest.py::test_transaction_count PASSED             [100%]

========================== 6 passed in 0.02s ===========================

fixtures — pytest's version of setUp

In pytest, fixtures are functions that set up shared test state. They are more powerful and flexible than setUp:

import pytest
from bank import BankAccount


@pytest.fixture
def account():
    """Create a fresh account for each test."""
    return BankAccount("Ali", balance=1000.0)


@pytest.fixture
def funded_account():
    """Account with some transactions already done."""
    acc = BankAccount("Sara", balance=5000.0)
    acc.deposit(1000)
    acc.withdraw(500)
    return acc


def test_initial_balance(account):
    assert account.balance == 1000.0


def test_deposit(account):
    account.deposit(500)
    assert account.balance == 1500.0


def test_withdraw(account):
    account.withdraw(200)
    assert account.balance == 800.0


def test_funded_account_transactions(funded_account):
    assert funded_account.get_transaction_count() == 2
    assert funded_account.balance == 5500.0

When a test function takes a fixture name as a parameter, pytest automatically calls the fixture and passes the result. Each test gets a fresh fixture — no shared state between tests.


parametrize — test many inputs at once

Instead of writing one test per input, use @pytest.mark.parametrize to run the same test with many different values:

import pytest
from bank import BankAccount


@pytest.mark.parametrize("deposit_amount, expected_balance", [
    (100,   1100.0),
    (500,   1500.0),
    (0.01,  1000.01),
    (9999,  10999.0),
])
def test_deposit_amounts(deposit_amount, expected_balance):
    account = BankAccount("Ali", balance=1000.0)
    account.deposit(deposit_amount)
    assert account.balance == expected_balance


@pytest.mark.parametrize("invalid_amount", [-100, -1, 0, -0.01])
def test_invalid_deposits_raise_error(invalid_amount):
    account = BankAccount("Ali", balance=1000.0)
    with pytest.raises(ValueError):
        account.deposit(invalid_amount)

Running this:

test_bank_pytest.py::test_deposit_amounts[100-1100.0] PASSED
test_bank_pytest.py::test_deposit_amounts[500-1500.0] PASSED
test_bank_pytest.py::test_deposit_amounts[0.01-1000.01] PASSED
test_bank_pytest.py::test_deposit_amounts[9999-10999.0] PASSED
test_bank_pytest.py::test_invalid_deposits_raise_error[-100] PASSED
test_bank_pytest.py::test_invalid_deposits_raise_error[-1] PASSED
test_bank_pytest.py::test_invalid_deposits_raise_error[0] PASSED
test_bank_pytest.py::test_invalid_deposits_raise_error[-0.01] PASSED

8 test cases from 2 test functions. Much cleaner than writing 8 separate functions.


What happens when a test fails

Change bank.py to introduce a bug — make deposit subtract instead of add:

def deposit(self, amount):
    if amount <= 0:
        raise ValueError("Deposit amount must be positive.")
    self.balance -= amount    # bug — should be +=

Now run pytest:

FAILED test_bank_pytest.py::test_deposit_increases_balance

========================== FAILURES ====================================
__________________ test_deposit_increases_balance ______________________

account = <bank.BankAccount object at 0x...>

    def test_deposit_increases_balance(account):
        account.deposit(500)
>       assert account.balance == 1500.0
E       AssertionError: assert 500.0 == 1500.0
E        +  where 500.0 = <BankAccount>.balance

========================= 1 failed, 5 passed in 0.03s ==================

pytest tells you exactly which test failed, what line, what the actual value was, and what you expected. Crystal clear.


Project structure for tests

Keep your tests organized:

my_project/
├── src/
│   ├── bank.py
│   ├── utils.py
│   └── models.py
├── tests/
│   ├── test_bank.py
│   ├── test_utils.py
│   └── test_models.py
├── requirements.txt
└── pyproject.toml

Run all tests at once:

pytest tests/

Run a specific file:

pytest tests/test_bank.py

Run a specific test:

pytest tests/test_bank.py::test_deposit_increases_balance

Useful pytest flags

pytest -v                  # verbose — show each test name
pytest -s                  # show print() output during tests
pytest -x                  # stop at the first failure
pytest --tb=short          # shorter traceback on failures
pytest -k "deposit"        # only run tests with "deposit" in the name
pytest --co                # collect and list tests without running them
pytest -q                  # quiet — minimal output

What makes a good test

A good test follows the AAA pattern:

def test_withdraw_decreases_balance(account):
    # Arrange — set up the starting state
    initial_balance = account.balance
    withdraw_amount = 200

    # Act — do the thing you are testing
    account.withdraw(withdraw_amount)

    # Assert — check the result is correct
    assert account.balance == initial_balance - withdraw_amount

Arrange — set up everything you need. Act — call the function or method being tested. Assert — verify the outcome.

Each test should test one thing. If a test fails, you should immediately know what broke — not have to figure out which of five things went wrong. Small, focused tests are far more valuable than large, complex ones.


A complete test file

# tests/test_bank.py
import pytest
from bank import BankAccount


@pytest.fixture
def account():
    return BankAccount("Ali", balance=1000.0)


class TestDeposit:
    def test_increases_balance(self, account):
        account.deposit(500)
        assert account.balance == 1500.0

    def test_returns_new_balance(self, account):
        result = account.deposit(500)
        assert result == 1500.0

    def test_records_transaction(self, account):
        account.deposit(500)
        assert account.get_transaction_count() == 1

    @pytest.mark.parametrize("amount", [-100, 0, -0.01])
    def test_invalid_amounts_raise_error(self, account, amount):
        with pytest.raises(ValueError):
            account.deposit(amount)


class TestWithdraw:
    def test_decreases_balance(self, account):
        account.withdraw(200)
        assert account.balance == 800.0

    def test_returns_new_balance(self, account):
        result = account.withdraw(200)
        assert result == 800.0

    def test_records_transaction(self, account):
        account.withdraw(200)
        assert account.get_transaction_count() == 1

    def test_insufficient_funds_raises_error(self, account):
        with pytest.raises(ValueError, match="Insufficient funds"):
            account.withdraw(9999)

    @pytest.mark.parametrize("amount", [-50, 0])
    def test_invalid_amounts_raise_error(self, account, amount):
        with pytest.raises(ValueError):
            account.withdraw(amount)


class TestTransactions:
    def test_multiple_transactions_counted(self, account):
        account.deposit(100)
        account.deposit(200)
        account.withdraw(50)
        assert account.get_transaction_count() == 3

    def test_fresh_account_has_no_transactions(self, account):
        assert account.get_transaction_count() == 0

Summary

Conceptunittestpytest
Test filetest_*.pytest_*.py
Test classclass TestX(unittest.TestCase)class TestX: (optional)
Test functiondef test_*(self)def test_*()
SetupsetUp(self)@pytest.fixture
Assert equalself.assertEqual(a, b)assert a == b
Assert raisesself.assertRaises(Error)pytest.raises(Error)
Run testspython3 -m unittestpytest
ParametrizeManual loops@pytest.mark.parametrize

On this page