Testing Guide
Learn how to test Dashforge forms, reactions, and components effectively using modern testing tools and best practices.
Testing Philosophy
What to test and what to skip
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
- Form submission: Does onSubmit receive correct data?
- Validation: Do errors appear when expected?
- Conditional visibility: Do fields show/hide correctly?
- Reactions: Do side effects run when values change?
- User interactions: Type, select, click, blur
❌ What NOT to Test
- Framework internals: Don't test if React Hook Form works
- Component registration: Don't test if fields register with the form
- Bridge mechanics: Don't test internal Dashforge contracts
- MUI rendering: Don't test if TextField renders an input
ℹ️
Trust that Dashforge and React Hook Form work. Test YOUR business logic, validations, and user flows.
Testing Forms
End-to-end form behavior testing
Test forms by simulating user interactions and asserting on the results. Use React Testing Library for a user-centric approach.
Basic Form Test
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',
});
});
});
});Testing Validation
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();
});
});✓
Use screen.getByLabelText() to find fields. This ensures your form is accessible and tests are user-centric.
Testing Reactions
Unit testing reaction logic in isolation
Reactions are pure configuration objects. Test them in isolation by mocking the context and calling the run function directly.
Reaction Definition
// 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 }
});
}
}
}
];Unit Test for Reaction
// 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()
});
});
});
});ℹ️
Testing reactions in isolation is fast and doesn't require rendering components. This is perfect for complex business logic.
Testing Conditional Visibility
Verify fields appear and disappear correctly
Test that 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();
});
});Testing with RBAC
Test permission-based behavior
When testing components with RBAC access control, wrap them in an RbacProvider with a test policy and subject.
import { RbacProvider } from '@dashforge/rbac';
// Test helper to render with RBAC context
function renderWithRbac(
ui: React.ReactElement,
{ subject, policy }: { subject: Subject; policy: Policy }
) {
return render(
<RbacProvider subject={subject} policy={policy}>
{ui}
</RbacProvider>
);
}
describe('BookingForm with RBAC', () => {
const policy = {
roles: {
viewer: { permissions: { allow: ['view:booking'] } },
editor: { permissions: { allow: ['view:booking', 'edit:booking'] } },
}
};
it('shows field as readonly for viewers', () => {
const 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 = { id: '1', roles: ['editor'] };
renderWithRbac(<BookingForm />, { subject, policy });
const customerField = screen.getByLabelText(/customer name/i);
expect(customerField).not.toHaveAttribute('readonly');
expect(customerField).not.toBeDisabled();
});
});Testing Best Practices
Guidelines for effective Dashforge tests
- Use userEvent over fireEvent:
userEventsimulates real user interactions more accurately - Query by role and label: Use
getByRoleandgetByLabelTextinstead of test IDs - Wait for async updates: Use
waitFororfindByqueries for async assertions - Test user flows, not implementation: Focus on what users do, not how the form works internally
- Mock external dependencies: Mock API calls, don't make real network requests
- Keep tests isolated: Each test should be independent and not rely on others
- Test edge cases: Empty values, very long inputs, special characters
- Use descriptive test names: Explain what behavior is being tested
✓
Good tests document how your forms should behave. Write tests that would help a new developer understand the requirements.
Common Testing Patterns
Reusable test utilities and helpers
Custom Render Helper
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { DashThemeProvider } from '@dashforge/theme-core';
import { MuiThemeAdapter } from '@dashforge/theme-mui';
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<DashThemeProvider mode="light">
<MuiThemeAdapter>
{children}
</MuiThemeAdapter>
</DashThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage in tests
import { renderWithProviders } from './test-utils';
it('renders form correctly', () => {
renderWithProviders(<MyForm />);
// ...
});Form Submission Helper
// 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();
});