Front-end unit testing---Lone Warrior-level tutorial

"The Lonely Brave" has been a mess recently, occupying elementary school students and even kindergartens. Even my 2-year-old son can hum a few words. Although he thought the song was Ultraman's theme song.

Back to the topic, the engineering and templating of modern front-end projects are growing day by day, and various class library frameworks are emerging one after another. For front-end unit testing, in the entire front-end field, unit testing is not a popular topic, and various testing frameworks and libraries are already very complete.

For a front-end development, we actually don't need to focus on all these test frameworks and tools. We just need to choose the one that suits us according to our needs (team, project needs).

And how to choose is actually very simple. Under the premise that most front-end projects are based on frameworks, find the unit testing part directly from the official documentation. Officials will inevitably provide test libraries or recommend excellent community test libraries.

From the front-end project level, it is nothing more than UI testing (simply understood as DOM testing) and functional code testing .

1. UI test:

It is easy to understand that the front-end project will still be displayed on the browser after all, so the so-called UI test is to test whether the code we write is consistent with the expected rendered UI.

Combined with our current actual projects, most of the cases are based on UI frameworks (such as React), we only need to follow the UI testing methods provided by the official, do not directly search for XX front-end unit testing frameworks and the like. Each framework has its own characteristics, more or less involving UI rendering, such as virtual DOM, life cycle and so on. Therefore, as Reactmentioned in the official documents @testing-library/react(and some have used relatively good ones Enzyme), Reactrelevant APIs will be provided according to the framework characteristics, such as: render、renderHookand so on.

This article does not introduce UI testing in detail.

2. Functional code testing:

Simply put, it is a piece of encapsulated code, whether it is a function or a class. In most cases, this code has nothing to do with the UI. We only need to pay attention to the function implementation itself. The simplest is a function, what parameters are input, and what values ​​are returned.

The testing of functional code will be the focus of this article. Of course, we need a testing framework to provide support, and this article Jestis about such a framework.

Before the introduction Jest, let's talk about what unit testing is and carry out effective unit testing.

Terrible unit tests

Speaking of unit testing, especially for students in the front-end industry, it is really frightening. After all, the word unit testing sounds awesome.

There are also warriors who often say that labor and capital React, antd have a stud, and they want a chicken unit test.

In real work, when you receive other people's code, most people may feel that they are taking over a mountain of shit, and they have to figure out how to carve on the mountain of shit, and also have to keep the mountain from collapsing. Assi, labor and capital might as well be reconstructed.

stop! Pulled away again.

Well, don't care about the black box, white box, performance, and function of the test classmates. For most of us, unit testing means testing code. How to test it?

code test code

Maybe you understand a little bit, then the question is, which excavator is stronger? sorry, the question is how to test the code with the code?

As a simple example, you write a function to determine whether the request is successful. Because of irresistible factors, the back-end classmates in your company team, each person's interface has a different definition of successful return. Some pass the http status code 200, some return a code field based on whether the success in the return value is true, and some return a code field whose value may be 200 or "200".

The reality is so cruel, the front-end partners are always writing such and such compatible solutions.

// utils.js
/**
 * 判断请求是否成功,成功返回,失败提示失败信息
 * @param {Object} response
 * @returns
 */
export const requestSuccess = response => {
    if (
        response.data.success === true ||
        response.data.code === 'success' ||
        response.data.code === '1000' ||
        response.data.code === 200 ||
        response.data.code === '200'
    ) {
        return response;
    }
    message.error(response.data.message || '请求失败');
};
复制代码

For such a common function, how do we need to unit test it?

The so-called test is nothing more than in a specific scenario (we can ignore the scenario first), input some values, get the output value, and then compare it with the expected output value to see if it is consistent, if it is consistent, it indicates this test The use case passes .

Looking at this simple example, we don't care what the following testand expectmethods do.

// utils.test.js
import { requestSuccess } from './utils.js'
test('requestSuccess方法 请求正常测试用例', () => {
    const input = {
        data: {
            success: true,
            code: 200,
            message: '请求成功'
        }
    };
    const expectOutput = input;
    const output = requestSuccess(input);
    expect(output).toEqual(expectOutput);
});
复制代码

In the above case, let's break it down step by step:

首先定义了一个输入:input

然后将input作为参数,调用了requestSuccess方法,本次调用得到另一个返回值output

最后就是判定,判定(也就是所谓的期望/断言)得到的输出值output等于期望的输出值expectOutput

这是一段最基础的,正常输入值的单元测试代码,我们可以总结出大概的步骤:

1、定义输入值

2、将输入值带上,执行代码,得到输出值

3、对输出值进行断言

这个断言就是说你觉得这个输出值应该是什么,也断言这个输出值和你期望输出值匹配。当然,实际输出值和你的期望输出可能不匹配,那就是表明你的这条用例执行失败。失败的原因可能是你的源代码有问题,也可能是你单元测试的用例代码有问题。

OK,我们了解了最基础的单元测试。那么真正意义的单元测试应该怎么写呢?

无非就是写单元测试用例,定义各种输入值,判断和期望输出是否一致,然后进行分析修改。

再回归到上面的requestSuccess方法,上面的测试用例仅仅是验证了正常情况下,当然这种情况可能占大多数,但是单元测试一般就是为了兼容小部分的异常场景。

那么接下来,我们就来分析下一般意义上请求失败场景的测试用例:

// utils.test.js
import { requestSuccess } from './utils';
​
test('requestSuccess方法 请求失败测试用例', () => {
    const input = {
        data: {
            success: false,
            message: '请求失败'
        }
    };
    const output = requestSuccess(input); // 没有返回值,output为undefine
    expect(output).toBeUndefined(); 
});
复制代码

好了,到这里,有的同学说,请求正常、请求异常的情况都覆盖了,单元测试完成,可以提交测试,然后进行愉快的摸鱼了。

等等,事情没有那么简单。

测试同学急急忙忙来找你了,说你的程序又崩了,页面空白了。

你让测试同学给你复现了,一步一步debug。原来发现,调用你requestSuccess方法的response参数,尽然为一个空对象: {}

你可能会直呼好家伙,后端不讲武德啊(当然可能性很多,可能并不是后端一个人的锅),因为不可抗拒因素,你又得去改代码,一边改一边骂。

改完之后的源码如下,然后你又得意的告诉测试同学已经改完,没有问题了。

export const requestSuccess = response => {
    if (
        response.data?.success === true ||
        response.data?.code === 'success' ||
        response.data?.code === '1000' ||
        response.data?.code === 200 ||
        response.data?.code === '200'
    ) {
        return response;
    }
    message.error(response.data.message || '请求失败');
};
复制代码

结果不一会,测试同学说,你的程序又崩了,页面空白了。

你慌了,自言自语的说道,没问题啊,劳资都写了兼容了,让测试同学给你复现了,一步一步debug。原来发现,调用你requestSuccess方法的response参数,尽然为undefined。你破口大骂,告诉测试是后端的锅,是另一个前端瞎鸡儿调用,和你无关。掰扯了一段时间,你又改了下你的代码:

// 当然下面的代码还是可以继续优化
export const requestSuccess = response => {
    if (
        response?.data?.success === true ||
        response?.data?.code === 'success' ||
        response?.data?.code === '1000' ||
        response?.data?.code === 200 ||
        response?.data?.code === '200'
    ) {
        return response;
    }
    message.error(response.data.message || '请求失败');
};
复制代码

再回到单元测试的正题上,上面的那些异常情况,在实际项目运行中比比皆是。而除了配合测试同学发现bug然后修改之外,我们在单元测试的时候即可发现,并优化自己的代码。

例如requestSuccess针对这个方法而言,我们先不用去管实际调用时候什么请求成功、请求失败,只去针对这个方法本身,调用requestSuccess方法的参数可能性是非常多的,各种类型的,所以我们可以以每一种类型的输入值作为一条测试用例。

// utils.test.js
import { requestSuccess } from './utils';
​
// 这个describe可以不用纠结,理解成几份用例的集合,只是统一为异常输入的描述
describe('requestSuccess方法异常输入测试用例', () => {
    test('response为空对象测试', () => {
        const input = {};
        const output = requestSuccess(input);
        expect(output).toBeUndefined();
    });
​
    test('response为undefined测试', () => {
        const output = requestSuccess();
        expect(output).toBeUndefined();
    });
​
    test('response为Number类型测试', () => {
        const output = requestSuccess(123);
        expect(output).toBeUndefined();
    });
});
复制代码

在写了这么多的异常输入的测试用例之后,你会发现你一开始写的requestSuccess不够强大,导致单元测试用例执行失败,所以你可以一遍又一遍的修改你的源码,直至测试用例都通过。

总结: 如何进行有效的单元测试,最简单的做法就是考虑各种异常/边界输入值,编写相应的测试用例,通过单元测试的执行,优化你的代码。

当然做好单元测试,并不仅仅只是说考虑各种异常输入即可,实际还会涉及到开发时候 的考虑(比如常说的测试驱动开发之类的)以及非常多的实现细节,这个可能就需要你慢慢的理解了。

Jest

介绍

官方链接

Jest is a delightful JavaScript Testing Framework with a focus on simplicity.

It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!

官方的介绍就是上面2段话,就是说jest是一个让人愉悦的js测试框架,专注于简单性。可以配合babel、ts、node、react、angular、vue等其他库 一起使用。

我们前文提及的什么describe、test、expect方法等等在Jest中都有相应的api。

一、基础教程

安装

可以使用yarn或者npm进行安装

yarn add jest -D | npm i jest -D
复制代码

源码开发

这里举了一个简单的例子,实际组件开发需要使用ts以及其他UI测试框架。

例如开发一个基础方法,返回2个参数的和。文件名为sum.ts

// sum.js
function sum(a, b) {
  return a + b;
}
export default sum;
复制代码

测试用例编写

首先我们根据上面的目标文件(sum.js)创建一个测试用例文件-- sum.test.js, 测试用例文件名称统一为*.test.js(后缀根据实际场景区分为.js或者.ts或者.tsx)

// sum.test.js
import sum from './sum';
​
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});
复制代码

开始测试

添加下面的部分到你的package.json中

{
  "scripts": {
    "test": "jest"
  }
}
复制代码

最后,执行yarn testornpm run test命令,Jest将会打印如下信息:

PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)
复制代码

就这样,基于jest的一个基础单元测试流程走好了,Jest的单元测试核心就是在test方法的第二个参数里面,expect方法返回一个期望对象,通过匹配器(例如toBe)进行断言,期望是否和你预期的一致,和预期一致则单元测试通过,不一致则测试无法通过,需要排除问题然后继续进行单元测试。

更多的配置以及命令行参数请参考官方文档下面开始讲解一些核心API。

二、核心API

全局方法

在你的测试文件中,Jest将下面这些方法和对象放置全局环境,所以你无需再显式的去require或者import。当然,如果你更喜欢显式的import,也可以使用例如import { describe, expect, it } from '@jest/globals'的方式。

1、test(name, fn, timeout)

test有别名it,两个方法是一样的。

第一个参数是你想要描述的测试用例名称; 第二个参数是包含测试期望的函数,也是测试用例的核心。第三个参数(可选)是超时时间,也就是超过多久将会取消测试(默认是5秒钟)

Note: 如果fn返回的是个promise,Jest在完成测试前将会等待Promise达到resolved状态。具体情况本文下面也会讲到如何对异步代码进行测试。

匹配器

Jest使用匹配器可以让你使用各种方式测试你的代码,Jest中的匹配器实际就是expect方法返回的期望对象中包含的相关方法。官方提供了非常多的匹配器,完善的学习请查看官方文档

下面摘选了几个最常见的匹配器方法。

1、.toBe(value)

toBe是最简单最基础的匹配器,就是判定是否精确匹配,toBe方法使用了Object.is方法来测试精确相等。

Object.is方法的判定准则可以参考这里

test('two plus two is four', () => {
  expect(2 + 2).toBe(4);
});
复制代码

在测试中,你有时需要区分 undefinednull,和 false,但有时你又不需要区分。 Jest 让你明确你想要什么。

  • toBeNull 只匹配 null
  • toBeUndefined 只匹配 undefined
  • toBeDefinedtoBeUndefined 相反
  • toBeTruthy 匹配任何 if 语句为真
  • toBeFalsy 匹配任何 if 语句为假
2、.not

非常容易理解,一般就是反向测试

test('two plus two is four', () => {
  expect(2 + 2).not.toBe(4);
});
复制代码
3、.toEqual

递归检查对象或数组的每个字段

和上面的toBe进行对比,toBe对比俩对象对比的是内存地址,toEqual比的是属性值。

test('object assignment', () => {
  const data1 = { one: 1, two: 2 };
  const data2 = { one: 1, two: 2 };
  expect(data1).toBe(data2); // 测试失败
  expect(data1).toEqual(data2);// 测试通过
});
复制代码
4、expect.assertions

expect.assertions(number) 验证一定数量的断言在某个测试用例中被调用。通常在异步代码测试中非常有用,目的就是为了确保所有的断言被真实的调用了。

比如下面这个例子,如果去掉了expect.assertions(2), 那么测试用例会通过测试,但实际的需求应该是失败的,因为我们最初的期望是catch中的断言也会被调用。而有了expect.assertions(2),Jest会判断断言实际调用的数量和我们预期是否一致,如果不一致则说明测试失败。

test('doAsync calls both callbacks', () => {
  expect.assertions(2);
  return Promise.resolve(123).then((data: number) => {
    expect(data).toBe(123);
    return; // 例如手抖写了return
    // 因为某些原因下面的代码没有执行
    throw new Error('报错了');
  }).catch(err => {
    expect(err).toBe('报错了');
  });
});
复制代码

异步代码测试

在JavaScript中执行异步代码是很常见的。 当你有以异步方式运行的代码时,Jest 需要知道当前它测试的代码是否已执行完成,然后它可以转移到另一个测试。 Jest有若干方法处理这种情况。

回调

最常见的异步模式就是回调函数,例如下面的setTimeout方法,下面的测试用例无法通过,原因是Jest无法知道callback具体的调用时间,所以会造成测试已经结束,但是setTimeout的callback还没有执行。

test('the data is peanut butter', () => {
  function callback(data: string) {
    expect(data).toBe('peanut butter');
  }
  setTimeout(() => {
    callback('peanut butter');
  }, 2000);
});
复制代码

想要解决上面的问题,非常简单,很容易就会联想到消息通知机制,也就是在callback调用的时候通知Jest,表示当前测试用例通过,可以跑下一个测试。

test方法的第二个参数fn,可以添加一个done参数,done是一个方法,调用了done,就是通知Jest测试完成,当然如果你的测试用例中的done方法始终没有执行,那么你的测试也会失败(超时),所以最好的方式就是加上try catch。

test('the data is peanut butter', done => {
  function callback(data: string) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (err) {
      done(err);
    }
  }
  setTimeout(() => {
    callback('peanut butter');
  }, 2000);
});
复制代码
Promise

如果你的代码使用了Promise, Jest提供了一种更加直接的方式去处理异步测试。在test第二个参数fn中直接返回一个promise,Jest就会等待这个promise达到resolved状态,如果达到了fulfilled状态,测试将会自动失败。

例如这个案例,此测试用例能够正常的通过

test('promise resolved', () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  }).then((data: string) => {
    expect(data).toBe('resolved');
  });
});
复制代码

如果promise fulfilled如下,则测试用例会跑失败

test('promise fulfilled', () => {
  return Promise.reject('fulfilled').then((data: string) => {
    expect(data).toBe('fulfilled');
  })
});
复制代码

当然我们也可以使用catch方法,例如下面这个例子,测试用例就能够正常的通过。

test('promise fulfilled', () => {
  expect.assertions(1);
  return Promise.reject('fulfilled').catch(err => {
    expect(err).toMatch('fulfilled');
  });
});
复制代码

promise代码可以配合匹配器.resolvesrejects一起使用,使用案例如下:

test('promise resolved', () => {
  return expect(Promise.resolve('resolved')).resolves.toBe('resolved');
});
test('promise fulfilled', () => {
  return expect(Promise.reject('fulfilled')).rejects.toMatch('fulfilled');
});
复制代码
Async/Await

如果你的代码使用了Promise, Jest提供了一种更加直接的方式去处理异步测试。在test第二个参数fn中直接返回一个promise,Jest就会等待这个promise达到resolved状态,如果达到了fulfilled状态,测试将会自动失败。

const TEN = 10;
const BASE = 5;
function fetchData () {
  return new Promise((resolve, reject) => {
    const random = Math.random() * TEN;
    random > BASE ? resolve(random) : reject(random);
  });
}
​
test('the random promise', async () => {
  expect.assertions(1);
  try {
    const random = await fetchData();
    expect(random).toBeGreaterThan(BASE);
  } catch (e) {
    expect(e).toBeLessThanOrEqual(BASE);
  }
});
复制代码

Mock Functions

Mock 函数简单的说就是模拟一个函数,这个功能很强大,例如nodejs中没有DOM/BOM,及时是jsdom也会缺少一些api,那么我们可以使用mock函数来进行一些测试,具体暂不详细说明。

有两种方法可以模拟函数:要么在测试代码中创建一个 mock 函数,要么编写一个手动 mock来覆盖模块依赖。

Mock Functions Doc

使用 mock 函数

假设我们要测试函数 forEach 的内部实现,这个函数为传入的数组中的每个元素调用一次回调函数。

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}
复制代码

为了测试此函数,我们可以使用一个 mock 函数,然后检查 mock 函数的状态来确保回调函数如期调用。

const mockCallback = jest.fn(x => 42 + x);
forEach([0, 1], mockCallback);
​
// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);
​
// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);
​
// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);
​
// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);
复制代码
.mock 属性

所有的 mock 函数都有这个特殊的 .mock属性,它保存了关于此函数如何被调用、调用时的返回值的信息。 .mock 属性还追踪每次调用时 this的值,所以我们同样可以也检视

Mock 的返回值
const filterTestFn = jest.fn();
​
// Make the mock return `true` for the first call,
// and `false` for the second call
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
​
const result = [11, 12].filter(num => filterTestFn(num));
​
console.log(result);
// > [11]
console.log(filterTestFn.mock.calls);
// > [ [11], [12] ]
复制代码
Mocking Modules

在Jest单元测试的真实场景中,会有很多的数据来自接口,但是Jest并不推荐直接在测试代码中去调用真实的接口,因为这可能会让测试变得非常缓慢而且脆弱,所以jest.fn().mockResolvedValue提供了mock接口的方式,使用假数据进行测试。

test('async test', async () => {
  const asyncMock = jest.fn().mockResolvedValue(23);
  const result = await asyncMock(); // 43
  expect(result).toBe(23);
});
复制代码

快照测试

每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。

一点典型的快照测试案例就是一个移动端的app渲染一个UI组件,拍下快照,然后将其与之前测试存储的参考快照文件进行对比,如果2个快照不匹配的话测试就会失败。简单的来说就是对比前后2次组件渲染的照片,这个测试方法非常适合React这类UI框架。

Jest快照测试第一次会生成一个快照文件,就像下面这样

// Jest Snapshot v1, https://goo.gl/fbAQLP
​
exports[`component--Loading 单元测试start 1`] = `
<div
  className="container"
>
  <div
    className="inner"
  >
    <div
      className="boxLoading"
    />
    <div
      className="title"
    >
      努力加载中...
    </div>
  </div>
</div>
`;
复制代码

我们可以在Jest 命令中加入--updateSnapshot,这样快照有跟新的话会跟新快照文件而不是直接让整个快照测试失败了。

建议把快照测试文件提交到git,快照测试文件也可以进行code-review。

Guess you like

Origin juejin.im/post/7120136930662023176