python unit testing is how you verify your code actually does what you think it does — and keeps doing it as your codebase grows. This article covers both major paths: unittest (built directly into the standard library) and pytest (the de facto third-party standard) — the unittest vs pytest comparison genuinely matters, and this article covers both fairly, so you can make an informed choice rather than starting with an assumption already made for you.
What Unit Testing Is and Why It Matters
What a unit test verifies
A unit test verifies that a single, isolated piece of code — usually a function or method — produces the expected output for given inputs. "Unit" specifically means small and isolated: testing one function's behavior on its own, not an entire workflow or system end to end.
def add(a, b):
return a + b
# A unit test verifies: does add(2, 3) actually return 5?
assert add(2, 3) == 5Why this matters practically
Unit tests catch logic bugs and regressions early — ideally the moment they're introduced, rather than after they've already reached production and affected real users. They also give you genuine confidence to refactor code later: if a comprehensive test suite still passes after you've restructured a function's internals, you have real evidence the change didn't silently break existing behavior, rather than just hoping it didn't.
Two paths, both covered here
Python offers two main paths for writing unit tests: unittest, built directly into the standard library (no installation required at all), and pytest, a third-party package that's become the de facto standard across much of the Python ecosystem. This article covers both, starting with the fundamentals either way.
Writing Your First Test with unittest
The core pattern
import unittest
def add(a, b):
return a + b
class TestAdd(unittest.TestCase):
def test_add_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
if __name__ == "__main__":
unittest.main()The core pattern: subclass unittest.TestCase, write methods whose names start with test_ (this naming convention is how unittest discovers which methods are actual tests), and use self.assertEqual() — and its many related methods — rather than a plain assert statement.
Running it
python test_add.py..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OKEach . represents one passing test. If a test fails, unittest reports exactly which one, along with the expected versus actual values — genuinely useful for immediately understanding what went wrong, without needing to add your own debugging print statements.
Key assertion methods
class TestExamples(unittest.TestCase):
def test_assertions(self):
self.assertEqual(2 + 2, 4)
self.assertTrue(5 > 3)
self.assertFalse(3 > 5)
self.assertIn("a", ["a", "b", "c"])
with self.assertRaises(ZeroDivisionError):
1 / 0assertTrue/assertFalse check a Boolean condition directly. assertIn checks membership. assertRaises, used as a context manager (exactly the with statement pattern covered in the earlier context managers articles), verifies that a specific block of code raises a specific exception — genuinely essential for testing your own error-handling logic, like the custom exceptions covered in an earlier article.
Why these give better failure messages than plain assert
# Plain assert — a failure just says "AssertionError", with no useful detail
assert add(2, 3) == 6
# self.assertEqual — a failure reports BOTH values automatically
self.assertEqual(add(2, 3), 6)
# AssertionError: 5 != 6unittest's assertion methods automatically include both the expected and actual values in a failure message, without you needing to write that detail into an error message yourself — a genuinely practical advantage over a bare assert statement, which by default gives you nothing beyond "this condition was false."
Setup, Teardown, and Test Fixtures
setUp() and tearDown()
import unittest
class Stack:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop()
class TestStack(unittest.TestCase):
def setUp(self):
self.stack = Stack() # runs before EVERY test method in this class
def test_push(self):
self.stack.push(1)
self.assertEqual(self.stack.items, [1])
def test_pop(self):
self.stack.push(1)
self.stack.push(2)
self.assertEqual(self.stack.pop(), 2)setUp() runs automatically before every single test method in the class — here, creating a fresh Stack instance each time, so test_push and test_pop each start from a genuinely clean, independent state, with no risk of one test's leftover data accidentally affecting another. tearDown(), defined the same way, runs automatically after each test method, useful for cleanup — closing a file, removing a temporary directory, disconnecting from a test database.
Why this matters: test isolation
Without setUp() recreating a fresh Stack for every test, if test_push ran before test_pop and they shared the same Stack instance, test_pop's result could depend on whatever test_push happened to leave behind — a genuine, common source of flaky, order-dependent tests. setUp() guarantees every test method starts from the same known, clean state, regardless of what order the tests happen to run in.
Skipping and expected-failure decorators
import unittest
class TestExamples(unittest.TestCase):
@unittest.skip("Not implemented yet")
def test_future_feature(self):
self.assertTrue(False)
@unittest.expectedFailure
def test_known_bug(self):
self.assertEqual(1, 2) # known to fail, tracked but not blocking the suite@unittest.skip(reason) marks a test as deliberately skipped — genuinely useful for a test that's temporarily inapplicable, perhaps because the feature it covers hasn't been implemented yet. @unittest.expectedFailure marks a test that's currently known to fail, without that failure blocking your overall test suite from reporting success — useful for tracking a known, already-filed bug without letting it hide new, unrelated failures among the noise of an already-expected one.
A Simpler Alternative: pytest
What changes with pytest
# test_add.py
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2pip install pytest
pytestWith pytest, there's no class to subclass at all — a plain function named test_*, living in a file named test_*.py, is automatically discovered and run. pytest also lets you go back to using plain assert statements, but it rewrites them internally to produce detailed, informative failure output (showing the actual values involved) — giving you the readability benefit of unittest's assertEqual without needing the special method names at all.
pytest fixtures
import pytest
class Stack:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
@pytest.fixture
def stack():
return Stack() # a fresh Stack, provided to any test that requests it
def test_push(stack): # 'stack' is automatically injected by pytest
stack.push(1)
assert stack.items == [1]@pytest.fixture is a more flexible, more reusable alternative to setUp()/tearDown() — any test function that includes a parameter matching a fixture's name automatically receives that fixture's return value, injected directly by pytest. Fixtures can also be shared across multiple test files by placing them in a special file named conftest.py, letting common setup logic live in exactly one place rather than being duplicated across every test module that needs it.
@pytest.mark.parametrize
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, -1, -2),
(0, 0, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expectedThis single test function runs four separate times, once for each tuple of parameters — pytest reports each combination as its own individual pass or fail. This is a genuinely notable ergonomic win over unittest, where testing several input/output pairs against the same logic typically requires either a manual loop inside one test method (which reports all cases as a single pass/fail, losing per-case granularity) or the more awkward subTest() context manager. @pytest.mark.parametrize avoids copy-pasting near-identical test methods entirely, while still reporting each case individually.
Choosing Between unittest and pytest, and Best Practices
Practical guidance
unittest needs zero installation — it's part of the standard library, genuinely fine for small scripts, simple projects, or situations where adding any third-party dependency at all is undesirable. pytest scales considerably better for real, growing projects, thanks to fixtures, parametrize, more readable assertion failures out of the box, and a much larger plugin ecosystem — coverage reporting, enhanced mocking helpers, timeout enforcement, and many others, all building on top of pytest's core.
A gradual migration path, not an all-or-nothing choice
Worth knowing: pytest can run existing unittest.TestCase-based tests directly, without modification. This means adopting pytest doesn't require rewriting an existing unittest-based test suite from scratch — you can start running your existing tests through pytest immediately, and write any new tests using pytest's more ergonomic plain-function style, migrating older tests over gradually (or never, if they're working fine as-is) rather than facing an all-or-nothing decision.
Best practices to close on
Structure tests with the Arrange-Act-Assert (AAA) pattern. This is a widely followed convention for organizing the inside of an individual test, regardless of which framework you're using:
def test_stack_push():
# Arrange — set up whatever the test needs
stack = Stack()
# Act — perform the actual operation being tested
stack.push(1)
# Assert — verify the result
assert stack.items == [1]This structure makes tests genuinely easier to read at a glance — a reader immediately knows which part is setup, which part is the actual behavior under test, and which part is the verification, rather than needing to parse an undifferentiated block of code to figure out which lines serve which purpose.
Keep each test focused on one behavior. A test that checks five unrelated things at once makes it genuinely harder to tell, from a failure alone, exactly what actually broke — smaller, focused tests give you more precise, more immediately actionable failure information.
Use unittest.mock to isolate a test from external dependencies. unittest.mock (part of the standard library, and usable with either unittest or pytest) lets you replace a real dependency — a database call, an API request — with a fake, controllable stand-in, so your unit test verifies your own code's logic without actually depending on a real database or network connection being available and correctly configured every time the test runs:
from unittest.mock import patch
def get_user_data(api_client):
return api_client.fetch("user123")
def test_get_user_data():
with patch("__main__.api_client") as mock_client:
mock_client.fetch.return_value = {"name": "Alex"}
result = get_user_data(mock_client)
assert result == {"name": "Alex"}This is a large enough topic to deserve its own dedicated treatment later in this series — for now, it's worth knowing unittest.mock exists specifically to keep your unit tests genuinely unit tests: fast, reliable, and testing your own logic in isolation, rather than accidentally depending on external systems that could be slow, flaky, or simply unavailable when your test suite runs.