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.

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().


Tech mentioned