├── README.EN.md ├── README.md ├── jest.js ├── package.json └── test.spec.js /README.EN.md: -------------------------------------------------------------------------------- 1 | [English](https://github.com/Wscats/jest-tutorial/blob/vm/README.EN.md) | [中文](https://github.com/Wscats/jest-tutorial/blob/vm/README.md) 2 | 3 | # Explain the implementation principle of the Jest framework in a simple way 4 | 5 | This article mainly provides you with an in-depth understanding of the operating principles behind Jest, which is convenient for responding to interviews and actual business needs. I believe we are already familiar with the preparation of Jest, but we may be very unfamiliar with how Jest works. Let us walk into Jest together. Inwardly, explore together. First attach the code to students in need, welcome to pay attention: [https://github.com/Wscats/jest-tutorial](https://github.com/Wscats/jest-tutorial/blob/vm/README.EN.md) 6 | 7 | # What is Jest 8 | 9 | Jest is a Javascript testing framework developed by Facebook. It is a JavaScript library for creating, running and writing tests. 10 | 11 | Jest is released as an NPM package and can be installed and run in any JavaScript project. Jest is currently one of the most popular test libraries for the front-end. 12 | 13 | # What does testing mean 14 | 15 | In technical terms, testing means checking whether our code meets certain expectations. For example: a function called sum (`sum`) should return the expected output given some operation result. 16 | 17 | There are many types of tests, and you will soon be overwhelmed by the terminology, but the long story short tests fall into three categories: 18 | 19 | - unit test 20 | - Integration Testing 21 | - E2E test 22 | 23 | # How do I know what to test 24 | 25 | In terms of testing, even the simplest code block may confuse beginners. The most common question is "how do I know what to test?". 26 | 27 | If you are writing a web page, a good starting point is to test every page of the application and every user interaction. However, the web page also needs to be composed of code units such as functions and modules to be tested. 28 | 29 | There are two situations most of the time: 30 | 31 | - You inherit the legacy code, which has no built-in tests 32 | - You must implement a new feature out of thin air 33 | 34 | so what should I do now? In both cases, you can think of the test as: checking whether the function produces the expected result. The most typical test process is as follows: 35 | 36 | - Import the function to be tested 37 | - Give the function an input 38 | - Define the desired output 39 | - Check if the function produces the expected output 40 | 41 | Generally, it's that simple. Master the following core ideas, writing tests will no longer be scary: 42 | 43 | > Input -> Expected output -> Assertion result. 44 | 45 | # Test blocks, assertions and matchers 46 | 47 | We will create a simple Javascript function code for the addition of 2 numbers and write a corresponding Jest-based test for it 48 | 49 | ```js 50 | const sum = (a, b) => a + b; 51 | ``` 52 | 53 | Now, for testing, create a test file in the same folder and name it `test.spec.js`. This special suffix is ​​a Jest convention and is used to find all test files. We will also import the function under test in order to execute the code under test. Jest tests follow the BDD style of tests. Each test should have a main `test` test block, and there can be multiple test blocks. Now you can write test blocks for the `sum` method. Here we write a test to add 2 Number and verify the expected result. We will provide the numbers 1 and 2, and expect 3 to be output. 54 | 55 | `test` It requires two parameters: a string to describe the test block, and a callback function to wrap the actual test. `expect` wraps the objective function and combines it with the matcher `toBe` to check whether the calculation result of the function meets expectations. 56 | 57 | This is the complete test: 58 | 59 | ```js 60 | test("sum test", () => { 61 | expect(sum(1, 2)).toBe(3); 62 | }); 63 | ``` 64 | 65 | We observe the above code and find two points: 66 | 67 | The `test` block is a separate test block, which has the function of describing and dividing the scope, that is, it represents a general container for the test we want to write for the calculation function `sum`. -`expect` is an assertion. This statement uses inputs 1 and 2 to call the `sum` method in the function under test, and expects an output of 3. -`toBe` is a matcher, used to check the expected value, if the expected result is not met, an exception should be thrown. 68 | 69 | ## How to implement a test block 70 | 71 | The test block is actually not complicated. The simplest implementation is as follows. We need to store the callback function of the actual test of the test package, so we encapsulate a `dispatch` method to receive the command type and the callback function: 72 | 73 | ```js 74 | const test = (name, fn) => { 75 | dispatch({ type: "ADD_TEST", fn, name }); 76 | }; 77 | ``` 78 | 79 | We need to create a callback function called `state` globally to save the test. The callback function of the test is stored in an array. 80 | 81 | ```js 82 | global["STATE_SYMBOL"] = { 83 | testBlock: [], 84 | }; 85 | ``` 86 | 87 | The `dispatch` method only needs to identify the corresponding commands at this time, and store the test callback function in the global `state`. 88 | 89 | ```js 90 | const dispatch = (event) => { 91 | const { fn, type, name } = event; 92 | switch (type) { 93 | case "ADD_TEST": 94 | const { testBlock } = global["STATE_SYMBOL"]; 95 | testBlock.push({ fn, name }); 96 | break; 97 | } 98 | }; 99 | ``` 100 | 101 | ## How to implement assertions and matchers 102 | 103 | The assertion library is also very simple to implement. You only need to encapsulate a function to expose the matcher method to satisfy the following formula: 104 | 105 | > `expect(A).toBe(B)` 106 | 107 | Here we implement the commonly used method `toBe`, when the result is not equal to the expectation, just throw an error: 108 | 109 | ```js 110 | const expect = (actual) => ({ 111 | toBe(expected) { 112 | if (actual !== expected) { 113 | throw new Error(`${actual} is not equal to ${expected}`); 114 | } 115 | } 116 | }; 117 | ``` 118 | 119 | Actually, try/catch is used in the test block to catch errors and print stack information to locate the problem. 120 | 121 | In simple cases, we can also use the `assert` module that comes with Node to make assertions. Of course, there are many more complex assertion methods, and the principles are similar in essence. 122 | 123 | ## CLI and configuration 124 | 125 | After writing the test, we need to enter the command in the command line to run the single test. Normally, the command is similar to the following: 126 | 127 | > `node jest xxx.spec.js` 128 | 129 | The essence here is to parse the parameters of the command line. 130 | 131 | ```js 132 | const testPath = process.argv.slice(2)[0]; 133 | const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString(); 134 | ``` 135 | 136 | In complex situations, you may also need to read the parameters of the local Jest configuration file to change the execution environment, etc. Here, Jest uses third-party libraries `yargs`, `execa` and `chalk`, etc. to parse, execute and print commands. 137 | 138 | # Simulation 139 | 140 | In complex test scenarios, we must not avoid a Jest term: mock (`mock`) 141 | 142 | In the Jest documentation, we can find that Jest has the following description of simulation: "The simulation function erases the actual implementation of the function, captures the call to the function, and the parameters passed in these calls, so that the link between the test codes becomes easy" 143 | 144 | In short, a simulation can be created by assigning the following code snippets to functions or dependencies: 145 | 146 | ```js 147 | jest.mock("fs", { 148 | readFile: jest.fn(() => "wscats"), 149 | }); 150 | ``` 151 | 152 | This is a simple simulation example that simulates the return value of the readFile function of the fs module in testing specific business logic. 153 | 154 | ## How to simulate a function 155 | 156 | Next, we will study how to implement it. The first is `jest.mock`. Its first parameter accepts the module name or module path, and the second parameter is the specific implementation of the module’s external exposure method. 157 | 158 | ```js 159 | const jest = { 160 | mock(mockPath, mockExports = {}) { 161 | const path = require.resolve(mockPath, { paths: ["."] }); 162 | require.cache[path] = { 163 | id: path, 164 | filename: path, 165 | loaded: true, 166 | exports: mockExports, 167 | }; 168 | }, 169 | }; 170 | ``` 171 | 172 | Our solution is actually the same as the implementation of the above `test` test block. You only need to find a place to save the specific implementation method, and replace it when the module is actually used later, so we save it in `require In .cache`, of course we can also store it in the global `state`. 173 | 174 | The implementation of `jest.fn` is not difficult. Here we use a closure `mockFn` to store the replaced functions and parameters, which is convenient for subsequent test inspections and statistics of call data. 175 | 176 | ```js 177 | const jest = { 178 | fn(impl = () => {}) { 179 | const mockFn = (...args) => { 180 | mockFn.mock.calls.push(args); 181 | return impl(...args); 182 | }; 183 | mockFn.originImpl = impl; 184 | mockFn.mock = { calls: [] }; 185 | return mockFn; 186 | }, 187 | }; 188 | ``` 189 | 190 | # Execution environment 191 | 192 | Some students may have noticed that in the testing framework, we don’t need to manually introduce the functions of `test`, `expect` and `jest`. Each test file can be used directly, so we need to create a run that injects these methods here. surroundings. 193 | 194 | ## V8 virtual machine and scope 195 | 196 | Since everything is ready, we only need to inject the methods required for testing into the V8 virtual machine, that is, inject the testing scope. 197 | 198 | ```js 199 | const context = { 200 | console: console.Console({ stdout: process.stdout, stderr: process.stderr }), 201 | jest, 202 | expect, 203 | require, 204 | test: (name, fn) => dispatch({ type: "ADD_TEST", fn, name }), 205 | }; 206 | ``` 207 | 208 | After injecting the scope, we can make the code of the test file run in the V8 virtual machine. The code I passed here is the code that has been processed into a string. Jest will do some code processing, security processing and SourceMap here. For sewing and other operations, our example does not need to be so complicated. 209 | 210 | ```js 211 | vm.runInContext(code, context); 212 | ``` 213 | 214 | Before and after the code is executed, the time difference can be used to calculate the running time of a single test. Jest will also pre-evaluate the size and number of single test files here, and decide whether to enable Worker to optimize the execution speed. 215 | 216 | ```js 217 | const start = new Date(); 218 | const end = new Date(); 219 | log("\x1b[32m%s\x1b[0m", `Time: ${end - start}ms`); 220 | ``` 221 | 222 | ## Run single test callback 223 | 224 | After the execution of the V8 virtual machine is completed, the global `state` will collect all the packaged test callback functions in the test block. Finally, we only need to traverse all these callback functions and execute them. 225 | 226 | ```js 227 | testBlock.forEach(async (item) => { 228 | const { fn, name } = item; 229 | try { 230 | await fn.apply(this); 231 | log("\x1b[32m%s\x1b[0m", `√ ${name} passed`); 232 | } catch { 233 | log("\x1b[32m%s\x1b[0m", `× ${name} error`); 234 | } 235 | }); 236 | ``` 237 | 238 | ## Hook function 239 | 240 | We can also add life cycles to the single test execution process, such as hook functions such as `beforeEach`, `afterEach`, `afterAll` and `beforeAll`. 241 | 242 | Adding the hook function to the above infrastructure is actually injecting the corresponding callback function in each process of executing the test. For example, `beforeEach` is placed before the traversal execution test function of `testBlock`, and `afterEach` is placed on `testBlock` After traversing the execution of the test function, it is very simple. You only need to put the right position to expose the hook function of any period. 243 | 244 | ```js 245 | testBlock.forEach(async (item) => { 246 | const { fn, name } = item; 247 | beforeEachBlock.forEach(async (beforeEach) => await beforeEach()); 248 | await fn.apply(this); 249 | afterEachBlock.forEach(async (afterEach) => await afterEach()); 250 | }); 251 | ``` 252 | 253 | And `beforeAll` and `afterAll` can be placed before and after all tests of `testBlock` are completed. 254 | 255 | ```js 256 | beforeAllBlock.forEach(async (beforeAll) => await beforeAll()); 257 | testBlock.forEach(async (item) => {}) + 258 | afterAllBlock.forEach(async (afterAll) => await afterAll()); 259 | ``` 260 | 261 | At this point, we have implemented a simple test framework. Based on this, we can enrich the assertion method, matcher and support parameter configuration, and read the personal notes of the source code below. 262 | 263 | # jest-cli 264 | 265 | Download Jest source code and execute it in the root directory 266 | 267 | ```bash 268 | yarn 269 | npm run build 270 | ``` 271 | 272 | It essentially runs two files build.js and buildTs.js in the script folder: 273 | 274 | ```json 275 | "scripts": { 276 | "build": "yarn build:js && yarn build:ts", 277 | "build:js": "node ./scripts/build.js", 278 | "build:ts": "node ./scripts/buildTs.js", 279 | } 280 | ``` 281 | 282 | build.js essentially uses the babel library, create a new build folder in the package/xxx package, and then use transformFileSync to generate the file into the build folder: 283 | 284 | ```js 285 | const transformed = babel.transformFileSync(file, options).code; 286 | ``` 287 | 288 | And buildTs.js essentially uses the tsc command to compile the ts file into the build folder, and use the execa library to execute the command: 289 | 290 | ```js 291 | const args = ["tsc", "-b", ...packagesWithTs, ...process.argv.slice(2)]; 292 | await execa("yarn", args, { stdio: "inherit" }); 293 | ``` 294 | 295 | ![image](https://user-images.githubusercontent.com/17243165/115947329-84fe4380-a4f9-11eb-9df2-02cf8fdadd08.png) 296 | 297 | Successful execution will display as follows, it will help you compile all files js files and ts files in the packages folder to the build folder of the directory where you are: 298 | 299 | ![image](https://user-images.githubusercontent.com/17243165/116343731-97d58880-a817-11eb-9507-96bae701e804.png) 300 | 301 | Next we can start the jest command: 302 | 303 | ```bash 304 | npm run jest 305 | # Equivalent to 306 | # node ./packages/jest-cli/bin/jest.js 307 | ``` 308 | 309 | Here you can do analysis processing according to the different parameters passed in, such as: 310 | 311 | ```bash 312 | npm run jest -h 313 | node ./packages/jest-cli/bin/jest.js /path/test.spec.js 314 | ``` 315 | 316 | It will execute the `jest.js` file, and then enter the run method in the `build/cli` file. The run method will parse various parameters in the command. The specific principle is that the yargs library cooperates with process.argv to achieve 317 | 318 | ```js 319 | const importLocal = require("import-local"); 320 | 321 | if (!importLocal(__filename)) { 322 | if (process.env.NODE_ENV == null) { 323 | process.env.NODE_ENV = "test"; 324 | } 325 | 326 | require("../build/cli").run(); 327 | } 328 | ``` 329 | 330 | # jest-config 331 | 332 | When various command parameters are obtained, the core method of `runCLI` will be executed, which is the core method of the `@jest/core -> packages/jest-core/src/cli/index.ts` library. 333 | 334 | ```js 335 | import { runCLI } from "@jest/core"; 336 | const outputStream = argv.json || argv.useStderr ? process.stderr : process.stdout; 337 | const { results, globalConfig } = await runCLI(argv, projects); 338 | ``` 339 | 340 | The `runCLI` method will use the input parameter argv parsed in the command just now to read the configuration file information with the `readConfigs` method. `readConfigs` comes from `packages/jest-config/src/index.ts`, here There will be normalize to fill in and initialize some default configured parameters. Its default parameters are recorded in the `packages/jest-config/src/Defaults.ts` file. For example, if you only run js single test, the default setting of `require. resolve('jest-runner')` is a runner that runs a single test, and it also cooperates with the chalk library to generate an outputStream to output the content to the console. 341 | 342 | By the way, let me mention the principle of introducing jest into the module. First, `require.resolve(moduleName)` will find the path of the module, and save the path in the configuration, and then use the tool library `packages/jest-util/src/requireOrImportModule The `requireOrImportModule` method of .ts` calls the encapsulated native `import/reqiure` method to match the path in the configuration file to take out the module. 343 | 344 | - globalConfig configuration from argv 345 | - configs are from the configuration of jest.config.js 346 | 347 | ```ts 348 | const { globalConfig, configs, hasDeprecationWarnings } = await readConfigs( 349 | argv, 350 | projects 351 | ); 352 | 353 | if (argv.debug) { 354 | /*code*/ 355 | } 356 | if (argv.showConfig) { 357 | /*code*/ 358 | } 359 | if (argv.clearCache) { 360 | /*code*/ 361 | } 362 | if (argv.selectProjects) { 363 | /*code*/ 364 | } 365 | ``` 366 | 367 | # jest-haste-map 368 | 369 | jest-haste-map is used to get all the files in the project and the dependencies between them. It achieves this by looking at the `import/require` calls, extracting them from each file and constructing a map containing each A file and its dependencies. Here Haste is the module system used by Facebook. It also has something called HasteContext, because it has HasteFS (Haste File System). HasteFS is just a list of files in the system and all dependencies associated with it. Item, it is a map data structure, where the key is the path and the value is the metadata. The `contexts` generated here will be used until the `onRunComplete` stage. 370 | 371 | ```ts 372 | const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps( 373 | configs, 374 | globalConfig, 375 | outputStream 376 | ); 377 | ``` 378 | 379 | # jest-runner 380 | 381 | The `_run10000` method will obtain `contexts` according to the configuration information `globalConfig` and `configs`. `contexts` will store the configuration information and path of each local file, etc., and then will bring the callback function `onComplete`, the global configuration `globalConfig` and scope `contexts` enter the `runWithoutWatch` method. 382 | ![image](https://user-images.githubusercontent.com/17243165/117241252-51aaa580-ae65-11eb-9883-f60b70fa9fcc.png) 383 | 384 | Next, you will enter the `runJest` method of the `packages/jest-core/src/runJest.ts` file, where the passed `contexts` will be used to traverse all unit tests and save them in an array. 385 | 386 | ```ts 387 | let allTests: Array = []; 388 | contexts.map(async (context, index) => { 389 | const searchSource = searchSources[index]; 390 | const matches = await getTestPaths( 391 | globalConfig, 392 | searchSource, 393 | outputStream, 394 | changedFilesPromise && (await changedFilesPromise), 395 | jestHooks, 396 | filter 397 | ); 398 | allTests = allTests.concat(matches.tests); 399 | return { context, matches }; 400 | }); 401 | ``` 402 | 403 | And use the `Sequencer` method to sort the single tests 404 | 405 | ```ts 406 | const Sequencer: typeof TestSequencer = await requireOrImportModule( 407 | globalConfig.testSequencer 408 | ); 409 | const sequencer = new Sequencer(); 410 | allTests = await sequencer.sort(allTests); 411 | ``` 412 | 413 | The `runJest` method calls a key method `packages/jest-core/src/TestScheduler.ts`'s `scheduleTests` method. 414 | 415 | ```ts 416 | const results = await new TestScheduler( 417 | globalConfig, 418 | { startRun }, 419 | testSchedulerContext 420 | ).scheduleTests(allTests, testWatcher); 421 | ``` 422 | 423 | The `scheduleTests` method will do a lot of things, it will collect the `contexts` in the `allTests` into the `contexts`, collect the `duration` into the `timings` array, and subscribe to four life cycles before executing all single tests : 424 | 425 | - test-file-start 426 | - test-file-success 427 | - test-file-failure 428 | - test-case-result 429 | 430 | Then traverse the `contexts` and use a new empty object `testRunners` to do some processing and save it, which will call the `createScriptTransformer` method provided by `@jest/transform` to process the imported modules. 431 | 432 | ```ts 433 | import { createScriptTransformer } from "@jest/transform"; 434 | 435 | const transformer = await createScriptTransformer(config); 436 | const Runner: typeof TestRunner = interopRequireDefault( 437 | transformer.requireAndTranspileModule(config.runner) 438 | ).default; 439 | const runner = new Runner(this._globalConfig, { 440 | changedFiles: this._context?.changedFiles, 441 | sourcesRelatedToTestsInChangedFiles: this._context?.sourcesRelatedToTestsInChangedFiles, 442 | }); 443 | testRunners[config.runner] = runner; 444 | ``` 445 | 446 | The `scheduleTests` method will call the `runTests` method of `packages/jest-runner/src/index.ts`. 447 | 448 | ```ts 449 | async runTests(tests, watcher, onStart, onResult, onFailure, options) { 450 | return await (options.serial 451 | ? this._createInBandTestRun(tests, watcher, onStart, onResult, onFailure) 452 | : this._createParallelTestRun( 453 | tests, 454 | watcher, 455 | onStart, 456 | onResult, 457 | onFailure 458 | )); 459 | } 460 | ``` 461 | 462 | In the final `_createParallelTestRun` or `_createInBandTestRun` method: 463 | 464 | There will be a `runTestInWorker` method, which, as the name suggests, is to perform a single test in the worker. 465 | 466 | ![image](https://user-images.githubusercontent.com/17243165/117279102-f3e18200-ae93-11eb-9a1b-100197240ebe.png) 467 | 468 | `_createInBandTestRun` will execute a core method `runTest` in `packages/jest-runner/src/runTest.ts`, and execute a method `runTestInternal` in `runJest`, which will prepare a lot of preparations before executing a single test The thing involves global method rewriting and hijacking of import and export methods. 469 | 470 | ```ts 471 | await this.eventEmitter.emit("test-file-start", [test]); 472 | return runTest( 473 | test.path, 474 | this._globalConfig, 475 | test.context.config, 476 | test.context.resolver, 477 | this._context, 478 | sendMessageToJest 479 | ); 480 | ``` 481 | 482 | In the `runTestInternal` method, the `fs` module will be used to read the content of the file and put it into `cacheFS`, which can be cached for quick reading later. For example, if the content of the file is json later, it can be read directly in `cacheFS`. Also use `Date.now` time difference to calculate time-consuming. 483 | 484 | ```ts 485 | const testSource = fs().readFileSync(path, "utf8"); 486 | const cacheFS = new Map([[path, testSource]]); 487 | ``` 488 | 489 | In the `runTestInternal` method, `packages/jest-runtime/src/index.ts` will be introduced, which will help you cache and read modules and trigger execution. 490 | 491 | ```ts 492 | const runtime = new Runtime( 493 | config, 494 | environment, 495 | resolver, 496 | transformer, 497 | cacheFS, 498 | { 499 | changedFiles: context?.changedFiles, 500 | collectCoverage: globalConfig.collectCoverage, 501 | collectCoverageFrom: globalConfig.collectCoverageFrom, 502 | collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, 503 | coverageProvider: globalConfig.coverageProvider, 504 | sourcesRelatedToTestsInChangedFiles: context?.sourcesRelatedToTestsInChangedFiles, 505 | }, 506 | path 507 | ); 508 | ``` 509 | 510 | Here, the `@jest/console` package is used to rewrite the global console. In order for the console of the single-tested file code block to print the results on the node terminal smoothly, in conjunction with the `jest-environment-node` package, set the global `environment.global` all Rewritten to facilitate subsequent methods to get these scopes in vm. 511 | 512 | ```ts 513 | // Essentially it is rewritten using node's console to facilitate subsequent overwriting of the console method in the vm scope 514 | testConsole = new BufferedConsole(); 515 | const environment = new TestEnvironment(config, { 516 | console: testConsole, // Suspected useless code 517 | docblockPragmas, 518 | testPath: path, 519 | }); 520 | // Really rewrite the console method 521 | setGlobal(environment.global, "console", testConsole); 522 | ``` 523 | 524 | `runtime` mainly uses these two methods to load the module, first judge whether it is an ESM module, if it is, use `runtime.unstable_importModule` to load the module and run the module, if not, use `runtime.requireModule` to load the module and run the module . 525 | 526 | ```ts 527 | const esm = runtime.unstable_shouldLoadAsEsm(path); 528 | 529 | if (esm) { 530 | await runtime.unstable_importModule(path); 531 | } else { 532 | runtime.requireModule(path); 533 | } 534 | ``` 535 | 536 | # jest-circus 537 | 538 | Immediately after the `testFramework` in `runTestInternal` will accept the incoming runtime to call the single test file to run, the `testFramework` method comes from a library with an interesting name `packages/jest-circus/src/legacy-code-todo-rewrite /jestAdapter.ts`, where `legacy-code-todo-rewrite` means **legacy code todo rewrite**, `jest-circus` mainly rewrites some methods of global `global`, involving These few: 539 | 540 | - afterAll 541 | - afterEach 542 | - beforeAll 543 | - beforeEach 544 | - describe 545 | - it 546 | - test 547 | 548 | ![image](https://user-images.githubusercontent.com/17243165/118916923-6bb6ae80-b962-11eb-8725-6c724e8b1952.png) 549 | 550 | Before calling the single test here, the `jestAdapter` function, which is the above-mentioned `runtime.requireModule`, will load the `xxx.spec.js` file. The execution environment `globals` has been preset using `initialize` before execution. `And `snapshotState`, and rewrite `beforeEach`. If `resetModules`, `clearMocks`, `resetMocks`, `restoreMocks`and`setupFilesAfterEnv` are configured, the following methods will be executed respectively: 551 | 552 | - runtime.resetModules 553 | - runtime.clearAllMocks 554 | - runtime.resetAllMocks 555 | - runtime.restoreAllMocks 556 | - runtime.requireModule or runtime.unstable_importModule 557 | 558 | After running the initialization of the `initialize` method, because `initialize` has rewritten the global `describe` and `test` methods, these methods are all rewritten here in `/packages/jest-circus/src/index.ts`, here Note that there is a `dispatchSync` method in the `test` method. This is a key method. Here, a copy of `state` will be maintained globally. `dispatchSync` means to store the functions and other information in the `test` code block in the `state`, In `dispatchSync` uses `name` in conjunction with the `eventHandler` method to modify the `state`. This idea is very similar to the data flow in redux. 559 | 560 | ```ts 561 | const test: Global.It = () => { 562 | return (test = (testName, fn, timeout) => (testName, mode, fn, testFn, timeout) => { 563 | return dispatchSync({ 564 | asyncError, 565 | fn, 566 | mode, 567 | name: "add_test", 568 | testName, 569 | timeout, 570 | }); 571 | }); 572 | }; 573 | ``` 574 | 575 | The single test `xxx.spec.js`, that is, the testPath file will be imported and executed after the `initialize`. Note that this single test will be executed when imported here, because the single test `xxx.spec.js` file is written according to the specifications , There will be code blocks such as `test` and `describe`, so at this time all callback functions accepted by `test` and `describe` will be stored in the global `state`. 576 | 577 | ```ts 578 | const esm = runtime.unstable_shouldLoadAsEsm(testPath); 579 | if (esm) { 580 | await runtime.unstable_importModule(testPath); 581 | } else { 582 | runtime.requireModule(testPath); 583 | } 584 | ``` 585 | 586 | # jest-runtime 587 | 588 | Here, it will first determine whether it is an esm module, if it is, use the method of `unstable_importModule` to import it, otherwise use the method of `requireModule` to import it, specifically will it enter the following function. 589 | 590 | ```ts 591 | this._loadModule(localModule, from, moduleName, modulePath, options, moduleRegistry); 592 | ``` 593 | 594 | The logic of \_loadModule has only three main parts 595 | 596 | - Judge whether it is a json suffix file, execute readFile to read the text, and use transformJson and JSON.parse to transform the output content. 597 | - Determine whether the node suffix file is, and execute the require native method to import the module. 598 | - For files that do not meet the above two conditions, execute the \_execModule execution module. 599 | 600 | \_execModule will use babel to transform the source code read by fs. This `transformFile` is the `transform` method of `packages/jest-runtime/src/index.ts`. 601 | 602 | ```ts 603 | const transformedCode = this.transformFile(filename, options); 604 | ``` 605 | 606 | ![image](https://user-images.githubusercontent.com/17243165/119518220-ea6c7b00-bdaa-11eb-8723-d8bb89673acf.png) 607 | 608 | \_execModule will use the `createScriptFromCode` method to call node's native vm module to actually execute js. The vm module accepts safe source code, and uses the V8 virtual machine with the incoming context to execute the code immediately or delay the execution of the code, here you can Accept different scopes to execute the same code to calculate different results, which is very suitable for the use of test frameworks. The injected vmContext here is the above global rewrite scope including afterAll, afterEach, beforeAll, beforeEach, describe, it, test, So our single test code will get these methods with injection scope when it runs. 609 | 610 | ```ts 611 | const vm = require("vm"); 612 | const script = new vm().Script(scriptSourceCode, option); 613 | const filename = module.filename; 614 | const vmContext = this._environment.getVmContext(); 615 | script.runInContext(vmContext, { 616 | filename, 617 | }); 618 | ``` 619 | 620 | ![image](https://user-images.githubusercontent.com/17243165/125756054-4c144a7a-447a-4b5b-973e-e3075b06daa0.png) 621 | 622 | When the global method is overwritten and the `state` is saved above, it will enter the logic of the callback function that actually executes the `describe`, in the `run` method of `packages/jest-circus/src/run.ts`, here Use the `getState` method to take out the `describe` code block, then use the `_runTestsForDescribeBlock` to execute this function, then enter the `_runTest` method, and then use the hook function before and after the execution of `_callCircusHook`, and use the `_callCircusTest` to execute. 623 | 624 | ```ts 625 | const run = async (): Promise => { 626 | const { rootDescribeBlock } = getState(); 627 | await dispatch({ name: "run_start" }); 628 | await _runTestsForDescribeBlock(rootDescribeBlock); 629 | await dispatch({ name: "run_finish" }); 630 | return makeRunResult(getState().rootDescribeBlock, getState().unhandledErrors); 631 | }; 632 | 633 | const _runTest = async (test, parentSkipped) => { 634 | // beforeEach 635 | // test function block, testContext scope 636 | await _callCircusTest(test, testContext); 637 | // afterEach 638 | }; 639 | ``` 640 | 641 | This is the core position of the hook function implementation and also the core element of the Jest function. 642 | 643 | # At last 644 | 645 | I hope this article can help you understand the core implementation and principles of the Jest testing framework. Thank you for reading patiently. If the articles and notes can bring you a hint of help or inspiration, please don’t be stingy with your Star and Fork. The articles are continuously updated synchronously, your affirmation Is my biggest motivation to move forward😁 646 | 647 | # Be included in 648 | 649 | - [https://medium.com/@kalone.cool/explain-the-implementation-principle-of-the-jest-framework-in-a-simple-way-222b11a55c04](https://medium.com/@kalone.cool/explain-the-implementation-principle-of-the-jest-framework-in-a-simple-way-222b11a55c04) 650 | - [https://softwaretestingnotes.com/](https://softwaretestingnotes.com/) 651 | - [https://dev.to/wscats/explain-the-implementation-principle-of-the-jest-framework-in-a-simple-way-4dio](https://dev.to/wscats/explain-the-implementation-principle-of-the-jest-framework-in-a-simple-way-4dio) 652 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [English](https://github.com/Wscats/jest-tutorial/blob/vm/README.EN.md) | [中文](https://github.com/Wscats/jest-tutorial/blob/vm/README.md) 2 | 3 | - [什么是Jest](#什么是Jest) 4 | - [测试意味着什么](#测试意味着什么) 5 | - [我怎么知道要测试什么](#我怎么知道要测试什么) 6 | - [测试块,断言和匹配器](#测试块,断言和匹配器) 7 | - [如何实现测试块](#如何实现测试块) 8 | - [如何实现断言和匹配器](#如何实现断言和匹配器) 9 | - [CLI 和配置](#CLI和配置) 10 | - [模拟](#模拟) 11 | - [怎么模拟一个函数](#怎么模拟一个函数) 12 | - [执行环境](#执行环境) 13 | - [作用域隔离](#作用域隔离) 14 | - [V8 虚拟机](#V8虚拟机) 15 | - [运行单测回调](#运行单测回调) 16 | - [钩子函数](#钩子函数) 17 | - [生成报告](#生成报告) 18 | - [jest-cli](#jest-cli) 19 | - [jest-config](#jest-config) 20 | - [jest-haste-map](#jest-haste-map) 21 | - [jest-runner](#jest-runner) 22 | - [jest-environment-node](#jest-environment-node) 23 | - [jest-circus](#jest-circus) 24 | - [jest-runtime](#jest-runtime) 25 | - [最后&源码](#最后) 26 | 27 | # 从零开始实现一个 Jest 单元测试框架 28 | 29 | 本文主要给大家深入了解 Jest 背后的运行原理,并从零开始简单实现一个 Jest 单元测试的框架,方便了解单元测试引擎是如何工作的,Jest 编写单测相信我们已经很熟悉了,但 Jest 是如何工作的我们可能还很陌生,那让我们一起走进 Jest 内心,一同探究单元测试引擎是如何工作的。 30 | 31 | 先附上 Jest 核心引擎的代码实现给有需要的同学,欢迎关注和交流:[https://github.com/Wscats/jest-tutorial](https://github.com/Wscats/jest-tutorial) 32 | 33 | # 什么是 Jest 34 | 35 | Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。 36 | 37 | Jest 作为 NPM 包发布,可以安装并运行在任何 JavaScript 项目中。Jest 是目前前端最流行的测试库之一。 38 | 39 | # 测试意味着什么 40 | 41 | 在技术术语中,测试意味着检查我们的代码是否满足某些期望。例如:一个名为求和(`sum`)函数应该返回给定一些运算结果的预期输出。 42 | 43 | 有许多类型的测试,很快你就会被术语淹没,但长话短说的测试分为三大类: 44 | 45 | - 单元测试 46 | - 集成测试 47 | - E2E 测试 48 | 49 | # 我怎么知道要测试什么 50 | 51 | 在测试方面,即使是最简单的代码块也可能使初学者也可能会迷惑。最常见的问题是“我怎么知道要测试什么?”。 52 | 53 | 如果您正在编写网页,一个好的出发点是测试应用程序的每个页面和每个用户交互。但是网页其实也需要测试的函数和模块等代码单元组成。 54 | 55 | 大多数时候有两种情况: 56 | 57 | - 你继承遗留代码,其自带没有测试 58 | - 你必须凭空实现一个新功能 59 | 60 | 那该怎么办?对于这两种情况,你可以通过将测试视为:检查该函数是否产生预期结果。最典型的测试流程如下所示: 61 | 62 | - 导入要测试的函数 63 | - 给函数一个输入 64 | - 定义期望的输出 65 | - 检查函数是否产生预期的输出 66 | 67 | 一般,就这么简单。掌握以下核心思路,编写测试将不再可怕: 68 | 69 | > 输入 -> 预期输出 -> 断言结果。 70 | 71 | # 测试块,断言和匹配器 72 | 73 | 我们将创建一个简单的 Javascript 函数代码,用于 2 个数字的加法,并为其编写相应的基于 Jest 的测试 74 | 75 | ```js 76 | const sum = (a, b) => a + b; 77 | ``` 78 | 79 | 现在,为了测试在同一个文件夹中创建一个测试文件,命名为 `test.spec.js`,这特殊的后缀是 Jest 的约定,用于查找所有的测试文件。我们还将导入被测函数,以便执行测试中的代码。Jest 测试遵循 BDD 风格的测试,每个测试都应该有一个主要的 `test` 测试块,并且可以有多个测试块,现在可以为 `sum` 方法编写测试块,这里我们编写一个测试来添加 2 个数字并验证预期结果。我们将提供数字为 1 和 2,并期望输出 3。 80 | 81 | `test` 它需要两个参数:一个用于描述测试块的字符串,以及一个用于包装实际测试的回调函数。`expect` 包装目标函数,并结合匹配器 `toBe` 用于检查函数计算结果是否符合预期。 82 | 83 | 这是完整的测试: 84 | 85 | ```js 86 | test("sum test", () => { 87 | expect(sum(1, 2)).toBe(3); 88 | }); 89 | ``` 90 | 91 | 我们观察上面代码有发现有两点: 92 | 93 | - `test` 块是单独的测试块,它拥有描述和划分范围的作用,即它代表我们要为该计算函数 `sum` 所编写测试的通用容器。 94 | - `expect` 是一个断言,该语句使用输入 1 和 2 调用被测函数中的 `sum` 方法,并期望输出 3。 95 | - `toBe` 是一个匹配器,用于检查期望值,如果不符合预期结果则应该抛出异常。 96 | 97 | ## 如何实现测试块 98 | 99 | 测试块其实并不复杂,最简单的实现不过如下,我们需要把测试包装实际测试的回调函数存起来,所以封装一个 `dispatch` 方法接收命令类型和回调函数: 100 | 101 | ```js 102 | const test = (name, fn) => { 103 | dispatch({ type: "ADD_TEST", fn, name }); 104 | }; 105 | ``` 106 | 107 | 我们需要在全局创建一个 `state` 保存测试的回调函数,测试的回调函数使用一个数组存起来。 108 | 109 | ```js 110 | global["STATE_SYMBOL"] = { 111 | testBlock: [], 112 | }; 113 | ``` 114 | 115 | `dispatch` 方法此时只需要甄别对应的命令,并把测试的回调函数存进全局的 `state` 即可。 116 | 117 | ```js 118 | const dispatch = (event) => { 119 | const { fn, type, name } = event; 120 | switch (type) { 121 | case "ADD_TEST": 122 | const { testBlock } = global["STATE_SYMBOL"]; 123 | testBlock.push({ fn, name }); 124 | break; 125 | } 126 | }; 127 | ``` 128 | 129 | ## 如何实现断言和匹配器 130 | 131 | 断言库也实现也很简单,只需要封装一个函数暴露匹配器方法满足以下公式即可: 132 | 133 | > `expect(A).toBe(B)` 134 | 135 | 这里我们实现 `toBe` 这个常用的方法,当结果和预期不相等,抛出错误即可: 136 | 137 | ```js 138 | const expect = (actual) => ({ 139 | toBe(expected) { 140 | if (actual !== expected) { 141 | throw new Error(`${actual} is not equal to ${expected}`); 142 | } 143 | } 144 | }; 145 | ``` 146 | 147 | 实际在测试块中会使用 `try/catch` 捕获错误,并打印堆栈信息方面定位问题。 148 | 149 | 在简单情况下,我们也可以使用 Node 自带的 `assert` 模块进行断言,当然还有很多更复杂的断言方法,本质上原理都差不多。 150 | 151 | ## CLI 和配置 152 | 153 | 编写完测试之后,我们则需要在命令行中输入命令运行单测,正常情况下,命令类似如下: 154 | 155 | > `node jest xxx.spec.js` 156 | 157 | 这里本质是解析命令行的参数。 158 | 159 | ```js 160 | const testPath = process.argv.slice(2)[0]; 161 | const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString(); 162 | ``` 163 | 164 | 复杂的情况可能还需要读取本地的 Jest 配置文件的参数来更改执行环境等,Jest 在这里使用了第三方库 `yargs` `execa` 和 `chalk` 等来解析执行并打印命令。 165 | 166 | # 模拟 167 | 168 | 在复杂的测试场景,我们一定绕不开一个 Jest 术语:模拟(`mock`) 169 | 170 | 在 Jest 文档中,我们可以找到 Jest 对模拟有以下描述:”模拟函数通过抹去函数的实际实现、捕获对函数的调用,以及在这些调用中传递的参数,使测试代码之间的链接变得容易“ 171 | 172 | 简而言之,可以通过将以下代码片段分配给函数或依赖项来创建模拟: 173 | 174 | ```js 175 | jest.mock("fs", { 176 | readFile: jest.fn(() => "wscats"), 177 | }); 178 | ``` 179 | 180 | 这是一个简单模拟的示例,模拟了 fs 模块 readFile 函数在测试特定业务逻辑的返回值。 181 | 182 | ## 怎么模拟一个函数 183 | 184 | 接下来我们就要研究一下如何实现,首先是 `jest.mock`,它第一个参数接受的是模块名或者模块路径,第二个参数是该模块对外暴露方法的具体实现 185 | 186 | ```js 187 | const jest = { 188 | mock(mockPath, mockExports = {}) { 189 | const path = require.resolve(mockPath, { paths: ["."] }); 190 | require.cache[path] = { 191 | id: path, 192 | filename: path, 193 | loaded: true, 194 | exports: mockExports, 195 | }; 196 | }, 197 | }; 198 | ``` 199 | 200 | 我们方案其实跟上面的 `test` 测试块实现一致,只需要把具体的实现方法找一个地方存起来即可,等后续真正使用改模块的时候替换掉即可,所以我们把它存到 `require.cache` 里面,当然我们也可以存到全局的 `state` 中。 201 | 202 | 而 `jest.fn` 的实现也不难,这里我们使用一个闭包 `mockFn` 把替换的函数和参数给存起来,方便后续测试检查和统计调用数据。 203 | 204 | ```js 205 | const jest = { 206 | fn(impl = () => {}) { 207 | const mockFn = (...args) => { 208 | mockFn.mock.calls.push(args); 209 | return impl(...args); 210 | }; 211 | mockFn.originImpl = impl; 212 | mockFn.mock = { calls: [] }; 213 | return mockFn; 214 | }, 215 | }; 216 | ``` 217 | 218 | # 执行环境 219 | 220 | 有些同学可能留意到了,在测试框架中,我们并不需要手动引入 `test`、`expect` 和 `jest` 这些函数,每个测试文件可以直接使用,所以我们这里需要创造一个注入这些方法的运行环境。 221 | 222 | ## 作用域隔离 223 | 224 | 由于单测文件运行时候需要作用域隔离。所以在设计上测试引擎是跑在 node 全局作用域下,而测试文件的代码则跑在 node 环境里的 vm 虚拟机局部作用域中。 225 | 226 | - 全局作用域 `global` 227 | - 局部作用域 `context` 228 | 229 | 两个作用域通过 `dispatch` 方法实现通信。 230 | 231 | `dispatch` 在 vm 局部作用域下收集测试块、生命周期和测试报告信息到 node 全局作用域 `STATE_SYMBOL` 中,所以 `dispatch` 主要涉及到以下各种通信类型: 232 | 233 | - 测试块 234 | - `ADD_TEST` 235 | - 生命周期 236 | - `BEFORE_EACH` 237 | - `BEFORE_ALL` 238 | - `AFTER_EACH` 239 | - `AFTER_ALL` 240 | - 测试报告 241 | - `COLLECT_REPORT` 242 | 243 | ## V8 虚拟机 244 | 245 | 既然万事俱备只欠东风,我们只需要给 V8 虚拟机注入测试所需的方法,即注入测试局部作用域即可。 246 | 247 | ```js 248 | const context = { 249 | console: console.Console({ stdout: process.stdout, stderr: process.stderr }), 250 | jest, 251 | expect, 252 | require, 253 | test: (name, fn) => dispatch({ type: "ADD_TEST", fn, name }), 254 | }; 255 | ``` 256 | 257 | 注入完作用域,我们就可以让测试文件的代码在 V8 虚拟机中跑起来,这里我传入的代码是已经处理成字符串的代码,Jest 这里会在这里做一些代码加工,安全处理和 SourceMap 缝补等操作,我们示例就不需要搞那么复杂了。 258 | 259 | ```js 260 | vm.runInContext(code, context); 261 | ``` 262 | 263 | 在代码执行的前后可以使用时间差算出单测的运行时间,Jest 还会在这里预评估单测文件的大小数量等,决定是否启用 Worker 来优化执行速度 264 | 265 | ```js 266 | const start = new Date(); 267 | const end = new Date(); 268 | log("\x1b[32m%s\x1b[0m", `Time: ${end - start} ms`); 269 | ``` 270 | 271 | ## 运行单测回调 272 | 273 | V8 虚拟机执行完毕之后,全局的 `state` 就会收集到测试块中所有包装好的测试回调函数,我们最后只需要把所有的这些回调函数遍历取出来,并执行。 274 | 275 | ```js 276 | testBlock.forEach(async (item) => { 277 | const { fn, name } = item; 278 | await fn.apply(this); 279 | }); 280 | ``` 281 | 282 | ## 钩子函数 283 | 284 | 我们还可以在单测执行过程中加入生命周期,例如 `beforeEach`,`afterEach`,`afterAll` 和 `beforeAll` 等钩子函数。 285 | 286 | 在上面的基础架构上增加钩子函数,其实就是在执行 test 的每个过程中注入对应回调函数,比如 `beforeEach` 就是放在 `testBlock` 遍历执行测试函数前,`afterEach` 就是放在 `testBlock` 遍历执行测试函数后,非常的简单,只需要位置放对就可以暴露任何时期的钩子函数。 287 | 288 | ```js 289 | testBlock.forEach(async (item) => { 290 | const { fn, name } = item; 291 | beforeEachBlock.forEach(async (beforeEach) => await beforeEach()); 292 | await fn.apply(this); 293 | afterEachBlock.forEach(async (afterEach) => await afterEach()); 294 | }); 295 | ``` 296 | 297 | 而 `beforeAll` 和 `afterAll` 就可以放在,`testBlock` 所有测试运行完毕前和后。 298 | 299 | ```js 300 | beforeAllBlock.forEach(async (beforeAll) => await beforeAll()); 301 | testBlock.forEach(async (item) => {}) 302 | afterAllBlock.forEach(async (afterAll) => await afterAll()); 303 | ``` 304 | 305 | # 生成报告 306 | 307 | 当单测执行完后,可以收集成功和捕捉错误的信息集, 308 | 309 | ```js 310 | try { 311 | dispatch({ type: "COLLECT_REPORT", name, pass: 1 }); 312 | log("\x1b[32m%s\x1b[0m", `√ ${name} passed`); 313 | } catch (error) { 314 | dispatch({ type: "COLLECT_REPORT", name, pass: 0 }); 315 | log("\x1b[32m%s\x1b[0m", `× ${name} error`); 316 | } 317 | ``` 318 | 319 | 然后劫持 `log` 的输出流,让详细的结果打印在终端上,也可以配合 IO 模块在本地生成报告。 320 | 321 | ```js 322 | const { reports } = global["STATE_SYMBOL"]; 323 | const pass = reports.reduce((pre, next) => pre.pass + next.pass); 324 | log("\x1b[32m%s\x1b[0m", `All Tests: ${pass}/${reports.length} passed`); 325 | ``` 326 | 327 | 至此,我们就实现了一个简单的 Jest 测试框架的核心部分,以上部分基本实现了测试块、断言、匹配器、CLI配置、函数模拟、使用虚拟机及作用域和生命周期钩子函数等,我们可以在此基础上,丰富断言方法,匹配器和支持参数配置,当然实际 Jest 的实现会更复杂,我只提炼了比较关键的部分,所以附上本人读 Jest 源码的个人笔记供大家参考。 328 | 329 | # jest-cli 330 | 331 | 下载 Jest 源码,根目录下执行 332 | 333 | ```bash 334 | yarn 335 | npm run build 336 | ``` 337 | 338 | 它本质跑的是 script 文件夹的两个文件 build.js 和 buildTs.js: 339 | 340 | ```json 341 | "scripts": { 342 | "build": "yarn build:js && yarn build:ts", 343 | "build:js": "node ./scripts/build.js", 344 | "build:ts": "node ./scripts/buildTs.js", 345 | } 346 | ``` 347 | 348 | build.js 本质上是使用了 babel 库,在 package/xxx 包新建一个 build 文件夹,然后使用 transformFileSync 把文件生成到 build 文件夹里面: 349 | 350 | ```js 351 | const transformed = babel.transformFileSync(file, options).code; 352 | ``` 353 | 354 | 而 buildTs.js 本质上是使用了 tsc 命令,把 ts 文件编译到 build 文件夹中,使用 execa 库来执行命令: 355 | 356 | ```js 357 | const args = ["tsc", "-b", ...packagesWithTs, ...process.argv.slice(2)]; 358 | await execa("yarn", args, { stdio: "inherit" }); 359 | ``` 360 | 361 | ![image](https://user-images.githubusercontent.com/17243165/115947329-84fe4380-a4f9-11eb-9df2-02cf8fdadd08.png) 362 | 363 | 执行成功会显示如下,它会帮你把 packages 文件夹下的所有文件 js 文件和 ts 文件编译到所在目录的 build 文件夹下: 364 | 365 | ![image](https://user-images.githubusercontent.com/17243165/116343731-97d58880-a817-11eb-9507-96bae701e804.png) 366 | 367 | 接下来我们可以启动 jest 的命令: 368 | 369 | ```bash 370 | npm run jest 371 | # 等价于 372 | # node ./packages/jest-cli/bin/jest.js 373 | ``` 374 | 375 | 这里可以根据传入的不同参数做解析处理,比如: 376 | 377 | ```bash 378 | npm run jest -h 379 | node ./packages/jest-cli/bin/jest.js /path/test.spec.js 380 | ``` 381 | 382 | 就会执行 `jest.js` 文件,然后进入到 `build/cli` 文件中的 run 方法,run 方法会对命令中各种的参数做解析,具体原理是 yargs 库配合 process.argv 实现 383 | 384 | ```js 385 | const importLocal = require("import-local"); 386 | 387 | if (!importLocal(__filename)) { 388 | if (process.env.NODE_ENV == null) { 389 | process.env.NODE_ENV = "test"; 390 | } 391 | 392 | require("../build/cli").run(); 393 | } 394 | ``` 395 | 396 | # jest-config 397 | 398 | 当获取各种命令参数后,就会执行 `runCLI` 核心的方法,它是 `@jest/core -> packages/jest-core/src/cli/index.ts` 库的核心方法。 399 | 400 | ```js 401 | import { runCLI } from "@jest/core"; 402 | const outputStream = argv.json || argv.useStderr ? process.stderr : process.stdout; 403 | const { results, globalConfig } = await runCLI(argv, projects); 404 | ``` 405 | 406 | `runCLI` 方法中会使用刚才命令中解析好的传入参数 argv 来配合 `readConfigs` 方法读取配置文件的信息,`readConfigs` 来自于 `packages/jest-config/src/index.ts`,这里会有 normalize 填补和初始化一些默认配置好的参数,它的默认参数在 `packages/jest-config/src/Defaults.ts` 文件中记录,比如:如果只运行 js 单测,会默认设置 `require.resolve('jest-runner')` 为运行单测的 runner,还会配合 chalk 库生成 outputStream 输出内容到控制台。 407 | 408 | 这里顺便提一下引入 jest 引入模块的原理思路,这里先会 `require.resolve(moduleName)` 找到模块的路径,并把路径存到配置里面,然后使用工具库 `packages/jest-util/src/requireOrImportModule.ts` 的 `requireOrImportModule` 方法调用封装好的原生 `import/reqiure` 方法配合配置文件中的路径把模块取出来。 409 | 410 | - globalConfig 来自于 argv 的配置 411 | - configs 来自于 jest.config.js 的配置 412 | 413 | ```ts 414 | const { globalConfig, configs, hasDeprecationWarnings } = await readConfigs( 415 | argv, 416 | projects 417 | ); 418 | 419 | if (argv.debug) { 420 | /*code*/ 421 | } 422 | if (argv.showConfig) { 423 | /*code*/ 424 | } 425 | if (argv.clearCache) { 426 | /*code*/ 427 | } 428 | if (argv.selectProjects) { 429 | /*code*/ 430 | } 431 | ``` 432 | 433 | # jest-haste-map 434 | 435 | jest-haste-map 用于获取项目中的所有文件以及它们之间的依赖关系,它通过查看 `import/require` 调用来实现这一点,从每个文件中提取它们并构建一个映射,其中包含每个文件及其依赖项,这里的 Haste 是 Facebook 使用的模块系统,它还有一个叫做 HasteContext 的东西,因为它有 HastFS(Haste 文件系统),HastFS 只是系统中文件的列表以及与之关联的所有依赖项,它是一种地图数据结构,其中键是路径,值是元数据,这里生成的 `contexts` 会一直被沿用到 `onRunComplete` 阶段。 436 | 437 | ```ts 438 | const { contexts, hasteMapInstances } = await buildContextsAndHasteMaps( 439 | configs, 440 | globalConfig, 441 | outputStream 442 | ); 443 | ``` 444 | 445 | # jest-runner 446 | 447 | `_run10000` 方法中会根据配置信息 `globalConfig` 和 `configs` 获取 `contexts`,`contexts` 会存储着每个局部文件的配置信息和路径等,然后会带着回调函数 `onComplete`,全局配置 `globalConfig` 和作用域 `contexts` 进入 `runWithoutWatch` 方法。 448 | ![image](https://user-images.githubusercontent.com/17243165/117241252-51aaa580-ae65-11eb-9883-f60b70fa9fcc.png) 449 | 450 | 接下来会进入 `packages/jest-core/src/runJest.ts` 文件的 `runJest` 方法中,这里会使用传过来的 `contexts` 遍历出所有的单元测试并用数组保存起来。 451 | 452 | ```ts 453 | let allTests: Array = []; 454 | contexts.map(async (context, index) => { 455 | const searchSource = searchSources[index]; 456 | const matches = await getTestPaths( 457 | globalConfig, 458 | searchSource, 459 | outputStream, 460 | changedFilesPromise && (await changedFilesPromise), 461 | jestHooks, 462 | filter 463 | ); 464 | allTests = allTests.concat(matches.tests); 465 | return { context, matches }; 466 | }); 467 | ``` 468 | 469 | 并使用 `Sequencer` 方法对单测进行排序 470 | 471 | ```ts 472 | const Sequencer: typeof TestSequencer = await requireOrImportModule( 473 | globalConfig.testSequencer 474 | ); 475 | const sequencer = new Sequencer(); 476 | allTests = await sequencer.sort(allTests); 477 | ``` 478 | 479 | `runJest` 方法会调用一个关键的方法 `packages/jest-core/src/TestScheduler.ts` 的 `scheduleTests` 方法。 480 | 481 | ```ts 482 | const results = await new TestScheduler( 483 | globalConfig, 484 | { startRun }, 485 | testSchedulerContext 486 | ).scheduleTests(allTests, testWatcher); 487 | ``` 488 | 489 | `scheduleTests` 方法会做很多事情,会把 `allTests` 中的 `contexts` 收集到 `contexts` 中,把 `duration` 收集到 `timings` 数组中,并在执行所有单测前订阅四个生命周期: 490 | 491 | - test-file-start 492 | - test-file-success 493 | - test-file-failure 494 | - test-case-result 495 | 496 | 接着把 `contexts` 遍历并用一个新的空对象 `testRunners` 做一些处理存起来,里面会调用 `@jest/transform` 提供的 `createScriptTransformer` 方法来处理引入的模块。 497 | 498 | ```ts 499 | import { createScriptTransformer } from "@jest/transform"; 500 | 501 | const transformer = await createScriptTransformer(config); 502 | const Runner: typeof TestRunner = interopRequireDefault( 503 | transformer.requireAndTranspileModule(config.runner) 504 | ).default; 505 | const runner = new Runner(this._globalConfig, { 506 | changedFiles: this._context?.changedFiles, 507 | sourcesRelatedToTestsInChangedFiles: this._context?.sourcesRelatedToTestsInChangedFiles, 508 | }); 509 | testRunners[config.runner] = runner; 510 | ``` 511 | 512 | 而 `scheduleTests` 方法会调用 `packages/jest-runner/src/index.ts` 的 `runTests` 方法。 513 | 514 | ```ts 515 | async runTests(tests, watcher, onStart, onResult, onFailure, options) { 516 | return await (options.serial 517 | ? this._createInBandTestRun(tests, watcher, onStart, onResult, onFailure) 518 | : this._createParallelTestRun( 519 | tests, 520 | watcher, 521 | onStart, 522 | onResult, 523 | onFailure 524 | )); 525 | } 526 | ``` 527 | 528 | 最终 `_createParallelTestRun` 或者 `_createInBandTestRun` 方法里面: 529 | 530 | - `_createParallelTestRun` 531 | 532 | 里面会有一个 `runTestInWorker` 方法,这个方法顾名思义就是在 worker 里面执行单测。 533 | 534 | ![image](https://user-images.githubusercontent.com/17243165/117279102-f3e18200-ae93-11eb-9a1b-100197240ebe.png) 535 | 536 | - `_createInBandTestRun` 里面会执行 `packages/jest-runner/src/runTest.ts` 一个核心方法 `runTest`,而 `runJest` 里面就执行一个方法 `runTestInternal`,这里面会在执行单测前准备非常多的东西,涉及全局方法改写和引入和导出方法的劫持。 537 | 538 | ```ts 539 | await this.eventEmitter.emit("test-file-start", [test]); 540 | return runTest( 541 | test.path, 542 | this._globalConfig, 543 | test.context.config, 544 | test.context.resolver, 545 | this._context, 546 | sendMessageToJest 547 | ); 548 | ``` 549 | 550 | 在 `runTestInternal` 方法中会使用 `fs` 模块读取文件的内容放入 `cacheFS`,缓存起来方便以后快读读取,比如后面如果文件的内容是 json 就可以直接在 `cacheFS` 读取,也会使用 `Date.now` 时间差计算耗时。 551 | 552 | ```ts 553 | const testSource = fs().readFileSync(path, "utf8"); 554 | const cacheFS = new Map([[path, testSource]]); 555 | ``` 556 | 557 | 在 `runTestInternal` 方法中会引入 `packages/jest-runtime/src/index.ts`,它会帮你缓存模块和读取模块并触发执行。 558 | 559 | ```ts 560 | const runtime = new Runtime( 561 | config, 562 | environment, 563 | resolver, 564 | transformer, 565 | cacheFS, 566 | { 567 | changedFiles: context?.changedFiles, 568 | collectCoverage: globalConfig.collectCoverage, 569 | collectCoverageFrom: globalConfig.collectCoverageFrom, 570 | collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, 571 | coverageProvider: globalConfig.coverageProvider, 572 | sourcesRelatedToTestsInChangedFiles: context?.sourcesRelatedToTestsInChangedFiles, 573 | }, 574 | path 575 | ); 576 | ``` 577 | 578 | # jest-environment-node 579 | 580 | 这里使用 `@jest/console` 包改写全局的 console,为了单测的文件代码块的 console 能顺利在 node 终端打印结果,配合 `jest-environment-node` 包,把全局的 `environment.global` 全部改写,方便后续在 vm 中能得到这些作用域的方法,本质上就是为 vm 的运行环境提供的作用域,为后续注入 `global` 提供便利,涉及到改写的 `global` 方法有如下: 581 | 582 | - global.global 583 | - global.clearInterval 584 | - global.clearTimeout 585 | - global.setInterval 586 | - global.setTimeout 587 | - global.Buffer 588 | - global.setImmediate 589 | - global.clearImmediate 590 | - global.Uint8Array 591 | - global.TextEncoder 592 | - global.TextDecoder 593 | - global.queueMicrotask 594 | - global.AbortController 595 | 596 | `testConsole` 本质上是使用 node 的 console 改写,方便后续覆盖 vm 作用域里面的 console 方法 597 | 598 | ```ts 599 | testConsole = new BufferedConsole(); 600 | const environment = new TestEnvironment(config, { 601 | console: testConsole, 602 | docblockPragmas, 603 | testPath: path, 604 | }); 605 | // 真正改写 console 地方的方法 606 | setGlobal(environment.global, "console", testConsole); 607 | ``` 608 | 609 | `runtime` 主要用这两个方法加载模块,先判断是否 ESM 模块,如果是,使用 `runtime.unstable_importModule` 加载模块并运行该模块,如果不是,则使用 `runtime.requireModule` 加载模块并运行该模块。 610 | 611 | ```ts 612 | const esm = runtime.unstable_shouldLoadAsEsm(path); 613 | 614 | if (esm) { 615 | await runtime.unstable_importModule(path); 616 | } else { 617 | runtime.requireModule(path); 618 | } 619 | ``` 620 | 621 | # jest-circus 622 | 623 | 紧接着 `runTestInternal` 中的 `testFramework` 会接受传入的 runtime 调用单测文件运行,`testFramework` 方法来自于一个名字比较有意思的库 `packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts`,其中 `legacy-code-todo-rewrite` 意思为**遗留代码待办事项重写**,`jest-circus` 主要会把全局 `global` 的一些方法进行重写,涉及这几个: 624 | 625 | - afterAll 626 | - afterEach 627 | - beforeAll 628 | - beforeEach 629 | - describe 630 | - it 631 | - test 632 | 633 | ![image](https://user-images.githubusercontent.com/17243165/118916923-6bb6ae80-b962-11eb-8725-6c724e8b1952.png) 634 | 635 | 这里调用单测前会在 `jestAdapter` 函数中,也就是上面提到的 `runtime.requireModule` 加载 `xxx.spec.js` 文件,这里执行之前已经使用 `initialize` 预设好了执行环境 `globals` 和 `snapshotState`,并改写 `beforeEach`,如果配置了 `resetModules`,`clearMocks`,`resetMocks`,`restoreMocks` 和 `setupFilesAfterEnv` 则会分别执行下面几个方法: 636 | 637 | - runtime.resetModules 638 | - runtime.clearAllMocks 639 | - runtime.resetAllMocks 640 | - runtime.restoreAllMocks 641 | - runtime.requireModule 或者 runtime.unstable_importModule 642 | 643 | 当运行完 `initialize` 方法初始化之后,由于 `initialize` 改写了全局的 `describe` 和 `test` 等方法,这些方法都在 `/packages/jest-circus/src/index.ts` 这里改写,这里注意 `test` 方法里面有一个 `dispatchSync` 方法,这是一个关键的方法,这里会在全局维护一份 `state`,`dispatchSync` 就是把 `test` 代码块里面的函数等信息存到 `state` 里面,`dispatchSync` 里面使用 `name` 配合 `eventHandler` 方法来修改 `state`,这个思路非常像 redux 里面的数据流。 644 | 645 | ```ts 646 | const test: Global.It = () => { 647 | return (test = (testName, fn, timeout) => (testName, mode, fn, testFn, timeout) => { 648 | return dispatchSync({ 649 | asyncError, 650 | fn, 651 | mode, 652 | name: "add_test", 653 | testName, 654 | timeout, 655 | }); 656 | }); 657 | }; 658 | ``` 659 | 660 | 而单测 `xxx.spec.js` 即 testPath 文件会在 `initialize` 之后会被引入并执行,注意这里引入就会执行这个单测,由于单测 `xxx.spec.js` 文件里面按规范写,会有 `test` 和 `describe` 等代码块,所以这个时候所有的 `test` 和 `describe` 接受的回调函数都会被存到全局的 `state` 里面。 661 | 662 | ```ts 663 | const esm = runtime.unstable_shouldLoadAsEsm(testPath); 664 | if (esm) { 665 | await runtime.unstable_importModule(testPath); 666 | } else { 667 | runtime.requireModule(testPath); 668 | } 669 | ``` 670 | 671 | # jest-runtime 672 | 673 | 这里的会先判断是否 esm 模块,如果是则使用 `unstable_importModule` 的方式引入,否则使用 `requireModule` 的方式引入,具体会进入下面吗这个函数。 674 | 675 | ```ts 676 | this._loadModule(localModule, from, moduleName, modulePath, options, moduleRegistry); 677 | ``` 678 | 679 | \_loadModule 的逻辑只有三个主要部分 680 | 681 | - 判断是否 json 后缀文件,执行 readFile 读取文本,用 transformJson 和 JSON.parse 转格输出内容。 682 | - 判断是否 node 后缀文件,执行 require 原生方法引入模块。 683 | - 不满足上述两个条件的文件,执行 \_execModule 执行模块。 684 | 685 | \_execModule 中会使用 babel 来转化 fs 读取到的源代码,这个 `transformFile` 就是 `packages/jest-runtime/src/index.ts` 的 `transform` 方法。 686 | 687 | ```ts 688 | const transformedCode = this.transformFile(filename, options); 689 | ``` 690 | 691 | ![image](https://user-images.githubusercontent.com/17243165/119518220-ea6c7b00-bdaa-11eb-8723-d8bb89673acf.png) 692 | 693 | \_execModule 中会使用 `createScriptFromCode` 方法调用 node 的原生 vm 模块来真正的执行 js,vm 模块接受安全的源代码,并用 V8 虚拟机配合传入的上下文来立即执行代码或者延时执行代码,这里可以接受不同的作用域来执行同一份代码来运算出不同的结果,非常合适测试框架的使用,这里的注入的 vmContext 就是上面全局改写作用域包含 afterAll,afterEach,beforeAll,beforeEach,describe,it,test,所以我们的单测代码在运行的时候就会得到拥有注入作用域的这些方法。 694 | 695 | ```ts 696 | const vm = require("vm"); 697 | const script = new vm().Script(scriptSourceCode, option); 698 | const filename = module.filename; 699 | const vmContext = this._environment.getVmContext(); 700 | script.runInContext(vmContext, { 701 | filename, 702 | }); 703 | ``` 704 | 705 | ![image](https://user-images.githubusercontent.com/17243165/125756054-4c144a7a-447a-4b5b-973e-e3075b06daa0.png) 706 | 707 | 当上面复写全局方法和保存好 `state` 之后,会进入到真正执行 `describe` 的回调函数的逻辑里面,在 `packages/jest-circus/src/run.ts` 的 `run` 方法里面,这里使用 `getState` 方法把 `describe` 代码块取出来,然后使用 `_runTestsForDescribeBlock` 执行这个函数,然后进入到 `_runTest` 方法,然后使用 `_callCircusHook` 执行前后的钩子函数,使用 `_callCircusTest` 执行。 708 | 709 | ```ts 710 | const run = async (): Promise => { 711 | const { rootDescribeBlock } = getState(); 712 | await dispatch({ name: "run_start" }); 713 | await _runTestsForDescribeBlock(rootDescribeBlock); 714 | await dispatch({ name: "run_finish" }); 715 | return makeRunResult(getState().rootDescribeBlock, getState().unhandledErrors); 716 | }; 717 | 718 | const _runTest = async (test, parentSkipped) => { 719 | // beforeEach 720 | // test 函数块,testContext 作用域 721 | await _callCircusTest(test, testContext); 722 | // afterEach 723 | }; 724 | ``` 725 | 726 | 这是钩子函数实现的核心位置,也是 Jest 功能的核心要素。 727 | 728 | # 最后 729 | 730 | 希望本文能够帮助大家理解 Jest 测试框架的核心实现和原理,感谢大家耐心的阅读,如果文章和笔记能带您一丝帮助或者启发,请不要吝啬你的 Star 和 Fork,文章同步持续更新,你的肯定是我前进的最大动力 😁 731 | -------------------------------------------------------------------------------- /jest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright © 1998 - 2021 Tencent. All Rights Reserved. 3 | * @author enoyao 4 | */ 5 | 6 | const vm = require("vm"); 7 | const path = require("path"); 8 | const fs = require("fs"); 9 | 10 | 11 | const testPath = process.argv.slice(2)[0]; 12 | const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString(); 13 | 14 | const dispatch = event => { 15 | const { fn, type, name, pass } = event; 16 | switch (type) { 17 | case "ADD_TEST": 18 | const { testBlock } = global["STATE_SYMBOL"]; 19 | testBlock.push({ fn, name }); 20 | break; 21 | case "BEFORE_EACH": 22 | const { beforeEachBlock } = global["STATE_SYMBOL"]; 23 | beforeEachBlock.push(fn); 24 | break; 25 | case "BEFORE_ALL": 26 | const { beforeAllBlock } = global["STATE_SYMBOL"]; 27 | beforeAllBlock.push(fn); 28 | break; 29 | case "AFTER_EACH": 30 | const { afterEachBlock } = global["STATE_SYMBOL"]; 31 | afterEachBlock.push(fn); 32 | break; 33 | case "AFTER_ALL": 34 | const { afterAllBlock } = global["STATE_SYMBOL"]; 35 | afterAllBlock.push(fn); 36 | break; 37 | case "COLLECT_REPORT": 38 | const { reports } = global["STATE_SYMBOL"]; 39 | reports.push({ name, pass }); 40 | break; 41 | } 42 | }; 43 | 44 | const createState = () => { 45 | global["STATE_SYMBOL"] = { 46 | testBlock: [], 47 | beforeEachBlock: [], 48 | beforeAllBlock: [], 49 | afterEachBlock: [], 50 | afterAllBlock: [], 51 | reports: [] 52 | }; 53 | }; 54 | 55 | createState(); 56 | 57 | const jest = { 58 | fn(impl = () => { }) { 59 | const mockFn = (...args) => { 60 | mockFn.mock.calls.push(args); 61 | return impl(...args); 62 | }; 63 | mockFn.originImpl = impl; 64 | mockFn.mock = { calls: [] }; 65 | return mockFn; 66 | }, 67 | mock(mockPath, mockExports = {}) { 68 | const path = require.resolve(mockPath, { paths: ["."] }); 69 | require.cache[path] = { 70 | id: path, 71 | filename: path, 72 | loaded: true, 73 | exports: mockExports, 74 | }; 75 | } 76 | }; 77 | 78 | const log = (color, text) => console.log(color, text); 79 | 80 | const expect = (actual) => ({ 81 | toBe(expected) { 82 | if (actual !== expected) { 83 | throw new Error(`${actual} is not equal to ${expected}`); 84 | } 85 | }, 86 | toEqual(expected) { 87 | try { 88 | assert.deepStrictEqual(actual, expected); 89 | } catch { 90 | throw new Error(`${JSON.stringify(actual)} is not equal to ${JSON.stringify(expected)}`); 91 | } 92 | }, 93 | }); 94 | 95 | const context = { 96 | console: console.Console({ stdout: process.stdout, stderr: process.stderr }), 97 | jest, 98 | expect, 99 | require, 100 | afterAll: (fn) => dispatch({ type: "AFTER_ALL", fn }), 101 | afterEach: (fn) => dispatch({ type: "AFTER_EACH", fn }), 102 | beforeAll: (fn) => dispatch({ type: "BEFORE_ALL", fn }), 103 | beforeEach: (fn) => dispatch({ type: "BEFORE_EACH", fn }), 104 | test: (name, fn) => dispatch({ type: "ADD_TEST", fn, name }), 105 | }; 106 | 107 | (async () => { 108 | const start = new Date(); 109 | 110 | vm.createContext(context); 111 | vm.runInContext(code, context); 112 | 113 | const { testBlock, beforeEachBlock, beforeAllBlock, afterEachBlock, afterAllBlock } = global["STATE_SYMBOL"]; 114 | 115 | await new Promise((done) => { 116 | beforeAllBlock.forEach(async (beforeAll) => await beforeAll()); 117 | testBlock.forEach(async (item) => { 118 | const { fn, name } = item; 119 | try { 120 | beforeEachBlock.forEach(async (beforeEach) => await beforeEach()); 121 | await fn.apply(this); 122 | dispatch({ type: "COLLECT_REPORT", name, pass: 1 }); 123 | afterEachBlock.forEach(async (afterEach) => await afterEach()); 124 | log("\x1b[32m%s\x1b[0m", `√ ${name} passed`); 125 | } catch (error) { 126 | dispatch({ type: "COLLECT_REPORT", name, pass: 0 }); 127 | console.error(error); 128 | log("\x1b[32m%s\x1b[0m", `× ${name} error`); 129 | } 130 | }); 131 | afterAllBlock.forEach(async (afterAll) => await afterAll()); 132 | done(); 133 | }) 134 | 135 | const end = new Date(); 136 | log("\x1b[32m%s\x1b[0m", `Time: ${end - start} ms`); 137 | const { reports } = global["STATE_SYMBOL"]; 138 | const pass = reports.reduce((pre, next) => pre.pass + next.pass); 139 | log("\x1b[32m%s\x1b[0m", `All Tests: ${pass}/${reports.length} passed`); 140 | })(); 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-tutorial", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "jest.js", 6 | "scripts": { 7 | "dev": "node jest ./test.spec.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/Wscats/jest-tutorial.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/Wscats/jest-tutorial/issues" 17 | }, 18 | "homepage": "https://github.com/Wscats/jest-tutorial#readme" 19 | } -------------------------------------------------------------------------------- /test.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('fs', { 2 | readFile: jest.fn(() => 'wscats'), 3 | }); 4 | 5 | const fs = require('fs'); 6 | const sum = (a, b) => a + b; 7 | test('sum test', () => { 8 | expect(sum(1, 2)).toBe(3); 9 | }); 10 | 11 | test('fs test', () => { 12 | const text = fs.readFile(); 13 | expect(text).toBe('wscats'); 14 | }); 15 | --------------------------------------------------------------------------------