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.0The moment it breaks, you know. Immediately. Before it reaches production.
Two testing tools
Python gives you two main options:
unittest | pytest | |
|---|---|---|
| Comes with Python | Yes — built in | No — install with pip |
| Style | Class-based | Function-based |
| Verbosity | More boilerplate | Minimal |
| Output | Basic | Detailed and readable |
| Used in | Legacy codebases | Modern 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.pyOutput:
......
----------------------------------------------------------------------
Ran 6 tests in 0.001s
OKEach . is a passing test. An F means a failure. An E means an error.
Run with more detail:
python3 -m unittest test_bank.py -vOutput:
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
OKunittest assertions
These are the most common self.assert* methods:
| Method | Checks |
|---|---|
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 pytestWriting 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() == 2Running pytest
pytestpytest 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 -vOutput:
========================= 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.0When 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] PASSED8 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.tomlRun all tests at once:
pytest tests/Run a specific file:
pytest tests/test_bank.pyRun a specific test:
pytest tests/test_bank.py::test_deposit_increases_balanceUseful 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 outputWhat 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_amountArrange — 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() == 0Summary
| Concept | unittest | pytest |
|---|---|---|
| Test file | test_*.py | test_*.py |
| Test class | class TestX(unittest.TestCase) | class TestX: (optional) |
| Test function | def test_*(self) | def test_*() |
| Setup | setUp(self) | @pytest.fixture |
| Assert equal | self.assertEqual(a, b) | assert a == b |
| Assert raises | self.assertRaises(Error) | pytest.raises(Error) |
| Run tests | python3 -m unittest | pytest |
| Parametrize | Manual loops | @pytest.mark.parametrize |