Skip to main content

Writing tests

Patterns + examples that map to KA26's actual codebase.

File-shape contract test (the most common)

Use this when you want to PREVENT a specific bug from ever recurring.

// tests/seller-bid-lifecycle.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const ROOT = join(__dirname, "..");
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8");

describe("BUG-007 — stale accepted bids lazy-expire (regression guard)", () => {
const route = read("src/app/api/seller/offers/route.ts");

it("flips accepted+lockedUntil<now+no order → expired", () => {
expect(route).toMatch(/status:\s*"accepted"/);
expect(route).toMatch(/lockedUntil:\s*\{\s*lt:\s*now\s*\}/);
expect(route).toMatch(/convertedOrderId:\s*null/);
});

it("scoped to THIS seller (anti-bulk safeguard)", () => {
expect(route).toMatch(/sellerId:\s*seller\.sellerId,[\s\S]+?status:\s*"accepted"/);
});
});

Conventions:

  • Name the describe block after the bug ID (BUG-007) so a future failure tells the next person what regression they caused
  • Use multiple small its instead of one giant assertion — failures are clearer
  • Read the file once at the top of describe (don't re-read inside each it)

jsdom component integration test

Use this when you've added a new UI component with user input.

// tests/components/VoiceRecorder.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import VoiceRecorder from "@/components/voice/VoiceRecorder";

// Mock heavy deps with a stub that returns the minimum the component needs
vi.mock("@/components/voice/WaveformVisualizer", () => ({
default: () => <div data-testid="waveform-stub" />,
}));

beforeEach(() => {
// Reset any global state between tests
Object.defineProperty(global.navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: vi.fn().mockResolvedValue(fakeStream) },
});
});

afterEach(() => {
cleanup();
});

describe("<VoiceRecorder /> recording → completion", () => {
it("calls onRecordingComplete with a Blob when user taps Stop", async () => {
const onComplete = vi.fn();
render(<VoiceRecorder onRecordingComplete={onComplete} ... />);
fireEvent.click(screen.getByText(/Tap to record/i));
await waitFor(() => screen.getByText(/^Recording$/i));
fireEvent.click(screen.getByRole("button", { name: /Stop/i }));
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
});
});

Gotchas:

  • Always use cleanup() in afterEach — Testing Library doesn't auto-cleanup with Vitest
  • Prefer getByRole({ name: /.../ }) over getByText — more specific, fewer false positives
  • For native APIs (audio, video), build a fake class with the same interface; don't try to use the real one
  • Real MediaRecorder fires events asynchronously — your fake should setTimeout(0) too, or you'll get bugs that don't repro in browsers

Mobile Jest test

// mobile-seller/__tests__/components/SellerOffersScreen.test.tsx
import { render, waitFor, fireEvent } from "@testing-library/react-native";

// Mock the API layer
const mockApiGet = jest.fn();
jest.mock("@/lib/api", () => ({
api: { get: (...a: any[]) => mockApiGet(...a), patch: jest.fn() },
}));

// Mock i18n so the component doesn't need <LanguageProvider>
jest.mock("@/contexts/LanguageContext", () => ({
useLanguage: () => ({
locale: "en",
setLocale: jest.fn(),
t: (key: string, vars?: Record<string, any>) => {
if (!vars) return key;
let out = key;
for (const [k, v] of Object.entries(vars)) {
out = out.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
return out;
},
}),
}));

import SellerOffersScreen from "../../app/offers/index";

describe("SellerOffersScreen", () => {
it("mounts without throwing in the loading state", () => {
mockApiGet.mockImplementation(() => new Promise(() => {})); // never resolves
expect(() => render(<SellerOffersScreen />)).not.toThrow();
});
});

Pattern: mock every context the screen depends on. The mock t() returns the key (or interpolated key) so tests can findByText("bids.action_accept") instead of having to load real locale dictionaries.

Live verification (after deploy)

See Live verification for the full pattern. Quick template:

SECRET=$(gcloud run services describe ka26-marketplace --region us-central1 \
--format=json | python3 -c "import json,sys; \
d=json.load(sys.stdin); \
env={e['name']:e.get('value','') for e in d['spec']['template']['spec']['containers'][0].get('env',[])}; \
print(env.get('JWT_SECRET',''))")

SELLER_TOKEN=$(node -e "
const jwt = require('/Users/.../node_modules/jsonwebtoken');
console.log(jwt.sign({sellerId: 13, type: 'seller'}, '$SECRET', {expiresIn:'1h'}));
")

curl -s -H "Cookie: seller_token=$SELLER_TOKEN" \
https://ka26.shop/api/seller/offers | jq

Anti-patterns (don't do these)

Anti-patternWhy it's badDo instead
expect(true).toBe(true) "smoke test"Catches nothingAt minimum, assert the function returns a defined value
it.skip(...) for "flaky" testsTest pollution; nobody fixes itQuarantine to a separate file with a comment naming the bug, OR fix it now
await new Promise(r => setTimeout(r, 1000)) "wait for it to settle"Slow + flakyUse waitFor(() => assertion) from Testing Library
Mocking the function under testTests the mock, not the functionMock dependencies (DB, network), test the actual code path
File-shape regex too strict (/exact phrase/)Breaks on every refactorUse expect(src).toContain(...) or toMatch(/[\s\S]+?key/) for flexibility
File-shape regex too loose (/foo/)Matches anywhere, even in commentsAnchor: /title=\{Send/or/from\s+["']@/lib/foo["']/`
Skipping live verification because "tests pass"File-shape ≠ runtime behaviorAfter every backend deploy, do at least ONE live curl check

Naming conventions

TypeFile locationNaming
Backend file-shapetests/foo.test.tssnake-case file name describing the FEATURE
Component integrationtests/components/Foo.test.tsxPascalCase matching the component
API integrationtests/api-foo.test.tsapi- prefix, hyphenated
Live e2e smoketests/e2e-foo-smoke.test.tse2e- prefix, -smoke suffix; runs in hourly cron
Mobile (any app)mobile*/__tests__/{components,unit,integration}/Foo.test.tsxmatches __tests__/ directory + Jest convention

When to add tests vs when to skip

ChangeAdd tests?
Bug fixAlways. Regression guard with bug ID in describe block.
New featureAlways. Layer 1 (file-shape) + appropriate higher layer.
Refactor (no behavior change)Update existing file-shape tests if their patterns no longer apply. Don't add new tests.
Comment / typo / docsNo
Renaming a functionUpdate file-shape tests that reference the old name
Removing dead codeRemove the test that pinned it (and note in commit message)