DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Property-Based Testing for Sorting Logic with fast-check

Property-Based Testing for Sorting Logic with fast-check

Property-Based Testing for Sorting Logic with fast-check

Property-based testing is a great way to verify code that must hold up across many inputs, not just a few hand-picked examples. In this tutorial, you’ll build a practical test strategy for sorting logic using fast-check, then layer it into a broader test pyramid so the suite stays fast and maintainable.

Why this topic matters

Sorting bugs are deceptive because they often pass a couple of example tests and fail on an odd edge case, like duplicates, negative numbers, empty arrays, or already-sorted input. Property-based tests help by generating many inputs automatically and checking invariants instead of exact examples.

A good fit for this tutorial is a function such as sortNumbersAscending(data), because its behavior can be described with simple properties like “the result is ordered” and “the result contains the same values as the input”. That gives you a useful testing technique you can reuse for data transforms, parsers, validators, and ranking logic.

Test strategy

Use a three-layer approach: unit tests for a few explicit examples, property-based tests for broad coverage, and a small number of end-to-end checks for critical flows. The test pyramid guidance recommends prioritizing lots of fast unit tests, a smaller number of integration tests, and only a few E2E tests.

For sorting logic, that means:

  • Example tests cover obvious cases like empty arrays and already sorted arrays.
  • Property tests cover many random arrays and edge cases you might not think of.
  • Integration tests verify the sorter is wired correctly into the larger feature or API.

Setup

Install fast-check alongside your test runner:

npm install save-dev fast-check
### or
pnpm add -D fast-check
Enter fullscreen mode Exit fullscreen mode

fast-check is a JavaScript/TypeScript property-based testing framework, and its core idea is simple: define a property, then let the framework generate many inputs to try to break it.

Here is the function we’ll test:

export function sortNumbersAscending(data: number[]): number[] {
  return [...data].sort((a, b) => a - b);
}
Enter fullscreen mode Exit fullscreen mode

The implementation is intentionally small, which is typical for tutorial code. The value of property-based testing is not in testing the sort call itself, but in capturing the behavior you expect from any sorting function.

Start with examples

Before using random generation, write a few direct tests. These make the intent obvious and protect against silly regressions.

import { describe, it, expect } from 'vitest';
import { sortNumbersAscending } from './sortNumbersAscending';

describe('sortNumbersAscending', () => {
  it('returns an empty array for empty input', () => {
    expect(sortNumbersAscending([])).toEqual([]);
  });

  it('sorts a simple unordered array', () => {
    expect(sortNumbersAscending()).toEqual();
  });

  it('keeps duplicates', () => {
    expect(sortNumbersAscending()).toEqual();
  });
});
Enter fullscreen mode Exit fullscreen mode

These tests are useful, but they only cover the cases you remember to write down. Property-based testing adds a second layer that explores a much wider range of values automatically.

Define properties

A property is a statement that should remain true for many valid inputs. For sorting, two excellent properties are:

  • The output is in nondecreasing order.
  • The output is a permutation of the input, meaning it contains the same values with the same multiplicities.

The first property checks ordering. The second property checks data preservation, which guards against accidental drops, duplication, or corruption.

Write the first property

Here is a property test that checks ordering:

import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import { sortNumbersAscending } from './sortNumbersAscending';

describe('sortNumbersAscending', () => {
  it('always returns values in ascending order', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (data) => {
        const sorted = sortNumbersAscending(data);

        for (let i = 1; i < sorted.length; i++) {
          expect(sorted[i - 1]).toBeLessThanOrEqual(sorted[i]);
        }
      })
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

This follows the same core idea shown in the fast-check quick start: generate an array, sort it, then assert that every adjacent pair is ordered correctly. The test works because it checks the shape of the result, not one exact expected array.

Add a stronger property

Ordering alone is not enough. A broken implementation could return a sorted list of the wrong numbers, so add a second test that compares element counts before and after sorting.

function frequencyMap(values: number[]): Map<number, number> {
  const counts = new Map<number, number>();
  for (const value of values) {
    counts.set(value, (counts.get(value) ?? 0) + 1);
  }
  return counts;
}

describe('sortNumbersAscending', () => {
  it('preserves the same multiset of values', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (data) => {
        const sorted = sortNumbersAscending(data);
        expect(frequencyMap(sorted)).toEqual(frequencyMap(data));
      })
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

This property is especially useful because it catches a class of bugs that example tests often miss, such as accidentally removing duplicates or mutating values during transformation.

Handle edge cases

Property tests become much more valuable when you think about the shape of your input space. Arrays can be empty, single-element, already sorted, reverse sorted, full of duplicates, or contain negative and large numbers. fast-check can generate all of these automatically, which is why it tends to expose cases humans forget to write down.

If your function has preconditions, express them clearly. For example, if you only want finite numbers, constrain the generator:

fc.array(fc.float({ noNaN: true, noDefaultInfinity: true }))
Enter fullscreen mode Exit fullscreen mode

This keeps the test aligned with the domain you actually support.

Shrinking failures

One of the best parts of property-based testing is shrinking. When fast-check finds a failing case, it tries to reduce it to a smaller counterexample so you can debug faster. Instead of getting a giant random array, you might get something minimal like ``, which is much easier to reason about.

That makes failures more actionable than traditional fuzzing, because the tool not only finds the bug but also helps isolate it. In practice, this can save a lot of time when a property fails only for certain combinations of values.

Avoid common traps

A common mistake is to test the function against itself. For example, using data.sort() as the expected output makes the test meaningless because you are trusting the same logic you are trying to validate. Instead, test observable properties like ordering, preservation, and idempotence.

Another trap is writing a property that is too weak. “The output is an array” is true even for broken code, so the property needs to describe behavior that matters. Good properties are specific enough to catch real defects but broad enough to hold for many inputs.

Put it in the pyramid

Property-based tests belong in the lower or middle part of your test pyramid, depending on what they cover. They are usually fast enough for regular CI, but they should complement, not replace, a small set of example-based tests.

A practical mix looks like this:

  • Unit tests: exact examples and boundary cases.
  • Property tests: broad invariants over randomized input.
  • Integration tests: verify sorting is used correctly in a service, UI table, or API response.

That structure gives you breadth without overloading the suite with slow end-to-end checks. It also matches the guidance to capture metrics such as execution time, flaky test rate, and defect leakage across layers.

A complete mini-suite

Here is a compact suite you can adapt:

`ts
import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import { sortNumbersAscending } from './sortNumbersAscending';

function frequencyMap(values: number[]): Map {
const counts = new Map();
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return counts;
}

describe('sortNumbersAscending', () => {
it('sorts a simple array', () => {
expect(sortNumbersAscending()).toEqual();
});

it('returns values in ascending order for all generated arrays', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
const sorted = sortNumbersAscending(data);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i - 1]).toBeLessThanOrEqual(sorted[i]);
}
})
);
});

it('preserves all values and duplicates', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
const sorted = sortNumbersAscending(data);
expect(frequencyMap(sorted)).toEqual(frequencyMap(data));
})
);
});
});
`

This suite is small, but it covers far more than a handful of hand-written examples because the property tests explore a wide input space automatically. That makes it a strong pattern for logic-heavy code where correctness depends on invariants rather than a few fixed outputs.

When to use it

Use property-based testing when the output should obey a rule, not just match one fixed answer. It works especially well for sorting, parsing, validation, normalization, date math, routing, and transformation code.

Skip it when the behavior is mostly visual, workflow-driven, or highly dependent on external systems. In those cases, traditional unit tests plus a few integration tests are usually a better fit within the test pyramid.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)