pytest vs unittest: Which Python Test Framework Should You Use?

For most new Python projects, choose pytest: plain assertions, composable fixtures, separate parameterized cases, markers, and plugins keep tests concise. Choose unittest when the standard library must be enough or an existing codebase already follows its TestCase conventions. Do not rewrite a working unittest suite first. Run it with pytest, then migrate only where pytest features help.
If you would rather not hand-write the tests at all, Qodex writes and runs API, UI and security tests for your Python project on every pull request.
pytest vs unittest at a glance.
| Decision point | pytest | unittest |
|---|---|---|
| Dependency | Install a third-party test framework | Included in Python's standard library |
| Test shape | Plain test functions or classes, using Python assert | TestCase classes, test methods, and named assertion methods |
| Setup and cleanup | Requested fixtures that can depend on other fixtures and use function, class, module, package, or session scope | setUp() and tearDown() around each method, plus class and module variants |
| Repeated cases | @pytest.mark.parametrize creates a separately collected item for every argument set | subTest() keeps a loop inside one test method and records each subtest context |
| Failure information | Assertion rewriting exposes values and context from plain asserts | Named methods such as assertEqual and assertRaises, including type-specific assertions |
| Discovery and selection | test_*.py and *_test.py; select paths, node IDs, names with -k, or markers with -m | test*.py; select modules, classes, methods, or fully qualified names with -k |
| Extension model | Registered custom markers and plugins for jobs such as parallel execution, coverage, and coroutine tests | Skip and expected-failure decorators in the standard library, without pytest's general custom-marker model |
| Existing suite | Can collect existing TestCase tests, with documented limits | No migration needed if its lifecycle and reporting meet the team's needs |
One baseline, stated once. The pytest documentation covers the pytest 9 line, whose changelog lists 9.1.1 as the latest release, and pytest 9 needs Python 3.10 or newer, or PyPy 3. The unittest documentation is served for Python 3.14.7, where unittest ships inside the standard library.
pytest vs unittest: the same test in both
Feature lists do not settle this. The same test written twice does. Here is one pricing function and two suites that cover it identically: the same tax rate, the same two data rows, the same rejected negative subtotal.
The three files sit in one directory, and both commands run from that directory, so from pricing import quote resolves. This is one matched example implemented in two frameworks, not two different examples.
# pricing.py
def quote(subtotal: float, tax_rate: float) -> float:
if subtotal < 0:
raise ValueError("subtotal must be non-negative")
return round(subtotal * (1 + tax_rate), 2)
The unittest version keeps state on the instance. setUp puts the tax rate on self before every test method, and the two data rows are a loop inside one method. Each iteration is wrapped in subTest, so the output can name the row that failed.
# test_pricing_unittest.py
import unittest
from pricing import quote
class TestQuote(unittest.TestCase):
def setUp(self):
self.tax_rate = 0.20
def test_totals(self):
cases = [(100, 120), (12.5, 15)]
for subtotal, expected in cases:
with self.subTest(subtotal=subtotal):
self.assertEqual(quote(subtotal, self.tax_rate), expected)
def test_rejects_negative_subtotal(self):
with self.assertRaises(ValueError):
quote(-1, self.tax_rate)
if __name__ == "__main__":
unittest.main()
python -m unittest -v test_pricing_unittest.py
The pytest version asks for what it needs. tax_rate is a fixture, and a test gets it by naming it as an argument, so the dependency sits in the signature, not on self. The two data rows move out of the loop into @pytest.mark.parametrize, which calls the test once per argument set. The equality check is a plain assert.
# test_pricing_pytest.py
import pytest
from pricing import quote
@pytest.fixture
def tax_rate():
return 0.20
@pytest.mark.parametrize(
"subtotal,expected",
[(100, 120), (12.5, 15)],
)
def test_totals(subtotal, expected, tax_rate):
assert quote(subtotal, tax_rate) == expected
def test_rejects_negative_subtotal(tax_rate):
with pytest.raises(ValueError, match="non-negative"):
quote(-1, tax_rate)
python -m pytest -q test_pricing_pytest.py
The counts differ, and that is the point. unittest reports two passing test methods, with two successful subtests inside test_totals. pytest reports three passing items, because each parameter row is collected as its own test. Same behavior covered, different unit of reporting.
Both suites isolate one function, with no database, no HTTP client, and no fixture data to load. If that boundary is what you are still deciding, the guide to what a unit test isolates covers it.
Fixtures vs setUp and tearDown
Follow one test call from setup to cleanup and the two models separate cleanly.
In pytest, a test requests a fixture by naming it as an argument. Fixtures can request other fixtures, so setup composes instead of accumulating. A fixture can be reused by many tests and can be scoped to a function, class, module, package, or session, which is how an expensive resource gets created once. A fixture written with yield puts setup before the yield and cleanup after it, in one function.
@pytest.fixture(scope="module")
def tax_rate():
yield 0.20
# cleanup runs here, after the last test in the module
That snippet illustrates scope and cleanup; it is not a second runnable suite.
In unittest, a new TestCase instance is created for each test method. setUp() runs before the method, and if setup succeeded, tearDown() runs whether the method passed or failed. Shared resources get setUpClass and tearDownClass, or setUpModule and tearDownModule.
| Lifecycle question | pytest fixtures | unittest setUp and tearDown |
|---|---|---|
| Dependency visibility | Named in the test signature | Written onto self, read inside the method |
| Reuse and composition | Reusable across tests, and a fixture can request other fixtures | Shared through the class and module hooks |
| Scope | Function, class, module, package, or session | Per method, plus class and module hooks |
| Cleanup | After yield, in the same function as the setup | tearDown, separate from setUp |
The practical difference is composition. A pytest fixture is an explicit dependency that other fixtures can build on, while setUp() writes state onto self and applies to every method in the class. That reading is an inference from the two APIs, not a benchmark.
parametrize vs subTest
Both handle one behavior with several inputs. They differ in what counts as a case.
@pytest.mark.parametrize invokes the test once for each argument set. Every row is collected separately, so each one can carry its own ID and its own mark, such as xfail for a row you know is broken. Fixture parameters and pytest_generate_tests give two further levels of parameterization when the rows themselves need building.
subTest() keeps the loop inside one TestCase method. Each context records the parameters you pass it, a failed iteration does not stop the ones after it, and the output identifies the values that failed. From the outside it is still one test method.
The rule follows from that. Use parametrize when a row needs its own identity, because you want to select it on the command line, see it in the report, or mark it as expected to fail. Use subTest when the cases belong inside a method that already exists. Neither model is better in general; they report at different granularity, and that reading is an editorial call based on how they execute.
Test discovery and selection
Put the rules side by side. This is where a migration first goes wrong.
With no arguments, pytest starts from configured testpaths or the current directory. It recurses into directories unless a directory matches norecursedirs, and in those directories it collects test_*.py and *_test.py files. Inside a file it collects test-prefixed functions and methods, methods in Test-prefixed classes that have no __init__, and unittest.TestCase subclasses. For selection it accepts directories, file names, and node IDs, plus -k for a name expression and -m for a marker expression.
For unittest, python -m unittest is the equivalent of python -m unittest discover, which starts from the current directory. The default file pattern is test*.py, and the files have to be importable from the top-level directory, which is the rule that bites when a test package is missing. A TestLoader finds TestCase classes and their test-prefixed methods. The command line takes modules, classes, and individual methods, and -k matches the fully qualified test name by substring, or by wildcard when the pattern contains an asterisk. Discovery itself is steered by -s for the start directory, -p for the file pattern, and -t for the top-level directory.
| Discovery question | pytest | unittest |
|---|---|---|
| Starting point | testpaths, else the current directory | The current directory, via discover |
| File pattern | test_*.py and *_test.py | test*.py, changed with -p |
| What is collected | test-prefixed functions and methods, Test classes without init, TestCase subclasses | TestCase classes and their test-prefixed methods |
| Selection | Paths, node IDs, -k, -m | Modules, classes, methods, -k |
Assertions and failure output
pytest rewrites plain assert statements before they execute. When one fails, the report can show the values behind it: results of calls, attributes, comparisons, and unary and binary operators, with context-aware differences for common data structures. You write assert quote(100, 0.2) == 120 and still get introspection, without a special method for every comparison.
unittest takes the other route. TestCase supplies named methods, assertEqual, assertRaises, and a long list of type-specific assertions, and each one knows how to describe its own failure. That vocabulary is more verbose to write and entirely stable to read, and it is why the same failure looks different in the two runners rather than better or worse.
So the difference to weigh is how a failing test reads at three in the morning, not whether one framework reports errors and the other does not.
Markers, plugins, coverage, parallel runs, and async tests
A marker attaches metadata to a test. pytest ships several: usefixtures, filterwarnings, skip, skipif, xfail, and parametrize. Register custom markers in the configuration file, select them with -m, and turn on strict marker validation so a misspelled mark errors instead of silently matching nothing. Marks apply to tests, and have no effect on fixtures. unittest has decorators for skipping and for expected failures, but not this general custom-marker and -m selection model.
Markers also open the plugin ecosystem. This page covers three of them.
pytest-xdist sends tests to worker processes. With -n auto it uses as many processes as the machine has physical CPU cores, and a number such as -n 8 fixes the count.
pip install pytest-xdist
pytest -n auto
Treat the speedup as something you measure, not something you get. Parallel runs are also how a suite discovers it depended on order or shared state, a reliable source of flaky tests.
pytest-cov 7.1.0 adds coverage.py measurement and reporting to a pytest run. It handles combining coverage data, and it works with xdist as long as every worker has the plugin installed.
pip install pytest-cov
pytest --cov=myproj tests/
A percentage from that run says which lines executed. It does not say the assertions were worth making, which is the distinction in the guide to test coverage techniques.
pytest-asyncio lets pytest execute coroutine test functions, so a test can await application code. Tests carry @pytest.mark.asyncio. Its documentation is explicit that standard unittest subclasses are not supported and points those users at unittest.IsolatedAsyncioTestCase, which matters in a half-migrated suite.
Run an existing unittest suite under pytest, then migrate safely
Yes, pytest runs an existing unittest suite. That is the fact that makes this a low-risk decision rather than a rewrite.
pytest tests
pytest collects unittest.TestCase subclasses and their test methods from test_*.py or *_test.py files. It supports the skip and skipIf decorators, setUp and tearDown, the class and module hooks, and subTest since pytest 9.0. The documented gap is the load_tests protocol, which pytest does not implement.
There is a second boundary, inside the class. Marks such as skip, skipif and xfail work on a TestCase, autouse fixtures work, and @pytest.mark.usefixtures works. Ordinary fixture arguments, @pytest.mark.parametrize, and custom pytest hooks do not, by design. Third-party plugins may or may not behave. Full pytest behavior starts when a test stops inheriting from TestCase and becomes a plain function or class.
That gives a sequence with a working suite at every step.
Pin pytest and change no tests. Add it to the development and CI dependencies, run
python -m pytest tests, and keep that first result as the baseline.Resolve collection differences. Rename files only where the pattern requires it, and replace any
load_testscustomization, since pytest does not implement it.Take the free benefits. Failure output, output capture,
-kselection, debugging flags, and an optional xdist run all arrive without converting a single class.Add shared setup carefully. Where pytest setup helps inside a TestCase, use an autouse fixture or
@pytest.mark.usefixtures. Do not try to inject a normal fixture argument into a TestCase method.Convert one class at a time. Only after the inheritance is gone should
setUpstate become fixture arguments andsubTestloops become@pytest.mark.parametrize. Keep behavior and data cases identical, so a red test means the conversion broke something.Add plugins last. Register custom markers and bring in coverage or parallel execution once the base suite is stable, which keeps plugin failures separate from migration failures.
The one-class-at-a-time order is risk control, not a documented requirement. There is also no honest number to attach to it, because nothing sourced says how long a migration takes or how many defects it prevents.
In the CI pipeline the change is one line. Before:
python -m unittest discover -s tests -p 'test*.py'
After:
python -m pytest -q
Where those commands sit in the wider pipeline, next to build and deploy stages, is covered in continuous API testing in DevOps pipelines.
How Qodex adds API execution to the pull request
That CI command covers the repository's own Python tests: the code your team wrote, on the runner your team configured. That is the whole of its job.
Follow one small change to an endpoint through a pull request and you can see where that stops. The Python suite runs first, and whichever framework you picked prints its failure in its own format. Useful, and bounded: if those are unit tests, nothing in the run has called the running endpoint the way a client will.
That second job is API testing, and it is what Qodex adds to the same pull request. It is not a Python unit-test runner. It starts from an OpenAPI spec, a Postman collection, a spreadsheet, or a sentence describing the flow. The generated scenarios include auth and role boundaries, so a cross-org read that should return 403 is a case rather than an afterthought, and they run against the pull request's own preview, as well as on demand, on a schedule, from CI, from a deploy hook, or from any webhook.
Then the evidence, which is what decides whether anyone trusts the gate. A failure comes back with the failing request, the response, and a screenshot, so nobody reproduces it by hand. Each one is classified: a real bug, a stale test whose repair is proposed as a diff you approve, or an environment issue that is flagged and not counted. The tests are standard Playwright and HTTP code, parameterized per environment and synced to git, and a replay runs that code with no model call, so the same failure can be reproduced later.
Run API scenarios on every pull request with Qodex.
Which framework should your team choose?
Scale does not decide this. NumPy moved from nose to pytest and asks that all its tests use pytest, and the pandas contributor guide runs its suite with pytest pandas. Django's unit tests use standard-library unittest, with django.test.TestCase subclassing unittest.TestCase and discovery finding test*.py. CPython has its own python -m test runner and uses python -m unittest for a single case. Four large codebases, two answers.
| Situation | Choose |
|---|---|
| New project, no constraints | pytest |
| Standard-library-only dependency policy | unittest |
| Established Django convention that works | unittest, on the django.test.TestCase base |
| Data-heavy suite, many input rows | pytest, with parametrize |
| Coroutine-heavy suite | pytest with pytest-asyncio, or IsolatedAsyncioTestCase if the suite stays on unittest |
| Large legacy unittest suite | pytest as the runner first, not a rewrite |
| Mixed suite, migration under way | pytest, converting one class at a time |
What follows the runner is the harder question: how the suite is structured, what it asserts, and where it runs. That is the subject of API test framework design.
Pick the framework that fits the repository you actually have, then prove the choice with readable failures, stable CI execution, and a migration you control.
Frequently Asked Questions
Is pytest better than unittest for a new Python project?
For most new projects, yes. Plain assertions, composable fixtures, parameterized cases, markers, and plugins keep the suite short. Two exceptions hold: a dependency policy that allows only the standard library, and a codebase whose conventions already sit on TestCase and cause nobody any pain.
Can pytest run existing unittest tests?
Yes. Run pytest tests. pytest collects unittest.TestCase subclasses and their methods from test_*.py or *_test.py files, and supports setUp, tearDown, the class and module hooks, skip decorators, and subTest since pytest 9.0. The documented exception is the load_tests protocol, which pytest does not implement.
Can pytest fixtures be used inside unittest.TestCase?
Partly. Marks such as skip, skipif and xfail work, autouse fixtures work, and @pytest.mark.usefixtures works. Passing a normal fixture as an argument to a TestCase method does not, and neither does parametrize or a custom hook. Full fixture use starts once the TestCase inheritance is gone.
What is the difference between pytest.mark.parametrize and subTest?
parametrize calls the test once per argument set and collects each row as a separate item, so a row can be selected, reported, and marked on its own. subTest keeps the iterations inside one test method, records each context, and continues after a failure is recorded.
Is pytest faster than unittest?
There is no honest answer either way. No controlled benchmark supports a speed claim for either runner, so this page does not make one. pytest-xdist can spread tests across worker processes, with -n auto using the physical CPU cores, but that speedup is something you measure on your own suite.
Does Django use pytest or unittest?
Django 6.1 documents standard-library unittest. Its own unit tests use that module, django.test.TestCase subclasses unittest.TestCase, and the default runner discovers files named test*.py. If your project already follows that convention and it causes nobody any pain, there is no reason to move.
Can pytest and unittest coexist in one repository?
Yes. pytest collects existing TestCase tests alongside plain pytest tests, which is what makes an incremental migration possible. Inside those TestCase classes the limits apply: no fixture arguments, no parametrize, no custom hooks, no load_tests, and third-party plugins that may or may not cooperate.
Is pytest only for unit testing, and is it tied to TDD or BDD?
pytest runs integration and end-to-end suites too, and it runs unittest-style tests, so the runner does not fix the level of the test. On methodology, we found no source tying either framework to test-driven or behaviour-driven development, so this page does not claim one. The runner and the development method are separate choices.





