Serious Jest: Making Sense of Hoisting

jordanlos

Jordan Los

Posted on May 24, 2022

Serious Jest: Making Sense of Hoisting

“The ticket’s almost done, I just need to write a few tests”
Later . . .

Image description

Hoisting Isn’t Always Hoisting

Even if you’re using Jest, you may not have thought too much about how Jest actually replaces your module with a mock. At least until you’re mocks don’t work.

Jest creates mocks through a process they refer to as hoisting. If you’ve used Javascript long enough you might assume you know what that means. But something I haven't seen elsewhere hoisting in Jest and hoisting in Javascript isn’t the same thing. In this article, I’ll explain both types of hoisting and walk through what happens each time your run a test.

Upcoming

  1. Four ways we might mock a component
  2. Jest Hoisting is Execution Order Manipulation
  3. Javascript Hoisting is Reference Assignment Manipulation
  4. Six Stages of Running a test
  5. Four ways to mock a component revisited

Four Ways We Might Mock A Component

For the sake of example, we’ll make a component called MoviePoster. Take a look at it below:



// MoviePoster.jsx
import { useDolphLundgren } from "ThunderGunExpress";
export function MoviePoster() {
    const ThunderGun = useDolphLundgren();
    return (
        <PosterContent>
          <ThunderGun />
        </PosterContent>
    )
}

// MoviePoster.test.jsx
import { useDolphLundgren } from "ThunderGunExpress";
describe("HomePage", () => {
    it("Should not hesistate", () => {
      // ... 
    });
});


Enter fullscreen mode Exit fullscreen mode

MoviePoster uses ThunderGunExpress to make an API call through a hook called useDolphLundgren. useDolphLundgren, returns a component we can pass as a child to a PosterContent. But our test suite can’t reproduce the output from the useDolphLundgren hook so we'll need to mock it. Let’s look at some ways in which we might mock that hook.

Jest Best Guess

Let’s look at four examples of how we might mock out useDolphLundgren using Jest’s factory mocks. Take a look at each example, guess whether it will work, and then guess why

Ex 1: Inline mock



jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: () => <></>,
}));


Enter fullscreen mode Exit fullscreen mode

Ex 2: Passing a function declaration



function MockHook(props) {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: MockHook,
}));


Enter fullscreen mode Exit fullscreen mode

Ex 3: Passing a function expression



const MockHook = (props) => {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: MockHook,
}));


Enter fullscreen mode Exit fullscreen mode

Ex 4: Calling a function expression within another function expression



const MockHook = (props) => {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: () => MockHook(),
}));


Enter fullscreen mode Exit fullscreen mode

Jest Best Guess Addressed

Example 1 fails with an error telling you that you can’t rely on that fragment you’ve imported to exist.

Example 2 works because the function definition is hoisted and exists when the useDolphLundgren mock gets created.

Example 3 fails because the MockHook variable is undefined when the mock function is created.

Example 4 works because the function expression calling MockHook() isn’t evaluated until it's invoked and at that time MockHook has been initialized.

And there you have it, Jest mocks demystified. Thanks for reading and remember to . . . oh, it's not clear why those work? Are you the type to get upset when you see a library as an answer to a StackOverflow question?

Image description

Before we can dig into what Jest is doing with these mocks we need to understand a bit about how Jest and Javascript actually run code.

Hoisting in Jest is Execution Order Manipulation

The Jest docs say Jest creates mocks through hoisting. But what Jest is doing is manipulating the code execution order. The Jest testing finds those mock() calls in a file and execute them before any of the import statements. So it doesn’t matter where you put your mock in a test file, Jest will “hoist” it above the import statements.

Image description

Hoisting in Javascript is Reference Assignment Manipulation

So while Jest actually changes the order your code runs, Javascript doesn’t. Hoisting is what the Javascript engine does before executing any code at all. The Javascript engine first runs through your code to create what's called an Execution Context. The process of creating an execution context is beyond the scope of this article, but I’ll review the relevant parts.

Javascript Execution Context Summary

  • The Javascript engine runs your code twice, creating an Execution Context from it and then executing it
  • The Execution Context has an identifier for every variable, function, and class
  • During the creation phase, the Javascript engine only assigns references in memory to function declarations and classes. It leaves everything else undefined
  • Function expressions differ from function declarations because function expressions are (1) undefined until you initialize them and (2) evaluated only when you invoke them

In other words, hoisting in Javascript means some variables get references to objects in memory before code execution happens.

Javascript Execution Context In-Depth

The Execution Context is a stack frame that gets created every time you call a function. During the creation stage, the Javascript engine runs through your code, grabs all the variables, functions, and classes, puts their name at the top of the scope (we'll call these names identifiers) and does things with them. What things the engine does depends on what it's looking at.

If the engine finds a class or function declaration, it takes the definition, adds it to the heap, and then points the identifier to that definition in memory. I.e. each identifier gets a reference to the definition.

If the engine finds a variable, it marks it undefined and moves on. It doesn’t matter if you defined the variable with var, const, or let because the Javascript engine still leaves them undefined.

Function expressions are like variables with an extra step: the function body isn’t evaluated until you invoke the function expression. We’ll cover this in more detail later.

After creating the execution context, the engine does a second pass on your code and evaluates it line by line. Look at the examples below to see how the Execution Context (EC) influences the output of our code:



// Variable isn't declared, so doesn't exist at all in the EC
console.log(needsDeclaration)

// Variable declared, but left undefined in the EC during creation phase
console.log(needsInitialization)
var needsInitialization = 5;

// Function defintion assigned during the creation phase, so you can call it before defining it
hoistedFoo()
function hoistedFoo(){...}

// Function expressions are just like variables, they are undefined until initialized
unhoistedBar()
var unhoistedBar = () => {...}

// Const and let are the same as var, but will raise an error to avoid accessing an undefined variable
console.log(alsoNeedsInitialization)
const alsoNeedsInitialization = 5


Enter fullscreen mode Exit fullscreen mode

To recap, Jest hoists mock() statements by manipulating the execution order, while Javascript hoists memory allocation by manipulating when it assigns references. The tricky part is that both of these ‘hoistings’ happen when you run a test: Javascript will manipulate your references during the creation phase, and then during the execution phase Jest will manipulate the order your code executes in.

Image description

Six Stages of Running A Jest Test

Stage 0: Javascript creation phase runs and sets up the Execution Context

Stage 1: Mocks are executed

  • Jest finds the jest.mock() calls and invokes those functions before the import statements occur.
  • Mocks are created during this stage, but the function expression’s passed to mock() are not yet evaluated.
  • This is happening during the Javascript engine’s execution phase, not its creation phase.

Stage 2: Import Calls are executed

  • Jest works down the component tree of each import statement before moving on to the next.
  • If Jest finds a mock, it will import that instead of the module.
  • The factory expression you passed to mock()gets evaluated here. Whatever is in the Execution Context at this stage is what gets used for that factory expression.

Stage 3: Jest collects the blocks, but doesn't execute them

  • Jest runs through the describe and it blocks in your test, but it doesn’t evaluate the function expressions you passed in.

Stage 4: Javascript executes the rest of the file

  • Whatever is defined below the describe and it blocks gets evaluated. This means the Execution Context your tests run in can be updated after the tests are written

Stage 5: Jest runs the tests

Revisiting the Examples

Now that we've covered beginning to end what happens when we run a test file, let's revisit the original examples. I’ll include the code again to save you from going back up:

Ex 1: Inline mock



jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: () => <></>,
}));


Enter fullscreen mode Exit fullscreen mode

This code raises an error because the mock will run during Stage 1 but the fragment would be imported during Stage 2. So Jest raises an error to remind you that the fragment may not exist.

Ex 2: Passing a function declaration



function MockHook(props) {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: MockHook,
}));


Enter fullscreen mode Exit fullscreen mode

This works because the function definition for MockHook gets added to memory and referenced to the MockHook identifier during Stage 0. When the mock gets created in Stage 1, Jest replaces the module with the function definition referenced by MockHook.

Ex 3: Passing a function expression



const MockHook = (props) => {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: MockHook,
}));


Enter fullscreen mode Exit fullscreen mode

This fails because Jest is hoisting the call to mock and so MockHook gets moved into the Temporal Dead Zone. The code actually executes like this:



jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: MockHook,
}));

const MockHook = (props) => {
    return <></>
};


Enter fullscreen mode Exit fullscreen mode

Ex 4: Calling the function expression mock within another function expression



const MockHook = (props) => {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: props => MockHook(props),
}));


Enter fullscreen mode Exit fullscreen mode

Line 5 looks like it should be identical to the same line in Example 3. But there’s a difference in when the variable MockHook is accessed. The timing for when a variable is accessed is subtle enough that it deserves some more attention.

Variable Access Timing

Let’s take Jest out of the equation and dig a little deeper into how function expressions get evaluated.



// needsInitializing is in the Temporal Deadzone
const foo = {
  bar: needsInitializing
}
const needsInitializing = () => console.log("initialized")
foo.bar()


Enter fullscreen mode Exit fullscreen mode

The Javascript engine checks the value of needsInitializing when attempting to assign it to bar during the creation phase. But that means needsInitializing is accessed before it is defined. Compare this to the example below:



// The expression at line 3 isn't evaluated until line 6
const foo = {
  bar: () => needsInitializing()
}
const needsInitializing = () => console.log("initialized")
foo.bar()


Enter fullscreen mode Exit fullscreen mode

This time, the Javascript engine accesses needsInitializing during the execution phase when foo.bar() is invoked. The difference is that during the creation phase Javascript engine doesn’t attempt to evaluate the function expression in line 2 and, so, never tries to access the needsInitializing variable.

Ex 4: One Last Time



// The expression at line 6 isn't evaluated until `useDolphLundgren` is called
const MockHook = () => {
    return <></>
};
jest.mock("ThunderGunExpress", () => ({
    useDolphLundgren: () => MockHook(),
}));


Enter fullscreen mode Exit fullscreen mode

Stage 0: Creation

  • MockHook is undefined,
  • (props) => MockHook(props) is unevaluated
  • MockHook is not accessed.

Stage 1: Mocks executes

  • Jest moves the execution order of jest.mock() above the const MockHook initialization to mock the useDolphLundgren module.

Stage 2: Import calls executed

Stage 3: Blocks collected

Stage 4: Rest of test file executed

  • MockHook is initialized

Stage 5: Tests Run

  • MoviePoster invokes useDolphLundgren() while rendering
  • Jest gets the mock component from useDolphLundgren
  • () => MockHook() gets evaluated
  • MockHook was defined in Stage 4 so the mock gets added.

Stage 6: Success

  • Use your Javascript skills to impress family and friends

Conclusion

Oftentimes we hit difficulties coding because we make unspoken and inaccurate assumptions. Its easy to assume that Jest and Javascript mean the same thing when they talk about hoisting. Jest, however, uses hoisting to mean manipulating the code execution order and Javascript uses hoisting to mean manipulating when objects in memory get references. I haven't covered how I tested those assumptions though. My first draft did include the steps I used to tease out the information here, but the length grew out of hand. I'll include in a follow up article the process I used to figure out the information here.

About Jobber

We're hiring for remote positions across Canada at all software engineering levels!

Our awesome Jobber technology teams span across Payments, Infrastructure, AI/ML, Business Workflows & Communications. We work on cutting edge & modern tech stacks using React, React Native, Ruby on Rails, & GraphQL.

If you want to be a part of a collaborative work culture, help small home service businesses scale and create a positive impact on our communities, then visit our careers site to learn more!

💖 💪 🙅 🚩
jordanlos
Jordan Los

Posted on May 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related