/// Demystify Property-Based Testing /// /// Arnaud Bailly /// /// @abailly.bsky.social - @abailly@piaille.fr - @dr_c0d3 (X) /// /// https://www.linkedin.com/in/arnaudbailly/ /// /// https://abailly.github.io /// /// An experiment and an experience /// /// https://app.radicle.xyz/nodes/seed.hydra.bzh/rad:z28SSH7hyCTu387guFhtPXYxcwtUs/tree/pbt/README.md /// /// 1. Introduction /// /// 2. Real world use cases /// /// 3. Developing a mini-quickcheck from a "concrete" use case /// import {Option, fromNullable, matchW, toNullable} from 'fp-ts/lib/Option.js'; // keep it simple export type Account = string; /// A basic "Adapter" to a banking system /// /// NOTE: we make the functions Promise-aware in order to support /// async/await easily. export interface Bank { /// Retrieve the balance of given account. /// /// @param account The account to retrieve balance of /// @return: nothing if account is unknown, its balance otherwise balance: (account: Account) => Promise>; /// Deposit amount into account /// /// This function increases the balance of the given account by the /// specified amount. It's a `void` function hence implicitly /// stateful: It's expected to change some internal state of the /// implementing class. /// /// @param account The account to deposit into /// @param amt The amount to deposit deposit: (account: Account, amt: number) => Promise; /// Withdraw amount from given `account`. /// /// This function decreases the balance of the given account by the /// specified amount. /// /// @param account The account to deposit into /// @param amt The amount to withdraw withdraw: (account: Account, amt: number) => Promise; } /// All possible operations export type Operation = GetBalance | Withdraw | Deposit; export type GetBalance = {op: 'GetBalance'; account: Account}; export type Withdraw = {op: 'Withdraw'; account: Account; amount: number}; export type Deposit = {op: 'Deposit'; account: Account; amount: number}; // // // // // // /// A model interpreting bank operations on a simple in-memory map. /// Interpreting a sequence of operations on the model will return an /// updated state (map of accounts) and a sequence of observed outputs. export function model( accounts: Map, ): (ops: Operation[]) => [Map, (number | null)[]] { return (ops: Operation[]) => { const current = new Map(accounts); const outputs: (number | null)[] = []; for (const op of ops) { switch (op.op) { case 'GetBalance': { outputs.push(current.get(op.account) || null); break; } case 'Deposit': { const bal = current.get(op.account); if (bal) { current.set(op.account, bal + op.amount); } break; } case 'Withdraw': { const bal = current.get(op.account); if (bal) { current.set(op.account, bal - op.amount); } break; } } } return [current, outputs]; }; } /// A predicate asserting that some `Bank` implementation matches /// our model. export const bankImplementationMatchesModel = ( initialAccounts: Map, // Some initial dataset to work with ) => ( bank: Bank, // The actual bank implementation we want to test ) => async ( ops: Operation[], // an (arbitrary) sequence of operations ): Promise => { // run operations on bank implementation const observedOutputs: (number | null)[] = []; for (const op of ops) { switch (op.op) { case 'Deposit': await bank.deposit(op.account, op.amount); break; case 'Withdraw': await bank.withdraw(op.account, op.amount); break; case 'GetBalance': observedOutputs.push(await bank.balance(op.account).then(toNullable)); break; } } // evaluate operations on model const [endBalances, outputs] = model(initialAccounts)(ops); // compare outputs for (const [expected, actual] of outputs.map((v, i) => [ v, observedOutputs[i], ])) { if (expected !== actual) { console.log(`Output mismatch: expected ${expected}, got ${actual}`); return false; } } // compare end balances for (const [account, expected] of endBalances.entries()) { const actual = await bank.balance(account).then(toNullable); if (actual !== expected) { console.log( `Final balance mismatch for account ${account}: expected ${expected}, got ${actual}`, ); return false; } } return true; }; // // // // // // // /// A "simulation" of Bank interface for integration testing purpose. /// This export class SimpleBank implements Bank { private accounts: Map; constructor(accounts: Map) { // clone the content of the map this.accounts = new Map(accounts); } balance(account: Account): Promise> { return Promise.resolve(fromNullable(this.accounts.get(account))); } deposit(account: Account, amt: number): Promise { const current = fromNullable(this.accounts.get(account)); matchW( () => { throw new Error(`Unknown account ${account}`); }, (bal: number) => { this.accounts.set(account, bal + amt); }, )(current); return Promise.resolve(); } withdraw(account: Account, amt: number): Promise { const current = fromNullable(this.accounts.get(account)); matchW( () => { throw new Error(`Unknown account ${account}`); }, (bal: number) => { this.accounts.set(account, bal - amt); }, )(current); return Promise.resolve(); } }