DEV Community

Cover image for A New Way to Test Retry Logic Without Refactoring Your JavaScript Code
typescript-guy
typescript-guy

Posted on

A New Way to Test Retry Logic Without Refactoring Your JavaScript Code

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; 
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`); 
}
Enter fullscreen mode Exit fullscreen mode

Output

Attempt 1 failed. Retrying in 0ms...
Attempt 2 failed. No retries left.
Caught error: Network Failure
Enter fullscreen mode Exit fullscreen mode

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 setTimeout entirely, 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:

If you have questions or ideas, feel free to open a discussion on GitHub.

Top comments (5)

Collapse
 
koda2026 profile image
Harun - solo dev •

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 overwrite property for bypassing settimeout delays 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:

  1. performance overhead: when running a large test suite, does the ast parsing and interpretation introduce noticeable latency compared to native execution? are there optimizations in place, like caching the transformed ast between test runs?
  2. closure capture: if the monitored function relies on variables from an outer lexical scope that aren't explicitly passed in the captures object, 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. 🐯🛠️

Collapse
 
typescript-guy profile image
typescript-guy •

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. captures is the most straightforward way to bridge that gap, but embed is another great tool for it. You can check out the full nuances of how they work in the README capturing and embedding section.

Collapse
 
koda2026 profile image
Harun - solo dev •

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 embed feature is a brilliant addition for bridging that lexical scope gap without polluting the captures object.

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. 🐯🛠️

Thread Thread
 
typescript-guy profile image
typescript-guy •

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!

Thread Thread
 
koda2026 profile image
Harun - solo dev •

typescript-guy, that distinction is crystal clear and incredibly valuable. baking the function's source code directly into the interpreted context via embed to 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. 🐯🛠️