This article is mainly to give you an in-depth understanding of the operation principle behind Jest, convenient to deal with the interview and actual business needs, Jest writing test BELIEVE that we are already familiar with, but Jest is how we may be very strange, that let us together into the heart of Jest, explore together.
First attached code to the need of the students, welcome to pay attention to: github.com/wscats/jest…
What is a Jest
Jest is a Javascript testing framework developed by Facebook for creating, running, and writing Javascript libraries for tests.
Jest is released as an NPM package and can be installed and run in any JavaScript project. Jest is one of the most popular front-end test libraries.
What testing means
In technical terms, testing means checking that our code meets certain expectations. For example, a function called sum should return the expected output for a given number of operations.
There are many types of tests, and you’ll soon be drowning in jargon, but tests to cut a long story short fall into three broad categories:
- Unit testing
- Integration testing
- E2E test
How do I know what to test for
On the testing side, even the simplest blocks of code can confuse even beginners. The most common question is “How do I know what to test?” .
If you’re writing a Web page, a good starting point is to test every page and every user interaction of your application. But the web page also needs to test functions and modules and other code units.
Most of the time there are two situations:
- You inherit legacy code that comes with no tests
- You have to implement a new feature out of thin air
So what to do? In both cases, you can check whether the function produces the desired result by thinking of the test as: The most typical test flow is as follows:
- Import the function to test
- Give the function an input
- Define the desired output
- Check that the function produces the expected output
Just so-so, simple as that. Writing tests is no longer scary with these core ideas:
Input -> expected output -> assertion result.
Test blocks, assertions, and matchers
We’ll create a simple Javascript function code for the addition of two numbers and write the corresponding Jest based test for it
const sum = (a, b) = > a + b;
Copy the code
Now, to test, create a test file in the same folder named test.spec.js. This special suffix is Jest’s convention for finding all the test files. We will also import the function under test to execute the code under test. Jest tests follow BDD style tests, each test should have one main test block, and can have multiple test blocks, now you can write a test block for the sum method, here we write a test to add two numbers and verify the expected results. We will supply numbers 1 and 2 and expect output 3.
Test takes two arguments: a string to describe the test block and a callback function to wrap the actual test. Expect wraps the target function and, in combination with the matcher toBe, checks that the function evaluates as expected.
Here’s the full test:
test("sum test".() = > {
expect(sum(1.2)).toBe(3);
});
Copy the code
When we look at the code above, we find two things:
test
A block is a separate test block that has the function of describing and scoping, i.e. it represents the function we want to calculate forsum
A generic container for the tests you write.expect
Is an assertion that calls the function under test with inputs 1 and 2sum
Method, and expects output 3.toBe
Is a matcher that checks the expected value and should throw an exception if the expected result is not met.
How to implement test blocks
The simplest implementation of the test block is as follows: we need to store the callback function that wraps the actual test, so encapsulate a dispatch method to receive the command type and callback function:
const test = (name, fn) = > {
dispatch({ type: "ADD_TEST", fn, name });
};
Copy the code
We need to create a state callback globally that holds the test, and the test callback is stored using an array.
global["STATE_SYMBOL"] = {
testBlock: [],};Copy the code
The dispatch method at this point simply identifies the corresponding command and stores the tested callback into the global state.
const dispatch = (event) = > {
const { fn, type, name } = event;
switch (type) {
case "ADD_TEST":
const { testBlock } = global["STATE_SYMBOL"];
testBlock.push({ fn, name });
break; }};Copy the code
How do you implement assertions and matchers
The assertion library is also simple to implement, simply encapsulating a function that exposes the matcher method to the following formula:
expect(A).toBe(B)
Here we implement the commonly used toBe method, which throws an error when the result is not the same as expected:
const expect = (actual) = > ({
toBe(expected) {
if(actual ! == expected) {throw new Error(`${actual} is not equal to ${expected}`); }}};Copy the code
In fact, try/catch is used in the test block to catch errors and print stack information to locate the problem.
In simple cases, you can also use Node’s built-in Assert module to assert, but there are many more complex assertion methods that are essentially the same.
CLI and configuration
After writing the test, we need to run the single test by typing a command on the command line. Normally, the command looks like this:
node jest xxx.spec.js
This is essentially parsing the command line arguments.
const testPath = process.argv.slice(2) [0];
const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString();
Copy the code
Complex cases may also require reading the parameters of the local Jest configuration file to change the execution environment, where Jest uses third-party libraries such as Yargs execa and Chalk to parse and print commands.
simulation
In a complex test scenario, there is one Jest term we can’t get around: mock.
In the Jest documentation, we can find the following description of Jest for emulation: “Emulation functions make it easy to link between test code by erasing the actual implementation of functions, capturing calls to functions, and arguments passed in those calls.”
In short, you can create a simulation by assigning the following snippet of code to a function or dependency:
jest.mock("fs", {
readFile: jest.fn(() = > "wscats")});Copy the code
This is an example of a simple simulation that simulates the fs module readFile function testing the return value of a particular business logic.
How do you simulate a function
Mock takes a module name or module path as its first argument, and an implementation of the exposed method as its second argument
const jest = {
mock(mockPath, mockExports = {}) {
const path = require.resolve(mockPath, { paths: ["."]});require.cache[path] = {
id: path,
filename: path,
loaded: true.exports: mockExports, }; }};Copy the code
In fact, our plan is the same as the implementation of the test block mentioned above. We just need to find a place to save the specific implementation method and replace it later when we actually use the modified module. Therefore, we store it in require.cache, and of course, we can also store it in the global state.
The implementation of jest. Fn is also easy. Here we use a closure mockFn to store the replacement functions and parameters for subsequent tests to check and count the call data.
const jest = {
fn(impl = () => {}) {
const mockFn = (. args) = > {
mockFn.mock.calls.push(args);
returnimpl(... args); }; mockFn.originImpl = impl; mockFn.mock = {calls: []};returnmockFn; }};Copy the code
The execution environment
Some of you may have noticed that there is no need to manually import test, Expect, and Jest in the testing framework. Each test file can be used directly, so we need to create an environment to inject these methods.
V8 Virtual Machines and scopes
Now that everything is ready to go, all we need to do is inject the methods needed for testing into the V8 virtual machine, namely inject the test scope.
const context = {
console: console.Console({ stdout: process.stdout, stderr: process.stderr }),
jest,
expect,
require.test: (name, fn) = > dispatch({ type: "ADD_TEST", fn, name }),
};
Copy the code
Once the scope is injected, we can make the code for the test file run in the V8 virtual machine. Here I pass in the code that has already been processed as a string. Jest does some code manipulation here, security handling, and SourceMap stitching, so our example doesn’t need to be complicated.
vm.runInContext(code, context);
Copy the code
Before and after the code execution, the time difference can be used to calculate the running time of the single test. Jest will also pre-evaluate the size and quantity of the single test file and decide whether to enable Worker to optimize the execution speed
const start = new Date(a);const end = new Date(a); log("\x1b[32m%s\x1b[0m".`Time: ${end - start}ms`);
Copy the code
Run the single-test callback
After the V8 virtual machine is executed, the global state will collect all the wrapped test callback functions in the test block, and we will just need to iterate over all these callback functions and execute.
testBlock.forEach(async (item) => {
const { fn, name } = item;
try {
await fn.apply(this);
log("\x1b[32m%s\x1b[0m".`)${name} passed`);
} catch {
log("\x1b[32m%s\x1b[0m".`...${name} error`); }});Copy the code
Hook function
We can also add life cycles to single test execution, such as beforeEach, afterEach, afterAll, and beforeAll hook functions.
BeforeEach test is executed before the testBlock iterates the test function, afterEach is executed after the testBlock iterates the test function, afterEach is executed after the testBlock iterates the test function. Very simple, just need to be in the right position to expose the hook function at any time.
testBlock.forEach(async (item) => {
const { fn, name } = item;
+beforeEachBlock.forEach(async (beforeEach) => await beforeEach());
await fn.apply(this);
+afterEachBlock.forEach(async (afterEach) => await afterEach());
});
Copy the code
BeforeAll and afterAll can be placed before and afterAll testBlock tests have been run.
beforeAllBlock.forEach(async (beforeAll) => await beforeAll());
testBlock.forEach(async (item) => {}) +
afterAllBlock.forEach(async (afterAll) => await afterAll());
Copy the code
At this point, we have implemented a simple testing framework on which to enrich assertion methods, matchers, and support parameter configurations. Attached is a personal note reading the source code.
jest-cli
Download Jest source code and execute it in the root directory
yarn
npm run build
Copy the code
It essentially runs the build.js and buildts.js files in the script folder:
"scripts": {
"build": "yarn build:js && yarn build:ts"."build:js": "node ./scripts/build.js"."build:ts": "node ./scripts/buildTs.js",}Copy the code
Build. js essentially uses the Babel library, creates a build folder in the package/ XXX package, and generates the file into the build folder using transformFileSync:
const transformed = babel.transformFileSync(file, options).code;
Copy the code
Buildts.js essentially uses the TSC command to compile ts files into the build folder and use the execa library to execute the command:
const args = ["tsc"."-b". packagesWithTs, ... process.argv.slice(2)];
await execa("yarn", args, { stdio: "inherit" });
Copy the code
This will help you to compile all js and TS files in the packages folder into the build folder:
Next we can launch the jest command:
npm run jest
# is equivalent to
# node ./packages/jest-cli/bin/jest.js
Copy the code
Parses can be done based on the different arguments passed in, for example:
npm run jest -h
node ./packages/jest-cli/bin/jest.js /path/test.spec.js
Copy the code
The jest. Js file is executed and the run method in the build/ CLI file is entered. The run method parses various parameters in the command
const importLocal = require("import-local");
if(! importLocal(__filename)) {if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "test";
}
require(".. /build/cli").run();
}
Copy the code
jest-config
When the various command parameters are retrieved, the methods of the runCLI core are executed, which are the core methods of the @jest/core -> Packages /jest-core/ SRC /cli/index.ts library.
import { runCLI } from "@jest/core";
const outputStream = argv.json || argv.useStderr ? process.stderr : process.stdout;
const { results, globalConfig } = await runCLI(argv, projects);
Copy the code
The runCLI method uses argv parsed in the preceding command to read configuration file information in conjunction with readConfigs, which comes from Packages/jst-config/SRC /index.ts. Normalize fills and initializes some of the default configured parameters. Its default parameters are recorded in packages/jest-config/ SRC/defaults. ts files, for example: Resolve (‘jest-runner’) is set to the runner running the single test by default, and outputStream output is generated to the console in conjunction with the Chalk library.
Resolve (moduleName) : resolve(moduleName) : resolve(moduleName) : resolve(moduleName) : Then use the tool library packages/jest – util/SRC/requireOrImportModule ts requireOrImportModule method invocation encapsulation good native import/reqiure Method to retrieve the module along with the path in the configuration file.
- GlobalConfig comes from the configuration of argv
- Configs comes from the configuration of jest.config.js
const { globalConfig, configs, hasDeprecationWarnings } = await readConfigs(
argv,
projects
);
if (argv.debug) {
/*code*/
}
if (argv.showConfig) {
/*code*/
}
if (argv.clearCache) {
/*code*/
}
if (argv.selectProjects) {
/*code*/
}
Copy the code
jest-haste-map
Jest-haste – Map is used to get all the files in the project and the dependencies between them, which it does by looking at the import/require call, extracting them from each file and building a map that contains each file and its dependencies, Haste here is the modular system that Facebook uses, and it also has something called HasteContext because it has HastFS (Haste File System), which is just a list of files in the system and all the dependencies associated with them, it’s a map data structure, Where keys are paths and values are metadata, the Contexts generated here will be used until the onRunComplete stage.
const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps(
configs,
globalConfig,
outputStream
);
Copy the code
jest-runner
_run10000
Method based on configuration informationglobalConfig
和 configs
To obtaincontexts
.contexts
It stores configuration information, paths, etc., for each local file, followed by a callback functiononComplete
, global configurationglobalConfig
And scopecontexts
Enter therunWithoutWatch
Methods.
The runJest method of the Packages /jest-core/ SRC /runJest. Ts file will be used to iterate through all unit tests with the passed Contexts and store them in an array.
let allTests: Array<Test> = [];
contexts.map(async (context, index) => {
const searchSource = searchSources[index];
const matches = await getTestPaths(
globalConfig,
searchSource,
outputStream,
changedFilesPromise && (await changedFilesPromise),
jestHooks,
filter
);
allTests = allTests.concat(matches.tests);
return { context, matches };
});
Copy the code
Sequencer method was used to rank the individual tests
const Sequencer: typeof TestSequencer = await requireOrImportModule(
globalConfig.testSequencer
);
const sequencer = new Sequencer();
allTests = await sequencer.sort(allTests);
Copy the code
RunJest method will be called a key method of packages/jest – core/SRC/TestScheduler ts scheduleTests method.
const results = await new TestScheduler(
globalConfig,
{ startRun },
testSchedulerContext
).scheduleTests(allTests, testWatcher);
Copy the code
The scheduleTests method does a lot of things, collecting Contexts from allTests into Contexts, duration into the Timings array, and subscribing to four life cycles before performing all single tests:
- test-file-start
- test-file-success
- test-file-failure
- test-case-result
The Context can then be traversed and stored with a new empty testRunners object, which will call the createScriptTransformer method provided by @jest/ Transform to handle the imported modules.
import { createScriptTransformer } from "@jest/transform";
const transformer = await createScriptTransformer(config);
const Runner: typeof TestRunner = interopRequireDefault(
transformer.requireAndTranspileModule(config.runner)
).default;
const runner = new Runner(this._globalConfig, {
changedFiles: this._context? .changedFiles,sourcesRelatedToTestsInChangedFiles: this._context? .sourcesRelatedToTestsInChangedFiles, }); testRunners[config.runner] = runner;Copy the code
The scheduleTests method calls the runTests methods of Packages /jest-runner/ SRC /index.ts.
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
return await (options.serial
? this._createInBandTestRun(tests, watcher, onStart, onResult, onFailure)
: this._createParallelTestRun(
tests,
watcher,
onStart,
onResult,
onFailure
));
}
Copy the code
In the _createParallelTestRun or _createInBandTestRun method:
_createParallelTestRun
There will be a runTestInWorker method, which, as the name implies, performs a single test inside the worker.
_createInBandTestRun
It will executepackages/jest-runner/src/runTest.ts
A core methodrunTest
And therunJest
There’s a method that executesrunTestInternal
There is a lot of preparation before single test, involving global method overwriting and hijacking of import and export methods.
await this.eventEmitter.emit("test-file-start", [test]);
return runTest(
test.path,
this._globalConfig,
test.context.config,
test.context.resolver,
this._context,
sendMessageToJest
);
Copy the code
In the runTestInternal method, the fs module is used to read the contents of a file in cacheFS to make it easier to read later. For example, if the contents of a file are JSON, they can be read directly in cacheFS. Date.now is also used to calculate the time difference.
const testSource = fs().readFileSync(path, "utf8");
const cacheFS = new Map([[path, testSource]]);
Copy the code
The runTestInternal method introduces Packages/jest-Runtime/SRC /index.ts, which will help you cache and read modules and trigger execution.
const runtime = new Runtime(
config,
environment,
resolver,
transformer,
cacheFS,
{
changedFiles: context? .changedFiles,collectCoverage: globalConfig.collectCoverage,
collectCoverageFrom: globalConfig.collectCoverageFrom,
collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom,
coverageProvider: globalConfig.coverageProvider,
sourcesRelatedToTestsInChangedFiles: context? .sourcesRelatedToTestsInChangedFiles, }, path );Copy the code
jest-environment-node
The @jest/console package is used to rewrite the global console. In order to test the file code block console can be successfully printed on the Node terminal, with the jest-environment-Node package. Rewrite all the global environment.global to facilitate the subsequent access to these scopes in VM, which is essentially the scope provided for the VM operating environment to facilitate the subsequent injection of global. The global methods involved in rewriting are as follows:
- global.global
- global.clearInterval
- global.clearTimeout
- global.setInterval
- global.setTimeout
- global.Buffer
- global.setImmediate
- global.clearImmediate
- global.Uint8Array
- global.TextEncoder
- global.TextDecoder
- global.queueMicrotask
- global.AbortController
// This is essentially a console override for Node to override console methods in the VM scope
testConsole = new BufferedConsole();
const environment = new TestEnvironment(config, {
console: testConsole, // Suspected useless code
docblockPragmas,
testPath: path,
});
// Actually rewrite the console method
setGlobal(environment.global, "console", testConsole);
Copy the code
Check whether the module is an ESM module. If yes, use Runtime. unstable_importModule to load the module and run it. You load the module and run it with runtime.requiremodule.
const esm = runtime.unstable_shouldLoadAsEsm(path);
if (esm) {
await runtime.unstable_importModule(path);
} else {
runtime.requireModule(path);
}
Copy the code
jest-circus
The testFramework in runTestInternal then takes the incoming Runtime and calls the single test file to run, TestFramework methods come from a library with an interesting name: Packages /jest-circus/ SRC /legacy-code-todo-rewrite/ jestAdapt.ts Legacy-code-todo-rewrite (” legacy code to-do rewrite “) circus (” jest-circus “) circus (” legacy code to-do rewrite “) circus (” legacy code To-do rewrite “) circus (” Legacy code To-do rewrite “) circus (” Legacy code To-do rewrite “) circus (” Legacy code To-do rewrite “)
- afterAll
- afterEach
- beforeAll
- beforeEach
- describe
- it
- test
In this case, the xxx.spec.js file is loaded in the jestAdapter function before the single test is called. BeforeEach is overridden. If resetModules, clearMocks, resetMocks are configured, RestoreMocks and setupFilesAfterEnv execute the following methods, respectively:
- runtime.resetModules
- runtime.clearAllMocks
- runtime.resetAllMocks
- runtime.restoreAllMocks
- The runtime. RequireModule or runtime. Unstable_importModule
After initialize, initialize overwrites global describe and test methods in /packages/jest/circus/ SRC /index.ts, because initialize overwrites global describe and test methods in /packages/jest/circus/ SRC /index.ts. The test method has a dispatchSync method. This is a key method. It maintains a state globally. DispatchSync uses name in conjunction with the eventHandler method to change state, much like redux data flows.
const test: Global.It = () = > {
return (test = (testName, fn, timeout) = > (testName, mode, fn, testFn, timeout) = > {
return dispatchSync({
asyncError,
fn,
mode,
name: "add_test",
testName,
timeout,
});
});
};
Copy the code
The single test xxx.spec.js (testPath) file will be introduced and executed after initialize. Note that the single test will be executed after initialize. The single test xxx.spec.js file will contain test and describe code blocks according to the specification. So at this point all callback functions accepted by test and describe are stored in the global state.
const esm = runtime.unstable_shouldLoadAsEsm(testPath);
if (esm) {
await runtime.unstable_importModule(testPath);
} else {
runtime.requireModule(testPath);
}
Copy the code
jest-runtime
If it is an ESM module, import it using unstable_importModule; otherwise, import it using requireModule.
this._loadModule(localModule, from, moduleName, modulePath, options, moduleRegistry);
Copy the code
The logic of _loadModule has only three main parts
- To determine whether the file is a JSON suffix, run readFile to read the text, and output the content using transformJson and json. parse.
- To determine whether or not the node file is suffixed, execute the require native method to import modules.
- For files that do not meet the above two criteria, execute _execModule to execute the module.
Execmodule uses Babel to transform source code read by FS. This transformFile is a package/jest-Runtime/SRC /index.ts transform method.
const transformedCode = this.transformFile(filename, options);
Copy the code
The createScriptFromCode method in execModule calls node’s native VM module to actually execute JS. The VM module takes secure source code and uses the V8 virtual machine with the context passed in to execute the code immediately or to execute the code latently. Here can accept different scope to perform the same code to evaluate the different results, the use of very appropriate testing framework, the injection vmContext here is to rewrite the global scope above contains afterAll, afterEach, beforeAll, beforeEach, Describe, it, test, so our single test code gets these methods with injection scope at run time.
const vm = require("vm");
const script = new vm().Script(scriptSourceCode, option);
const filename = module.filename;
const vmContext = this._environment.getVmContext();
script.runInContext(vmContext, {
filename,
});
Copy the code
After overwriting the global method and saving the state above, we enter the logic that actually performs the describe callback function. In the Run method of Packages /jest-circus/ SRC /run.ts, Here we use the getState method to extract the describe code block, then use _runTestsForDescribeBlock to execute this function, and then enter the _runTest method. The preceding and following hook functions are then executed using _callCircusHook and _callCircusTest.
const run = async() :Promise<Circus.RunResult> => {
const { rootDescribeBlock } = getState();
await dispatch({ name: "run_start" });
await _runTestsForDescribeBlock(rootDescribeBlock);
await dispatch({ name: "run_finish" });
return makeRunResult(getState().rootDescribeBlock, getState().unhandledErrors);
};
const _runTest = async (test, parentSkipped) => {
// beforeEach
// Test function block, testContext scope
await _callCircusTest(test, testContext);
// afterEach
};
Copy the code
This is the core of hook function implementation, and a core element of Jest functionality.
The last
Attach the code to the students who need it, welcome to pay attention to: github.com/wscats/jest…
I hope this article can help you understand the core implementation and principle of Jest testing framework, thank you for your patience to read, if the article and notes can bring you a bit of help or inspiration, please do not spare your Star and Fork, the article continues to update synchronously, your affirmation is my biggest power forward 😁