Testing | Dashforge-UI
DocsStarter Kits
v0.1.0-alpha

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: userEvent simulates real user interactions more accurately
  • Query by role and label: Use getByRole and getByLabelText instead of test IDs
  • Wait for async updates: Use waitFor or findBy queries 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();
});
On This Page