Multi-Adapter Testing
Run the same test against multiple implementations — direct code interfaces, HTTP API, and browser UI. Define behavior once, verify it everywhere.
Why bother with multiple levels instead of just E2E? Because good design at the domain boundary changes the economics. When your domain objects have clean public interfaces — service objects with intentional methods — your HTTP controllers and browser handlers become thin translation layers. The real logic lives in one place. The unit adapter tests that logic fast. The HTTP and browser adapters verify that the thin layers shuttle data correctly, not that the business rules work. You get the confidence of E2E with the speed of unit tests for your inner development loop.
You need a domain and at least two adapters. This guide uses a task board example with three adapters.
Domain
Section titled “Domain”import { defineDomain, action, query, assertion } from '@averspec/core'
export const taskBoard = defineDomain({ name: 'task-board', actions: { createTask: action<{ title: string }>(), moveTask: action<{ title: string; status: string }>(), }, queries: {}, assertions: { taskInStatus: assertion<{ title: string; status: string }>(), },})from averspec import domain, action, assertion
@domain("task-board")class TaskBoard: create_task = action(str) # title move_task = action(str, str) # title, status task_in_status = assertion(str, str) # title, statusclass TaskBoard < Aver::Domain domain_name "task-board"
action :create_task # title action :move_task # title, status assertion :task_in_status # title, statusendtype TaskBoardDomain struct { CreateTask aver.Action[CreateTaskParams] MoveTask aver.Action[MoveTaskParams] TaskInStatus aver.Assertion[TaskInStatusParams]}
var TaskBoard = aver.NewDomain[TaskBoardDomain]("task-board")aver_domain! { name: "task-board", struct TaskBoard { create_task: Action<CreateTaskParams>, move_task: Action<MoveTaskParams>, task_in_status: Assertion<TaskInStatusParams>, }}val taskBoard = domain("task-board") { action<CreateTaskParams>("createTask") action<MoveTaskParams>("moveTask") assertion<TaskInStatusParams>("taskInStatus")}Unit Adapter
Section titled “Unit Adapter”Tests against your code’s public interfaces directly. Fast — how fast depends on your design choices (see Making unit adapters fast below).
import { adapt, unit } from '@averspec/core'import { expect } from 'vitest'import { Board } from '../src/board'import { taskBoard } from '../domains/task-board'
export const unitAdapter = adapt(taskBoard, { protocol: unit(() => new Board()), actions: { createTask: async (board, { title }) => board.create(title), moveTask: async (board, { title, status }) => board.move(title, status), }, queries: {}, assertions: { taskInStatus: async (board, { title, status }) => { const task = board.details(title) expect(task?.status).toBe(status) }, },})from averspec import implement, unitfrom domains.task_board import TaskBoardfrom src.board import Board
adapter = implement(TaskBoard, protocol=unit(lambda: Board()))
@adapter.handle(TaskBoard.create_task)def create_task(board, title): board.create(title)
@adapter.handle(TaskBoard.move_task)def move_task(board, title, status): board.move(title, status)
@adapter.handle(TaskBoard.task_in_status)def task_in_status(board, title, status): task = board.details(title) assert task.status == statusclass TaskBoardUnit < Aver::Adapter domain TaskBoard protocol :unit, -> { Board.new }
def create_task(board, title:) board.create(title) end
def move_task(board, title:, status:) board.move(title, status) end
def task_in_status(board, title:, status:) expect(board.details(title).status).to eq(status) endendunitAdapter := aver.Implement(TaskBoard, aver.Unit(func() *Board { return NewBoard()}), func(h *aver.Handlers[*Board]) { aver.OnAction(h, TaskBoard.CreateTask, func(board *Board, p CreateTaskParams) error { return board.Create(p.Title) }) aver.OnAction(h, TaskBoard.MoveTask, func(board *Board, p MoveTaskParams) error { return board.Move(p.Title, p.Status) }) aver.OnAssertion(h, TaskBoard.TaskInStatus, func(t *testing.T, board *Board, p TaskInStatusParams) { task := board.Details(p.Title) assert.Equal(t, p.Status, task.Status) })})let unit_adapter = AdapterBuilder::new(TaskBoard, unit(|| Board::new())) .on_action(&m.create_task, |board, p| { board.create(&p.title); }) .on_action(&m.move_task, |board, p| { board.move_task(&p.title, &p.status); }) .on_assertion(&m.task_in_status, |board, p| { let task = board.details(&p.title); assert_eq!(task.status, p.status); }) .build();val unitAdapter = implement(taskBoard, UnitProtocol { Board() }) { onAction(taskBoard.createTask) { board, p -> board.create(p.title) } onAction(taskBoard.moveTask) { board, p -> board.move(p.title, p.status) } onAssertion(taskBoard.taskInStatus) { board, p -> assertEquals(p.status, board.details(p.title).status) }}HTTP Adapter
Section titled “HTTP Adapter”Tests against a REST API. Faster than the browser, slower than direct interfaces.
import { adapt } from '@averspec/core'import { expect } from 'vitest'import { http } from '@averspec/protocol-http'import { taskBoard } from '../domains/task-board'
export const httpAdapter = adapt(taskBoard, { protocol: http({ baseUrl: 'http://localhost:3000' }), actions: { createTask: async (ctx, { title }) => { await ctx.post('/tasks', { title }) }, moveTask: async (ctx, { title, status }) => { await ctx.patch(`/tasks/${encodeURIComponent(title)}`, { status }) }, }, queries: {}, assertions: { taskInStatus: async (ctx, { title, status }) => { const res = await ctx.get(`/tasks/${encodeURIComponent(title)}`) const task = await res.json() expect(task.status).toBe(status) }, },})from averspec import implementfrom averspec.protocol_http import httpfrom domains.task_board import TaskBoard
adapter = implement(TaskBoard, protocol=http(base_url="http://localhost:3000"))
@adapter.handle(TaskBoard.create_task)def create_task(ctx, title): ctx.post("/tasks", json={"title": title})
@adapter.handle(TaskBoard.move_task)def move_task(ctx, title, status): ctx.patch(f"/tasks/{title}", json={"status": status})
@adapter.handle(TaskBoard.task_in_status)def task_in_status(ctx, title, status): res = ctx.get(f"/tasks/{title}") assert res.json()["status"] == statusclass TaskBoardHttp < Aver::Adapter domain TaskBoard protocol :http, base_url: "http://localhost:3000"
def create_task(ctx, title:) ctx.post("/tasks", { title: title }) end
def move_task(ctx, title:, status:) ctx.patch("/tasks/#{title}", { status: status }) end
def task_in_status(ctx, title:, status:) res = ctx.get("/tasks/#{title}") expect(res.json["status"]).to eq(status) endendhttpAdapter := aver.Implement(TaskBoard, httpProto, func(h *aver.Handlers[*http.Client]) { aver.OnAction(h, TaskBoard.CreateTask, func(ctx *http.Client, p CreateTaskParams) error { return ctx.Post("/tasks", map[string]string{"title": p.Title}) }) aver.OnAction(h, TaskBoard.MoveTask, func(ctx *http.Client, p MoveTaskParams) error { return ctx.Patch("/tasks/"+p.Title, map[string]string{"status": p.Status}) }) aver.OnAssertion(h, TaskBoard.TaskInStatus, func(t *testing.T, ctx *http.Client, p TaskInStatusParams) { var task Task ctx.Get("/tasks/"+p.Title, &task) assert.Equal(t, p.Status, task.Status) })})let http_adapter = AdapterBuilder::new(TaskBoard, http("http://localhost:3000")) .on_action(&m.create_task, |ctx, p| { ctx.post("/tasks", &json!({ "title": p.title })); }) .on_action(&m.move_task, |ctx, p| { ctx.patch(&format!("/tasks/{}", p.title), &json!({ "status": p.status })); }) .on_assertion(&m.task_in_status, |ctx, p| { let task: Task = ctx.get(&format!("/tasks/{}", p.title)).json(); assert_eq!(task.status, p.status); }) .build();val httpAdapter = implement(taskBoard, HttpProtocol("http://localhost:3000")) { onAction(taskBoard.createTask) { ctx, p -> ctx.post("/tasks", mapOf("title" to p.title)) } onAction(taskBoard.moveTask) { ctx, p -> ctx.patch("/tasks/${p.title}", mapOf("status" to p.status)) } onAssertion(taskBoard.taskInStatus) { ctx, p -> val task = ctx.get("/tasks/${p.title}").json<Task>() assertEquals(p.status, task.status) }}Playwright Adapter
Section titled “Playwright Adapter”Tests against a browser UI. Slowest adapter, highest confidence.
import { adapt } from '@averspec/core'import { expect } from '@playwright/test'import { playwright } from '@averspec/protocol-playwright'import { taskBoard } from '../domains/task-board'
export const playwrightAdapter = adapt(taskBoard, { protocol: playwright(), actions: { createTask: async (page, { title }) => { await page.getByPlaceholder('Task title').fill(title) await page.getByRole('button', { name: 'Add' }).click() }, moveTask: async (page, { title, status }) => { await page.getByTestId(`task-${title}`).dragTo( page.getByTestId(`column-${status}`) ) }, }, queries: {}, assertions: { taskInStatus: async (page, { title, status }) => { const column = page.getByTestId(`column-${status}`) await expect(column.getByText(title)).toBeVisible() }, },})from averspec import implementfrom averspec.protocol_playwright import playwrightfrom domains.task_board import TaskBoard
adapter = implement(TaskBoard, protocol=playwright())
@adapter.handle(TaskBoard.create_task)def create_task(page, title): page.get_by_placeholder("Task title").fill(title) page.get_by_role("button", name="Add").click()
@adapter.handle(TaskBoard.move_task)def move_task(page, title, status): page.get_by_test_id(f"task-{title}").drag_to( page.get_by_test_id(f"column-{status}") )
@adapter.handle(TaskBoard.task_in_status)def task_in_status(page, title, status): column = page.get_by_test_id(f"column-{status}") expect(column.get_by_text(title)).to_be_visible()class TaskBoardPlaywright < Aver::Adapter domain TaskBoard protocol :playwright
def create_task(page, title:) page.get_by_placeholder("Task title").fill(title) page.get_by_role("button", name: "Add").click end
def move_task(page, title:, status:) page.get_by_test_id("task-#{title}").drag_to( page.get_by_test_id("column-#{status}") ) end
def task_in_status(page, title:, status:) column = page.get_by_test_id("column-#{status}") expect(column.get_by_text(title)).to be_visible endendpwAdapter := aver.Implement(TaskBoard, playwrightProto, func(h *aver.Handlers[playwright.Page]) { aver.OnAction(h, TaskBoard.CreateTask, func(page playwright.Page, p CreateTaskParams) error { page.GetByPlaceholder("Task title").Fill(p.Title) page.GetByRole("button", playwright.RoleOpts{Name: "Add"}).Click() return nil }) aver.OnAction(h, TaskBoard.MoveTask, func(page playwright.Page, p MoveTaskParams) error { page.GetByTestId("task-"+p.Title).DragTo(page.GetByTestId("column-"+p.Status)) return nil }) aver.OnAssertion(h, TaskBoard.TaskInStatus, func(t *testing.T, page playwright.Page, p TaskInStatusParams) { column := page.GetByTestId("column-" + p.Status) assert.True(t, column.GetByText(p.Title).IsVisible()) })})let pw_adapter = AdapterBuilder::new(TaskBoard, playwright()) .on_action(&m.create_task, |page, p| { page.get_by_placeholder("Task title").fill(&p.title); page.get_by_role("button", "Add").click(); }) .on_action(&m.move_task, |page, p| { page.get_by_test_id(&format!("task-{}", p.title)) .drag_to(page.get_by_test_id(&format!("column-{}", p.status))); }) .on_assertion(&m.task_in_status, |page, p| { let column = page.get_by_test_id(&format!("column-{}", p.status)); assert!(column.get_by_text(&p.title).is_visible()); }) .build();val pwAdapter = implement(taskBoard, PlaywrightProtocol()) { onAction(taskBoard.createTask) { page, p -> page.getByPlaceholder("Task title").fill(p.title) page.getByRole(AriaRole.BUTTON, HasNameOptions("Add")).click() } onAction(taskBoard.moveTask) { page, p -> page.getByTestId("task-${p.title}").dragTo(page.getByTestId("column-${p.status}")) } onAssertion(taskBoard.taskInStatus) { page, p -> val column = page.getByTestId("column-${p.status}") assertThat(column.getByText(p.title)).isVisible }}Register All Adapters
Section titled “Register All Adapters”See your language’s Getting Started guide for equivalent configuration.
import { defineConfig } from '@averspec/core'import { unitAdapter } from './adapters/task-board.unit'import { httpAdapter } from './adapters/task-board.http'import { playwrightAdapter } from './adapters/task-board.playwright'
export default defineConfig({ adapters: [unitAdapter, httpAdapter, playwrightAdapter],})Write Tests Once
Section titled “Write Tests Once”The test file imports the domain, never the adapters:
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { setupFiles: ['./aver.config.ts'], },})import { suite } from '@averspec/core'import { taskBoard } from '../domains/task-board'
const { test } = suite(taskBoard)
test('create and move a task', async ({ act, assert }) => { await act.createTask({ title: 'Fix login bug' }) await assert.taskInStatus({ title: 'Fix login bug', status: 'backlog' }) await act.moveTask({ title: 'Fix login bug', status: 'in-progress' }) await assert.taskInStatus({ title: 'Fix login bug', status: 'in-progress' })})from averspec import suitefrom domains.task_board import TaskBoard
s = suite(TaskBoard)
@s.testdef test_create_and_move_a_task(ctx): ctx.act.create_task(title="Fix login bug") ctx.assert_.task_in_status(title="Fix login bug", status="backlog") ctx.act.move_task(title="Fix login bug", status="in-progress") ctx.assert_.task_in_status(title="Fix login bug", status="in-progress")require "averspec"require_relative "../domains/task_board"
s = Aver.suite(TaskBoard)
s.test "create and move a task" do |ctx| ctx.act.create_task(title: "Fix login bug") ctx.assert.task_in_status(title: "Fix login bug", status: "backlog") ctx.act.move_task(title: "Fix login bug", status: "in-progress") ctx.assert.task_in_status(title: "Fix login bug", status: "in-progress")endfunc TestTaskBoard(t *testing.T) { s := aver.NewSuite(TaskBoard)
s.Run(t, "create and move a task", func(ctx aver.TestCtx) { aver.Act(ctx, TaskBoard.CreateTask, CreateTaskParams{Title: "Fix login bug"}) aver.Assert(ctx, TaskBoard.TaskInStatus, TaskInStatusParams{Title: "Fix login bug", Status: "backlog"}) aver.Act(ctx, TaskBoard.MoveTask, MoveTaskParams{Title: "Fix login bug", Status: "in-progress"}) aver.Assert(ctx, TaskBoard.TaskInStatus, TaskInStatusParams{Title: "Fix login bug", Status: "in-progress"}) })}#[test]fn test_task_board() { let s = suite(TaskBoard);
s.run("create and move a task", |m, ctx| { ctx.act(&m.create_task, CreateTaskParams { title: "Fix login bug".into() }); ctx.assert(&m.task_in_status, TaskInStatusParams { title: "Fix login bug".into(), status: "backlog".into() }); ctx.act(&m.move_task, MoveTaskParams { title: "Fix login bug".into(), status: "in-progress".into() }); ctx.assert(&m.task_in_status, TaskInStatusParams { title: "Fix login bug".into(), status: "in-progress".into() }); });}class TaskBoardTest { val s = suite(taskBoard)
@Test fun `create and move a task`() = s.run { ctx -> ctx.Act(taskBoard.createTask, CreateTaskParams("Fix login bug")) ctx.Assert(taskBoard.taskInStatus, TaskInStatusParams("Fix login bug", "backlog")) ctx.Act(taskBoard.moveTask, MoveTaskParams("Fix login bug", "in-progress")) ctx.Assert(taskBoard.taskInStatus, TaskInStatusParams("Fix login bug", "in-progress")) }}npx aver run ✓ tests/task-board.spec.ts ✓ create and move a task [unit] 1ms ✓ create and move a task [http] 14ms ✓ create and move a task [playwright] 312msOne test, three adapters, three levels of confidence.
Filtering
Section titled “Filtering”Run a specific adapter:
npx aver run --adapter unitnpx aver run --adapter httpRun a specific domain:
npx aver run --domain task-boardMaking unit adapters fast
Section titled “Making unit adapters fast”The unit adapter is your inner development loop. The faster it runs, the tighter your feedback. The question is how to handle the IO dependencies that sit behind your domain objects.
Nullables
Section titled “Nullables”Replace IO dependencies with nullable implementations that work in-memory by default. Your production code accepts either the real thing or the nullable; no mocking framework required.
export class TaskRepo { constructor(private db: Database | null = null) {}
async create(title: string) { if (!this.db) { this.tasks.push({ title, status: 'backlog' }) return } await this.db.insert('tasks', { title, status: 'backlog' }) }
// In-memory fallback — no IO, sub-millisecond private tasks: Array<{ title: string; status: string }> = []}// adapters/task-board.unit.ts — no database, no mocksprotocol: unit(() => new TaskRepo()) // null db → in-memoryThis is the fastest option — no IO means no waiting. The trade-off: you’re testing the in-memory path, not the database path. That’s what the HTTP and Playwright adapters are for.
Dependency injection
Section titled “Dependency injection”Pass dependencies into your domain objects. Swap real implementations for fast fakes in the unit adapter. (Fowler on DI)
export class Board { constructor(private repo: TaskRepo) {}
async create(title: string) { await this.repo.create(title) }}
// adapters/task-board.unit.tsprotocol: unit(() => new Board(new InMemoryTaskRepo()))Similar speed to nullables — no real IO in the test path. The difference is structural: DI puts the seam at the constructor boundary, nullables put it inside the class. DI is more conventional; nullables are simpler when you control the dependency.
Vitest mocks
Section titled “Vitest mocks”Use vi.fn() or vi.mock() to stub out IO at the module level. Fastest to set up for existing code that wasn’t designed for injection.
import { vi } from 'vitest'import * as db from '../src/db'
vi.spyOn(db, 'insert').mockResolvedValue(undefined)vi.spyOn(db, 'findOne').mockResolvedValue({ title: 'Fix bug', status: 'backlog' })
protocol: unit(() => new Board()) // Board calls db internally, gets mocksFast but brittle — the mocks are coupled to internal implementation details. (Fowler: Mocks Aren’t Stubs) When someone renames db.insert to db.save, the mock breaks even though the behavior hasn’t changed. Use this as a stepping stone, not a destination.
Fake local services
Section titled “Fake local services”Run a lightweight in-process version of your dependency. SQLite instead of Postgres, an in-memory Express server instead of a remote API.
import Database from 'better-sqlite3'
protocol: unit(() => { const db = new Database(':memory:') db.exec('CREATE TABLE tasks (title TEXT, status TEXT)') return new Board(db)})Slower — there’s real IO happening — but higher fidelity. You’re testing real SQL, real query behavior. Good for domains where the database behavior is the business logic (complex queries, transactions, constraints).
Replay proxies / fixtures
Section titled “Replay proxies / fixtures”Record real HTTP responses and replay them in tests. Tools like Polly.js or MSW intercept network calls and serve recorded fixtures.
import { setupServer } from 'msw/node'import { http, HttpResponse } from 'msw'
const server = setupServer( http.post('/api/tasks', () => HttpResponse.json({ id: '1' })), http.get('/api/tasks/:title', ({ params }) => HttpResponse.json({ title: params.title, status: 'backlog' }) ),)
beforeAll(() => server.listen())afterAll(() => server.close())Faster than hitting the real service, slower than pure in-memory. Good for adapters that wrap third-party APIs you don’t control. Record once against the real service, replay in CI forever.
Choosing a strategy
Section titled “Choosing a strategy”| Strategy | Speed | Fidelity | Setup cost | Best for |
|---|---|---|---|---|
| Nullables | Fastest — no IO | Lower — tests in-memory path | Low | Code you control, fast inner loop |
| Dependency injection | Fastest — no IO | Lower — tests fake path | Medium | Established DI patterns |
| Vitest mocks | Fastest — no IO | Lowest — coupled to internals | Low | Legacy code, temporary |
| Fake local services | Moderate — real IO | Higher — real query behavior | Medium | DB-heavy domains |
| Replay proxies | Moderate — intercepted IO | Medium — recorded real responses | Medium | Third-party APIs |
You can mix strategies. Use nullables for the inner development loop, a fake database for integration-sensitive domains, and replay proxies for external API adapters. The point is: each adapter level gives you a different trade-off between speed and fidelity. Make the unit adapter as fast as your design allows.