Test Framework
Glyph uses Vitest for frontend testing:
- Fast execution (powered by Vite)
- Jest-compatible API
- Native TypeScript support
- Watch mode with HMR
Note
Rust backend tests use Rust’s built-in cargo test framework.
Running Tests
All Tests
pnpm test
# Runs all tests once and exitsWatch Mode
pnpm test:watch
# Re-runs tests on file changes
# Shows interactive UISingle Test File
pnpm test -- src/lib/diff.test.ts
# Only runs tests in diff.test.tsSingle Test Case
pnpm test -- -t "computes line diff"
# Runs tests matching the nameCoverage Report
pnpm test -- --coverage
# Generates coverage report in coverage/Frontend Test Structure
Test File Naming
- Place tests next to source:
utils.ts→utils.test.ts - Use
.test.tsor.test.tsxextension - Integration tests:
.integration.test.ts
Example Test
import { describe, it, expect } from 'vitest';
import { computeLineDiff } from './diff';
describe('computeLineDiff', () => {
it('computes line diff for simple change', () => {
const oldText = 'Hello\nWorld';
const newText = 'Hello\nGlyph';
const diff = computeLineDiff(oldText, newText);
expect(diff).toEqual([
{ type: 'unchanged', value: 'Hello' },
{ type: 'removed', value: 'World' },
{ type: 'added', value: 'Glyph' }
]);
});
it('handles empty strings', () => {
expect(computeLineDiff('', '')).toEqual([]);
});
});Testing Patterns
Utility Functions
import { parentDir } from './path';
it('extracts parent directory', () => {
expect(parentDir('notes/daily/2024-03-15.md')).toBe('notes/daily');
expect(parentDir('notes/example.md')).toBe('notes');
expect(parentDir('example.md')).toBe('');
});it('handles edge cases', () => {
expect(parentDir('')).toBe('');
expect(parentDir('/')).toBe('');
expect(parentDir('no-slash')).toBe('');
});React Hooks
import { renderHook, waitFor } from '@testing-library/react';
import { useFileTree } from './useFileTree';
it('loads directory entries', async () => {
const { result } = renderHook(() => useFileTree({
spacePath: '/test/space',
// ... other deps
}));
await result.current.loadDir('notes');
await waitFor(() => {
expect(result.current.entries).toHaveLength(3);
});
});TipTap Extensions
import { describe, it, expect } from 'vitest';
import { createEditor } from '@tiptap/core';
import { WikiLink } from './wikiLink';
describe('WikiLink extension', () => {
it('parses [[wiki links]]', () => {
const editor = createEditor({
extensions: [WikiLink],
content: 'See [[example-note]] for details'
});
const json = editor.getJSON();
expect(json.content[0].content[1].type).toBe('wikiLink');
expect(json.content[0].content[1].attrs.target).toBe('example-note');
});
});Mocking Tauri Commands
import { vi } from 'vitest';
// Mock the invoke function
export const mockInvoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke
}));import { mockInvoke } from './tauri.mock';
import { invoke } from '@/lib/tauri';
it('calls space_open command', async () => {
mockInvoke.mockResolvedValueOnce({
root: '/path/to/space',
schema_version: 1
});
const result = await invoke('space_open', { path: '/path/to/space' });
expect(mockInvoke).toHaveBeenCalledWith('space_open', { path: '/path/to/space' });
expect(result.root).toBe('/path/to/space');
});Rust Testing
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_join_under_safe_path() {
let base = PathBuf::from("/space");
let result = join_under(&base, "notes/example.md");
assert_eq!(result.unwrap(), PathBuf::from("/space/notes/example.md"));
}
#[test]
fn test_join_under_rejects_traversal() {
let base = PathBuf::from("/space");
let result = join_under(&base, "../../../etc/passwd");
assert!(result.is_err());
}
}Run with:
cd src-tauri
cargo testIntegration Tests
use glyph_lib::space;
#[test]
fn test_create_and_open_space() {
let temp_dir = tempdir().unwrap();
let space_path = temp_dir.path().to_str().unwrap();
// Create space
let info = space::space_create(space_path.to_string()).unwrap();
assert_eq!(info.schema_version, 1);
// Verify structure
assert!(temp_dir.path().join("notes").exists());
assert!(temp_dir.path().join("assets").exists());
assert!(temp_dir.path().join("space.json").exists());
}Test Coverage
Current Coverage
Run coverage report:
pnpm test -- --coverageOutput:
---------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------------|---------|----------|---------|---------|-------------------
All files | 67.82 | 58.33 | 71.43 | 67.82 |
src/lib/diff.ts | 100 | 100 | 100 | 100 |
src/lib/path.ts | 85.71 | 66.67 | 100 | 85.71 | 12-15
src/utils/ | 45.23 | 33.33 | 50.00 | 45.23 |
---------------------|---------|----------|---------|---------|-------------------Coverage Goals
- Utilities: 90%+ coverage (pure functions)
- Hooks: 70%+ coverage (harder to test)
- Components: 50%+ coverage (UI-heavy)
- Integration: Key workflows covered
Test Organization
Tested Modules
src/lib/diff.test.ts- Text diffingsrc/lib/shortcuts.test.ts- Keyboard shortcutssrc/lib/notePreview.test.ts- Preview generationsrc/lib/errorUtils.test.ts- Error handlingsrc/utils/path.test.ts- Path utilities
src/components/editor/extensions/wikiLink.integration.test.tssrc/components/editor/extensions/markdownImage.integration.test.tssrc/components/editor/extensions/table.integration.test.tssrc/components/editor/markdown/wikiLinkCodec.test.ts
src/lib/database/config.test.ts- Config validationsrc/lib/database/board.test.ts- Board layoutsrc/hooks/database/useDatabaseTable.test.ts
src/hooks/fileTreeHelpers.test.tssrc/lib/canvasLayout.test.ts
Writing New Tests
Step 1: Create Test File
# Create next to source file
touch src/lib/myfeature.test.tsStep 2: Import Vitest
import { describe, it, expect, beforeEach, afterEach } from 'vitest';Step 3: Group Tests
describe('MyFeature', () => {
describe('basic functionality', () => {
it('does something', () => {
// Test code
});
});
describe('edge cases', () => {
it('handles empty input', () => {
// Test code
});
});
});Step 4: Write Assertions
expect(value).toBe(42);
expect(object).toEqual({ key: 'value' });
expect(array).toHaveLength(3);expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();expect(text).toContain('substring');
expect(text).toMatch(/regex/);expect(array).toContain(item);
expect(array).toContainEqual({ key: 'value' });expect(() => dangerousFunction()).toThrow();
expect(() => dangerousFunction()).toThrow('Error message');Continuous Integration
Tests run on every PR via GitHub Actions:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 10.28.2
- uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- run: pnpm install
- run: pnpm test
- run: cd src-tauri && cargo testBest Practices
Test behavior, not implementation
Focus on what the function does, not how it does it.
it('filters markdown files', () => {
const files = ['a.md', 'b.txt', 'c.md'];
expect(filterMarkdown(files)).toEqual(['a.md', 'c.md']);
});it('uses Array.filter internally', () => {
const spy = vi.spyOn(Array.prototype, 'filter');
filterMarkdown(['a.md']);
expect(spy).toHaveBeenCalled(); // Too implementation-specific
});One assertion per test (generally)
Each test should verify one thing.
it('parses frontmatter title', () => {
expect(parseFrontmatter('---\ntitle: Hello\n---').title).toBe('Hello');
});
it('parses frontmatter tags', () => {
expect(parseFrontmatter('---\ntags: [a, b]\n---').tags).toEqual(['a', 'b']);
});Use descriptive test names
Test name should explain what’s being tested.
it('rejects path traversal with ../', () => { ... });it('works', () => { ... });Test edge cases
Always test boundary conditions.
describe('splitLines', () => {
it('handles empty string', () => {
expect(splitLines('')).toEqual([]);
});
it('handles single line', () => {
expect(splitLines('hello')).toEqual(['hello']);
});
it('handles multiple lines', () => {
expect(splitLines('a\nb\nc')).toEqual(['a', 'b', 'c']);
});
});