Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active May 26, 2025 16:46
Show Gist options
  • Save mizchi/fe025e243442f6a7cdab732034607410 to your computer and use it in GitHub Desktop.
Save mizchi/fe025e243442f6a7cdab732034607410 to your computer and use it in GitHub Desktop.
/**
* mizchi/pbt - A simple property-based testing library by generator style
* Inspired by fast-check
* Example
* ```ts
* import { generate, integer, string } from "@mizchi/pbt";
*
* const g = generate(integer({ min: 1, max: 10 }));
* for (const n of g(10000)) {
* if (n < 1 || n > 10) {
* throw new Error(`Number out of range: ${n}`);
* }
* }
* ```
* @module
*/
export type PropertyGenerator<T> = () => T;
export function generate<T>(
p: PropertyGenerator<T>,
opts: {
filter?: (value: T) => boolean;
} = {}
): (n: number) => Generator<T> {
const g = function* (n: number): Generator<T> {
if (opts.filter) {
let i = 0;
while (i < n) {
const value = p();
if (opts.filter(value)) {
yield value;
i++;
}
}
} else {
for (let i = 0; i < n; i++) {
yield p();
}
}
};
return (n: number) => g(n);
}
export function integer(def: {
min: number;
max: number;
}): PropertyGenerator<number> {
return () => Math.floor(Math.random() * (def.max - def.min + 1)) + def.min;
}
export function float(def: {
min: number;
max: number;
}): PropertyGenerator<number> {
return () => Math.random() * (def.max - def.min) + def.min;
}
export function number(): PropertyGenerator<number> {
const MAX = Number.MAX_SAFE_INTEGER;
return () => {
const r = Math.random();
return r * MAX - MAX / 2;
}
}
export function array<T>(def: {
minLength: number;
maxLength: number;
item: PropertyGenerator<T>;
}): PropertyGenerator<Array<T>> {
return () => {
const length =
Math.floor(Math.random() * (def.maxLength - def.minLength + 1)) +
def.minLength;
return Array.from({ length }, () => def.item());
};
}
export function boolean(def?: {
trueProbability?: number;
}): PropertyGenerator<boolean> {
return () => Math.random() < (def?.trueProbability ?? 0.5);
}
export function string(def: {
minLength: number;
maxLength: number;
}): PropertyGenerator<string> {
const length =
Math.floor(Math.random() * (def.maxLength - def.minLength + 1)) +
def.minLength;
return () =>
Array.from({ length }, () =>
String.fromCharCode(Math.floor(Math.random() * 26) + 97)
).join("");
}
export function object<
T extends {
[key: string]: PropertyGenerator<any>;
}
>(
def: T
): PropertyGenerator<{
[K in keyof T]: ReturnType<T[K]>;
}> {
return () => {
const result = {} as any;
for (const key in def) {
result[key] = def[key]();
}
return result;
};
}
export function $null(): PropertyGenerator<null> {
return () => null;
}
export function $undefined(): PropertyGenerator<undefined> {
return () => undefined;
}
export function $void(): PropertyGenerator<void> {
return () => undefined;
}
export function nullable<T>(
p: PropertyGenerator<T>,
def?: { nullProbability?: number }
): PropertyGenerator<T | void> {
const nullProbability = def?.nullProbability ?? 0.5;
return () => (Math.random() < nullProbability ? p() : undefined);
}
export function anyOf<G extends Array<PropertyGenerator<any>>>(
generators: G,
def: {
/**
* @returns returns index of generator to use
*/
bias?: () => number;
} = {}
): PropertyGenerator<ReturnType<G[number]>> {
return () => {
const index = def.bias?.() ?? Math.floor(Math.random() * generators.length);
return generators[index]();
};
}
export const g = {
integer,
float,
number,
array,
boolean,
string,
object,
null: $null,
undefined: $undefined,
void: $void,
nullable,
anyOf,
};
export function map<T, U>(
p: PropertyGenerator<T>,
fn: (value: T) => U
): PropertyGenerator<U> {
return () => fn(p());
}
Deno.test("integer", () => {
const g = generate(integer({ min: 1, max: 10 }));
for (const n of g(10000)) {
if (n < 1 || n > 10) {
throw new Error(`Number out of range: ${n}`);
}
}
});
Deno.test("float", () => {
const g = generate(float({ min: 1.0, max: 10.0 }));
for (const n of g(10000)) {
if (n < 1.0 || n > 10.0) {
throw new Error(`Number out of range: ${n}`);
}
}
});
Deno.test("number", () => {
const g = generate(number());
for (const num of g(10000)) {
if (num < -Number.MAX_SAFE_INTEGER || num > Number.MAX_SAFE_INTEGER) {
throw new Error(`Number out of range: ${num}`);
}
}
});
Deno.test("array", () => {
const g = generate(
array({
minLength: 1,
maxLength: 5,
item: integer({ min: 1, max: 10 }),
})
);
for (const arr of g(100)) {
if (arr.length < 1 || arr.length > 5) {
throw new Error(`Array length out of range: ${arr.length}`);
}
for (const num of arr) {
if (num < 1 || num > 10) {
throw new Error(`Number out of range in array: ${num}`);
}
}
}
});
Deno.test("boolean", () => {
const g = generate(boolean());
for (const b of g(10000)) {
if (typeof b !== "boolean") {
throw new Error(`Expected boolean, got: ${b}`);
}
}
});
Deno.test("object", () => {
const g = generate(
object({
id: integer({ min: 1, max: 100 }),
name: string({ minLength: 1, maxLength: 10 }),
score: float({ min: 0.0, max: 100.0 }),
})
);
for (const obj of g(100)) {
if (obj.id < 1 || obj.id > 100) {
throw new Error(`ID out of range: ${obj.id}`);
}
if (obj.name.length < 1 || obj.name.length > 10) {
throw new Error(`Name length out of range: ${obj.name.length}`);
}
if (obj.score < 0.0 || obj.score > 100.0) {
throw new Error(`Score out of range: ${obj.score}`);
}
}
});
Deno.test("map", () => {
const g = generate(map(integer({ min: 1, max: 1000 }), (n) => n * 2));
for (const num of g(10000)) {
if (num % 2 !== 0) {
throw new Error(`Mapped number is not even: ${num}`);
}
}
});
Deno.test("anyOf", () => {
const g = generate(
anyOf([integer({ min: 1, max: 10 }), $null()], {
bias: () => (Math.random() < 0.5 ? 0 : 1), // 50% chance for each
})
);
for (const num of g(10000)) {
const v: number | null = num;
if (v == null) {
continue; // null is allowed
}
if (typeof v == "number") {
continue; // number is allowed to be in range 1-10
}
throw new Error(`Number out of range: ${num}`);
}
});
Deno.test("custom", async () => {
const g = generate(() => Math.floor(Math.random() * 3) + 1);
for await (const num of g(10)) {
if (num < 1 || num > 3) {
throw new Error(`Number out of range: ${num}`);
}
}
});
Deno.test("nullable", () => {
const g = generate(
nullable(integer({ min: 1, max: 10 }), { nullProbability: 0.3 })
);
for (const value of g(10000)) {
if (value == null) {
continue; // null is allowed
} else if (typeof value === "number") {
continue;
} else {
throw new Error(`Expected number or null, got: ${typeof value}`);
}
}
});
Deno.test("null", () => {
const g = generate($null());
for (const value of g(10000)) {
if (value !== null) {
throw new Error(`Expected null, got: ${value}`);
}
}
});
Deno.test("undefined", () => {
const g = generate($undefined());
for (const value of g(10000)) {
if (value !== undefined) {
throw new Error(`Expected undefined, got: ${value}`);
}
}
});
import { expect } from "@std/expect/expect";
Deno.test("generate with filter", () => {
const g = generate(integer({ min: 1, max: 100 }), {
filter: (n) => n % 2 === 0, // only even numbers
});
let generated = 0;
for (const num of g(10000)) {
if (num % 2 !== 0) {
throw new Error(`Expected even number, got: ${num}`);
}
generated++;
}
expect(generated).toBe(10000);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment