Topic 17.9
Capstone: Test, Package and Ship It
In one line
Finish docqa like a professional project: a test suite built on fakes, Ruff and a type checker, a pyproject.toml with a console command and a lock file, a Dockerfile, a README, and a list of what to build next.
Think of it like this
The last day before a shop opens. The shelves are stocked (the code works), but you still check the lights (tests), tidy the shop (linting and formatting), put a sign on the door (README), write the opening hours and the key-holder list (packaging and configuration), and lock the back door (secrets and a clean image). Shipping is the set of boring steps that make other people able to use what you built.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Fixture
- Set-up code that gives a test what it needs, such as a temporary folder.
- Linter
- A tool that reads code without running it and reports mistakes and style problems.
- Type checker
- A tool that uses type hints to find mismatched types before the program runs.
- Lock file
- A file recording the exact version of every dependency.
- Entry point
- A name (like the command docqa) that runs a particular function when typed in a terminal.
- Wheel
- A ready-to-install package file.
- CI
- Continuous integration: a server that runs your checks automatically on every change.
- Image
- A packaged filesystem and settings that Docker starts containers from.
Step by step
01Write the tests from the requirements
The first runnable example puts a short chunker and a FakeModel in the file and tests them with unittest: overlap, a short text, empty text, bad settings, and that the answer function sends the context in the prompt and joins the streamed tokens. In a project the code under test lives in src/docqa/ and the tests in tests/.
Notice that no test needs the network, a key, or a sleep. That is the payoff of the design from Topics 17.6 to 17.8.
02The same tests in pytest style
In the project you would write these as pytest functions. tmp_path is a built-in fixture that provides a fresh temporary directory. The block below is not run in the browser because it needs pytest and the installed project.
import pytest
from docqa.cli import main
from docqa.chunking import chunk_text
def test_overlap():
assert chunk_text("a b c d e f", 4, 2) == ["a b c d", "c d e f"]
def test_bad_settings():
with pytest.raises(ValueError):
chunk_text("a b c", 2, 2)
def test_cli_answers_with_fake_model(tmp_path, capsys):
(tmp_path / "refunds.txt").write_text("Return any item within 30 days for a full refund.")
code = main(["How long to return?", "--docs", str(tmp_path)], env={})
out = capsys.readouterr().out
assert code == 0
assert "refunds.txt" in out # the sources line
def test_missing_folder_is_a_clean_error(tmp_path, capsys):
code = main(["q", "--docs", str(tmp_path / "nope")], env={})
assert code == 103pyproject.toml, parsed to prove it is valid
The second runnable example writes a pyproject.toml and reads it back with tomllib (the standard-library TOML parser), then checks the fields every shippable project needs: name, version, a Python requirement, a console script and a dev extra. A check like this is a tiny version of what pip does when it installs your project.
04Ruff and the type checker
The [tool.ruff], [tool.pytest.ini_options] and [tool.mypy] sections in the file above configure the tools. Run them locally and in CI. The commands are the same in both places.
ruff check . # lint
ruff format --check . # formatting must be clean
mypy src # type check
pytest # tests
uv lock # write or update uv.lock (exact dependency versions)
uv build # build the wheel and source archive05A Dockerfile
Dependency files (pyproject.toml and uv.lock) are copied and installed before the source, so Docker reuses the cached dependency layer when only your code changes. Installing from the lock file (--frozen) means the image gets the exact versions you tested. The tool runs as a normal user, and the model key arrives at run time (docker run -e DOCQA_API_KEY ...), never baked into the image. Mount your documents as a volume. In real use, pin the uv image to a specific version tag instead of latest, and pin the Python tag you test with.
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# 1. dependencies only: this layer is reused until pyproject.toml or uv.lock change
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
# 2. the project itself
COPY src ./src
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd --create-home appuser
USER appuser
ENTRYPOINT ["docqa"]
CMD ["--help"]06Check that it ships
Before announcing it, test from a clean state: a new virtual environment (or the Docker image), install, run docqa --help, run one real question against the sample documents, and run the test suite. If any step needs knowledge that is only in your head, write it in the README.
Try it yourself
- 1
Write the failing test first
In the first example, add a test that
chunk_text("a b c", 4, -1)raisesValueError. It passes, because the function checksoverlap < 0. Now prove the test is worth having: deleteoverlap < 0 orfrom theif, rerun, and watch the new test go red (negative overlap makes the step larger thansize, so words are silently dropped and nothing is raised). Put the check back and watch it go green. A test you have seen fail is a test you can trust. - 2
Make the project check itself
Extend the second example so it also asserts that
requires-pythonstarts with>=and thatdependenciescontainsnumpy. Cheap structural checks like these catch mistakes in configuration files that no unit test would notice.
Code & diagrams
Break the chunker on purpose (change `size - overlap` to `size`) and watch which tests fail.
Expected output
ran 5 tests, 0 failures, 0 errorsDelete the `[project.scripts]` section from the text and run again to see the problem list.
Expected output
name: docqa | python: >=3.12
command 'docqa' runs: docqa.cli:main
runtime dependencies: ['numpy']
extras: ['dev', 'openai']
tools configured: ['mypy', 'pytest', 'ruff']
problems: noneNot run here. Runs the same four commands on every push. Versions of actions change over time, so check the current ones when you copy this.
name: checks
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -e ".[dev]"
- run: ruff check .
- run: mypy src
- run: pytestBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Compare floats with ==
Write a test that asserts an average of 0.1 and 0.2 equals 0.15.
def average_score(scores):
return sum(scores) / len(scores)
assert average_score([0.1, 0.2]) == 0.15
print("ok")Myth vs fact
Myth
Tests slow you down.
Fact
Untested code slows you down every time you change it, because you cannot tell what broke. A fast suite on fakes lets you refactor with confidence.
Myth
100% coverage means the code is correct.
Fact
Coverage shows which lines ran, not whether the assertions were meaningful. It is a guide for finding untested code, not a goal.
Myth
Docker is only for big systems.
Fact
A small Dockerfile is the simplest way to make a tool run the same on another machine. It is also how most services get deployed.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep slow or paid tests (real model calls) behind a marker or a separate command, run them on a schedule, and keep the default test run offline and fast. Record real responses once (a 'cassette') to use as fixtures when the exact response shape matters.
- ▸
Pin tool versions in the dev extra or in the lock file so formatting rules do not change under you. Pre-commit hooks can run Ruff before each commit.
- ▸
Publishing to PyPI needs a unique name, a build, and a trusted-publisher or token setup. Do not publish a toy project under a name someone else may want; install from a Git URL or a private index instead.
Remember this
- 1
Tests with fakes (Topics 11.2 to 11.4). A fake is a stand-in object with scripted behaviour. The whole point of the earlier design pays off now. Test the chunker with plain strings; test the retriever with the toy embedder (deterministic, no network); test the answer flow with
FakeModelClient, asserting on the prompt it received and the text it returns; test the CLI by callingmain([...], env)with fixed arguments. In pytest, tests are plain functions with plainassert;tmp_pathgives a clean folder for file tests;capsyscaptures printed output;monkeypatchsets environment variables. The first runnable example usesunittestso it runs in the browser, but the same assertions work in pytest. - 2
What to test. Each requirement from Topic 17.6 becomes at least one test. Add the edge cases: empty text,
size <= overlap, no matching document,klarger than the number of chunks, a model that fails twice and then succeeds, a missing API key. Test behaviour, not private details, so refactoring does not break tests. One test that fails first and then passes is worth ten that never could fail: when you fix a bug, write the failing test first. - 3
Lint, format, type-check (Topics 11.6 and 10.3). Ruff is a very fast linter and formatter:
ruff check .finds errors and style problems,ruff format .formats. A type checker (mypy or pyright) reads your annotations and finds mismatches without running the code. Put their settings inpyproject.tomlso everyone, and CI, uses the same rules. Run all three plus the tests in one command, and run that command in CI on every push. - 4
**Packaging with
pyproject.toml** (Topics 6.6 to 6.9). The file names the project, the Python version it needs, its runtime dependencies, optional extras (devfor tools,openaifor one client), and[project.scripts], which creates thedocqacommand and points it todocqa.cli:main. Install in editable mode during development (pip install -e ".[dev]"). A lock file (uv.lockfromuv lock, or pip-compile output) records exact versions for repeatable installs. Build a wheel withpython -m buildoruv build. - 5
A Dockerfile (Topic 15.9). A container packages the tool and its Python version so it runs the same everywhere. Use a slim official Python base image, copy only what the build needs, install the project, run as a non-root user, and never copy secrets into the image: pass the API key at run time as an environment variable. Keep the image small and the build cacheable by copying dependency files before source files. The Docker course on Hectal (
/docker) goes deeper. - 6
README and what's next. The README answers four questions in this order: what is it, how do I install it, how do I use it (one real example), how do I develop it (run tests and checks). Then list known limits honestly. Good next steps: read PDFs and web pages, persist the index to disk, a real embedding model behind the
EmbedderProtocol, reranking, a small evaluation set of questions with expected sources, a web interface with FastAPI, and caching of answers. Each is a replacement behind an interface you already have.
Explain it without notes
How do fakes and dependency injection make the docqa test suite fast and deterministic?
What goes in pyproject.toml, and what does the lock file add?
What belongs in a good Dockerfile for a Python tool, and what must never be in the image?
Which checks would you run in CI for this project, and in what order?
How do pytest fixtures and parametrize improve a test suite compared with plain unittest classes?
Practice
Write a unittest test class with two tests for a function normalise(text) that lowercases and strips text, then run it with a TestResult and print the counts.
Use tomllib to read this text and print the project name and version: [project] newline name = "demo" newline version = "1.2.3".
Write a function has_secret(text, secret) and a test showing that describe_config-style output never contains the secret string.
Trade-offs
- ↔
Strict type checking versus speed of writing. Strict mode catches more but needs more annotations. Start lenient on a new project and tighten module by module.
- ↔
Slim image versus convenience. A slim image is smaller and has fewer vulnerabilities, but may lack system libraries that some packages need. Add only what a build requires.
Done when you can
Done when you can write tests for each requirement using fakes and fixed arguments.
Done when you can configure Ruff, pytest and a type checker in
pyproject.toml.Done when you can add a console script and install the project in editable mode.
Done when you can write a safe, small Dockerfile and keep secrets out of it.
Done when you can write a README and list honest next steps.