Skip to main content

Custom Adapters Guide

Create custom adapters to extend or replace default implementations.

Overview

Adapters implement port interfaces, allowing you to:

  • Use different HTTP clients (Axios, fetch, etc.)
  • Integrate with custom auth systems
  • Add logging, metrics, or retry logic
  • Mock responses for testing

When to Create Custom Adapters

ScenarioSolution
Different HTTP clientCustom ApiPort adapter
Custom auth flowCustom AuthPort adapter
Custom cleanup rulesCustom CleanupPort adapter
Different browser automationCustom UiPort adapter
Different terminal frameworkCustom TuiPort adapter

Creating an API Adapter

Implement the Interface

// adapters/axios-api.adapter.ts
import type { ApiPort, ApiMethod, ApiResult } from '@esimplicity/stack-tests';
import axios, { AxiosInstance } from 'axios';

export class AxiosApiAdapter implements ApiPort {
private client: AxiosInstance;

constructor(baseURL: string) {
this.client = axios.create({
baseURL,
timeout: 30000,
});
}

async sendJson(
method: ApiMethod,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<ApiResult> {
try {
const response = await this.client.request({
method,
url: path,
data: body,
headers: {
'Content-Type': 'application/json',
...headers,
},
});

return {
status: response.status,
text: JSON.stringify(response.data),
json: response.data,
headers: response.headers as Record<string, string>,
contentType: response.headers['content-type'],
response: response as any, // Adapter-specific
};
} catch (error: any) {
if (error.response) {
return {
status: error.response.status,
text: JSON.stringify(error.response.data),
json: error.response.data,
headers: error.response.headers,
contentType: error.response.headers['content-type'],
response: error.response,
};
}
throw error;
}
}

async sendForm(
method: 'POST' | 'PUT' | 'PATCH',
path: string,
form: Record<string, string>,
headers?: Record<string, string>
): Promise<ApiResult> {
const formData = new URLSearchParams(form).toString();

return this.sendJson(method, path, formData, {
'Content-Type': 'application/x-www-form-urlencoded',
...headers,
});
}
}

Register the Adapter

// features/steps/fixtures.ts
import { createBddTest } from '@esimplicity/stack-tests';
import { AxiosApiAdapter } from './adapters/axios-api.adapter';

export const test = createBddTest({
createApi: () => new AxiosApiAdapter(process.env.API_BASE_URL!),
});

Creating an Auth Adapter

Custom Auth Flow

// adapters/oauth-auth.adapter.ts
import type { AuthPort, ApiPort, UiPort, World } from '@esimplicity/stack-tests';

export class OAuthAuthAdapter implements AuthPort {
constructor(
private api: ApiPort,
private ui: UiPort,
private config: {
clientId: string;
clientSecret: string;
tokenUrl: string;
}
) {}

async apiLoginAsAdmin(world: World): Promise<void> {
const result = await this.api.sendForm('POST', this.config.tokenUrl, {
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
scope: 'admin',
});

if (result.status !== 200) {
throw new Error(`OAuth login failed: ${result.status}`);
}

const token = (result.json as any).access_token;
world.headers['Authorization'] = `Bearer ${token}`;
}

async apiLoginAsUser(world: World): Promise<void> {
const result = await this.api.sendForm('POST', this.config.tokenUrl, {
grant_type: 'password',
client_id: this.config.clientId,
username: process.env.DEFAULT_USER_USERNAME!,
password: process.env.DEFAULT_USER_PASSWORD!,
});

if (result.status !== 200) {
throw new Error(`OAuth login failed: ${result.status}`);
}

const token = (result.json as any).access_token;
world.headers['Authorization'] = `Bearer ${token}`;
}

apiSetBearer(world: World, token: string): void {
world.headers['Authorization'] = `Bearer ${token}`;
}

async uiLoginAsAdmin(world: World): Promise<void> {
await this.ui.goto('/login');
await this.ui.fillLabel('Email', process.env.DEFAULT_ADMIN_USERNAME!);
await this.ui.fillLabel('Password', process.env.DEFAULT_ADMIN_PASSWORD!);
await this.ui.clickButton('Sign In');
}

async uiLoginAsUser(world: World): Promise<void> {
await this.ui.goto('/login');
await this.ui.fillLabel('Email', process.env.DEFAULT_USER_USERNAME!);
await this.ui.fillLabel('Password', process.env.DEFAULT_USER_PASSWORD!);
await this.ui.clickButton('Sign In');
}
}

Register Auth Adapter

import { OAuthAuthAdapter } from './adapters/oauth-auth.adapter';

export const test = createBddTest({
createAuth: ({ api, ui }) => new OAuthAuthAdapter(api, ui, {
clientId: process.env.OAUTH_CLIENT_ID!,
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
tokenUrl: '/oauth/token',
}),
});

Creating a Cleanup Adapter

Custom Cleanup Rules

// adapters/custom-cleanup.adapter.ts
import type { CleanupPort, World } from '@esimplicity/stack-tests';
import { registerCleanup } from '@esimplicity/stack-tests';

type CleanupRule = {
varMatch: string | RegExp;
method: 'DELETE' | 'POST' | 'PATCH' | 'PUT';
path: string;
};

export class CustomCleanupAdapter implements CleanupPort {
private rules: CleanupRule[];

constructor(rules: CleanupRule[]) {
this.rules = rules;
}

registerFromVar(world: World, varName: string, id: unknown): void {
const idStr = String(id);

for (const rule of this.rules) {
const matches = typeof rule.varMatch === 'string'
? varName.toLowerCase().includes(rule.varMatch.toLowerCase())
: rule.varMatch.test(varName);

if (matches) {
const path = rule.path.replace('{id}', idStr);
registerCleanup(world, { method: rule.method, path });
return;
}
}

console.warn(`No cleanup rule found for variable: ${varName}`);
}
}

Register Cleanup Adapter

import { CustomCleanupAdapter } from './adapters/custom-cleanup.adapter';

export const test = createBddTest({
createCleanup: () => new CustomCleanupAdapter([
{ varMatch: 'user', method: 'DELETE', path: '/api/users/{id}' },
{ varMatch: 'order', method: 'DELETE', path: '/api/orders/{id}' },
{ varMatch: 'product', method: 'DELETE', path: '/api/products/{id}' },
{ varMatch: /^team/, method: 'DELETE', path: '/api/teams/{id}' },
]),
});

Adapter with Logging

Logging Wrapper

// adapters/logging-api.adapter.ts
import type { ApiPort, ApiMethod, ApiResult } from '@esimplicity/stack-tests';

export class LoggingApiAdapter implements ApiPort {
constructor(private delegate: ApiPort) {}

async sendJson(
method: ApiMethod,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<ApiResult> {
console.log(`[API] ${method} ${path}`);
if (body) {
console.log(`[API] Body:`, JSON.stringify(body, null, 2));
}

const start = Date.now();
const result = await this.delegate.sendJson(method, path, body, headers);
const duration = Date.now() - start;

console.log(`[API] ${result.status} (${duration}ms)`);
return result;
}

async sendForm(
method: 'POST' | 'PUT' | 'PATCH',
path: string,
form: Record<string, string>,
headers?: Record<string, string>
): Promise<ApiResult> {
console.log(`[API] ${method} ${path} (form)`);

const start = Date.now();
const result = await this.delegate.sendForm(method, path, form, headers);
const duration = Date.now() - start;

console.log(`[API] ${result.status} (${duration}ms)`);
return result;
}
}

Use Logging Wrapper

import { PlaywrightApiAdapter } from '@esimplicity/stack-tests';
import { LoggingApiAdapter } from './adapters/logging-api.adapter';

export const test = createBddTest({
createApi: ({ apiRequest }) => {
const baseAdapter = new PlaywrightApiAdapter(apiRequest);
return process.env.DEBUG
? new LoggingApiAdapter(baseAdapter)
: baseAdapter;
},
});

Adapter with Retry Logic

// adapters/retry-api.adapter.ts
import type { ApiPort, ApiMethod, ApiResult } from '@esimplicity/stack-tests';

export class RetryApiAdapter implements ApiPort {
constructor(
private delegate: ApiPort,
private maxRetries: number = 3,
private retryDelay: number = 1000
) {}

async sendJson(
method: ApiMethod,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<ApiResult> {
let lastError: Error | undefined;

for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const result = await this.delegate.sendJson(method, path, body, headers);

// Retry on server errors
if (result.status >= 500 && attempt < this.maxRetries) {
console.warn(`[Retry] Attempt ${attempt} failed with ${result.status}`);
await this.delay(this.retryDelay * attempt);
continue;
}

return result;
} catch (error) {
lastError = error as Error;
console.warn(`[Retry] Attempt ${attempt} failed:`, error);

if (attempt < this.maxRetries) {
await this.delay(this.retryDelay * attempt);
}
}
}

throw lastError || new Error('All retry attempts failed');
}

async sendForm(
method: 'POST' | 'PUT' | 'PATCH',
path: string,
form: Record<string, string>,
headers?: Record<string, string>
): Promise<ApiResult> {
// Similar retry logic...
return this.delegate.sendForm(method, path, form, headers);
}

private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

Mock Adapter for Testing

// adapters/mock-api.adapter.ts
import type { ApiPort, ApiMethod, ApiResult } from '@esimplicity/stack-tests';

type MockResponse = {
status: number;
json: unknown;
};

export class MockApiAdapter implements ApiPort {
private mocks: Map<string, MockResponse> = new Map();

mock(method: ApiMethod, path: string, response: MockResponse): void {
this.mocks.set(`${method}:${path}`, response);
}

async sendJson(
method: ApiMethod,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<ApiResult> {
const key = `${method}:${path}`;
const mock = this.mocks.get(key);

if (!mock) {
throw new Error(`No mock defined for ${key}`);
}

return {
status: mock.status,
text: JSON.stringify(mock.json),
json: mock.json,
headers: {},
contentType: 'application/json',
response: {} as any,
};
}

async sendForm(): Promise<ApiResult> {
throw new Error('Not implemented in mock');
}
}

Testing Your Adapter

// adapters/__tests__/custom-api.adapter.test.ts
import { describe, it, expect } from 'vitest';
import { CustomApiAdapter } from '../custom-api.adapter';

describe('CustomApiAdapter', () => {
it('should send JSON request', async () => {
const adapter = new CustomApiAdapter('http://localhost:3000');

const result = await adapter.sendJson('GET', '/health');

expect(result.status).toBe(200);
});

it('should handle errors', async () => {
const adapter = new CustomApiAdapter('http://localhost:3000');

const result = await adapter.sendJson('GET', '/not-found');

expect(result.status).toBe(404);
});
});

Best Practices

Keep Adapters Focused

// Good - single responsibility
class ApiAdapter implements ApiPort { /* HTTP only */ }
class AuthAdapter implements AuthPort { /* Auth only */ }

// Avoid - multiple responsibilities
class EverythingAdapter implements ApiPort, AuthPort, CleanupPort { }

Use Composition

// Good - compose behaviors
const api = new RetryApiAdapter(
new LoggingApiAdapter(
new PlaywrightApiAdapter(request)
)
);

Handle Errors Gracefully

async sendJson(...): Promise<ApiResult> {
try {
// ... implementation
} catch (error) {
// Return error result instead of throwing
return {
status: 0,
text: error.message,
json: { error: error.message },
headers: {},
};
}
}