Ship a Todo API With Tests You Will Actually Run

ship-a-todo-api-with-tests-you-will-actually-run

Ship a Todo API With Tests You Will Actually Run. A Build Next Stack field guide for learners shipping small projects.

Scope: three endpoints and one SQLite file

You will build a tiny REST-shaped API: POST /todos creates a item, GET /todos lists them, PATCH /todos/:id toggles done. Storage is SQLite in a file next to the app—no Docker, no Postgres install, no ORM tour. Framework choice is yours (Flask, FastAPI, Express, Hono); the lesson is HTTP semantics, predictable JSON, and tests that run in under five seconds on a laptop battery.

Define “done” as: a curl script in the repo creates two todos, lists them, marks one done, lists again, and exits zero. Tests duplicate that script in code so you cannot lie to yourself. This is not a production SaaS; it is a machine for learning request/response cycles you will reuse when you add auth, pagination, and webhooks later.

Keep response bodies small. A todo object needs four fields, not twenty. Extra metadata invites premature optimization—priority, labels, and due_date can wait until you have users who ask for them, which for this project means never.

Milestone one: POST and GET that return honest JSON

Start with a single module and an in-memory list if you must—but move to SQLite before writing tests so restart persistence is real. Schema: todos(id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0, created_at TEXT).

POST /todos accepts {“title”: “buy milk”}, returns 201 with the full object including generated id. Reject empty title with 400 and a JSON error body {“error”: “title required”}—not HTML. GET /todos returns 200 and an array; default sort newest first.

Log requests to stdout in dev; turn logging off in test mode via an env var TESTING=1. Keep response shapes stable: always an object with the same keys, never sometimes a string. Frontend friends—and future you—will thank you.

Content-Type headers matter: application/json on every JSON response, including errors. Missing headers confuse curl users and break fetch clients silently. One middleware or before-hook to set JSON content type saves repeated bugs.

Milestone two: tests that fail when behavior drifts

Pick one test runner (pytest, vitest, go test) and write four tests before adding PATCH:

  1. POST valid todo → 201, body contains id.
  2. POST empty title → 400.
  3. GET after two POSTs → array length 2.
  4. GET on empty DB → 200, empty array—not 404.

Use a temp database file per test or wrap each test in a transaction rollback. Sharing one DB file between tests creates order-dependent failures that teach bad habits. Run pytest -q or equivalent before every commit; hook optional, habit mandatory.

Add PATCH last: toggle done, return updated object, 404 for unknown id. One test for happy path, one for missing id. Stop there—no DELETE until v2.

Structure tests in three layers if your framework allows: pure functions for validation, DB integration without HTTP, and one end-to-end test hitting routes. Collapsing everything into HTTP tests slows feedback. Fast tests get run; slow tests get ignored—that is human nature, not a personal flaw.

Acceptance checks your future self will trust

Without looking at code, run:

  • curl -X POST with and without title; verify status codes.
  • Restart the server; GET still shows todos—proves SQLite path is correct.
  • Run the test suite twice in a row; same pass count, no flaky ordering.
  • Read the JSON error for bad input; message is human-readable, not a stack trace.

Document base URL, port, and how to reset the DB in README. A five-line “reset” script that deletes todos.db prevents mysterious state during demos.

Traps when learning APIs through frameworks

Trap: magic global state. A module-level list looks fast until tests interfere. Inject the DB path or use app factory pattern early—even if it feels heavy for day one.

Trap: 200 on errors. Returning {“ok”: false} with HTTP 200 trains clients to ignore status codes. Use 4xx/5xx correctly; tests should assert status, not only JSON fields.

Trap: OpenAPI before behavior. Swagger is nice after green tests. Generating spec from decorators while POST is broken wastes an afternoon.

Trap: Docker as a crutch. If you cannot run the API with one command on localhost, fix that before containerizing. Containers hide broken relative paths to SQLite.

Trap: test suites that need Wi-Fi. If tests hit a real server on the internet, delete them. Unit and integration tests here must run offline in a café.

What to defer until after the first green CI run

After local tests pass, add GitHub Actions or similar with one job: install deps, run tests, exit. Only then consider:

  • DELETE endpoint and soft-delete flags.
  • Pagination query params ?limit=.
  • Filtering ?done=true.
  • OpenAPI doc generated from route definitions.

Each feature gets at least one new test the same day you write the route. The stack lesson is not CRUD completeness—it is the loop of specify behavior, encode in tests, implement until green. That loop is what separates toy tutorials from things you can extend six months later without fear.

When CI is green, screenshot the passing run and paste into your learning journal—not for a portfolio, but so you remember what green looked like before the next dependency upgrade broke imports.

Run the test suite before your next feature branch

Ship POST, GET, and PATCH with four passing tests and a curl script in scripts/demo.sh. Run the demo after lunch; if it fails, fix before opening a second endpoint. Speed of iteration matters less than repeatability.

When someone asks what you built, show the test file and the demo script—not a slide deck. The API fits in one screen of code; the tests prove you understand HTTP, not that you memorized framework trivia.

Tag v0.1.0 when the acceptance list is green. Write one paragraph in the README about why SQLite is enough for this scope. That paragraph is practice for stack-choice writing later.