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) + @testing-library/jest-dom matchers
  • Component testing: @testing-library/svelte
  • Environment: jsdom (simulated DOM)
  • Coverage: v8 provider

Component cleanup between tests is handled automatically by the @testing-library/svelte Vite plugin (svelteTesting()), wired in vitest.config.ts alongside the svelte() plugin. The setup file (src/tests/setup.ts) only extends expect with the jest-dom matchers and mocks crypto.randomUUID / window.matchMedia.

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 -- project-session.svelte.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

Tests live next to the code they cover. Reactive state modules and services use the .svelte.ts suffix, so their tests are *.svelte.test.ts; component tests are *.svelte.test.ts beside each .svelte file.

src/
├── lib/
│   ├── schemas.test.ts            # Zod schema validation   ├── validation.test.ts         # JSON schema validation (AJV)   └── panzoom.test.ts            # Viewport transform math
├── state/
│   ├── project-session.svelte.test.ts   # Project session (document + dirty tracking)   ├── machine-session.svelte.test.ts   # Marlin streaming session   ├── preview-session.svelte.test.ts   # Preview generation   └── ...                              # ui-state, theme, notifications, cli-health
├── services/
│   └── file-operations.svelte.test.ts   # File open/save/export flows
├── types/
│   └── converters.test.ts         # Document ↔ wind-definition conversion
└── components/
    ├── forms/MandrelForm.svelte.test.ts
    ├── layers/LayerList.svelte.test.ts
    └── ...                        # editors, dialogs, machine panels

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");
    }
  });
});

State Tests

Purpose: Verify reactive state classes update correctly. Because each state module exports its class, tests instantiate a fresh, isolated instance.

import { describe, it, expect, beforeEach } from "vitest";
import { ProjectSession, createEmptyDocument } from "./project-session.svelte";

describe("ProjectSession", () => {
  let session: ProjectSession;
  beforeEach(() => {
    session = new ProjectSession();
  });

  it("starts from the canonical empty document", () => {
    expect(session.document).toEqual(createEmptyDocument());
    expect(session.document.mandrel.diameter).toBe(150);
  });

  it("is not dirty until a mutation, then dirty until saved", () => {
    expect(session.isDirty).toBe(false);
    session.updateMandrel({ diameter: 200 });
    expect(session.isDirty).toBe(true);
    session.markSaved();
    expect(session.isDirty).toBe(false);
  });
});

.svelte.test.ts files run through the Svelte compiler, so runes ($state, $derived) work inside the class under test — that's why state tests carry the .svelte suffix.

Component Tests

Purpose: Verify Svelte components render correctly and handle interactions. Pass the component (and any props) to render; drive the shared state singleton and reset it in beforeEach.

import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import MandrelForm from "./MandrelForm.svelte";
import { projectSession } from "../../state/project-session.svelte";

beforeEach(() => {
  projectSession.newDocument();
});

describe("MandrelForm.svelte", () => {
  it("shows the current diameter from the session", () => {
    projectSession.document.mandrel.diameter = 200;
    render(MandrelForm);
    const input = screen.getByLabelText("Diameter") as HTMLInputElement;
    expect(input.value).toBe("200");
  });

  it("updates the mandrel when the diameter input changes", async () => {
    render(MandrelForm);
    const input = screen.getByLabelText("Diameter");
    await fireEvent.input(input, { target: { value: "180" } });
    expect(projectSession.document.mandrel.diameter).toBe(180);
  });
});

For props, pass them as the second argument: render(NumberField, { id, label, value, oninput }).

Integration Tests

Purpose: Test complete flows across state, services, and conversion.

import { describe, it, expect, beforeEach } from "vitest";
import { projectSession } from "../state/project-session.svelte";

describe("Prepare workflow", () => {
  beforeEach(() => projectSession.newDocument());

  it("creates a project, adds a layer, and updates the mandrel", () => {
    const id = projectSession.addLayer("helical");
    projectSession.updateLayer(id, { helical: { wind_angle: 45 } as any });
    projectSession.updateMandrel({ diameter: 200, wind_length: 1000 });

    expect(projectSession.document.layers).toHaveLength(1);
    expect(projectSession.document.mandrel.diameter).toBe(200);
    expect(projectSession.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
  ];
  validCases.forEach((data, i) => {
    it(`should accept valid case ${i + 1}`, () => {
      expect(HelicalLayerSchema.safeParse(data).success).toBe(true);
    });
  });
  invalidCases.forEach((data, i) => {
    it(`should reject invalid case ${i + 1}`, () => {
      expect(HelicalLayerSchema.safeParse(data).success).toBe(false);
    });
  });
});

Mocking Tauri Commands

When testing state/services that call Tauri commands, mock @tauri-apps/api/core:

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

it("streams via the Marlin bridge", async () => {
  vi.mocked(invoke).mockResolvedValue({ /* StreamSummary */ });
  await streamProgram("out.gcode", { baudRate: 115200, dryRun: true });
  expect(invoke).toHaveBeenCalledWith(
    "stream_program",
    expect.objectContaining({ gcodePath: "out.gcode" }),
  );
});

Compute wrappers are tested against a mocked openapi-fetch client — see CLI Integration.

Testing Error Handling

Verify components surface errors:

it("shows a backend validation error for diameter", () => {
  projectSession.setValidationError("mandrel.diameter", "Too small");
  render(MandrelForm);
  expect(screen.getByText("Too small")).toBeInTheDocument();
});

Testing Debounced Behavior

Use waitFor to assert state that lands after a debounce window:

import { waitFor } from "@testing-library/svelte";
it("live-validates after the debounce window", async () => {
  render(MandrelForm);
  await fireEvent.input(screen.getByLabelText("Diameter"), { target: { value: "0" } });
  await waitFor(() =>
    expect(screen.getByText("Diameter must be greater than 0")).toBeInTheDocument(),
  );
});

Coverage Goals

Coverage thresholds are enforced in vitest.config.ts:

  • Lines: 60%+
  • Functions: 65%+
  • Branches: 50%+
  • Statements: 60%+

Critical Areas (high coverage expected)

  • Schema validation (src/lib/schemas.ts)
  • Error handling (src/lib/validation.ts)
  • Project session (src/state/project-session.svelte.ts)

Lower Priority

  • Presentational components (focus on critical paths)
  • Type definitions

Debugging Tests

View Test Output

npm test -- --reporter=verbose

Debug Single Test

it.only("should validate mandrel", () => {
  // This is the only test that will run
});
it("updates state", () => {
  session.updateMandrel({ diameter: 200 });
  console.log("Document:", session.document);
  expect(session.document.mandrel.diameter).toBe(200);
});

Use Vitest UI

npm test -- --ui

Opens a browser UI showing the test hierarchy, pass/fail status, console output, coverage, and re-run buttons.

CI Integration

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

- name: Run tests
  run: npm test -- --run

PR Requirements:

  • All tests must pass
  • No new tsc/svelte-check type errors
  • Coverage must not drop below the configured thresholds

Common Issues

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

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

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

Runes don't work in a test

Solution: Code using $state/$derived must compile through Svelte. Name the file *.svelte.test.ts (and the module under test *.svelte.ts) so the Svelte plugin processes it.

Tests fail but the app works

Solution: You may be asserting implementation details. Prefer visible behavior:

// Avoid: asserting private internals
// Prefer: asserting rendered output
expect(screen.getByText("Count: 1")).toBeInTheDocument();

Mock not working

Solution: Ensure vi.mock is hoisted above imports:

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

Next Steps