If you build robust JavaScript/TypeScript applications, you know that testing functions with internal state or retries is a headache. The standard advice is always: "Refactor your code to use dependency injection so you can mock the delays."
But what if you don't want to pollute your clean production API just to satisfy a test?
This is exactly where the @typescript-guy/fn-monitor package shines. It's a lightweight tool that lets you monitor, intercept, and control the execution of any function at the AST level, without needing to change the code that runs in production. It achieves this by running your functions through a JS-in-JS interpreter.
Today, I want to highlight one of its most powerful capabilities: the overwrite property. It uses AST mutation to force specific variables to always resolve to your specified values, bypassing internal logic.
Consider this snippet of typical production code:
// 🏭 Fake API
const api = {
get: async (url: string) => { throw new Error("Network Failure"); }
};
// 🏭 Production code: Resilient retry logic
async function fetchWithRetry(url: string) {
let attempts = 0;
let lastError: any = null;
const retryDelayMs = 2000; // 😫 Painful to wait in tests!
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
return await api.get(url);
} catch (error) {
attempts++;
lastError = error;
if (attempts === maxAttempts) {
console.log(`Attempt ${attempts} failed. No retries left.`);
break;
}
console.log(`Attempt ${attempts} failed. Retrying in ${retryDelayMs}ms...`);
await new Promise(r => setTimeout(r, retryDelayMs));
}
}
throw lastError;
}
Now, let's use fn-monitor to create a test version of this function that fails instantly.
First, install the package:
npm install @typescript-guy/fn-monitor
Then add the following code to the same file:
import { monitor } from "@typescript-guy/fn-monitor";
// ... `api` and `fetchWithRetry` definitions
// 🧪 Test: Force instant failure (No refactoring needed!)
const failFastFetch = monitor({
main: {
ref: fetchWithRetry, // The function to monitor and transform
captures: { api }, // Inject the API into the interpreter's environment
overwrite: {
retryDelayMs: 0, // ✨ Eliminates the 2-second wait
maxAttempts: 2 // ✨ Forces failure on the 2nd try
}
}
});
try {
// Call the new function exactly like the original
await failFastFetch('https://api.example.com/data');
} catch (error) {
console.log(`Caught error: ${(error as Error).message}`);
}
Output
Attempt 1 failed. Retrying in 0ms...
Attempt 2 failed. No retries left.
Caught error: Network Failure
Why this approach protects your production code
- Zero Refactoring: Mock internal state without changing function signatures or adding boilerplate.
-
No Fake Timers: Bypasses the delay in
setTimeoutentirely, eliminating the need for complex fake timer setups in your test runner. - Universal Mocking: Virtually any variable the function uses can be seamlessly substituted with a mock value.
If this approach sounds useful for your testing workflow, check out:
- 📦 The repository and documentation: https://github.com/The-BigMan-tech/fn-monitor
- 📝 My articles for in-depth tutorials: https://dev.to/typescript-guy
If you have questions or ideas, feel free to open a discussion on GitHub.
Top comments (5)
typescript-guy, this is a brilliant escape hatch from the "dependency injection for everything" testing dogma. using ast mutation to intercept and overwrite internal state is exactly the kind of pragmatic tooling that saves hours of boilerplate.
the
overwriteproperty for bypassingsettimeoutdelays in retry logic is a massive quality-of-life improvement over wrestling with fake timers in jest or vitest.i'm curious about the mechanics of the js-in-js interpreter under the hood, specifically regarding two edge cases:
capturesobject, how does the interpreter resolve those bindings without breaking the closure chain or throwing reference errors?fantastic first release. the developer experience focus here is top-tier. 🐯🛠️
Thanks for checking out the package and for the great questions!
To address your first point: yes, there is a noticeable performance overhead when running code through the interpreter, though the package caches ASTs and reuses internal objects to keep that cost as minimal as possible.
Regarding your second point: exactly right—since it doesn't execute in the native JS engine, it loses access to its lexical scope.
capturesis the most straightforward way to bridge that gap, butembedis another great tool for it. You can check out the full nuances of how they work in the README capturing and embedding section.typescript-guy, thanks for the detailed breakdown!
the ast caching makes total sense for keeping the overhead manageable across a large test suite. and the
embedfeature is a brilliant addition for bridging that lexical scope gap without polluting thecapturesobject.one quick follow-up on
embed: does it handle dynamic bindings well (like a loop variable or a closure that changes per iteration), or is it best suited for static references injected at the time of monitoring?really impressive work on this. it's exactly the kind of pragmatic tooling that makes testing less painful. 🐯🛠️
Thanks! To clarify how captures and embed work under the hood:
Both captures and embed statically bind data at the time of monitoring. The core difference isn't about dynamic loops vs. static references, but about data type and visibility:
captures: Works for all data types (objects, primitives, functions). It treats captured functions as external references—meaning to the interpreter, calling a captured function is a "black box" call.
embed: Is exclusive to functions. Instead of treating the function as an external reference, embed bakes its source code directly into the same interpreted context as the monitored function.
Because an embedded function lives inside the same execution context, debugging/inspection hooks like inspector can see right through its execution step-by-step when called by the main function, whereas captures won't expose those internal steps.
Hope that clarifies the distinction!
typescript-guy, that distinction is crystal clear and incredibly valuable. baking the function's source code directly into the interpreted context via
embedto enable step-by-step inspection is a brilliant architectural choice for deep debugging.one quick follow-up on
embed: when the embedded function's source is baked in, how does it resolve references to variables or modules from its original lexical scope that aren't explicitly passed in? does the interpreter automatically hoist those dependencies, or does the developer need to manually ensure all external bindings are available in the new context?thanks for the detailed breakdown. this level of transparency in your documentation is exactly what makes open-source tools trustworthy. 🐯🛠️