
unit testing agent email in typescript with typed mocks
How to mock email services in TypeScript tests for AI agents. Compares Jest, ts-mockito, and SDK-level approaches with typed examples.
Your agent's test suite runs green. Every email function passes. Then you deploy, and the agent fires off 47 real emails to actual customers during a CI run. Someone forgot to mock the transport layer.
This happens more often than anyone will admit. And the problem compounds when email isn't a simple "fire and forget" call. Agents make decisions based on email content: parsing verification codes, routing based on sender, retrying after bounces, handling unsubscribes. The test surface is wide, and a careless mock hides bugs instead of catching them.
TypeScript should help. The type system gives you safety rails for defining email interfaces, matching response shapes, and catching mismatches at compile time. But most mocking approaches throw all of that away with as any casts the moment an email SDK gets involved. You end up with tests that compile, pass, and prove nothing about how your agent actually behaves.
This guide covers how to mock email dependencies in TypeScript without losing type safety, with a focus on agent workflows where email is part of a larger decision loop. Whether your agent creates its own inbox through auto-signup or uses a shared address, these patterns apply.
TypeScript email mocking libraries compared#
Five main approaches exist for mocking email in TypeScript tests. Each sits at a different abstraction level with different tradeoffs between type safety and setup effort.
| Library | Type safety | Email-specific | Jest compatible | Best for |
|---|---|---|---|---|
jest.fn() | Manual typing | None | Native | Simple function stubs |
| ts-mockito | Interface-driven | None | Yes | Complex class mocks |
| typemoq | Strong generics | None | Yes | Strict verification |
| nodemailer-mock | Partial (JS-first) | SMTP transport | Yes | Nodemailer projects |
| SDK-level mock | Full (mirrors SDK) | Full lifecycle | Yes | Agent email workflows |
The first three are general-purpose mocking libraries. They work for any TypeScript interface, including email. nodemailer-mock replaces Nodemailer's SMTP transport in-memory, which makes it a natural fit for projects already using Nodemailer, but it doesn't help when your agent uses a dedicated email API like LobsterMail. The last category, SDK-level mocking, means building a test double that mirrors your email provider's client interface, so agent code follows the same path in tests and production.
Three layers where you can intercept#
When an agent sends email, the call passes through at least three layers. Where you intercept determines what your test actually proves.
HTTP/transport layer#
Tools like msw (Mock Service Worker) or nock intercept outgoing HTTP requests before they leave the process. Your agent code and SDK client both run for real. Only the network call is faked.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.post('https://api.your-email-provider.com/v1/send', () => {
return HttpResponse.json({
id: 'msg_test_123',
status: 'queued',
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
This gives you high confidence because the full code path executes. It also makes it straightforward to simulate specific failures like 429 rate limits or 503 outages. If you're looking for patterns around testing agent email without hitting production, HTTP-layer interception is a solid starting point.
SDK/client layer#
Replace the email SDK instance with a typed stub. This is faster than HTTP-level mocking and isolates your agent logic from network serialization concerns.
import { mock, instance, when, verify, anything } from 'ts-mockito';
import type { EmailClient } from './email-client';
const mockClient = mock<EmailClient>();
when(mockClient.send(anything())).thenResolve({
id: 'msg_test_456',
status: 'queued',
});
const agent = new EmailAgent(instance(mockClient));
await agent.handleTask('send welcome email to user@example.com');
verify(mockClient.send(anything())).once();
ts-mockito generates mocks directly from TypeScript interfaces. No `as any`, no hand-typed return values. If your `EmailClient` interface changes (say, `send()` gains a required `priority` parameter), the mock breaks at compile time. That's exactly what you want from a typed mock.
For those unfamiliar: ts-mockito is a TypeScript mocking library inspired by Java's Mockito. It creates proxy objects that implement your interfaces and records calls for later verification with verify(). It works alongside Jest, Vitest, or Mocha without conflict.
Service abstraction layer#
Define your own interface that wraps the email provider. Mock that interface in tests. This decouples your agent from any specific SDK.
interface AgentMailer {
send(to: string, subject: string, body: string): Promise<SendResult>;
receive(inboxId: string): Promise<Email[]>;
getInbox(name: string): Promise<Inbox>;
}
Mocking at this level is the fastest to write and easiest to maintain. The tradeoff: you're not testing the SDK integration path at all. Pair these unit tests with a small set of integration tests that hit a real sandbox environment to cover that gap.
Testing retry and fallback behavior#
Agents that send email need to handle failures. A 503 from the provider or a bounced address can't just crash the process. Your tests should verify the agent's response to these conditions, not just the happy path.
Here's a typed retry test using Jest:
const sendMock = jest.fn<Promise<SendResult>, [SendParams]>();
sendMock
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
.mockResolvedValueOnce({ id: 'msg_789', status: 'queued' });
const agent = new EmailAgent({ send: sendMock } as AgentMailer);
const result = await agent.sendWithRetry({
to: 'user@example.com',
subject: 'Verification code',
body: 'Your code is 4821',
});
expect(sendMock).toHaveBeenCalledTimes(2);
expect(result.status).toBe('queued');
The generic type on jest.fn() is doing real work here. Without <Promise<SendResult>, [SendParams]>, TypeScript can't verify that your mock returns the right shape. This is where most email test suites quietly fail: the mock accepts anything and returns anything, so the test passes even when the real implementation would throw a type error.
You should also test what happens when retries are exhausted. Does the agent fall back to a secondary provider? Log the failure and continue? Silently drop the message? Each behavior deserves an explicit test case, and a 429 should be tested separately from a 503 since the retry timing logic differs.
Mocking webhook events for agent state machines#
If your agent reacts to inbound email events (bounces, replies, delivery confirmations), you need to simulate those events in tests. Factory functions that produce realistic webhook payloads make this manageable:
function bounceEvent(overrides?: Partial<BounceEvent>): BounceEvent {
return {
type: 'bounce',
recipient: 'invalid@example.com',
reason: 'mailbox_full',
timestamp: new Date().toISOString(),
...overrides,
};
}
test('agent marks address as invalid after hard bounce', async () => {
await agent.handleWebhook(bounceEvent({ reason: 'invalid_address' }));
expect(agent.isAddressValid('invalid@example.com')).toBe(false);
});
For multi-step orchestration (send email, wait for reply, parse content, take action), chain your mocks into a sequence. jest.fn() supports .mockResolvedValueOnce() for exactly this purpose: each call returns the next value in the sequence, letting you simulate a realistic conversation timeline without any external dependencies.
Keeping mocks in sync as APIs evolve#
The biggest risk with any mock is drift. Your email provider ships a new SDK version. A field becomes required. A response shape changes. If your mocks don't break when this happens, they're actively hiding bugs.
Tip
If your email provider publishes TypeScript types for their SDK, import those types directly in your mocks. Using mock<ProviderClient>() with ts-mockito will flag every breaking API change at compile time, before your tests even run.
Two practices that prevent drift:
-
Derive mock types from the real interface. Use
jest.fn<ReturnType, ParamType>()or ts-mockito'smock<Interface>()instead of hand-writing mock objects. When the interface changes, the compiler catches it immediately. -
Run at least one integration test against a real sandbox. Mocks verify logic. Integration tests verify compatibility. Even a single test that creates an inbox, sends an email, and reads it back catches drift that no mock will surface.
The right split for most agent projects is 80% unit tests with typed mocks for speed and 20% integration tests against a real environment for confidence. Unit tests run in milliseconds and cover branching logic. Integration tests take seconds but catch the things mocks structurally cannot.
Pick the right layer, then enforce the types#
If your agent treats email as simple outbound notifications, mock at the service abstraction layer and move on. If email events drive agent decisions (parsing replies, extracting verification codes), mock at the HTTP layer so you're testing the full deserialization path.
Whatever layer you choose, use TypeScript's type system to enforce the contract. Every as any in a test file is a spot where a real bug can hide undetected. ts-mockito and typed jest.fn() generics exist specifically to close those gaps. Combine them with one or two integration tests and you'll catch both logic errors and API drift before they reach production.
For agents that self-provision inboxes through LobsterMail, the same patterns apply to inbox creation, polling, and webhook handling. If you want to test against a real API, and write your first typed mock against the SDK.
Frequently asked questions
How do you mock email sending in TypeScript?
Use jest.fn() with generic type parameters, ts-mockito's mock<Interface>(), or HTTP-level interceptors like msw. The key is preserving type safety by deriving mock types from your real email interface rather than casting to any.
What is nodemailer-mock and how does it work with Jest?
nodemailer-mock replaces Nodemailer's SMTP transport with an in-memory mock. You call jest.mock('nodemailer') and it intercepts createTransport(). It's useful for Nodemailer projects but doesn't help when your agent uses a dedicated email API.
How do you enforce type safety when mocking an email interface without using any?
Use ts-mockito's mock<YourInterface>() to generate a proxy that implements the interface, or pass generic types to jest.fn<ReturnType, [ParamTypes]>(). Both approaches cause compile-time failures when the real interface changes.
What is ts-mockito used for in Node.js?
ts-mockito is a TypeScript mocking library inspired by Java's Mockito. It creates proxy-based mocks from interfaces or classes, supports call verification with verify(), and provides argument matchers like anything(), anyString(), and deepEqual().
Should you mock or stub email dependencies in unit tests?
For unit tests, mock or stub at the service abstraction layer for speed. For integration tests, use HTTP-level interception or a real sandbox. The distinction between mocks (verify behavior) and stubs (return canned data) matters less than whether your test double preserves type safety.
How do you test async functions that send emails in TypeScript?
Use async/await in your test and mockResolvedValue() or mockRejectedValue() on your mock. Always test both the success path and the failure path, including errors like rate limits, timeouts, and malformed responses.
How should you structure unit tests for an AI agent that sends email?
Test each decision branch separately: successful send, retry after failure, fallback to a secondary provider, and giving up after max retries. Use typed mocks for the email client and inject them through constructor parameters so you can swap implementations in tests.
When should you use a mock versus a real sandbox for email tests?
Use mocks for unit tests that verify agent logic (fast, deterministic). Use a real sandbox for integration tests that verify your code works with the actual API. Most projects need both to catch logic errors and API drift respectively.
How do you mock email delivery webhooks in tests?
Create factory functions that return typed webhook payloads (bounce, open, click, reply) with sensible defaults. Pass these directly to your agent's webhook handler. Use Partial<T> overrides to customize specific fields per test case.
Can you use jest.mock() to replace an entire email SDK without losing type safety?
Yes, but you need a typed factory. Use jest.mock('./email-client', () => ({ EmailClient: jest.fn().mockImplementation(() => mockInstance) })) and type mockInstance against your real interface to maintain compile-time checks.
How do you verify the recipient and subject of a mocked email in Jest?
With jest.fn(), use expect(mock).toHaveBeenCalledWith(expect.objectContaining({ to: 'user@example.com', subject: 'Welcome' })). With ts-mockito, use verify(mockClient.send(deepEqual({ to: 'user@example.com' }))).once().
What is the difference between mocking and stubbing in Jest?
A stub returns predetermined data via mockReturnValue. A mock also records calls for later assertions with toHaveBeenCalledWith. In practice, Jest's jest.fn() does both. The distinction is mostly about intent: stubs isolate, mocks verify.


