DEV Community

Mateus Cechetto
Mateus Cechetto

Posted on

How to Test Functions That Return Functions in TypeScript with Jest

In one of my recent projects, I had a function that, given an input, generates and returns a new function. While this is not an everyday occurrence, it's crucial to understand how to test such patterns effectively. In this article, we'll explore techniques for testing functions that return functions in TypeScript using Jest.

The Challenge of Function Equality

Initially, I approached testing by comparing the generated function to an expected function:

describe("function generator", () => {
    const generator = (message: string) => {
        return (name: string) => {
            return `${message}, ${name}`;
        };
    };

    it("shoud return the expected function", () => {
        const expected = (name: string) => {
            return `hello, ${name}`;
        };
        expect(generator("hello")).toEqual(expected);
    });
});
Enter fullscreen mode Exit fullscreen mode

This test fails:

    expect(received).toEqual(expected) // deep equality

    Expected: [Function expected]
    Received: [Function anonymous]
Enter fullscreen mode Exit fullscreen mode

The failure occurs because JavaScript functions are objects with unique identities. Even if two functions are structurally identical, they are not considered equal unless they reference the same object. Moreover, generated functions often create closures, capturing variables from their surrounding scope. These closed-over variables, or "lexical environments" add another layer of complexity to testing.

First-Class Functions and Closures

Before we dive deeper, let's review some core concepts. In JavaScript (and by extension, TypeScript), functions are considered first-class objects, meaning they can:

  • Be stored in variables or properties
  • Be passed as arguments to other functions
  • Be returned as the result of another function

This allows for powerful patterns like higher-order functions (functions that return other functions). When a function is generated inside another function, it often forms a closure, meaning the inner function has access to variables defined in its parent scope, even after the parent function has finished executing.

For instance, in the following code:

const generator = (message: string) => {
    return (name: string) => `${message}, ${name}`;
};

const greet = generator("hello");
console.log(greet("world")); // "hello, world"
Enter fullscreen mode Exit fullscreen mode

The function greet closes over the message variable, keeping it alive even after generator has returned.

Different Testing Strategy

To effectively test functions that return other functions, we must focus on the output of the generated function rather than comparing the functions themselves. This approach ensures we're validating behavior, which is the true goal of testing.

When testing a sum function, for instance, you check if the result is as expected: expect(sum(1, 2)).toBe(3). Similarly, when testing a generator function, we must evaluate the returned function's behavior by calling it with various inputs.

Here's how to properly test a function generator:

describe("function generator", () => {
    const generator = (message: string) => {
        return (name: string) => {
            return `${message}, ${name}`;
        };
    };

    it("should handle different messages and names", () => {
        const resulting = generator("hello");
        expect(resulting("world")).toEqual("hello, world");
        expect(resulting("Mateus")).toEqual("hello, Mateus");

        const resulting2 = generator("bye");
        expect(resulting2("world")).toEqual("bye, world");
        expect(resulting2("Mateus")).toEqual("bye, Mateus");
    });
});
Enter fullscreen mode Exit fullscreen mode

By testing the output, we can ensure that the generator function works correctly for different inputs, providing the desired behavior.

Testing More Advanced Scenarios: Closures

Now, let's dive into some more advanced scenarios. When testing generator functions that form closures, you may want to validate that the closed-over variables are handled properly, and that the function behaves as expected even in edge cases.

Consider this function:

const counterGenerator = (start: number) => {
    let counter = start;
    return () => ++counter;
};
Enter fullscreen mode Exit fullscreen mode

Here, the generated function closes over the counter variable. Testing this pattern ensures that each invocation of the returned function correctly updates the counter:

describe("counter generator", () => {
    it("should increment the counter on each call", () => {
        const counter = counterGenerator(10);

        expect(counter()).toBe(11); // First call, counter is incremented to 11
        expect(counter()).toBe(12); // Second call, counter is incremented to 12
        expect(counter()).toBe(13); // Third call, counter is incremented to 13
    });
});
Enter fullscreen mode Exit fullscreen mode

In this case, you're not just testing the output of a single call but also ensuring that the closure works properly by keeping the internal counter variable alive and updating it between function calls.

Conclusion

Testing functions that return functions in TypeScript can seem tricky at first due to the nature of JavaScript's function objects and closures. However, by focusing on testing the behavior of the generated functions—rather than comparing them directly—you can effectively validate their correctness.

To recap:

  • Functions in JavaScript are first-class objects with unique identities, which complicates direct comparison.
  • The solution is to test the output of generated functions by calling them with different inputs.
  • For more complex cases, like closures, ensure that your tests validate how closed-over variables are handled over time.

By following these strategies, you'll be well-equipped to test function generators effectively in your projects.

Top comments (0)