03 Property tests 6 chapters

Theme 05 · Test-Confidence Oriented

Property tests

Explanation

Property tests

Plain Human Explanation

A property test checks a rule across many generated examples instead of only the examples a person remembered to write. The rule is the important part: totals should never go negative, sorting should not lose items, and a round trip should return the same meaning.

This is useful when the product promise is broad. A discount calculator should work for many carts, not just one cart from a unit test.

Technical Explanation

Use a generator to create valid inputs, then assert an invariant that should always hold. An invariant is a rule that remains true no matter which valid input the test receives.

Good property tests keep generators realistic. They do not generate impossible data unless the behavior being tested is input rejection.

Why It Matters

  • User impact: edge cases are less likely to hide between hand-picked examples.
  • Product behavior: the test documents the rule that must always hold.
  • Risk: property tests can become noisy if generators create unrealistic inputs or the invariant is vague.
  • Decision point: use this when a small number of named examples cannot cover the shape of the problem.

The Core Move

Name the invariant first, then generate many valid inputs to challenge it. Keep a few normal examples too, because they explain the rule faster to humans.

Small Example

Property tests: Small Example

Bad TypeScript Example

function applyDiscount(totalCents: number, discountCents: number) {
  return totalCents - discountCents;
}

test("discount lowers the total", () => {
  expect(applyDiscount(1000, 200)).toBe(800);
});
function applyDiscount(
  totalCents: number,
  discountCents: number,
) {
  return totalCents - discountCents;
}

test("discount lowers the total", () => {
  expect(applyDiscount(1000, 200)).toBe(
    800,
  );
});

Good TypeScript Example

import fc from "fast-check";

function applyDiscount(totalCents: number, discountCents: number) {
  return Math.max(0, totalCents - discountCents);
}

test("discounted totals never become negative", () => {
  fc.assert(
    fc.property(
      fc.integer({ min: 0, max: 1_000_000 }),
      fc.integer({ min: 0, max: 1_000_000 }),
      (totalCents, discountCents) => {
        expect(applyDiscount(totalCents, discountCents)).toBeGreaterThanOrEqual(0);
      },
    ),
  );
});
import fc from "fast-check";

function applyDiscount(
  totalCents: number,
  discountCents: number,
) {
  return Math.max(
    0,
    totalCents - discountCents,
  );
}

test("discounted totals never become negative", () => {
  fc.assert(
    fc.property(
      fc.integer({
        min: 0,
        max: 1_000_000,
      }),
      fc.integer({
        min: 0,
        max: 1_000_000,
      }),
      (totalCents, discountCents) => {
        expect(
          applyDiscount(
            totalCents,
            discountCents,
          ),
        ).toBeGreaterThanOrEqual(0);
      },
    ),
  );
});

What Changed

  • The bad version proves one remembered example.
  • The good version names the product invariant: totals cannot go below zero.
  • Generated inputs make edge cases like full discounts and oversized discounts routine.

Realistic Example

Property tests: Realistic Example

Cart totals are a good fit for property tests because there are many combinations of quantities, prices, and discount percentages.

Bad TypeScript Example

type CartLine = {
  quantity: number;
  priceCents: number;
};

function calculateCartTotal(lines: CartLine[], discountPercent: number) {
  const subtotal = lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0);
  return subtotal - subtotal * discountPercent;
}

test("cart total applies discount", () => {
  expect(calculateCartTotal([{ quantity: 2, priceCents: 500 }], 0.1)).toBe(900);
});
type CartLine = {
  quantity: number;
  priceCents: number;
};

function calculateCartTotal(
  lines: CartLine[],
  discountPercent: number,
) {
  const subtotal = lines.reduce(
    (sum, line) =>
      sum + line.priceCents * line.quantity,
    0,
  );

  return (
    subtotal - subtotal * discountPercent
  );
}

test("cart total applies discount", () => {
  expect(
    calculateCartTotal(
      [
        {
          quantity: 2,
          priceCents: 500,
        },
      ],
      0.1,
    ),
  ).toBe(900);
});

Good TypeScript Example

import fc from "fast-check";

type CartLine = {
  quantity: number;
  priceCents: number;
};

function calculateCartTotal(lines: CartLine[], discountPercent: number) {
  const subtotal = lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0);
  const discount = Math.round(subtotal * discountPercent);
  return Math.max(0, subtotal - discount);
}

const cartLine = fc.record({
  quantity: fc.integer({ min: 1, max: 20 }),
  priceCents: fc.integer({ min: 1, max: 100_000 }),
});

test("cart total stays between zero and subtotal", () => {
  fc.assert(
    fc.property(
      fc.array(cartLine, { minLength: 1, maxLength: 30 }),
      fc.float({ min: 0, max: 1, noNaN: true }),
      (lines, discountPercent) => {
        const subtotal = lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0);
        const total = calculateCartTotal(lines, discountPercent);

        expect(total).toBeGreaterThanOrEqual(0);
        expect(total).toBeLessThanOrEqual(subtotal);
        expect(Number.isInteger(total)).toBe(true);
      },
    ),
  );
});
import fc from "fast-check";

type CartLine = {
  quantity: number;
  priceCents: number;
};

function calculateCartTotal(
  lines: CartLine[],
  discountPercent: number,
) {
  const subtotal = lines.reduce(
    (sum, line) =>
      sum + line.priceCents * line.quantity,
    0,
  );

  const discount = Math.round(
    subtotal * discountPercent,
  );

  return Math.max(0, subtotal - discount);
}

const cartLine = fc.record({
  quantity: fc.integer({
    min: 1,
    max: 20,
  }),
  priceCents: fc.integer({
    min: 1,
    max: 100_000,
  }),
});

test("cart total stays between zero and subtotal", () => {
  fc.assert(
    fc.property(
      fc.array(cartLine, {
        minLength: 1,
        maxLength: 30,
      }),
      fc.float({
        min: 0,
        max: 1,
        noNaN: true,
      }),
      (lines, discountPercent) => {
        const subtotal = lines.reduce(
          (sum, line) =>
            sum +
            line.priceCents * line.quantity,
          0,
        );

        const total = calculateCartTotal(
          lines,
          discountPercent,
        );

        expect(
          total,
        ).toBeGreaterThanOrEqual(0);

        expect(total).toBeLessThanOrEqual(
          subtotal,
        );

        expect(
          Number.isInteger(total),
        ).toBe(true);
      },
    ),
  );
});

What Changed

  • The bad version mixes a broad pricing rule with one narrow example.
  • The good version generates many realistic carts and discount percentages.
  • The assertions describe durable product promises instead of one expected number.

System Example

Property tests: System Example

At system scale, property tests are useful for transformations: imports, exports, normalization, ordering, deduplication, and state transitions.

Larger System-Level Bad TypeScript Example

type ImportedContact = {
  email: string;
  name: string;
};

function normalizeContacts(rows: ImportedContact[]) {
  return rows.map((row) => ({
    email: row.email.trim().toLowerCase(),
    name: row.name.trim(),
  }));
}

test("normalizes contacts", () => {
  expect(normalizeContacts([{ email: " A@EXAMPLE.COM ", name: " Ada " }])).toEqual([
    { email: "a@example.com", name: "Ada" },
  ]);
});
type ImportedContact = {
  email: string;
  name: string;
};

function normalizeContacts(
  rows: ImportedContact[],
) {
  return rows.map((row) => ({
    email: row.email.trim().toLowerCase(),
    name: row.name.trim(),
  }));
}

test("normalizes contacts", () => {
  expect(
    normalizeContacts([
      {
        email: " A@EXAMPLE.COM ",
        name: " Ada ",
      },
    ]),
  ).toEqual([
    {
      email: "a@example.com",
      name: "Ada",
    },
  ]);
});

Larger System-Level Good TypeScript Example

import fc from "fast-check";

type ImportedContact = {
  email: string;
  name: string;
};

function normalizeContacts(rows: ImportedContact[]) {
  const seen = new Set<string>();
  const normalized: ImportedContact[] = [];

  for (const row of rows) {
    const email = row.email.trim().toLowerCase();
    if (seen.has(email)) continue;
    seen.add(email);
    normalized.push({ email, name: row.name.trim() });
  }

  return normalized;
}

const contactRow = fc.record({
  email: fc.emailAddress().map((email) => ` ${email.toUpperCase()} `),
  name: fc.string({ minLength: 1, maxLength: 80 }),
});

test("contact import keeps normalized emails unique", () => {
  fc.assert(
    fc.property(fc.array(contactRow, { maxLength: 100 }), (rows) => {
      const contacts = normalizeContacts(rows);
      const emails = contacts.map((contact) => contact.email);

      expect(new Set(emails).size).toBe(emails.length);
      expect(emails.every((email) => email === email.trim())).toBe(true);
      expect(emails.every((email) => email === email.toLowerCase())).toBe(true);
      expect(contacts.length).toBeLessThanOrEqual(rows.length);
    }),
  );
});
import fc from "fast-check";

type ImportedContact = {
  email: string;
  name: string;
};

function normalizeContacts(
  rows: ImportedContact[],
) {
  const seen = new Set<string>();

  const normalized: ImportedContact[] = [];

  for (const row of rows) {
    const email = row.email
      .trim()
      .toLowerCase();

    if (seen.has(email)) continue;

    seen.add(email);

    normalized.push({
      email,
      name: row.name.trim(),
    });
  }

  return normalized;
}

const contactRow = fc.record({
  email: fc
    .emailAddress()
    .map(
      (email) => ` ${email.toUpperCase()} `,
    ),
  name: fc.string({
    minLength: 1,
    maxLength: 80,
  }),
});

test("contact import keeps normalized emails unique", () => {
  fc.assert(
    fc.property(
      fc.array(contactRow, {
        maxLength: 100,
      }),
      (rows) => {
        const contacts =
          normalizeContacts(rows);

        const emails = contacts.map(
          (contact) => contact.email,
        );

        expect(new Set(emails).size).toBe(
          emails.length,
        );

        expect(
          emails.every(
            (email) =>
              email === email.trim(),
          ),
        ).toBe(true);

        expect(
          emails.every(
            (email) =>
              email === email.toLowerCase(),
          ),
        ).toBe(true);

        expect(
          contacts.length,
        ).toBeLessThanOrEqual(rows.length);
      },
    ),
  );
});

What Changed

  • The bad version checks one contact and misses duplicate behavior.
  • The good version tests the import promises across many generated rows.
  • The invariant focuses on what the system must preserve: unique, normalized emails without inventing extra contacts.

When To Use It

Property tests: When To Use It

Use This When

  • The behavior has a rule that should hold across many valid inputs.
  • Hand-picked examples keep missing edge cases in pricing, parsing, sorting, deduping, or state transitions.
  • You can describe the expected behavior with an invariant, not only with one exact output.

Avoid This When

  • A few named examples explain the behavior completely.
  • The generated inputs would be mostly unrealistic.
  • The failure would be hard to understand because the invariant is too broad or vague.

Tradeoffs

Property tests can find surprising bugs, but they require careful generators and clear failure messages. Keep a small set of example tests alongside them so humans can understand the business rule quickly.

  • Integration tests
  • Real seams
  • Observable behavior over mocks/spies

Practice Prompt

Property tests: Practice Prompt

Beginner Exercise

Find a function with several similar example tests. Write one sentence describing the rule those examples are trying to prove.

Intermediate Exercise

Create a generator for realistic inputs and assert one invariant that should always hold.

Stretch Exercise

Add a property test around a transformation such as import normalization, sorting, deduplication, or encode/decode behavior.

Reflection Question

What inputs should your generator refuse because they are not realistic product cases?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.