Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-testing-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add Vitest testing infrastructure with example tests and contributor documentation
18 changes: 18 additions & 0 deletions .github/workflows/quality-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ jobs:
- name: Run Knip
run: pnpm run knip

tests:
name: Tests
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup app
uses: ./.github/actions/setup

- name: Run tests
run: pnpm run test:run

build:
name: Build
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
experiment
dist
coverage
node_modules
devAssets

Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ It is not always possible to phrase every change in such a manner, but it is des

Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.

**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, and `knip` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request:
**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, `knip`, and `tests` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request:

- `pnpm run fmt:check`
- `pnpm run lint`
- `pnpm run typecheck`
- `pnpm run knip`
- `pnpm run test:run`

If your change touches logic with testable behaviour, please include tests. See [docs/TESTING.md](./docs/TESTING.md) for a guide on how to write them.

## Restrictions on Generative AI Usage

Expand Down
137 changes: 137 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Testing Guide

Sable uses [Vitest](https://vitest.dev/) as its test runner and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for component tests. Tests run in a [jsdom](https://github.com/jsdom/jsdom) environment and coverage is collected via V8.

---

## Running tests

```sh
# Watch mode — reruns affected tests on file save (recommended during development)
pnpm test

# Single run — equivalent to what CI runs
pnpm test:run

# With browser UI (interactive results viewer)
pnpm test:ui

# With coverage report
pnpm test:coverage
```

Coverage reports are written to `coverage/`. Open `coverage/index.html` in your browser for the full HTML report.

---

## Writing tests

### Where to put test files

Place test files next to the source file they cover, with a `.test.ts` or `.test.tsx` suffix:

```
src/app/utils/colorMXID.ts
src/app/utils/colorMXID.test.ts

src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomTimeline.test.tsx
```

### Testing plain utility functions

For pure functions in `src/app/utils/`, no special setup is needed — just import and assert:

```ts
import { describe, it, expect } from 'vitest';
import { bytesToSize } from './common';

describe('bytesToSize', () => {
it('converts bytes to KB', () => {
expect(bytesToSize(1500)).toBe('1.5 KB');
});
});
```

### Testing React components

Use `@testing-library/react` to render components inside the jsdom environment. Query by accessible role/text rather than CSS classes or implementation details:

```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MyButton } from './MyButton';

describe('MyButton', () => {
it('calls onClick when pressed', async () => {
const user = userEvent.setup();
const onClick = vi.fn();

render(<MyButton onClick={onClick}>Click me</MyButton>);

await user.click(screen.getByRole('button', { name: 'Click me' }));

expect(onClick).toHaveBeenCalledOnce();
});
});
```

### Testing hooks

Use `renderHook` from `@testing-library/react`:

```ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMyHook } from './useMyHook';

describe('useMyHook', () => {
it('returns the expected initial value', () => {
const { result } = renderHook(() => useMyHook());
expect(result.current.value).toBe(0);
});
});
```

### Mocking

Vitest has Jest-compatible mocking APIs:

```ts
import { vi } from 'vitest';

// Mock a module
vi.mock('./someModule', () => ({ doThing: vi.fn(() => 'mocked') }));

// Spy on a method
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});

// Restore after test
afterEach(() => vi.restoreAllMocks());
```

### Path aliases

All the project's path aliases work inside tests — you can import using `$utils/`, `$components/`, `$features/`, etc., just like in application code.

---

## What to test

Not every file needs tests. Focus on logic that would be painful to debug when broken:

| Worth testing | Less valuable |
| ----------------------------------------------- | ---------------------------------------------- |
| Pure utility functions (`src/app/utils/`) | Purely presentational components with no logic |
| Custom hooks with non-trivial state transitions | Thin wrappers around third-party APIs |
| State atoms and reducers | Generated or declarative config |
| Data transformation / formatting functions | |

When you fix a bug, consider adding a regression test that would have caught it — the description in the test is useful documentation.

---

## CI

`pnpm test:run` is part of the required quality checks and runs on every pull request alongside `lint`, `typecheck`, and `knip`. A PR with failing tests cannot be merged.
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"ignoreDependencies": [
"buffer",
"@element-hq/element-call-embedded",
"@matrix-org/matrix-sdk-crypto-wasm"
"@matrix-org/matrix-sdk-crypto-wasm",
"@testing-library/user-event"
],
"ignoreBinaries": ["knope"],
"rules": {
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
"typecheck": "tsc",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"knip": "knip",
"knope": "knope",
"document-change": "knope document-change",
Expand Down Expand Up @@ -95,6 +99,9 @@
"@eslint/js": "9.39.3",
"@rollup/plugin-inject": "^5.0.5",
"@rollup/plugin-wasm": "^6.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chroma-js": "^3.1.2",
"@types/file-saver": "^2.0.7",
"@types/is-hotkey": "^0.1.10",
Expand All @@ -106,12 +113,15 @@
"@types/sanitize-html": "^2.16.0",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"buffer": "^6.0.3",
"eslint": "9.39.3",
"eslint-config-airbnb-extended": "3.0.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.5",
"globals": "17.3.0",
"jsdom": "^29.0.0",
"knip": "5.85.0",
"prettier": "3.8.1",
"typescript": "^5.9.3",
Expand All @@ -121,6 +131,7 @@
"vite-plugin-static-copy": "^3.2.0",
"vite-plugin-svgr": "4.5.0",
"vite-plugin-top-level-await": "^1.6.0",
"vitest": "^4.1.0",
"wrangler": "^4.70.0"
}
}
Loading
Loading