The discipline that distinguishes a script from a tool. You add three or more pytest tests to one of your prior labs and write a README that lets a stranger run it.
Theme
A tool with no tests is a tool you have to manually re-verify every time you change it. A tool with no README is a tool only you can run. Both are the difference between "I have a thing that works on my machine" and "I have a thing somebody else can use."
pytest is the standard testing framework for Python. The stdlib has unittest (older, more ceremony); the third-party pytest (more flexible, less boilerplate) has won the practitioner mindshare. Almost every Python project of any size uses pytest.
The discipline of testing has two parts. First: what to test. The course's rule for FND-102: the happy path; the most obvious edge case; one regression test for a bug you found. That is three tests; that is the minimum bar. Real production code has dozens to hundreds; the discipline scales but the principle does not change.
Second: what a test looks like. A pytest test is a function named test_* that asserts something. No class boilerplate, no setUp / tearDown unless you need it. The simplicity is the point.
The week's lab takes one of your prior labs (Lab 6 / Lab 9 / Lab 11 / Lab 12 are good candidates) and adds three or more tests plus a README. By the end of the week your chosen tool is shippable: a stranger can clone, read the README, install dependencies, run the tests, and use the tool.
By the end of week 13 you can: write a pytest test function; use assert for any predicate; use fixtures for setup/teardown; use pytest.raises for expected exceptions; structure a project's tests/ directory; write a README that answers what / install / run.
Reading list (~1 hour)
- Matthes, Python Crash Course 2nd ed., Ch 11 ("Testing Your Code"). Matthes uses
unittest; the structural points (test isolation, edge cases) translate to pytest. Read for the why; ignore the unittest-specific syntax. - pytest documentation: "Get Started" at
https://docs.pytest.org/en/stable/getting-started.html. ~15 min. The official tutorial; reads in 15 minutes. Read it. - pytest documentation: "Fixtures" at
https://docs.pytest.org/en/stable/explanation/fixtures.html. ~20 min. Fixtures are pytest's killer feature; this explanation is worth careful reading. - Real Python: "Effective Python Testing With Pytest" at
https://realpython.com/pytest-python-testing/. ~30 min. Worked examples, parametrize, fixtures, common patterns. - GitHub: "About READMEs" at
https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes. ~10 min. The README's job and conventions. - A good README example. Open one of:
https://github.com/psf/requests/blob/main/README.md,https://github.com/pallets/flask/blob/main/README.md. Notice the structure; you will write something similar.
Lecture outline (~1.5 hours, 2 sessions of ~50 min)
Session 1: pytest
Section 1.1: Setup
- Install pytest:
python3 -m pip install --user pytest
- Verify:
python3 -m pytest --version
Section 1.2: A first test
- File:
test_temperature.py:from temperature import f_to_c def test_f_to_c_freezing(): assert f_to_c(32) == 0 def test_f_to_c_boiling(): assert f_to_c(212) == 100
- Run:
pytest
- pytest discovers files named
test_*.pyand*_test.py, and functions namedtest_*inside them. No registration, no class structure required. Run; see the results. - Output:
============================= test session starts ============================== collected 2 items test_temperature.py .. [100%] ============================== 2 passed in 0.01s ===============================
Section 1.3: assert for anything
asserttakes a Boolean expression. If it is False, the test fails with the expression's value reported.def test_list_sum(): assert sum([1, 2, 3]) == 6 assert [1, 2, 3].count(2) == 1 assert 'hello'.startswith('hel')
- Add a message for clearer failure output:
assert result == 42, f'expected 42, got {result}'
- pytest's assertion rewriting shows you the values of sub-expressions in the failure output. Most other test frameworks need
assertEqual(a, b); pytest just usesassert a == band gives you the same diagnostic.
Section 1.4: Test discovery and organization
- Tests live in a
tests/directory next to your source:my-tool/ ├── tool.py └── tests/ └── test_tool.py
- Or alongside the source (acceptable for small projects):
my-tool/ ├── tool.py └── test_tool.py
- Run
pytestfrom the project root; it walks the tree and finds alltest_*.py. pytest --collect-onlyshows what pytest WOULD run without actually running. Useful for debugging "why is my test not being picked up?"
Section 1.5: Fixtures
- A fixture is a function that pytest calls before your test, passing the result as an argument:
import pytest from pathlib import Path @pytest.fixture def sample_log(tmp_path): """Create a sample log file in a temp directory; return its path.""" log = tmp_path / 'sample.log' log.write_text('INFO ok\nERROR fail\nWARNING slow\nERROR retry\n') return log def test_scan_counts_errors(sample_log): from scanner import scan errors = list(scan(sample_log)) assert len(errors) == 2
tmp_pathis a built-in pytest fixture that gives you a fresh temp directory per test. After the test, pytest cleans up.- Fixtures compose: a fixture can use other fixtures.
- Common built-in fixtures:
tmp_path,tmp_path_factory,monkeypatch,capsys,caplog.
Section 1.6: Expected exceptions
- For "this function should raise an exception":
import pytest def test_negative_raises(): with pytest.raises(ValueError): square_root(-1) def test_error_message(): with pytest.raises(ValueError, match='must be non-negative'): square_root(-1)
pytest.raisesis a context manager. The test passes if the block raises the expected exception; fails otherwise.
Section 1.7: Parametrize
- For "the same test against several inputs":
@pytest.mark.parametrize('fahrenheit, celsius', [ (32, 0), (212, 100), (98.6, 37.0), (-40, -40), ]) def test_f_to_c(fahrenheit, celsius): assert f_to_c(fahrenheit) == pytest.approx(celsius)
- Each tuple becomes a separate test case. The failure report names which case failed.
pytest.approx(...)is the float-comparison helper: handles "0.1 + 0.2 != 0.3" floating-point fuzz correctly.
Session 2: READMEs
Section 2.1: What a README does
- A README answers three questions:
- What is this? (one paragraph)
- How do I install it? (commands a stranger can copy)
- How do I use it? (one or two examples)
- That is the minimum. Optionally:
- What does it require? (Python version, OS, dependencies)
- How do I test it? (
pytest) - Contributing? (how to file issues, send PRs)
- License?
Section 2.2: The five-section template
# Project Name
One-paragraph description. What does this tool do? Who is it for? What
problem does it solve?
## Install
```bash
git clone https://github.com/user/repo.git
cd repo
python3 -m pip install --user -r requirements.txt
Usage
python3 mytool.py --input data.txt --top 10
One paragraph of explanation. What does --input mean? What does the
output look like?
Tests
pytest
License
MIT (or whatever).
- Five sections; ~150 words total. That is enough.
- A README this short is easy to keep up-to-date. A long README rots; a short one stays accurate.
**Section 2.3: What NOT to put in a README**
- API reference for every function. (That belongs in docstrings.)
- Long design discussion. (That belongs in a separate DESIGN.md or your team's wiki.)
- Marketing copy ("revolutionary," "industry-leading"). (Belongs nowhere.)
- Outdated install instructions. (Worse than no instructions.)
**Section 2.4: Examples in the README**
- One concrete worked example per command is the right amount.
- Show the actual output (or a stylized abbreviation). The stranger reading your README wants to know what success looks like.
- For options with non-obvious values: explain inline. "`--top 10`: show the top 10 most common words."
**Section 2.5: `requirements.txt`**
- A `requirements.txt` lists your project's third-party dependencies, one per line:
requests>=2.20 pytest>=7.0
- `pip install -r requirements.txt` installs everything.
- For FND-102: your projects probably need only `requests` and `pytest`. A two-line `requirements.txt` is enough.
- Forward-pointer to `pyproject.toml`, which is the modern packaging file. CSA-101 toolchain uses pyproject.toml; for FND-102 the simpler `requirements.txt` works.
## Labs (~90 minutes)
**Lab 13: Tests + README** (`labs/lab-13-tests-readme.md`)
- Goal: take ONE prior lab (Lab 6, Lab 9, Lab 11, or Lab 12) and add three or more pytest tests plus a README; commit
- Time: ~90 minutes
- Artifact: `tests/` directory plus `README.md` for the chosen prior lab
## Independent practice (~4 hours)
1. **First test (30 min).** Pick the SIMPLEST function in your prior labs (say, a helper that converts bytes to a hex digest). Write three tests: happy path, edge case (empty input), known value (verify the hash of a known string matches what `sha256sum` says).
2. **Refactor for testability (60 min).** Take a function that does I/O (reads a file, prints, calls an API). Refactor it so the I/O is at the EDGE of the function and the LOGIC is in the middle. Test the logic; mock or fixture the I/O. Concrete example:
```python
# Before (hard to test)
def report_top_errors(log_path):
with open(log_path) as f:
lines = f.readlines()
errors = [l for l in lines if 'ERROR' in l]
print(f'Found {len(errors)} errors')
# After (easy to test)
def filter_errors(lines):
return [l for l in lines if 'ERROR' in l]
def report_top_errors(log_path):
with open(log_path) as f:
lines = f.readlines()
errors = filter_errors(lines)
print(f'Found {len(errors)} errors')
# Test:
def test_filter_errors():
assert filter_errors(['INFO ok', 'ERROR bad', 'WARN slow']) == ['ERROR bad']
-
Parametrize practice (30 min). Take any function with multiple expected inputs/outputs. Write ONE test with
@pytest.mark.parametrizeand 5-10 cases. Compare with the version that writes 5-10 separate test functions. -
Fixture practice (45 min). Use
tmp_pathto test a function that writes a file. The test creates a tmp directory; calls the function; reads the output; asserts. pytest cleans up the temp directory automatically. -
README practice (45 min). Take one of your prior labs (any of 1-12) and write a 5-section README for it. Have a peer (or yourself the next day) read it and try to run the tool. Adjust based on what was unclear.
-
pytest --cov(30 min, optional stretch). Installpytest-cov(pip install pytest-cov); runpytest --cov=mymodule. The output tells you which lines your tests do NOT exercise. Useful for finding "I tested the happy path but forgot the error branch" gaps.
Reflection prompts (~30 minutes)
- Before this week, did you test your code at all? If so, how (manual? print debugging? assertions in
__main__?). What did formalized tests add? - The "refactor for testability" exercise (practice 2) usually changes how you structure code: I/O at the edge, logic in the middle. Did your existing labs follow this structure, or did you have to refactor?
pytest.raiseslets you test "this function should fail this way." Did you have any prior-lab function that raised an exception you should have tested? Add the test.- A 150-word README forces brevity. What did you cut from your first draft that the stranger reader did not need?
- One thing from this week you want to know more about?
Tool journal (week 13)
pytest: the standard testing frameworkpytestdiscovery rules:test_*.py,test_*functionsassertfor any predicate (with assertion rewriting)pytest.raises(Exception): expected-exception testing@pytest.mark.parametrize: same test, many inputstmp_path,tmp_path_factory,monkeypatch,capsys,caplog: built-in fixturespytest.approx: float comparisonpytest --cov: coverage report (optional)- README: what / install / use / tests / license, ~150 words
What comes next
Week 14 is the capstone workshop. You pick a real automation task from your own life, sketch the scope in a 10-minute meeting with the instructor, then spend the rest of the week building it. The capstone presentation block (outside the per-week budget) follows; you give a 5-minute demo and answer Q&A from the cohort. The course's last piece.