Jest入门学习笔记

Jest 是前端最流行的测试框架之一,使用非常简单,很多著名的前端项目比如 Vue 、 React 都在使用它。 初始化一个基本的 Jest 配置,执行下面命令生成 jest.config.js 文件。

目录
  1. 基本配置
    1. 初始化
    2. 配置 Babel
  2. 匹配器
    1. 基本
    2. 数值
    3. 字符串
    4. 布尔
    5. 数组和迭代器
    6. 其他
  3. 命令行
  4. 异步代码测试
    1. 使用 done
    2. 使用 Promise
    3. 使用 Async/Await
  5. 钩子函数
  6. 作用域
  7. mock 函数
    1. mock 函数的作用
    2. 举个例子
    3. 改进例子
    4. 设置 mock 函数的返回值
    5. 模拟库(改变函数的内部实现)
    6. 其他 API
    7. 模拟和替换掉方法
    8. Timers
  8. 快照测试
  9. class 的测试
  10. DOM 节点测试
  11. 其他 API

Jest 是前端最流行的测试框架之一,使用非常简单,很多著名的前端项目比如VueReact都在使用它。

基本配置#

初始化#

初始化一个基本的 Jest 配置,执行下面命令生成jest.config.js文件。

jest --init

配置 Babel#

配置 Babel 后就可以使用 ES6 模块,而不仅仅局限于 Node.js 的 Commonjs 模块。

  • 首先安装依赖
yarn add @babel/core @babel/preset-env -D
  • 然后在根目录中配置babel.config.js文件
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};

匹配器#

基本#

  • toBe:主要用于基本类型的匹配,包括数字,字符串,布尔值,nullundefined。 - toEqual:主要用于引用类型的匹配,包括数组,对象等。

数值#

  • toBeGreaterThan:大于
  • toBeGreaterThanOrEqual:大于或者等于
  • toBeLessThan:小于
  • toBeLessThanOrEqual:小于或者等于

字符串#

  • toMatch:匹配正则表达式的字符串

布尔#

  • toBeNull:只匹配null
  • toBeUndefined:只匹配undefined
  • toBeDefined:与toBeUndefined 相反
  • toBeTruthy:匹配任何值为真的表达式
  • toBeFalsy:匹配任何值为假的表达式

数组和迭代器#

  • toContain:匹配是否包含值

其他#

  • toThrow :匹配异常
  • not:匹配器取反
  • toBeCalled():匹配函数被调用
  • toBeCalledWith(args):匹配函数被调用时传入的参数
  • toHaveBeenCalledTimes(number):匹配函数被调用的次数

命令行#

jest --help # 查看 jest 命令行帮助

watch 模式:监听测试文件,有修改的时候重新运行测试文件。

  • a: 每次都运行全部的测试文件。jest --watchAll 使用这个模式。
  • o:只测试有修改的测试文件,需要配合git使用,只有带git的文件夹才能使用。jest --watch使用这个模式。
  • p:只运行匹配到的测试文件
  • t:只运行匹配到的测试名称的文件
  • q:退出观察模式
  • enter:重新测试文件

异步代码测试#

Jest 的异步测试代码无法等到异步结束就已经完成测试,所以返回的测试结果都是成功,这是错误的。

test("the data is peanut butter", () => {
  function callback(data) {
    expect(data).toBe("peanut butter");
  }
  fetchData(callback);
});

使用 done#

传入 done 参数,然后在测试函数中调用done(),告诉 Jest 异步已经结束, 此时 Jest 才能准确返回测试结果。

test("the data is peanut butter", (done) => {
  function callback(data) {
    expect(data).toBe("peanut butter");
    done();
  }
  fetchData(callback);
});

使用 Promise#

then 或者catch 方法里面编写异步测试代码。注意代码运行成功,无法使用 catch 捕获错误,需要使用expect.assertions(number)强制断言expect需要被执行,括号内为执行的次数。

注意:Promise 必须要返回测试的结果 ,使用return

test("the data is peanut butter", () => {
  return fetchData().then((data) => {
    expect(data).toBe("peanut butter");
  });
});
test("the fetch fails with an error", () => {
  expect.assertions(1); // 强制 expect 执行一次
  return fetchData().catch((e) => expect(e).toMatch("error"));
});

也可以使用 Jest 提供的resolvesrejects API 来进行异步测试,示例代码如下:

test("the data is peanut butter", () => {
  return expect(fetchData()).resolves.toBe("peanut butter");
});
test("the fetch fails with an error", () => {
  return expect(fetchData()).rejects.toMatch("error");
});

使用 Async/Await#

可以使用Async/Await进行异步测试,示例代码如下

test("the data is peanut butter", async () => {
  const data = await fetchData();
  expect(data).toBe("peanut butter");
});
 
test("the fetch fails with an error", async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (e) {
    expect(e).toMatch("error");
  }
});

还可以结合resolvesrejects API 来测试,示例代码如下

test("the data is peanut butter", async () => {
  await expect(fetchData()).resolves.toBe("peanut butter");
});
 
test("the fetch fails with an error", async () => {
  await expect(fetchData()).rejects.toThrow("error");
});

钩子函数#

  • beforeAll:每次测试之前运行,只执行一次。
  • afterAll:每次测试之后运行,只执行一次。
  • beforeEach:每个测试代码块测试之前都会运行,主要用于重置初始化条件,避免测试文件之间互相影响。
  • afterEach:每个测试代码块测试之后都会运行。

注意:beforeAll 先于beforeEachafterAll 后于afterEach

作用域#

describe形成一个作用域,可以把文件中一些相同功能的测试代码块放在一起,便于区分管理。

describe作用域中,beforeAll 优先级始终最高,另外有多个beforeAll 时,先执行最外面作用域beforeAll 的,再执行里面作用域 的beforeAll ,然后每个代码块测试前再执行beforeEachafterAll 同理。

另外,不在钩子函数或者测试函数的代码会最先执行。

describe("test", () => {
  console.log("console1"); // 1 不在钩子函数的代码先执行
 
  beforeAll(() => {
    console.log("beforeAll outer"); // 3
  });
 
  afterAll(() => {
    console.log("afterAll outer"); // 10 最外面的作用域的 afterAll 最后执行
  });
 
  beforeEach(() => {
    console.log("beforeEach outer"); // 5
  });
 
  afterEach(() => {
    console.log("afterEach outer"); // 8
  });
 
  describe("test 1", () => {
    console.log("console2"); // => 2 不在钩子函数的代码先执行
 
    beforeAll(() => {
      console.log("beforeAll inner"); // 4
    });
 
    afterAll(() => {
      console.log("afterAll inner"); // 9
    });
 
    beforeEach(() => {
      console.log("beforeEach inner"); // 6
    });
 
    afterEach(() => {
      console.log("afterEach inner"); // 7
    });
 
    test("test", () => {
      expect(2).toBe(2);
    });
  });
});

打印的执行顺序是:

1.console1 2.console2 3.beforeAll outer 4.beforeAll inner 5.beforeEach outer 6.beforeEach inner 7.afterEach inner 8.afterEach outer 9.afterAll inner 10.afterAll outer

规律是:从外到内再到外,先beforeAllbeforeEach,先afterEachafterAll

mock 函数#

mock 函数是虚拟的函数,它可以模拟真实的函数,而且更好管控。

mock 函数的作用#

  • 捕获函数的调用和返回结果,以及使用 this 和 调用顺序。
  • 自由的设置返回结果。
  • 改变内部函数的实现。

举个例子#

const runCallback = (callback) => {
  callback();
};
 
test("test runCallback", () => {
  const fn = jest.fn();
  runCallback(fn);
  expect(fn).toBeCalled();
  console.log(fn.mock); // 打印 mock 函数的值
});

打印 mock 属性

{ calls: [[]],
    instances: [undefined],
   	invocationCallOrder: [1],
    results: [{type: 'return', value: undefined}]
  }

改进例子#

const runCallback = (constr) => {
  new constr("abc"); // 输入参数
};
 
test("test runCallback", () => {
  const fn = jest.fn(() => {
    return "456";
  }); // 传入一个函数
  runCallback(fn);
  runCallback(fn); // 再调用一次
  expect(fn.mock.calls.length).toBe(2);
  console.log(fn.mock); // 打印 mock 函数的值
});

打印 mock 属性

{ calls: [['abc'], ['abc']],
    instances: [mockConstructor {}, mockConstructor {}],
   	invocationCallOrder: [1, 2],
    results: [{type: 'return', value: '456'}, {type: 'return', value: '456'}]
  }

mock 函数的 mock 属性主要包含四个属性

  • calls:函数调用的次数,以及函数传入的参数
  • instances:每次调用的 this 指向,比如创建实例时 this 会指向实例
  • invocationCallOrder:函数的调用顺序
  • results:每次函数的调用的结果

设置 mock 函数的返回值#

  1. 直接在fn() 中传入一个有返回值的函数。
const fn = jest.fn(() => {
  return "abc";
});
  1. 手动设置返回值,更加灵活。
const fn = jest.fn();
fn.mockReturnValueOnce(10).mockReturnValueOnce("x").mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

其中,fn.mockReturnValueOnce()方法只设置一次的返回值,而fn.mockReturnValue()方法设置每一次的返回值。

模拟库(改变函数的内部实现)#

在真实的测试项目中一般不会去发送真实的异步请求去请求后端数据,而是去模拟请求的数据。

const getData = () => {
  axios.get("/api").then((res) => res.data);
};
 
// test
import axios from "axios";
jest.mock(axios);
test("test getData", async () => {
  axios.get.mockResolvedValue({ data: "hello" });
  await getData().then((data) => {
    expect(data).toBe("hello");
  });
});

首先,需要把要模拟的库放入到mock方法中,然后设置模拟数据。

其中,mockResolvedValue() 方法模拟异步返回的数据,同理mockResolvedValueOnce()模拟一次异步返回的数据。

其他 API#

  • mockImplementation()mockImplementationOnce()这两个方法可以模拟函数。

传入一个函数来模拟 mock 函数,和jest.fn()类似,mockImplementationOnce 方法只实现一次函数模拟。

const fn = jest.fn();
fn.mockImplementation(() => {
  return "abc";
});
// 相当于 const fn = jest.fn(() => 'abc')
  • mockReturnThis()方法返回this
    • 等价于mockImplementation(() => {return this})
    • 等价于jest.fn(() => {return this})

模拟和替换掉方法#

创建__mocks_文件夹,然后在里面创建一个和需要模拟的方法同名的文件进行模拟。然后在测试文件中必须使用jest.mock()来引入原来的文件。

比如有需要测试的文件fetchData.js,这个文件的代码如下

import axios from "axios";
export const fetchData = () => axios.get("/api").then((res) => res.data);

然后在__mocks__ 文件夹下面创建同名的fetchData.js文件,编写下面的代码

// 使用 Promise 模拟异步请求
export const fetchData = () =>
  new Promise((resolve, reject) => {
    resolve({
      data: 'hello'
    })
  })
}

之后在测试文件fetchData.spec.js中,使用jest.mock() 引入文件,这样真实的方法就会替换成__mocks__下面的方法

jest.mock("./fetchData"); // 使用 jest.mock() 引入文件,而不是使用模块引入
import { fetchData } from "./fetchData";
 
test("test fetchData", () => {
  return fetchData.then((data) => {
    expect(data).toBe("hello");
  });
});

有时候,有的方法需要使用 mock 来替换掉,有的又不需要替换掉,它们可以共存呢?

在真实文件fetchData.js中新增一个同步方法,这个不需要被 mock 方法替换掉,这个方法如下

export const getNumber = () => '123'

因为这个getNumber方法不需要使用 mock 方法替换掉,这时需要使用jest.requireActual()方法引入真实的方法,而不是默认使用 mock 的方法,这样就可以测试真实的方法了。

const { getNumber } = jest.requireActual("./fetchData"); // 使用 jest.requireActual() 引入
test("test getNumber", () => {
  expect(getNumber()).toBe("123");
});

Timers#

测试 timers 函数的测试,如下面的代码

const timer = (callback) =>
  setTimeout(() => {
    callback();
  }, 3000);

使用测试代码可以正确测试上面的函数,但是发现很耗时间。

test("test timer", (done) => {
  timer(() => {
    expect(1).toBe(1);
    done();
  });
});

这里才定时 3000 毫秒,如果是定时几个小时甚至几天的话,按照上面的时间测试的话,还会需要更久。

这时候需要使用 mock 函数来节省测试时间。下面使用 mock 函数模拟

jest.useFakeTimers();
test("test timer", (done) => {
  const fn = jest.fn();
  timer(fn);
  jest.runAllTimers();
  expect(fn).toHaveBeenCalledTimes(1);
});

使用jest.useFakeTimers()方法搭配jest.runAllTimers() 方法来取消 timer 的时间限制。

其中,jest.runAllTimers() 会取消所有的 timer 的时间限制。

嵌套的 Timers

下面测试一个嵌套的 timers

const timer = (callback) =>
  setTimeout(() => {
    callback();
    setTimeout(() => {
      callback();
    }, 3000);
  }, 3000);
 
// test
jest.useFakeTimers();
test("test timer", (done) => {
  const fn = jest.fn();
  timer(fn);
  jest.runOnlyPendingTimers(); // 只运行队列中的 timer
  expect(fn).toHaveBeenCalledTimes(1);
});

jest.runOnlyPendingTimers() 方法只取消队列中的 timer 的时间限制。

或者使用jest.advanceTimersByTime(time) 方法来快进时间,这样也相对取消了时间限制。

const timer = (callback) =>
  setTimeout(() => {
    callback();
    setTimeout(() => {
      callback();
    }, 3000);
  }, 3000);
 
// test
jest.useFakeTimers();
test("test timer", (done) => {
  const fn = jest.fn();
  timer(fn);
  jest.advanceTimersByTime(3000); // 快进 3 秒
  expect(fn).toHaveBeenCalledTimes(1);
  jest.advanceTimersByTime(3000); // 再快进 3 秒
  expect(fn).toHaveBeenCalledTimes(2);
});

快照测试#

下面是快照测试的示例代码

export const generateConfig = () => {
  return {
    host: "127.0.0.1",
    port: 8080,
  };
};
 
// test
test("test generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot();
});

使用toMatchSnapshot匹配器的时候,第一次会生成一个快照文件,存放在__snapshots__文件夹下,快照的名称和每个测试文件同名,比如测试文件demo.spec.js 的快照文件名为demo.spec.js.snap

上面的测试代码生成的快照文件如下:

// Jest Snapshot v1, https://goo.gl/fbAQLP
 
exports[`test generateConfig 1`] = `
Object {
  "host": "127.0.0.1",
  "port": 8080,
}
`;
 

如果你修改了配置文件,比如增加了新的字段time: 2019。此时,测试结果就会报错,因为 Jest 发现生成现在的快照和以前生成的快照不一样。

这个时候,你可以在测试命令行中按下u 键更新快照文件,然后重新进行快照测试,测试才能通过。

在快照测试报错后中,我们发现命令行中多了两个选项,分别是u 选项和i 选项

 Press u to update failing snapshots.
 Press i to update failing snapshots interactively.

其中,按下u 键会更新所有的快照,而按下i 键则是交互的更新快照,不想按下u键一样一次性全部更新快照。

其实,每个测试文件里面的所有的测试代码块生成的快照都保存同一个文件上。如果你想确认其中一个快照的修改,则需要按下i 键一个个来处理快照,如果可以更新这个快照,再按下u 更新。如果不想更新这个快照,再按下s (skip) 跳过这个快照的更新,进行下一个快照的操作。另外,你不更新快照也可以撤销代码的修改。

快照测试每次都会保存这一次的快照,如果下一次的快照不一样就会报错,如果我们的快照每次都不一样呢?

比如设置时间为最新的时间time: new Date(),这样不是每次运行测试都会报错,有没有别的办法避免这个问题?其实可以设置time的这个字段不去测试,在toMatchSnapshot中传入一个选项忽略这个字段即可。

export const generateConfig = () => {
  return {
    host: "127.0.0.1",
    port: 8080,
    time: new Date(),
  };
};
 
test("test generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot({
    time: expect.any(Date), // ! 忽略 time 字段的测试
  });
});

行内快照测试:快照测试还有一种行内快照测试,它的 API 是toMatchInlineSnapshot。行内快照就不会把生成的快照文件放在专门的文件夹__snapshots__ 下,而是保存在 API 的第二个参数上。

注意:使用toMatchInlineSnapshot方法生成行内快照需要先安装prettier 这个库。另外这个功能是新出的,可能存在 BUG。

export const generateConfig = () => {
  return {
    host: "127.0.0.1",
    port: 8080,
    time: new Date(),
  };
};
 
// test
test("test generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot(
    {
      time: expect.any(Date),
    },
    // test 之后生成的行内快照
    `
    Object {
      "host": "127.0.0.1",
      "port": 8080,
      "time": Any<Date>,
    }
  `
  );
});

class 的测试#

下面是 class 测试的示例代码

class Util {
  init() {}
  a() {}
  b() {}
}
 
const func = (a, b) => {
  const util = new Util();
  util.a(a);
  util.b(b);
};
 
// test
jest.mock("./util"); // 模拟类
import Util from "./util";
import func from "./func";
test("test func", () => {
  func();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].a).toHaveBeenCalled();
  expect(Util.mock.instances[0].b).toHaveBeenCalled();
});

jest.mock() 模拟类后,会自动把类的构造函数和方法转都换成 mock 函数。

当前,也可以完全模拟和替换掉真实的类,在__mocks__文件夹下创建同名文件util.js,编写下面的模拟类的代码

const Util = jest.fn(() => {
  console.log("constructor");
});
Util.prototype.a = jest.fn(() => {
  console.log("a");
});
Util.prototype.b = jest.fn(() => {
  console.log("b");
});
export default Util;

也可以直接在jest.mock()方法的第二个参数上,传入一个 mock 函数来模拟类

// test
jest.mock("./util", () => {
  const Util = jest.fn(() => {
    console.log("constructor --");
  });
  Util.prototype.a = jest.fn(() => {
    console.log("a --");
  });
  Util.prototype.b = jest.fn(() => {
    console.log("b --");
  });
  return Util;
});

DOM 节点测试#

Jest 的默认测试模式就是浏览器测试,即jsdom模式,这个模式可以直接测试浏览器端的代码。

function addDivToBody() {
  const div = document.createElement("div");
  div.className = "hello";
  div.innerText = "hello";
  document.body.appendChild(div);
}
 
// test
test("test addDivToBody", () => {
  addDivToBody();
  addDivToBody();
  expect(document.querySelectorAll(".hello").length).toBe(2);
});

其他 API#

  • test.only():使用only 方法之后,重新运行测试文件时只会测试这一个测试,其他的测试都会被忽略。
  • jest.unmock():取消 mock 。