51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

查看: 580|回复: 0
打印 上一主题 下一主题

[资料] 效率神器Jest让你打破单元测试的传统认知

[复制链接]
  • TA的每日心情
    无聊
    4 小时前
  • 签到天数: 944 天

    连续签到: 3 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2023-6-20 11:14:32 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    1、前言
      在平时的开发中,可能大家都或多或少的听说过单元测试,并且大家可能都知道一些测试的库(因为在vue-cli创建项目时,它会提示你是否引入测试框架,看多了,自然也就有印象),但是实际开发中,总感觉这个东西离我们很远。阅读完本文之后,将会颠覆你对单元测试的认知。我在之前其实并没有把这个东西当回事,虽然我也知道单元测试,但我刻板的认为,只有在编写组件库的时候才需要单元测试。
      2、什么是单元测试?
      单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
      以上是百度百科对单元测试的解释。
      作为一名野生前端程序员,其实我也不关心其概念,大致明白其意思就是能够对我们的函数进行测试,如果一个函数没有问题了,那么多个函数组成的功能基本上出问题的可能性也不太了。
      3、单元测试在前端开发中的作用
      1)大家有没有想过一个问题,像vue这类开源项目,平时有全世界的开发者向他的仓库提交代码,怎么能够保证新提交的feature不破坏已有逻辑呢?
      2)之前在github上一个库比较搞笑(名字我记不得),具体的内容似乎是打印了一行与功能无关的内容,但是引入这个库需要增加项目30M的体积,试问,这样的库你敢用吗?这个库也向我们告知了一个问题,一个开源项目中应该尽量删除不需要的代码。
      3)在某些场景下,手动测试的场景比较复杂,就举个简单的例子来说吧,假设有一个Form表单需要提交步骤,现在一共有4个步骤,前面3个步骤的内容已经完成了,现在需要测试第4步,那么,是不是你手动测试的话,每次都要去点前面的3个步骤,真是累死人还不讨好。
      正因为有这些问题,所以在前端中接入单元测试才有必要,单元测试相比较我们的手动测试,其实就是写一些描述文件将我们需要手动测试的这些过程保存成文件,然后用程序模拟它的环境去执行它。
      有了单元测试,对于第一个问题,vue的维护者只需要将之前的单元测试用例全部跑一遍,如果通过则接受合并。对于第二个问题,有个术语叫单元测试覆盖率,像ant-design就号称100%的单元测试覆盖率,也就是说,这里面的所有代码至少都是这个库所需要的,是有用的,并且还是经过测试,质量有保证的。对于第三个问题,就更绝了,因为单元测试是针对函数级别的测试,直接mock出Form表单所需要的数据,直接执行就可以知道函数是否符合预期了,省时省力。
      我个人还有另外一个痛点,如果是科班出生的程序员一定在大学上过《Java程序设计》,Java有一个框架叫做JUnit,对于某个函数只需要为其贴上一个装饰器(Java中叫做注解),这个函数就能运行起来。
      而JS是运行在浏览器或nodejs中的,每次如果我们想要写一个函数,测试的时候必须要在浏览器的console面板(或者新建一个页面,或者新建一个js文件,用nodejs来跑)执行,并且构造测试数据也是一件令人不爽的事儿。还有更痛的一个点,随着Typescript的逐渐普及,我们更愿意用TS来编写代码,但是浏览器都不认这个玩意儿,要不然我们就用vue-cli新建一个项目,在里面写,写完扔掉,要不然我们就自己先用TSC编译,然后再copy到浏览器或者nodejs中跑,而且你debug的还不是源码,简直让人炸裂(ts-node,我觉得也比较难用,不好调试)。
      有了单元测试,可以直接将其作为一个编译环境,可以方便的调试源码,关键是这些测试的过程保留下来了,如果是别人来阅读你的代码,能够大致的知道你的设计思路。
      好了,说了这么多,重要到了本文的主角Jest粉末登场了。
      4、Jest的使用
      Jest是Facebook出品的一个单元测试框架,废话就不多说了,它的官方网站:jestjs.org,有什么想知道的直接去看吧。
      在这之前,首先,我们需要给VSCode装一个插件:

      在普通的JS项目中接入jest,大家可以参考jest的官网。
      本文的配置
      package.json内容如下:
      {
        "name": "jest-test",
        "version": "1.0.0",
        "description": "",
        "main": "index.js",
        "type": "module",
        "scripts": {
          "test": "jest"
        },
        "keywords": [],
        "author": "",
        "license": "ISC",
        "devDependencies": {
          "@babel/core": "^7.21.4",
          "@babel/preset-env": "^7.21.4",
          "@types/jest": "^29.5.0",
          "babel-jest": "^29.5.0",
          "jest": "^29.5.0"
        }
      }


      .babelrc内容如下:
      {
        "presets": ["@babel/preset-env"]
      }


      jest.config.js内容如下:
      export default {
        transform: {
          "^.+\\.jsx?$": "babel-jest",
        },
      };


      (上述这些配置,主要是为了能够在nodejs中使用上ES6的语法,如果你直接用commonjs的语法,那么只需要安装jest和@types/jest即可)。
      然后,新建一个index.spec.js(意思是告诉jest,这是一个单元测试文件),填入以下内容:
      describe("demo", () => {
        it("test 1", () => {
          expect(0.1 + 0.2).toBeCloseTo(0.3);
        });
      });


      上述内容中,describe, it,expect这些方法jest在启动的时候,会为我们注入,如果你不需要用全局的方法,可以这样:
      bash复制代码npm i @jest/globals -D
      然后从这个库中导出这些方法:
      import { describe, it, expect } from '@jest/globals'

      然后,我们的插件就能识别到这些单元测试用例,点击上面的按钮就能跑起来了,跟我们正常的调试没区别。

      对于TS项目的话,配置要稍微多一些,这儿,我是复用了nest脚手架生成的配置,内容如下:
      package.json:
      {
        "name": "hsuyang.blog",
        "devDependencies": {
          "@jest/globals": "^29.5.0",
          "@types/jest": "^29.5.0",
          "@types/node": "^18.15.11",
          "jest": "^29.5.0",
          "ts-jest": "^29.1.0",
          "typescript": "^5.0.4"
        }
      }


      jest.config.js的内容如下:
      module.exports = {
        moduleFileExtensions: ["js", "json", "ts"],
        rootDir: "docs",
        testRegex: ".*\\.spec\\.ts$",
        transform: {
          "^.+\\.(t|j)s$": "ts-jest",
        },
        collectCoverageFrom: ["**/*.(t|j)s"],
        coverageDirectory: "../coverage",
        testEnvironment: "node",
      };


      经过这些配置之后,就能跑ts文件了,并且能够直接在源文件上断点。
      以下是我早期博客里面编写的栈的实现:
      /**
       * 栈的节点元素定义,必须使用双向链表,便于我们查找前驱和后继元素
       */
      interface LinkedListNode<T> {
        next: LinkedListNode<T> | null;
        prev: LinkedListNode<T> | null;
        data: T;
      }
      export class Stack<T> {
        /**
         * 链表的头结点
         */
        private head: LinkedListNode<T> | null = null;
        private length = 0;
        public get size() {
          return this.length;
        }
        /* 获取栈顶元素 */
        public get top(): T | null {
          return this.isEmpty() ? null : this.head!.data;
        }
        /**
         * 压栈
         * @param ele
         */
        public push(ele: T) {
          const newNode: LinkedListNode<T> = {
            next: null,
            prev: null,
            data: ele,
          };
          // 栈长度增加
          this.length++;
          // 如果一个元素都没有,直接让head指向这个节点
          if (this.head === null) {
            this.head = newNode;
          } else {
            newNode.next = this.head;
            this.head.prev = newNode;
            // 让原本的头指针指向新来的节点
            this.head = newNode;
          }
        }
        /**
         * 退栈
         */
        public pop() {
          if (this.isEmpty()) {
            throw new Error("can not pop from an empty stack");
          }
          // 获取到头节点的后继节点
          let head = this.head!.next;
          // 栈中的元素
          let ele = this.head!.data;
          if (head) {
            // 解开第一个节点的后继节点
            this.head!.next = null;
            // 解开第一个节点的后继节点的前驱节点
            head.prev = null;
            // 让栈首元素指向新的栈首元素
            this.head = head;
          } else {
            this.head = null;
          }
          // 栈长度递减
          this.length--;
          return ele;
        }
        /**
         * 栈是否为空
         * @returns
         */
        public isEmpty() {
          return this.length === 0;
        }
      }


      这个用例文件是我让chatgpt为我生成的(AIGC真爽啊,??):
      import { Stack } from "./stack";
      describe("Stack", () => {
        let stack: Stack<number>;
        beforeEach(() => {
          stack = new Stack<number>();
        });
        describe("push", () => {
          it("should add element to stack", () => {
            stack.push(1);
            expect(stack.size).toBe(1);
            expect(stack.top).toBe(1);
            stack.push(2);
            expect(stack.size).toBe(2);
            expect(stack.top).toBe(2);
          });
        });
        describe("pop", () => {
          it("should remove top element from stack", () => {
            stack.push(1);
            stack.push(2);
            expect(stack.pop()).toBe(2);
            expect(stack.size).toBe(1);
            expect(stack.top).toBe(1);
            expect(stack.pop()).toBe(1);
            expect(stack.size).toBe(0);
            expect(stack.top).toBeNull();
          });
          it("should throw an error when popping an empty stack", () => {
            expect(() => stack.pop()).toThrowError("can not pop from an empty stack");
          });
        });
        describe("isEmpty", () => {
          it("should return true when stack is empty", () => {
            expect(stack.isEmpty()).toBe(true);
          });
          it("should return false when stack is not empty", () => {
            stack.push(1);
            expect(stack.isEmpty()).toBe(false);
          });
        });
        describe("top", () => {
          it("should return null when stack is empty", () => {
            expect(stack.top).toBeNull();
          });
          it("should return the top element of the stack", () => {
            stack.push(1);
            stack.push(2);
            expect(stack.top).toBe(2);
          });
        });
        describe("size", () => {
          it("should return the size of the stack", () => {
            expect(stack.size).toBe(0);
            stack.push(1);
            expect(stack.size).toBe(1);
            stack.push(2);
            expect(stack.size).toBe(2);
          });
        });
      });


      我运行了其中一个测试用例,得到了一个非常精准的断点。

      vue文件也能测,比如Vue脚手架中生成的Demo:
      <template>
        <div class="hello">
          <h1>{{ msg }}</h1>
        </div>
      </template>
      <script lang="ts">
      import { Options, Vue } from 'vue-class-component';
      @Options({
        props: {
          msg: String,
        },
      })
      export default class HelloWorld extends Vue {
        msg!: string
      }
      </script>


      单测内容:
      import { shallowMount } from '@vue/test-utils';
      import HelloWorld from '@/components/HelloWorld.vue';
      describe('HelloWorld.vue', () => {
        it('renders props.msg when passed', () => {
          const msg = 'new message';
          const wrapper = shallowMount(HelloWorld, {
            props: { msg },
          });
          expect(wrapper.text()).toMatch(msg);
        });
      });


      实际的使用还远比这些复杂,对于一些高级的配置,大家可以参考jest的官方网站。
      另外,现在的AI特别强大,对于这类问题,交给它做是非常好的选择,所以大家如果不想写单测的话,可以用AI来帮助你生成,我们最后只需要再校对一遍,至少比自己全部都亲力亲为要好受的多。
      总结
      本文只是粗略的带大家了解了一下Jest,关于Jest的使用其实还是有很多坑要爬的(比如本文就没有提到过使用jest如何模拟一些环境),对于jest一些不太明白的配置最简单的办法就是交给chatgpt。另外,不要觉得编写单元测试用例是负担,一定不要先把功能都交付了再补单元测试,而应该使用单元测试来提高我们代码的健壮性。不说了,我要去为我的博客补单元测试用例了,哈哈哈。
      1、能够随心所欲的mock出自己想要的环境,省时省力,能够作为一个Runner来使用。
      2、能够提高你的专业性,让使用你代码的人放心,同时也能够减少自己犯错的概率。
      3、能够减少你的加班,不用担心在新增功能的时候将已有功能损坏。
      4、单元测试能够帮助你快速的理解设计者的思维
      5、能够反过来激励你写出高内聚,低耦合的代码(试想,如果你的一个函数动不动就几千行,多个功能耦合在一起,那单测就根本没法写)

    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-5-8 13:24 , Processed in 0.066400 second(s), 22 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表