---
title: "Testing a Rayfin app"
description: "Test a Rayfin app's data and auth logic in Vitest without a live backend, using a swappable auth service and an in-memory fallback."
url: https://rayfin.ai/docs/recipes/testing
markdown_url: https://rayfin.ai/docs/recipes/testing.md
section: recipes
product: Rayfin
sdk_version: 1.34.0
cli_version: 1.33.2
last_updated: 2026-08-23T01:28:43-07:00
source: recipes/testing.mdx
---

# Testing a Rayfin app

> Test a Rayfin app's data and auth logic in Vitest without a live backend, using a swappable auth service and an in-memory fallback.

A Rayfin frontend can be fully tested — data operations and auth-gated UI included —
without a deployed backend or network access. This walkthrough uses two seams already
present in a scaffolded app: an injectable auth service, and a local-mode fallback in the
data layer.

```prompt title="Test Rayfin data and auth logic without a live backend"
In my Rayfin + React + Vite project, set up Vitest with jsdom (environment: 'jsdom') and a
setup file that shims localStorage on globalThis so the Rayfin auth client doesn't throw in
tests. My data service module (e.g. src/services/todos.ts) reads a getRayfinClient() plus
an isLocalBackend() flag from src/services/rayfinClient.ts and falls back to an in-memory
array when isLocalBackend() is true — in my tests, mock src/services/rayfinClient.ts with
vi.mock so isLocalBackend always returns true, forcing that in-memory path so no network
calls happen. My auth layer is injected through an IAuthService interface (signIn,
signOut, getCurrentUser, initEmbeddedAuth, fabricAuthEnabled) passed into an AuthProvider
component; in each test, construct a plain object implementing IAuthService with stubbed
async methods and pass it as the authService prop instead of a real implementation. Write
component tests with @testing-library/react that render through this stubbed AuthProvider
and assert on rendered UI and on spies over the data service functions.
```

## Test runner setup [#test-runner-setup]

Vitest runs in `jsdom` so React components can render, with a path alias matching the
app's own `@/*` imports and a setup file loaded before every test file:

```typescript title="vitest.config.ts"
import react from '@vitejs/plugin-react-swc';
import { resolve } from 'path';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': resolve(import.meta.dirname, 'src'),
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    exclude: ['node_modules', 'dist'],
    setupFiles: ['./src/__tests__/setup.ts'],
  },
});
```

## Global test setup [#global-test-setup]

`jsdom` doesn't provide a usable `localStorage`, and the Rayfin auth client reads and
writes session data through it — the setup file shims one so the client doesn't throw, and
clears it between tests so state doesn't leak across cases:

```typescript title="src/__tests__/setup.ts"
import '@testing-library/jest-dom';
import { beforeEach } from 'vitest';

// Minimal localStorage shim so the Rayfin auth client can read/write tokens
// inside jsdom without crashing.
const localStorageMock = {
  store: {} as Record<string, string>,
  getItem(key: string) {
    return this.store[key] ?? null;
  },
  setItem(key: string, value: string) {
    this.store[key] = String(value);
  },
  removeItem(key: string) {
    delete this.store[key];
  },
  clear() {
    this.store = {};
  },
};

Object.defineProperty(globalThis, 'localStorage', {
  value: localStorageMock,
  writable: true,
});

beforeEach(() => {
  localStorageMock.clear();
});
```

## The auth seam: IAuthService [#the-auth-seam-iauthservice]

Production code never imports `MockAuthService` or `RayfinAuthService` directly — it
depends on an `IAuthService` interface instead:

```typescript title="src/services/IAuthService.ts"
/** Trimmed view of the authenticated user shown in the UI. */
export interface AuthUser {
  id: string;
  email: string;
  name: string;
}

/**
 * Auth service contract used by the React layer.
 *
 * Two implementations ship with this template:
 *
 * - {@link MockAuthService} — used when the API URL points at localhost. Signs into
 *   a local backend with a fixture email and password — both out of scope for a
 *   Fabric-only app; see "What MockAuthService actually does" below.
 * - {@link RayfinAuthService} — used once deployed. Wraps the Fabric
 *   brokered auth flow from `@microsoft/rayfin-auth-provider-fabric`.
 *
 * `bootstrapAuth()` picks the right one from VITE_* env vars at startup.
 */
export interface IAuthService {
  /**
   * True when this service requires Fabric/Entra interactive sign-in.
   * The AuthPage uses this to choose its loading-state label.
   */
  readonly fabricAuthEnabled: boolean;

  /**
   * Acquire a session interactively. For Fabric this opens the broker
   * popup and must be called from a user-gesture handler.
   */
  signIn(): Promise<AuthUser>;

  signOut(): Promise<void>;

  /** Return the current session's user, or `null` if not signed in. */
  getCurrentUser(): Promise<AuthUser | null>;

  /**
   * Try to acquire a session via the embedded (iframe) Fabric flow without
   * any UI. Returns `null` when not running inside a Fabric iframe.
   */
  initEmbeddedAuth(): Promise<AuthUser | null>;
}
```

At runtime, `bootstrapAuth()` picks between the two real implementations based on where
the API URL points:

```typescript title="src/services/bootstrap.ts"
import type { IAuthService } from './IAuthService';
import { MockAuthService } from './MockAuthService';
import { RayfinAuthService } from './RayfinAuthService';
import { initRayfinClient } from './rayfinClient';

function isLocalBackendUrl(url: string): boolean {
  try {
    const { hostname } = new URL(url);
    return hostname === 'localhost' || hostname === '127.0.0.1';
  } catch {
    return false;
  }
}

/**
 * Read VITE_* env vars, initialize the Rayfin client, and return the right
 * auth service for the target backend.
 *
 * - Localhost API URL → {@link MockAuthService}
 * - Anything else     → {@link RayfinAuthService} (requires VITE_FABRIC_* vars)
 */
export function bootstrapAuth(): IAuthService {
  const apiUrl = import.meta.env.VITE_RAYFIN_API_URL || 'http://localhost:5168';
  const localDev = isLocalBackendUrl(apiUrl);
  const publishableKey = import.meta.env.VITE_RAYFIN_PUBLISHABLE_KEY;

  if (!publishableKey && !localDev) {
    throw new Error(
      'VITE_RAYFIN_PUBLISHABLE_KEY environment variable is required'
    );
  }

  const client = initRayfinClient({
    baseUrl: apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`,
    publishableKey: publishableKey ?? 'local-dev-key',
    localDev,
  });

  if (localDev) {
    return new MockAuthService(client);
  }

  const workspaceId = import.meta.env.VITE_FABRIC_WORKSPACE_ID;
  const projectId = import.meta.env.VITE_FABRIC_ITEM_ID;
  const fabricPortalUrl = import.meta.env.VITE_FABRIC_PORTAL_URL;

  if (!workspaceId || !projectId || !fabricPortalUrl) {
    throw new Error(
      'Missing required Fabric config. Set VITE_FABRIC_WORKSPACE_ID, VITE_FABRIC_ITEM_ID, and VITE_FABRIC_PORTAL_URL.'
    );
  }

  return new RayfinAuthService(client, {
    workspaceId,
    projectId,
    fabricPortalUrl,
    returnOrigin: window.location.origin,
  });
}
```

## What `MockAuthService` actually does [#what-mockauthservice-actually-does]

`bootstrapAuth()` picks `MockAuthService` whenever the configured API URL's hostname is
`localhost` or `127.0.0.1` — including the default it falls back to
(`http://localhost:5168`) when `VITE_RAYFIN_API_URL` is unset. In a fresh scaffold, that is
the path the app takes until you point it at a deployed Fabric backend.

`MockAuthService` is not an inert placeholder. Its `signIn()` authenticates with a fixture
credential pair — a hardcoded email and a hardcoded password — against whatever backend
the client was constructed with, and if that account doesn't exist yet on that backend, it
registers the account first, then retries:

```typescript title="src/services/MockAuthService.ts (scaffolded default)"
// Local-dev fixture credentials. The bundled local backend ships without
// Fabric/Entra, so this auth service signs in with a shared dev account.
// These values only ever reach a developer's local machine — never use
// them in production.
const MOCK_EMAIL = 'dev@contoso.com';
const MOCK_PASSWORD = 'LocalDev!Pass123';
```

That is a credential-based sign-in against a local backend — both out of scope for a
Fabric-only app (see [Fabric SSO is the only auth method](/docs/auth#fabric-sso-is-the-only-auth-method) —
Fabric SSO is the only supported authentication method, and a Rayfin app has no local
backend to sign in against). Leaving this file untouched means the app depends on that
unsupported path any time `VITE_RAYFIN_API_URL` is unset or resolves to
`localhost`/`127.0.0.1` — for example, in CI, or on a machine where the env file hasn't
been set up yet — and a reader who never opens `MockAuthService.ts` has no reason to know
that.

For a Fabric-only app, do one of:

* **Delete it.** Remove `MockAuthService.ts` and the `localDev` branch in `bootstrap.ts` so
  `bootstrapAuth()` always constructs `RayfinAuthService` (Fabric SSO).
* **Replace its body with an in-memory double.** Keep the class and the seam, but make it
  genuinely local — no `RayfinClient`, no request to any backend, just a fixed in-memory
  user:

```typescript title="src/services/MockAuthService.ts (in-memory replacement — calls no backend)"
import type { AuthUser, IAuthService } from './IAuthService';

const FAKE_USER: AuthUser = {
  id: 'local-dev-user',
  email: 'dev@example.com',
  name: 'Local Dev',
};

/**
 * Zero-backend stand-in for local iteration. Never calls a backend — there is
 * no email/password exchange and nothing here points at a server.
 */
export class MockAuthService implements IAuthService {
  readonly fabricAuthEnabled = false;
  private signedIn = false;

  async signIn(): Promise<AuthUser> {
    this.signedIn = true;
    return FAKE_USER;
  }

  async signOut(): Promise<void> {
    this.signedIn = false;
  }

  async getCurrentUser(): Promise<AuthUser | null> {
    return this.signedIn ? FAKE_USER : null;
  }

  async initEmbeddedAuth(): Promise<AuthUser | null> {
    return null;
  }
}
```

This keeps the exact seam the rest of this page tests against — `IAuthService` plus the
constructor injection in `bootstrapAuth()` — without shipping a credentialed sign-in or an
assumption that a local backend exists. It is the same shape as the ad hoc stub objects
constructed inline in the tests below; the difference is this one lives in
`src/services/` and is wired in by `bootstrapAuth()` instead of being constructed per test.

Tests skip `bootstrapAuth()` entirely. Because the app takes its auth service as a prop
(`<AuthProvider authService={...}>`), a test can construct a third, minimal implementation
— a plain object with stubbed async methods — and inject that instead:

```tsx
const stubAuthService: IAuthService = {
  fabricAuthEnabled: false,
  async signIn() {
    return { id: 'u1', email: 'dev@contoso.com', name: 'dev' };
  },
  async signOut() {},
  async getCurrentUser() {
    return null;
  },
  async initEmbeddedAuth() {
    return null;
  },
};

render(
  <AuthProvider authService={stubAuthService}>
    <YourComponent />
  </AuthProvider>
);
```

No real sign-in flow, browser popup, or network call happens — `AuthProvider` only ever
calls the methods on the interface, so a same-shaped stub is indistinguishable from a real
implementation as far as the component tree is concerned.

## The data seam: isLocalBackend() [#the-data-seam-islocalbackend]

`src/services/todos.ts` checks `isLocalBackend()` (from `rayfinClient.ts`) before every
operation, and falls back to an in-memory array when it's `true` — the same fallback that
lets the app run locally with no database configured. Tests force this path by mocking the
`rayfinClient` module itself, so no `RayfinClient` instance is ever constructed and no
request ever leaves the process:

```typescript title="src/__tests__/todos.test.ts"
import { describe, expect, it, vi, beforeEach } from 'vitest';

vi.mock('@/services/rayfinClient', () => ({
  isLocalBackend: () => true,
  getRayfinClient: vi.fn(),
}));

import { createTodo, deleteTodo, getTodos, updateTodo } from '@/services/todos';

describe('todos service (in-memory mode)', () => {
  beforeEach(async () => {
    // Drain any in-memory state left over from a previous test.
    for (const todo of await getTodos()) {
      await deleteTodo(todo.id);
    }
  });

  it('creates, lists, updates, and deletes todos', async () => {
    expect(await getTodos()).toEqual([]);

    const created = await createTodo('write tests');
    expect(created.title).toBe('write tests');
    expect(created.isCompleted).toBe(false);

    const list = await getTodos();
    expect(list).toHaveLength(1);
    expect(list[0]?.id).toBe(created.id);

    const updated = await updateTodo(created.id, { isCompleted: true });
    expect(updated.isCompleted).toBe(true);

    await deleteTodo(created.id);
    expect(await getTodos()).toEqual([]);
  });
});
```

`vi.mock('@/services/rayfinClient', ...)` must be declared before the `import` of the
module under test — Vitest hoists `vi.mock` calls to the top of the file, but keeping the
mock visually first avoids confusion about ordering.

## Testing a component that uses both seams [#testing-a-component-that-uses-both-seams]

A component test combines the stub `IAuthService` with the same `rayfinClient` mock, then
renders through `AuthProvider` and asserts on the UI plus spies over the service functions:

```tsx title="src/__tests__/HomePage.test.tsx"
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { AuthUser, IAuthService } from '@/services/IAuthService';

vi.mock('@/services/rayfinClient', () => ({
  isLocalBackend: () => true,
  getRayfinClient: vi.fn(),
}));

import { AuthProvider } from '@/hooks/AuthContext';
import { HomePage } from '@/pages/HomePage';
import * as todosService from '@/services/todos';

const stubUser: AuthUser = { id: 'u1', email: 'dev@contoso.com', name: 'dev' };

const stubAuthService: IAuthService = {
  fabricAuthEnabled: false,
  async signIn() {
    return stubUser;
  },
  async signOut() {},
  async getCurrentUser() {
    return stubUser;
  },
  async initEmbeddedAuth() {
    return stubUser;
  },
};

function renderHome() {
  return render(
    <AuthProvider authService={stubAuthService}>
      <HomePage />
    </AuthProvider>
  );
}

describe('HomePage', () => {
  beforeEach(async () => {
    for (const todo of await todosService.getTodos()) {
      await todosService.deleteTodo(todo.id);
    }
    vi.restoreAllMocks();
  });

  it('shows the empty state once loaded', async () => {
    renderHome();
    expect(await screen.findByText(/All caught up/i)).toBeInTheDocument();
  });

  it('adds a todo optimistically and renders it immediately', async () => {
    renderHome();
    await screen.findByText(/All caught up/i);

    const createSpy = vi.spyOn(todosService, 'createTodo');

    const input = screen.getByLabelText(/New todo title/i);
    fireEvent.change(input, { target: { value: 'buy milk' } });
    fireEvent.submit(input.closest('form')!);

    expect(screen.getByText('buy milk')).toBeInTheDocument();
    await waitFor(() => expect(createSpy).toHaveBeenCalledWith('buy milk'));
  });

  it('rolls back and shows an error when a mutation fails', async () => {
    await todosService.createTodo('rollback me');
    renderHome();

    await screen.findByText('rollback me');

    const failure = new Error('network down');
    const deleteSpy = vi
      .spyOn(todosService, 'deleteTodo')
      .mockRejectedValueOnce(failure);

    fireEvent.click(screen.getByLabelText(/Delete todo/i));

    expect(deleteSpy).toHaveBeenCalledTimes(1);

    await waitFor(() =>
      expect(screen.getByRole('alert')).toHaveTextContent('network down')
    );
    expect(screen.getByText('rollback me')).toBeInTheDocument();
  });
});
```

Notice `stubAuthService.signIn` and `getCurrentUser` return a fixed `stubUser` here, rather
than `null` — that signs the component tree in immediately on mount, so tests can assert on
the authenticated `HomePage` UI without simulating a sign-in click first. Spying on
`todosService.createTodo`/`deleteTodo` (rather than re-testing `todos.ts` itself, already
covered above) confirms `HomePage` calls the service layer correctly and reacts to success
and failure — the rest of the real test suite covers toggling, inline editing, and more
failure-rollback cases with the same two seams.

## Running tests [#running-tests]

```bash
npm run test
```

This runs `vitest run` — a single pass suitable for CI. Use `npx vitest` directly for
watch mode during development.
