Tutorial: From Legacy Code to Multi-Adapter Tests

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:

  1. Locks in existing behavior with approval tests
  2. Extracts a domain vocabulary
  3. Runs the same tests against both a unit adapter and an HTTP adapter

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:

// src/invoice.ts
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

Before changing anything, capture the current behavior as a safety net.

npm install --save-dev @averspec/core @averspec/approvals vitest
// tests/invoice-characterization.spec.ts
import { 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)
})

Create the baselines:

AVER_APPROVE=1 npx vitest run tests/invoice-characterization.spec.ts

This 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.85
 Discount: 10%
-Tax: $10.79
-Total: $145.66
+Tax: $12.14
+Total: $147.01

The 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 Vitest tests, no domain or adapter needed. It’s also aliased as characterize() if that reads better for your characterization tests.

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 TypeScript 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

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:

// domains/pricing.ts
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(),
  },
})

The vocabulary says nothing about formatting, hardcoded tiers, or tax rates. It describes what pricing means to the business.

Step 3: Write acceptance tests

Tests use domain language only. No implementation details leak in:

// tests/pricing.spec.ts
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
})

These tests won’t pass yet — there’s no adapter.

Step 4: Build the unit adapter

An adapter binds domain vocabulary to a real implementation. Start with the unit protocol for in-memory testing:

// adapters/pricing.unit.ts
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)
    },
  },
})

Register it:

// aver.config.ts
import { defineConfig } from '@averspec/core'
import { unitAdapter } from './adapters/pricing.unit.js'

export default defineConfig({
  adapters: [unitAdapter],
})
// vitest.config.ts
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]             0ms

Three tests pass against the unit adapter.

Step 5: Add an HTTP adapter

Now suppose you have an Express API for pricing. Here’s a minimal server:

// src/server.ts
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:

// adapters/pricing.http.ts
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:

// aver.config.ts
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]               9ms

Three 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

domains/pricing.ts           # Domain vocabulary — what pricing means
adapters/pricing.unit.ts     # Unit adapter — in-memory implementation
adapters/pricing.http.ts     # HTTP adapter — Express API
tests/pricing.spec.ts        # Tests — domain language only
aver.config.ts               # Config — registers adapters

The 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


MIT License © 2026 Nate Jackson

This site uses Just the Docs, a documentation theme for Jekyll.