Global WatchGlobal Watch Docs
Testing

Property-Based Testing

Property-Based Testing with fast-check

Global Watch uses fast-check for property-based testing. Unlike traditional unit tests that verify specific examples, property-based tests verify that certain properties hold true across all valid inputs.

Why Property-Based Testing?

Traditional unit tests check specific examples:

// Traditional unit test
it('should add two numbers', () => {
  expect(add(2, 3)).toBe(5);
  expect(add(0, 0)).toBe(0);
  expect(add(-1, 1)).toBe(0);
});

Property-based tests verify universal properties:

// Property-based test
it('should be commutative', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), (a, b) => {
      return add(a, b) === add(b, a);
    })
  );
});

Benefits

  1. Find edge cases - Automatically discovers inputs you didn't think of
  2. Better coverage - Tests hundreds of inputs per property
  3. Document invariants - Properties serve as executable specifications
  4. Shrinking - When a test fails, fast-check finds the minimal failing case

Getting Started

Installation

fast-check is included in the project dependencies:

pnpm add -D fast-check

Basic Usage

import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';

describe('String utilities', () => {
  it('reverse(reverse(s)) === s', () => {
    fc.assert(
      fc.property(fc.string(), (s) => {
        return reverse(reverse(s)) === s;
      })
    );
  });
});

Writing Property Tests

Property Structure

A property test has three parts:

  1. Arbitraries - Generators for random test data
  2. Property function - The invariant to verify
  3. Assertion - Usually implicit (return true/false)
fc.assert(
  fc.property(
    // 1. Arbitraries (generators)
    fc.string(),
    fc.integer({ min: 0, max: 100 }),
    
    // 2. Property function
    (str, num) => {
      // 3. Return true if property holds
      return someCondition(str, num);
    }
  ),
  // Optional: configuration
  { numRuns: 100 }
);

Common Arbitraries

fast-check provides many built-in arbitraries:

// Primitives
fc.integer()                    // Any integer
fc.integer({ min: 0, max: 100 }) // Bounded integer
fc.nat()                        // Natural number (>= 0)
fc.float()                      // Floating point
fc.boolean()                    // true or false
fc.string()                     // Any string
fc.string({ minLength: 1 })     // Non-empty string

// Collections
fc.array(fc.integer())          // Array of integers
fc.array(fc.string(), { minLength: 1, maxLength: 10 })
fc.set(fc.integer())            // Set of integers
fc.dictionary(fc.string(), fc.integer())

// Objects
fc.record({
  name: fc.string(),
  age: fc.nat(),
  email: fc.emailAddress(),
})

// Special
fc.uuid()                       // UUID v4
fc.date()                       // Date object
fc.emailAddress()               // Valid email
fc.ipV4()                       // IPv4 address
fc.json()                       // Valid JSON

Custom Arbitraries

Create domain-specific generators:

// Custom arbitrary for Project
const projectArbitrary = fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1, maxLength: 100 }),
  accountId: fc.uuid(),
  geometry: fc.option(polygonArbitrary),
  status: fc.constantFrom('active', 'archived'),
  createdAt: fc.date(),
});

// Custom arbitrary for valid email
const validEmailArbitrary = fc
  .tuple(
    fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[a-z]+$/.test(s)),
    fc.constantFrom('example.com', 'test.org', 'mail.net')
  )
  .map(([local, domain]) => `${local}@${domain}`);

// Custom arbitrary for GeoJSON polygon
const polygonArbitrary = fc
  .array(
    fc.tuple(
      fc.float({ min: -180, max: 180 }),  // longitude
      fc.float({ min: -90, max: 90 })     // latitude
    ),
    { minLength: 4, maxLength: 10 }
  )
  .map((coords) => ({
    type: 'Polygon',
    coordinates: [[...coords, coords[0]]], // Close the ring
  }));

Property Patterns

1. Roundtrip Properties

Verify that encoding/decoding are inverses:

describe('JSON serialization', () => {
  it('fromJSON(toJSON(entity)) === entity', () => {
    fc.assert(
      fc.property(projectArbitrary, (project) => {
        const json = project.toJSON();
        const restored = Project.fromJSON(json);
        return deepEqual(project, restored);
      })
    );
  });
});

describe('URL encoding', () => {
  it('decode(encode(s)) === s', () => {
    fc.assert(
      fc.property(fc.string(), (s) => {
        return decodeURIComponent(encodeURIComponent(s)) === s;
      })
    );
  });
});

2. Invariant Properties

Verify that operations maintain invariants:

describe('Hectares value object', () => {
  it('should always be non-negative', () => {
    fc.assert(
      fc.property(fc.float({ min: 0 }), (value) => {
        const result = Hectares.create(value);
        if (result.ok) {
          return result.value.toNumber() >= 0;
        }
        return true; // Invalid input is ok
      })
    );
  });

  it('add should increase or maintain value', () => {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 1000 }),
        fc.float({ min: 0, max: 1000 }),
        (a, b) => {
          const ha = Hectares.create(a).value!;
          const hb = Hectares.create(b).value!;
          const sum = ha.add(hb);
          return sum.toNumber() >= ha.toNumber();
        }
      )
    );
  });
});

3. Idempotent Properties

Verify that applying an operation twice has the same effect as once:

describe('Project.archive()', () => {
  it('archive is idempotent (conceptually)', () => {
    fc.assert(
      fc.property(projectArbitrary, (projectData) => {
        const project1 = Project.create(projectData);
        const project2 = Project.create(projectData);
        
        project1.archive();
        project2.archive();
        // Second archive would throw, but state is same
        
        return project1.isArchived === project2.isArchived;
      })
    );
  });
});

4. Commutativity Properties

Verify that order doesn't matter:

describe('Set operations', () => {
  it('union is commutative', () => {
    fc.assert(
      fc.property(
        fc.set(fc.integer()),
        fc.set(fc.integer()),
        (a, b) => {
          const union1 = new Set([...a, ...b]);
          const union2 = new Set([...b, ...a]);
          return setsEqual(union1, union2);
        }
      )
    );
  });
});

5. Oracle Properties

Compare implementation against a reference:

describe('calculateArea', () => {
  it('should match turf.js calculation', () => {
    fc.assert(
      fc.property(polygonArbitrary, (geometry) => {
        const ourResult = calculateArea(geometry);
        const turfResult = turf.area(geometry) / 10000; // m² to ha
        
        return Math.abs(ourResult - turfResult) < 0.01;
      })
    );
  });
});

Testing Domain Logic

Testing Entities

// core/domain/project/__tests__/project.property.test.ts
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { Project } from '../project.entity';

/**
 * Property 1: Project Name Validation
 * **Validates: Requirements 2.1**
 */
describe('Project Entity Properties', () => {
  const validNameArbitrary = fc.string({ minLength: 1, maxLength: 100 });
  const invalidNameArbitrary = fc.constantFrom('', '   ', null, undefined);

  it('should accept any non-empty name', () => {
    fc.assert(
      fc.property(validNameArbitrary, fc.uuid(), (name, accountId) => {
        const project = Project.create({ name: name.trim(), accountId });
        return project.name.length > 0;
      }),
      { numRuns: 100 }
    );
  });

  it('should reject empty names', () => {
    fc.assert(
      fc.property(fc.uuid(), (accountId) => {
        try {
          Project.create({ name: '', accountId });
          return false; // Should have thrown
        } catch {
          return true;
        }
      }),
      { numRuns: 50 }
    );
  });
});

Testing Value Objects

// core/domain/value-objects/__tests__/email.property.test.ts
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { Email } from '../email.vo';

/**
 * Property 2: Email Validation
 * **Validates: Requirements 3.2**
 */
describe('Email Value Object Properties', () => {
  it('should normalize all valid emails to lowercase', () => {
    fc.assert(
      fc.property(fc.emailAddress(), (email) => {
        const result = Email.create(email);
        if (result.ok) {
          return result.value.toString() === email.toLowerCase();
        }
        return true;
      }),
      { numRuns: 100 }
    );
  });

  it('should reject strings without @ symbol', () => {
    fc.assert(
      fc.property(
        fc.string().filter(s => !s.includes('@')),
        (invalidEmail) => {
          const result = Email.create(invalidEmail);
          return !result.ok;
        }
      ),
      { numRuns: 100 }
    );
  });
});

Testing Repository Contracts

// infrastructure/__tests__/repository.property.test.ts
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';

/**
 * Property 3: Repository CRUD Consistency
 * **Validates: Requirements 4.1**
 */
describe('Repository Properties', () => {
  it('findById should return what was created', () => {
    fc.assert(
      fc.asyncProperty(projectArbitrary, async (projectData) => {
        const created = await repository.create(projectData);
        if (!created.ok) return true;
        
        const found = await repository.findById(created.value.id);
        
        return found.ok && found.value.id === created.value.id;
      }),
      { numRuns: 50 }
    );
  });

  it('delete should make findById return not found', () => {
    fc.assert(
      fc.asyncProperty(projectArbitrary, async (projectData) => {
        const created = await repository.create(projectData);
        if (!created.ok) return true;
        
        await repository.delete(created.value.id);
        const found = await repository.findById(created.value.id);
        
        return !found.ok;
      }),
      { numRuns: 50 }
    );
  });
});

Configuration Options

Number of Runs

fc.assert(
  fc.property(fc.integer(), (n) => n + 0 === n),
  { numRuns: 1000 } // Run 1000 times instead of default 100
);

Seed for Reproducibility

fc.assert(
  fc.property(fc.integer(), (n) => n + 0 === n),
  { seed: 42 } // Use specific seed for reproducibility
);

Verbose Output

fc.assert(
  fc.property(fc.integer(), (n) => n + 0 === n),
  { verbose: true } // Show all generated values
);

Handling Failures

Shrinking

When a property fails, fast-check automatically shrinks to find the minimal failing case:

// If this fails with input [1000, 500, 300, 200, 100]
// fast-check will shrink to find minimal case like [1]
it('array should not exceed max length', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      return arr.length <= 5; // Will fail and shrink
    })
  );
});

Debugging Failures

// When a test fails, fast-check provides:
// 1. The failing input
// 2. The seed to reproduce
// 3. The shrunk minimal case

// Example failure output:
// Property failed after 23 tests
// Seed: 1234567890
// Counterexample: [6]
// Shrunk 5 time(s)

Reproducing Failures

// Use the seed from failure to reproduce
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return arr.length <= 5;
  }),
  { seed: 1234567890 } // Reproduce exact failure
);

Integration with Vitest

Test Annotations

/**
 * Feature: project-management
 * Property 1: Project Area Calculation
 * **Validates: Requirements 2.3**
 */
describe('Project Area Properties', () => {
  it('area should be non-negative for any valid geometry', () => {
    fc.assert(
      fc.property(polygonArbitrary, (geometry) => {
        const project = Project.create({
          name: 'Test',
          accountId: 'acc-123',
          geometry,
        });
        return project.calculateArea() >= 0;
      }),
      { numRuns: 100 }
    );
  });
});

Running Property Tests

# Run all tests including property tests
pnpm --filter web test:run

# Run only property tests
pnpm --filter web test:run --grep "Property"

# Run with specific seed
FAST_CHECK_SEED=42 pnpm --filter web test:run

Best Practices

1. Start with Simple Properties

// Start simple
it('length is non-negative', () => {
  fc.assert(fc.property(fc.string(), (s) => s.length >= 0));
});

// Then add complexity
it('split and join are inverses', () => {
  fc.assert(
    fc.property(fc.string(), fc.string({ minLength: 1 }), (s, sep) => {
      return s.split(sep).join(sep) === s || !s.includes(sep);
    })
  );
});

2. Use Preconditions

it('division is inverse of multiplication', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), (a, b) => {
      fc.pre(b !== 0); // Skip when b is 0
      return (a * b) / b === a;
    })
  );
});

3. Combine with Unit Tests

describe('Email validation', () => {
  // Unit tests for specific cases
  it('should accept standard email', () => {
    expect(Email.create('user@example.com').ok).toBe(true);
  });

  it('should reject email without domain', () => {
    expect(Email.create('user@').ok).toBe(false);
  });

  // Property test for general behavior
  it('should normalize all valid emails', () => {
    fc.assert(
      fc.property(fc.emailAddress(), (email) => {
        const result = Email.create(email);
        return !result.ok || result.value.toString() === email.toLowerCase();
      })
    );
  });
});

Next Steps

On this page