Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~29 min· topic 9 of 10

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.

tests/test_cli.pywhole filepython
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 == 1

03pyproject.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.

terminalwhole filebash
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 archive

05A 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.

Dockerfilewhole filedockerfile
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.

Check that it shipsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Write the failing test first

    In the first example, add a test that chunk_text("a b c", 4, -1) raises ValueError. It passes, because the function checks overlap < 0. Now prove the test is worth having: delete overlap < 0 or from the if, rerun, and watch the new test go red (negative overlap makes the step larger than size, 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. 2

    Make the project check itself

    Extend the second example so it also asserts that requires-python starts with >= and that dependencies contains numpy. Cheap structural checks like these catch mistakes in configuration files that no unit test would notice.

Code & diagrams

A test suite built on a fake model New tab

Break the chunker on purpose (change `size - overlap` to `size`) and watch which tests fail.

Sign in to run this example in your browser.

Expected output

ran 5 tests, 0 failures, 0 errors
Write a pyproject.toml and verify it with tomllib New tab

Delete the `[project.scripts]` section from the text and run again to see the problem list.

Sign in to run this example in your browser.

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: none
Continuous integration (GitHub Actions)yaml

Not 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: pytest

Break 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.

main.pywhole filepython
def average_score(scores):
    return sum(scores) / len(scores)


assert average_score([0.1, 0.2]) == 0.15
print("ok")
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 5, in <module>
assert average_score([0.1, 0.2]) == 0.15
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

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. 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 calling main([...], env) with fixed arguments. In pytest, tests are plain functions with plain assert; tmp_path gives a clean folder for file tests; capsys captures printed output; monkeypatch sets environment variables. The first runnable example uses unittest so it runs in the browser, but the same assertions work in pytest.

  2. 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, k larger 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. 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 in pyproject.toml so 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. 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 (dev for tools, openai for one client), and [project.scripts], which creates the docqa command and points it to docqa.cli:main. Install in editable mode during development (pip install -e ".[dev]"). A lock file (uv.lock from uv lock, or pip-compile output) records exact versions for repeatable installs. Build a wheel with python -m build or uv build.

  5. 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. 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 Embedder Protocol, 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

01

How do fakes and dependency injection make the docqa test suite fast and deterministic?

02

What goes in pyproject.toml, and what does the lock file add?

03

What belongs in a good Dockerfile for a Python tool, and what must never be in the image?

04

Which checks would you run in CI for this project, and in what order?

05

How do pytest fixtures and parametrize improve a test suite compared with plain unittest classes?

Practice

01

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.

02

Use tomllib to read this text and print the project name and version: [project] newline name = "demo" newline version = "1.2.3".

03

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.