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
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')
})
})
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'
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}`,
)
}),
)
})
})
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}`,
)
}),
)
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"]
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"
}
);
});
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
Top comments (2)
The error message differs from what's in the codepen.
Above:
Codepen:
I had no idea there was a quick check for JS/TS that works with jest.
NOICE.