The unittest module: the classic framework
You open an older codebase, and the tests don't look like the ones you just wrote: classes, inheritance from TestCase, checks via self.assertEqual instead of a plain assert. That's unittest — the testing framework from Python's standard library, in classic OOP style (familiar to anyone who's seen JUnit in Java). Most new projects pick pytest, but unittest hasn't gone anywhere and still shows up:
- In legacy code (the framework's been there since Python 2.1).
- In projects that ban external dependencies.
- In tests of the standard library itself.
Knowing the basics is useful: sooner or later you'll open someone else's repo with unittest and need to navigate it.
Basic structure
Python 3.13import unittest def add(a, b): return a + b class TestAddFunction(unittest.TestCase): def test_add_positive(self): self.assertEqual(add(3, 5), 8) def test_add_negative(self): self.assertEqual(add(-1, -1), -2) if __name__ == "__main__": unittest.main()
What differs from pytest:
- Tests live in a class that inherits from unittest.TestCase.
- Method names start with test_, same as pytest.
- Instead of plain assert, you use special methods: self.assertEqual(a, b) instead of assert a == b.
- unittest.main() at the end is the entry point for python test_file.py.
Run via plain python or python -m unittest:
python test_addition.py # or python -m unittest test_addition.py
The main assert methods
TestCase ships with many specialized assertions. Six come up daily:
Example of assertRaises via a context manager:
Python 3.13import unittest def divide(a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b class TestDivide(unittest.TestCase): def test_zero_division(self): with self.assertRaises(ValueError): divide(10, 0) def test_normal(self): self.assertEqual(divide(10, 2), 5)
setUp and tearDown
unittest has no fixtures: it has setUp() (runs before each test) and tearDown() (runs after each test, even if the test failed):
Python 3.13import unittest class TestUserStorage(unittest.TestCase): def setUp(self): self.users = {"id": 1, "name": "Anna"} def tearDown(self): self.users = None def test_has_name(self): self.assertIn("name", self.users) def test_has_id(self): self.assertEqual(self.users["id"], 1)

For each test the cycle setUp → test_X → tearDown restarts; that guarantees tests are independent. There are also setUpClass/tearDownClass for one-time-per-class setup, but those are special cases.
unittest vs pytest
pytest is more concise and more flexible, and is the default for new projects. But unittest is built in and needs no install; for scripts and stdlib tests that's a plus.
Mocks in unittest
unittest.mock (which we covered in the previous article) is part of the same module. The same @patch and Mock work in unittest tests exactly as in pytest. The test method just receives mock_* as a parameter after self:
Python 3.13import unittest from unittest.mock import patch class TestUserAPI(unittest.TestCase): @patch("requests.get") def test_get_user(self, mock_get): mock_get.return_value.json.return_value = {"id": 1} # ... testing code that uses requests.get
Understanding check
What is true about the unittest module?
Next (and last article in the testing module): how to measure coverage with pytest-cov and automate test runs with CI (using GitHub Actions as an example).
