Classroom Glossary Public page

Lab 13: Tests + README

1,029 words

~90 minutes. Pick ONE prior lab (6, 9, 11, or 12). Add three or more pytest tests. Write a five-section README. Ship it as if you would hand it to a stranger.


Goal: add tests/test_*.py with three or more passing tests AND a README.md to one of your prior labs. Commit both.

Estimated time: 90 minutes

Prerequisites: Week 13 lecture (pytest, README discipline). The chosen prior lab complete and working.


Setup

Pick which prior lab you will add tests + README to:

  • Lab 6 (log scanner with argparse + logging): rich enough; the scan generator + format_size-style helpers are testable
  • Lab 9 (du wrapper): testable parsing logic; the subprocess wrapping needs mocking (harder)
  • Lab 11 (integrity checker): excellent fit; hash_file, build_manifest, verify are all pure functions
  • Lab 12 (weather CLI): testable if you mock requests.get; harder than Lab 11

Lab 11 is the recommended pick for most students: the functions are pure (no I/O at the boundary), making them easy to test. The instructions below use Lab 11 as the worked example; substitute your chosen lab as needed.

Create the lab-13 directory and copy your chosen prior lab:

mkdir -p ~/fnd-102/lab-13
cd ~/fnd-102/lab-13
cp ../lab-11/lab-11-checker.py checker.py
mkdir tests

Install pytest if you haven't:

python3 -m pip install --user pytest

Part A: Write the first test (15 min)

tests/test_checker.py:

"""Tests for lab-11-checker (now copied to checker.py for lab-13)."""
import hashlib
import json
from pathlib import Path

import pytest

from checker import hash_file, build_manifest, verify, write_manifest, read_manifest


def test_hash_file_known_value(tmp_path):
    """The hash of 'hello' (no newline) is a known SHA-256 value."""
    f = tmp_path / 'data.bin'
    f.write_bytes(b'hello')
    expected = hashlib.sha256(b'hello').hexdigest()
    assert hash_file(f) == expected

Notice:

  • tmp_path is a pytest built-in fixture that gives a fresh temp directory.
  • hashlib.sha256(b'hello').hexdigest() lets us assert against the known correct value WITHOUT hardcoding the long hex string in the test.
  • from checker import ... assumes the test is run from ~/fnd-102/lab-13 (the project root); pytest's default is to add the test's parent directories to sys.path so this works.

Run:

cd ~/fnd-102/lab-13
pytest

If you see ModuleNotFoundError: No module named 'checker', you renamed the file from lab-11-checker.py to checker.py (good); pytest finds it because the current directory is on sys.path. If pytest does not find it, add an empty __init__.py to the test directory and an explicit conftest.py in the project root.


Part B: Write the second test (15 min)

Test the manifest-building function:

def test_build_manifest_three_files(tmp_path):
    """Building a manifest of a 3-file tree produces 3 entries with the right hashes."""
    (tmp_path / 'a.txt').write_text('apple')
    (tmp_path / 'b.txt').write_text('banana')
    (tmp_path / 'sub').mkdir()
    (tmp_path / 'sub' / 'c.txt').write_text('cherry')

    manifest = build_manifest(tmp_path)

    assert len(manifest) == 3
    assert 'a.txt' in manifest
    assert 'b.txt' in manifest
    assert 'sub/c.txt' in manifest
    assert manifest['a.txt'] == hashlib.sha256(b'apple').hexdigest()

The test covers:

  • Counting: 3 files become 3 manifest entries
  • Path handling: subdirectories are joined with / correctly (POSIX style)
  • Hashing: the manifest's hash matches a known reference

Run again; both tests should pass.


Part C: Write the third test (a regression test) (15 min)

A regression test guards against a bug that was found and fixed. Either find a real bug (try to break your verify function) or pick a known edge case:

def test_verify_detects_modified_added_deleted(tmp_path):
    """A modified, an added, and a deleted file are all detected by verify."""
    # Initial tree
    (tmp_path / 'a.txt').write_text('apple')
    (tmp_path / 'b.txt').write_text('banana')
    manifest_path = tmp_path / 'manifest.json'
    write_manifest(build_manifest(tmp_path), manifest_path)

    # Modify a.txt; add c.txt; delete b.txt
    (tmp_path / 'a.txt').write_text('apricot')
    (tmp_path / 'c.txt').write_text('cherry')
    (tmp_path / 'b.txt').unlink()
    # ALSO delete the manifest.json so verify does not see it
    manifest_path.rename(tmp_path.parent / 'manifest.json')

    result = verify(tmp_path, tmp_path.parent / 'manifest.json')

    assert 'a.txt' in result['modified']
    assert 'c.txt' in result['added']
    assert 'b.txt' in result['deleted']
    # ensure manifest.json itself is not somehow in the results
    assert 'manifest.json' not in result['added']

This test is intentionally exhaustive: it exercises all three change categories in one test. Some teams prefer one assertion per test (smaller failure surface); others prefer integration-style tests like this one (clearer "what scenario broke?"). Pick the style that fits your team.

Run; all three should pass.


Part D: Add a parametrized test (10 min)

For variety, add a parametrized test using @pytest.mark.parametrize:

@pytest.mark.parametrize('content, expected_hash', [
    (b'', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'),  # empty
    (b'a', 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'),  # single byte
    (b'hello', '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'),  # short string
])
def test_hash_file_various_contents(tmp_path, content, expected_hash):
    """Hash of various inputs matches known SHA-256 reference values."""
    f = tmp_path / 'data.bin'
    f.write_bytes(content)
    assert hash_file(f) == expected_hash

Run; you now have at least 6 test cases (3 from parametrize + 3 named tests). pytest reports each parametrize case separately.

Verify the known hashes by running echo -n 'hello' | sha256sum from the shell; the output should match the hex string.


Part E: Write the README (25 min)

README.md in ~/fnd-102/lab-13/:

# Directory Integrity Checker

A small CLI tool that computes SHA-256 hashes for every file in a directory
tree, saves them as a manifest, and later detects which files have been
modified, added, or deleted. Structurally similar to (but simpler than) AIDE
and Tripwire.

## Install

```bash
git clone https://github.com/YOUR-USERNAME/fnd-102.git
cd fnd-102/lab-13
python3 -m pip install --user -r requirements.txt

Requires Python 3.11+ and pytest (for tests; no third-party deps for the tool itself).

Usage

Write a manifest:

python3 checker.py manifest /path/to/tree --out manifest.json
# Wrote manifest with 47 files to manifest.json

Verify the tree against the manifest later:

python3 checker.py verify /path/to/tree --manifest manifest.json
# OK: all files match the manifest

# Or, after changes:
# CHANGES DETECTED:
#   MODIFIED  src/main.py
#   ADDED     src/new-file.py
#   DELETED   docs/README.md

Exit codes: 0 if clean, 1 if changes detected.

Tests

pytest

License

MIT (or whatever; if this is just a course artifact, no formal license needed; mention "course artifact for VCA-FND-102 at Virtus Cyber Academy").

Write `requirements.txt`:

pytest>=7.0

(That is it; the tool itself uses only the stdlib.)

---

## Part F: Verify the stranger experience (5 min)

Close your editor. Open a new terminal. `cd ~/fnd-102/lab-13`. Read the README from the top. Follow the install instructions; run the tests; run the tool. Does anything trip you up?

If it does, fix the README. The discipline: the README is the contract between you and your future self / a stranger.

---

## Part G: Commit your work (10 min)

```bash
cd ~/fnd-102/lab-13
git add checker.py tests/test_checker.py README.md requirements.txt
git commit -m "lab-13: add pytest tests + README to integrity checker

Six test cases covering hash_file, build_manifest, and verify (modified /
added / deleted detection). Five-section README that lets a stranger
clone, install, and run the tool. requirements.txt with pytest as the
only third-party dependency."

Expected output / artifact

  • ~/fnd-102/lab-13/checker.py (the tool, copied from your chosen prior lab)
  • ~/fnd-102/lab-13/tests/test_checker.py with ≥3 test functions (one parametrized counts as multiple cases)
  • ~/fnd-102/lab-13/README.md with the 5 sections (what / install / use / tests / license)
  • ~/fnd-102/lab-13/requirements.txt
  • All tests pass when you run pytest from ~/fnd-102/lab-13

What's the failure mode?

This lab's likely failure modes:

  1. Tests pass locally but fail in CI. Almost always a path issue: your test assumes a file location that differs in CI. Always use tmp_path for filesystem state in tests; never read or write to the working directory directly.
  2. README says one thing; code does another. Drift between docs and code. The fix is to RUN the README's commands as part of your test suite (a make verify target, or a CI job that exercises the README's install/run steps).
  3. Test takes 60 seconds. A test that hits the network, calls subprocess, or hashes a 100 MB file is too slow to run on every commit. Use pytest.mark.slow to tag it and skip by default; run weekly.
  4. pytest not installed in CI. requirements.txt solves this. Some teams use requirements-dev.txt for dev-only dependencies (pytest, ruff); split if your team's convention says so.

Common pitfalls

  • import checker fails with ModuleNotFoundError. Either pytest is being run from the wrong directory, or checker.py is not on sys.path. Run pytest from the project root; ensure there is no conflicting conftest.py interfering.
  • A test passes for the wrong reason. A test that assert True always passes; that does not mean the code under test works. Make sure each test actually exercises the function under test.
  • Mutable shared state between tests. A test that writes to a module-level list leaves state for the next test. Use fixtures + tmp_path to give each test fresh state.
  • README installs that do not work. Test them on a fresh machine (or fresh container) before merging. The "works on my machine" anti-pattern lives in READMEs as much as in code.

Stretch (optional)

  1. Add a pytest-cov report. pip install pytest-cov; pytest --cov=checker. See which lines are not exercised; write a test for the most important uncovered line.
  2. Add a GitHub Actions workflow. .github/workflows/test.yml that runs pytest on every push. ~20 lines of YAML; gives you green-CI feedback every commit.
  3. Add a Makefile with make test, make lint, make install targets. Many teams use it; the convention pre-dates the modern Python tooling.
  4. Add ruff for linting (pip install ruff; ruff check checker.py). Catches common style issues automatically. Adds another dev-dependency.
  5. Write a CHANGELOG.md. Document what changed in each "release" (here, each commit on main). Forward-pointer to semver and tagged releases (CSA-101 and beyond).

Lab 13 v0.1.