jest单元测试

1,270 阅读12分钟

下载

  • 运行环境 node
  • npm install jest -D
  • 将 package.json 中的命令改为"test" : "jest"

支持的测试文件格式(命名规范)

  • 两种方式
  • 他会寻找这两种方法提供文件进行测试
  • 两种方法二选一即可
测试文件格式 1
  • 支持两种后缀(实际上使用一种即可,只是提供了两种)
    • xxx.spec.js
    • xxx.test.js
测试文件格式 2
  • 创建__tests__文件夹

方法

  • it||test
    • 断言,用于测试,it 和 test 一样
    • 两个参数
      • 标题(string)
      • 回调(function)
  • describe
    • 用法同上
    • 作用,创建一个独立作用域
  • expect
    • 测试值
  • 在上述文件中引入要测试的模块
//../index.js
const add = (a, b) => a + b;
module.exports = add;

//add.spec.js || add.test.js
const add = require("../index");

describe("add 方法测试-index", () => {
  test("1+1应该等于2", () => {
    const res = add(1, 1);
    expect(res).toBe(2);
  });
});

匹配器

有参数
  • toBe
    • 使用的是 Object.is 来判断的,所以需要绝对相等
  • toEqual
    • 常用来检查引用数据类型是否相等,不比对空间地址,使用了递归,
    • 他也可以用来检查基本数据数据类型
    • 如果是函数的话,引用空间地址不相同也会导致比对失败
数字
  • 不会进行数据类型转换
  • toBeGreaterThanOrEqual
    • 大于等于
  • toBeGreaterThan
    • 小于
  • toBeLessThan
    • 大于
  • toBeLessThanOrEqual
    • 大于等于
字符串
  • toMatch
    • 可以传递正则字符串
    • 传递字符串的话,是包含就可以,等同于 includes,但是区别在于 toMatch 不进行数据类型转换
数组
  • toContain
    • 查看是否存在数组中
报错
  • toThrow
    • 参数可选填
    • 不填,就是比对是否有报错信息
    • 可以填写类型,如 Error 或 TypeError,这样是检查报错类型
    • 可填写字符串或正则,检查报错内容
无参数
  • toBeNull
    • 是否为 null
  • toBeUndefined
    • 是否为 undefined
  • toBeDefined
    • 不是 undefined,等同于not.toBeUndefined
  • toBeTruthy
    • 与 if 判断相同,可以理解为if(true) === toBeTruthy()
  • toBeFalsy
    • 与 if 判断相反,可以理解为if(false) === toBeFalsy()

修饰符

  • not
    • 相反的

异步

  • 异步方法的测试(没有返回值)
    • expect.assertions(1)表示异步任务执行次数(确保异步方法被执行)
    • expect(true).toBoe(true)表示执行了(因为 true === true 所以执行到这一行,就代表执行了)
    • 注意:在在 test 函数的 callback 处,使用 done 变量接受参数,在异步任务最后执行 done,表示我们应该在这里结束异步任务
//../index.js
const delay = (callback) => {
  setTimeout(() => {
    callback && callback();
  }, 1000);
};
module.exports = delay;

//__tests__/index.js
const delay = require("../index");

test("callback 被执行", (done) => {
  expect.assertions(1);
  const callback = () => {
    console.log("我执行");
    expect(true).toBe(true);
    done();
  };
  delay(callback);
});
  • promise 的测试
    • 在这里如果不使用 done 的话,不要接收(否则会报错)
    • 最后需要将 promise 的执行给 return 出去(async的不用)
    • 也可以使用 async 和 await 来测试
    • 也可以使用 jest 自带的 resolvesrejects 方法来处理 promise,用法在下面
const delayPromise = (callback) => {
  return new Promise((resolve, reject) => {
    try {
      setTimeout(() => {
        const res = callback && callback();
        resolve(res);
      }, 1000);
    } catch (e) {
      reject(e);
    }
  });
};
//这里如果不使用done方法,就不要接收,否则会报错
//需要把promise给return出去
test("promise 被执行", () => {
  expect.assertions(1);
  const callback = () => 1;
  return delayPromise(callback).then((res) => {
    expect(res).toBe(1);
  });
});

//jest自带的resolves方法
test("promise使用jest自带的resolves方法", () => {
  expect.assertions(1);
  const callback = () => 1;
  return expect(delayPromise(callback)).resolves.toBe(1);
});
//async的不需要return
test("promise async 被执行", async () => {
  expect.assertions(1);
  const callback = () => 1;
  const res = await delayPromise(callback);
  expect(res).toBe(1);
});

mock

创建一个 mock 函数
  • jest.fn()
    • 用来创建一个 mock 函数
    • 参数:选填(function)
      • 不传递也会返回一个 mock 实例,可以利用实例上的一些方法来做一些事情
      • 传递参数,mock 的就是这个函数,也可以调用实例方法
mock 函数的 mock 属性
  • 所有的 mock 函数都有一个.mock属性(object 类型),通过这个属性可以查看这个函数是怎么调用等等,达到监控效果
  • calls 拿到函数的信息
    • length 拿到函数执行次数
  • results 拿到第指定索引(以 0 开始)执行的结果
// 测试mock
function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}
const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

it("测试mock", () => {
  //拿到函数执行次数
  expect(mockCallback.mock.calls.length).toBe(2);
  //console.log(mockCallback.mock.calls);
  //calls[0][0],第一个[0]代表第一次执行,第二个[0]代表第一个参数
  //作用:拿到函数第一次执行时候的第一个参数
  expect(mockCallback.mock.calls[0][0]).toBe(0);
  //拿到第二次执行时候的第一个参数
  expect(mockCallback.mock.calls[1][0]).toBe(1);
  //拿到第一次执行的结果
  expect(mockCallback.mock.results[0].value).toBe(42);
  //[undefined, undefined]
  console.log(mockCallback.mock.instances);
});
  • instances 拿到 this 指向集合
it("测试mock的this指向集合", () => {
  const myMock = jest.fn();

  const a = new myMock();
  const b = { b: 1 };
  const bound = myMock.bind(b);
  //要执行函数才会有第二次的{ b: 1 },因为bind只是将this指向暂存,但是还没有执行
  bound();

  console.log(myMock.mock.instances);
  // > [ mockConstructor {}, { b: 1 } ]
});
  • mockReturnValue() 模拟函数返回值
    • 只需要在 mockReturnValue 的括号内放入指定值即可
    • mockReturnValue 会影响到下一个测试,需要使用mockRestore清除副作用
//   两个相等
const callback = () => 1;
const callback = jest.fn().mockReturnValue(1);
  • spyOn
    • 监听某个方法或值
    • 两个参数
      • 方法名
    • 可以配合 mockReturnValue 来进行劫持
  • mockRestore
    • 清除副作用
// 返回0-10之间的随机数
const getRandom = () => {
  return Math.floor(Math.random() * 10);
};
test("随机数测试,小于10", () => {
  expect(getRandom()).toBeLessThan(10);
});
test("随机数测试,大于等于0", () => {
  expect(getRandom()).toBeGreaterThanOrEqual(0);
});
test("Math.random返回1,结果为10", () => {
  // spyOn监听,然后使用mockReturnValue控制返回值
  const mockRandom = jest.spyOn(Math, "random");
  mockRandom.mockReturnValue(1);
  expect(getRandom()).toBe(10);
  // 使用mockRestore清除副作用,如果不清除,下一步拿到的是10,肯定失败
  mockRandom.mockRestore();
});
test("测试上一步是否影响我,小于10", () => {
  expect(getRandom()).toBeLessThan(10);
});
  • mockReturnValueOnce() 同上,区别在于只模拟一次(可以连续调用)
const myMock = jest.fn();

it("模拟返回值", () => {
  myMock.mockReturnValueOnce(10);

  console.log(myMock(), myMock());
  // > 10, undefined, true, true
});
it("试下是不是影响后面的one", () => {
  console.log(myMock());
  //undefined  没有收到影响
});
it("可以连续调用", () => {
  myMock.mockReturnValueOnce(10).mockReturnValueOnce("x").mockReturnValue(true);
  console.log(myMock(), myMock(), myMock(), myMock());
  // 10, 'x', true, true
});
  • mockImplementationOnce 同上,只不过是用来模拟函数,也是一次
  • mockName
    • 作用:模拟名称,官方给的作用是更容易定位错误信息
  • getMockName
    • 使用这个方法来获取别名
const myMockFn = jest
  .fn()
  .mockReturnValue("default")
  .mockImplementation((scalar) => 42 + scalar)
  .mockName("add42");
myMockFn.getMockName(); //"add42s"
  • 语法糖
it("语法糖", () => {
  myMockFn(1);
  myMockFn();
  //myMockFn函数至少被调用2次,规律 1+1,toBeGreaterThan方法可为负数
  expect(myMockFn.mock.calls.length).toBeGreaterThan(1);
  //函数至少使用这种参数执行一次类型
  expect(myMockFn.mock.calls).toContainEqual([1]);
  //函数最后一次调用,参数是否为指定的, 我们最后执行没有传递参数,所以为[]
  expect(myMockFn.mock.calls[myMockFn.mock.calls.length - 1]).toEqual([]);
  //函数第一次调用,第一个参数是1
  expect(myMockFn.mock.calls[0][0]).toBe(1);
  //函数别名是否为"add42"
  expect(myMockFn.getMockName()).toBe("add42");
});
模拟模块
  • 需要使用jest.mock方法
// users.js
import axios from "axios";

class Users {
  static all() {
    return axios.get("/users.json").then((resp) => resp.data);
  }
}

export default Users;

//__tests__/mockTest.js
import axios from "axios";
import Users from "../src/users";

jest.mock("axios");

test("should fetch users", () => {
  const users = [{ name: "Bob" }];
  const resp = { data: users };
  axios.get.mockResolvedValue(resp);
  //data是[{ name: "Bob" }]
  return Users.all().then((data) => expect(data).toEqual(users));
});
jest.mock 也可以传递回调
  • 导入模块的时候,希望对整个模块进行一定操作,就可以传入回调
  • jest.mock 的回调会在上面的 console.log 前执行,且回调的 return 值会覆盖 import 接收的值,不返回则为 undefined
  • 在回调中使用的时候需要使用 jest.requireActual 方法来导入
//mockObj.js
export const mockObj = {
  a: 1,
  b: () => {
    return "b";
  },
};
export default () => "default";

//__tests__/test.js
import defaultFn from "../src/mockObj";
console.log(defaultFn, 2); //{a:9090}
jest.mock("../src/mockObj", () => {
  const old = jest.requireActual("../src/mockObj");
  console.log(old, "1");
  /*
  {
    default: () => "default",
    mockObj: {
      a: 1,
      b: () => {
        return "b";
      },
    }
  }
  */
  return {
    a: 9090,
  };
});
批量处理
  • beforeEach/beforeAll
    • beforeEach 在测试前执行,每个测试实例执行的时候都执行一次
    • beforeAll 在测试前执行,但是只执行一次,作用到正规作用域
    • 参数,callbak
  • afterEach/afterAll
    • 在测试后执行,其他同上
    • 参数,callbak
    • 使用场景
      • 一件事在多个测试中使用
// 处理前
test("Math.random返回1,结果为10", () => {
  const mockRandom = jest.spyOn(Math, "random");
  mockRandom.mockReturnValue(1);
  expect(getRandom()).toBe(10);
  mockRandom.mockRestore();
});
test("Math.random返回0.1,结果为1", () => {
  const mockRandom = jest.spyOn(Math, "random");
  mockRandom.mockReturnValue(0.1);
  expect(getRandom()).toBe(1);
  mockRandom.mockRestore();
});

//可以看到都调用了jest.spyOn(Math, 'random');和mockRandom.mockRestore();
//这时候就可以处理一下
beforeEach(() => {
  mockRandom = jest.spyOn(Math, "random");
});
afterEach(() => {
  mockRandom.mockRestore();
});
test("Math.random返回1,结果为10", () => {
  mockRandom.mockReturnValue(1);
  expect(getRandom()).toBe(10);
});
test("Math.random返回0.1,结果为1", () => {
  mockRandom.mockReturnValue(0.1);
  expect(getRandom()).toBe(1);
});

快照(测试 UI 组件)

  • 安装依赖
    • npm install @babel/core @babel/preset-env @babel/preset-react react-test-renderer -D
    • npm install react -S
  • 创建.babelrc
//./babelrc
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}
  • 创建 UI 组件
//./BaiduLink.js
import React from "react";

const BaiduLink = () => {
  const url = "https://www.baidu.com";
  const text = "百度一下";
  return <a href={url}>{text}</a>;
};
module.exports = BaiduLink;
  • 创建测试文件
    • 我们在这里使用了 react-test-renderer 这个库来帮助我们生成映射文件
    • 使用 create 生成了快照
外联快照
  • 使用 toMatchSnapshot 进行新老映射比较
  • 外联会生成__snapshots__文件夹,里面是生成的映射文件(后缀是.snap),下面有写
//./__tests__/BaiduLink.js
import Link from "../BaiduLink";
import renderer from "react-test-renderer";
import React from "react";
it("我是外联快照", () => {
  const tree = renderer.create(
    <Link page="https://www.baidu.com/">百度一下</Link>
  );
  expect(tree).toMatchSnapshot();
});
  • 开始测试,npm test
    • 第一次测试会在__teste__目录下生成__snapshots__文件夹,里面是生成的映射文件(后缀是.snap)
    • 假设我们生成这个映射文件以后,再次修改了./BaiduLink.js,再次执行npm test的时候就会抛出异常
      • 因为他会把本次的映射跟以前生成的映射进行对比(所以,如果生成以后,git add 的时候记得把生成的映射文件也提交了)
    • 如果想要覆盖原有的映射,在 package.json 的命令添加jest --updateSnapshot然后执行即可(就会生成一个新的映射,然后覆盖以前的)
内联快照
  • toMatchInlineSnapshot
  • 第一次使用的时候,这个方法不传递值
  • 当我们执行 npm test 后,会在这个方法的括号内生成映射结构
  • 他没有在外部生成文件,而是传递给本身了,这是他与外联的区别
it("我是内联快照", () => {
  const tree = renderer.create(
    <Link page="https://www.baidu.com/">百度一下</Link>
  );
  //第一次方法不传递参数
  expect(tree).toMatchInlineSnapshot();
});

// 第一次执行以后就成这样了,它在函数括号内,放置了生成的映射文件
/*
  it("我是内联快照", () => {
  const tree = renderer.create(
    <Link page="https://www.baidu.com/">百度一下</Link>
  );
  expect(tree).toMatchInlineSnapshot(`
    <a
      href="https://www.baidu.com/"
    >
      百度一下
    </a>
  `);
  });
*/
对一些不稳定的数据建立快照
  • 假设存储了一个数据,是new Date,那它每次都不相同,所以每次都会报错
  • 这时候就可以在 toMatchSnapshot 函数内进行处理,断言为 any 类,这样他就会将每次更新的快照保存,且不报错
it("测试对象快照", () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: "LeBron James",
  };
  //当前示例中如果不传参数,会导致第一次以后的每次保存都会失败
  expect(user).toMatchSnapshot({
    //声明为any类
    createdAt: expect.any(Date),
    id: expect.any(Number),
  });
});
开启测试覆盖率
  • 在 package.json 的测试命令后加上 --coverage
  • 在执行以后,会显示覆盖率表格,且会自动生成coverage文件夹,里面有详细的信息
    • 我们可以打开 coverage/lcov-report/index.html 文件查看详情,点击 file 对对应的文件,可以看到具体代码
      • 如果为红色背景就是没有覆盖到
      • 代码行前面的数字代表的是本次测试这行代码执行了几次

react 测试

@testing-library/react
  • 安装npm install @testing-library/react @testing-library/jest-dom -D
  • 使用前还需要将jest的环境设置为jsdom
  • 根目录下新建 jest.config.js,配置文件官方链接https://jestjs.io/docs/configuration
  • 内容
module.exports = {
  setupFilesAfterEnv: ["@testing-library/jest-dom"],
  testEnvironment: "jsdom",
};
  • 测试
import React from "react";
import { render } from "@testing-library/react";

test("component", () => {
  const { getByLabelText } = render(<button aria-label="Button" />);
  expect(getByLabelText("Button")).toBeEmptyDOMElement();
});
react-dom 自带了一个测试库react-dom/test-utils
  • 可以通过解构拿到具体的方法
  • act
    • 一个参数,callback
    • 对 ui 组件进行渲染,不仅仅限于第一次渲染,也可进行状态更新操作渲染
    • 在断言前使用,保证我们的组件已经渲染完成
it("使用act", () => {
  act(() => {
    ReactDOM.render(<组件 />, dom节点);
  });
});
  • Simulate
    • 用来测试事件,他要比原生的MouseEvent好用很多
    • 用法:Simulate.事件(node节点,{参数})第二个参数为选填
//模仿change事件
import React, { useState } from "react";
const Input = () => {
  const [val, setVal] = useState("12");
  return (
    <input
      type="text"
      className="input"
      value={val}
      onChange={(e) => {
        setVal(e.target.value);
        // setVal('100');
      }}
    />
  );
};
export default Input;

//测试文件
import React from "react";
import InputChange from "../src/component/InputChange";
import { act, Simulate } from "react-dom/test-utils";
import { render, unmountComponentAtNode } from "react-dom";

let container = null;
beforeEach(() => {
  // 创建一个 DOM 元素作为渲染目标
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // 退出时进行清理
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("change事件", () => {
  act(() => {
    render(<InputChange />, container);
  });
  let input = container.querySelector(".input");
  expect(input.value).toBe("12"); //成功
  act(() => {
    Simulate.change(input, { target: { value: "123" } });
  });
  console.log(input.value); //123
});
MouseEvent
  • 原生模拟事件,不推荐使用
  • 作用:测试 dom 事件
  • 使用方法
    • dom.dispatchEvent(new MouseEvent("事件", { bubbles: true }));
  • 注意的是事件派发的时候需要{bubbles:true},这样 react 才可以监听到
  • 在测试的时候 innerText 拿不到值,需要使用 textContent
//BtnClick.tsx
import React, { useState } from "react";
const BtnClick = () => {
  const [num, setNum] = useState(0);
  return (
    <>
      <div className="num">当前数字{num}</div>
      <div
        className="Btn"
        onClick={() => {
          setNum((old) => {
            return old + 1;
          });
        }}
      >
        点击+1
      </div>
    </>
  );
};
// interface BtnClick {
//     state: any
//     setState: any
// }
// class BtnClick extends React.Component {
//     constructor(props) {
//         super(props);
//         this.state = { num: 0 };
//         this.handleClick = this.handleClick.bind(this);
//     }
//     handleClick() {
//         this.setState(state => ({
//             num: state.num + 1,
//         }));
//     }
//     render() {
//         return (
//             <>
//                 <div className="num">当前数字{this.state.num}</div>
//                 <div className="Btn" onClick={this.handleClick}>点击+1</div>
//             </>
//         )
//     }
// }
export default BtnClick;

//test.jsx
import React from "react";
import BtnClick from "../src/component/BtnClick";
import { render, unmountComponentAtNode } from "react-dom";
import { act, Simulate } from "react-dom/test-utils";

let container = null;
beforeEach(() => {
  // 创建一个 DOM 元素作为渲染目标
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // 退出时进行清理
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("点击事件", () => {
  act(() => {
    render(<BtnClick />, container);
  });
  let num = container.querySelector(".num");
  console.log(num.textContent);
  //   expect(numValue).toBe("当前数字0");
  act(() => {
    container.querySelector(".Btn").dispatchEvent(
      new MouseEvent("click", {
        bubbles: true,
      })
    );
    //这样也可以
    //Simulate.click(container.querySelector(".Btn"));
  });
  console.log(num.textContent);
});

jest 结合 @testing-library/react 进行实战

fireEvent 属性

  • 模拟事件
fireEvent.click(screen.queryByText("文字"), {
  target: {
    value: 1,
  },
});

screen

  • 代表当前挂载 dom,且内置了一些方法

render

  • 渲染
  • 渲染后,可以使用 screen 上的属性来进行 dom 操作等,也可以使用解构,两者相同
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Click from "../src/pages/test/click";
test("测试一下不解构", () => {
  render(<Click />);
  screen.getByText(0);
  fireEvent.click(screen.getByText("+"));
  screen.getByText("1");
});
test("测试一下解构", () => {
  const { getByText } = render(<Click />);
  getByText(0);
  fireEvent.click(getByText("+"));
  getByText("1");
});
  • 生成快照,asFragment
it("测试快照", () => {
  const { asFragment } = render(<Click />);
  expect(asFragment()).toMatchSnapshot();
});
测试 setTimeout
  • 因为涉及到了异步,所以直接获取是拿不到值的
  • 使用 waitFor 配合 async/await,在这里会等待不到 1 秒的时间,如果异步时间太长也会导致失败
//click.tsx
import React, { useState } from "react";
export default () => {
  const [count, setCount] = useState(0);
  const sync = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 0);
  };
  return (
    <>
      <button onClick={sync}>异步+</button>
      <div data-testid="data">+{count}</div>
    </>
  );
};

import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
import Click from "./click";
it("测试定时器异步", async () => {
  const { getByTestId, getByText } = render(<Click />);
  fireEvent.click(getByText("异步+"));
  const counter = await waitFor(() => getByText("+1"));
  expect(counter).toHaveTextContent("+1");
});
测试请求,二次封装的 axios
  • 与定时器大同小异,只不过需要模拟一下模块返回值
//click.js
import React, { useState } from "react";
import api from "../../common/api"; //封装过的请求方法
export default () => {
  const [count, setCount] = useState(0);
  // 测试axios请求
  const get = () => {
    api.get({}).then((res: any) => {
      setCount(res["count"]);
    });
  };
  return (
    <>
      <div data-testid="data">+{count}</div>
      <button onClick={get}>发请求</button>
    </>
  );
};
// 测试文件
import React from "react";
import {
  render,
  screen,
  fireEvent,
  cleanup,
  waitFor,
} from "@testing-library/react";
import Click from "../src/pages/test/click";
// 这里是值只模拟了get模块的返回值
jest.mock("../src/common/api", () => {
  return {
    get: () => Promise.resolve({ count: 10 }),
  };
});
it("测试axios请求", async () => {
  const { getByText } = render(<Click />);
  // api.get.mockResolvedValue({
  //     data: { count: '1' },
  // })
  fireEvent.click(getByText("发请求"));
  const counter = await waitFor(() => getByText("+10"));
  expect(counter).toHaveTextContent("+10");
});