Learn Build a Real SaaS with Claude Code AI-Assisted Testing for This App

AI-Assisted Testing for This App

Intermediate 🕐 20 min Lesson 17 of 25
What you'll learn
  • Explain why globals: false in vitest.config.ts makes test files explicit about their dependencies
  • Describe the documented scope limitation of this app's Playwright suite and why it is honest rather than a gap to hide
  • Apply the extract-a-pure-function pattern to make webhook or event-handling logic unit testable
  • Read reconcileSubscriptionUpdate as an example of a function that takes plain data in and returns plain data out
  • Write a Claude Code testing prompt that names the adversarial case explicitly instead of asking for generic test coverage
  • Explain why a narrow test with one assertion per real-world scenario catches bugs that a loose does-not-throw test would miss

"Write Tests for This" Is a Weak Prompt

Asked to "write tests for this file," an AI model will reliably produce something that runs and passes -- and just as reliably miss the cases that actually matter, because it has no signal about which behavior is load-bearing. The reputation dashboard's test suite (npm test runs Vitest and passes 9/9; npm run test:e2e runs Playwright and passes 5/5) was built around a more specific instinct: test the logic that would be expensive to get wrong, and make that logic possible to test in isolation in the first place.

The Setup: Vitest for Logic, Playwright for Flows

vitest.config.ts is short and specific about scope:

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
    globals: false,
    setupFiles: ["./tests/setup.ts"],
    exclude: ["**/node_modules/**", "**/e2e/**"],
  },
});

globals: false means every test file imports describe, it, and expect explicitly from "vitest" rather than relying on injected globals -- a small choice that keeps test files honest about what they depend on. playwright.config.ts is the e2e half, and its own doc comment is upfront about scope: it boots a real next dev server via webServer and covers only the unauthenticated journey -- landing page, login form validation, and confirming /dashboard genuinely redirects when signed out -- because exercising the authenticated flow would require a live Supabase project this build environment did not have. That is a real, stated limitation, not a gap to paper over: e2e/auth-flow.spec.ts tests browser-native HTML validation (type="email", minLength={8}) by reading el.validity.valid directly, and confirms both /dashboard and /dashboard/billing redirect to /login -- real assertions against a real running server, just scoped to what is actually verifiable here.

Extract the Pure Function, Then Test That

The most useful pattern in this codebase for AI-assisted testing is tests/reconcile.test.ts against lib/billing/reconcile.ts. The Stripe webhook handler in app/api/billing/webhook/route.ts is hard to unit test directly -- it needs a signed Stripe payload, a real request object, and a database connection. So the decision logic inside it was pulled out into its own pure function, reconcileSubscriptionUpdate(business, subscription), that takes plain data in and returns plain data out:

export function reconcileSubscriptionUpdate(
  business: Business,
  subscription: Stripe.Subscription,
): SubscriptionPatch | null {
  const hasDifferentSubscriptionOnFile =
    business.stripeSubscriptionId !== null &&
    business.stripeSubscriptionId !== subscription.id;
  const storedSubscriptionStillCurrent = !TERMINAL_STATUSES.includes(
    business.subscriptionStatus,
  );
  const isStale = hasDifferentSubscriptionOnFile && storedSubscriptionStillCurrent;
  if (isStale) return null;

  return {
    subscriptionTier: tierFromStatus(subscription.status),
    subscriptionStatus: subscription.status,
    stripeSubscriptionId: subscription.id,
  };
}

No database, no HTTP request, no Stripe signature verification -- just two plain objects in, a decision out. That is what makes it possible to write five fast, deterministic test cases against it in tests/reconcile.test.ts, each one a different real-world event sequence: a fresh activation, a stale out-of-order cancellation that must not overwrite newer state, a legitimate cancellation of the current subscription, a genuine resubscription after a prior cancellation, and a first-ever subscription with nothing on file yet. None of those scenarios involve mocking a database or constructing a fake signed webhook payload, because the function under test does not touch either one.

This is the single most transferable lesson in this section, and it applies to your own webhook handlers, cron jobs, or any "react to an external event" code: if a prompt to Claude Code is "test this webhook route," push back on yourself first and ask whether the route's actual decision logic could be extracted into a plain function first. A good prompt names that explicitly:

This webhook route has a database write tangled together with the business decision of whether to apply an incoming Stripe event. Extract the decision logic into a pure function that takes the current business record and the incoming Stripe subscription object, and returns either a patch to apply or null. Then write Vitest tests against that pure function covering: a fresh subscription, a stale out-of-order event that should be ignored, and a legitimate state transition. Do not mock Stripe or the database -- the function should not need either.

"Do not mock Stripe or the database" is doing real work in that prompt -- it is the constraint that forces the extraction in the first place, rather than letting the model reach for heavier mocking as a substitute for a smaller function.

A Test That Actually Caught Something

This is not a hypothetical benefit. While building this reconciliation logic, one of these tests failed against the first draft of the fix -- a real assertion catching a real bug in the staleness check before it shipped, not after. The deliberate, narrow scope of tests/reconcile.test.ts -- specific business states, specific event sequences, one assertion per scenario -- is exactly what made that catch possible. A looser test ("call the function with some subscription data and check it doesn't throw") would have passed against the buggy version too.

What This Means for Your Own Prompts to Claude Code

When asking Claude Code to add tests, three questions produce sharper output than "write tests for this": What is the actual decision this code makes, separate from the I/O around it? What is the case that would be expensive to get wrong in production -- the one a generic happy-path test would never think to construct? And can the logic under test be pulled out into something that takes plain values in and returns a plain value out, the way reconcileSubscriptionUpdate does? Asking the model to name the adversarial case explicitly -- "what is the out-of-order delivery scenario here, not just the happy path" -- is what separates a test suite that pads a coverage number from one that would have caught the bug this one did.

With a real, narrow test suite in place, the next lesson turns Claude Code toward reviewing your own diffs before they ship -- specifically tuned to this stack's actual risk areas rather than a generic review checklist.

Key takeaways
  • npm test runs Vitest (9/9 passing) and npm run test:e2e runs Playwright (5/5 passing) against this reference app -- both are real, run suites, not aspirational counts
  • The Playwright suite intentionally covers only the unauthenticated journey because the authenticated flow needs a live Supabase project this build environment did not have
  • lib/billing/reconcile.ts was extracted specifically so the webhook's decision logic could be tested without mocking Stripe or a database
  • reconcileSubscriptionUpdate takes a business record and a Stripe subscription object in, returns a patch or null out -- a pure function is the easiest thing in any codebase to write a sharp test against
  • One of the reconcile tests caught a real bug in the first draft of the staleness fix before it shipped, because the test asserted a specific scenario rather than just absence of an exception
  • A strong AI testing prompt names the adversarial scenario explicitly and constrains the approach (e.g. "do not mock Stripe") rather than asking generically for test coverage