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:
AuthProvideraccepts anIAuthService, so tests can pass a stub object instead of opening a Fabric sign-in flow.src/services/todos.tschecksisLocalBackend()before every data operation, so tests can mocksrc/services/rayfinClient.tsand force the in-memory branch.
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.
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.
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.
/** 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.
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:
// 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.
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.
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:
- Mock
rayfinClient.tssotodos.tsstays in memory. - Render through
AuthProviderwith an authenticatedIAuthServicestub. - 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.
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 testThe scaffolded script runs vitest run, which is suitable for CI.
Use npx vitest for watch mode while editing tests.
Build a todo app
Build a Fabric-authenticated todo app end to end, from a per-user data model through a React UI to a deploy on Microsoft Fabric.
Reference overview
Landing page for the complete Rayfin reference — CLI commands, configuration schema, SDK packages, rules for coding agents, known limitations, deprecations, and troubleshooting.