Rayfin

Testing a Rayfin app

Test Rayfin data and auth logic in Vitest with injectable auth stubs and the todo template's in-memory data seam.

A Rayfin frontend can be tested without calling Fabric from the test process. The 1.36.2 todo template already has two useful seams:

  1. AuthProvider accepts an IAuthService, so tests can pass a stub object instead of opening a Fabric sign-in flow.
  2. src/services/todos.ts checks isLocalBackend() before every data operation, so tests can mock src/services/rayfinClient.ts and force the in-memory branch.
PromptTest Rayfin data and auth logic without Fabric calls
In my Rayfin React + Vite project, set up Vitest with jsdom and a setup file that shims localStorage. For data service tests, mock src/services/rayfinClient.ts before importing the service under test so isLocalBackend returns true and getRayfinClient is a vi.fn(), forcing the in-memory branch. For component tests, create a plain object implementing IAuthService and pass it to AuthProvider as authService. Add tests that cover create/list/update/delete service behavior and an AuthProvider render without calling Fabric.

Test runner setup

Vitest runs in jsdom so React components can render. The config keeps the app's @/* import alias and loads a setup file before each test file. This file matches the 1.36.2 todo template.

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

jsdom does not provide the same localStorage behavior as a browser. Rayfin auth code reads and writes session state through browser storage, so the setup file provides a minimal implementation and clears it between tests. This file matches the 1.36.2 todo template.

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

Production code never imports MockAuthService or RayfinAuthService directly. It depends on an IAuthService interface instead.

This interface and helper match the 1.36.2 todo template.

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 the bundled local backend with a fixture email/password.
 * - {@link RayfinAuthService} — used in production. 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>;
}

/** Map the raw session user shape to the trimmed view used in the UI. */
export function toAuthUser(user: {
  id: string;
  email: string;
  name?: string;
}): AuthUser {
  return {
    id: user.id,
    email: user.email,
    name: user.name || user.email.split('@')[0],
  };
}

How bootstrapAuth() chooses implementations

At runtime, bootstrapAuth() reads the generated Vite environment variables, initializes the Rayfin client, and chooses an auth service. This file matches the 1.36.2 todo template.

src/services/bootstrap.ts
import { resolveRayfinFunctionsBaseUrl } from '@microsoft/rayfin-local-dev';

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 async function bootstrapAuth(): Promise<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 = await initRayfinClient({
    baseUrl: apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`,
    publishableKey: publishableKey ?? 'local-dev-key',
    localDev,
    functionsBaseUrl: resolveRayfinFunctionsBaseUrl(),
    runtimeConfig: {
      workspaceId: import.meta.env.VITE_FABRIC_WORKSPACE_ID,
      itemId: import.meta.env.VITE_FABRIC_ITEM_ID,
      portalUrl: import.meta.env.VITE_FABRIC_PORTAL_URL,
    },
  });

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

  const {
    workspaceId,
    itemId: projectId,
    portalUrl: fabricPortalUrl,
  } = client.runtimeConfig ?? {};

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

Tests should not call bootstrapAuth(). They construct the auth service stub directly and pass it to AuthProvider. That keeps tests independent of generated environment variables, Fabric SSO, local automatic sign-in, and local Functions routing.

MockAuthService scope caveat

The 1.36.2 todo template still includes MockAuthService for the template's localhost branch. That implementation signs into a bundled local backend with fixture credentials. The service file is unchanged from 1.35.1, but that credential-based local backend path is outside this documentation site's Fabric-only product scope.

Keep the seam; do not copy the credential sign-in pattern into new application guidance. For tests, pass an IAuthService stub instead of using MockAuthService.

The relevant template constants are:

src/services/MockAuthService.ts (excerpt)
// 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';

The data seam: isLocalBackend()

src/services/todos.ts checks isLocalBackend() before every operation. When it is true, the service uses an in-memory array instead of client.data.Todo.

Tests force that branch by mocking src/services/rayfinClient.ts before importing todos.ts. That ordering matters: keep vi.mock() visually above the import of the module under test.

The following file matches the 1.36.2 todo template.

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([]);
  });
});

Test AuthProvider directly

Start with a small provider test before exercising a page. This confirms the injected auth service is used and the provider finishes its initial session check.

The following file matches the 1.36.2 todo template.

src/__tests__/AuthProvider.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { AuthProvider } from '@/hooks/AuthContext';
import type { IAuthService } from '@/services/IAuthService';

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;
  },
};

describe('AuthProvider', () => {
  it('renders children once initial auth check completes', async () => {
    render(
      <AuthProvider authService={stubAuthService}>
        <div data-testid="content">ready</div>
      </AuthProvider>
    );

    await waitFor(() => {
      expect(screen.getByTestId('content')).toHaveTextContent('ready');
    });
  });
});

Test a component that uses both seams

A page-level test combines both replacements:

  1. Mock rayfinClient.ts so todos.ts stays in memory.
  2. Render through AuthProvider with an authenticated IAuthService stub.
  3. Assert on UI and on spies over the service functions.

The todo template does not ship this HomePage test file, but it uses the same seams as the shipped tests.

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

stubAuthService.getCurrentUser() returns stubUser in this page test so the component tree starts signed in. The provider test above returns null because it only verifies initialization.

Run tests

npm run test

The scaffolded script runs vitest run, which is suitable for CI. Use npx vitest for watch mode while editing tests.

Something wrong on this page?Report an issueEdit this page

On this page