├── .gitignore ├── LICENSE ├── README.md ├── cover.jpeg ├── deploy.sh ├── docs ├── .vuepress │ ├── config.js │ ├── public │ │ └── images │ │ │ └── logo.png │ └── styles │ │ └── palette.styl ├── README.md ├── bad-translation.png ├── basic │ ├── automation │ │ ├── README.md │ │ ├── add-repo.png │ │ ├── badge.png │ │ ├── coveralls.png │ │ └── signin.png │ ├── component-test │ │ ├── README.md │ │ ├── jest-less-error.png │ │ ├── matchers.png │ │ ├── mock-less.png │ │ ├── stackoverflow.png │ │ └── ts-less-error.png │ ├── config-react │ │ ├── README.md │ │ └── react-preview.png │ ├── getting-started │ │ ├── README.md │ │ ├── coverage.png │ │ ├── jest-config.png │ │ └── test-result.png │ ├── hook-test │ │ ├── README.md │ │ ├── pure-func-error.png │ │ └── useLocation-error.png │ ├── how-to-mock │ │ ├── README.md │ │ └── getter-error.png │ ├── mock-timer │ │ ├── README.md │ │ ├── job-queue.png │ │ ├── log.png │ │ ├── message-queue.png │ │ ├── sleep-error.png │ │ └── sleep-timeout-error.png │ ├── navigation │ │ ├── README.md │ │ ├── img.png │ │ ├── jsdom-global-ts.png │ │ └── location-error.png │ ├── performance │ │ ├── README.md │ │ ├── haste-map.png │ │ ├── jest-architecture.png │ │ ├── multiple-workers.png │ │ ├── single-worker.png │ │ ├── swc.png │ │ ├── transpile.png │ │ └── ts-jest.png │ ├── redux-test │ │ └── README.md │ ├── snapshot-test │ │ ├── README.md │ │ ├── diff-error.png │ │ └── title-preview.png │ ├── static-tool │ │ └── README.md │ ├── tdd │ │ ├── README.md │ │ └── cycle.jpg │ ├── test-environment │ │ ├── README.md │ │ ├── setupFiles-vs-setupFilesAfterEnv.png │ │ ├── storage-env-success.png │ │ ├── storage-error.png │ │ └── storage-setup-success.png │ └── transformer │ │ ├── README.md │ │ ├── error.png │ │ ├── esbuild.png │ │ ├── path-error.png │ │ └── swc.png ├── end │ ├── end.md │ └── github.md ├── intro │ └── why-test │ │ └── README.md ├── kentcdodds.png ├── qrcode.gif └── thoughts │ └── articles.md ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node template 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | ### macOS template 120 | # General 121 | .DS_Store 122 | .AppleDouble 123 | .LSOverride 124 | 125 | # Icon must end with two \r 126 | Icon 127 | 128 | # Thumbnails 129 | ._* 130 | 131 | # Files that might appear in the root of a volume 132 | .DocumentRevisions-V100 133 | .fseventsd 134 | .Spotlight-V100 135 | .TemporaryItems 136 | .Trashes 137 | .VolumeIcon.icns 138 | .com.apple.timemachine.donotpresent 139 | 140 | # Directories potentially created on remote AFP share 141 | .AppleDB 142 | .AppleDesktop 143 | Network Trash Folder 144 | Temporary Items 145 | .apdisk 146 | 147 | .idea 148 | .vscode 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🃏《Jest 实践指南》 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/haixiangyan/jest-tutorial-example/badge.svg?branch=main)](https://coveralls.io/github/haixiangyan/jest-tutorial?branch=main) 4 | ![](https://visitor-badge.glitch.me/badge?page_id=jest-tutorial) 5 | 6 | 7 | 8 | * [《Jest 实践指南》访问链接](https://github.yanhaixiang.com/jest-tutorial/) 9 | * [《Jest 实践指南》配套项目](https://github.com/haixiangyan/jest-tutorial-example) 10 | 11 | ## 前言 12 | 13 | [Jest](https://jestjs.io/) 看似很简单,就像很多博客写的那样: 14 | 15 | ```js 16 | expect(sum(1, 1)).toEqual(2) 17 | ``` 18 | 19 | 然而在真实业务中,写出一个好测试的难度并没有大家想的那么低。我总结了一下写测试的几个难点: 20 | 21 | **不会配置。** Jest 的上手文档非常简单,甚至不需要配置。但真实情况是只要一个配置没配好,所有测试都跑不起来。测试不像开发,代码有问题可以慢慢调。 22 | 测试是一个 0 - 1 游戏,不是成功就是失败,挫败感非常强。 23 | 24 | **不知道要怎么 Mock。** 这个绝对是经典中的经典。虽然官方文档有教程,但是真实的业务往往不是那么理想,远比文档要复杂的多。 25 | 26 | **不会构造测试用例。** 刚接触测试时,很容易把做业务那套 “实现 XXX 功能” 的想法代入测试。但测试的重点不在于实现功能,而是构造用例。 27 | 28 | **没有测试策略。** 上面是 “技” 的难点,测试还有 “术” 的难点。闷着头一通肝测试代码并不高效,使用合适的测试策略远比写 10 个测试用例重要。 29 | 30 | 上面这些问题很容易让人写出难以维护和复杂的测试。只要业务一改,不仅要维护业务代码还要维护测试代码。 31 | 这时,你不禁感叹:“测试真浪费时间”,最终放弃写测试,直接开摆。 32 | 33 | **好的测试会让你获得很高的代码信心,而不好的测试则会严重拖垮项目开发。所以,大家所厌恶的不应该是测试本身,而是那些维护性差的测试。** 34 | 35 | ## 目的 36 | 37 | 我在网上翻找关于前端测试的资料时,我发现真的太少了,几乎可以分为几类: 38 | 39 | 1. **入门类。** 安装 Jest,外加 `expect(1 + 1).toEqual(2)` 小例子 40 | 2. **API 说明书类。** 类似于把 Jest 官网抄了一遍 41 | 3. **理论类。** 是什么、为什么、测试分类等,但最重要的 “怎么做” 没有说 42 | 43 | 并不是说这些文章不好,只是,这些文章大多数停留在最初级,很不利于其他同学来学习一门新技术。 44 | 45 | 同时,我还拜访了一下 [Jest 的官网](https://jestjs.io/zh-Hans/) 。没想到,都 2022 年了,中文翻译依然这么难看: 46 | 47 | ![](./docs/bad-translation.png) 48 | 49 | 先不说翻译的正确性如何,单看这中文的内容就让人没有想看下去的欲望,真希望 Jest 能找稍微专业一点的人来做翻译。 50 | 由于官网的中文翻译做的实在太烂,遇到问题几乎在中文社区是找不到的。 51 | 52 | 终于,我看到了 React Testing Library 作者 Kent C. Dodds 的 [博客](https://kentcdodds.com/) 。 53 | 54 | ![](./docs/kentcdodds.png) 55 | 56 | 他写了很多关于测试思路的文章,每一篇都非常精彩。**受他的启发,我觉得有必要把这些思想和技巧分享出来,最终形成了这本小书。** 57 | 58 | 59 | ## 内容 60 | 61 | > 此次教程主要分享测试的思路为主,虽然以 React 为主要技术栈,但使用其它技术栈的读者依然可以流畅阅读。 62 | 63 | **本教程是我结合了自身实践、Kent C. Dodds 文章、StackOverflow、Github Issue 以及别的博客最终总结出来的一套实践指南。** 64 | 65 | 小书包含 3 部分: 66 | 67 | **基础实践。** 从 0 到 1 写项目和测试,每一章会通过一个业务例子来分享测试难点、解法和思路。 68 | 69 | **测试思路。** 分享一些 Kent 的文章(中文翻译)以及测试总结。 70 | 71 | **[配套项目](https://github.com/haixiangyan/jest-tutorial-example)。** 如果你在某一步卡壳了,也可以参考这个项目。 72 | 73 | 最近给这个 Repo 开了一个 [讨论区](https://github.com/haixiangyan/jest-tutorial/discussions) ,如果你有任何问题(Jest、测试、小书) 74 | ,都可以在这里一起讨论 😄。 75 | 76 | ## 求关注 77 | 78 | **这教程 + 配套项目写了 3 周,说实话挺累的。原创不易,打赏就不必了,观众老爷省着吧。只求大家多关注一下我的新公众号【写代码的海怪】。** 79 | 80 | ![](./docs/qrcode.gif) 81 | -------------------------------------------------------------------------------- /cover.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/cover.jpeg -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 确保脚本抛出遇到的错误 4 | set -e 5 | 6 | # 生成静态文件 7 | npm run docs:build 8 | 9 | # 进入生成的文件夹 10 | cd docs/.vuepress/dist 11 | 12 | # 如果是发布到自定义域名 13 | # echo 'www.example.com' > CNAME 14 | 15 | git init 16 | git add -A 17 | git commit -m 'deploy' 18 | 19 | # 如果发布到 https://.github.io 20 | # git push -f git@github.com:/.github.io.git master 21 | 22 | # 如果发布到 https://.github.io/ 23 | git push -f git@github.com:haixiangyan/jest-tutorial.git master:gh-pages 24 | 25 | cd - 26 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | base: "/jest-tutorial/", 3 | title: "Jest 实践指南", 4 | description: "从基础实战到测试思维,带你全面了解和掌握前端测试", 5 | head: [ 6 | [ 7 | "meta", 8 | { 9 | name: "keywords", 10 | content: 11 | "jest, testing, typescript, eslint, 前端, 测试", 12 | }, 13 | ], 14 | ["meta", { name: "author", content: "海怪" }], 15 | ], 16 | plugins: [ 17 | "@vuepress/medium-zoom", 18 | "@vuepress/back-to-top", 19 | "@vuepress/active-header-links", 20 | ], 21 | markdown: { 22 | lineNumbers: true, 23 | }, 24 | themeConfig: { 25 | logo: "/images/logo.png", 26 | repo: "https://github.com/haixiangyan/jest-tutorial", 27 | nav: [ 28 | { 29 | text: "Issue", 30 | link: "https://github.com/haixiangyan/jest-tutorial/issues", 31 | }, 32 | ], 33 | sidebar: [ 34 | { 35 | title: "介绍", 36 | collapsable: false, 37 | children: [ 38 | "/", 39 | "/intro/why-test/", 40 | ], 41 | }, 42 | { 43 | title: "基础实践", 44 | collapsable: false, 45 | children: [ 46 | "/basic/getting-started/", 47 | "/basic/transformer/", 48 | "/basic/test-environment/", 49 | "/basic/navigation/", 50 | "/basic/tdd/", 51 | "/basic/mock-timer/", 52 | "/basic/config-react/", 53 | "/basic/snapshot-test/", 54 | "/basic/component-test/", 55 | "/basic/how-to-mock/", 56 | "/basic/redux-test/", 57 | "/basic/hook-test/", 58 | "/basic/static-tool/", 59 | "/basic/performance/", 60 | "/basic/automation/", 61 | ], 62 | }, 63 | { 64 | title: "测试思路", 65 | collapsable: false, 66 | children: [ 67 | "/thoughts/articles.md", 68 | ], 69 | }, 70 | { 71 | title: "最后", 72 | collapsable: false, 73 | children: [ 74 | "/end/github.md", 75 | "/end/end.md", 76 | ] 77 | } 78 | ], 79 | 80 | // 搜索 81 | search: true, 82 | searchMaxSuggestions: 10, 83 | lastUpdated: "最后更新", 84 | }, 85 | 86 | // PWA 配置 87 | serviceWorker: true, 88 | }; 89 | -------------------------------------------------------------------------------- /docs/.vuepress/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/.vuepress/public/images/logo.png -------------------------------------------------------------------------------- /docs/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | // 颜色 2 | $accentColor = #924058 3 | $textColor = #2c3e50 4 | $borderColor = #eaecef 5 | $codeBgColor = #282c34 6 | $arrowBgColor = #ccc 7 | $badgeTipColor = #924058 8 | $badgeWarningColor = darken(#ffe564, 35%) 9 | $badgeErrorColor = #DA5961 10 | 11 | // 布局 12 | $navbarHeight = 3.6rem 13 | $sidebarWidth = 20rem 14 | $contentWidth = 740px 15 | $homePageWidth = 960px 16 | 17 | // 响应式变化点 18 | $MQNarrow = 959px 19 | $MQMobile = 719px 20 | $MQMobileNarrow = 419px 21 | 22 | .navbar .logo { 23 | margin-right: 0; 24 | } 25 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 小书介绍 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/haixiangyan/jest-tutorial-example/badge.svg?branch=main)](https://coveralls.io/github/haixiangyan/jest-tutorial?branch=main) 4 | ![](https://visitor-badge.glitch.me/badge?page_id=jest-tutorial) 5 | 6 | 7 | 8 | * [《Jest 实践指南》访问链接](https://github.yanhaixiang.com/jest-tutorial/) 9 | * [《Jest 实践指南》配套项目](https://github.com/haixiangyan/jest-tutorial-example) 10 | 11 | ## 测试难点 12 | 13 | [Jest](https://jestjs.io/) 看似很简单,就像很多博客写的那样: 14 | 15 | ```js 16 | expect(sum(1, 1)).toEqual(2) 17 | ``` 18 | 19 | 然而在真实业务中,写出一个好测试的难度并没有大家想的那么低。我总结了一下写测试的几个难点: 20 | 21 | **不会配置。** Jest 的上手文档非常简单,甚至不需要配置。但真实情况是只要一个配置没配好,所有测试都跑不起来。测试不像开发,代码有问题可以慢慢调。 22 | 测试是一个 0 - 1 游戏,不是成功就是失败,挫败感非常强。 23 | 24 | **不知道要怎么 Mock。** 这个绝对是经典中的经典。虽然官方文档有教程,但是真实的业务往往不是那么理想,远比文档要复杂的多。 25 | 26 | **不会构造测试用例。** 刚接触测试时,很容易把做业务那套 “实现 XXX 功能” 的想法代入测试。但测试的重点不在于实现功能,而是构造用例。 27 | 28 | **没有测试策略。** 上面是 “技” 的难点,测试还有 “术” 的难点。闷着头一通肝测试代码并不高效,使用合适的测试策略远比写 10 个测试用例重要。 29 | 30 | 上面这些问题很容易让人写出难以维护和复杂的测试。只要业务一改,不仅要维护业务代码还要维护测试代码。 31 | 这时,你不禁感叹:“测试真浪费时间”,最终放弃写测试,直接开摆。 32 | 33 | **好的测试会让你获得很高的代码信心,而不好的测试则会严重拖垮项目开发。所以,大家所厌恶的不应该是测试本身,而是那些维护性差的测试。** 34 | 35 | ## 目的 36 | 37 | 我在网上翻找关于前端测试的资料时,我发现真的太少了,几乎可以分为几类: 38 | 39 | 1. **入门类。** 安装 Jest,外加 `expect(1 + 1).toEqual(2)` 小例子 40 | 2. **API 说明书类。** 类似于把 Jest 官网抄了一遍 41 | 3. **理论类。** 是什么、为什么、测试分类等,但最重要的 “怎么做” 没有说 42 | 43 | 并不是说这些文章不好,只是,这些文章大多数停留在最初级,很不利于其它同学来学习一门新技术。 44 | 45 | 同时,我还拜访了一下 [Jest 的官网](https://jestjs.io/zh-Hans/) 。没想到,都 2022 年了,中文翻译依然这么难看: 46 | 47 | ![](./bad-translation.png) 48 | 49 | 先不说翻译的正确性如何,单看这中文的内容就让人没有想看下去的欲望,真希望 Jest 能找稍微专业一点的人来做翻译。 50 | 由于官网的中文翻译做的实在太烂,遇到问题几乎在中文社区是找不到的。 51 | 52 | 终于,我看到了 React Testing Library 作者 Kent C. Dodds 的 [博客](https://kentcdodds.com/) 。 53 | 54 | ![](./kentcdodds.png) 55 | 56 | 他写了很多关于测试思路的文章,每一篇都非常精彩。**受他的启发,我觉得有必要把这些思想和技巧分享出来,最终形成了这本小书。** 57 | 58 | 59 | ## 内容 60 | 61 | ::: tip 62 | 此次教程主要分享测试的思路为主,虽然以 React 为主要技术栈,但使用其它技术栈的读者依然可以流畅阅读。 63 | ::: 64 | 65 | **本教程是我结合了自身实践、Kent C. Dodds 文章、StackOverflow、Github Issue 以及别的博客最终总结出来的一套实践指南。** 66 | 67 | 小书包含 3 部分: 68 | 69 | **基础实践。** 从 0 到 1 写项目和测试,每一章会通过一个业务例子来分享测试难点、解法和思路。 70 | 71 | **测试思路。** 分享一些 Kent 的文章(中文翻译)以及测试总结。 72 | 73 | **[配套项目](https://github.com/haixiangyan/jest-tutorial-example)。** 如果你在某一步卡壳了,也可以参考这个项目。 74 | 75 | 最近给这个 Repo 开了一个 [讨论区](https://github.com/haixiangyan/jest-tutorial/discussions) ,如果你有任何问题(Jest、测试、小书) 76 | ,都可以在这里一起讨论 😄。 77 | 78 | ## 求关注 79 | 80 | **这教程 + 配套项目写了 3 周,说实话挺累的。原创不易,打赏就不必了,观众老爷省着吧。只求大家多关注一下我的新公众号【写代码的海怪】。** 81 | 82 | ![](./qrcode.gif) 83 | -------------------------------------------------------------------------------- /docs/bad-translation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/bad-translation.png -------------------------------------------------------------------------------- /docs/basic/automation/README.md: -------------------------------------------------------------------------------- 1 | # 自动化测试 2 | 3 | 每次手动 `npm run test` 跑测试是很痛苦的。通常我们会把执行测试这一步放到流水线中,这也是前端工程化非常重要的一步,称为 **“自动化测试”**。 4 | 5 | 正好我们可以使用 Github 推出的流水线工具—— [Github Actions](https://github.com/features/actions) 。这一章,就带大家一起配置一下吧。 6 | 7 | ## Github Actions 8 | 9 | 在根目录添加 `.github/workflows/node.js.yml`: 10 | 11 | ```yml 12 | # .github/workflows/node.js.yml 13 | name: Node.js CI 14 | 15 | on: 16 | push: 17 | branches: [ main ] 18 | pull_request: 19 | branches: [ main ] 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | 26 | strategy: 27 | matrix: 28 | node-version: [16.x] 29 | 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | - run: npm install 37 | - run: npm test 38 | ``` 39 | 40 | 这里 `on` 会监听 `main` 分支的 `git push` 和 PR 提交两个操作。 **开发者在 `main` 分支推代码或者 PR 合并都会执行下面配置的 `jobs`。** 41 | 下面的 `jobs` 也很好理解,就是执行一些 `bash` 脚本。 42 | 43 | 现在把代码推到 `main`,会发现 Github Actions 开始执行,并成功。 44 | 45 | ## Coveralls 46 | 47 | 还记得刚开始时我们配置的 Jest 测试覆盖率么?如果流水线每次跑完都生成一份可视化的测试报告就完美了,不仅能做预警,还能实时了解整体测试覆盖情况。 48 | 49 | 比较著名的测试平台有 [Coveralls](https://coveralls.io/),它能够读取 Jest 生成的 `lcov.info` 覆盖率文件,并以可视化的方法展示出来: 50 | 51 | ![](./coveralls.png) 52 | 53 | 首先在 [Coveralls 官网](https://coveralls.io/sign-in) 用 Github 账号登入: 54 | 55 | ![](./signin.png) 56 | 57 | 接下来,添加你的 Github 项目: 58 | 59 | ![](./add-repo.png) 60 | 61 | 添加完项目并没有结束,如果你使用 [Travis CI](https://travis-ci.org/) ,可能要手动写命令把 `lcov.info` 传给 [Coveralls](https://coveralls.io) 。而 Github Actions 里有 Coveralls 组件,使用它就能自动完成: 62 | 63 | ```yaml 64 | name: Node.js CI 65 | 66 | on: 67 | push: 68 | branches: [ main ] 69 | pull_request: 70 | branches: [ main ] 71 | 72 | jobs: 73 | build: 74 | 75 | runs-on: ubuntu-latest 76 | 77 | strategy: 78 | matrix: 79 | node-version: [16.x] 80 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 81 | 82 | steps: 83 | - uses: actions/checkout@v3 84 | - name: Use Node.js ${{ matrix.node-version }} 85 | uses: actions/setup-node@v3 86 | with: 87 | node-version: ${{ matrix.node-version }} 88 | - run: npm install 89 | - run: npm test 90 | 91 | - name: Coveralls 92 | uses: coverallsapp/github-action@master 93 | with: 94 | github-token: ${{ secrets.GITHUB_TOKEN }} 95 | ``` 96 | 97 | 再次把代码推到 `main` 分支,等上一会,就可以在 [Coveralls 项目列表页](https://coveralls.io/repos) 看到你的测试覆盖率情况了。 98 | 99 | 一旦把测试报告推到 Coveralls,你就可以在项目详情页里找到这个 Badge: 100 | 101 | ![](./badge.png) 102 | 103 | 点击 `embed` 可以看到有多种嵌入方法,一般会把这个 Badge 放到 `README.md`,让项目看起来更有逼格: 104 | 105 | [![Coverage Status](https://coveralls.io/repos/github/haixiangyan/jest-tutorial-example/badge.svg?branch=main)](https://coveralls.io/github/haixiangyan/jest-tutorial-example?branch=main) 106 | 107 | ## 总结 108 | 109 | 这一章我们学会了如何配置 Github Actions,在每次推代码和合并 PR 时自动跑测试,并通过 `coverallsapp/github-action@master` 组件把测试覆盖率报告发送给 [Coveralls](https://coveralls.io),将测试情况可视化。 110 | -------------------------------------------------------------------------------- /docs/basic/automation/add-repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/automation/add-repo.png -------------------------------------------------------------------------------- /docs/basic/automation/badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/automation/badge.png -------------------------------------------------------------------------------- /docs/basic/automation/coveralls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/automation/coveralls.png -------------------------------------------------------------------------------- /docs/basic/automation/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/automation/signin.png -------------------------------------------------------------------------------- /docs/basic/component-test/README.md: -------------------------------------------------------------------------------- 1 | # 组件测试 2 | 3 | 终于到正片了,这一章我们来讲讲组件测试中的一些技巧和策略吧。 4 | 5 | ## 需求 6 | 7 | 首先,我们还是以一个需求来开始这一章:**实现一个 `AuthButton`,通过 `getLoginState` 获取当前用户的身份并在按钮中展示用户身份。** 8 | 9 | 简单分析一下这个需求: 10 | 11 | 1. 实现 `AuthButton` 业务组件 12 | 2. 在 API 函数 `getLoginState` 发请求获取用户身份 13 | 3. 把 Http 请求的返回 `loginStateResponse` 展示到按钮上 14 | 15 | 我们先来安装一下 `axios`: 16 | 17 | ```shell 18 | npm i axios@0.26.1 19 | ``` 20 | 21 | 然后添加 `src/apis/user.ts`,里面写发送获取用户角色身份的 Http 请求: 22 | 23 | ```ts 24 | import axios from "axios"; 25 | 26 | // 用户角色身份 27 | export type UserRoleType = "user" | "admin"; 28 | 29 | // 返回 30 | export interface GetUserRoleRes { 31 | userType: UserRoleType; 32 | } 33 | 34 | // 获取用户角色身份 35 | export const getUserRole = async () => { 36 | return axios.get("https://mysite.com/api/role"); 37 | }; 38 | ``` 39 | 40 | 添加业务组件 `src/components/AuthButton/index.tsx`: 41 | 42 | ```tsx 43 | // src/components/AuthButton/index.tsx 44 | import React, { FC, useEffect, useState } from "react"; 45 | import { Button, ButtonProps, message } from "antd"; 46 | import classnames from "classnames"; 47 | import styles from "./styles.module.less"; 48 | import { getUserRole, UserRoleType } from "apis/user"; 49 | 50 | type Props = ButtonProps; 51 | 52 | // 身份文案 Mapper 53 | const mapper: Record = { 54 | user: "普通用户", 55 | admin: "管理员", 56 | }; 57 | 58 | const AuthButton: FC = (props) => { 59 | const { children, className, ...restProps } = props; 60 | 61 | const [userType, setUserType] = useState(); 62 | 63 | // 获取用户身份并设置 64 | const getLoginState = async () => { 65 | const res = await getUserRole(); 66 | setUserType(res.data.userType); 67 | }; 68 | 69 | useEffect(() => { 70 | getLoginState().catch((e) => message.error(e.message)); 71 | }, []); 72 | 73 | return ( 74 | 78 | ); 79 | }; 80 | 81 | export default AuthButton; 82 | ``` 83 | 84 | 这里我们还不忘搞点花里胡哨,引入了 `src/components/AuthButton/styles.module.less`: 85 | 86 | ```less 87 | // src/components/AuthButton/styles.module.less 88 | .authButton { 89 | border: 1px solid red; 90 | } 91 | ``` 92 | 93 | 最后在 `App.tsx` 里使用一下: 94 | 95 | ```tsx 96 | // src/App.tsx 97 | const App = () => { 98 | return ( 99 |
100 |
101 | 登录 102 |
103 |
104 | ) 105 | } 106 | ``` 107 | 108 | ## Less 的引入问题 109 | 110 | 好,现在我们一个一个问题来看。写完上面的代码后,TS 首先受不了报错: 111 | 112 | ![](./ts-less-error.png) 113 | 114 | 我们需要在全局类型声明文件 `src/types/global.d.ts` 里添加 `.less` 文件的类型定义: 115 | 116 | ```ts 117 | // src/types/global.d.ts 118 | declare module "*.less" { 119 | const content: any; 120 | export default content; 121 | } 122 | ``` 123 | 124 | 相信大家都能看懂 `AuthButton` 的代码,就不展示页面的情况了,下面主要来讨论测试用例的问题。 125 | 创建一个用例 `tests/components/AuthButton/simple.test.tsx`: 126 | 127 | ```tsx 128 | // tests/components/AuthButton/simple.test.tsx 129 | import { render, screen } from "@testing-library/react"; 130 | import AuthButton from "components/AuthButton"; 131 | import React from "react"; 132 | 133 | describe('AuthButton', () => { 134 | it('可以正常展示', () => { 135 | render(登录) 136 | 137 | expect(screen.getByText('登录')).toBeDefined(); 138 | }); 139 | }) 140 | ``` 141 | 142 | 执行一下,直接报错: 143 | 144 | ![](./jest-less-error.png) 145 | 146 | 前面的章节说了 **Jest 不会转译任何内容**,因此我们一直用 `tsc` 来转译 TypeScript。**由于 `tsc` 看不懂引入的 `.less`,导致了 `Unexpected Token` 报错。** 147 | 148 | 相信有的同学说:*刚刚配置 React 时,不是有一个 `webpack.config.js` 配置了 `less-loader` 了么?为什么还是会报错?* 149 | 150 | **很简单,Jest 只是 Test Runner,只负责跑测试,`tsc` 负责转译 `.ts` 文件,`webpack` 则作为脚手架用于跑项目的工具,所以这三者本身不存在任何交集。** 151 | 只不过,中间 `ts-jest` 把 `jest` 和 `tsc` 联系起来,`ts-loader` 则把 `webpack` 和 `tsc` 联系在了一起。所以无论你的 Webpack 配得怎么天花乱坠,Jest 根本不看。 152 | 153 | 那问题要怎么解决呢?**比较推荐的方法是把 `.less` 转译成空文件。** 这在 [StackOverflow 上有很多例子](https://stackoverflow.com/questions/54627028/jest-unexpected-token-when-importing-css) : 154 | 155 | ![](./stackoverflow.png) 156 | 157 | 除了 `.less` 文件,我们还要对非 JS 静态资源做转译,比如 `jpg`, `svg`, `png` 等等(这些不会影响测试)。 158 | 这里推荐使用 [jest-transform-stub](https://www.npmjs.com/package/jest-transform-stub) 这个库: 159 | 160 | ```shell 161 | npm i -D jest-transform-stub@2.0.0 162 | ``` 163 | 164 | 然后在 `jest.config.js` 里添加转译配置: 165 | 166 | ```js 167 | // jest.config.js 168 | module.exports = { 169 | // ... 170 | transform: { 171 | ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub" 172 | } 173 | } 174 | ``` 175 | 176 | 现在再来跑测试就会发现测试通过。 177 | 178 | ## 更多 Matchers 179 | 180 | 不知道你会不会觉得 `expect(screen.getByText('登录')).toBeDefined();` 有点不靠谱啊。假如 `getByText` 返回的是一个 `null`,这样一来就算找不到元素依然会测试通过。 181 | 实际上,我们更希望的断言是:判断带有 “登录” 的元素是否渲染出来,而不是看拿到的元素是否为 `undefined` 或者 `null`。 182 | 183 | **这种要判断 XXX 的东西,我们称之为 Matcher(中文翻译为匹配器,个人觉得翻译很烂,记住 Matcher 就好)**。 184 | 正好 [@testing-library/jest-dom](https://www.npmjs.com/package/@testing-library/jest-dom) 这个库提供了很多关于 DOM 的 Matcher API: 185 | 186 | * toBeDisabled 187 | * toBeEnabled 188 | * toBeEmptyDOMElement 189 | * toBeInTheDocument 190 | * toBeInvalid 191 | * toBeRequired 192 | * toBeValid 193 | * toBeVisible 194 | * toContainElement 195 | * toContainHTML 196 | * toHaveAccessibleDescription 197 | * toHaveAccessibleName 198 | * toHaveAttribute 199 | * toHaveClass 200 | * toHaveFocus 201 | * toHaveFormValues 202 | * toHaveStyle 203 | * toHaveTextContent 204 | * toHaveValue 205 | * toHaveDisplayValue 206 | * toBeChecked 207 | * toBePartiallyChecked 208 | * toHaveErrorMessage 209 | 210 | 下面我们安装一下: 211 | 212 | ```shell 213 | npm i -D @testing-library/jest-dom@5.16.4 214 | ``` 215 | 216 | 然后在 `tests/jest-setup.ts` 里引入一下: 217 | 218 | ```ts 219 | // tests/jest-setup.ts 220 | import '@testing-library/jest-dom' 221 | // ... 222 | ``` 223 | 224 | 同时,要在 `tsconfig.json` 里引入这个库的类型声明: 225 | 226 | ```json 227 | { 228 | "compilerOptions": { 229 | "types": ["node", "jest", "@testing-library/jest-dom"] 230 | } 231 | } 232 | ``` 233 | 234 | 然后我们的用例就可以写成这样了: 235 | 236 | ```tsx 237 | describe('AuthButton', () => { 238 | it('可以正常展示', () => { 239 | render(登录) 240 | 241 | expect(screen.getByText('登录')).toBeInTheDocument(); 242 | }); 243 | }) 244 | ``` 245 | 246 | 这样一来,我们就不用担心 `getByText` 返回值了。 247 | 248 | ::: warning 249 | 如果你的项目报了 `Entry point of type library '@testing-library/jest-dom' specified in compilerOptions` 这个错误, 250 | 可以按照 [这个 Issue](https://github.com/haixiangyan/jest-tutorial/issues/26) 来修复。 251 | ::: 252 | 253 | ## 三种 Mock 思路 254 | 255 | 上面只是一个 Demo 测试,现在我们来测这个组件的功能。 256 | 257 | ### Mock Axios 258 | 259 | 相信大家看过不少教 Jest Mock 的文章,它们经常用的第一个例子就是 Mock `axios`。这里也用了 `axios` 发请求,我们可以来 Mock 一下。 260 | 添加 `tests/components/AuthButton/mockAxios.test.tsx`: 261 | 262 | ```tsx 263 | // tests/components/AuthButton/mockAxios.test.tsx 264 | import React from "react"; 265 | import axios from "axios"; 266 | import { render, screen } from "@testing-library/react"; 267 | import AuthButton from "components/AuthButton"; 268 | 269 | // 更偏向细节,效果并不好 270 | describe("AuthButton Mock Axios", () => { 271 | it("可以正确展示普通用户按钮内容", async () => { 272 | jest.spyOn(axios, "get").mockResolvedValueOnce({ 273 | // 其它的实现... 274 | data: { userType: "user" }, 275 | }); 276 | 277 | render(你好); 278 | 279 | expect(await screen.findByText("普通用户你好")).toBeInTheDocument(); 280 | }); 281 | 282 | it("可以正确展示管理员按钮内容", async () => { 283 | jest.spyOn(axios, "get").mockResolvedValueOnce({ 284 | // 其它的实现... 285 | data: { userType: "admin" }, 286 | }); 287 | 288 | render(你好); 289 | 290 | expect(await screen.findByText("管理员你好")).toBeInTheDocument(); 291 | }); 292 | }); 293 | ``` 294 | 295 | 上面我们分别对两个用例的 `axios.get` 进行了 Mock,使得一个 Mock 返回 `user`,另一个 Mock 返回 `admin`。 296 | 最后,在这两个用例里分别断言 `` 的渲染内容。 297 | 298 | Mock `axios` 确实是一个方法,但只有这种方法么?No! 299 | 300 | ### Mock API 函数 301 | 302 | 另一种方法是 Mock `apis/user.ts` 里的 `getUserRole` API 函数,一样能达到相同的效果: 303 | 304 | ```tsx 305 | // tests/components/AuthButton/mockGetUserRole.test.tsx 306 | import React from "react"; 307 | import { render, screen } from "@testing-library/react"; 308 | import AuthButton from "components/AuthButton"; 309 | // 注意:这里要写成 * as userUtils!!! 310 | import * as userUtils from "apis/user"; 311 | import { AxiosResponse } from "axios"; 312 | 313 | // 也很偏向细节,效果也不好 314 | describe("AuthButton Mock Axios", () => { 315 | it("可以正确展示普通用户按钮内容", async () => { 316 | jest.spyOn(userUtils, "getUserRole").mockResolvedValueOnce({ 317 | // 其它的实现... 318 | data: { userType: "user" }, 319 | } as AxiosResponse); 320 | 321 | render(你好); 322 | 323 | expect(await screen.findByText("普通用户你好")).toBeInTheDocument(); 324 | }); 325 | 326 | it("可以正确展示管理员按钮内容", async () => { 327 | jest.spyOn(userUtils, "getUserRole").mockResolvedValueOnce({ 328 | // 其它的实现... 329 | data: { userType: "admin" }, 330 | } as AxiosResponse); 331 | 332 | render(你好); 333 | 334 | expect(await screen.findByText("管理员你好")).toBeInTheDocument(); 335 | }); 336 | }); 337 | ``` 338 | 339 | 和第一种方法类似,我们依然监听了某个函数(这里是 `getUserRole`),通过 Mock 其返回来模拟不同场景。 340 | 341 | ### Mock Http 342 | 343 | 那还有没有别的方法呢?当然有!我们可以不 Mock 任何函数实现,只对 Http 请求进行 Mock!先安装 [msw](https://github.com/mswjs/msw) : 344 | 345 | ::: tip 346 | [msw](https://github.com/mswjs/msw) 可以拦截指定的 Http 请求,有点类似 [Mock.js](http://mockjs.com/) ,是做测试时一个非常强大好用的 Http Mock 工具。 347 | ::: 348 | 349 | ```shell 350 | npm i -D msw@0.39.2 351 | ``` 352 | 353 | 先在 `tests/mockServer/handlers.ts` 里添加 Http 请求的 Mock Handler: 354 | 355 | ```ts 356 | import { rest } from "msw"; 357 | 358 | const handlers = [ 359 | rest.get("https://mysite.com/api/role", async (req, res, ctx) => { 360 | return res( 361 | ctx.status(200), 362 | ctx.json({ 363 | userType: "user", 364 | }) 365 | ); 366 | }), 367 | ]; 368 | 369 | export default handlers; 370 | ``` 371 | 372 | 然后在 `tests/mockServer/server.ts` 里使用这些 `handlers` 创建 Mock Server 并导出它: 373 | 374 | ```ts 375 | import { setupServer } from "msw/node"; 376 | import handlers from "./handlers"; 377 | 378 | const server = setupServer(...handlers); 379 | 380 | export default server; 381 | ``` 382 | 383 | 最后,在我们的 `tests/jest-setup.ts` 里使用 Mock Server: 384 | 385 | ```ts 386 | import server from "./mockServer/server"; 387 | 388 | beforeAll(() => { 389 | server.listen(); 390 | }); 391 | 392 | afterEach(() => { 393 | server.resetHandlers(); 394 | }); 395 | 396 | afterAll(() => { 397 | server.close(); 398 | }); 399 | ``` 400 | 401 | 这样一来,在所有测试用例中都能获得 `handlers.ts` 里的 Mock 返回了。**如果你想在某个测试文件中想单独指定某个接口的 Mock 返回, 402 | 可以使用 `server.use(mockHandler)` 来实现。** 我们以 `` 为例: 403 | 404 | ```tsx 405 | // tests/components/AuthButton/mockHttp.test.tsx 406 | // 更偏向真实用例,效果更好 407 | import server from "../../mockServer/server"; 408 | import { rest } from "msw"; 409 | import { render, screen } from "@testing-library/react"; 410 | import AuthButton from "components/AuthButton"; 411 | import React from "react"; 412 | import { UserRoleType } from "apis/user"; 413 | 414 | // 初始化函数 415 | const setup = (userType: UserRoleType) => { 416 | server.use( 417 | rest.get("https://mysite.com/api/role", async (req, res, ctx) => { 418 | return res(ctx.status(200), ctx.json({ userType })); 419 | }) 420 | ); 421 | }; 422 | 423 | describe("AuthButton Mock Http 请求", () => { 424 | it("可以正确展示普通用户按钮内容", async () => { 425 | setup("user"); 426 | 427 | render(你好); 428 | 429 | expect(await screen.findByText("普通用户你好")).toBeInTheDocument(); 430 | }); 431 | 432 | it("可以正确展示管理员按钮内容", async () => { 433 | setup("admin"); 434 | 435 | render(你好); 436 | 437 | expect(await screen.findByText("管理员你好")).toBeInTheDocument(); 438 | }); 439 | }); 440 | ``` 441 | 442 | 这里声明了一个 `setup` 函数,用于在每个用例前初始化 Http 请求的 Mock 返回。通过传不同值给 `setup` 就可以灵活模拟测试场景了。 443 | 444 | ## 如何取舍 445 | 446 | 现在我们一共有 3 种 Mock 策略: 447 | 1. **Mock `axios`** 448 | 2. **Mock API 实现** 449 | 3. **Mock Http 请求** 450 | 451 | 到底如何选择呢?你可以停下来思考一两分钟。 452 | 453 | 在说出我的选择前,我们不妨想想我们做测试的初衷是什么。是因为我让你测试才测试?还是因为你要完成那可怜的 100% 覆盖率?再或者是因为没有测试显得你的项目很 Low?都不是! 454 | **无论后端测试也好、前端测试也好,不管你要测什么,测试的目的都是为了让你能对测过的代码充满信心(Confidence)。** 455 | 456 | 你可以通过作弊的手法骗过覆盖率,可以通过写一些无聊没有意义的用例骗过你的老板,也可以干脆去掉测试环节,让大家直接在 `master` 上推代码。即使你能骗过所有人, 457 | 你依然无法骗自己 —— 在重构和代码改造时,你仍然不信任写过的代码。那么,**什么样的测试才能提高代码自信呢?很简单:像真实用户那样去测你的代码。** 458 | 459 | 这里说的用户一共分为两种: 460 | 1. **普通用户。** 也即使用网页的人 461 | 2. **开发者。** 接口使用者、数据消费者、API 调用侠 462 | 463 | 刚接触测试的人很容易犯的一个错误就是:**过度测试代码细节!** 虽然我们经常讨论前端单测,单测不就是白盒么?白盒就是要关注代码细节呀。但是很多时候,特别是在测业务相关逻辑的时候, 464 | **过度关注代码细节无形中会创造第三种用户:测试用户。** 也即,**只有测试用户才会像你测试用例那样使用你的代码/组件/页面。** 465 | 466 | 我们回到刚刚的例子,可以看出来 Mock `axios` 和 Mock `getUserRole` 都融入很多代码细节,无论对于使用 `AuthButton` 组件的开发者以及和 `AuthButton` 交互的用户, 467 | 都不应该关注组件里到底用的是哪个接口和调用了哪个函数,这太细节了!这就产生了测试用户,因为只有测试用户才会用假的 `axios` 和 `getUserRole` 实现。 468 | 469 | 对于上面两类用户来说,他们感知到的就是管理员和普通用户两个 case,最能模拟出这种感知的就是第三种 Mock —— Mock Http 请求,所以,**在这个例子中,我觉得对 Http 请求 Mock 更合理一些。** 470 | 471 | 测试实现细节还有很多缺点,比如,在 Mock `getUserRole` 这个方法中,如果有人把 `getUserRole` 重命名成 `getUserRoleById`,那么你的测试直接就挂了。这就是上一章所说的 **“假错误”**。 472 | 对于不熟悉 `AuthButton` 实现的人来说,要找到测试失败的源头是比较麻烦的。 473 | 474 | ## 如何避免测试用户 475 | 476 | 当然,上面说的并不是完全不测代码细节。对于一些工具纯函数是需要深入测好每个 `if-else` 的,**这是因为这种场景下 “真实用户” 变成了开发者, 477 | 所以当然要深入细节来测,这与刚刚说的观点并不冲突。** 478 | 479 | **上文观点主要是希望大家避免产生 “测试用户”。** 一旦你的测试面向 “测试用户” 编写,那么你的测试会变得非常冗余,难以维护。一旦要做重构,所有测试都要跟着改。 480 | 这也是为什么大家特别讨厌测试的原因。并不是测试影响了你的效率,而是那些差劲的测试代码拖累了你。 481 | 482 | 所以要怎么才能避免测试用户呢?拿到要测的业务代码后,我们要去分析: 483 | 484 | 1. 这段代码的 **真实用户** 是谁?开发者还是普通用户?谁会从中获利? 485 | 2. 确定了用户后,用测试用例模拟一个相对 **真实的使用场景** 486 | 3. 接下来思考 **为了模拟这个场景**,要做哪些 Mock 操作? 487 | 488 | 带着上面的方法回过头来看 `AuthButton` 这个例子: 489 | 490 | 1. 用户是谁:这个组件的真实用户更偏向普通用户(个人觉得) 491 | 2. 使用场景是什么:`登录` 492 | 3. 存在哪些外部依赖:要把 Http 请求 Mock 掉 493 | 494 | 在真实业务中,开发者一般喜欢用 `antd` 或者 `element-ui` 来封装业务组件,对于这些组件我们实际更关注的是交互逻辑,而不是实现逻辑。 495 | 496 | 当然,也有可能你会觉得这里的 “用户” 是开发者,最终选择把 `getUserRole` Mock 掉。这样做也没问题,**因为测试本来就是一个取舍的过程,不像写业务有一个绝对准确的结果。** 497 | 只要你觉得这么测能带给你更强的代码信心,那就放手去写吧! 498 | 499 | ## 总结 500 | 501 | 这一章里,我们学到了: 502 | 503 | * 可以用 [jest-transform-stub](https://www.npmjs.com/package/jest-transform-stub) 来转译非 JS 静态资源文件 504 | * 可以用 `jest.spyOn` 来 Mock 对象中的函数 505 | * 在做测试策略的选择时,我们要避免产生测试用户,要面向 **开发者** 和 **普通用户** 来写测试用例 506 | -------------------------------------------------------------------------------- /docs/basic/component-test/jest-less-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/component-test/jest-less-error.png -------------------------------------------------------------------------------- /docs/basic/component-test/matchers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/component-test/matchers.png -------------------------------------------------------------------------------- /docs/basic/component-test/mock-less.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/component-test/mock-less.png -------------------------------------------------------------------------------- /docs/basic/component-test/stackoverflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/component-test/stackoverflow.png -------------------------------------------------------------------------------- /docs/basic/component-test/ts-less-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/component-test/ts-less-error.png -------------------------------------------------------------------------------- /docs/basic/config-react/README.md: -------------------------------------------------------------------------------- 1 | # 引入 React(纯配置) 2 | 3 | 前面这几章讲的都是纯函数的测试,相对比较简单。而我们平常面对更多的是 `React` 和 `Vue` 的业务代码,对于这样的代码又该如何做测试呢? 4 | 下面以 `React` 为例,继续我们的测试之旅。 5 | 6 | ::: tip 7 | **如果你的技术栈是 `Vue` 也没关系,本教程更多的是想分享 **“测试思路”**,无论你是用 `Vue` 还是 `React`,都能在后面的章节里学到一样的知识。** 8 | ::: 9 | 10 | ::: warning 11 | **这一章主要是配置 `React` 开发环境,不涉及测试内容,所以跟着我的代码复制即可。** 12 | ::: 13 | 14 | ## 配置 Webpack 15 | 16 | 可以不使用我下面的版本,不过最好保证 React 是 17 的,小于 17 太老,大于 17 又不太稳定。 17 | 18 | ```shell 19 | # Webpack 依赖 20 | npm i -D webpack@5.72.0 webpack-cli@4.10.0 webpack-dev-server@4.8.1 html-webpack-plugin@5.5.0 21 | 22 | # Loader 23 | npm i -D less@4.1.2 less-loader@10.2.0 style-loader@3.3.1 css-loader@6.7.1 ts-loader@9.2.8 24 | 25 | # React 以及业务 26 | npm i react@17.0.2 react-dom@17.0.2 axios@0.26.1 antd@4.19.5 classnames@2.3.1 27 | npm i -D @types/react@17.0.2 @types/react-dom@17.0.2 28 | ``` 29 | 30 | 在根目录添加 Webpack 配置文件 `webpack.config.js`: 31 | 32 | ```js 33 | const path = require('path'); 34 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 35 | 36 | module.exports = { 37 | mode: 'development', 38 | entry: { 39 | index: './src/index.tsx' 40 | }, 41 | module: { 42 | rules: [ 43 | // 解析 TypeScript 44 | { 45 | test: /\.(tsx?|jsx?)$/, 46 | use: 'ts-loader', 47 | exclude: /(node_modules|tests)/ 48 | }, 49 | // 解析 CSS 50 | { 51 | test: /\.css$/i, 52 | use: [ 53 | { loader: 'style-loader' }, 54 | { loader: 'css-loader' }, 55 | ] 56 | }, 57 | // 解析 Less 58 | { 59 | test: /\.less$/i, 60 | use: [ 61 | { loader: "style-loader" }, 62 | { 63 | loader: "css-loader", 64 | options: { 65 | modules: { 66 | mode: (resourcePath) => { 67 | if (/pure.css$/i.test(resourcePath)) { 68 | return "pure"; 69 | } 70 | if (/global.css$/i.test(resourcePath)) { 71 | return "global"; 72 | } 73 | return "local"; 74 | }, 75 | } 76 | } 77 | }, 78 | { loader: "less-loader" }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | resolve: { 84 | extensions: ['.tsx', '.ts', '.js', '.less', 'css'], 85 | // 设置别名 86 | alias: { 87 | utils: path.join(__dirname, 'src/utils/'), 88 | components: path.join(__dirname, 'src/components/'), 89 | apis: path.join(__dirname, 'src/apis/'), 90 | hooks: path.join(__dirname, 'src/hooks/'), 91 | store: path.join(__dirname, 'src/store/'), 92 | } 93 | }, 94 | devtool: 'inline-source-map', 95 | // 3000 端口打开网页 96 | devServer: { 97 | static: './dist', 98 | port: 3000, 99 | hot: true, 100 | }, 101 | // 默认输出 102 | output: { 103 | filename: 'index.js', 104 | path: path.resolve(__dirname, 'dist'), 105 | clean: true, 106 | }, 107 | // 指定模板 html 108 | plugins: [ 109 | new HtmlWebpackPlugin({ 110 | template: './public/index.html', 111 | }), 112 | ], 113 | }; 114 | ``` 115 | 116 | 在 `public/index.html` 添加模板 HTML 文件: 117 | 118 | ```html 119 | 120 | 121 | 122 | 123 | React App 124 | 125 | 126 |
127 | 128 | 129 | ``` 130 | 131 | 在 `package.json` 添加启动命令: 132 | 133 | ```json 134 | { 135 | "scripts": { 136 | "start": "webpack serve", 137 | "test": "jest" 138 | } 139 | } 140 | ``` 141 | 142 | ## 添加入口 143 | 144 | 现在来实现我们的 React App。在 `src/index.tsx` 添加入口: 145 | 146 | ```tsx 147 | import React from "react"; 148 | import ReactDOM from "react-dom"; 149 | import App from "./App"; 150 | import "antd/dist/antd.css"; 151 | 152 | ReactDOM.render(, document.querySelector("#root")); 153 | ``` 154 | 155 | 添加 `src/App.tsx` 根组件: 156 | 157 | ```tsx 158 | import React from 'react'; 159 | import { Button } from 'antd'; 160 | 161 | const App = () => { 162 | return ( 163 |
164 |

Hello

165 | 166 |
167 | ) 168 | } 169 | 170 | export default App; 171 | ``` 172 | 173 | 到这里我们的业务就完事了,接下来再配置一下 `tsconfig.json`,所需要用的路径都做一下映射: 174 | 175 | ```json 176 | { 177 | "compilerOptions": { 178 | "jsx": "react", 179 | "esModuleInterop": true, 180 | "baseUrl": "./", 181 | "paths": { 182 | "utils/*": ["src/utils/*"], 183 | "components/*": ["src/components/*"], 184 | "apis/*": ["src/apis/*"], 185 | "hooks/*": ["src/hooks/*"], 186 | "store/*": ["src/store/*"] 187 | } 188 | } 189 | } 190 | ``` 191 | 192 | ## 启动应用 193 | 194 | 现在执行 `npm run start`,进入 `localhost:3000` 就能看到我们的页面了: 195 | 196 | ![](./react-preview.png) 197 | -------------------------------------------------------------------------------- /docs/basic/config-react/react-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/config-react/react-preview.png -------------------------------------------------------------------------------- /docs/basic/getting-started/README.md: -------------------------------------------------------------------------------- 1 | # 起步 2 | 3 | 首先,我们来创建一个新项目。 4 | 5 | ::: warning 6 | 阅读中遇到卡壳时,可以参考 [配套项目](https://github.com/haixiangyan/jest-tutorial-example) 。 7 | ::: 8 | 9 | ## 创建项目 10 | 11 | 使用 `npm` 初始化,并安装 `jest`。 12 | 13 | ```shell 14 | # 创建项目 15 | mkdir jest-starter 16 | cd jest-starter 17 | 18 | # 初始化 19 | npm init -y 20 | 21 | # 安装依赖 22 | npm i -D jest@27.5.1 23 | ``` 24 | 25 | ::: warning 26 | 目前 Jest 已经来到 28 版本了,但是我在实践中发现 `Jest@28` 和 `react@18` 以及 `@testing-library/react` 一起使用时会有冲突,建议大家跟着我使用 **稳定** 的版本。 27 | ::: 28 | 29 | 安装 Jest 后,用 `jest-cli` 初始化 `jest` 配置文件: 30 | 31 | ```shell 32 | npx jest --init 33 | ``` 34 | 35 | 初始化配置文件时,Jest 会问你一堆问题,可以先按我下面的图来选择(只打开覆盖率和自动清除 Mock),别的以后再说: 36 | 37 | ![](./jest-config.png) 38 | 39 | 执行完之后,就会看到有一个 `jest.config.js` 的配置文件: 40 | 41 | ```shell 42 | // jest.config.js 43 | module.exports = { 44 | // 自动清除 Mock 45 | clearMocks: true, 46 | 47 | // 开启覆盖率 48 | collectCoverage: true, 49 | 50 | // 指定生成覆盖率报告文件存放位置 51 | coverageDirectory: "coverage", 52 | 53 | // 不用管 54 | coverageProvider: "v8", 55 | }; 56 | ``` 57 | 58 | ::: tip 59 | 建议不要犯了强迫症把 `jest.config.js` 的注释去掉,它们可以作为配置 Jest 的简单版文档。 60 | ::: 61 | 62 | ## 第一个测试 63 | 64 | 有了基本配置后,添加一个工具函数文件 `src/utils/sum.js` 作为我们第一个业务文件: 65 | 66 | ```js 67 | // src/utils/sum.js 68 | const sum = (a, b) => { 69 | return a + b; 70 | } 71 | 72 | module.exports = sum; 73 | ``` 74 | 75 | 然后,添加我们项目的第一个测试用例 `tests/utils/sum.test.js`: 76 | 77 | ```js 78 | // tests/utils/sum.test.js 79 | const sum = require("../../src/utils/sum"); 80 | 81 | describe('sum', () => { 82 | it('可以做加法', () => { 83 | expect(sum(1, 1)).toEqual(2); 84 | }); 85 | }) 86 | ``` 87 | 88 | 项目结构如下: 89 | 90 | ``` 91 | ├── jest.config.js 92 | ├── package-lock.json 93 | ├── package.json 94 | ├── src 95 | │ └── utils 96 | │ └── sum.js 97 | └── tests 98 | └── utils 99 | └── sum.test.js 100 | ``` 101 | 102 | ## 执行测试 103 | 104 | 一切就绪,执行以下命令启动测试: 105 | 106 | ```shell 107 | # npx jest 108 | npm run test 109 | ``` 110 | 111 | 执行结果如下: 112 | 113 | ![](./test-result.png) 114 | 115 | ## 单文件测试 116 | 考虑如下场景,如果我们只想测试项目中某一个单独的文件。 117 | ```shell 118 | # npx jest 119 | npm run test <文件的相对路径> 120 | ``` 121 | 122 | **🎉 成功 🎉** 123 | 124 | ## 覆盖率报告 125 | 126 | 上面终端里展示的就是覆盖率情况,只不过以终端的形式展示。现在我们打开根目录下的 `coverage` 目录,会发现生成很多覆盖率文件: 127 | 128 | ``` 129 | ├── clover.xml # Clover XML 格式的覆盖率报告 130 | ├── coverage-final.json # JSON 格式的覆盖率报告 131 | ├── lcov-report # HTML 格式的覆盖率报告 132 | │ ├── base.css 133 | │ ├── block-navigation.js 134 | │ ├── favicon.png 135 | │ ├── index.html # 覆盖率根文件 136 | │ ├── prettify.css 137 | │ ├── prettify.js 138 | │ ├── sort-arrow-sprite.png 139 | │ ├── sorter.js 140 | │ └── sum.js.html # sum.js 的覆盖率情况 141 | └── lcov.info 142 | ``` 143 | 144 | Jest 会在 `coverage` 目录下生成各种不同格式的覆盖率报告文件,有 `XML`,`JSON`,也有 `HTML` 的。生成这么多不同格式的测试报告只只是为了方便不同工具的读取, 145 | 比如 JS 读 JSON 就比读 XML 容易,它们描述的内容都是一样的。 146 | 147 | 无论哪种格式,我们都很难直观地看懂。因此,Jest 也支持生成网页的测试报告,打开 `lcov-report/index.html` 就可以看到网页版的测试报告了: 148 | 149 | ![](./coverage.png) 150 | 151 | ## 总结 152 | 153 | 这一章里,我们创建了新项目,并成功编写并测试了第一个测试用例。 154 | 155 | 然而,这只是开始,后面还有很多问题等着我们解决,马上去看下一章吧。 156 | -------------------------------------------------------------------------------- /docs/basic/getting-started/coverage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/getting-started/coverage.png -------------------------------------------------------------------------------- /docs/basic/getting-started/jest-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/getting-started/jest-config.png -------------------------------------------------------------------------------- /docs/basic/getting-started/test-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/getting-started/test-result.png -------------------------------------------------------------------------------- /docs/basic/hook-test/README.md: -------------------------------------------------------------------------------- 1 | ## React Hook 测试 2 | 3 | 上一章讲了如何给 Redux 代码写测试,我们日常写的 React App 还有一个很重要的部分:React Hooks,这一章就来讲讲如何测试这部分的代码。 4 | 5 | ## useCounter 6 | 7 | 我们依然从一个需求开始讲起。假如要实现一个 `useCounter` 的 Hook,在 `src/hooks/useCounter.ts` 添加: 8 | 9 | ```ts 10 | // src/hooks/useCounter.ts 11 | import { useState } from "react"; 12 | 13 | export interface Options { 14 | min?: number; 15 | max?: number; 16 | } 17 | 18 | export type ValueParam = number | ((c: number) => number); 19 | 20 | function getTargetValue(val: number, options: Options = {}) { 21 | const { min, max } = options; 22 | let target = val; 23 | if (typeof max === "number") { 24 | target = Math.min(max, target); 25 | } 26 | if (typeof min === "number") { 27 | target = Math.max(min, target); 28 | } 29 | return target; 30 | } 31 | 32 | function useCounter(initialValue = 0, options: Options = {}) { 33 | const { min, max } = options; 34 | 35 | const [current, setCurrent] = useState(() => { 36 | return getTargetValue(initialValue, { 37 | min, 38 | max, 39 | }); 40 | }); 41 | 42 | const setValue = (value: ValueParam) => { 43 | setCurrent((c) => { 44 | const target = typeof value === "number" ? value : value(c); 45 | return getTargetValue(target, { 46 | max, 47 | min, 48 | }); 49 | }); 50 | }; 51 | 52 | const inc = (delta = 1) => { 53 | setValue((c) => c + delta); 54 | }; 55 | 56 | const dec = (delta = 1) => { 57 | setValue((c) => c - delta); 58 | }; 59 | 60 | const set = (value: ValueParam) => { 61 | setValue(value); 62 | }; 63 | 64 | const reset = () => { 65 | setValue(initialValue); 66 | }; 67 | 68 | return [ 69 | current, 70 | { 71 | inc, 72 | dec, 73 | set, 74 | reset, 75 | }, 76 | ] as const; 77 | } 78 | 79 | export default useCounter; 80 | ``` 81 | 82 | 这个 Hook 很简单,就是经典的计数器,拥有增加、减少、设置和重置 4 个操作。 83 | 84 | ## 误区 85 | 86 | 有些同学会觉得 `hook` 不就是纯函数么?为什么不能直接像纯函数那样去测呢? 87 | 88 | ```ts 89 | describe("useCounter", () => { 90 | it("可以加 1", () => { 91 | const [counter, utils] = useCounter(0); 92 | 93 | expect(counter).toEqual(0); 94 | 95 | utils.inc(1); 96 | 97 | expect(counter).toEqual(1); 98 | }); 99 | }); 100 | ``` 101 | 102 | 由于这里用到了 `useState`,而 React 规定 **只有在组件中才能使用这些 Hooks**,所以这样测试的结果就会得到下面的报错: 103 | 104 | ![](./pure-func-error.png) 105 | 106 | 那我们是否可以通过前面讲的 Mock 手段来处理掉 `useState` 呢?**千万别这么做!** 假如 Hook 里不仅有 `useState`,还有 `useEffect` 这样的呢? 107 | 难道你要每个 React API 都要 Mock 一遍么? 108 | 109 | 想想我们做测试的初衷,我们做测试是为什么? **测试的初衷是为了带我们带来强大的代码信心。** 好了,我知道你听这句话都听烦了,我自己都说烦了。 110 | 111 | 如果你把测试初衷忘掉,会很容易掉入测试代码细节的陷阱。一旦你的关注点不是代码的信心,而是测试代码细节,那么你的测试用例会变得非常脆弱,难以维护。 112 | 这样写出来的测试不仅不能给你带来代码信心,还会拖垮开发进程,真的不如不做测试。 113 | 114 | 不好意思,唠叨了一会,我们回来看看这个例子。要解决测试代码细节这个问题,唯一的办法就是把这个东西看成整体,比如......我们真的写一个组件来做测试?**可以!安排!** 115 | 116 | ## 测试组件 117 | 118 | 安装 [@testing-library/user-event](https://www.npmjs.com/package/@testing-library/user-event) ,用于处理点击事件: 119 | 120 | ```shell 121 | npm i -D @testing-library/user-event@14.1.0 122 | ``` 123 | 124 | 添加 `tests/hooks/useCounter/TestComponent.test.tsx`: 125 | 126 | ```tsx 127 | import useCounter from "hooks/useCounter"; 128 | import { render, screen } from "@testing-library/react"; 129 | import userEvent from "@testing-library/user-event"; 130 | import React from "react"; 131 | 132 | // 测试组件 133 | const UseCounterTest = () => { 134 | const [counter, { inc, set, dec, reset }] = useCounter(0); 135 | return ( 136 |
137 |
Counter: {counter}
138 | 139 | 140 | 141 | 142 |
143 | ); 144 | }; 145 | 146 | describe("useCounter", () => { 147 | it("可以做加法", async () => { 148 | render(); 149 | 150 | const incBtn = screen.getByText("inc(1)"); 151 | 152 | await userEvent.click(incBtn); 153 | 154 | expect(screen.getByText("Counter: 1")).toBeInTheDocument(); 155 | }); 156 | 157 | it("可以做减法", async () => { 158 | render(); 159 | 160 | const decBtn = screen.getByText("dec(1)"); 161 | 162 | await userEvent.click(decBtn); 163 | 164 | expect(screen.getByText("Counter: -1")).toBeInTheDocument(); 165 | }); 166 | 167 | it("可以设置值", async () => { 168 | render(); 169 | 170 | const setBtn = screen.getByText("set(10)"); 171 | 172 | await userEvent.click(setBtn); 173 | 174 | expect(screen.getByText("Counter: 10")).toBeInTheDocument(); 175 | }); 176 | 177 | it("可以重置值", async () => { 178 | render(); 179 | 180 | const incBtn = screen.getByText("inc(1)"); 181 | const resetBtn = screen.getByText("reset()"); 182 | 183 | await userEvent.click(incBtn); 184 | await userEvent.click(resetBtn); 185 | 186 | expect(screen.getByText("Counter: 0")).toBeInTheDocument(); 187 | }); 188 | }); 189 | ``` 190 | 191 | 上面我们写了一个 `UseCounterTest` 的组件,然后在组件内使用 `useCounter`,并把增加、减少、设置和重置功能绑定到 ` 193 | 194 | ); 195 | }; 196 | 197 | export default User; 198 | ``` 199 | 200 | 在 `App.tsx` 里使用它: 201 | 202 | ```tsx 203 | import User from "components/User"; 204 | 205 | const App = () => { 206 | return ( 207 |
208 | {/*...*/} 209 |
210 | 211 |
212 |
213 | ) 214 | } 215 | ``` 216 | 217 | 最后在 `index.tsx` 入口里使用 `Provider` 来包裹整个 App: 218 | 219 | ```tsx 220 | import store from "./store"; 221 | 222 | ReactDOM.render( 223 | 224 | 225 | , 226 | document.querySelector("#root") 227 | ); 228 | ``` 229 | 230 | 到此,我们的 App 就实现完了。**如果你不熟悉 `redux` 以及 `@redux/toolkit` 的用法,也可以把它们当成伪代码来看,业务代码不重要。** 231 | 232 | ## 单元测试 233 | 234 | 首先,我们尝试给上面的 Redux 代码写一下单测。经过分析,上面 `redux` 的代码一共有两个 `selector`: 235 | * `selectUser` 236 | * `selectUserFetchStatus` 237 | 238 | 还有两个 `action`: 239 | * `updateUserName` 240 | * `fetchUserThunk` 241 | 242 | 我们先写 `selector` 的单测。由于是纯函数,所以这两个单测比较简单,添加 `tests/store/user/selectors.test.ts`: 243 | 244 | ```ts 245 | // tests/store/user/selectors.test.ts 246 | import { selectUser, selectUserFetchStatus } from "store/user/selectors"; 247 | 248 | describe("selector", () => { 249 | describe("selectUser", () => { 250 | it("可以获取用户信息", () => { 251 | expect( 252 | selectUser({ 253 | user: { 254 | id: "1", 255 | name: "Jack", 256 | age: 19, 257 | status: "complete", 258 | }, 259 | }) 260 | ).toEqual({ 261 | id: "1", 262 | name: "Jack", 263 | age: 19, 264 | }); 265 | }); 266 | }); 267 | 268 | describe("selectUserFetchStatus", () => { 269 | it("可以获取加载状态", () => { 270 | expect( 271 | selectUserFetchStatus({ 272 | user: { 273 | id: "1", 274 | name: "Jack", 275 | age: 19, 276 | status: "loading", 277 | }, 278 | }) 279 | ).toEqual("loading"); 280 | }); 281 | }); 282 | }); 283 | ``` 284 | 285 | 这样的纯函数单测过于简单了,给它们上单测有点大炮打蚊子。**在真实业务中,我们可以暂时不对它们写测试,等函数变足够复杂了再做测试也不迟。** 286 | 287 | 现在我们来挑战一下 `action` 的测试,首先是 `updateUserName`,在 `tests/store/user/reducer.test.ts` 添加这个 `action` 的测试用例: 288 | 289 | ```ts 290 | // tests/store/user/reducer.test.ts 291 | import reducer, { updateUserName } from "store/user/reducer"; 292 | 293 | describe("reducer", () => { 294 | describe("测试 reducer", () => { 295 | describe("updateUserName", () => { 296 | it("可以更新用户姓名", () => { 297 | // 测试 reducer 纯函数 298 | const currentState = reducer( 299 | { 300 | id: "", 301 | name: "", 302 | age: 0, 303 | status: "", 304 | }, 305 | updateUserName({ name: "hello" }) 306 | ); 307 | 308 | expect(currentState.name).toEqual("hello"); 309 | }); 310 | }); 311 | }); 312 | }); 313 | ``` 314 | 315 | `reducer` 本身也是纯函数,它的作用就是改变数据状态,所以这里我们在第一个参数传入当前状态,在第二个参数传入 `action`, 316 | 最后 `expect` 一下返回的新状态 `currentState` 就完成测试了。 317 | 318 | 下面来看看 `fetchUserThunk` 的测试。它涉及到 `redux-thunk` 中间件、API 异步函数还有 Http 请求,所以我们不能直接调用 `reducer` 来做测试。 319 | 320 | 为了更好地测试 `thunk`,开发者们发明了很多 NPM 包,比如 [redux-mock-store](https://github.com/reduxjs/redux-mock-store) , [redux-actions-assertions](https://github.com/redux-things/redux-actions-assertions) 等。 321 | 这里就以 `redux-mock-store` 为例,先安装一下: 322 | 323 | ```shell 324 | npm i -D redux-mock-store@1.5.4 @types/redux-mock-store@1.0.3 325 | ``` 326 | 327 | 然后写如下测试用例: 328 | 329 | ```ts 330 | // tests/store/user/reducer.test.ts 331 | import reducer, { updateUserName } from "store/user/reducer"; 332 | import configureStore from "redux-mock-store"; 333 | import thunk from "redux-thunk"; 334 | import server from "../../mockServer/server"; 335 | import { rest } from "msw"; 336 | import { fetchUserThunk } from "store/user/thunks"; 337 | 338 | // 初始化函数 339 | const setupHttp = (name?: string, age?: number) => { 340 | server.use( 341 | rest.get("https://mysite.com/api/users", async (req, res, ctx) => { 342 | return res( 343 | ctx.status(200), 344 | ctx.json({ 345 | id: "1", 346 | name: name || "Jack", 347 | age: age || 18, 348 | }) 349 | ); 350 | }) 351 | ); 352 | }; 353 | 354 | // 非常不推荐这样去测 redux 的代码 355 | describe("reducer", () => { 356 | describe("测试 reducer", () => { 357 | describe("fetchUserThunk", () => { 358 | it("可以获取用户", async () => { 359 | // Mock Http 返回 360 | setupHttp("Mary", 10); 361 | 362 | // Mock redux 的 store 363 | const middlewares = [thunk]; 364 | const mockStore = configureStore(middlewares); 365 | const store = mockStore({ 366 | id: "", 367 | name: "", 368 | age: 0, 369 | status: "", 370 | }); 371 | 372 | // 开始 dispatch 373 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 374 | // @ts-ignore 375 | const data = await store.dispatch(fetchUserThunk()); 376 | 377 | expect(data.payload).toEqual({ 378 | id: "1", 379 | name: "Mary", 380 | age: 10, 381 | }); 382 | 383 | // 失败,因为 redux-mock-store 只能测 action 部分 384 | // 详情:https://github.com/reduxjs/redux-mock-store/issues/71 385 | // expect(store.getState()).toEqual({ 386 | // id: "1", 387 | // name: "Mary", 388 | // age: 10, 389 | // status: "complete", 390 | // }); 391 | }); 392 | }); 393 | }); 394 | }); 395 | ``` 396 | 397 | 一共做了 4 件事: 398 | 399 | * 使用 `msw` Mock Http 的返回 400 | * 使用 `redux-mock-store` 里的 `configureStore` 创建一个假 `store` 401 | * 在假 `store` 里引入 `redux-thunk` 中间件 402 | * 最后对 `data.payload` 做了断言 403 | 404 | 这也太复杂了,不仅要引入一个奇奇怪怪的库,还要手动创建一个假 `store`,并接入 `redux-thunk` 中间件。 405 | 406 | 这样测试的问题是:**过度测试代码实现细节!** 现在围绕 `react` 的状态管理库可不只有 `@reduxjs/toolkit`,还有 `dva`,`redux-saga`,`mobx` 等等。 407 | 一旦不用 `redux` 的 `thunk`,改成 `generator` 或者装饰器,那直接玩完了,上面所有测试用例全部报废。 408 | 409 | **这就是过度测试实现细节的代价!** 全局状态管理属于一个非常偏业务的功能模块,所以这里使用集成测试更合适。 410 | 411 | ## 集成测试 412 | 413 | 集成测试的关键点有 2 个: 414 | * 像真实用户那样去和组件交互 415 | * Mock Http 请求(外部依赖) 416 | 417 | 下面我们来实现这个功能的集成测试吧。首先,我们来改造一下 `React Tesitng Library` 提供的 `render` 函数: 418 | 419 | ```tsx 420 | // tests/testUtils/render.tsx 421 | import React, { FC } from "react"; 422 | import { render as rtlRender, RenderOptions } from "@testing-library/react"; 423 | import { configureStore } from "@reduxjs/toolkit"; 424 | import { Provider } from "react-redux"; 425 | import { reducer, RootState } from "store/index"; 426 | 427 | interface CustomRenderOptions extends RenderOptions { 428 | preloadedState?: RootState; 429 | store?: ReturnType; 430 | } 431 | 432 | const render = (ui: React.ReactElement, options: CustomRenderOptions) => { 433 | // 获取自定义的 options,options 里带有 store 内容 434 | const { 435 | preloadedState = {}, 436 | store = configureStore({ reducer, preloadedState }), 437 | ...renderOptions 438 | } = options; 439 | 440 | // 使用 Provider 包裹 441 | const Wrapper: FC = ({ children }) => { 442 | return {children}; 443 | }; 444 | 445 | // 使用 RTL 的 render 函数 446 | return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); 447 | }; 448 | 449 | export default render; 450 | ``` 451 | 452 | 自定义 `render` 的作用就是:创建一个使用 `redux` 的环境,用 `` 包裹传入的业务组件,并且可以让我们决定当前 `redux` 的初始状态。 453 | 然后在 `tests/components/User/index.test.tsx` 使用自定义的 `render` 来渲染 `` 组件: 454 | 455 | ```tsx 456 | // tests/components/User/index.test.tsx 457 | import React from "react"; 458 | import server from "../../mockServer/server"; 459 | import { rest } from "msw"; 460 | import render from "../../testUtils/render"; 461 | import { fireEvent, screen } from "@testing-library/react"; 462 | import User from "components/User"; 463 | 464 | // 初始化 Http 请求 465 | const setupHttp = (name?: string, age?: number) => { 466 | server.use( 467 | rest.get("https://mysite.com/api/users", async (req, res, ctx) => { 468 | return res( 469 | ctx.status(200), 470 | ctx.json({ 471 | id: "1", 472 | name: name || "Jack", 473 | age: age || 18, 474 | }) 475 | ); 476 | }) 477 | ); 478 | }; 479 | 480 | describe("User", () => { 481 | it("点击可以正常获取用户列表", async () => { 482 | setupHttp("Mary", 10); 483 | 484 | render(, { 485 | preloadedState: { 486 | user: { 487 | id: "", 488 | name: "", 489 | age: 10, 490 | status: "", 491 | }, 492 | }, 493 | }); 494 | 495 | // 还没开始请求 496 | expect(screen.getByText("无用户信息")).toBeInTheDocument(); 497 | 498 | // 开始请求 499 | fireEvent.click(screen.getByText("加载用户")); 500 | 501 | // 请求结束 502 | expect(await screen.findByText("ID:1")).toBeInTheDocument(); 503 | expect(screen.getByText("姓名:Mary")).toBeInTheDocument(); 504 | expect(screen.getByText("年龄:10")).toBeInTheDocument(); 505 | 506 | expect(screen.queryByText("加载中...")).not.toBeInTheDocument(); 507 | }); 508 | }); 509 | ``` 510 | 511 | 虽然这个集成测试做了 4 件事,但看起来清爽多了: 512 | 513 | 1. Mock Http 返回 514 | 2. 渲染 `` 组件 515 | 3. 点击按钮拉取用户信息 516 | 4. 做断言 517 | 518 | 而且这些操作没有一项是和状态管理有直接关联的,唯一有关联的就是传入的初始 `state`。也就是说,无论底层的状态管理用了 `redux-saga`,还是 `dva`, 519 | 还是 `mobx`,测试用例完全不关注,它只关注组件是否正确渲染最终结果。**这其实也是普通用户的使用行为,他可不关注代码用了什么库,只管页面变化。** 520 | 521 | ## 隔离 vs 集成 522 | 523 | 看到这,你会发现其实我们并不是在给 Redux 代码做测试,而是对业务组件做测试! 524 | 525 | 上面这个例子在 [Redux 官网的 《Writing Tests》 章节](https://redux.js.org/usage/writing-tests) 里有具体的阐述,Dan 在 526 | [《The Evolution of Redux Testing Approaches》](https://blog.isquaredsoftware.com/2021/06/the-evolution-of-redux-testing-approaches/) 527 | 里也详细论述了 Redux 测试的演变过程,即从 Isolation-style tests(隔离式测试)转向 Integration-style tests(集成式测试)。 528 | 529 | 在使用单测这种隔离式测试时,我们需要花很大精力在 Mock 上,而且有时不得不用上一些非常暴力的 Hacky 方法。因此,**我们在做测试时,特别是对业务代码做测试时, 530 | 一定要合理使用单测。** 某些情况下,单测甚至无法测到一些边界条件。 531 | 532 | 集成测试则不仅可以 **相对真实地** 模拟用户和组件的交互,而且跑得比 E2E 测试要快很多,因此一般在业务项目中用集成测试会更多一些。 533 | 534 | ## `getBy*` vs `queryBy*` vs `findBy*` 535 | 536 | 上面集成测试中,我们用到了 `React Testing Library` 的查询 API,这里说说 `getBy*`,`queryBy*` 以及 `findBy*` 三者的区别。 537 | 538 | | 查询类型 | 不命中 | 1 个命中 | 多个命中 | 重试(Async/Await) | 539 | |-----------------|-----------|-------|------|-----------------| 540 | | 单个元素 | | | | | 541 | | `getBy...` | 抛出错误 | 返回元素 | 抛出错误 | 无 | 542 | | `queryBy...` | 返回 `null` | 返回元素 | 抛出错误 | 无 | 543 | | `findBy...` | 抛出错误 | 返回元素 | 抛出错误 | 有 | 544 | | 多个元素 | | | | | 545 | | `getAllBy...` | 抛出错误 | 返回元素 | 抛出错误 | 无 | 546 | | `queryAllBy...` | 返回 `[]` | 返回元素 | 抛出错误 | 无 | 547 | | `findAllBy...` | 抛出错误 | 返回元素 | 抛出错误 | 有 | 548 | 549 | 总的来说就是: 550 | 551 | * 当要断言元素是否存在时,使用 `getBy...`,因为找不到时,它会直接抛出错误来让测试失败 552 | * 当要做异步逻辑,然后再获取元素时,使用 `await findBy...`,因为它会不断地寻找元素 553 | * 上面两种情况都不满足时,可以使用 `queryBy...` 这个 API 554 | 555 | ## 总结 556 | 557 | 这一章我们学会了如何对 `redux` 的 `action` 和 `selector` 代码进行单测。同时也知道这样做测试的意义并不大,容易写出很多冗余、维护性差的测试用例。 558 | 559 | 如果要测试 Redux 代码逻辑,最好的方法是对这个功能进行集成测试,不仅能测试真实用户的交互,还能保证 Redux 代码的正确性。 560 | 561 | 当然,给 Redux 代码做单测的情况不是不存在,当你遇到非常复杂的 `action` 以及 `selector` 时,单测是个不错的选择。 562 | -------------------------------------------------------------------------------- /docs/basic/snapshot-test/README.md: -------------------------------------------------------------------------------- 1 | # 快照测试 2 | 3 | 上一章我们在项目中引入了 `React`,现在我们开始 React App 的开发和测试吧。 4 | 5 | 说起组件测试,很多人都会第一时间想到 **快照测试**。可能你也听过这个名字,但是你是否了解其中的细节呢?这一章就来聊聊 **快照测试**。 6 | 7 | ## Title 组件 8 | 9 | 我们在 `src/components/Title.tsx` 写一个 `Title` 组件: 10 | 11 | ```tsx 12 | // src/components/Title.tsx 13 | import React, { CSSProperties, FC } from "react"; 14 | 15 | interface Props { 16 | type: "large" | "small"; 17 | title: string; 18 | } 19 | 20 | // large 样式 21 | const largeStyle: CSSProperties = { 22 | fontSize: "2em", 23 | color: "red", 24 | }; 25 | 26 | // small 样式 27 | const smallStyle: CSSProperties = { 28 | fontSize: "0.5em", 29 | color: "green", 30 | }; 31 | 32 | // 样式 Mapper 33 | const styleMapper: Record<"small" | "large", CSSProperties> = { 34 | small: smallStyle, 35 | large: largeStyle, 36 | }; 37 | 38 | // 组件 39 | const Title: FC = (props) => { 40 | const { title, type } = props; 41 | 42 | return

{title}

; 43 | }; 44 | 45 | export default Title; 46 | ``` 47 | 48 | 然后在 `src/App.tsx` 里使用这个组件: 49 | 50 | ```tsx 51 | // src/App.tsx 52 | import React from 'react'; 53 | import Title from "components/Title"; 54 | 55 | const App = () => { 56 | return ( 57 |
58 |
59 | 60 | <Title type="large" title="大字" /> 61 | </section> 62 | </div> 63 | ) 64 | } 65 | 66 | export default App; 67 | ``` 68 | 69 | 在页面上可以看到这样的效果: 70 | 71 | ![](./title-preview.png) 72 | 73 | ## 第一个快照 74 | 75 | 在写测试前,我们要安装一下 React 的测试库。或许你听说过很多测试 React 的测试库,这里我只推荐 [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) : 76 | 77 | ```shell 78 | npm i -D @testing-library/react@12.1.4 79 | ``` 80 | 81 | 在 `tests/components/Title.test.tsx` 添加一个快照测试: 82 | 83 | ```tsx 84 | // tests/components/Title.test.tsx 85 | import React from "react"; 86 | import { render } from "@testing-library/react"; 87 | import Title from "components/Title"; 88 | 89 | describe("Title", () => { 90 | it("可以正确渲染大字", () => { 91 | const { baseElement } = render(<Title type="large" title="大字" />); 92 | expect(baseElement).toMatchSnapshot(); 93 | }); 94 | 95 | it("可以正确渲染小字", () => { 96 | const { baseElement } = render(<Title type="small" title="小字" />); 97 | expect(baseElement).toMatchSnapshot(); 98 | }); 99 | }); 100 | ``` 101 | 102 | 执行测试后,会发现在 `tests/components/` 下多了一个 `Title.test.tsx.snap` 文件,打开来看看: 103 | 104 | ```js 105 | // tests/components/Title.test.tsx.snap 106 | // Jest Snapshot v1, https://goo.gl/fbAQLP 107 | 108 | exports[`Title 可以正确渲染大字 1`] = ` 109 | <body> 110 | <div> 111 | <p 112 | style="font-size: 2em; color: red;" 113 | > 114 | 大字 115 | </p> 116 | </div> 117 | </body> 118 | `; 119 | 120 | exports[`Title 可以正确渲染小字 1`] = ` 121 | <body> 122 | <div> 123 | <p 124 | style="font-size: 0.5em; color: green;" 125 | > 126 | 小字 127 | </p> 128 | </div> 129 | </body> 130 | `; 131 | ``` 132 | 133 | ## 什么是快照测试 134 | 135 | 在讲 “什么是快照测试” 之前,我们先来说说 “为什么要有快照测试”。 136 | 137 | 使用 `jest` 和 `React Testing Library` 都能完成基础的交互测试以及功能测试,但是组件毕竟是组件,是有 HTML 结构的。 138 | 如果不对比一下 HTML 结构,很难说服自己组件没问题。但是这就引来了一个问题了:**要怎么对比 HTML 结构?** 139 | 140 | 最简单的方法就是把这个组件的 `HTML` 打印出来,拷贝到一个 `xxx.txt` 文件里,然后在下次跑用例时,把当前组件的 `HTML` 字符串和 `xxx.txt` 141 | 文件里的内容对比一下就知道哪里有被修改过。 **这就是快照测试的基本理念,即:先保存一份副本文件,下次测试时把当前输出和上次副本文件对比就知道此次重构是否破坏了某些东西。** 142 | 143 | 只不过 `jest` 的快照测试提供了更高级的功能: 144 | 1. 自动创建把输出内容写到 `.snap` 快照文件,下次测试时可以自动对比 145 | 2. 输出格式化的快照文件,阅读友好,开发者更容易看懂 146 | 3. 当在做 `diff` 对比时,`jest` 能高亮差异点,而且对比信息更容易阅读 147 | 148 | 现在我们来看上面这个例子: 149 | 150 | 1. 用例第一次执行时,把 `baseElement` 的结构记录到 `Title.test.tsx.snap` 151 | 2. 等下次再执行,Jest 会自动对比当前 `baseElement` DOM 快照以及上一次 `Title.test.tsx.snap` 的内容 152 | 153 | 快照测试通过说明渲染组件没有变,如果不通过则有两种可能: 154 | 155 | 1. **代码有 Bug。** 本来好好的,被你这么一改,改出了问题 156 | 3. **实现了新功能。** 新功能可能会改变原有的 DOM 结构,所以你要用 `jest --updateSnapshot` 来更新快照 157 | 158 | 这就是快照测试了......吗?当然不是!哦,我是说做法就是这么简单,但是思路上并不简单。 159 | 160 | ## 缺陷 161 | 162 | 快照测试虽然简单,但是它有非常多的问题。我搜罗了网上很多资料,这里稍微总结一下它的缺点以及应对思路。 163 | 164 | ### 避免大快照 165 | 166 | 现在 `Title` 比较简单,所以看起来还可以,但真实业务组件中动辄就有十几个标签,还带上很多乱七八糟的属性,生成的快照文件会变得无比巨大。 167 | 168 | 对于这个问题,**我们能做的就是避免大快照,不要无脑地记录整个组件的快照**,特别是有别的 UI 组件参与其中的时候: 169 | 170 | ```tsx 171 | const Title: FC<Props> = (props) => { 172 | const { title, type } = props; 173 | 174 | return ( 175 | <Row style={styleMapper[type]}> 176 | <Col> 177 | 第一个 Col 178 | </Col> 179 | <Col> 180 | <div>{title}</div> 181 | </Col> 182 | </Row> 183 | ) 184 | }; 185 | ``` 186 | 187 | ::: warning 188 | **注:这里引用了 `antd` 的 `Col` 和 `Row` 组件,跑测试时会报:`[TypeError: window.matchMedia is not a function]`。 189 | 这是因为 [jsdom](https://www.npmjs.com/package/jsdom) 没有实现 `window.matchMedia`,所以你要在 `jest-setup.ts` 里添加这个 API 的 Mock:** 190 | ::: 191 | 192 | ```js 193 | // tests/jest-setup.ts 194 | // 详情:https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 195 | Object.defineProperty(window, 'matchMedia', { 196 | writable: true, 197 | value: jest.fn().mockImplementation(query => ({ 198 | matches: false, 199 | media: query, 200 | onchange: null, 201 | addListener: jest.fn(), // deprecated 202 | removeListener: jest.fn(), // deprecated 203 | addEventListener: jest.fn(), 204 | removeEventListener: jest.fn(), 205 | dispatchEvent: jest.fn(), 206 | })), 207 | }); 208 | ``` 209 | 210 | 上面测试生成的快照会是这样: 211 | 212 | ```js 213 | exports[`Title 可以正确渲染大字 1`] = ` 214 | <body> 215 | <div> 216 | <div 217 | class="ant-row" 218 | style="font-size: 2em; color: red;" 219 | > 220 | <div 221 | class="ant-col" 222 | > 223 | 第一个 Col 224 | </div> 225 | <div 226 | class="ant-col" 227 | > 228 | <div> 229 | 大字 230 | </div> 231 | </div> 232 | </div> 233 | </div> 234 | </body> 235 | `; 236 | ``` 237 | 238 | 杂揉了 `antd` 的 DOM 结构后,快照变得非常难读。**解决方法是只记录第二个 `Col` 的 DOM 结构就好:** 239 | 240 | ```tsx 241 | describe("Title", () => { 242 | it("可以正确渲染大字", () => { 243 | const { getByText } = render(<Title type="large" title="大字" />); 244 | const content = getByText('大字'); 245 | expect(content).toMatchSnapshot(); 246 | }); 247 | 248 | it("可以正确渲染小字", () => { 249 | const { getByText } = render(<Title type="small" title="小字" />); 250 | const content = getByText('小字'); 251 | expect(content).toMatchSnapshot(); 252 | }); 253 | }); 254 | ``` 255 | 256 | 执行 `npx jest --updateSnapshot` 后,会生成如下快照: 257 | 258 | ```js 259 | exports[`Title 可以正确渲染大字 1`] = ` 260 | <div> 261 | 大字 262 | </div> 263 | `; 264 | 265 | exports[`Title 可以正确渲染小字 1`] = ` 266 | <div> 267 | 小字 268 | </div> 269 | `; 270 | ``` 271 | 272 | 看这样的快照就清爽多了。**不过,快照并不是越小越好。因为如果快照太小你可能会想:这样的快照还不如我写 `expect(content.children).toEqual('大字')` 来得简单。** 273 | 274 | **所以,对于那种输出很复杂,而且不方便用 `expect` 做断言时,快照测试才算是一个好方法。** 275 | 这也是为什么组件 DOM 结构适合做快照,因为 DOM 结构有大量的大于、小于、引号这些字符。如果都用 `expect` 来断言,`expect` 的结果会写得非常痛苦。 276 | 不过,需要注意的是:**不要把无关的 DOM 也记录到快照里,这无法让人看懂。** 277 | 278 | ### 假错误 279 | 280 | 假如现在把 `title` 的 “大字” 改成 “我是一个大帅哥”: 281 | 282 | ```tsx 283 | describe("Title", () => { 284 | it("可以正确渲染大字", () => { 285 | const { getByText } = render(<Title type="large" title="我是一个大帅哥" />); 286 | const content = getByText('大字'); 287 | expect(content).toMatchSnapshot(); 288 | }); 289 | }); 290 | ``` 291 | 292 | 马上就得到这样的报错: 293 | 294 | ![](./diff-error.png) 295 | 296 | **这里只是文案改了一下,业务代码并没有任何问题,测试却出错了,这就是测试中的 “假错误”。** 虽然普通的单测、集成测试里也可能出现 “假错误”, 297 | 但是快照测试出现 “假错误” 的概率会更高,这也很多人不信任快照测试的主要原因。 298 | 299 | 在一些大快照,复杂组件的情况下,只要别的开发者改了某个地方,很容易导致一大片快照报错,基于人性的弱点,他们是没耐心看测试失败的原因的, 300 | 再加上更新快照的成本很低,只要加个 `--updateSnapshot` 就可以了,所以人们在面对快照测试不通过时,往往选择更新快照而不去思考 DOM 结构是否真的变了。 301 | 302 | 这些因素造成的最终结果就是:**不再信任快照测试。** 所以,你也会发现市面上很多前端测试的总结以及文章都很少做 **快照测试**。很大原因是快照测试本身比较脆弱, 303 | 而且容易造成 “假错误”。 304 | 305 | ## 快照的扩展 306 | 307 | 很多人喜欢把快照测试直接等于组件的 UI 测试,或者说快照测试是只用来测组件的。而事实上并不是! 308 | Jest 的快照可不仅仅能记录 DOM 结构,还能记录 **一切能被序列化 的内容,比如纯文本、JSON、XML 等等。** 309 | 310 | 举个例子: 311 | 312 | ```ts 313 | // getUserById.ts 314 | const getUserById = async (id: string) => { 315 | return request.get('user', { 316 | params: { id } 317 | }) 318 | } 319 | 320 | // getUserById.test.ts 321 | describe('getUserById', () => { 322 | it('可以获取 userId == 1 的用户', async () => { 323 | const result = await getUserById('1') 324 | expect(result).toEqual({ 325 | // 非常巨大的一个 JSON 返回... 326 | }) 327 | }) 328 | }); 329 | ``` 330 | 331 | 这个例子我们测试了 `getUserById` 的结果。在平常业务开发中,HTTP 请求返回的结果都是比较大的,有时候还会带一些冗余的数据,写 `expect` 的结果太麻烦了。 332 | **这里我们可以给当前的 `response` 拍一张快照,下次再用这张快照对比就好了:** 333 | 334 | ```ts 335 | // getUserById.ts 336 | const getUserById = async (id: string) => { 337 | return request.get('user', { 338 | params: { id } 339 | }) 340 | } 341 | 342 | // getUserById.test.ts 343 | describe('getUserById', () => { 344 | it('可以获取 userId == 1 的用户', async () => { 345 | const result = await getUserById('1') 346 | expect(result).toMatchSnapshot(); 347 | }) 348 | }); 349 | ``` 350 | 351 | **快照测试还适用于那些完全没有测试的项目。** 想想看,如果你要把测试引入一个项目,你可能连 `expect` 的结果都不知道是啥样。 352 | 353 | 你会怎么做?要是我,我就先不写断言,先跑一次测试,把 `result` 打印出来,再把打印内容贴到 `toEqual({...})`,这样的过程不就是上面的快照测试么? 354 | **所以,快照测试非常适合在线上跑了很久的老项目,不仅能验证组件,还能验证函数返回、接口结果等。** 355 | 356 | ## 总结 357 | 358 | 这一章我们学会了 **快照测试**。快照测试的思想很简单: 359 | 360 | * 先执行一次测试,**把输出结果记录到 `.snap` 文件**,以后每次测试都会把输出结果和 `.snap` 文件做 **对比** 361 | * 快照失败有两种可能: 362 | * 业务代码变更后导致输出结果和以前记录的 `.snap` 不一致,**说明业务代码有问题**,要排查 Bug 363 | * 业务代码有更新导致输出结果和以前记录的 `.snap` 不一致,**新增功能改变了原有的 DOM 结构**,要用 `npx jest --updateSnapshot` 更新当前快照 364 | 365 | 不过现实中这两种失败情况并不好区分,更多的情况是你既在重构又要加新需求,这就是为什么快照测试会出现 **“假错误”**。而如果开发者还滥用快照测试,并生成很多大快照, 366 | 那么最终的结果是没有人再相信快照测试。一遇到快照测试不通过,都不愿意探究失败的原因,而是选择更新快照来 “糊弄一下”。 367 | 368 | 要避免这样的情况,需要做好两点: 369 | 370 | * **生成小快照。** 只取重要的部分来生成快照,必须保证快照是能让你看懂的 371 | * **合理使用快照。** 快照测试不是只为组件测试服务,同样组件测试也不一定要包含快照测试。快照能存放一切可序列化的内容。 372 | 373 | 根据上面两点,还能总结出快照测试的适用场景: 374 | 375 | * **组件 DOM 结构的对比** 376 | * **在线上跑了很久的老项目** 377 | * **大块数据结果的对比** 378 | 379 | **从这一章可以看出,做测试没有我们想的 —— 干就完了。很多时候需要我们做取舍,找到合适的测试策略。** 这在下一章会有更明显的体验,现在让我们进入下一章的学习吧。 380 | -------------------------------------------------------------------------------- /docs/basic/snapshot-test/diff-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/snapshot-test/diff-error.png -------------------------------------------------------------------------------- /docs/basic/snapshot-test/title-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/snapshot-test/title-preview.png -------------------------------------------------------------------------------- /docs/basic/static-tool/README.md: -------------------------------------------------------------------------------- 1 | # 静态检查工具 2 | 3 | 前面的章节讲了很多关于单测和集成测试的内容,它们可以有效地提高我们的代码信心。 4 | 5 | 不过,不只有写测试才能提高代码信心,我们还能通过静态代码检查工具来实现,比如 TypeScript、ESLint 等。TypeScript 之前已跟大家聊过了, 6 | 这章就来讲讲 ESLint。 7 | 8 | ## ESLint 9 | 10 | **[ESLint](https://eslint.org/) 是一个前端标准的静态代码检查工具,它可以根据配置的规则来检查代码是否符合规范。** 11 | 现在市面上已经有很多 ESLint 相关的规则集,我们只需安装配置它们就好了: 12 | 13 | ```shell 14 | # ESLint 15 | npm i -D eslint@8.13.0 16 | # TypeScript 相关 17 | npm i -D @typescript-eslint/eslint-plugin@5.19.0 @typescript-eslint/parser@5.19.0 18 | ``` 19 | 20 | 在项目根目录添加 `.eslintrc.js`: 21 | 22 | ```js 23 | module.exports = { 24 | root: true, 25 | env: { 26 | browser: true, 27 | node: true, 28 | }, 29 | plugins: [], 30 | extends: [ 31 | "eslint:recommended", 32 | "plugin:@typescript-eslint/recommended", 33 | ], 34 | rules: { 35 | // 关闭规则 36 | "@typescript-eslint/no-non-null-assertion": "off", 37 | "@typescript-eslint/no-explicit-any": "off", 38 | }, 39 | }; 40 | ``` 41 | 42 | 我们配置的 ESLint 继承了 `eslint:recommended` 以及 `plugin:@typescript-eslint/recommended` 的 **ESLint 配置**。 43 | 44 | 执行 `npx eslint src --fix` 会自动修复 `src` 文件下的代码了。 45 | 46 | ## Prettier 47 | 48 | **[Prettier](https://prettier.io/) 是一个代码格式化工具。** 前一小节说到 ESLint 是通过制定的的规范来检查代码的,这里的 **规范** 有两种: 49 | * 代码风格规范 50 | * 代码质量规范 51 | 52 | Prettier 主要负责的是代码风格。 53 | 54 | ::: tip 55 | **绝大多数程序是不会对代码质量规范有异议的,但他们往往很难在代码风格上达成共识。** 56 | 而代码风格又是一个很主观的东西,公说公有理,婆说婆有理,怎么办呢? 57 | 58 | Prettier 就说:别吵了,我来定一个 **最完美** 的规范,你们就先按我的来,哪条不满意的,再自己配吧。 59 | ::: 60 | 61 | 现在我们来安装一下 Prettier: 62 | 63 | ```shell 64 | # Prettier 65 | npm i -D prettier@2.6.2 66 | # Prettier x ESLint 的配置和插件 67 | npm i -D eslint-config-prettier@8.5.0 eslint-plugin-prettier@4.0.0 68 | ``` 69 | 70 | 在根目录添加配置文件 `.prettierrc`: 71 | 72 | ```json 73 | {} 74 | ``` 75 | 76 | 这里我们不修改任何 Prettier 配置。然后在 `.eslintrc.js` 里引入 Prettier: 77 | 78 | ```js 79 | module.exports = { 80 | // ... 81 | extends: [ 82 | // ... 83 | "plugin:prettier/recommended", 84 | ], 85 | }; 86 | ``` 87 | 88 | **注意:要把 Prettier 的推荐配置 `plugin:prettier/recommended` 放在 `extends` 最后一项。** 89 | 90 | ## 写测试的规则 91 | 92 | 那么代码质量方面呢?著名的有 Airbnb 研发的 [eslint-config-airbnb](https://www.npmjs.com/package/eslint-config-airbnb) ESLint 配置。 93 | 不过,由于这个项目重点在测试,所以我们只关注测试代码的质量就好了。正好 Jest 和 React Testing Library 也推出了自己的 ESLint 规则和配置,我们来安装一下: 94 | 95 | ```shell 96 | # Jest 和 RTL 的 97 | npm i -D eslint-plugin-jest@26.1.4 eslint-plugin-testing-library@5.3.1 98 | ``` 99 | 100 | 在 `.eslintrc.js` 里引入它们,最终 ESLint 的完整配置如下: 101 | 102 | ```js 103 | module.exports = { 104 | root: true, 105 | env: { 106 | browser: true, 107 | node: true, 108 | "jest/globals": true, 109 | }, 110 | plugins: [], 111 | extends: [ 112 | // ESLint 113 | "eslint:recommended", 114 | // TypeScript 115 | "plugin:@typescript-eslint/recommended", 116 | // Jest 117 | "plugin:jest/recommended", 118 | // React Testing Library 119 | "plugin:testing-library/react", 120 | // Prettier 121 | "plugin:prettier/recommended", 122 | ], 123 | rules: { 124 | // 关闭规则 125 | "@typescript-eslint/no-non-null-assertion": "off", 126 | "@typescript-eslint/no-explicit-any": "off", 127 | "no-var": "off", 128 | "@typescript-eslint/no-var-requires": "off", 129 | "testing-library/no-dom-import": "off", 130 | // 错误提示 131 | "jest/no-focused-tests": "error", 132 | "jest/no-identical-title": "error", 133 | "jest/valid-expect": "error", 134 | "testing-library/await-async-query": "error", 135 | "testing-library/no-await-sync-query": "error", 136 | // 告警提示 137 | "jest/no-disabled-tests": "warn", 138 | "jest/prefer-to-have-length": "warn", 139 | "testing-library/no-debugging-utils": "warn", 140 | }, 141 | }; 142 | ``` 143 | 144 | 有了这种提示,你写测试代码时就会更规范,更少犯错。 145 | 146 | ## `extends` vs `plugins` 147 | 148 | 这一节我想聊聊 ESLint 中 `extends` 和 `plugins` 这两个配置参数的区别,相信这会困扰很多人。 149 | 150 | 举个例子,假如我们要配置 ESLint x TypeScript,可以看到官网有这样的配置: 151 | 152 | ```js 153 | module.exports = { 154 | root: true, 155 | parser: '@typescript-eslint/parser', 156 | plugins: [ 157 | '@typescript-eslint', 158 | ], 159 | extends: [ 160 | 'eslint:recommended', 161 | 'plugin:@typescript-eslint/recommended', 162 | ], 163 | }; 164 | ``` 165 | 166 | 神奇的是,当你去掉 `plugins` 之后发现 `eslint` 依然可以正常工作。更神奇的是,只要你写了 `extends`,那么连 `parser` 也可以不用加,要知道没有指定 `parser` 选项,eslint 可看不懂你的 TypeScript 文件。 167 | 168 | 所以说,到底是 `plugins` 加上了 TypeScript 的能力还是 `extends` 加上了 TypeScript 的规则呢?真让人头大,直到终于有一天受不了了,翻找了一下网上的资料发现了[这个帖子](https://stackoverflow.com/questions/61528185/eslint-extends-vs-plugins-v2020)。 169 | 170 | 先来说结论吧:**`plugins` 只是开启了这个插件,而 `extends` 则会继承别人写好的一份 `.eslintrc` 的配置,这份配置不仅仅包括了 `rules` 还有 `parser`,`plugins` 之类的东西。** 171 | 172 | 所以回到问题,为什么在继承了 `plugin:@typescript-eslint/recommended` 之后就可以不写 `plugins` 和 `parser` 呢?因为别人已经把配置都放在 `recommended` 这份配置表里了,这样对使用的人来说,就可以少写很多配置项了。 173 | 174 | 也就是说,下面两份配置是等价的: 175 | 176 | ```js 177 | module.exports = { 178 | parser: "@typescript-eslint/parser", 179 | parserOptions: { sourceType: "module" }, 180 | plugins: ["@typescript-eslint"], 181 | extends: [], 182 | rules: { 183 | "@typescript-eslint/explicit-function-return-type": [ 184 | "error", 185 | { 186 | allowExpressions: true 187 | } 188 | ] 189 | } 190 | } 191 | ``` 192 | 193 | 以及 194 | 195 | ```js 196 | module.exports = { 197 | plugins: [], 198 | extends: ["plugin:@typescript-eslint/recommended"], 199 | rules: { 200 | "@typescript-eslint/explicit-function-return-type": [ 201 | "error", 202 | { 203 | allowExpressions: true 204 | } 205 | ] 206 | } 207 | } 208 | ``` 209 | 210 | 对于第一份配置: 211 | * 需要手动添加 `parser`, `parserOptions`, `plugins` 212 | * 只开启了 `@typescript-eslint/explicit-function-return-type` 一个规则 213 | 214 | 对于第二份配置: 215 | * `plugin:@typescript-eslint/recommended` 自动添加了 `parser`, `parserOptions`, `plugins` 216 | * 自动加上一些推荐的 TypeScript 的 ESLint 规则 217 | * 自定义了 `@typescript-eslint/explicit-function-return-type` 规则 218 | 219 | ## 总结 220 | 221 | **看完这一章,我们了解到不仅写测试能提高代码信心,静态代码检查工具也可以给我们很强的代码自信。** 具体的途径是配置 TypeScript 和 ESLint。 222 | 223 | ESLint 中包含了两类规范: 224 | * 代码风格规范 225 | * 代码质量规范 226 | 227 | Prettier 可以给我们提供一份相对全面的代码风格规范。建议把它与 ESLint 结合起来使用。 228 | 229 | 对于代码质量规范,开发者需要自己去找对应的框架提供的 ESLint 规范,比如有 Vue、React、Jest、React Testing Library 等。 230 | 231 | 最后顺带解释了 `extends` 与 `plugins` 两个配置项的区别:**`plugins` 只是开启了这个插件,而 `extends` 则会继承别人写好的一份 `.eslintrc` 的配置, 232 | 这份配置不仅仅包括了 `rules` 还有 `parser`,`plugins` 之类的配置项。** 233 | -------------------------------------------------------------------------------- /docs/basic/tdd/README.md: -------------------------------------------------------------------------------- 1 | # 测试驱动开发 2 | 3 | 在上一章,我用一个例子给大家解释了 `getSearchObj` 函数的作用: 4 | 5 | ```ts 6 | window.location.href = 'https://www.baidu.com?a=1&b=2' 7 | 8 | const result = getSearchObj() 9 | 10 | // result = { 11 | // a: '1', 12 | // b: '2', 13 | // } 14 | ``` 15 | 16 | 如果大家没有写过 `getSearchObj`,可能会在实现的时候打很多个 `console.log` 来调试它: 17 | 18 | ```ts 19 | const getSearchObj = () => { 20 | // ?a=1&b=2 21 | const { search } = window.location; 22 | 23 | const searchStr = search.slice(1); 24 | console.log('searchStr', searchStr) // a=1&b=2 25 | 26 | const pairs = searchStr.split("&"); 27 | console.log('pairs', pairs); // ['a=1', 'b=2'] 28 | 29 | const searchObj: Record<string, string> = {}; 30 | console.log('searchObj', searchObj); // { 'a': '1' } 31 | 32 | pairs.forEach((pair) => { 33 | 34 | const [key, value] = pair.split("="); 35 | console.log('key', key, 'value', value) // [a, 1] 36 | searchObj[key] = value; 37 | }); 38 | 39 | return searchObj; 40 | }; 41 | 42 | console.log('result', getSearchObj()); 43 | ``` 44 | 45 | 这些 `log` 不仅难看,而且 `log` 出来后我们还要把他们删掉。而且,这样最多只能测到一两种 Case,不能覆盖到所有边界情况。为了能手动执行它, 46 | 我们还得一遍遍刷新网页,然后肉眼找茬。 47 | 48 | ::: tip 49 | **这里的 `log` 操作,也可以看成是一种手动测试:`执行 -> 用眼睛看结果`。** 50 | ::: 51 | 52 | 那为什么不把上这些操作自动化,用程序代替手工呢?**这就是 TDD(Test Driven Development)测试驱动开发。** 53 | 54 | ## 原理 55 | 56 | TDD 是一种非常好用的开发模式,做法很简单:**先写测试,再写业务代码,当所有测试用例都通过后,你的业务代码也就实现完了**,图解: 57 | 58 | ![](./cycle.jpg) 59 | 60 | * 🚨 红色部分:在你还没添加新功能前先写的一个测试。然后你会得到一个失败的测试用例(看到 “红色” 的报错信息)。 61 | * ✅ 绿色部分:不断添加业务代码来让测试通过(看到 “绿色” 成功通过信息)。 62 | * 🌀 重构部分:再回过头看审视自己的代码,把它重构成可读性和维护性更高的代码(这一步最爽的点就是在重构时,你有一个测试会告诉你有没有搞崩原有逻辑)。 63 | * 🔁 重复:这就是个循环,反正 😉 一直走下去,直到写完这个功能 64 | 65 | **TDD 站在开发者的视角测试一个功能的组成模块比如函数或方法是否预期,而 BDD(Behavior Driven Development)行为驱动测试,与之不同是站在用户的角度描述测试用例。更多可阅读 [Understanding the Differences Between BDD & TDD](https://cucumber.io/blog/bdd/bdd-vs-tdd/#:~:text=BDD%20is%20designed%20to%20test,pieces%20of%20functionality%20in%20isolation.)。** 66 | 67 | 我们之前写的 `sum`, `storage` 以及 `getSearchObj` 属于 TDD 开发模式。 68 | 69 | ## 适用场景 70 | 71 | 或许有些人不太能接受先写测试再写业务这种模式,觉得先花那么多时候在测试上不值得。这其实是一种误解,他们并没有搞清楚 TDD 要解决的问题以及它的适用场景。 72 | TDD 有以下适用场景: 73 | 74 | **第一种:写纯函数场景。** 前面几章写的 `sum` 和 `getSearchObj` 就是纯函数。不管里面的逻辑简单还是复杂,我们都很容易就能想到这些函数的输入输出, 75 | 所以写这些函数的测试用例是最简单的。先在写业务前先定义好 90% 使用场景的输入输出,Jest 就能快速帮我们验证业务实现是否正确,而不用频繁地 `console.log`。 76 | 77 | **第二种:修 Bug 场景。** 这对没有任何测试的项目非常实用。当遇到 Bug 时,先写一个测试用例来复现问题,然后通过这个用例来调试业务代码。用例通过后, 78 | 业务代码也自然被修复了。 79 | 80 | **第三种:UI 交互场景。** 在开始写页面逻辑时,把 HTTP 请求给 Mock 掉(在后面会讲到),先用测试模拟真实用户的使用路径,然后再实现业务逻辑。 81 | 当测试用例通过后,说明需求的主逻辑也是能走通的。 82 | 83 | 可以看到上面这些场景中没有说到 “代码质量”,“覆盖率”,“异常情况”,**所以,TDD 的主要作用不是保证代码质量, 84 | 而是给开发者创造一个更友好的开发环境,在这基础上保障了代码的主逻辑。** 85 | 86 | 从上面场景,我们可以得出结论:**TDD 比较适合那些实现复杂,但输入输出很明确的场景。** 因此,TDD 也被广泛用到工具函数,数据转换函数, 87 | 以及后端的接口测试。 88 | 89 | ## 用例文档化 90 | 91 | **TDD 的另一个好处(应该说是所有测试用例的好处)就是用例文档化。** 92 | 93 | 还记得上一章我是怎么给大家说清楚 `getSearchObj` 的含义和作用么?我嫌啰嗦,直接套用了一个例子来让你明白什么叫 **“把网页地址中的查询参数字符串转换为对象”**: 94 | 95 | ```ts 96 | window.location.href = 'https://www.baidu.com?a=1&b=2' 97 | 98 | const result = getSearchObj() 99 | 100 | // result = { 101 | // a: '1', 102 | // b: '2', 103 | // } 104 | ``` 105 | 106 | 如果这时又有一个人问我这个函数是用来干嘛的,我又得说一遍这个例子。那为什么不把它记录到代码里呢?**当使用 TDD 时, 107 | 提前写的测试用例都会提交到代码仓库里,可以作为一份简单的使用文档。** 这样的好处有: 108 | 109 | **降低上手难度。** 相信大家在使用别人的模块时,经常会听到:“你看 XXX 文件就知道 YYY 怎么用了”。但这个 XXX 文件往往有非常多的依赖和干扰项, 110 | 很难弄清楚最简单的使用方法。**而测试用例(特别是单测)通常只测其中一个使用场景,所以它们会记录着最简单、朴素的 Use Case。使用者只需看测试用例, 111 | 即可知道如何上手。** 112 | 113 | **用例正确性。** 接口文档化是做后端(Node 端)非常重要的一步,有的人写在腾讯文档,有的人喜欢存在 Postman,以方便调试。但这些用例有个致命的缺点: 114 | 接口发生变更时,很难及时更新对应的示例。测试用例正好可以解决这个问题,**由于测试用例必须全部通过才能 Push 代码,所以我们永远不用担心用例的过期问题。** 115 | 116 | ## 实战 117 | 118 | 言归正传,我们来简单使用一下 TDD。假如现在有一个需求:*把给定对象转换成查询参数字符串。* 119 | 120 | 首先,我们添加 `tests/utils/objToSearchStr.test.ts`,用测试用例来描述这个需求: 121 | 122 | ```ts 123 | import objToSearchStr from "utils/objToSearchStr"; 124 | 125 | describe("objToSearchStr", () => { 126 | it("可以将对象转化成查询参数字符串", () => { 127 | expect( 128 | objToSearchStr({ 129 | a: "1", 130 | b: "2", 131 | }) 132 | ).toEqual("a=1&b=2"); 133 | }); 134 | }); 135 | ``` 136 | 137 | 然后,再来添加 `src/utils/objToSearchStr.ts`,边看业务输入输出边实现代码逻辑: 138 | 139 | ```ts 140 | const objToSearchStr = (obj: Record<string, string | number>) => { 141 | // ['a=1', 'b=2'] 142 | const strPairs: string[] = []; 143 | 144 | Object.entries(obj).forEach((keyValue) => { 145 | const [key, value] = keyValue; // [a, 1] 146 | const pair = key + "=" + String(value); // a=1 147 | strPairs.push(pair); 148 | }, []); 149 | 150 | // a=1&b=2 151 | return strPairs.join("&"); 152 | }; 153 | 154 | export default objToSearchStr; 155 | ``` 156 | 157 | 在执行测试时,我们还能用 IDE 的调试器来做断点调试,绝对比疯狂打 `console.log` 来得高效。 158 | 159 | ## 总结 160 | 161 | 在这一章里,我们学到 TDD 和 BDD 两种开发模式: 162 | * TDD:先测试,之后补充业务 163 | * BDD:先写业务,再对重要部分补充测试 164 | 165 | 我们还了解到TDD 的使用场景和要解决的问题、以及用例文档化的好处。最后,我们用 TDD 的开发模式来实现了 `objToSearchStr` 这个函数。 166 | -------------------------------------------------------------------------------- /docs/basic/tdd/cycle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/tdd/cycle.jpg -------------------------------------------------------------------------------- /docs/basic/test-environment/README.md: -------------------------------------------------------------------------------- 1 | # 测试环境 2 | 3 | 刚刚的 `sum` 实在是太简单了,根本没难度。这一章我们来搞点有难度的。 4 | 5 | 在很多时候,我们前端的代码往往只在浏览器里运行,经常要用到浏览器的 API。我之前就封装过一个 `storage` 文件, 6 | 通过指定 `type = 'indexedDB' | 'cookie' | 'localStorage'` 来切换存储的方式,而且还可以生成自定义的 `key`,防止全局污染。 7 | 8 | **相信大家也见过不少这种和浏览器强绑定的工具文件,那我们该如何测它们呢?** 9 | 10 | ## 例子 11 | 12 | 对刚说的 `storage` 做下简化,我们只对 `localStorage` 进行封装,一共有 `set` 和 `get` 两个函数。添加 `src/utils/storage.ts`: 13 | 14 | ```ts 15 | // src/utils/storage.ts 16 | const KEY_NAME = "my-app-"; 17 | 18 | const set = (key: string, value: string) => { 19 | localStorage.setItem(KEY_NAME + key, value); 20 | }; 21 | 22 | const get = (key: string) => { 23 | return localStorage.getItem(KEY_NAME + key); 24 | }; 25 | 26 | const storage = { 27 | get, 28 | set, 29 | }; 30 | 31 | export default storage; 32 | ``` 33 | 34 | 然后在 `tests/utils/storage.test.ts` 添加这个文件的测试用例: 35 | 36 | ```js 37 | // tests/utils/storage.test.ts 38 | import storage from "utils/storage"; 39 | 40 | describe("storage", () => { 41 | it("可以缓存值", () => { 42 | storage.set("newKey", "hello"); 43 | expect(localStorage.getItem("my-app-newKey")).toEqual("hello"); 44 | }); 45 | 46 | it("可以设置值", () => { 47 | localStorage.setItem("my-app-newKey", "hello"); 48 | expect(storage.get("newKey")).toEqual("hello"); 49 | }); 50 | }); 51 | ``` 52 | 53 | 由于 Node.js 环境并没有 `localStorage`,所以你会得到这样的报错: 54 | 55 | ![](./storage-error.png) 56 | 57 | ## 全局 Mock 58 | 59 | 既然没有 `localStorage`,那我们可以给它 Mock 一个!首先添加 `tests/jest-setup.ts` 文件,然后放置 `localStorage` 的 Mock 实现: 60 | 61 | ```ts 62 | // tests/jest-setup.ts 63 | Object.defineProperty(global, 'localStorage', { 64 | value: { 65 | store: {} as Record<string, string>, 66 | setItem(key: string, value: string) { 67 | this.store[key] = value; 68 | }, 69 | getItem(key: string) { 70 | return this.store[key]; 71 | }, 72 | removeItem(key: string) { 73 | delete this.store[key]; 74 | }, 75 | clear() { 76 | this.store = {} 77 | } 78 | }, 79 | configurable: true, 80 | }) 81 | ``` 82 | 83 | ::: tip 84 | 得益于刚刚配置的 TypeScript,这里的 setup 文件也可以写成 `.ts` 了! 85 | ::: 86 | 87 | 然后在 `jest.config.js` 里添加 `setupFilesAfterEnv` 配置: 88 | 89 | ```js 90 | module.exports = { 91 | setupFilesAfterEnv: ['./tests/jest-setup.ts'], 92 | }; 93 | ``` 94 | 95 | ::: warning 96 | **推荐:使用 `setupFilesAfterEnv` 而不是 `setupFiles`。** 97 | ::: 98 | 99 | **设置了之后,`jest-setup.ts` 会在每个测试文件执行前先执行一次。** 相当于每执行一次测试,都会在全局添加一次 `localStorage` 的 Mock 实现。 100 | 现在再来执行一次 `npm run test`,会发现执行成功: 101 | 102 | ![](./storage-setup-success.png) 103 | 104 | 105 | ## `setupFilesAfterEnv` vs `setupFiles` 106 | 107 | 插入一下:相信很多人都知道 Jest 的 `setupFiles`,但不太了解 `setupFilesAfterEnv`,这里简单讲讲它们的区别 108 | **(可从 [官网的介绍](https://jestjs.io/docs/configuration#setupfiles-array) 了解更多)**: 109 | 110 | ![](./setupFiles-vs-setupFilesAfterEnv.png) 111 | 112 | 简单来说: 113 | * `setupFiles` 是在 **引入测试环境(比如下面的 `jsdom`)之后** 执行的代码 114 | * `setupFilesAfterEnv` 则是在 **安装测试框架之后** 执行的代码 115 | 116 | 具体应用场景是:在 `setupFiles` 可以添加 **测试环境** 的补充,比如 Mock 全局变量 `abcd` 等。而在 `setupFilesAfterEnv` 可以引入和配置 **Jest/Jasmine(Jest 内部使用了 Jasmine)** 插件。 117 | 118 | 如果你试图在 `setupFiles` 添加 Jest 的扩展/插件,那么你可能会得到 `expect is not defined` 报错。[详见这个 Issue](https://github.com/testing-library/jest-dom/issues/122#issuecomment-650520461) 。 119 | 120 | **为了简便,本教程把初始化代码都放在 `setupFilesAfterEnv` 中。在真实项目中,大家再按自己的需求来对应做配置即可。** 121 | 122 | ## jsdom 测试环境 123 | 124 | 回到主题,像上面 Mock `LocalStorage` 这样有点傻,因为我们不可能把浏览器里所有的 API 都 Mock 一遍,而且不可能做到 100% 还原所有功能。因此,`jest` 提供了 `testEnvironment` 配置: 125 | 126 | ```js 127 | module.exports = { 128 | testEnvironment: "jsdom", 129 | } 130 | ``` 131 | 132 | 添加 `jsdom` 测试环境后,全局会自动拥有完整的浏览器标准 API。**原理是使用了 [jsdom](https://github.com/jsdom/jsdom) 。 133 | 这个库用 JS 实现了一套 Node.js 环境下的 Web 标准 API。** 由于 Jest 的测试文件也是 Node.js 环境下执行的,所以 Jest 用这个库充当了浏览器环境的 Mock 实现。 134 | 135 | 现在清空 `jest-setup.ts` 里的代码,直接 `npm run test` 也会发现测试成功: 136 | 137 | ![](./storage-env-success.png) 138 | 139 | ::: warning 140 | **请不要把 `jest-setup.ts` 删掉,后面还大有用处!** 141 | ::: 142 | 143 | 那 `testEnvironment` 除了 `jsdom` 还有没有别的呢?有,不过一般都只是 `jsdom` 的扩展环境,在下一章会讲到,那现在我们就进入下一章的学习吧~ 144 | 145 | ## 总结 146 | 147 | 这一章里,我们学到了 `setupFilesAfterEnv`,它可以指定一个文件,在每执行一个测试文件前都会跑一遍里面的代码。在这个 setup 文件中, 148 | 可以放置全局的 Mock 实现,以及一些初始化操作。 149 | 150 | 为了方便测试浏览器环境下的代码,我们可以配置 `testEnvironment: 'jsdom'` 来创建一个 Node.js 的浏览器环境。这样我们就不用每个 API 都 Mock 一次了。 151 | -------------------------------------------------------------------------------- /docs/basic/test-environment/setupFiles-vs-setupFilesAfterEnv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/test-environment/setupFiles-vs-setupFilesAfterEnv.png -------------------------------------------------------------------------------- /docs/basic/test-environment/storage-env-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/test-environment/storage-env-success.png -------------------------------------------------------------------------------- /docs/basic/test-environment/storage-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/test-environment/storage-error.png -------------------------------------------------------------------------------- /docs/basic/test-environment/storage-setup-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/test-environment/storage-setup-success.png -------------------------------------------------------------------------------- /docs/basic/transformer/README.md: -------------------------------------------------------------------------------- 1 | # 转译器 2 | 3 | 如今 2022 年,无论我们写业务还是写测试,都会采用比较高级的 JavaScript 语法,或者 TypeScript。 4 | 5 | **但是,Jest 本身不做代码转译工作。** 在执行测试时,它会调用已有的 **转译器/编译器** 来做代码转译。在前端,我们最熟悉的两个转译器就是 [Babel](https://babeljs.io/) 以及 [TSC](https://www.typescriptlang.org/) 了。 6 | 7 | 下面我们就以 `Jest x TypeScript` 为例子来讲如何对测试代码做转译吧。 8 | 9 | ## TSC 转译 10 | 11 | ::: warning 12 | 本教程使用 `ts-jest` 作为主力转译器,后面 **《Jest 性能部分》** 会尝试使用其它转译器。 13 | ::: 14 | 15 | 首先安装 [typescript](https://www.npmjs.com/package/typescript) : 16 | 17 | ```shell 18 | npm i -D typescript@4.6.3 19 | ``` 20 | 21 | 安装 `typescript` 的同时也会安装转译器 `tsc`,可以用它来初始化 TypeScript 的配置: 22 | 23 | ```shell 24 | npx tsc --init 25 | ``` 26 | 27 | 会发现在根目录创建了一个 `tsconfig.json` 文件: 28 | 29 | ```json 30 | { 31 | "compilerOptions": { 32 | "target": "es2016", 33 | "module": "commonjs", 34 | "esModuleInterop": true, 35 | "forceConsistentCasingInFileNames": true, 36 | "strict": true, 37 | "skipLibCheck": true 38 | } 39 | } 40 | ``` 41 | 42 | 现在安装 [ts-jest](https://kulshekhar.github.io/ts-jest/) : 43 | 44 | ```shell 45 | npm i -D ts-jest@27.1.4 46 | ``` 47 | 48 | ::: warning 49 | **注意,这里 `ts-jest` 一定要和 `jest` 的大版本一致!** 比如 27 对 27,或者 26 对 26,否则会有兼容问题! 50 | ::: 51 | 52 | 在 `jest.config.js` 里添加一行配置: 53 | 54 | ```js 55 | module.exports = { 56 | preset: 'ts-jest', 57 | // ... 58 | }; 59 | ``` 60 | 61 | 把 `sum.js` 改成 `sum.ts`: 62 | 63 | ```ts 64 | // sum.ts 65 | const sum = (a: number, b: number) => { 66 | return a + b; 67 | } 68 | 69 | export default sum; 70 | ``` 71 | 72 | 把 `sum.test.js` 改成 `sum.test.ts`: 73 | 74 | ```ts 75 | import sum from '../../src/utils/sum'; 76 | 77 | describe('sum', () => { 78 | it('可以做加法', () => { 79 | expect(sum(1, 1)).toEqual(2); 80 | }); 81 | }) 82 | ``` 83 | 84 | 不过,改完后你会发现有很多的报错: 85 | 86 | ![](./error.png) 87 | 88 | ## Jest 的类型声明 89 | 90 | 上面的报错是因为 TS 找不到 `describe` 和 `it` 的类型定义,这里要安装对应的 Jest 类型声明包: 91 | 92 | ```shell 93 | npm i -D @types/jest@27.4.1 94 | ``` 95 | 96 | ::: tip 97 | 同样地,TS 声明类型包的大版本最好和 `jest` 一样。 98 | ::: 99 | 100 | 然后在 `tsconfig.json` 里加上 `jest` 和 `node` 类型声明: 101 | 102 | ```json 103 | { 104 | "compilerOptions": { 105 | "types": ["node", "jest"] 106 | } 107 | } 108 | ``` 109 | 110 | 最后执行 `npm run test`,测试通过。 111 | 112 | ## 更多转译器 113 | 114 | 还记得这一章开头说的:**Jest 本身不做转译,而是利用别的转译器的能力来转译。** 因此,我们除了用 `tsc` 来转译,还能用其它转译器。 115 | 116 | ### Babel 转译器 117 | 118 | 可能有些同学的项目就是 Webpack + Babel 为主,那么你也可以选择使用 `babel-jest` 来做转译, 119 | 具体配置看 [官网的教程](https://jestjs.io/docs/getting-started#using-typescript-via-babel) 。 120 | 121 | Babel 做转译的 **缺点是无法让 Jest 在运行时做类型检查**,所以更推荐大家使用 `ts-jest`,利用 `tsc` 来转译 TypeScript。 122 | 123 | > Because TypeScript support in Babel is purely transpilation, Jest will not type-check your tests as they are run. —— 官网 124 | 125 | ### 非官方转译器 126 | 127 | 当然,我们也能用现在非常火的 [esbuild](https://esbuild.github.io/) 和 [swc](https://swc.rs/docs/getting-started) 来做转译。 128 | 由于它们都不是 Jest 官方推荐的转译器,所以使用时要注意兼容性和坑。 129 | 130 | 顺便说一下,`esbuild` 是 [Golang](https://go.dev/) 写的一个转译器,速度巨快: 131 | 132 | ![](./esbuild.png) 133 | 134 | 而 `swc` 则是 [Rust](https://www.rust-lang.org/) 写的一个转译器,速度更快: 135 | 136 | ![](./swc.png) 137 | 138 | 不过,速度快只是一方面,Jest 在构建测试环境的时会有很多 Tricky 的操作,**但并不是所有转译器都支持这些骚操作的**。 139 | 像 `swc` 这种要用到计算机比较底层的转译工具,在不同平台的的表现可能有所不同,所以,使用这些转译器会存在一定的风险。 140 | 141 | 目前来说,建议大家把它们当作实验品来试用,就算出问题再回退到 `babel` 和 `tsc` 也很简单。 142 | 143 | ::: tip 144 | 生产环境推荐使用 `ts-jest`,后面会用 `@swc/jest` 作为实验品带大家体验一下。 145 | ::: 146 | 147 | ## 路径简写 148 | 149 | 你对这个测试满意了么?反正我还不满意,为啥我要写一句 `../../src/utils/sum` 这么长的路径?我写成 `utils/sum` 不是更香? 150 | 这也是很多大型项目的必备配置了 —— **路径简写/别名**。 151 | 152 | 要实现这样的效果,我们可以在 `moduleDirectories` 添加 `"src"`: 153 | 154 | ```js 155 | // jest.config.js 156 | module.exports = { 157 | moduleDirectories: ["node_modules", "src"], 158 | // ... 159 | } 160 | ``` 161 | 162 | 这样一来 `jest` 就能看懂 `utils/sum` 对应的是 `../../src/utils/sum`,但是,`tsc` 看不懂呀: 163 | 164 | ![](./path-error.png) 165 | 166 | 我们还得在 `tsconfig.json` 里指定 `baseUrl` 和 `paths` 路径: 167 | 168 | ```json 169 | { 170 | "compilerOptions": { 171 | "baseUrl": "./", 172 | "paths": { 173 | "utils/*": ["src/utils/*"] 174 | } 175 | } 176 | } 177 | ``` 178 | 179 | 解释一下, **所谓的 “路径简写” 本质上只是路径映射。所以 `tsconfig.json` 里的 `paths` 就是把 `utils/xxx` 映射成 `src/utils/xxx`, 180 | 而 `jest.config.js` 里的 `moduleDirectories` 则稍微狠一点,直接把 `utils/sum` 当作第三方模块,先在 `node_modules` 里找,找不到再从 `src/xxx` 下去找。 181 | 所以这两者是有区别的。** 182 | 183 | 有的同学可能不会这么写,而是用别名作为路径开头:`import sum from "@/utils/sum"`。这依旧是路径匹配,`tsconfig.json` 的配置相当简单: 184 | 185 | ```json 186 | { 187 | "compilerOptions": { 188 | "paths": { 189 | "@/*": ["src/*"] 190 | } 191 | } 192 | } 193 | ``` 194 | 195 | 但对 Jest 的配置就不能再用 `moduleDirectories` 了,也得用路径匹配。我们可以使用 `moduleNameMapper`,这也是使用频率非常高的一个配置项: 196 | 197 | ```js 198 | // jest.config.js 199 | modulex.exports = { 200 | "moduleNameMapper": { 201 | "@/(.*)": "<rootDir>/src/$1" 202 | } 203 | } 204 | ``` 205 | 206 | 那有的同学就会问了:难道每次写路径匹配规则都在 `tsconfig.json` 和 `jest.config.js` 写两份么?**很遗憾,确实如此。造成这个问题的主要原因是 `jest` 根本不管 `tsc`。** 207 | 不过,好消息是,你可以用 `ts-jest` 里的工具函数 `pathsToModuleNameMapper` 来把 `tsconfig.json` 里的 `paths` 配置复制到 `jest.config.js` 里的 `moduleNameMapper`: 208 | 209 | ```js 210 | // jest.config.js 211 | const { pathsToModuleNameMapper } = require('ts-jest/utils') 212 | const { compilerOptions } = require('./tsconfig') 213 | 214 | module.exports = { 215 | // [...] 216 | // { prefix: '<rootDir/>' } 217 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 218 | prefix: "<rootDir>/", 219 | }), 220 | } 221 | ``` 222 | 223 | 看到这样的配置方法,你是不是觉得 JS 的单一原则太难顶了?这么简单的一个功能都要通过第三方的 `ts-jest` 来提供?然而,坏消息是 `webpack` 的配置也不会读 `tsconfig.json` 里面的 `paths`, 224 | **所以,开发者不仅要在 `tsconfig.json` 里写一份路径映射,还要在 `webpack.config.js` 里再写一份** 。[详见这里](https://stackoverflow.com/questions/40443806/webpack-resolve-alias-does-not-work-with-typescript) 。 225 | 226 | ::: tip 227 | 本次教程将用 `moduleDirectories` 来实现路径别名,如果你想用 `moduleNameMapper`,那么后续的 Webpack 配置可能也要跟着改一下。 228 | ::: 229 | 230 | ## 总结 231 | 232 | 这一章,我们了解到了 Jest 与转译器的关系,Jest 本身不做任何转译,只是利用了其它转译器的能力来做代码转译。 233 | 234 | 常见的转译器有 `babel`, `tsc`, `esbuild` 和 `swc`,后面两个速度较快,但存在一定风险。 235 | 236 | 在一些大型项目中,经常会出现路径别名。由于 Jest 不做转译,所以在做转译时需要在 `tsconfig.json` 里不仅要做别名的路径映射, 237 | 还要在 `jest.config.js` 里也要做同样的路径匹配。这可以通过配置 `moduleDirectories` 和 `moduleNameMapper` 来实现。 238 | -------------------------------------------------------------------------------- /docs/basic/transformer/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/transformer/error.png -------------------------------------------------------------------------------- /docs/basic/transformer/esbuild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/transformer/esbuild.png -------------------------------------------------------------------------------- /docs/basic/transformer/path-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/transformer/path-error.png -------------------------------------------------------------------------------- /docs/basic/transformer/swc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/basic/transformer/swc.png -------------------------------------------------------------------------------- /docs/end/end.md: -------------------------------------------------------------------------------- 1 | # 结语 2 | 3 | OK,终于大功告成了!这是我写的第三本小书,前一本是 [Linter上手完全指南](https://github.yanhaixiang.com/linter-tutorial/) ,有兴趣可以看看,海怪又完成了一个小目标。 4 | 5 | **如果喜欢我的文章,也可以关注公众号【写代码的海怪】,每周周五准时更新技术文章,不会太水也不会太干,纯属聊天。** 6 | 7 | ![](../qrcode.gif) 8 | -------------------------------------------------------------------------------- /docs/end/github.md: -------------------------------------------------------------------------------- 1 | # Github 项目 2 | 3 | 本教程 Github 可以 [点这里查看](https://github.com/haixiangyan/jest-tutorial)。 4 | 5 | 本教程的实战示例项目 Github 可以 [点这里查看](https://github.com/haixiangyan/jest-tutorial-example)。 6 | -------------------------------------------------------------------------------- /docs/intro/why-test/README.md: -------------------------------------------------------------------------------- 1 | ## 为什么要测试 2 | 3 | 一谈到单测,可能大家的第一反应都是敬而远之。 4 | 5 | > **没啥用,没时间,我不会** 6 | 7 | 我承认写单测是个非常有挑战性,且难度不小的活,但 **我依然推荐大家尝试去写一写单元测试,因为它所带来的好处不仅仅是大家想的那么简单:“只是 Bug 少了一点”**。 所以,**我会尝试从另外一些角度来讨论单测可以给我们带来哪些好处。** 8 | 9 | ## 优化流程 10 | 11 | 接着刚刚说到的 “只是 Bug 少一点” 这句话,可能大多数觉得单测就是在提测前减少一点 Bug 而已: 12 | 13 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f3364ba640d443e7bb4ce5eca49d1a1a~tplv-k3u1fbpfcp-zoom-1.image) 14 | 15 | 这样的想法确实是最直观的。但这只是想到了第一层,如果我们把 **开发流程所有步骤** 都加进来,会发现是这样的: 16 | 17 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/235e9c8636374d338641fc8ed4fff6fa~tplv-k3u1fbpfcp-zoom-1.image) 18 | 19 | 在 `开发过程` 后面,几乎每个流程都可能抛出 Bug。**越是到后面流程才抛出的 Bug,程序员就越是要投入比开发阶段更大的时间和业务,而且所承受的风险也是最高的。** 20 | 21 | 或许大家会想:不就改个 Bug,改几行而已。 **可是大家有没有想过在跟测的过程中,很可能你已经开始另一个需求的评审了!** 此时的你在解决突然插入的 Bug 的时候,心态还会像刚开始写代码时候那么轻松么? 22 | 23 | 实际上,还有更多的隐性成本没有考虑,**比如反复确认产品逻辑、反复确认交互设计、反复确认前后端接口设计、各端对产品的理解。** 有的时候,你就会发现这样很魔幻的场景:明明是一个字段的展示问题,竟然要花上一上午,拉了 4、5 个人来开会核对的情况。 24 | 25 | 下面这张图,也在说明两个问题:一是 85% 的缺陷都在代码设计阶段产生;二是发现 Bug 的阶段越靠后,耗费成本就越高,呈指数级别的增长。这种 “指数成本” 的案例也经常发生,当我们改正一个 Bug 的时候,可能随之而来又会多出 3 个 Bug,俗称:**改崩了。** 26 | 27 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0dc5069bfff84135b32f8940715c2c4c~tplv-k3u1fbpfcp-zoom-1.image) 28 | 29 | **所以,在早期的单元测试就能发现bug,不仅可以省时省力,在开发流程上提高效率,也能降低反复修改出现的风险和时间成本。** 30 | 31 | ## 保证质量 32 | 33 | 这一节主题就是大家经常想的:减少 Bug 率。我们不妨来想一个问题:**什么才是 Bug?** 相信所有开发人员都不愿意写 Bug,在 **《软件测试》** 这本书中将 Bug 描述成 “软件缺陷”,里面说道: 34 | 35 | > 大多数的 “软件缺陷” 并非源自编程错误,对众多从小到大的项目进行研究而得出的结论往往是一致的,导致软件缺陷最大的原因是产品说明书!见下图 36 | 37 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/58cc7c6deab74de9b7d900935d34d664~tplv-k3u1fbpfcp-zoom-1.image) 38 | 39 | 大多数的产品还是能够写出一份清晰明了的需求单的,奈何 ta 也不可能把所有情况想都枚举出来,这也导致了开发时很容易出现考虑不周全的情况。**往往能够发现异常情况的人要么是测试、要么是交互视觉、要么是后期产品体验。** 那到这个时候才发现的问题,然后再去修复又会出现的 **指数爆炸的成本**。 40 | 41 | 如果把实现功能看成走迷宫,把找到通路看成上线需求, **那么编码实现的过程就像从入口找出口,而单元测试则像从出口找入口。** 这种开两个线程 “双向奔赴” 的找通路方法能够用最精准最快的方式找到通路。 42 | 43 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d2d176624866416184fd183f1a670146~tplv-k3u1fbpfcp-zoom-1.image) 44 | 45 | 单测所保障的不仅仅只是代码的正确性,毕竟大家在边开发边 Debug 的时候已经能验证 99% 的正确性了,而单测更大的地方在于 **让我们不得不去思考到一些异常情况** ,这无形中就能增强代码的质量。 46 | 47 | ## 优化更新项目的后盾 48 | 49 | 可能大家对上面这一节也不以为意,我能理解大家的侥幸心理。毕竟在公司里,开发写完 Bug,然后交给测试找出来是大家其乐融融表现。而且不写测试大家过得还挺好的,也没出什么大乱子。 50 | 51 | 造成这样的错觉在两个方面:**一是测试找 Bug,开发再 Debug,这确实能解决燃眉之急,短期内很有效果。二是需求一直不断快速迭代,一期的 Bug,二期还能合着去改,二期改不了还有三期,三期结束了还有四期......** 。 52 | 53 | 这种永无止境的测试 + 开发模式能在一定程度上让我们的代码 **“看起来是有保障的”** 。 54 | 55 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dd00b5dc94404943aea524e400dc1bdc~tplv-k3u1fbpfcp-zoom-1.image) 56 | 57 | 人肉测试固然好用,但是也有下面的缺点: 58 | 59 | * 使用一次成本非常高 60 | * 回归测试成本更高 61 | * 只有到上线功能的时候才会使用一次人力测试来轰炸 62 | 63 | 由于成本很高,人肉测试一般只会用来测业务功能,并没有太多测试资源可以分配到优化需求、技术需求上。 **所以对于这类需求只能通过前端开发人员自测,到目前为止也只是优化一个点,然后点点鼠标来自测,效率并不高。一旦优化过程中改出了问题,回滚、和修复的成本又会非常高,这也会助长大家 “不敢优化”、“能不动就不动” 的思想。** 64 | 65 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4283da0c0f94446a9216f65829261166~tplv-k3u1fbpfcp-zoom-1.image) 66 | 67 | **如果能有一定量的测试,则有足够强大的信心来支撑项目的优化,也有助于整个项目的未来发展和改进。** 68 | 69 | ## 测试驱动开发 70 | 71 | 测试驱动开发(Testing-Driven Development)是敏捷开发中的一项核心实践和技术,也是一种设计方法论。 72 | 73 | **上面说的单测特点比较偏向于 “防守”,而 TDD 中的测试则偏向于 “进攻”。** TDD 的原理是在开发功能代码之前,先编写单元测试用例代码,在此基础上再补充产品代码。比如要实现 `getUserById` 这个服务,那么可以先写如下测试,然后再补充 `getUserById` 的实现: 74 | 75 | ```js 76 | describe('getUserById', () => { 77 | it('可以根据 id 返回用户信息', () => { 78 | // TODO: getUserById 未实现 79 | const user = getUserById('122'); 80 | expect(user.id).toEqual('122'); 81 | }) 82 | }) 83 | ``` 84 | 85 | 这种方法在 Node 端非常实用。由于 Node 端要依赖的项非常多,比如数据库、各方接口、配置中心等等。每次用 Postman 去测接口,就会一次性将多个模块以及服务一起测了。 **如果别的服务还在开发或者有问题,就会直接阻塞了接口的开发。** 86 | 87 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d7045c1cea674dd5a928f055ba7c816f~tplv-k3u1fbpfcp-zoom-1.image) 88 | 89 | 虽然 Postman 在接口测试的时候很好用,但是它也有如下缺点: 90 | 91 | * **用例不足。** 由于 Postman 一般只做简单的接口测试,并不像单测那样会把所有分支情况都枚举 92 | * **用例无法共享。** 虽然 Postman 也能写简单的用例,但是现在每个人的 Postman 会有自己的用例,难以覆盖所有情况 93 | * **用例无法保鲜。** 当接口更新了之后,Postman 的用例可能存在过期的情况 94 | 95 | 96 | **单元测试则很好地填补了这一块,利用单测强大的 Mock 能力先将依赖项都 Mock 掉,开发时可以只关注某个函数、服务的开发,不会受其依赖项干扰:** 97 | 98 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/77788c9e58224baba8366ca5a75c1162~tplv-k3u1fbpfcp-zoom-1.image) 99 | 100 | 由于每次提交代码都应该保证测试通过率 100%,所以我们也不会担心这些例子是否过期的问题。 101 | 102 | ## 用例即例子 103 | 104 | 测试用例还有个很好的功能:**将使用案例记录在案。** 105 | 106 | 很多时候别人写一些工具函数和方法,使用者是不能一眼就能学会怎么用的。往往这时写函数的人就会说:**你看 XXX 文件就知道怎么用了。** 但这些 “真实例子” 中通常会夹杂着很多依赖项,无法作用一个最小 Use Case 来理解。 107 | 108 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/567c665c9c2840b698d960bbd57e0d10~tplv-k3u1fbpfcp-zoom-1.image) 109 | 110 | 111 | **而单测里的每个用例都可以看成一个最小的 `example`,通过阅读 Test Case 就能马上知道这个函数怎么使用了。** 这里举 redux 的 `compose` 函数的例子: 112 | 113 | ```js 114 | describe('Utils', () => { 115 | describe('compose', () => { 116 | it('composes from right to left', () => { 117 | const double = (x: number) => x * 2 118 | const square = (x: number) => x * x 119 | expect(compose(square)(5)).toBe(25) 120 | expect(compose(square, double)(5)).toBe(100) 121 | expect(compose(double, square, double)(5)).toBe(200) 122 | }) 123 | }) 124 | } 125 | ``` 126 | 127 | 就算我们不知道 `compose` 是用来干嘛的,但是我们很清楚地知道,使用方法就是从右到左地执行回调。 128 | 129 | **由于每次发布时我们都要保证单测 100% 通过率,所以永远不用担心这个 Use Case 无法使用、过期的问题。** 130 | 131 | ## 提升个人能力 132 | 133 | 抛开这些项目质量、优化流程的原因,推荐大家写单测的另一重要原因就是 **提升个人能力**。 134 | 135 | 几乎所有 Jest 的入门文章的开头都会有一个非常简单的 Test Case: 136 | 137 | ```js 138 | expect(1 + 1).toEqual(2) 139 | ``` 140 | 141 | 这很容易让人误以为单测很简单,以为不就是学一个框架那样嘛。然而,只有在真正编写测试用例的时候才会发现单测的难度呈指数级上涨。 **因为测试的本身是另一个领域,是需要通过不断练习才能掌握测试技巧的。** 对前端单测来说,它的难度包括但不限于如下几点: 142 | 143 | * **测试框架与开发框架的不配合。** 比如版本冲突问题 144 | * **模拟环境问题。** 比如模拟浏览器环境,往往项目一出现 `localStorage`,`cookie` 这些浏览器独有的东西时,Jest 就会报错,很多人受不了直接放弃了 145 | * **不同框架、库的测试方法都是需要学习的**。有的框架 Nest.js 有 `@nestjs/testing`,React.js 有 `react-testing-library`。有的库 Redux 又会有自己独特的 testing guide 146 | 147 | 总的来说,写单测并不像大家想的这么简单,`jest` 只是个开始的地方。不过,从另一个角度来看,如果你能坚持写好单测,对个人能力也大有裨益: 148 | 149 | * **提升不同环境的 Mock 能力。** 掌握不同测试框架的测试技巧 150 | * **提升异常分支的感知能力。** 写代码的时候也能代入测试者视角,在开发时能马上发现并处理异常分支 151 | * **了解并实践更多的测试策略。** 如自上而下、自下而上,影子数据库等 152 | * **为简历增光添彩。** 在写测试的过程中我们也可以深入测试这个领域,将编程知识融会贯通 153 | 154 | ## 总结 155 | 156 | 稍微总结一下,单测可以在 **优化开发流程、保证项目质量、给项目优化上保险、驱动开发、提供 Use Case、提升个人能力** 方面有着非常大的益处。 157 | 158 | 当然,这一章也是希望大家能够多尝试自己领域之外的东西,不要固步自封。对个人而言,多练习写单测能力肯定是好处多于坏处。现在,让我们开始测试之旅吧。 159 | -------------------------------------------------------------------------------- /docs/kentcdodds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/kentcdodds.png -------------------------------------------------------------------------------- /docs/qrcode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/jest-tutorial/4a9a89d24d7b4a614e156b83f02fbd224f094f8e/docs/qrcode.gif -------------------------------------------------------------------------------- /docs/thoughts/articles.md: -------------------------------------------------------------------------------- 1 | # 所有文章 2 | 3 | * [前端测试一共有哪几种?](https://juejin.cn/post/7091890131786792967) 4 | * [前端单测,为什么不要测 “实现细节”?](https://juejin.cn/post/7079232962025226277) 5 | * [如何测自定义的 React Hooks?](https://juejin.cn/post/7089286456329371662) 6 | * [测试代码怎么做抽象?](https://juejin.cn/post/7086704811927666719) 7 | * [前端单测,我们应该测什么?](https://juejin.cn/post/7084526003548061703) 8 | * [使用 React Testing Library 的 15 个常见错误](https://juejin.cn/post/7081890362700070925) 9 | * [TDD 的原理和使用场景](https://juejin.cn/post/7094487842709045285) 10 | * [如何把测试带给团队?](https://juejin.cn/post/7097781959174127653) 11 | * [前端测试常见的 3 个误区](https://juejin.cn/post/7102267445083111454) 12 | * [测试中如何处理 Http 请求?](https://juejin.cn/post/7107476872430092295) 13 | * 更新中... 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-tutorial", 3 | "version": "1.0.0", 4 | "description": "🃏《Jest 实践指南》", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "docs:dev": "vuepress dev docs", 9 | "docs:build": "vuepress build docs" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/haixiangyan/jest-tutorial.git" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/haixiangyan/jest-tutorial/issues" 20 | }, 21 | "homepage": "https://github.com/haixiangyan/jest-tutorial#readme", 22 | "devDependencies": { 23 | "@vuepress/plugin-active-header-links": "^1.9.7", 24 | "@vuepress/plugin-back-to-top": "^1.9.7", 25 | "@vuepress/plugin-medium-zoom": "^1.9.7", 26 | "vuepress": "^1.9.7" 27 | } 28 | } 29 | --------------------------------------------------------------------------------