Test Writer — Travmaskinen
Write tests that match the project's established conventions exactly. The codebase has 2,032 backend tests and 101 frontend tests — new tests must be indistinguishable from existing ones in style.
Decision: Which Layer?
| Target | Framework | Config | Run Command |
|---|---|---|---|
Python module (backend/**/*.py) | pytest + asyncio | pyproject.toml | pytest backend/tests/ -v --tb=short |
React component (apps/web/**/*.tsx) | Vitest + RTL | apps/web/vitest.config.ts | pnpm --filter web test |
| User flow (multi-page) | Playwright | apps/web/playwright.config.ts | pnpm --filter web test:e2e |
Coverage commands:
- Backend:
pytest backend/tests/ --cov=backend --cov-report=term-missing - Frontend:
pnpm --filter web test:coverage
TDD Flow (Always)
- Red: Write the test first — it MUST fail
- Green: Write minimal code to make it pass
- Refactor: Clean up without changing behavior
- Run the full suite to confirm no regressions
Backend Tests (pytest)
File Placement
backend/tests/
├── unit/ # Module-level logic, mocked dependencies
├── integration/ # Full endpoint tests via TestClient
└── ml/ # Model invariants, feature parity
Name: test_{module_name}.py — mirrors the source file.
Shared Fixtures (from conftest.py)
Use these — don't recreate them:
fixture_game(session): Full ATG game fromscripts/fixtures/v85_2026-03-07.jsonfixture_race(function): First race from fixture_gamefixture_horses(function): Horse entries from first racemock_intel(function):MagicMockofV85DatabaseIntelligencewith sensible zero-data defaults for all 30+ methodsclient(function):FastAPI TestClientwith mocked DB and settings
Patterns
Async endpoint/service test
@pytest.mark.asyncio
async def test_cache_miss_calls_predictor():
"""When cache is empty, predictor.predict() is called."""
import backend.api.predict as mod
mock_pred = MagicMock()
mock_pred._loaded = True
mock_pred.predict = AsyncMock(return_value=[{"horse_id": 1, "prob": 0.5}])
with patch.object(mod, "predictor", mock_pred), \
patch.object(mod, "cache_get", new_callable=AsyncMock, return_value=None), \
patch.object(mod, "cache_set", new_callable=AsyncMock):
result = await mod.predict_race("2026-03-15_1_1")
mock_pred.predict.assert_called_once()
assert result[0]["prob"] == 0.5
Pydantic validation
def test_invalid_race_id_rejected():
from pydantic import ValidationError
with pytest.raises(ValidationError):
PredictRequest(race_id="bad-id")
Integration endpoint
def test_endpoint_returns_200(client):
resp = client.get("/api/v1/races/today")
assert resp.status_code == 200
assert "legs" in resp.json()
ML invariant
def test_probabilities_sum_to_one(fixture_race, mock_intel):
probs = predictor.predict(fixture_race, mock_intel)
total = sum(p["win_probability"] for p in probs)
assert abs(total - 1.0) < 0.01, f"Probabilities sum to {total}, expected ~1.0"
Rules
asyncio_mode = "auto"— no need for@pytest.mark.asyncioon fixtures, but DO use it on async test functions- Use
AsyncMockfor any async return value - Use
patch.object(module, "name")— neverpatch("string.path")(brittle) - Test BOTH success AND failure paths (cache hit/miss, model loaded/not, 200/404/500)
fail_under = 100in pyproject.toml — every line must be covered or marked# pragma: no cover- Exclude lines:
if TYPE_CHECKING:,if __name__ == "__main__":,raise NotImplementedError
Frontend Tests (Vitest + RTL)
File Placement
Tests live next to source: ComponentName.test.tsx beside ComponentName.tsx.
Test Utilities (from lib/test-utils.tsx)
Use these factories — don't create ad-hoc objects:
createHorse(overrides?)— fullBackendHorsewith sensible defaultscreateScratchedHorse(overrides?)— scratched variantcreateFreeSession()/createProSession()/createAdminSession()— auth sessionscreateUnauthenticatedSession()— no auth
Required Mocks
Most components need these at the top of the file:
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), forward: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/',
}))
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
}))
Patterns
Component render + content assertion
it('renders horse name', () => {
const horse = createHorse({ horse: { name: 'Elitlansen', id: 101 } })
render(<HorseCard horse={horse} />)
expect(screen.getByText('Elitlansen')).toBeInTheDocument()
})
Negative assertion (element NOT present)
it('hides badge when scratched', () => {
const horse = createScratchedHorse()
render(<HorseCard horse={horse} />)
expect(screen.queryByText('FAVORIT')).not.toBeInTheDocument()
})
User interaction
it('calls onToggle when clicked', () => {
const onToggle = vi.fn()
render(<HorseCard horse={createHorse()} onToggle={onToggle} />)
fireEvent.click(screen.getByText('Elitlansen').closest('[class*="rounded-lg"]')!)
expect(onToggle).toHaveBeenCalledTimes(1)
})
API/fetch mocking
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
it('fetches data with auth header', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
}))
await api.get('/api/v1/races', { token: 'jwt-123' })
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer jwt-123' }),
}),
)
})
Rules
vi.mock()calls must be at file top (before imports that use the mocked module)- Always
vi.restoreAllMocks()inafterEach - Use
screen.getByText()/screen.getByRole()— nevercontainer.querySelector()(fragile) - Use
queryByText()for "should NOT exist" — returns null instead of throwing - Use
expect.objectContaining()for partial object matching - Import alias:
@/lib/...maps to./lib/...
E2E Tests (Playwright)
File Placement
apps/web/e2e/{feature}.spec.ts
Patterns
import { test, expect } from '@playwright/test'
test.describe('Game page interactions', () => {
test('loads V85 game with predictions', async ({ page }) => {
await page.goto('/spel/V85')
await expect(page.locator('h1')).toBeVisible()
await expect(page).toHaveURL(/spel\/V85/)
})
test('navigates between legs', async ({ page }) => {
await page.goto('/spel/V85')
const tab = page.getByRole('tab', { name: 'Avd 2' })
await tab.click()
await expect(tab).toHaveAttribute('aria-selected', 'true')
})
})
Rules
- Tests run on 3 browsers: chromium, firefox, mobile-chrome
baseURL = http://localhost:3000— dev server auto-starts locally- Use
page.locator()for CSS,page.getByRole()for semantic elements - Always
awaitinteractions and assertions - Traces on first retry, screenshots only on failure
Checklist Before Done
- All new tests pass: run the relevant suite
- No existing tests broken: run the full suite
- Coverage check: run with
--cov/test:coverage - Test names are descriptive:
test_cache_miss_returns_fresh_predictionnottest_1 - Both happy path AND error path covered
- Factories/fixtures used (no inline 50-line objects)