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
describeblock 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 eachit)
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()inafterEach— Testing Library doesn't auto-cleanup with Vitest - Prefer
getByRole({ name: /.../ })overgetByText— 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-pattern | Why it's bad | Do instead |
|---|---|---|
expect(true).toBe(true) "smoke test" | Catches nothing | At minimum, assert the function returns a defined value |
it.skip(...) for "flaky" tests | Test pollution; nobody fixes it | Quarantine 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 + flaky | Use waitFor(() => assertion) from Testing Library |
| Mocking the function under test | Tests the mock, not the function | Mock dependencies (DB, network), test the actual code path |
File-shape regex too strict (/exact phrase/) | Breaks on every refactor | Use expect(src).toContain(...) or toMatch(/[\s\S]+?key/) for flexibility |
File-shape regex too loose (/foo/) | Matches anywhere, even in comments | Anchor: /title=\{Send/or/from\s+["']@/lib/foo["']/` |
| Skipping live verification because "tests pass" | File-shape ≠ runtime behavior | After every backend deploy, do at least ONE live curl check |
Naming conventions
| Type | File location | Naming |
|---|---|---|
| Backend file-shape | tests/foo.test.ts | snake-case file name describing the FEATURE |
| Component integration | tests/components/Foo.test.tsx | PascalCase matching the component |
| API integration | tests/api-foo.test.ts | api- prefix, hyphenated |
| Live e2e smoke | tests/e2e-foo-smoke.test.ts | e2e- prefix, -smoke suffix; runs in hourly cron |
| Mobile (any app) | mobile*/__tests__/{components,unit,integration}/Foo.test.tsx | matches __tests__/ directory + Jest convention |
When to add tests vs when to skip
| Change | Add tests? |
|---|---|
| Bug fix | Always. Regression guard with bug ID in describe block. |
| New feature | Always. 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 / docs | No |
| Renaming a function | Update file-shape tests that reference the old name |
| Removing dead code | Remove the test that pinned it (and note in commit message) |