什么是单元测试,如何去写一个单元测试

8,746 阅读11分钟

这是我参与更文挑战的第2天,活动详情查看:更文挑战

相信单元测试是属于那种没有用过也听过的技术(如果你是大佬,听过也用过,欢迎提出宝贵的意见🧎‍♀️🧎‍♂️)。那么到底什么是单元测试,单元测试在实际项目开发中能给我们带来什么样的好处?我们站在前端开发的角度一起来聊一聊单元测试。

📚(一)什么是单元测试

📢单元测试概念

单元测试是指对软件中最小可测单元进行检查和验证;c语言中单元指一个函数,java中指一个类。图形化软件中可以指一个窗口或者一个菜单。总的来说,单元就是认为规定最小的被测试模块。 这个便是对百度百科上对单元测试的介绍,那么对于我们前端来说单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。

大多数单元测试包括四个主体:
  • 测试套件describe、
  • 测试用例it、
  • 判定条件expect、
  • 断言结果toEqual。
什么不是单元测试

在了解了什么是单元测试的基础上,那么什么不是单元测试呢?在《修改代码艺术》一书上有这样的介绍:

  • 需要访问数据库的测试不是单元测试
  • 需要访问网络的测试不是单元测试
  • 需要访问文件系统的测试不是单元测试

以上便是对单元测试概念的简单介绍,那么为什么要使用单元测试,单元测试有什么优势,不考虑回报的程序员不是好的程序员。


📚(二)单元测试对我们开发程序有什么好处

  • 首先是一个前端单元测试的根本性原由:JavaScript 是动态语言,缺少类型检查,编译期间无法定位到错误; JavaScript 宿主的兼容性问题。比如 DOM 操作在不同浏览器上的表现。

  • 正确性:测试可以验证代码的正确性,在上线前做到心里有底。

  • 自动化:当然手工也可以测试,通过console可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行。

  • 解释性:测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些API。其他开发人员如果要使用这些API,那阅读测试用例是一种很好地途径,有时比文档说明更清晰。

  • 驱动开发,指导设计:代码被测试的前提是代码本身的可测试性,那么要保证代码的可测试性,就需要在开发中注意API的设计,TDD将测试前移就是起到这么一个作用。

  • 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构。

我们知道 高覆盖率的单元测试,可以保证每次上线bug率大大降低,也是代码重构的基础。很多老项目,开发人员离职、新接手的人员不敢重构,慢慢称为团队负担、又不能下线,就是因为没有单元测试,改一点都怕出现不可测的bug。简单来说,也可以概括为以下几点

  1. 提高代码质量
  2. 减少bug,快速定位bug
  3. 放心地修改、重构
  4. 单元测试不但会使你的工作完成得更轻松。而且会令你的设计会变得更好,甚至大大减少你花在 调试上面的时间

📚(三)如何编写单元测试用例

如何编写单元测试用例,单元测试用例的原则是什么:

  • 测试代码时,只考虑测试,不考虑内部实现;
  • 数据尽量模拟现实,越靠近现实越好,
  • 充分考虑数据的边界条件下·
  • 对重点、复杂、核心代码、重点测试
  • 利用AOP(面向切面编程),减少测试代码,避免无用功能
  • 测试、功能开发相结合,有利于设计和代码重构

🔎插一个小知识点:那么这里提到的AOP是什么意思,AOP是Aspect Oriented Program的首字母缩写意思是面向切面编程,这种在运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。
📌一般而言,我们管切入到指定类指定方法的代码片段称为切面,而切入到哪些类、哪些方法则叫切入点。有了AOP,我们就可以把几个类共有的代码,抽取到一个切片中,等到需要时再切入对象中去,从而改变其原有的行为。

再简单了解了单元测试之后我们将其带入到我们实际项目开发中,来尝试一下


📚(四)组件化后,组件哪部分最具测试价值?(以React为例)

image.png

1. Component

Component 应着重关注render以及副作用,同时业务逻辑的处理过程,都应该尽量提取到Hooks和Utils文件中。因此,对于Component的测试,我们完全可以将重心主要放在以下这两方面问题上:

  • 组件是否正常渲染了?
  • 组件副作用是否正常处理了?
2. Hooks

如何测试React Hooks,社区目前已有相对成熟的解决方案,即@testing-library/react-hooks + react-test-renderer[2]。通过这两个依赖,开发人员可以很轻松的mock出Hooks执行所依赖的环境,把store的数据当作hooks的输入,关注在hooks内的业务逻辑,即可把Hooks当作纯方法(Pure Function)来进行测试。

3. Redux

对于Redux,如果项目在使用 Redux Toolkit 的话,事情会简单很多,开发人员只需要关注Dispatch的Actions即可。但如果Actions和Reducer是分开编写,则需要针对性处理

4. Service

不同项目或团队对Service的定义各不相同,这里我们要聊的主要指负责处理HTTP请求的request和response,以及相应的异常处理的数据层。Service主要的功能是对接Action,因而理想情况下Service只需要包含与API通信的代码,这种情况下,UT可有可无。但一些场景下,如果项目中没有使用BFF承担数据处理的角色,后端也没能提供完全符合前端数据结构需求的接口时,不可避免的,开发人员需要在此处完善数据处理的逻辑,以便获取清洗或聚合后的数据,因而这种情况下,UT覆盖是非常有必要的。

5. Utils/Helpers

Utils/Helpers主要包含以下几类类型: 数据结构的转化,各种convert工具函数 数据结构的处理,比如数据提取、合并压缩、整理工具函数 公共的工具函数 根据我们目前的项目习惯,当一段逻辑需要在Utils/Helpers中实现时,那么它一定是纯函数,其中多数情况又会包含一定程度的数据处理逻辑,所以基本都需要UT覆盖

在了解了项目组件哪些部分最具有测试价值之后,我们就要上手了,跃跃欲试中🤸‍♀️🤸‍♂️


📚(五)如何让我们的测试用例更易编写、维护?

举个例子💁‍🌰,先看代码,看不看得懂不重要🧎‍♀️,我们先来了解一下

// production code
const computeTotalAmount = (products) => {
  return products.reduce((total, product) => total + product.price, 0); 
}

// testing code
it('should return summed up total amount 1000 when there are three products priced 200, 300, 500', () => {
  // given - 准备数据
  const products = [
    { name: 'nike', price: 200 },
    { name: 'adidas', price: 300 },
    { name: 'lining', price: 500 },
  ]

  // when - 调用被测函数
  const result = computeTotalAmount(products)

  // then - 断言结果
  expect(result).toBe(1000)
})

可以看到我们首先定义了一个computeTotalAmount的待测函数,it包裹了我们的测试用例。在测试用例中,首先第一步:准备数据,然后调用被测函数,最后输出断言结果。 可以看到这样的结果清晰明了。
好的单元测试应该遵循AAA的模式,AAA模式:编排(Arrange),执行(Act),断言(Assert)。可以让你写出比较清晰的测试结构,既易于阅读,也易于编写

编写单元有以下几个编写原则:
  • 🎈mock数据集中管理,考虑mock数据极端情况

  • 🎈只关注输入输出,不关注内部实现

  • 🎈一个单元测试只测一个业务场景

    • 如此你才能给它一个好的描述,这个测试才能保护这个特定的业务场景,挂了的时候能给你细致到输入输出级别的 业务反馈。
  • 🎈表达力极强,不包含逻辑

    • 表达力强的测试,能在失败的时候给你非常迅速的反馈,看到测试时,你就知道它测的业务点是啥 测试挂掉时,能清楚地知道失败的业务场景、期望数据与实际输出的差异,跟写声明式的代码一样的道理,测试需要都是简单的声明:准备数据、调用函数、断言,让人一眼就明白这个测试在测什么。如果含有逻辑,你读的时候就要多花时间理解;一旦测试挂掉,你咋知道是实现挂了还是测试本身就挂了呢?
  • 🎈运行速度快

    • 可以使用mock适当隔离掉三方的依赖,将依赖、集成等耗时、依赖三方返回的地方放到更高层级的测试中,有策略性地去做
  • 🎈隔离性

    • 单元测试是对代码独立的单元进行测试,这个独立的意思不是说这个函数(单元)不会调用另外一个函数(单元),而是说我们在测试这个函数的时候如果它有调用到其它的函数我们就需要mock它们,从而将我们的测试逻辑只放在被测试函数的逻辑上,不会受到其它依赖函数的影响

最后我们带入项目实际开发一下吧


📚(六)react单元测试框架enzyme实际应用

1. 测试工具:主要用到的测试工具是 jest 和 enzyme
2. 待测组件:可以添加删除的一个简单列表;
3. 我们想要测试四点:

1、组件渲染

2、渲染时初始待办事项的展示

3、我们可以创建一个新的待办事项然后返回三个待办事项

4、我们可以删除一个初始的待办事项并且只留下一个

上代码ing.....

上组件👩‍🌾👩‍🌾👩‍🌾

import React, { useState, useRef } from "react";
const Todo = () => {
    const [list, setList] = useState([
        { id: 1, item: "Fix bugs" },
        { id: 2, item: "Take out the trash" }
    ]);
    const todoRef = useRef();
    const removeTodo = id => {
        setList(list.filter(todo => todo.id !== id));
    };
    const addList = data => {
        let id = list.length + 1;
        setList([
            ...list,
            {
                id,
                item: data
            }
        ]);
    };
    const handleNewTodo = e => {
        e.preventDefault();
        const item = todoRef.current;
        addList(item.value);
        item.value = "";
    };
    return (
        <div className="container">
            <div className="row">
                <div className="col-md-6">
                    <h2>Add Todo</h2>
                </div>
            </div>
            <form>
                <div className="row">
                    <div className="col-md-6">
                        <input
                            type="text"
                            autoFocus
                            ref={todoRef}
                            placeholder="Enter a task"
                            className="form-control"
                            data-testid="input"
                        />
                    </div>
                </div>
                <div className="row">
                    <div className="col-md-6">
                        <button
                            type="submit"
                            onClick={handleNewTodo}
                            className="btn btn-primary"
                        >
                        Add Task
                        </button>
                    </div>
                </div>
            </form>
            <div className="row todo-list">
                <div className="col-md-6">
                    <h3>Lists</h3>
                    {!list.length ? (
                        <div className="no-task">No task!</div>
                    ) : (
                        <ul data-testid="list">
                            {list.map(todo => {
                                return (
                                    <li key={todo.id}>
                                        <div>
                                            <span>{todo.item}</span>
                                            <button
                                                className="btn btn-danger"
                                                data-testid="delete-button"
                                                onClick={() => removeTodo(todo.id)}
                                            >
                                                删除
                                            </button>
                                        </div>
                                    </li>
                                );
                            })}
                        </ul>
                    )}
                </div>
            </div>
        </div>
    );
};
export default Todo;

组件很简单,一个添加一个删除的简单功能,但是直接看代码会感觉乱七八糟的,一点也不清晰明了,那么我们来看测试用例,乌拉......

import React from "react";
import { shallow, mount } from "enzyme";
//import 'jsdom-global/register';//在测试单测完成度时打开
import App from "./App";

describe("Todo", () => {
  //将一个组件渲染成虚拟DOM对象,但是只渲染第一层,不渲染所有子组件,所以处理速度非常快
    it("组件渲染", () => {
        shallow(<App />);
    }) 
    it("查询li个数", () => {
      const wrapper = mount(<App />);
      //mount可以渲染组件APP下的所有子组件
      expect(wrapper.find("li")).toHaveLength(2);
      //我们通过find找到元素li,因为有组件中list有两条默认数据,所以元素li应该是有两个,我们直接输出断言结果。
    });
    it("调用addList", () => {
      const wrapper = mount(<App />);
      wrapper.find("input").instance().value = "新增加一个";
      //这里同理找到input元素,并给其赋值“新增加一个”
      expect(wrapper.find("input").instance().value).toEqual("新增加一个");
      //赋值完成后,我们找到对应的input元素,查询input的值,增加断言判断是否为“新增加一个”
      wrapper.find('[type="submit"]').simulate("click");
      //找到type为submit的button,触发click事件
      expect(wrapper.find("li")).toHaveLength(3);
      //断言li的长度是否为3
      expect(wrapper.find("li div span").last().text()).toEqual("新增加一个");
      //断言最后一个li(新添加的那一个),其中子元素span的值是否为“新增加一个”
    });

    it("调用removeTodo", () => {
      const wrapper = mount(<App />);
      wrapper.find("li button").first().simulate('click');
      //找到以一个li中的button,触发click事件
      expect(wrapper.find("li")).toHaveLength(1);
      //触发删除按钮后,查询li的长度,应该为1
      expect(wrapper.find("li div span").last().text()).toEqual("Take out the trash");
      //最后查询第一个li的子元素span, 它的值是否为Take out the trash
    })
});

那么我们运行一下 yarn test

image.png

可以看到每个测试用例的运行时间

我们再来试一下单测覆盖度 jest --coverage

image.png

可以看到我们的覆盖率(lines)是100%的

那么以上便是对单元测试的简单介绍,这里附上最后这个例子🌰的 📎github地址