Skip to content

Module 1: pytest fundamentals

In Module 0 you got one test passing. Now we’ll understand pytest itself: how it finds your tests, why a plain assert is all you need, how to run exactly the tests you want, and the two helpers — pytest.raises and pytest.approx — you’ll reach for constantly. Everything here is grounded in TestMarket Lab, so it already feels like real test work.

Keep TestMarket Lab running at http://localhost:3000 (from Module 0) and your .venv active for the app-based examples.

🎬 Video coming soon

Module 1: pytest at a glance — slide guide Slides — opens in a new tab

A pytest test is a plain Python function whose name starts with test_. Inside it, you make a claim with assert. If the claim holds, the test passes; if not, it fails.

tests/test_basics.py
def test_addition():
assert 1 + 1 == 2
def test_string_upper():
name = "playwright"
assert name.upper() == "PLAYWRIGHT"

No classes to inherit, no special assert methods to memorize — just functions and assert. That simplicity is a big part of why pytest is the SDET standard.

Remember: a pytest test is a function named test_* that makes a claim with assert — no boilerplate, no base class.


When you run pytest, it walks the folder looking for tests using simple naming rules:

KindRule
Filestest_*.py or *_test.py
Functionsstart with test_
Classes (optional grouping)start with Test (and have no __init__)
tests/test_discovery_demo.py
def test_this_runs(): # discovered: starts with test_
assert True
def helper_not_a_test(): # ignored: no test_ prefix
return 42
class TestProducts: # discovered: class starts with Test
def test_inside_a_class(self):
assert helper_not_a_test() == 42

This is exactly why the Module 0 smoke test had to be test_smoke.py with a function named test_products_endpoint_is_up — anything off-pattern is silently skipped, which is the most common “why did no tests run?” gotcha.

Remember: files test_*.py/*_test.py, functions test_*, classes Test*. Off-pattern names are skipped silently — if a test “doesn’t run,” check the name first.


In some frameworks you write assertEqual(a, b) or assertTrue(x). pytest rewrites the plain assert statement so that when it fails, it shows you both sides. Watch what a failure looks like:

tests/test_failure_demo.py
def test_intentional_failure():
expected = 5
actual = 2 + 2
assert actual == expected

Running it prints the values, not just “assertion failed”:

def test_intentional_failure():
expected = 5
actual = 2 + 2
> assert actual == expected
E assert 4 == 5
tests/test_failure_demo.py:5: AssertionError

You instantly see 4 == 5 is the problem. You can add a message for extra context, shown after the expression:

assert actual == expected, f"got {actual}, expected {expected}"

Remember: pytest’s rewritten assert shows both sides on failure (assert 4 == 5), so you rarely need special assert methods. Add , "message" only for extra context.


A quick tour, with the kinds of checks API tests are full of:

tests/test_assert_styles.py
def test_equality():
assert 2 + 2 == 4
def test_membership():
roles = ["customer", "admin"]
assert "admin" in roles
assert "guest" not in roles
def test_truthiness():
products = [1, 2, 3]
assert products # non-empty list is truthy
assert not [] # empty list is falsy
def test_comparisons():
price = 29.99
assert price > 0
assert 0 < price < 1000

Remember: lean on Python’s own operators — ==, in / not in, truthiness (assert products), and chained comparisons (0 < price < 1000). pytest reports all of them clearly.


Sometimes the correct behavior is to raise an error. You assert that with pytest.raises as a context manager — the test passes only if the block raises the expected exception type:

tests/test_exceptions.py
import pytest
def test_bad_int_conversion_raises():
with pytest.raises(ValueError):
int("not a number")
def test_can_inspect_the_error_message():
with pytest.raises(ValueError) as exc_info:
int("nope")
assert "invalid literal" in str(exc_info.value)

If the block doesn’t raise, the test fails — which is what you want when you’re verifying that invalid input is rejected.

Remember: with pytest.raises(ExceptionType): asserts the block raises that error; capture it as exc_info to assert on the message. No raise = failed test.


Floating-point math is inexact, so 0.1 + 0.2 == 0.3 is False in Python. Prices and totals are floats, so compare them with pytest.approx:

tests/test_floats.py
import pytest
def test_float_math_needs_approx():
assert 0.1 + 0.2 == pytest.approx(0.3) # passes
# assert 0.1 + 0.2 == 0.3 # would FAIL
def test_order_total():
# 29.99 + 89.99 + 24.99
assert 29.99 + 89.99 + 24.99 == pytest.approx(144.97)

Remember: never compare computed floats with ==; use == pytest.approx(value) for prices, totals, and any arithmetic result.


pytest runs everything by default, but you’ll constantly want to run less. The flags that matter day to day:

Terminal window
pytest # run all tests
pytest -v # verbose: one line per test with PASSED/FAILED
pytest -q # quiet: compact output
pytest tests/test_basics.py # just one file
pytest tests/test_basics.py::test_addition # one specific test
pytest -x # stop at the first failure
pytest -k "product" # only tests whose name matches the expression

-k takes a substring expression and supports and / or / not:

Terminal window
pytest -k "product and not slow" # names containing 'product' but not 'slow'
pytest -k "login or register"

A typical debugging loop is pytest -x -k "the_thing_im_fixing" — stop on the first failure, and only consider the tests you care about.

Remember: -v (verbose), -q (quiet), -x (stop on first failure), -k "expr" (filter by name, supports and/or/not), and file::test to target one test. pytest -x -k … is the everyday debug loop.


Putting it together against TestMarket Lab

Section titled “Putting it together against TestMarket Lab”

These tests hit the real app — make sure TestMarket Lab is running. They’re deliberately small; Module 3 goes deep on API testing. The first one repeats the /api/products smoke check from Module 0 — a handy health check to keep at the top of an API test file.

tests/test_products_api.py
import requests
BASE_URL = "http://localhost:3000"
def test_products_returns_a_nonempty_list():
response = requests.get(f"{BASE_URL}/api/products")
assert response.status_code == 200
products = response.json()
assert isinstance(products, list)
assert len(products) > 0
def test_each_product_has_core_fields():
products = requests.get(f"{BASE_URL}/api/products").json()
assert products # guard: an empty list would pass the loop vacuously
for product in products:
assert "id" in product
assert "name" in product
assert "price" in product
def test_category_filter_returns_only_that_category():
response = requests.get(
f"{BASE_URL}/api/products", params={"category": "electronics"}
)
assert response.status_code == 200
products = response.json()
assert len(products) > 0
assert all(p["category"] == "electronics" for p in products)

Run only this file while you work on it:

Terminal window
pytest tests/test_products_api.py -v
tests/test_products_api.py::test_products_returns_a_nonempty_list PASSED
tests/test_products_api.py::test_each_product_has_core_fields PASSED
tests/test_products_api.py::test_category_filter_returns_only_that_category PASSED

Notice requests.get(url, params={...}) builds the query string (?category=electronics) for you — no manual string-joining.

Remember: real API tests are still just test_* functions with asserts — assert the status and the body shape (a list, the right fields, every item matching a filter). requests.get(url, params={...}) builds the query string for you.


Work in your python-sdet project with TestMarket Lab running. Add the files under tests/ and run them with pytest -v.

Write tests/test_discovery.py with: one function that runs and passes, one helper function that pytest ignores (wrong name), and one test grouped inside a Test-prefixed class. Run pytest tests/test_discovery.py -v and confirm only the two real tests appear.

Write a test that you expect to fail (e.g. assert 2 + 2 == 5). Run it and read the output — confirm pytest shows both sides (assert 4 == 5). Then add a failure message and re-run to see where it appears. Finally, fix the assertion so it passes.

In tests/test_helpers.py:

  • Assert that int("abc") raises ValueError, and that the message contains "invalid literal".
  • Assert that 0.1 + 0.1 + 0.1 == pytest.approx(0.3) (and confirm plain == 0.3 would fail).
  • Assert that the three prices 19.99 + 4.99 + 49.99 equal pytest.approx(74.97).

Exercise 4 — Run exactly what you want (5 min)

Section titled “Exercise 4 — Run exactly what you want (5 min)”

With several test files present, practice the filters:

  • Run a single test by its file::test path.
  • Use -k "product" to run only product-related tests.
  • Use -k "not product" to run everything except them.
  • Use -x to stop at the first failure (introduce a failing test to see it).

Exercise 5 — Extend the products API tests (15 min)

Section titled “Exercise 5 — Extend the products API tests (15 min)”

You already have the three tests from the worked example in tests/test_products_api.py (write that file first if you skipped ahead). Add two more to it — the category filter with a different value, and the search param you haven’t used yet:

  • GET /api/products?category=accessories → status 200 and every result has category == "accessories".
  • GET /api/products?search=keyboard → at least one result, and “Keyboard” appears in some product’s name. (Hint: requests.get(url, params={"search": "keyboard"}).)

Write a small helper get_json(path) that does requests.get(f"{BASE_URL}{path}").json() and reuse it across your tests to cut repetition. (In Module 2 we’ll turn this kind of setup into a proper pytest fixture.)


In Module 2 we tackle fixtures and parametrize — sharing setup (like a base URL or a freshly reset database) across tests, and running one test over many inputs without copy-paste.