Testing

Property-Based Testing in JavaScript

A practical guide to property-based testing in JavaScript using fast-check to generate random inputs, find edge cases, and prove correctness properties of functions.

Property-Based Testing in JavaScript

Example-based tests verify that specific inputs produce specific outputs. add(2, 3) returns 5. sort([3, 1, 2]) returns [1, 2, 3]. These tests prove your code works for the examples you thought of. Property-based tests prove your code works for inputs you did not think of.

Instead of writing individual examples, you describe properties that must hold for all valid inputs. "For any two numbers, add(a, b) equals add(b, a)." The framework generates hundreds of random inputs and checks the property against each one. When it finds a failing input, it shrinks it to the smallest case that still fails.

This catches bugs that example-based tests miss — edge cases at boundary values, special characters in strings, empty arrays, negative numbers, and combinations you would never think to test manually.

Prerequisites

  • Node.js installed (v16+)
  • Jest or Mocha configured
  • Understanding of unit testing

Setup

npm install --save-dev fast-check

fast-check is the most popular property-based testing library for JavaScript. It integrates with Jest, Mocha, and other test frameworks.

Your First Property Test

Example-Based vs Property-Based

// Example-based: tests specific cases
test("sorts numbers ascending", function() {
  expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
  expect(sort([5, 5, 5])).toEqual([5, 5, 5]);
  expect(sort([])).toEqual([]);
  expect(sort([1])).toEqual([1]);
});

// Property-based: tests properties that hold for ALL inputs
var fc = require("fast-check");

test("sort produces ordered output", function() {
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      var sorted = sort(arr);

      // Property: every element is <= the next element
      for (var i = 0; i < sorted.length - 1; i++) {
        expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
      }
    })
  );
});

test("sort preserves array length", function() {
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      var sorted = sort(arr);
      expect(sorted.length).toBe(arr.length);
    })
  );
});

test("sort preserves elements", function() {
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      var sorted = sort(arr);

      // Same elements, possibly in different order
      var original = arr.slice().sort(function(a, b) { return a - b; });
      expect(sorted).toEqual(original);
    })
  );
});

fast-check generates hundreds of random integer arrays (including empty arrays, single-element arrays, arrays with duplicates, very large arrays) and checks each property.

Arbitraries: Generating Test Data

Arbitraries are generators for random test data. fast-check provides built-in arbitraries for common types.

Primitive Types

var fc = require("fast-check");

// Integers
fc.integer()                        // Any integer
fc.integer({ min: 0, max: 100 })    // Bounded integer
fc.nat()                            // Non-negative integer

// Floats
fc.float()                          // Any float
fc.double()                         // Any double

// Strings
fc.string()                         // Random string
fc.string({ minLength: 1, maxLength: 50 })
fc.hexaString()                     // Hex characters only
fc.asciiString()                    // ASCII characters only
fc.unicodeString()                  // Unicode characters

// Booleans
fc.boolean()                        // true or false

// Constants
fc.constant("fixed-value")          // Always returns the same value
fc.constantFrom("a", "b", "c")     // One of the provided values

Compound Types

// Arrays
fc.array(fc.integer())              // Array of integers
fc.array(fc.string(), { minLength: 1, maxLength: 10 })

// Objects
fc.record({
  name: fc.string({ minLength: 1 }),
  age: fc.integer({ min: 0, max: 150 }),
  email: fc.emailAddress()
})

// Tuples
fc.tuple(fc.string(), fc.integer())  // [string, number]

// One of several types
fc.oneof(fc.integer(), fc.string(), fc.boolean())

Custom Arbitraries

// User data generator
var userArbitrary = fc.record({
  name: fc.string({ minLength: 1, maxLength: 100 }),
  email: fc.emailAddress(),
  age: fc.integer({ min: 13, max: 120 }),
  role: fc.constantFrom("user", "admin", "moderator"),
  isActive: fc.boolean()
});

// Product data generator
var productArbitrary = fc.record({
  name: fc.string({ minLength: 1, maxLength: 200 }),
  price: fc.float({ min: 0.01, max: 99999.99 }),
  sku: fc.hexaString({ minLength: 6, maxLength: 10 }),
  category: fc.constantFrom("electronics", "books", "clothing", "food"),
  inStock: fc.boolean()
});

// Order with items
var orderItemArbitrary = fc.record({
  productId: fc.nat(),
  quantity: fc.integer({ min: 1, max: 100 }),
  unitPrice: fc.float({ min: 0.01, max: 9999.99 })
});

var orderArbitrary = fc.record({
  userId: fc.nat(),
  items: fc.array(orderItemArbitrary, { minLength: 1, maxLength: 20 }),
  couponCode: fc.option(fc.hexaString({ minLength: 6, maxLength: 6 }))
});

Common Properties to Test

Roundtrip / Encode-Decode

If you encode data and decode it, you should get the original data back:

test("JSON roundtrip preserves data", function() {
  fc.assert(
    fc.property(
      fc.record({
        name: fc.string(),
        value: fc.integer(),
        active: fc.boolean()
      }),
      function(obj) {
        var encoded = JSON.stringify(obj);
        var decoded = JSON.parse(encoded);
        expect(decoded).toEqual(obj);
      }
    )
  );
});

test("URL encode/decode roundtrip", function() {
  fc.assert(
    fc.property(fc.asciiString(), function(str) {
      var encoded = encodeURIComponent(str);
      var decoded = decodeURIComponent(encoded);
      expect(decoded).toBe(str);
    })
  );
});

test("base64 encode/decode roundtrip", function() {
  fc.assert(
    fc.property(fc.string(), function(str) {
      var encoded = Buffer.from(str).toString("base64");
      var decoded = Buffer.from(encoded, "base64").toString();
      expect(decoded).toBe(str);
    })
  );
});

Idempotency

Applying the operation twice gives the same result as applying it once:

test("trim is idempotent", function() {
  fc.assert(
    fc.property(fc.string(), function(str) {
      expect(str.trim().trim()).toBe(str.trim());
    })
  );
});

test("sort is idempotent", function() {
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      var once = sort(arr);
      var twice = sort(sort(arr));
      expect(twice).toEqual(once);
    })
  );
});

test("normalize path is idempotent", function() {
  var path = require("path");

  fc.assert(
    fc.property(fc.asciiString(), function(p) {
      var once = path.normalize(p);
      var twice = path.normalize(path.normalize(p));
      expect(twice).toBe(once);
    })
  );
});

Commutativity

Order of arguments does not matter:

test("addition is commutative", function() {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), function(a, b) {
      expect(add(a, b)).toBe(add(b, a));
    })
  );
});

test("set union is commutative", function() {
  fc.assert(
    fc.property(
      fc.array(fc.integer()),
      fc.array(fc.integer()),
      function(a, b) {
        var union1 = setUnion(a, b);
        var union2 = setUnion(b, a);
        expect(union1.sort()).toEqual(union2.sort());
      }
    )
  );
});

Invariants

Properties that must always hold regardless of input:

test("array length is non-negative", function() {
  fc.assert(
    fc.property(fc.array(fc.anything()), function(arr) {
      expect(arr.length).toBeGreaterThanOrEqual(0);
    })
  );
});

test("absolute value is non-negative", function() {
  fc.assert(
    fc.property(fc.float({ noNaN: true }), function(n) {
      expect(Math.abs(n)).toBeGreaterThanOrEqual(0);
    })
  );
});

test("filtered array is never longer than original", function() {
  fc.assert(
    fc.property(
      fc.array(fc.integer()),
      fc.integer(),
      function(arr, threshold) {
        var filtered = arr.filter(function(x) { return x > threshold; });
        expect(filtered.length).toBeLessThanOrEqual(arr.length);
      }
    )
  );
});

Oracle / Reference Implementation

Compare your implementation against a simpler reference:

test("custom sort matches built-in sort", function() {
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      var custom = myCustomSort(arr.slice());
      var builtin = arr.slice().sort(function(a, b) { return a - b; });
      expect(custom).toEqual(builtin);
    })
  );
});

Practical Examples

Testing a Validation Function

// validate.js
function validateAge(age) {
  if (typeof age !== "number") return { valid: false, error: "Age must be a number" };
  if (isNaN(age)) return { valid: false, error: "Age must be a number" };
  if (age < 0) return { valid: false, error: "Age cannot be negative" };
  if (age > 150) return { valid: false, error: "Age is unrealistic" };
  if (age !== Math.floor(age)) return { valid: false, error: "Age must be a whole number" };
  return { valid: true };
}

module.exports = { validateAge: validateAge };
var fc = require("fast-check");
var validate = require("./validate");

describe("validateAge", function() {
  test("accepts valid ages", function() {
    fc.assert(
      fc.property(
        fc.integer({ min: 0, max: 150 }),
        function(age) {
          var result = validate.validateAge(age);
          expect(result.valid).toBe(true);
        }
      )
    );
  });

  test("rejects negative ages", function() {
    fc.assert(
      fc.property(
        fc.integer({ min: -1000, max: -1 }),
        function(age) {
          var result = validate.validateAge(age);
          expect(result.valid).toBe(false);
          expect(result.error).toContain("negative");
        }
      )
    );
  });

  test("rejects non-numbers", function() {
    fc.assert(
      fc.property(
        fc.oneof(fc.string(), fc.boolean(), fc.constant(null), fc.constant(undefined)),
        function(value) {
          var result = validate.validateAge(value);
          expect(result.valid).toBe(false);
        }
      )
    );
  });

  test("rejects fractional ages", function() {
    fc.assert(
      fc.property(
        fc.double({ min: 0.01, max: 149.99, noNaN: true }),
        function(age) {
          if (age === Math.floor(age)) return; // Skip whole numbers

          var result = validate.validateAge(age);
          expect(result.valid).toBe(false);
          expect(result.error).toContain("whole number");
        }
      )
    );
  });
});

Testing a Slugify Function

// slugify.js
function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_]+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "");
}

module.exports = slugify;
var fc = require("fast-check");
var slugify = require("./slugify");

describe("slugify properties", function() {
  test("output is always lowercase", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var slug = slugify(input);
        expect(slug).toBe(slug.toLowerCase());
      })
    );
  });

  test("output contains no spaces", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var slug = slugify(input);
        expect(slug.indexOf(" ")).toBe(-1);
      })
    );
  });

  test("output contains only valid URL characters", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var slug = slugify(input);
        expect(slug).toMatch(/^[a-z0-9-]*$/);
      })
    );
  });

  test("output does not start or end with hyphens", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var slug = slugify(input);
        if (slug.length === 0) return;
        expect(slug[0]).not.toBe("-");
        expect(slug[slug.length - 1]).not.toBe("-");
      })
    );
  });

  test("output has no consecutive hyphens", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var slug = slugify(input);
        expect(slug.indexOf("--")).toBe(-1);
      })
    );
  });

  test("slugify is idempotent", function() {
    fc.assert(
      fc.property(fc.string(), function(input) {
        var once = slugify(input);
        var twice = slugify(slugify(input));
        expect(twice).toBe(once);
      })
    );
  });
});

Testing a Price Calculation

var fc = require("fast-check");

function calculateDiscount(price, discountPercent) {
  if (price < 0) throw new Error("Price cannot be negative");
  if (discountPercent < 0 || discountPercent > 100) throw new Error("Invalid discount");

  var discount = price * (discountPercent / 100);
  return Math.round((price - discount) * 100) / 100;
}

describe("calculateDiscount", function() {
  test("result is always non-negative", function() {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 100000, noNaN: true }),
        fc.float({ min: 0, max: 100, noNaN: true }),
        function(price, discount) {
          var result = calculateDiscount(price, discount);
          expect(result).toBeGreaterThanOrEqual(0);
        }
      )
    );
  });

  test("result is never more than the original price", function() {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 100000, noNaN: true }),
        fc.float({ min: 0, max: 100, noNaN: true }),
        function(price, discount) {
          var result = calculateDiscount(price, discount);
          expect(result).toBeLessThanOrEqual(price + 0.01); // Allow rounding
        }
      )
    );
  });

  test("0% discount returns original price", function() {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 100000, noNaN: true }),
        function(price) {
          var rounded = Math.round(price * 100) / 100;
          var result = calculateDiscount(rounded, 0);
          expect(result).toBe(rounded);
        }
      )
    );
  });

  test("100% discount returns 0", function() {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 100000, noNaN: true }),
        function(price) {
          var result = calculateDiscount(price, 100);
          expect(result).toBe(0);
        }
      )
    );
  });

  test("higher discount means lower price", function() {
    fc.assert(
      fc.property(
        fc.float({ min: 1, max: 100000, noNaN: true }),
        fc.float({ min: 0, max: 49, noNaN: true }),
        function(price, lowDiscount) {
          var highDiscount = lowDiscount + 50;
          if (highDiscount > 100) highDiscount = 100;

          var lowResult = calculateDiscount(price, lowDiscount);
          var highResult = calculateDiscount(price, highDiscount);

          expect(highResult).toBeLessThanOrEqual(lowResult);
        }
      )
    );
  });
});

Shrinking

When fast-check finds a failing input, it automatically shrinks it to the smallest failing case:

test("demonstrates shrinking", function() {
  // This will fail — fast-check will shrink to the smallest failing array
  fc.assert(
    fc.property(fc.array(fc.integer()), function(arr) {
      // Buggy: fails for arrays longer than 3
      if (arr.length > 3) {
        throw new Error("Array too long: " + arr.length);
      }
    })
  );
  // fast-check reports: [0, 0, 0, 0] — the smallest array with length > 3
});

Shrinking finds the simplest reproduction case, making debugging easier. Instead of reporting a failure with a 50-element array of random numbers, it reports [0, 0, 0, 0].

Configuration

// Run more iterations for thorough testing
fc.assert(
  fc.property(fc.string(), function(s) {
    expect(slugify(s)).toBe(slugify(s));
  }),
  { numRuns: 1000 } // Default is 100
);

// Reproducible failures with seed
fc.assert(
  fc.property(fc.integer(), function(n) {
    expect(Math.abs(n)).toBeGreaterThanOrEqual(0);
  }),
  { seed: 42 } // Same random values every run
);

// Verbose output for debugging
fc.assert(
  fc.property(fc.integer(), function(n) {
    return n * 2 === n + n;
  }),
  { verbose: true }
);

Common Issues and Troubleshooting

Tests are slow because fast-check generates too many cases

The default 100 runs is usually sufficient. Complex arbitraries slow things down:

Fix: Reduce numRuns for slow tests. Constrain arbitraries to realistic ranges. Use simpler arbitraries where possible. Run property tests in a separate CI step from unit tests.

Property test fails but the error is hard to understand

The generated input is large and complex:

Fix: fast-check automatically shrinks failing cases. If the shrunk case is still complex, simplify the arbitrary. Add { verbose: true } to see the full shrinking process. Log the failing input in the property function.

Cannot express the property I want to test

Not every behavior has a simple property:

Fix: Start with universal properties: idempotency, roundtrip, invariants. Use oracle testing — compare against a simpler reference implementation. Sometimes example-based tests are more appropriate for complex business rules.

Property test passes but code has bugs

The property is too weak — it does not capture the actual requirement:

Fix: Write multiple properties that together fully describe the behavior. Combine property-based tests with example-based tests for specific edge cases. Review properties to ensure they would fail for known bug patterns.

Best Practices

  • Combine property-based and example-based tests. Property tests find unexpected edge cases. Example tests document specific requirements. Use both.
  • Start with simple properties. Idempotency, roundtrip, and invariant properties are easy to write and catch many bugs. Add more specific properties as needed.
  • Constrain arbitraries to realistic inputs. Testing with integers from -2^31 to 2^31 may not be useful if your function only accepts ages 0-150. Realistic ranges find relevant bugs.
  • Let fast-check shrink. Do not manually simplify failing cases. fast-check's shrinking finds the minimal reproduction automatically.
  • Use seed for reproducibility. When a property test fails in CI, record the seed from the error output. Use it to reproduce the failure locally.
  • Keep properties independent. Each property should test one aspect of behavior. Multiple simple properties are better than one complex property.
  • Test error handling with invalid arbitraries. Generate inputs outside the valid range and verify the function rejects them correctly.
  • Run property tests in CI. With a fixed seed for reproducibility, property tests are deterministic and suitable for CI. Without a seed, they explore different inputs on each run — valuable but potentially flaky.

References

Powered by Contentful