Unit Testing in Python with pytest: A Beginner's Guide
A beginner's guide to pytest built on a small real module: writing tests as plain functions with assert, reading pytest's failure reports, parametrising edge cases, testing that errors are raised, and using fixtures, tmp_path and monkeypatch so tests never touch real files or settings.
GloryTecks Team
Training & Placement Faculty
Last updated: 25 September 2026
Most people test their code by running it and looking at the output. That works once. It does not tell you, three months later, whether a small change broke a calculation in a corner you did not look at. Automated tests do: short functions that call your code with known inputs and check the results, run in seconds, every time anything changes. pytest is the tool most Python projects use to write and run them (pytest: get started).
The code under test
A small pricing module, the kind of logic that must be right every time:
# pricing.py
from decimal import Decimal, ROUND_HALF_UP
def apply_discount(price: Decimal, percent: int) -> Decimal:
"""Return the price after a percentage discount, rounded to paise."""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100, got {percent}")
discounted = price * (100 - percent) / 100
return discounted.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def gst(amount: Decimal, rate_percent: int = 18) -> Decimal:
return (amount * rate_percent / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)Your first tests
A pytest test is a function whose name starts with test_, in a file whose name starts with test_. It checks results with a plain assert:
# test_pricing.py
from decimal import Decimal
import pytest
from pricing import apply_discount, gst
def test_ten_percent_off():
assert apply_discount(Decimal("1499.00"), 10) == Decimal("1349.10")
@pytest.mark.parametrize(
("price", "percent", "expected"),
[
("100.00", 0, "100.00"),
("100.00", 100, "0.00"),
("99.99", 33, "66.99"),
],
)
def test_discount_edges(price, percent, expected):
assert apply_discount(Decimal(price), percent) == Decimal(expected)
def test_rejects_impossible_percent():
with pytest.raises(ValueError, match="between 0 and 100"):
apply_discount(Decimal("100"), 120)
def test_gst_on_discounted_price():
assert gst(apply_discount(Decimal("1000"), 10)) == Decimal("162.00")Run pytest in the project folder. It finds the test files, runs every test function, and reports:
$ pytest -q
...... [100%]
6 passed in 0.03sFour test functions produced six results, because the parametrised test ran once for each of its three cases (pytest: parametrize). Each dot is a passing test.
What each test is doing
test_ten_percent_offis the typical case: known input, known answer.test_discount_edgescovers the boundaries, 0% and 100%, plus a rounding case.parametrizeturns a table of inputs into separate tests, so adding a case is one line, and a failure names the exact case.test_rejects_impossible_percentchecks that bad input fails loudly.pytest.raisespasses only if the block raises that exception, andmatchchecks its message too.test_gst_on_discounted_pricechecks two functions working together.
Good tests are small, independent and specific: each checks one behaviour, and none depends on another having run first.
Reading a failure
Suppose someone "fixes" the expected rounding and writes 67.00 instead of 66.99. pytest's report shows exactly what went wrong (pytest: assertions):
___________ test_discount_edges[99.99-33-67.00] ___________
price = '99.99', percent = 33, expected = '67.00'
def test_discount_edges(price, percent, expected):
> assert apply_discount(Decimal(price), percent) == Decimal(expected)
E AssertionError: assert Decimal('66.99') == Decimal('67.00')
E + where Decimal('66.99') = apply_discount(Decimal('99.99'), 33)The test ID names the failing case, the > marks the failing line, and the E lines show the actual value, the expected value and the call that produced it. That detail comes from plain assert statements; pytest rewrites them to report intermediate values, so there is no need for special assertion methods. Here the test was wrong, not the code: 99.99 Γ 0.67 is 66.9933, which rounds to 66.99.
Fixtures: shared setup, and no real files
A fixture is a function that provides something a test needs, such as sample data, a temporary folder or a database connection. A test requests it by naming it as a parameter (pytest: fixtures). pytest also ships built-in fixtures: tmp_path is a fresh temporary directory for each test, and monkeypatch changes environment variables or attributes for one test and undoes the change afterwards (pytest: monkeypatch).
# test_reports.py
import pytest
from reports import report_email, write_summary
@pytest.fixture
def sample_rows():
return [{"region": "south", "total": 1650}, {"region": "west", "total": 900}]
def test_summary_file_has_header_and_rows(tmp_path, sample_rows):
path = write_summary(sample_rows, tmp_path)
assert path.read_text(encoding="utf-8").splitlines() == ["region,total", "south,1650", "west,900"]
def test_email_comes_from_environment(monkeypatch):
monkeypatch.setenv("REPORT_EMAIL", "finance@example.com")
assert report_email() == "finance@example.com"
def test_email_default(monkeypatch):
monkeypatch.delenv("REPORT_EMAIL", raising=False)
assert report_email() == "reports@example.com"$ pytest -q
......... [100%]
9 passed in 0.03sThe file test writes into a temporary folder instead of the project, and the environment tests cannot leak a setting into other tests or into your shell. write_summary and report_email are two short functions in a reports.py module: one writes a CSV with csv.DictWriter, the other reads an environment variable with a default.
Running tests efficiently
A few command-line options make the daily loop fast:
pytest -xstops at the first failure, useful when one broken thing causes many failures.pytest -k discountruns only tests whose names contain "discount".pytest --lfreruns only the tests that failed last time, so you can fix and recheck quickly.pytest -vlists every test by name with its result, instead of dots.
Run the whole suite before every commit, and let a continuous integration service run it again on every push, so nobody merges code with failing tests.
Test names are documentation
A test's name appears in every report, so make it describe the behaviour: test_rejects_impossible_percent tells a reader what the code must do before they read a line of the test. When a suite is named this way, the list of tests doubles as a specification of the module, and a failure message explains itself.
What to test first
- Calculations and rules where a wrong answer costs money or trust: prices, taxes, eligibility.
- Boundaries: empty input, zero, maximum values, the first and last day of a month.
- Error handling: invalid input should raise the error you intend, with a clear message.
- Bugs you have fixed: write a test that reproduces the bug before fixing it, so it can never return.
Code that talks to networks, databases or the clock is easier to test when those are passed in as arguments rather than created inside the function; then a test can pass a fake. Keep tests in a tests/ folder next to your package, as described in how to structure a Python project, and run them on every change, locally and in continuous integration.
pytest or unittest?
How many tests are enough?
Where do I put fixtures that several test files use?
Should tests hit the real database or API?
Tests are the safety net for everything else in Python best practices, and they pair naturally with the error handling guide for testing failure paths. Unit testing basics, debugging and writing clean Python code form the testing and best practices module of the Python Programming course.
Sources
- Get Started (pytest) β pytest documentation
- How to parametrize fixtures and test functions β pytest documentation
- How to write and report assertions in tests β pytest documentation
- How to use fixtures β pytest documentation
- How to monkeypatch/mock modules and environments β pytest documentation
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master Python?
Join GloryTecksβ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.