Testing Forms using @testing-library/user-event

Testing Forms using @testing-library/user-event

This workflow will cover the basics of testing your Redwood-powered form-components. I’m going to assume you’re building your own form, using react-hook-form (this is the library used by Redwood’s form components).

While this step by step builds the form by hand (my personal preference), Redwood’s components should work with no modification to the tests; feedback would be cool :smiley:

We’ll be making use of the @testing-library/user-event package:

userevent tries to simulate the real events that would happen in the browser as the user interacts with it. For example userEvent.click(checkbox) would change the state of the checkbox.

Installing @testing-library/user-event

It can be installed by cd-ing into the web directory and running yarn add -D @testing-library/user-event

@testing-library/dom, its dependency, is provided by Redwood

TL;DR

I don’t blame you; you can find the finished source here.

Form Component

We’ll need a component to actually perform our tests against. This component will contain the entire form: providing an API for consuming onSubmit and onInvalid callbacks and for providing defaultValues for our form’s fields. We’ll be writing it in TypeScript.

We’ll need two dependencies for our component. It’s a functional-component, so we’ll need the useCallback hook for submition and invalid-submition handling. Then, of course, we’ll need the useForm hook which acts as the entry-point for react-hook-form.

import { useCallback } from 'react'
import { useForm } from 'react-hook-form'

Let’s get our interfaces out of the way. We’ll use two:

  1. For providing react-hook-form a generic; used to type our results and errors.
  2. To let consumers of our component know what props are expected; and offer a way to extend our component.
interface OurFormSubmitData {
  name: string
  nickname?: string
}

interface OurFormProps {
  name?: string
  nickname?: string
  onInvalid?: (err: React.BaseSyntheticEvent) => void
  onSubmit: (data: OurFormSubmitData) => void
}

Our component’s implementation is similar to what you’d expect when building a Redwood form (or a react-hook-form, to be specific).

const OurForm: React.FC<OurFormProps> = ({
  name = '',
  nickname = '',
  onInvalid,
  onSubmit,
}) => {
  const { handleSubmit, register } = useForm<OurFormSubmitData>({
    mode: 'all', // Validation will trigger on the blur and change events. See https://react-hook-form.com/api/useform "mode"
    defaultValues: {
      name,
      nickname,
    },
  })

  // Our callback-handlers have "internal" variants to allow mutation before passing values back up to the parent.
  // As shown, it can act as a way to ensure consumer's provide the (required) props, we expect.
  const _onInvalid = useCallback(
    (err) => {
      if (typeof onInvalid === 'function') {
        onInvalid(err)
      }
    },
    [onInvalid]
  )

  const _onSubmit = useCallback(
    (data) => {
      if (typeof onSubmit === 'function') {
        onSubmit(data)
      }
    },
    [onSubmit]
  )

  return (
    <form onSubmit={handleSubmit(_onSubmit, _onInvalid)}>
      <input
        name="name"
        placeholder="Name"
        ref={register({
          required: 'A Name is required for this form',
        })}
      />
      <input
        name="nickname"
        placeholder="Nickname"
        ref={register()}
      />
      <button>Submit</button>
    </form>
  )
}

Let’s finish off our component by exporting all of our declarations, and the component itself as the file’s default.

export default OurForm
export type { OurFormProps, OurFormSubmitData }

OurForm’s Tests

Now, our tests:

The top of our test-file resembles what Redwood generates with its CLI. All that’s changed is:

  • An import to @testing-library/user-event for its default.
  • What we’re importing from @redwoodjs/testing.
  • We provide our required props to our “renders successfully” test.
import user from '@testing-library/user-event'
import { render, screen, waitFor } from '@redwoodjs/testing'

import OurForm from './OurForm'

describe('OurForm', () => {
  it('renders successfully', () => {
    const onSubmit = jest.fn()
    const onInvalid = jest.fn()

    expect(() =>
      render(<OurForm onInvalid={onInvalid} onSubmit={onSubmit} />)
    ).not.toThrow()
  })

Now we’ll create three, painfully complex tests. They will cover:

  1. Does our component NOT submit when required fields are empty?
  2. Does our component submit when required fields are populated?
  3. Does our component submit, passing our (submit) handler the expected data?

And some noteworthy takeaways are:

  • We use await because react-hook-form will cause our component’s state to change multiple times; otherwise, our expect-ation would trigger prematurely.
  • We use waitFor because the functions from user are synchronous, which would make our await useless.
    • waitFor also acts as our declaration of act, required when updating the state of a React component from a test.

Why don’t we wrap render() in an act()-call, as suggested by the React documentation?

Because the render() method already provides the call.

  it('triggers invalid when required fields are empty', async () => {
    const onSubmit = jest.fn()
    const onInvalid = jest.fn()

    render(<OurForm onInvalid={onInvalid} onSubmit={onSubmit} />)

    const submitButton = screen.getByText('Submit')

    await waitFor(() => user.click(submitButton))

    expect(onInvalid).toHaveBeenCalledTimes(1)
    expect(onSubmit).toHaveBeenCalledTimes(0)
  })

  it('triggers submit when required fields are populated', async () => {
    const name = 'Malcolm McCormick'
    const nickname = ''

    const onSubmit = jest.fn()
    const onInvalid = jest.fn()

    render(<OurForm onInvalid={onInvalid} onSubmit={onSubmit} />)

    const nameField = screen.getByPlaceholderText('Name')
    const submitButton = screen.getByText('Submit')

    await waitFor(() => user.type(nameField, name))
    await waitFor(() => user.click(submitButton))

    expect(onInvalid).toHaveBeenCalledTimes(0)
    expect(onSubmit).toHaveBeenCalledTimes(1)
    expect(onSubmit).toHaveBeenCalledWith({ name, nickname })
  })

  it('triggers submit with full results', async () => {
    const name = 'Malcolm McCormick'
    const nickname = 'Mac Miller'

    const onSubmit = jest.fn()
    const onInvalid = jest.fn()

    render(<OurForm onInvalid={onInvalid} onSubmit={onSubmit} />)

    const nameField = screen.getByPlaceholderText('Name')
    const nicknameField = screen.getByPlaceholderText('Nickname')
    const submitButton = screen.getByText('Submit')

    await waitFor(() => user.type(nameField, name))
    await waitFor(() => user.type(nicknameField, nickname))
    await waitFor(() => user.click(submitButton))

    expect(onInvalid).toHaveBeenCalledTimes(0)
    expect(onSubmit).toHaveBeenCalledTimes(1)
    expect(onSubmit).toHaveBeenCalledWith({ name, nickname })
  })
}) // Closes `describe(...)`

:tada::tada::tada:

That’s all there is to it; the way in which you get the different elements can be improved, and obviously these test don’t include everything; I didn’t show how we’d test our defaultValues API, among many things.

Bonus Points: A Form with a Story

“But, wait!”, I hear you shout.

Storybook is included in Redwood testing. WHERE is my storybook?”

Ok… ok. Calm down. Let’s do that:

We’ll start with creating (or using a generated) *.stories.tsx file. OurForm.stories.tsx seems befitting.

First, we’ll import some types from @storybook/react. Then our form component and its props interface.

import type { Meta, Story } from '@storybook/react'

import OurForm from './OurForm'
import type { OurFormProps } from './OurForm'

We need to provide Storybook with some metadata about our stories. We do so by exporting a default object, which has the shape of the Meta type we just imported.

With it, we are doing the following:

  • Giving our stories a title
  • Declaring that our stories are for the OurForm component.
    • Storybook uses this field to extract metadata about the component, used, most notably, to power: documentation and a source-code preview.
  • Define argTypes for our component; we need to explicitly define actions, which will let us view the data passed back by our components callback-handlers.
export default {
  title: 'Components/OurForm',
  component: OurForm,
  argTypes: {
    onInvalid: {
      action: 'onInvalid',
    },
    onSubmit: {
      action: 'onSubmit',
    },
  },
} as Meta

Finally, we create a Template to act as our stories primary implementation. We then have a named-export, which will act as the story viewed by those making use of it.

const Template: Story<OurFormProps> = (args) => <OurForm {...args} />

export const Default = Template.bind({})

Bonus: Bonus: Documentation

With our Story setup, we can take full advantage of it by providing a JSDoc to our component and its props. These will be displayed on Storybook’s “Docs” tab.

To do so, we’ll edit our component (OurForm.tsx):

interface OurFormProps {
  /**
   * Provide a default `name` value, which the user will then have the ability to edit.
   */
  name?: string
  /**
   * Provide a default `nickname` value, which the user will then have the ability to edit.
   */
  nickname?: string
  /**
   * Callback-handler for invalid-submition attempts.
   */
  onInvalid?: (err: React.BaseSyntheticEvent) => void
  /**
   * Callback-handler for valid-submition attempts.
   */
  onSubmit: (data: OurFormSubmitData) => void
}

/**
 * A simple form component for gathering a user's `name` and `nickname`.
 */
const OurForm: React.FC<OurFormProps> = (...) => {...}

For my visual friends, you can find a video demonstrating this Storybook setup here.


This is my first step-by-step, tutorial, short story, whatever; feedback in terms of the educational takeaway would mean the world.

4 Likes

Thanks a lot @realStandal , as it happens I was looking into this since yesterday, didn’t expect to find anything about it here!

For anyone bumping into scss import problems in jest, here’s how you can elude it:

$  yarn workspace web add identity-obj-proxy --dev

And in ./web/jest.config.js:

# ./web/jest.config.js
config.moduleNameMapper = {
  ...config?.moduleNameMapper,
  '^.+\\.(css|sass|less|scss)$': 'identity-obj-proxy',
}

module.exports = config

OMG @realStandal this is amazing. Would you consider a PR to add something similar to our official Testing doc? I didn’t include anything in there about forms! :grimacing:

Glad it could help! Almost the exact reason I decided to post it, took me a few iterations to get await and waitFor behaving right.

I take it that’s a temp patch for the SCSS? I haven’t gotten around to really using it in Redwood, sorry, Tailwind and CSS have done the trick so far (knock on wood).

Sure! I was thinking of making an issue to have @testing-library/user-event included in the @redwoodjs/testing library as well, seems useful and fits the “well it works” mantra:

The library is still a work in progress and any help is appreciated.


I’ll add it below “Testing Cells” and make it more to-the-point, I’ll link the PR here once I’ve created it.

3 Likes

I think it’s actually a more “definitive” patch for the scss, even though we’d see variations of it over the web, it seems to be the way to go. Provided one uses scss indeed :).

I get it x), my iterations have been very long! For my entry test, for a checkbox input, I have an average of 80s execution time. I didn’t mention it because it happens on scaffolded tests as well, so I guess I’m investigating yet another exotic :bug: in the machine.
Anyway, that tuto is a cool addition to the KB :+1:

this is AMAZING!! thank you so much :tada:, a few weeks ago I attempted to test a form very similar to this, but kept running into the "not wrapped in act(...)" warning and the test would’t pass.

I tried again today, been hitting a wall all day long, but just found this thread, about 5 mins ago I had my test passing 🥲

Those await waitFor were exactly what I was missing!

Yay! Glad it was of use!

I’m working on a PR for the main Redwood repo right now, soon as I finish that I’ll get this post put into the main RedwoodDocs to make finding it easier.