Tutorial
This tutorial takes you from untested legacy code to a domain-driven test suite running against two adapters. It takes about 15 minutes.
Starting from scratch? If you don’t have existing code to characterize, try the greenfield tutorial instead.
You’ll build a real test suite that:
- Locks in existing behavior with approval tests
- Extracts a domain vocabulary
- Runs the same tests against both a unit adapter and an HTTP adapter
The starting point
Section titled “The starting point”Here’s a pricing calculator like the ones that exist in every codebase. Calculation, formatting, and business policy are tangled into one function:
export function calculateInvoice( items: Array<{ product: string; quantity: number; unitPrice: number }>,): string { let subtotal = 0 let totalQty = 0 for (const item of items) { subtotal += item.quantity * item.unitPrice totalQty += item.quantity }
let discountPct = 0 if (totalQty >= 50) discountPct = 20 else if (totalQty >= 10) discountPct = 10
const discountAmount = subtotal * (discountPct / 100) const afterDiscount = subtotal - discountAmount const tax = afterDiscount * 0.08 const total = afterDiscount + tax
return [ `Subtotal: $${subtotal.toFixed(2)}`, `Discount: ${discountPct}%`, `Tax: $${tax.toFixed(2)}`, `Total: $${total.toFixed(2)}`, ].join('\n')}Three things make this hard to test: the only output is a formatted string, the discount tiers are hardcoded, and there’s no way to get the total as a number.
Step 1: Lock in what exists
Section titled “Step 1: Lock in what exists”Before changing anything, capture the current behavior as a safety net.
npm install --save-dev @averspec/core @averspec/approvals vitestimport { test } from 'vitest'import { approve } from '@averspec/approvals'import { calculateInvoice } from '../src/invoice.js'
test('invoice with quantity discount', async () => { const result = calculateInvoice([ { product: 'Widget', quantity: 15, unitPrice: 9.99 }, ]) await approve(result)})
test('invoice without discount', async () => { const result = calculateInvoice([ { product: 'Gadget', quantity: 3, unitPrice: 24.99 }, ]) await approve(result)})from averspec.approvals import approvefrom src.invoice import calculate_invoice
def test_invoice_with_quantity_discount(): result = calculate_invoice([ {"product": "Widget", "quantity": 15, "unit_price": 9.99}, ]) approve(result)
def test_invoice_without_discount(): result = calculate_invoice([ {"product": "Gadget", "quantity": 3, "unit_price": 24.99}, ]) approve(result)require "averspec/approvals"require_relative "../src/invoice"
RSpec.describe "Invoice characterization" do it "invoice with quantity discount" do result = calculate_invoice([ { product: "Widget", quantity: 15, unit_price: 9.99 } ]) Aver.approve(result) end
it "invoice without discount" do result = calculate_invoice([ { product: "Gadget", quantity: 3, unit_price: 24.99 } ]) Aver.approve(result) endendfunc TestInvoiceWithQuantityDiscount(t *testing.T) { result := CalculateInvoice([]LineItem{ {Product: "Widget", Quantity: 15, UnitPrice: 9.99}, }) approvals.Approve(result, approvals.Opts{Name: "quantity-discount"})}
func TestInvoiceWithoutDiscount(t *testing.T) { result := CalculateInvoice([]LineItem{ {Product: "Gadget", Quantity: 3, UnitPrice: 24.99}, }) approvals.Approve(result, approvals.Opts{Name: "no-discount"})}#[test]fn invoice_with_quantity_discount() { let result = calculate_invoice(&[ LineItem { product: "Widget", quantity: 15, unit_price: 9.99 }, ]); approve("quantity-discount", &result, &[]);}
#[test]fn invoice_without_discount() { let result = calculate_invoice(&[ LineItem { product: "Gadget", quantity: 3, unit_price: 24.99 }, ]); approve("no-discount", &result, &[]);}@Test fun `invoice with quantity discount`() { val result = calculateInvoice(listOf( LineItem("Widget", 15, 9.99) )) approve(result, "quantity-discount")}
@Test fun `invoice without discount`() { val result = calculateInvoice(listOf( LineItem("Gadget", 3, 24.99) )) approve(result, "no-discount")}Create the baselines:
npx aver approve tests/invoice-characterization.spec.tsThis serializes each result and writes it to an __approvals__/ directory next to your test file — one .approved file per approve() call, named after the test. Objects are stored as stable-sorted JSON; strings as plain text. Every subsequent run compares against those baselines. Any change to the function’s output fails the test with a diff.
For example, suppose someone changes the tax rate from 8% to 9% without realizing tests exist. The next run shows exactly what broke:
Subtotal: $149.85Discount: 10%Tax: $10.79Total: $145.66Tax: $12.14Total: $147.01The safety net caught a change before it reached production. You can now decide whether to update the baseline (if the tax rate change was intentional) or revert the code.
approve()is standalone — it works with plain test files, no domain or adapter needed. It’s also aliased ascharacterize()if that reads better for your characterization tests.
Finding your first operations
Section titled “Finding your first operations”Now comes the messiest part: extracting domain vocabulary from code that was never designed to have one. You’re staring at a function that does five things, and you need to decide which of those things are operations worth naming.
Start by reading the characterization test outputs. Each approved baseline is a snapshot of behavior — and each distinct behavior is a candidate for a domain operation. In our case, the invoice function does three things worth naming: it accumulates line items, it applies (or doesn’t apply) a discount, and it computes a total. Those are your first operations.
You’ll get the names wrong. That’s expected. Maybe you start with calculateTotal and later realize invoiceTotal reads better as a query. Maybe you create a discountTier query before realizing you only ever assert about the discount, never query it directly. Renaming is cheap — the domain file is a single source of truth, and the type system will flag every call site that needs updating. The goal isn’t to get the vocabulary perfect on the first pass; it’s to get something named so you can start writing tests in domain language and let the awkward names reveal themselves through use.
Step 2: Name the behaviors
Section titled “Step 2: Name the behaviors”Look at what the characterization tests revealed. The function accumulates line items, applies discount rules, and computes a taxed total. Name these in domain language:
import { defineDomain, action, query, assertion } from '@averspec/core'
export const pricing = defineDomain({ name: 'pricing', actions: { addLineItem: action<{ product: string; quantity: number; unitPrice: number }>(), }, queries: { invoiceTotal: query<void, number>(), appliedDiscount: query<void, number>(), }, assertions: { totalEquals: assertion<{ expected: number }>(), discountApplied: assertion<{ percent: number }>(), noDiscount: assertion(), },})from averspec import domain, action, query, assertion
@domain("pricing")class Pricing: add_line_item = action(str, int, float) # product, quantity, unit_price invoice_total = query(type(None), float) applied_discount = query(type(None), int) total_equals = assertion(float) # expected discount_applied = assertion(int) # percent no_discount = assertion()class Pricing < Aver::Domain domain_name "pricing"
action :add_line_item # product, quantity, unit_price query :invoice_total # -> number query :applied_discount # -> number assertion :total_equals # expected assertion :discount_applied # percent assertion :no_discountendtype PricingDomain struct { AddLineItem aver.Action[AddLineItemParams] InvoiceTotal aver.Query[struct{}, float64] AppliedDiscount aver.Query[struct{}, int] TotalEquals aver.Assertion[TotalEqualsParams] DiscountApplied aver.Assertion[DiscountParams] NoDiscount aver.Assertion[struct{}]}
var Pricing = aver.NewDomain[PricingDomain]("pricing")aver_domain! { name: "pricing", struct Pricing { add_line_item: Action<AddLineItemParams>, invoice_total: Query<(), f64>, applied_discount: Query<(), i32>, total_equals: Assertion<TotalEqualsParams>, discount_applied: Assertion<DiscountParams>, no_discount: Assertion<()>, }}val pricing = domain("pricing") { action<AddLineItemParams>("addLineItem") query<Unit, Double>("invoiceTotal") query<Unit, Int>("appliedDiscount") assertion<TotalEqualsParams>("totalEquals") assertion<DiscountParams>("discountApplied") assertion<Unit>("noDiscount")}The vocabulary says nothing about formatting, hardcoded tiers, or tax rates. It describes what pricing means to the business.
Step 3: Write acceptance tests
Section titled “Step 3: Write acceptance tests”Tests use domain language only. No implementation details leak in:
import { suite } from '@averspec/core'import { pricing } from '../domains/pricing.js'
const { test } = suite(pricing)
test('basic invoice total', async ({ given, when, then }) => { await given.addLineItem({ product: 'Widget', quantity: 2, unitPrice: 10.00 }) await when.addLineItem({ product: 'Gadget', quantity: 1, unitPrice: 5.00 }) await then.totalEquals({ expected: 27.00 }) // (20 + 5) * 1.08})
test('quantity discount kicks in at 10 items', async ({ given, then }) => { await given.addLineItem({ product: 'Widget', quantity: 10, unitPrice: 10.00 }) await then.discountApplied({ percent: 10 }) await then.totalEquals({ expected: 97.20 }) // 100 * 0.9 * 1.08})
test('no discount below threshold', async ({ given, then }) => { await given.addLineItem({ product: 'Widget', quantity: 5, unitPrice: 10.00 }) await then.noDiscount() await then.totalEquals({ expected: 54.00 }) // 50 * 1.08})from averspec import suitefrom domains.pricing import Pricing
s = suite(Pricing)
@s.testdef test_basic_invoice_total(ctx): ctx.given.add_line_item(product="Widget", quantity=2, unit_price=10.00) ctx.when.add_line_item(product="Gadget", quantity=1, unit_price=5.00) ctx.then.total_equals(expected=27.00) # (20 + 5) * 1.08
@s.testdef test_quantity_discount_kicks_in_at_10_items(ctx): ctx.given.add_line_item(product="Widget", quantity=10, unit_price=10.00) ctx.then.discount_applied(percent=10) ctx.then.total_equals(expected=97.20) # 100 * 0.9 * 1.08
@s.testdef test_no_discount_below_threshold(ctx): ctx.given.add_line_item(product="Widget", quantity=5, unit_price=10.00) ctx.then.no_discount() ctx.then.total_equals(expected=54.00) # 50 * 1.08require "averspec"require_relative "../domains/pricing"
s = Aver.suite(Pricing)
s.test "basic invoice total" do |ctx| ctx.given.add_line_item(product: "Widget", quantity: 2, unit_price: 10.00) ctx.when.add_line_item(product: "Gadget", quantity: 1, unit_price: 5.00) ctx.then.total_equals(expected: 27.00) # (20 + 5) * 1.08end
s.test "quantity discount kicks in at 10 items" do |ctx| ctx.given.add_line_item(product: "Widget", quantity: 10, unit_price: 10.00) ctx.then.discount_applied(percent: 10) ctx.then.total_equals(expected: 97.20) # 100 * 0.9 * 1.08end
s.test "no discount below threshold" do |ctx| ctx.given.add_line_item(product: "Widget", quantity: 5, unit_price: 10.00) ctx.then.no_discount ctx.then.total_equals(expected: 54.00) # 50 * 1.08ends := aver.NewSuite(Pricing)
s.Run(t, "basic invoice total", func(ctx aver.TestCtx) { aver.Given(ctx, Pricing.AddLineItem, AddLineItemParams{Product: "Widget", Quantity: 2, UnitPrice: 10.00}) aver.When(ctx, Pricing.AddLineItem, AddLineItemParams{Product: "Gadget", Quantity: 1, UnitPrice: 5.00}) aver.Then(ctx, Pricing.TotalEquals, TotalEqualsParams{Expected: 27.00})})
s.Run(t, "quantity discount kicks in at 10 items", func(ctx aver.TestCtx) { aver.Given(ctx, Pricing.AddLineItem, AddLineItemParams{Product: "Widget", Quantity: 10, UnitPrice: 10.00}) aver.Then(ctx, Pricing.DiscountApplied, DiscountParams{Percent: 10}) aver.Then(ctx, Pricing.TotalEquals, TotalEqualsParams{Expected: 97.20})})let s = suite(Pricing);
s.run("basic invoice total", |m, ctx| { ctx.given(&m.add_line_item, AddLineItemParams { product: "Widget", quantity: 2, unit_price: 10.00 }); ctx.when(&m.add_line_item, AddLineItemParams { product: "Gadget", quantity: 1, unit_price: 5.00 }); ctx.then(&m.total_equals, TotalEqualsParams { expected: 27.00 });});
s.run("quantity discount kicks in at 10 items", |m, ctx| { ctx.given(&m.add_line_item, AddLineItemParams { product: "Widget", quantity: 10, unit_price: 10.00 }); ctx.then(&m.discount_applied, DiscountParams { percent: 10 }); ctx.then(&m.total_equals, TotalEqualsParams { expected: 97.20 });});val s = suite(pricing)
@Test fun `basic invoice total`() = s.run { ctx -> ctx.Given(pricing.addLineItem, AddLineItemParams("Widget", 2, 10.00)) ctx.When(pricing.addLineItem, AddLineItemParams("Gadget", 1, 5.00)) ctx.Then(pricing.totalEquals, TotalEqualsParams(27.00))}
@Test fun `quantity discount kicks in at 10 items`() = s.run { ctx -> ctx.Given(pricing.addLineItem, AddLineItemParams("Widget", 10, 10.00)) ctx.Then(pricing.discountApplied, DiscountParams(10)) ctx.Then(pricing.totalEquals, TotalEqualsParams(97.20))}These tests won’t pass yet — there’s no adapter.
Step 4: Build the unit adapter
Section titled “Step 4: Build the unit adapter”An adapter binds domain vocabulary to a real implementation. Start with the unit protocol, which calls your code’s public interfaces directly:
import { adapt, unit } from '@averspec/core'import { expect } from 'vitest'import { pricing } from '../domains/pricing.js'
interface PricingContext { items: Array<{ product: string; quantity: number; unitPrice: number }>}
function calculate(ctx: PricingContext) { const subtotal = ctx.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0) const totalQty = ctx.items.reduce((s, i) => s + i.quantity, 0) const discountPct = totalQty >= 50 ? 20 : totalQty >= 10 ? 10 : 0 const afterDiscount = subtotal * (1 - discountPct / 100) return { subtotal, discountPct, total: afterDiscount * 1.08 }}
export const unitAdapter = adapt(pricing, { protocol: unit((): PricingContext => ({ items: [] })), actions: { addLineItem: async (ctx, item) => { ctx.items.push(item) }, }, queries: { invoiceTotal: async (ctx) => calculate(ctx).total, appliedDiscount: async (ctx) => calculate(ctx).discountPct, }, assertions: { totalEquals: async (ctx, { expected }) => { expect(calculate(ctx).total).toBeCloseTo(expected, 2) }, discountApplied: async (ctx, { percent }) => { expect(calculate(ctx).discountPct).toBe(percent) }, noDiscount: async (ctx) => { expect(calculate(ctx).discountPct).toBe(0) }, },})from averspec import implement, unitfrom domains.pricing import Pricing
def make_context(): return {"items": []}
def calculate(ctx): subtotal = sum(i["quantity"] * i["unit_price"] for i in ctx["items"]) total_qty = sum(i["quantity"] for i in ctx["items"]) discount_pct = 20 if total_qty >= 50 else 10 if total_qty >= 10 else 0 after_discount = subtotal * (1 - discount_pct / 100) return {"subtotal": subtotal, "discount_pct": discount_pct, "total": after_discount * 1.08}
adapter = implement(Pricing, protocol=unit(make_context))
@adapter.handle(Pricing.add_line_item)def add_line_item(ctx, product, quantity, unit_price): ctx["items"].append({"product": product, "quantity": quantity, "unit_price": unit_price})
@adapter.handle(Pricing.invoice_total)def invoice_total(ctx): return calculate(ctx)["total"]
@adapter.handle(Pricing.applied_discount)def applied_discount(ctx): return calculate(ctx)["discount_pct"]
@adapter.handle(Pricing.total_equals)def total_equals(ctx, expected): assert abs(calculate(ctx)["total"] - expected) < 0.01
@adapter.handle(Pricing.discount_applied)def discount_applied(ctx, percent): assert calculate(ctx)["discount_pct"] == percent
@adapter.handle(Pricing.no_discount)def no_discount(ctx): assert calculate(ctx)["discount_pct"] == 0class PricingUnit < Aver::Adapter domain Pricing protocol :unit, -> { { items: [] } }
def add_line_item(ctx, product:, quantity:, unit_price:) ctx[:items] << { product: product, quantity: quantity, unit_price: unit_price } end
def invoice_total(ctx) calculate(ctx)[:total] end
def total_equals(ctx, expected:) expect(calculate(ctx)[:total]).to be_within(0.01).of(expected) end
def discount_applied(ctx, percent:) expect(calculate(ctx)[:discount_pct]).to eq(percent) end
def no_discount(ctx) expect(calculate(ctx)[:discount_pct]).to eq(0) end
private
def calculate(ctx) subtotal = ctx[:items].sum { |i| i[:quantity] * i[:unit_price] } total_qty = ctx[:items].sum { |i| i[:quantity] } discount_pct = total_qty >= 50 ? 20 : total_qty >= 10 ? 10 : 0 after_discount = subtotal * (1 - discount_pct / 100.0) { subtotal: subtotal, discount_pct: discount_pct, total: after_discount * 1.08 } endendunitAdapter := aver.Implement(Pricing, aver.Unit(func() *PricingCtx { return &PricingCtx{Items: []LineItem{}}}), func(h *aver.Handlers[*PricingCtx]) { aver.OnAction(h, Pricing.AddLineItem, func(ctx *PricingCtx, p AddLineItemParams) error { ctx.Items = append(ctx.Items, LineItem(p)) return nil }) aver.OnQuery(h, Pricing.InvoiceTotal, func(ctx *PricingCtx, _ struct{}) (float64, error) { return calculate(ctx).Total, nil }) aver.OnAssertion(h, Pricing.TotalEquals, func(t *testing.T, ctx *PricingCtx, p TotalEqualsParams) { assert.InDelta(t, p.Expected, calculate(ctx).Total, 0.01) }) aver.OnAssertion(h, Pricing.DiscountApplied, func(t *testing.T, ctx *PricingCtx, p DiscountParams) { assert.Equal(t, p.Percent, calculate(ctx).DiscountPct) })})let unit_adapter = AdapterBuilder::new(Pricing, unit(|| PricingCtx { items: vec![] })) .on_action(&m.add_line_item, |ctx, p| { ctx.items.push(p); }) .on_query(&m.invoice_total, |ctx, _| calculate(ctx).total) .on_query(&m.applied_discount, |ctx, _| calculate(ctx).discount_pct) .on_assertion(&m.total_equals, |ctx, p| { assert!((calculate(ctx).total - p.expected).abs() < 0.01); }) .on_assertion(&m.discount_applied, |ctx, p| { assert_eq!(calculate(ctx).discount_pct, p.percent); }) .on_assertion(&m.no_discount, |ctx, _| { assert_eq!(calculate(ctx).discount_pct, 0); }) .build();data class PricingCtx(val items: MutableList<LineItem> = mutableListOf())
val unitAdapter = implement(pricing, UnitProtocol { PricingCtx() }) { onAction(pricing.addLineItem) { ctx, p -> ctx.items.add(p.toLineItem()) } onQuery(pricing.invoiceTotal) { ctx, _ -> calculate(ctx).total } onQuery(pricing.appliedDiscount) { ctx, _ -> calculate(ctx).discountPct } onAssertion(pricing.totalEquals) { ctx, p -> assertEquals(p.expected, calculate(ctx).total, 0.01) } onAssertion(pricing.discountApplied) { ctx, p -> assertEquals(p.percent, calculate(ctx).discountPct) } onAssertion(pricing.noDiscount) { ctx, _ -> assertEquals(0, calculate(ctx).discountPct) }}Register it. See your language’s Getting Started guide for equivalent configuration.
import { defineConfig } from '@averspec/core'import { unitAdapter } from './adapters/pricing.unit.js'
export default defineConfig({ adapters: [unitAdapter],})import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { setupFiles: ['./aver.config.ts'] },})Run it:
npx aver run tests/pricing.spec.ts ✓ basic invoice total [unit] 1ms ✓ quantity discount kicks in at 10 items [unit] 0ms ✓ no discount below threshold [unit] 0msThree tests pass against the unit adapter.
Step 5: Add an HTTP adapter
Section titled “Step 5: Add an HTTP adapter”Now suppose you have an Express API for pricing. Here’s a minimal server:
import express from 'express'
const app = express()app.use(express.json())
const sessions = new Map<string, Array<{ product: string; quantity: number; unitPrice: number }>>()
app.post('/session', (req, res) => { const id = crypto.randomUUID() sessions.set(id, []) res.json({ id })})
app.post('/session/:id/items', (req, res) => { sessions.get(req.params.id)?.push(req.body) res.sendStatus(204)})
app.get('/session/:id/total', (req, res) => { const items = sessions.get(req.params.id) ?? [] const subtotal = items.reduce((s, i) => s + i.quantity * i.unitPrice, 0) const totalQty = items.reduce((s, i) => s + i.quantity, 0) const discountPct = totalQty >= 50 ? 20 : totalQty >= 10 ? 10 : 0 const afterDiscount = subtotal * (1 - discountPct / 100) res.json({ total: afterDiscount * 1.08, discountPct })})
export { app }Write an HTTP adapter for the same domain:
import { adapt } from '@averspec/core'import { expect } from 'vitest'import { pricing } from '../domains/pricing.js'import type { Protocol } from '@averspec/core'import { app } from '../src/server.js'
interface HttpContext { baseUrl: string sessionId: string server: any}
const protocol: Protocol<HttpContext> = { name: 'http', async setup() { const server = app.listen(0) const port = (server.address() as any).port const baseUrl = `http://localhost:${port}` const res = await fetch(`${baseUrl}/session`, { method: 'POST' }) const { id } = await res.json() as { id: string } return { baseUrl, sessionId: id, server } }, async teardown(ctx) { ctx.server.close() },}
export const httpAdapter = adapt(pricing, { protocol, actions: { addLineItem: async (ctx, item) => { await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item), }) }, }, queries: { invoiceTotal: async (ctx) => { const res = await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/total`) const data = await res.json() as { total: number } return data.total }, appliedDiscount: async (ctx) => { const res = await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/total`) const data = await res.json() as { discountPct: number } return data.discountPct }, }, assertions: { totalEquals: async (ctx, { expected }) => { const total = await (await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/total`)).json() as { total: number } expect(total.total).toBeCloseTo(expected, 2) }, discountApplied: async (ctx, { percent }) => { const data = await (await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/total`)).json() as { discountPct: number } expect(data.discountPct).toBe(percent) }, noDiscount: async (ctx) => { const data = await (await fetch(`${ctx.baseUrl}/session/${ctx.sessionId}/total`)).json() as { discountPct: number } expect(data.discountPct).toBe(0) }, },})Register both adapters:
import { defineConfig } from '@averspec/core'import { unitAdapter } from './adapters/pricing.unit.js'import { httpAdapter } from './adapters/pricing.http.js'
export default defineConfig({ adapters: [unitAdapter, httpAdapter],})Run the tests again — the same tests, no changes:
npx aver run tests/pricing.spec.ts ✓ basic invoice total [unit] 1ms ✓ basic invoice total [http] 18ms ✓ quantity discount kicks in at 10 items [unit] 0ms ✓ quantity discount kicks in at 10 items [http] 12ms ✓ no discount below threshold [unit] 0ms ✓ no discount below threshold [http] 9msThree tests. Two adapters. Six runs. The test code didn’t change — only the config did.
If the unit adapter and HTTP adapter ever disagree on a behavior, that disagreement surfaces a real bug: the API returns different data than the in-memory implementation.
What you built
Section titled “What you built”domains/pricing.ts # Domain vocabulary — what pricing meansadapters/pricing.unit.ts # Unit adapter — in-memory implementationadapters/pricing.http.ts # HTTP adapter — Express APItests/pricing.spec.ts # Tests — domain language onlyaver.config.ts # Config — registers adaptersThe domain vocabulary is the stable center. Tests compose vocabulary into scenarios. Adapters are interchangeable. Add a Playwright adapter when the UI exists — the tests still don’t change.
Next steps
Section titled “Next steps”- Architecture — how the three-layer model works and why
- Getting Started — install, scaffold, and configure a fresh project
- Multi-Adapter Testing — adding Playwright and protocol-specific tests
- CI Integration — running aver tests in your pipeline