Introduction: Why Unit Tests Don't Save You From Everything
\n\nImagine you wrote an array sorting function. You covered it with a dozen unit tests: an empty array, a single element, an already sorted list, reverse order. The tests are green. You're confident in the code. But a week later, production crashes — on an array of 1000 elements with duplicates and negative numbers. Sound familiar?
\n\nTraditional TypeScript testing using examples (example-based testing) only checks the scenarios you thought of. But what if a bug is hiding in a combination you didn't consider? This is where property-based testing comes in — an approach that automatically generates thousands of random inputs and checks the invariants of your code.
\n\nIn this article, we'll explore how to apply property-based testing in TypeScript with the fast-check library, which properties to check, and how this changes the development approach.
\n\nWhat is Property-based Testing?
\n\nInstead of writing:
\nit('sorts [3, 1, 2] → [1, 2, 3]', () => {\n expect(sort([3, 1, 2])).toEqual([1, 2, 3]);\n});\n\n\nYou describe a property — an assertion that should be true for all valid inputs:
\nit('a sorted array does not change the order of elements', () => {\n fc.assert(\n fc.property(fc.array(fc.integer()), (arr) => {\n const sorted = sort(arr);\n // Property 1: length is preserved\n expect(sorted.length).toBe(arr.length);\n // Property 2: each element of the original array is present\n // Property 3: the array is sorted in ascending order\n for (let i = 1; i < sorted.length; i++) {\n expect(sorted[i - 1]).toBeLessThanOrEqual(sorted[i]);\n }\n })\n );\n});\n\n\nThe fast-check library will automatically generate hundreds of random arrays (including empty ones, with NaN, with fractional numbers, with huge values) and run your test on each. If even one fails — you'll know about the problem.
\n\nWhy is This Needed Specifically in TypeScript?
\n\nTypeScript provides static typing, but it doesn't guarantee the correctness of logic. Property-based testing in TypeScript bridges the gap between "types match" and "the code works correctly." It is especially useful for:
\n\n- \n
- Data transformation functions (map, filter, reduce) \n
- Parsers and validators \n
- Algorithms with non-trivial logic \n
- Functions working with boundary values \n
The fast-check Library: Quick Start
\n\nInstall fast-check in your project:
\nnpm install --save-dev fast-check\n\n\nBasic syntax:
\nimport fc from 'fast-check';\n\ndescribe('properties of the reverse function', () => {\n it('reverse(reverse(arr)) === arr', () => {\n fc.assert(\n fc.property(fc.array(fc.anything()), (arr) => {\n expect(reverse(reverse(arr))).toEqual(arr);\n })\n );\n });\n});\n\n\nHere fc.array(fc.anything()) is a generator of arbitrary arrays. You can combine generators:
- \n
fc.integer()— integers \n fc.string()— strings \n fc.record({ name: fc.string(), age: fc.integer({ min: 0, max: 120 }) })— objects \n fc.oneof(...)— random selection from multiple generators \n
Which Properties to Check? 5 Practical Examples
\n\n1. Invariance (Reversibility)
\nAny operation that has an inverse should be reversible:
\nit('JSON serialization and deserialization', () => {\n fc.assert(\n fc.property(fc.object(), (obj) => {\n const json = JSON.stringify(obj);\n const parsed = JSON.parse(json);\n expect(parsed).toEqual(obj);\n })\n );\n});\n<