Skip to main content
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
A form is code that collects user input — it deserves the same test rigor as any other critical path. This guide shows how to test FormEngine forms at every level: unit tests with React Testing Library, integration tests for validation and submit flow, and end-to-end tests with Playwright.

Setup

Install the test stack you need — FormEngine forms render as regular DOM, so any React testing tool works.
npm install --save-dev \
  @testing-library/react @testing-library/user-event @testing-library/jest-dom \
  jest jest-environment-jsdom
For accessibility assertions:
npm install --save-dev jest-axe @axe-core/react
For E2E:
npm install --save-dev @playwright/test

Rendering a form in a test

import { render, screen } from '@testing-library/react'
import { FormViewer } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'

import formJson from './form.json'

function renderForm(props = {}) {
  return render(
    <FormViewer
      view={view}
      getForm={() => JSON.stringify(formJson)}
      {...props}
    />
  )
}

test('renders all fields', () => {
  renderForm()
  expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
  expect(screen.getByLabelText(/age/i)).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
})

Testing user input

Use userEvent (not fireEvent) — it simulates real typing, focus, and keyboard events:
import userEvent from '@testing-library/user-event'

test('user can type into email field', async () => {
  const user = userEvent.setup()
  renderForm()

  const email = screen.getByLabelText(/email/i)
  await user.type(email, 'alice@example.com')

  expect(email).toHaveValue('alice@example.com')
})

Testing validation

Assert that invalid input produces the expected error message:
test('shows error when email is invalid', async () => {
  const user = userEvent.setup()
  renderForm()

  await user.type(screen.getByLabelText(/email/i), 'not-an-email')
  await user.click(screen.getByRole('button', { name: /submit/i }))

  expect(await screen.findByText(/valid email/i)).toBeInTheDocument()
})

test('accepts valid input', async () => {
  const user = userEvent.setup()
  const onChange = jest.fn()
  renderForm({ onFormDataChange: onChange })

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/age/i), '30')

  expect(onChange).toHaveBeenCalled()
  const lastCall = onChange.mock.calls.at(-1)?.[0]
  expect(lastCall.data).toEqual({ email: 'alice@example.com', age: 30 })
})

Testing submit with a mock API

Mock fetch (or your API client) and assert the request body:
test('submits form data to API', async () => {
  const user = userEvent.setup()
  global.fetch = jest.fn(() =>
    Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 1 }) })
  ) as jest.Mock

  renderForm()

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/age/i), '30')
  await user.click(screen.getByRole('button', { name: /submit/i }))

  await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1))
  const [, init] = (fetch as jest.Mock).mock.calls[0]
  expect(JSON.parse(init.body)).toEqual({
    email: 'alice@example.com',
    age: 30,
  })
})

Testing with initialData

Pre-populate a form to test edit flows:
test('displays initial values', () => {
  renderForm({
    initialData: { email: 'bob@example.com', age: 25 },
  })

  expect(screen.getByLabelText(/email/i)).toHaveValue('bob@example.com')
  expect(screen.getByLabelText(/age/i)).toHaveValue('25')
})

Testing imperatively via viewerRef

For complex flows, grab the viewer ref and call methods directly:
import { useRef } from 'react'
import { IFormViewer } from '@react-form-builder/core'

function TestHarness() {
  const ref = useRef<IFormViewer>(null)
  return (
    <>
      <FormViewer view={view} getForm={getForm} viewerRef={ref} />
      <button
        onClick={async () => {
          const validation = await ref.current?.formData.getValidationResult()
          document.body.dataset.valid = String(!validation)
        }}
      >
        Check
      </button>
    </>
  )
}

test('validates programmatically', async () => {
  const user = userEvent.setup()
  render(<TestHarness />)

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/age/i), '30')
  await user.click(screen.getByRole('button', { name: /check/i }))

  await waitFor(() => expect(document.body.dataset.valid).toBe('true'))
})

Accessibility tests

Run axe against every form — catch contrast issues, missing labels, and bad ARIA early:
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)

test('form has no a11y violations', async () => {
  const { container } = renderForm()
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

Testing conditional logic

When a field only appears under certain conditions, assert both states:
test('address field appears when shipping checked', async () => {
  const user = userEvent.setup()
  renderForm()

  expect(screen.queryByLabelText(/address/i)).not.toBeInTheDocument()

  await user.click(screen.getByLabelText(/ship to different address/i))

  expect(await screen.findByLabelText(/address/i)).toBeInTheDocument()
})

Testing custom actions

Provide a mock actions object and assert it was called with the right event args:
test('custom action runs on button click', async () => {
  const user = userEvent.setup()
  const handler = jest.fn()

  render(
    <FormViewer
      view={view}
      getForm={getForm}
      actions={{
        myAction: ActionDefinition.functionalAction(async (e) => handler(e.data)),
      }}
    />
  )

  await user.click(screen.getByRole('button', { name: /run action/i }))

  await waitFor(() =>
    expect(handler).toHaveBeenCalledWith(expect.objectContaining({ /* ... */ }))
  )
})

E2E with Playwright

For full-browser tests, Playwright drives a real browser against your running app:
import { test, expect } from '@playwright/test'

test('user completes signup', async ({ page }) => {
  await page.goto('/signup')

  await page.getByLabel(/email/i).fill('alice@example.com')
  await page.getByLabel(/password/i).fill('supersecret')
  await page.getByRole('button', { name: /create account/i }).click()

  await expect(page.getByText(/welcome/i)).toBeVisible()
})

Common pitfalls

  • waitFor missing on async validation — validation runs async; always await findBy* or waitFor for errors.
  • Testing CSS classes instead of state — assert on aria-invalid, not .has-error. CSS can change; semantics shouldn’t.
  • Using getByText for form labels — use getByLabelText so you test the label-input association.
  • Over-mocking — let the real FormViewer run. Only mock network calls and timers.
  • Not awaiting userEvent — every userEvent.* call returns a promise in v14+.
Last modified on April 16, 2026