单元测试入门与进阶

单元测试

单元测试(Unit Testing)是指对前端应用中的单个最小功能单元进行测试,以验证其是否按预期工作。这个"最小功能单元"通常是一个函数、方法、组件或模块。

单元测试的目标是确保代码在最基本的粒度上是正确的,通常由开发人员自己编写,并且是在开发过程中进行的。

作用

  • 提高代码质量:通过早期发现代码中的错误,减少了后期调试和修复的成本。
  • 代码重构更容易:有了单元测试,开发人员可以在重构代码时更有信心,因为可以通过运行现有的单元测试来验证重构后代码的正确性。
  • 文档化代码行为:单元测试可以作为代码行为的一部分文档,其他开发人员可以通过阅读测试代码了解函数的预期行为。
  • 调试方便:通过执行单元测试,可以快速定位出问题所在,特别是在复杂的逻辑中。

单元测试入门

单元测试对项目非常有帮助,笔者的公司对单元测试非常看重,要求所有项目单元测试覆盖率达到80%以上。

笔者站在零基础的角度讲述如何写好单元测试。本篇文章中基于React示例代码和Vitest测试框架讲述单元测试的方方面面。

React是一个非常流行的前端UI库,用于构建前端页面,对React不熟的小伙伴可以通过React文档进行了解

Vitest测试框架是基于Vite开发的新一代测试框架,完美兼容Jest,功能十分强大。

Test Runner

上面提到了Vitest测试框架,在使用Vitest前,先来学习Test Runner概念。

Test Runner是一个用于自动化执行测试用例、收集测试结果并报告测试执行状态的工具或组件。在软件开发中,测试运行器帮助开发人员方便地执行单元测试、集成测试等,并输出相关的结果,如成功、失败、跳过等信息。

Test Runner 的主要功能:

  1. 自动化执行测试:Test Runner 会自动执行预定义的测试用例,而无需手动逐一运行每个测试。
  2. 结果报告:它会生成测试执行的结果报告,通常包括通过的测试、失败的测试、错误信息、测试覆盖率等。
  3. 管理测试用例:Test Runner 通常能够管理和组织测试用例,支持按照不同的分组、标签、优先级等运行。
  4. 提供反馈:Test Runner 会为每个测试用例提供反馈,指明测试是通过了还是失败了,并给出详细的错误信息。
  5. 集成到持续集成(CI)工具:Test Runner 常常与持续集成(CI)工具(如 Jenkins、GitLab CI、Travis CI)结合,自动化执行测试并生成报告。

Vitest就是一个 Test Runner

单元测试表现形式

形如上图中的形式就是单元测试。下面来分解其中的所有概念

describe

  • 定义describe 是一个用于组织和分组测试用例的函数。它用来将一组相关的测试放在一起,通常用来描述测试的功能或模块。
  • 作用describe 用于将多个 it 测试用例进行分组,使得测试报告更加清晰。例如,可以将所有与某个函数相关的测试放在同一个 describe 块中。
  • 语法describe(description, callback),其中 description 是描述测试组的文本,callback 是一个包含多个测试用例的函数。
js 复制代码
// 把 describe 整体称为测试套件
describe('Array operations', () => {
  // 包含的多个测试用例,
});

it/test

  • 定义it 是定义具体测试用例的函数。它通常用来描述某个功能的预期行为。
  • 作用it 定义了一个具体的测试场景,通常以"should"开始,描述这个测试应该验证什么行为或期望的输出。每个 it 都包含一个测试步骤,通常是一个断言(如 expect())。
  • 语法it(description, callback),其中 description 是对当前测试行为的简短描述,callback 是一个函数,包含实际的测试逻辑。
js 复制代码
// 下面的代码整体称为一个测试用例
it('should add two numbers correctly', () => {
  expect(add(1, 2)).toBe(3);
});

expect

  • 定义expect 是一种断言机制,用来检查某个值是否符合预期。它是 JavaScript 测试框架(如 Jest)提供的 API。
  • 作用expect 用来生成一个期望值,之后通过调用匹配器(如 .toBe())来验证该期望值是否满足预期条件。
  • 语法expect(value),表示测试值。然后可以使用不同的匹配器来判断该值是否符合期望。例如,expect(1).toBe(1) 会检查 1 是否等于 1
js 复制代码
// expect(1)是断言语句,后面的 toBe 是匹配器
expect(1).toBe(1);  // 检查 1 是否等于 1

匹配器

  • 定义toBe 是一个匹配器,用于检查被测试的值是否与期望值完全相等。它是 Jest 提供的一个常见的匹配方法,属于 "相等" 类型的断言。
  • 作用 :它检查实际值和预期值是否严格相等(即使用 === 比较)。
  • 语法expect(value).toBe(expectedValue),其中 value 是要测试的值,expectedValue 是期望的值。

总结

一个完整的测试文件可以写多个测试套件,测试套件由测试用例、断言语句和匹配器构成

实战

下面通过一个简单的工具函数来具体说明如何写测试。请看下面的工具方法示例

js 复制代码
// isNumber.js
function isNumber(value) {
  return typeof value === "number";
}

这段代码定义了一个名为 isNumber 的 JavaScript 函数,用于检查一个给定的值是否是数字类型。这是非常简单的工具函数,它的单元测试也非常简单

创建测试文件

示例中的代码在isNumber.js文件,那么创建测试文件名只需要加上testspec即可,比如isNumber.test.jsisNumber.spec.js。通用做法是在要测试的文件名后直接添加testspec即可,这样不需要思考如何测试文件命名

定义测试套件

describe 用来将 isNumber 相关的测试放在一起。describe 接收的第一个参数通常写成要测试的内容,本题中是测试 isNumber 那就写成 isNumber

js 复制代码
describe("isNumber", () => {});

定义测试用例

测试用例用来描述某个功能的预期行为。

js 复制代码
describe("isNumber", () => {
  it("should return true for numbers", () => {
    expect(isNumber(1)).toBe(true);
  });

  it("should return false for strings", () => {
    expect(isNumber("1")).toBe(false);
  });
});

上面的代码中有两个测试用例,it方法接收的第一个参数是测试用例描述,一般是测试功能的语义化描述,第二个参数是匿名方法,函数题是断言语句,判断isNumber传入的值是否符合预期的返回值。

isNumber的测试整体结构就是这样简单。还可以添加更多的测试用例覆盖更多的情况

React中的单元测试

通过前面的学习,大家对单元测试有一个大概的了解,通过简单示例大家了解如何书写单元测试。而实际项目中的代码非常复杂,那么相应的单元测试也会非常复杂

下面对单元测试进行分类,给出代码示例,也可以看看从哪些角度写单元测试

工具方法

在React中,工具方法常用于处理数据,格式化等。

示例代码

js 复制代码
export function toLowercase(val) {
  return val.toLowerCase();
}

toLowercase 的单元测试如下,

js 复制代码
import { toLowercase } from "../toLowercase";

describe("toLowercase", () => {
  it("should convert a string to lowercase", () => {
    expect(toLowercase("HELLO")).toBe("hello");
  });

  it("should return an empty string if input is an empty string", () => {
    expect(toLowercase("")).toBe("");
  });

  it("should handle mixed case strings", () => {
    expect(toLowercase("HeLLo WoRLd")).toBe("hello world");
  });

  it("should handle strings with numbers and special characters", () => {
    expect(toLowercase("Hello123!")).toBe("hello123!");
  });

  it("should throw an error if input is not a string", () => {
    expect(() => toLowercase(123)).toThrow();
    expect(() => toLowercase(null)).toThrow();
    expect(() => toLowercase(undefined)).toThrow();
  });
});

需要测试任何数据类型的参数被toLowercase处理后的结果。像这些工具方法与业务弱相关或者无关,安全可以利用AI工具帮助生成,其准确率特别高

React组件UI展示

React组件是构建页面的基石,所以在React项目中,基本上每一个React组件都应该写测试,对于React组件,主要从UI元素正常显示的角度去写测试

示例代码

js 复制代码
import { useState } from "react";
import reactLogo from "./assets/react.svg";

import "./App.css";

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p data-testid="test_HMR_text">
          Edit <code>src/App.jsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

在上面的React组件中,单元测试覆盖所有的页面UI正常显示,利用AI写出基础的单元测试,再根据情况修改

js 复制代码
import { render, screen, fireEvent } from "@testing-library/react";
import App from "../App";

describe("App component", () => {
  it("renders Vite and React logos", () => {
    render(<App />);
    const reactLogo = screen.getByAltText("React logo");
    expect(reactLogo).toBeInTheDocument();
  });

  it("renders the heading", () => {
    render(<App />);
    const heading = screen.getByText("Vite + React");
    expect(heading).toBeInTheDocument();
  });

  it("renders the edit message", () => {
    render(<App />);
    const editMessage = screen.getByTestId("test_HMR_text");
    expect(editMessage).toBeInTheDocument();
  });

  it("renders the documentation message", () => {
    render(<App />);
    const docsMessage = screen.getByText(
      /Click on the Vite and React logos to learn more/i
    );
    expect(docsMessage).toBeInTheDocument();
  });
});

React 组件交互

上面写完了组件的UI的单元测试,同等重要的是UI交互测试,也就是React组件的交互测试,以上面例子的中组件而言,请看它的组件交互测试

js 复制代码
import { render, screen, fireEvent } from "@testing-library/react";
import App from "../App";

describe("App component", () => {
  it("increments count on button click", () => {
    render(<App />);
    const button = screen.getByRole("button", { name: /count is/i });
    fireEvent.click(button);
    expect(button).toHaveTextContent("count is 1");
    fireEvent.click(button);
    expect(button).toHaveTextContent("count is 2");
  });
});

点击按钮,可以看到页面上的内容发生变化,

React Hooks

在React项目中,React Hooks 自然是离不开的重要内容。对于自定义的React Hooks需要使用@testing-library/react-hooks第三方依赖

示例代码

js 复制代码
import { useState, useEffect } from "react";

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

测试代码如下所示

js 复制代码
import { renderHook } from "@testing-library/react-hooks";
import useDebounce from "../useDebounce";

import { act } from "react";

describe("useDebounce", () => {
  vi.useFakeTimers();

  it("should return the initial value immediately", () => {
    const { result } = renderHook(() => useDebounce("initial", 500));

    expect(result.current).toBe("initial");
  });

  it("should debounce the value", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      {
        initialProps: { value: "initial", delay: 500 },
      }
    );

    rerender({ value: "changed", delay: 500 });

    // Value should not change immediately
    expect(result.current).toBe("initial");

    // Fast-forward time
    act(() => {
      vi.advanceTimersByTime(500);
    });

    // Value should be updated after the delay
    expect(result.current).toBe("changed");
  });

  it("should reset the debounce timer if value changes within the delay period", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      {
        initialProps: { value: "initial", delay: 500 },
      }
    );

    rerender({ value: "changed", delay: 500 });

    // Fast-forward time by less than the delay
    act(() => {
      vi.advanceTimersByTime(300);
    });

    // Value should not change yet
    expect(result.current).toBe("initial");

    // Change the value again
    rerender({ value: "changed again", delay: 500 });

    // Fast-forward time by the remaining delay
    act(() => {
      vi.advanceTimersByTime(200);
    });

    // Value should still not change
    expect(result.current).toBe("initial");

    // Fast-forward time by the new delay
    act(() => {
      vi.advanceTimersByTime(300);
    });

    // Value should be updated after the new delay
    expect(result.current).toBe("changed again");
  });
});

自定义Hooks的单元测试相对组件UI测试更简单,由于没有UI干扰,只需要验证逻辑操作前后的状态变化就行了

异步测试

在写React组件时,如果组件用的数据来自服务端,有小伙伴会在useEffect中写接口请求,此时需要注意异步请求的问题

js 复制代码
import React, { useState, useEffect } from "react";

export default function AccountList() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 模拟API请求
    async function fetchUsers() {
      try {
        const response = await fetch("/api/users");
        if (!response.ok) {
          throw new Error("Failed to fetch users");
        }
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      }
    }

    fetchUsers();
  }, []);

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

单元测试如下所示

js 复制代码
// AccountList.test.js
import { render, screen, waitFor } from "@testing-library/react";
import AccountList from "../AccountList/AccountList";

// 模拟 API 请求
global.fetch = vi.fn();

describe("AccountList", () => {
  it("should display users after successful fetch", async () => {
    // 模拟成功的 API 响应
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
      ],
    });

    render(<AccountList />);

    // 等待用户数据加载
    await waitFor(() => screen.getByText("Alice"));
    await waitFor(() => screen.getByText("Bob"));

    // 检查用户是否渲染
    expect(screen.getByText("Alice")).toBeInTheDocument();
    expect(screen.getByText("Bob")).toBeInTheDocument();
  });

  it("should display an error message when fetch fails", async () => {
    // 模拟失败的 API 响应
    fetch.mockRejectedValueOnce(new Error("Failed to fetch users"));

    render(<AccountList />);

    // 等待错误消息渲染
    await waitFor(() => screen.getByText(/Error:/));

    // 检查错误消息
    expect(
      screen.getByText("Error: Failed to fetch users")
    ).toBeInTheDocument();
  });

  it("should display an error message when API response is not ok", async () => {
    // 模拟 API 返回失败的响应(非 2xx 状态)
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
      json: async () => ({ message: "Internal Server Error" }),
    });

    render(<AccountList />);

    // 等待错误消息渲染
    await waitFor(() => screen.getByText(/Error:/));

    // 检查错误消息
    expect(
      screen.getByText("Error: Failed to fetch users")
    ).toBeInTheDocument();
  });
});

在异步测试中,尤其要注意请求的等待时间,需要等数据更新后,再验证状态变化

异常测试

异常测试是专门测试代码在异常情况(如错误输入、异常状态、网络失败等)下的行为。目的是确保应用在遇到错误时能正确处理,而不是崩溃或产生意外行为。

示例代码

js 复制代码
import React, { useState, useEffect } from "react";

export default function AccountList() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 模拟API请求
    async function fetchUsers() {
      try {
        const response = await fetch("/api/users");
        if (!response.ok) {
          throw new Error("Failed to fetch users");
        }
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      }
    }

    fetchUsers();
  }, []);

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

快照测试

快照测试(Snapshot Testing)是一种用于测试 UI 组件的技术,主要用于检测 React 组件的输出是否发生了意外的变化。它的核心思想是将组件的渲染结果(HTML 结构)保存为快照文件,并在后续测试时对比当前渲染结果和快照文件是否一致

示例代码

js 复制代码
export default function Button({ label }) {
  return <button>{label}</button>;
}

快照测试代码如下

js 复制代码
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Button Component Snapshot > should match the snapshot 1`] = `
<DocumentFragment>
  <button>
    Click Me
  </button>
</DocumentFragment>
`;

边界测试

边界测试(Boundary Testing)是一种测试输入数据边界值的测试方法 ,主要用于检测系统在极端值边界情况下的行为是否符合预期。

尤其是一些工具方法的测试,方法要接收所有类型的参数的测试,写出所有的测试用例

相关推荐
GISer_Jing37 分钟前
前端性能指标及优化策略——从加载、渲染和交互阶段分别解读详解并以Webpack+Vue项目为例进行解读
前端·javascript·vue
不知几秋38 分钟前
数字取证-内存取证(volatility)
java·linux·前端
水银嘻嘻2 小时前
08 web 自动化之 PO 设计模式详解
前端·自动化
Zero1017134 小时前
【详解pnpm、npm、yarn区别】
前端·react.js·前端框架
&白帝&4 小时前
vue右键显示菜单
前端·javascript·vue.js
Wannaer4 小时前
从 Vue3 回望 Vue2:事件总线的前世今生
前端·javascript·vue.js
羽球知道5 小时前
在Spark搭建YARN
前端·javascript·ajax
光影少年5 小时前
vue中,created和mounted两个钩子之间调用时差值受什么影响
前端·javascript·vue.js
青苔猿猿5 小时前
node版本.node版本、npm版本和pnpm版本对应
前端·npm·node.js·pnpm
一只码代码的章鱼6 小时前
Spring的 @Validate注解详细分析
前端·spring boot·算法