Property-based testing with React and fast-check

tobiastimm

Tobias Timm

Posted on March 25, 2020

Property-based testing with React and fast-check

Property-based testing is quite a popular testing method in the functional world. Mainly introduced by QuickCheck in Haskell, it targets all the scope covered by example-based testing: from unit tests to integration tests.

If you have never heard anything about property-based testing or QuickCheck, don't worry, I've got you covered 😉.

Like the name is intending, this testing philosophy is all about properties.

It checks that a system under test abides by a property. Property can be seen as a trait you expect to see in your output, given the inputs. It does not have to be the expected result itself, and most of the time, it will not be.
fast-check documentation

Our example application

To demonstrate what the benefits are and why you should also consider this testing method, let's assume that we have the following react application written in TypeScript.

In this example, we will use fast-check, a framework for this testing method.

Our application is a pixel to rem converter. The purpose is to enter a pixel value, which is converted to the corresponding rem value, assuming that the base font size is 16px.

RemConverter.tsx

import React, { FC, useState, FormEvent } from 'react'

interface Props {}

const RemConverter: FC<Props> = () => {
  const [baseFontSize] = useState(16)
  const [px, setPx] = useState(baseFontSize)
  const [rem, setRem] = useState(px2Rem(px, baseFontSize))

  const convert = (e: FormEvent) => {
    e.preventDefault()
    setRem(px2Rem(px, baseFontSize))
  }

  return (
    <div>
      <form onSubmit={convert}>
        <h6>Base font-size: {baseFontSize}</h6>

        <div>
          <label>PX</label>
          <input
            data-testId="px"
            value={px}
            onChange={e => setPx(parseInt(e.target.value, 10))}
          />
        </div>

        <div>
          <label>REM</label>
          <input data-testId="rem" value={rem} disabled />
        </div>

        <button type="submit">Convert</button>
      </form>
    </div>
  )
}

export function px2Rem(px: number, baseFontSize: number) {
  return px / baseFontSize
}

export default RemConverter
Enter fullscreen mode Exit fullscreen mode

Our <RemConverter /> is a functional component that expects an input for the pixel value and outputs the corresponding rem in another input. Nothing to fancy yet.

Getting into testing

To begin our testing adventure, we will write a regular integration test with @testing-library/react.

So what do we want to test here?

Scenario: We want to enter a pixel value of 32 and press on the Convert button. The correct rem value of 2 is displayed.

RemConverter.test.tsx

import React from 'react'
import { cleanup, render, fireEvent } from '@testing-library/react'
import RemConverter from '../RemConverter'

afterEach(cleanup)

describe('<RemConverter />', () => {
  it('renders', () => {
    expect(render(<RemConverter />)).toBeDefined()
  })

  it('should convert px to the right rem value', async () => {
    const { getByTestId, getByText } = render(<RemConverter />)
    fireEvent.change(getByTestId('px'), {
      target: { value: '32' },
    })
    fireEvent.click(getByText('Convert'))
    expect((getByTestId('rem') as HTMLInputElement).value).toBe('2')
  })

})
Enter fullscreen mode Exit fullscreen mode

Above is an easy and simple test to validate our scenario and prove that it is working.

Now you should start thinking 🤔

  • Did I cover all the possible values?
  • What happens if I press the button multiple times?
  • ...

If you go the TDD way, you should have thought about things like that beforehand, but I don't want to get into that direction with the article.

We could start creating a list of possible values with it.each, but this is where property-based testing can help us.

QuickCheck in Haskell, for example, creates n-amount of property-values to prove that your function is working.

fast-check, like said before, is a library for that written in TypeScript.

So let's rewrite our test with fast-check.

Testing with fast-check

To start writing tests with fast-check and jest, all you need to do is import it.

import fc from 'fast-check'
Enter fullscreen mode Exit fullscreen mode

Afterward, we can use specific features to generate arguments.

Our test would look like this:

import React from 'react'
import { cleanup, render, fireEvent } from '@testing-library/react'
import fc from 'fast-check'
import RemConverter from '../RemConverter'

afterEach(cleanup)

describe('<RemConverter />', () => {
  it('renders', () => {
    expect(render(<RemConverter />)).toBeDefined()
  })

  it('should convert px to the right value with fc', async () => {
    const { getByTestId, getByText } = render(<RemConverter />)
    fc.assert(
      fc.property(fc.nat(), fc.constant(16), (px, baseFontSize) => {
        fireEvent.change(getByTestId('px'), {
          target: { value: `${px}` },
        })
        fireEvent.click(getByText('Convert'))
        expect((getByTestId('rem') as HTMLInputElement).value).toBe(
          `${px / baseFontSize}`,
        )
      }),
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

Quite different, doesn't it?

The most important part is

 fc.assert(
      fc.property(fc.nat(), fc.constant(16), (px, baseFontSize) => {
        fireEvent.change(getByTestId('px'), {
          target: { value: `${px}` },
        })
        fireEvent.click(getByText('Convert'))
        expect((getByTestId('rem') as HTMLInputElement).value).toBe(
          `${px / baseFontSize}`,
        )
      }),
    )
Enter fullscreen mode Exit fullscreen mode

We will go through it step by step.

First of all, we tell fast-check with fc.assert to run something with automated inputs.

fc.property defines that property. The first argument is fc.nat() that represents a natural number. The second argument is our base font size served with the constant 16.

Last but not least, the callback function is containing the automatically created inputs.

Within this callback function, we include our previous test using the given parameters.

That's it 🎉.

If we run our test with jest now, fast-check generates number inputs for us.

How can I reproduce my test, if something goes wrong?
Whenever fast-check detects a problem, it will print an error message containing the settings required to replay the very same test.

Property failed after 1 tests
{ seed: -862097471, path: "0:0", endOnFailure: true }
Counterexample: [0,16]
Shrunk 1 time(s)
Got error: Error: Found multiple elements by: [data-testid="px"]
Enter fullscreen mode Exit fullscreen mode

Adding the seed and path parameter will replay the test, starting with the latest failing case.

   fc.assert(
      fc.property(fc.nat(), fc.constant(16), (px, baseFontSize) => {
        fireEvent.change(getByTestId("px"), {
          target: { value: `${px}` }
        });
        fireEvent.click(getByText("Convert"));
        expect((getByTestId("rem") as HTMLInputElement).value).toBe(
          `${px / baseFontSize}`
        );
      }),
      {
        // seed and path taken from the error message
        seed: -862097471,
        path: "0:0"
      }
    );
  });
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is only a simple example of what you can do with the power of property-based testing and fast-check.

You can generate objects, strings, numbers, complex data structures, and much more awesome stuff.

I would recommend everybody to look into fast-check because it can automate and enhance many of your tests with generated arguments.

For further reading and many more examples, please visit the fast-check website.

The example application can be found on CodeSandbox and GitHub

💖 💪 🙅 🚩
tobiastimm
Tobias Timm

Posted on March 25, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related