Controlling time inside a simulated AWS Lambda handler with Yulin
A lot of bugs in real systems are related to time. Time logic is difficult to test, so it often ends up covered by narrow unit tests around single functions, rather than wider system behaviours. Those kinds of tests are better than nothing, but they tend not to expose problems that occur across multiple parts of the system.
The simulated clock in Yulin
helps with testing time logic across more meaningful parts of a system. A clock belongs to
a SimAws instance rather than to the process running it, so moving it moves time for everything
in that simulation without affecting anything outside it. It’s safe to have multiple SimAws
instances in one process, for example in concurrent test cases.
The simulator clock reaches inside
a simulated Lambda function as
well. Date.now() and new Date() in the handler report the simulation’s time rather than the time
the host process is running at. Handler code which makes a decision based on time, for example, can be tested
against a clock controlled by the test.
It works the same way as advancing the clock to expire a session for the simulated services themselves.
Here’s a simulated Lambda function whose handler asks JavaScript for the time, in a simulation which is frozen at a fixed instant:
import { CreateFunctionCommand, InvokeCommand } from "@aws-sdk/client-lambda";
import { SimAws, SimFixedClock } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws({
clock: new SimFixedClock(new Date("2026-07-28T09:00:00.000Z")),
});
const lambda = simAws.lambda();
await lambda.createFunction(
new CreateFunctionCommand({
FunctionName: "foobar-stamper",
Role: "arn:aws:iam::111111111111:role/FooStamperRole",
Code: {
ZipFile: makeLambdaZipFileInput(() => ({
at: new Date().toISOString(),
})),
},
}),
);
makeLambdaZipFileInput() takes a real in-process handler function and passes it through the
SDK-shaped Code.ZipFile input, so the handler here is an ordinary function in the same Node.js
process as the test.
Invoking that Lambda stamps the instant the simulation is stopped at, and advancing the clock changes the time that the next invocation sees:
const first = await lambda.invoke(
new InvokeCommand({ FunctionName: "foobar-stamper" }),
);
console.log(Buffer.from(first.Payload!).toString());
await simAws.clock().advanceBy({ hours: 2 });
const second = await lambda.invoke(
new InvokeCommand({ FunctionName: "foobar-stamper" }),
);
console.log(Buffer.from(second.Payload!).toString());
{ "at": "2026-07-28T09:00:00.000Z" }
{ "at": "2026-07-28T11:00:00.000Z" }
How the simulation’s clock reaches the function code depends on where that code runs.
- Zip-packaged code runs in a
vmcontext owning its own globals, so it is handed aDatebound to the simulation’s clock. Nothing outside the sandbox is affected. - A real in-process handler is a closure over the module scope it was written in, and reads the
global
Datelike everything else in the test run. So that global is substituted for one which reports the invocation’s clock while an invocation is running, and the host clock otherwise.
The substitution is installed lazily, on the first invocation of an in-process handler, so a test
run using only ZIP source code never has its Date replaced at all. The invocation’s clock is tracked with
AsyncLocalStorage,
so it follows a handler across async operations, and two simulations invoking at the same time each
read their own time rather than picking up the other’s.
Only the current time comes from the clock. new Date("2020-03-12"), Date.parse(...),
Date.UTC(...) and instanceof Date all behave as normal. When there is no invocation running,
the substituted global behaves the same as the host’s own Date.
One issue to be aware of is that Yulin can’t practically do anything about time accessed in a module
scope, i.e. at import time. So if a Lambda handler accesses Date.now() in its module scope and not
inside its handler function, that will see the host clock.
That’s similar to the situation with environment variables in simulated Lambda functions. Also,
timers inside a handler are still host timers, so setTimeout waits in real time and advancing the
simulation’s clock does not release a sleeping handler.
A frozen clock also means Date.now() returns the same number for the whole invocation, so handler
code which waits for it to change never finishes. Call resume() before invoking if the code under
test polls the clock. context.getRemainingTimeInMillis() reads the same clock as well, so a
stopped clock leaves a handler with a constant budget rather than one draining in real time.
Real Lambda has no current-time API of its own. The context object carries
getRemainingTimeInMillis() and no timestamp, and no runtime environment variable holds one, so
handler code reading new Date() on real AWS is reading the machine clock.
What AWS does provide is the time on the event, such as time on an EventBridge event,
requestContext.timeEpoch on a Function URL or API Gateway event, and eventTime on an S3
notification record. In simulated Lambda, those all get correctly substituted with the simulator
clock time.
In both real AWS and the simulator, it’s advisable to use those timestamp properties on the input
event to get the event timestamp, rather than relying on Date.now().