4.9 · Testing, CI/CD, and Deploy

4.9 · 25 min

Testing, CI/CD, and Deploy

You have written more tests in this track than most tutorials ship in total, and almost none of them are unit tests. That is deliberate, and it is the opposite of the testing advice you absorbed from a decade of pyramids-with-a-wide-unit-base diagrams. For an agent-built solo SaaS, the tests that catch real defects are the ones that check whether tenants are isolated, whether billing survives a failed payment, whether a job dead-letters instead of vanishing. Those are integration and policy tests. This lesson names the pyramid worth having, adds the one browser test that proves the product works end to end, and turns the whole suite into a wall that unreviewed code cannot climb over.

The pyramid worth having, solo

The classic pyramid says: many unit tests, fewer integration tests, a handful of end-to-end tests. For this kind of project, invert your attention. Here is the position: the defects that will actually hurt a solo SaaS live at the seams, so weight the suite toward the seams.

Policy and integration tests (the heavy layer). These you already have: the RLS tenancy suite (4.5), the unauthorized-access suite (4.6), the billing lifecycle test (4.7), the job lifecycle test (4.8). Each exercises a real boundary with a real database, and each was written as a merge gate, not an afterthought. These catch the failures an agent actually ships: the missing where org_id, the client-side-only check, the redirect-as-truth, the silently-lost job. This is where your testing time goes.

A thin layer of unit tests, only where logic is genuinely tricky. Pure functions with real branching earn a unit test: the digest's "did anything happen worth emailing" predicate, the subscription-status-to-access mapping, a date-window calculation. Do not write unit tests for glue, for framework behavior, or for code with no branches; those tests assert that the code is the code, cost maintenance, and catch nothing. An agent will happily generate two hundred of them if you let it, and they will all pass forever while a tenancy leak sails through.

One critical-path smoke test (the tip). A single Playwright test that walks the product's spine: sign up, create a project, upload a deliverable, approve it, confirm the audit record. If that passes, the product's core promise works; if it breaks, something structural did. You want one or a few of these, not a hundred; end-to-end tests are slow and brittle in bulk, and their value is proving the whole thing hangs together, which one good path does.

The through-line: test behavior at boundaries heavily, test tricky logic in isolation lightly, prove the spine end to end once. A hundred unit tests on getters plus zero policy tests is precisely the suite that feels thorough and ships a data leak.

The critical-path smoke test

Playwright is the tool, installed via npm init playwright@latest, which offers to drop in the GitHub Actions workflow for you. The one test that matters:

briefcase/tests/e2e/critical-path.spec.tsts
import { test, expect } from '@playwright/test'

test('the spine: signup to approved deliverable with audit', async ({
page,
}) => {
// Sign up and land in a fresh org.
await page.goto('/signup')
await page.getByLabel('Email').fill('owner@studio.test')
await page.getByLabel('Password').fill('correct-horse-staple')
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/dashboard/)

// Create a project and upload a deliverable.
await page.getByRole('button', { name: 'New project' }).click()
await page.getByLabel('Project name').fill('Acme rebrand')
await page.getByRole('button', { name: 'Create' }).click()
await page.getByRole('button', { name: 'Upload deliverable' }).click()
await page.getByLabel('File').setInputFiles('tests/fixtures/logo.png')
await page.getByRole('button', { name: 'Upload' }).click()
await expect(page.getByText('logo.png')).toBeVisible()

// Move to review, then approve, and check the audit trail exists.
await page.getByRole('button', { name: 'Send for review' }).click()
await page.getByRole('button', { name: 'Approve' }).click()
await expect(page.getByText('Approved by owner@studio.test'))
  .toBeVisible()
// The approval is the product's core value: an audit record that
// holds up in a dispute. Assert it is shown, dated, attributed.
})

The Playwright config points at a dev server so CI can boot the app and drive it:

briefcase/playwright.config.tsts
import { defineConfig } from '@playwright/test'

export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://localhost:3000' },
// CI boots the app, then runs the specs against it.
webServer: {
  command: 'npm run build && npm run start',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
},
})

CI as merge protection

The 4.4 skeleton ran typecheck, lint, and build. Now it grows the teeth that make it a gate: it runs every suite, spins up a database for the policy and integration tests, and (this is the part that matters) is set as a required status check on the main branch so a red run blocks the merge. A gate that reports but does not block is a suggestion, and 3.2 was clear that a gate you can merge past is not a gate.

briefcase/.github/workflows/ci.ymlyaml
name: CI
on:
push: { branches: [main] }
pull_request: { branches: [main] }
jobs:
verify:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v5
    - uses: actions/setup-node@v5
      with: { node-version: lts/*, cache: npm }
    - run: npm ci
    - run: npm run typecheck
    - run: npm run lint
    - run: npm run build
    # Policy + integration tests need a database.
    - run: supabase start
    - run: supabase db reset          # migrations apply cleanly
    - run: supabase test db           # RLS/tenancy policy suite
    - run: npm test                   # billing + jobs + auth suites
    - name: Install Playwright browsers
      run: npx playwright install --with-deps
    - run: npx playwright test        # the critical-path smoke
    - uses: actions/upload-artifact@v4
      if: ${{ !cancelled() }}
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 7

Then, in GitHub branch protection settings, mark the verify job a required check and require a PR to merge. Now the path to main is: branch, push, CI runs every gate, red blocks. This is where all the per-feature gates from 4.5 through 4.8 stop being local rituals and become an enforced property of the repository. An agent can no longer produce a green-looking "done" that merges a tenancy leak, because the merge itself is gated on the tenancy suite.

Deploy: previews as review artifacts, production on merge

Connect the GitHub repo to Vercel once. From then on Vercel's git integration gives you two behaviors straight from its environments model. A push to any non-production branch, or opening a PR, creates a preview deployment at a generated URL: a live, running copy of that exact change, which is the best review artifact there is, since you can click the actual feature instead of imagining it from a diff. A merge to main (the production branch) triggers a production deployment that updates your live domain. Environment variables are set per environment in Vercel (production keys for production, test keys for preview), and vercel env pull brings them down for local dev.

The crons from 4.8 (vercel.json) activate on the production deployment; Vercel reads that file and schedules the GETs against your production URL. Nothing else is required to turn the jobs on in production, which is the payoff of having designed them as plain route handlers.

The critical operational rule: preview deployments run against non-production data, production against production data, and the two never share credentials. A preview that can write your production database is a loaded gun in every PR.

The dispatch

PromptReference dispatch: testing, CI, deploy (lab 4-9)

DISPATCH: briefcase test pyramid, CI merge gate, and deploy

GOAL: the critical-path smoke test passes; CI runs every suite (typecheck, lint, build, policy, integration, e2e) and is a required status check blocking merge to main; Briefcase is deployed to Vercel with a live production URL and PR previews.

IN SCOPE: playwright.config.ts; tests/e2e/critical-path.spec.ts + fixtures; a thin unit test file for the two or three genuinely tricky pure functions (digest predicate, status-to-access map); .github/workflows/ci.yml (grow to full gate); vercel.json (crons, already present); README deploy notes; STATUS.md.

OUT OF SCOPE: adding e2e tests beyond the one spine path; unit-testing glue or framework behavior; new features; changing the app to make tests pass (fix the test or the real bug, never weaken the assertion, per 3.2).

DON'T TOUCH: the existing policy/integration/billing/job suites (they are the gate; do not loosen them); migrations; the env hook; docs/*.

VERIFICATION:

  1. Full CI green: typecheck, lint, build, supabase test db, npm test, playwright test.
  2. The e2e test asserts the APPROVAL AUDIT RECORD is shown and attributed, not just that an upload appears. I will read it.
  3. Operator: open a PR, confirm CI runs AND a Vercel preview URL is posted; click the preview and run the spine by hand.
  4. Operator: merge, confirm production deploys to the live URL and the app works there; confirm branch protection blocks a merge when I push a deliberately failing test.

Read STATUS.md first. If any existing suite is flaky in CI, report it; do not fix flakiness by deleting or weakening assertions.

Where it breaks

The first failure is the inverted-effort suite: two hundred unit tests, zero policy tests, green forever, and a live data leak. Coverage percentage is the seductive lie here, because getters and glue are easy to cover and boundaries are not, so a high number often means the easy code is tested and the dangerous code is not. Judge the suite by what it would catch, not by its coverage bar: if deleting your where org_id clause does not turn something red, the suite is decorative where it matters.

The second is the reporting gate that does not block. CI that runs on PRs but is not a required check lets a red build merge, and under deadline someone (you, at 1am) will. The fix is one settings toggle, branch protection requiring the check, and it is the difference between a gate and a dashboard.

The third is the preview that touches production. The convenience path, one set of env vars for everything, means every preview deployment and every local test run can mutate live customer data. Keep production credentials in the production environment only; previews and local get test credentials. This is not paranoia, it is the same failure-domain discipline from 4.2 applied to your own deploy pipeline.

Lablab-4-9Capstone dispatch: pyramid, merge gate, live deploy

Goal: Add the critical-path smoke test, make CI a blocking merge gate, and deploy Briefcase to a live Vercel URL with working previews.

Prereqs: the repo through 4.8 (all four boundary suites exist), a Vercel account, a hosted Supabase project for production (the local stack was for development).

  1. Save the reference dispatch as dispatches/2026-07-deploy.md and run it in a fresh session. Review the plan: confirm it adds exactly one e2e path and only unit-tests the genuinely tricky functions, not glue.
  2. Run VERIFICATION item 2 yourself: read the e2e test and confirm it asserts the approval audit record is shown and attributed. If it stops at "upload appears", it does not prove the product's core value; send it back.
  3. Set up CI as a real gate: after the workflow is green, go to the repo's branch-protection settings and require the verify check plus a PR to merge to main. Prove it works: push a branch with a deliberately failing assertion, open a PR, and confirm the merge button is blocked until you fix it.
  4. Connect Vercel to the repo. Set production env vars (production Supabase + Stripe live-or-test keys) in the Vercel production environment, and preview env vars (test credentials) in the preview environment. Never share them.
  5. Open a PR for a small real change; confirm a preview URL is posted and click through the spine on it. Merge; confirm production deploys and the live URL works. Confirm the crons appear in the Vercel dashboard.
  6. Close the loop: STATUS.md (live URL recorded), commit, /clear.

Verify

  • Full CI (typecheck, lint, build, supabase test db, npm test, playwright test) is green, and verify is a required status check: a PR with a failing test cannot be merged (you proved this).
  • The e2e test asserts the attributed, dated approval audit record, not just a visible upload.
  • A PR produces a clickable Vercel preview URL running that change; merging to main deploys production to a live URL where the spine works.
  • Production and preview use different credentials; no preview or local run can write the production database.
>Troubleshooting
  • Playwright passes locally but fails in CI: usually the app was not built/started in CI or the browsers were not installed. Confirm the webServer command builds and starts, and that npx playwright install --with-deps runs before the test step.
  • CI is green but merges are not actually blocked: the workflow runs but the check is not marked required in branch protection. Green-but-not-required is a dashboard; flip the required-check toggle.
  • The Vercel build fails on env vars that exist locally: local .env.local is not uploaded. Set each variable in the Vercel dashboard per environment, then vercel env pull to re-sync locally so the two match.

Knowledge check

Knowledge check

Q1An agent proudly reports 92% test coverage on the Briefcase repo: hundreds of unit tests on components, getters, and utility wrappers, all green. Why does this lesson treat that as a warning sign rather than reassurance?
Q2Your CI workflow runs all suites on every PR and is green. Merges to main still occasionally ship broken code. What is almost certainly missing?
Q3Why does this lesson call a Vercel preview deployment a better review artifact than the diff itself?
Q4A teammate makes CI pass on a flaky billing test by changing expect(row.status).toBe('past_due') to expect(['past_due','active']).toContain(row.status). CI goes green. What happened?

Sources

  • Playwright Setting up CI (npm init playwright, GitHub Actions workflow with checkout@v5 / setup-node@v5, install --with-deps, upload-artifact report): https://playwright.dev/docs/ci-intro (fetched July 2026)
  • Vercel Environments (preview deployments on non-production branches and PRs, production deploy on merge to the production branch, per-environment env vars, vercel env pull): https://vercel.com/docs/deployments/environments (fetched July 2026)
  • Vercel Cron Jobs (vercel.json crons scheduled against the production deployment): https://vercel.com/docs/cron-jobs (fetched July 2026)
  • Best practices for Claude Code (verification-first, do-not-weaken-assertions discipline the dispatch enforces): https://code.claude.com/docs/en/best-practices (fetched July 2026)
  • The Briefcase test config, e2e spine test, and CI gate are this course's reference artifacts, authored against the fetched Playwright and Vercel docs.