Introduction to Python Testing
Learn how to test Python code with unittest or pytest, write useful first tests, run them, and troubleshoot common failures.
Python testing means checking whether a piece of code produces the behavior you expect for specific inputs and conditions. Start with the standard-library unittest module if you want no test-framework dependency; choose pytest for concise test functions, ordinary assert statements, fixtures, and automatic discovery. A passing test suite is evidence for the cases it covers, not proof that the program has no defects.
Write and run your first test
Suppose your project has this small module:
# mymodule.py
def add(a, b):
return a + b
With pytest, save the following as test_math.py:
from mymodule import add
def test_add_two_numbers():
assert add(2, 3) == 5
From the project directory, install pytest in the active Python environment and run it:
python -m pip install -U pytest
python -m pytest
Pytest discovers files named test_*.py or *_test.py in the current directory and its subdirectories. A test function name should start with test_. Check the pytest getting-started guide for current version and Python compatibility details.
Choose unittest or pytest
| Consideration | unittest |
pytest |
|---|---|---|
| Installation | Included in Python’s standard library. | Third-party package, installed in the project environment. |
| Basic test style | TestCase subclasses, test methods, named assertion methods. |
Functions and ordinary assert statements with detailed failure output. |
| Setup and cleanup | setUp, tearDown, and other fixture scopes. |
Fixtures requested by test functions, including temporary-directory fixtures. |
| Existing test suite | Run with the standard library’s test runner. | Can run many unittest tests, which can help with a gradual transition. |
Pick the framework already used by your project when possible. For a small learning exercise, pytest’s function style is compact. If avoiding dependencies matters or your project already uses unittest, keep using it. You can also try pytest as a runner for many existing unittest tests before changing their style. In unittest.TestCase classes, pytest fixture arguments and parametrization do not work like they do for plain pytest functions.
Write the same test with unittest
Save this as test_math.py alongside mymodule.py:
import unittest
from mymodule import add
class AddTests(unittest.TestCase):
def test_add_two_numbers(self):
self.assertEqual(add(2, 3), 5)
if __name__ == "__main__":
unittest.main()
Run it with either command:
python -m unittest
python -m unittest test_math.py
Test methods must start with test. setUp() runs before each test method, and tearDown() is for cleanup after each method. Python creates a separate test-case instance for each test method. Write tests so they can run independently and in any order; avoid relying on another test to prepare state.
Structure tests around behavior
A useful test usually follows four steps: arrange the inputs and needed state, act by calling one behavior, assert the observable result, and clean up any state that could affect another test. Cleanup is not needed in every test, and this pattern is a guide rather than a rigid form.
from mymodule import add
def test_add_handles_zero():
# Arrange
left, right = 0, 7
# Act
result = add(left, right)
# Assert
assert result == 7
Cover ordinary inputs, meaningful boundaries, and expected errors. Assert behavior users or callers can observe where practical, rather than implementation details that can change without changing the outcome. Keep file, database, network, clock, and other external state controlled so the same test can be repeated. Use fixtures or mocks where they make setup clearer and more reliable.
Test boundary cases and errors
Tests are most useful when they express the expected contract, including what should happen for invalid inputs. For example, if a function must reject a negative quantity, make that rule explicit in both code and test:
# inventory.py
def units_in_stock(total, reserved):
if total < 0 or reserved < 0:
raise ValueError("quantities must be non-negative")
return total - reserved
# test_inventory.py
import pytest
from inventory import units_in_stock
def test_units_in_stock_subtracts_reserved():
assert units_in_stock(10, 3) == 7
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="non-negative"):
units_in_stock(-1, 0)
The equivalent unittest error check uses assertRaises:
import unittest
from inventory import units_in_stock
class InventoryTests(unittest.TestCase):
def test_negative_total_is_rejected(self):
with self.assertRaisesRegex(ValueError, "non-negative"):
units_in_stock(-1, 0)
Do not add speculative edge cases just to increase test count. Focus on boundaries and failure conditions that follow from the function’s intended contract.
Use fixtures for repeatable setup
When tests need shared setup, pytest fixtures make the dependency explicit in the test function arguments. For file-based tests, pytest’s temporary path fixture provides a temporary directory that is cleaned up after the test:
def test_write_and_read_file(tmp_path):
path = tmp_path / "note.txt"
path.write_text("hello", encoding="utf-8")
assert path.read_text(encoding="utf-8") == "hello"
In unittest, use setUp() for per-test preparation and tearDown() for cleanup. Keep fixtures narrow: state shared by many tests can hide dependencies and make failures harder to diagnose.
Organize and discover tests
Follow your repository’s existing layout and test-runner configuration rather than forcing one directory structure. A small project can keep test_math.py beside the code; another may place tests in a tests/ directory. Use clear module and function names, and verify that your chosen command actually collects them.
- With pytest, the default filename patterns are
test_*.pyand*_test.py; functions and methods are collected when named with atestprefix. - With unittest, use
TestCasesubclasses and methods beginning withtest.python -m unittestperforms test discovery; a specific module can be named on the command line. - Keep tests in a location that can import the project code under the same environment and working directory used by your team or build job.
Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| No tests collected | Filename or function does not match discovery naming, or command runs from an unexpected directory. | Use a supported test filename and test_ name; run from the project root and inspect the collection output. |
ModuleNotFoundError importing your module |
The test runner’s environment or working directory cannot see the project package. | Activate the intended environment, run from the repository root, and use the project’s established package/install setup. |
pytest: command not found |
pytest is not installed in the active environment, or its script directory is not on the path. | Install with python -m pip install -U pytest and invoke with python -m pytest. |
| A test passes alone but fails in the suite | It depends on state left by another test, ordering, shared files, or external resources. | Make setup local to the test, clean up changes, and isolate external state. |
| Expected exception assertion fails | The code raises a different exception, raises before the assertion context, or does not raise on that input. | Check the contract and exact call inside the context; assert the intended exception type and only match a message when it matters. |
| pytest fixture argument fails in a TestCase method | pytest does not inject fixtures into unittest-style methods the same way it does into plain pytest functions. | Use a plain pytest test function or set up the resource through unittest’s fixture methods. |
Speed, reliability, and cost
For the basic examples here, test execution is local and has no per-run service charge; the costs are the time to write and maintain tests and any dependencies you choose to add. unittest is available without installing a test framework. pytest adds a package to the project environment. Keep the environment reproducible and record dependencies using the project’s normal dependency-management process.
Fast, isolated tests are easier to run often. Avoid unnecessary network calls and uncontrolled clocks or files. When tests need external resources, make their setup and cleanup explicit and distinguish them from quick unit-level checks so a slow or unavailable service does not obscure a local logic failure. A passing suite only supports the behaviors and cases the tests actually exercise; add cases when the code’s contract or observed failures call for them.
Or skip the browser setup
If a Python project also needs website screenshots, ScreenshotNeo provides a screenshot API and MCP server. A single GET request can return PNG, JPEG, WebP, or PDF output. Here is a Python call using the documented API pattern; replace the URL with the page you need and provide your API key:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
See the ScreenshotNeo API documentation for request options and response details. Cookie banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, no card required.
FAQ
Does testing require a separate test directory?
No. Follow the project’s layout and runner configuration. Separate test modules can be convenient, but a single required layout does not fit every repository.
Can I use pytest with tests written for unittest?
pytest can run many unittest test cases. Its fixture arguments and parametrization are most straightforward with plain pytest functions, so mixed projects should use each style’s setup conventions.
How many tests should I write?
There is no universal count. Write enough tests to cover the important behavior, boundaries, and expected failures in the code’s contract, then add cases when changes or bugs reveal gaps.
Does a green test run mean the code is correct?
No. It means the collected tests passed for the conditions they exercised. Unspecified cases and defects can remain.


