这是我参与「第五届青训营 」伴学笔记创作活动的第 20 天
一、本堂课重点内容:
本堂课的知识要点有哪些?
- 认识前端自动化测试
- 什么是自动化测试
- 为什么做自动化测试
- 技术选型
- 如何进行自动化测试
- 自动化测试的持续集成
二、详细知识点介绍:
认识前端自动化测试
什么是自动化测试
在软件测试中,自动化测试指的是使用独立于待测软件的其他软件来自动执行测试、比较实际结果与预期并生成测试报告这一过程。在测试流程已经确定后,测试自动化可以自动执行的一些重复但必要测试的工作。也可以完成手动测试几乎不可能完成的测试。对于持续交付和持续集成的开发方式而言,测试自动化是至关重要的。
理想的软件开发V模型:
- 单元测试:开发团队对自己的代码进行正确性校验
- 集成测试:不同模块集成后,QA团队对正常通信和功能进行多维度测试
- 系统测试:QA团队对整个系统进行端对端测试
现实中的软件开发:
需求开发:
开发=>功能测试=>验收上线
功能测试:
- 开发团队单测
- QA端对端测试
为什么做自动化测试
使用单元测试框架(如JUnit、NUnit等“xUnit”类型测试框架)执行自动化测试是目前软件开发行业的大趋势。
单元测试框架的应用使得各部分代码开发完成后立即进行相关单元测试来验证它们是否如预期在运行成为可能。
手工完成一些软件测试的工作(例如大量的低级接口的回归测试)十分艰苦耗时,而且寻找某些种类的缺陷时效率并不高,因而测试自动化,提供一种完成这类工作的有效方法。
一旦自动化测试方法开发完成,日后的测试工作将可以高效循环完成。很多时候这是针对软件产品进行长期回归测试的高效方法。毕竟,早期一个微小的补丁中引入的回归问题可能在日后导致巨大的损失。
QA(质量保障):
- 集成测试:测试团队保障整体系统功能符合预期的一种方式
- 研发角度的自动化测试:属于开发保障系统功能的一种方式,时间点不同;关注后续功能的稳定性,不完全是本次功能的稳定
- 统一代码风格
- 组件readme用例化,保持时效性
- 有效审视代码中的耦合逻辑
- 避免后续迭代影响历史功能,发布breaking change
技术选型:
原则:
- 开发成本低
- 不会频繁变更
- 上手快,学习成本低
前端单元测试:
- Jest
- 辅助库(DOM和事件的模拟):Enzyme(允许访问代码内部) React testing library(从用户角度展开测试)
比较复杂的场景:
E2E测试
Enzyme
// ./App.test.tsx
import { mount } from "enzyme";
import App from "./App";
describe("test", () => {
it("first unit test", () => {
const app = mount(<App />);
expect(app.find(".read-the-docs").getDOMNode().textContent).toEqual(
"Click on the Vite and React logos to learn more"
);
});
});
报错信息:
安装依赖
npm install --save-dev prop-types
React testing library
// ./src/App.test.tsx 测试DOM
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
技术框架
Jest + React testing library + Cypress + stroybook:
- 测试框架:Jest 提供一个可运行的环境、测试结构、结构报告、代码覆盖、断言、mocking、snapshot
- 测试辅助库:React testing library (DOM查询、断言和事件模拟、React18推荐的UI自动化的集成测试辅助库)
- Cypress + storybook:E2E端对端测试,覆盖单元不易于覆盖的复杂场景
如何进行自动化测试
配置
示例仓库: GitHub - czm1290433700/test_demo_for_config: 《前端自动化测试精讲》- 单测的相关配置示例
demo仓库:GitHub - czm1290433700/test_demo: 掘金小册《前端自动化测试精讲》教学代码
自动化测试的基本元素
import React from "react";
import { render, screen } from "@testing-library/react";
import { DomExpect } from "../components/DomExpect/index";
import { act } from "react-dom/test-utils";
describe("tests for 《6 | DOM断言:页面元素的断言》", () => {//模块
test("visible validation without semi", () => {//用例
render(<DomExpect />);//渲染元素
const emptyNote = screen.getByRole("generic", { name: "empty_note" });//查询
const [hiddenNote] = screen.getAllByRole("note", { hidden: true });
const normalNote = screen.getByRole("note");
expect(emptyNote).toBeEmptyDOMElement();//断言
expect(hiddenNote).not.toBeVisible();
expect(emptyNote).toBeInTheDocument();
expect(hiddenNote).toBeInTheDocument();
expect(normalNote).toBeInTheDocument();
expect(normalNote).toHaveTextContent(/1/i);
});
- 用例(一组待验证的逻辑)
- 模块(一类用例)
- 渲染元素
- 查询
- 断言 对查询元素的预期
查询
-
单查:getBy queryBy FindBy
-
多查:getAllBy queryAllBy FindAllBy
- Get: 用于查询正常存在的元素(找不到报错)
- Query: 用于查询希望不存在的元素(找不到返回null,不报错)
- Find: 则用于查询需要等待的异步元素,返回一个promise,默认超时时间为1000ms,如果没有元素匹配或查找超时,promise状态切为reject
ARIA
ARIA (Accessible Rich Internet Applications) 是一组属性,用于定义使残障人士更容易访问 Web 内容和 Web 应用程序(尤其是使用 JavaScript 开发的应用程序)的方法。
查询示例:
import React from "react";
import { render, screen } from "@testing-library/react";
import { DomQuery } from "../components/DomQuery";
describe("tests for 《4 | DOM查询(上):页面元素的渲染和行为查询》 & 《5 | DOM查询(下):页面元素的参照物查询和优先级》", () => {
test("get & query & find", () => {
render(<DomQuery />);
const getElement = screen.getByText("test1");
const getAllElement = screen.getAllByText(/test/i);
const queryElement = screen.queryByText("test3");
const queryAllElement = screen.queryAllByText("test3");
});
test("default role", () => {
render(<DomQuery />);
const button = screen.getAllByRole("button");
screen.debug(button);
});
断言
分为Jest和Jest DOM提供,一组判断当前元素是否符合某种预期的效果
-
Jest提供一系列基础断言,如toBe、not、toEqual等。 可参考: Getting Started · Jest (jestjs.io)
-
Jest DOM是React testing library 补充的一系列与DOM有关的特殊断言,可参考: GitHub - testing-library/jest-dom: Custom jest matchers to test the state of the DOM
import React, { FC, useState } from "react";
import { Form } from "@douyinfe/semi-ui";
interface IProps {}
// 《6 | DOM断言:页面元素的断言》
export const DomExpect: FC<IProps> = ({}) => {
const [semiFormValues, setSemiFormValues] = useState({
username: "zhenmin",
age: 23,
sex: "man",
hobby: "code",
});
import React from "react";
import { render, screen } from "@testing-library/react";
import { DomExpect } from "../components/DomExpect/index";
import { act } from "react-dom/test-utils";
describe("tests for 《6 | DOM断言:页面元素的断言》", () => {
test("visible validation without semi", () => {
render(<DomExpect />);
const emptyNote = screen.getByRole("generic", { name: "empty_note" });
const [hiddenNote] = screen.getAllByRole("note", { hidden: true });
const normalNote = screen.getByRole("note");
expect(emptyNote).toBeEmptyDOMElement();//断言
expect(hiddenNote).not.toBeVisible();
expect(emptyNote).toBeInTheDocument();//断言
expect(hiddenNote).toBeInTheDocument();
expect(normalNote).toBeInTheDocument();
expect(normalNote).toHaveTextContent(/1/i);
});
事件模拟:
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { DomEvent } from "../components/DomEvent";
import userEvent from "@testing-library/user-event";
describe("tests for 《7 | User-event: 怎么对 Dom 组件绑定事件进行模拟触发?》", () => {
test("mock events with fireEvent", () => {
const clickEvent = jest.fn();
render(<DomEvent onClick={clickEvent} />);
fireEvent.click(screen.getByRole("note"));
expect(clickEvent).toBeCalled();
expect(clickEvent).toBeCalledTimes(1);
});
test("mock events with userEvent", () => {
const clickEvent = jest.fn();
render(<DomEvent onClick={clickEvent} />);
userEvent.click(screen.getByRole("note"));
expect(clickEvent).toBeCalled();
expect(clickEvent).toBeCalledTimes(1);
});
});
//fireEvent中事件的实现,(所有事件通用,直接触发)
function fireEvent(element, event) {
return (0, _config.getConfig)().eventWrapper(() => {
if (!event) {
throw new Error(`Unable to fire an event - please provide an event object.`);
}
if (!element) {
throw new Error(`Unable to fire a "${event.type}" event - please provide a DOM element.`);
}
return element.dispatchEvent(event);
});
}
- fireEvent 调度对应事件
- userEvent 贴近真实事件模拟事件
异步
//定义一个组件,在500ms后完成加载,显示出"a demo for async test"的区域
import { FC, useEffect, useMemo, useState } from "react";
interface IProps {}
// 《8 | Async 异步:异步方法如何进行单测?》
export const DomAsync: FC<IProps> = ({}) => {
const [text, setText] = useState("");
const hasDescription = useMemo(() => {
return text !== "a demo for async test";
}, [text]);
useEffect(() => {
setTimeout(() => {
setText("a demo for async test");
}, 500);
}, []);
return (
<div>
<div>{text}</div>
{hasDescription && <div>加载中...</div>}
</div>
);
};
import React from "react";
import {
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import { DomAsync } from "../components/DomAsync";
// 8 | Async 异步:异步方法如何进行单测?
describe("examples for async", () => {
test("for jest", async () => {
const fetchData = async () => {
const res = await new Promise((resolve) =>
resolve("this is a demo for fetching data")
);
return res;
};
const data = await fetchData();
expect(data).toBe("this is a demo for fetching data");
await expect(fetchData()).resolves.toBe("this is a demo for fetching data");
// await expect(fetchData()).rejects.toBe('this is a demo for fetching data');
});
test("for react testing library", async () => {
render(<DomAsync />);
waitForElementToBeRemoved(screen.queryByText("加载中...")).then(() => {
console.log("元素加载完成");
});
const testDom = await screen.findByText("a demo for async test");
expect(testDom).toBeInTheDocument();
await waitFor(//waitfor API 自定义超时时间 or 特殊逻辑
() => {
const waitTestDom = screen.getByText("a demo for async test");
expect(waitTestDom).toBeInTheDocument();
},
{
timeout: 1000,
interval: 100,
}
);
});
});
findBy和getBy的不同点:
- 重复执行回调查找对应元素,直到超过默认1000ms的超时时间
- findBy只能固定查找元素,而且超时时间固定
React testing library提供一个waitfor的API可满足此场景。
export interface waitForOptions {
container?: HTMLElement
timeout?: number
interval?: number
onTimeout?: (error: Error) => Error
mutationObserverOptions?: MutationObserverInit
}
export function waitFor<T>(
callback: () => Promise<T> | T,
options?: waitForOptions,
): Promise<T>
快进定时任务
一些需要长时间执行的任务,需要通过“快进”的方式来完成
// 《9 | FakeTimer:如何"快进"测试定时任务?》
const sleep = async (time: number, result: string): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(result);
}, time);
});
};
const loopSleep = async (time: number, result: string): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(result);
setTimeout(() => {
loopSleep(time, result);
}, time);
}, time);
});
};
const asyncSleep = async (time: number, fn: () => void): Promise<void> => {
setTimeout(() => {
Promise.resolve().then(() => {
fn();
});
}, time);
};
export { sleep, loopSleep, asyncSleep };
import React from "react";
import { sleep, loopSleep, asyncSleep } from "../components/FakeTimer";
// 9 | FakeTimer:如何"快进"测试定时任务?
describe("examples for fakeTimers", () => {
beforeAll(() => {
jest.useFakeTimers();
});
test("a test for a simple setTimeout", async () => {
const res = sleep(6000, "this is a simple setTimeout test");
jest.runAllTimers();
await expect(res).resolves.toBe("this is a simple setTimeout test");
});
test("a test for a controllable setTimeout", async () => {
const res = sleep(6000, "this is a controllable setTimeout");
jest.advanceTimersByTime(6000);
await expect(res).resolves.toBe("this is a controllable setTimeout");
});
test("a test for a recursion setTimeout", async () => {
const res = loopSleep(6000, "this is a recursion setTimeout test");
// jest.runAllTimers();
jest.runOnlyPendingTimers();
await expect(res).resolves.toBe("this is a recursion setTimeout test");
});
test("a test for a setTimeout with async function", async () => {
const fn = jest.fn();
asyncSleep(6000, fn);
jest.runOnlyPendingTimers();
await Promise.resolve();
expect(fn).toBeCalled();
});
});
Mock
实际的业务场景中,一个文件中穿插着各种引用,其中包含着一些测试环境中没有的API或全局变量,或不在测试范围内的外部文件;这些现象都需要使用Mock来模拟它们来测试。
import React from "react";
import axios from "axios";
import mock from "../components/Mock";
import { mocked } from "jest-mock";
jest.mock("axios");
// 10 | Mock: 怎么替代不那么重要的逻辑?
describe("examples for mock", () => {
test("a test for global mock", async () => {
const res = "this is a test for global mock";
// axios.get.mockResolvedValue(res);
mocked(axios.get).mockResolvedValue(res);
const data = await axios.get("/");
expect(data).toBe("this is a test for global mock");
});
test("a test for single mock", () => {
jest.doMock("../components/Mock", () => ({
__esModule: true,
getMockData: () => {
return "newMockData";
},
}));
// expect(mock.getMockData()).toBe("newMockData");
const mock = require("../components/Mock");
expect(mock.getMockData()).toBe("newMockData");
});
test("other ways for single mock", () => {
jest.spyOn(mock, "getMockData").mockReturnValue("newMockData");
expect(mock.getMockData()).toBe("newMockData");
});
});
快照测试
每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。
典型的做法是在渲染了UI组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者UI组件已经更新到了新版本。
import React from "react";
import { render, screen } from "@testing-library/react";
import { DomSnap } from "../components/DomSnap";
// 12 | 快照测试:怎么保障组件 UI 的完整?
describe("examples for snap", () => {
test("a test for component snap", () => {
const { baseElement } = render(<DomSnap />);
expect(baseElement).toMatchSnapshot();
});
test("a test for part component snap", () => {
render(<DomSnap />);
expect(
screen.getByRole("textbox", { name: "form_username" })
).toMatchSnapshot();
});
test("a test for string snap", () => {
expect("a test for string snap1").toMatchSnapshot();
});
});
E2E
滚动跳转等难以使用单元测试的场景需要借助端对端覆盖。
E2E(end to end)端到端测试是最直观可以理解的测试类型。在前端应用程序中,端到端测试可以从用户的视角通过真实浏览器自动检查应用程序是否正常工作。
import { FC, useMemo, useState } from "react";
import "./styles.css";
interface IProps {
data: string[];
height: string | number;
pageSize?: number;
}
/**
* 滚动 list, 拉到底部刷新新的一页
* @param data
* @param height
* @returns
*/
export const ScrollList: FC<IProps> = ({ data, height, pageSize = 10 }) => {
const [page, setPage] = useState(1);
const currentData = useMemo(
() => data.slice(0, pageSize * page),
[pageSize, page]
);
return (
<div
className="scrollList"
style={{ height }}
onScroll={(e) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget;
if (
scrollTop + clientHeight >= scrollHeight &&
currentData.length < data.length
) {
alert(`当前page为${page}`);
setPage(page + 1);
}
}}
>
{currentData.map((item, index) => {
return (
<div className="item" key={index}>
{item}
</div>
);
})}
</div>
);
};
describe("tests for ScrollList", () => {
it("should render ", () => {
cy.visit("/iframe.html?id=example-scrolllist--list");
cy.get(".item").should("have.length", 3);
cy.get(".scrollList").scrollTo("bottom");
cy.get(".item").should("have.length", 6);
cy.get(".scrollList").scrollTo("bottom");
cy.get(".item").should("have.length", 9);
cy.get(".scrollList").scrollTo("bottom");
cy.get(".item").should("have.length", 10);
});
});
export {};
自动化测试的持续集成
覆盖率
测试覆盖率被用作衡量软件测试质量和有效性的指标。它是一种用于确定应用程序代码的测试覆盖率和测试运行时行使的代码量的方法,例如。如果有100个需求,为100个需求创建了100个测试,90个测试被执行,那么测试覆盖率为90%。
指标:
- statement:语句覆盖率,是否每一个语句都被执行
- branch:分支覆盖率,是否每一个if判断都被执行
- function:函数覆盖率,是否每一个函数都被执行
- line:行覆盖率,是否每一行都被执行
提升覆盖率
- 确保测试所有新代码。确保任何新代码都伴随着至少一个测试是提高测试覆盖率的最佳策略之一。这保证了测试正在使用新代码,并帮助在出现任何错误时立即识别它们。
- 使用工具进行代码覆盖。您可能会发现代码库的某些部分是否未使用各种方法进行测试。这些工具可以用作测试套件的组件,并提供显示测试代码比例的报告。Jest 和 Istanbul 是两种常见的 JavaScript 工具。
- 对于边缘情况,请创建测试。测试至关重要,不仅对于快乐的路线,而且对于一开始可能不明显的几种边缘情况也是如此。由于这一点,可能会发现原本可能被忽视的角落实例。
- 利用测试框架 Mocha、Jasmine 和 Jest 等测试框架提供的功能和断言使编写和组织测试变得更加简单。代码覆盖率工具通常包含在这些框架中,这使得在添加其他测试时跟踪测试覆盖率变得更加简单。
- 创建单元测试 单元测试是简短的独立测试,用于评估单个代码段,例如函数。编写一组单元测试可以更轻松地在问题发生时查找问题,并确保测试代码的所有组件。
持续集成
通过Git Actions 可以把用例的执行和覆盖率控制进项目的CI/CD