Mocking SQS and SNS Clients in Vitest
Code that talks to SQS and SNS through the AWS SDK v3 is easy to write and awkward to test: every operation is a command object sent through a client, the client needs credentials and a region, and the interesting behaviours — batch sends that partially fail, messages redelivered after a visibility timeout, attributes that route to the wrong subscriber — are hard to produce against the real service. This guide covers stubbing the v3 clients with aws-sdk-client-mock for fast, typed unit tests, asserting on exactly what was sent including message attributes, handling batch partial failures, and running the consumer loop against ElasticMQ where the queue semantics themselves matter. It targets @aws-sdk/client-sqs and @aws-sdk/client-sns v3 with Vitest 2.x, and sits under event-driven and queue mocking.
Root Cause Analysis
The v3 SDK’s command pattern defeats the most obvious mocking approach. Code does not call sqs.sendMessage(...); it calls client.send(new SendMessageCommand(...)), so replacing a method by name catches every command at once and gives no way to respond differently to a send and a receive. Hand-rolled mocks end up switching on command.constructor.name, which works until a minor upgrade renames something.
Beyond mechanics, the behaviours worth testing are ones the real service rarely exhibits on demand. SendMessageBatch returns successfully even when some entries failed — the failures are in a Failed array that code routinely ignores. A publish to SNS with a missing message attribute is accepted and then silently not delivered to subscribers whose filter policy required it. Neither produces an error; both produce missing messages.
Finally, the consumer side depends on queue semantics — visibility timeouts, receive counts, redrive policies — that a client mock cannot model at all. Those need a real queue, and a local SQS-compatible server provides one without an AWS account.
Reproducible Setup
Install the client mock and its Vitest matchers, and make sure the code under test receives its client rather than constructing one per call.
npm install -D aws-sdk-client-mock aws-sdk-client-mock-vitest
// vitest.setup.ts
import { expect } from 'vitest';
import { allCustomMatcher } from 'aws-sdk-client-mock-vitest';
expect.extend(allCustomMatcher);
// src/notifications/publish.ts — the code under test
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
export async function publishOrderEvent(sns: SNSClient, event: { type: string; orderId: string; region: string }) {
return sns.send(new PublishCommand({
TopicArn: process.env.ORDERS_TOPIC_ARN,
Message: JSON.stringify(event),
MessageAttributes: {
type: { DataType: 'String', StringValue: event.type },
region: { DataType: 'String', StringValue: event.region },
},
}));
}
export async function enqueueEmails(sqs: SQSClient, emails: Array<{ id: string; to: string }>) {
const res = await sqs.send(new SendMessageBatchCommand({
QueueUrl: process.env.EMAIL_QUEUE_URL,
Entries: emails.map((e) => ({ Id: e.id, MessageBody: JSON.stringify(e) })),
}));
return { failed: (res.Failed ?? []).map((f) => f.Id!) };
}
Implementation
Step 1 — Create a mock per client and reset it before each test. The mock intercepts every command sent through any instance of that client class.
// src/notifications/publish.test.ts
import { beforeEach, test, expect, vi } from 'vitest';
import { mockClient } from 'aws-sdk-client-mock';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
import { publishOrderEvent, enqueueEmails } from './publish';
const snsMock = mockClient(SNSClient);
const sqsMock = mockClient(SQSClient);
beforeEach(() => {
snsMock.reset();
sqsMock.reset();
vi.stubEnv('ORDERS_TOPIC_ARN', 'arn:aws:sns:eu-west-2:000000000000:orders');
vi.stubEnv('EMAIL_QUEUE_URL', 'http://localhost/queue/emails');
});
Step 2 — Assert on the full command input, including attributes. Subscription filter policies route on message attributes, so a missing or misnamed attribute is a delivery bug that only this assertion catches.
test('publishes with the attributes subscribers filter on', async () => {
snsMock.on(PublishCommand).resolves({ MessageId: 'm-1' });
await publishOrderEvent(new SNSClient({}), { type: 'OrderPlaced', orderId: 'o1', region: 'uk' });
expect(snsMock).toHaveReceivedCommandWith(PublishCommand, {
TopicArn: 'arn:aws:sns:eu-west-2:000000000000:orders',
MessageAttributes: {
type: { DataType: 'String', StringValue: 'OrderPlaced' },
region: { DataType: 'String', StringValue: 'uk' },
},
});
});
Step 3 — Test batch partial failure explicitly. The batch call succeeds as a whole; the individual failures are data the code must look at.
test('reports which batch entries failed', async () => {
sqsMock.on(SendMessageBatchCommand).resolves({
Successful: [{ Id: 'a', MessageId: 'x', MD5OfMessageBody: '' }],
Failed: [{ Id: 'b', Code: 'InternalError', SenderFault: false }],
});
const result = await enqueueEmails(new SQSClient({}), [
{ id: 'a', to: 'ada@example.test' },
{ id: 'b', to: 'grace@example.test' },
]);
expect(result).toEqual({ failed: ['b'] });
});
Step 4 — Simulate service errors with the SDK’s own exception types. Code that retries on throttling and gives up on permission errors should be tested against the real exception classes, not generic errors.
import { KMSAccessDeniedException } from '@aws-sdk/client-sqs';
test('does not retry on an access-denied error', async () => {
sqsMock.on(SendMessageBatchCommand).rejects(
new KMSAccessDeniedException({ message: 'denied', $metadata: {} }),
);
await expect(enqueueEmails(new SQSClient({}), [{ id: 'a', to: 'x@example.test' }])).rejects.toThrow('denied');
expect(sqsMock).toHaveReceivedCommandTimes(SendMessageBatchCommand, 1);
});
Step 5 — Run the consumer loop against ElasticMQ. Visibility timeouts, receive counts and redrive to a dead-letter queue are queue semantics; a local SQS-compatible server provides them without AWS.
// test/integration/email-consumer.test.ts
import { GenericContainer, type StartedTestContainer } from 'testcontainers';
import { SQSClient, CreateQueueCommand, SendMessageCommand, ReceiveMessageCommand } from '@aws-sdk/client-sqs';
let mq: StartedTestContainer;
let sqs: SQSClient;
beforeAll(async () => {
mq = await new GenericContainer('softwaremill/elasticmq-native:1.6.0').withExposedPorts(9324).start();
sqs = new SQSClient({
endpoint: `http://${mq.getHost()}:${mq.getMappedPort(9324)}`,
region: 'elasticmq',
credentials: { accessKeyId: 'x', secretAccessKey: 'x' },
});
}, 60_000);
afterAll(() => mq.stop());
test('a message that fails three times is moved to the dead-letter queue', async () => {
// create a queue with a redrive policy of maxReceiveCount 3, send a poison message,
// run the consumer until it gives up, then receive from the DLQ and assert it arrived
});
Step 6 — Keep client construction out of module scope. A client built at import time cannot be pointed at ElasticMQ in integration tests without module resets; pass it in, or build it from configuration read at call time.
A last habit that pays for itself: when a new command is introduced into the code, add a mock response for it deliberately rather than letting it fall through. By default the mock returns undefined for unconfigured commands, which the code then tries to read properties from, producing a TypeError far from the cause. Configuring every command a test touches keeps failures pointed at the right line.
Verification
Check that the unit tests cover the silent failures, not only the successful sends.
npx vitest run src/notifications --reporter=verbose
# ✓ publishes with the attributes subscribers filter on
# ✓ reports which batch entries failed
# ✓ does not retry on an access-denied error
Then confirm the attribute assertion is doing real work by removing the region attribute from the publish call. The test must fail with a diff naming the missing attribute — which is the bug that would otherwise surface as a subscriber silently receiving nothing.
Troubleshooting
Symptom: the mock never intercepts and the test tries to reach AWS. Diagnosis: two copies of the SDK client package are installed, and the mock was created against a different class than the one the code imports. Fix: deduplicate the @aws-sdk/* packages so there is one version, and check with your package manager’s why command.
Symptom: a test passes in isolation and fails after another. Diagnosis: the mock’s configured responses persist across tests. Fix: call reset() in beforeEach as in Step 1 — aws-sdk-client-mock keeps behaviour on the module-level mock object, not per test.
Symptom: the SDK complains about missing credentials even with the mock. Diagnosis: the client resolves credentials when constructed, before any command is sent. Fix: construct test clients with explicit dummy credentials and region, or stub the environment variables the credential chain reads.
Symptom: ElasticMQ tests are slow to receive messages. Diagnosis: the consumer uses long polling with a twenty-second wait, and the test waits for the full period when the queue is empty. Fix: use a short WaitTimeSeconds in tests and poll for the expected outcome with a timeout rather than relying on a single receive.
FAQ
Should I use LocalStack instead of ElasticMQ?
LocalStack emulates SNS, SQS and much more in one container, which is useful when a test needs SNS-to-SQS fan-out with filter policies. ElasticMQ is lighter and faster for SQS alone. Pick by what the test needs; for pure queue semantics ElasticMQ starts in a second or two.
Is mocking the client enough for the producer side?
For the producer, yes — its behaviour is fully described by what it sends and how it handles the response. The consumer side is where queue semantics enter, and that is where a real queue earns its keep. Keep the two sets of tests separate so the fast producer tests are not slowed by container startup.
How do I test SNS filter policies?
The policy is evaluated by the service, so a client mock cannot evaluate it. Test the attributes your code sends with the mock, and test the policy itself once against LocalStack or a real sandbox account. A mismatch between the two is the classic silent-delivery bug, so both halves deserve a test.
Does this work with Lambda handlers triggered by SQS?
The handler receives an event object with a Records array; test it by constructing that event and calling the handler directly. The batch item failure response — returning the identifiers of records to retry — is worth a dedicated test, because getting it wrong either loses messages or reprocesses the whole batch.
Related
- Back to Event-Driven & Queue Mocking
- Testing Kafka consumers with an in-memory broker — the same consumer responsibilities for Kafka.
- Stubbing email and notification providers — what typically sits at the far end of these queues.
- Isolating environment variables per test — the queue URLs and ARNs these tests stub.