0%

背景

干研发的有个高频词语:抽象,这个词语可应用于各种场景,我今天聊的是代码抽象,在此篇就比较low逼的理解成代码复用吧,不然感觉有点虚。

为啥记录这个呢还是源于近段时间遇到的一些矛盾,重复代码该不该都抽出来,在这之前我会毫不犹豫的说应该,包括现在团队里也几乎是这样的声音,但是是不是就一定对呢?现在我觉得这个观点是不对的,因为我发现有些代码抽出来之后反倒变得越来越不可掌控。

所以我在思考克制抽象是不是也应该提出来。为了验证这个思考,遂搜了搜,别说还真有那么些大佬早就提出了这个观点。

AHA

AHA (读作”Aha!” ):Avoid Hasty Abstractions(避免草率的抽象)

读了几篇文章特别感动,尤其是Sandi Metz的 The Wrong Abstraction,特别有共鸣。

核心观点就是

宁愿复制而不是错误的抽象

具体的支撑克制抽象内容,这几篇文章说的很清楚了,我就不再来一遍了。

我就给个现实的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
* 获取某个CI模型数据 v1.0
* @param {object} code 模型code
* @returns {object} 格式化之后的模型对象
*/
export const getCi = async (code) => {
const meta = await request.post('/api/v1/model/ci/getCi', { code });
// 绑定数据字典
meta.attributes = meta.attributes.map((item) => {
const attr = { ...item };
if (attr.changeValue === 'dict') {
if (!DICT.get(attr.code)) {
rlog.error(`找不到 ${attr.code} 对应的数据字典`);
} else {
attr.dict = DICT.get(attr.code).items;
}
}
return attr;
});
return meta;
};

/**
* 获取某个CI模型数据 v2.0
* @param {object} code 模型code
* @param {boolean} userVisibleFilter 是否按照模型的userVisible过滤,发现页面不过滤
* @returns {object} 格式化之后的模型对象
*/
export const getCi = async (code, userVisibleFilter = true) => {
const meta = await request.post('/api/v1/model/ci/getCi', { code });
// 获取过滤userVisible=true的属性(用户可见)
const { attributes } = meta;
const visibleAttributes = userVisibleFilter
? attributes.filter((item) => item.userVisible)
: attributes;
// 属性绑定数据字典
meta.attributes = visibleAttributes.map((item) => {
const attr = { ...item };
if (attr.changeValue === 'dict') {
if (!DICT.get(attr.code)) {
console.error(`找不到 ${attr.code} 对应的数据字典`);
} else {
attr.dict = DICT.get(attr.code).items;
}
}
return attr;
});
return meta;
};


/**
*
* 获取某个CI模型数据
* @param {object} code 模型code
* @param {boolean} userVisibleFilter 是否按照模型的userVisible过滤,发现页面不过滤
* @param {boolean} dict 是否需要绑定数据字典
* @returns {object} 格式化之后的模型对象
*/
export const getCi = async (code, userVisibleFilter = true, dict = true) => {
const { meta, visibleAttributes } = await formatMeta(code, userVisibleFilter);

if (!dict) {
meta.attributes = visibleAttributes;
return meta;
}

// 属性绑定数据字典
return bindDict(meta, visibleAttributes);
};

/**
*
* 获取某个CI模型数据 v3.0
* @param {object} code 模型code
* @param {boolean} userVisibleFilter 是否按照模型的userVisible过滤,发现页面不过滤
* @param {boolean} dict 是否需要绑定数据字典
* @returns {object} 格式化之后的模型对象
*/
export const getCi = async (code, userVisibleFilter = true, dict = true) => {
const meta = await formatVisibleAttributes(code, userVisibleFilter);
if (!dict) {
// 属性绑定数据字典
return bindDict(meta);
}
return meta;
};

/**
*
* 获取某个CI模型数据 v4.0
* @param {object} code 模型code
* @param {boolean} userVisibleFilter 是否按照模型的userVisible过滤,发现页面不过滤
* @param {boolean} dict 是否需要绑定数据字典
*/
export const getCi = async (code, userVisibleFilter = true, dict = true) => {
const meta = await formatVisibleAttributes(code, userVisibleFilter);
// 属性绑定数据字典
if (dict) {
try {
// 用户自建属性(数据字典)
const userDict = await getUserDicts(code);
return bindDict(meta, userDict);
} catch (error) {
rlog.error(error);
return meta;
}
}
return meta;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 过滤可见属性
* @param {string} code
* @param {boolean} userVisibleFilter
* @returns {object} 只包含可见属性的模型对象
*/
async function formatVisibleAttributes(code, userVisibleFilter) {
const meta = await request.post('/api/v1/model/getCi', { code });
if (meta) {
let { attributes } = meta;
if (!attributes) {
return meta;
}
// 适配后端,使属性正序
attributes = attributes.reverse();
// 获取过滤userVisible=true的属性(用户可见)
if (userVisibleFilter) {
meta.attributes = attributes.filter(
(item) => item.userVisible === 'true'
);
}
return meta;
}
return meta;
}
如上所示

总共经历了至少4次的改动,逻辑变得越来越复杂,因为需要适配多种场景,本来我一开始抽出来,理由很简单,因为该api是一个获取底层数据的api,大多数前端的功能都需要调用该api,且都是需要有数据字典的,因为要正确的展示数据,所以我抽了一个方法。

这个时候还是很美好的,不过后续就像 The Wrong Abstraction里写的一样,各个使用方或找我或自己对该方法进行了扩展,这方法那是叫惨不忍睹啊,就这还是我重构之后的样子,没重构之前更丑。

那有人就问了,为什么就扩展了呢?

  1. 个人风格问题,该方法之前满足我得需求现在不满足了,所以我要改它,这样最简单,我可不管其它模块需不需要这个逻辑。
  2. 我也知道可能在上面加不太合适,因为加的扩展逻辑不是所有模块都需要的,但是也不是我一个人需要的,比如A、B、C、D…,A、B都需要,那为了不重复写代码,在原有方法上扩展我觉得也还行。

后来当我发现的时候,我就在群里发出了一个共识。

  1. 这类公共的api原则上不加个性化的扩展但是可加通用性(不影响整体数据结构且没有业务逻辑,比如:对原始数据进行数据筛选(eg:可见、不可见))的扩展,且加的时候需要与该api的最初作者对齐。
  2. 如果要扩展个性化,请自行copy一份代码再修改。

我的理由是如果再这么搞那我就不维护了爱咋咋滴………………..当然前面是意淫的咱们是一个team,和为贵。

真正的理由是维护成本会越来越高且与当初抽象的意义渐行渐远。

可能我给的例子不够有足够力量的说服力,但是我还是觉得,抽象不一定就一定时好的必须的,有些时候我们得反过来想想,任何事情都有两面性。虽然咱没有能力提出牛逼得理论和观点,但是我们可以基于大佬们提出得理论和观点,做些反思、验证…。

有句话不是说吗:站在巨人的肩膀上。这句话我理解不是说巨人的肩膀才稳,而是说能看得更远。

You Know

Duplication is far cheaper than the wrong abstraction

本文引用的内容,如有侵权请联系我删除,给您带来的不便我很抱歉。

// eslint-disable-next-line no-prototype-builtins

​ if (!a.hasOwnProperty(key) || !b.hasOwnProperty(key)) return 0;

no-prototype-builtins - Rules - ESLint - Pluggable JavaScript linter

Rule Details

This rule disallows calling some Object.prototype methods directly on object instances.

Examples of incorrect code for this rule:

1
2
3
4
5
6
7
/*eslint no-prototype-builtins: "error"*/

var hasBarProperty = foo.hasOwnProperty("bar");

var isPrototypeOfBar = foo.isPrototypeOf(bar);

var barIsEnumerable = foo.propertyIsEnumerable("bar");

Examples of correct code for this rule:

1
2
3
4
5
6
7
/*eslint no-prototype-builtins: "error"*/

var hasBarProperty = Object.prototype.hasOwnProperty.call(foo, "bar");

var isPrototypeOfBar = Object.prototype.isPrototypeOf.call(foo, bar);

var barIsEnumerable = {}.propertyIsEnumerable.call(foo, "bar");

背景

由于事业部专门成立团队做部署平台,由于各种因素迟迟没有上线,以前的Jenkins也不能用了,但是前后端需要有环境进行联调,后端还好说每个模块本地起个服务就行,前端就尴尬了本地起服务限制太多,所以需要想办法搞个环境,遂想着悄悄咪咪(深藏功与名…)搞了个环境让大家能先跑起来不至于耽误工期。

满足以下要求:

  1. 前端更新简单
  2. 无脑一条命令搞定start\stop\restart
  3. 于生成环境尽量贴合

实操

因为生成环境是基于docker做部署,所以先弄个nginx的镜像。nginx的镜像茫茫多,不过大体都很全面,我觉得太重,所以就弄了个最简单的。

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
version: '3.1'
web:
my-nginx:
container_name: new-monitor-nginx
image: nginx
restart: always
logging:
driver: "journald"
volumes:
- /home/xxx/mount/nginx.conf:/etc/nginx/nginx.conf
- /home/xxx/mount/html:/etc/nginx/html

ports:
- "443:443"
network_mode: "bridge"
environment:
- TZ=Asia/Shanghai
ulimits:
core: 0

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 用官方的nginx镜像
FROM nginx
# ENTRYPOINT 类似于 CMD 指令,但其不会被 docker run 的命令行参数指定的指令所覆盖,用于传参。
# eg:docker run nginx:one -c /etc/nginx/new.conf,容器内会运行nginx -c /etc/nginx/new.conf
ENTRYPOINT ["nginx", "-c"]

#COPY 复制,从上下文目录中复制文件或者目录到容器里指定路径。
COPY nginx.conf /etc/nginx/nginx.conf
COPY html /etc/nginx/html

# RUN,在 docker build时作用
RUN rm /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf

# EXPOSE 声明端口,镜像服务的守护端口,以方便配置映射。
EXPOSE 8088


# VOLUME 用于挂载数据卷
VOLUME /var/log/nginx/log

#CMD 类似于 RUN 指令,在docker run 时运行。
CMD ["nginx", "-g", "daemon off;"]

构建、启动容器

1
2
3
4
docker build -t xxx
docker-compose down
sleep 10
docker-compose up -d

后续直接

1
docker restart 容器名

后续

其实最终我又把docker干掉了….因为有同事悄悄咪咪手动装了nginx,优化了一下把nginx挂到了系统服务上,同样的也是直接systemctl xxx就行了。

反正结果是好的,结合alibaba toolkit也是玩的飞快了。

干研发的不就得折腾吗,对吧。

背景

最近新产品线,因为架构调整,增加了一个node应用,关于node混淆部署了解了一下,保护自主知识产权嘛。

在这儿简单做个记录。

正文

https://sailsjs.com/

安装 forever:sudo npm install -g forever
更多关于 forever 的资讯:https://github.com/nodejitsu/forever
或安装 PM2:sudo npm install pm2 -g –unsafe-perm
更多关于 PM2 的资讯:https://github.com/Unitech/pm2

https://github.com/Unitech/pm2

https://github.com/vercel/ncc

背景

前面提到过我们要从头做一个产品,暂定代号”新监控“,现阶段是进行概设、预研等阶段,因为涉及到很多模块做概设,业务专家、产品经理、研发、测试等在参与,为了能更有效的产出,所以架构组拎出来了DDD,借用DDD这一套东西期望能让大家跑的更顺一些,当然DDD也仅仅是当成工具来使用,不生搬硬套。

DDD

我知道DDD是,前几年突然就有很多文章提了这个概念挺火的(后面好像有段时间又有很多吐槽DDD的文章),为了了解是个啥玩意,我还还专门买了本书领域驱动设计,说实话没看完,对当年的我来说有点抽象了,因为经历的少了,找不到对应的场景来理解DDD,所以暂时就搁置了,刚好借这个机会,基于实践来学习这玩意,看到底咋样。

先把架构师给的一些资料做个记录

背景

you know年纪大了就想着研究研究自个 儿。

我这人其实工作上是真的从不拖延,正儿八经的,不过生活中就相反了。我就很想找找其中的原因。

读书

遂选了几本书,看我还能不能抢救一下:

  1. 《图解心理学》
  2. 《心理学通识》
  3. 《少有人走的路》
  4. 《拖延心理学》
  5. 《理解人性》

编号就是读的顺序,此篇先占位,待我都读完之后来写写收获。

背景

之前有提过现阶段要把之前的产品推翻重做,做带中台调性的新监控。其中一块核心的内容就是CMDB,虽然CMDB的设计主要是架构师以及后端同学的活,但是架不住我爱掺合啊,这块的前端是我来弄,所以也有正当理由摸鱼撒。所以简单也做个记录,稍微深入的了解下CMDB这玩意,也不能细说一个是我没那么精通另一个我不得保护我们的产品知识产权吗不然老板不把我弄死啊…跑题了。

开始

CMDB刚开始接触的时候我把它笼统的理解为资产管理,当然这种理解肯定是不对的,太局限。

因为我们的产品大多数情况下都是部署在客户的内网,CMDB在我们产品中的定位:

  • 运维对象管理系统。
  • 支撑监控能力的基础设施。
  • 以应用为核心对象串联其它资源。

识别运维对象,主要分为两个部分:

基础设施层面:网络设备、服务器、存储设备等;

应用层面:应用元信息、告警配置信息等

当我们识别出运维对象和对象间的关系,并形成了统一的标准之后,接下来要做的事情就是将这些标准固化,固化到信息管理平台中,也就形成了我们说的CMDB(配置管理)。

运维对象识别

思路跟下图很像,从消费场景入手,识别对象以及对象具有的元素应该有哪些。

最终细化一下会识别出几种类型,一个是基础资源对象,一个是应用对象,一个是逻辑对象(组织和人),把这几种类型对象按照相应的规则的建立关系,从而管理属性、关系、状态、场景。

发现

前一小结确定了运维对象识别的核心思想,其中一个大的作用就是指导资源发现,我们第一版的发现的方式包含两种:

  • 网络拓扑发现(自动):通过SNMP扫描网络,发现其中的网络设备,并判定其间的网络连接关系。

  • 指定类型发现(人工或者流程):用户指定资产类型,发现时不需要依据判定规则。

    下图是我傍的一个后端大佬画的,我悄悄盗过来,镇场面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
classDiagram

class 发现规则 {
+ 判定规则
+ 收集规则
}


任务 "1" -- "N" 任务执行
发现代理 -- 发现结果
任务执行 -- 发现代理
任务执行 -- 资产数据
资产数据 -- CMDB

发现规则 -- 发现代理
全局例外 -- 发现代理
SNMP特殊判定 -- 发现规则
任务 "1" -- "N" 连接信息

建立关系

首先明确一点,以应用为中心,从应用的视角去看,从应用的角度构建资源管理的关系(拓扑关系)。

看了几篇相关的文章基本上,关系类型可简化为下面几种:

  • 主从关系。这种关系是一种强父子关系,主不存在了,则从就不存在了。比如主机和网卡、硬盘。
  • 依赖关系。是一种对象属性级之间的关联关系,比如说服务器放在机柜上,机柜摆在某个机房内,这是对象级别的关系。通过对象的属性关联来表达。
  • 连接关系。主机和存储、主机和网络设备的关系,是连接关系。这种关系是动态生成的,是一种实例级的关系。

依赖关系和连接关系有什么不同?

  • 依赖是一对多的关系,并且这个关系是靠人维护的,比如说机柜上放了很多服务器。
  • 连接是多对多关系,并且这个关系是因为某种“连接”产生的,比如说服务器连接了交换机。通常是通过自动发现来实现。

我们产品第一版关系设计跟上诉差不太多,只是叫法有一些区别。

比如主从关系:属于、包含。

依赖关系:运行在

连接关系:连接

由于各种原因,大概就整理这么多。

小结

大体围绕CMDB的设计思路如下图。

设计的时候考虑的要点:

  1. 领导要参与,团队理解要一致。通过场景带入的方式。
  2. 领域分析是核心,除非有必要,不然不考虑技术实现。
    1. 为啥DDD,建立研发、产品等各方的通用语言。
    2. 为啥DDD,DDD是一套完整而系统的设计方法,基于这套方法使面对高复杂度的业务,设计思路能更加清晰,设计过程也能更加规范。
  3. 从应用视角切入,应用是核心。
  4. CMDB最好分为两个维度的内容进行共创:配置管理、资源管理(资源管理 ≠ 资产管理)。
  5. 整理的资源都是服务于各种消费场景的,原则上不应该存在游离态的资源。
  6. CMDB的模型定义一定是有层次的,比如分为核心模型和扩展模型。
    1. 核心模型记录业务、应用和主机,有了核心模型系统基本就能跑起来了。
    2. 扩展模型是依赖核心模型扩展出来的,比如基于主机找到关联的机柜等信息,这块信息是有核心模型驱动逐步完善的。

本文引用的图片,如有侵权请联系我删除。

感谢

背景

新建构是基于Ice.js搭建的,在写单元测试的时候遇到一些问题,此篇作为填坑记录。

一开始ice是不支持运行单元测试,因为暴露出来的ice是alias出来的虚包,所以直接在单元测试里import是没法运行的,遂到社区提了issue,前几天传来利好,有了alpha版本能支持写单元测试了,所以下面实践一把。

实操

services

测试services(ice框架)里的方法

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { request } from "ice";

const mockId = "00000000-0d00-0000-0000-000000000000";

export default {
async fetchList(params, config={}) {
return await request.post(
"/api/v1/domain/getResourcesByDomainId",
{
...params,
id: mockId,
},
{ ...config }
);
},
};

测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @jest-environment node
*/

//@jest-environment node 表示测试环境你,默认为jsdom(类浏览器),node表示为node服务的方式(测试跨域请求时需要用到)
import listService from '../../../../src/pages/ResourceCenter/services/list'
import getCookie from '../../../_helper/getCookie'

let headers = {}
beforeEach(async () => {
const Cookie = await getCookie()
headers = { Cookie }
})

describe('fetchList', () => {
test('listService fetchList', async () => {
const params = { pageNum: 1, pageSize: 10 }
const config = {
withCredentials: true,
headers: { ...headers },
}
const data = await listService.fetchList(params, config)
expect(data).not.toBeNull()
})
})

带store的组件

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import React, { useEffect } from 'react';
import { Grid, Tab } from '@alifd/next';
import { store as pageStore } from 'ice/ResourceCenter';
import ResourceList from './components/list.jsx';
import styles from './index.module.scss';
import ResourceTypeTree from './components/tree.jsx';
import DetailConfig from './components/detail_config.jsx';

const { Row, Col } = Grid;

const AlarmAnalyze = () => {
const [treeState, treeDispatchers] = pageStore.useModel('tree');
const [listState, listDispatchers] = pageStore.useModel('list');
useEffect(() => {
treeDispatchers.fetchTree();
listDispatchers.fetchList();
}, []);

return (
<div>
<Row>
<Col span="4">
<div className={styles['layout-aside']}>
<ResourceTypeTree data={treeState.data} />
</div>
</Col>
<Col span="20">
<div className="layout-content">
<Tab>
<Tab.Item title="xxx" key="1">
<ResourceList data={listState} />
</Tab.Item>
<Tab.Item title="xxx" key="2">
<DetailConfig data={listState} />
</Tab.Item>
</Tab>
</div>
</Col>
</Row>
</div>
);
};

export default AlarmAnalyze;
AlarmAnalyze.pageConfig={
auth:["/resourceCenter"]
}

测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import React from 'react'
import AlarmAnalyze from '../../../src/pages/ResourceCenter/index'
import { store as pageStore } from 'ice/ResourceCenter'
import { shallow, mount } from 'enzyme'
//测试内容中需要处理store时,需要用provider包裹
const PageProvider = pageStore.Provider

const WithPageModel = (props) => {
return (
<PageProvider>
<AlarmAnalyze {...props} />
</PageProvider>
)
}

let wrapper
beforeEach(() => {
wrapper = mount(<WithPageModel />)
})

describe('render', () => {
test('Test ResourceList ', () => {
const cwrapper = wrapper.find('ResourceList')
expect(cwrapper.prop('data')).toEqual({ ciType: '', current: 1, keyword: '', list: [], pageSize: 10, total: 0 })
//取消挂载
wrapper.unmount()
})

test('Test pagination ', () => {
const pagination = wrapper.find('Pagination')
expect(pagination.prop('total')).toBe(0)
//取消挂载
wrapper.unmount()
})

test('Test ResourceTypeTree ', () => {
const cwrapper = wrapper.find('ResourceTypeTree')
expect(cwrapper.prop('data')).toEqual(expect.arrayContaining([]))
//取消挂载
wrapper.unmount()
})
})

命令

注意只能用icejs来运行,想想也是应该的,因为你要测的东西就是ice那一套的,不可能随便拽个Jest就能跑,Jest咋知道哪跟哪呢。

1
2
"icetest:watch": "icejs test --jest-watch",
"ice:coverage": "icejs test --jest-coverage",

背景

我们的产品通常进入客户现场之前都会有一系列的安全扫描(公司级的、客户级的),去年我们团队就解决了好几个安全问题,不过通常都是后端的方式来解决,但其实某些安全问题前端也能解,有些问题前端解更合适,这不借着重新出发的春风,往前端工程化中加了一步安全

此篇文章收获颇多。WEB前端安全自查和加固

实操

对于安全方面的共识
  1. 对于第三方包的引入,必须经过npm audit+snyk安全扫描后且经过小组内评审通过后方才能引入。
  2. 对于我们自己发布的包,必须去掉敏感信息。
  3. 每次更新了package.json之后都需要进行安全扫描。

如下图,是我们项目里我用snyk执行扫描之后报出问题的包信息。

小结

后续会把考虑把该步骤拿到流水线里去做,因为虽然大家意识层面共识了,落地的时候可能还是会有各种遗漏,所以干脆直接交给机器来做。

安全无小事,先动起来!

本文引用的内容,如有侵权请联系我删除,给您带来的不便我很抱歉。

背景

其实老早以前就想在产品中引入单元测试,领导也让我好好做一个分享主题,分享好做花功夫就行,但是我觉得此主题的分享最终目的是为了落地,所以需要一个契机,不然大家听了就完了也没啥x用。

刚好大佬们上个月做了个决定,决定从前到后整体重新架构,重新梳理,做一个带中台属性的系统,从而是产品能有更多平台能力,也能更好的和另外的产线想结合。当然我说的比较云淡风轻的,干起来你才知道这是一个多么有挑战的目标,扯远了,下面说说前端测试这块的内容。

名词定义

基础组件:antd、next等提供的组件。

通用组件:咱们系统中抽取出来的组件公用的组件(完全自定义的以及基于基础组件改造的)=>单元测试(UI测试可选)

业务组件:通过基础组件、通用组件等组合起来的组件(区块)=>单元测试、集成测试(UI测试可选)

为什么需要测试?

  • 提高代码质量
  • 倒逼开发对编码的设计更加低耦合以及合理,方便后续的迭代/重构
  • 最大程度保证产品符合预期(因为各种场景都测试)
  • 减少测试的投入(比如回归)
  • 提升开发的信心(bug少)

前端测试的类型

单元测试(Unit Test)集成测试(Integration Test)UI 测试(UI Test)

单元测试应用:
  • 代码中多个组件共用的工具类库、多个组件共用的子组件等。

  • 能进行单元测试的函数/组件,一定是低耦合的,这也从一定程度上保证了我们的代码质量。

    比如我们的工具库、组件库这类通用低耦合的内容就比较适用单元测试。

「通常情况下,在公共函数/组件中一定要有单元测试来保证代码能够正常工作。单元测试也应该是项目中数量最多、覆盖率最高的。」

集成测试应用:
  • 耦合度较高的函数/组件、经过二次封装的函数/组件、多个函数/组件组合而成的函数/组件等。
  • 集成测试的目的在于,测试经过单元测试后的各个模块组合在一起是否能正常工作。会对组合之后的代码整体暴露在外接口进行测试,查看组合后的代码工作是否符合预期。

「集成测试是安全感较高的测试,能很大程度提升开发者的信心,集成测试用例设计合理且测试都通过能够很大程度保证产品符合预期。」

UI测试应用:

先做个说明,大家不要把UI 测试(UI Test)和端到端测试(E2E Test)混为一谈,认为是同一个测试类型。

事实上,UI 测试(UI Test)和端到端测试(E2E Test)是稍有区别的:

UI 测试(UI Test)只是对于前端的测试,是脱离真实后端环境的,仅仅只是将前端放在真实环境中运行,而后端和数据都应该使用 Mock 的。

端到端测试(E2E Test)则是将整个应用放到真实的环境中运行,包括数据在内也是需要使用真实的。

就前端而言,UI 测试(UI Test)更贴近于我们的开发流程。在前后端分离的开发模式中,前端开发通常会使用到 Mock 的服务器和数据。因而我们需要在开发基本完成后进行相应的 UI 测试(UI Test)。

可以理解为交付测试之前的自测阶段我们做的就是UI测试。而测试做的就是端到端测试。

开始

此篇介绍的是单元测试的应用。

先简单画下边界,此篇只说明基于工具库、组件库的分享示例。高耦合的组件或者自动化测试应用不在此次讨论范围内。

选择何种测试思想

其实本不用做这个说明,因为其实我们已经选好了(BDD)。

但是避免有像我一样懵懂的同学,所以这儿咱们也简单说明一下。因为测试框架对不同规范的支持可能不一样。所以我们先搞明白测试思想有哪些?

测试驱动开发(TDD)行为驱动开发(BDD)

TDD是一种开发技术,更多地侧重于功能的实现。

TDD代表测试驱动开发。 在这种软件开发技术中,我们首先创建测试用例,然后编写这些测试用例的基础代码。 尽管TDD是一种开发技术,但它也可以用于自动化测试开发。

实施TDD的团队需要花费更多的时间进行开发,但是,他们发现的缺陷很少。 TDD可以提高代码质量,并提高代码的可重用性和灵活性。

TDD还有助于实现大约90-100%的高测试覆盖率。 对于遵循TDD的开发人员而言,最具挑战性的事情是在编写代码之前先编写测试用例。

BDD是一种专注于系统行为的开发技术。

BDD是TDD的扩展,它不是编写测试用例,而是从编写行为开始。 后来,我们开发了应用程序执行该行为所需的代码。

BDD方法中定义的方案使开发人员,测试人员和业务用户易于协作

当涉及自动测试时,BDD被认为是最佳实践,因为它专注于应用程序的行为,而不是考虑代码的实现。

应用程序的行为是BDD的关注重点,它迫使开发人员和测试人员去了解产品以及用户。

TDD BDD
该过程从编写测试用例开始。 该过程从编写测试用例开始。
TDD专注于功能的实现方式。 BDD专注于最终用户的应用程序行为。
测试用例是用编程语言编写的。 与TDD相比,场景以简单的Given、When、Then关键字编写,因此更具可读性。
应用程序功能的变化对TDD中的测试用例有很大影响。 BDD方案基本不受功能更改的影响。
仅在开发人员之间需要协作。 所有利益相关者之间都需要合作。
只有具有编程知识的人才能理解TDD中的测试。 任何人都可以理解BDD中的测试,包括那些没有任何编程知识的人。
TDD降低了测试中出现错误的可能性。 与TDD相比,测试中的错误很难跟踪。
小结:

我简单总结下大家有个简单的概念,具体有兴趣大家再下去查资料。

TDD主体是开发,说白了就是测试用例也是开发来写,先写测试用例,很显然测试用例肯定都过不了因为还没编码,那怎么办呢,就通过编码的方式不断的丰富功能然后再回去跑测试用例,如此循环最终达到所有测试用例都通过。比较适用后端API的测试。

BDD没有主体,因为所有人都需要参与(测试、开发、产品),大家用同一套语言来理解产品的功能和用户行为,大家的思考角度会更侧重用户方。比较适用前端测试的场景,因为前端是直接面对用户的。

Jest用的断言库就是BDD的思维。eg:expect(wrapper.find(‘.bar’)).toHaveLength(3);一品就是given、when、then的路数

找测试框架

诉求

  1. 能跑JavaScript也能跑React。
  2. 最好能开箱即用,不需要做很多配置。
  3. 社区生态良好,能及时支持新特性且能及时响应issue。

Jest、Enzyme

Jest:

其实测试框架茫茫多,最终选择它的原因就是其完美匹配上诉的所有诉求,Facebook亲生的,star 接近32K,React不用说了,同时也能跑JavaScript。另外Jest还具备Mock等能力,所以Jest不仅仅是个测试框架而且还是一个集合多种能力的结合体,开箱即用。

Enzyme:

用于测试的React工具库,可看作是一个集成测试框架,与Jest组合也是备受推崇的。它提供获取元素的能力。

Enzyme渲染一个或多个组件,以及查找元素以及与元素进行交互的能力,触发事件处理程序等能力。

首先任何框架都有优点和缺点,那为什么选这两个呢:

  1. ice用的就是这两个,有大厂(阿里)的前面探路,对于我们没有经验的团队来说作为第一次的试水我觉得跟风不是一个贬义词。
  2. 基于我们的诉求我也做了一下简单的调研,因为我们主要还是以React为主所以选择Jest是自然而然的过程,另外Enzyme+Jest也是国内外热度都比较高的,两则的社区也比较活跃,所以很显然两者搭配是没有问题的,刚好ice就是用的这两个所以没什么好纠结的。

Enzyme只是提供了额外的能力对React组件进行测试,不用Enzyme只用Jest可测试,但是只有Enzyme则不能测试,Enzyme是在测试框架上能力的扩展。

  • Jest主要提供测试框架(提供测试需要的所有能力)
  • Enzyme(React测试工具库)基于Jest等测试框架增加DOM操作的能力,提供了强大的 API 能力支持 UI 交互测试

实操

Jest

1
2
3
npm install jest
npm install babel-plugin-transform-class-properties --save-dev
npm install react-test-renderer

修改根目录下.babelrc

1
2
3
4
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": ["@babel/plugin-syntax-jsx","transform-class-properties"]
}

学习文档

icejs测试

jest-community/awesome-jest

提供了很多能力,增强测试的体验。

以下几个是比较有代表性的

Snapshot

Jest使用 Snapshot 进行 UI 测试

Snapshot 测试是 Jest 提供的能力,可以自动生成组件 UI 输出的描述文件,确保你的 UI 不会发生意外的改变。

1
2
//更新快照
jest --updateSnapshot

组件实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 import React from "react";
import { Card, Button } from "@alifd/next";

const RCard = (props) => {
const { title, subTitle, content, deleteFunc } = props;
const commonProps = {
subTitle: subTitle || "",
};
return (
<div>
<Card free style={{ width: 300 }}>
<Card.Header title={title || ""} {...commonProps} />
<Card.Content>{content || ""}</Card.Content>
<Card.Actions>
<Button type="primary" onClick={deleteFunc || null}>
删除
</Button>
</Card.Actions>
</Card>
</div>
);
};

export default RCard;

组件测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react'
import renderer from 'react-test-renderer'
import RCard from '../../../src/components/Card/index'
import { shallow } from 'enzyme'

const props = {
title: 'firstRCard',
subTitle: '测试',
content: '这是第一个测试demo',
deleteFunc: jest.fn((e) => {
// throw 'error'
return 'OK'
}),
}

// UI快照测试
test('jest renders Snapshot', () => {
const tree = renderer.create(<RCard {...props} />).toJSON()
expect(tree).toMatchSnapshot()
})

在第一次运行后,Jest Snapshot 将会生成对应的 .snap 文件。

对于一个React组件而言, 传入相同的props,我们是期望得到相同的输出,所以后续如果组件的输出内容发生变更,则会导致测试用例无法通过。

ice结合 Jest CLI参数

–watch

1
eg:"test": "icejs test --jest-watch",

监视文件是否有更改,并重新运行与已更改文件相关的测试。 如果要在文件更改后重新运行所有测试,请改用–watchAll选项。

–watchAll

1
eg:"test": "icejs test --jest-watchAll",

监视文件中的更改,并在发生更改时重新运行所有测试。 如果只想重新运行依赖于已更改文件的测试,请使用–watch选项。

也可单独运行

1
2
3
//package.json
"coverage": "jest --colors --coverage",
"test": "jest --watch",

安装

vscode插件

  • jest

  • jest runner

编辑根目录下的jest.config.js

使其支持jsx、es6等

1
2
3
4
5
6
module.exports = {
verbose: true,
roots: ['<rootDir>/src/', '<rootDir>/test/'],
testMatch: ['<rootDir>/test/**/*.js'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
}

示例

Jest具体的概念说明看Jest

expect,各种断言。

随便看几个实例

Number

Jest生命周期钩子:

异步代码测试

使用 Enzyme 测试组件

Enzyme 是 Airbnb 提供的测试类库,它提供了一套简洁强大的 API。能够灵活操作 DOM,是 React 社区推荐的测试方案。

准备工作

安装测试相关依赖包

1
$ npm install --save-dev enzyme enzyme-adapter-react-16 react-test-renderer

基于 React 开发的测试,需要安装对应的 React Adapter 来保证 enzyme 渲染的版本和项目中使用的版本一致,以 react 16 版本为例,需要进行如下设置:

1
2
3
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });

如果不想每个测试用例都去定义一遍,可以将上述内容保存至 src/setupTests.js 文件中,并自定义 Jest 配置中的 setupFilesAfterEnv

1
2
// jest.config.js
module.exports = { setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],};

Mount, Shallow, Render

1
import { mount, shallow, render } from ‘enzyme';

为了具有要测试的组件,必须使用上述之一,如上面的示例所示。

mount(常用)

  • 完整的DOM渲染,包括子组件
  • 当您具有可能与DOM API交互的组件或使用React生命周期方法以完全测试组件的用例时,它是理想的选择
  • 由于它实际上是将组件安装在DOM中,因此应在每次测试后调用unmount(),以停止相互影响的测试
  • 允许访问直接传递到根组件中的props(包括默认props)和传递到子组件中的props

Shallow(常用)

  • 仅渲染单个组件,不包括其子组件。 这对于隔离组件进行纯单元测试很有用。 它可以防止子组件中的更改影响测试输出。
  • 默认情况下,浅层组件可以访问生命周期方法
  • 无法访问传递到根组件的props (因此也不是默认props ),但是可以访问传递到子组件的props ,并且可以测试传递到根组件的props 的效果。

Render

  • 渲染为静态HTML,包括子级
  • 没有访问React生命周期方法的权限
  • 比mount简单,但功能较少
总结

react测试利器enzyme有三种渲染方式:shallow, mount, render。

shallow渲染叫浅渲染,仅仅对当前jsx结构内的顶级组件进行渲染,而不对这些组件的内部子组件进行渲染,因此,它的性能上最快的,大部分情况下,如果不深入组件内部测试,那么可以使用shallow渲染。Shallow Rendering (浅渲染)指的是,将一个组件渲染成虚拟DOM对象,但是只渲染第一层,不渲染所有子组件,所以处理速度非常快。它不需要DOM环境,因为根本没有加载进DOM。

mount则会进行完整渲染,而且完全依赖DOM API,也就是说mount渲染的结果和浏览器渲染结果说一样的,结合jsdom这个工具,可以对上面提到的有内部子组件实现复杂交互功能的组件进行测试。

render也会进行完整渲染,但不依赖DOM API,而是渲染成HTML结构,并利用cheerio实现html节点的选择,它相当于只调用了组件的render方法,得到jsx并转码为html,所以组件的生命周期方法内的逻辑都测试不到,所以render常常只用来测试一些数据(结构)一致性对比的场景。

这里还提到,shallow实际上也测试不到componentDidMount/componentDidUpdate这两个方法内的逻辑。

shallow和mount对组件的渲染结果不是html的dom树,而是react树,如果你chrome装了react devtool插件,
他的渲染结果就是react devtool tab下查看的组件结构,而render函数的结果是element tab下查看的结果。

render: render采用的是第三方库Cheerio的渲染,渲染结果是普通的html结构,对于snapshot使用render比较合适。

这些只是渲染结果上的差别,更大的差别是shallow和mount的结果是个被封装的 ReactWrapper
可以进行多种操作,譬如find()、parents()、children()等选择器进行元素查找;
state()、props()进行数据查找,setState()、setprops()操作数据;
simulate()模拟事件触发。

常用方法示例

常用函数

  • simulate(event, mock):用来模拟事件触发,event为事件名称,mock为一个event object;
  • instance():返回测试组件的实例;
  • find(selector):根据选择器查找节点,selector可以是CSS中的选择器,也可以是组件的构造函数,以及组件的display name等;
  • at(index):返回一个渲染过的对象;
  • get(index):返回一个react node,要测试它,需要重新渲染;
  • contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为react对象或对象数组;
  • text():返回当前组件的文本内容;
  • html(): 返回当前组件的HTML代码形式;
  • props():返回根组件的所有属性;
  • prop(key):返回根组件的指定属性;
  • state():返回根组件的状态;
  • setState(nextState):设置根组件的状态;
  • setProps(nextProps):设置根组件的属性;
.find(selector) => ShallowWrapper

selector选择器

从渲染树中查找与提供的选择器匹配的每个节点。

Arguments

  1. selector (EnzymeSelector): The selector to match.

Examples

CSS Selectors:

1
2
3
4
5
6
7
8
9
const wrapper = shallow(<MyComponent />);
expect(wrapper.find('.foo')).toHaveLength(1);
expect(wrapper.find('.bar')).toHaveLength(3);

// compound selector
expect(wrapper.find('div.some-class')).toHaveLength(3);

// CSS id selector
expect(wrapper.find('#foo')).toHaveLength(1);
simulate(event[, mock])

在对应的元素节点上模拟事件。

Arguments

  1. event (String): 事件名称
  2. mock (Object [optional]): 模拟事件对象,它将与传递给处理程序的事件对象合并

Returns

ReactWrapper: Returns itself.

Example class component
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Foo extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}

render() {
const { count } = this.state;
return (
<div>
<div className={`clicks-${count}`}>
{count} clicks
</div>
<a href="url" onClick={() => { this.setState({ count: count + 1 }); }}>
Increment
</a>
</div>
);
}
}

const wrapper = shallow(<Foo />);

expect(wrapper.find('.clicks-0').length).to.equal(1);
wrapper.find('a').simulate('click');
expect(wrapper.find('.clicks-1').length).to.equal(1);
Example functional component

component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React, { useState } from "react";
import PropTypes from "prop-types";
import { Form, Input, Select, Button } from "@alifd/next";

const FormItem = Form.Item;

const formItemLayout = {
labelCol: {
span: 6,
},
wrapperCol: {
span: 14,
},
};
const SearchInput = ({ placeholder, onSearch }) => {
const submitHandle = (values) => {
if (Object.keys(values).length === 0) {
return;
}
onSearch(values.search);
};
return (
<div
style={{
marginBottom: "10px",
marginTop: "5px",
marginLeft: "5px",
width: "420px",
}}
>
<Form inline>
<Form.Item
label="策略名称:"
key="search"
required
requiredTrigger="onBlur"
hasFeedback
requiredMessage="请输入"
>
<Input placeholder={placeholder} name="search" />
</Form.Item>
<FormItem wrapperCol={{ offset: 5 }}>
<Form.Submit
validate
type="primary"
onClick={submitHandle}
style={{ marginRight: 10 }}
>
搜索
</Form.Submit>
</FormItem>
</Form>
</div>
);
};
SearchInput.propTypes = {
onChange: PropTypes.func,
onClick: PropTypes.func,
};
export default SearchInput;

测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SearchInput from '../index'
import { mount } from 'enzyme'
import React from 'react'

//describe相当于一个块,可以将测试用例放在一个块中
describe('SearchInput unit test', () => {
// case1 标签选择器+修改props
test('SearchInput tag selector', () => {
const wrapper = mount(<SearchInput placeholder="please input" />)
expect(wrapper.find('input').at(0).prop('placeholder')).toEqual('please input')
//设置属性
wrapper.setProps({ placeholder: '请输入' })
expect(wrapper.find('input').at(0).prop('placeholder')).toEqual('请输入')
})

// case2属性选择器+修改props
test('SearchInput props', () => {
const wrapper = mount(<SearchInput placeholder="" />)
// 判断是否存在输入框
expect(wrapper.exists('input[name="search"]')).toBeTruthy
expect(wrapper.find('input[name="search"]')).toHaveLength(1)
//设置属性
wrapper.setProps({ placeholder: 'serach' })
expect(wrapper.find('input#search').prop('placeholder')).toBe('serach')
})
//修改state
test('SearchInput state', () => {
const wrapper = mount(<SearchInput placeholder="" />)
// 判断是否存在输入框
expect(wrapper.exists('input[name="search"]')).toBeTruthy
expect(wrapper.find('input[name="search"]')).toHaveLength(1)
//设置state,只有class components才可以使用
// wrapper.setState({ loading: true })
// expect(wrapper.find('.next-btn next-medium next-btn-primary next-btn-loading')).toHaveLength(1)
})
})

看前面的一堆代码可能削微有点绕啊

简单总结一下:

expect是Jest提供的断言,所以当你需要该判断结果是否正确的时候去Jest查expect的文档就行。

wrapper可看做是enzyme提供了一套类似Jquery的API。所以Jquery做的她都能做,如:查找元素、获取元素及其属性、触发元素行为等。对应的也直接去enzyme查文档就行

测试报告解读

File 测试用例文件
% Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了
% Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if,||,?:)
% Funcs 函数覆盖率(function coverage):是否每个函数都调用了
% Lines 行覆盖率(line coverage):覆盖的行
% Uncovered Line #s 行覆盖率(line coverage):是否每一行都执行了
Test Suites 测试用例文件个数
Tests 测试用例个数
Snapshots UI快照个数

本文引用的图片,如有侵权请联系我删除。

测试外传

测试框架有很多基于不同的需求不同的语言选择不同,比如Puppeteer、@testing-library库等等,有机会咱也试一试。

现阶段在团队里落地的案例:核心的方法(覆盖率100%)、核心的组件(覆盖率>80%)

感谢