测试的目的是为了带给我们带来强大的代码信心,如果把测试初衷忘掉,会很容易掉入测试代码细节的陷阱。一旦关注点不是代码的信心,而是测试代码细节,那么测试用例会变得非常脆弱,难以维护。
代码信心的体现
什么是细节?使用你代码的人不会用到、看到、知道的东西。
那谁才是我们代码的用户呢?第一种就是跟页面交互的真实用户,第二种则是使用这些代码的开发者。对 React Component 组件来说,用户可以分为 End User 和 Developer,我们只需要关注这两者即可 。
接下来的问题就是:我们代码中的哪部分是这两类用户会看到、用到和知道的呢?
对 End User 来说,他们只会和 render 函数里的内容有交互,而 Developer 则会和组件传入的 Props 有交互。所以,我们的测试用例只和传入的 Props 以及输出内容的 render 函数进行交互就够了。
为抹平单测环境差异,节省各业务线接入成本,现提供单测接入脚手架工具,该工具包基于jest@29.6.3 @testing-library/react@12.1.5
npm i -D @liepin/js-jest4r-fe@beta
若在安装的过程报错,注意以下可能存在的问题:
V6工程目录下执行
npx jest4r setup4project
这将完成以下工作
cnpm包目录下执行
npx jest4r setup4package
这将完成以下工作
jest.config.js
@liepin/js-jest4r-fe 提供的默认配置如下,该预设内容在 @liepin/js-jest4r-fe/jest-preset.js 中
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
* https://zhuanlan.zhihu.com/p/535048414 详细字段作用说明
*/
module.exports = {
// 预设配置
preset: '@liepin/js-jest4r-fe',
// 生成覆盖率报告所存放的目录,苍穹会根据该目录配置读取覆盖率报告
coverageDirectory: '<rootDir>/tests/coverage-jest'
}
由于不同的工程的业务方向不同,导致每个工程或cnpm包都有自己的第三方依赖包集合,因此针对第三方包的编译规则有所不同,这里需要根据工程情况自行覆盖预设配置,比如:
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
* https://zhuanlan.zhihu.com/p/535048414 详细字段配置描述
*/
// 默认设置
const compileModules = ['@babel', '@ant-design', '@liepin', 'uuid'];
// 默认设置
const transformIgnorePatterns = [
// Ignore modules without es dir.
// Update: @babel/runtime should also be transformed
// 匹配以 /node_modules/ 开头,后面跟着一个不以 compileModules 开头的目录名,然后再跟着一个不以 es/ 开头的目录名。
`/node_modules/(?!${compileModules.join('|')})[^/]+?/(?!(es)/)`
];
module.exports = {
// 必须配置
preset: '@liepin/js-jest4r-fe',
// 生成覆盖率报告所存放的目录,苍穹会根据该目录配置读取覆盖率报告
coverageDirectory: '<rootDir>/tests/coverage-jest',
// 非必须配置
// transformIgnorePatterns这个配置项配置的是将一些文件忽略,不使用transform的转换器进行转换
// 如果遇到第三方包报错,可优先确认此配置
transformIgnorePatterns,
};
module.exports = (api) => {
const isTest = api.env('test');
if (!isTest) {
return {
presets: ['@tools/babel-preset']
};
}
return {
env: {
test: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-react',
'@babel/preset-typescript'
],
plugins: [
[
'@babel/plugin-proposal-decorators',
{
legacy: true
}
],
[
'@babel/plugin-proposal-class-properties',
{
loose: true
}
],
// 移除组件的 <style jsx></style> 标签内容
[
'@liepin/js-jest4r-fe/config/babel-plugin-remove-element',
{
elements: ['style']
}
]
]
}
}
};
};
如果在tsconfig.json配置中设置了 typeRoots 字段,需保证该字段包含 node_modules/@types,方可提供完整类型提示
"typeRoots": ["node_modules/@types", "其他类型文件位置"]
window.location
对象的库。它的主要作用是使你能够在测试代码中模拟修改和访问window.location
的行为,而无需实际在浏览器环境中执行。(已默认引入,不需要手动再次引入).spec.tsx
后缀。例如,如果组件的名称是 FormPublishBtn
,则文件名可以是 FormPublishBtn.spec.tsx
。.spec.tsx
后缀。例如,如果文件名是 Form.tsx
,则文件名可以是 Form.spec.tsx
。安装 VSCode Jest 运行插件
名称: Jest Runner ID: firsttris.vscode-jest-runner 说明: Simple way to run or debug a single (or multiple) tests from context-menu 版本: 0.4.69 发布者: firsttris VS Marketplace 链接: https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner
npm run test
基于测试结果生成测试报告如下:
注意:需关注控制台的警告或者报错信息,及时修复
在控制台中打印出当前的 DOM Tree。
describe("screen.debug", () => {
test("Print the current DOM Tree in the console", () => {
render(<SomeComponent />);
screen.debug(); // debug document
screen.debug(screen.getByText("test")); // debug single element
screen.debug(screen.getAllByText("multi-test")) // debug multiple elements
});
});
会在控制台中打印一个链接,点开它就可以在 testing-playground 中交互的调试。
testing-playground 是一个交互式的沙盒 (网页),你可以在其中用鼠标选择 DOM 节点,testing-playground 会告诉你查找此 DOM 节点的最佳查询规则。
describe("screen.logTestingPlaygroundURL", () => {
test("logTestingPlaygroundURL", () => {
render(<SomeComponent />);
// 控制台中会打印出来访问链接
screen.logTestingPlaygroundURL(); // log entire document to testing-playground
screen.logTestingPlaygroundURL(screen.getByText('test')); // log a single element
});
});
testing-playground 展示如下
添加断点
启动调试模式
开始调试
覆盖率收集来源
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.{spec,test}.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/stories/**',
'!src/**/__tests__/**'
]
覆盖率指标(试运行)
npm run test:coverage
注意:
为方便统计,需在miigo需求对应的任务中分类录入
或在任务管理中,进入单元测试报告界面查看
当行云流水线执行项目发布时,根据行云的门禁配置会自动执行项目的单元测试
和苍穹主动执行单测的区别是,苍穹主动执行单测只会执行单元测试,不执行项目发布,而行云会同时执行项目发布和单测
推荐使用 *ByRole 来获取元素,参考官方文档 Which query should I use?
// 假如现在我们有这样的 DOM:
// <button><span>Hello</span> <span>World</span></button>
screen.getByText(/hello world/i)
// ❌ 报错:
// Unable to find an element with the text: /hello world/i. This could be
// because the text is broken up by multiple elements. In this case, you can
// provide a function for your text matcher to make your matcher more flexible.
screen.getByRole('button', {name: /hello world/i})
// ✅ 成功!
其实大家不使用 *ByRole
做查询的原因之一是因为不熟悉在元素上的隐式 Role。这里大家可以参考 MDN,MDN 上有写这些元素上的 Role List,或者参考 “单测工具” 一节
import { render, screen } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import FormCard from './index';
describe('预发职位', () => {
test('预发职位初始化展示', () => {
const { getByText } = render(<FormCard />);
// 通过screen.debug()查看渲染出来的HTML DOM树是什么样的,在写测试代码前,先通过debug查看当前页面中可见的元素,再开始查询元素,这会有助于编写测试代码.
screen.debug();
expect(getByText('发 布')).toBeInTheDocument();
});
});
import { useState } from "react";
export interface Options {
min?: number;
max?: number;
}
export type ValueParam = number | ((c: number) => number);
function getTargetValue(val: number, options: Options = {}) {
const { min, max } = options;
let target = val;
if (typeof max === "number") {
target = Math.min(max, target);
}
if (typeof min === "number") {
target = Math.max(min, target);
}
return target;
}
function useCounter(initialValue = 0, options: Options = {}) {
const { min, max } = options;
const [current, setCurrent] = useState(() => {
return getTargetValue(initialValue, {
min,
max,
});
});
const setValue = (value: ValueParam) => {
setCurrent((c) => {
const target = typeof value === "number" ? value : value(c);
return getTargetValue(target, {
max,
min,
});
});
};
const inc = (delta = 1) => {
setValue((c) => c + delta);
};
const dec = (delta = 1) => {
setValue((c) => c - delta);
};
const set = (value: ValueParam) => {
setValue(value);
};
const reset = () => {
setValue(initialValue);
};
return [
current,
{
inc,
dec,
set,
reset,
},
] as const;
}
export default useCounter;
import { renderHook } from "@testing-library/react-hooks";
import useCounter from "@hooks/useCounter";
import { act } from "@testing-library/react";
describe("useCounter", () => {
test("可以做加法", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].inc(1);
});
expect(result.current[0]).toEqual(1);
});
test("可以做减法", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].dec(1);
});
expect(result.current[0]).toEqual(-1);
});
test("可以设置值", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].inc(10);
});
expect(result.current[0]).toEqual(10);
});
test("可以重置值", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].inc(1);
result.current[1].reset();
});
expect(result.current[0]).toEqual(0);
});
test("可以使用最大值", () => {
const { result } = renderHook(() => useCounter(100, { max: 10 }));
expect(result.current[0]).toEqual(10);
});
test("可以使用最小值", () => {
const { result } = renderHook(() => useCounter(0, { min: 10 }));
expect(result.current[0]).toEqual(10);
});
});
基于 mswjs Mock Http 请求,升级 @liepin/js-jest4r-fe 到最新 beta 版本即可支持,需重新执行对应的单测环境配置命令。手动安装需安装 msw@1.3.2的版本,msw@2.x版本要求nodejs@18 及以上、typescript@4.7及以上
import { rest } from 'msw';
import { lptGatewayDomain } from '../../Header/services/gatewayAxios';
import { userInfo } from './constants';
export const handlers = [
/** 获取用户信息 */
rest.post(`${lptGatewayDomain}/api/com.liepin.usere.bpc.get-user-base`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
flag: 1,
data: userInfo,
})
);
})
];
export const userInfo = {
groupId: 0,
usereAuthStatus: '1',
ecompServiceStatus: '1',
ecompName: '飞署柱般(昌都)有限责任公司',
ecompAuditFlagCode: '1',
photo: '//image0.lietou-static.com/normal/5f8f986bdfb13a7dee342f2108u.jpg',
showZctArticle: false,
avatarDecor: '//image0.lietou-static.com/img/605d9761e98efa22cd72659606u.png',
ecompFirstShortName: '飞署柱般',
vipAccountRoleCode: '2',
ecompRootId: '7853689',
ecompId: 7853689,
creditScore: 12,
roleKindCode: '2',
ejobManagerCode: '1',
hasPhoneNum: '1',
accountUsereIdEncode: 'cf9b21c458e54187ddc3763ececc39ac',
name: 'xxxx',
lastLoginTime: '2023-12-12',
};
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
import { server } from './server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import { rest } from 'msw';
import { Cookie } from '@liepin/js-bcore-pc';
import BHeader from '../Header';
import { EHeaderType } from '../Header/model';
import { server } from './mockServer/server';
import { userInfo } from './mockServer/constants';
import './mockServer/setupTests';
import { lptGatewayDomain } from '../Header/services/gatewayAxios';
describe('BHeader 导航', () => {
test('有logo 姓名的 static 导航类型', async () => {
/** 渲染组件会调用获取用户信息接口 */
render(<BHeader type={EHeaderType.STATIC} />);
await waitFor(() => {
expect(screen.getAllByText('xxxx').length).toBe(2);
expect(screen.getByText('安全退出')).toBeInTheDocument();
});
});
test('非管理员不展示企业管理Tab', async () => {
/** 覆盖预定义的接口 */
server.use(
rest.post(`${lptGatewayDomain}/api/com.liepin.usere.bpc.get-user-base`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...userInfo,
roleKindCode: '0',
})
);
})
);
const { container } = render(<BHeader type={EHeaderType.COMPLETE} selectTab="home" />);
await waitFor(() => {
expect(container.querySelector('[data-selector="company"]')).not.toBeInTheDocument();
});
});
});
import * as wxApis from '../../BLoginModal/services/wxApi';
// 这种方式设计到代码细节问题需避免使用,如果方法名 getWXSanqrAjax 变更将导致测试用例执行失败
jest.spyOn(wxApis, 'getWXSanqrAjax').mockResolvedValue({
flag: 1,
data: {
expireSeconds: 120,
qrKey: 'd9dcf50fed64deb45d87d57e78d62062',
qrLink:
'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQHi7jwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAyR0dOZ1FGdHhiT2Uxa0h6TWhCY1EAAgSzYnBlAwR4AAAA',
qrUrl: 'http://weixin.qq.com/q/02GGNgQFtxbOe1kHzMhBcQ',
},
});
import { render } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import FormCard from './index';
import { store } from '../../store'
describe('预发职位', () => {
test('预发职位初始化展示', () => {
// 模拟store方法,注意这种方法会涉及到代码细节问题,应避免使用,这里只做示意
jest.spyOn(store, 'handleFormInstance');
const { getByText } = render(<FormCard />);
expect(getByText('发 布')).toBeInTheDocument();
expect(store.handleFormInstance).toBeCalledTimes(1);
});
});
import { render } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { saveAgreementLog } from '@common/js/utils/safetyAgreement';
import { asyncThrowError } from '@common/js/utils';
import { store } from '../../store';
import FormPublishBtn from './index';
// 这种mock方式需要团队内评审,因为当store中新增方法时,此处mock也需要同步修改,否则可能导致报错:store下方法找不到
jest.mock('../../store', () => {
const store = {
formInstance: {
validateFields: jest.fn().mockResolvedValue(Promise.resolve({})),
scrollToField: jest.fn()
},
submitFormData: jest.fn()
};
return {
__esModule: true,
store,
default: () => store
};
});
jest.mock('@common/js/utils/safetyAgreement', () => {
return {
saveAgreementLog: jest.fn()
};
});
jest.mock('@common/js/utils', () => ({
asyncThrowError: jest.fn()
}));
describe('预发职位-发布操作', () => {
test('展示发布按钮,提交表单', () => {
const { getByText } = render(<FormPublishBtn />);
expect(getByText('发 布')).toBeInTheDocument();
});
test('点击发布按钮', async () => {
const user = userEvent.setup();
const { getByText } = render(<FormPublishBtn />);
const button = getByText('发 布');
await user.click(button);
expect(store.formInstance!.validateFields).toHaveBeenCalled();
expect(saveAgreementLog).toHaveBeenCalledWith({
sceneTypeCode: 'S0001',
confirmMode: 1,
agreementTypes: ['A0003']
})
expect(store.submitFormData).toHaveBeenCalled();
});
test('校验失败', async () => {
const user = userEvent.setup();
(store.formInstance!.validateFields as jest.Mock).mockRejectedValueOnce({
errorFields: [{ name: ['errorField'] }]
});
const { getByText } = render(<FormPublishBtn />);
const button = getByText('发 布');
await user.click(button);
expect(asyncThrowError).toHaveBeenCalled();
expect(store.formInstance!.scrollToField).toHaveBeenCalledWith('errorField', {
behavior: 'smooth'
});
});
});
重点在于对 Form.useForm() 的处理,其返回值包含了Form组件数据管理相关方法。
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { Form } from 'antd';
import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import moment from 'moment';
import DuomianMobileModal from '../LPTVideoInterview/compontents/DuomianMobileModal';
import * as apis from '../LPTVideoInterview/service';
describe('视频面试弹窗', () => {
beforeEach(() => {
// 接口mock参考接口测试一节内容介绍,这里只是快速示意
jest.spyOn(apis, 'createVideoInterviewAjax').mockResolvedValue({
flag: 1,
data: {
usercName: '招工组',
compName: '猎聘测测',
curJobPosition: '测试工程师',
portrait: '',
ejobId: '1',
oppositeImId: '',
},
});
jest.spyOn(apis, 'createInterviewAjax').mockResolvedValue({
flag: 1,
data: null,
});
});
test('打开视频面试弹窗', async () => {
render(<DuomianMobileModal />);
expect(apis.createVideoInterviewAjax).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.getByText('发送面试邀请')).toBeInTheDocument();
});
});
test('表单提交', async () => {
const user = userEvent.setup();
// 创建一个真实的表单数据管理实例,由于 useForm 是一个hook方法,因此这里借助 renderHook 方法,详见React Hook 测试
const { result } = renderHook(() => Form.useForm());
jest.spyOn(Form, 'useForm').mockImplementation(() => result.current);
render(<DuomianMobileModal />);
let btnEle;
await waitFor(() => {
btnEle = screen.getByText('发送面试邀请');
});
result.current[0].setFieldsValue({
job: '1',
date: moment('2023-09-23'),
time: moment('2023-09-23 8:30'),
});
await user.click(btnEle);
expect(btnEle).toBeInTheDocument();
expect(apis.createInterviewAjax).toHaveBeenCalledWith({
oppositeImId: '',
inputInfo: JSON.stringify({
ejobId: '1',
interviewTime: `${moment('2023-09-23').format('YYYYMMDD')}${moment('2023-09-23 8:30').format('HHmm')}00`,
}),
});
});
});
快照测试的基本理念:先保存一份副本文件,下次测试时把当前输出和上次副本文件对比就知道此次改动是否破坏了某些东西。
应避免UI快照过大,不要无脑地记录整个组件的快照,特别是有别的 UI 组件参与其中的时候(比如antd多层级组件,将会使快照文件过于庞大,另外快照中杂揉了 antd
的 DOM 结构后,快照变得非常难读)。
解决方案是:不要把无关的 DOM 记录到快照里,只记录我们想要的DOM结构就好
const Title: FC<Props> = (props) => {
const { title, type } = props;
return (
<Row style={styleMapper[type]}>
<Col>
第一个 Col
</Col>
<Col>
<div>{title}</div>
</Col>
</Row>
)
};
describe("Title", () => {
test("可以正确渲染大字", () => {
const { getByText } = render(<Title type="large" title="大字" />);
const content = getByText('大字');
expect(content).toMatchSnapshot();
});
test("可以正确渲染小字", () => {
const { getByText } = render(<Title type="small" title="小字" />);
const content = getByText('小字');
expect(content).toMatchSnapshot();
});
});
exports[`Title 可以正确渲染大字 1`] = `
<div>
大字
</div>
`;
exports[`Title 可以正确渲染小字 1`] = `
<div>
小字
</div>
`;
// after1000ms.ts
type AnyFunction = (...args: any[]) => any;
const after1000ms = (callback?: AnyFunction) => {
console.log("准备计时");
setTimeout(() => {
console.log("午时已到");
callback && callback();
}, 1000);
};
export default after1000ms;
import after1000ms from "utils/after1000ms";
describe("after1000ms", () => {
beforeAll(() => {
jest.useFakeTimers();
});
test("可以在 1000ms 后自动执行函数", () => {
jest.spyOn(global, "setTimeout");
const callback = jest.fn();
expect(callback).not.toHaveBeenCalled();
after1000ms(callback);
jest.runAllTimers();
expect(callback).toHaveBeenCalled();
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000);
});
});
describe("网页地址的Mock", () => {
test("可以获取当前网址的查询参数对象", () => {
// 使用 jest-location-mock (本包配置中已配置)。 这种方法会监听 window.location.assign,通过它来改变网页地址。
window.location.assign('https://www.baidu.com?a=1&b=2');
expect(window.location.search).toEqual("?a=1&b=2");
expect(getSearchObj()).toEqual({
a: "1",
b: "2",
});
});
test("空参数返回空", () => {
window.location.assign('https://www.baidu.com');
expect(window.location.search).toEqual("");
expect(getSearchObj()).toEqual({});
});
});
jest
这些类型的,所以会报以下错误:import axios from 'axios';
import Users from './users';
jest.mock('axios');
test('should fetch users', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
// TS2339: Property 'mockResolveValues' does not exist on type ' >(url: string, config?: AxiosRequestConfig | undefined) => Promise '.
axios.get.mockResolvedValue(resp);
// 你也可以使用下面这样的方式:
// axios.get.mockImplementation(() => Promise.resolve(resp))
return Users.all().then(data => expect(data).toEqual(users));
});
解决方法一:推荐
jest.spyOn(axios, 'get').mockResolvedValue(resp);
// 你也可以使用下面这样的方式:
// jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(resp))
解决方法二
import { mocked } from 'jest-mock';
const mockedGet = mocked(axios.get); // 带上 jest 的类型提示
mockedGet.mockResolvedValue(resp); // 含有 jest 的类型提示
这种情况通常是由于在一组测试用例中,前一个测试用例没有正确地清理或重置测试环境,导致后续的测试无法找到期望的元素或状态。为了解决这个问题,可以尝试从以下几点入手:
beforeEach
函数或 beforeAll
函数在每个测试用例开始之前进行初始化设置。这样可以确保每个测试用例都在相同的初始状态下运行,并且没有残留的状态或影响。afterEach
函数或 afterAll
函数来清理测试环境。这样可以确保每个测试用例完成后,不会留下任何对后续测试用例有影响的状态。await
关键字或适当的异步测试工具(如 waitFor
)来等待异步操作的完成。console.error node_modules/react-dom/cjs/react-dom.development.js:530
Warning: An update to UsernameForm inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
in UsernameForm
import { render, screen, act } from '@testing-library/react';
act
是一个用于处理 React 组件的异步更新和副作用的工具函数,它的主要作用是确保在测试中正确地触发和等待组件更新。
act
的使用场景如下:
act
来确保组件在更新后进行正确的断言。setTimeout
、Promise
等)时,可以使用 act
来等待异步操作完成后再进行断言。// ❌
const submitButton = await waitFor(() =>
screen.getByRole('button', {name: /submit/i}),
)
// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})
上面两段代码几乎是等价的(find*
其实也是在内部用了 waitFor
),但是第二种使用方法更清晰,而且抛出的错误信息会更友好。
// ❌
await waitFor(() => {
fireEvent.keyDown(input, {key: 'ArrowDown'})
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
// ✅
fireEvent.keyDown(input, {key: 'ArrowDown'})
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
waitFor 适用的情况是:在执行的操作和断言之间存在不确定的时间量。因此,callback 可在不确定的时间和频率(在间隔以及 DOM 变化时调用)被调用(或者检查错误)。所以这也意味着你的副作用可能会被多次调用!
建议:
- 把副作用放在 waitFor 回调的外面,回调里只能有断言
- waitFor 的 callback 里只放一个断言
import React from 'react';
function Test() {
return (
<>
<span>test 数据</span>
<style jsx>{`
.c-highlight {
color: #ff6400;
@media print {
color: inherit;
background: inherit;
}
:global(&.c-keyword-active) {
background-color: #ff7c2d;
color: #fff;
}
}
`}</style>
</>
);
}
export default Test;
报错如下:
解决方法在配置babel环境一节,需添加对应babel插件
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。