pytest dominates professional Python testing, but unittest ā zero dependencies, built into every Python installation ā remains genuinely relevant: for small scripts that shouldn't need an extra dependency, and because a huge amount of existing, older Python code is written against it. This lesson covers it on its own terms.
1A Class-Based Structure, With Its Own Assertion Vocabulary
Where pytest tests are plain functions using ordinary assert, unittest requires every test to be a *method* on a class inheriting from unittest.TestCase ā TestSquare(unittest.TestCase), with test_positive_number(self) and test_negative_number(self) as methods on it. This structural requirement is unittest's core design, modeled on the xUnit testing pattern common across many languages (JUnit for Java, being the most well-known ancestor).
unittest.TestCase provides a family of specific assertion methods ā self.assertEqual(a, b), self.assertTrue(x), self.assertIn(item, container), self.assertRaises(ExceptionType), and many more ā each producing a tailored failure message specific to the kind of comparison being made, achieving similar diagnostic value to pytest's assertion rewriting, but through an explicit vocabulary of methods rather than plain assert statement instrumentation.
Knowing this vocabulary, even in a pytest-dominated professional environment, matters concretely for two reasons: reading and maintaining existing codebases (a significant amount of real, older Python code ā and code from teams that specifically prefer the standard library's zero-dependency guarantee ā is written against unittest), and for genuinely small, standalone scripts where adding pytest as a dependency is disproportionate to the script's actual needs.
import unittest
def square(x: int) -> int:
return x * x
class TestSquare(unittest.TestCase):
def test_positive_number(self):
self.assertEqual(square(4), 16)
def test_negative_number(self):
self.assertEqual(square(-3), 9)Methods, not functions ā with self.assertEqual(), not plain assert
2setUp/tearDown: unittest's Answer to Fixture-Style Setup
setUp(self), defined on a TestCase subclass, runs automatically *before every single test method* in that class ā precisely the same 'fresh setup per test' guarantee pytest's default function-scoped fixtures provide, expressed through unittest's class-based mechanism instead of pytest's parameter-injection approach. tearDown(self), the symmetric counterpart, runs after every test method, whether it passed or failed ā the same reliable-cleanup guarantee yield-based pytest fixtures provide.
The structural difference worth internalizing: pytest fixtures are independently reusable functions that any test can request by naming as a parameter, composable and shareable flexibly across many different test files and classes. setUp/tearDown are tied specifically to one TestCase class ā every test method within that specific class automatically gets the same setup, with no equivalent to naming *which* fixtures a specific test needs, since setUp runs unconditionally for the whole class.
This is a genuine trade-off: unittest's approach is simpler to reason about for a single, self-contained test class (there's exactly one setup/teardown pair to understand), while pytest's fixture composition scales more flexibly to complex test suites needing different combinations of setup across many different test functions and files ā part of why pytest ultimately won out for larger, more complex professional codebases despite unittest's structural simplicity for smaller cases.
class TestDatabaseOperations(unittest.TestCase):
def setUp(self):
self.db = create_test_database() # runs before EVERY test method
def tearDown(self):
self.db.close() # runs after EVERY test method
def test_insert(self):
self.db.insert({"id": 1})
self.assertEqual(self.db.count(), 1)Run before/after EVERY test method in the class, automatically
3Zero-Dependency Test Discovery
python -m unittest discover, run from a project's root, automatically finds and executes every TestCase-based test in the project ā genuinely zero third-party dependencies required, since unittest ships as part of every standard Python installation. This is a real, concrete advantage for specific situations: a small, standalone script or utility that doesn't otherwise need a pyproject.toml full of dependencies, an environment where adding even one additional package is genuinely constrained (some highly locked-down enterprise or embedded environments), or simply verifying that a codebase's tests can run in the most minimal possible environment with nothing beyond a bare Python interpreter.
This zero-dependency property connects directly back to the Dependency Management module: every dependency a project adds is something that needs to be declared, locked, and kept updated ā unittest's built-in nature means choosing it specifically eliminates one dependency (and, transitively, pytest's own dependencies) from that maintenance surface entirely, a genuine, if often minor, simplification.
For the overwhelming majority of professional, larger codebases, pytest's ergonomic advantages (covered in the pytest fundamentals lesson) outweigh this dependency-minimization benefit, which is precisely why pytest, not unittest, is the de facto professional standard this curriculum has focused on throughout the rest of this section. But knowing unittest well enough to read, write, and reason about it confidently remains a genuinely useful, practical skill ā for legacy code, for specific small-script situations, and simply as part of understanding the full landscape of Python's testing tools rather than only one corner of it.
$ python -m unittest discover
........
----------------------------------------------------------------------
Ran 8 tests in 0.045s
OK
# No pip install needed -- unittest ships with Python itselfComplete test discovery and execution ā zero pip installs required
4Step-by-Step Breakdown
Every Python installation has a complete testing framework built in, no pip install required. Let's know it well enough to read and write it confidently.
unittest tests are METHODS on a class inheriting from unittest.TestCase -- structurally different from pytest's plain functions.
Checkpoint: What must a unittest test class inherit from for its test_ methods to be discovered and run?
- āunittest.TestCase
- āNothing ā any plain class works, exactly like pytest
setUp() and tearDown() are unittest's equivalent of a pytest fixture -- run automatically before/after EVERY test method in the class.
Checkpoint: How often does setUp() run, relative to the test methods in a TestCase class?
- āOnce before EVERY individual test method in the class, giving each test a fresh instance
- āOnce total, before the first test method in the class runs
python -m unittest discover runs an entire test suite with ZERO third-party dependencies -- built directly into every Python installation.
That completes Python Testing ā pytest fundamentals, fixtures, mocking, parameterization, coverage, and now the standard library's own native option. Next, Packaging & Distribution covers shipping the code you've been testing.
Run a Real unittest Suite. Finish run_suite(): unittest is Python's own built-in test runner, no install needed.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Know unittest well enough to read and maintain existing codebases written against it
A meaningful amount of real, existing Python code ā especially older or dependency-conscious codebases ā is written with unittest, and being unable to read it fluently is a genuine practical gap.
Reach for unittest specifically for small, standalone scripts where adding pytest is disproportionate
unittest's zero-dependency, built-in nature is a real, concrete advantage in specific situations where minimizing dependencies matters more than pytest's ergonomic improvements.
Frequent Bugs
Forgetting that unittest test methods must be defined on a class inheriting from unittest.TestCase, writing a plain function expecting it to be discovered and run the way a pytest test would be.
Ensure every unittest test method is defined inside a class inheriting from unittest.TestCase ā unlike pytest, unittest's discovery mechanism specifically requires this class-based structure.
Real-World Examples
Maintaining an Existing unittest-Based Legacy Test Suite
A team inherits a legacy codebase whose existing test suite is written entirely in unittest, and needs to add new tests consistent with the existing style before considering any migration to pytest.
import unittest
class TestInventoryManager(unittest.TestCase):
def setUp(self):
self.inventory = InventoryManager()
def test_add_item_increases_count(self):
self.inventory.add_item("widget", quantity=5)
self.assertEqual(self.inventory.get_count("widget"), 5)
def test_remove_item_decreases_count(self):
self.inventory.add_item("widget", quantity=5)
self.inventory.remove_item("widget", quantity=2)
self.assertEqual(self.inventory.get_count("widget"), 3)
if __name__ == "__main__":
unittest.main()