You will learn: Make pytest discover the intended project, select one test precisely, classify where a run failed, and use rich assertion evidence to repair the earliest useful cause.
Practice in: A local src project and terminal; a notebook can reproduce direct assertions but not the complete discovery and process workflow
A good assertion is useful only if the test runner can find it, import the intended package, create its dependencies, and report the observation clearly. This lesson turns the small Meteor Watch checks into a real project and treats pytest output as structured debugging evidence.
Keep these questions visible:
How does pytest turn directories, files, classes, functions, and parameter cases into collected node IDs?
Which commands select exactly the evidence you need without hiding context?
Did the run fail during collection, setup, the test call, or teardown?
What do the traceback, assertion explanation, captured output, and summary each contribute?
How can you repair one failure without allowing later noise to lead you away from the earliest cause?
The src layout makes an important mistake visible: importing code merely because the current directory happens to contain a package. Install the project into its virtual environment and let tests import it by its public package name.
testpaths tells pytest where ordinary suite discovery begins. -ra adds a short summary for outcomes such as skips and expected failures. Configuration should make the ordinary command predictable; it should not hide warnings or force a maze of plugins.
Place the public function in src/meteor_watch/risk.py:
def risk_level(wind_kph):"""Return low, medium, or high for a non-negative wind speed."""if wind_kph <0:raiseValueError("wind must be non-negative")if wind_kph <40:return"low"if wind_kph <70:return"medium"return"high"
Re-export it in src/meteor_watch/__init__.py:
from .risk import risk_level__all__= ["risk_level"]
Create and activate an environment using the Unit 10 instructions for your platform, then run:
python-m pip install -e .python-m pytest -q
The editable install means changes under src/ are visible without rebuilding a wheel. It does not mean “import any file near the terminal.” Confirm the interpreter and imported location when results are surprising:
WarningDo not repair an import error by editing a correct assertion
If collection cannot import meteor_watch, pytest has not called the test. Check the active interpreter, editable installation, package directory, and module spelling before changing expected values.
2. Follow discovery from a path to a node ID
With default conventions, pytest searches configured directories for files named test_*.py or *_test.py. It then collects test functions and methods whose names begin with test. A class can group tests when its name begins with Test and it does not define its own constructor.
Ask pytest to show the collection without running the bodies:
python-m pytest --collect-only-q
Representative output:
tests/test_import.py::test_installed_package_exposes_risk_leveltests/test_risk.py::test_calm_wind_is_lowtests/test_risk.py::test_forty_begins_mediumtests/test_risk.py::TestHighRisk::test_seventy_begins_high4 tests collected in 0.02s
Each :: adds another collected level. The complete string is a node ID. It is both a location and a precise selector:
Pytest first discovers candidates and imports modules. Only successfully collected nodes can enter setup, call, and teardown.
flowchart LR
A["Configured test path"] --> B["Matching test files"]
B --> C["Import each module"]
C -->|"import succeeds"| D["Collect classes and functions"]
C -->|"import fails"| E["Collection error"]
D --> F["Stable node IDs"]
F --> G["Run selected nodes"]
Diagnose a test that never appears
This function is valid Python but is not collected:
Rename it test_medium_boundary. Do not add a manual call at the bottom of the file; that would run during import and blur collection with execution.
Likewise, risk_checks.py is not a conventional test filename. Discovery conventions make a suite predictable for maintainers and tools. A custom pattern is possible, but changing configuration to accommodate one accidental name is usually less clear than naming the file test_risk.py.
3. Select enough evidence, not more noise
Use the broadest command that still answers the current question:
Do nodes that failed in the previous run now pass?
python -m pytest -vv
Which full node IDs and parameter IDs are running?
-k matches collected names and parents; it is not a text search through source. Quote complex expressions in the shell, such as -k "boundary and not negative".
-x reduces noise while debugging the earliest failure. It is not a final verification command. After the focused node turns green, run its file and then the complete suite. A local repair may expose a second failure or accidentally break another contract.
For a collected test, pytest can run three phases:
setup obtains fixtures and prepares the test world;
call executes the test function; and
teardown releases fixture-managed resources.
An assertion mismatch during the call phase is reported as a failure. An unexpected exception during fixture setup or teardown is usually reported as an error. Collection errors happen earlier, before those phases exist.
The report phase narrows the search. Editing the classifier cannot repair a misspelled fixture or a module that never imported.
flowchart LR
A["Collection"] --> B["Setup"] --> C["Call"] --> D["Teardown"]
A -->|"import or syntax problem"| E["Collection error"]
B -->|"fixture cannot prepare"| F["Setup error"]
C -->|"assertion is false"| G["Test failure"]
C -->|"unexpected exception"| H["Call error evidence"]
D -->|"cleanup fails"| I["Teardown error"]
Pytest can collect the function, but setup cannot find station_reccord. The report includes fixture 'station_reccord' not found and lists available fixtures. The production parser never ran. Rename the argument or define the intended fixture; do not change parsing code.
By contrast, this test reaches the call and observes a wrong result:
============================= test session starts ==============================platform linux -- Python 3.12.4, pytest-9.1.1rootdir: /work/meteor-watch-labconfigfile: pyproject.tomlcollected 1 itemtests/test_risk.py F [100%]=================================== FAILURES ===================================________________________ test_forty_begins_medium _________________________ def test_forty_begins_medium(): observed = risk_level(40)> assert observed == "medium"E AssertionError: assert 'low' == 'medium'EE - mediumE + lowtests/test_risk.py:11: AssertionError=========================== short test summary info ============================FAILED tests/test_risk.py::test_forty_begins_medium - AssertionError: ...============================== 1 failed in 0.06s ===============================
Annotate it:
session header: interpreter, pytest version, root directory, and selected configuration identify the environment;
collection: one item confirms the intended node existed;
failure heading and node: identify the scenario;
source excerpt and >: show the exact expression that was false;
E lines: show the compared values and pytest’s explanation;
location: points to the test assertion, from which the call can be traced;
short summary: collects every non-passing node for navigation; and
duration/result: confirms the run ended with one failure rather than a collection interruption.
The report does not order you to change low to medium in production. It states the observation. The interval contract decides the correct repair: change <= 40 to < 40.
6. Let plain assertions produce useful diffs
Pytest rewrites assertions as test modules are imported, retaining information about subexpressions. Use direct comparisons so that information stays visible.
A misspelled suffix produces a character-level diff. Avoid replacing this with assert "HIGH" in observed unless the rest of the format truly does not matter.
Use pytest.raises as a context around only the action expected to fail:
import pytestdef parse_wind(text):try: wind =float(text)exceptValueErroras error:raiseValueError(f"invalid wind: {text!r}") from errorif wind <0:raiseValueError("wind must be non-negative")return winddef test_parse_wind_rejects_non_numeric_text():with pytest.raises(ValueError, match="invalid wind") as error_info: parse_wind("east")assert"'east'"instr(error_info.value)
Code after the call belongs outside the with block. If you place another operation inside, an exception from that operation can satisfy a broad context and create false confidence.
Use a narrow message regular expression. User-specific values can be checked through error_info.value. Do not assert the entire traceback or platform path. Those are report details rather than the exception contract.
8. Compare numeric results with an explicit tolerance
pytest.approx is asymmetric in how it treats expected and observed values, but ordinary use reads naturally:
Choose a tolerance from the domain or numeric operation, not merely a large number that makes red disappear. If the requirement is “display one decimal place,” test the formatted string separately from the numeric calculation.
9. Use skip and expected failure deliberately
Sometimes a test cannot run in a known environment, or a documented defect is temporarily accepted. Pytest can represent those states:
An expected failure can use @pytest.mark.xfail(reason="issue 42"). Add strict=True when an unexpected pass should force review rather than silently remaining XPASS. These marks communicate a specific condition; they are not a bin for inconvenient failures. A failing boundary test whose contract is current should remain red until repaired.
10. Repair the earliest useful failure
Use this repeatable sequence:
run --collect-only if discovery or import is uncertain;
run with -x to stop at the earliest selected failure;
identify collection, setup, call, or teardown;
state one hypothesis that explains the exact report;
run the smallest node or collection command capable of testing it;
change one cause;
rerun the focused evidence;
rerun the file; and
rerun the complete suite from a clean process.
Changing production and expected values together destroys the red/green evidence. Likewise, deleting a test because it fails is not a repair unless the contract has intentionally changed and that decision is recorded.
Create a disposable copy of the Meteor Watch project. Add four faults, one in each category:
in tests/test_import.py, import meteor_wotch to create a collection error;
in one collected test, request a nonexistent weather_record fixture;
change the 40-kph comparison in production from < 40 to <= 40;
write one expected dictionary with the wrong note.
Your task is not to repair all four immediately. Produce an evidence record:
Run
Phase
Earliest useful line
Hypothesis
One change
Result
1
collection
…
…
…
…
2
setup
…
…
…
…
3
call
…
…
…
…
4
call
…
…
…
…
Use --collect-only, -x, an exact node ID, and finally the complete suite. Annotate at least one report with the environment, node, source line, compared values, and summary.
Hint 1: zero collected items changes the first question
If module import fails, no fixture or assertion in that file can run. Correct the import spelling and rerun collection before reasoning about wind values.
Hint 2: distinguish fixture lookup from the test call
A missing fixture is setup evidence. Pytest lists available fixtures. Make the argument name match an existing fixture or add the small fixture the test actually needs.
Hint 3: do not change a contract-derived expected value
For the 40-kph failure, compare the production condition with 0 <= wind < 40. For the dictionary failure, compare the expected note with the supplied input. Only one side should contradict the contract in each case.
Show a completed diagnosis sequence
One valid order is:
--collect-only -q exposes ModuleNotFoundError: meteor_wotch; correct the import and confirm nodes collect.
-x exposes fixture 'weather_record' not found; correct the requested fixture and rerun that node.
The next -x run reaches test_forty_begins_medium and reports low versus medium; restore < 40 and rerun the exact node.
The dictionary test shows only the note differs; correct whichever side contradicts its written input/contract.
Run python -m pytest from a fresh process and retain the complete green summary.
The important artifact is the phase-based explanation, not merely four edited lines.
Key points
A src layout plus editable installation makes import assumptions visible.
Discovery creates stable node IDs from matching files, classes, functions, and later parameter cases.
Select a suite, file, node, keyword, or previous failure based on the question you are answering.
Collection, setup, call, and teardown evidence point to different causes.
Plain assertions let pytest display useful scalar, text, sequence, and mapping differences.
pytest.raises and pytest.approx express exception and numeric contracts precisely.
Skip and xfail marks should state a real condition, never hide an unexplained failure.
Repair one cause, rerun the focused evidence, then verify the complete suite.