Skip to content

Testing Guide

Comprehensive testing documentation for FiberPath GUI test suite.

Test Stack

  • Framework: Vitest (Vite-native test runner)
  • Assertions: Expect API (Jest-compatible)
  • React Testing: @testing-library/react
  • Environment: jsdom (simulated DOM)
  • Coverage: v8 provider

Running Tests

All Tests

npm test

Runs all tests in src/**/*.{test,spec}.{ts,tsx} and displays summary.

Watch Mode

npm test -- --watch

Re-runs tests on file changes. Useful during development.

Specific Test File

npm test -- schemas.test.ts
npm test -- validation.test.ts
npm test -- projectStore.test.ts

With Coverage

npm test -- --coverage

Generates coverage report in coverage/ directory.

UI Mode

npm test -- --ui

Opens interactive test UI in browser for exploring tests and results.

Test Organization

Current Test Suite

src/
├── lib/
│   ├── schemas.test.ts        # 43 tests - Zod schema validation   └── validation.test.ts     # JSON schema validation (AJV)
├── state/
│   └── projectStore.test.ts   # Zustand store actions
├── types/
│   └── converters.test.ts     # Type conversion utilities
└── tests/
    └── integration/
        └── workflows.test.ts  # End-to-end workflows

Test Counts

  • Schema validation: 43 tests (Zod runtime validation)
  • State management: ~15 tests (store actions)
  • Validation: ~25 tests (JSON schema)
  • Type converters: ~10 tests
  • Integration: ~5 tests

Total: ~100 tests, all passing ✅

Writing Tests

Schema Validation Tests

Purpose: Verify Zod schemas accept valid data and reject invalid data.

import { describe, it, expect } from "vitest";
import { MandrelParametersSchema } from "./schemas";

describe("MandrelParametersSchema", () => {
  it("should validate correct mandrel parameters", () => {
    const valid = {
      diameter: 150,
      windLength: 800,
    };

    const result = MandrelParametersSchema.safeParse(valid);
    expect(result.success).toBe(true);
  });

  it("should reject negative diameter", () => {
    const invalid = {
      diameter: -10,
      windLength: 800,
    };

    const result = MandrelParametersSchema.safeParse(invalid);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].message).toContain("positive");
    }
  });
});

Store Tests

Purpose: Verify Zustand actions correctly update state.

import { describe, it, expect, beforeEach } from "vitest";
import { useProjectStore } from "./projectStore";

describe("projectStore", () => {
  beforeEach(() => {
    // Reset store to initial state
    useProjectStore.setState({
      project: null,
      activeLayerId: null,
      isDirty: false,
      filePath: null,
    });
  });

  it("should create new project", () => {
    const store = useProjectStore.getState();
    store.newProject();

    expect(store.project).not.toBeNull();
    expect(store.project?.layers).toHaveLength(1);
    expect(store.isDirty).toBe(false);
  });

  it("should mark project as dirty after update", () => {
    const store = useProjectStore.getState();
    store.newProject();
    store.updateMandrel({ diameter: 200 });

    expect(store.isDirty).toBe(true);
  });
});

Component Tests

Purpose: Verify React components render correctly and handle interactions.

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MyComponent } from './MyComponent';

describe('MyComponent', () => {
  it('should render with props', () => {
    render(<MyComponent title="Test" />);
    expect(screen.getByText('Test')).toBeInTheDocument();
  });

  it('should handle click', () => {
    const onClick = vi.fn();
    render(<MyComponent onClick={onClick} />);

    fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledOnce();
  });
});

Integration Tests

Purpose: Test complete workflows across multiple components/stores.

import { describe, it, expect } from "vitest";
import { useProjectStore } from "../state/projectStore";

describe("Plan Workflow", () => {
  it("should create project, add layer, and update mandrel", () => {
    const store = useProjectStore.getState();

    // Step 1: New project
    store.newProject();
    expect(store.project).not.toBeNull();

    // Step 2: Add helical layer
    store.addLayer({
      windType: "helical",
      windAngle: 45,
      terminal: false,
    });
    expect(store.project?.layers).toHaveLength(2);

    // Step 3: Update mandrel
    store.updateMandrel({ diameter: 200, windLength: 1000 });
    expect(store.project?.mandrelParameters.diameter).toBe(200);

    // Verify dirty state
    expect(store.isDirty).toBe(true);
  });
});

Test Patterns

Valid/Invalid Data Pairs

For every schema, test both valid and invalid inputs:

describe("HelicalLayerSchema", () => {
  const validCases = [
    { windAngle: 45, terminal: false },
    { windAngle: 30, terminal: true, skipEvery: 2 },
  ];

  const invalidCases = [
    { windAngle: 100, terminal: false }, // Angle > 90
    { windAngle: -10, terminal: false }, // Negative angle
    { windAngle: 45 }, // Missing terminal
  ];

  validCases.forEach((data, i) => {
    it(`should accept valid case ${i + 1}`, () => {
      const result = HelicalLayerSchema.safeParse(data);
      expect(result.success).toBe(true);
    });
  });

  invalidCases.forEach((data, i) => {
    it(`should reject invalid case ${i + 1}`, () => {
      const result = HelicalLayerSchema.safeParse(data);
      expect(result.success).toBe(false);
    });
  });
});

Mocking Tauri Commands

When testing components that call Tauri commands:

import { vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";

vi.mock("@tauri-apps/api/core", () => ({
  invoke: vi.fn(),
}));

it("should call plan command", async () => {
  vi.mocked(invoke).mockResolvedValue({ success: true });

  const result = await planProject(projectData);

  expect(invoke).toHaveBeenCalledWith("plan_project", {
    windDef: expect.any(Object),
  });
});

Testing Error Handling

Verify components handle errors gracefully:

it('should display error message on validation failure', () => {
  const invalidData = { diameter: -10 };

  render(<MandrelForm initialData={invalidData} />);

  expect(screen.getByText(/diameter must be positive/i)).toBeInTheDocument();
});

Coverage Goals

Target Coverage

  • Statements: 80%+
  • Branches: 75%+
  • Functions: 80%+
  • Lines: 80%+

Critical Areas (100% coverage required)

  • Schema validation (src/lib/schemas.ts)
  • Error handling (src/lib/validation.ts)
  • State management (src/state/projectStore.ts)

Lower Priority (50%+ acceptable)

  • UI components (focus on critical paths)
  • Styling modules
  • Type definitions

Debugging Tests

View Test Output

npm test -- --reporter=verbose

Shows individual test names and durations.

Debug Single Test

Add .only to focus one test:

it.only("should validate mandrel", () => {
  // This is the only test that will run
});
it("should update state", () => {
  store.updateMandrel({ diameter: 200 });

  console.log("State:", store.project); // Visible in test output

  expect(store.project?.mandrelParameters.diameter).toBe(200);
});

Use Vitest UI

npm test -- --ui

Opens browser UI showing:

  • Test hierarchy
  • Pass/fail status
  • Console output
  • Code coverage
  • Re-run buttons

CI Integration

Tests run automatically on every push and PR via GitHub Actions:

# .github/workflows/test.yml
- name: Run tests
  run: npm test -- --run

PR Requirements:

  • ✅ All tests must pass
  • ✅ No new TypeScript errors
  • ✅ Coverage must not decrease

Common Issues

"Cannot find module '@/lib/schemas'"

Solution: Check path alias in vitest.config.ts:

resolve: {
  alias: {
    '@': path.resolve(__dirname, './src'),
  },
}

Tests fail but code works

Solution: May be testing implementation details. Focus on behavior:

// Bad: Testing internal state
expect(component.state.count).toBe(1);

// Good: Testing visible behavior
expect(screen.getByText("Count: 1")).toBeInTheDocument();

Mock not working

Solution: Ensure mock is hoisted before imports:

vi.mock("@tauri-apps/api/core"); // Must be at top

import { MyComponent } from "./MyComponent";

Next Steps