Abdulkader Safi

Tests without a test runner

GitBasedDocs has 11 runnable check files and no test framework. How they work, the guard that keeps them off the real database, and why lint, typecheck and build close the gate.

5 min read

Share
Terminal output of the GitBasedDocs check files, each printing one ok line

GitBasedDocs has no test runner and no test script in package.json. It still has 11 test files, about 900 lines of them, and all 11 pass in roughly four seconds on my laptop. Each one is a plain TypeScript file that Bun runs directly.

One file beside each unit

The convention is a *.check.ts next to the module it covers. lib/sync/sync.ts has lib/sync/sync.check.ts, lib/github/webhook.ts has lib/github/webhook.check.ts, and so on. A check imports the real functions, calls them, and asserts with Node's built-in node:assert/strict. If an assertion fails, the process throws and exits non-zero. If everything holds, it prints one line:

// Runnable check for the recheck scheduler: bun lib/sync/schedule.check.ts
import assert from "node:assert/strict"

import { intervalFromEnv, nextDelay } from "./schedule"

assert.equal(intervalFromEnv("1"), 5 * MIN, "floor at 5 minutes")
assert.equal(intervalFromEnv("60"), 15 * MIN, "ceiling at 15 minutes")

The first line of each file is the command that runs it. There is nothing to configure. Bun runs TypeScript as is and resolves the @/ path alias from tsconfig.json, so bun lib/viewer/tree.check.ts just works.

What they cover

Seven checks are pure logic and need nothing but Bun:

  • sync.check.ts: the helpers that decide what a file becomes. Slug from path, title from frontmatter or the first heading, sort order and draft flag. Also the excerpt, and which paths stay hidden.
  • markdown.check.ts: the biggest, at 223 lines. It feeds <script>, onerror attributes and javascript: links into the renderer and asserts none of them come out, then covers wikilinks and callouts, math, and code blocks with line marks and diffs.
  • tree.check.ts: sidebar order and folder index pages, plus the breadcrumb trail and prev/next links.
  • webhook.check.ts: signature verification, branch matching, repeat deliveries and both payload shapes GitHub can send.
  • schedule.check.ts: the recheck interval clamp and the rate-limit back-off of 1 minute, then 5, then back to normal.
  • assets.check.ts and commits.check.ts: MIME types for cached files, and turning GitHub's commit list into who changed a page.

Four checks need a database, because what they test is SQL: access control, search, user management and the danger zone actions. Mocking the database there would test the mock. They run against a real SQLite file with the real migrations applied.

The guard on the database checks

Bun loads .env automatically, and my .env points DATABASE_URL at the app's real database. A database check that ran with that setting would insert test users and projects into it. So each one refuses to start unless the URL names its own scratch file:

if (!process.env.DATABASE_URL?.includes("access-check")) {
  console.error("Refusing to run: set DATABASE_URL=file:./data/access-check.db")
  process.exit(1)
}
rmSync("./data/access-check.db", { force: true })

const { getDb } = await import("@/lib/db")

The guard runs before the database module is even imported, which is why the imports below it are dynamic. The file is deleted at the start and again at the end, so every run begins from an empty schema and leaves nothing behind. Run it without the variable and you get the refusal, not a polluted database. With it:

DATABASE_URL=file:./data/access-check.db bun lib/access/access.check.ts

Bugs become assertions

The pattern I kept to: when a bug is fixed, the fix commit adds the assertion that would have caught it.

  • 79ad4be: GitHub's webhook form defaults to application/x-www-form-urlencoded, and the route only parsed JSON, so a webhook left on that default rejected every push. The fix added checks that both content types parse to the same object, and that the signature covers the raw form body rather than the decoded JSON.
  • 6008c45: a folder's index.md renamed the folder in the sidebar to the page title. The fix added "a titled index must not rename its folder" to tree.check.ts.
  • 9f8af20: Obsidian keeps deleted notes in .trash, and they were being published. sync.check.ts now asserts that .trash/old note.md is hidden and that notes/v1.2.md is not, because a dot inside a file name is fine.

The assertions that pin a bug carry a message saying what broke, so a failure reads as a sentence instead of expected true, got false.

The gate

The final check is three commands, in this order:

bun run lint && bun run typecheck && bun run build

Lint is Next's core-web-vitals and TypeScript config. Typecheck is tsc --noEmit, and since tsconfig.json includes every .ts file, the checks get type-checked with everything else. The build is last because it is the slowest and the one that proves the app actually compiles as a whole.

Why this was enough

The risk in this app sits in a handful of pure functions and a few queries: who can see a project, what a file turns into, whether a webhook is genuine, and when to back off from GitHub. Those are exactly what the checks pin down. The React components are thin, and they are checked by opening the page.

A runner would add a dependency and a config file, plus a second way of loading TypeScript. What I would lose without one is watch mode and a summary table, and for 11 files that run in four seconds I have not missed either.

Last updated 10 Sep 2026 · filed under Typescript, tools, nextjs, bun

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 7.

Read the series →

FAQ

Frequently asked questions

How do you run tests without Jest or Vitest?

Each module has a plain TypeScript file beside it, named *.check.ts, that imports the real functions and asserts with node:assert/strict. Bun runs it directly, for example bun lib/sync/schedule.check.ts. A failed assertion throws and exits non-zero, and a passing file prints one ok line.

How do the database checks avoid touching the real database?

Bun loads .env automatically, and .env points at the app's database. Each database check refuses to start unless DATABASE_URL names its own scratch file, runs that guard before the database module is imported, and deletes the scratch file at the start and end of every run.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.