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.