You wrote a Lambda handler that saves a video to DynamoDB and returns a presigned S3 URL. Now you write the test, run it, and it fails, or worse, it actually writes to your real table. Either way, your unit test is talking to AWS.
This post shows the simple pattern that fixes it: put your AWS code behind classes, spy on their methods in a beforeEach, and assert what the handler asked for. It also shows where a real test, with no mocks, still belongs.
(The reasoning behind test-driven development is in Test-driven development for AWS Lambda with Jest. This post is the how-to for the mocking mechanics.)
The setup: wrap AWS in a class
A handler that creates a DynamoDBClient inline is hard to test. Wrap the database in a class, with its configuration passed in:
// entity/db.ts
export class Db {
private client: DynamoDBDocumentClient;
constructor(private config: { region: string; tableName: string }) {
this.client = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.region }), {
marshallOptions: { removeUndefinedValues: true },
});
}
async save(document: Record<string, unknown>) {
return this.client.send(new PutCommand({ TableName: this.config.tableName, Item: document }));
}
}Two small design points. The client is a property created once in the constructor, so save, and the update and delete you add later, all share it, and the configuration arrives from outside, not from hard-coded constants. Your handler then uses new VideoDb({...}), and the test can replace just the method it cares about.
The problem
Without any mocking, this test does a real network call:
it("should create the video and return an upload url", async () => {
const res = await handler(event as any, {} as any, () => {});
expect(res).toMatchObject({ statusCode: 200 });
});It fails if you have no credentials, no table, or no internet, and it is slow and not repeatable even when it passes. That is what you see in the first run after adding a Db class: some tests fail because save and the S3 helper run for real.
The fix: spy in beforeEach
jest.spyOn(object, "method") wraps an existing method so you can observe it. Adding .mockImplementation(...) also replaces what it does. Because instances share their methods through the class prototype, spying on VideoDb.prototype replaces the method for every instance, including the one your handler creates internally:
import { VideoDb } from "../entity/video";
import { UploadBucket } from "../entity/upload-bucket";
describe("put video handler", () => {
let mockSave: jest.SpyInstance;
let mockGetUploadUrl: jest.SpyInstance;
beforeEach(() => {
mockSave = jest.spyOn(VideoDb.prototype, "save").mockImplementation(async () => ({} as any));
mockGetUploadUrl = jest
.spyOn(UploadBucket.prototype, "getUploadUrl")
.mockImplementation(async () => "https://example.test/upload");
});
afterEach(() => {
jest.restoreAllMocks();
});
it("saves the video and returns an upload url", async () => {
const res = await handler(validEvent as any, {} as any, () => {}) as APIGatewayProxyResult;
expect(res.statusCode).toBe(200);
expect(mockSave).toHaveBeenCalledTimes(1);
expect(JSON.parse(res.body).uploadUrl).toBe("https://example.test/upload");
});
});
Why beforeEach?
beforeEach runs before every test in the block, so each test starts with fresh spies and no leftover calls. Without it, you would repeat the spy setup in every test, or forget it in one, and that one would hit AWS. afterEach with restoreAllMocks puts the real methods back, so tests in other files are not affected. (You can also set restoreMocks: true in the Jest config.)
TypeScript complains about the mock
A mock that returns nothing does not match a save that returns a promise of a DynamoDB result, and TypeScript reports an error in the test. Two common fixes: cast the implementation (async () => ({} as any)), or use mockResolvedValue:
jest.spyOn(VideoDb.prototype, "save").mockResolvedValue({} as any);Keep the casts in test files only.
Assert what matters
Because the spy records every call, you can check the handler's behaviour instead of its result:
expect(mockSave).toHaveBeenCalledTimes(1);
// the first argument of the first call:
expect(mockSave.mock.calls[0][0]).toMatchObject({ title: "My video", status: "NOT_UPLOADED" });
// or the shorter form for exact arguments:
expect(mockGetUploadUrl).toHaveBeenCalledWith(expect.any(String));mock.calls is an array of calls, and each call is an array of its arguments. So mock.calls[0][0] means "first call, first argument". A guard against the wrong path works the same way: expect(spyGet).not.toHaveBeenCalled().
Handy Jest switches
| Need | Use |
|---|---|
| Run only this test while you work | it.only("...", ...) or jest -t "name" |
| Skip a test for now | it.skip("...", ...) |
| Run a single test file | jest path/to/file.test.ts |
| Watch mode | jest --watch |
| Reset spies between tests | afterEach(() => jest.restoreAllMocks()) |
Remember to remove .only before committing. It silently skips every other test in the file.
But also write one real test
If everything is mocked, nothing ever proves that save really works with DynamoDB. For the database class itself, a real test is the right choice, because the thing you are testing is the call to AWS:
// test/db.test.ts (integration: needs a real test table and credentials)
describe("VideoDb", () => {
it("saves a document in the table", async () => {
const db = new VideoDb({ region: "us-east-1", tableName: "vidshare-video-test" });
const res = await db.save({ id: "test-123", userId: "user-1", title: "A test" });
expect(res.$metadata.httpStatusCode).toBe(200);
});
});Use a separate test table, never production, and expect this test to be slower. Many teams mark such tests so that they run only in CI or with a flag, and keep the fast mocked tests for every save. The general rule is: mock the boundary in the code that uses it, and test the boundary for real in one place.
Key takeaways
- ✓Wrap AWS calls in classes so a test can replace one method without touching the rest.
- ✓jest.spyOn(Class.prototype, 'method').mockImplementation(...) replaces that method for every instance.
- ✓Put the spies in beforeEach, and restore them in afterEach, so every test starts clean.
- ✓mock.calls[0][0] is the first argument of the first call. toHaveBeenCalledWith checks exact arguments.
- ✓Keep one real integration test for the database class against a separate test table.
Learn it by building a backend test-first
VidShare: Build a Video Streaming Platform on AWS writes every Lambda handler this way: test first, spy on the boundary, then implement. 109 lessons covering API Gateway, Lambda, DynamoDB, S3, MediaConvert, OpenSearch and CDK. Lifetime access, and three lessons are free to watch.
