Browse docs
Browse docs
Learn how to test Dashforge forms, reactions, and components effectively using modern testing tools and best practices.
Dashforge is built on React Hook Form and follows the same testing philosophy: test behavior, not implementation. Focus on what users do and what they see, not internal mechanics.
✅ What to Test
onSubmit receive correct data?❌ What NOT to Test
Test forms by simulating user interactions and asserting on the results. Use React Testing Library for a user-centric approach.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with valid data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// Find fields by label (user-centric)
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
// Simulate user typing
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password123');
// Submit form
await user.click(submitButton);
// Assert onSubmit was called with correct data
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
});
});it('shows validation errors for invalid input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
// Type invalid email
await user.type(emailInput, 'notanemail');
// Blur to trigger validation
await user.tab();
// Assert error appears
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
// Correct the email
await user.clear(emailInput);
await user.type(emailInput, '[email protected]');
// Error should disappear
await waitFor(() => {
expect(screen.queryByText(/invalid email/i)).not.toBeInTheDocument();
});
});Reactions are pure configuration objects. Test them in isolation by mocking the context and calling the run function directly.
// addressReactions.ts
export const addressReactions = [
{
id: 'load-states',
watch: ['country'],
run: async (ctx) => {
const country = ctx.getValue<string>('country');
if (!country) {
ctx.setRuntime('state', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-states');
ctx.setRuntime('state', { status: 'loading' });
const states = await fetchStates(country);
if (ctx.isLatest('fetch-states', requestId)) {
ctx.setRuntime('state', {
status: 'ready',
data: { options: states }
});
}
}
}
];// addressReactions.test.ts
import { addressReactions } from './addressReactions';
import { fetchStates } from './api';
jest.mock('./api');
describe('addressReactions', () => {
describe('load-states', () => {
it('loads states when country is selected', async () => {
// Mock context
const mockCtx = {
getValue: jest.fn().mockReturnValue('United States'),
setRuntime: jest.fn(),
beginAsync: jest.fn().mockReturnValue(1),
isLatest: jest.fn().mockReturnValue(true),
};
// Mock API
(fetchStates as jest.Mock).mockResolvedValue([
'California',
'Texas',
'New York'
]);
// Get reaction and run it
const reaction = addressReactions.find(r => r.id === 'load-states');
await reaction!.run(mockCtx);
// Assert loading state was set
expect(mockCtx.setRuntime).toHaveBeenCalledWith('state', {
status: 'loading'
});
// Assert final state was set with options
expect(mockCtx.setRuntime).toHaveBeenCalledWith('state', {
status: 'ready',
data: { options: ['California', 'Texas', 'New York'] }
});
});
it('clears state when country is null', async () => {
const mockCtx = {
getValue: jest.fn().mockReturnValue(null),
setRuntime: jest.fn(),
beginAsync: jest.fn(),
isLatest: jest.fn(),
};
const reaction = addressReactions.find(r => r.id === 'load-states');
await reaction!.run(mockCtx);
expect(mockCtx.setRuntime).toHaveBeenCalledWith('state', {
status: 'idle',
data: null
});
});
it('ignores stale responses', async () => {
const mockCtx = {
getValue: jest.fn().mockReturnValue('Canada'),
setRuntime: jest.fn(),
beginAsync: jest.fn().mockReturnValue(1),
isLatest: jest.fn().mockReturnValue(false), // Stale!
};
(fetchStates as jest.Mock).mockResolvedValue(['Ontario', 'Quebec']);
const reaction = addressReactions.find(r => r.id === 'load-states');
await reaction!.run(mockCtx);
// Should set loading state
expect(mockCtx.setRuntime).toHaveBeenCalledWith('state', {
status: 'loading'
});
// Should NOT set final state (stale response)
expect(mockCtx.setRuntime).not.toHaveBeenCalledWith('state', {
status: 'ready',
data: expect.anything()
});
});
});
});Verify fields with visibleWhen show and hide based on other field values.
it('shows company field when account type is business', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
// Company field should not be visible initially
expect(screen.queryByLabelText(/company name/i)).not.toBeInTheDocument();
// Select business account type
const accountTypeSelect = screen.getByLabelText(/account type/i);
await user.click(accountTypeSelect);
await user.click(screen.getByRole('option', { name: /business/i }));
// Company field should now be visible
expect(await screen.findByLabelText(/company name/i)).toBeInTheDocument();
// Change to personal
await user.click(accountTypeSelect);
await user.click(screen.getByRole('option', { name: /personal/i }));
// Company field should disappear
await waitFor(() => {
expect(screen.queryByLabelText(/company name/i)).not.toBeInTheDocument();
});
});When testing components with RBAC access control, wrap them in an RbacProvider with a test policy and subject.
import { RbacProvider } from '@dashforge/rbac';
import type { RbacPolicy, Subject } from '@dashforge/rbac';
// Test helper to render with RBAC context
function renderWithRbac(
ui: React.ReactElement,
{ subject, policy }: { subject: Subject; policy: RbacPolicy }
) {
return render(
<RbacProvider subject={subject} policy={policy}>
{ui}
</RbacProvider>
);
}
describe('BookingForm with RBAC', () => {
const policy: RbacPolicy = {
roles: [
{
name: 'viewer',
permissions: [
{ action: 'read', resource: 'booking' },
],
},
{
name: 'editor',
permissions: [
{ action: 'read', resource: 'booking' },
{ action: 'edit', resource: 'booking' },
],
},
],
};
it('shows field as readonly for viewers', () => {
const subject: Subject = { id: '1', roles: ['viewer'] };
renderWithRbac(<BookingForm />, { subject, policy });
const customerField = screen.getByLabelText(/customer name/i);
expect(customerField).toHaveAttribute('readonly');
});
it('allows editing for editors', () => {
const subject: Subject = { id: '1', roles: ['editor'] };
renderWithRbac(<BookingForm />, { subject, policy });
const customerField = screen.getByLabelText(/customer name/i);
expect(customerField).not.toHaveAttribute('readonly');
expect(customerField).not.toBeDisabled();
});
});userEvent simulates real user interactions more accuratelygetByRole and getByLabelText instead of test IDswaitFor or findBy queries for async assertions// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { DashforgeThemeProvider } from '@dashforge/tw-theme';
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<DashforgeThemeProvider>
{children}
</DashforgeThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage in tests
import { renderWithProviders } from './test-utils';
it('renders form correctly', () => {
renderWithProviders(<MyForm />);
// ...
});// test-helpers.ts
export async function submitForm(user: ReturnType<typeof userEvent.setup>) {
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
}
export async function fillField(
user: ReturnType<typeof userEvent.setup>,
label: string | RegExp,
value: string
) {
const field = screen.getByLabelText(label);
await user.clear(field);
await user.type(field, value);
}
// Usage
it('submits login form', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await fillField(user, /email/i, '[email protected]');
await fillField(user, /password/i, 'password123');
await submitForm(user);
expect(handleSubmit).toHaveBeenCalled();
});