再谈前端测试之集成测试

260 阅读4分钟

心法

写测试的目的是让我们重构代码和提测时更有信心,而不是为了写测试而写测试,甚至是花时间维护测试代码。

如果项目还在需求频繁变更,界面 UI 还不稳定,还在尝试不同的调整,写测试用例的收益是小于成本的。如果项目已经比较稳定,或者本来就是一个比较稳定的界面,比如工具类的 APP,UI 一般都是小调整,变更不会频繁,这种时候可以配上大量全面的测试用例。

什么时候写单元测试?

工具类这种高度与逻辑挂钩的,或者单一的公共组件,可以写单元测试。

什么时候写集成测试?

高度与业务挂钩的,比如 UI 组件中加了一堆业务代码,重点关注交互的,可以写集成测试。

实践

我们写一个 todo list,然后再补齐集成测试,最后进行重构并确保通过所有测试用例。

每条测试用例都遵循 given when then 的格式,即模拟数据、测试、检查断言。每条测试用例只关注输入输出,不包含逻辑。

这是我们要写的 todo list:

Pasted image 20230730093156.png

交互逻辑如下:

  • 初始时,屏幕上有三个 todo
  • 每个 todo 都有 Toggle 和 Remove 按钮,用于完成和删除 todo
  • 如果点击 Toggle,则划去 todo,如果再点击一次 Toggle,则重置 todo
  • 如果点击 Remove,则删除 todo
  • 在 input box 输入 todo,按回车新增 todo

todolist 代码:

// App.tsx
import React, { useState } from "react";
import "./App.css";

interface TodoType {
  text: string;
  isCompleted?: boolean;
}

function App() {
  const mockTodos: TodoType[] = [
    {
      text: "Go to gym",
      isCompleted: false,
    },
    {
      text: "Buy milk",
      isCompleted: true,
    },
    {
      text: "Sleep early",
      isCompleted: false,
    },
  ];

  const [todos, setTodos] = useState<TodoType[]>(mockTodos);
  const [currentInput, setCurrentInput] = useState<string>("");

  const toggleTodo = (index: number) => {
    const todo = todos[index];
    todo.isCompleted = !todo.isCompleted;
    setTodos([...todos]);
  };

  const removeTodo = (index: number) => {
    todos.splice(index, 1);
    setTodos([...todos]);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!currentInput) {
      return;
    }
    const newTodo: TodoType = {
      text: currentInput,
    };
    setTodos([...todos, newTodo]);
    setCurrentInput("");
  };

  return (
    <div>
      {todos.map((todo, index) => {
        return (
          <div data-testid="todo-item" key={index}>
            <span
              style={{ textDecoration: todo.isCompleted ? "line-through" : "" }}
            >
              {todo.text}
              <button
                onClick={() => toggleTodo(index)}
                data-testid="toggle-todo"
              >
                Toggle
              </button>
              <button
                onClick={() => removeTodo(index)}
                data-testid="remove-todo"
              >
                Remove
              </button>
            </span>
          </div>
        );
      })}
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="What's your plan?"
          value={currentInput}
          onChange={(e) => setCurrentInput(e.target.value)}
        />
      </form>
    </div>
  );
}

export default App;

写集成测试

针对上面的交互逻辑,可以写出下面的集成测试用例。我们跳过了单元测试只写集成测试,这是因为后面还要进行重构,而重构可能会破坏单元测试

注意,下面的集成测试都是停留在 App 这个容器组件层面的,没有涉及到任何的代码实现细节,这样我们后面进行代码重构的时候,就不需要再修改测试用例了。

import { cleanup, render, screen, within } from "@testing-library/react";
import App from "./App";
import userEvent from "@testing-library/user-event";
import '@testing-library/jest-dom/extend-expect';

describe("todo list", () => {
  afterEach(() => {
    // reset the DOM after each test
    cleanup();
  });

  test("should render default todo lists", () => {
    /**
     * render 组件,然后获取页面上相应的元素,最后进行判断
     */

    // given
    render(<App />);

    // when
    const todos = screen.getAllByTestId("todo-item");

    // then
    expect(todos).toHaveLength(3);
  });

  test("should add todo item", () => {
    /**
     * render 组件,模拟用户输入,获取页面上相应的元素,最后进行判断
     */
    // given
    render(<App />);

    // when
    userEvent.type(
      screen.getByPlaceholderText("What's your plan?"),
      "Call mom{enter}"
    );

    // then
    const todos = screen.getAllByTestId("todo-item");
    expect(todos).toHaveLength(4);
  });

  test("should remove todo item", () => {
    /**
     * render 组件,模拟用户按钮点击,获取页面上相应的元素,最后进行判断
     */
    // given
    render(<App />);

    // then
    userEvent.click(
      within(screen.getByText("Go to gym")).getByTestId("remove-todo")
    );

    // when
    const todos = screen.getAllByTestId("todo-item");
    expect(todos).toHaveLength(2);
  });

  test("should toggle todo item", () => {
    /**
     * render 组件,模拟用户按钮点击,获取页面上相应的元素,最后判断 CSS 样式
     */
    // given
    render(<App />);

    // when
    const todoItem = within(screen.getByText("Go to gym"));
    userEvent.click(todoItem.getByText("Toggle"));

    // then
    const todoItemAfterClick = screen.getByText("Go to gym");
    expect(todoItemAfterClick).toHaveStyle("text-decoration: line-through");

    // toggle again should remove the line-through style
    userEvent.click(todoItem.getByText("Toggle"));
    const todoItemAfterClickAgain = screen.getByText("Go to gym");
    expect(todoItemAfterClickAgain).not.toHaveStyle("text-decoration: line-through");
  });
});

可以看到测试用例全部通过
Pasted image 20230731211728.png

进行重构

有了上面的测试用例,我们再进行重构的时候就有很大的信心了。 现在我们可以开启 npm test,尽情的重构了~

重构完的 todo app 大概是这样
Pasted image 20230801213224.png

检查测试用例

重构完成后记得检查测试用例是否都通过了
Pasted image 20230731211728.png

Jest 配置踩坑

坑:jest 不识别 jsx 语法

Pasted image 20230730094713.png 解决方法:配置 babel 来转译 jsx 语法

// babel.config.json
{
  "presets": ["@babel/preset-react"]
}

坑:jest 不识别 es6 的 import 语法

Pasted image 20230730094911.png 解决:配置 jest.config.js 来告诉 jest 用 babel 转译器

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  transform: {
    '^.+\\.(ts|tsx)?$': 'ts-jest',
    "^.+\\.(js|jsx)$": "babel-jest",
  }
};

坑:jest 不识别静态文件

Pasted image 20230730095903.png 解决:mock 静态文件

// jest.config.js
  moduleNameMapper: {
    "\\.(css|less)$": "identity-obj-proxy",
  },

坑:wrong test environment

Pasted image 20230730100743.png 解决:jest 默认运行环境为 node,把运行环境改为 dom 环境

// jest.config.js
testEnvironment: 'jest-environment-jsdom'

References

www.bilibili.com/video/BV1rv…