Adding New Adapters
Guide to implementing adapter classes for @esimplicity/stack-tests ports.
Overview
Adapters implement port interfaces, providing the actual integration with tools, libraries, and services.
When to Create an Adapter
Create a new adapter when:
- Implementing a port for the first time
- Supporting a new backend/service for existing port
- Creating test doubles (mocks, stubs)
- Targeting different environments
Adapter Design Principles
1. Full Interface Implementation
// Must implement ALL port methods
export class MailhogAdapter implements EmailPort {
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// Implementation required
}
async getLastEmail(to: string): Promise<Email | null> {
// Implementation required
}
async waitForEmail(to: string, options?: WaitOptions): Promise<Email> {
// Implementation required
}
async clearEmails(): Promise<void> {
// Implementation required
}
}
2. Constructor Injection
// Good - dependencies injected via constructor
export class MailhogAdapter implements EmailPort {
constructor(
private readonly config: MailhogConfig,
private readonly httpClient?: HttpClient
) {}
}
// Avoid - hard-coded dependencies
export class MailhogAdapter implements EmailPort {
private readonly client = new HttpClient(); // Hard to test
}
3. Configuration via Options
export interface MailhogConfig {
/** Mailhog API URL */
apiUrl: string;
/** Request timeout in ms */
timeout?: number;
/** Retry attempts for waitForEmail */
retries?: number;
}
export class MailhogAdapter implements EmailPort {
private readonly config: Required<MailhogConfig>;
constructor(config: MailhogConfig) {
this.config = {
timeout: 30000,
retries: 10,
...config,
};
}
}
4. Resource Cleanup
export class DatabaseAdapter implements DatabasePort {
private connection?: Connection;
async connect(): Promise<void> {
this.connection = await createConnection(this.config);
}
async close(): Promise<void> {
if (this.connection) {
await this.connection.close();
this.connection = undefined;
}
}
}
Step-by-Step Guide
1. Create Adapter Directory
mkdir -p src/adapters/email
touch src/adapters/email/mailhog.adapter.ts
touch src/adapters/email/index.ts
2. Implement the Adapter
// src/adapters/email/mailhog.adapter.ts
import type { EmailPort, Email, WaitOptions } from '../../ports/email.port.js';
/**
* Configuration for Mailhog adapter.
*/
export interface MailhogConfig {
/** Mailhog API URL (e.g., http://localhost:8025) */
apiUrl: string;
/** Request timeout in milliseconds */
timeout?: number;
/** Polling interval for waitForEmail */
pollInterval?: number;
}
/**
* EmailPort adapter using Mailhog for email capture.
*
* Mailhog is a local email testing tool that captures
* SMTP messages and provides an API to retrieve them.
*
* @example
* ```typescript
* const email = new MailhogAdapter({
* apiUrl: 'http://localhost:8025',
* });
*
* await email.waitForEmail('user@test.com');
* ```
*
* @see https://github.com/mailhog/MailHog
*/
export class MailhogAdapter implements EmailPort {
private readonly apiUrl: string;
private readonly timeout: number;
private readonly pollInterval: number;
constructor(config: MailhogConfig) {
this.apiUrl = config.apiUrl.replace(/\/$/, '');
this.timeout = config.timeout ?? 30000;
this.pollInterval = config.pollInterval ?? 500;
}
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// Mailhog captures SMTP - this would typically be handled
// by your application's email service. This method is a no-op
// or could use nodemailer to send via Mailhog SMTP.
throw new Error(
'MailhogAdapter.sendEmail() not implemented. ' +
'Use your application email service to send emails.'
);
}
async getLastEmail(to: string): Promise<Email | null> {
const response = await fetch(`${this.apiUrl}/api/v2/messages`);
if (!response.ok) {
throw new Error(`Mailhog API error: ${response.status}`);
}
const data = await response.json() as MailhogResponse;
// Find emails matching recipient
const matching = data.items.filter(item =>
item.To.some(recipient =>
recipient.Mailbox + '@' + recipient.Domain === to
)
);
if (matching.length === 0) {
return null;
}
// Return most recent
const latest = matching[0];
return this.mapToEmail(latest);
}
async waitForEmail(to: string, options?: WaitOptions): Promise<Email> {
const timeout = options?.timeout ?? this.timeout;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const email = await this.getLastEmail(to);
if (email) {
// Check subject filter if provided
if (options?.subjectContains) {
if (email.subject.includes(options.subjectContains)) {
return email;
}
} else {
return email;
}
}
await this.sleep(this.pollInterval);
}
throw new Error(
`Timeout waiting for email to ${to} after ${timeout}ms`
);
}
async clearEmails(): Promise<void> {
const response = await fetch(`${this.apiUrl}/api/v1/messages`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to clear Mailhog messages: ${response.status}`);
}
}
private mapToEmail(item: MailhogMessage): Email {
const to = item.To[0];
const from = item.From;
return {
from: `${from.Mailbox}@${from.Domain}`,
to: `${to.Mailbox}@${to.Domain}`,
subject: item.Content.Headers.Subject?.[0] ?? '',
body: item.Content.Body,
html: item.Content.Headers['Content-Type']?.[0]?.includes('html')
? item.Content.Body
: undefined,
receivedAt: new Date(item.Created),
};
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Mailhog API types
interface MailhogResponse {
total: number;
items: MailhogMessage[];
}
interface MailhogMessage {
ID: string;
From: MailhogAddress;
To: MailhogAddress[];
Content: {
Headers: Record<string, string[]>;
Body: string;
};
Created: string;
}
interface MailhogAddress {
Mailbox: string;
Domain: string;
}
3. Create Index Export
// src/adapters/email/index.ts
export { MailhogAdapter } from './mailhog.adapter.js';
export type { MailhogConfig } from './mailhog.adapter.js';
4. Export from Adapters Index
Update src/adapters/index.ts:
// Existing exports
export * from './api/index.js';
export * from './ui/index.js';
export * from './tui/index.js';
export * from './auth/index.js';
export * from './cleanup/index.js';
// New export
export * from './email/index.js';
5. Export from Main Index
Update src/index.ts:
// Adapters
export {
PlaywrightApiAdapter,
PlaywrightUiAdapter,
TuiTesterAdapter,
DefaultAuthAdapter,
DefaultCleanupAdapter,
MailhogAdapter, // New
} from './adapters/index.js';
export type {
ApiAdapterConfig,
UiAdapterConfig,
TuiAdapterConfig,
AuthConfig,
CleanupConfig,
MailhogConfig, // New
} from './adapters/index.js';
Creating Mock Adapters
For testing step definitions:
// src/adapters/email/mock-email.adapter.ts
import type { EmailPort, Email, WaitOptions } from '../../ports/email.port.js';
/**
* Mock EmailPort adapter for unit testing.
*
* Stores emails in memory and provides methods to
* inspect captured emails in tests.
*/
export class MockEmailAdapter implements EmailPort {
private emails: Email[] = [];
private sendEmailMock?: (to: string, subject: string, body: string) => void;
/**
* Sets a mock function to be called on sendEmail.
*/
onSendEmail(fn: (to: string, subject: string, body: string) => void): void {
this.sendEmailMock = fn;
}
/**
* Adds an email to the inbox (for test setup).
*/
addEmail(email: Email): void {
this.emails.unshift(email);
}
async sendEmail(to: string, subject: string, body: string): Promise<void> {
const email: Email = {
from: 'test@example.com',
to,
subject,
body,
receivedAt: new Date(),
};
this.emails.unshift(email);
this.sendEmailMock?.(to, subject, body);
}
async getLastEmail(to: string): Promise<Email | null> {
return this.emails.find(e => e.to === to) ?? null;
}
async waitForEmail(to: string, options?: WaitOptions): Promise<Email> {
const email = await this.getLastEmail(to);
if (!email) {
throw new Error(`No email found for ${to}`);
}
if (options?.subjectContains && !email.subject.includes(options.subjectContains)) {
throw new Error(
`Email subject "${email.subject}" doesn't contain "${options.subjectContains}"`
);
}
return email;
}
async clearEmails(): Promise<void> {
this.emails = [];
}
/**
* Returns all captured emails (for test assertions).
*/
getAllEmails(): Email[] {
return [...this.emails];
}
}
Testing Adapters
Unit Tests
// tests/adapters/mailhog.adapter.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MailhogAdapter } from '../../src/adapters/email/mailhog.adapter.js';
describe('MailhogAdapter', () => {
let adapter: MailhogAdapter;
beforeEach(() => {
adapter = new MailhogAdapter({
apiUrl: 'http://localhost:8025',
});
});
describe('getLastEmail()', () => {
it('should return null when no emails exist', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ total: 0, items: [] }),
} as Response);
const result = await adapter.getLastEmail('test@example.com');
expect(result).toBeNull();
});
it('should return mapped email when found', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({
total: 1,
items: [{
ID: '1',
From: { Mailbox: 'sender', Domain: 'example.com' },
To: [{ Mailbox: 'test', Domain: 'example.com' }],
Content: {
Headers: { Subject: ['Test Subject'] },
Body: 'Test body',
},
Created: '2024-01-15T10:00:00Z',
}],
}),
} as Response);
const result = await adapter.getLastEmail('test@example.com');
expect(result).toEqual({
from: 'sender@example.com',
to: 'test@example.com',
subject: 'Test Subject',
body: 'Test body',
html: undefined,
receivedAt: expect.any(Date),
});
});
});
describe('waitForEmail()', () => {
it('should timeout when email not found', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ total: 0, items: [] }),
} as Response);
await expect(
adapter.waitForEmail('test@example.com', { timeout: 100 })
).rejects.toThrow('Timeout waiting for email');
});
});
describe('clearEmails()', () => {
it('should call DELETE endpoint', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
} as Response);
await adapter.clearEmails();
expect(fetchSpy).toHaveBeenCalledWith(
'http://localhost:8025/api/v1/messages',
{ method: 'DELETE' }
);
});
});
});
Integration Tests
// tests/adapters/mailhog.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { MailhogAdapter } from '../../src/adapters/email/mailhog.adapter.js';
// Only run if Mailhog is available
describe.skipIf(!process.env.MAILHOG_URL)('MailhogAdapter Integration', () => {
let adapter: MailhogAdapter;
beforeEach(async () => {
adapter = new MailhogAdapter({
apiUrl: process.env.MAILHOG_URL!,
});
await adapter.clearEmails();
});
it('should capture and retrieve emails', async () => {
// Send email via your app's email service
await sendTestEmail('test@example.com', 'Hello', 'World');
const email = await adapter.waitForEmail('test@example.com');
expect(email.subject).toBe('Hello');
expect(email.body).toContain('World');
});
});
Configuration Options Pattern
Use sensible defaults with override capability:
export interface AdapterConfig {
// Required - no default possible
apiUrl: string;
// Optional with defaults
timeout?: number;
retries?: number;
debug?: boolean;
}
export class MyAdapter {
private readonly config: Required<AdapterConfig>;
constructor(config: AdapterConfig) {
this.config = {
timeout: 30000,
retries: 3,
debug: false,
...config, // User values override defaults
};
}
}
Checklist
- Adapter implements full port interface
- Dependencies injected via constructor
- Configuration options with sensible defaults
- JSDoc comments on class and methods
- Error handling with descriptive messages
- Resource cleanup method (if applicable)
- Exported from adapter index
- Exported from main index
- Unit tests with mocked dependencies
- Integration tests (if external service)
- Documentation updated
Related Guides
- Adding Ports - Define interfaces
- Adding Steps - Create step definitions
- Testing - Testing strategies
- Custom Adapters Guide - Usage guide