理想国

我要看尽这世间繁华


  • 首页

  • 归档

  • 标签

  • 分类

  • 关于

  • 搜索

数据结构与算法: 链表

发表于 2020-03-03 | 更新于 2019-04-03 | 分类于 Development , 数据结构 & 算法

本系列文章是在学习数据结构和算法时记录的学习笔记,概念讲解部分主要引用自《极客时间》的相关专栏,代码实践部分由本人采用 Python 实现。
版权归极客时间所有。

概念

链表(Linked List)与数组一样,也是一种线性表数据结构,不过它不用一组连续的内存空间,而是通过指针的形式,将零散的内存块串联起来。

每一个内存块称为链表的节点。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的相邻结点的地址,记录下一个节点的指针叫作后继指针 next,记录上一个节点的指针叫作前驱指针 previous。

最常见的链表结构有:单链表、双向链表和循环链表。

单链表(Linked List)

单链表的特点:

  • 节点存储内容:当前节点的数据data、后继指针next
  • 头结点:记录链表 基地址,有了它,我们就可以遍历得到整条链表
  • 两个尾结点:后继指针指向 空地址NULL,遍历时作为尾结点的判断条件

双向链表(Doubly Linked List)

双向链表的特点:

  • 节点存储内容:当前节点的数据data、后继指针next、前驱指针previous,相比于单链表,双向链表会占用更多的内存空间
  • 头结点:记录链表 基地址,前驱指针指向 空地址NULL,有了它,我们就可以遍历得到整条链表
  • 尾结点:后继指针指向 空地址NULL,遍历时作为尾结点的判断条件

复杂度分析

相比于数组,链表主要有以下优势:

  • 改善“插入”和“删除”的效率
  • 不受内存连续性的限制,可以存储大量元素,例如 blockchain

对应的弊端就是,随机访问效率较低,需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。

单链表和双向链表在随机访问、查询操作时的时间复杂度相同:

  • Access: O(n)
  • Search: O(n)

在对链表进行“删除”操作时,存在两种情况:

(1)删除结点中“值等于某个给定值”的结点 q
(2)删除给定指针指向的结点 q

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再进行删除操作。查询时间复杂度 O(n),删除时间复杂度 O(1),因此总体时间复杂度为 O(1)。

对于第二种情况,已经确定了要删除的结点,但是删除某个结点 q 需要知道其前驱节点,因此在此情况下单链表和双向链表就存在较大差异:

  • 单链表:需要从头节点依次遍历,找到 q 节点的前驱节点 p(p->next = q);查询时间复杂度 O(n),删除时间复杂度 O(1),因此总体时间复杂度为 O(n)。
  • 双向链表:可直接获取 q 节点的前驱节点 p(q->previous);查询时间复杂度 O(1),删除时间复杂度 O(1),因此总体时间复杂度为 O(n)。

删除的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 单链表
while p->next {
if (p->next = q) {
break;
}
p = p->next;
}
p->next = q->next;
q->next = null;

// 双向链表
q->previous->next = q->next;
q->next = null;

在对链表进行“插入”操作时,比如在链表的某个指定结点(q)前面插入一个结点(x),双向链表也有更大的优势:

  • 单链表:需要从头节点依次遍历,找到 q 节点的前驱节点 p(p->next = q);查询时间复杂度 O(n),插入时间复杂度 O(1),因此总体时间复杂度为 O(n)。
  • 双向链表:可直接获取 q 节点的前驱节点 p(q->previous);查询时间复杂度 O(1),插入时间复杂度 O(1),因此总体时间复杂度为 O(1)。

插入的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 单链表
while p->next {
if (p->next = q) {
break;
}
p = p->next;
}
p->next = x;
x->next = q;

// 双向链表
q->previous->next = x;
x->next = q;

实战

典型问题

1、实现单链表,支持增删操作 😊

在 Python 语言中没有链表的数据结构,需要模拟进行实现。

定义单链表:

1
2
3
4
class ListNode:
def __init__(self, x):
self.val = x
self.next = None

创建单链表(5->4->3->2->1->None):

1
2
3
4
5
6
7
8
9
head = None
for num in [5,4,3,2,1]:
if not head:
head = curr = ListNode(num)
else:
curr.next = ListNode(num)
curr = curr.next

return head

遍历单链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 输出 5、4、3、2、1
# head 当前对应节点 5
while head:
print(head.data)
head = head.next

# 遍历完成后,head is None,链表结构消失

# 若需要在遍历完成仍保留链表结构,则需使用一个临时变量
probe = head
while probe:
print(probe.data)
probe = probe.next

# 遍历完成后,probe is None,head 当前对应节点 5

插入单链表

插入单链表,将 20 插入到 3 和 2 之间:



1
2
3
4
5
6
7
8
9
10
# head 当前对应节点 5
probe = head
while probe:
if probe.data == 3:
new_node = Node(20)
new_node.next = probe.next
probe.next = new_node
break
else:
probe = probe.next


2、实现单链表反转 😊


点击查看

Input: 1->2->3->4->5->NULL
Output: 5->4->3->2->1->NULL

1
2
3
4
5
6
7
8
9
10
def reverseList(head: ListNode) -> ListNode:
curr, prev = head, None
while curr:
curr.next, prev, curr = prev, curr, curr.next
# next_temp = curr.next
# curr.next = prev
# prev = curr
# curr = next_temp

return prev


LeetCode: 206. Reverse Linked List

3、实现双向链表、循环链表,支持增删操作 🤔


TODO

定义双向链表:

1
2
3
4
5
class BiNode(object):
def __init__(self, data, previous=None, next=None):
self.data = data
self.previous = previous
self.next = next


4、实现两个有序的链表合并为一个有序链表 🤔


TODO

5、实现求链表的中间结点 🤔


TODO

LeetCode

debugtalk/geekcode

引用

  • 数据结构与算法之美 | 05 | 数组:为什么很多编程语言中数组都从0开始编号?
  • 算法面试通关40讲 | 05 | 理论讲解:数组 & 链表
  • 算法面试通关40讲 | 06 | 反转一个单链表&判断链表是否有环
  • 数据结构与算法之美 | Day 1:数组和链表

数据结构与算法: 数组

发表于 2020-03-03 | 更新于 2019-04-03 | 分类于 Development , 数据结构 & 算法

本系列文章是在学习数据结构和算法时记录的学习笔记,概念讲解部分主要引用自《极客时间》的相关专栏,代码实践部分由本人采用 Python 实现。
版权归极客时间所有。

概念

数组(Array)是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。

数组有两个最大的特点:

  • 线性表数据结构:线性表(Linear List)就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。数组,链表、队列、栈 都是线性表结构。与之对应的是非线性表数据结构,如二叉树、堆、图等。
  • 连续的内存空间和相同类型的数据。

线性表 vs. 非线性表


复杂度分析

数组具有如下特性:

  • 非常高效的“随机访问”:通过下标访问只需一次操作即可获取到数据
  • 低效的“插入”和“删除”:插入和删除后,需要同时移动数组中的其它数据

对应的时间复杂度如下:

  • Access: O(1)
  • Insert: 最小 O(1),最大 O(n),平均 O(n)
  • Delete: 最小 O(1),最大 O(n),平均 O(n)

注意:数组的查询操作并非为 O(1),即便是排好序的数组,采用二分查找,时间复杂度也是 O(logn)。

代码实践

典型问题

1、实现一个支持动态扩容的数组 🤔


TODO

1
2
3
class Array(object):
def __init__(self):
pass


2、实现一个大小固定的有序数组,支持动态增删改操作 🤔


TODO

3、实现两个有序数组合并为一个有序数组 🤔


TODO

LeetCode

debugtalk/geekcode

引用

  • 数据结构与算法之美 | 05 | 数组:为什么很多编程语言中数组都从0开始编号?
  • 算法面试通关40讲 | 05 | 理论讲解:数组 & 链表
  • 数据结构与算法之美 | Day 1:数组和链表

我的 2018 年终总结

发表于 2019-02-02 | 更新于 2019-04-09 | 分类于 成长历程

在 2019 农历新年即将来临之际,趁着在家闲适的日子,我也对我的 2018 年进行下年终总结。这一年,我也不知不觉迈过了 30 岁,成为了大家口中的“人到中年”。想总结的内容挺多,就从工作、生活、个人成长和困惑几部分进行展开吧。

工作

在 2017 年,我开始承担了部分技术管理的角色,个人的工作内容和重心也有了一些转变。在 2018 年初,随着部门的组织架构调整,之前我所带的测试开发小组也变成了测试开发部,部门工作职责也进行了更明确的划分。打个比喻,如果产品测试部是冲锋在各大战场一线的战士,那么我们测试开发部就是兵工厂,保障一线战士拥有理念先进、功能强大、稳定好用的武器就是我们的首要责任。当然,我们也会经常对一线战士进行指导和培训,让他们掌握武器的技术原理,从而更好地使用武器,发挥更大的战斗力。因为是全新的部门,因此我也从 0 开始梳理了适合当前大部门环境的测试开发团队规章制度,主要包括目标价值导向、协作机制、统一技术栈、技术规范、项目运作机制、培训机制、奖惩机制等等,并且团队人数也从年初的 3 个人稳步增长到当前的 7 个人。

在工作内容方面,今年我们做的事情比较多且杂,主要包括技术体系建设和重点项目支撑两大方面。

在技术体系建设方面,今年重点建设了接口自动化测试平台和性能测试平台,这两个平台都是基于 HttpRunner 来做的,因此也很好地实现了脚本的复用,并初步在公司内数十个项目中投入了使用,为后续要做的数据服务平台、线上业务功能监控、CI/CD 也做好了铺垫。在功能测试方面,基于商业测试用例管理平台 TestRail 做了些二次开发,将所有测试覆盖项目的测试用例全部统一规范管理了起来,告别了之前全靠 Excel 文件传来传去的低效,也极大地推动了开发自测,保障了质量体系规范的落地。在项目质量度量方面,我们基于 Jira 建设了质量看板,为项目质量概况、部门质量概况、业务线质量概况等不同维度提供了共二十余个质量度量指标,实现了各项目的可视化质量度量,并对于月度质量数据统计、项目质量回顾这类统计工作也提大地减少了人力时间投入。另外,团队在 UI自动化测试(Katalon)、网页资源爬虫检测、移动 APP 自动化(外购 MQC)等方面也有投入,但覆盖的项目有限。

在重点项目支撑方面,主要是进入到重点项目主导进行效率提升。当时我们的测试技术平台初步建成,虽然在一些项目中开始投入使用,但效果并不理想,我们也开始重点考虑怎么让我们的测试平台能更好地在项目中落地。后来我们听取了一位领导的建议,与其将10个项目都提升10%~20%,不如集中精力将一个重点项目提升70%。于是我们选择了一些重点项目,测试开发团队的人员深入到项目中,帮助项目组建设并梳理质量保障体系,并带领项目组成员(主要是产品测试和开发人员)一起进行效率提升方面的建设工作。在这个过程中,我们测试开发团队成员更加贴近了实际业务场景,对产品测试同学的痛点有了更多的体会,同时也收集了很多需求,帮助我们后续的迭代优化。

乍一看,感觉我们今年做出了挺多的成果,但实际上也存在比较多的问题。概括地说,就是我们在 2018 年做了非常多的事情,但目标不够聚焦,并行项目太多,人力投入分散,并且缺乏对工作内容和成果的数据化度量。最终造成的结果就是,2018 年我们建设了很多工具平台,支撑了很多重点项目,并且协助产品测试同学解决了很多技术问题,团队成员也都非常辛苦,但具体有多少工作内容对公司的业务和项目产生了正向作用,产出了多大的价值,我们很难给出数字化的展现。我们也不得不承认,没有度量的提升不算提升,无法展现的成果不算很好的成果。在《如何度量测试开发的价值产出?》一文中,我也很是赞同一个观点:我们可以反过来看,现在有了这些测试工具平台各个项目组可能都在用,那假如没有了这些测试工具平台会怎么样?是毫无影响?是变得有点不大方便?还是无法正常开展工作?显然,当然我们做得离不可或缺还存在比较大的差距。

这一年来,我感觉自己也发生了比较大的变化。在之前做工程师的时候,崇尚技术,“在墙角安静地写代码才是我的最爱”,不想去碰管理,因为感觉比较繁琐且不够纯粹。当然,我现在也非常热爱技术,后续也会一直都在一线参与开发工作,但对于技术和工作本身,却有了更多的感悟。最主要的,就是深刻认识到了相比于“攻克技术难题”、“快速开发实现和交付”,将问题想清楚、明确方向和重点会更加重要。比较幸运的是,领导给了我充分的自由度,让我有机会按照自己的想法去选择团队工作方向、建设和管理团队,但这同时我也具有更多的责任和义务,带着团队走对方向,做出更多的价值产出,收获更多的认可,最终帮助团队成员收获更高的回报。其实对于每个人来说,工作基本上都奔着两个方面,更高的经济回报和更好的个人成长。经济回报方面我能决定的有限,这很多时候会受到经济大环境和公司、部门业绩的影响,我能做的就是尽量让团队在正确的方向做对事情,对他们的工作成果更好地对外进行呈现,最终在帮他们争取荣誉和经济回报的时候也有更多的砝码。在个人成长方面,我有更多的自主权,也有更多的责任,给团队成员更多的支持和帮助,给予他们舞台和机会,让他们获得更好更快的个人成长。值得庆幸的是,今年我们测试开发团队成员一共收获了 3 次月季度优秀员工的称号(占比质量部全年的 1/4),并有一名成员被评选为质量部年度优秀员工(占比质量部总名额的 1/2),这对于我们团队成员数仅占质量部总人数 1/10 的情况来说,还算是个不错的成绩。另外,在 2018 年事业部内部建立了职级通道,所有岗位拉通了职级,我们团队有两名成员成功完成了晋升,成功率 100%,远高于事业部的总体通过率(不足20%)。团队成员能在质量部层面和事业部层面收获到这些认可,我真的着实为他们高兴。不过,我仍然清晰地记得,每次替团队成员争取优秀名额最终落选时懊恼和自责的心情。希望在下一年,团队能做出更多的成绩,收获更多的认可和回报,这也是我努力为之奋斗的方向。

生活

在生活方面,总体来说还是很幸福和满足的。

2018 年,是我当爸爸后的第一年,我也有幸见证了小坚果从 0 到 1 的成长。从学会翻身到爬行,再到走路、小跑,小坚果总是在我们不知不觉中又学会了新的技能,给我们一次又一次带来惊喜。说到爬行,小坚果的运动能力着实惊人,当时在还只会爬行的时候带他去参加了一个爬行比赛,结果毫无悬念地获得了冠军,拿到了他人生中的第一块金牌。当然,随着他逐渐长大,也越来越调皮捣蛋,在还只能勉强走路的时候,就开始翻箱倒柜,一不注意就把家里进行了乾坤大挪移,一会儿把厨房的小米袋子提到了书房,一会儿又把书架的书全翻下来扔满了客厅,让我感觉家里像是刚被洗劫过一番。除了运动,小坚果的音乐感也十分强大,一听到音乐就开始摇摆,小手也挥个不停。在发现家里有把尘封多年的吉他后,就经常拉我的手让我给他弹。当然,我也早忘了怎么弹了,但装着样子拨几下弦,他也开心的不得了。哦对了,其实小坚果最感兴趣的还是我的笔记本电脑,在他还只有半岁不到的时候,一看到我打开笔记本电脑就啥也不顾地非要往电脑前挤,然后在键盘上乱按一通,手法还有模有样,看着屏幕有变化,就特别开心,流着口水对着我笑,让我一度感觉他以后又要继承我程序员的衣钵。不知道是不是父爱泛滥,对小坚果的笑完全没有抵抗力;得益于老婆的创作能力,用微信给他做了好多表情包,我也很是嘚瑟,经常在微信群里发他的表情包。有件令我老婆很是想不通的事情,虽然我平时上班很少在家,每天陪小坚果的时间基本上只有早上半个小时,但他特别喜欢我这个爸爸,看见我的照片就”baba~baba“叫个不停,有时候刚睡醒还迷糊着也是不停地叫着”baba“。在外面逛的时候,基本上只要我抱;而每次早上我要去上班的时候,更是生离死别般哭个不停,有时让我也很是不忍。

今年与小坚果一起度过了两个重要的日子。一个是他满百天的时候,当时带着他第一次回到了我的老家,办了一个百天宴,也让我的爷爷奶奶第一次抱上了曾孙,两位老人异常高兴,一大家人久违地聚在了一起,拍了四世同堂的全家福,非常圆满。另一个就是在他满一周岁的时候,给他在深圳公租房家里办了一个生日 Party,当时我妈从重庆过来了,也来了许多朋友和邻居,很是热闹。当时在生日 Party 上,老婆让我讲几句,我酝酿了下,终究没有讲出来。其实我想说,感谢小坚果的到来,我非常幸福,也非常自豪;此生我一定会做一个好爸爸,陪伴小坚果健康快乐成长。

一口气唠叨了这么多小坚果的点点滴滴,还这么煽情,这些在我当爸爸之前都是完全不敢想象的。

从广州搬到深圳两年多以后,我也基本决定后续不会再回广州工作了。因此索性将户口从广州迁到了深圳,之前在广州空了两年多的房子也租出去了。得益于公司的福利,这两年一直都住的是公租房,因此在深圳的住房方面没有操太多的心。刚开始的时候是在龙海家园,还只是一室的,有了孩子后比较拥挤;而在今年则换到了深康村的三房,除了小区商业配套差了不少外,住房宽敞了不少,小坚果在室内也有了充分的活动空间。交通也很是方便,小区门口有地铁站,离公司四个站,早上开车去公司也只要十几分钟。而房租每个月只需交600块的物业水电费,这地段这价格,着实让不少朋友羡慕不已。

但在国人的内心,普遍都崇尚安居乐业,对拥有自己的房子也充满了信仰。我本来是对此不太在意的,觉得租一辈子房也没啥,但有了小坚果以后,不得不考虑得更多。再加上公司的福利,满足条件的情况下可以申请到10年期150万的无息房贷,在同事的劝说下,我也动了些在深圳买房的心思。不过在我初步去看了几处二手房后,内心感到异常难受。三四百万的价格,实打实的真金白银,但在深圳只能买到老旧破烂的小房子,着实很不甘心。想着当前对买房的需求还不算强烈,暂且作罢。

个人成长

在个人成长方面,今年我仍在持续学习中,并有幸完成了一些个人突破。

首先是我从 2017 年开始做的开源项目 HttpRunner,经过一年半的时间,居然从最开始的个人业余练手项目一路迭代至今,不仅在大疆内部成为了测试技术体系的基石,在测试业界也有了一定的知名度,形成了一定的开源生态并被众多公司广泛使用。截至当前,HttpRunner 在 GitHub 上收获了一千多个 star,在 TesterHome 的开源项目列表上也排到了第一的位置,这都是我始料未及的。不过,因为工作和个人时间的关系,今年在该项目中我也存在做的不好的地方,一个是该项目在 GitHub 上的 issue 我没能及时的处理,当前已经累积有一百多个未处理的 issue,在 TesterHome 和天使用户群的好多提问我也没能完成解答;另外就是在文档方面比较滞后,好多新的功能特性都没能及时更新文档,这些在后续都是要重点进行改善的。在 2019 年初,2.0 版本也正式发布了,《HttpRunner 2.0 正式发布》,后续我也会持续地优化 HttpRunner,并希望能找到更多的朋友一起来维护该项目,让 HttpRunner 能有更好的发展。

源于 HttpRunner,今年我受邀参加了 4 场行业大会进行技术分享,包括移动互联网测试开发大会(MTSC2018)和 PyCon China 2018 这些千人以上规模的大会,并且收获了比较不错的成绩。在移动互联网测试开发大会(MSTC2018)中,在大会开幕式上我有幸收获了两个奖项,个人的年度社区贡献奖 和 HttpRunner 项目的 年度开源贡献奖;大会结束后,最终经过听众投票,我也有幸被评选为服务端专场明星讲师,在此也非常感谢大家的认可和鼓励。而在 PyCon China 2018 中,我的 topic 也有幸在 30 余个分享主题中,被推选作为主会场分享主题(总共 3 个),与 Python 海外核心开发者一起在主会场面向千余名听众进行分享;这是我第一次面向这么多的听众进行技术分享,听众还基本都是 Python 开发者,这对于我也都是全新的挑战,《PyCon China 2018 归来,感谢曾经没有怂的自己》。

在个人职位方面,今年在公司内部开始建设职级通道,所在的事业部也将所有岗位拉通了职级,包括开发、测试、产品、运营、数据等。一年有两次申请机会,在第一次申请的时候,毫无意外地没有通过,通过率低得吓人,高级工程师升架构师(或技术经理)仅有一人通过(1/6)。在第二次申请的时候,本就不敢再报希望了,但想着要充分利用好每次锻炼和磨练(自虐)的机会,仍然尽力去做了准备,没想到最终竟然成功通过了(1/6),然后就有了测试架构师的 title,在此非常感谢事业部各领导的认可。不过我也非常清楚,我当前距离我心目中架构师的title仍然有非常大的差距,我后续也会不断努力,争取早日能配得上这个title。

在个人学习方面,今年极客邦出了极客时间这个产品,我也成为了铁杆用户,前前后后差不多买了三十来门课程。之所以这么喜欢这个产品,一方面是上面的课程内容基本都面向互联网从业者,专项技术和技术管理都有,内容质量也都非常不错,很多课程我买来后没有都看,但将其当做一个资料库,在需要的时候经常能找到不错资料(不过检索功能十分欠缺,希望后续能加强)。另一方面就是课程基本上都附带音频,特别适合在开车途中使用,这样我就能很好地将每天上下班途中的固定时间利用了起来;不过,很多时候下班回家时大脑十分疲惫,基本上就听歌了,所以主要还是上班途中用的比较多。通过极客时间这个产品,一方面在知识上开了眼界,另外也被不少讲师大牛圈粉了,有了奋斗的目标和方向。特别地,在今年参加大会做分享的时候,我也有幸认识了《软件测试52讲》的作者茹炳晟,《趣谈网络协议》的作者刘超,面对面进行了更多的交流。

说来惭愧,今年有几家出版社(也包括 GitChat、慕课网、实验楼 这类在线知识付费平台)跟我联系,询问我是否有意向进行合作和出版,而我在 2017 年年初跟博文视点签的出版合同,本来还打算在小坚果出生前出版呢,现在儿子都一岁多了,至今还没交稿,确切地说,是搁置好久了。唉,时间是一方面,主要还是执行力不够。不仅是写书,今年的博客和公众号也是沉寂了大半年,整个 2018 年基本就写了十来篇。回头再看看去年年终总结时的展望,打脸打得生疼。

个人困惑

在这一年,我也遇到了不少困惑,或者说,当前我暂时还没有想清楚的问题。

今年,我有幸收到了多个在其它公司前辈的邀请,他们都给出了非常诱惑的条件。想走技术路线,可以给到更高职级的title;想带团队,预期团队规模是我当前团队的好几倍;而薪资待遇,也会有不错的增长。说实话,这些都是非常不错的工作机会,再加上在当前公司待了两年多后,多多少少会有些不如意的地方,所以到底要不要换个环境,着实也会有些犹豫。其实这也回到了对于工作本身,我到底追求的是什么了。前面我也说了,每个人对于工作基本上都奔着两个方面,更高的经济回报和更好的个人成长,只是对于每个人的不同阶段来说,这两个方面的关注重点或权重可能会有所侧重。在经济回报方面,毫不掩饰地说,我在乎钱,期望能给家庭更好的经济基础;但在我当前阶段,对于自身的个人成长和职业发展我应该是更在乎的。还记得之前在 UC 工作的时候,在一次内部分享会上,UC浏览器总经理朱挺分享了他自身的一个观点,我也很是认同,跳个槽也许可以在月薪上涨个几千块,但如果只是冲着这个点,终究是量的提升,相比下来,个人能力和格局成长带来的经济回报才是质的飞跃。

那在职业发展方面呢,对于我所在的行业来说,主要就是技术和管理两条路线。前段时间在极客时间《重学前端》专栏中看到一篇文章,其中一段话着实让我扎心了许久。

做了管理,技术没跟上,并且还错过了最佳的学习时间,这个境遇可想而知,他们在工作中大概率只能是被动地接受需求解决问题,然后也同时焦虑着自己的未来,焦虑着自己的竞争力。

显然,这并不是我想要的状态,我个人也是希望更多地在技术线发展。但我觉得这里说的管理可能偏向于纯管理角色,而我即使是作为团队负责人,也更多地是希望成为技术 leader 的角色(关于 leader 和 boss 的区别,可参考陈皓在极客时间《左耳听风》专栏中的解释),因此并不冲突。那最终的核心点就在于,我到底想在技术这条路上走多远,以及,我要怎么去走我的技术之路。

去互联网大厂(成熟型),有更多地解决海量用户技术难题的机会,也有更多与技术大牛共事的机会,在工作内容上可以在某一细分领域上持续投入,在技术深度上可以有更好的积累,我上一家所在的 UC 基本上也算是这种类型。而在当前类型的公司(成长型),可以有更多的机会按照自己的想法从 0 去做很多事情,工作内容覆盖面比较广,得到较为全面的锻炼,个人也有更多晋升的机会。所以说,这两种选择更多的就像是围城,不能说哪种更好,关键还是在哪种环境更适合自己,或者说自己更想要哪种环境。

如今我已经 30 了,可以说前 30 年基本上都挺中规中矩的,读书、毕业、参加工作、工作日上班、跳槽换家公司继续上班,稳步提升自己的个人能力和市场价格,总体上来说还是十分安稳的状态。如果我要打算在深圳买套还算不错的商品房,可能也要像同事那样,首付两百万,月供两万多,持续二三十年,然后也成为有房贷要还有娃要养的中年群体中的一员。一场说走就走的旅行?嗯,我之前没有想过,今后估计也更不敢想。

之前在网上看到过一些自由职业者(独立软件开发者)的故事,他们可以自由安排自己的时间,挑选自己喜欢的工作地点,按照自己的节奏做自己喜欢的事情。很多人都很羡慕这样的状态,表示等自己财务自由后就去过这样的生活。但什么才算是财务自由呢?年薪百万、千万?还是资产过亿、十亿?每个人的标准并不一样。前些天 stormzhang 发了篇文章,《我,自由了!》,里面提到的一个观点我也很是认同,其实财务自由更多的取决于一个人的欲望,简单来说,如果收入能覆盖所需的支出,那么就可以算作是财务自由了。

我也开始反思为什么要在深圳,为了更好的生活?还是为了孩子更好的未来?显然,都做不到。如果说生活,在重庆老家的生活品质比我们在深圳高太多了,每次回老家感受都特别强烈,不管是住房小区环境,消费水平,还是生活节奏,都能生活得更好。特别是今年回家去表姐家的洋房转了下后,着实惊叹不已,很不错的地段带前后花园的房子当初才买成两百多万,即使是在经历了近两年的大幅涨价后也才四百多万,而四百多万在深圳可以买到什么品质的住房呢?如果说是为了孩子,论教育资源和医疗资源,深圳的资源跟重庆也没好多少,甚至可以说还不如重庆(或成都)。

那为什么非要留在深圳呢?为了更多的工作机会,为了更好的工作环境,为了安放我这颗年轻躁动可能还没想清楚自己要什么的内心,嗯,真的就更多的是为了我自己。但为了我自己,让老婆孩子在深圳过更差的生活,真的是一个好的选择么?在若干年后,我真的可以通过自己的努力,让老婆孩子在深圳也能过上跟老家同品质的生活么?说实话,我没有十足的把握。

在这一刻,我有些迷茫了。

展望

本想就对自己的 2018 年进行下年终总结,结果不知不觉就写了这么多。不过,这些也的确是我 2018 年在工作方面主要投入和思考的内容,索性都记录下来,虽然会显得稚嫩且琐碎,但能给未来的自己多留点记忆也挺好的。

最后再展望下 2019 年吧。

1、找到内心的平静。当前存在的困惑和迷茫只能靠自己去寻找答案,也只有真正明确了自己内心的追求和渴望,才能获取内心的平静,做出最合适的选择。

2、持续提升个人能力和认知水平。个人能力方面,在前后端 Web 开发方面会加强系统性的知识积累,并锻炼更多的产品思维,期待能在测试开发领域做出更好的产品。

3、坚持阅读和写作。多阅读,勤思考,并通过写作进行记录,记录下自己成长的轨迹。

4、学会生活,锻炼身体,陪伴家人,工作是长跑,讲究的是可持续发展。再次引用了去年和前年的展望,希望 2019 年做得更好。

5、做好时间任务管理。明确工作任务的优先级和重要性,更好地管理个人时间;滴答清单是个不错的产品,后续会坚持使用。

成长轨迹

  • 《我的 2017 年终总结》
  • 《我的 2016 年终总结》
  • 《我的 2015 年终总结》

如何度量测试开发的价值产出?

发表于 2019-01-13 | 更新于 2019-04-03 | 分类于 成长历程

每到年底的时候,不管是个人还是团队,总是避免不了要对这一年的工作成果进行总结和汇报。而对于测试开发岗位来说,通常会面临一个共性的问题:做了这么多事情,究竟产出了多大的业务价值?

在很长一段时间内,我对这个问题也是非常困惑。困惑的原因倒不是觉得工作内容没有价值,而是对于测试开发类的工作,通常没有明确的业务需求方,对于工作成果度量也没有统一的方式。

为什么测试开发岗位会面临这个问题呢?

这应该和测试岗位的职责和工作内容有很大的关系。关于测试开发工程师的定义,在《Google测试之道》一书中已经有了很全面的解释,我也很是认同。测试开发工程师(SDET,Software Development Engineer in Testing)首先应该是开发角色,只是相比于业务开发工程师,他们的目标用户更多的是公司内部的测试人员(也包括其他岗位的项目组成员),而核心工作内容就是提供通用测试技术解决方案,开发实现测试工具或平台,协助测试人员更好地完成测试工作和项目交付,而效率和质量也是他们最为关注的方面。

从岗位职责和工作内容可以看出,测试开发通常不会直接参与业务交付,并且他们通常也不会隶属于具体的项目组,因此对于他们的工作到底产出了多少实际的价值收益,在上面的领导或老板看来就不是那么明确,最终他们面临价值产出度量的问题也就在所难免了。

本文就围绕测试开发价值产出度量的问题,谈下我的一些思考和建议。

何为业务价值?

我们总是在说业务价值,那业务价值究竟指的是什么?为什么同样是写代码开发系统平台,大家通常会觉得开发电商、售后平台是产出业务价值,而开发测试工具平台就不产生业务价值呢?这种想法是否正确?

其实当我们回归商业的本质,就会得知问题的答案了。对于商业公司来说,通常是以盈利为目标的,而为了达成这个目标,就需要通过业务手段,对用户提供价值,最终获得用户的买单。从这个角度来讲,决定是否对公司产生业务价值与岗位类型无关,也与开发实现了什么系统或平台无关。例如,对于提供测试类服务的公司或项目组来说,例如听云、WeTest,开发出的测试工具平台就直接面向客户,并以此获得盈利,那么参与该类项目的测试开发工程师就直接产出了业务价值。而在绝大多数非测试服务类商业公司中,测试工具平台更多是提供一种辅助手段,帮助项目组更好更快地完成业务需求交付,而并不直接创造业务价值。当然,这个问题不仅在测试开发岗位上存在,对于某些开发岗位也是同样存在的,例如开发公司内部即时通讯工具、流程审批工具、消息网关、中间件等等。

因此,对于测试开发岗位来说,不必揪着“业务价值”不放,我们完全可以从其它角度来对工作成果产出进行度量和展现。

节省人天数?

那要使用什么度量指标呢?

在很多时候,大家可能会想到使用“节省人天数”这样一个指标。因为测试开发的主要职责之一就是提升测试效率,那如果能度量出在使用测试工具平台后减少了多少人力投入,那么就能很好地体现该工具平台的价值。

那么要怎么计算“节省人天数”呢?之前我们使用过的方式如下:

  • 统计出项目的回归测试场景,以及在固定周期内的发版次数(假设为N次);
  • 估算出通过人工去执行这些测试场景的耗时(假设为M人天);
  • 统计出工具平台执行测试的耗时(通常该耗时可忽略不计);
  • 那么节省的人天数就为:N * M

乍一看,这个思路没啥问题,也能计算出具体的节省人天数。但在实际项目中尝试运作之后,我们发现该计算方式存在比较大的漏洞。

例如,某测试工具平台在 A 项目组投入使用后,通过计算,每月节省了人力10人天。可是,A 项目组的发版频率并没有改变,项目组人员编制也没有缩减,甚至根据招聘需求,人员编制还出现了增长的情况。那在这种情况下,通过计算得出节省的人力去哪儿了?

对此我们并不能给出很好的回答。事实上,测试人员借助测试工具平台从之前的重复手工工作解放出来后,他们可能花了更多的时间在需求分析上,也可能花了更多的时间在测试策略设计上。这都是我们所期望的结果,但问题在于,这些内容我们并不能很好地去统计和量化。这也就导致我们统计出的“节省人天数”缺乏说服力。

而且从更宏观的层面来看,度量项目组的质量情况时,更多是会关注交付效率和线上质量(漏测率)两个维度。交付效率,可以通过“交付需求数/投入人天数”进行计算,而线上质量(漏测率),可以通过“线上bug数/测试发现总bug数”得出。可以看出,线上质量(漏测率)与“节省人天数”基本没有关系,而交付效率方面,除非项目投入人天数真的减少了(通常不大可能),那么交付效率也很难通过“节省人天数”提升。

因此,“节省人天数”并不是一个可行的度量指标。

建议的方案

那有没有其它更合适的度量指标呢?其实我也没法给出绝对正确的答案。

针对这个问题,我也请教了多位测试行业大佬,收获了诸多不错的建议。

其中,茹炳晟给出的一个观点给了我比较大的启发。我们可以反过来看,现在有了这些测试工具平台各个项目组可能都在用,那假如没有了这些测试工具平台会怎么样?是毫无影响?是变得有点不大方便?还是无法正常开展工作?问题越严重,说明工具平台本身的价值就更大。这也可以作为我们不断自我衡量工作成果产出价值大小的一种思路。

但要更好地进行量化,用户使用率会是一个比较不错的度量方式。

回归工具的属性,假如一个工具真的能帮助项目组带来价值,不管是效率优化还是质量提升方面,那么项目组成员肯定会更多地使用该工具;否则,项目组成员完全没有理由在这些测试工具平台上投入时间,因为使用也是有人力时间成本的。特别是在没有强制要求项目组使用的前提下,最终工具的覆盖用户范围和使用频率更能充分说明问题。这和当前各商业工具平台追求的用户数和日活数也是同样的思路。

因此,在 2019 年,我们也打算改变下思路:

1、在质量部总体层面,不再对各项目组制定自动化测试覆盖率的目标要求,对于项目组测试人员的考核方式也不再关注测试工具平台使用的情况,最终只重点关注交付效率和线上质量两大维度(统计方式同上)。

2、对于测试开发团队,测试工具平台的价值展现将更多地通过覆盖用户范围和使用频率进行展现;若要更多的提升用户范围,那么就需要更主动地去挖掘业务项目组的痛点,让开发出的工具平台能帮助更多的人(目标也不再局限于测试人员)解决实际工作中遇到的问题;而要达到比较高的使用频率(日活数),那么就势必要提升平台的可靠性,对问题反馈进行更快地响应,以及进行更多的宣传和推广。

当然,除了用户使用率(覆盖使用人数、日活数)这一类最核心的指标,我们也会关注其它的一些指标,包括:故障响应效率、平台可靠性、发现问题数、口碑评价反馈、响应需求数等。总之,这些指标都是可以明确度量和展现的,并且所有指标最终都将指向用户的实际使用情况(Adoption Rate)。

写在最后

有时候我不禁在想,做测试开发这个岗位也真挺不容易的。我们不仅需要负责需求规划和交互设计(想清楚要做什么),然后是开发和测试(将想法实现落地),并且要花费较多的时间和精力去进行推广(获取反馈及时调整),最后还要对工作成果进行度量和展现(收获价值认可,获得更多资源),只有我们开发出的工具平台最终在各业务项目中得到了很好的应用,才能说明我们的工作成果产出了价值。

这个过程跟创业真的挺像的,我也一直都是希望我所在的测试开发团队能更多地用创业的心态来对待我们的工作,而整个经历的过程,也许就是最大的乐趣所在吧。

HttpRunner 2.0 正式发布

发表于 2019-01-01 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在 2017 年 6 月份的时候我写了一篇博客,《接口自动化测试的最佳工程实践(ApiTestEngine)》,并同时开始了 ApiTestEngine(HttpRunner的前身)的开发工作。转眼间一年半过去了,回顾历程不禁感慨万千。HttpRunner 从最开始的个人业余练手项目,居然一路迭代至今,不仅在大疆内部成为了测试技术体系的基石,在测试业界也有了一定的知名度,形成了一定的开源生态并被众多公司广泛使用,这都是我始料未及的。

但随着 HttpRunner 的发展,我在收获成就感的同时,亦感到巨大的压力。HttpRunner 在被广泛使用的过程中暴露出了不少缺陷,而且有些缺陷是设计理念层面的,这主要都是源于我个人对自动化测试理解的偏差造成的。因此,在近期相当长的一段时间内,我仔细研究了当前主流自动化测试工具,更多的从产品的角度,学习它们的设计理念,并回归测试的本质,对 HttpRunner 的概念重新进行了梳理。

难以避免地,HttpRunner 面临着一些与之前版本兼容的问题。对此我也纠结了许久,到底要不要保持兼容性。如果不兼容,那么对于老用户来说可能会造成一定的升级成本;但如果保持兼容,那么就相当于继续保留之前错误的设计理念,对后续的推广和迭代也会造成沉重的负担。最终,我还是决定告别过去,给 HttpRunner 一个新的开始。

经过两个月的迭代开发,HttpRunner 2.0 版本的核心功能已开发完毕,并且在大疆内部数十个项目中都已投入使用(实践证明,升级也并没有多么痛苦)。趁着 2019 开年之际,HttpRunner 2.0 正式在 PyPI 上发布了。从版本号可以看出,这会是一个全新的版本,本文就围绕 HttpRunner 2.0 的功能实现和开源项目管理两方面进行下介绍。

功能实现

在 2.0 版本中,功能实现方面变化最大的有两部分,测试用例的组织描述方式,以及 HttpRunner 本身的模块化拆分。当时也是为了完成这两部分的改造,基本上对 HttpRunner 80% 以上的代码进行了重构。除了这两大部分的改造,2.0 版本对于测试报告展现、性能测试支持、参数传参机制等一系列功能特性都进行了较大的优化和提升。

本文就只针对测试用例组织调整和模块化拆分的变化进行下介绍,其它功能特性后续会在使用说明文档中进行详细描述。

测试用例组织调整

之所以要对测试用例的组织描述方式进行改造,是因为 HttpRunner 在一开始并没有清晰准确的定义。对于 HttpRunner 的老用户应该会有印象,在之前的博客文章中会提到 YAML/JSON 文件中的上下文作用域包含了 测试用例集(testset) 和 测试用例(test) 两个层级;而在测试用例分层机制中,又存在 模块存储目录(suite)、场景文件存储目录(testcases) 这样的概念,实在是令人困惑和费解。

事实上,之前的概念本身就是有问题的,而这些概念又是自动化测试工具(框架)中最核心的内容,必须尽快纠正。这也是推动 HttpRunner 升级到 2.0 版本最根本的原因。

在此我也不再针对之前错误的概念进行过多阐述了,我们不妨回归测试用例的本质,多思考下测试用例的定义及其关键要素。

那么,测试用例(testcase)的准确定义是什么呢?我们不妨看下 wiki 上的描述。

A test case is a specification of the inputs, execution conditions, testing procedure, and expected results that define a single test to be executed to achieve a particular software testing objective, such as to exercise a particular program path or to verify compliance with a specific requirement.

概括下来,一条测试用例(testcase)应该是为了测试某个特定的功能逻辑而精心设计的,并且至少包含如下几点:

  • 明确的测试目的(achieve a particular software testing objective)
  • 明确的输入(inputs)
  • 明确的运行环境(execution conditions)
  • 明确的测试步骤描述(testing procedure)
  • 明确的预期结果(expected results)

对应地,我们就可以对 HttpRunner 的测试用例描述方式进行如下设计:

  • 测试用例应该是完整且独立的,每条测试用例应该是都可以独立运行的;在 HttpRunner 中,每个 YAML/JSON 文件对应一条测试用例。
  • 测试用例包含测试脚本和测试数据两部分:
    • 测试用例 = 测试脚本 + 测试数据
    • 测试脚本重点是描述测试的业务功能逻辑,包括预置条件、测试步骤、预期结果等,并且可以结合辅助函数(debugtalk.py)实现复杂的运算逻辑;可以将测试脚本理解为编程语言中的类(class);
    • 测试数据重点是对应测试的业务数据逻辑,可以理解为类的实例化数据;测试数据和测试脚本分离后,就可以比较方便地实现数据驱动测试,通过对测试脚本传入一组数据,实现同一业务功能在不同数据逻辑下的测试验证。
  • 测试用例是测试步骤的有序集合,而对于接口测试来说,每一个测试步骤应该就对应一个 API 的请求描述。
  • 测试场景和测试用例集应该是同一概念,它们都是测试用例的无序集合,集合中的测试用例应该都是相互独立,不存在先后依赖关系的;如果确实存在先后依赖关系怎么办,例如登录功能和下单功能;正确的做法应该是,在下单测试用例的预置条件中执行登录操作。

理清这些概念后,那么 接口(API)、测试用例(testcase)、辅助函数(debugtalk.py)、YAML/JSON、hooks、validate、环境变量、数据驱动、测试场景、测试用例集 这些概念及其相互之间的关系也就清晰了。关于更具体的内容本文不再展开,后续会单独写文档并结合示例进行详细的讲解。

模块化拆分(Pipline)

随着 HttpRunner 功能的逐步增长,如何避免代码出现臃肿,如何提升功能特性迭代开发效率,如何提高代码单元测试覆盖率,如何保证框架本身的灵活性,这些都是 HttpRunner 本身的架构设计需要重点考虑的。

具体怎么去做呢?我采用的方式是遵循 Unix 哲学,重点围绕如下两点原则:

  • Write programs that do one thing and do it well.
  • Write programs to work together.

简而言之,就是在 HttpRunner 内部将功能进行模块化拆分,每一个模块只单独负责一个具体的功能,并且对该功能定义好输入和输出,各个功能模块也是可以独立运行的;从总体层面,将这个功能模块组装起来,就形成了 HttpRunner 的核心功能,包括自动化测试和性能测试等。

具体地,HttpRunner 被主要拆分为 6 个模块。

  • load_tests: 加载测试项目文件,包括测试脚本(YAML/JSON)、辅助函数(debugtalk.py)、环境变量(.env)、数据文件(csv)等;该阶段主要负责文件加载,不会涉及解析和动态运算的操作。
  • parse_tests: 对加载后的项目文件内容进行解析,包括 变量(variables)、base_url 的优先级替换和运算,辅助函数运算,引用 API 和 testcase 的查找和替换,参数化生成测试用例集等。
  • add tests to test suite: 将解析后的测试用例添加到 unittest,组装成 unittest.TestSuite。
  • run test suite: 使用 unittest 运行组装好的 unittest.TestSuite。
  • aggregate results: 对测试过程的结果数据进行汇总,得到汇总结果数据。
  • generate html report: 基于 Jinja2 测试报告模板,使用汇总结果数据生成 html 测试报告。

为了更好地展现自动化测试的运行过程,提升出现问题时排查的效率,HttpRunner 在运行时还可以通过增加 --save-tests 参数,将各个阶段的数据保存为 JSON 文件。

  • XXX.loaded.json: load_tests 运行后加载生成的数据结构
  • XXX.parsed.json: parse_tests 运行后解析生成的数据结构
  • XXX.summary.json: 最终汇总得到的测试结果数据结构

可以看出,这 6 个模块组装在一起,就像一条流水线(Pipline)一样,各模块分工协作各司其职,最终完成了整个测试流程。

基于这样的模块化拆分,HttpRunner 极大地避免了代码臃肿的问题,每个模块都专注于解决具体的问题,不仅可测试性得到了保障,遇到问题时排查起来也方便了很多。同时,因为每个模块都可以独立运行,在基于 HttpRunner 做二次开发时也十分方便,减少了很多重复开发工作量。

开源项目管理

除了功能实现方面的调整,为了 HttpRunner 能有更长远的发展,我也开始思考如何借助社区的力量,吸引更多的人加入进来。特别地,近期在学习 ASF(Apache Software Foundation)如何运作开源项目时,也对 Community Over Code 理念颇为赞同。

当然,HttpRunner 现在仍然是一个很小的项目,不管是产品设计还是代码实现都还很稚嫩。但我也不希望它只是一个个人自嗨的项目,因此从 2.0 版本开始,我希望能尽可能地将项目管理规范化,并寻找更多志同道合的人加入进来共同完善它。

开源项目管理是一个很大的话题,当前我也还处于初学者的状态,因此本文就不再进行展开,只介绍下 HttpRunner 在 2.0 版本中将改进的几个方面。

logo

作为一个产品,不仅要有个好名字,也要有个好的 logo。这个“好”的评价标准可能因人而异,但它应该是唯一的,能与产品本身定位相吻合的。

之前 HttpRunner 也有个 logo,但说来惭愧,那个 logo 是在网上找的,可能存在侵权的问题是一方面,logo 展示的含义与产品本身也没有太多的关联。

因此,借着 2.0 版本发布之际,我自己用 Keynote 画了一个。

HttpRunner-logo

个人的美工水平实在有限,让大家见笑了。

对于 logo 设计的解释,主要有如下三点:

  • 中间是个拼图(puzzle pieces),形似 H 字母,恰好是 HttpRunner 的首字母
  • 拼图的寓意,对应的也是 HttpRunner 的设计理念;HttpRunner 本身作为一个基础框架,可以组装形成各种类型的测试平台,而在 HttpRunner 内部,也是充分解耦的各个模块组装在一起形成的
  • 最后从实际的展示效果来看,个人感觉看着还是比较舒服的,在 HttpRunner 天使用户群 里给大家看了下,普遍反馈也都不错

版本号机制

作为一个开源的基础框架,版本号是至关重要的。但在之前,HttpRunner 缺乏版本规划,也没有规范的版本号机制,版本号管理的确存在较大的问题。

因此,从 2.0 版本开始,HttpRunner 在版本号机制方面需要规范起来。经过一轮调研,最终确定使用 Semantic Versioning 的机制。该机制由 GitHub 联合创始人 Tom Preston-Werner 编写,当前被广泛采用,遵循该机制也可以更好地与开源生态统一,避免出现 “dependency hell” 的情况。

具体地,HttpRunner 将采用 MAJOR.MINOR.PATCH 的版本号机制。

  • MAJOR: 重大版本升级并出现前后版本不兼容时加 1
  • MINOR: 大版本内新增功能并且保持版本内兼容性时加 1
  • PATCH: 功能迭代过程中进行问题修复(bugfix)时加 1

当然,在实际迭代开发过程中,肯定也不会每次提交(commit)都对 PATCH 加 1;在遵循如上主体原则的前提下,也会根据需要,在版本号后面添加先行版本号(-alpha/beta/rc)或版本编译元数据(+20190101)作为延伸。

HREPs

在今年的一些大会上,我分享 HttpRunner 的开发设计思路时提到了博客驱动开发,主要思路就是在开发重要的功能特性之前,不是直接开始写代码,而是先写一篇博客详细介绍该功能的需求背景、目标达成的效果、以及设计思路。通过这种方式,一方面可以帮助自己真正地想清楚要做的事情,同时也可以通过开源社区的反馈来从更全面的角度审视自己的想法,继而纠正可能存在的偏差,或弥补思考的不足。

直到我后来更深入地了解到了 PEPs(Python Enhancement Proposals),以及类似的 IPEPs(IPython Enhancement Proposals),我才知道原来我曾经使用过的博客驱动开发并不是一个新方法,而是已经被广泛使用且行之有效的开发方式。

因此,从 2.0 版本开始,在 HttpRunner 的开发方面我想继续沿用这种方式,并且将其固化为一种机制。形式方面,会借鉴 PEPs 的方式,新增 HREPs(HttpRunner Enhancement Proposals);关于 HREPs 的分类和运作机制,后面我再具体进行梳理。

License

最后再说下 License 方面。

HttpRunner 最开始选择的是 MIT 开源协议,从 2.0 版本开始,将切换为 Apache-2.0 协议。

如果熟悉这两个 License 的具体含义,应该清楚这两个协议对于用户来说都是十分友好的,不管是个人或商业使用,还是基于 HttpRunner 的二次开发,开源或闭源,都是没有任何限制的,因此协议切换对于大家来说没有任何影响。

总结

以上,便是 HttpRunner 2.0 发布将带来的主要变化。

截止当前,HttpRunner 在 GitHub 上已经收获了近一千个star,在 TesterHome 的开源项目列表中也排到了第二名的位置,在此十分感谢大家的支持和认可。

希望 HttpRunner 2.0 会是一个新的开始,朝着更高的目标迈进。

PyCon China 2018 归来,感谢曾经没有怂的自己

发表于 2018-10-14 | 更新于 2019-04-03 | 分类于 成长历程

今天有幸作为 PyCon China 2018 的分享嘉宾,在主会场面向近千名 Python 开发工程师做了一场关于自动化测试框架方面的分享。虽然之前多少有些忐忑和不自信,但终究挺过来了,最终现场效果也还不错,总算松了口气,个人也算是完成了一次自我突破。这会儿在返回深圳的航班上,借着这段空闲时间进行下总结和记录。

本件事情的起源挺有意思的,下面重点说下。

大概在两三个月前,无意中在微信公众号中看到了 PyCon China 2018 的主题征集,在里面看到了测试的字眼。因为近些年来 Python 一直都是我的主要工作语言,也是我个人最喜欢的编程语言,因此我也产生了些许兴趣,加了文章中主办方联系人(辛庆姐)的微信,主要是想询问下大会中会有哪些跟测试相关的主题分享。

在简单聊了下后,得知当前还没有测试相关的主题,对方也向我咨询是否有啥好的建议,同时也欢迎我参加大会进行下测试方面的分享。当时我也表达了我的疑虑,毕竟大会的参会者基本都是 Python 开发者,感觉测试相关的 topic 不一定受欢迎。然后就没继续聊了,这事儿我也就忘了。结果大概过了近一个月后,辛庆姐又跟我联系,还是希望我能做一场测试方面的分享,毕竟 Python 的应用领域这么广泛,当前在测试领域也有较多的应用。我想了下,那就参加下深圳分场的大会吧,多认识些珠三角地区的 Python 大佬也好。当然,我也存在点私心,就是想借这个机会再推广下我的开源项目 HttpRunner,要是能得到些指点就更好了,毕竟参会者基本都是 Python 开发者。

结果令我万万没想到的是,几天后的一个早晨,我睡醒后看到辛庆姐的信息,说是希望我能到北京主会场千人峰会进行分享。我顿时懵逼了,觉得很不可思议。我又再次确认了下,大会总共就一天,主会场总共就 4 个主题,而其它三位嘉宾中一位是洪教授,另外两位是国外嘉宾,他们都是 Python 领域非常资深的前辈,编程年限都快跟我岁数差不多了。我还是觉得难以置信,测试主题居然也可以排进主会场(不是对测试不自信,毕竟是开发者大会)?而且在之前的主题征集中,基本都是围绕 Python 核心语言特性、当前火热的机器学习、大数据方面,测试只是放在其它类别中,所以在被告知安排后的确觉得非常诧异。再三询问才得知,今年大会也是在做改革,想做 Pythoneer 想听的大会,而且组委会中也有成员之前看过我的博客,所以比较支持我(非常感谢)。当然,面对这前所未有的自我挑战的机会,虽然心里没底儿,我也挺想尝试的,就当作一次突破自己的机会好了。不过我也跟组委会说希望他们能再考虑下,因为我还是担心最终不能达成好的效果。再后来,组委会又进行了一轮投票,最终超过一半的成员同意将我的 topic 安排在主会场,这件事就这么定下来了。在此我也非常感谢组委会的认可和信任。

后来的事情就没啥特别的了,无非就是鼓足勇气,尽量克服内心的忐忑和不自信,然后尽量做好准备硬上了。最终结果证明,面对近千名听众进行分享也没那么可怕,和上一次 MTSC2018 服务端专场中面对三四百名听众相比感觉也都差不多,主要的差异还是在上台前的自我暗示。很庆幸,当初我克服了自身的恐惧心理将这件事答应下来了,我才能借助这次机会完成了一次自我突破,后面等我儿子长大了也多了件向他吹牛逼的素材。

说到这里,可能有人希望我能分享下如何克服在大会进行主题分享的恐惧心理。

其实对于这一点,我觉得也没有太多的秘诀,主要还是要多讲。如果还没有过技术分享的经历,不妨从公司的组内分享开始,勇敢地跨出第一步,然后不断地逼迫自己迎接更大的挑战,在部门层面、公司层面、行业沙龙活动等等,机会是非常多的。在这个过程中,积极地收集听众反馈并进行改进,多做几次之后,肯定大不一样。

另外,在分享准备阶段,推荐给大家两个比较有可操作性的做法。

  • 首先是分享的主题思路一定要清晰和明确,各部分内容的内在衔接尽量做到自然和不生硬,做到这一点后,听众会更便于掌握主题的思路,分享人也能讲得更流畅,避免因为生硬的内容切换造成忘词儿的尴尬情况。同时,分享的内容一定要都是分享者充分理解的内容,避免在网上抄一些自己都不清楚真实含义的概念和解释。
  • 另一个很重要的点就是要充分重视开场白,如果心里没底儿,建议将开场白逐字写出来并反复进行斟酌,最终修改形成一份让自己满意的开场白,并多次自我演练直至熟练。之所以这么强调开场白,除了是要跟听众尽量留下好的第一印象外,还因为在开场阶段是演讲者最容易紧张的阶段,如果开场讲得不流畅,很容易造成演讲者变得更加紧张,影响后续一连串的表现,甚至会出现大脑一片空白,完全讲不下去的尴尬场面(之前我就有过这样的经历)。而如果开场经过精心准备并且有了一个比较好的表现后,就可以很好地建立自信,并且在这个过程中也熟悉了面向听众的感觉,后续的演讲也就不会有什么问题了。

当然,这里只是列举了我个人觉得比较重要的两个点,对于其他人不一定适用。提升演讲和分享能力是一个持久的过程,我当前也是在不断摸索和提升的过程中。希望大家也能在留言中分享下这方面的经验,大家互相学习,共同进步。

最后,再说件比较尴尬的事情。

在会后,有几家出版社(也包括 GitChat 这类在线知识付费平台)的编辑跟我联系,询问我是否有意向进行合作和出版。我当然是非常难为情啦,去年年初跟博文视点签的出版合同,本来还打算在小坚果出生前出版呢,现在儿子都快满一岁了,至今还没交稿呢(确切地说,是搁置好久了)。唉,时间是一方面,主要还是执行力不够啊。所以现在对于出书的作者我是格外佩服的,内容好坏暂且不说,能坚持下来就真的很不容易了。如今我也有幸认识了不少畅销书的作者了,向他们看齐,加油吧。

知识爆炸时代,技术人该如何克服焦虑?

发表于 2018-06-27 | 更新于 2019-04-03 | 分类于 成长历程

生活在这个知识爆炸的时代,我们是幸福的,但有时也是不幸的。

相比于早些年,我们有了太多的渠道和途径接收新的知识和信息,只要你想学,总能在网上找到大量相关的教程和学习资料。但与此同时,我们往往也会陷入更多的焦虑当中。不信你可以看下在你微信中收藏的技术文章,在上一次满减打折时囤的技术书籍,以及在你网盘中攒的以GB为单位的学习资料。

这个问题很普遍,仿佛也很无奈。要学的东西太多太多,但时间有限,我们发现根本学不完,为了克服焦虑,我们可能本能地就是搜藏更多的学习资料,继而更加焦虑,从而陷入了恶性循环。

有时候我甚至挺羡慕古代的人们,没有那么多选择,一本九阴真经读透了就能打遍天下无敌手。要是当时的信息也那么发达,同时给他们100本秘籍宝典,估计他们也会陷入选择困难症,最终也很难练出神功吧。

回到现状,当前我们普遍都是碎片化学习,微信公众号、知乎专栏、网络公开课都在学都在看,但很多都没有形成体系,多一榔头西一棒槌,最后的学习成果也可想而知了。

其实这个问题要克服也很简单,那就是针对你要学习的领域方向,找一份体系化的学习资料,然后啥也别想,坚持学下去就好了。这里的学习资料,可以是权威的经典书籍,也可以是行业领域专业人士的付费课程。具体形式不重要,重要的是一定要形成知识体系。

对于我个人而言,我推荐【极客时间】这款产品,这也不是我第一次跟大家推荐了。【极客时间】主要聚焦在互联网技术领域,里面的专栏内容的确非常不错,虽然是收费的订阅专栏,但知识体系很系统,比自己零散的学习效果好很多。

现在我已经是【极客时间】的重度用户,在上面累计订阅了12门付费课程,每天上下班的路上都会听这上面的课程(是的,包含音频这一点很是方便)。如果大家感兴趣,我后面可以再详细分享下我个人是如何利用通勤时间进行学习的。

最近,极客时间上线了第一门软件测试相关的课程,而其作者正是前两天朋友圈刷屏文章《“去QE”时代下,QE如何破茧重生?》的作者,茹炳晟。

这门课程的内容我不用多介绍了,大家可以直接看下课程列表,内容还是很不错的,基本涵盖了当前互联网测试领域中主要技术内容。不管是刚入门软件测试领域的新人,还是工作多年的从业者,相信都会有不少收获。

使用爬虫技术实现 Web 页面资源可用性检测

发表于 2018-05-28 | 更新于 2019-04-03 | 分类于 Testing , 自动化测试

背景

对于电商类型和内容服务类型的网站,经常会出现因为配置错误造成页面链接无法访问的情况(404)。

显然,要确保网站中的所有链接都具有可访问性,通过人工进行检测肯定是不现实的,常用的做法是使用爬虫技术定期对网站进行资源爬取,及时发现访问异常的链接。

对于网络爬虫,当前市面上已经存在大量的开源项目和技术讨论的文章。不过,感觉大家普遍都将焦点集中在爬取效率方面,例如当前就存在大量讨论不同并发机制哪个效率更高的文章,而在爬虫的其它特性方面探讨的不多。

个人认为,爬虫的核心特性除了快,还应该包括全和稳,并且从重要性的排序来看,全、稳、快应该是从高到低的。

全排在第一位,是因为这是爬虫的基本功能,若爬取的页面不全,就会出现信息遗漏的情况,这种情况肯定是不允许的;而稳排在第二位,是因为爬虫通常都是需要长期稳定运行的,若因为策略处理不当造成爬虫运行过程中偶尔无法正常访问页面,肯定也是无法接受的;最后才是快,我们通常需要爬取的页面链接会非常多,因此效率就很关键,但这也必须建立在全和稳的基础上。

当然,爬虫本身是一个很深的技术领域,我接触的也只是皮毛。本文只针对使用爬虫技术实现 Web 页面资源可用性检测的实际场景,详细剖析下其中涉及到的几个技术点,重点解决如下几个问题:

  • 全:如何才能爬取网站所有的页面链接?特别是当前许多网站的页面内容都是要靠前端渲染生成的,爬虫要如何支持这种情况?
  • 稳:很多网站都有访问频率限制,若爬虫策略处理不当,就常出现 403 和 503 的问题,该种问题要怎么解决?
  • 快:如何在保障爬虫功能正常的前提下,尽可能地提升爬虫效率?

爬虫实现前端页面渲染

在早些年,基本上绝大多数网站都是通过后端渲染的,即在服务器端组装形成完整的 HTML 页面,然后再将完整页面返回给前端进行展现。而近年来,随着 AJAX 技术的不断普及,以及 AngularJS 这类 SPA 框架的广泛应用,前端渲染的页面越来越多。

不知大家有没有听说过,前端渲染相比于后端渲染,是不利于进行 SEO 的,因为对爬虫不友好。究其原因,就是因为前端渲染的页面是需要在浏览器端执行 JavaScript 代码(即 AJAX 请求)才能获取后端数据,然后才能拼装成完整的 HTML 页面。

针对这类情况,当前也已经有很多解决方案,最常用的就是借助 PhantomJS、puppeteer 这类 Headless 浏览器工具,相当于在爬虫中内置一个浏览器内核,对抓取的页面先渲染(执行 Javascript 脚本),然后再对页面内容进行抓取。

不过,要使用这类技术,通常都是需要使用 Javascript 来开发爬虫工具,对于我这种写惯了 Python 的人来说的确有些痛苦。

直到某一天,kennethreitz 大神发布了开源项目 requests-html,看到项目介绍中的那句 Full JavaScript support! 时不禁热泪盈眶,就是它了!该项目在 GitHub 上发布后不到三天,star 数就达到 5000 以上,足见其影响力。

requests-html 为啥会这么火?

写过 Python 的人,基本上都会使用 requests 这么一个 HTTP 库,说它是最好的 HTTP 库一点也不夸张(不限编程语言),对于其介绍语 HTTP Requests for Humans 也当之无愧。也是因为这个原因,Locust 和 HttpRunner 都是基于 requests 来进行开发的。

而 requests-html,则是 kennethreitz 在 requests 的基础上开发的另一个开源项目,除了可以复用 requests 的全部功能外,还实现了对 HTML 页面的解析,即支持对 Javascript 的执行,以及通过 CSS 和 XPath 对 HTML 页面元素进行提取的功能,这些都是编写爬虫工具非常需要的功能。

在实现 Javascript 执行方面,requests-html 也并没有自己造轮子,而是借助了 pyppeteer 这个开源项目。还记得前面提到的 puppeteer 项目么,这是 GoogleChrome 官方实现的 Node API;而 pyppeteer 这个项目,则相当于是使用 Python 语言对 puppeteer 的非官方实现,基本具有 puppeteer 的所有功能。

理清了以上关系后,相信大家对 requests-html 也就有了更好的理解。

在使用方面,requests-html 也十分简单,用法与 requests 基本相同,只是多了 render 功能。

1
2
3
4
5
from requests_html import HTMLSession

session = HTMLSession()
r = session.get('http://python-requests.org')
r.html.render()

在执行 render() 之后,返回的就是经过渲染后的页面内容。

爬虫实现访问频率控制

为了防止流量攻击,很多网站都有访问频率限制,即限制单个 IP 在一定时间段内的访问次数。若超过这个设定的限制,服务器端就会拒绝访问请求,即响应状态码为 403(Forbidden)。

这用来应对外部的流量攻击或者爬虫是可以的,但在这个限定策略下,公司内部的爬虫测试工具同样也无法正常使用了。针对这个问题,常用的做法就是在应用系统中开设白名单,将公司内部的爬虫测试服务器 IP 加到白名单中,然后针对白名单中的 IP 不做限制,或者提升限额。但这同样可能会出现问题。因为应用服务器的性能不是无限的,假如爬虫的访问频率超过了应用服务器的处理极限,那么就会造成应用服务器不可用的情况,即响应状态码为 503(Service Unavailable Error)。

基于以上原因,爬虫的访问频率应该是要与项目组的开发和运维进行统一评估后确定的;而对于爬虫工具而言,实现对访问频率的控制也就很有必要了。

那要怎样实现访问频率的控制呢?

我们可以先回到爬虫本身的实现机制。对于爬虫来说,不管采用什么实现形式,应该都可以概括为生产者和消费者模型,即:

  • 消费者:爬取新的页面
  • 生产者:对爬取的页面进行解析,得到需要爬取的页面链接

对于这种模型,最简单的做法是使用一个 FIFO 的队列,用于存储未爬取的链接队列(unvisited_urls_queue)。不管是采用何种并发机制,这个队列都可以在各个 worker 中共享。对于每一个 worker 来说,都可以按照如下做法:

  • 从 unvisited_urls_queue 队首中取出一个链接进行访问;
  • 解析出页面中的链接,遍历所有的链接,找出未访问过的链接;
  • 将未访问过的链接加入到 unvisited_urls_queue 队尾
  • 直到 unvisited_urls_queue 为空时终止任务

然后回到我们的问题,要限制访问频率,即单位时间内请求的链接数目。显然,worker 之间相互独立,要在执行端层面协同实现整体的频率控制并不容易。但从上面的步骤中可以看出,unvisited_urls_queue 被所有 worker 共享,并且作为源头供给的角色。那么只要我们可以实现对 unvisited_urls_queue 补充的数量控制,就实现了爬虫整体的访问频率控制。

以上思路是正确的,但在具体实现的时候会存在几个问题:

  • 需要一个用于存储已经访问链接的集合(visited_urls_set),该集合需要在各个 worker 中实现共享;
  • 需要一个全局的计数器,统计到达设定时间间隔(rps即1秒,rpm即1分钟)时已访问的总链接数;

并且在当前的实际场景中,最佳的并发机制是选择多进程(下文会详细说明原因),每个 worker 在不同的进程中,那要实现对集合的共享就不大容易了。同时,如果每个 worker 都要负责对总请求数进行判断,即将访问频率的控制逻辑放到 worker 中实现,那对于 worker 来说会是一个负担,逻辑上也会比较复杂。

因此比较好的方式是,除了未访问链接队列(unvisited_urls_queue),另外再新增一个爬取结果的存储队列(fetched_urls_queue),这两个队列都在各个 worker 中共享。那么,接下来逻辑就变得简单了:

  • 在各个 worker 中,只需要从 unvisited_urls_queue 中取数据,解析出结果后统统存储到 fetched_urls_queue,无需关注访问频率的问题;
  • 在主进程中,不断地从 fetched_urls_queue 取数据,将未访问过的链接添加到 unvisited_urls_queue,在添加之前进行访问频率控制。

具体的控制方法也很简单,假设我们是要实现 RPS 的控制,那么就可以使用如下方式(只截取关键片段):

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
start_timer = time.time()
requests_queued = 0

while True:
try:
url = self.fetched_urls_queue.get(timeout=5)
except queue.Empty:
break

# visited url will not be crawled twice
if url in self.visited_urls_set:
continue

# limit rps or rpm
if requests_queued >= self.requests_limit:
runtime_secs = time.time() - start_timer
if runtime_secs < self.interval_limit:
sleep_secs = self.interval_limit - runtime_secs
# exceed rps limit, sleep
time.sleep(sleep_secs)

start_timer = time.time()
requests_queued = 0

self.unvisited_urls_queue.put(url)
self.visited_urls_set.add(url)
requests_queued += 1

提升爬虫效率

对于提升爬虫效率这部分,当前已经有大量的讨论了,重点都是集中在不同的并发机制上面,包括多进程、多线程、asyncio等。

不过,他们的并发测试结果对于本文中讨论的爬虫场景并不适用。因为在本文的爬虫场景中,实现前端页面渲染是最核心的一项功能特性,而要实现前端页面渲染,底层都是需要使用浏览器内核的,相当于每个 worker 在运行时都会跑一个 Chromium 实例。

众所周知,Chromium 对于 CPU 和内存的开销都是比较大的,因此为了避免机器资源出现瓶颈,使用多进程机制(multiprocessing)充分调用多处理器的硬件资源无疑是最佳的选择。

另一个需要注意也是比较被大家忽略的点,就是在页面链接的请求方法上。

请求页面链接,不都是使用 GET 方法么?

的确,使用 GET 请求肯定是可行的,但问题在于,GET 请求时会加载页面中的所有资源信息,这本身会是比较耗时的,特别是遇到链接为比较大的图片或者附件的时候。这无疑会耗费很多无谓的时间,毕竟我们的目的只是为了检测链接资源是否可访问而已。

比较好的的做法是对网站的链接进行分类:

  • 资源型链接,包括图片、CSS、JS、文件、视频、附件等,这类链接只需检测可访问性;
  • 外站链接,这类链接只需检测该链接本身的可访问性,无需进一步检测该链接加载后页面中包含的链接;
  • 本站页面链接,这类链接除了需要检测该链接本身的可访问性,还需要进一步检测该链接加载后页面中包含的链接的可访问性;

在如上分类中,除了第三类是必须要使用 GET 方法获取页面并加载完整内容(render),前两类完全可以使用 HEAD 方法进行代替。一方面,HEAD 方法只会获取状态码和 headers 而不获取 body,比 GET 方法高效很多;另一方面,前两类链接也无需进行页面渲染,省去了调用 Chromium 进行解析的步骤,执行效率的提高也会非常明显。

总结

本文针对如何使用爬虫技术实现 Web 页面资源可用性检测进行了讲解,重点围绕爬虫如何实现 全、稳、快 三个核心特性进行了展开。对于爬虫技术的更多内容,后续有机会我们再进一步进行探讨。

HttpRunner 实现 hook 机制

发表于 2018-05-12 | 更新于 2019-04-03 | 分类于 Development , 测试框架

背景

在自动化测试中,通常在测试开始前需要做一些预处理操作,以及在测试结束后做一些清理性的工作。

例如,测试使用手机号注册账号的接口:

  • 测试开始前需要确保该手机号未进行过注册,常用的做法是先在数据库中删除该手机号相关的账号数据(若存在);
  • 测试结束后,为了减少对测试环境的影响,常用的做法是在数据库中将本次测试产生的相关数据删除掉。

显然,在自动化测试中的这类预处理操作和清理性工作,由人工来做肯定是不合适的,我们最好的方式还是在测试脚本中进行实现,也就是我们常说的 hook 机制。

hook 机制的概念很简单,在各个主流的测试工具和测试框架中也很常见。

例如 Python 的 unittest 框架,常用的就有如下几种 hook 函数。

  • setUp:在每个 test 运行前执行
  • tearDown:在每个 test 运行后执行
  • setUpClass:在整个用例集运行前执行
  • tearDownClass:在整个用例集运行后执行

概括地讲,就是针对自动化测试用例,要在单个测试用例和整个测试用例集的前后实现 hook 函数。

描述方式设想

在 HttpRunner 的 YAML/JSON 测试用例文件中,本身就具有分层的思想,用例集层面的配置在 config 中,用例层面的配置在 test 中;同时,在 YAML/JSON 中也实现了比较方便的函数调用机制,$func($a, $b)。

因此,我们可以新增两个关键字:setup_hooks 和 teardown_hooks。类似于 variables 和 parameters 关键字,根据关键字放置的位置来决定是用例集层面还是单个用例层面。

根据设想,我们就可以采用如下形式来描述 hook 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- config:
name: basic test with httpbin
request:
base_url: http://127.0.0.1:3458/
setup_hooks:
- ${hook_print(setup_testset)}
teardown_hooks:
- ${hook_print(teardown_testset)}

- test:
name: get headers
times: 2
request:
url: /headers
method: GET
setup_hooks:
- ${hook_print(---setup-testcase)}
teardown_hooks:
- ${hook_print(---teardown-testcase)}
validate:
- eq: ["status_code", 200]
- eq: [content.headers.Host, "127.0.0.1:3458"]

同时,hook 函数需要定义在项目的 debugtalk.py 中。

1
2
def hook_print(msg):
print(msg)

基本实现方式

基于 hook 机制的简单概念,要在 HttpRunner 中实现类似功能也就很容易了。

在 HttpRunner 中,负责测试执行的类为 httprunner/runner.py 中的 Runner。因此,要实现用例集层面的 hook 机制,只需要将用例集的 setup_hooks 放置到 __init__ 中,将 teardown_hooks 放置到 __del__ 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Runner(object):

def __init__(self, config_dict=None, http_client_session=None):
# 省略

# testset setup hooks
testset_setup_hooks = config_dict.pop("setup_hooks", [])
if testset_setup_hooks:
self.do_hook_actions(testset_setup_hooks)

# testset teardown hooks
self.testset_teardown_hooks = config_dict.pop("teardown_hooks", [])

def __del__(self):
if self.testset_teardown_hooks:
self.do_hook_actions(self.testset_teardown_hooks)

类似地,要实现单个用例层面的 hook 机制,只需要将单个用例的 setup_hooks 放置到 request 之前,将 teardown_hooks 放置到 request 之后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Runner(object):

def run_test(self, testcase_dict):

# 省略

# setup hooks
setup_hooks = testcase_dict.get("setup_hooks", [])
self.do_hook_actions(setup_hooks)

# request
resp = self.http_client_session.request(method, url, name=group_name, **parsed_request)

# teardown hooks
teardown_hooks = testcase_dict.get("teardown_hooks", [])
if teardown_hooks:
self.do_hook_actions(teardown_hooks)

# 省略

至于具体执行 hook 函数的 do_hook_actions,因为之前我们已经实现了文本格式函数描述的解析器 context.eval_content,因此直接调用就可以了。

1
2
3
4
def do_hook_actions(self, actions):
for action in actions:
logger.log_debug("call hook: {}".format(action))
self.context.eval_content(action)

通过以上方式,我们就在 HttpRunner 中实现了用例集和单个用例层面的 hook 机制。

还是上面的测试用例,我们执行的效果如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ hrun tests/httpbin/hooks.yml
setup_testset
get headers
INFO GET /headers
---setup-testcase
INFO status_code: 200, response_time(ms): 10.29 ms, response_length: 151 bytes
---teardown-testcase
.
get headers
INFO GET /headers
---setup-testcase
INFO status_code: 200, response_time(ms): 4.46 ms, response_length: 151 bytes
---teardown-testcase
.

----------------------------------------------------------------------
Ran 2 tests in 0.028s

OK
teardown_testset

可以看出,这的确已经满足了我们在用例集和单个用例层面的 hook 需求。

进一步优化

以上实现已经可以满足大多数场景的测试需求了,不过还有两种场景无法满足:

  • 需要对请求的 request 内容进行预处理,例如,根据请求方法和请求的 Content-Type 来对请求的 data 进行加工处理;
  • 需要根据响应结果来进行不同的后续处理,例如,根据接口响应的状态码来进行不同时间的延迟等待。

在之前的实现方式中,我们无法实现上述两个场景,是因为我们无法将请求的 request 内容和响应的结果传给 hook 函数。

问题明确了,要进行进一步优化也就容易了。

因为我们在 hook 函数(类似于$func($a, $b))中,是可以传入变量的,而变量都是存在于当前测试用例的上下文(context)中的,那么我们只要将 request 内容和请求响应分别作为变量绑定到当前测试用例的上下文即可。

具体地,我们可以约定两个变量,$request和$response,分别对应测试用例的请求内容(request)和响应实例(requests.Response)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Runner(object):

def run_test(self, testcase_dict):

self.context.bind_variables({"request": parsed_request}, level="testcase")

# 省略

# setup hooks
setup_hooks = testcase_dict.get("setup_hooks", [])
self.do_hook_actions(setup_hooks)

# request
resp = self.http_client_session.request(method, url, name=group_name, **parsed_request)

# teardown hooks
teardown_hooks = testcase_dict.get("teardown_hooks", [])
if teardown_hooks:
self.context.bind_variables({"response": resp}, level="testcase")
self.do_hook_actions(teardown_hooks)

# 省略

在优化后的实现中,新增了两次调用,self.context.bind_variables,作用就是将解析后的 request 内容和请求的响应实例绑定到当前测试用例的上下文中。

然后,我们在 YAML/JSON 测试用例中就可以在需要的时候调用$request和$response了。

1
2
3
4
5
6
7
8
9
10
11
12
- test:
name: headers
request:
url: /headers
method: GET
setup_hooks:
- ${setup_hook_prepare_kwargs($request)}
teardown_hooks:
- ${teardown_hook_sleep_N_secs($response, 1)}
validate:
- eq: ["status_code", 200]
- eq: [content.headers.Host, "127.0.0.1:3458"]

对应的 hook 函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def setup_hook_prepare_kwargs(request):
if request["method"] == "POST":
content_type = request.get("headers", {}).get("content-type")
if content_type and "data" in request:
# if request content-type is application/json, request data should be dumped
if content_type.startswith("application/json") and isinstance(request["data"], (dict, list)):
request["data"] = json.dumps(request["data"])

if isinstance(request["data"], str):
request["data"] = request["data"].encode('utf-8')

def teardown_hook_sleep_N_secs(response, n_secs):
""" sleep n seconds after request
"""
if response.status_code == 200:
time.sleep(0.1)
else:
time.sleep(n_secs)

值得特别说明的是,因为 request 是可变参数类型(dict),因此该函数参数为引用传递,我们在 hook 函数里面对 request 进行修改后,后续在实际请求时也同样会发生改变,这对于我们需要对请求参数进行预处理时尤其有用。

更多内容

  • 中文使用说明文档:http://cn.httprunner.org/advanced/request-hook/
  • 代码实现:GitHub commit

HttpRunner 再议参数化数据驱动机制

发表于 2018-03-25 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在《HttpRunner 实现参数化数据驱动机制》一文中,我们实现了参数化数据驱动的需求,并阐述了其设计思路的演变历程和开发实现涉及的核心要素。

问题及思考

经过一段时间的实际应用后,虽然参数化数据驱动的功能可以正常使用,但终究感觉不够优雅。

概括下来,主要有如下 4 个方面。

1、调用方式不够自然,描述方式比较繁琐。

1
2
3
4
5
- config:
name: "user management testset."
parameters:
- user_agent: Random
- app_version: Sequential

描述参数取值方式的时候,需要采用Sequential和Random来进行指定是要顺序取值还是随机乱序取值。暂且不说Sequential这个单词大家能否总是保证拼写正确,绝大多数情况下都是顺序取值,却也总是需要指定Sequential,的确会比较繁琐。

2、即使是简单的数据驱动场景,也同样需要准备 CSV 文件,问题复杂化。

指定数据驱动的数据源时,必须创建一个 CSV 文件,并将参数化数据放置在其中。对于大数据量的情况可能没啥问题,但是假如是非常简单的场景,例如上面的例子中,我们只需要对app_version设定参数列表 ['2.8.5', '2.8.6'],虽然只有两个参数值,也同样需要去单独创建一个 CSV 文件,就会显得比较繁琐了。

试想,假如对于简单的参数化数据驱动场景,我们可以直接在 YAML/JSON 测试用例中描述参数列表,如下所示,那就简单得多了。

1
2
3
4
5
- config:
name: "user management testset."
parameters:
- user_agent: ['iOS/10.1', 'iOS/10.2', 'iOS/10.3']
- app_version: ['2.8.5', '2.8.6']

3、无法兼顾没有现成参数列表,或者需要更灵活的方式动态生成参数列表的情况。

例如,假如我们期望每次执行测试用例的时候,里面的参数列表都是按照特定规则动态生成的。那在之前的模式下,我们就只能写一个动态生成参数的函数,然后在每次运行测试用例之前,先执行函数生成参数列表,然后将这些参数值导入到 CSV 文件中。想想都感觉好复杂。

既然 HttpRunner 已经实现了在 YAML/JSON 测试用例中调用函数的功能,那为啥不将函数调用与获取参数化列表的功能实现和描述语法统一起来呢?

试想,假如我们需要动态地生成 10 个账号,包含用户名和密码,那我们就可以将动态生成参数的函数放置到 debugtalk.py 中:

1
2
3
4
5
6
7
8
def get_account(num):
accounts = []
for index in range(1, num+1):
accounts.append(
{"username": "user%s" % index, "password": str(index) * 6},
)

return accounts

然后,在 YAML/JSON 测试用例中,再使用 ${} 的语法来调用函数,并将函数返回的参数列表传给需要参数化的变量。

1
2
3
- config:
parameters:
- username-password: ${get_account(10)}

实现了这一特性后,要再兼容从 CSV 文件数据源中读取参数列表的方式也很简单了。我们只需要在 HttpRunner 中内置一个解析 CSV 文件的 parameterize 函数(也可以简写为 P 函数),然后就可以在 YAML/JSON 中通过函数调用的方式引用 CSV 文件了。如下例中的 user_id 所示。

1
2
3
4
5
6
- config:
name: "demo"
parameters:
- user_agent: ["iOS/10.1", "iOS/10.2", "iOS/10.3"]
- user_id: ${P(user_id.csv)}
- username-password: ${get_account(10)}

这样一来,我们就可以优雅地实现参数列表数据源的指定了,并且从概念理解和框架实现的角度也能完成统一,即对于 parameters 中的参数变量而言,传入的都是一个参数列表,这个列表可以是直接指定的,可以是从 CSV 文件中加载的,也可以是通过调用自定义函数动态生成的。

4、数据驱动只能在测试用例集(testset)层面,不能针对单个测试用例(testcse)进行数据驱动。

例如,用例集里面有两个接口,第一个接口是获取 token,第二个接口是创建用户(参考 QuickStart 中的 demo-quickstart-6.json)。那么按照之前的设计,在 config 中配置了参数化之后,就是针对整个测试用例集(testset)层面的数据驱动,使用每一组参数运行的时候都要先执行第一个接口,再执行第二个接口。

这可能就跟我们预期的情况不一样了。假如我们期望的是只针对第二个接口做数据驱动,即第一个接口只需要调用一次获取到 token,然后使用参数列表中的数值分别调用第二个接口创建用户,那么之前的方法就行不通了。

既然有这类需求,因此数据驱动也应该具有作用域的概念。

类似于定义的 variables,定义在 config 中是全局有效的,定义在 test 中就只对当前测试用例有效。同样地,我们也可以针对 parameters 增加作用域的概念,若只需实现对当前用例(testcase)的参数化数据驱动,就可以将 parameters 配置放置到当前 test 中。

新的实现

想法明确了,改造实现也就比较简单了。

从版本 1.1.0 开始,HttpRunner 便支持了上述新的数据驱动方式。详细的使用方法,可参考如下使用说明文档:

http://cn.httprunner.org/advanced/data-driven/

至此,HttpRunner 的数据驱动机制就比较完善和稳定了,应该可以解决绝大多数数据驱动场景的需求。

遗留问题

不过,还有一类场景暂时没有实现支持,即需要根据先前接口返回结果来对后续接口进行数据驱动的情况。

以如下场景为例:

  • 加载用户列表,获取当前用户列表中的所有用户;
  • 依次对每一个用户进行点赞或者发送消息的操作。

这和前面的第三条有点类似,都需要先动态获取参数列表,然后再使用获取得到的参数列表进行数据驱动。但也存在较大的差异,即获取用户列表的操作也是测试场景的一部分,并且通常因为需要共享 session 和 cookies,因此不能将第一步的请求放置到 debugtalk.py 中。

之前的一个想法是,在第一个接口中,将结果返回的用户列表提取(extract)出来保存至变量(user_list),然后在后续需要做数据驱动的接口中,在 parameters 中引用前面提取出的用户列表($user_list);若有需要,还可以自定义函数(parse_users),将前面提取出来的用户列表转换至框架支持的格式。

1
2
3
4
5
6
7
8
9
10
11
- test:
name: load user list
request: {...}
extract:
- user_list: content.users

- test:
name: send message to user
parameters:
- user: ${parse_users($user_list)}
request: {...}

这个方式乍一看是可行的,但实际却是行不通的。

问题在于,在 HttpRunner 的数据驱动机制中,采用参数列表构造测试用例是在初始化阶段,做的工作主要是根据参数列表中的数据生成测试用例并添加至 unittest 的 TestSuite 中,此时测试用例还没有进入执行环节,因此也没法从接口的响应结果中提取参数列表。

若非要解决这个问题,针对 test 的数据驱动,可以将解析 parameters 的实现放置到 request 中;这的确可以实现上述场景中的功能,但在测试用例执行统计方面就会出现问题。以该场景为例,假如获取到的用户列表有100个用户,那么整个用例集将执行101次测试用例,但最终生成的测试报告中却只会展示运行了2条测试用例。

针对该场景,我还没有想到很好的解决方案,暂且将其作为一个遗留问题吧。若你有比较好的实现方案,欢迎反馈给我,或者直接提交 PR。

12…8
solomiss

solomiss

75 日志
11 分类
50 标签
GitHub E-Mail
Creative Commons
© 2019 solomiss