Naalya Handbook

Testing

How tests run with Vitest and React Testing Library, where they live, and how to write a simple one.

Tests in Education Hub aren't a separate project with its own toolchain — they ride the same Vite build you already use for dev. The test runner is Vitest, which means the path aliases, TypeScript config, and module resolution that work in the app work in tests too, with zero extra setup. You write a .test.ts file next to the code it covers, and bun test picks it up.

The goal of this page is small and practical — show you how to run the suite, where tests live, and what a good one looks like so you can add your own without copying ceremony from somewhere else.

Running tests

There's one command, defined in package.json:

package.json
"scripts": {
  "test": "vitest run"
}

vitest run is one-shot — it runs every test once and exits with a pass/fail code, which is exactly what you want in CI and in a quick local check:

terminal
bun test

If you're iterating on a single file and want it to re-run on every save, drop the run and point Vitest at a path:

terminal
bunx vitest src/lib/theme/gradient.test.ts

bun test runs Vitest, not Bun's own runner

The test script explicitly calls vitest run, so bun test is just a thin wrapper around Vitest. Don't reach for Bun's built-in test runner — the project standardizes on Vitest so tests share Vite's resolver and the @/ alias.

Where tests live

Tests are co-located — a test sits in the same folder as the code it exercises, named *.test.ts (or *.test.tsx when it renders a component). There's no top-level tests/ directory to keep in sync. Open any folder and the test is right there next to its subject:

co-located test files
src/lib/theme/gradient.ts
src/lib/theme/gradient.test.ts          // tests gradient.ts
src/components/cbt/available-streams.ts
src/components/cbt/available-streams.test.ts
src/queries/classroom/interfaces/my-subjects.test.ts

This keeps the unit and its test moving together — rename or delete the feature and the test goes with it. The pattern to follow when you add one: same directory, same base name, .test before the extension.

A simple test

Most of the suite today tests pure functions — the data-shaping helpers that sit under your queries and components. That's deliberate: pure logic is the cheapest, most valuable thing to lock down, because it has no DOM, no network, and no React to wrestle with. Here's the real shape, trimmed:

src/components/cbt/available-streams.test.ts
import { describe, expect, it } from 'vitest'
import { classesForSubject } from './available-streams'

describe('classesForSubject', () => {
  it('returns unique classes sorted by name', () => {
    const rows = [/* ...SubjectXClassDto rows... */]
    const result = classesForSubject(rows)
    expect(result[0]).toEqual({ id: 'c1', name: 'Alpha' })
  })
})

Three imports from vitest (describe, it, expect), a describe block per function, an it per behavior — including the edge cases, like empty input and missing relations. When you write a new data helper for the data layer, this is the template: feed it rows, assert the output.

Testing a component

When you do need to render UI, the project ships React Testing Library and a jsdom environment (both are already in devDependencies). The mindset is the same as the rest of the app — query the way a user would, by role and text, not by class names or test ids:

src/components/example/badge.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StatusBadge } from './status-badge'

describe('StatusBadge', () => {
  it('shows the status label', () => {
    render(<StatusBadge status="active" />)
    expect(screen.getByText('Active')).toBeInTheDocument()
  })
})

render mounts the component into jsdom; screen.getByRole/getByText find elements; expect asserts. Reach for getByRole('button', { name: 'Save' }) when you can — it's the closest thing to how a real user (and a screen reader) sees the page.

Components that read context need a wrapper

A component that calls useQuery, useRouter, or any other provider-backed hook will throw if you render it bare. Wrap it in a small harness first — a QueryClientProvider with a fresh QueryClient, plus a router if it navigates — and pass that as the wrapper option to render. Keep the harness tiny and local to the test; you're isolating the unit, not booting the whole app.

Where to go next

On this page