测试规范
测试工具的选择
在本项目中以 ant design pro
项目为测试项目,测试工具最终选型为
- jest
javascript
的常用测试框架 - enzyme 支持
react
的jest测试库 - react-test-renderer 快照测试的常用测试库
- puppeteer “布偶人” – E2E测试工具
上述工具除react-test-render
外,在ant design pro
项目中都已内置
jest的初体验
安装jest
npm i jest
或 yarn add jest
jest 官方推荐使用 yarn 工具
创建sum.js样本文件
function sum(a, b) {
return a + b;
}
module.exports = sum;
创建测试文件sum.test.js
const sum = require('./sum');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
在package.json中添加要执行的命令
{
"scripts": {
"test": "jest"
}
}
执行jest
npm run test
或 yarn test
- jest 默认遵循 AMD 规范,import 等语法会导致测试不通过。
- 为满足测试的需求,ES6、JSX等语法支持可通过手动配置babel支持。
匹配器
在上述代码中 .toBe 这个精准匹配测试结果的语法叫做匹配器。匹配器主要有以下几种:
普通匹配器
- toBe 精确相等
- not.toBe 相反
Truthiness(译:真实性。即为特殊类型匹配器)**
-
toBeNull
只匹配null
-
toBeUndefined
只匹配undefined
-
toBeDefined
与toBeUndefined
相反 -
toBeTruthy
匹配任何if
语句为真 -
toBeFalsy
匹配任何if
语句为假
数字匹配器
-
toEqual 等于
-
toBeGreaterThan 大于
-
toBeGreaterThanOrEqual 大于等于
-
toBeLessThan 小于
-
toBeLessThanOrEqual 小于等于
-
toBeCloseTo toBe的浮点数用法
字符串(可使用正则表达式)
- toMatch 精确匹配
- not.toMatch 相反
Arrays and iterables(数组和可迭代对象)
- toContain 是否包含
其他
- toThrow 是否抛出了错误
执行异步代码
回调函数
test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
Jest会等done
回调函数执行结束后,结束测试。若 done()
函数从未被调用,测试用例会=执行失败(显示超时错误)。若 expect
执行失败,它会抛出一个错误,后面的 done()
不再执行。
Promises
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
Async/Await
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
});
Jest的代码执行顺序
beforeEach、afterEach
在每一次测试的前后执行的方法
beforeAll、afterAll
在文件执行的前后执行的方法(只执行一次)
describe
describe会将测试文件分组,先执行每一个describe,再去执行内部的test
**如果测试失败,第一件要检查的事就是,当仅运行这条测试时,它是否仍然失败。
其他食用方法可查看jest官方文档
AntdPro中的单元测试
AntdPro项目测试介绍
这是一个标准AntdPro项目的文件结构,其中e2e
tests
目录即为测试文件,tests
内包含一些测试前的基本配置和环境检测,例如浏览器检测、puppeteer插件检测、测试语法环境检测和项目测试入口文件。
e2e
目录结构如下,其中包含了一个E2E的测试用例,以及一个空less文件的mock。
E2E测试:即端到端测试(End To End),也叫冒烟测试,用于测试真实浏览器环境下前端应用的流程和表现,相当于代替人工去操作应用。
自定义jest单元测试
由于现有的项目文件夹下,单元测试文件是直接放在同级的组件下的,为了能形成统一,我们遵循jest规范在src
目录下新建一个__test__
目录用于单元测试。因为单元测试包括项目中的公共组件、页面组件以及公用第三方函数,所以我们在__test__
下继续新建目录,现在的目录结构如下:
分别对应各自的测试功能。
为了测试的简洁,在单元测试中我们引入enzyme
测试库,并添加React16的语法支持。
// enzyme的React16语法支持
const Enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');
Enzyme.configure({ adapter: new Adapter() });
因为此段配置需要在测试之前完成,我们可以在__test__
下新建setup.js
文件,用来做一些测试的初始化配置。另大家也可以在tests/beforeTest.js
或其他测试之前的文件下添加上述配置。
并且在项目根目录jest.config.js
下添加启动文件,很多的单元测试配置都会在这个文件下完成。
// jest.config.js
module.exports = {
testURL: 'http://localhost:8000',
setupFilesAfterEnv: ["./src/__test__/setup.js"], // 添加启动文件
testEnvironment: './tests/PuppeteerEnvironment',
verbose: false,
globals: {
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: false,
localStorage: null,
},
};
最后,也是最关键的一点,
- 安装
jest-environment-jsdom
- 修改
tests/PuppeteerEnvironment.js
文件
// eslint-disable-next-line
// const NodeEnvironment = require('jest-environment-node'); // 注销
const NodeEnvironment = require('jest-environment-jsdom'); // 添加
const getBrowser = require('./getBrowser');
class PuppeteerEnvironment extends NodeEnvironment {
// Jest is not available here, so we have to reverse engineer
// the setTimeout function, see https://github.com/facebook/jest/blob/v23.1.0/packages/jest-runtime/src/index.js#L823
setTimeout(timeout) {
if (this.global.jasmine) {
// eslint-disable-next-line no-underscore-dangle
this.global.jasmine.DEFAULT_TIMEOUT_INTERVAL = timeout;
} else {
this.global[Symbol.for('TEST_TIMEOUT_SYMBOL')] = timeout;
}
}
async setup() {
const browser = await getBrowser();
const page = await browser.newPage();
this.global.browser = browser;
this.global.page = page;
}
async teardown() {
const { page, browser } = this.global;
if (page) {
await page.close();
}
if (browser) {
await browser.disconnect();
}
if (browser) {
await browser.close();
}
}
}
module.exports = PuppeteerEnvironment;
这一步的目的是更换当前的测试环境,因为在jest.config.js
配置中指定了这个文件为测试环境的执行文件,但它默认是为node
环境,使用它进行测试会出现一些意料之外的错误,具体原因我还未得知,如有知道的小伙伴可以在本帖下回复,一同讨论。
公用组件测试
由于公用组件高度封装的特性,很少会涉及页面逻辑的部分,比较适合我们的第一个单元测试。我的公用测试组件为MyTableWithSear
,所以在compontents
新建一个名为compontents.test.jsx
的测试文件。
测试文件的名称可跟随自己喜好,一般的测试文件都会以
.test.js(x)
.test.ts(x)
.spec.js
为后缀
// MyTableWithSear 组件
/***
* @param {children },
* @param {mySear} 表格搜索栏,
* @param {expandtop} 表格扩展栏,
* @param {breadroutes} 头部面包屑
* @return {Element} 返回内容
* <PageHeaderWrapper breadcrumb={breadcfg}>
* <div className={`${styles.board} test-mytablewithsear-board`} style={{ boxSizing: 'border-box', padding: '20px 10px' }}>
* {expandtop}
* {mySear ? <div className={styles.searchBox}>
* {mySear}
* </div> : null}
* {children}
* </div>
* </PageHeaderWrapper>
*/
接下来我们就可以根据上面的组件详情来书写相应的组件测试用例了。首先检测一下 children
参数情况。
import React from 'react'
// 快照测试第三方包
import renderer from 'react-test-renderer';
// 待测试组件
import MyTableWithSear from '../../components/MyTableWithSear/index'
// shallow 浅渲染 mount 深层渲染
import { mount } from 'enzyme';
describe('测试组件:MyTableWithSear', () => {
it('children参数预期检测', () => {
const children = <div className="children">子元素</div>
const wrapper = mount(<MyTableWithSear>{children}</MyTableWithSear>);
expect(wrapper.find('.children').length).toBe(1);
expect(wrapper.find('.children').text()).toEqual("子元素");
const children1 = "子元素二"
const wrapper1 = mount(<MyTableWithSear>{children1}</MyTableWithSear>);
expect(wrapper1.find('MyTableWithSear').text()).toEqual("子元素二");
const children2 = 3
const wrapper2 = mount(<MyTableWithSear>{children2}</MyTableWithSear>);
expect(wrapper2.find('MyTableWithSear').text()).toBe("3");
const children3 = null
const wrapper3 = mount(<MyTableWithSear>{children3}</MyTableWithSear>);
expect(wrapper3.find('MyTableWithSear')).toEqual({});
})
}
以上即为children
的参数检测用例,组件的其他参数测试可依此进行。
最后运行一下这个测试文件,npm test
(命令在antdpro项目中已内置,无需手动添加)
一些涉及到标签的测试用例可能会受到外层标签的影响,这时我们通常可以通过
- 手动构建一个外层环境
- 更换测试用例的标签
两种方法解决。在
MyTableWithSear
组件breadroutes
参数就会存在此问题,因外层没有Route
包裹Link
导致出错,可以通过将Link
换成普通a
标签解决此问题。
快照测试
每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。
目前提供快照功能的测试库有很多,经过一些比较和项目的需要,最终我选择了react-test-renderer
作为快照测试库,各位也可根据需要自行选择。同样我们还是用MyTableWithSear
组件作为快照测试的例子。
// 快照测试 -- 首次运行,会在 ./snapshot 下生成快照静态文件,每次运行会对比结果是否与上次一致
describe('测试组件:MyTableWithSear', () => {
it('快照测试', () => {
const wrapper = renderer.create(<MyTableWithSear />).toJSON()
expect(wrapper).toMatchSnapshot()
})
})
快照测试的语法相对简单,但作用不容小觑,关于快照测试的其他用法大家也可以自行了解,这里不做详细描述。
页面组件测试
页面组件的单元测试可能与公用组件的测试方法稍有不同,因为页面组件可能会涉及到业务逻辑与redux连接,特别是在AntdPro项目中,umi+dva
的模式已经将许多工具都进行了再封装,使得测试难度变得很大。
首先介绍一下用来作为例子的页面组件UserManage.jsx
// UserManage.jsx
import React from 'react'
import { Form, Input, message, Space, Popconfirm, Select, Row, Col, Table, Button, Radio, DatePicker, Switch, Card, Modal } from 'antd'
import MyTableWithSear from '../../components/MyTableWithSear'
import { EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import moment from 'moment'
import { connect } from 'umi'
import styles from './index.less'
const { RangePicker } = DatePicker;
const { Option } = Select;
@connect(({ usermanage, loading }) => ({
listData: usermanage,
loadingData: loading.effects['usermanage/getlist'],
}))
class UserManage extends React.Component {
editFormRef = React.createRef();
searFormRef = React.createRef();
constructor(props) {
super(props)
this.state = {
pagination: {},
data: null,
addModalShow: false,
highDangerShow: false,
editModalShow: false,
curEditObj: {},
}
}
UNSAFE_componentWillMount() {
this.getList()
}
UNSAFE_componentWillReceiveProps() {
const { listData } = this.props
const { list, pagination, } = listData
this.setState({
data: list,
pagination
})
}
onPageChange(page) {
const { dispatch } = this.props
let { pagination } = this.state
pagination.current = page.current
pagination.pageSize = page.pageSize
dispatch({
type: "usermanage/pageChange",
payload: pagination
})
}
getList() {
const { dispatch } = this.props
const { current, pageSize } = this.state.pagination
dispatch({
type: "usermanage/getlist",
payload: { pageSize: pageSize || 10, pageNum: current || 1 }
})
}
openAddModal() {
this.setState({
addModalShow: true
})
}
switchState(row, state) {
{/* TODO */}
}
renderAddModal() {
const { addModalShow, typeBox } = this.state
return (
<Modal
width="60%"
className={styles.addModal}
visible={addModalShow}
onOk={() => this.setState({ addModalShow: false })}
onCancel={() => this.setState({ addModalShow: false })}
>
{/* TODO */}
</Modal>
)
}
renderEditModal() {
const { editModalShow, typeBox } = this.state
return (
<Modal
width="60%"
className={styles.addModal}
visible={editModalShow}
onOk={() => this.setState({ editModalShow: false })}
onCancel={() => this.setState({ editModalShow: false })}
>
{/* TODO */}
</Modal>
)
}
openEditModal(row) {
if (this.editFormRef.current) {
this.editFormRef.current.setFieldsValue(row);
}
this.setState({ curEditObj: row, editModalShow: true });
}
renderHighDangerModal() {
const { highDangerShow } = this.state
const columns = [
{/* TODO */}
]
return (
<Modal
width="60%"
visible={highDangerShow}
onOk={() => this.setState({ highDangerShow: false })}
onCancel={() => this.setState({ highDangerShow: false })}
>
{/* TODO */}
</Modal>
)
}
renderSearBox() {
const { dispatch } = this.props
const { current, pageSize } = this.state.pagination
const search = () => {
dispatch({
type: "usermanage/getlist",
payload: { pageSize: pageSize || 10, pageNum: current || 1, ...this.searFormRef.current.getFieldsValue() }
})
}
return (
<Form
layout="inline"
ref={this.searFormRef}
>
{/* TODO */}
</Form>
)
}
render() {
let { loadingData } = this.props
const { pagination, data } = this.state
const columns = [
{/* TODO */}
]
return (
<MyTableWithSear mySear={this.renderSearBox()}>
<Table loading={loadingData} pagination={pagination} columns={columns} dataSource={data} scroll={{ x: 2000 }} />
{this.renderAddModal()}
{this.renderEditModal()}
{this.renderHighDangerModal()}
</MyTableWithSear>
)
}
}
export default UserManage
这个页面组件的信息是不全面的,一些项目敏感信息已删除,但是仍能看出逻辑。
在我看来,页面组件的单元测试无非三件事:
- 利用各种选择器找标签
- 测试子组件内部的逻辑函数
- 快照测试
但是在这之前我们得先将测试组件构建出来,我试过用这种最常见的方法去构建
// UserManage.test.js
import React from 'react';
import { mount } from 'enzyme';
import UserManage from '../../pages/UserManage/index'
describe('测试组件:UserManage', () => {
it('基础标签检测', () => {
const wrapper = mount(< UserManage />)
// TESTS
}
}
接着在后面写了一些测试用例,然后就报错了 …
TypeError: (0 , _umi.connect) is not a function
这是错误提示,在上面已经说过umi+dva
已经将redux
进行了再封装(项目根目录下的.umi
文件夹下可查看封装细节),所以从umi
引入的connect
单纯的用作单元测试上会产生umi的依赖问题(描述的不清晰,也可能是我的理解不够深刻)。在GitHub上也有一篇关于这个问题的文章,文章详情。很遗憾,里面提供的解决方案比较少,且对我的项目不起作用,各位也可以自行尝试。
然后我找到了另一种解决办法:
-
修改页面组件的
connct
方法的来源,使用react-redux
下的connect
方法// UserManage.jsx // ... -- import { connect } from 'umi' ++ import { connect } from 'react-redux' // ...
-
在测试用例中构建
redux
中定义的state
,用参数的形式传到组件内import React from 'react'; import { mount } from 'enzyme'; import renderer from 'react-test-renderer'; import UserManage from '../../pages/UserManage/index' let listData = { pagination: { current: 1, hideOnSinglePage: true, pageSize: 10, total: 100, }, list: [{ cardnum: 36, clientname: "掷夆刚", dangerrecord: 5, industrytype: 0, key: 0, markstate: 0, mobilenum: "11193608363", proposaltime: 1599543986241, remark: "僛刯惘仭嗁奨控壦寠业", state: 0, userbalance: 9732, username: "撏忞嬐", }] } const dispatch = () => { } describe('测试组件:UserManage', () => { it('基础标签检测', () => { const wrapper = mount(< UserManage dispatch={dispatch} listData={listData} />); // TESTS } }
-
在setup.js上加一段内容(如果未出现类似报错可不加)
// setup.js // 解决报错 TypeError: window.matchMedia is not a function Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: jest.fn(), // deprecated removeListener: jest.fn(), // deprecated addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), })), });
这种方法勉强解决了当下测试上的问题,也是我当前正在使用的方案,但是它还是没有能直接连接redux,只是用了一种虚拟数据的方式,绕过了测试的错误检测机制。
有的小伙伴会提出在组件的外部自构建
<Provider></Provider>
的方式,但是这种方式在AntdPro项目中会很复杂,这还是因为dva
封装后的redux
的书写方式与传统的redux
书写方式是不一样的,如果按这种方式去测试的话,无异于将项目的redux
部分重写一遍。(仅代表个人观点)既然勉强将测试组件构建出来了,那接下来就可以对组件进行测试了,测试的步骤在上述也已经提到过,主要就是核验标签、测试功能函数和快照测试。下面贴上我的
UserManage.test.js
的测试代码。// UserManage.test.js import React from 'react'; import { mount } from 'enzyme'; import renderer from 'react-test-renderer'; import UserManage from '../../pages/UserManage/index' let listData = { pagination: { current: 1, hideOnSinglePage: true, pageSize: 10, total: 100, }, list: [{ cardnum: 36, clientname: "掷夆刚", dangerrecord: 5, industrytype: 0, key: 0, markstate: 0, mobilenum: "11193608363", proposaltime: 1599543986241, remark: "僛刯惘仭嗁奨控壦寠业", state: 0, userbalance: 9732, username: "撏忞嬐", }] } const dispatch = () => { } let wrapper let wrapperSnap beforeAll(() => { wrapper = mount(< UserManage.WrappedComponent dispatch={dispatch} listData={listData} />); wrapperSnap = renderer.create(< UserManage.WrappedComponent dispatch={dispatch} listData={listData} />).toJSON(); }) describe('测试组件:UserManage', () => { it('基础标签检测', () => { // 大容器 expect(wrapper.find('.ant-pro-page-container').length).toBe(1); // 搜索框内容检测 expect(wrapper.find('.test-mytablewithsear-board').length).toBe(1); expect(wrapper.find('.test-mytablewithsear-board .ant-form .ant-row').length).toBe(6); // 表格内容检测 expect(wrapper.find('.test-main-table .ant-table table thead tr th').length).toBe(12); // 一条数据的表格 tbody 内的 tr 为两条,antd 表格自封装一条 tr // wrapper.find('.test-main-table .ant-table table tbody tr').length = 2 expect(wrapper.find('.test-main-table .ant-table table tbody tr').length).toBeLessThanOrEqual(10); expect(wrapper.find('Table').length).toBe(2); }) it('openAddModal检测', () => { wrapper.instance().openAddModal() let addModalShow = wrapper.state().addModalShow expect(addModalShow).toBeTruthy() }) it('分页检测', () => { let page = { current: 10, pageSize: 10 } wrapper.instance().onPageChange(page) let pagination = wrapper.state().pagination expect(pagination).toMatchObject(page) }) }) describe('测试组件:UserManage', () => { it('快照测试', () => { expect(wrapperSnap).toMatchSnapshot() }) })
wrapper
下的许多方法可直接访问组件下的属性,这一点是做组件内功能函数测试的关键。然后执行一遍测试用例
到此,页面组件的单元测试就基本完成了,尽管它在dva
的表现上不那么完美,但好在基本的测试功能确实是能达到的。如果有更好解决方案的小伙伴也可在文章下方留言哦。
第三方函数测试
第三方函数的测试应该是比较简单的一种了,测试文件可放在./src/__test__/untils
下,而且在原有的项目./src/utils
目录下已经有一个名为untils.test.js
的第三方函数测试文件,测试的用例流程也很全面,各位可以按照格式构建自己的测试即可。
以上就是单元测试的全部内容,单元测试在前端测试中占的比重较高,而且在AntdPro项目中的测试覆盖率也是以单元测试在代码中所涉及的行数去计算的,所以单元测试的内容在前端测试中显得尤为重要。
E2E测试
在AntdPro项目中,umi
已经为我们搭建了E2E测试环境了,其默认的E2E测试工具为puppeteer
,puppeteer语法与emzyme
有相似之处,如果只用于写E2E测试的话,上手比较容易,关于puppeteer的语法请点击puppeteer文档。
下面我会用puppeteer
写一个关于网站登录的E2E测试用例,在这之前,为了更好的观察E2E测试的执行过程,我们可以修改一下./tests/getBrowser.js
下的配置
// getBrowser.js
/* eslint-disable global-require */
/* eslint-disable import/no-extraneous-dependencies */
const findChrome = require('carlo/lib/find_chrome');
const getBrowser = async () => {
try {
// eslint-disable-next-line import/no-unresolved
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
headless: false, // *********在这里新增参数**********
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-zygote',
'--no-sandbox',
],
});
return browser;
} catch (error) {
// console.log(error)
}
try {
// eslint-disable-next-line import/no-unresolved
const puppeteer = require('puppeteer-core');
const findChromePath = await findChrome({});
const { executablePath } = findChromePath;
const browser = await puppeteer.launch({
executablePath,
headless: false, // *********在这里新增参数**********
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-zygote',
'--no-sandbox',
],
});
return browser;
} catch (error) {
console.log('???? no find chrome');
}
throw new Error('no find puppeteer');
};
module.exports = getBrowser;
这里的修改是为了在E2E文件执行期间我们可以观察到浏览器内部的测试细节,便于我们调整测试走向,当然在正式测试时因关闭此项配置。
// UserManage.e2e.js
// regeneratorRuntime 引入运行时
const regeneratorRuntime = require('regenerator-runtime/runtime.js')
const { getTestUrl } = require('../../../utils/test-config.js')
beforeEach(async() => {
await page.setViewport({ width: document.body.clientWidth, height: document.body.clientHeight });
await page.goto(getTestUrl());
await page.evaluate(() => {
// 设置localStroge,绕过登录检测
// localStorage.setItem('antd-pro-authority', '["admin"]');
// localStorage.setItem('userInfo', JSON.stringify({ userid: "00000001" }));
});
});
describe("用户管理页面e2e测试", () => {
it('e2e登录', async() => {
await page.goto(getTestUrl("/login"))
await page.waitFor("form.ant-form")
// await page.waitFor(3000)
await page.type('#userName', 'admin')
await page.type('#password', '123456')
await page.click('button[type=submit]')
let loginRes
await page.on('requestfinished', async request => {
if (request.url().endsWith('/login/account')) {
let res = await request.response().json()
loginRes = res
}
}).waitFor(2000);
// 添加延时以便我们能看清操作
await page.waitFor(3000)
expect(loginRes.status).toEqual("ok")
}, 15000)
}
这一段就是测试用户登录流程的E2E代码了,getTestUrl
是我自封装的函数,它会根据传入参数返回测试地址。
在这个测试中,首先进入登陆页面,然后陆续填充用户名密码,点击提交按钮,随后监听请求的返回结果,核验返回的内容即完成本个测试。
一些注意事项:
- 当我们在本地运行
npm test
时,应确保测试地址是可用的,否则E2E会直接报错。你也可以运行npm test:all
命令,它会自动帮你启动项目,并完成所有的测试工作。但是采用这种方式要记得在测试完毕后关闭命令行窗口,这样程序才能回收测试残留的子进程,防止资源占用。- E2E测试还有一个前提条件是本地环境必须安装有chrome内核,因为
puppeteer
是基于chrome浏览器运行的。- 在打开页面之前,如果想向浏览器LocalStroge写入一些内容时,应事先允许浏览器使用第三方cookie。
总结
以上就是近几天我研究AntdPro
项目前端测试的主要内容,其中可能有许多测试场景和意外在本文中都没有涉及到,有一些测试完成程度也不尽如人意,就像页面组件的单元测试一样,但好在,经过一番折腾最终勉强达到了测试效果。随后在真正普及项目测试时,肯定还会遇到许多问题,我也会持续关注,对上述测试流程进行优化以达到更好的效果。
附:
一些建议:
-
一个大的测试组件内部标签众多,可以适量的加一些
id``class
加以甄别。 -
公用组件的稳定性是比较重要的,为了测试组件的健壮性,通常可以从参数类型入手测试用例。
-
涉及到类似
Link
的测试,需要外部有标签包裹(Route
)时,我们也可以尝试替换内部标签(将Link替换为a
),这样我们也免于构建外部环境。 -
本文中提到的测试目录结构是我个人构建的,各位也可以根据自己的喜好构建适合自己的测试目录。
作者释: 由于本人水平一般,如果在文中有误导之处,还请各位能及时指正。