“The ticket’s almost done, I just need to write a few tests”
Later . . .
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
- Four ways we might mock a component
- Jest Hoisting is Execution Order Manipulation
- Javascript Hoisting is Reference Assignment Manipulation
- Six Stages of Running a test
- 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", () => {
// ...
});
});
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: () => <></>,
}));
Ex 2: Passing a function declaration
function MockHook(props) {
return <></>
};
jest.mock("ThunderGunExpress", () => ({
useDolphLundgren: MockHook,
}));
Ex 3: Passing a function expression
const MockHook = (props) => {
return <></>
};
jest.mock("ThunderGunExpress", () => ({
useDolphLundgren: MockHook,
}));
Ex 4: Calling a function expression within another function expression
const MockHook = (props) => {
return <></>
};
jest.mock("ThunderGunExpress", () => ({
useDolphLundgren: () => MockHook(),
}));
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?
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.
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
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.
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 theimport
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
andit
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
andit
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: () => <></>,
}));
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,
}));
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,
}));
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 <></>
};
Ex 4: Calling the function expression mock within another function expression
const MockHook = (props) => {
return <></>
};
jest.mock("ThunderGunExpress", () => ({
useDolphLundgren: props => MockHook(props),
}));
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()
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()
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(),
}));
Stage 0: Creation
-
MockHook
isundefined
, -
(props) => MockHook(props)
is unevaluated -
MockHook
is not accessed.
Stage 1: Mocks executes
- Jest moves the execution order of
jest.mock()
above theconst MockHook
initialization to mock theuseDolphLundgren
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
invokesuseDolphLundgren()
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!
Top comments (5)
Jest's hoisting is actually a custom Babel transform: npmjs.com/package/babel-plugin-jes...
Hi, thanks for the article. I was left with a little misunderstanding after reading it:
1) Based on the line:
const ThunderGun = useDolphLundgren();
useDolphLundgren
should look like this:But based on further tests it should be something like:
I will consider the second option
2) I failed to repeat the first example, I tried ts-jest & @swc/jest. In both cases it turned out that react/jsx-runtime containing
_jsxruntime.Fragment
was imported beforejest.mock('./ThunderGunExpress', () => {}
this is how
MoviewPoster.tsx
looks like:this is how my
MoviePoster.test.tsx
looks like:and this is compiled versions by @swc/jest:
At the time of factory execution of
'./ThunderGunExpress',
-_jsxruntime.fragment
is already presented.I create sandbox for this case
I love the explanation of mocks and order of operations. I have struggled with this in the past.
I had assumed example 4 would fail for the same reason as example 1, because it is still using an inline function expression. I think I am misunderstanding why example 1 fails.
Jest prevents you from attempting to use anything imported in your mocks because
mock()
is executed beforeimport
. So the fragment imported from react-native won't exist in example 1 because it gets imported after the inline mock.In the case of example 4, the function expression is evaluated later at a point when the import calls will already have been made.
Thanks, I am so used to the JSX stuff I forgot it is actually coming from an import. I glossed over that in the explanation.