[{"categories":["Growth"],"collections":null,"content":"距离 2023 年 5 月延期发布我的 2022 年终总结后，转眼间又快两年时间了。连续坚持了六七年的年终总结「个人仪式活动」也中断了，虽然写了初稿，中间也挣扎过多次，但终究还是没有完成，颇为遗憾。 究其原因，可能是前几年的年终总结越写越多，无形中给了自己一些压力和负担。但换个角度，年终总结本来就是记录自己的经历，最核心的读者就是未来的自己，因此没啥好纠结的。我打算趁着这个春节假期间隙，不管怎样还是要对自己最近两年的工作和生活做点总结回顾，清空过往然后继续前行，迎接全新的 2025。 ","date":"2025-03-07","heading":"","objectID":"/post/my-2324-summary/:0:0","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 回顾我的 23/24 年，还是感慨良多的，迷茫过，奋斗过，经历过挫折，收获过喜悦，个人职业发展路线和心态也做了些调整。 ","date":"2025-03-07","heading":"工作","objectID":"/post/my-2324-summary/:1:0","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"工作方向确定 首先在工作方向方面，之前有记录过，由于身处横向的专项测试团队，又是一个从头组建的团队，因此前些年一直都处于探索方向的状态。做过技术中台组件，也尝试过 ToB，但都没有系统性做起来。在 22 年下半年，我们开始投入在体验评测方向，核心思路是基于评测帮助抖音等内部产品明确行业竞争力水位，协同业务挖掘高价值优化点并提供优化建议。 幸运的是，这个方向经过验证是可行的。我们也与多个内外部团队建立了紧密的协作，基于性能体验评测先后拓展了带宽成本评测、播放器性能评测、音画质评测、内容监测、交互体验评测等方向，持续支撑了搜索/直播/点播/小说等十多个垂类业务和公司级重点专项，成长为公司内最大的评测解决方案提供方。 我简要介绍下各个评测方向的重点和难点吧。 性能体验评测，重点是关注耗时/流畅度/功耗/物理资源等性能指标。源于对指标精度的高要求，我们研发了基于高帧率互拍机架 + OCR/CV 视觉驱动的方案，尽可能降低采集方案对设备和被测 app 的性能影响，迭代完善了基于视频分帧 + CV 预标注 + 打通标注资源的指标提取能力，并沉淀了针对流畅度、功耗等指标的精细化评测方法。与此同时，我们也与兄弟团队协作，重点建设了诊断归因的能力，目标是将黑盒的体验指标下钻拆解为技术指标，例如函数耗时（Trace 分析）、网络耗时拆分、后台任务诊断分析、资源性能（磁盘IO/功耗/内存）等，将问题定位到更细粒度的具体环节，帮助产研团队明确问题根因和提供优化建议。 带宽成本评测，起源于降本增效的大背景，整体思路是基于线下分布式机架数据采样，探测分析码率/浪费率/CDN 类型占比等影响带宽成本的指标。由于指标置信度与推荐内容强相关，因此其中一个很大的难点就是实现自动化对用户行为画像的仿真，让刷到的视频内容/类型分布尽可能与线上大盘一致。在这个过程中，我们也迭代了多版仿真方案，优化指标置信度的同时，也对推荐系统有了更多认知。 播放器性能和音画质评测，核心是要帮助业务回答「对齐码率的情况下谁的画质更好、对齐画质的情况下谁的码率更低」基础性问题，并实现精确归因和策略分析。围绕这一目标方向，我们进行了大量的技术攻坚，重点是针对播放策略的影响因素构建精细化的变量控制能力，包括同源视频对比、设备状态控制、网速关联、垂类视频内容打标、用户行为特征仿真等，实现对播放器垂类精细化策略/算法的探索性分析。在此基础上，我们也沉淀了播放器评测标准和解决方案，并落地了平台化能力，横向迁移复用至更多的播放类业务场景。 随着抖音体验优化进入深水区，精细化体验优化成为重点发力方向，内容体验、交互体验等泛体验方向的优化预计会带来更多的优化空间。内容体验监测的难点在于数据源的获取，以及需要结合自动化仿真工程，在时限约束条件下实现尽可能高的内容采集覆盖度。而交互体验，难点在于如何从主观的交互体验中提炼出客观可验证的范式，并实现线下规模化、多场景的问题走查及研判，最终转化为可供消费的优化点。在 24 年下半年，我们已经与兄弟团队协作初步完成了技术可行性和价值验证，预计 25 年会持续重点投入，建设形成体系化的评测能力。 最近两年的工作内容大概就是这些。在这些概述总结的名词背后，充满了太多艰辛。交付压力倒还是次要的，主要还是每一次新方向的探索，常常都伴随着未知迷茫和自我怀疑的煎熬，堪比一次次小型的创业。但不管怎样，团队总算是有了一个明确的长期工作方向，我个人也跟「评测技术」深度绑定了。 说到这里，当初刚毕业就进入工信部电子五所软件评测中心，从事 CNAS 第三方软件功能/性能测评工作，结果兜兜转转，最后到抖音以后又回归到了评测技术能力体系的建设，感觉自己跟「评测」还挺有缘分的。 ","date":"2025-03-07","heading":"工作方向确定","objectID":"/post/my-2324-summary/:1:1","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#工作方向确定"},{"categories":["Growth"],"collections":null,"content":"个人角色转变 作为互联网技术从业者，基本上都无法绕开一个问题，那就是技术和管理路线的选择问题。 从之前每年的年终总结可以看出，这个问题在我身上尤其突出。纠结的原因，一方面是出于本心，我个人更喜欢做一个闷头写代码的工程师，但另一方面，国内大环境的主流声音又无时无刻都在告诉我，纯写代码很难有发展空间，甚至会早早被淘汰。 也正是因为有这些纠结，这些年我一直纠结着长期发展路线的选择问题。早在 2017 年在大疆时，我开始「被动」承担技术管理的角色，19 年主动选择离职到字节跳动做一线工程师，21 年在抖音又开始协助 leader 组建成都团队，这些年期间或主动或被动一直都兼具了一些技术管理的职责。 在 23 年下半年的时候，团队开始暴露出了一些子方向之间技术方案割裂、凝聚力下降的问题。在一次与 leader 深入沟通之后，我开始意识到自己在团队管理方面不称职的严重性。从当时个人职责范围来看，协助 leader 管理团队人员是必须且必要的，只是「被动」 与「主动」的差异。而自己一直以来整体在思想上偏「被动」，时间虽耗费了不少，效果却只能勉强达到三四十分。既然如此，不如当作一项重点工作「主动」去做，优化流程和效率，争取达到七八十分。 在扭转心态之后，我从 23 年国庆后也尝试做了较多的转变。包括主动与团队成员 1on1 沟通、组织团队内部双周会、牵头梳理评测技术原子能力等，帮助团队成员加强协作和沟通。除了这些常规的动作之外，最主要的还是我自己工作方式的调整。在面对每一份工作事项时，我强迫自己尽量避免一头扎进去思考技术方案，而是主动思考这些工作应该交给谁来做更合适。甚至本着矫枉必须过正的想法，我还强迫自己放下写代码的执念，将开发工作通通分配出去，让自己只关注在重点问题拆解和风险把控层面。 就这样持续了差不多大半年的时间，团队之前的问题有了些好转，但整体也只能算平平无奇，只能说没出啥大的问题。与此同时，团队由于转型升级的需要，leader 决定从外部引入更高阶的管理者，而我将转变为评测技术架构师的角色，聚焦在评测技术体系的建设工作。还记得在那次跟 leader 沟通时第一次得知到这个决策信息后，怎么说呢，我内心虽然出现了一些被否定的失落，但经过短暂的思考后，我更多的感受是如释重负。 强迫自己转型团队管理的角色，强迫自己放下写代码的执念，强迫自己关注团队人员大于关注技术实现方案，虽然这些从主流职业发展的角度可能是更正确的做法，但对我个人而言终究充满了太多的强迫和刻意。我内心深处，一直以来还是想做好一名「工匠」，相比于激励人员成长之类的管理工作，亲手打磨工具产品、设计实现有挑战的技术方案显然会有更高的成就感。因此，leader 的决策对我而言未尝不是一件好事儿，很多事情只有自己亲身经历了、尝试了，才知道什么才是更适合自己的。 从 24 年国庆之后，我就开始切换至新的角色定位了。这里我也简单聊聊自己对新的角色的认知吧。 首先，作为架构师，写代码是必须的，但绝不能只是闷头写代码做交付实现。如果只是承接大量需求深陷项目一线做开发交付，即使做得再多再好，那也只能算是「以战术的勤奋代替战略的懒惰」。对于架构师（公司级技术专家）而言，更重要的还是要具备行业视野、前瞻性，明确重点投入方向，即规划做什么；而对于技术实现如何做，重点是要提升架构的可复用性、可维护性、可拓展性，目标是提升团队的人效和 ROI。 其次，架构师虽然不负责团队管理工作，但项目的规模和复杂性决定了，架构师同样需要组织协调团队内外部人员做好项目攻坚和风险把控，因此关注人的因素仍然是必须且必要的。只是，相比于团队管理者的绩效权，架构师更多需要建设自身的技术影响力，让别人愿意跟你合作，以及建立良好的协作机制，来保障项目协作顺利推进。在 24 年底，我重点投入了可观测性治理的工作，针对所有评测自动化相关组件实现了状态码规范定义和子类拆分，上线了面向各评测业务方向、评测基建、评测技术组件等视角的指标看板，并与相关协作团队明确了人员的职责范围和负责的指标，初步建立了数据驱动研发迭代优化的协作基础。 坦诚讲，当前我还不算是一个合格的架构师。继续努力吧，期望明年年终总结时会有新的感悟和认知。 ","date":"2025-03-07","heading":"个人角色转变","objectID":"/post/my-2324-summary/:1:2","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#个人角色转变"},{"categories":["Growth"],"collections":null,"content":"职业路线思考 还有三四年我就要进入「不惑」之年了，但展望更长远的未来，我的下半场职业发展路线是什么？我个人的核心竞争力在哪里？ 最近我一直在思考这方面的问题，暂时先记录一些初步的想法吧。 从过往经历看，毕业后这十多年我一直都在软件质量保障这个大方向，基本上大多岗位都尝试过了。 软件测试/质量管理：工作早期有短暂接触过，这些方向的专业性主要体现在业务理解和沟通协调推动能力，职业发展后期基本上都要往质量管理、业务测试负责人方向发展。虽然当前市面上大多高阶岗位都集中在这些方向，但这应该是属于我既不擅长，也不感兴趣的方向。 测试开发/质量研发：这是我之前做得最多的工作，主要是面向质量方向开发工具平台类产品，包括 UI/接口/性能测试工具平台，辅助测试提效是最核心的目标。对于该方向，优秀的软件开发能力是基础，更重要的要有产品思维，以打磨产品的心态进行研发运营迭代。辩证地看，单纯的测试提效天花板会比较低，但在降本增效的大背景下，测试提效仍具有重要的价值。 专项测试：理论上囊括的范围会比较广，常规功能性测试之外的大多都可以算作专项测试。我之前做得最多的是服务端性能测试，进入抖音专项测试团队之后，最近几年主要聚焦在围绕客户端的行业评测。总体来说，这个方向具有更强的技术专业性，甚至高于一般的业务研发，例如防劣化需要更深入系统底层，评测需要紧密结合垂类专业领域（成本/性能/播放器/音画质/交互体验等）。从市场岗位需求来看，交叉学科领域专家相比单一的业务测试/测试开发会更有竞争力。 结合个人经验和兴趣的话，我的 PlanA 是在「评测技术」方向持续深耕，目标是成长为行业级的评测技术专家。从需求上看，行业会不断变化，公司重点也会不断调整，但竞争是永远不会消失的，基于行业评测挖掘优化点加速自身产品迭代优化的需求会一直存在。关键还是要对行业动态和公司业务发展保持敏感度，并不断迭代完善评测技术体系，让评测工作成为业务侧依赖的基础能力。 但与此同时，我也时常会想到可能存在的风险。从打工的角度，我从事的工作方向变得越来越细分，细分到只有少数大公司才会有相关岗位需求，特别是在成都这个二线互联网城市更是如此。鉴于行业大环境的现实情况和看到的一些案例，我对于自己在公司还能干多久并没有足够的信心。而一旦哪天被动毕业了，基本上就大概率失业了，这确实是不得不考虑的现实问题。 因此，从风险应对的角度，我也需要提前考虑 PlanB 的方案，早做准备，避免到时太过被动。 当前基本明确的想法是，先做好打工人的工作，尽可能延长在字节跳动的职业年限，能多干一年是一年，等哪天真被毕业了就不再找工作了。一是考虑到自身年龄大了，再找工作也很难遇到合适的。二是打工了太久，多多少少有些厌倦了。之前考虑的最多的是做一个独立开发者或自由职业者，特别是当前 AI 大模型技术日益成熟，超级个体、一人企业之类的成功案例也越来越多，让这条路的可能性变得更大了。我对这个方向一直挺感兴趣的，但也清楚这条路本质上是要找准商业化模式，并与市场直接交易，这对个人的产品/市场/运营/技术等综合性能力要求会相比打工高太多。因此在当前工作期间，我也需要关注个人自身成长，锻炼自己不依赖资源做事的能力。 无论如何，不管是 PlanA 还是 PlanB，最重要的还是要保持初心，持续学习，将内心的焦虑转化为实际的行动。也许这就是一项核心竞争力（respect hy）。 ","date":"2025-03-07","heading":"职业路线思考","objectID":"/post/my-2324-summary/:1:3","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#职业路线思考"},{"categories":["Growth"],"collections":null,"content":"生活 再来回顾下最近两年生活中印象较深的事件吧。 ","date":"2025-03-07","heading":"生活","objectID":"/post/my-2324-summary/:2:0","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"搬新家 首先是住房方面，之前在成都买的新房在延期整整一年之后总算交房了。整个过程充满了坎坷，开发商半死不活，房子差点烂尾，我也参加了好几次维权活动，最严重的一次还因为拉横幅有几个业主被抓去了派出所，业主微信群也被解散了。 庆幸的是，最终在业主努力和政府干预下，楼盘成功交房并完成了产权办理，守住了最基本的保障。除此之外，房子质量方面比较惨，除了大的工区和外立面问题得到了整改，室内的各种问题基本只能由业主自行买单了。而之前宣传的八元一平高端物业服务就更一言难尽了，由于开发商留下了太多坑，物业硬件实在是拉跨，只有看后面是否能成功更换物业了。 不管怎样，我们总算搬进新家安定了下来，结束了十多年来频繁搬家的漂泊状态。 ","date":"2025-03-07","heading":"搬新家","objectID":"/post/my-2324-summary/:2:1","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#搬新家"},{"categories":["Growth"],"collections":null,"content":"家庭时光 不管在外面遇到再多挫折和困难，家庭总是我最坚强的后盾。 小坚果哥哥已经褪去了萌娃的稚嫩，小小的身体开始有了大大的梦想。当前他最大的梦想是挣很多很多的钱，独自开着房车听着音乐环游世界。不过进入到了「狗都嫌弃」的年龄阶段，有时候我也忍不住想踹他两脚😂。也不知道他哪来那么旺盛的精力，一身牛劲使不完，早上起得特别早，白天基本不睡午觉，时而还特别聒噪。然后我们发现他的运动天赋还真挺不错，跳绳一分钟能到一百四十多，骑车四五十公里完全不累，参加一年级运动会 50 米短跑居然获得了年级第一（9.11 秒），被体育老师看中拉进了校队。在性格方面，坚果一直都挺温和的，会照顾妹妹，比较讨女孩子们喜欢，我们基本不用担心他以后找不到女朋友了。在教育方面，我们还没有开始鸡学习，给他报的兴趣班和社团活动都是体能运动方面的，并且尽量鼓励他去锻炼一些胆量，包括在国旗下背古诗、做小演讲，以及报名竞选幼儿园毕业典礼主持人。而我也尽量给他做好表率，在幼儿园毕业典礼上作为家长代表上台进行了致辞，对他的未来表达了老父亲的祝福。 小云朵妹妹今年两岁了，到了最可爱的年龄阶段，经常会忍不住想亲她一口（跟哥哥形成了鲜明的对比）。之前妹妹刚出生时黄疸值超标差点要换血治疗，有一定概率会变成智障或失聪。庆幸的是，妹妹各项生长指标都挺好的，不到一岁就展现了很强的语言表达能力，总算让我们松了口气，后面每次接到华西医院定期回访电话时都后怕不已。在性格方面，我深刻见识到了「龙生九子各有不同」的含义，妹妹相比哥哥会更急躁，也像我小时候一样特别「浑（叛逆）」，要是没关注到她的情绪或者没有尊重她的想法，那后果是相当严重的。云朵妈妈也时常感叹，要是之前小坚果的脾气是这样的话，估计就不想再生二胎了。当然啦，小云朵还是非常聪明可爱的，而且特别爱分享，经常第一口吃的都会先主动喂给我，让我切身感受到小棉袄的温暖😄。 跟孩子一起玩是快乐幸福的，但要是一直陪孩子也会特别抓狂，我曾遇到过带娃一上午就崩溃的情况。这里非常感谢我老婆，全靠她用心陪伴照顾两个孩子，让我可以全身心投入在工作事业上。在孩子们身上，她确实投入了太多心血。之前发现坚果远视储备不足后，她特别焦虑，甚至在大夏天中午专门跑去幼儿园接坚果出来提升户外时长，然后下午前再送回去，日复一日，想想都好累。另外，因为有两个孩子的牵盼，她也基本上牺牲了自己的职业发展，当前职场大环境确实对育儿的女性太不友好了。虽然我偶尔会担忧家庭单一收入来源的风险，但考虑到孩子们的成长，现阶段的分工配合仍然会是更好的选择。今年，她开始尝试自媒体路线，围绕亲子旅行、育儿、近视防控等主题发布了一系列视频。虽然运营数据方面还没太大起色，但视频内容我觉得挺好的，也欢迎大家关注支持下。 随着小云朵长大了些，我们又可以开始出去旅行了。在最近两年，我们一家四口去了不少地方，让孩子们印象最深的有两个。一个是上海之旅，坐了 C919 和磁悬浮列车，住了树屋酒店，游玩了迪士尼，上了东方明珠塔，参观了天文博物馆，坚果还结交了一个好朋友。另一个是青甘大环线房车之旅，从成都出发往返 5000 公里，饱览了壮阔的大西北风光，215 国道上驱车追逐过落日，无人机俯瞰见证了石油小镇遗址的荒凉，体验了鸣沙山月牙泉万人大合唱之后的沙尘暴，嘉峪关戈壁滩上骑了骆驼，门源高原大草原上骑了马，还偶遇了一对房车旅行 99 天环游全中国的新婚恋人。另外呢，我也经历了无人区没油的恐慌和两次行车过程中的有惊无险，突破了 9 天五千多公里的自驾记录。 ","date":"2025-03-07","heading":"家庭时光","objectID":"/post/my-2324-summary/:2:2","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#家庭时光"},{"categories":["Growth"],"collections":null,"content":"年度体检 最后再说下我自己，今年的年度体检报告不太乐观，有多项指标都暴露了些问题。 在我去华西医院做增强 CT 复查时，跟好多脸色沉重的病人们一起排队，手腕扎留置针，躺在核磁检查仪器床上，被机器反复推入推出拍光片。我知道我应该还没啥问题，但联想到万一以后哪天真的身体出现了严重的问题，我该怎么办，我的家人们该怎么办。那一刻我竟然变得有些紧张和害怕了。 也好，这次年度体检给我再次敲了一次警钟，我需要开始做些改变了。其实应对方案也比较明确，管住嘴，迈开腿。晚饭尽量少吃，夜宵/零食是不能再碰了。锻炼方面，每天单独去健身房比较难，我就选择走路去公司。单程 3.5 公里，快步走大概 37 分钟的样子，相比坐公交也多不了多少时间。关键还是重在坚持。 ","date":"2025-03-07","heading":"年度体检","objectID":"/post/my-2324-summary/:2:3","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#年度体检"},{"categories":["Growth"],"collections":null,"content":"展望 总算把这两年的经历和想法沉淀为文字了，心情也舒畅了不少。 最后再展望下 2025 年吧。 1、工作方面，持续投入在评测技术体系建设方向。业务层面会重点聚焦在泛体验新方向，包括交互体验和内容体验；技术层面会重点结合 AI 大模型技术实现评测技术架构升级改造，全面提升评测效能、评测置信度和业务场景覆盖度。 2、技能方面，重点加强播放器/音画质相关领域知识的学习，并在大模型技术应用落地的过程中加深对相关底层技术原理的理解。加强个人技术输出，以技术博客和专利申请倒逼自己更高效的学习。 3、生活方面，尽量平衡好工作和生活，周末多增加些家庭户外运动，争取再找个时间完成一趟不一样的家庭旅行。 4、健康方面，重点是管住嘴迈开腿，优化饮食结构，坚持步行上班，期待下次年度体检指标能有显著改善。 ","date":"2025-03-07","heading":"展望","objectID":"/post/my-2324-summary/:3:0","tags":["年终总结"],"title":"我的 2324 年终总结","uri":"/post/my-2324-summary/#展望"},{"categories":["Growth"],"collections":null,"content":" 这份 2022 年终总结原本应该是在春节期间完成的，但当时因为拖延症只零零散散写了不到一半。春节复工后又一直在忙工作上的事情，都到了 5 月份还没下定决心动工。最近这个周末我迎来了 35 岁这个互联网圈的魔咒年龄，我想，坚持了六七年每年一篇的年终总结不能就这么断了，于是就趁着这个周末，花了 2 天时间补上了，心里总算是松了口气。 在 2021 年春节假期快结束的时候，我发布了我的 2021 年终总结。怀揣着创业的激情，带着对 2022 年的憧憬，我踏上了回成都的路。 转眼间一年又过去了，回首整个 2022 年，于我而言是充满艰辛的一年，也是收获颇丰的一年。不管怎样，我想在本文中记录下我的 2022，清空自己然后继续前行，迎接全新的 2023。 ","date":"2023-05-13","heading":"","objectID":"/post/my-2022-summary/:0:0","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 首先从工作方面开始吧。 在 2021 年初，基于之前的 ToB 探索和思考（详见我的 2021 年终总结），我和团队计划将工作重心投入在信创性能测试工具（QuickRunner）和体验评测两个方向，前者是面向公司外部客户做商业化探索，后者是面向公司内部业务寻求业务价值收益。总体来说，这两个方向都处于从 0 到 1 的阶段，于我个人而言，这一年是充满艰辛和曲折的，一方面是感受到了前所未有的身心疲惫（不断探索试错），另一方面是经历了入职字节近四年以来最大的挫折挑战（结果价值导向）。 在「钱多事少离家近，位高权重责任轻」的世俗眼光里，我这一年的个人 ROI 是比较低的。但我更倾向于从长期价值的角度看问题，并不后悔这一年的「折腾」，并且恰恰相反，我非常感谢能有这么一次历练的机会，让我获得了全方位的成长。 接下来我就结合具体项目方向来进行下回顾和总结。 ","date":"2023-05-13","heading":"工作","objectID":"/post/my-2022-summary/:1:0","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"QuickRunner \u0026amp; 商业化探索 对于信创性能测试工具（QuickRunner）这个项目，我们初期的目标是比较明确的，那就是重点面向信创领域研发一款具有行业竞争力的性能测试工具，在信创产业市场中寻求一些商业化机会；同时通过对底层核心开源组件 HttpRunner 的用户社区运营，触达更多的外部客户，为我们的其它 ToB 商业化产品和服务进行导流。 在开始这个项目的时候，我是非常兴奋的。在我之前的文章中，我多次提到了「创业心态」，近些年我也一直在尝试以创业者的视角来思考自己的工作，想象如果我是在创业，我会怎么去做；而 QuickRunner 这个项目给了我一个非常好的商业化探索机会，我终于可以真枪实弹地体验真实的创业过程了。其次是性能测试工具这个方向，这应该算是我的老本行了，从毕业至今的多半时间我都是围绕着性能测试在转；只是之前我大都是作为成熟商业化产品和开源项目的用户，或者面向公司内部开发工具平台，而这次我有机会带领团队面向市场打造理想中的商业化工具产品，成就感自然是不一样的。 兴奋归兴奋，既然决定要投入这个方向，那我们的产品就必须要具备足够的行业竞争力，乃至成为细分领域 top 才会有机会。作为项目负责人，我也感受到了前所未有的挑战和压力。 首先是项目方向方面。为什么选择信创方向？为什么选择性能测试工具方向？行业产值有多大？产品的价值是什么？产品要解决什么痛点，是刚需还是消费升级？目标客户有哪些？我们的市场机会在哪里？这里需要进行大量的调研和思考，说服自己，说服领导，提升团队成员和相关合作方的信心。然后是竞争环境方面。行业内还有哪些玩家？市场份额是怎样的？他们的产品有什么亮点？主要客户有哪些？客户采购方式是怎样的？利润来源在哪里？最后也是最重要的，我们的产品要做成什么样子？产品路线是怎样的？我们相比竞品会有什么优势？我们的壁垒是什么？我们靠什么去赢？ 需要做什么才能让客户离不开我们？ 这一系列的问题确实挺令人头大的，因为很多问题本身就没有明确的答案。特别是在目标用户选择和需求痛点方面，我们闷头想是想不清楚的，必须得尽可能早尽可能多地触达外部用户，收集最真实的用户反馈。 那如何高效地触达目标用户呢？这块儿的思路比较清晰，那就是借助 HttpRunner 开源社区，因为 HttpRunner 最开始的能力属性就是围绕接口和性能测试，并且已经在行业内积累了一定的知名度和用户群体，预计会与 QuickRunner 目标用户存在一定的重叠。基于该思路，我在 2022 年初的时候面向 HttpRunner 用户发布了一份调研问卷，目标是在 HttpRunner 用户群体中筛选出一批种子用户，重点包括信创客户、有采购预算的用户、乐于共创成功案例的客户，以及处于出海、金融证券、国企央企的行业群体，并组建了核心用户群。同时，针对匹配度和意向度均较高的用户，我们还与他们约了视频会议进行重点访谈和交流，收集他们实际工作中的痛点；而且通常情况下，他们之前也有使用过其它相关产品，那其它产品在哪些方面做得比较好，哪些方面无法满足他们的需求，也都是我们需要重点关注的内容。 借助这一轮问卷调研和用户访谈，我们收集到了 500+ 用户的问卷反馈，重点触达了米哈游、有米科技、环球医疗等 10+ 外部客户，并收获了多位行业知名专家的指导建议。在此非常感谢大家的热心支持🌹，帮助我们全方位审视了 HttpRunner 的优势和不足，进一步明确了 QuickRunner 的发力方向。 在接下来的时间里，产品、UED 设计、前后端研发、测试、运营（共同兼职），大家通力配合密切协作，整个团队都干劲十足，共同为了一个目标而奋斗，现在回想起来，真是一段难忘的时光。在大家的努力下，除了产品研发迭代外，QuickRunner 完成了麒麟和统信的兼容性认证工作，拿到了 7 份正式证书；HttpRunner 微信公众号订阅人数突破了 6000，发布了 14 篇博客，单篇最高阅读量 2.6w，累计阅读量 10w+；HttpRunner \u0026amp; QuickRunner 入选 TesterHome 2022 上半年测试技术期刊；HttpRunner 开源项目 GitHub star 数增长至 3.7k，并在 MTSC 年度最佳开源项目奖荣获第二名的优异成绩。 看上去形势一片大好，但我们却不得不在 2022 年 7-8 月份暂停了 QuickRunner 项目迭代，可谓是狠狠地踩了一个急刹车。这个急刹车是非常令人痛苦且沮丧的，但考虑到团队生存问题和平衡短期收益，这又是不得不「快刀斩乱麻」做出的决定。 首先，最主要的原因还是来源于大环境的劣化。2022 年受疫情和国际形势的持续影响，各大行业都开始进入了寒冬，普遍都从「求发展」转变为了「保生存」状态。在客户侧，大量公司开始节衣缩食，对于质量、效能、体验这类在大多数公司看来只是锦上添花的工具服务，预算都是能省则省。同时，在我们公司内部也出现了显著的变化，之前还是「HC 不限、人才储备优先」，也开始需要做项目收益盘点了。 然后在这种形势下，我们 QuickRunner 项目就变得异常艰难了。之前的「高歌猛进、如火如荼」，退潮以后只能算是「赚吆喝不赚买卖」，除了实际订单收益外，其它都是虚的。然而，信创这个行业虽然有前景，但成单周期普遍较长，定制化占比高，并且市场销售占主导（偏 ToG）。这对我们这个技术导向的团队来说，充满了太多非技术性挑战和不确定性，即使后续可能有较大收益，但「三年不开张、开张吃三年」，在当前大环境下对公司来说也是不可接受的。不仅如此，QuickRunner 项目还有个致命的问题，就是一开始就只瞄向外部市场，在公司内部没有业务落地基础，短期内也较难找到足够大的机会点。 最终，我们成都团队暂停了 QuickRunner 项目的迭代，从 7-8 双月开始从 ToB 对外转向公司内部，重点投入到体验评测方向。 ","date":"2023-05-13","heading":"QuickRunner \u0026amp; 商业化探索","objectID":"/post/my-2022-summary/:1:1","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#quickrunner--商业化探索"},{"categories":["Growth"],"collections":null,"content":"体验评测 大概是在 2021 年 9 月开始，我们成都团队开始投入了部分人力在体验评测方向。最开始投入这个方向的初衷，支撑抖音内部评测是一方面，但更多的想法还是面向 ToB 的目标，期望通过更低成本、更高效率的方式触达目标客户，收集客户反馈，进而逐步打磨我们的 SaaS 服务，驱动我们整个数字体验监测产品体系的迭代和演进。 然而随着大环境的变化，增量市场逐步转变为存量博弈，各个行业赛道的竞争变得愈发激烈。特别是针对短视频行业来说，电商、本地生活、娱乐、社交、资讯类应用，不管之前是做啥业务方向的，现在基本上都入局了短视频和直播，甚至将视频业务放入到了第一级流量入口。 那对于抖音来说，在竞争对手越来越多、功能和内容越来越同质化的情况下，如何持续保持领先优势呢？应对措施是多方面的，但「保持体验行业领先」绝对是重中之重。在此背景下，公司内部各个业务线的行业体验评测需求开始井喷式爆发，我们团队也在这股洪流之下，与体验 QA 团队密切协作，承接了点播、直播、搜索等行业赛道的双月评测需求，并在电商、本地生活等赛道迎来了更多需求，目标是帮助业务明确其在行业中的体验竞争力水位，并协同业务挖掘高价值体验优化点。 从 2022 年 7-8 双月开始，我个人及团队的工作重心就从 QuickRunner 转向了体验评测，重点是建立一套通用的评测技术体系，横向支撑多业务赛道的行业应用体验评测需求。 那对于体验评测来说，最核心的要素是什么？ 如果只能提一个要求，那一定是评测数据真实准确。这也比较好理解，因为这是体验评测的基础，只有数据足够置信，能反映线上用户真实体验情况，才有进一步做数据分析和问题挖掘的意义。然而，虽然「真实准确」只有四个字，但对我们的整体体验评测能力会有非常高的要求。 首先是「真实性」。 线上的真实用户环境是多种多样的，评测环境肯定是没法做到和真实用户环境完全一致。但我们可以将环境因素进行拆解，挑选出对评测结果可能会有较大影响的因素。例如， 在机型方面，Android/iOS 系统的功能实现会存在显著差异，高中低端机型的设备性能也会存在较大差距，评测时需要重点考虑主流用户人群的机型特征，针对设备系统类型和性能等级进行区分；在地域方面，不同地区的服务部署、调度策略可能会存在较大差异，需要考虑分地域进行评测，至少需要区分国内和海外，一线城市和非一线城市；在网络方面，不同电信运营商的网络基础设施建设会存在一些差异，用户真实使用环境下的网络状态也是多种多样，包括 2G/3G/4G/5G/WiFi/弱网 等，通常产品在不同网络环境下也会有针对性的策略调整，在评测时必须要能实现对不同网络状况的模拟。而且对于真实的用户环境，影响的因素通常不只是一种，而是多种因素的组合，例如我们期望能评测 Android 低端机用户在移动运营商弱网环境下抖音的视频预加载策略。这就需要我们的评测环境具备自由组合模拟多种环境因素的能力，并在组合环境下对客户端性能指标进行采集和分析，最终挖掘出可能存在的体验问题。 然后是「准确性」。 既然是评测，要完全消除误差是不现实的。但对于体验评测来说，数据准确性的要求是非常高的，无论是耗时还是卡顿误差，都必须要将误差控制到 100ms 以内。在如此高的精度要求下，评测方案必须要保障足够的精度，并且评测工具的性能损耗也要控制在一定的范围内。例如，在采集 app 启动耗时的体验指标时，采用人工掐秒表的方式肯定是不行的，我们只能通过对整个场景操作过程进行录屏，然后对录制的视频进行分帧，基于 app 启动过程中的帧数来计算得到耗时数据。即便如此，经过实验发现，手机在开启内置录屏的情况下会存在较大的性能损耗，对测试的结果会产生较大的影响，特别是在一些低端机设备上，录屏带来的性能影响是不可接受的。因此我们只能采用外置拍摄录制的方式，即使用另外的高速摄像机或手机来拍摄被测设备，以此实现无损的性能采集。另一方面，评测数据需要防止数据波动和一些极端 case 对整体测试数据的影响，评测的数据量级肯定是越多越好；评测的数据只有达到一定的量级，才能具备统计学意义，评测的结论才能足够置信。 最后是「高效率」。 在对评测数据量级提出要求的情况下，我们不得不考虑评测成本的问题，并需要重点提升评测的整体效率。在进行大规模体验评测时，我们需要重点关注「指标采集」和「指标提取」两个方面的效率。 针对指标采集，我们需要借助 UI 自动化框架来模拟各种复杂业务场景，并进行过程视频录制和性能指标采集。这里的难点主要是需要兼容 Android/iOS 各种厂商和机型设备，降低 UI 自动化框架自身性能损耗对体验指标的影响，自动处理各种弹窗和风控策略；同时为了降低视频录制本身的性能损耗，我们使用了互拍机架的方案，这对工程整体的稳定性带来了较大的挑战。 在指标提取方面，我们需要自动化实现分帧和体验指标的提取。这里的难点在于面对千奇百怪的 app 内容形态，如何提升 CV 算法识别的准召率，特别是首帧耗时和卡顿类指标。在算法识别准召率短期内优化不理想的情况下，我们不得不结合人工标注的形式，将部分指标的提取工作交给标注人员来完成。这部分人力投入的成本是比较大的，因此我们需要对标注工作进行提效，包括不断优化标注工具和算法识别能力，以及将标注流程标准化，最终提升单人天的标注吞吐量。 总的来说，对于一次完整的体验评测流程，基本上可以拆分为如下几个环节。 行业评测涉及的内容较多，我这里就不详细展开了。在 2022 年，我受邀担任 GIAC 大会测试前沿技术专场的出品人，团队小伙伴在该专场分享了我们团队的体验评测实践《抖音用户体验自动化评测实践》；同时我也受邀参与了《现代软件测试技术权威指南》书籍的共同撰写，贡献了「数字体验测试技术精要」章节的内容，后续如有时间我会将相关内容分享到我的博客和公众号上。 ","date":"2023-05-13","heading":"体验评测","objectID":"/post/my-2022-summary/:1:2","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#体验评测"},{"categories":["Growth"],"collections":null,"content":"成本专项 在行业寒冬、降本增效的大环境下，产品服务的整体成本同样也会决定着产品的生死存亡。因此在 2022 年，FinOps 开始成为新的行业热点，各大公司也开始积极加速 FinOps 实践，为企业降本增效。 在此背景下，我们需要开始重点考虑的是，对于抖音这样的视频服务，如何在保障用户体验领先于行业应用的同时，整体成本比行业应用更低？或者我们是否可能站在更全局的视角，在保障质量（画质）和体验适当有损的情况下，大幅降低技术资源成本，提升业务整体的 ROI？ 面对新的行业转折点，出现了新的挑战，同时也预示着新的机会。因此从 2022 年底开始，在 leader 的指引下，我又踏上了新的征程，开始投入到了成本专项的探索。 在起初的一段时间里，我主要是以兼职探索学习的方式，东学学西看看，先尽量建立关于成本相关的认知。但说实话这个过程挺痛苦的，具体要做什么，怎么做，一切都充满了未知。曾经在一段时间里，我特别焦虑双周会的来临，因为不知道进展该怎么同步，或者说就是毫无进展。 经过了大概两个月的摸索和学习，我们在业务理解和专业技能方面有了一些积累，再加上我们拉兄弟团队一起入了伙（能力互补），并找到点播、直播业务负责成本的 POC 进行了多轮沟通交流，成本专项的规划思路总算逐渐变得清晰，我们也终于开始步入了正轨。经过这件事情之后，我再次感受到资源整合的力量，与能力互补型团队建立紧密合作，找到核心业务的陪跑者，合作才能办成大事。 从 2023 年 3 月开始，我正式全职投入到了成本专项，预计在整个 23 年都会聚焦在这个方向。等明年初写 2023 年终总结的时候，我再做些分享吧。 ","date":"2023-05-13","heading":"成本专项","objectID":"/post/my-2022-summary/:1:3","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#成本专项"},{"categories":["Growth"],"collections":null,"content":"生活 这一年整体精力差不多都被工作掏空了，生活方面就挑几件重点事情做下记录吧。 ","date":"2023-05-13","heading":"生活","objectID":"/post/my-2022-summary/:2:0","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"小云朵 今年我收获最大的一件事情，就是二宝小云朵顺利出生了，我终于有女儿啦！说到小云朵这个名字，去年在我发布年终总结后就有朋友问，这么软萌的名字，提前知道是妹妹啦？其实我们也并没有查性别，只是我单方面期待要一个女儿，结果最后真的就梦想成真了，我也有幸成为了人们口中「儿女双全」的人生赢家。 小云朵的出生比预产期早了几天。当时我们还在中和湿地公园散步，原计划晚上还跟朋友约吃饭呢，结果我妻子的羊水突然就破裂了。然后我就赶紧去把车开到公园最近的出口，搀扶妻子上车后送往医院。好在这次我们选择的是一家私立医院，不用排队等候，到了医院就可以立即安排生产。另外私立医院还有个好处，家人是可以在产房全程陪产的，因此我有机会亲眼见证了小云朵出生的全过程。说到这里必须得感谢下我的爱人，确实辛苦了，感恩最后母女平安。 小云朵出生以后也经历了一些波折。在她出生一个星期后，黄疸值比较高。开始我们没有引起太高的重视，因为之前小坚果出生后也有黄疸，后面过段时间就自然消退了，而且新生儿黄疸本身就是一个特别普遍的症状。然而这次却有些不一样，小云朵的黄疸值一路飙升，把我们吓坏了，送到医院检查后才得知这次是病理性黄疸，最后在华西住院照了三天的加强蓝光才终于恢复正常。想想也是后怕，要是再晚些时间可能就需要换血治疗，甚至可能会导致耳聋、智障等症状了。在此也特别提醒后面要生小孩的朋友们，新生儿黄疸务必要重视，不要盲目听信周围的人说的「黄疸没事儿晒晒太阳就好了」，生理性黄疸都还好，如果是病理性黄疸的话一定是要及早干预治疗的。 在去年的年终总结中我就说过，小云朵跟小坚果相比，获得的关注度会低一些，应该是我们已经过了从 0 到 1 的探索阶段，习惯了父母这个角色了。除了这个，小云朵还比较吃亏的在于很多东西都是二手的，特别是衣服、玩具等物品。但是，小云朵有一个很幸福的点，从出生开始，除了有爱她的爸爸妈妈以外，还有一个一直爱她的小坚果哥哥。她也特别喜欢哥哥，经常看见她哥哥就笑得特别开心，当前发出的第一个音不是 BaBa 和 MaMa，而是 Ge，哈哈。 ","date":"2023-05-13","heading":"小云朵","objectID":"/post/my-2022-summary/:2:1","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#小云朵"},{"categories":["Growth"],"collections":null,"content":"小坚果 小坚果今年满 5 岁了，已经不再是可以随便忽悠的三岁小孩了，他言语之间有了不少自己的想法，我们也多了一些斗嘴的乐趣。 普遍到了这个年龄段，很多家长会给孩子报各种兴趣班，俗称「鸡娃」。我们是比较佛系了，在这块儿没有做特别的投入，只是有一次坚果妈妈偶然看到了一个美术体验课，抱着让他去玩的想法给他报名了，每周日上一次课。然后我们惊奇地发现他在画画方面还颇有些小天赋，经常画出来的画都挺超出我们预期的。而且在每次课程结束的时候有个介绍环节，他要到台前跟在场的其他小朋友和家长们介绍自己的画，上台不怯场，逻辑性也还挺不错，让我这个老父亲颇是欣慰。 为了从小培养小坚果的金钱意识观，我们今年给他找了份洗碗挣钱的工作，洗一次碗一块钱，他很是乐在其中，后面居然靠洗碗攒了二十几块钱。渐渐地我们发现，他开始对金钱有了更多的感知，居然还是个小财迷。比如我说要带他去买薯条和冰淇淋，他很开心，但当我说要用他的钱来买时，他立即就改口说薯条和冰淇淋也没啥好吃的，不要买了。而且他也开始好奇我的工作和挣钱的方式，并提出了一些困惑。在他看来，我的工作就是整天坐在电脑面前叽叽喳喳的开会说话，讲一些方案啊什么的，并没有什么用啊。我问他那什么才算有用呢？他的回答是洗碗、修房子、卖东西才算。哈哈，这么小已经能感知到实体经济和虚拟经济了。 对于小坚果，今年我和他妈妈都感到一些亏欠。妈妈是因为需要将很多精力投入到照顾二宝，陪他的时间减少了许多。估计也是这个原因，他开始变得特别黏妈妈，不想去幼儿园，甚至经常通过调皮捣蛋的方式来引起妈妈的关注。而我则是工作时间太长压力也大，平时的空闲时间里，要么是脑子里装着挥之不去工作坐那儿发呆，要么是疲惫得瘫在沙发上不想动，真正陪他有效玩耍的时间挺少的。今年某次周末，我照着抖音视频使用冰箱包装箱给他做了一个小房子，他开心得不得了，后面他又花了很多时间在小房子的装饰上。估计也是因为他平时自己玩的时间太多，没有充足的户外活动时间，身高和视力发育都开始出现了些问题。对此我很是内疚，新的一年必须要有所改观了。 ","date":"2023-05-13","heading":"小坚果","objectID":"/post/my-2022-summary/:2:2","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#小坚果"},{"categories":["Growth"],"collections":null,"content":"一些遗憾 今年，我的爷爷去世了，我失去了最后一位祖（外）父母。这是我随着年龄增长，虽然不想面对，但终究不得不面对的事情。 当时在周三的时候，我爸给我打电话说爷爷身体状态不大好，已经不怎么吃饭了。由于还在上班，马上就要到元旦假期了，我就说等周五下班后回老家看看。结果在周五还没等到下班，我就接到了爷爷离世的噩耗，就此错过了跟爷爷的最后一面。通过家里的网络摄像头回放视频可以看到，当时爷爷估计是想起来走走，起床后没走几步就摔倒在地上了。由于恰好在摄像头盲区，在视频中无法看到当时的情景，但从断断续续的拐棍碰触地面的声音可以判断，当时爷爷应该是想爬起来，但一直没能成功。直到十几分钟后，爷爷终于爬起来了，却在返回床铺的过程中再次跌倒，就此永远离开了。看到这个视频我着实难受了好一阵子，爷爷恰好在家人有事出门的半个小时内，独自一个人走完了人生的最后一程，当时在地上的那十几分钟，他一定感到非常无助吧。 相对庆幸的是，在去年八月的时候，我们有带两个孩子回家看过一次爷爷，让爷爷抱过了三个多月大的小云朵。在 2021 年的时候，爸爸经过反复劝说，总算带爷爷出了一次重庆坐高铁来到成都，我们带他去动物园看了熊猫，带他去宽窄巷子体验了掏耳朵和看川剧，祖孙四代一起合影拍了很多照片。 不过我还是有一个非常遗憾的事情，几年前我就想把全家人聚会的合影和孩子的照片做成相册送给爷爷奶奶，结果直到他们相继离世，这个想法一直都因为我的拖延症没有实现。终于，在这次丧假期间，我将之前爷爷奶奶的所有照片汇总起来，排版订做了几本相册。在排版照片的过程中，与爷爷奶奶相处的一幕幕再次跃然脑海，浮现眼前。感恩爷爷奶奶的爱与陪伴，祝他们一路走好，愿天堂一切安好！ ","date":"2023-05-13","heading":"一些遗憾","objectID":"/post/my-2022-summary/:2:3","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#一些遗憾"},{"categories":["Growth"],"collections":null,"content":"住房 在 2021 年我们买了新房，结果期盼的 22 年底交房一拖再拖，当前说是延期到 23 年 6 月底了，到时具体是否能交也还是未知。 总的来说还是受大环境的影响，整个房地产行业变得不太景气。而合景泰富开发商又自己作死，前些年激进举债扩张拿地，潮退以后自食其果也就罢了，还苦了我们这类掏干家底儿的买房人。 相对庆幸的是，我们买的这个楼盘是严格受资金监管的，当前账上的剩余资金还够。但由于承建方在与开发商合作的其它项目中拿不到钱，因此消极怠工，开发商基本上也失去了对承建方的控制力。 唉现在买个房是真心累，当韭菜也就罢了，基本的保质保量都成了奢望。建议大家能不买期房最好还是别买期房，风险太大了。 ","date":"2023-05-13","heading":"住房","objectID":"/post/my-2022-summary/:2:4","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#住房"},{"categories":["Growth"],"collections":null,"content":"个人思考 再来记录下这一年一些零散的想法吧。 ","date":"2023-05-13","heading":"个人思考","objectID":"/post/my-2022-summary/:3:0","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#个人思考"},{"categories":["Growth"],"collections":null,"content":"关于创业（创新项目） 在整个 2022 年，我算是经历了 2 ~3 次创业，或者在公司内部叫作创新项目吧。第一次是 QuickRunner 商业化探索，第二次是体验评测的从 0 到 1（算半次吧），第三次是成本专项的方向探索。一整年下来，说不累肯定是假的，「字节一年，人间三年」的网络段子，放我身上还真挺贴切。 对于创新性项目的负责人来说，心累、迷茫、挫败、自我怀疑是家常便饭，在这一年我也偶尔会冒出一些放弃的想法。相比于前两年自己埋头写代码的纯粹，这一年多以来我不断折腾，干得事情更多更杂了，压力更大了，操的心更多了，结果个人收益还降低了（owner 对结果负责），我到底图个啥啊？因为我们身在公共技术团队，我们不在具体的业务里面，我们不会有直接的业务收益，因此我们需要不断地想办法证明自己，让业务认可我们工作的价值收益，既然如此，我干嘛不直接转到业务团队啊？ 在今年春节期间，我跟家人一起去了趟重庆大足石刻。在观看史实介绍时，有一句话对我触动挺深的。宝顶石刻创凿者赵智凤，「一个人，一座山，择一事，终一生」，最终将大足石刻推向了顶峰。从当前的视角来看，赵智凤他是一名真正的工匠，同时他也是一位卓越的架构师，一个优秀的创业者，在 70 余年的时间里，带领数千名工匠将他的想法落地呈现，最终创建了中国石刻艺术史上的一座丰碑，并被评为世界文化遗产。很难想象，在他 70 余年的时间里，他中间遇到了多少艰难险阻，他是否也会产生过自我怀疑和放弃的想法，但是最终，他还是坚持下来了。 引用一句贺炜的解说词，「如何面对失败，却把人分成了不同的样子」。其实上面的两种选择并不存在对错之分，遭遇挫败产生负面的想法也挺正常，但每个人的认知和选择，最终会造就不一样的人生。我想，创业折腾应该才是我更想要的，我也真的期望在还能折腾的年龄阶段，做成一些让自己多年后还感到自豪的事情。 ","date":"2023-05-13","heading":"关于创业（创新项目）","objectID":"/post/my-2022-summary/:3:1","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#关于创业创新项目"},{"categories":["Growth"],"collections":null,"content":"关于向上管理 说到「向上管理」，可能大家普遍都会往负面方向进行联想。但经过这一年，我产生了一些不一样的想法。 我有一个很不一样的老板。一般领导分两种，一种是「你们给我上」，还有一种是「跟我一起上」；但我的老板属于第三种，「我先上了，你们跟上」。他是一个真正想做事做成事的人，对他来说不存在专业领域之分，只要是他看准的事情，他都会投入很大的精力进行学习和钻研，不管是凌晨、清晨还是周末节假日，很少看到他空闲的时候。打心底说，我是真佩服他的精力和执行力，我在他身上也深切地感受到人和人的差距真的好大。以前我自认为是很上进很爱学习的，但跟他比起来，我感觉自己太堕落了。 正因为他是这样一个「狠人」，可能在他看来都是正常的要求，但却常常会让我感受到较大的压力，有时我也会内心埋怨他太激进预期太高。那在这种情况下要怎么办呢？抱怨？吐槽？解决不了任何问题。摆烂？怠工？这不是我本意，我也是想把事情做成的。 要解决问题，最主要的还是需要换位思考，积极沟通。其实站在老板的视角，我能感受到他所面临的处境，而且他也确实为我们扛了很多压力。换位思考以后，他的很多看似不合理的要求也就能很好的理解了。想法是理解了，但那也只是我自认为理解，我的理解是否存在偏差呢？这就还需要积极沟通主动对齐。 字节范儿文化中有一条叫作「坦诚清晰」。之前在飞书的时候听到一位业务负责人分享了他对「坦诚清晰」的阐述，令我印象特别深刻。坦诚清晰不光是一种态度，同时更是一种能力。如何通过沟通让对方清晰理解自己的想法，如何将自己和团队方向的工作重点、进展和价值清晰地展现出来，这本身就是一项非常重要、需要持续提升的能力。 再回到前面说的创新项目，的确会充满很多不确定性和压力。但作为项目负责人，除了要有足够的韧性和技术热情之外，还有一个很重要的事项就是做好「向上管理」。这里的向上，并不只是指领导，还应包括客户、需求业务方和协作方。首先在规划上需要对齐预期，先从 POC 做起，不要指望一步突破。同时，项目攻坚虽然是长期的过程，但要给人看到希望，需要在每一步都拿到一些业务结果。只有这样，我们才能将项目持续做下去，最终才有可能取得成功。 转变心态以后，同样一件事情，我们就会有不一样的心境感受。例如，曾经内心抵触、当作是工作负担的团队周报，其实是一个很好的展示团队工作价值的窗口，而且如果我们发现很难把工作重点和价值讲清楚，大概率说明我们的工作思路存在问题需要及时调整。因此与其被动应付，还不如以更积极的心态去面对，把它当作一个自我精进的锻炼机会。 ","date":"2023-05-13","heading":"关于向上管理","objectID":"/post/my-2022-summary/:3:2","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#关于向上管理"},{"categories":["Growth"],"collections":null,"content":"新年展望 最后再展望下 2023 年吧（虽然已经到 5 月了）。 今年，我在字节入职满四周年了，这已经是我毕业工作 12 年以来待的最久的一家公司了。庆幸的是，我挺喜欢字节跳动公司文化和当前团队的工作氛围，虽然累，但实打实的是在做事，做有挑战的事。如有可能，字节跳动应该是我工作的最后一家互联网大厂或公司了，等哪天毕业了，到时候就再做点自由职业养家糊口吧。 在具体的工作方向上，我今年会聚焦到成本专项（FinOps）。这在行业中算是一个相对新兴的领域，对我而言也是新的开始。借助这个项目，我有机会深入学习视频流媒体相关的技术，这是抖音最核心的技术领域，我也算是没有白来过抖音。 就在最近两天，行业内特别钦佩的一位前辈，左耳朵浩哥突发心梗离世了。在去年七月的时候，我有幸在 GIAC 会场的讲师餐厅见到他，跟他简单交流并加了他的微信，没想到那就是最后一次跟他见面的机会了。着实难受，也许这就是世事难料吧。 见过了世事无常，我越发体会到平安健康的弥足珍贵。在新的一年，期待家人孩子们都健康顺利，等小云朵稍大些后安排一次家庭旅行。对于物质方面我也没啥追求了，期望新房顺利交房，持续攒钱，尽早把房贷清了。 ","date":"2023-05-13","heading":"新年展望","objectID":"/post/my-2022-summary/:4:0","tags":["年终总结"],"title":"我的 2022 年终总结","uri":"/post/my-2022-summary/#新年展望"},{"categories":["Growth"],"collections":null,"content":"还有几天就要到农历新年了，堕落到一年一更的博客也不好意思再拖延了。按照老习惯，先翻出去年的年终总结看一遍找点感觉，然后在幕布上分维度罗列下要点和关键词，再大致整理下思路，开写！ 这里也提前说明下，我的年终总结更多的是记录自己一年来的经历和思考，内容比较多且杂，大家挑选感兴趣的部分看看就好，也欢迎大家在评论区多多交流。 ","date":"2022-02-05","heading":"","objectID":"/post/my-2021-summary/:0:0","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 在 2020 年六月，我从深圳字节转岗到了成都互娱研发部门，转眼间在成都已经工作了一年半时间了。整体来说，在成都工作和之前在深圳广州工作并没有感觉到太大的差异，我对现在的工作内容和工作状态的也挺满意的。 在工作方面，我个人 2021 年最大的关键词就是「ToB」和「从 0 到 1」了。但回顾这一整年做的事情，最大的问题还是投入方向比较零散，走了些弯路，好在下半年逐步进行了聚焦，目标也清晰了许多。这里就挑几个投入时间相对较多的方向进行下总结。 ","date":"2022-02-05","heading":"工作","objectID":"/post/my-2021-summary/:1:0","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"测试任务调度 在年初的时候，我们专项团队在成都只有两个人，另一个同学跟我还不是一个方向的，因此整个上半年我基本还是延续 2020 年的工作内容，做测试任务调度相关的技术框架建设。只是相比于 2020 年，今年更多是提升服务易用性和可用性，实现更多的业务落地。 在服务易用性提升方面，考虑到通用的任务接入方式（shell/python 脚本）存在一定的使用门槛，因此集成了几种常用的专项能力，包括 Web/H5 场景化测试、Fastbot 稳定性测试、shoots UI 自动化测试等，并且新增支持了调度执行 gitlab 仓库的能力，这样就可以将用户跑在内网的测试工程无额外成本地调度运行在全球各地的边缘节点。在服务可用性提升方面，主要是完善了核心指标体系，实现了通过看板准确度量任务成功率和明确 top 异常问题的「数据驱动」研发能力；并且新增了多项问题定位辅助工具，提升了关联服务 auth 同步、RPC 调用等异常问题的排查效率。而在业务落地方面，主要就是作为任务调度中台，与相关合作方进行协作共建，稳定支撑了内外部近十个专项测试平台。除此之外，还做了些软能力的建设，包括优化项目管理机制、推动实践 monorepo 开发模式、解决各模块 code review 流程标准不统一的问题。 总体来说，这部分工作还是面向公司内部，算是常规意义层面的测试开发工作内容。 ","date":"2022-02-05","heading":"测试任务调度","objectID":"/post/my-2021-summary/:1:1","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#测试任务调度"},{"categories":["Growth"],"collections":null,"content":"ToB 探索 在去年年初的时候，我们专项团队在公司内部完成了 ToB 产品立项，业务方向主要做是数字体验监测（DEM）。用一句话进行概况，核心就是利⽤分布在全球各地的真实设备和真实⽹络，模拟⽤⼾的真实⾏为，采集体验相关数据，提供体验问题发现，智能归因及报警服务；详细内容可看下这篇介绍文档。 为什么一个测试开发团队要去做 ToB 的事情？原因是多方面的。从国内大环境来看，ToC 市场已经基本饱和，接下来十年在 ToB 市场会有更多的机会，各个大厂都在加大 B 端市场的布局和投入。字节公司层面也十分鼓励内部将技术中台能力对外开放，因为内部技术平台需要有更高的标准，光是内部反馈说好还不够，能走出公司收获市场的认可才是真的好。而在我们团队内部，leader 是一个连续创业者，对 ToB 具有非常大的激情和决心，在他的感染之下，我内心深处的创业热情也被激发出来了。还记得之前我在大疆的时候写过一篇文章《如何度量测试开发的价值产出？》，作为测试开发角色，工作内容和形式很多时候跟创业很像，我个人也一直是以创业的心态来对待自己的工作。而现在我终于可以有机会真正地参与到商业化 ToB 从 0 到 1 的创业过程，我自身也是兴奋不已。 兴奋归兴奋，真实的 ToB 探索路阻且长。虽然我们的产品在年初就通过了立项，而且已有的工具平台在公司内部已经持续支撑了抖音、头条和众多明星出海业务，取得了非常多的价值收益，但要实现对外开放试用，还有非常多的工作要做。安全合规改造是最重要的一项，包括隐私合规要求梳理、开源组件 license 梳理、法务流程审批等等。而在技术层面，需要将之前部署在字节云的系统迁移部署到 K8S 云原生的火山引擎，那么针对字节云特有的内部特性就需要进行改造重新实现；同时在国际化机架和边缘节点设备层面，需要在保障资源复用的前提下，解决 ToD/ToB 环境和资源隔离的问题。 除了安全和技术方面，最头疼的还是产品设计层面的问题。说实话，公司内部的测试平台大都是面向内部技术人员，甚至有些还只是给自己使用，更多还停留在技术工具层面，距离目标用户「好用」「易用」「爱用」的产品还有相当大的差距。哪些是我们的目标用户？用户的痛点是什么？关注点在哪里？使用场景是什么？使用习惯如何？这些都是作为一个商业化产品需要进行关注的，也是我们团队当前充满未知的。比较好的方式，就是尽可能早尽可能多地触达外部用户，特别是针对一些重点 KA 客户，可以通过线下拜访和免费开放试用的方式来收集反馈。 然后我就开始兼职做商务和售前的角色，从 5 月开始跟团队的相关负责人一起到客户现场进行交流，包括大疆和多家知名出海企业。后面再借助公司和团队成员的资源，我们团队全年累计触达了 20+ 个 KA 客户，覆盖了政府、泛互联网、游戏、金融、运营商、车联网等行业，并初步与一些生态合作伙伴建立了合作。我也借此机会又回到了老东家（大疆和工信部电子五所）的工区，熟悉的地方，熟悉的同事，甚是亲切。在这里也非常感谢各位前同事和合作伙伴们，在交流的过程中给予了我们较多的反馈，为我们的复盘改进和调整产品方向提供了很大的帮助。 经过接触外部客户，我们发现部分产品方向和很多客户的需求存在一定的 gap。例如，网络性能体验对于字节的产品非常重要，提升 0.5% 就会产生非常大的经济收益；而对于一般体量的用户公司来说，提升网络性能体验的 ROI 很低，相比下来，提升研发过程中的测试覆盖率、解决功能性 bug 是更高优的事情。又比如针对服务可用性监测，我们的产品优势在于全球化 150+ 国家地区的主流机型和当地 WiFi/3G/4G/5G 真实运营商网络链路的覆盖，而客户通常不需要这么细粒度的真实环境，对于他们来说找几台 AWS 服务器部署个脚本监控主干网就够了。这里存在的矛盾点就在于，对于大多数中小企业来说，用户体量还比较小，业务量级还没到关注数字体验的阶段；而体量已经很大的公司都不缺资源，基本都倾向于自己建设。 其实上面的 gap 还是很好理解的。参考马斯洛的需求层次结构，客户对软件产品的需求大都也会遵循一定的金字塔层级模型：功能（接口/UI）、性能这种处于金字塔底层，用户需求肯定是最多最普遍的；而体验、本地化、安全合规这类需求处于金字塔顶端，用户受众会少很多。当然了，用户的需求也不是一成不变的，他们在满足底层需求以后也会逐步关注上层的需求。但我们之前存在的问题是产品只专注于数字体验、本地化、安全合规这些处于金字塔顶端的需求，而对于最广泛的接口/UI 功能、服务端性能这些能力完全没有投入，这就会造成我们直接对最广大的用户群体关闭了大门，继而错失了转化用户的机会。 一不小心 ToB 探索这部分就写了好多，但实际需要考虑的因素还远不止这些。对于 ToB 而言，产品和技术本身并不是全部，营销获客效率、行业领域特点、用户付费意识、客户决策链路等等，这些都是需要去重点关注的维度。在整个探索的过程中，遇到的挫败会非常多，拿热脸贴冷屁股也是常有的事儿，但这就是最真实的商业化世界。虽然有时候我也产生过自我怀疑，但我还是非常感谢能有这样的学习和锻炼机会，可以让自己获得更全面的成长。幸运的是，部门对 ToB 这件事儿并不急功近利，也没有给我营收压力，而是立足长远，坚信做有挑战的事情、提升核心能力、锻炼有战斗力的团队，才是我们最核心的目标。 差不多到了 9 月的时候，经过多次沟通，我这边重点投入到了 XX 性能检测工具（基于 HttpRunner+）和体验评测报告这两个方向。 ","date":"2022-02-05","heading":"ToB 探索","objectID":"/post/my-2021-summary/:1:2","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#tob-探索"},{"categories":["Growth"],"collections":null,"content":"XX 性能检测工具（基于 HttpRunner+） 先说下 XX 性能检测工具，这其实也算是机缘巧合，我们发现面向 XX 领域做性能测试工具可能存在一些机会。具体领域这里先保密，产品形态基本上可以理解为是基于 HttpRunner+ 做的一款简单易用、功能强大的桌面客户端压测工具。 为什么选择 HttpRunner 呢？ 说来惭愧，之前因为工作方向变动的原因，我已经快一年没有投入到 HttpRunner 的开发了。但我惊喜地发现，HttpRunner 已经有了较大的用户基数和知名度。在搜索引擎中搜索 HttpRunner 时，除了我之前写的教程，还可以搜到很多用户自分享的文章，甚至还有培训班以此开设了付费课程，以及有人写书时以较大篇幅对 HttpRunner 进行了介绍。在抖音中搜索测试工具关键词时，我也在多个视频中看到有人提到了 HttpRunner。这些反馈给了我极大的鼓舞，同时我也觉得大众对于 HttpRunner 的接受度较高，而且已有的品牌效应不能浪费，运营好 HttpRunner 可以帮助我们触达更多的用户群体。 然后在功能设计层面，HttpRunner 的一个很大的优势是采用了约定大于配置的思路，脚本是结构化的，方便生成和转换，因此可以非常方便地与其它工具进行集成。而且 HttpRunner 本身支持接口测试和性能测试，那么基于 HttpRunner 就可以快速开发出我们的目标性能测试工具。不过，既有的 HttpRunner 可能还没法直接满足我们的需求。因为我们的核心目标是要做一款性能测试工具，对于发压能力和数据准确度具有非常高的要求；而之前的 HttpRunner 是基于 Python 开发的，性能测试部分使用的是 Locust，其单机发压能力和数据准确性都存在较大的问题。 经过短暂的思考后，我打算采用 Golang 重写 HttpRunner，这也就是现有的 HttpRunner+（hrp）。选择 Golang 替换 Python，这里面的确有我个人的技术偏好，但可以明确的是，采用 Golang 开发的 hrp 相比于原有的 Python 版本，在保持使用方式基本不变的前提下，功能会更加丰富，运行更稳定，并且性能更强，也更易于部署使用。 下图罗列了 HttpRunner+ 从 9 月开始开发至今已经实现的功能特性，以及近期要完成的重点功能特性。可以看出，当前 golang 版的 hrp 已经在功能上基本追平了 Python 版的 HttpRunner，并且在性能测试方向增加了许多 locust/boomer 缺失的特性。 限于本文重点，这里就不再对 HttpRunner+ 进行过多介绍了，接下来我将会重点补齐用户文档后，再通过多个渠道进行下宣传介绍，后续也将加强下针对 HttpRunner+ 的运营和用户支持。 额外再补充一句，HttpRunner+ 将永久开源，并且保持以 Apache-2.0 的开源协议，商业友好，大家完全不必有任何顾虑。相信后续 HttpRunner 在获得商业化支持后，会有一个更持续、稳定的发展。 ","date":"2022-02-05","heading":"XX 性能检测工具（基于 HttpRunner+）","objectID":"/post/my-2021-summary/:1:3","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#xx-性能检测工具基于-httprunner"},{"categories":["Growth"],"collections":null,"content":"体验评测报告 而投入体验评测报告方向，主要有两方面的原因。 一方面，抖音作为短视频头部产品，用户体量巨大，轻微的体验指标劣化都会导致严重的经济损失，因此对各维度核心指标的关注度极高。我们作为抖音部门的专项测试团队，承担了对抖音等核心产品的体验监测和评测能力建设工作。例如，近期我们成都团队就重点针对行业内的 top 30 短视频应用的 5 大核心指标（首帧、首刷、卡顿率、卡顿时长、帧率）进行了横向评测比对，在此过程中建立了初步的评测能力，包括自动化数据采集能力、标准化算法识别能力、数据标注能力等。在未来，我们团队除了会不断优化短视频评测能力外，还会拓展评测范围，包括音视频、网络等技术维度，覆盖更多行业领域。这些能力除了在内部支撑业务解决评测问题外，我们更期望将技术溢出到外部，对外提供商业化服务。 另一方面，选择测评报告的服务形式，还可以解决我们之前遇到的一个困境。 前面在 ToB 探索部分有提到，已有的内部工具平台还不算是成熟的产品，对外部用户而言并不友好。在这种情况下，我们需要对已有系统进行重构优化，甚至要在产品层面进行重新设计。这个周期会比较长，那么怎么避免我们的重构不是内部自嗨？怎么确保我们新的产品设计是用户期望的？能切实帮助用户解决问题呢？ 经过内部讨论，我们决定先通过评测报告服务的形式来触达用户。因为回归数字体验监测最终的需求目标，用户也并不是为了多一个好用的平台，而是可以借助平台能力获取到关注的数据指标，挖掘出可能存在的问题，进而产出相应的评测报告。在这个逻辑下，平台并不是必须的，最核心的还是我们的数据产出能力和问题诊断能力。 因此，我们在短期内可以先以提升报告交付效率为目标，构建稳定的行业评测报告能力，持续产出高质量的行业评测报告。更具体地，如何获取更全面的数据，如何更高效地采集数据，如何保障数据的准确性，如何在海量数据中提取出关键问题，挖掘出系统可能存在的问题，这些都是我们需要解决的最核心的问题。在解决这些核心问题的过程中势必会倒逼我们不断去优化底层的数据采集能力、服务稳定性、提升标准化算法能力和数据标注能力、问题分析诊断能力等等。 通过这种方式，我们就可以比较好地确保我们团队始终走在正确的道路上，并且以此来通过更低成本、更高效率的方式触达目标客户，收集客户反馈，进而逐步打磨我们的 SaaS 服务，驱动我们整个数字体验监测产品体系的迭代和演进，并逐步形成面向跨境电商、出海游戏、金融、电信、音视频等行业领域的整体解决方案。 在此也打个小广告，如果你所在的企业具有相关评测需求，欢迎联系我，非常期望能在新的一年和大家一起搞事情。 ","date":"2022-02-05","heading":"体验评测报告","objectID":"/post/my-2021-summary/:1:4","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#体验评测报告"},{"categories":["Growth"],"collections":null,"content":"团队建设 除了 ToB 工作的从 0 到 1，成都团队今年也可以说是经历了从 0 到 1 的建设过程。年初的时候还只有我一个，到年末的时候我们团队已经有 9 个人了，研发、产品、测试的角色也基本都凑齐了。 得益于 leader 的信任，我同时担任了所有新人的 mentor（3 社招 + 5 校招），刚开始压力还是蛮大的。特别是在 7 月初的时候，同时入职了 4 位校招生，如何帮助这些新同学快速融入工作和团队也是一个比较有挑战性的事情。我自身呢也不喜欢管得太细，更多还是尽量做好背景同步（为什么做）和目标明确（要做成什么样子），然后就在关键节点给些反馈，唯一卡得严格些的就是 code review 了。新同学们都很给力，很快就进入了工作状态。后面在两位社招同学的热心协助下，我又可以有时间可以继续写代码了，哈哈。 在工作之余，我们团队氛围也特别融洽，平时午饭晚饭都会聚在一起吹水，饭后就在工区边上遛会儿弯儿，没女生在的时候偶尔还会在某人的带领下高低整两句（工牌已摘）。今年我们搞了两次稍微远点的团建，去融创滑了一次雪，去峨眉山登了一次金顶，计划明年再自驾走一趟川西小环线。成都因为没有车牌限制，因此成都团队的人均车辆数在整个团队中是最高的，再加上成都周边玩的地方确实多，自驾游的选择多了不少，后面还可以再打开下想象力。（此处是不是应该插入个招聘广告） 哦对了，今年我还有幸完成了字节技术学院「导师辅导技巧」精品课程的学习和认证，成为公司该课程首批次 30 位认证讲师之一（成都唯一一位），并面向成都 3 个研发部门的近 50 位新 mentor 进行了现场授课。从反馈的满意度评分来看还可以，但我自己也知道，知易行难，自己在辅导新人方面还有太多要提升的地方。新的一年继续加油！ ","date":"2022-02-05","heading":"团队建设","objectID":"/post/my-2021-summary/:1:5","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#团队建设"},{"categories":["Growth"],"collections":null,"content":"生活 再说下这一年的生活方面。 2021 年，是我离开一线城市，在成都工作的第一个完整年，整体感觉还是挺好的，我也挺庆幸当时做出的决定。 ","date":"2022-02-05","heading":"生活","objectID":"/post/my-2021-summary/:2:0","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"购房 首先是住房方面，今年我总算把购房问题解决了。成都毕竟是二线城市，平均房价不到深圳的 1/5，从一线城市回来还不是降维打击，随便买么？嗯么，我刚开始回成都的时候也是这么想的，将之前在广州郊区的房子卖了以后当首付，在成都还是可以有很多选择的，因此还一度膨胀过，跟老婆去逛过各种 top 板块的豪宅，麓湖生态城、天新 F4、成都金融广场（CFC）等等，涨了不少见识。后面随着有了更多的了解，我们也逐渐开始清醒了，房子终究是买来住的，相比于高大上，还是位置便捷+资源成熟来得实在，高新区对我们来说才是最合适的选择。然而超出我们预料的是，高新区竞争太激烈了，成都人民也太有钱了，按照成都的顺位购房规则，我们这一年出头的社保时长基本上无缘稍有剪刀差的楼盘。特别是在经历了报名凯德世纪名邸苦等十来天结果摇号池都没进以后，我们基本上醒悟了，再好的楼盘买不到等于零。于是我们又进一步将注意力锁定在高新区总价最高竞争相对没那么激烈的三个新盘，中洲锦城湖二期、合景泰富臻林、成都金融广场（CFC），想好的策略就是哪个先开就报哪个，然后就听天由命看运气了。回顾整个心态变化历程也是挺有意思的，从最开始「随便挑」的膨胀，到后来「只求买到」的卑微，只能说自己还是太年轻了。 然后大概在三月的时候，合景泰富臻林开盘了，就是之前传说的中英双语管家主动式物业服务的那个楼盘，价格开的是政府限价的最高价，单价差不多 3.4 万的样子，跟周边二手房基本上没啥剪刀差。而且这个楼盘渲染的逼格很高，总之就是要贵，物业费 8 块一平，停车位的物管费也要 180 一个月。再加上公摊面积高，中庭小的问题后，楼盘的追捧度并不太高。从内心意向来看，这个楼盘是我们的第三选择，但经过短暂的纠结后，这可能是我们机会最大的一次了，与其继续等待其它两个不知道啥时候能开的楼盘，还是先报名这个吧，万一还摇不上呢，毕竟只有 1/3 的概率。然后我们就冲了，然后就摇中了，然后就想着一步到位梭哈 180 平了，然后就背上了 400 多万的贷款，兜里全空了。说到这里，又不得不吐槽下成都的房贷利率，商业贷款首套利率都达到了 6+%，不懂金融的我们刚开始还没啥感知，后面每个月要还两万三的月供时才惊觉，没法躺平了。 不过现在回过头来看，幸好当时还是选择下手了，另外两个盘至今开盘还没影儿。再多说一句，成都的房价相比于两三年前涨了不少，要是早回来两年，房贷至少省一半，所以说还是选择大于努力啊。但不管怎样，今年总算了却一件心事，接下来不用再继续关注房子的事情了。 ","date":"2022-02-05","heading":"购房","objectID":"/post/my-2021-summary/:2:1","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#购房"},{"categories":["Growth"],"collections":null,"content":"小坚果 今年小坚果满 4 岁了。到了这个年龄阶段，小坚果的问题就多了好多，并且总是喜欢刨根问底。 小坚果：我是怎么来的？ 妈妈：你是爸爸的小蝌蚪游到妈妈的肚子里，然后在妈妈的肚子里孕育长大的。 小坚果：那爸爸的小蝌蚪是怎么进入到妈妈的肚子里的？ 妈妈：。。。 小坚果的交流能力已经很流畅了，而且在跟他聊天的时候，经常会震惊于他的想法。之前我跟他妈妈都要去上班，他在家自己觉得不好玩。后来就跟我们说，想让我跟他妈妈在家陪他玩。但是我们要出去上班挣钱钱啊，没有钱钱就没有饭吃。然后他就说那就让奶奶和外婆去上班吧。。。 还有一次，妈妈让小坚果学习瓜瓜龙英语。小坚果就问为什么要学英语啊？他已经表现出一些偏好了，更喜欢学习瓜瓜龙思维（数学）。妈妈就说，学了英语就能去国外找瓜瓜龙玩了呀。「可是，瓜瓜龙也可以说普通话啊」。呃么，挺有道理的，看来全球普及中文就靠下一代了。 今年小坚果开始去上幼儿园了。刚开始他还挺开心的，但上了一段时间后，他就不怎么喜欢去幼儿园了。问他为什么，他就说「幼儿园就是吃东西和睡觉，也不学习，一点都不好玩，要不是可以挣钱都不想去了」。原来他一直以为自己去上幼儿园就跟我去上班一样，是可以挣钱的。哈哈，这么小就展现出学霸潜质了，还知道为家里分担负担了。 ","date":"2022-02-05","heading":"小坚果","objectID":"/post/my-2021-summary/:2:2","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#小坚果"},{"categories":["Growth"],"collections":null,"content":"小云朵 今年我们还干了一件大事儿，积极响应了国家号召，后续要新增一个小云朵板块啦。 不得不说，二胎跟一胎相比，关注度低了好多，应该是我们已经习惯了爸爸妈妈这个角色。我老婆说，之前怀小坚果的时候，会经常通过宝宝树看宝宝长多大了，而现在有时候甚至会忘了还怀了一个宝宝。我也有点担心，怕以后小云朵出生后，我写不出像跟小坚果那么多煽情的点滴了。 回到成都以后，我们实现了私立医院自由。之前在深圳的时候，压根没敢想过去私立医院，每次去公立医院产检都挺多人的，体验很不好。而这次在选择医院的时候，我们发现成都的私立医院并不比公立医院贵多少，但环境和服务要好太多了，医生也是慈眉善目、轻言细语，耐心特别好，平时有事儿还可以通过微信及时进行沟通。 ","date":"2022-02-05","heading":"小云朵","objectID":"/post/my-2021-summary/:2:3","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#小云朵"},{"categories":["Growth"],"collections":null,"content":"一些阴霾 成都整体环境都挺好的，但就是阴天比较多，很少能看到太阳。成年人的世界也没有容易二字，只是大多数时候我们选择只对外展示阳光的一面，而将阴霾留给自己。 今年，我老婆和我的奶奶接连去世了，我奔了两次丧。老婆的奶奶是一个特别慈祥的老太太，我虽然跟她接触不多，但每次看到她的时候她都是笑呵呵的，给人一种很温暖的感觉，小坚果也特别喜欢她。去年春节的时候，我开车走的时候还跟她打过招呼，没想到清明节后她就走了。庆幸的是，她身体一直挺硬朗的，走的时候也没受罪。而我的奶奶就没那么幸运了，她之前因为脑溢血患上了半身不遂，在轮椅上瘫坐了近十年，走之前那段时间也是异常痛苦。小时候我爸妈在外打工，我就跟着我爷爷奶奶一起生活，我奶奶基本上算是跟我一起生活最长时间的亲人了。她学着写自己的名字给我作业签字，她去我的学校参加家长会，她打着手电筒到处寻找在外贪玩夜不归宿的我，而今年，她就这么走了。 同样是今年，我爸妈的关系基本破裂了。起初我也尝试过劝和，但随着了解得越多，我越发地感受到无力感，两个人走到今天并不是一时的矛盾，而是长年累月的问题积累。双方都有各自的问题，又缺乏好的沟通方式，导致最终都以寒心收场。也许，他们分开以后可以各自过得更好。作为子女，我尊重他们的选择。 因为见证失去，所以更懂珍惜。我能做的，就是给家人更多的耐心和理解，人生苦短，家人永远是最重要的。 ","date":"2022-02-05","heading":"一些阴霾","objectID":"/post/my-2021-summary/:2:4","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#一些阴霾"},{"categories":["Growth"],"collections":null,"content":"个人思考 说完了工作和生活，再来记录下这一年一些零散的想法。 ","date":"2022-02-05","heading":"个人思考","objectID":"/post/my-2021-summary/:3:0","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#个人思考"},{"categories":["Growth"],"collections":null,"content":"35 岁焦虑 又是「35 岁」这个话题。不得不说，互联网人的确是最容易焦虑的一个群体，在网上经常可以看到相关的问题，「35 岁以后还能找到工作么」、「35+ 还有出路么」、「35 岁还没升到 leader 还能混下去么」等等。 在当前国内的大环境下，这的确是一个短期内「无解」的问题。公司的目标是盈利，那么势必就会考虑 ROI 和性价比。近些年因为互联网有一些行业红利，IT 从业者的收入相比于传统行业有一些优势，导致很多只要跟计算机、软件沾点边的专业的毕业生都在应聘互联网岗位，甚至还有很多其它行业的人也会通过培训转行进入互联网行业。而在国内普遍都是应用层 CRUD 的现状下，一个应届毕业生基本上 3 年就能成长为技术骨干，很多 5 年差不多就开始带团队了。那么在这种供需关系下，企业还有必要花费两三倍的价格去招一个 10 年+ 的员工来干差不多的活儿么，毕业三四年、无家庭牵挂、听话好管还便宜的年轻人他不香么。 这话听上去是挺残酷的，但这就是当前国内的市场现状。但另一方面，即使是在成都这样的二线城市，我们也可以在招聘网站上看到很多企业花费 50k+ 的薪资招聘各种技术专家，而且这类需求还特别多，一直处于招不够的状态。从我自己待过的公司来看，这也是当前真实的现状，高级的专业技术人才在哪里都是供不应求的。因此回归招聘市场需求，它并不是缺人，而是缺在特定行业领域有深厚积累的专业人才。 想清楚这些点以后，其实也不用再对「35 岁」感到焦虑了，因为焦虑也没有用。我们能做的就是提前做好个人规划，让自己年龄大点以后不至于变得那么被动。如果自己就是想躺平，对技术没兴趣，那么就提前找好退路，或者降低自己的心理预期，既要舒适又要高薪还要稳定是不太现实的。而如果自己本身就很热爱这个行业，喜欢技术本身，那么就多投入，把自己变得不可或缺吧。当然了，这里说的「多投入」也并不是拼加班，陷入低级别内卷是无意义的，而是我们要尽量去做有挑战性的事情，不断提升自己的技术视野和能力积累。 ","date":"2022-02-05","heading":"35 岁焦虑","objectID":"/post/my-2021-summary/:3:1","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#35-岁焦虑"},{"categories":["Growth"],"collections":null,"content":"我的未来方向 还有一年多我就要满 35 岁了，我未来的方向在哪里呢？ 还记得在我的 2018 年终总结中，我就写过关于技术和管理两条路径的选择，关于个人职业发展路径的困惑。现在回过头来看，当时自己的格局还是小了。因为所谓的「35 岁失业焦虑」、「技术还是管理」、「职级晋升」，这些归根结底还是停留在打工的思维，将主动权给到了企业，而不是握在自己手上。 那要是我们跳出打工者的思维呢？杨震原在一次 All-hands 面对面中分享过一个主题，《成长是自己的事情》，里面提到的一个观点我十分认同，「不管你做什么样的工作，实际上你一直在创业；在公司内部工作就是在公司内创业，在公司外部就是在社会上创业」。如果把我们团队当作一个创业公司，从成本来说，这又何尝不是从投资者拿到的风险投资？那么我们要达成怎样的目标做出怎样的成绩才能说服投资方持续投入，甚至加大投资规模？ 这其实就是创业者需要重点考虑的事情，关键还是要以创业的心态进行思考和做事。我也非常喜欢当前所处的工作状态，在拿着还不错的收入的同时，还可以做着 ToB 探索的事情，不断提升自己的技术视野和积累行业经验，这是多么好的成长锻炼机会啊。那么在多年以后，当我自身的综合能力处在一个更高的位置时，与其被动等待公司或团队的工作安排，我已经能主动追寻自己的事业目标了。 在转换思维以后，再回过头来看自己的未来发展方向，可能性就会变得非常丰富多彩了。也许，我可以做一名独立开发者，按照自己的想法做产品，感受来自用户的直接反馈，要是能看着用户量自发地增长那就再开心不过了。也许我还可以做一个技术自媒体，写技术文章，出版技术书籍，做技术咨询，将自己的技术积累以通俗易懂的方式触达更多读者。或者我也可以专职投入到运营开源项目，探索开源项目 + 付费服务的商业化模式。当然啦，留在字节继续打工也是一个非常好的选择，可能是做一个沉浸式的技术专家（码农），也可能是做一个项目负责人或技术团队 leader。 总之，我期望选择权掌握在我自己手里。 ","date":"2022-02-05","heading":"我的未来方向","objectID":"/post/my-2021-summary/:3:2","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#我的未来方向"},{"categories":["Growth"],"collections":null,"content":"对 ToB 的一点思考 我今年才开始正式投入 ToB 的工作，对 ToB 的认知层次还挺浅的。不过经过短暂的犹豫后，我打算还是把自己关于 ToB 的一些不成熟的思考记录下来。相比于前面 ToB 探索部分的内容，这里的想法会更偏宏观一些。 我们 ToB 应该选择什么产品方向？我们的目标用户群体在哪里？ 回归 ToB 的本质，是面向企业提供服务。而企业的首要目标，永远是挣钱（盈利），而且是持续稳定地挣钱。从这个角度来讲，ToB 服务就应该聚焦于如何更好地帮助企业用户挣钱盈利。那如何实现盈利呢？无非就是「开源节流」。开源，指的是帮助企业提升产品能力，扩展市场规模，支撑企业更好地服务用户。而节流，则是帮助企业更好地提升内部效率，缩减人力资源成本。除此之外，还得考虑「持续稳定」，解决企业用户可能存在的风险，避免在遭受极端风险时受到巨大的损失。 这里举一个更通俗的例子。市场上有很多金矿，企业就好比是一个个掘金者，只有持续挖到金子才能存活和发展。那作为 ToB 服务者，就应该是帮助掘金者更好地挖矿。「卖铲子」是一个方向，给没有铲子的掘金者提供铲子，或者帮助已经有铲子的掘金者升级为更高效的挖掘机，这既可以算作是开源，也可以算作为节流。另一方面，掘金者要想保障业务的持续稳定，还需要关注安全合规，否则可能因为一次安全事故遭受大额罚款甚至关停，这也是 ToB 服务者可以重点考虑的方向。 再回到我所在的质量或体验这个业务领域，我们的 ToB 机会在哪里呢？一个比较尴尬的现状是，当前质量这条线在大多数公司内部都是成本部门，跟企业的盈利很难产生直接关联。也是基于这个原因，当前很多公司内部的质量部门都喜欢提「业务价值」，搞「价值度量」，算「价值收益」，因为只有这样才能体现出质量团队本身的价值。在这样的大环境下，帮助企业更好地降低测试门槛、提升测试效率、追溯质量问题、度量测试价值，应该会是较为普遍的需求。这些都算是「节流」的范畴。 那我们有没有可能支撑企业进行「开源」呢？其实也是可以的。首先，我们可以先重点关注下通过测试业务来挣钱的企业。例如，第三方测评机构就是一个典型的目标客户群体，他们主要通过提供第三方测评服务、出具测评报告来获得产值。帮助测评机构更低成本、更高效率地开展测评工作，可能会是一个机会。其次，我们可以关注下哪些行业客户的系统性能、可用性、安全合规等因素，会严重导致客户的收益损失。例如银行证券类客户，银监会对这类网站的性能和服务可用性有明确要求，出现问题后可能会导致评级降低，甚至领导被追责。又例如出海类的企业，出海产品会面临欧美的强监管政策，安全合规方面稍有不慎可能会面临高额罚单甚至产品下架。如何帮助这些客户更早、更快地发现问题，甚至帮助客户在源头上避免出现这些问题，助力客户的业务「持续稳定」地发展，也会存在较多的机会。 归根结底，ToB 的核心还是要为客户提供价值，帮助客户解决痛点和问题。 ","date":"2022-02-05","heading":"对 ToB 的一点思考","objectID":"/post/my-2021-summary/:3:3","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#对-tob-的一点思考"},{"categories":["Growth"],"collections":null,"content":"新年展望 最后再展望下 2022 年吧。 1、工作方面，我在字节跳动还有一年多点就要满四年了， 2022 年将会是非常关键的一年。我个人的工作重心已经全部投入到了 ToB 方面，我就假设投资人的钱只够维持最后一年了，那我这一年所有的努力，都会围绕着怎么获得业务突破，融到下一笔资金。 2、开源项目方面，我会重点投入 HttpRunner+ 的迭代和推广，争取产品能给测试行业带来一些惊喜。与此同时，我也会尽量多到一些行业大会上进行分享，触达更多用户，收集更多的反馈。如果你对我们当前在做的事情有兴趣，欢迎直接跟我联系，我们可以线下进行更深入的交流。 3、生活方面，期待老婆和小云朵都顺顺利利，我也尽量平衡好工作和生活，多留些时间带着小坚果一起运动；同时期待新房顺利交房，一起迎接全新的生活。 4、系统性学习，勤思考，多输出，博客公众号继续写起来。 ","date":"2022-02-05","heading":"新年展望","objectID":"/post/my-2021-summary/:4:0","tags":["年终总结"],"title":"我的 2021 年终总结","uri":"/post/my-2021-summary/#新年展望"},{"categories":["Growth"],"collections":null,"content":"一年没写博客了，时而想起曾经立下的「日更」「周更」的 flag，羞愧不已，然后又埋头写代码去了。但不管怎样，一年一度的年终总结是必不可少的，趁着春节在家的空闲时间，我强迫自己戒掉代码，对自己的 2020 这一年进行总结回顾。 在 2019 年终总结中，我写到「想清楚这些之后，也就没啥好纠结的了，还是过段时间去成都吧」；出乎意料的是，这个计划超前实现了。在 2020 年 6 月，我终于离开了工作和生活了 9 年的广州深圳，跟家人一起来到了成都，并打算在成都定居了。总的来说，到成都以后，工作内容和节奏没太大变化，但生活质量和幸福感得到了极大的提升，我也挺满意当前的状态。 接下来还是按照既往的年终总结模式，回顾下这一年的工作、生活和个人思考，并适当做一些新年展望吧。 ","date":"2021-02-18","heading":"","objectID":"/post/my-2020-summary/:0:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 在工作方面，今年最大的变化是从深圳飞书部门转岗到了成都互娱研发部门，中间也经历了一些转岗的波折和外部面试寻找新机会的尝试。 ","date":"2021-02-18","heading":"工作","objectID":"/post/my-2020-summary/:1:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"飞书（深圳） 首先是在飞书期间，我的工作内容基本上算是 2019 年的延续。只不过随着外部形势的变化，飞书的业务重心从海外向国内做了较多的倾斜，特别是在国内大客户私有化部署上投入了更多的资源。而我作为私有化部署性能验收方向的 owner，工作重心自然也都投入到性能验收方面了。具体工作事项比较多且杂，我就不一一赘述了，最终实现的效果就是将绝大多数性能验收工作进行了自动化：对于任意 KA 私有化环境，只需要给定租户 admin 账号，即可批量生成租户下所有测试账号在所有子产品线的相关压测数据，一键启动实现精准流量比例控制下的混合场景性能压测，并在压测结束后完成相关基础组件核心性能指标和压测结果数据的采集汇总和统计，压测所需投入的人力和耗时都压缩到了最低。 除了提升性能验收的效率外，当时还有一项重点工作是保障飞书第一家私有化部署的客户小米集团完成验收交付，我也自然地成为了一个「苦逼」乙方，需要对接小米 QA 部门及时响应甲方爸爸的各种需求。当时有一个比较有意思的插曲，就是小米 QA 部门必须要求获取接口信息以便他们自己能进行压测验证，否则就不给验收通过（这个要求不过分）；字节的安全部门要求不能对外提供接口信息，安全高于一切（这个要求也很合理）。而我们作为业务部门夹在中间就很头疼了，两边都很强势都没有商量的余地，那我们到底要怎么做才能在遵守公司安全合规要求的前提下满足客户的需求，消除合同风险呢？事实证明，办法总比困难多。最终经过探索实践，我基于开源的 Locust + Boomer 封装了一套私有化压测工具，在无需对已有的 golang 压测脚本做调整的情况下，将压测脚本和压测工具整体编译生成一个可执行的二进制文件，再经过安全加固后交付给小米 QA 部门；他们无需再编写压测脚本（也无法获取接口参数细节），最多只需要在配置文件里面调整下接口的流量比例，即可通过一条命令将压测工具进行启动，然后在 Locust Web 界面里面进行压测验证工作。这套方案很好地满足了客户需求，也被顺利地推广到了其它 ToB 业务线（People）；我也收获到了比较多的认可，包括加薪和 Spot Bonus 奖励，也算得上是一个 happy ending 了。 ","date":"2021-02-18","heading":"飞书（深圳）","objectID":"/post/my-2020-summary/:1:1","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#飞书深圳"},{"categories":["Growth"],"collections":null,"content":"转岗经历 在飞书工作和发展都挺好的，为什么会选择离开呢？当时 leader 和 leader 的 leader 都有找我深入地进行过沟通，也都问了我这个问题。其实我也很不想离开飞书团队，但选择离开更多的还是基于现实因素。回顾前两年的年终总结，我都有提到过深圳的房价压力，不是上不了车，而是深深地感到不值，已然高位的房价和其对应的居住品质、教育、医疗资源严重不匹配，性价比太低了。再加上在字节跳动工作一年了，平时工作中的合作伙伴大多都是在北京上海（异地），以及疫情期间在家办公了近两个月，我越发地觉得工作地点对我自己本身的工作并没有多大的影响，既然如此，我为何不转岗到成都的字节跳动呢？ 明确想法后，我罗列了三种转到成都的途径。一个是 base 转到成都，但仍然在当前飞书团队。跟 leader 沟通过后，leader 同意了这种方案，并为我去做了尽力争取。可惜的是从 HR 的角度，这种方案不太利于组织管理，不能开这个口子，而且长期脱离团队也存在比较大的稳定性风险。 第二个方案是转岗到 People 团队，当时字节也只有 People 业务线在成都有研发团队。因为之前做飞书私有化的时候跟 People 团队有过较多交流，我也推广了我的私有化压测方案，因此对方是欢迎接收的。但因为 People 跟飞书同属一个大部门，要转过去可能会面临着同时要兼顾既有岗位工作和新岗位工作的尴尬处境，再加上我自身对于 People 的业务类型不是很喜欢，因此我比较犹豫。 第三个方案就是跳槽了，这是我最不想选择的方案，因为我才在字节待了一年，不想那么快换工作。只是迫于当时的形势，我也看了下成都的其它大厂机会，主要是是腾讯的几个产品，包括王者荣耀、腾讯云和企业微信。但由于腾讯不能同时面试多个业务线，因此我在开始面试之前先联系到了各个产品的质量线 leader，打算详细了解情况和匹配度后再决定面试部门。王者荣耀那边名气很大，传说中年终奖很多，但我之前一直没做过游戏相关，自己平时也不玩游戏，因此就简单聊了下作罢。腾讯云那边看岗位描述还挺有意向的，大多都是容器化云平台相关的，不过聊的时候感觉气场不太合拍，也放弃了。对比下来，企业微信是最合适的选择，方向对口、wxg 光环、业务前景喜人，于是我就正式投递了企业微信，开始了「漫长」的面试。说是漫长，主要是轮数很多，业务部门 3 轮技术面 + wxg 面委会 3 轮技术面 + 1 轮 HR 面，但总体耗时也还好，在对方 leader 的给力推进下，七轮面试差不多半个月就搞完了。我也终于见识了传说中的 wxg 面试，果真名不虚传，基本上每轮技术面都要先写代码，甚至有一轮面试一上来就给了三道题，要求 40 分钟内写完。还好我已经习惯了这种形式，代码这块儿没遇到太大问题，项目经历也聊得不错，最终顺利拿到了 offer。 正当我在纠结要转岗到 People 还是跳槽去企业微信的时候，字节互娱研发（IES）的抖音某业务线（后面简称抖音 S 业务）在成都开设研发部门了，第一时间就有两位 IES 部门的好友同事（GLL 和 HY，都是之前通过 MSTC 大会认识的）给我转发了该消息。不过初步设立的岗位只有业务线开发岗，没有质量方向相关岗位。说到这里非常感谢莉莉的热心张罗，帮我先后联系了抖音 S 业务线服务端负责人和 IES 质量负责人 XBB，以及非常感谢 HY 的大力推荐，感谢 BB 的认可，最终 IES QA 在成都的招聘计划提前了，我也有幸成为了 IES 在成都的第一位测试开发。哦对了，为啥没有考虑跳槽到企业微信呢？最大的原因还是在字节才一年，不想跳槽太频繁；特别是这一年见了很多大佬，无不是之前都在某大厂干了十来年，有所积累和成就的，而我也非常喜欢字节的企业文化，期望能在字节长久发展。接下来就是走正式的转岗流程，退租，搬家，自驾到成都，这些后面再细说。 转岗到互娱研发后，首先非常震惊的是 QA 部门很大，内部方向也很多。为了帮助我快速了解整体情况，HY 特别有心地帮我联系了各个方向的负责人，然后让我出差到北京、上海、杭州等城市跟各方向负责人当面聊了一遍，熟悉业务的同时也混个脸熟。具体工作方向方面，虽然 IES 在成都的业务方向是抖音 S 业务，但测试开发相对独立，不局限于抖音 S 业务。当时给我的自由度也很高，可以跟着 HY 一起做国际化机架相关的，也可以找新的独立方向。从发展前景来看，单独立新的方向可能会有更多的机会，但经过短暂的考虑后，我还是决定加入 HY 的专项测试团队一起做国际化机架项目。原因很简单，一个是之前跟 HY 团队已经有过密切的合作，他们在做的国际化机架项目具有很多有挑战的点，能满足我个人的技术成长诉求；另一个原因就是在跟HY团队初步接触后，被他们的热情给深深打动了，跟团队成员相处也很融洽。下图是刚转岗时HY拉的一个关怀群，让各个子方向的 owner 帮助我快速融入。 ","date":"2021-02-18","heading":"转岗经历","objectID":"/post/my-2020-summary/:1:2","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#转岗经历"},{"categories":["Growth"],"collections":null,"content":"抖音（成都） 国际化机架在 2019 年终总结的「全球化测试」部分已经做了较多的介绍，这里就不再重复。恰好在个人角色从合作团队转向内部团队成员后，我发现当时机架项目还存在缺乏统一测试调度服务、各专项测试 agent 分散的现状，于是很自然地我就先投入到了这部分的建设。总体思路跟之前在飞书的设计方案类似，除了改良之前设计的不足之处外，主要是从项目长远发展的角度，对技术选型做了调整，server 部分（之前由另一位同事使用 Java 开发）和 worker 部分（之前由我使用 Python 开发）都调整为了 Golang，开发框架也都与字节内部技术栈进行了对齐。我也借此机会，使用 Golang 从零开始开发了一套生产级别的分布式系统，熟悉了字节内部的 TCE 云平台、SCM 打包平台、MS 监控平台、Ginex HTTP 框架、Kitex RPC 框架等研发组件。得益于近半年对 Golang 的高频使用，我对 Golang 的熟练度获得了大幅提升，开发效率基本赶上了我使用 Python 的水平，这也算是近半年最大的收获之一了。说到这里，我想说虽然 Python/Ruby 这种动态语言（之前我的主力编程语言）运行很方便，但使用 Golang 这种静态语言时只需要按照思路一顿梭哈、随意重构、只要 IDE 没有报错提示的情况下基本就能运行正确的感觉太爽了。是的，我的主力编程语言已经切换为 Golang 了。 当然了，今年在测试调度服务方面的工作肯定不仅局限于使用 Golang 将 Task Server/Worker 重写了一遍，而是跟机架整体项目全方位进行了深度融合，并且以 ToB 的标准进行了设计和迭代。在 IaaS 层，Task Worker 整合了所有设备资源，包括 Darwin/Linux/Windows/Android/iOS 等，并初步提供了一些基础能力，实现了对设备的调用操作；在 PaaS 层，Task Server 实现了通用的任务调度能力，并对接了众多字节跳动基础设施，对外提供了统一的 OpenAPI，简化了用户的接入成本；在 SaaS 层，业务方基于 PaaS/IaaS 层的基础能力，就不用再关注设备和任务调度方面的细节，可以更专注于专项测试能力本身的建设。非常开心的是，整个大机架项目（名字先不透露，我做的工作仅占其中一小部分）最终在公司内部通过了 ToB 立项，后续将对外开放提供商业化服务。我有幸能参与到整个项目从 0 到 1 的建设，接下来应该也会全力投入到该项目，期待项目能做大做强，早日扬帆出海。 转岗到成都后，我很自然地承担起了成都测开岗位的技术面试工作；同时，我也有幸晋升为了二面面试官，会参与到专项测试团队的技术二面。在这里不禁感慨下，成都测试开发岗位真的好难招人。主要原因是成都本地互联网公司普遍都较小，里面基本都只有测试岗位，即使一些有测试开发 title 的人大多数也都是干着测试的事情，最多就是写点自动化脚本，不具备完善的工程开发能力；而少数大厂在成都的分部，里面的人都很稳定，很少跳槽，例如我就了解到成都腾讯，里面有大把一待就是近十年的人。也是因为这个原因，这半年抖音 S 业务线都入职 XX 个测试人员了，我们测试开发团队才只有两个人，发出去的 X 个 offer 也都是应届生（大多数都是同时拿了其它大厂后端开发岗的 offer）。后面如果你们有谁想到成都发展的，请务必联系我啊。 除了国内的技术面试工作外，我今年还参与了一些海外外包的面试工作；是的，我所在的专项测试团队是一个国际化团队，需要招聘分布在主流国家地区的海外外包。虽然自己的英语口语听力严重退化，但当时也是抱着「锻炼英语口语，体验全球各地英语口音」的想法，将海外面试写进了自己的 OKR，先硬着头皮上，经过近半年累计十来场的面试，总算「基本适应」了。当然了，一开始并没那么顺利。在首场独立面试之前，为了先找回点感觉，我先使用「开言英语」练习英语听力和口语，每天 30-60 分钟的样子，坚持了近 2 个月。还记得当时第一场面试，作为面试官，我应该是比面试者还紧张得多；在面试开始前两个小时，就开始有些「瑟瑟发抖」的感觉；对方是个德裔的荷兰人，面试过程中我听力完全不够用，口语表达也捉襟见肘，一个小时下来我感觉自己基本瘫了。即使是现在，所谓的「基本适应」也是有很多限制条件的，例如印度这种口音 Hard 模式我还是不敢挑战的，而北美这种英语母语国家，自由问答交流环节（面试结束后，通常会问候选人有啥想了解的），我也很难招架得住；相比下来，俄罗斯、韩国这类都是非英语母语的会温和不少，一般遇到这种类型的候选人我相对就不怎么紧张了。 ","date":"2021-02-18","heading":"抖音（成都）","objectID":"/post/my-2020-summary/:1:3","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#抖音成都"},{"categories":["Growth"],"collections":null,"content":"生活 伴随工作变迁，今年在生活方面最大的变化也是从深圳转到了成都。我之前是从广州到的深圳，为什么不考虑回广州呢？说来也奇怪，2016 年从广州去深圳的时候，深圳的房价已经很高了，当时我就想的是在深圳待几年以后再回广州。结果今年离开深圳时，却基本没有纠结，毫不犹豫地选择到成都定居。互联网就业环境方面，成都的确比广州还是差挺多的，特别是缺少微信总部这样的机会；但近些年成都的大厂多了不少（虽然都是分部），包括字节跳动多个业务线研发团队的入住，也能满足我自身的职业发展了。而抛开就业方面，成都的其它方面对我具有更大的吸引力，毕竟作为土生土长的重庆（四川）人，成都的美食更合口味，离重庆更近，跟家人和同学相聚也更方便。 目标明确后，离开深圳时心情格外的轻松，下图是自驾离开深圳，途径前海收费站发的朋友圈。当时我老婆也在她的公众号发了一篇文章，《在告别深圳的路上，我说成都的夕阳也那么美，你说是的》，里面也记录了很多我们在深圳的美好回忆。 在文章开头说到，转岗到成都后，工作内容和节奏没太大变化，但生活质量和幸福感得到了极大的提升。 首先，提升最明显的方面在于居住品质。在深圳的时候，每个月房租加上物业水电费花费接近八千，感觉花的已经挺多了，但也只能租到七十来平米、装修简陋、楼龄老旧的小两房。虽然地段是挺好的，但居住体验真的不咋地。特别是还遇到个虚伪的房东，满嘴的吃亏是福、南无阿弥陀佛（信佛），一副对钱无所谓的样子，实际上想的是吃亏的绝对不能是自己，设施老旧后要找他维修也是极不情愿的样子，最后在我们退租时两个月的押金一万四千多硬是一分钱都没退（之前签约了两年，提前退租不退押金也合法，只是有些气）。当然了，遇到的房东只能算是个例，不能以偏概全，但实际经历还是让自己对深圳少了一分留念。来到成都后，因为成都限购的原因，我们还没有购房资格，因此需要先租房。但选择明显就好了太多。同样是在公司附近（步行上下班），比较不错的小区环境，非常齐全且品质较高的家具家电配套，三房，只需要深圳月租一半的价格。房东人也非常好，相处起来很是愉快。 哦对了，影响居住品质的还有一个重要因素就是停车。回想起之前在深圳的停车经历真是一把辛酸泪。在龙海家园、深康村居住的时候，经常需要早起去挪车，甚至有时回去比较晚完全找不到车位，只有停在别的车前面，然后半夜被电话叫起来挪车；在信和自由广场租房的时候，小区不给租户办理停车卡，最终不得已在中介的指点下，买烟疏通隔壁小区的保安，将车停在隔壁小区路面上，每次要开个车也很是麻烦。而回到成都后，停车的痛苦就再也不存在了，轻轻松松租了一个地下固定停车位，我也终于体验到了随时回家都有车位的幸福感。 除了当前实实在在居住品质的改变，幸福感的提升还包括具有了更多选择的可能性。之前在年终总结中也说过，其实以当前自己的收入水平，要在深圳买房也是可以的，但真的只能勉强上车，地段、小区品质不敢奢求太多，更别说对好学位的追求了。而到成都以后，顿时多了很多选择。同样是 500-600 万，在深圳南山只能买两房老破小，而在成都 top 板块的高端小区兼学区房，都是可以买得起的。这半年我们周末也花了很多时间看房，着实开了不少眼界，「新风、地暖、中央空调」标配三大件、带私家花园的叠拼别墅、2.0 容积率大平层、两梯两户私家电梯入户、铝合金钢挂石材外立面、玻璃钻石切面、中英双语管家服务、主动式物业服务，与之相应的，8-10 块每平米的物业费。这些在深圳闻所未闻想都不敢想，在成都居然都可以作为选择的对象了。不过，成都当前二手房挂的偏高，不满五年还有 5.3％ 的增值税，能买到新房是最好的；而高新区的新盘竞争也挺激烈的，拼顺位拼社保时长拼摇号运气，300 万以下总价的神盘基本上没指望进摇号池，好地段好学区 400 万以上总价的盘抢的也很厉害，今年万科公园五号还炸出了 101 岁的刚需代持者。总之还是挺难的，但不管再怎么难，至少还有可能还有希望还有选择，这比起无奈的佛系已经好太多了，不是么？ 不知不觉地，小坚果今年已经三岁了。非常庆幸的是，小坚果胃口很好，身体倍儿棒，这一年健健康康的，基本上没有生病。这一年，小坚果的表达能力强了很多，会一本正经地讲故事了，表达的时候还多了一些连接词，例如「不是的」「我小时候」「然后」「因为」。并且偶尔会冒出一些很新奇的表达，「我肚肚饿到糖了」、「起雾了就是 whoops」、「谢，什么谢，螃蟹的 xie 么」，让我们颇是惊喜。同时，他开始有自己的主见了，自己想唱歌的时候不让我们说话，只能他唱；衣服鞋袜必须要自己脱，要是一不小心帮他脱了，就哭得昏天黑地，必须要给他重新穿上，让他自己重新再做一遍才行。这跟我小时候简直像极了，当年要是父母走在我前面，我也耍浑不干，说路被走过了。 这一年，小坚果也有了不少改变。他的性格一直都很温和，不争不抢的，玩具被其它小朋友拿走后也默不作声（内心可能也会很难过）；但坚果妈告诉他，如果心里不情愿的话，就要勇敢拒绝，守卫自己的东西；结果他后面在被大哥哥抢玩具的时候，还真的硬刚，把玩具抢回来了。哦对了，他的数数能力也增长了很多。早上上班前我经常会跟他玩一会儿火车轨道之类的游戏，为了能顺利出门，就会让他自己说还要玩几圈。以前他只会说三圈，四圈；后来估计是长记性了，就会说 14 圈，甚至 60 圈，越来越不好忽悠了😂。 ","date":"2021-02-18","heading":"生活","objectID":"/post/my-2020-summary/:2:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"成长 \u0026amp; 思考 说完了工作和生活，再来记录下这一年一些零散的想法。 互联网行业相比于传统行业，有一个很大的特点就是节奏很快，相应地，我也可以很明显地感觉到互联网从业者会更加浮躁。在空闲的时候，有时候我会逛逛「脉脉」匿名区，看下各个互联网公司的八卦。在里面经常会感受到一些焦虑，「35 岁危机」、「晋升」、「加薪」、「跳槽」都是很高频的话题；最近在快手上市股价猛涨以后，也看到一些提问，怎么能在早期加入下一个快手，实现财务自由。说实话，早几年的时候我也有过这些想法，也幻想过要是再早几年加入字节跳动的话该多好。这就像很多炒币或买房的人，经常会说要是早知道比特币要翻那么多倍，深圳房价要涨那么厉害，早些年买入的话就好了。 然而，世上没有如果，这种幻想除了浪费时间外，一点意义都没有。而且我也深刻地认识到一个问题，很多时候不是缺少机会，运气也只占一部分因素，而是在机会到来时，我们是否有相应的认知去识别机会，是否有匹配的能力去抓住机会。就以字节跳动为例，最佳的加入时间应该是在 2015 年以前，就算那时我在北京工作，也有幸知道这家公司，但以自己当时的能力，能否面试通过进入字节\b呢（多半是面试机会都没有）？就算面试通过了，能否拿到期权呢（低阶岗位，基本也没有）？假如当时真有能力可以拿到期权，那多半也是可以拿到其它大厂的 offer，那以当时的整体形势，是否愿意加入一个小公司，而放弃其它大厂的工作机会呢？ 其实，抛开远的不说，就在我 2019 年换工作的时候，也错过了「暴富」的机会。在「写在大疆毕业之际」中，我当时经历了 offer 选择的纠结，OPPO、蚂蚁金服、Shopee、字节跳动，最终我经过深思熟虑，选择了字节跳动。而 Shopee，则在近一年的时间内，股价从当时 26 美金涨到了当前的 270 美金，翻了十多倍，之前授予的股票当前市值差不多达到了 600 万人民币。另一个是发生在身边的例子。一位关系不错的同事（工位就挨着我），在 2020 年初的时候从飞书离职，跳槽去了蚂蚁金服，薪资涨了一大波儿，也拿了不少蚂蚁的期权；结果在蚂蚁待了几个月，因为实在受不了长期出差，就跳槽去了快手；当时因为蚂蚁马上要上市了，市场一片看好，因此跟快手谈 offer 的时候溢价能力很高，又涨了一拨儿，而且谈 offer 时快手是按照 300 亿美金的估值算的期权价格（8 美金一股）。后来的事情大家都知道了，蚂蚁金服 IPO 被迫中断，市场预期大幅降低，而快手上市后，市值突破万亿人民币，我同事的年薪总包相比从字节离职时也翻了几倍。上次跟我这同事聊天时，他回想起自己的经历也感叹太神了，刚好都踩在了点上；我也非常替他开心，送他两个字，牛逼！再回到我在 2019 年换工作的时候，假如当时蚂蚁金服没有临时冻结 HC，以我当时想直接回成都的心态，估计多半就选择成都蚂蚁金服了，那就又是另一个悲伤的故事了。 所以说，世事难料啊，没有那么多如果。当前网络上流传着一句话，我深以为然，「你永远赚不到你认知之外的钱」。既然如此，我们与其去焦虑，幻想一些虚无缥缈的东西，还不如脚踏实地做好手头的工作，提升自身的专业能力和认知水平，那么在下一次真有机会来临的时候，我们才有可能不再错过。而且再退一步想，我们身处互联网行业，其实已经享受到非常多的行业红利了，人还是知足常乐的好；比起「财务自由」这种跟买彩票中头奖一样的极小概率事件，工作顺心、家庭和睦、身体健康才更实际，也更重要。 ","date":"2021-02-18","heading":"成长 \u0026amp; 思考","objectID":"/post/my-2020-summary/:3:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#成长--思考"},{"categories":["Growth"],"collections":null,"content":"健康 年龄大了以后，说到健康的问题，总是稍显沉重了些。 今年受疫情的影响，年初的时候我在家办公了近两个月。工作方面虽然没受啥影响，倒是整天家里蹲，体重涨了好多。然后这一年基本上也没怎么运动（想起去年的新年展望又啪啪打脸），体重就再也没降下去，维持到一百四十多斤了。果不其然，年底在公司年度体检时，查出了轻度脂肪肝，再次给我敲响了警钟。 今年还有一个身边的事例对我触动挺大的。当时从深圳转岗回到成都时，到一位同事家做客吃饭，还见过同事的父亲，当时都还觉得挺好的；结果几个月后，就听说了噩耗，同事父亲身体不舒服，去医院检查发现是癌症晚期，然后没过多久人就走了。当时我挺难接受这么个事实的，伤痛的同时，也不禁感叹生命的脆弱和健康的宝贵。 虽然说对健康问题有了更多的重视，但今年在实际行动方面还做的很不够。仅有的行动就是给自己和家人又多买了几份重疾险和医疗险，提升了保额；同时我也打算给自己买几份寿险，万一自己哪天真的遭遇不测后，至少能保证家人的生活不受太大影响。当然了，保险只是作为对最坏情况的保障，最好是永远都用不到的好，健康主要还是得靠良好的生活习惯和日常锻炼，新的一年必须得落实到行动了。 ","date":"2021-02-18","heading":"健康","objectID":"/post/my-2020-summary/:4:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#健康"},{"categories":["Growth"],"collections":null,"content":"新年展望 不知不觉地，2020 年终总结又写了这么多。说来惭愧，一年没写文章了，上一次博客公众号发文还是 2019 年终总结。也是因为太久没写了，手生得厉害，这篇年终总结写得磕磕绊绊，横跨了整个春节假期。不过回顾下来，发现 2020 年竟然经历了那么多事情，通过文字记录下来留点回忆也挺好的。 最后再展望下 2021 年吧。 1、工作方面，持续在当前全球化数字体验监测平台项目深耕，期待自己的技术深度能有更大的提升；同时期待项目能顺利推进，早日实现商业化盈利。 2、个人成长方面，埋头写代码的同时，也多关注下外部方案和商业化的东西，全面提升自己的技术视野；多申请几个技术专利吧。 3、生活方面，期待摇中心仪楼盘，早日把购房问题搞定了。 4、健康方面，跟家人一起保持良好的生活习惯，早睡早起；日常锻炼也得提上日程，体重争取降到 130 斤以下。 5、输入与输出相结合，系统性学习，勤思考，多输出，坚持博客创作。 ","date":"2021-02-18","heading":"新年展望","objectID":"/post/my-2020-summary/:5:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#新年展望"},{"categories":["Growth"],"collections":null,"content":"往年记录 《我的 2019 年终总结》 《我的 2018 年终总结》 《我的 2017 年终总结》 《我的 2016 年终总结》 ","date":"2021-02-18","heading":"往年记录","objectID":"/post/my-2020-summary/:6:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#往年记录"},{"categories":["Growth"],"collections":null,"content":"内推岗位 最后再打个小广告。当前我所在的团队正大力招聘测试开发工程师，特别是成都这边，HC 多多。岗位工作内容在我文中提到了一些，大家如有兴趣可以参考如下岗位 JD，或者找我详细了解更多信息。 JD: https://job.toutiao.com/s/JojBoqE Email: lilong.129#bytedance.com ","date":"2021-02-18","heading":"内推岗位","objectID":"/post/my-2020-summary/:7:0","tags":["年终总结"],"title":"我的 2020 年终总结","uri":"/post/my-2020-summary/#内推岗位"},{"categories":["Growth"],"collections":null,"content":"还有两天就农历新年了，我也拖家带口，踏上了回重庆老家的列车。按照往年惯例，每年的年终总结必不可少，这是对自己过去一年的交代，也是给未来的自己留点记录和回忆。趁着高铁上的空闲时间，我开始逼迫自己快速回忆这一年来的点点滴滴，整理成为 2019 年终总结。 ","date":"2020-01-30","heading":"","objectID":"/post/my-2019-summary/:0:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 今年最大的变化就是告别工作三年的大疆来到了字节跳动，从技术管理岗再次转变为一线测试开发工程师岗。关于换工作的想法及历程，我在《写在大疆毕业之际》进行了较多的记录，这里就不再重复。 从大疆离职之前，恰逢之前的接口测试平台做到一定阶段，测试数据方面的需求也越来越重，因此今年春节后就开始计划测试数据服务方面的建设。基本思路还是基于 HttpRunner，通过接口自动化和数据驱动相结合的方式，实现各种类型测试数据的生成，并通过将参数入参和生成结果进行映射存储，最终实现测试数据查询服务。当时我正在学习极客时间的《10 倍程序员工作法》专栏，被其中「以终为始」、「最小可行性产品（MVP）」的理念深深折服，因此在项目启动之初就在团队内部推广这些理念。大家的接受度很高，因此我们的项目迭代方式也尽量遵循这些方法论，用户故事规划、任务拆解都做得有模有样，也算是一段难忘的经历。 来到字节跳动以后，隶属于 Lark 业务线的测试开发团队。相比于之前，一个很大的不同点在于 Lark 这边没有产品测试团队（今日头条、抖音等部门都还是有独立质量团队的），产品质量由研发团队全权负责。我个人是非常认可这种管理模式的，这也是我当初选择加入这边的一个主要因素。在明确“开发对质量负责”的基调下，不管是从流程还是实际操作层面，减少了沟通成本，大大提升了组织效率。并且这也迫使开发人员具有更高的质量意识，在开发过程中会更多地去主动关注代码质量和可测性，毕竟没有测试和运维人员再为自己的工作进行兜底，线上出了问题半夜 oncall 也不是那么好受的。而且我也惊奇地发现，在这种研发管理模式下，开发人员对测试普遍都比较热衷（虽然很无奈），不仅单元测试覆盖率普遍较高，各类自动化测试工具和框架也玩得特别溜，甚至会自行开发一些面向业务特性的测试工具。 当然，这对于测试开发团队也提出了更高的要求。相比于产品测试人员，开发人员对工具更加挑剔，而且他们最不缺的就是开发能力。要是测试开发提供的工具不好用，或者无法很好地解决他们的质量痛点，那么他们很有可能会自己去开发出一套工具。因此对于测试开发人员，扎实的开发能力只能算是最基本的能力要求；对研发质量保障的理解和积累、对测试工具的设计规划（品味）、面向具体业务技术特点的测试规划能力，这些才是更能体现测试开发价值的地方，也是业务开发团队需求更大的方面。 回到我在字节跳动这大半年的工作内容。全球化、跨租户、专私有化部署都是 Lark 业务的显著业务特点，业务开发团队在这些特性上普遍存在大量痛点，因此我们测试开发团队就重点围绕这些领域开展工作。内容太多，这里只重点回顾下全球化测试集群和私有化部署验收测试两个方面吧。 全球化测试这块儿，常见的案例就是某个国家地区的 VIP 用户投诉图片无法加载、文件无法上传、视频电话无法拨通等跨国连通性问题。如何先于用户及时发现问题、如何在用户投诉后快速复现排查问题，这都是业务开发急迫需要的能力。当时也是带着这些问题，我在公司内部的几大部门出差调研了一圈，最后幸运地在 IES（抖音、TikTok 所在的部门）找到了方案。说到国际化，TikTok 可以算是一个非常成功的全球化产品，当前已经在全球一百多个国家地区运营提供服务。与此同时，TikTok 也不可避免地面临着众多国际化带来的质量问题，因此 IES 之前就已经在全球数十个国家地区建设了测试机架（组成部分包括 MacMini + 手机 + 当地运营商 SIM 卡），并基于改进增强的 STF 方案给业务团队提供远程真机服务（该项目负责人黄冶在 MTSC 2019 大会上也有分享）。既然都有 MacMini 了，那可以做的事情就很多了，假如我们将接口自动化测试和 UI 自动化测试跑在这些分布在全球各地的 MacMini 上，地理位置和当地网络都有了，那不就可以实现全球化的自动化拨测和问题复现了么？这里不得不赞下字节跳动的公司文化，部门间协作非常流畅，基本上只要能找到共同目标，都可以很好地达成合作（感谢黄冶团队的大力支持）。于是，我们就在 IES 国际化机架的基础上，建设了全球化测试集群。简单地说，全球化测试集群主要包括两部分，一个是统一的测试调度服务（Task Server），可以将任意类型的任务调度到任意指定的国家地区的设备上运行；另外一个是执行端（Task Worker），部署在全球各机架的 MacMini 上，可以拉取并执行测试任务。我是负责 Task Worker 端的开发（Python + Bash），主要需要解决的难点就是保障节点高可用率和尽量降低运维成本，具体做的事情包括实现在任意设备上一键部署，支持远程升级热更新（或版本回退），支持多种测试任务类型（API、Web UI、Android/iOS UI/Monkey），确保在遇到系统重启、网络中断、进程退出等异常事件后 Worker 能自动恢复正常工作（杀不掉、删不掉，跟病毒颇为相似）。也是因为全球化接口拨测具有普遍需求，因此 HttpRunner 也得以有机会推广到业务线，我也可以有机会投入部分工作时间继续对 HttpRunner 进行迭代开发。 在私有化部署这块儿，这应该算是 to B 业务相比于 to C 业务独有的需求特性，而且也存在较多的痛点。整个 lark 产品，十多个业务上百个系统数百个微服务，要实现私有化部署是非常复杂的事情，部署、配置、集成、验收、运维、onboarding，每个方面都涉及大量工作要做，如果没有完善的自动化能力，人工投入将会非常巨大。我们测试开发团队主要负责验收这一部分。那么多系统模块，那么多配置项，任意环节出问题后都可能造成系统功能的不可用，因此私有化部署后，功能验收必不可少。但很显然，我们团队没有足够的人力和时间自行去做功能测试，我们更多的是担任教练的角色，为各个业务线提供各端的测试工具和方法论，指引业务开发人员去做自动化测试建设，例如服务端团队需要进行接口自动化覆盖，移动端团队需要完成 Android/iOS UI P0 自动化覆盖。这部分工作我主要是负责接口测试工具方向，包括对 HttpRunner 本身拓展支持 Protobuf、Thrift RPC 等内部通讯协议，与公司内现有接口测试平台打通集成，规划建设更适用于 Lark 业务特点的接口测试平台等等。除了需要保障功能正常，私有化部署环境的硬件通常与字节跳动机房的硬件资源具有较大差异，甚至存在不同构的情况，因此私有化部署后的性能验收同样必不可少。我因为之前有过相对较多的服务端性能测试经验，因此就承担起了性能验收这部分的 owner。具体事项方面，压测工具平台是一方面，但不是主要的问题，公司内已经有多个成熟的压测平台，并支持多种压测模式，因此我做的更多的是工具选型，以及结合平台的压测技术方案规划。除了技术方面，更大的挑战在于如何协调那么多业务线开展性能验收，如何确定哪些业务必须要进行性能验收，具体要压测哪些场景，性能测试要压测到多大的并发量才合适，达到多大的吞吐量（QPS）才算是满足要求，这些都是需要在性能验收方案里进行明确的。显然，这些不能都靠拍脑袋，因此我就跟数据团队的同学进行合作，由他们基于当前 SaaS top 租户的数据进行统计，筛选出流量较高的接口，进而确定出需要覆盖的业务线和场景。场景确定了，就可以指导业务开发人员写出压测脚本；但对于压测来说，更难的在于压测数据的准备。因此，我这边还有个重点工作就是基于压测场景，与业务开发人员梳理出涉及到的压测数据类型，并统一规划压测数据的准备，打通测试数据服务等。具体的工作事项还有很多，这里就不再详细展开。对我而言，这些都是很好的锻炼和学习的机会，至少通过压测工作，我可以在短期内熟悉到当前业务线的技术架构，也在“赶鸭子上架”的节奏中，入门了 Go 语言。 不知不觉工作上的事情已经写了这么多。总体来说，入职字节跳动后的体验还是很不错的，公司内部文化非常开放透明，提倡「Context Over Control」，因此一线员工的自由度比较高，可以自己选择去做很多事情。而且由于 Lark 这边的去“QE”研发管理模式，我日常工作更多的是跟各个业务线的一线开发人员进行协作，方案评审、code review 之外，也接触了更多的开发设计和架构领域，这对自己的技术提升都起到了很好的促进作用。当然，我也有过不适应的地方。刚入职的一段时间感觉公司对外较为封闭，例如曾写过一篇《入职字节跳动两个月的个人体验》，由于涉及到一些公司文化方面的内容最终没能发出来；对外技术分享都需要经过严格的审核，因此各个技术大会上字节跳动的分享相对都比较少。但我后来也非常理解公司的做法，并认可公司的理念，「只有对外保密做得好，对内沟通才会更透明」。也是因为这样的制度，在公司内可以定期通过 CEO 面对面了解到公司的发展方向，研发员工可以获得非常开放的知识库和代码仓库开放权限等等，这些对于员工来说都是非常有益的。另外一块儿，就是不知道为啥，虽然公司没有 KPI 文化，没有要求上下班打卡，但大家都很拼，按照某同事的玩笑话说，就是「工作过于饱和」，完全没有摸鱼的闲暇时光。也许，这就是字节跳动这家公司的成功之处吧。 ","date":"2020-01-30","heading":"工作","objectID":"/post/my-2019-summary/:1:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"个人成长 接下来说下个人成长部分吧。 其实对于当前我所处的阶段而言，基本上除了生活就是工作了。而这里的个人成长部分，更多指的是抛开公司事务和生活之外的部分。 首先想说的是个人角色的转变，从技术管理岗再次转变成为一线工程师岗。之前跟朋友聊天时，朋友说从团队 leader 转为一线员工后通常会有些不适应，工作内容是一方面，更主要的可能是心理落差方面。其实对我而言还好，因为我本身也清楚当前自己的需求，希望能在一线获得更多的锻炼和积累。虽然说「屁股决定脑袋」，但经历过技术管理，习惯了站在团队整体去思考之后，重回一线后同样可以尝试去换位思考。很多时候站在团队甚至业务线的角度去思考问题，那么对于什么事情重要什么事情优先级更高，就会想得更加清楚。作为团队中的一名成员，我不负责建设团队管理制度，但我也会积极地参与进去。例如团队 OKR 管理、晨会制度、双周报形式这些日常工作方式，与其单纯地作为执行者，不如积极地参与到改进中去，提出优化方案，让自己所处的工作环境更舒适、更高效。其实说白了就是需要有「主人翁」心态，工作时不要觉得是在为公司打工，当做每件事情时保持创业心态，全身心投入后，产出结果和个人获得的成长收获都会大得多。 来到字节跳动后，我继续担任面试官，会参与技术面试工作。都说字节跳动的面试难度很高，同样地，公司对面试官也会有严格的要求。其中一个要求是每场面试都要有详细的面试记录，包括每一个面试问题和候选人的回答内容。这样做的好处非常明显，后续面试官可以查看到之前的面试内容，避免重复问题，而 HR 和终面面试官也可以更清晰地还原面试现场，对候选人进行更充分准确的评估。显然，一字不差地将候选人的回答记录下来不现实，也是没有必要的。而且很多时候候选人的回答思路比较混乱，我曾试过使用讯飞语记将语音实时转换为文字，发现也是完全没法看。因此这需要我在面试的过程中抓住候选人回答的要点进行记录，并同时聆听候选人的回答跟上思路。虽然之前在大疆已经前前后后参与了上百次面试，但刚在字节跳动实习面试的时候还是会有些不适应，记录速度跟不上是一方面，面试全程保持高度专注，精力消耗估计也不比候选人低。不过经过几次实习练习后，我总算可以正常适应了，后面也逐步习惯了这样的面试形式。在面试这块儿我还有个需要改进的点，就是对面试时间的把握，当前每场面试基本都要花费 1 个小时甚至更多时间。但实际上，在保障面试效果的基础上，面试时间是可以进行压缩的，而这本质上就是提问和识人的能力，这些方面我也需要持续进行锻炼和提升。 不同于往年，我今年基本上没有再去大会上进行分享。工作忙是一方面，更多的原因还是觉得自己当前的重心在于输入，需要在技术深度和项目能力上进行更多的沉淀和积累。但也有个例外，今年 MSTC 2019 新开了个 TTF 开源项目专场，HttpRunner 作为 TTF 首批孵化的重点项目，缺席肯定是不合适了。因此我今年就去北京参加了 MSTC 2019，分享主题为《HttpRunner 2.0 技术架构与接口测试应用》，重点介绍了 HttpRunner 2.0 方面的内容。出乎意料的是，最终经过听众投票，我的分享以 46% 的投票获得了「TTF \u0026amp; 质量保障 2 专场」的第一名，这是我第二次获得专场最高票数了，实在是受宠若惊，在此也非常感谢大家的认可和鼓励。关于大会的参会经历，我还写了一篇博客，《我的 MTSC 2019 分享经历》。 再说下我的个人开源项目「HttpRunner」。从 2017 年 6 月开始发布第一个版本，到现在已经快三年了，很庆幸这个项目还活着。截至当前，HttpRunner 的 GitHub star 数约 1.8k，并位于「TesterHome 开源项目榜单」第一位。今年思寒成立 TTF 基金会，HttpRunner 有幸成为了首批被赞助的项目。同时，HttpRunner 通过申请，获得了 Sentry 的开源项目支持。当然，收获认可越多，面临的压力也会更大。特别是当前 HttpRunner contributor 数虽然有 12 位，但基本都还是我个人在迭代和维护，当我工作比较忙之后，的确是很难再投入时间和精力在开源项目上。说到这里，前段时间才被用户骂了一次，「兼容性稀烂，发版前你做过 Windows 兼容性测试么？？？」，说实话还真没有；心里那个苦啊，自己没有 Windows 兼容性需求，周边也没有 Windows 设备可以用于进行验证，但 Windows 用户基数那么大，放弃兼容又不合适，只有硬撑着。看着 issues 数量逐步上升，我也越发地感觉到时间精力跟不上。庆幸的是，HttpRunner 在字节跳动内部逐步被部分业务线接受，我也有机会继续投入工作时间对 HttpRunner 进行迭代开发。另外，今年恒捷开始负责 TTF 开源项目，跟达峰和我一起筹划 TTF 开源项目运作机制，期望能吸引更多的人加入进来。开源项目只有形成了良好的社区和拥有稳定的核心开发者小组，才能可持续地发展下去，这也是 HttpRunner 接下来要努力的方向。 最后，对于博客公众号这块儿，今年基本上是荒废了，实在惭愧。原因有很多，但更多的都是借口，当内心懈怠懒成习惯以后，再捡起来就很难了。很久没写东西，手也生了不少，本篇年终总结写得也是格外费力。其实在很早之前我就认可一个观点，「最好的学习方式是教授别人」，而博客就是一个很好的实现方式。在新的一年里，加油吧。 总体来说，之前在 2018 年终总结中的困惑少了许多，我也感激自己再次跟随了自己内心的声音，勇敢地做出选择付诸了行动。 ","date":"2020-01-30","heading":"个人成长","objectID":"/post/my-2019-summary/:2:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#个人成长"},{"categories":["Growth"],"collections":null,"content":"生活 再说下这一年的生活方面。不得不说，有了孩子以后，心态上会有比较大的变化，生活也都基本上是围绕着孩子。 众所周知，互联网公司的工作节奏比较快（网传字节跳动尤甚），普遍下班时间不会太早。当然，整体氛围是这样，但具体几点下班还是可以自己把握的。因此我给自己制定了一套时间安排，每周一周二周四会在公司待得比较晚（22~23 点），尽量多做些事情，包括工作上的事情和个人技术学习；周三和周五晚上会下班回家跟家人一起吃饭，有工作上的事儿就带电脑回家在家处理；周末则基本上就不再做工作上的事情，会开车带着家人出去玩（遛娃）。通过这种方式，也算是取得了一定的工作和生活的平衡。 这一年，小坚果满两岁了，随着他的语言能力越来强，我们的生活也多了许多趣味。例如在我早上要出门上班的时候，他会不舍地拉着我说“不上班”，或者说要“跟爸爸一起去上班”；我安慰他说过两天周末了我们就出去玩，问他到时候想去哪里玩，结果他的要求总是那么低，不是“玩沙沙”就是“找个有滑滑梯的公园玩”。我也沾了小坚果的光，这一年没有以前那么宅了，深圳大点的公园、景点基本上都去了个遍，深圳湾公园都不知道去了多少次。坚果妈在家的时候，他就经常嚷着要“接爸爸下班”；到了来福士，他会在办公区安全闸口候着我，有时候我有点事儿耽误了会儿，他就会跟他妈妈念叨，“怎么还没出来哦”，待看到我后就会冲上来让我抱，这也是我最幸福的时光。小坚果的记忆力也是惊人，很久前去过的地方、吃过的东西，他居然也会记得；有次我在读三字经，发现他居然可以接下句，于是我试着让他来背，发现他居然可以从“人之初”背到“贵以专”，着实令我惊叹不已。当然，这是小坚果乖的时候，调皮捣蛋起来也是忍不住想捶他得不得了。在家里经常把东西翻得到处都是，有时候故意把垃圾桶推倒然后就跑，还“嘎嘎嘎”地笑得特别有成就感。说到这儿又是气，高铁上在我用手机写这份年终总结的时候，他趁我不注意扑过来抢我手机乱点了几下，害得我丢了好多内容（在 apple notes 上没有版本回退）。跟小坚果的点点滴滴太多了，坚果妈在他两岁生日的时候写了一篇，《来，我们一起》，满满的温馨回忆。 从大疆离职后，我就告别了大疆的公租房福利，开始在外面租房了，这也是我第一次自行在深圳租房。为了上班方便，以及享受字节跳动的租房补贴福利，我就租在了公司旁边，走路十分钟的样子。怎么说呢，住得近也是有好有坏吧。好的方面，不用任何交通工具，每天在路上的时间非常短，再加上公司上班时间晚，因此我基本上都是睡到自然醒，差不多 9:40 才出门去公司（因为公司的早饭 10 点停止供应）。但也是住得太近，感觉就没有了上下班的感觉（俗称仪式感），基本上工作日就是两点一线，不是在公司上班就是在家睡觉，仿佛回到了中学住校的时候。有时候我甚至挺怀念之前开车上下班的时光，在开车的同时还能听下极客时间，而现在这个场景也彻底没了。 在深圳租房不得不面对的另外一块儿就是房租，七十来平的小两房，每个月房租加上物业水电费花费接近八千了，着实是一笔不小的开销。很多朋友都说，这租金都快赶上月供了，还不如买一套。说到在深圳买房，我之前是有考虑过，在 2018 年终总结中也困惑过。但现在我和老婆都想清楚了，算是彻底放弃了这个念头。深圳的房价已经这么高了，南山福田均价早已过了七八万，要再上车也不是不可以，但能买到啥品质的住房我也是清楚的。例如我当前租的这套楼龄十多年的小两房，当前市场价差不多也要 550 万的样子，而且还没有啥好学位。之前跟同事闲聊为啥要留在深圳的话题，同事的普遍说法是为了给孩子更高的起点，让孩子以后大学毕业后不再像我们这一代这样，还要纠结是否能留在一线城市。但我觉得那都太遥远了，先不说等孩子长大后社会形态如何，眼前的是深圳学位严重紧张，好点的学位房价格早已高攀不起，即使留在深圳，也无法给孩子提供好的教育资源和居住环境，那又何必呢？想清楚这些之后，也就没啥好纠结的了，还是过段时间去成都吧。 随着年龄的增大，我也越发地感受到健康的重要性。今年因为皮肤有些问题，跑了几趟医院。医生的说法是慢性荨麻疹，本身不是啥大的问题，主要是由于生活作息不规律、运动少、压力大、抵抗力下降造成的。这个春节爆发新型冠状病毒肺炎疫情后，我也初步了解了些健康相关的知识，对于身体抵抗力的重要性有了更多的认识。在过去的这些年，我在锻炼身体这方面的确做得很不到位，今年一时兴起每天中午到公司健身房跑步 5 公里，也就坚持了两个月。好在这两年身体还算健康没出啥大问题，今年的慢性荨麻疹也算是给我敲了个警钟，后面的日常锻炼必须得安排起来了。 ","date":"2020-01-30","heading":"生活","objectID":"/post/my-2019-summary/:3:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"新年展望 最后再展望下 2020 年吧。 持续提升个人的技术深度，特别是在服务端方向，充分学习并掌握字节跳动的统一技术栈。 深入学习 Go 语言（恰逢鼠年，Go 语言的吉祥物是土拨鼠，也算是应景），并将 Go 语言作为自己的第一生产力编程语言（替代 Python）。 HttpRunner 3.0 重构，支持更多的协议和应用场景，实现 Go 版本也不是不可能。 输入与输出相结合，系统性学习，勤思考，多输出，坚持技术博客创作。 学会生活，锻炼身体，陪伴家人，工作是长跑，讲究的是可持续发展。 ","date":"2020-01-30","heading":"新年展望","objectID":"/post/my-2019-summary/:4:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#新年展望"},{"categories":["Growth"],"collections":null,"content":"往年记录 《我的 2018 年终总结》 《我的 2017 年终总结》 《我的 2016 年终总结》 ","date":"2020-01-30","heading":"往年记录","objectID":"/post/my-2019-summary/:5:0","tags":["年终总结"],"title":"我的 2019 年终总结","uri":"/post/my-2019-summary/#往年记录"},{"categories":["Growth"],"collections":null,"content":"今年有幸第二次作为移动互联网测试开发大会（MTSC 2019）的讲师，在 TTF 开源项目专场分享了 HttpRunner 2.0 方面的内容。对于大会，这已经是连续第五届了，每年的规模也在逐步扩大，今年参会人数已经达到 2000+ 了，在此为组委会点个赞。 说起这次大会，能参加分享还真是挺意外的。因为今年上半年我主要都是在换工作的过渡期，感觉也没啥可以分享的内容，所以今年也没打算参加任何大会做讲师，想的还是再多积累沉淀下，等做出些东西后明年再看。前段时间茹炳晟老师邀请我参加 GIAC 做分享时，当时也是很不好意思地拒绝了。意外的是，五月底的时候思寒将我和孙圣翔（ATX 作者）拉了个小群，说希望在本次 MTSC 大会上搞个 TTF 开源项目专场，分享议题就是我们的开源项目，包括思寒自己的 AppCrawler，孙圣翔的 ATX 和我的 HttpRunner。这是大会的一个新的尝试，感觉挺有意思的，也是对开源项目进行宣传和推广的不错机会。特别地，当前 HttpRunner 仍然还主要是我一个人开发维护，或多或少有些孤独，要是能借此机会吸引一些小伙伴一起来做就更好了。所以，我也就欣然接受了这次参加大会做分享的机会。 答应下来后，就需要开始准备分享议题了。我们这个专场相比于其它会场还有点特殊，除了会更多聚焦于开源项目本身外，还会多一个现场演示环节，让听众能更好地体验到项目的功能和特色。那么在准备的时候，除了 Keynote 外，就还得准备好演示的案例。而恰恰是这个演示案例，在经过一些尝试后发现并没有想象中的那么容易。首先，这不能是一个纯粹的 demo，会显得太没诚意，肯定要结合真实的应用或业务场景。而涉及到业务场景的话，挑选的被测对象一定要稳定，不然在现场演示时出现点意外就比较尴尬；同时不能涉及太多的业务概念或业务逻辑，要在现场演示那么短时间内让听众先熟悉这些概念，本身就是一个比较高的理解成本，而且会显得喧宾夺主，毕竟听众重点要听的是测试框架的使用。当时思寒跟我推荐企业微信的接口，我看过官方文档后也是因为涉及较多业务概念而放弃了。因此，现场演示挑选的功能场景一定要简单，最好对听众来说不要有任何理解成本。但光是简单还不行，还得能覆盖接口测试时常见的一些机制，包括参数关联、隐私变量处理、公共变量处理、参数化、测试用例分层等等。挑来挑去，最后发现我平常用的大纲笔记 幕布 就挺合适，而且它现在也是字节跳动旗下的产品，假如遇到技术问题直接跟项目开发人员沟通也方便。 确定演示案例后，我自己也尝试了下脚本开发调试全流程，功能实现是没什么问题，但是操作总是不够流畅，特别是稍微遇到个问题卡一下壳，时间就会变得完全不可控。平时操作还好，但要在分享现场面对三四百号听众的时候，心境可能又是完全不一样了。不过我还是不想就这样放弃了，毕竟是开源项目专场，如果只是讲讲 Keynote 或者播放个视频回放，风险是降低了，但实际效果就大打折扣了。于是，我还是明确了采用现场演示的形式，只是要提前解决好可能存在的问题。其实仔细想想，使用测试框架开发调试脚本的操作不流畅，说明框架本身还是存在易用性的问题。因此，我也在准备案例的过程中，重点针对体验方面的问题进行了一些优化，包括工程脚手架脚生成、脚本格式展示、默认参数配置、日志展示等，直到达到自己相对满意的状态。 后来跟孙圣翔交流时得知，他也是在准备案例演示时对 ATX 进行了一些优化，发布了多个优化的小版本。通过这个共性的现象，我觉得是可以得出一些有指导意义的方法论的。 我们做工具框架时还是要多站在演示的角度，演示不是目的，但它可以营造条件，倒逼我们去对工具进行极致优化和改进。这也跟我之前推崇的一个观点非常类似，知识只有做到可以通过文字或语言通俗易懂地教授给别人，才算是真正理解和掌握了。 实际在现场分享时，现场来的听众比预期中多了不少，将整个会场塞得满满的，在此也十分感谢大家的关注和支持。而我自己比较出乎意料的是，从头到尾基本上没有紧张的感觉，可能是之前已经有过多次大会分享的经历了吧，也可能是自己心态变好（脸皮变厚）了，想好最坏的结果无非就是现场演示出现意外，然后在一阵“哈哈哈”中拿出事先录制好的演示视频，总不会被轰下去吧。庆幸的是，现场网络环境、被测对象、框架和脚本都很稳定，最终演示也一切顺利。不过时间把控还是没有做好，最后没有剩下时间跟大家 Q\u0026amp;A 互动，颇为遗憾。 现场演示出现小意外时，表情被摄影师捕获到了，😝 另外，很开心的是又一次跟行业内的朋友们聚在一起。平时大家都是分散在全国各地的网友，即使是同在一个城市，因为时间关系也很少见面。有了 MTSC 后，大会已经成为了大家每年一聚的节日，大家聚在一起闲扯下近况和八卦，兴致来了再合个影，这些都是我们终将逝去的青春啊。 最后，祝愿 MTSC 越来越好，我们明年再见！ 附录：本次分享的 Keynote 下载链接。 ","date":"2019-06-29","heading":"","objectID":"/post/MTSC-2019/:0:0","tags":["演讲"],"title":"我的 MTSC 2019 分享经历","uri":"/post/MTSC-2019/#"},{"categories":["Growth"],"collections":null,"content":"2019 年 5 月 9 日，是我加入大疆创新刚好三周年的日子，也是我从大疆毕业的日子。非常感谢在大疆的三年时光，伴随着大疆的快速发展，我收获了许多成长，也完成了诸多人生中的大事，结了婚、生了娃，尝试了技术管理。庆幸的是，这三年来我每年都有写年终总结，详细记录了成长过程中的心路历程，回过头看，也是感慨万千，这些都将是我人生中最美好的回忆。 在开启新的职业旅程之前，我想通过这篇文章对自己的想法和选择进行下记录。 ","date":"2019-05-09","heading":"","objectID":"/post/dji-graduated/:0:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#"},{"categories":["Growth"],"collections":null,"content":"换工作的想法 首先，为什么想换工作了？是大疆不好么？ 大疆并没有不好。恰恰相反，大疆仍然是我心目中崇敬的世界级科技公司。在这几年，我也切身感受到大疆越来越好，不管是流程规范还是员工福利方面。对应到我个人，领导给了我非常大的认可和施展的空间，而公司也给了我丰厚的激励回报，包括 Top绩效排名、职级晋升、内部股奖励、免费人才住房、150万无息购房贷款资格等等。可以确切地说，在大疆只要你做得好，确实是可以获得丰厚回报的，所以对于想来大疆的朋友也不要有顾虑。 而最终确定离开大疆，的确也是我综合个人当前阶段职业发展诉求后确定的想法。 在《我的 2018 年终总结》中，我也聊到了当前阶段面临的困惑，当时罗列了诸多没有想清楚的问题，后来我又仔细想了下，归根结底，我觉得问题还是在于自己当前的技术视野局限性。虽然我也去一些大会做了分享并收获了不少好评，但在我自己看来，我当前的技术积累还是远远不够，甚至是局限于皮毛的。这肯定不是我想要的状态。 另外，我个人一直存在着一个较大的短板，那就是缺乏对特定业务领域的持续积累。最开始在工信部电子五所的时候做的第三方测评，接触的行业太多太杂，电子政务、电网、互联网、交通、石化、民爆等都有涉及；后来去了 UC 做测试开发，当时主要是纯技术研发，基本上也没有涉及到公司业务；而到了大疆这家智能硬件的生产制造企业以后，也是根据工作需要，在电商、生产制造供应链、企业 IT 信息化等系统之间切换。说的好听点是见多识广，实际情况就是业务方面啥也不精。 再加上在大疆待了三年，做了团队管理者，我感觉自己也在逐渐走入舒适区，这对我而言也是一个十分危险的信号。因此，想着现在才刚过三十，自己也还有动力和体力去折腾，那我就不妨再折腾一次吧。 确定了内心的诉求后，离职的想法也就明确了，接下来就是开始尝试去接触新的公司了。 ","date":"2019-05-09","heading":"换工作的想法","objectID":"/post/dji-graduated/:1:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#换工作的想法"},{"categories":["Growth"],"collections":null,"content":"面试过程 回想起之前每一次找（换）工作，感觉自己也挺草率的，不管是校招进工信部电子五所，还是从五所到 UC，UC 到大疆，都是只面一家，通过后就定下来了。现在想来，要是当时校招时勤快点多面几家公司，现在的路可能就又很不一样了吧。因此，这次在一开始就告诫自己要克服懒癌，多面几家，在面试的过程中确定出最适合自己的，毕竟换工作的成本是越来越高的。 得益于身处互联网行业，在拉勾网上更新简历后，就有不少猎头和 HR 开始主动联系了。另外，跟一些朋友聊了想法后，他们也热心地帮我做了引荐。再次得益于互联网行业，面试基本都可以通过电话或视频，而且可以安排在晚上，因此基本上不用请假。 这次接触的公司还是挺多的，包括 OPPO、腾讯（PCG \u0026amp; 游戏 \u0026amp; 微信）、蚂蚁金服（IOT \u0026amp; 国际）、字节跳动、shopee。当然，这里面也并不是全都去完整聊过。例如腾讯游戏，那边 HR 联系后，约了电话面试时间，后面因为其它事情改了几次时间，但最主要还是因为我自己对游戏兴趣不大，平时也不玩游戏，所以最后就没去面了。而腾讯微信，因为是在广州，而且面试轮数至少有六七轮，想想都好累，所以就放弃了（还是太懒，囧）。 实际参加面试的有 6 家，时间上没有严格的先后，但大致的顺序是 Oppo、蚂蚁金服IOT、腾讯PCG、字节跳动、shopee、蚂蚁金服国际。最后面试的结果，有两家没通过，四家通过。 ","date":"2019-05-09","heading":"面试过程","objectID":"/post/dji-graduated/:2:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#面试过程"},{"categories":["Growth"],"collections":null,"content":"失败经历 先从面试失败的两家说起吧。 一个是蚂蚁金服的 IOT，当时 HR 跟我联系的时候，因为之前我对 IOT 也有大致的了解，感觉前景也很不错，因此就打算尝试下。不过在细看岗位描述后，发现基本都是偏嵌入式的，对于编程语言也都是要求 C/C++，对此也向 HR 表达了疑惑和顾虑。而 HR 给我的回答是没关系，不强制要求语言，也不要求有嵌入式的背景，总之就是没问题的快来面试吧。然后我就接受了面试邀请，约了某个晚上的视频面试。结果在开始面试后，着实也惊了一把。接通视频后，面试官没有任何寒暄，也没让我做自我介绍，直接发给我一道链表相关的算法题，要求我用 C/C++ 语言实现。呃，说好的编程语言不限呢。但我也是在大学毕业后基本上就没再写过 C 语言，C++ 更是从没学过，于是只能硬着头皮说我只能用 Python 来写，但 Python 里面没有指针和链表数据结构啊，我就只有想着先用 Python 构造一个链表的存储结构。想法是这样，但最后终究没有给出正确的代码实现。狼狈地结束了代码部分后，才开始面试问答环节，基本上也是岗位需要的内容（嵌入式）我没相关经验，我擅长的部分面试官没怎么接触。在面试的最后，我也问了面试官几个感兴趣的问题，诸如蚂蚁金服的 IOT 跟阿里云的 IOT 业务有啥差异，在蚂蚁金服官网上写的三大战略方向是国际、农村和绿色金融，那 IOT 在其中是什么角色定位，结果面试官都以公司机密不方便透露回绝了我。 另一个失败经历是腾讯的 PCG。本来是没有投递简历的，结果有一天一位没聊过的微信好友跟我发消息，说听思寒说我在看机会，问我要不要去他那儿聊下。问了以后才得知，他就是 PCG 的测试总监，当时着实也令我颇为惊讶，自己加了这么位大佬的微信居然都不知道。然后就约了个工作日晚上八点的时间过去聊，结果恰好那天他晚上有其它事情插入，于是就改为提前去腾讯食堂一起吃晚饭。就这样，我就去腾讯食堂蹭了一顿饭。说回聊的过程，大佬非常谦和，也跟我详细介绍了他们的各个业务线；因为业务中会有比较多的推荐算法，因此对算法背景的要求会比较高；另外也有一些相对偏传统的，接口、UI自动化测试之类的。同时他也了解了下我的想法，总之聊的过程是比较轻松愉快的。吃过晚饭也聊完了，大佬找了一位负责技术的面试官，就跟我开始了正式的面试。从总体面试的感觉来看，面试官对我之前在 UC 做 Android 客户端性能专项的那段工作经历最感兴趣，问的比较深比较细，估计也是跟他们的工作需求最为匹配吧，而由于我近三年在大疆基本都在 Web 和服务端方向，移动端没再接触，所以当时在 UC 涉及的技术细节很多也记不清了。后面在考察编程语言的基础知识时，问了我一些 C++ 方面的问题，而我的确没有学过 C++，也是比较尴尬。最后有个让我至今比较费解的点，在面试快结束的时候有听面试官说后续再聊，结果再详细看了我的简历，发现第一段工作经历在电子五所待了四年后，面试官的态度出现了些变化，结束面试后，就再也没了然后。后面我也没好意思去问大佬原因，应该就是不合适吧。 这两场面试结束后我一度十分沮丧，颇受打击。虽然技术方向不匹配是一方面（蚂蚁金服的IOT，HR是为了拉人去凑数的吗？），但我也通过这次面试认识到自身存在的不足，主要有两大问题： 一方面，我在算法和数据结构方面存在较大不足，平时工作过程中用的很少。特别是在考察算法和数据结构时，通常都会涉及到链表、栈、二叉树等数据结构，而我熟悉的 Python 编程语言是没有指针和这些数据结构的。显然，为了面试现去学 C/C++ 肯定是来不及了，那么就需要尽快熟悉如何使用 Python 语言构造各种常见的数据结构，以及常见算法的 Python 实现。 另一方面，我对自己新工作的方向定位没有想清楚。当时 PCG 技术面试官问了我一个问题，我对这份新工作的期望是什么，更具体地，期望在哪些方面能有更大的提升，当时我也的确是没有想清楚的。同时，自己在面试时也没有针对性地展示自己的优势和特长。虽然自己之前各个方向都有做过，但像三年前在 UC 做的 Android 客户端性能方向，好多年没再接触，的确也是忘的差不多了。所以在面试过程中的确是需要对面试官主动进行一些引导，避免面试官在这些我不再熟悉的领域纠缠。 理清问题后，在接下来的几个周末里，我也针对性地做了补救准备。针对第一个问题，就是恶补算法和数据结构，特别是常见的算法数据结构，都尽量使用 Python 语言实现一遍。针对第二个问题，就是对自己的技能进行梳理，针对许久没有做过的技术方向，还是需要再至少找出一两个亮点进行回顾，避免问到该方面问题时完全懵逼；另外更主要的，就是想清楚自己后续的目标方向，对自己擅长的领域进行整理，并结合自己的工作经历，尽量准备好怎么在面试时将面试官的兴趣吸引到自己擅长的领域，以及怎么去更好地展现自己的成绩和亮点。 庆幸也是有了这么个突击准备，在后续面试中虽然没有都用上，但也从容了许多，基本上也没有再遇到什么太大的问题。 ","date":"2019-05-09","heading":"失败经历","objectID":"/post/dji-graduated/:2:1","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#失败经历"},{"categories":["Growth"],"collections":null,"content":"成功经历 接下来再讲下其它几家的面试经历。 Oppo 首先说下 Oppo，面的是其互联网部门，这是我最早面试的一家。其实那时我也还没有特别明确的想法，只是恰好当时卡总去了 Oppo，推荐我也过去看看，于是就答应了。最开始本来是约的晚上去和部门测试负责人（廖总）聊下，结果廖总也是客气，后面改为在外面一起吃饭，一起的还有卡总和另一位测试架构师。在席间其实也不算是面试，更多的还是闲聊，包括个人的工作情况和想法，以及了解了下那边的工作内容和规划。当时了解到的情况感觉还是很不错的，当前 Oppo 手机市场份额已经很高了，而且当前各大手机厂商的份额也基本开始固定，手机硬件利润也是越来越薄，急需在互联网软件方向上加大投入获取利润，毕竟 Oppo 作为亿级用户流量入口，可以施展的空间是非常大的。那次聊完后，廖总应该是给了我一个初步的定位（同时貌似也是过了第二轮部门面），接下来就是走正式的面试流程，包括第一轮技术面、第三轮 Oppo 测试总监面、第四轮 HR 面。效率还是非常高的，花了几个工作日晚上就聊完了。个人总体感觉，整个面试过程更多的都是围绕个人做过的项目（做了什么、怎么做的），以及一些宏观方法论、沟通协调能力、工作态度和换工作动机的考察，在技术基础细节和编码能力方面考察的很少。所以当时虽然是面试的第一家公司，也没有做过任何准备，但最后也都通过了面试。在此也给 Oppo 提个建议，技术基础细节和编码能力还是得多考察一些（近期华为在面试方面也做了很大调整，加大了编码能力考察力度），一方面可以提升面试候选人的技术准入门槛，另一方面也可以对外加强公司技术能力要求的形象，让候选人更多地感受到这是一家技术要求高、工程师文化强的公司。 在 Oppo 面试完成后，因为这还是我面的第一家，想再多聊下其它公司综合做下对比，所以当时就跟 HR 说明了想法，暂时先不给答复。然后，就是面了蚂蚁金服 IOT 和腾讯 PCG，前面也已经详述了这两段失败的经历。 字节跳动 接下来就是在准备的过程中，有猎头推荐了字节跳动。其实当时猎头跟我推荐字节跳动的时候我内心是很抗拒的，因为那段时间网上也有很多文章抨击今日头条和抖音让太多人沉迷于其中，耗费大量时间，从政治正确的角度，当时我也的确是不太想去参与到该类产品。但猎头跟我聊的时候，她的观点我不得不认同，字节跳动能做出这么多爆款产品，其产品布局能力、基础设施技术建设、公司管理文化肯定都是相当优秀的，加入字节跳动肯定可以学习到很多。另一方面，她给我推荐的部门不是今日头条和 IES 互娱，而是其效率工程部，主要的业务方向做办公协作套件和效率工具，面向的是海外 B 端市场，并且公司在此方面也具有非常大的资源投入。而且当前国内各个大厂（BAT）主要的营收都在 C 端市场，与国际大厂（FLAG）B端、C端市场营收各占一半相比，国内 B 端市场还有相当大的空间，因此当前国内各大厂也都在加大 B 端市场的布局。当时也是基于这些信息，我产生了比较大的兴趣，打算去尝试下。但众所周知，字节跳动面试对算法的考察是非常严格的，在网上也看到了非常多的吐槽，再加上前面的面试失败经历，我当时也是完全没底儿。于是我在跟字节跳动约面试的时间时，特地往后延了一两周，主要也是想多争取点时间进行下突击准备。 总算到了约定的面试时间，虽然准备仍然非常不充分，但也没有办法，只能硬着头皮上了。字节跳动的面试都是通过视频进行，从面试体验上讲还是非常不错的，完全不比现场面试的效果差。从面试的内容上讲，考察的也的确是最深的（相比面试的其它几家而言），个人项目经历中涉及到的底层原理、计算机基础技术细节都问的比较多，而代码能力部分，在每轮技术面都会进行考察，要求做到运行正确且 bugfree，并且会针对写出的代码实现提出性能优化的要求。庆幸的是我遇到的题目都是常见的算法和数据结构，思路是知道的，剩下的就是代码工程能力了，相对来说问题不大。印象最深的是第一轮面试，面试官是李昶博（有兴趣可以在网上搜下他的信息），当时整场面试持续了两个小时，从晚上八点到十点多。之所以会持续这么久，主要是在代码部分，当时我最开始写完了实现，并且写了比较完善的单元测试用例（TDD）；也得益于这些单元测试用例，当时我因为有些紧张，虽然没有把算法实现想清楚，但也通过一些补丁的方式跑通了所有单元测试，实现了 bugfree。但面试官提出要求，这些补丁代码太丑陋，代码也存在冗余，要求将代码行数缩短到15行之内。然后我就继续优化，紧张加上心理压力，头脑有些发懵，调试也有些卡壳，总之不是很顺利，但面试官也非常耐心，不断对我进行引导，直到最终完成了目标。在面试结束的时候，他还针对我的编码过程，给了我两个十分中肯的建议，着实令我非常感动，对字节跳动的认可度也大幅增强。回顾字节跳动的面试历程，总共面了3轮技术面，1轮HR面。面试给我的总体感受，就是面试官都非常有活力，虽然年龄不一定小，但能明显感受到特别有朝气。 Shopee 在面试字节跳动的过程中，又有猎头跟我推荐了 Shopee，因为之前对该公司也有一定的了解，所以就参与了面试。 当时面试的是 Shopee 的金融业务线（Airpay），可以打个比方，Shopee 做的是东南亚市场的电商，如果类比淘宝，那么 Airpay 就类比支付宝，主要做的就是电子支付和信用卡方向。之前该业务是在新加坡，也是在不久前将整个业务迁到深圳，因此人员缺口比较大，也在重点招 leader 岗位。从总体的面试经历来看，在技术方面还是非常顺利的，一方面是技术栈比较匹配，面试难度适中，介于 Oppo 和字节跳动之间，没有遇到太大问题；另一个主要的原因是 Shopee 的另一条业务线告知 Airpay 这边的测试经理，他们那边有多个项目也在使用我个人的开源测试框架 HttpRunner，因此在这方面应该是给了我不少加分，Airpay 的测试经理也对我比较认可，定位为 leader 岗位。相对来说不大顺利的是第二轮面试，面试官是 Airpay 的业务线负责人。在这一轮面试时没有涉及到技术，主要都是对我个人在团队管理、沟通协作、职业规划方面的考察。当时在面试过程中我也很是纠结，一方面我个人想法是在技术方向有更深入的投入和积累，但同时因为岗位定位为 leader 岗，我也知道完全按照内心真实想法去回答肯定也不行，所以实际在回答时有些心口不一，着实难受。在二面中，面试官还问了我一个问题，自己觉得在性格方面的优缺点是什么，我当时也是很懵，优点能罗列出几个，但性格上的缺点怎么说呢，的确是不知道怎么回答，最后就勉强说了个不太会拒绝别人（囧）。本以为二轮面试挂了，毕竟自我感觉是很不好，但最后居然也通过了。然后第三轮面试就是 Shopee 在深圳的黄老板了，面试能去跟老板聊，着实也是个收获。黄老板的确是个很有意思（个性）的人，面试过程很随性，总共就聊了不到15分钟，这其中还包括我向他提问的五六分钟。后来听乙醇说他当时面试跟黄老板总共就聊了6分钟，估计风格就是如此吧。 在跟黄老板聊的时候，能感受到他的确是一个很有想法的人。当时我问了他一个问题： shopee 的直接竞争对手 lazada 是阿里系，团队狼性，加班凶猛，而 shopee 比较佛性，团队基本不加班。当然这只是战术层面的差异，并不能起到决定性的作用，想了解下从战略层面上，shopee 与 lazada 有哪些不同，来保障不会落后于竞争对手呢？ 黄老板的回答很谦虚，说战略层面的事情他也不懂，这是 CEO 关注的问题。但从他的角度来看，如果做事的方向错了，加班做得越多，也就错的越多。他还打了个比喻，公司做事跟骡子拉磨没啥区别，都是想让拉磨越快越好。但正确的做法无非就两个，一个是找更多的骡子一起来拉磨，即扩大团队规模，另一个就是将骡子换成马，即进行组织升级、提升团队战斗力；而让骡子加班加点去拉磨，虽然在短期内也可以达成目标，但骡子也会累的，从长期来看结果并不一定会更好。听完这回答我不禁肃然起敬，在当前遍地打鸡血搞 996 的环境下，老板能有这么正的价值观，着实难得。再说回做事的方向，当前 Shopee 跟 lazada 一个很大的不同点在于，Shopee 在东南亚各个国家的市场团队都是本地人员，更熟悉当地文化风俗，而 lazada 更多的是将国内淘宝的模式在东南亚各个国家进行复制。 然后我又问了他另外一个问题： 当前 Shopee 内技术团队的地位和话语权是怎样的？（我问这问题的目的主要是想知道 Shopee 是不是一家技术导向的公司） 结果他的回答让我很出乎意料。在他看来，公司内讲团队的话语权本就是个伪命题。各个部门团队也只是分工协作不一样，最终的目的都是达成公司的商业目标。接着他没有继续回答这个问题，而是做了个类比。从做事的难度来看，相比于互联网软件开发，生产电视机的工序更多更复杂，但做电视机技术人员的薪酬就是比不上互联网软件开发人员，这都是市场行情和岗位供求关系产生的结果。具体的我记不大清了，但确实感受到他的观点和想法角度很不一样，实在是佩服。 蚂蚁金服国际事业部 最后还有一家是蚂蚁金服的国际事业部，这家是思寒找他熟悉的在蚂蚁金服的朋友（紫嫣）帮我内推的；后来我才知道原来我也加过紫嫣的微信，之前在 MTSC 都做过讲师，圈子的确也真是小哇。 对于蚂蚁金服，之前我也是有过一些了解，一方面是猎头推荐，还有就是跟朋友聊天时，大家都对其前（钱）景十分看好。从业务角度，做金融业务的确是不会过时的业务，按照卡总的说法，社会生产力总是少不了金融支撑的。而从技术角度，金融系统对系统的稳定性和可靠性要求非常高，质量保障也会是非常高的优先级。总的来说就是，不管是业务前景和个人职业发展，蚂蚁金服都是个挺","date":"2019-05-09","heading":"成功经历","objectID":"/post/dji-graduated/:2:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#成功经历"},{"categories":["Growth"],"collections":null,"content":"成功经历 接下来再讲下其它几家的面试经历。 Oppo 首先说下 Oppo，面的是其互联网部门，这是我最早面试的一家。其实那时我也还没有特别明确的想法，只是恰好当时卡总去了 Oppo，推荐我也过去看看，于是就答应了。最开始本来是约的晚上去和部门测试负责人（廖总）聊下，结果廖总也是客气，后面改为在外面一起吃饭，一起的还有卡总和另一位测试架构师。在席间其实也不算是面试，更多的还是闲聊，包括个人的工作情况和想法，以及了解了下那边的工作内容和规划。当时了解到的情况感觉还是很不错的，当前 Oppo 手机市场份额已经很高了，而且当前各大手机厂商的份额也基本开始固定，手机硬件利润也是越来越薄，急需在互联网软件方向上加大投入获取利润，毕竟 Oppo 作为亿级用户流量入口，可以施展的空间是非常大的。那次聊完后，廖总应该是给了我一个初步的定位（同时貌似也是过了第二轮部门面），接下来就是走正式的面试流程，包括第一轮技术面、第三轮 Oppo 测试总监面、第四轮 HR 面。效率还是非常高的，花了几个工作日晚上就聊完了。个人总体感觉，整个面试过程更多的都是围绕个人做过的项目（做了什么、怎么做的），以及一些宏观方法论、沟通协调能力、工作态度和换工作动机的考察，在技术基础细节和编码能力方面考察的很少。所以当时虽然是面试的第一家公司，也没有做过任何准备，但最后也都通过了面试。在此也给 Oppo 提个建议，技术基础细节和编码能力还是得多考察一些（近期华为在面试方面也做了很大调整，加大了编码能力考察力度），一方面可以提升面试候选人的技术准入门槛，另一方面也可以对外加强公司技术能力要求的形象，让候选人更多地感受到这是一家技术要求高、工程师文化强的公司。 在 Oppo 面试完成后，因为这还是我面的第一家，想再多聊下其它公司综合做下对比，所以当时就跟 HR 说明了想法，暂时先不给答复。然后，就是面了蚂蚁金服 IOT 和腾讯 PCG，前面也已经详述了这两段失败的经历。 字节跳动 接下来就是在准备的过程中，有猎头推荐了字节跳动。其实当时猎头跟我推荐字节跳动的时候我内心是很抗拒的，因为那段时间网上也有很多文章抨击今日头条和抖音让太多人沉迷于其中，耗费大量时间，从政治正确的角度，当时我也的确是不太想去参与到该类产品。但猎头跟我聊的时候，她的观点我不得不认同，字节跳动能做出这么多爆款产品，其产品布局能力、基础设施技术建设、公司管理文化肯定都是相当优秀的，加入字节跳动肯定可以学习到很多。另一方面，她给我推荐的部门不是今日头条和 IES 互娱，而是其效率工程部，主要的业务方向做办公协作套件和效率工具，面向的是海外 B 端市场，并且公司在此方面也具有非常大的资源投入。而且当前国内各个大厂（BAT）主要的营收都在 C 端市场，与国际大厂（FLAG）B端、C端市场营收各占一半相比，国内 B 端市场还有相当大的空间，因此当前国内各大厂也都在加大 B 端市场的布局。当时也是基于这些信息，我产生了比较大的兴趣，打算去尝试下。但众所周知，字节跳动面试对算法的考察是非常严格的，在网上也看到了非常多的吐槽，再加上前面的面试失败经历，我当时也是完全没底儿。于是我在跟字节跳动约面试的时间时，特地往后延了一两周，主要也是想多争取点时间进行下突击准备。 总算到了约定的面试时间，虽然准备仍然非常不充分，但也没有办法，只能硬着头皮上了。字节跳动的面试都是通过视频进行，从面试体验上讲还是非常不错的，完全不比现场面试的效果差。从面试的内容上讲，考察的也的确是最深的（相比面试的其它几家而言），个人项目经历中涉及到的底层原理、计算机基础技术细节都问的比较多，而代码能力部分，在每轮技术面都会进行考察，要求做到运行正确且 bugfree，并且会针对写出的代码实现提出性能优化的要求。庆幸的是我遇到的题目都是常见的算法和数据结构，思路是知道的，剩下的就是代码工程能力了，相对来说问题不大。印象最深的是第一轮面试，面试官是李昶博（有兴趣可以在网上搜下他的信息），当时整场面试持续了两个小时，从晚上八点到十点多。之所以会持续这么久，主要是在代码部分，当时我最开始写完了实现，并且写了比较完善的单元测试用例（TDD）；也得益于这些单元测试用例，当时我因为有些紧张，虽然没有把算法实现想清楚，但也通过一些补丁的方式跑通了所有单元测试，实现了 bugfree。但面试官提出要求，这些补丁代码太丑陋，代码也存在冗余，要求将代码行数缩短到15行之内。然后我就继续优化，紧张加上心理压力，头脑有些发懵，调试也有些卡壳，总之不是很顺利，但面试官也非常耐心，不断对我进行引导，直到最终完成了目标。在面试结束的时候，他还针对我的编码过程，给了我两个十分中肯的建议，着实令我非常感动，对字节跳动的认可度也大幅增强。回顾字节跳动的面试历程，总共面了3轮技术面，1轮HR面。面试给我的总体感受，就是面试官都非常有活力，虽然年龄不一定小，但能明显感受到特别有朝气。 Shopee 在面试字节跳动的过程中，又有猎头跟我推荐了 Shopee，因为之前对该公司也有一定的了解，所以就参与了面试。 当时面试的是 Shopee 的金融业务线（Airpay），可以打个比方，Shopee 做的是东南亚市场的电商，如果类比淘宝，那么 Airpay 就类比支付宝，主要做的就是电子支付和信用卡方向。之前该业务是在新加坡，也是在不久前将整个业务迁到深圳，因此人员缺口比较大，也在重点招 leader 岗位。从总体的面试经历来看，在技术方面还是非常顺利的，一方面是技术栈比较匹配，面试难度适中，介于 Oppo 和字节跳动之间，没有遇到太大问题；另一个主要的原因是 Shopee 的另一条业务线告知 Airpay 这边的测试经理，他们那边有多个项目也在使用我个人的开源测试框架 HttpRunner，因此在这方面应该是给了我不少加分，Airpay 的测试经理也对我比较认可，定位为 leader 岗位。相对来说不大顺利的是第二轮面试，面试官是 Airpay 的业务线负责人。在这一轮面试时没有涉及到技术，主要都是对我个人在团队管理、沟通协作、职业规划方面的考察。当时在面试过程中我也很是纠结，一方面我个人想法是在技术方向有更深入的投入和积累，但同时因为岗位定位为 leader 岗，我也知道完全按照内心真实想法去回答肯定也不行，所以实际在回答时有些心口不一，着实难受。在二面中，面试官还问了我一个问题，自己觉得在性格方面的优缺点是什么，我当时也是很懵，优点能罗列出几个，但性格上的缺点怎么说呢，的确是不知道怎么回答，最后就勉强说了个不太会拒绝别人（囧）。本以为二轮面试挂了，毕竟自我感觉是很不好，但最后居然也通过了。然后第三轮面试就是 Shopee 在深圳的黄老板了，面试能去跟老板聊，着实也是个收获。黄老板的确是个很有意思（个性）的人，面试过程很随性，总共就聊了不到15分钟，这其中还包括我向他提问的五六分钟。后来听乙醇说他当时面试跟黄老板总共就聊了6分钟，估计风格就是如此吧。 在跟黄老板聊的时候，能感受到他的确是一个很有想法的人。当时我问了他一个问题： shopee 的直接竞争对手 lazada 是阿里系，团队狼性，加班凶猛，而 shopee 比较佛性，团队基本不加班。当然这只是战术层面的差异，并不能起到决定性的作用，想了解下从战略层面上，shopee 与 lazada 有哪些不同，来保障不会落后于竞争对手呢？ 黄老板的回答很谦虚，说战略层面的事情他也不懂，这是 CEO 关注的问题。但从他的角度来看，如果做事的方向错了，加班做得越多，也就错的越多。他还打了个比喻，公司做事跟骡子拉磨没啥区别，都是想让拉磨越快越好。但正确的做法无非就两个，一个是找更多的骡子一起来拉磨，即扩大团队规模，另一个就是将骡子换成马，即进行组织升级、提升团队战斗力；而让骡子加班加点去拉磨，虽然在短期内也可以达成目标，但骡子也会累的，从长期来看结果并不一定会更好。听完这回答我不禁肃然起敬，在当前遍地打鸡血搞 996 的环境下，老板能有这么正的价值观，着实难得。再说回做事的方向，当前 Shopee 跟 lazada 一个很大的不同点在于，Shopee 在东南亚各个国家的市场团队都是本地人员，更熟悉当地文化风俗，而 lazada 更多的是将国内淘宝的模式在东南亚各个国家进行复制。 然后我又问了他另外一个问题： 当前 Shopee 内技术团队的地位和话语权是怎样的？（我问这问题的目的主要是想知道 Shopee 是不是一家技术导向的公司） 结果他的回答让我很出乎意料。在他看来，公司内讲团队的话语权本就是个伪命题。各个部门团队也只是分工协作不一样，最终的目的都是达成公司的商业目标。接着他没有继续回答这个问题，而是做了个类比。从做事的难度来看，相比于互联网软件开发，生产电视机的工序更多更复杂，但做电视机技术人员的薪酬就是比不上互联网软件开发人员，这都是市场行情和岗位供求关系产生的结果。具体的我记不大清了，但确实感受到他的观点和想法角度很不一样，实在是佩服。 蚂蚁金服国际事业部 最后还有一家是蚂蚁金服的国际事业部，这家是思寒找他熟悉的在蚂蚁金服的朋友（紫嫣）帮我内推的；后来我才知道原来我也加过紫嫣的微信，之前在 MTSC 都做过讲师，圈子的确也真是小哇。 对于蚂蚁金服，之前我也是有过一些了解，一方面是猎头推荐，还有就是跟朋友聊天时，大家都对其前（钱）景十分看好。从业务角度，做金融业务的确是不会过时的业务，按照卡总的说法，社会生产力总是少不了金融支撑的。而从技术角度，金融系统对系统的稳定性和可靠性要求非常高，质量保障也会是非常高的优先级。总的来说就是，不管是业务前景和个人职业发展，蚂蚁金服都是个挺","date":"2019-05-09","heading":"Oppo","objectID":"/post/dji-graduated/:2:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#oppo"},{"categories":["Growth"],"collections":null,"content":"成功经历 接下来再讲下其它几家的面试经历。 Oppo 首先说下 Oppo，面的是其互联网部门，这是我最早面试的一家。其实那时我也还没有特别明确的想法，只是恰好当时卡总去了 Oppo，推荐我也过去看看，于是就答应了。最开始本来是约的晚上去和部门测试负责人（廖总）聊下，结果廖总也是客气，后面改为在外面一起吃饭，一起的还有卡总和另一位测试架构师。在席间其实也不算是面试，更多的还是闲聊，包括个人的工作情况和想法，以及了解了下那边的工作内容和规划。当时了解到的情况感觉还是很不错的，当前 Oppo 手机市场份额已经很高了，而且当前各大手机厂商的份额也基本开始固定，手机硬件利润也是越来越薄，急需在互联网软件方向上加大投入获取利润，毕竟 Oppo 作为亿级用户流量入口，可以施展的空间是非常大的。那次聊完后，廖总应该是给了我一个初步的定位（同时貌似也是过了第二轮部门面），接下来就是走正式的面试流程，包括第一轮技术面、第三轮 Oppo 测试总监面、第四轮 HR 面。效率还是非常高的，花了几个工作日晚上就聊完了。个人总体感觉，整个面试过程更多的都是围绕个人做过的项目（做了什么、怎么做的），以及一些宏观方法论、沟通协调能力、工作态度和换工作动机的考察，在技术基础细节和编码能力方面考察的很少。所以当时虽然是面试的第一家公司，也没有做过任何准备，但最后也都通过了面试。在此也给 Oppo 提个建议，技术基础细节和编码能力还是得多考察一些（近期华为在面试方面也做了很大调整，加大了编码能力考察力度），一方面可以提升面试候选人的技术准入门槛，另一方面也可以对外加强公司技术能力要求的形象，让候选人更多地感受到这是一家技术要求高、工程师文化强的公司。 在 Oppo 面试完成后，因为这还是我面的第一家，想再多聊下其它公司综合做下对比，所以当时就跟 HR 说明了想法，暂时先不给答复。然后，就是面了蚂蚁金服 IOT 和腾讯 PCG，前面也已经详述了这两段失败的经历。 字节跳动 接下来就是在准备的过程中，有猎头推荐了字节跳动。其实当时猎头跟我推荐字节跳动的时候我内心是很抗拒的，因为那段时间网上也有很多文章抨击今日头条和抖音让太多人沉迷于其中，耗费大量时间，从政治正确的角度，当时我也的确是不太想去参与到该类产品。但猎头跟我聊的时候，她的观点我不得不认同，字节跳动能做出这么多爆款产品，其产品布局能力、基础设施技术建设、公司管理文化肯定都是相当优秀的，加入字节跳动肯定可以学习到很多。另一方面，她给我推荐的部门不是今日头条和 IES 互娱，而是其效率工程部，主要的业务方向做办公协作套件和效率工具，面向的是海外 B 端市场，并且公司在此方面也具有非常大的资源投入。而且当前国内各个大厂（BAT）主要的营收都在 C 端市场，与国际大厂（FLAG）B端、C端市场营收各占一半相比，国内 B 端市场还有相当大的空间，因此当前国内各大厂也都在加大 B 端市场的布局。当时也是基于这些信息，我产生了比较大的兴趣，打算去尝试下。但众所周知，字节跳动面试对算法的考察是非常严格的，在网上也看到了非常多的吐槽，再加上前面的面试失败经历，我当时也是完全没底儿。于是我在跟字节跳动约面试的时间时，特地往后延了一两周，主要也是想多争取点时间进行下突击准备。 总算到了约定的面试时间，虽然准备仍然非常不充分，但也没有办法，只能硬着头皮上了。字节跳动的面试都是通过视频进行，从面试体验上讲还是非常不错的，完全不比现场面试的效果差。从面试的内容上讲，考察的也的确是最深的（相比面试的其它几家而言），个人项目经历中涉及到的底层原理、计算机基础技术细节都问的比较多，而代码能力部分，在每轮技术面都会进行考察，要求做到运行正确且 bugfree，并且会针对写出的代码实现提出性能优化的要求。庆幸的是我遇到的题目都是常见的算法和数据结构，思路是知道的，剩下的就是代码工程能力了，相对来说问题不大。印象最深的是第一轮面试，面试官是李昶博（有兴趣可以在网上搜下他的信息），当时整场面试持续了两个小时，从晚上八点到十点多。之所以会持续这么久，主要是在代码部分，当时我最开始写完了实现，并且写了比较完善的单元测试用例（TDD）；也得益于这些单元测试用例，当时我因为有些紧张，虽然没有把算法实现想清楚，但也通过一些补丁的方式跑通了所有单元测试，实现了 bugfree。但面试官提出要求，这些补丁代码太丑陋，代码也存在冗余，要求将代码行数缩短到15行之内。然后我就继续优化，紧张加上心理压力，头脑有些发懵，调试也有些卡壳，总之不是很顺利，但面试官也非常耐心，不断对我进行引导，直到最终完成了目标。在面试结束的时候，他还针对我的编码过程，给了我两个十分中肯的建议，着实令我非常感动，对字节跳动的认可度也大幅增强。回顾字节跳动的面试历程，总共面了3轮技术面，1轮HR面。面试给我的总体感受，就是面试官都非常有活力，虽然年龄不一定小，但能明显感受到特别有朝气。 Shopee 在面试字节跳动的过程中，又有猎头跟我推荐了 Shopee，因为之前对该公司也有一定的了解，所以就参与了面试。 当时面试的是 Shopee 的金融业务线（Airpay），可以打个比方，Shopee 做的是东南亚市场的电商，如果类比淘宝，那么 Airpay 就类比支付宝，主要做的就是电子支付和信用卡方向。之前该业务是在新加坡，也是在不久前将整个业务迁到深圳，因此人员缺口比较大，也在重点招 leader 岗位。从总体的面试经历来看，在技术方面还是非常顺利的，一方面是技术栈比较匹配，面试难度适中，介于 Oppo 和字节跳动之间，没有遇到太大问题；另一个主要的原因是 Shopee 的另一条业务线告知 Airpay 这边的测试经理，他们那边有多个项目也在使用我个人的开源测试框架 HttpRunner，因此在这方面应该是给了我不少加分，Airpay 的测试经理也对我比较认可，定位为 leader 岗位。相对来说不大顺利的是第二轮面试，面试官是 Airpay 的业务线负责人。在这一轮面试时没有涉及到技术，主要都是对我个人在团队管理、沟通协作、职业规划方面的考察。当时在面试过程中我也很是纠结，一方面我个人想法是在技术方向有更深入的投入和积累，但同时因为岗位定位为 leader 岗，我也知道完全按照内心真实想法去回答肯定也不行，所以实际在回答时有些心口不一，着实难受。在二面中，面试官还问了我一个问题，自己觉得在性格方面的优缺点是什么，我当时也是很懵，优点能罗列出几个，但性格上的缺点怎么说呢，的确是不知道怎么回答，最后就勉强说了个不太会拒绝别人（囧）。本以为二轮面试挂了，毕竟自我感觉是很不好，但最后居然也通过了。然后第三轮面试就是 Shopee 在深圳的黄老板了，面试能去跟老板聊，着实也是个收获。黄老板的确是个很有意思（个性）的人，面试过程很随性，总共就聊了不到15分钟，这其中还包括我向他提问的五六分钟。后来听乙醇说他当时面试跟黄老板总共就聊了6分钟，估计风格就是如此吧。 在跟黄老板聊的时候，能感受到他的确是一个很有想法的人。当时我问了他一个问题： shopee 的直接竞争对手 lazada 是阿里系，团队狼性，加班凶猛，而 shopee 比较佛性，团队基本不加班。当然这只是战术层面的差异，并不能起到决定性的作用，想了解下从战略层面上，shopee 与 lazada 有哪些不同，来保障不会落后于竞争对手呢？ 黄老板的回答很谦虚，说战略层面的事情他也不懂，这是 CEO 关注的问题。但从他的角度来看，如果做事的方向错了，加班做得越多，也就错的越多。他还打了个比喻，公司做事跟骡子拉磨没啥区别，都是想让拉磨越快越好。但正确的做法无非就两个，一个是找更多的骡子一起来拉磨，即扩大团队规模，另一个就是将骡子换成马，即进行组织升级、提升团队战斗力；而让骡子加班加点去拉磨，虽然在短期内也可以达成目标，但骡子也会累的，从长期来看结果并不一定会更好。听完这回答我不禁肃然起敬，在当前遍地打鸡血搞 996 的环境下，老板能有这么正的价值观，着实难得。再说回做事的方向，当前 Shopee 跟 lazada 一个很大的不同点在于，Shopee 在东南亚各个国家的市场团队都是本地人员，更熟悉当地文化风俗，而 lazada 更多的是将国内淘宝的模式在东南亚各个国家进行复制。 然后我又问了他另外一个问题： 当前 Shopee 内技术团队的地位和话语权是怎样的？（我问这问题的目的主要是想知道 Shopee 是不是一家技术导向的公司） 结果他的回答让我很出乎意料。在他看来，公司内讲团队的话语权本就是个伪命题。各个部门团队也只是分工协作不一样，最终的目的都是达成公司的商业目标。接着他没有继续回答这个问题，而是做了个类比。从做事的难度来看，相比于互联网软件开发，生产电视机的工序更多更复杂，但做电视机技术人员的薪酬就是比不上互联网软件开发人员，这都是市场行情和岗位供求关系产生的结果。具体的我记不大清了，但确实感受到他的观点和想法角度很不一样，实在是佩服。 蚂蚁金服国际事业部 最后还有一家是蚂蚁金服的国际事业部，这家是思寒找他熟悉的在蚂蚁金服的朋友（紫嫣）帮我内推的；后来我才知道原来我也加过紫嫣的微信，之前在 MTSC 都做过讲师，圈子的确也真是小哇。 对于蚂蚁金服，之前我也是有过一些了解，一方面是猎头推荐，还有就是跟朋友聊天时，大家都对其前（钱）景十分看好。从业务角度，做金融业务的确是不会过时的业务，按照卡总的说法，社会生产力总是少不了金融支撑的。而从技术角度，金融系统对系统的稳定性和可靠性要求非常高，质量保障也会是非常高的优先级。总的来说就是，不管是业务前景和个人职业发展，蚂蚁金服都是个挺","date":"2019-05-09","heading":"字节跳动","objectID":"/post/dji-graduated/:2:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#字节跳动"},{"categories":["Growth"],"collections":null,"content":"成功经历 接下来再讲下其它几家的面试经历。 Oppo 首先说下 Oppo，面的是其互联网部门，这是我最早面试的一家。其实那时我也还没有特别明确的想法，只是恰好当时卡总去了 Oppo，推荐我也过去看看，于是就答应了。最开始本来是约的晚上去和部门测试负责人（廖总）聊下，结果廖总也是客气，后面改为在外面一起吃饭，一起的还有卡总和另一位测试架构师。在席间其实也不算是面试，更多的还是闲聊，包括个人的工作情况和想法，以及了解了下那边的工作内容和规划。当时了解到的情况感觉还是很不错的，当前 Oppo 手机市场份额已经很高了，而且当前各大手机厂商的份额也基本开始固定，手机硬件利润也是越来越薄，急需在互联网软件方向上加大投入获取利润，毕竟 Oppo 作为亿级用户流量入口，可以施展的空间是非常大的。那次聊完后，廖总应该是给了我一个初步的定位（同时貌似也是过了第二轮部门面），接下来就是走正式的面试流程，包括第一轮技术面、第三轮 Oppo 测试总监面、第四轮 HR 面。效率还是非常高的，花了几个工作日晚上就聊完了。个人总体感觉，整个面试过程更多的都是围绕个人做过的项目（做了什么、怎么做的），以及一些宏观方法论、沟通协调能力、工作态度和换工作动机的考察，在技术基础细节和编码能力方面考察的很少。所以当时虽然是面试的第一家公司，也没有做过任何准备，但最后也都通过了面试。在此也给 Oppo 提个建议，技术基础细节和编码能力还是得多考察一些（近期华为在面试方面也做了很大调整，加大了编码能力考察力度），一方面可以提升面试候选人的技术准入门槛，另一方面也可以对外加强公司技术能力要求的形象，让候选人更多地感受到这是一家技术要求高、工程师文化强的公司。 在 Oppo 面试完成后，因为这还是我面的第一家，想再多聊下其它公司综合做下对比，所以当时就跟 HR 说明了想法，暂时先不给答复。然后，就是面了蚂蚁金服 IOT 和腾讯 PCG，前面也已经详述了这两段失败的经历。 字节跳动 接下来就是在准备的过程中，有猎头推荐了字节跳动。其实当时猎头跟我推荐字节跳动的时候我内心是很抗拒的，因为那段时间网上也有很多文章抨击今日头条和抖音让太多人沉迷于其中，耗费大量时间，从政治正确的角度，当时我也的确是不太想去参与到该类产品。但猎头跟我聊的时候，她的观点我不得不认同，字节跳动能做出这么多爆款产品，其产品布局能力、基础设施技术建设、公司管理文化肯定都是相当优秀的，加入字节跳动肯定可以学习到很多。另一方面，她给我推荐的部门不是今日头条和 IES 互娱，而是其效率工程部，主要的业务方向做办公协作套件和效率工具，面向的是海外 B 端市场，并且公司在此方面也具有非常大的资源投入。而且当前国内各个大厂（BAT）主要的营收都在 C 端市场，与国际大厂（FLAG）B端、C端市场营收各占一半相比，国内 B 端市场还有相当大的空间，因此当前国内各大厂也都在加大 B 端市场的布局。当时也是基于这些信息，我产生了比较大的兴趣，打算去尝试下。但众所周知，字节跳动面试对算法的考察是非常严格的，在网上也看到了非常多的吐槽，再加上前面的面试失败经历，我当时也是完全没底儿。于是我在跟字节跳动约面试的时间时，特地往后延了一两周，主要也是想多争取点时间进行下突击准备。 总算到了约定的面试时间，虽然准备仍然非常不充分，但也没有办法，只能硬着头皮上了。字节跳动的面试都是通过视频进行，从面试体验上讲还是非常不错的，完全不比现场面试的效果差。从面试的内容上讲，考察的也的确是最深的（相比面试的其它几家而言），个人项目经历中涉及到的底层原理、计算机基础技术细节都问的比较多，而代码能力部分，在每轮技术面都会进行考察，要求做到运行正确且 bugfree，并且会针对写出的代码实现提出性能优化的要求。庆幸的是我遇到的题目都是常见的算法和数据结构，思路是知道的，剩下的就是代码工程能力了，相对来说问题不大。印象最深的是第一轮面试，面试官是李昶博（有兴趣可以在网上搜下他的信息），当时整场面试持续了两个小时，从晚上八点到十点多。之所以会持续这么久，主要是在代码部分，当时我最开始写完了实现，并且写了比较完善的单元测试用例（TDD）；也得益于这些单元测试用例，当时我因为有些紧张，虽然没有把算法实现想清楚，但也通过一些补丁的方式跑通了所有单元测试，实现了 bugfree。但面试官提出要求，这些补丁代码太丑陋，代码也存在冗余，要求将代码行数缩短到15行之内。然后我就继续优化，紧张加上心理压力，头脑有些发懵，调试也有些卡壳，总之不是很顺利，但面试官也非常耐心，不断对我进行引导，直到最终完成了目标。在面试结束的时候，他还针对我的编码过程，给了我两个十分中肯的建议，着实令我非常感动，对字节跳动的认可度也大幅增强。回顾字节跳动的面试历程，总共面了3轮技术面，1轮HR面。面试给我的总体感受，就是面试官都非常有活力，虽然年龄不一定小，但能明显感受到特别有朝气。 Shopee 在面试字节跳动的过程中，又有猎头跟我推荐了 Shopee，因为之前对该公司也有一定的了解，所以就参与了面试。 当时面试的是 Shopee 的金融业务线（Airpay），可以打个比方，Shopee 做的是东南亚市场的电商，如果类比淘宝，那么 Airpay 就类比支付宝，主要做的就是电子支付和信用卡方向。之前该业务是在新加坡，也是在不久前将整个业务迁到深圳，因此人员缺口比较大，也在重点招 leader 岗位。从总体的面试经历来看，在技术方面还是非常顺利的，一方面是技术栈比较匹配，面试难度适中，介于 Oppo 和字节跳动之间，没有遇到太大问题；另一个主要的原因是 Shopee 的另一条业务线告知 Airpay 这边的测试经理，他们那边有多个项目也在使用我个人的开源测试框架 HttpRunner，因此在这方面应该是给了我不少加分，Airpay 的测试经理也对我比较认可，定位为 leader 岗位。相对来说不大顺利的是第二轮面试，面试官是 Airpay 的业务线负责人。在这一轮面试时没有涉及到技术，主要都是对我个人在团队管理、沟通协作、职业规划方面的考察。当时在面试过程中我也很是纠结，一方面我个人想法是在技术方向有更深入的投入和积累，但同时因为岗位定位为 leader 岗，我也知道完全按照内心真实想法去回答肯定也不行，所以实际在回答时有些心口不一，着实难受。在二面中，面试官还问了我一个问题，自己觉得在性格方面的优缺点是什么，我当时也是很懵，优点能罗列出几个，但性格上的缺点怎么说呢，的确是不知道怎么回答，最后就勉强说了个不太会拒绝别人（囧）。本以为二轮面试挂了，毕竟自我感觉是很不好，但最后居然也通过了。然后第三轮面试就是 Shopee 在深圳的黄老板了，面试能去跟老板聊，着实也是个收获。黄老板的确是个很有意思（个性）的人，面试过程很随性，总共就聊了不到15分钟，这其中还包括我向他提问的五六分钟。后来听乙醇说他当时面试跟黄老板总共就聊了6分钟，估计风格就是如此吧。 在跟黄老板聊的时候，能感受到他的确是一个很有想法的人。当时我问了他一个问题： shopee 的直接竞争对手 lazada 是阿里系，团队狼性，加班凶猛，而 shopee 比较佛性，团队基本不加班。当然这只是战术层面的差异，并不能起到决定性的作用，想了解下从战略层面上，shopee 与 lazada 有哪些不同，来保障不会落后于竞争对手呢？ 黄老板的回答很谦虚，说战略层面的事情他也不懂，这是 CEO 关注的问题。但从他的角度来看，如果做事的方向错了，加班做得越多，也就错的越多。他还打了个比喻，公司做事跟骡子拉磨没啥区别，都是想让拉磨越快越好。但正确的做法无非就两个，一个是找更多的骡子一起来拉磨，即扩大团队规模，另一个就是将骡子换成马，即进行组织升级、提升团队战斗力；而让骡子加班加点去拉磨，虽然在短期内也可以达成目标，但骡子也会累的，从长期来看结果并不一定会更好。听完这回答我不禁肃然起敬，在当前遍地打鸡血搞 996 的环境下，老板能有这么正的价值观，着实难得。再说回做事的方向，当前 Shopee 跟 lazada 一个很大的不同点在于，Shopee 在东南亚各个国家的市场团队都是本地人员，更熟悉当地文化风俗，而 lazada 更多的是将国内淘宝的模式在东南亚各个国家进行复制。 然后我又问了他另外一个问题： 当前 Shopee 内技术团队的地位和话语权是怎样的？（我问这问题的目的主要是想知道 Shopee 是不是一家技术导向的公司） 结果他的回答让我很出乎意料。在他看来，公司内讲团队的话语权本就是个伪命题。各个部门团队也只是分工协作不一样，最终的目的都是达成公司的商业目标。接着他没有继续回答这个问题，而是做了个类比。从做事的难度来看，相比于互联网软件开发，生产电视机的工序更多更复杂，但做电视机技术人员的薪酬就是比不上互联网软件开发人员，这都是市场行情和岗位供求关系产生的结果。具体的我记不大清了，但确实感受到他的观点和想法角度很不一样，实在是佩服。 蚂蚁金服国际事业部 最后还有一家是蚂蚁金服的国际事业部，这家是思寒找他熟悉的在蚂蚁金服的朋友（紫嫣）帮我内推的；后来我才知道原来我也加过紫嫣的微信，之前在 MTSC 都做过讲师，圈子的确也真是小哇。 对于蚂蚁金服，之前我也是有过一些了解，一方面是猎头推荐，还有就是跟朋友聊天时，大家都对其前（钱）景十分看好。从业务角度，做金融业务的确是不会过时的业务，按照卡总的说法，社会生产力总是少不了金融支撑的。而从技术角度，金融系统对系统的稳定性和可靠性要求非常高，质量保障也会是非常高的优先级。总的来说就是，不管是业务前景和个人职业发展，蚂蚁金服都是个挺","date":"2019-05-09","heading":"Shopee","objectID":"/post/dji-graduated/:2:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#shopee"},{"categories":["Growth"],"collections":null,"content":"成功经历 接下来再讲下其它几家的面试经历。 Oppo 首先说下 Oppo，面的是其互联网部门，这是我最早面试的一家。其实那时我也还没有特别明确的想法，只是恰好当时卡总去了 Oppo，推荐我也过去看看，于是就答应了。最开始本来是约的晚上去和部门测试负责人（廖总）聊下，结果廖总也是客气，后面改为在外面一起吃饭，一起的还有卡总和另一位测试架构师。在席间其实也不算是面试，更多的还是闲聊，包括个人的工作情况和想法，以及了解了下那边的工作内容和规划。当时了解到的情况感觉还是很不错的，当前 Oppo 手机市场份额已经很高了，而且当前各大手机厂商的份额也基本开始固定，手机硬件利润也是越来越薄，急需在互联网软件方向上加大投入获取利润，毕竟 Oppo 作为亿级用户流量入口，可以施展的空间是非常大的。那次聊完后，廖总应该是给了我一个初步的定位（同时貌似也是过了第二轮部门面），接下来就是走正式的面试流程，包括第一轮技术面、第三轮 Oppo 测试总监面、第四轮 HR 面。效率还是非常高的，花了几个工作日晚上就聊完了。个人总体感觉，整个面试过程更多的都是围绕个人做过的项目（做了什么、怎么做的），以及一些宏观方法论、沟通协调能力、工作态度和换工作动机的考察，在技术基础细节和编码能力方面考察的很少。所以当时虽然是面试的第一家公司，也没有做过任何准备，但最后也都通过了面试。在此也给 Oppo 提个建议，技术基础细节和编码能力还是得多考察一些（近期华为在面试方面也做了很大调整，加大了编码能力考察力度），一方面可以提升面试候选人的技术准入门槛，另一方面也可以对外加强公司技术能力要求的形象，让候选人更多地感受到这是一家技术要求高、工程师文化强的公司。 在 Oppo 面试完成后，因为这还是我面的第一家，想再多聊下其它公司综合做下对比，所以当时就跟 HR 说明了想法，暂时先不给答复。然后，就是面了蚂蚁金服 IOT 和腾讯 PCG，前面也已经详述了这两段失败的经历。 字节跳动 接下来就是在准备的过程中，有猎头推荐了字节跳动。其实当时猎头跟我推荐字节跳动的时候我内心是很抗拒的，因为那段时间网上也有很多文章抨击今日头条和抖音让太多人沉迷于其中，耗费大量时间，从政治正确的角度，当时我也的确是不太想去参与到该类产品。但猎头跟我聊的时候，她的观点我不得不认同，字节跳动能做出这么多爆款产品，其产品布局能力、基础设施技术建设、公司管理文化肯定都是相当优秀的，加入字节跳动肯定可以学习到很多。另一方面，她给我推荐的部门不是今日头条和 IES 互娱，而是其效率工程部，主要的业务方向做办公协作套件和效率工具，面向的是海外 B 端市场，并且公司在此方面也具有非常大的资源投入。而且当前国内各个大厂（BAT）主要的营收都在 C 端市场，与国际大厂（FLAG）B端、C端市场营收各占一半相比，国内 B 端市场还有相当大的空间，因此当前国内各大厂也都在加大 B 端市场的布局。当时也是基于这些信息，我产生了比较大的兴趣，打算去尝试下。但众所周知，字节跳动面试对算法的考察是非常严格的，在网上也看到了非常多的吐槽，再加上前面的面试失败经历，我当时也是完全没底儿。于是我在跟字节跳动约面试的时间时，特地往后延了一两周，主要也是想多争取点时间进行下突击准备。 总算到了约定的面试时间，虽然准备仍然非常不充分，但也没有办法，只能硬着头皮上了。字节跳动的面试都是通过视频进行，从面试体验上讲还是非常不错的，完全不比现场面试的效果差。从面试的内容上讲，考察的也的确是最深的（相比面试的其它几家而言），个人项目经历中涉及到的底层原理、计算机基础技术细节都问的比较多，而代码能力部分，在每轮技术面都会进行考察，要求做到运行正确且 bugfree，并且会针对写出的代码实现提出性能优化的要求。庆幸的是我遇到的题目都是常见的算法和数据结构，思路是知道的，剩下的就是代码工程能力了，相对来说问题不大。印象最深的是第一轮面试，面试官是李昶博（有兴趣可以在网上搜下他的信息），当时整场面试持续了两个小时，从晚上八点到十点多。之所以会持续这么久，主要是在代码部分，当时我最开始写完了实现，并且写了比较完善的单元测试用例（TDD）；也得益于这些单元测试用例，当时我因为有些紧张，虽然没有把算法实现想清楚，但也通过一些补丁的方式跑通了所有单元测试，实现了 bugfree。但面试官提出要求，这些补丁代码太丑陋，代码也存在冗余，要求将代码行数缩短到15行之内。然后我就继续优化，紧张加上心理压力，头脑有些发懵，调试也有些卡壳，总之不是很顺利，但面试官也非常耐心，不断对我进行引导，直到最终完成了目标。在面试结束的时候，他还针对我的编码过程，给了我两个十分中肯的建议，着实令我非常感动，对字节跳动的认可度也大幅增强。回顾字节跳动的面试历程，总共面了3轮技术面，1轮HR面。面试给我的总体感受，就是面试官都非常有活力，虽然年龄不一定小，但能明显感受到特别有朝气。 Shopee 在面试字节跳动的过程中，又有猎头跟我推荐了 Shopee，因为之前对该公司也有一定的了解，所以就参与了面试。 当时面试的是 Shopee 的金融业务线（Airpay），可以打个比方，Shopee 做的是东南亚市场的电商，如果类比淘宝，那么 Airpay 就类比支付宝，主要做的就是电子支付和信用卡方向。之前该业务是在新加坡，也是在不久前将整个业务迁到深圳，因此人员缺口比较大，也在重点招 leader 岗位。从总体的面试经历来看，在技术方面还是非常顺利的，一方面是技术栈比较匹配，面试难度适中，介于 Oppo 和字节跳动之间，没有遇到太大问题；另一个主要的原因是 Shopee 的另一条业务线告知 Airpay 这边的测试经理，他们那边有多个项目也在使用我个人的开源测试框架 HttpRunner，因此在这方面应该是给了我不少加分，Airpay 的测试经理也对我比较认可，定位为 leader 岗位。相对来说不大顺利的是第二轮面试，面试官是 Airpay 的业务线负责人。在这一轮面试时没有涉及到技术，主要都是对我个人在团队管理、沟通协作、职业规划方面的考察。当时在面试过程中我也很是纠结，一方面我个人想法是在技术方向有更深入的投入和积累，但同时因为岗位定位为 leader 岗，我也知道完全按照内心真实想法去回答肯定也不行，所以实际在回答时有些心口不一，着实难受。在二面中，面试官还问了我一个问题，自己觉得在性格方面的优缺点是什么，我当时也是很懵，优点能罗列出几个，但性格上的缺点怎么说呢，的确是不知道怎么回答，最后就勉强说了个不太会拒绝别人（囧）。本以为二轮面试挂了，毕竟自我感觉是很不好，但最后居然也通过了。然后第三轮面试就是 Shopee 在深圳的黄老板了，面试能去跟老板聊，着实也是个收获。黄老板的确是个很有意思（个性）的人，面试过程很随性，总共就聊了不到15分钟，这其中还包括我向他提问的五六分钟。后来听乙醇说他当时面试跟黄老板总共就聊了6分钟，估计风格就是如此吧。 在跟黄老板聊的时候，能感受到他的确是一个很有想法的人。当时我问了他一个问题： shopee 的直接竞争对手 lazada 是阿里系，团队狼性，加班凶猛，而 shopee 比较佛性，团队基本不加班。当然这只是战术层面的差异，并不能起到决定性的作用，想了解下从战略层面上，shopee 与 lazada 有哪些不同，来保障不会落后于竞争对手呢？ 黄老板的回答很谦虚，说战略层面的事情他也不懂，这是 CEO 关注的问题。但从他的角度来看，如果做事的方向错了，加班做得越多，也就错的越多。他还打了个比喻，公司做事跟骡子拉磨没啥区别，都是想让拉磨越快越好。但正确的做法无非就两个，一个是找更多的骡子一起来拉磨，即扩大团队规模，另一个就是将骡子换成马，即进行组织升级、提升团队战斗力；而让骡子加班加点去拉磨，虽然在短期内也可以达成目标，但骡子也会累的，从长期来看结果并不一定会更好。听完这回答我不禁肃然起敬，在当前遍地打鸡血搞 996 的环境下，老板能有这么正的价值观，着实难得。再说回做事的方向，当前 Shopee 跟 lazada 一个很大的不同点在于，Shopee 在东南亚各个国家的市场团队都是本地人员，更熟悉当地文化风俗，而 lazada 更多的是将国内淘宝的模式在东南亚各个国家进行复制。 然后我又问了他另外一个问题： 当前 Shopee 内技术团队的地位和话语权是怎样的？（我问这问题的目的主要是想知道 Shopee 是不是一家技术导向的公司） 结果他的回答让我很出乎意料。在他看来，公司内讲团队的话语权本就是个伪命题。各个部门团队也只是分工协作不一样，最终的目的都是达成公司的商业目标。接着他没有继续回答这个问题，而是做了个类比。从做事的难度来看，相比于互联网软件开发，生产电视机的工序更多更复杂，但做电视机技术人员的薪酬就是比不上互联网软件开发人员，这都是市场行情和岗位供求关系产生的结果。具体的我记不大清了，但确实感受到他的观点和想法角度很不一样，实在是佩服。 蚂蚁金服国际事业部 最后还有一家是蚂蚁金服的国际事业部，这家是思寒找他熟悉的在蚂蚁金服的朋友（紫嫣）帮我内推的；后来我才知道原来我也加过紫嫣的微信，之前在 MTSC 都做过讲师，圈子的确也真是小哇。 对于蚂蚁金服，之前我也是有过一些了解，一方面是猎头推荐，还有就是跟朋友聊天时，大家都对其前（钱）景十分看好。从业务角度，做金融业务的确是不会过时的业务，按照卡总的说法，社会生产力总是少不了金融支撑的。而从技术角度，金融系统对系统的稳定性和可靠性要求非常高，质量保障也会是非常高的优先级。总的来说就是，不管是业务前景和个人职业发展，蚂蚁金服都是个挺","date":"2019-05-09","heading":"蚂蚁金服国际事业部","objectID":"/post/dji-graduated/:2:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#蚂蚁金服国际事业部"},{"categories":["Growth"],"collections":null,"content":"offer 选择 总算是经历完了曲折的面试过程，但在这之后，我迎来了新的苦恼，那就是到底选择去哪一家，Oppo、字节跳动、Shopee，还是等蚂蚁金服国际。 说实话，这几家都是十分优秀的公司，并没有明确的优劣之分。从给到我的薪酬方案来看，Oppo、字节跳动、Shopee 基本差异不太大，总包差异在十万以内；而从市场行情来看，假如蚂蚁金服发了 Offer，估计也都差不多，所以薪酬这块儿不算是主要的因素，更多还是要从工作本身进行考虑。 ","date":"2019-05-09","heading":"offer 选择","objectID":"/post/dji-graduated/:3:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#offer-选择"},{"categories":["Growth"],"collections":null,"content":"进一步跟未来主管详聊 对于工作本身的信息，虽然在面试的过程中可以了解到一些，以及在面试结束后都会有机会向面试官进行提问，但那时作为面试候选人，能问的问题终究是有限的。所以在拿到 offer 后，肯定还是会有许多未知的信息，这也是造成我拿不准想法的重要原因。 因此，在存在较多未知信息的时候，我觉得最好的方式还是跟各个公司未来的直接主管再深入地聊下，详细了解下业务和团队方面的情况，个人岗位的具体工作内容，以及未来主管对自己的期望。当时我的确也是这么做的，主动跟 Shopee、字节跳动、Oppo 的未来直接主管提出想再深入聊下的想法，感激的是他们也都非常乐意跟我约时间详细解答我的疑惑。 在聊之前我也做了个大概的问题梳理，主要是围绕如下几方面的信息去了解： 工作内容：包括业务形态、业务中的痛点难点、业务面临的技术调整、当前测试技术建设等情况 团队情况：包括团队规模、团队定位、分工协作方式、技术栈、团队成员背景等 岗位情况：包括岗位具体工作内容、主管对个人的期望、考核评价标准和形式等 基本上，将这几方面聊完后，对于工作本身就有了足够清晰的了解了。 其实在最开始聊之前我也存在一些顾虑，那就是未来主管是否愿意跟我聊，毕竟挺花时间的，而且要是聊了以后最后没选择去人家那里，感觉也特别不好意思。后来我也想清楚了，个人在工作中接触最多的就是直接主管，跟 TA 相处融洽想法一致是再重要不过的了，假如 TA 都不愿意花这个时间，那过去后在工作中的沟通也可能存在比较多的问题了。另外，我觉得详细了解足够的信息，对于自己做决定的确是非常有必要的，假如自己没有充分了解清楚，结果去了新公司后发现不是自己想要的，工作很痛苦或者再有离开的想法，那么对于新公司的影响会更大，相信这更不是未来主管愿意看到的。 ","date":"2019-05-09","heading":"进一步跟未来主管详聊","objectID":"/post/dji-graduated/:3:1","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#进一步跟未来主管详聊"},{"categories":["Growth"],"collections":null,"content":"基本信息对比 掌握了足够的信息后，就可以对每家公司的优缺点进行对比了。当然，这个更多是从我个人的角度，具有非常大的主观性，信息也可能存在较大的偏差。 首先说 Oppo。在面试过程中，也了解到了其业务前景和技术挑战空间都是很大的，给予到我个人也有比较好的认可和定位。从了解到的信息来看，Oppo 对员工在给钱方面还是很大方的，不光是在职期间（听说如果选择浮动薪酬方案的话），甚至员工主动离职也会补偿 N+1，在离职后还会补发年终奖，持有的股票也能继续持有并享有分红等。从脉脉匿名区看到的信息，普遍对于 Oppo 在这方面都评价很正面。对应到给我的薪酬方案，固定薪酬方案是比较明确的现金（月薪和年终），相比其它家也还比较有竞争力；浮动方案看不大懂，但听说按照往年的情况，选择浮动方案的实际到手会比选择固定方案多很多（公司效益正常个人绩效正常的情况）。还有点比较有吸引力的，可以选择成都研发中心，薪酬不变。不足的方面，一个是感觉当前技术建设偏滞后，工程师文化偏弱，与第一二梯队的互联网公司存在一定差距；另外就是听说加班会很多，公司管理文化也偏传统一些（特别是想吐槽下收集信息的那个表格，感觉是好多年前的模版，还有科室类的字眼）。总结起来就是：钱多多+可选成都+技术建设滞后+加班多+偏传统。 接着说 Shopee。从业务上来讲，东南亚电商是一个非常有增长空间的市场，类似于X年前的国内，所以当前阿里电商也在重点投入东南亚。而 Shopee 在东南亚电商市场还是非常给力的，虽然当前排名第二，但增长非常迅速，远高于阿里系的 lazada，前景可期。给予到个人的机会也很不错，核心业务、leader 岗位，可以施展的空间也非常大。另外，相比于其它几家加班严重的情况，Shopee 可以说是一股清流，完全不加班，15天带薪年假，可以充分享受工作和生活的平衡。与此同时，薪酬也是很给力的，总包上看跟其它家给的都差不多，因此从时薪上算可以说是完爆其它家。技术氛围方面，听说也还不错，对入职员工的要求也不低，BAT背景的听说比例也非常高（40%～50%）。总结起来就是：时薪最高+技术氛围不错+不加班+外企文化。 然后说字节跳动效率工程。从业务上来看，其实是最不被很多人看好的，在脉脉上也有很多人表达了对这个部门的不认可。不过他们了解到的信息有限，以为这只是服务于字节跳动公司内部的 IT 支撑部门。而实际上，这是字节跳动在 B 端市场的重要布局，并且在企业效率工具方面已经有了较多投入，包括收购幕布、朝夕日历、坚果云，投资石墨文档成为其最大股东等。当前各个大厂都在重点投入 B 端市场，这也说明了 B 端市场的确是存在较大的机会。而字节跳动这家公司自上而下对企业效率工具都是有非常高的要求，当前已经产出的 lark 也获得了广泛好评，使用过的用户都赞不绝口，并已形成了自增长的趋势（的确不得不佩服其做产品的能力），因此我个人还是非常看好其业务前景的。同时，我个人也是非常喜欢效率工具，平时也投了很多业余时间来把玩这些产品，例如幕布、滴答清单、各种笔记软件等，所以可以说字节跳动效率工程的业务跟我的个人兴趣是最对口的。在技术氛围方面，从外界了解到的情况和自己的感受，我觉得其工程师文化是很强的。除此之外，还有两个很吸引我的点。一个是字节跳动全公司采用 OKR 文化，员工可以看到任意其他人的 OKR，所以说信息是非常透明的。另一个是效率工程部采用的是去 QE 的模式，没有业务测试人员，产品质量由开发人员全权负责，测试开发对接开发人员提供测试工具平台，这也是我个人非常期望尝试的协作模式。总的来说，不管是业务方向、技术氛围，还是公司文化，我个人都是很满意的。但除此之外，还是有一些让我纠结的问题。最主要的就是期权价格，当前入职时每股的期权价格已经达到 60 美金了，对应到的公司估值要达到 900 亿美金。而字节跳动的估值去年年底是 750 亿美金（对应期权价格 50 美金），短短几个月时间，就涨出了一个大疆，着实让人感觉有些不可思议。实际上，去年年底在估值 750 亿美金的时候，网上已经存在非常多质疑的言论，认为泡沫太大。所以虽然我个人也是非常看好字节跳动的发展，但对于是否存在泡沫太大的问题着实也是心里没底儿（当前百度的市值已经跌到不足 450 亿美金了）。所以在薪酬这块儿，我也不得不做好公司上市后跌破发行价的心理准备，那么实际对应到个人的年收入，相比其它家实实在在的现金来说，可能就存在一定的风险。还有个比较不爽的点，就是今年字节跳动在招人的薪资策略上也做了些调整，不再像往年那么给力，基本上都会卡 30% 的涨幅；而且都说字节跳动的工作强度会比较大，加班比较多，那么在总包跟 Shopee 这类完全不加班的公司差异不大的情况下，计算时薪就会很低了。总结起来就是：业务符合口味+技术氛围好+公司文化赞+薪酬回报风险+加班多。 最后再说下蚂蚁金服国际。这个的确是比较悲剧，赶在了尴尬的时间点，我也的确不可能直接拒掉其它几家 offer 而专门等这家的，风险因素太大，主要也还是意向没强到那个程度，所以就此错过吧。其实回头想想，蚂蚁金服最吸引我的，除了前面说的业务前景以及阿里的技术积累外，最大的点还是在于可以选择去到成都吧。虽然 Oppo 也可以选择成都，但从个人对公司技术氛围的期望来看，蚂蚁金服对我的吸引力会更大些。当然，蚂蚁金服国际的机会也并不是十全十美，其同样也存在一些不足的点。最大的一个点应该是阿里的 361 文化，团队里总要有个 3.25，但谁不希望是自己，造成的结果就是一个比一个拼，加班一个比一个猛，甚至团队之间还会出现抢活干的情况，在这个环境下个人的压力是非常巨大的。另外，在期权价格上，蚂蚁金服当前也存在与字节跳动类似的情况，就是当前的期权价格已经非常高了，再往上的空间很有限，也有可能出现上市跌破发行价的情况，因此从财务回报的角度存在一定的风险。还有就是选择成都的蚂蚁金服，听说那边的业务比较边缘一些，毕竟核心业务都在杭州和上海，而且去到那边是做偏业务测试的测试开发岗位，而个人还是更倾向于测试工具平台的产品规划和开发工作。 从详细对比可以看出，十全十美的工作机会是不存在的，每家公司都有其亮点和不足。在这个时候，如果想通过找别人给建议帮助自己做决定，肯定是不合适的。真实情况是，每个人都有自己的出发点和偏好，因此不同人做出的选择也可能出现比较大的差异，而且更重要的是，别人帮忙做出的决定通常并不一定适合自己。当时我也很是迷茫，所以我就在脉脉匿名区去发了个贴，期望得到一些建议。结果居然大多数人的建议颇为一致，那就是选 Shopee 啊，工作轻松时薪高，干嘛要去其它家苦逼加班。这个答案也在我的意料之中，但对于我自身而言，当前阶段并不是想轻松，挣钱也并不是最主要的目标啊。 ","date":"2019-05-09","heading":"基本信息对比","objectID":"/post/dji-graduated/:3:2","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#基本信息对比"},{"categories":["Growth"],"collections":null,"content":"想清楚内心的真实诉求 所以，我不得不承认，要做出最适合自己的决定，一定要想清楚自己内心最真实的渴望是什么。那对我而言，当前我想换工作的最大诉求是什么呢？ 其实对于这个问题我已经想的很清楚了，那就是在当前阶段趁自己还有动力和体力的时候，快速提升自己的技术视野，在技术深度上有更多的积累和沉淀。而相比于这些，至于是否带团队、是否加班多压力大、是否挣更多的钱，的确就没那么重要了。 ","date":"2019-05-09","heading":"想清楚内心的真实诉求","objectID":"/post/dji-graduated/:3:3","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#想清楚内心的真实诉求"},{"categories":["Growth"],"collections":null,"content":"最终选择 明确了自己的真实诉求后，再回到前面的选择也就没那么难了，字节跳动效率工程部的确是我当前阶段最合适的选择。 \b那后续规划呢？是否要带团队走技术管理路线，是否要离开深圳去成都？ 我的初步想法是，在我 35 岁之前（还有四年的时间），提升技术视野和加强技术积累都是我的首要目标，在有了足够的积累后，我再考虑是继续走技术专家路线还是转做技术管理，相信那时也都有足够的底气，个人想法也更明确了。 而成都呢？在今年五一节前我和家人去成都市区玩了几天，对于成都的环境的确是比较满意的。我也了解到一个信息，当前成都买房限购，需要有成都户口和社保年限累计才能获得购房资格。但要解决这两个问题也不难，成都落户很简单，只要是本科学历就可以直接落户；而社保这块儿，即使没有在成都工作，也是可以自己找中介公司代缴社保。因此我也不用急着转去成都工作，毕竟当前小孩才一岁多，离他上小学还有好几年的时间；同步地，可以让我老婆先将户口迁去成都，再在当地缴纳社保，那么最快在一年之后，就可以获得购房资格，如果合适，就先在成都购房，解决住房和学位的问题。然后等几年后，需要考虑小孩上学了，成都的互联网就业环境应该也已变得更好了（现在也已经比较不错），到时再转去成都工作也就比较自然了。 ","date":"2019-05-09","heading":"最终选择","objectID":"/post/dji-graduated/:4:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#最终选择"},{"categories":["Growth"],"collections":null,"content":"写在最后 终于，我又要开启新的职业旅程了。我不确定这是否是一个正确的或者好的决定，但我确定这是我自己充分聆听内心想法并经过思考后做的改变，我想这就够了。 我也比较庆幸自己从毕业至今一路走来，虽然也经受了不少社会的摧残，但我仍然没有太多的改变，至少我自认为我还是那个充满斗志和活力、不断努力将自己变得更好的少年。是的，虽已一把年纪，但我内心还是个少年。 最后，真心祝愿大疆越来越好，各位领导和同事一切顺利！ ","date":"2019-05-09","heading":"写在最后","objectID":"/post/dji-graduated/:5:0","tags":["思考记录","大疆"],"title":"写在大疆毕业之际","uri":"/post/dji-graduated/#写在最后"},{"categories":["OpenSource"],"collections":null,"content":"背景描述 在《HttpRunner 2.0 正式发布》中提到，HttpRunner 从 2.0 版本开始，对测试用例组织形式进行了较大的调整，更正了之前在自动化测试概念上的偏差。 对应地，测试用例分层机制也进行了重新设计，因此在概念和使用方法方面都会出现很大的差异。本文便是对新的测试用例分层机制进行介绍。 ","date":"2019-04-18","heading":"背景描述","objectID":"/post/HttpRunner-testcase-layer-2x/:1:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#背景描述"},{"categories":["OpenSource"],"collections":null,"content":"测试用例分层模型 关于为什么要使用测试用例分层机制，在《HttpRunner 的测试用例分层机制（已过期）》中已经进行了详细的介绍，虽然使用方法变化了，但原理上都是相同的。 概括来说，测试用例分层机制的核心是将接口定义、测试步骤、测试用例、测试场景进行分离，单独进行描述和维护，从而尽可能地减少自动化测试用例的维护成本。 逻辑关系图如下所示： 同时，强调如下几点核心概念： 测试用例（testcase）应该是完整且独立的，每条测试用例应该是都可以独立运行的 测试用例是测试步骤（teststep）的 有序 集合，每一个测试步骤对应一个 API 的请求描述 测试用例集（testsuite）是测试用例的 无序 集合，集合中的测试用例应该都是相互独立，不存在先后依赖关系的；如果确实存在先后依赖关系，那就需要在测试用例中完成依赖的处理 如果对于上述第三点感觉难以理解，不妨看下上图中的示例： testcase1 依赖于 testcase2，那么就可以在测试步骤（teststep12）中对 testcase2 进行引用，然后 testcase1 就是完整且可独立运行的； 在 testsuite 中，testcase1 与 testcase2 相互独立，运行顺序就不再有先后依赖关系了。 ","date":"2019-04-18","heading":"测试用例分层模型","objectID":"/post/HttpRunner-testcase-layer-2x/:2:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#测试用例分层模型"},{"categories":["OpenSource"],"collections":null,"content":"分层描述详解 理解了测试用例分层模型，接下来我们再来看下在分层模型下，接口、测试用例、测试用例集的描述形式。 ","date":"2019-04-18","heading":"分层描述详解","objectID":"/post/HttpRunner-testcase-layer-2x/:3:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#分层描述详解"},{"categories":["OpenSource"],"collections":null,"content":"接口定义（API） 为了更好地对接口描述进行管理，推荐使用独立的文件对接口描述进行存储，即每个文件对应一个接口描述。 接口定义描述的主要内容包括：name、variables、request、base_url、validate 等，形式如下： name: get headers base_url: http://httpbin.org variables: expected_status_code: 200 request: url: /headers method: GET validate: - eq: [\"status_code\", $expected_status_code] - eq: [content.headers.Host, \"httpbin.org\"] 其中，name 和 request 部分是必须的，request 中的描述形式与 requests.request 完全相同。 另外，API 描述需要尽量保持完整，做到可以单独运行。如果在接口描述中存在变量引用的情况，可在 variables 中对参数进行定义。通过这种方式，可以很好地实现单个接口的调试。 $ hrun api/get_headers.yml INFO Start to run testcase: get headers headers INFO GET http://httpbin.org/headers INFO status_code: 200, response_time(ms): 477.32 ms, response_length: 157 bytes . ---------------------------------------------------------------------- Ran 1 test in 0.478s OK ","date":"2019-04-18","heading":"接口定义（API）","objectID":"/post/HttpRunner-testcase-layer-2x/:3:1","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#接口定义api"},{"categories":["OpenSource"],"collections":null,"content":"测试用例（testcase） 引用接口定义 有了接口的定义描述后，我们编写测试场景时就可以直接引用接口定义了。 在测试步骤（teststep）中，可通过 api 字段引用接口定义，引用方式为对应 API 文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 - config: name: \"setup and reset all.\" variables: user_agent: 'iOS/10.3' device_sn: \"TESTCASE_SETUP_XXX\" os_platform: 'ios' app_version: '2.8.6' base_url: \"http://127.0.0.1:5000\" verify: False output: - session_token - test: name: get token (setup) api: api/get_token.yml variables: user_agent: 'iOS/10.3' device_sn: $device_sn os_platform: 'ios' app_version: '2.8.6' extract: - session_token: content.token validate: - eq: [\"status_code\", 200] - len_eq: [\"content.token\", 16] - test: name: reset all users api: api/reset_all.yml variables: token: $session_token 若需要控制或改变接口定义中的参数值，可在测试步骤中指定 variables 参数，覆盖 API 中的 variables 实现。 同样地，在测试步骤中定义 validate 后，也会与 API 中的 validate 合并覆盖。因此推荐的做法是，在 API 定义中的 validate 只描述最基本的校验项，例如 status_code，对于与业务逻辑相关的更多校验项，在测试步骤的 validate 中进行描述。 引用测试用例 在测试用例的测试步骤中，除了可以引用接口定义，还可以引用其它测试用例。通过这种方式，可以在避免重复描述的同时，解决测试用例的依赖关系，从而保证每个测试用例都是独立可运行的。 在测试步骤（teststep）中，可通过 testcase 字段引用其它测试用例，引用方式为对应测试用例文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 例如，在上面的测试用例（\u0026ldquo;setup and reset all.\u0026quot;）中，实现了对获取 token 功能的测试；同时，在很多其它功能中都会依赖于获取 token 的功能，如果将该功能的测试步骤脚本拷贝到其它功能的测试用例中，那么就会存在大量重复，当需要对该部分进行修改时就需要修改所有地方，显然不便于维护。 比较好的做法是，在其它功能的测试用例（如创建用户）中，引用获取 token 功能的测试用例（testcases/setup.yml）作为一个测试步骤，从而创建用户（\u0026ldquo;create user and check result.\u0026quot;）这个测试用例也变得独立可运行了。 - config: name: \"create user and check result.\" id: create_user base_url: \"http://127.0.0.1:5000\" variables: uid: 9001 device_sn: \"TESTCASE_CREATE_XXX\" output: - session_token - test: name: setup and reset all (override) for $device_sn. testcase: testcases/setup.yml output: - session_token - test: name: create user and check result. variables: token: $session_token testcase: testcases/deps/check_and_create.yml ","date":"2019-04-18","heading":"测试用例（testcase）","objectID":"/post/HttpRunner-testcase-layer-2x/:3:2","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#测试用例testcase"},{"categories":["OpenSource"],"collections":null,"content":"测试用例（testcase） 引用接口定义 有了接口的定义描述后，我们编写测试场景时就可以直接引用接口定义了。 在测试步骤（teststep）中，可通过 api 字段引用接口定义，引用方式为对应 API 文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 - config: name: \"setup and reset all.\" variables: user_agent: 'iOS/10.3' device_sn: \"TESTCASE_SETUP_XXX\" os_platform: 'ios' app_version: '2.8.6' base_url: \"http://127.0.0.1:5000\" verify: False output: - session_token - test: name: get token (setup) api: api/get_token.yml variables: user_agent: 'iOS/10.3' device_sn: $device_sn os_platform: 'ios' app_version: '2.8.6' extract: - session_token: content.token validate: - eq: [\"status_code\", 200] - len_eq: [\"content.token\", 16] - test: name: reset all users api: api/reset_all.yml variables: token: $session_token 若需要控制或改变接口定义中的参数值，可在测试步骤中指定 variables 参数，覆盖 API 中的 variables 实现。 同样地，在测试步骤中定义 validate 后，也会与 API 中的 validate 合并覆盖。因此推荐的做法是，在 API 定义中的 validate 只描述最基本的校验项，例如 status_code，对于与业务逻辑相关的更多校验项，在测试步骤的 validate 中进行描述。 引用测试用例 在测试用例的测试步骤中，除了可以引用接口定义，还可以引用其它测试用例。通过这种方式，可以在避免重复描述的同时，解决测试用例的依赖关系，从而保证每个测试用例都是独立可运行的。 在测试步骤（teststep）中，可通过 testcase 字段引用其它测试用例，引用方式为对应测试用例文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 例如，在上面的测试用例（\u0026ldquo;setup and reset all.\u0026quot;）中，实现了对获取 token 功能的测试；同时，在很多其它功能中都会依赖于获取 token 的功能，如果将该功能的测试步骤脚本拷贝到其它功能的测试用例中，那么就会存在大量重复，当需要对该部分进行修改时就需要修改所有地方，显然不便于维护。 比较好的做法是，在其它功能的测试用例（如创建用户）中，引用获取 token 功能的测试用例（testcases/setup.yml）作为一个测试步骤，从而创建用户（\u0026ldquo;create user and check result.\u0026quot;）这个测试用例也变得独立可运行了。 - config: name: \"create user and check result.\" id: create_user base_url: \"http://127.0.0.1:5000\" variables: uid: 9001 device_sn: \"TESTCASE_CREATE_XXX\" output: - session_token - test: name: setup and reset all (override) for $device_sn. testcase: testcases/setup.yml output: - session_token - test: name: create user and check result. variables: token: $session_token testcase: testcases/deps/check_and_create.yml ","date":"2019-04-18","heading":"引用接口定义","objectID":"/post/HttpRunner-testcase-layer-2x/:3:2","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#引用接口定义"},{"categories":["OpenSource"],"collections":null,"content":"测试用例（testcase） 引用接口定义 有了接口的定义描述后，我们编写测试场景时就可以直接引用接口定义了。 在测试步骤（teststep）中，可通过 api 字段引用接口定义，引用方式为对应 API 文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 - config: name: \"setup and reset all.\" variables: user_agent: 'iOS/10.3' device_sn: \"TESTCASE_SETUP_XXX\" os_platform: 'ios' app_version: '2.8.6' base_url: \"http://127.0.0.1:5000\" verify: False output: - session_token - test: name: get token (setup) api: api/get_token.yml variables: user_agent: 'iOS/10.3' device_sn: $device_sn os_platform: 'ios' app_version: '2.8.6' extract: - session_token: content.token validate: - eq: [\"status_code\", 200] - len_eq: [\"content.token\", 16] - test: name: reset all users api: api/reset_all.yml variables: token: $session_token 若需要控制或改变接口定义中的参数值，可在测试步骤中指定 variables 参数，覆盖 API 中的 variables 实现。 同样地，在测试步骤中定义 validate 后，也会与 API 中的 validate 合并覆盖。因此推荐的做法是，在 API 定义中的 validate 只描述最基本的校验项，例如 status_code，对于与业务逻辑相关的更多校验项，在测试步骤的 validate 中进行描述。 引用测试用例 在测试用例的测试步骤中，除了可以引用接口定义，还可以引用其它测试用例。通过这种方式，可以在避免重复描述的同时，解决测试用例的依赖关系，从而保证每个测试用例都是独立可运行的。 在测试步骤（teststep）中，可通过 testcase 字段引用其它测试用例，引用方式为对应测试用例文件的路径，绝对路径或相对路径均可。推荐使用相对路径，路径基准为项目根目录，即 debugtalk.py 所在的目录路径。 例如，在上面的测试用例（\u0026ldquo;setup and reset all.\u0026quot;）中，实现了对获取 token 功能的测试；同时，在很多其它功能中都会依赖于获取 token 的功能，如果将该功能的测试步骤脚本拷贝到其它功能的测试用例中，那么就会存在大量重复，当需要对该部分进行修改时就需要修改所有地方，显然不便于维护。 比较好的做法是，在其它功能的测试用例（如创建用户）中，引用获取 token 功能的测试用例（testcases/setup.yml）作为一个测试步骤，从而创建用户（\u0026ldquo;create user and check result.\u0026quot;）这个测试用例也变得独立可运行了。 - config: name: \"create user and check result.\" id: create_user base_url: \"http://127.0.0.1:5000\" variables: uid: 9001 device_sn: \"TESTCASE_CREATE_XXX\" output: - session_token - test: name: setup and reset all (override) for $device_sn. testcase: testcases/setup.yml output: - session_token - test: name: create user and check result. variables: token: $session_token testcase: testcases/deps/check_and_create.yml ","date":"2019-04-18","heading":"引用测试用例","objectID":"/post/HttpRunner-testcase-layer-2x/:3:2","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#引用测试用例"},{"categories":["OpenSource"],"collections":null,"content":"测试用例集（testsuite） 当测试用例数量比较多以后，为了方便管理和实现批量运行，通常需要使用测试用例集来对测试用例进行组织。 在前文的测试用例分层模型中也强调了，测试用例集（testsuite）是测试用例的 无序 集合，集合中的测试用例应该都是相互独立，不存在先后依赖关系的；如果确实存在先后依赖关系，那就需要在测试用例中完成依赖的处理。 因为是 无序 集合，因此测试用例集的描述形式会与测试用例有些不同，在每个测试用例集文件中，第一层级存在两类字段： config: 测试用例集的总体配置参数 testcases: 值为字典结构（无序），key 为测试用例的名称，value 为测试用例的内容；在引用测试用例时也可以指定 variables，实现对引用测试用例中 variables 的覆盖。 非参数化场景 config: name: create users with uid variables: device_sn: ${gen_random_string(15)} var_a: ${gen_random_string(5)} var_b: $var_a base_url: \"http://127.0.0.1:5000\" testcases: create user 1000 and check result.: testcase: testcases/create_user.yml variables: uid: 1000 var_c: ${gen_random_string(5)} var_d: $var_c create user 1001 and check result.: testcase: testcases/create_user.yml variables: uid: 1001 var_c: ${gen_random_string(5)} var_d: $var_c 参数化场景（parameters） 对于参数化场景，可通过 parameters 实现，描述形式如下所示。 config: name: create users with parameters variables: device_sn: ${gen_random_string(15)} base_url: \"http://127.0.0.1:5000\" testcases: create user $uid and check result for $device_sn.: testcase: testcases/create_user.yml variables: uid: 1000 device_sn: TESTSUITE_XXX parameters: uid: [101, 102, 103] device_sn: [TESTSUITE_X1, TESTSUITE_X2] 参数化后，parameters 中的变量将采用笛卡尔积组合形成参数列表，依次覆盖 variables 中的参数，驱动测试用例的运行。 ","date":"2019-04-18","heading":"测试用例集（testsuite）","objectID":"/post/HttpRunner-testcase-layer-2x/:3:3","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#测试用例集testsuite"},{"categories":["OpenSource"],"collections":null,"content":"测试用例集（testsuite） 当测试用例数量比较多以后，为了方便管理和实现批量运行，通常需要使用测试用例集来对测试用例进行组织。 在前文的测试用例分层模型中也强调了，测试用例集（testsuite）是测试用例的 无序 集合，集合中的测试用例应该都是相互独立，不存在先后依赖关系的；如果确实存在先后依赖关系，那就需要在测试用例中完成依赖的处理。 因为是 无序 集合，因此测试用例集的描述形式会与测试用例有些不同，在每个测试用例集文件中，第一层级存在两类字段： config: 测试用例集的总体配置参数 testcases: 值为字典结构（无序），key 为测试用例的名称，value 为测试用例的内容；在引用测试用例时也可以指定 variables，实现对引用测试用例中 variables 的覆盖。 非参数化场景 config: name: create users with uid variables: device_sn: ${gen_random_string(15)} var_a: ${gen_random_string(5)} var_b: $var_a base_url: \"http://127.0.0.1:5000\" testcases: create user 1000 and check result.: testcase: testcases/create_user.yml variables: uid: 1000 var_c: ${gen_random_string(5)} var_d: $var_c create user 1001 and check result.: testcase: testcases/create_user.yml variables: uid: 1001 var_c: ${gen_random_string(5)} var_d: $var_c 参数化场景（parameters） 对于参数化场景，可通过 parameters 实现，描述形式如下所示。 config: name: create users with parameters variables: device_sn: ${gen_random_string(15)} base_url: \"http://127.0.0.1:5000\" testcases: create user $uid and check result for $device_sn.: testcase: testcases/create_user.yml variables: uid: 1000 device_sn: TESTSUITE_XXX parameters: uid: [101, 102, 103] device_sn: [TESTSUITE_X1, TESTSUITE_X2] 参数化后，parameters 中的变量将采用笛卡尔积组合形成参数列表，依次覆盖 variables 中的参数，驱动测试用例的运行。 ","date":"2019-04-18","heading":"非参数化场景","objectID":"/post/HttpRunner-testcase-layer-2x/:3:3","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#非参数化场景"},{"categories":["OpenSource"],"collections":null,"content":"测试用例集（testsuite） 当测试用例数量比较多以后，为了方便管理和实现批量运行，通常需要使用测试用例集来对测试用例进行组织。 在前文的测试用例分层模型中也强调了，测试用例集（testsuite）是测试用例的 无序 集合，集合中的测试用例应该都是相互独立，不存在先后依赖关系的；如果确实存在先后依赖关系，那就需要在测试用例中完成依赖的处理。 因为是 无序 集合，因此测试用例集的描述形式会与测试用例有些不同，在每个测试用例集文件中，第一层级存在两类字段： config: 测试用例集的总体配置参数 testcases: 值为字典结构（无序），key 为测试用例的名称，value 为测试用例的内容；在引用测试用例时也可以指定 variables，实现对引用测试用例中 variables 的覆盖。 非参数化场景 config: name: create users with uid variables: device_sn: ${gen_random_string(15)} var_a: ${gen_random_string(5)} var_b: $var_a base_url: \"http://127.0.0.1:5000\" testcases: create user 1000 and check result.: testcase: testcases/create_user.yml variables: uid: 1000 var_c: ${gen_random_string(5)} var_d: $var_c create user 1001 and check result.: testcase: testcases/create_user.yml variables: uid: 1001 var_c: ${gen_random_string(5)} var_d: $var_c 参数化场景（parameters） 对于参数化场景，可通过 parameters 实现，描述形式如下所示。 config: name: create users with parameters variables: device_sn: ${gen_random_string(15)} base_url: \"http://127.0.0.1:5000\" testcases: create user $uid and check result for $device_sn.: testcase: testcases/create_user.yml variables: uid: 1000 device_sn: TESTSUITE_XXX parameters: uid: [101, 102, 103] device_sn: [TESTSUITE_X1, TESTSUITE_X2] 参数化后，parameters 中的变量将采用笛卡尔积组合形成参数列表，依次覆盖 variables 中的参数，驱动测试用例的运行。 ","date":"2019-04-18","heading":"参数化场景（parameters）","objectID":"/post/HttpRunner-testcase-layer-2x/:3:3","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#参数化场景parameters"},{"categories":["OpenSource"],"collections":null,"content":"文件目录结构管理 \u0026amp;\u0026amp; 脚手架工具 在对测试用例文件进行组织管理时，对于文件的存储位置均没有要求和限制，在引用时只需要指定对应的文件路径即可。但从约定大于配置的角度，最好是按照推荐的文件夹名称进行存储管理，并可通过子目录实现项目模块分类管理。 总结如下： debugtalk.py 放置在项目根目录下，假设为 PRJ_ROOT_DIR 接口定义（API）放置在 PRJ_ROOT_DIR/api 目录下 测试用例（testcase）放置在 PRJ_ROOT_DIR/testcases 目录下 测试用例集（testsuite）文件必须放置在 PRJ_ROOT_DIR/testsuite 目录下 目录结构如下所示： $ tree tests tests ├── api │ ├── create_user.yml │ ├── get_headers.yml │ ├── get_token.yml │ ├── get_user.yml │ └── reset_all.yml ├── debugtalk.py ├── testcases │ ├── create_user.yml │ ├── deps │ │ └── check_and_create.yml │ └── setup.yml └── testsuites ├── create_users.yml └── create_users_with_parameters.yml 同时，在 HttpRunner 中实现了一个脚手架工具，可以快速创建项目的目录结构。该想法来源于 Django 的 django-admin.py startproject project_name。 使用方式也与 Django 类似，只需要通过 --startproject 指定新项目的名称即可。 $ hrun --startproject demo Start to create new project: demo CWD: /Users/debugtalk/MyProjects/examples created folder: demo created folder: demo/api created folder: demo/testcases created folder: demo/testsuites created folder: demo/reports created file: demo/debugtalk.py created file: demo/.env ","date":"2019-04-18","heading":"文件目录结构管理 \u0026amp;\u0026amp; 脚手架工具","objectID":"/post/HttpRunner-testcase-layer-2x/:4:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#文件目录结构管理--脚手架工具"},{"categories":["OpenSource"],"collections":null,"content":"相关参考 《HttpRunner 的测试用例分层机制（已过期）》 测试用例分层详细示例：HttpRunner/tests ","date":"2019-04-18","heading":"相关参考","objectID":"/post/HttpRunner-testcase-layer-2x/:5:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（适用于 2.X）","uri":"/post/HttpRunner-testcase-layer-2x/#相关参考"},{"categories":["Growth"],"collections":null,"content":"在 2019 农历新年即将来临之际，趁着在家闲适的日子，我也对我的 2018 年进行下年终总结。这一年，我也不知不觉迈过了 30 岁，成为了大家口中的“人到中年”。想总结的内容挺多，就从工作、生活、个人成长和困惑几部分进行展开吧。 ","date":"2019-02-02","heading":"","objectID":"/post/my-2018-summary/:0:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#"},{"categories":["Growth"],"collections":null,"content":"工作 在 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%）。团队成员能在质量部层面和事业部层面收获到这些认可，我真的着实为他们高兴。不过，我仍然清晰地记得，每次替团队成员争取优秀名额最终落选时懊恼和自责的心情。希望在下一年，团队能做出更多的成绩，收获更多的认可和回报，这也是我努力为之奋斗的方向。 ","date":"2019-02-02","heading":"工作","objectID":"/post/my-2018-summary/:1:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#工作"},{"categories":["Growth"],"collections":null,"content":"生活 在生活方面，总体来说还是很幸福和满足的。 2018 年，是我当爸爸后的第一年，我也有幸见证了小坚果从 0 到 1 的成长。从学会翻身到爬行，再到走路、小跑，小坚果总是在我们不知不觉中又学会了新的技能，给我们一次又一次带来惊喜。说到爬行，小坚果的运动能力着实惊人，当时在还只会爬行的时候带他去参加了一个爬行比赛，结果毫无悬念地获得了冠军，拿到了他人生中的第一块金牌。当然，随着他逐渐长大，也越来越调皮捣蛋，在还只能勉强走路的时候，就开始翻箱倒柜，一不注意就把家里进行了乾坤大挪移，一会儿把厨房的小米袋子提到了书房，一会儿又把书架的书全翻下来扔满了客厅，让我感觉家里像是刚被洗劫过一番。除了运动，小坚果的音乐感也十分强大，一听到音乐就开始摇摆，小手也挥个不停。在发现家里有把尘封多年的吉他后，就经常拉我的手让我给他弹。当然，我也早忘了怎么弹了，但装着样子拨几下弦，他也开心的不得了。哦对了，其实小坚果最感兴趣的还是我的笔记本电脑，在他还只有半岁不到的时候，一看到我打开笔记本电脑就啥也不顾地非要往电脑前挤，然后在键盘上乱按一通，手法还有模有样，看着屏幕有变化，就特别开心，流着口水对着我笑，让我一度感觉他以后又要继承我程序员的衣钵。不知道是不是父爱泛滥，对小坚果的笑完全没有抵抗力；得益于老婆的创作能力，用微信给他做了好多表情包，我也很是嘚瑟，经常在微信群里发他的表情包。有件令我老婆很是想不通的事情，虽然我平时上班很少在家，每天陪小坚果的时间基本上只有早上半个小时，但他特别喜欢我这个爸爸，看见我的照片就”baba~baba“叫个不停，有时候刚睡醒还迷糊着也是不停地叫着”baba“。在外面逛的时候，基本上只要我抱；而每次早上我要去上班的时候，更是生离死别般哭个不停，有时让我也很是不忍。 今年与小坚果一起度过了两个重要的日子。一个是他满百天的时候，当时带着他第一次回到了我的老家，办了一个百天宴，也让我的爷爷奶奶第一次抱上了曾孙，两位老人异常高兴，一大家人久违地聚在了一起，拍了四世同堂的全家福，非常圆满。另一个就是在他满一周岁的时候，给他在深圳公租房家里办了一个生日 Party，当时我妈从重庆过来了，也来了许多朋友和邻居，很是热闹。当时在生日 Party 上，老婆让我讲几句，我酝酿了下，终究没有讲出来。其实我想说，感谢小坚果的到来，我非常幸福，也非常自豪；此生我一定会做一个好爸爸，陪伴小坚果健康快乐成长。 一口气唠叨了这么多小坚果的点点滴滴，还这么煽情，这些在我当爸爸之前都是完全不敢想象的。 从广州搬到深圳两年多以后，我也基本决定后续不会再回广州工作了。因此索性将户口从广州迁到了深圳，之前在广州空了两年多的房子也租出去了。得益于公司的福利，这两年一直都住的是公租房，因此在深圳的住房方面没有操太多的心。刚开始的时候是在龙海家园，还只是一室的，有了孩子后比较拥挤；而在今年则换到了深康村的三房，除了小区商业配套差了不少外，住房宽敞了不少，小坚果在室内也有了充分的活动空间。交通也很是方便，小区门口有地铁站，离公司四个站，早上开车去公司也只要十几分钟。而房租每个月只需交600块的物业水电费，这地段这价格，着实让不少朋友羡慕不已。 但在国人的内心，普遍都崇尚安居乐业，对拥有自己的房子也充满了信仰。我本来是对此不太在意的，觉得租一辈子房也没啥，但有了小坚果以后，不得不考虑得更多。再加上公司的福利，满足条件的情况下可以申请到10年期150万的无息房贷，在同事的劝说下，我也动了些在深圳买房的心思。不过在我初步去看了几处二手房后，内心感到异常难受。三四百万的价格，实打实的真金白银，但在深圳只能买到老旧破烂的小房子，着实很不甘心。想着当前对买房的需求还不算强烈，暂且作罢。 ","date":"2019-02-02","heading":"生活","objectID":"/post/my-2018-summary/:2:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#生活"},{"categories":["Growth"],"collections":null,"content":"个人成长 在个人成长方面，今年我仍在持续学习中，并有幸完成了一些个人突破。 首先是我从 2017 年开始做的开源项目 HttpRunner，经过一年半的时间，居然从最开始的个人业余练手项目一路迭代至今，不仅在大疆内部成为了测试技术体系的基石，在测试业界也有了一定的知名度，形成了一定的开源生态并被众多公司广泛使用。截至当前，HttpRunner 在 GitHub 上收获了一千多个 star，在 TesterHome 的开源项目列表上也排到了第一的位置，这都是我始料未及的。不过，因为工作和个人时间的关系，今年在该项目中我也存在做的不好的地方，一个是该项目在 GitHub 上的 issue 我没能及时的处理，当前已经累积有一百多个未处理的 issue，在 TesterHome 和天使用户群的好多提问我也没能完成解答；另外就是在文档方面比较滞后，好多新的功能特性都没能及时更新文档，这些在后续都是要重点进行改善的。在 2019 年初，2.0 版本也正式发布了，《HttpRunner 2.0 正式发布》，后续我也会持续地优化 HttpRunner，并希望能找到更多的朋友一起来维护该项目，让 HttpRunner 能有更好的发展。 源于 HttpRunner，今年我受邀参加了 4 场行业大会进行技术分享，包括移动互联网测试开发大会（MTSC2018）和 PyCon China 2018 这些千人以上规模的大会，并且收获了比较不错的成绩。在移动互联网测试开发大会（MTSC2018）中，在大会开幕式上我有幸收获了两个奖项，个人的年度社区贡献奖 和 HttpRunner 项目的 年度开源贡献奖；大会结束后，最终经过听众投票，我也有幸被评选为服务端专场明星讲师，在此也非常感谢大家的认可和鼓励。而在 PyCon China 2018 中，我的 topic 也有幸在 30 余个分享主题中，被推选作为主会场分享主题（总共 3 个），与 Python 海外核心开发者一起在主会场面向千余名听众进行分享；这是我第一次面向这么多的听众进行技术分享，听众还基本都是 Python 开发者，这对于我也都是全新的挑战，《PyCon China 2018 归来，感谢曾经没有怂的自己》。 在个人职位方面，今年在公司内部开始建设职级通道，所在的事业部也将所有岗位拉通了职级，包括开发、测试、产品、运营、数据等。一年有两次申请机会，在第一次申请的时候，毫无意外地没有通过，通过率低得吓人，高级工程师升架构师（或技术经理）仅有一人通过（1/6）。在第二次申请的时候，本就不敢再报希望了，但想着要充分利用好每次锻炼和磨练（自虐）的机会，仍然尽力去做了准备，没想到最终竟然成功通过了（1/6），然后就有了测试架构师的 title，在此非常感谢事业部各领导的认可。不过我也非常清楚，我当前距离我心目中架构师的title仍然有非常大的差距，我后续也会不断努力，争取早日能配得上这个title。 在个人学习方面，今年极客邦出了极客时间这个产品，我也成为了铁杆用户，前前后后差不多买了三十来门课程。之所以这么喜欢这个产品，一方面是上面的课程内容基本都面向互联网从业者，专项技术和技术管理都有，内容质量也都非常不错，很多课程我买来后没有都看，但将其当做一个资料库，在需要的时候经常能找到不错资料（不过检索功能十分欠缺，希望后续能加强）。另一方面就是课程基本上都附带音频，特别适合在开车途中使用，这样我就能很好地将每天上下班途中的固定时间利用了起来；不过，很多时候下班回家时大脑十分疲惫，基本上就听歌了，所以主要还是上班途中用的比较多。通过极客时间这个产品，一方面在知识上开了眼界，另外也被不少讲师大牛圈粉了，有了奋斗的目标和方向。特别地，在今年参加大会做分享的时候，我也有幸认识了《软件测试52讲》的作者茹炳晟，《趣谈网络协议》的作者刘超，面对面进行了更多的交流。 说来惭愧，今年有几家出版社（也包括 GitChat、慕课网、实验楼 这类在线知识付费平台）跟我联系，询问我是否有意向进行合作和出版，而我在 2017 年年初跟博文视点签的出版合同，本来还打算在小坚果出生前出版呢，现在儿子都一岁多了，至今还没交稿，确切地说，是搁置好久了。唉，时间是一方面，主要还是执行力不够。不仅是写书，今年的博客和公众号也是沉寂了大半年，整个 2018 年基本就写了十来篇。回头再看看去年年终总结时的展望，打脸打得生疼。 ","date":"2019-02-02","heading":"个人成长","objectID":"/post/my-2018-summary/:3:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#个人成长"},{"categories":["Growth"],"collections":null,"content":"个人困惑 在这一年，我也遇到了不少困惑，或者说，当前我暂时还没有想清楚的问题。 今年，我有幸收到了多个在其它公司前辈的邀请，他们都给出了非常诱惑的条件。想走技术路线，可以给到更高职级的title；想带团队，预期团队规模是我当前团队的好几倍；而薪资待遇，也会有不错的增长。说实话，这些都是非常不错的工作机会，再加上在当前公司待了两年多后，多多少少会有些不如意的地方，所以到底要不要换个环境，着实也会有些犹豫。其实这也回到了对于工作本身，我到底追求的是什么了。前面我也说了，每个人对于工作基本上都奔着两个方面，更高的经济回报和更好的个人成长，只是对于每个人的不同阶段来说，这两个方面的关注重点或权重可能会有所侧重。在经济回报方面，毫不掩饰地说，我在乎钱，期望能给家庭更好的经济基础；但在我当前阶段，对于自身的个人成长和职业发展我应该是更在乎的。还记得之前在 UC 工作的时候，在一次内部分享会上，UC浏览器总经理朱挺分享了他自身的一个观点，我也很是认同，跳个槽也许可以在月薪上涨个几千块，但如果只是冲着这个点，终究是量的提升，相比下来，个人能力和格局成长带来的经济回报才是质的飞跃。 那在职业发展方面呢，对于我所在的行业来说，主要就是技术和管理两条路线。前段时间在极客时间《重学前端》专栏中看到一篇文章，其中一段话着实让我扎心了许久。 做了管理，技术没跟上，并且还错过了最佳的学习时间，这个境遇可想而知，他们在工作中大概率只能是被动地接受需求解决问题，然后也同时焦虑着自己的未来，焦虑着自己的竞争力。 显然，这并不是我想要的状态，我个人也是希望更多地在技术线发展。但我觉得这里说的管理可能偏向于纯管理角色，而我即使是作为团队负责人，也更多地是希望成为技术 leader 的角色（关于 leader 和 boss 的区别，可参考陈皓在极客时间《左耳听风》专栏中的解释），因此并不冲突。那最终的核心点就在于，我到底想在技术这条路上走多远，以及，我要怎么去走我的技术之路。 去互联网大厂（成熟型），有更多地解决海量用户技术难题的机会，也有更多与技术大牛共事的机会，在工作内容上可以在某一细分领域上持续投入，在技术深度上可以有更好的积累，我上一家所在的 UC 基本上也算是这种类型。而在当前类型的公司（成长型），可以有更多的机会按照自己的想法从 0 去做很多事情，工作内容覆盖面比较广，得到较为全面的锻炼，个人也有更多晋升的机会。所以说，这两种选择更多的就像是围城，不能说哪种更好，关键还是在哪种环境更适合自己，或者说自己更想要哪种环境。 如今我已经 30 了，可以说前 30 年基本上都挺中规中矩的，读书、毕业、参加工作、工作日上班、跳槽换家公司继续上班，稳步提升自己的个人能力和市场价格，总体上来说还是十分安稳的状态。如果我要打算在深圳买套还算不错的商品房，可能也要像同事那样，首付两百万，月供两万多，持续二三十年，然后也成为有房贷要还有娃要养的中年群体中的一员。一场说走就走的旅行？嗯，我之前没有想过，今后估计也更不敢想。 之前在网上看到过一些自由职业者（独立软件开发者）的故事，他们可以自由安排自己的时间，挑选自己喜欢的工作地点，按照自己的节奏做自己喜欢的事情。很多人都很羡慕这样的状态，表示等自己财务自由后就去过这样的生活。但什么才算是财务自由呢？年薪百万、千万？还是资产过亿、十亿？每个人的标准并不一样。前些天 stormzhang 发了篇文章，《我，自由了！》，里面提到的一个观点我也很是认同，其实财务自由更多的取决于一个人的欲望，简单来说，如果收入能覆盖所需的支出，那么就可以算作是财务自由了。 我也开始反思为什么要在深圳，为了更好的生活？还是为了孩子更好的未来？显然，都做不到。如果说生活，在重庆老家的生活品质比我们在深圳高太多了，每次回老家感受都特别强烈，不管是住房小区环境，消费水平，还是生活节奏，都能生活得更好。特别是今年回家去表姐家的洋房转了下后，着实惊叹不已，很不错的地段带前后花园的房子当初才买成两百多万，即使是在经历了近两年的大幅涨价后也才四百多万，而四百多万在深圳可以买到什么品质的住房呢？如果说是为了孩子，论教育资源和医疗资源，深圳的资源跟重庆也没好多少，甚至可以说还不如重庆（或成都）。 那为什么非要留在深圳呢？为了更多的工作机会，为了更好的工作环境，为了安放我这颗年轻躁动可能还没想清楚自己要什么的内心，嗯，真的就更多的是为了我自己。但为了我自己，让老婆孩子在深圳过更差的生活，真的是一个好的选择么？在若干年后，我真的可以通过自己的努力，让老婆孩子在深圳也能过上跟老家同品质的生活么？说实话，我没有十足的把握。 在这一刻，我有些迷茫了。 ","date":"2019-02-02","heading":"个人困惑","objectID":"/post/my-2018-summary/:4:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#个人困惑"},{"categories":["Growth"],"collections":null,"content":"展望 本想就对自己的 2018 年进行下年终总结，结果不知不觉就写了这么多。不过，这些也的确是我 2018 年在工作方面主要投入和思考的内容，索性都记录下来，虽然会显得稚嫩且琐碎，但能给未来的自己多留点记忆也挺好的。 最后再展望下 2019 年吧。 1、找到内心的平静。当前存在的困惑和迷茫只能靠自己去寻找答案，也只有真正明确了自己内心的追求和渴望，才能获取内心的平静，做出最合适的选择。 2、持续提升个人能力和认知水平。个人能力方面，在前后端 Web 开发方面会加强系统性的知识积累，并锻炼更多的产品思维，期待能在测试开发领域做出更好的产品。 3、坚持阅读和写作。多阅读，勤思考，并通过写作进行记录，记录下自己成长的轨迹。 4、学会生活，锻炼身体，陪伴家人，工作是长跑，讲究的是可持续发展。再次引用了去年和前年的展望，希望 2019 年做得更好。 5、做好时间任务管理。明确工作任务的优先级和重要性，更好地管理个人时间；滴答清单是个不错的产品，后续会坚持使用。 ","date":"2019-02-02","heading":"展望","objectID":"/post/my-2018-summary/:5:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#展望"},{"categories":["Growth"],"collections":null,"content":"成长轨迹 《我的 2017 年终总结》 《我的 2016 年终总结》 《我的 2015 年终总结》 ","date":"2019-02-02","heading":"成长轨迹","objectID":"/post/my-2018-summary/:6:0","tags":["年终总结"],"title":"我的 2018 年终总结","uri":"/post/my-2018-summary/#成长轨迹"},{"categories":["Growth"],"collections":null,"content":"每到年底的时候，不管是个人还是团队，总是避免不了要对这一年的工作成果进行总结和汇报。而对于测试开发岗位来说，通常会面临一个共性的问题：做了这么多事情，究竟产出了多大的业务价值？ 在很长一段时间内，我对这个问题也是非常困惑。困惑的原因倒不是觉得工作内容没有价值，而是对于测试开发类的工作，通常没有明确的业务需求方，对于工作成果度量也没有统一的方式。 为什么测试开发岗位会面临这个问题呢？ 这应该和测试岗位的职责和工作内容有很大的关系。关于测试开发工程师的定义，在《Google测试之道》一书中已经有了很全面的解释，我也很是认同。测试开发工程师（SDET，Software Development Engineer in Testing）首先应该是开发角色，只是相比于业务开发工程师，他们的目标用户更多的是公司内部的测试人员（也包括其他岗位的项目组成员），而核心工作内容就是提供通用测试技术解决方案，开发实现测试工具或平台，协助测试人员更好地完成测试工作和项目交付，而效率和质量也是他们最为关注的方面。 从岗位职责和工作内容可以看出，测试开发通常不会直接参与业务交付，并且他们通常也不会隶属于具体的项目组，因此对于他们的工作到底产出了多少实际的价值收益，在上面的领导或老板看来就不是那么明确，最终他们面临价值产出度量的问题也就在所难免了。 本文就围绕测试开发价值产出度量的问题，谈下我的一些思考和建议。 ","date":"2019-01-13","heading":"","objectID":"/post/SDET-Value-Measurement/:0:0","tags":["价值度量","思考记录","大疆"],"title":"如何度量测试开发的价值产出？","uri":"/post/SDET-Value-Measurement/#"},{"categories":["Growth"],"collections":null,"content":"何为业务价值？ 我们总是在说业务价值，那业务价值究竟指的是什么？为什么同样是写代码开发系统平台，大家通常会觉得开发电商、售后平台是产出业务价值，而开发测试工具平台就不产生业务价值呢？这种想法是否正确？ 其实当我们回归商业的本质，就会得知问题的答案了。对于商业公司来说，通常是以盈利为目标的，而为了达成这个目标，就需要通过业务手段，对用户提供价值，最终获得用户的买单。从这个角度来讲，决定是否对公司产生业务价值与岗位类型无关，也与开发实现了什么系统或平台无关。例如，对于提供测试类服务的公司或项目组来说，例如听云、WeTest，开发出的测试工具平台就直接面向客户，并以此获得盈利，那么参与该类项目的测试开发工程师就直接产出了业务价值。而在绝大多数非测试服务类商业公司中，测试工具平台更多是提供一种辅助手段，帮助项目组更好更快地完成业务需求交付，而并不直接创造业务价值。当然，这个问题不仅在测试开发岗位上存在，对于某些开发岗位也是同样存在的，例如开发公司内部即时通讯工具、流程审批工具、消息网关、中间件等等。 因此，对于测试开发岗位来说，不必揪着“业务价值”不放，我们完全可以从其它角度来对工作成果产出进行度量和展现。 ","date":"2019-01-13","heading":"何为业务价值？","objectID":"/post/SDET-Value-Measurement/:1:0","tags":["价值度量","思考记录","大疆"],"title":"如何度量测试开发的价值产出？","uri":"/post/SDET-Value-Measurement/#何为业务价值"},{"categories":["Growth"],"collections":null,"content":"节省人天数？ 那要使用什么度量指标呢？ 在很多时候，大家可能会想到使用“节省人天数”这样一个指标。因为测试开发的主要职责之一就是提升测试效率，那如果能度量出在使用测试工具平台后减少了多少人力投入，那么就能很好地体现该工具平台的价值。 那么要怎么计算“节省人天数”呢？之前我们使用过的方式如下： 统计出项目的回归测试场景，以及在固定周期内的发版次数（假设为N次）； 估算出通过人工去执行这些测试场景的耗时（假设为M人天）； 统计出工具平台执行测试的耗时（通常该耗时可忽略不计）； 那么节省的人天数就为：N * M 乍一看，这个思路没啥问题，也能计算出具体的节省人天数。但在实际项目中尝试运作之后，我们发现该计算方式存在比较大的漏洞。 例如，某测试工具平台在 A 项目组投入使用后，通过计算，每月节省了人力10人天。可是，A 项目组的发版频率并没有改变，项目组人员编制也没有缩减，甚至根据招聘需求，人员编制还出现了增长的情况。那在这种情况下，通过计算得出节省的人力去哪儿了？ 对此我们并不能给出很好的回答。事实上，测试人员借助测试工具平台从之前的重复手工工作解放出来后，他们可能花了更多的时间在需求分析上，也可能花了更多的时间在测试策略设计上。这都是我们所期望的结果，但问题在于，这些内容我们并不能很好地去统计和量化。这也就导致我们统计出的“节省人天数”缺乏说服力。 而且从更宏观的层面来看，度量项目组的质量情况时，更多是会关注交付效率和线上质量（漏测率）两个维度。交付效率，可以通过“交付需求数/投入人天数”进行计算，而线上质量（漏测率），可以通过“线上bug数/测试发现总bug数”得出。可以看出，线上质量（漏测率）与“节省人天数”基本没有关系，而交付效率方面，除非项目投入人天数真的减少了（通常不大可能），那么交付效率也很难通过“节省人天数”提升。 因此，“节省人天数”并不是一个可行的度量指标。 ","date":"2019-01-13","heading":"节省人天数？","objectID":"/post/SDET-Value-Measurement/:2:0","tags":["价值度量","思考记录","大疆"],"title":"如何度量测试开发的价值产出？","uri":"/post/SDET-Value-Measurement/#节省人天数"},{"categories":["Growth"],"collections":null,"content":"建议的方案 那有没有其它更合适的度量指标呢？其实我也没法给出绝对正确的答案。 针对这个问题，我也请教了多位测试行业大佬，收获了诸多不错的建议。 其中，茹炳晟给出的一个观点给了我比较大的启发。我们可以反过来看，现在有了这些测试工具平台各个项目组可能都在用，那假如没有了这些测试工具平台会怎么样？是毫无影响？是变得有点不大方便？还是无法正常开展工作？问题越严重，说明工具平台本身的价值就更大。这也可以作为我们不断自我衡量工作成果产出价值大小的一种思路。 但要更好地进行量化，用户使用率会是一个比较不错的度量方式。 回归工具的属性，假如一个工具真的能帮助项目组带来价值，不管是效率优化还是质量提升方面，那么项目组成员肯定会更多地使用该工具；否则，项目组成员完全没有理由在这些测试工具平台上投入时间，因为使用也是有人力时间成本的。特别是在没有强制要求项目组使用的前提下，最终工具的覆盖用户范围和使用频率更能充分说明问题。这和当前各商业工具平台追求的用户数和日活数也是同样的思路。 因此，在 2019 年，我们也打算改变下思路： 1、在质量部总体层面，不再对各项目组制定自动化测试覆盖率的目标要求，对于项目组测试人员的考核方式也不再关注测试工具平台使用的情况，最终只重点关注交付效率和线上质量两大维度（统计方式同上）。 2、对于测试开发团队，测试工具平台的价值展现将更多地通过覆盖用户范围和使用频率进行展现；若要更多的提升用户范围，那么就需要更主动地去挖掘业务项目组的痛点，让开发出的工具平台能帮助更多的人（目标也不再局限于测试人员）解决实际工作中遇到的问题；而要达到比较高的使用频率（日活数），那么就势必要提升平台的可靠性，对问题反馈进行更快地响应，以及进行更多的宣传和推广。 当然，除了用户使用率（覆盖使用人数、日活数）这一类最核心的指标，我们也会关注其它的一些指标，包括：故障响应效率、平台可靠性、发现问题数、口碑评价反馈、响应需求数等。总之，这些指标都是可以明确度量和展现的，并且所有指标最终都将指向用户的实际使用情况（Adoption Rate）。 ","date":"2019-01-13","heading":"建议的方案","objectID":"/post/SDET-Value-Measurement/:3:0","tags":["价值度量","思考记录","大疆"],"title":"如何度量测试开发的价值产出？","uri":"/post/SDET-Value-Measurement/#建议的方案"},{"categories":["Growth"],"collections":null,"content":"写在最后 有时候我不禁在想，做测试开发这个岗位也真挺不容易的。我们不仅需要负责需求规划和交互设计（想清楚要做什么），然后是开发和测试（将想法实现落地），并且要花费较多的时间和精力去进行推广（获取反馈及时调整），最后还要对工作成果进行度量和展现（收获价值认可，获得更多资源），只有我们开发出的工具平台最终在各业务项目中得到了很好的应用，才能说明我们的工作成果产出了价值。 这个过程跟创业真的挺像的，我也一直都是希望我所在的测试开发团队能更多地用创业的心态来对待我们的工作，而整个经历的过程，也许就是最大的乐趣所在吧。 ","date":"2019-01-13","heading":"写在最后","objectID":"/post/SDET-Value-Measurement/:4:0","tags":["价值度量","思考记录","大疆"],"title":"如何度量测试开发的价值产出？","uri":"/post/SDET-Value-Measurement/#写在最后"},{"categories":["OpenSource"],"collections":null,"content":"在 2017 年 6 月份的时候我写了一篇博客，《接口自动化测试的最佳工程实践（ApiTestEngine）》，并同时开始了 ApiTestEngine（HttpRunner 的前身）的开发工作。转眼间一年半过去了，回顾历程不禁感慨万千。HttpRunner 从最开始的个人业余练手项目，居然一路迭代至今，不仅在大疆内部成为了测试技术体系的基石，在测试业界也有了一定的知名度，形成了一定的开源生态并被众多公司广泛使用，这都是我始料未及的。 但随着 HttpRunner 的发展，我在收获成就感的同时，亦感到巨大的压力。HttpRunner 在被广泛使用的过程中暴露出了不少缺陷，而且有些缺陷是设计理念层面的，这主要都是源于我个人对自动化测试理解的偏差。因此，在近期相当长的一段时间内，我仔细研究了当前主流自动化测试工具，更多的从产品的角度，学习它们的设计理念，并回归测试的本质，对 HttpRunner 的概念重新进行了梳理。 难以避免地，HttpRunner 面临着一些与之前版本兼容的问题。对此我也纠结了许久，到底要不要保持兼容性。如果不兼容，那么对于老用户来说可能会造成一定的升级成本；但如果保持兼容，那么就相当于继续保留之前错误的设计理念，对后续的推广和迭代也会造成沉重的负担。最终，我还是决定告别过去，给 HttpRunner 一个新的开始。 经过两个月的迭代开发，HttpRunner 2.0 版本的核心功能已开发完毕，并且在大疆内部数十个项目中都已投入使用（实践证明，升级也并没有多么痛苦）。趁着 2019 开年之际，HttpRunner 2.0 正式在 PyPI 上发布了。从版本号可以看出，这会是一个全新的版本，本文就围绕 HttpRunner 2.0 的功能实现和开源项目管理两方面进行下介绍。 ","date":"2019-01-01","heading":"","objectID":"/post/httprunner-v2-release/:0:0","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#"},{"categories":["OpenSource"],"collections":null,"content":"功能实现 在 2.0 版本中，功能实现方面变化最大的有两部分，测试用例的组织描述方式，以及 HttpRunner 本身的模块化拆分。当时也是为了完成这两部分的改造，基本上对 HttpRunner 80% 以上的代码进行了重构。除了这两大部分的改造，2.0 版本对于测试报告展现、性能测试支持、参数传参机制等一系列功能特性都进行了较大的优化和提升。 本文就只针对测试用例组织调整和模块化拆分的变化进行下介绍，其它功能特性后续会在使用说明文档中进行详细描述。 ","date":"2019-01-01","heading":"功能实现","objectID":"/post/httprunner-v2-release/:1:0","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#功能实现"},{"categories":["OpenSource"],"collections":null,"content":"测试用例组织调整 之所以要对测试用例的组织描述方式进行改造，是因为 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、环境变量、数据驱动、测试场景、测试用例集 这些概念及其相互之间的关系也就清晰了。关于更具体的内容本文不再展开，后续会单独写文档并结合示例进行详细的讲解。 ","date":"2019-01-01","heading":"测试用例组织调整","objectID":"/post/httprunner-v2-release/:1:1","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#测试用例组织调整"},{"categories":["OpenSource"],"collections":null,"content":"模块化拆分（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 个模块组装在一起，就像一条流水线（Pipeline）一样，各模块分工协作各司其职，最终完成了整个测试流程。 基于这样的模块化拆分，HttpRunner 极大地避免了代码臃肿的问题，每个模块都专注于解决具体的问题，不仅可测试性得到了保障，遇到问题时排查起来也方便了很多。同时，因为每个模块都可以独立运行，在基于 HttpRunner 做二次开发时也十分方便，减少了很多重复开发工作量。 ","date":"2019-01-01","heading":"模块化拆分（Pipline）","objectID":"/post/httprunner-v2-release/:1:2","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#模块化拆分pipline"},{"categories":["OpenSource"],"collections":null,"content":"开源项目管理 除了功能实现方面的调整，为了 HttpRunner 能有更长远的发展，我也开始思考如何借助社区的力量，吸引更多的人加入进来。特别地，近期在学习 ASF（Apache Software Foundation）如何运作开源项目时，也对 Community Over Code 理念颇为赞同。 当然，HttpRunner 现在仍然是一个很小的项目，不管是产品设计还是代码实现都还很稚嫩。但我也不希望它只是一个个人自嗨的项目，因此从 2.0 版本开始，我希望能尽可能地将项目管理规范化，并寻找更多志同道合的人加入进来共同完善它。 开源项目管理是一个很大的话题，当前我也还处于初学者的状态，因此本文就不再进行展开，只介绍下 HttpRunner 在 2.0 版本中将改进的几个方面。 ","date":"2019-01-01","heading":"开源项目管理","objectID":"/post/httprunner-v2-release/:2:0","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#开源项目管理"},{"categories":["OpenSource"],"collections":null,"content":"logo 作为一个产品，不仅要有个好名字，也要有个好的 logo。这个“好”的评价标准可能因人而异，但它应该是唯一的，能与产品本身定位相吻合的。 之前 HttpRunner 也有个 logo，但说来惭愧，那个 logo 是在网上找的，可能存在侵权的问题是一方面，logo 展示的含义与产品本身也没有太多的关联。 因此，借着 2.0 版本发布之际，我自己用 Keynote 画了一个。 个人的美工水平实在有限，让大家见笑了。 对于 logo 设计的解释，主要有如下三点： 中间是个拼图（puzzle pieces），形似 H 字母，恰好是 HttpRunner 的首字母 拼图的寓意，对应的也是 HttpRunner 的设计理念；HttpRunner 本身作为一个基础框架，可以组装形成各种类型的测试平台，而在 HttpRunner 内部，也是充分解耦的各个模块组装在一起形成的 最后从实际的展示效果来看，个人感觉看着还是比较舒服的，在 HttpRunner 天使用户群 里给大家看了下，普遍反馈也都不错 ","date":"2019-01-01","heading":"logo","objectID":"/post/httprunner-v2-release/:2:1","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#logo"},{"categories":["OpenSource"],"collections":null,"content":"版本号机制 作为一个开源的基础框架，版本号是至关重要的。但在之前，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）作为延伸。 ","date":"2019-01-01","heading":"版本号机制","objectID":"/post/httprunner-v2-release/:2:2","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#版本号机制"},{"categories":["OpenSource"],"collections":null,"content":"HREPs 在今年的一些大会上，我分享 HttpRunner 的开发设计思路时提到了 博客驱动开发，主要思路就是在开发重要的功能特性之前，不是直接开始写代码，而是先写一篇博客详细介绍该功能的需求背景、目标达成的效果、以及设计思路。通过这种方式，一方面可以帮助自己真正地想清楚要做的事情，同时也可以通过开源社区的反馈来从更全面的角度审视自己的想法，继而纠正可能存在的偏差，或弥补思考的不足。 直到我后来更深入地了解到了 PEPs(Python Enhancement Proposals)，以及类似的 IPEPs(IPython Enhancement Proposals)，我才知道原来我曾经使用过的 博客驱动开发 并不是一个新方法，而是已经被广泛使用且行之有效的开发方式。 因此，从 2.0 版本开始，在 HttpRunner 的开发方面我想继续沿用这种方式，并且将其固化为一种机制。形式方面，会借鉴 PEPs 的方式，新增 HREPs(HttpRunner Enhancement Proposals)；关于 HREPs 的分类和运作机制，后面我再具体进行梳理。 ","date":"2019-01-01","heading":"HREPs","objectID":"/post/httprunner-v2-release/:2:3","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#hreps"},{"categories":["OpenSource"],"collections":null,"content":"License 最后再说下 License 方面。 HttpRunner 最开始选择的是 MIT 开源协议，从 2.0 版本开始，将切换为 Apache-2.0 协议。 如果熟悉这两个 License 的具体含义，应该清楚这两个协议对于用户来说都是十分友好的，不管是个人或商业使用，还是基于 HttpRunner 的二次开发，开源或闭源，都是没有任何限制的，因此协议切换对于大家来说没有任何影响。 ","date":"2019-01-01","heading":"License","objectID":"/post/httprunner-v2-release/:2:4","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#license"},{"categories":["OpenSource"],"collections":null,"content":"总结 以上，便是 HttpRunner 2.0 发布将带来的主要变化。 截止当前，HttpRunner 在 GitHub 上已经收获了近一千个star，在 TesterHome 的开源项目列表中也排到了第二名的位置，在此十分感谢大家的支持和认可。 希望 HttpRunner 2.0 会是一个新的开始，朝着更高的目标迈进。 ","date":"2019-01-01","heading":"总结","objectID":"/post/httprunner-v2-release/:3:0","tags":["HttpRunner"],"title":"HttpRunner 2.0 正式发布","uri":"/post/httprunner-v2-release/#总结"},{"categories":["Growth"],"collections":null,"content":"今天有幸作为 PyCon China 2018 的分享嘉宾，在主会场面向近千名 Python 开发工程师做了一场关于自动化测试框架方面的分享。虽然之前多少有些忐忑和不自信，但终究挺过来了，最终现场效果也还不错，总算松了口气，个人也算是完成了一次自我突破。这会儿在返回深圳的航班上，借着这段空闲时间进行下总结和记录。 本件事情的起源挺有意思的，下面重点说下。 大概在两三个月前，无意中在微信公众号中看到了 PyCon China 2018 的主题征集，在里面看到了测试的字眼。因为近些年来 Python 一直都是我的主要工作语言，也是我个人最喜欢的编程语言，因此我也产生了些许兴趣，加了文章中主办方联系人（辛庆姐）的微信，主要是想询问下大会中会有哪些跟测试相关的主题分享。 在简单聊了下后，得知当前还没有测试相关的主题，对方也向我咨询是否有啥好的建议，同时也欢迎我参加大会进行下测试方面的分享。当时我也表达了我的疑虑，毕竟大会的参会者基本都是 Python 开发者，感觉测试相关的 topic 不一定受欢迎。然后就没继续聊了，这事儿我也就忘了。结果大概过了近一个月后，辛庆姐又跟我联系，还是希望我能做一场测试方面的分享，毕竟 Python 的应用领域这么广泛，当前在测试领域也有较多的应用。我想了下，那就参加下深圳分场的大会吧，多认识些珠三角地区的 Python 大佬也好。当然，我也存在点私心，就是想借这个机会再推广下我的开源项目 HttpRunner，要是能得到些指点就更好了，毕竟参会者基本都是 Python 开发者。 结果令我万万没想到的是，几天后的一个早晨，我睡醒后看到辛庆姐的信息，说是希望我能到北京主会场千人峰会进行分享。我顿时懵逼了，觉得很不可思议。我又再次确认了下，大会总共就一天，主会场总共就 4 个主题，而其它三位嘉宾中一位是洪教授，另外两位是国外嘉宾，他们都是 Python 领域非常资深的前辈，编程年限都快跟我岁数差不多了。我还是觉得难以置信，测试主题居然也可以排进主会场（不是对测试不自信，毕竟是开发者大会）？而且在之前的主题征集中，基本都是围绕 Python 核心语言特性、当前火热的机器学习、大数据方面，测试只是放在其它类别中，所以在被告知安排后的确觉得非常诧异。再三询问才得知，今年大会也是在做改革，想做 Pythoneer 想听的大会，而且组委会中也有成员之前看过我的博客，所以比较支持我（非常感谢）。当然，面对这前所未有的自我挑战的机会，虽然心里没底儿，我也挺想尝试的，就当作一次突破自己的机会好了。不过我也跟组委会说希望他们能再考虑下，因为我还是担心最终不能达成好的效果。再后来，组委会又进行了一轮投票，最终超过一半的成员同意将我的 topic 安排在主会场，这件事就这么定下来了。在此我也非常感谢组委会的认可和信任。 后来的事情就没啥特别的了，无非就是鼓足勇气，尽量克服内心的忐忑和不自信，然后尽量做好准备硬上了。最终结果证明，面对近千名听众进行分享也没那么可怕，和上一次 MTSC2018 服务端专场中面对三四百名听众相比感觉也都差不多，主要的差异还是在上台前的自我暗示。很庆幸，当初我克服了自身的恐惧心理将这件事答应下来了，我才能借助这次机会完成了一次自我突破，后面等我儿子长大了也多了件向他吹牛逼的素材。 说到这里，可能有人希望我能分享下如何克服在大会进行主题分享的恐惧心理。 其实对于这一点，我觉得也没有太多的秘诀，主要还是要多讲。如果还没有过技术分享的经历，不妨从公司的组内分享开始，勇敢地跨出第一步，然后不断地逼迫自己迎接更大的挑战，在部门层面、公司层面、行业沙龙活动等等，机会是非常多的。在这个过程中，积极地收集听众反馈并进行改进，多做几次之后，肯定大不一样。 另外，在分享准备阶段，推荐给大家两个比较有可操作性的做法。 首先是分享的主题思路一定要清晰和明确，各部分内容的内在衔接尽量做到自然和不生硬，做到这一点后，听众会更便于掌握主题的思路，分享人也能讲得更流畅，避免因为生硬的内容切换造成忘词儿的尴尬情况。同时，分享的内容一定要都是分享者充分理解的内容，避免在网上抄一些自己都不清楚真实含义的概念和解释。 另一个很重要的点就是要充分重视开场白，如果心里没底儿，建议将开场白逐字写出来并反复进行斟酌，最终修改形成一份让自己满意的开场白，并多次自我演练直至熟练。之所以这么强调开场白，除了是要跟听众尽量留下好的第一印象外，还因为在开场阶段是演讲者最容易紧张的阶段，如果开场讲得不流畅，很容易造成演讲者变得更加紧张，影响后续一连串的表现，甚至会出现大脑一片空白，完全讲不下去的尴尬场面（之前我就有过这样的经历）。而如果开场经过精心准备并且有了一个比较好的表现后，就可以很好地建立自信，并且在这个过程中也熟悉了面向听众的感觉，后续的演讲也就不会有什么问题了。 当然，这里只是列举了我个人觉得比较重要的两个点，对于其他人不一定适用。提升演讲和分享能力是一个持久的过程，我当前也是在不断摸索和提升的过程中。希望大家也能在留言中分享下这方面的经验，大家互相学习，共同进步。 最后，再说件比较尴尬的事情。 在会后，有几家出版社（也包括 GitChat 这类在线知识付费平台）的编辑跟我联系，询问我是否有意向进行合作和出版。我当然是非常难为情啦，去年年初跟博文视点签的出版合同，本来还打算在小坚果出生前出版呢，现在儿子都快满一岁了，至今还没交稿呢（确切地说，是搁置好久了）。唉，时间是一方面，主要还是执行力不够啊。所以现在对于出书的作者我是格外佩服的，内容好坏暂且不说，能坚持下来就真的很不容易了。如今我也有幸认识了不少畅销书的作者了，向他们看齐，加油吧。 ","date":"2018-10-14","heading":"","objectID":"/post/pycon-china-2018/:0:0","tags":["演讲"],"title":"PyCon China 2018 归来，感谢曾经没有怂的自己","uri":"/post/pycon-china-2018/#"},{"categories":["Growth"],"collections":null,"content":"生活在这个知识爆炸的时代，我们是幸福的，但有时也是不幸的。 相比于早些年，我们有了太多的渠道和途径接收新的知识和信息，只要你想学，总能在网上找到大量相关的教程和学习资料。但与此同时，我们往往也会陷入更多的焦虑当中。不信你可以看下在你微信中收藏的技术文章，在上一次满减打折时囤的技术书籍，以及在你网盘中攒的以 GB 为单位的学习资料。 这个问题很普遍，仿佛也很无奈。要学的东西太多太多，但时间有限，我们发现根本学不完，为了克服焦虑，我们可能本能地就是搜藏更多的学习资料，继而更加焦虑，从而陷入了恶性循环。 有时候我甚至挺羡慕古代的人们，没有那么多选择，一本九阴真经读透了就能打遍天下无敌手。要是当时的信息也那么发达，同时给他们 100 本秘籍宝典，估计他们也会陷入选择困难症，最终也很难练出神功吧。 回到现状，当前我们普遍都是碎片化学习，微信公众号、知乎专栏、网络公开课都在学都在看，但很多都没有形成体系，多一榔头西一棒槌，最后的学习成果也可想而知了。 其实这个问题要克服也很简单，那就是针对你要学习的领域方向，找一份体系化的学习资料，然后啥也别想，坚持学下去就好了。这里的学习资料，可以是权威的经典书籍，也可以是行业领域专业人士的付费课程。具体形式不重要，重要的是一定要形成知识体系。 对于我个人而言，我推荐【极客时间】这款产品，这也不是我第一次跟大家推荐了。【极客时间】主要聚焦在互联网技术领域，里面的专栏内容的确非常不错，虽然是收费的订阅专栏，但知识体系很系统，比自己零散的学习效果好很多。 现在我已经是【极客时间】的重度用户，在上面累计订阅了 12 门付费课程，每天上下班的路上都会听这上面的课程（是的，包含音频这一点很是方便）。如果大家感兴趣，我后面可以再详细分享下我个人是如何利用通勤时间进行学习的。 ","date":"2018-06-27","heading":"","objectID":"/post/recommend-geektime/:0:0","tags":["思考记录"],"title":"知识爆炸时代，技术人该如何克服焦虑？","uri":"/post/recommend-geektime/#"},{"categories":["Testing"],"collections":null,"content":"背景 对于电商类型和内容服务类型的网站，经常会出现因为配置错误造成页面链接无法访问的情况（404）。 显然，要确保网站中的所有链接都具有可访问性，通过人工进行检测肯定是不现实的，常用的做法是使用爬虫技术定期对网站进行资源爬取，及时发现访问异常的链接。 对于网络爬虫，当前市面上已经存在大量的开源项目和技术讨论的文章。不过，感觉大家普遍都将焦点集中在爬取效率方面，例如当前就存在大量讨论不同并发机制哪个效率更高的文章，而在爬虫的其它特性方面探讨的不多。 个人认为，爬虫的核心特性除了快，还应该包括全和稳，并且从重要性的排序来看，全、稳、快应该是从高到低的。 全排在第一位，是因为这是爬虫的基本功能，若爬取的页面不全，就会出现信息遗漏的情况，这种情况肯定是不允许的；而稳排在第二位，是因为爬虫通常都是需要长期稳定运行的，若因为策略处理不当造成爬虫运行过程中偶尔无法正常访问页面，肯定也是无法接受的；最后才是快，我们通常需要爬取的页面链接会非常多，因此效率就很关键，但这也必须建立在全和稳的基础上。 当然，爬虫本身是一个很深的技术领域，我接触的也只是皮毛。本文只针对使用爬虫技术实现 Web 页面资源可用性检测的实际场景，详细剖析下其中涉及到的几个技术点，重点解决如下几个问题： 全：如何才能爬取网站所有的页面链接？特别是当前许多网站的页面内容都是要靠前端渲染生成的，爬虫要如何支持这种情况？ 稳：很多网站都有访问频率限制，若爬虫策略处理不当，就常出现 403 和 503 的问题，该种问题要怎么解决？ 快：如何在保障爬虫功能正常的前提下，尽可能地提升爬虫效率？ ","date":"2018-05-28","heading":"背景","objectID":"/post/requests-crawler/:1:0","tags":["爬虫","requests","requests-crawler","requests-html"],"title":"使用爬虫技术实现 Web 页面资源可用性检测","uri":"/post/requests-crawler/#背景"},{"categories":["Testing"],"collections":null,"content":"爬虫实现前端页面渲染 在早些年，基本上绝大多数网站都是通过后端渲染的，即在服务器端组装形成完整的 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 功能。 from requests_html import HTMLSession session = HTMLSession() r = session.get('http://python-requests.org') r.html.render() 在执行 render() 之后，返回的就是经过渲染后的页面内容。 ","date":"2018-05-28","heading":"爬虫实现前端页面渲染","objectID":"/post/requests-crawler/:2:0","tags":["爬虫","requests","requests-crawler","requests-html"],"title":"使用爬虫技术实现 Web 页面资源可用性检测","uri":"/post/requests-crawler/#爬虫实现前端页面渲染"},{"categories":["Testing"],"collections":null,"content":"爬虫实现访问频率控制 为了防止流量攻击，很多网站都有访问频率限制，即限制单个 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 的控制，那么就可以使用如下方式（只截取关键片段）： 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 \u003e= self.requests_limit: runtime_secs = time.time() - start_timer if runtime_secs \u003c 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 ","date":"2018-05-28","heading":"爬虫实现访问频率控制","objectID":"/post/requests-crawler/:3:0","tags":["爬虫","requests","requests-crawler","requests-html"],"title":"使用爬虫技术实现 Web 页面资源可用性检测","uri":"/post/requests-crawler/#爬虫实现访问频率控制"},{"categories":["Testing"],"collections":null,"content":"提升爬虫效率 对于提升爬虫效率这部分，当前已经有大量的讨论了，重点都是集中在不同的并发机制上面，包括多进程、多线程、asyncio等。 不过，他们的并发测试结果对于本文中讨论的爬虫场景并不适用。因为在本文的爬虫场景中，实现前端页面渲染是最核心的一项功能特性，而要实现前端页面渲染，底层都是需要使用浏览器内核的，相当于每个 worker 在运行时都会跑一个 Chromium 实例。 众所周知，Chromium 对于 CPU 和内存的开销都是比较大的，因此为了避免机器资源出现瓶颈，使用多进程机制（multiprocessing）充分调用多处理器的硬件资源无疑是最佳的选择。 另一个需要注意也是比较被大家忽略的点，就是在页面链接的请求方法上。 请求页面链接，不都是使用 GET 方法么？ 的确，使用 GET 请求肯定是可行的，但问题在于，GET 请求时会加载页面中的所有资源信息，这本身会是比较耗时的，特别是遇到链接为比较大的图片或者附件的时候。这无疑会耗费很多无谓的时间，毕竟我们的目的只是为了检测链接资源是否可访问而已。 比较好的的做法是对网站的链接进行分类： 资源型链接，包括图片、CSS、JS、文件、视频、附件等，这类链接只需检测可访问性； 外站链接，这类链接只需检测该链接本身的可访问性，无需进一步检测该链接加载后页面中包含的链接； 本站页面链接，这类链接除了需要检测该链接本身的可访问性，还需要进一步检测该链接加载后页面中包含的链接的可访问性； 在如上分类中，除了第三类是必须要使用 GET 方法获取页面并加载完整内容（render），前两类完全可以使用 HEAD 方法进行代替。一方面，HEAD 方法只会获取状态码和 headers 而不获取 body，比 GET 方法高效很多；另一方面，前两类链接也无需进行页面渲染，省去了调用 Chromium 进行解析的步骤，执行效率的提高也会非常明显。 ","date":"2018-05-28","heading":"提升爬虫效率","objectID":"/post/requests-crawler/:4:0","tags":["爬虫","requests","requests-crawler","requests-html"],"title":"使用爬虫技术实现 Web 页面资源可用性检测","uri":"/post/requests-crawler/#提升爬虫效率"},{"categories":["Testing"],"collections":null,"content":"总结 本文针对如何使用爬虫技术实现 Web 页面资源可用性检测进行了讲解，重点围绕爬虫如何实现 全、稳、快 三个核心特性进行了展开。对于爬虫技术的更多内容，后续有机会我们再进一步进行探讨。 ","date":"2018-05-28","heading":"总结","objectID":"/post/requests-crawler/:5:0","tags":["爬虫","requests","requests-crawler","requests-html"],"title":"使用爬虫技术实现 Web 页面资源可用性检测","uri":"/post/requests-crawler/#总结"},{"categories":["OpenSource"],"collections":null,"content":"背景 在自动化测试中，通常在测试开始前需要做一些预处理操作，以及在测试结束后做一些清理性的工作。 例如，测试使用手机号注册账号的接口： 测试开始前需要确保该手机号未进行过注册，常用的做法是先在数据库中删除该手机号相关的账号数据（若存在）； 测试结束后，为了减少对测试环境的影响，常用的做法是在数据库中将本次测试产生的相关数据删除掉。 显然，在自动化测试中的这类预处理操作和清理性工作，由人工来做肯定是不合适的，我们最好的方式还是在测试脚本中进行实现，也就是我们常说的 hook 机制。 hook 机制的概念很简单，在各个主流的测试工具和测试框架中也很常见。 例如 Python 的 unittest 框架，常用的就有如下几种 hook 函数。 setUp：在每个 test 运行前执行 tearDown：在每个 test 运行后执行 setUpClass：在整个用例集运行前执行 tearDownClass：在整个用例集运行后执行 概括地讲，就是针对自动化测试用例，要在单个测试用例和整个测试用例集的前后实现 hook 函数。 ","date":"2018-05-12","heading":"背景","objectID":"/post/httprunner-hook/:1:0","tags":["HttpRunner","hook"],"title":"HttpRunner 实现 hook 机制","uri":"/post/httprunner-hook/#背景"},{"categories":["OpenSource"],"collections":null,"content":"描述方式设想 在 HttpRunner 的 YAML/JSON 测试用例文件中，本身就具有分层的思想，用例集层面的配置在 config 中，用例层面的配置在 test 中；同时，在 YAML/JSON 中也实现了比较方便的函数调用机制，$func($a, $b)。 因此，我们可以新增两个关键字：setup_hooks 和 teardown_hooks。类似于 variables 和 parameters 关键字，根据关键字放置的位置来决定是用例集层面还是单个用例层面。 根据设想，我们就可以采用如下形式来描述 hook 机制。 - 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 中。 def hook_print(msg): print(msg) ","date":"2018-05-12","heading":"描述方式设想","objectID":"/post/httprunner-hook/:2:0","tags":["HttpRunner","hook"],"title":"HttpRunner 实现 hook 机制","uri":"/post/httprunner-hook/#描述方式设想"},{"categories":["OpenSource"],"collections":null,"content":"基本实现方式 基于 hook 机制的简单概念，要在 HttpRunner 中实现类似功能也就很容易了。 在 HttpRunner 中，负责测试执行的类为 httprunner/runner.py 中的 Runner。因此，要实现用例集层面的 hook 机制，只需要将用例集的 setup_hooks 放置到 __init__ 中，将 teardown_hooks 放置到 __del__ 中。 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 之后。 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，因此直接调用就可以了。 def do_hook_actions(self, actions): for action in actions: logger.log_debug(\"call hook: {}\".format(action)) self.context.eval_content(action) 通过以上方式，我们就在 HttpRunner 中实现了用例集和单个用例层面的 hook 机制。 还是上面的测试用例，我们执行的效果如下所示。 $ 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 需求。 ","date":"2018-05-12","heading":"基本实现方式","objectID":"/post/httprunner-hook/:3:0","tags":["HttpRunner","hook"],"title":"HttpRunner 实现 hook 机制","uri":"/post/httprunner-hook/#基本实现方式"},{"categories":["OpenSource"],"collections":null,"content":"进一步优化 以上实现已经可以满足大多数场景的测试需求了，不过还有两种场景无法满足： 需要对请求的 request 内容进行预处理，例如，根据请求方法和请求的 Content-Type 来对请求的 data 进行加工处理； 需要根据响应结果来进行不同的后续处理，例如，根据接口响应的状态码来进行不同时间的延迟等待。 在之前的实现方式中，我们无法实现上述两个场景，是因为我们无法将请求的 request 内容和响应的结果传给 hook 函数。 问题明确了，要进行进一步优化也就容易了。 因为我们在 hook 函数（类似于$func($a, $b)）中，是可以传入变量的，而变量都是存在于当前测试用例的上下文（context）中的，那么我们只要将 request 内容和请求响应分别作为变量绑定到当前测试用例的上下文即可。 具体地，我们可以约定两个变量，$request和$response，分别对应测试用例的请求内容（request）和响应实例（requests.Response）。 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了。 - 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 函数如下所示： 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 进行修改后，后续在实际请求时也同样会发生改变，这对于我们需要对请求参数进行预处理时尤其有用。 ","date":"2018-05-12","heading":"进一步优化","objectID":"/post/httprunner-hook/:4:0","tags":["HttpRunner","hook"],"title":"HttpRunner 实现 hook 机制","uri":"/post/httprunner-hook/#进一步优化"},{"categories":["OpenSource"],"collections":null,"content":"更多内容 中文使用说明文档：https://httprunner.com/docs/user-guide/request-hook/ 代码实现：GitHub commit ","date":"2018-05-12","heading":"更多内容","objectID":"/post/httprunner-hook/:5:0","tags":["HttpRunner","hook"],"title":"HttpRunner 实现 hook 机制","uri":"/post/httprunner-hook/#更多内容"},{"categories":["OpenSource"],"collections":null,"content":"在《HttpRunner 实现参数化数据驱动机制》一文中，我们实现了参数化数据驱动的需求，并阐述了其设计思路的演变历程和开发实现涉及的核心要素。 ","date":"2018-03-25","heading":"","objectID":"/post/httprunner-data-driven-refactor/:0:0","tags":["HttpRunner"],"title":"HttpRunner 再议参数化数据驱动机制","uri":"/post/httprunner-data-driven-refactor/#"},{"categories":["OpenSource"],"collections":null,"content":"问题及思考 经过一段时间的实际应用后，虽然参数化数据驱动的功能可以正常使用，但终究感觉不够优雅。 概括下来，主要有如下 4 个方面。 1、调用方式不够自然，描述方式比较繁琐。 - 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 测试用例中描述参数列表，如下所示，那就简单得多了。 - 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 中： 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 测试用例中，再使用 ${} 的语法来调用函数，并将函数返回的参数列表传给需要参数化的变量。 - config: parameters: - username-password: ${get_account(10)} 实现了这一特性后，要再兼容从 CSV 文件数据源中读取参数列表的方式也很简单了。我们只需要在 HttpRunner 中内置一个解析 CSV 文件的 parameterize 函数（也可以简写为 P 函数），然后就可以在 YAML/JSON 中通过函数调用的方式引用 CSV 文件了。如下例中的 user_id 所示。 - 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 中。 ","date":"2018-03-25","heading":"问题及思考","objectID":"/post/httprunner-data-driven-refactor/:1:0","tags":["HttpRunner"],"title":"HttpRunner 再议参数化数据驱动机制","uri":"/post/httprunner-data-driven-refactor/#问题及思考"},{"categories":["OpenSource"],"collections":null,"content":"新的实现 想法明确了，改造实现也就比较简单了。 从版本 1.1.0 开始，HttpRunner 便支持了上述新的数据驱动方式。详细的使用方法，可参考如下使用说明文档： https://httprunner.com/docs/user-guide/data-driven/ 至此，HttpRunner 的数据驱动机制就比较完善和稳定了，应该可以解决绝大多数数据驱动场景的需求。 ","date":"2018-03-25","heading":"新的实现","objectID":"/post/httprunner-data-driven-refactor/:2:0","tags":["HttpRunner"],"title":"HttpRunner 再议参数化数据驱动机制","uri":"/post/httprunner-data-driven-refactor/#新的实现"},{"categories":["OpenSource"],"collections":null,"content":"遗留问题 不过，还有一类场景暂时没有实现支持，即需要根据先前接口返回结果来对后续接口进行数据驱动的情况。 以如下场景为例： 加载用户列表，获取当前用户列表中的所有用户； 依次对每一个用户进行点赞或者发送消息的操作。 这和前面的第三条有点类似，都需要先动态获取参数列表，然后再使用获取得到的参数列表进行数据驱动。但也存在较大的差异，即获取用户列表的操作也是测试场景的一部分，并且通常因为需要共享 session 和 cookies，因此不能将第一步的请求放置到 debugtalk.py 中。 之前的一个想法是，在第一个接口中，将结果返回的用户列表提取（extract）出来保存至变量（user_list），然后在后续需要做数据驱动的接口中，在 parameters 中引用前面提取出的用户列表（$user_list）；若有需要，还可以自定义函数（parse_users），将前面提取出来的用户列表转换至框架支持的格式。 - 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。 ","date":"2018-03-25","heading":"遗留问题","objectID":"/post/httprunner-data-driven-refactor/:3:0","tags":["HttpRunner"],"title":"HttpRunner 再议参数化数据驱动机制","uri":"/post/httprunner-data-driven-refactor/#遗留问题"},{"categories":["Growth"],"collections":null,"content":"提到测试开发这个岗位，可能有的人会有些陌生，都听说过软件测试和软件开发，但测试开发又是干什么的呢？ 说到这里，我又想起之前在阿里移动事业群的一件趣事儿。当时也是做测试开发岗位，我所在的组在公司内部有一个响当当的名号，“牲畜组”，生产力促进组嘛。有的同事更直接，亲切地叫我们组为”畜牲组“，促进生产力组的简称。 可以看到，不管是”牲畜“还是”畜牲“，测试开发这个岗位的核心都是提升团队生产力，也就是工作效率。更具体地，在大多数公司内部，测试开发岗位的职责就是提升业务测试人员的测试效率。 接触过软件测试的同学都知道，在项目版本迭代的过程中，业务测试人员需要进行大量的回归测试，重复工作量较大，在短暂的时间内也很难保证较大的测试覆盖率。这就需要测试开发人员来辅助开发相应的工具和平台，常见的包括实现接口测试自动化、UI自动化、性能专项、持续集成、线上监控等，将测试人员从重复性的工作解放出来，从而能有更多的时间精力投入到探索性测试当中去。 从这个层面上来讲，当前各个互联网公司的做法都差不多，只是不同公司可能会因为业务类型差异而有所侧重而已。 但回归生产力的本质，我们会发现，测试效率只能算是团队生产力的一部分。这就好比一个木桶，测试效率只是其中的一块木板，而研发效率、运维效率、项目管理等木板，同样制约着项目团队的整体工作效率。 可能有人会说，这些都不是测试开发的工作范畴啊！ 事实上，很多公司的测试开发的确是没法触及到测试以外的工作内容的。甚至很多时候，整个测试团队都是直接向研发团队进行工作汇报，即使在提升测试效率的本职工作中话语权也是少之又少，更别谈测试以外的了。 那么，在大疆互联网事业部做测试开发又有什么不同呢？ 得益于大疆“激极尽致，求真品诚”的企业文化，岗位并没有那么严格的界限划分。如果你发现一件事确实有价值，但当前却没有人去做，那么即使这不在你的工作范畴之内，你也可以主动站出来挑起这块儿的担子。 对于大疆的测试开发岗位同样如此，岗位性质决定了我们会比其它任何岗位都更关注团队的生产力和工作效率，那么不管是研发、运维、测试、运营还是项目管理，假如其中任何一个环节存在效率低下的问题，我们都可以申请立项，通过开发工具或平台来解决团队的痛点。当然，可能某些事情并不是测试开发岗位的同学就能独自完成的，那也没有关系，只要提出的问题确实具有业务价值，那么我们也可以申请到其它岗位的同学来协作一起完成。可能对于某些公司来说不可想象，但这在大疆确实就是切实可行的。 另一方面，大疆发展极其迅速，触及的领域也越来越广。这对我们测试开发来说，既是机遇，也是挑战。在这种环境下，我们有机会充分发挥主观能动性，去挖掘并解决团队中实际存在的痛点。分析需求、设计方案、技术选型、编码实现、收集反馈、迭代优化，这么一个流程下来，产出的工具或平台就如同自己精心打造的产品，成就感和按部就班地搬砖是完全不一样的。也许，我们测试开发岗位才是最接近”全栈“的工程师？ 除此之外，在大疆的测试开发工程师还兼具着更多的职责。例如，整个互联网事业部的所有系统，遇到新品发布、技术升级改造、系统架构重构等重大事件时，压力测试都是由我们测试开发组来主导完成的。在质量部内部，我们测试开发组还会兼顾对业务测试同学进行测试技术和编程语言的培训，以及协助业务测试同学解决一些技术难题等。在这个过程中，我们测试开发本身的技术视野和解决问题的能力也得到了极大的增强。 最后，如果你对我们的岗位感兴趣，欢迎加入我们！ ","date":"2018-03-06","heading":"","objectID":"/post/test-dev-in-dji/:0:0","tags":["内推","大疆"],"title":"在大疆做测试开发是一种什么体验？","uri":"/post/test-dev-in-dji/#"},{"categories":["Growth"],"collections":null,"content":"广告时间 近期，大疆创新开启新的一轮招聘啦，深圳总部、北京研发中心、上海分公司均有大量岗位招聘需求。 详细的招聘需求和日程安排请见：【内推】大疆创新春季招聘开启啦（深圳+北京） 如需找我内推，请发送简历至我的个人邮箱: mail@debugtalk.com ","date":"2018-03-06","heading":"广告时间","objectID":"/post/test-dev-in-dji/:1:0","tags":["内推","大疆"],"title":"在大疆做测试开发是一种什么体验？","uri":"/post/test-dev-in-dji/#广告时间"},{"categories":["OpenSource"],"collections":null,"content":" 从 1.1.0 版本开始，数据驱动机制进行了较大的优化和调整。 请参考：《HttpRunner 再议参数化数据驱动机制》 ","date":"2018-02-16","heading":"","objectID":"/post/httprunner-data-driven/:0:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#"},{"categories":["OpenSource"],"collections":null,"content":"背景 在自动化测试中，经常会遇到如下场景： 1、测试搜索功能，只有一个搜索输入框，但有10种不同类型的搜索关键字； 2、测试账号登录功能，需要输入用户名和密码，按照等价类划分后有20种组合情况。 这里只是随意找了两个典型的例子，相信大家都有遇到过很多类似的场景。总结下来，就是在我们的自动化测试脚本中存在参数，并且我们需要采用不同的参数去运行。 经过概括，参数基本上分为两种类型： 单个独立参数：例如前面的第一种场景，我们只需要变换搜索关键字这一个参数 多个具有关联性的参数：例如前面的第二种场景，我们需要变换用户名和密码两个参数，并且这两个参数需要关联组合 然后，对于参数而言，我们可能具有一个参数列表，在脚本运行时需要按照不同的规则去取值，例如顺序取值、随机取值、循环取值等等。 对于这一块儿，没有太多新的概念，这就是典型的参数化和数据驱动。遗憾的是，当前HttpRunner并未支持该功能特性。 考虑到该需求的普遍性，并且近期提到该需求的的人也越来越多（issue #74, issue #87, issue #88, issue #97），因此趁着春节假期的空闲时间，决定优先实现下。 经过前面的场景分析，我们的目标已经很明确了，接下来就是如何实现的问题了。 ","date":"2018-02-16","heading":"背景","objectID":"/post/httprunner-data-driven/:1:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#背景"},{"categories":["OpenSource"],"collections":null,"content":"借鉴 LoadRunner 的数据参数化 要造一个轮子，最好是先看下现有知名轮子的实现机制。之前有用过一段时间的 LoadRunner，对其参数化机制印象蛮深的，虽然它是性能测试工具，但在脚本参数化方面是通用的。 我们先看下在 LoadRunner 中是如何实现参数化的。 在 LoadRunner 中，可以在脚本中创建一个参数，然后参数会保存到一个.dat的文件中，例如下图中的psd.dat。 在.dat文件中，是采用表格的形式来存储参数值，结构与CSV基本一致。 对于单个独立参数，可以将参数列表保存在一个单独的.dat文件中，第一行为参数名称，后续每一行为一个参数值。例如本文背景介绍中的第一类场景，数据存储形式如下所示： Keyword hello world debugtalk 然后对于参数的取值方式，可以通过Select next row和Update value on进行配置。 Select next row的可选方式有： Sequential：顺序取值 Random：随机取值 Unique：为每个虚拟用户分配一条唯一的数据 Update value on的可选方式有： Each iteration：每次脚本迭代时更新参数值 Each occurrence：每次出现参数引用时更新参数值 Once：每条数据只能使用一次 而且，可以通过对这两种方式进行组合，配制出9种参数化方式。 另外，因为 LoadRunner 本身是性能测试工具，具有长时间运行的需求，假如Select next row选择为Unique，同时Update value on设置为Each iteration，那么就会涉及到参数用完的情况。在该种情况下，可通过When out of value配置项实现如下选择： Abort vuser：当超出时终止脚本 Continue in a cyclic manner：当超出时回到列表头再次取值 Continue with last value：使用参数表中的最后一个值 对于多个具有关联性的参数，可以将关联参数列表保存在一个.dat文件中，第一行为参数名称，后续每一行为一个参数值，参数之间采用逗号进行分隔。例如本文背景介绍中的第二类场景，数据存储形式如下所示： UserName,Password test1,111111 test2,222222 test3,333333 对于参数的取值方式，与上面单个独立参数的取值方式基本相同。差异在于，我们可以只配置一个参数（例如UserName）的取值方式，然后其它参数（例如Password）的取值方式选择为same line as UserName。如此一来，我们就可以保证参数化时的数据关联性。 LoadRunner 的参数化机制就回顾到这里，可以看出，其功能还是很强大的，使用也十分灵活。 ","date":"2018-02-16","heading":"借鉴 LoadRunner 的数据参数化","objectID":"/post/httprunner-data-driven/:2:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#借鉴-loadrunner-的数据参数化"},{"categories":["OpenSource"],"collections":null,"content":"设计思路演变历程 现在再回到我们的 HttpRunner，要如何来实现参数化机制呢？ 因为 LoadRunner 的参数化机制比较完善，用户群体也很大，因此我在脑海里最先冒出的想法就是照抄 LoadRunner，将 LoadRunner 在 GUI 中配置的内容在 HttpRunner 中通过YAML/JSON来进行配置。 按照这个思路，在 HttpRunner 的 config 中，就要有一块儿地方用来进行参数化配置，暂且设定为parameters吧。然后，对于每一个参数，其参数列表要单独存放在文件中，考虑到LoadRunner中的.dat文件基本就是CSV格式，因此可以约定采用大众更熟悉的.csv文件来存储参数；在脚本中，要指定参数变量从哪个文件中取值，那么就需要设定一个parameter_file，用于指定对应的参数文件路径。接下来，要实现取值规则的配置，例如是顺序取值还是随机取值，那么就需要设定select_next_row和update_value_on。 根据该设想，在YAML测试用例文件中，数据参数化将描述为如下形式： - config: name: \"demo for data driven.\" parameters: - Keyword: parameter_file: keywords.csv select_next_row: Random update_value_on: EachIteration - UserName: parameter_file: account.csv select_next_row: Sequential update_value_on: EachIteration - Password: parameter_file: account.csv select_next_row: same line as UserName 这个想法基本可行，但就是感觉配置项有些繁琐，我们可以尝试再对其进行简化。 首先，比较明显的，针对每个参数都要配置select_next_row和update_value_on，虽然从功能上来说比较丰富，但是对于用户来说，这些功能并不都是必须的。特别是update_value_on这个参数，绝大多数情况下我们的需求应该都是采用Each iteration，即每次脚本再次运行时更新参数值。因此，我们可以去除update_value_on这个配置项，默认都是采用Each iteration的方式。 经过第一轮简化，配置描述方式变为如下形式： - config: name: \"demo for data driven.\" parameters: - Keyword: parameter_file: keywords.csv select_next_row: Random - UserName: parameter_file: account.csv select_next_row: Sequential - Password: parameter_file: account.csv select_next_row: same line as UserName 然后，我们可以看到UserName和Password这两个参数，它们有关联性，但却各自单独进行了配置；而且对于有关联性的参数，除了需要对第一个参数配置取值方式外，其它参数的select_next_row应该总是为same line as XXX，这样描述就显得比较累赘了。 既然是有关联性的参数，那就放在一起吧，参数名称可以采用约定的符号进行分离。考虑到参数变量名称通常包含字母、数字和下划线，同时要兼顾YAML/JSON中对字符的限制，因此选择短横线（-）作为分隔符吧。 经过第二轮简化，配置描述方式变为如下形式： - config: name: \"demo for data driven.\" parameters: - Keyword: parameter_file: keywords.csv select_next_row: Random - UserName-Password: parameter_file: account.csv select_next_row: Sequential 接着，我们再看下parameter_file参数。因为我们测试用例中的参数名称必须与数据源进行绑定，因此这一项信息是不可少的。但是在描述形式上，还是会感觉有些繁琐。再一想，既然我们本来就要指定参数名称，那何必不将参数名称约定为文件名称呢？ 例如，对于参数Keyword，我们可以将其数据源文件名称约定为Keyword.csv；对于参数UserName和Password，我们可以将其数据源文件名称约定为UserName-Password.csv；然后，再约定数据源文件需要与当前YAML/JSON测试用例文件放置在同一个目录。 经过第三轮简化，配置描述方式变为如下形式： - config: name: \"demo for data driven.\" parameters: - Keyword: select_next_row: Random - UserName-Password: select_next_row: Sequential 同时该用例文件同级目录下的数据源文件名称为Keyword.csv和UserName-Password.csv。 现在，我们就只剩下select_next_row一个配置项了。既然是只剩一项，那就也省略配置项名称吧。 最终，我们的配置描述方式变为： - config: name: \"demo for data driven.\" parameters: - Keyword: Random - UserName-Password: Sequential 不过，我们还忽略了一个信息，那就是脚本的运行次数。假如参数取值都是采用Sequential的方式，那么我们可以将不同组参数进行笛卡尔积的组合，这是一个有限次数，可以作为自动化测试运行终止的条件；但如果参数取值采用Random的方式，即每次都是在参数列表里面随机取值，那么就不好界定自动化测试运行终止的条件了，我们只能手动进行终止，或者事先指定运行的总次数，不管是采用哪种方式，都会比较麻烦。 针对参数取值采用Random方式的这个问题，我们不妨换个思路。从数据驱动的角度来看，我们期望在自动化测试时能遍历数据源文件中的所有数据，那么重复采用相同参数进行测试的意义就不大了。因此，在选择Random的取值方式时，我们可以先将参数列表进行乱序排序，然后采用顺序的方式进行遍历；对于存在多组参数的情况，也可以实现乱序排序后再进行笛卡尔积的组合方式了。 到此为止，我们的参数化配置方式应该算是十分简洁了，而且在功能上也能满足常规参数化的配置需求。 最后，我们再回过头来看脚本参数化设计思路的演变历程，基本上都可以概括为约定大于配置，这的确也是HttpRunner崇尚和遵循的准则。 ","date":"2018-02-16","heading":"设计思路演变历程","objectID":"/post/httprunner-data-driven/:3:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#设计思路演变历程"},{"categories":["OpenSource"],"collections":null,"content":"开发实现 设计思路理顺了，实现起来就比较简单了，点击此处查看相关代码，就会发现实际的代码量并不多。 在这里我就只挑几个典型的点讲下。 ","date":"2018-02-16","heading":"开发实现","objectID":"/post/httprunner-data-driven/:4:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#开发实现"},{"categories":["OpenSource"],"collections":null,"content":"数据源格式约定 既然是参数化，那么肯定会存在数据源的问题，我们约定采用.csv文件格式来存储参数列表。同时，在同一个测试场景中可能会存在多个参数的情况，为了降低问题的复杂度，我们可以约定独立参数存放在独立的.csv文件中，多个具有关联性的参数存放在一个.csv文件中。另外，我们同时约定在.csv文件中的第一行必须为参数名称，并且要与文件名保持一致；从第二行开始为参数值，每个值占一行。 例如，keyword这种独立的参数就可以存放在keyword.csv中，内容形式如下： keyword hello world debugtalk username和password这种具有关联性的参数就可以存放在username-password.csv中，内容形式如下： username,password test1,111111 test2,222222 test3,333333 ","date":"2018-02-16","heading":"数据源格式约定","objectID":"/post/httprunner-data-driven/:4:1","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#数据源格式约定"},{"categories":["OpenSource"],"collections":null,"content":"csv 解析器 数据源的格式约定好了，我们要想进行读取，那么就得有一个对应的解析器。因为我们后续想要遍历每一行数据，并且还会涉及到多个参数进行组合的情况，因此我们希望解析出来的每一行数据应该同时包含参数名称和参数值。 于是，我们的数据结构就约定采用list of dict的形式。即每一个.csv文件解析后会得到一个列表（list），而列表中的每一个元素为一个字典结构（dict），对应着一行数据的参数名称和参数值。具体实现的代码函数为_load_csv_file。 例如，上面的username-password.csv经过解析，会生成如下形式的数据结构。 [ {'username': 'test1', 'password': '111111'}, {'username': 'test2', 'password': '222222'}, {'username': 'test3', 'password': '333333'} ] 这里还会涉及到一个问题，就是参数取值顺序。 在YAML/JSON测试用例中，我们会配置参数的取值顺序，是要顺序取值（Sequential）还是乱序随机取值（Random）。对于顺序的情况没啥好说的，默认从.csv文件中读取出的内容就是顺序的；对于随机取值，更确切地说，应该是乱序取值，我们需要进行一次乱序排序，实现起来也很简单，使用random.shuffle函数即可。 if fetch_method.lower() == \"random\": random.shuffle(csv_content_list) ","date":"2018-02-16","heading":"csv 解析器","objectID":"/post/httprunner-data-driven/:4:2","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#csv-解析器"},{"categories":["OpenSource"],"collections":null,"content":"多个参数的组合 然后，对于多个参数的情况，为了组合出所有可能的情况，我们就需要用到笛卡尔积的概念。直接看例子可能会更好理解些。 例如我们在用例场景中具有三个参数，a为独立参数，参数列表为[1, 2]；x和y为关联参数，参数列表为[[111,112], [121,122]]；经过解析后，得到的数据分别为： a: [{\"a\": 1}, {\"a\": 2}] x \u0026 y: [ {\"x\": 111, \"y\": 112}, {\"x\": 121, \"y\": 122} ] 那么经过笛卡尔积，就可以组合出4种情况，组合后的结果应该为： [ {'a': 1, 'x': 111, 'y': 112}, {'a': 1, 'x': 121, 'y': 122}, {'a': 2, 'x': 111, 'y': 112}, {'a': 2, 'x': 121, 'y': 122} ] 这里需要强调的是，多个参数经过笛卡尔积运算转换后，仍然是list of dict的数据结构，列表中的每一个字典（dict）代表着参数的一种组合情况。 ","date":"2018-02-16","heading":"多个参数的组合","objectID":"/post/httprunner-data-driven/:4:3","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#多个参数的组合"},{"categories":["OpenSource"],"collections":null,"content":"参数化数据驱动 现在，我们已经实现了在YAML/JSON测试用例文件中对参数进行配置，从.csv数据源文件中解析出参数列表，并且生成所有可能的组合情况。最后还差一步，就是如何使用参数值来驱动测试用例的执行。 听上去很高大上，但实际却异常简单，直接对照着代码来说吧。 对于每一组参数组合情况来说，我们完全可以将其视为当前用例集运行时定义的变量值。而在 HttpRunner 中每一次运行测试用例集的时候都需要对runner.Runner做一次初始化，里面会用到定义的变量（即config_dict[\u0026quot;variables\u0026quot;]），那么，我们完全可以在每次初始化的时候将组合好的参数作为变量传进去，假如存在同名的变量，就进行覆盖。 这样一来，我们就可以使用所有的参数组合情况来依次驱动测试用例的执行，并且每次执行时都采用了不同的参数，从而也就实现了参数化数据驱动的目的。 ","date":"2018-02-16","heading":"参数化数据驱动","objectID":"/post/httprunner-data-driven/:4:4","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#参数化数据驱动"},{"categories":["OpenSource"],"collections":null,"content":"效果展示 最后我们再来看下实际的运行效果吧。 假设我们有一个获取token的接口，我们需要使用 user_agent 和 app_version 这两个参数来进行参数化数据驱动。 YAML 测试用例的描述形式如下所示： - config: name: \"user management testset.\" parameters: - user_agent: Random - app_version: Sequential variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' request: base_url: $BASE_URL headers: Content-Type: application/json device_sn: $device_sn - test: name: get token with $user_agent and $app_version api: get_token($user_agent, $device_sn, $os_platform, $app_version) extract: - token: content.token validate: - \"eq\": [\"status_code\", 200] - \"len_eq\": [\"content.token\", 16] 其中，user_agent 和 app_version 的数据源列表分别为： user_agent iOS/10.1 iOS/10.2 iOS/10.3 app_version 2.8.5 2.8.6 那么，经过笛卡尔积组合，应该总共有6种参数组合情况，并且 user_agent 为乱序取值，app_version 为顺序取值。 最终的测试结果如下所示： $ hrun tests/data/demo_parameters.yml Running tests... ---------------------------------------------------------------------- get token with iOS/10.2 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 13 ms, response_length: 46 bytes OK (0.014845)s get token with iOS/10.2 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 2 ms, response_length: 46 bytes OK (0.003909)s get token with iOS/10.1 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes OK (0.004090)s get token with iOS/10.1 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 5 ms, response_length: 46 bytes OK (0.006673)s get token with iOS/10.3 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes OK (0.004775)s get token with iOS/10.3 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes OK (0.004846)s ---------------------------------------------------------------------- Ran 6 tests in 0.046s 至此，我们就已经实现了参数化数据驱动的需求。对于测试用例中参数的描述形式，大家要是发现还有更加简洁优雅的方式，欢迎反馈给我。 最后，本文发表于 2018 年大年初一，祝大家新年快乐，狗年旺旺旺！ ","date":"2018-02-16","heading":"效果展示","objectID":"/post/httprunner-data-driven/:5:0","tags":["HttpRunner"],"title":"HttpRunner 实现参数化数据驱动机制","uri":"/post/httprunner-data-driven/#效果展示"},{"categories":["OpenSource"],"collections":null,"content":"背景介绍 近期，某位同学对HttpRunner提了一个需求点： 能否支持类似unittest中的skip注解，方便灵活剔除某些用例，不执行。 目前在接口测试日常构建中，会遇到一些接口开发暂时屏蔽了或者降级，导致用例执行失败；所以想当遇到这些情况的时候，能够临时剔除掉某些用例不执行；等后续恢复后，再去掉，然后恢复执行。 针对这种情况，HttpRunner的确没有直接支持。之所以说是没有直接支持，是因为在HttpRunner中存在times关键字，可以指定某个test的运行次数。 例如，如下test中指定了times为3，那么该test就会运行3次。 - test: name: demo times: 3 request: {...} validate: [...] 假如要实现临时屏蔽掉某些test，那么就可以将对应test的times设置为0。 这虽然也能勉强实现需求，但是这跟直接将临时不运行的test注释掉没什么区别，都需要对测试用例内容进行改动，使用上很是不方便。 考虑到该需求的普遍性，HttpRunner的确应该增加对该种情况的支持。 在这方面，unittest已经有了清晰的定义，有三种常用的装饰器可以控制单元测试用例是否被执行： @unittest.skip(reason)：无条件跳过当前测试用例 @unittest.skipIf(condition, reason)：当条件表达式的值为true时跳过当前测试用例 @unittest.skipUnless(condition, reason)：当条件表达式的值为false时跳过当前测试用例 该功能完全满足我们的需求，因此，我们可以直接复用其概念，尝试实现同样的功能。 ","date":"2018-02-08","heading":"背景介绍","objectID":"/post/HttpRunner-skip-feature/:1:0","tags":["HttpRunner"],"title":"HttpRunner 通过 skip 机制实现对测试用例的分组执行控制","uri":"/post/HttpRunner-skip-feature/#背景介绍"},{"categories":["OpenSource"],"collections":null,"content":"实现方式 目标明确了，那需要怎么实现呢？ 首先，我们先看下unittest中这三个函数是怎么实现的；这三个函数定义在unittest/case.py中。 class SkipTest(Exception): \"\"\" Raise this exception in a test to skip it. Usually you can use TestCase.skipTest() or one of the skipping decorators instead of raising this directly. \"\"\" pass def skip(reason): \"\"\" Unconditionally skip a test. \"\"\" def decorator(test_item): if not isinstance(test_item, (type, types.ClassType)): @functools.wraps(test_item) def skip_wrapper(*args, **kwargs): raise SkipTest(reason) test_item = skip_wrapper test_item.__unittest_skip__ = True test_item.__unittest_skip_why__ = reason return test_item return decorator def skipIf(condition, reason): \"\"\" Skip a test if the condition is true. \"\"\" if condition: return skip(reason) return _id def skipUnless(condition, reason): \"\"\" Skip a test unless the condition is true. \"\"\" if not condition: return skip(reason) return _id 不难看出，核心有两点： 对于skip，只需要在该测试用例中raise SkipTest(reason)，而SkipTest是unittest/case.py中定义的一个异常类； 对于skipIf和skipUnless，相比于skip，主要是需要指定一个条件表达式（condition），然后根据该表达式的实际值来决定是否skip当前测试用例。 明确了这两点之后，我们要如何在HttpRunner中实现同样的功能，思路应该就比较清晰了。 因为HttpRunner同样也是采用unittest来组织和驱动测试用例执行的，而具体的执行控制部分都是在httprunner/runner.py的_run_test方法中；同时，在_run_test方法中会传入testcase_dict，也就是具体测试用例的全部信息。 那么，最简单的做法，就是在YAML/JSON测试用例中，新增skip/skipIf/skipUnless参数，然后在_run_test方法中根据参数内容来决定是否执行raise SkipTest(reason)。 例如，在YAML测试用例中，我们可以按照如下形式新增skip字段，其中对应的值部分就是我们需要的reason。 - test: name: demo skip: \"skip this test unconditionally\" request: {...} validate: [...] 接下来在_run_test方法，要处理就十分简单，只需要判断testcase_dict中是否包含skip字段，假如包含，则执行raise SkipTest(reason)即可。 def _run_test(self, testcase_dict): ... if \"skip\" in testcase_dict: skip_reason = testcase_dict[\"skip\"] raise SkipTest(skip_reason) ... 这对于skip机制来做，完全满足需求；但对于skipIf/skipUnless，可能就会麻烦些，因为我们的用例是在YAML/JSON文本格式的文件中，没法像在unittest中执行condition那样的Python表达式。 嗯？谁说在YAML/JSON中就不能执行函数表达式的？在HttpRunner中，我们已经实现了该功能，即： 在debugtalk.py中定义函数，例如func(a, b) 在YAML/JSON中通过${func(a,b)}对函数进行调用 在此基础上，我们要实现skipIf/skipUnless就很简单了；很自然地，我们可以想到采用如下形式来进行描述。 - test: name: create user which existed (skip if condition) skipIf: ${skip_test_in_production_env()} request: {...} validate: [...] 其中，skip_test_in_production_env定义在debugtalk.py文件中。 def skip_test_in_production_env(): \"\"\" skip this test in production environment \"\"\" return os.environ[\"TEST_ENV\"] == \"PRODUCTION\" 然后，在_run_test方法中，我们只需要判断testcase_dict中是否包含skipIf字段，假如包含，则将其对应的函数表达式取出，运行得到其结果，最后再根据运算结果来判断是否执行raise SkipTest(reason)。对函数表达式进行解析的方法在httprunner/context.py的exec_content_functions函数中，具体实现方式可阅读之前的文章。 def _run_test(self, testcase_dict): ... if \"skip\" in testcase_dict: skip_reason = testcase_dict[\"skip\"] raise SkipTest(skip_reason) elif \"skipIf\" in testcase_dict: skip_if_condition = testcase_dict[\"skipIf\"] if self.context.exec_content_functions(skip_if_condition): skip_reason = \"{} evaluate to True\".format(skip_if_condition) raise SkipTest(skip_reason) ... skipUnless与skipIf类似，不再重复。 通过该种方式，我们就可以实现在不对测试用例文件做任何修改的情况下，通过外部方式（例如设定环境变量的值）就可以控制是否执行某些测试用例。 ","date":"2018-02-08","heading":"实现方式","objectID":"/post/HttpRunner-skip-feature/:2:0","tags":["HttpRunner"],"title":"HttpRunner 通过 skip 机制实现对测试用例的分组执行控制","uri":"/post/HttpRunner-skip-feature/#实现方式"},{"categories":["OpenSource"],"collections":null,"content":"效果展示 skip/skipIf/skipUnless机制实现后，我们对测试用例的执行控制就更加灵活方便了。 例如，我们可以很容易地实现如下常见的测试场景： 对测试用例进行分组，P0/P1/P2等，然后根据实际需求选择执行哪些用例 通过环境变量来控制是否执行某些用例 更重要的是，我们无需对测试用例文件进行任何修改。 在HttpRunner项目中存在一个示例文件，httprunner/tests/data/demo_testset_cli.yml，大家可以此作为参考。 在运行该测试集后，生成的测试报告如下所示。 ","date":"2018-02-08","heading":"效果展示","objectID":"/post/HttpRunner-skip-feature/:3:0","tags":["HttpRunner"],"title":"HttpRunner 通过 skip 机制实现对测试用例的分组执行控制","uri":"/post/HttpRunner-skip-feature/#效果展示"},{"categories":["Growth"],"collections":null,"content":"2017年已经结束了，为了给以后的自己留点回忆，还是写篇博客记录下吧。 先从工作说起吧。这一年，我依然在大疆的互联网事业部做测试开发岗位。有点变化的是，年初是自己一个人做，然后逐步有新的伙伴加入，成立了测试开发小组，并担任了小组负责人。简单地说，就是除了纯技术，也开始兼具了一点技术管理的角色，这对于我个人也是一个挺大的转变。看过我个人博客自我介绍的同学都知道，“在墙角安静地写代码才是我的最爱”，所以我对于做管理是一直都挺抗拒的。不过，后来通过阅读一些互联网前辈的文章（主要有池建强老师、左耳朵耗子皓叔、硅谷女神安姐等），再加上leader的循序善诱，我的想法也发生了一些转变。我渐渐地也开始认同，技术管理与做好技术并不冲突，但发挥团队的力量，却可以做更大的事情，产生更大的价值。同时，担任技术管理也意味着多了一份责任，自己不能再由着个人喜好去摸索一些看似酷炫实则无用的“黑科技”，而应时刻关注技术转化的业务价值，这不管是对于公司还是个人，都是至关重要的。 在大疆除了测试开发工作，我还负责一些重要节点的性能压测工作。今年最大的一次压测应该就是准备5月24日的Spark新品发布了。在这个项目中，我担任性能测试总负责人，负责PC商城、手机商城、官网、直播系统、支付中心等相关电商系统的整体性能测试工作。当然，那段时间加班也挺多的，以至于我后面出差到达纽约发布会现场都不用倒时差了。说到出差，感谢领导和同事的信任，让我去发布会现场做直播系统的技术支持，我也有机会第一次去到美国，这个我一直特别想去体验的国家。现在回想起来，当时也真是人品爆发，在办美国商务签证的时候很顺利地就通过了（可怜我的另两位同事，他们虽然都准备得非常充分，但后来去办签证的时候都进了审核）；另外，当时自己因为疏忽，忘了eVUS这么一个东西，结果去登机时才被告知必须要提前完成eVUS的登记审核，搞得我一脸懵逼，所幸当时立即提交申请后居然很快就审批通过了，不然差点就真错过了。 当时到纽约完成了新品发布会的工作后，考虑到机会难得，我也申请了两天的假期，在纽约市区（主要就是曼哈顿区）转了下，因为时间有限，去的也都是闻名已久的地标，例如时代广场、世贸中心、帝国大厦、中央公园、自然历史博物馆等，以及远距离看了下自由女神像的侧面。对于纽约，印象是极其深刻的，如果让我用一个词来概括，那就是“大城市”，虽然在国内去的地方也不少，意大利法国的首都也去旅游过，但到纽约后真的有种进城了的感觉。当时在返程的飞机上趁着兴奋劲，还写了篇记录文章，《纽约出差之城市印象》，写了近两千字还没有收尾，结果后来拖延证一犯就一直没发出来。除了逛纽约，当时也跟多年未见的同学聚了下。一个是高中同学，在纽约大学任教，在他的带领下我也逛了下纽约大学，可惜当时天气不是特别好，没能见到草地上满是穿着比基尼晒太阳的妹子，甚是遗憾。另一个是大学室友，本来是在波士顿工作的，结果也带着媳妇儿（同是大学校友）开车五六个小时到纽约聚了下。哦对，我们聚会都是吃的中餐厅。在异国他乡与多年未见的同学相聚，叙叙旧聊聊人生，感觉还是蛮不错的。 今年除了到美国纽约，我还到了其它几个国家地区。还是纽约出差那次，因为国内直达的航班机票价格太高，所以我选择了中转的方式；当时就从香港机场乘机，先飞到了韩国的首尔机场，然后再转机到纽约的肯尼迪机场。当时在韩国首尔机场停歇了四五个小时，虽然没有出机场，但是在机场里面逛的地方也挺多的，除了尝下韩国本土特色小吃外，还碰到了换班的空姐，一大波韩国空姐陆陆续续从眼前走过，场景甚是壮观。除了那次出差，在年初春节的时候，跟媳妇儿和朋友媳妇一起到意大利、法国旅行度蜜月，大概12天的自由行，游玩的城市有罗马、梵蒂冈、佛罗伦萨、米兰和巴黎。当时也是第一次出国，新鲜感蛮大的，还写了一篇游记，《春节旅行之意法印象》。 再说回技术方面。今年做了一个自己还算比较满意的开源项目，HttpRunner（起初叫做ApiTestEngine），核心特色就是基于现有成熟的Python开源项目requests和Locust，打造了一套HTTP测试框架，可以实现只需采用YAML/JSON格式维护一套脚本，就可同时实现自动化测试和性能测试。回顾今年的技术博客，有16篇文章都是围绕HttpRunner写的，《HttpRunner 开发博客》，可见自己在这上面投入的精力还是非常大的。值得欣慰的是，该框架产生的收效还不错，除了在大疆内部的多个项目中投入了使用，当前已知的有好几个其它公司的测试同学也都在使用这个框架。也源于开源，我在开发该框架的时候收到了非常多的反馈和建议，这对HttpRunner的持续优化迭代产生了非常巨大的帮助。从情感上讲，HttpRunner就如同自己的亲骨肉，里面融入了我自身非常多的对测试的思考，后续我也将继续不断优化HttpRunner，期望它能有朝一日在测试届大放异彩。 今年，我也在公司内外做过一些分享。一个是六月份的时候，当时公司新进了一批实习生，当时分配给我的任务是对我们质量部的岗位进行介绍，给实习生们讲解下我们测试工程师的工作日常情况。由于面向的都是新人，而且各种岗位的都有，因此也只能是科普介绍了。讲完后，我又整理下内容写了篇博客文章，《【科普】互联网测试岗位的工作日常》，阅读量居然还挺高。另一次分享是大疆与TesterHome合办的测试沙龙，我作为其中一位分享者，演讲的主题也是围绕着HttpRunner，题目是《低成本实现系统接口测试 \u0026ndash; 自动化、性能、持续集成\u0026amp;线上监控》，从现场互动上来看，还是挺不错的。沙龙活动之后，TOP100的某位主编联系到我，希望我能在2017年第6届全球软件案例研究峰会上做了分享，当时想着也是个锻炼的机会，也就提交了案例，并且入了榜单；不过后来主办方的做法有些让人呵呵，在此我也不想多提了，总之最后我没去现场（虽然赠送给我一张全程票），后续应该也不会考虑与他们合作了。关于分享这块儿，比较遗憾的是错过了TesterHome的测试开发大会，五月份的时候思寒问我topic的时候，当时忙于公司的新品发布会没时间准备，就此错过了与诸多大佬见面的机会，只能看2018年是否还有机会了。 今年另一件比较有意思的事情，签订了一份图书出版合同。六月份的时候，电子工业出版社博文视点的一位编辑跟我联系，说看到我博客上的文章还不错，问我是否有兴趣出版图书。当时我既惊讶又惊喜，因为写书这事儿我之前从来没敢想过，所以一时心里也没底儿。好在陈编也给了我很多肯定和鼓励，同时我也想挑战一下自己，最后就答应了出版的事儿，并最终与博文视点签订了合同，书名暂定为《互联网系统测试精要：自动化、性能、持续集成》。合同签订后，我开始后悔自己太过乐观了，写书比我想象中难得多。因为写书只能是业余时间，有时工作太忙就完全顾不上了，等过一段时间再想提笔的时候发现手感灵感全没了。是的，别说是写书了，就是写博客，隔段时间不写再想捡起来也是异常痛苦。有过这段经历，我对书籍更多了一分敬畏，先不说书的内容质量，光是作者坚持下去的这份毅力，也是难能可贵的。还好出版社也没有给我压力，在这个过程中也给了我不少鼓励。当时签约的交稿日期是2017年12月中旬，但实际完成率嘛，嗯，希望我能在2018年尽早完成吧。 再说点生活上的事儿吧。今年我买了人生中的第一辆车，从此也算是有房有车了，虽然房子远在广州山区，车也不是啥好车。在买车之前，我基本对车完全不懂，可能除了奔驰宝马奥迪的车标能认出来外，其它一概不知。当时要去4S店看车之前，担心被销售看出啥也不懂的尴尬，还在汽车之家上好生科普了一番，总算对汽车有了点了解。然后就是一番小纠结，先是犹豫买SUV还是轿车，听闻20万以下的城市SUV也就那样后就决定买轿车；然后考虑轿车买A级还是B级，想到近几年也不会换车，还是一次性买个宽敞点的吧，就决定买B级车；然后就在几个品牌的B级车里选了，丰田凯美瑞、本田雅阁、雪佛兰迈锐宝、马自达阿特兹、别克君威都有看过，最后被马自达的信仰洗脑了，再加上阿特兹的颜值，从而就选定了阿特兹这款车，而且选的是骚气的魂动红，应该比较符合我闷骚的气质吧。另外，由于深圳和广州的车牌都需要摇号，拍卖价格又太高，所以就选择了在老家上牌，幸运的是选中的车牌还比较满意，DBG256，跟我个人的职业也比较搭。在车技这块儿，虽然我的驾照满六年都换过一次证了，但这些年摸车的确比较少，当时为了找手感，还提前在58同城上约了一个陪驾服务，师傅是一位号称有18年驾龄的资深美女。所幸经过一段时间的熟悉，现在车技已经好多了，至少刚开始时那些停车就不小心剐蹭到旁边的奔驰、上高速就手心冒汗这些心理障碍已经差不多没了。 2017年，一不小心就写了这么多。最后的压轴戏，当然是我刚出生的小坚果宝宝啦。刚当上爸爸，咋说呢，心情有些复杂，既兴奋又紧张。虽然是足月，而且还晚了两天才出生，但当第一次看到小家伙的时候，还是会感觉有些惊慌失措，从此我便多了一层父亲的身份，需要肩负更多的责任。还记得多年前曾和一位领导兼前辈聊人生，他跟我说工作并不是生活的全部，下班回家逗逗儿子也是挺有意思的。当时我还不能完全理解，现在我终于有了切身的体会，看着孩子的喜怒哀乐，陪伴的孩子的成长，本身就是一件非常幸福的事情。而我自身，也需要做好爸爸的榜样，言传身教还是很重要的。对于宝宝的未来，我也并没有太多期待，健康快乐地成长就好了，过两年再大点的时候，顺便再把Python学会了，嗯，徒手反转二叉树啥的技能也得学下。 最后按照惯例，再展望下即将来临的2018年吧。 1、坚持写作。博客公众号文章的更新频率得提升下，平均每月3~5篇还是要保证的。另外就是尽快把签约的书稿结了，不然就算出版社不催，我也不","date":"2017-12-31","heading":"","objectID":"/post/my-2017-summary/:0:0","tags":["年终总结"],"title":"我的 2017 年终总结","uri":"/post/my-2017-summary/#"},{"categories":["Growth"],"collections":null,"content":"成长轨迹 《我的 2016 年终总结》 《我的 2015 年终总结》 ","date":"2017-12-31","heading":"成长轨迹","objectID":"/post/my-2017-summary/:1:0","tags":["年终总结"],"title":"我的 2017 年终总结","uri":"/post/my-2017-summary/#成长轨迹"},{"categories":["OpenSource"],"collections":null,"content":" 从版本 1.4 开始，测试用例分层机制已进行重新设计，本文内容不再适用。 ","date":"2017-12-23","heading":"","objectID":"/post/HttpRunner-testcase-layer/:0:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#"},{"categories":["OpenSource"],"collections":null,"content":"背景描述 在 HttpRunner 中，测试用例引擎最大的特色就是支持 YAML/JSON 格式的用例描述形式。 采用 YAML/JSON 格式编写维护测试用例，优势还是很明显的： 相比于表格形式，具有更加强大的灵活性和更丰富的信息承载能力； 相比于代码形式，减少了不必要的编程语言语法重复，并最大化地统一了用例描述形式，提高了用例的可维护性。 以最常见的登录注销为例，我们的测试用例通常会描述为如下形式： - config: name: demo-login-logoff variable_binds: - UserName: test001 - Password: 123456 request: base_url: http://xxx.debugtalk.com headers: Accept: application/json User-Agent: iOS/10.3 - test: name: Login request: url: /api/v1/Account/Login method: POST json: UserName: $UserName Pwd: $Password VerCode: \"\" validators: - eq: [\"status_code\", 200] - eq: [\"content.IsSuccess\", True] - eq: [\"content.Code\", 200] - test: name: Logoff request: url: /api/v1/Account/LoginOff method: GET validators: - eq: [\"status_code\", 200] - eq: [\"content.IsSuccess\", True] - eq: [\"content.Code\", 200] 相信大家已经对该种用例描述形式十分熟悉了。不过，该种描述形式的问题在于，接口通常会出现在多个测试场景中，而每次都需要对接口进行定义描述，包括请求的URL、Header、Body、以及预期响应值等，这就会产生大量的重复。 例如，在某个项目中存在三个测试场景： 场景A：注册新账号（API_1/2）、登录新注册的账号（API_3/4/5）、查看登录状态（API_6）； 场景B：登录已有账号（API_3/4/5）、注销登录（API_7/8）； 场景C：注销登录（API_7/8）、查看登录状态（API_6）、注册新账号（API_1/2）。 按照常规的接口测试用例编写方式，我们需要创建3个场景文件，然后在各个文件中分别描述三个测试场景相关的接口信息。示意图如下所示。 在本例中，接口（API_1/2/6）在场景A和场景C中都进行了定义；接口（API_3/4/5）在场景A和场景B中都进行了定义；接口（API_7/8）在场景B和场景C中都进行了定义。可以预见，当测试场景增多以后，接口定义描述的维护就会变得非常困难和繁琐。 ","date":"2017-12-23","heading":"背景描述","objectID":"/post/HttpRunner-testcase-layer/:1:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#背景描述"},{"categories":["OpenSource"],"collections":null,"content":"接口的分层定义描述 那要如何进行优化呢？ 其实也很简单，在编程语言中，如果出现重复代码块，我们通常会将其封装为类或方法，然后在需要时进行调用，以此来消除重复。同样地，我们也可以将项目的API进行统一定义，里面包含API的请求和预期响应描述，然后在测试场景中进行引用即可。 示意图如下所示。 具体地，我们可以约定将项目的所有API接口定义放置在 api 目录下，并在 api 目录中按照项目的系统模块来组织接口的定义；同时，将测试场景放置到 testcases 目录中。 此时测试用例文件的目录结构如下所示： ✗ tree tests tests ├── api │ └── v1 │ ├── Account.yml │ ├── BusinessTrip.yml │ ├── Common.yml │ └── Leave.yml ├── debugtalk.py └── testcases ├── scenario_A.yml ├── scenario_B.yml └── scenario_C.yml 而对于API接口的定义，与之前的描述方式基本一致，只做了两点调整： 接口定义块（block）的标识为 api； 接口定义块中包含 def 字段，形式为 api_name(*args) ，作为接口的唯一标识ID；需要注意的是，即使 api 没有参数，也需要带上括号，api_name()；这和编程语言中定义函数是一样的。 - api: def: api_v1_Account_Login_POST($UserName, $Password) request: url: /api/v1/Account/Login method: POST json: UserName: $UserName Pwd: $Password VerCode: \"\" validators: - eq: [\"status_code\", 200] - eq: [\"content.IsSuccess\", True] - eq: [\"content.Code\", 200] - api: def: api_v1_Account_LoginOff_GET() request: url: /api/v1/Account/LoginOff method: GET validators: - eq: [\"status_code\", 200] - eq: [\"content.IsSuccess\", True] - eq: [\"content.Code\", 200] 有了接口的定义描述后，我们编写测试场景时就可以直接引用接口定义了。 同样是背景描述中的登录注销场景，测试用例就描述为变为如下形式。 - config: name: demo variable_binds: - UserName: test001 - Password: 123456 request: base_url: http://xxx.debugtalk.com headers: Accept: application/json User-Agent: iOS/10.3 - test: name: Login api: api_v1_Account_Login_POST($UserName, $Password) - test: name: Logoff api: api_v1_Account_LoginOff_GET() 不难看出，对API接口进行分层定义后，我们在测试用例场景中引用接口定义时，与编程语言里面调用函数的形式基本完全一样，只需要指定接口的名称，以及所需传递的参数值；同样的，即使没有参数，也需要带上括号。 实现接口的分层定义描述后，我们就可以避免接口的重复定义。但是，我们回过头来看之前的案例，发现仍然会存在一定的重复。 如上图所示，场景A和场景C都包含了注册新账号（API_1/2）和查看登录状态（API_6），场景A和场景B都包含了登录已有账号（API_3/4/5），场景B和场景C都包含了注销登录（API_7/8）。 虽然我们已经将接口的定义描述抽离出来，避免了重复的定义；但是在实际业务场景中，某些功能（例如登录、注销）会在多个场景中重复出现，而该功能又涉及到多个接口的组合调用，这同样也会出现大量的重复。 ","date":"2017-12-23","heading":"接口的分层定义描述","objectID":"/post/HttpRunner-testcase-layer/:2:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#接口的分层定义描述"},{"categories":["OpenSource"],"collections":null,"content":"接口的模块化封装 玩过积木的同学可能就会想到，我们也可以将系统的常用功能封装为模块（suite），只需要在模块中定义一次，然后就可以在测试场景中重复进行引用，从而避免了模块功能的重复描述。 具体地，我们可以约定将项目的所有模块定义放置在 suite 目录下，并在 suite 目录中按照项目的功能来组织模块的定义。 后续，我们在 testcases 目录中描述测试场景时，就可同时引用接口定义和模块定义了；模块和接口的混合调用，必将为我们编写测试场景带来极大的灵活性。 此时测试用例文件的目录结构如下所示： ✗ tree tests tests ├── api │ └── v1 │ ├── Account.yml │ ├── BusinessTrip.yml │ ├── Common.yml │ └── Leave.yml ├── debugtalk.py ├── suite │ ├── BusinessTravelApplication │ │ ├── approve-application.yml │ │ ├── executive-application.yml │ │ ├── reject-application.yml │ │ └── submit-application.yml │ └── LeaveApplication │ ├── approve.yml │ ├── cancel.yml │ └── submit-application.yml └── testcases ├── scenario_A.yml ├── scenario_B.yml └── scenario_C.yml 需要注意的是，我们在组织测试用例描述的文件目录结构时，遵循约定大于配置的原则： API接口定义必须放置在 api 目录下 模块定义必须放置在 suite 目录下 测试场景文件必须放置在 testcases 目录下 相关的函数定义放置在 debugtalk.py 中 至此，我们实现了测试用例的 接口-模块-场景 分层，从而彻底避免了重复定义描述。 ","date":"2017-12-23","heading":"接口的模块化封装","objectID":"/post/HttpRunner-testcase-layer/:3:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#接口的模块化封装"},{"categories":["OpenSource"],"collections":null,"content":"脚手架工具 得益于约定大于配置的原则，在 HttpRunner 中实现了一个脚手架工具，可以快速创建项目的目录结构。该想法来源于 Django 的 django-admin.py startproject project_name。 使用方式也与 Django 类似，只需要通过 --startproject 指定新项目的名称即可。 $ hrun --startproject helloworld INFO:root: Start to create new project: /Users/Leo/MyProjects/helloworld INFO:root: created folder: /Users/Leo/MyProjects/helloworld INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/api INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/suite INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/testcases INFO:root: created file: /Users/Leo/MyProjects/helloworld/tests/debugtalk.py 运行之后，就会在指定的目录中生成新项目的目录结构，接下来，我们就可以按照测试用例的 接口-模块-场景 分层原则往里面添加用例描述信息了。 ","date":"2017-12-23","heading":"脚手架工具","objectID":"/post/HttpRunner-testcase-layer/:4:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#脚手架工具"},{"categories":["OpenSource"],"collections":null,"content":"总结 如果看到这里你还不明白测试用例分层的必要性，那也没关系，测试用例分层不是必须的，你还是可以按照之前的方式组织测试用例。不过当你某一天发现需要进行分层管理时，你会发现它就在那里，很实用。 最后，在 HttpRunner 项目的 examples/HelloWorld 目录中，包含了一份完整的分层测试用例示例，相信会对大家有所帮助。 ","date":"2017-12-23","heading":"总结","objectID":"/post/HttpRunner-testcase-layer/:5:0","tags":["HttpRunner"],"title":"HttpRunner 的测试用例分层机制（已过期）","uri":"/post/HttpRunner-testcase-layer/#总结"},{"categories":["OpenSource"],"collections":null,"content":"在测试用例中，包含预期结果这么一项，用于辅助测试人员执行测试用例时判断系统的功能是否正常。而在自动化测试中，我们的目标是让测试用例自动执行，因此自动化测试用例中同样需要包含预期结果一项，只不过系统响应结果不再由人工来进行判断，而是交由测试工具或框架来实现。 这部分功能对应的就是测试结果校验器（validator），基本上能称得上自动化测试工具或框架的都包含该功能特性。 ","date":"2017-12-13","heading":"","objectID":"/post/HttpRunner-validator-optimization/:0:0","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#"},{"categories":["OpenSource"],"collections":null,"content":"设计之初 HttpRunner在设计之初，结果校验器（validator）的实现比较简单。 对于每一个test，可以指定0个或多个校验项，放置在validate中。在自动化测试执行的时候，会在发起HTTP请求、解析结果响应之后，逐个检查各个校验项，若存在任意校验项不通过的情况，则该test将终止并被标记为失败。 - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: GET extract: - token: content.token validate: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expect\": 200} - {\"check\": \"content.token\", \"comparator\": \"len_eq\", \"expect\": 16} 如上例所示，每一个校验项均为一个json结构，里面包含check、expect、comparator三个属性字段。其中，check对应着要检查的字段，expect对应着检查字段预期的值，这两项是必须指定的；comparator字段对应着比较方法，若不指定，则默认采用eq，即检查字段与预期值相等。 为了实现尽可能强大的检查功能，check属性值可通过链式操作精确指定具体的字段，comparator也内置实现了大量的检查功能。 举个例子可能会更清晰些。假如某结构的响应结果如下： // status code: 200 // response headers { \"Content-Type\": \"application/json\" } // response body content { \"success\": False, \"person\": { \"name\": { \"first_name\": \"Leo\", \"last_name\": \"Lee\", }, \"age\": 29, \"cities\": [\"Guangzhou\", \"Shenzhen\"] } } 那么假如我们要检查status code，check就可以指定为status_code；假如要检查response headers中的Content-Type，check就可以指定为headers.content-type；假如要检查response body中的first_name，check就可以指定为content.person.name.first_name。可以看出，假如下一层级为字典结构，那么就可以通过.运算符指定下一层级的key，依次类推。 对于字段内容为列表list的情况略有不同，我们需要通过序号来指定具体检查哪一项内容。例如，Guangzhou对应的检查项为content.person.cities.0，Shenzhen对应的检查项为content.person.cities.1。 在比较方式（comparator）方面，HttpRunner除了eq，还内置了大量的检查方法。例如，我们可以通过gt、ge、lt、le等比较数值大小，通过len_eq、len_gt、len_lt等比较长度是否相等（列表、字典、字符串均适用），通过contains、contained_by来判断包含关系，通过startswith、endswith判断字符串的开头结尾，甚至通过regex_match来判断是否满足正则匹配等。详细的比较方式还有许多，需要时可查看comparator表格。 ","date":"2017-12-13","heading":"设计之初","objectID":"/post/HttpRunner-validator-optimization/:1:0","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#设计之初"},{"categories":["OpenSource"],"collections":null,"content":"存在的局限性 在大多数情况下，HttpRunner的结果校验器（validator）是够用的。不过问题在于，框架不可能为用户实现所有的检查方法，假如用户需要某些特殊的检查方法时，HttpRunner就没法实现了。 这的确是一个问题，之前Junho2010提的issue #29中举了一个例子，应该也算是比较有代表性。 发送请求时的数据使用了随机生成，然后需要比较结果中的数据是否是和这个相关（通过某个算法转换）。比如我输入的是321，我的结果是(3+2+1) * avg(3+2+1)这种转化，目前的comparator是比较难于实现的。 要解决这个问题，最好的方式应该是在HttpRunner中实现自定义结果校验器的机制；用户在有需要的时候，可以自己编写校验函数，然后在validate中引用校验函数。之前也介绍过HttpRunner的热加载机制，《约定大于配置：ApiTestEngine实现热加载机制》，自定义结果校验器应该也是可以采用这种方式来实现的。 第二个需要优化的点，HttpRunner的结果校验器还不支持变量引用，会造成某些场景下的局限性。例如，testwangchao曾提过一个issue #52： 接口response内，会返回数据库内的自增ID。ID校验的时候，希望expected为参数化的值。 validate: - {\"check\": \"content.data.table_list.0.id\", \"expected\": \"$id\"} 另外，在《ApiTestEngine，不再局限于API的测试》一文中有介绍过，结果提取器（extract）新增实现了通过正则表达式对任意文本响应内容的字段提取。考虑到结果校验器（validate）也需要先从结果响应中提取出特定字段才能与预期值进行比较，在具体实现上完全可以复用同一部分代码，因此在validate的check部分也可以进行统一化处理。 经过前面的局限性问题描述，我们的改造目标也明确了，主要有三个方面： 新增支持自定义结果校验器 结果校验器中实现变量引用 结果校验内容新增支持正则表达式提取 ","date":"2017-12-13","heading":"存在的局限性","objectID":"/post/HttpRunner-validator-optimization/:2:0","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#存在的局限性"},{"categories":["OpenSource"],"collections":null,"content":"改造结果 具体的改造过程就不写了，有兴趣的同学可以直接阅读源码，重点查看httprunner/context.py中的parse_validator、do_validation和validate三个函数。 经过优化后，改造目标中的三项功能都实现了。为了更好地展现改造后的结果校验器，此处将结合实例进行演示。 ","date":"2017-12-13","heading":"改造结果","objectID":"/post/HttpRunner-validator-optimization/:3:0","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#改造结果"},{"categories":["OpenSource"],"collections":null,"content":"新增支持自定义结果校验器 先来看第一个优化项，新增支持自定义结果校验器。 假设我们需要使用HTTP响应状态码各个数字的和来进行校验，例如，201状态码对应的数字和为3，503状态码对应的数字和为8。该实例只是为了演示用，实际上并不会用到这样的校验方式。 首先，该种校验方式在HttpRunner中并没有内置，因此需要我们自己来实现。实现方式与热加载机制相同，只需要将自定义的校验函数放置到当前YAML/JSON文件同级或者父级目录的debugtalk.py中。 对于自定义的校验函数，需要遵循三个规则： 自定义校验函数需放置到debugtalk.py中 参数有两个：第一个为原始数据，第二个为原始数据经过运算后得到的预期结果值 在校验函数中通过assert将实际运算结果与预期结果值进行比较 对于前面提到的演示案例，我们就可以在debugtalk.py中编写如下校验函数。 def sum_status_code(status_code, expect_sum): \"\"\" sum status code digits e.g. 400 =\u003e 4, 201 =\u003e 3 \"\"\" sum_value = 0 for digit in str(status_code): sum_value += int(digit) assert sum_value == expect_sum 然后，在YAML/JSON格式测试用例的validate中，我们就可以将校验函数名称sum_status_code作为comparator进行使用了。 - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: GET validate: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expect\": 200} - {\"check\": \"status_code\", \"comparator\": \"sum_status_code\", \"expect\": 2} 由此可见，自定义的校验函数sum_status_code与HttpRunner内置的校验方法eq在使用方式上完全相同，应该没有理解上的难度。 ","date":"2017-12-13","heading":"新增支持自定义结果校验器","objectID":"/post/HttpRunner-validator-optimization/:3:1","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#新增支持自定义结果校验器"},{"categories":["OpenSource"],"collections":null,"content":"结果校验器中实现变量引用 对于第二个优化项，结果校验器中实现变量引用。在使用方式上我们应该与request中的变量引用一致，即通过$var的方式来引用变量var。 - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: GET variables: - expect_status_code: 200 - token_len: 16 extract: - token: content.token validate: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expect\": \"$expect_status_code\"} - {\"check\": \"content.token\", \"comparator\": \"len_eq\", \"expect\": \"$token_len\"} - {\"check\": \"$token\", \"comparator\": \"len_eq\", \"expect\": \"$token_len\"} 通过以上示例可以看出，在结果校验器validate中，check和expect均可实现实现变量的引用；而引用的变量，可以来自四种类型： 当前test中定义的variables，例如expect_status_code 当前test中提取（extract）的结果变量，例如token 当前测试用例集testset中，先前test中提取（extract）的结果变量 当前测试用例集testset中，全局配置config中定义的变量 而check字段除了可以引用变量，以及保留了之前的链式操作定位字段（例如上例中的content.token）外，还新增了采用正则表达式提取内容的方式，也就是第三个优化项。 ","date":"2017-12-13","heading":"结果校验器中实现变量引用","objectID":"/post/HttpRunner-validator-optimization/:3:2","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#结果校验器中实现变量引用"},{"categories":["OpenSource"],"collections":null,"content":"结果校验内容新增支持正则表达式提取 假设如下接口的响应结果内容为LB123abcRB789，那么要提取出abc部分进行校验，就可以采用如下描述方式。 - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: GET validate: - {\"check\": \"LB123(.*)RB789\", \"comparator\": \"eq\", \"expect\": \"abc\"} 可见在使用方式上与在结果提取器（extract）中完全相同。 ","date":"2017-12-13","heading":"结果校验内容新增支持正则表达式提取","objectID":"/post/HttpRunner-validator-optimization/:3:3","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#结果校验内容新增支持正则表达式提取"},{"categories":["OpenSource"],"collections":null,"content":"结果校验器的进一步简化 最后，为了进一步简化结果校验的描述，我在validate中新增实现了一种描述方式。 简化后的描述方式与原始方式对比如下： validate: - comparator_name: [check_item, expect_value] - {\"check\": check_item, \"comparator\": comparator_name, \"expect\": expect_value} 同样是前面的例子，采用新的描述方式后会更加简洁。而两种方式表达的含义是完全等价的。 - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: GET validate: - eq: [\"status_code\", $expect_status_code] - sum_status_code: [\"status_code\", 2] - len_eq: [\"$token\", $token_len] - len_eq: [\"content.token\", 16] - eq: [\"LB123(.*)RB789\", \"abc\"] 当然，此次优化保证了与历史版本的兼容，之前编写的测试用例脚本的运行是完全不会受到任何影响的。 ","date":"2017-12-13","heading":"结果校验器的进一步简化","objectID":"/post/HttpRunner-validator-optimization/:3:4","tags":["HttpRunner"],"title":"HttpRunner 的结果校验器优化","uri":"/post/HttpRunner-validator-optimization/#结果校验器的进一步简化"},{"categories":["OpenSource"],"collections":null,"content":"HttpRunner开始支持HAR啦！！！ 如果你还没有体会到这三个感叹号的含义，那们你可能对HAR还不了解。 ","date":"2017-11-14","heading":"","objectID":"/post/HttpRunner-supports-HAR/:0:0","tags":["HttpRunner","HAR"],"title":"HttpRunner 支持 HAR 意味着什么？","uri":"/post/HttpRunner-supports-HAR/#"},{"categories":["OpenSource"],"collections":null,"content":"HAR 是什么？ HAR的全称为HTTP Archive，是W3C(World Wide Web Consortium)发布的一个通用标准。简单地说，HAR是一个约定的JSON文件格式，用于记录HTTP请求交互的所有内容，包括请求响应的详细记录和性能度量数据。 虽然当前HAR标准还处于Draft状态，但它已经被业界广泛地采用了，许多我们日常使用的工具都已支持HAR。在下面罗列的工具中，相信大家都已经比较熟悉了。 Fiddler Charles Web Proxy Google Chrome Firebug HttpWatch Firefox Internet Explorer 9 Microsoft Edge Paw Restlet Client 可以看出，工具覆盖了主流的抓包工具、浏览器和接口测试工具。这些工具都支持HAR标准，可以将录制得到的数据包导出为.har的文件。 假如我们可以将HAR格式转换为HttpRunner的自动化测试用例，这就相当于HttpRunner可以和非常多的工具结合使用，并获得了接口录制和用例生成功能，灵活性和易用性都将得到极大的提升。 那么，将HAR格式转换为HttpRunner的自动化测试用例是否可行呢？ 我们不妨先研究下HAR的格式。 ","date":"2017-11-14","heading":"HAR 是什么？","objectID":"/post/HttpRunner-supports-HAR/:1:0","tags":["HttpRunner","HAR"],"title":"HttpRunner 支持 HAR 意味着什么？","uri":"/post/HttpRunner-supports-HAR/#har-是什么"},{"categories":["OpenSource"],"collections":null,"content":"HAR 格式详解 通过如上列出的任意一款工具，都可以将录制得到的数据包导出为.har的文件。我们采用文本编辑器打开.har文件后，会发现是一个JSON的数据结构。 默认情况下，.har文件的JSON数据结构是经过压缩的，直接看可能不够直观。推荐大家可以在文本编辑器中安装Prettify JSON的插件，然后就可以将压缩后的JSON数据一键转换为美观的格式。 更好的方式是，我们可以直接查看W3C编写的HAR格式标准。 通过文档可知，HAR是只有一个key的JSON数据结构，并且key值只能为log；而log的值也为一个JSON结构，里面的key包括：version、creator、browser、pages、entries、comment。 { \"log\": { \"version\": \"\", \"creator\": {}, \"browser\": {}, \"pages\": [], \"entries\": [], \"comment\": \"\" } } 其中，version、creator和entries是必有字段，不管是哪款工具导出的.har文件，肯定都会包含这三个字段。而我们在转换生成自动化测试用例时，只需获取HTTP请求和响应的内容，这些全都包含在entries里面，因此我们只需要关注entries的内容即可。 entries字段对应的值为一个列表型数据结构，里面的值按照请求时间进行排序，罗列出各个HTTP请求的详细内容。具体地，HTTP请求记录的信息如下所示： \"entries\": [ { \"pageref\": \"page_0\", \"startedDateTime\": \"2009-04-16T12:07:23.596Z\", \"time\": 50, \"request\": {...}, \"response\": {...}, \"cache\": {...}, \"timings\": {}, \"serverIPAddress\": \"10.0.0.1\", \"connection\": \"52492\", \"comment\": \"\" }, ] 由此可见，记录的HTTP信息非常全面，包含了HTTP请求交互过程中的所有内容。 而从生成自动化测试用例的角度来看，我们并不需要那么多信息，我们只需从中提取关键信息即可。 编写自动化测试用例，最关键的信息是要知道接口的请求URL、请求方法、请求headers、请求数据等，这些都包含在request字段对应的字典中。 \"request\": { \"method\": \"GET\", \"url\": \"http://www.example.com/path/?param=value\", \"httpVersion\": \"HTTP/1.1\", \"cookies\": [], \"headers\": [], \"queryString\" : [], \"postData\" : {}, \"headersSize\" : 150, \"bodySize\" : 0, \"comment\" : \"\" } 根据这些信息，我们就可以完成HTTP请求的构造。 当请求发送出去后，我们要想实现自动化地判断接口响应是否正确，我们还需要设置一些断言。而与HTTP响应相关的所有信息全都包含在response字段对应的字典中。 \"response\": { \"status\": 200, \"statusText\": \"OK\", \"httpVersion\": \"HTTP/1.1\", \"cookies\": [], \"headers\": [], \"content\": {}, \"redirectURL\": \"\", \"headersSize\" : 160, \"bodySize\" : 850, \"comment\" : \"\" } 从通用性的角度考虑，我们会判断HTTP响应的状态码是否正确，这对应着status字段；如果我们还想在接口业务层面具有更多的判断，我们还会判断响应内容中的一些关键字段是否符合预期，这对应着content字段。 \"content\": { \"size\": 33, \"compression\": 0, \"mimeType\": \"text/html; charset=utf-8\", \"text\": \"\\n\", \"comment\": \"\" } 对于content字段，可能会稍微复杂一些，因为接口响应内容的格式可能多种多样。 例如，响应内容可能text/html页面的形式，也可能是application/json的形式，具体类型可以查看mimeType得到，而具体的内容存储在text字段中。 另外，有时候响应数据还可能是经过编码的，用的最多的编码方式为base64。我们可以根据encoding字段获取得到具体的编码形式，然后采用对应的解码方式对text进行解码，最终获得原始的响应内容。 \"content\": { \"size\": 63, \"mimeType\": \"application/json; charset=utf-8\", \"text\": \"eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=\", \"encoding\": \"base64\" }, 以上面的content为例，我们通过encoding查看到编码形式为base64，并通过text字段获取到编码后的内容；那么我们就可以采用base64的解码函数，转换得到原始的内容。 \u003e\u003e\u003e import base64 \u003e\u003e\u003e base64.b64decode(text) b'{\"IsSuccess\":true,\"Code\":200,\"Value\":{\"BlnResult\":true}}' 同时，我们根据mimeType可以得到响应内容application/json数据类型，那么就可以对其再进行json.loads操作，最终得到可供程序处理的JSON数据结构。 通过上述对HAR格式的详细介绍，可以看出HAR格式十分清晰，在对其充分了解的基础上，再编写测试用例转换工具就很简单了。 ","date":"2017-11-14","heading":"HAR 格式详解","objectID":"/post/HttpRunner-supports-HAR/:2:0","tags":["HttpRunner","HAR"],"title":"HttpRunner 支持 HAR 意味着什么？","uri":"/post/HttpRunner-supports-HAR/#har-格式详解"},{"categories":["OpenSource"],"collections":null,"content":"har2case 编码过程没有太多值得说的，直接看最终成品吧。 最终产出的工具就是har2case，是一个命令行工具，可以直接将.har文件转换为YAML或JSON格式的自动化测试用例。 当前har2case已经上传到PYPI上了，通过pip或easy_install即可安装。 $ pip install har2case # or $ easy_install har2case 使用方式很简单，只需在har2case命令后分别带上HAR源文件路径和目标生成的YAML/JSON路径即可。 $ har2case tests/data/demo.har demo.yml INFO:root:Generate YAML testset successfully: demo.yml $ har2case tests/data/demo.har demo.json INFO:root:Generate JSON testset successfully: demo.json 可以看出，具体是生成YAML还是JSON格式的问题，取决于指定目标文件的后缀：后缀为.yml或.yaml则生成YAML文件，后缀为.json则生成JSON文件。 如果不指定目标文件也行，则会默认生成JSON文件，文件名称和路径与.har源文件相同。 $ har2case tests/data/demo.har INFO:root:Generate JSON testset successfully: tests/data/demo.json 具体的使用方式可以通过执行har2case -h查看。 在大多数情况下，生成的用例可直接在HttpRunner中使用，当然，是做接口自动化测试、接口性能测试，还是持续集成线上监控，这都取决于你。 不过，假如录制的场景中包含动态关联的情况，即后续接口请求参数依赖于前面接口的响应，并且每次调用接口时参数都会动态变化，那么就需要人工再对生成的脚本进行关联处理，甚至包括编写一些自定义函数等。 ","date":"2017-11-14","heading":"har2case","objectID":"/post/HttpRunner-supports-HAR/:3:0","tags":["HttpRunner","HAR"],"title":"HttpRunner 支持 HAR 意味着什么？","uri":"/post/HttpRunner-supports-HAR/#har2case"},{"categories":["OpenSource"],"collections":null,"content":"后续计划 读到这里，相信大家应该能体会到文章开头那三个感叹号的含义了，我也的确是带着难以言表的兴奋之情发布这个新功能的。 经过小范围的实际使用，效果很是不错，接口自动化测试用例的编写效率得到了极大的提升。而且，由于HAR本身的开放性，留给用户的选择非常多。 即便如此，我觉得HttpRunner的易用性还可以得到更大的提升。 当前，我规划了两项新特性将在近期完成： 支持PostMan：将Postman Collection Format格式转换为HttpRunner支持的YAML/JSON测试用例； 支持Swagger：将Swagger定义的API转换为HttpRunner支持的YAML/JSON测试用例。 等这两个新特性完成之后，相信HttpRunner会更上一个台阶。 如果你们有什么更好的想法，欢迎联系我。 ","date":"2017-11-14","heading":"后续计划","objectID":"/post/HttpRunner-supports-HAR/:4:0","tags":["HttpRunner","HAR"],"title":"HttpRunner 支持 HAR 意味着什么？","uri":"/post/HttpRunner-supports-HAR/#后续计划"},{"categories":["OpenSource"],"collections":null,"content":"在《ApiTestEngine，不再局限于API的测试》一文的末尾，我提到随着ApiTestEngine的发展，它的实际功能特性和名字已经不大匹配，需要考虑改名了。 经过慎重考虑，最终决定将ApiTestEngine正式更名为HttpRunner。 ","date":"2017-11-08","heading":"","objectID":"/post/ApiTestEngine-rename-to-HttpRunner/:0:0","tags":["HttpRunner"],"title":"ApiTestEngine 正式更名为 HttpRunner","uri":"/post/ApiTestEngine-rename-to-HttpRunner/#"},{"categories":["OpenSource"],"collections":null,"content":"名字的由来 为什么选择HttpRunner这个名字呢？ 在改名之前，我的想法很明确，就是要在新名字中体现该工具最核心的两个特点： 该工具可实现任意基于HTTP协议接口的测试（自动化测试、持续集成、线上监控都是以此作为基础） 该工具可同时实现性能测试（这是区别于其它工具的最大卖点） 围绕着这两点，我开始踏上了纠结的取名之路。 首先想到的，ApiTestEngine实现HTTP请求是依赖于Python Requests，实现性能测试是依赖于Locust，而Locust同样依赖于Python Requests。可以说，ApiTestEngine完全是构建在Python Requests之上的，后续无论怎么进化，这一层关系应该都不会变。 考虑到Python Requests的slogan是： Python HTTP Requests for Humans™ 因此，我想在ApiTestEngine的新名字中应该包含HTTP。 那如何体现性能测试呢？ 想到的关键词就load、perf、meter这些（来源于LoadRunner，NeoLoad，JMeter），但又不能直接用，因为名字中带有这些词让人感觉就只是性能测试工具。而且，还要考虑跟HTTP这个词进行搭配。 最终，感觉runner这个词比较合适，一方面这来源于LoadRunner，大众的认可度可能会比较高；同时，这个词用在自动化测试和性能测试上都不会太牵强。 更重要的是，HttpRunner这个组合词当前还没有人用过，不管是PyPI还是GitHub，甚至域名都是可注册状态。 所以，就认定HttpRunner这个名字了。 ","date":"2017-11-08","heading":"名字的由来","objectID":"/post/ApiTestEngine-rename-to-HttpRunner/:1:0","tags":["HttpRunner"],"title":"ApiTestEngine 正式更名为 HttpRunner","uri":"/post/ApiTestEngine-rename-to-HttpRunner/#名字的由来"},{"categories":["OpenSource"],"collections":null,"content":"相关影响 ApiTestEngine更名为HttpRunner之后，会对用户产生哪些影响呢？ 先说结论，没有任何不好的影响！ 在链接访问方面，受益于GitHub仓库链接的自动重定向机制，仓库在改名或者过户（Transfer ownership）之后，访问原有链接会自动实现重定向，因此之前博客中的链接也都不会受到影响。 新的仓库地址：https://github.com/httprunner/httprunner 在使用的命令方面，HttpRunner采用httprunner作为新的命令代替原有的ate命令；当然，为了考虑兼容性，HttpRunner对ate命令也进行了保留，因此httprunner和ate命令同时可用，并完全等价。在性能测试方面，locusts命令保持不变。 $ httprunner -V HttpRunner version: 0.8.1b PyUnitReport version: 0.1.3b 既然是全新的名字，新的篇章必然也得有一些新的东西。 为了方面用户安装，HttpRunner已托管至PyPI；后续大家可以方便的采用pip命令进行安装。 $ pip install HttpRunner 同时，HttpRunner 新增了大量使用说明文档，访问网址：https://httprunner.com/docs 关于项目改名这事儿，就说到这儿吧，希望你们也喜欢。 Hello World, HttpRunner. ","date":"2017-11-08","heading":"相关影响","objectID":"/post/ApiTestEngine-rename-to-HttpRunner/:2:0","tags":["HttpRunner"],"title":"ApiTestEngine 正式更名为 HttpRunner","uri":"/post/ApiTestEngine-rename-to-HttpRunner/#相关影响"},{"categories":["OpenSource"],"collections":null,"content":"背景 从编写《接口自动化测试的最佳工程实践（ApiTestEngine）》至今，已经快半年了。在这一段时间内，ApiTestEngine经过持续迭代，也已完全实现了当初预设的目标。 然而，在设计ApiTestEngine之初只考虑了面向最常规的API接口类型，即HTTP响应内容为JSON数据结构的类型。那么，如果HTTP接口响应内容不是JSON，而是XML或SOAP，甚至为HTML呢？ 答案是，不支持！ 不支持的原因是什么呢？ 其实，不管是何种业务类型或者技术架构的系统接口，我们在对其进行测试时都可以拆分为三步： 发起接口请求（Request） 解析接口响应（Parse Response） 校验测试结果（Validation） 而ApiTestEngine不支持XML/HTML类型的接口，问题恰恰是出现在解析接口响应和校验测试结果这两个环节。考虑到校验测试结果环节是依赖于解析接口响应，即需要先从接口响应结果中解析出具体的字段，才能实现与预期结果的校验检测，因此，制约ApiTestEngine无法支持XML/HTML类型接口的根本原因在于无法支持对XML/HTML的解析。 也因为这个原因，ApiTestEngine存在局限性，没法推广到公司内部的所有项目组。遇到JSON类型以外的接口时，只能再使用别的测试工具，体验上很是不爽。 在经历了一段时间的不爽后，我开始重新思考ApiTestEngine的设计，希望使其具有更大的适用范围。通过前面的分析我们也不难看出，解决问题的关键在于实现针对XML/HTML的解析器。 ","date":"2017-11-06","heading":"背景","objectID":"/post/apitestengine-not-only-about-json-api/:1:0","tags":["HttpRunner","正则表达式"],"title":"ApiTestEngine，不再局限于 API 的测试","uri":"/post/apitestengine-not-only-about-json-api/#背景"},{"categories":["OpenSource"],"collections":null,"content":"JSON接口的解析 在实现XML/HTML的解析器之前，我们不妨先看下ApiTestEngine的JSON解析器是怎么工作的。 在JSON类型的数据结构中，无论结构有多么复杂，数据字段都只可能为如下三种数据类型之一： 值（value）类型，包括数字、字符串等；该种数据类型的特点是不会再有下一层极的数据； 字典（dict）类型；该种数据类型的特点是包含无序的下一层极的数据； 列表（list）类型：该种数据类型的特点是包含有序的下一层极的数据。 基于这一背景，ApiTestEngine在实现JSON的字段提取器（extractor）时，就采用了点（.）的运算符。 例如，假如HTTP接口响应的headers和body为如下内容： response headers: { \"Content-Type\": \"application/json\", \"Content-Length\": 69 } response body: { \"success\": false, \"person\": { \"name\": { \"first_name\": \"Leo\", \"last_name\": \"Lee\", }, \"age\": 29, \"cities\": [\"Guangzhou\", \"Shenzhen\"] } } 那么对应的字段提取方式就为： \"headers.content-type\" =\u003e \"application/json\" \"headers.content-length\" =\u003e 69 \"body.success\"/\"content.success\"/\"text.success\" =\u003e false \"content.person.name.first_name\" =\u003e \"Leo\" \"content.person.age\" =\u003e 29 \"content.person.cities\" =\u003e [\"Guangzhou\", \"Shenzhen\"] \"content.person.cities.0\" =\u003e \"Guangzhou\" \"content.person.cities.1\" =\u003e \"Shenzhen\" 可以看出，通过点（.）运算符，我们可以从上往下逐级定位到具体的字段： 当下一级为字典时，通过.key来指定下一级的节点，例如.person，指定了content下的person节点； 当下一级为列表时，通过.index来指定下一级的节点，例如.0，指定了cities下的第一个元素。 定位到具体字段后，我们也就可以方便地提取字段值供后续使用了，作为参数或者进行结果校验均可。 ","date":"2017-11-06","heading":"JSON接口的解析","objectID":"/post/apitestengine-not-only-about-json-api/:2:0","tags":["HttpRunner","正则表达式"],"title":"ApiTestEngine，不再局限于 API 的测试","uri":"/post/apitestengine-not-only-about-json-api/#json接口的解析"},{"categories":["OpenSource"],"collections":null,"content":"实现XML/HTML的解析器 从点（.）运算符的描述形式上来看，它和XML/HTML的xpath十分类似。既然如此，那我们针对XML/HTML类型的接口，是否可以基于xpath来实现解析器呢？ 在大多数情况下的确可以。例如，针对如下HTML页面，当我们要获取标题信息时，我们就可以通过xpath来指定提取字段：body/h1 订单页面 订单号：SA89193 然而，如果我们想获取订单号（SA89193）时，使用xpath就没有办法了（通过body/div/p获取到的是订单号：SA89193，还需进一步地进行处理）。 那除了xpath，我们还能使用什么其它方法从XML/HTML中提取特定字段呢？ 由于早些年对LoadRunner比较熟悉，因此我首先想到了LoadRunner的web_reg_save_param函数；在该函数中，我们可以通过指定左右边界（LB \u0026amp; RB）来查找字段，将其提取出来并保存到变量中供后续使用。借鉴这种方式虽然可行，但在描述方式上还是比较复杂，特别是在YAML测试用例的extract中描述的时候。 再一想，这种方式的底层实现不就是正则表达式么。而且我们通过Python脚本解析网页时，采用正则表达式来对目标字段进行匹配和提取，的确也是通用性非常强的方式。 例如，假设我们现在想从https://debugtalk.com首页中提取出座右铭，通过查看网页源代码，我们可以看到座右铭对应的位置。 探索一个软件工程师的无限可能 那么，要提取“探索一个软件工程师的无限可能”字符串时，我们就可以使用正则表达式r\u0026quot;blog-motto\\\u0026quot;\u0026gt;(.*)\u0026lt;/h2\u0026gt;\u0026quot;进行匹配，然后使用regex的group将匹配内容提取出来。 对应的Python脚本实现如下所示。 \u003e\u003e\u003e import re, requests \u003e\u003e\u003e resp = requests.get(\"https://debugtalk.com\") \u003e\u003e\u003e content = resp.text \u003e\u003e\u003e matched = re.search(r\"blog-motto\\\"\u003e(.*)\", content) \u003e\u003e\u003e matched.group(1) '探索一个软件工程师的无限可能' 思路确定后，实现起来就很快了。 此处省略256字。。。 最终，我在ApiTestEngine中新增实现了一个基于正则表达式的提取器。使用形式与JSON解析保持一致，只需要将之前的点（.）运算符更改为正则表达式即可。 还是前面提取座右铭的例子，我们就可以通过YAML格式来编写测试用例。 - test: name: demo request: url: https://debugtalk.com/ method: GET extract: - motto: 'blog-motto\\\"\u003e(.*)' validate: - {\"check\": \"status_code\", \"expected\": 200} 需要说明的是，指定的正则表达式必须满足r\u0026quot;.*\\(.*\\).*\u0026quot;的格式要求，必须并且只能有一个分组（即一对括号）。如果在同一段内容中需要提取多个字段，那就分多次匹配即可。 ","date":"2017-11-06","heading":"实现XML/HTML的解析器","objectID":"/post/apitestengine-not-only-about-json-api/:3:0","tags":["HttpRunner","正则表达式"],"title":"ApiTestEngine，不再局限于 API 的测试","uri":"/post/apitestengine-not-only-about-json-api/#实现xmlhtml的解析器"},{"categories":["OpenSource"],"collections":null,"content":"写在最后 实现了基于正则表达式的提取器后，我们就彻底实现了对任意格式HTTP响应内容的解析，不仅限于XML/HTML类型，对于任意基于HTTP协议的的接口，ApiTestEngine都可以适用了。当然，如果接口响应是JSON类型，我们虽然可以也使用正则表达式提取，但更建议采用原有的点（.）运算符形式，因为描述更清晰。 至此，ApiTestEngine可以说是真正意义上实现了，面向任意类型的HTTP协议接口，只需要编写维护一份YAML用例，即可同时实现接口自动化测试、性能测试、持续集成、线上监控的全测试类型覆盖！ 现在看来，ApiTestEngine的名字与其实际功能有些不大匹配了，是该考虑改名了。 ","date":"2017-11-06","heading":"写在最后","objectID":"/post/apitestengine-not-only-about-json-api/:4:0","tags":["HttpRunner","正则表达式"],"title":"ApiTestEngine，不再局限于 API 的测试","uri":"/post/apitestengine-not-only-about-json-api/#写在最后"},{"categories":["OpenSource"],"collections":null,"content":"背景描述 在ApiTestEngine中编写测试用例时，我们有时需要定义全局的变量，或者引用外部函数实现一些动态的计算逻辑。当前采用的方式是： 若需定义全局的参数变量，则要在YAML/JSON的config中，使用variables定义变量； 若需引用外部函数，则要在YAML/JSON的config中，使用import_module_items导入指定的Python模块。 虽然这种方式提供了极大的灵活性，但是对于用户来说可能会显得比较复杂。另外一方面，这种方式也会造成大量重复的情况。 例如，对于变量来说，假如我们的项目中存在100个测试场景，而每个场景中都需要将用户账号（test@ijd）作为全局变量来使用，那么在现有模式下，我们只能在这100个YAML/JSON文件的config中都采用如下方式定义一遍： - config: name: \"smoketest for scenario A.\" variables: - username: test@ijd 同样的，对于外部函数来说，假如我们项目的100个测试场景都需要用到生成随机字符串的函数（gen_random_string），那么我们也不得不在这100个YAML/JSON文件的config中都导入一次该函数所在的Python模块（假设相对于工作目录的路径为extra/utils.py）。 - config: name: \"smoketest for scenario A.\" import_module_items: - extra.utils 由此可见，当测试场景越来越多以后，要维护好全局变量和外部函数，必定会是一个很大的工作量。 那么，如果既要能引用公共的变量和函数，又要减少重复的定义和导入，那要怎么做呢？ ","date":"2017-09-09","heading":"背景描述","objectID":"/post/apitestengine-hot-plugin/:1:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#背景描述"},{"categories":["OpenSource"],"collections":null,"content":"pytest 的 conftest.py 前段时间在接触pytest时，看到pytest支持conftest.py的插件机制，这是一种在测试文件中可以实现模块自动发现和热加载的机制。具体地，只要是在文件目录存在命名为conftest.py的文件，里面定义的hook函数都会在pytest运行过程中被导入，并可被测试用例进行调用。同时，conftest.py存在优先级策略，从测试用例所在目录到系统根目录的整个路径中，越靠近测试用例的conftest.py优先级越高。 其实这也是采用了约定大于配置（convention over configuration）的思想。约定大于配置是一种软件设计范式，旨在减少软件开发人员需做决定的数量，在遵从约定的过程中就不自觉地沿用了最佳工程实践。我个人也是比较喜欢这种方式的，所以在设计ApiTestEngine的时候，也借鉴了一些类似的思想。 受到该启发，我想也可以采用类似的思想，采用自动热加载的机制，解决背景描述中存在的重复定义和引用的问题。 既然是约定大于配置，那么我们首先就得定一个默认的Python模块名，类似于pytest的conftest.py。 这就是debugtalk.py。 ","date":"2017-09-09","heading":"pytest 的 conftest.py","objectID":"/post/apitestengine-hot-plugin/:2:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#pytest-的-conftestpy"},{"categories":["OpenSource"],"collections":null,"content":"debugtalk.py 的命名由来 为啥会采用debugtalk.py这个命名呢？ 其实当时在想这个名字的时候也是耗费了很多心思，毕竟是要遵从约定大于配置的思想，因此在设计这个约定的命名时就格外谨慎，但始终没有想到一个既合适又满意的。 在我看来，这个命名应该至少满足如下两个条件： 唯一性强 简单易记 首先，约定的模块名应该具有较强的唯一性和较高的区分度，是用户通常都不会采用的命名；否则，可能就会出现测试用例在运行过程中，热加载时导入预期之外的Python模块。 但也不能仅仅为了具有区分度，就使用一个很长或者毫无意义的字符串作为模块名；毕竟还是要给用户使用的，总不能每次写用例时还要去查看下文档吧；所以命名简单易记便于用户使用也很重要。 也是因为这两个有点互相矛盾的原则，让我在设计命名时很是纠结。最终在拉同事讨论良久而无果的时候，同事说，不如就命名为debugtalk.py得了。 仔细一想，这命名还真符合要求。在唯一性方面，采用debugtalk.py在Google、Bing、Baidu等搜索引擎中采用精确匹配，基本没有无关信息，这样在后续遇到问题时，也容易搜索到已有的解决方案；而在简单易记方面，相信这个命名也不会太复杂。 当然，debugtalk.py只是作为框架默认加载的Python模块名，如果你不喜欢，也可以进行配置修改。 ","date":"2017-09-09","heading":"debugtalk.py 的命名由来","objectID":"/post/apitestengine-hot-plugin/:3:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#debugtalkpy-的命名由来"},{"categories":["OpenSource"],"collections":null,"content":"热加载机制实现原理 然后，再来讲解下热加载机制的实现。 其实原理也不复杂，从背景描述可以看出，我们期望实现的需求主要有两点： 自动发现debugtalk.py函数模块，并且具有优先级策略； 将debugtalk.py函数模块中的变量和函数导入到当前框架运行的内存空间。 将这两点与测试用例引擎的实现机制结合起来，ApiTestEngine在运行过程中的热加载机制应该就如下图所示。 这个流程图对热加载机制描述得已经足够清晰了，我再针对其中的几个点进行说明： 1、在初始化测试用例集（testset）的时候，除了将config中variables和import_module_items指定的变量和函数导入外，还会默认导入ate/built_in.py模块。之所以这么做，是因为对于大多数系统可能都会用到一些通用的函数，例如获取当前时间戳（get_timestamp）、生成随机字符串（gen_random_string）等。与其在每个项目中都单独去实现这些函数，不如就将其添加到框架中作为默认支持的函数（相当于框架层面的debugtalk.py），这样大家在项目中就不需要再重复做这些基础性工作了。 2、在ApiTestEngine框架中，存在测试用例（testcase）和测试用例集（testset）两个层面的作用域，两者的界限十分明确。这样设计的目的在于，我们既可以实现用例集层面的变量和函数的定义和导入，也可以保障各个用例之间的独立性，不至于出现作用域相互污染的情况。具体地，作用域在用例集初始化时定义或导入的变量和函数，会存储在用例集层面的作用域；而在运行每条测试用例时，会先继承（deepcopy）用例集层面的作用域，如果存在同名的变量或函数定义，则会对用例集层面的变量和函数进行覆盖，同时用例集层面的变量和函数也并不会被修改。 3、从热加载的顺序可以看出，查找变量或函数的顺序是从测试用例所在目录开始，沿着父路径逐层往上，直到系统的根目录。因此，我们可以利用这个优先级原则来组织我们的用例和依赖的Python函数模块。例如，我们可以将不同模块的测试用例集文件放在不同的文件夹下：针对各个模块独有的依赖函数和变量，可以放置在对应文件夹的debugtalk.py文件中；而整个项目公共的函数和变量，就可以放置到项目文件夹的debugtalk.py中。 文件组织结构如下所示： ➜ project ✗ tree . . ├── debugtalk.py ├── module_A │ ├── __init__.py │ ├── debugtalk.py │ ├── testsetA1.yml │ └── testsetA2.yml └── module_B ├── __init__.py ├── debugtalk.py ├── testsetB1.yml └── testsetB2.yml 这其中还有一点需要格外注意。因为我们在框架运行过程中需要将debugtalk.py作为函数模块进行导入，因此我们首先要保障debugtalk.py满足Python模块的要求，也就是在对应的文件夹中要包含__init__.py文件。 如果对热加载机制的实现感兴趣，可直接阅读框架源码，重点只需查看ate/utils.py中的三个函数： search_conf_item(start_path, item_type, item_name) get_imported_module_from_file(file_path) filter_module(module, filter_type) ","date":"2017-09-09","heading":"热加载机制实现原理","objectID":"/post/apitestengine-hot-plugin/:4:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#热加载机制实现原理"},{"categories":["OpenSource"],"collections":null,"content":"测试用例编写方式的变化 在新增热加载机制之后，编写测试用例的方式发生一些改变（优化），主要包括三点： 导入Python模块的关键词改名为import_module_items（原名为import_module_functions）； 不再需要显式指定导入的Python模块路径，变更为热加载机制自动发现； Python模块中的变量也会被导入，公共变量可放置在Python模块中，而不再必须通过variables定义。 考虑到兼容性问题，框架升级的同时也保留了对原有测试用例编写方式的支持，因此框架升级对已有测试用例的正常运行也不会造成影响。不过，我还是强烈建议大家采用最新的用例编写方式，充分利用热加载机制带来的便利。 ","date":"2017-09-09","heading":"测试用例编写方式的变化","objectID":"/post/apitestengine-hot-plugin/:5:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#测试用例编写方式的变化"},{"categories":["OpenSource"],"collections":null,"content":"写在最后 现在回过头来看ApiTestEngine的演进历程，以及之前写的关于ApiTestEngine设计方面的文章，会发现当初的确是有一些考虑不周全的地方。也许这也是编程的乐趣所在吧，在前行的道路中，总会有新的感悟和新的收获，迭代优化的过程，就仿佛是在打磨一件艺术品。 这种感觉，甚好！ ","date":"2017-09-09","heading":"写在最后","objectID":"/post/apitestengine-hot-plugin/:6:0","tags":["HttpRunner"],"title":"约定大于配置：ApiTestEngine实现热加载机制","uri":"/post/apitestengine-hot-plugin/#写在最后"},{"categories":["OpenSource"],"collections":null,"content":"ApiTestEngine不是接口测试框架么，也能实现性能测试？ 是的，你没有看错，ApiTestEngine集成了Locust性能测试框架，只需一份测试用例，就能同时实现接口自动化测试和接口性能测试，在不改变Locust任何特性的情况下，甚至比Locust本身更易用。 如果你还没有接触过Locust这款性能测试工具，那么这篇文章可能不适合你。但我还是强烈推荐你了解一下这款工具。简单地说，Locust是一款采用Python语言编写实现的开源性能测试工具，简洁、轻量、高效，并发机制基于gevent协程，可以实现单机模拟生成较高的并发压力。关于Locust的特性介绍和使用教程，我之前已经写过不少，你们可以在我的博客中找到对应文章。 如果你对实现的过程没有兴趣，可以直接跳转到文章底部，看最终实现效果章节。 ","date":"2017-08-27","heading":"","objectID":"/post/apitestengine-supersede-locust/:0:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#"},{"categories":["OpenSource"],"collections":null,"content":"灵感来源 在当前市面上的测试工具中，接口测试和性能测试基本上是两个泾渭分明的领域。这也意味着，针对同一个系统的服务端接口，我们要对其实现接口自动化测试和接口性能测试时，通常都是采用不同的工具，分别维护两份测试脚本或用例。 之前我也是这么做的。但是在做了一段时间后我就在想，不管是接口功能测试，还是接口性能测试，核心都是要模拟对接口发起请求，然后对接口响应内容进行解析和校验；唯一的差异在于，接口性能测试存在并发的概念，相当于模拟了大量用户同时在做接口测试。 既然如此，那接口自动化测试用例和接口性能测试脚本理应可以合并为一套，这样就可以避免重复的脚本开发工作了。 在开发ApiTestEngine的过程中，之前的文章也说过，ApiTestEngine完全基于Python-Requests库实现HTTP的请求处理，可以在编写接口测试用例时复用到Python-Requests的所有功能特性。而之前在学习Locust的源码时，发现Locust在实现HTTP请求的时候，也完全是基于Python-Requests库。 在这一层关系的基础上，我提出一个大胆的设想，能否通过一些方式或手段，可以使ApiTestEngine中编写的YAML/JSON格式的接口测试用例，也能直接让Locust直接调用呢？ ","date":"2017-08-27","heading":"灵感来源","objectID":"/post/apitestengine-supersede-locust/:1:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#灵感来源"},{"categories":["OpenSource"],"collections":null,"content":"灵感初探 想法有了以后，就开始探索实现的方法了。 首先，我们可以看下Locust的脚本形式。如下例子是一个比较简单的场景（截取自官网首页）。 from locust import HttpLocust, TaskSet, task class WebsiteTasks(TaskSet): def on_start(self): self.client.post(\"/login\", { \"username\": \"test_user\", \"password\": \"\" }) @task def index(self): self.client.get(\"/\") @task def about(self): self.client.get(\"/about/\") class WebsiteUser(HttpLocust): task_set = WebsiteTasks min_wait = 5000 max_wait = 15000 在Locust的脚本中，我们会在TaskSet子类中描述单个用户的行为，每一个带有@task装饰器的方法都对应着一个HTTP请求场景。而Locust的一个很大特点就是，所有的测试用例脚本都是Python文件，因此我们可以采用Python实现各种复杂的场景。 等等！模拟单个用户请求，而且还是纯粹的Python语言，我们不是在接口测试中已经实现的功能么？ 例如，下面的代码就是从单元测试中截取的测试用例。 def test_run_testset(self): testcase_file_path = os.path.join( os.getcwd(), 'examples/quickstart-demo-rev-3.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) test_runner.run_testset是已经在ApiTestEngine中实现的方法，作用是传入测试用例（YAML/JSON）的路径，然后就可以加载测试用例，运行整个测试场景。并且，由于我们在测试用例YAML/JSON中已经描述了validators，即接口的校验部分，因此我们也无需再对接口响应结果进行校验描述了。 接下来，实现方式就非常简单了。 我们只需要制作一个locustfile.py的模板文件，内容如下。 #coding: utf-8 import zmq import os from locust import HttpLocust, TaskSet, task from ate import utils, runner class WebPageTasks(TaskSet): def on_start(self): self.test_runner = runner.Runner(self.client) self.testset = self.locust.testset @task def test_specified_scenario(self): self.test_runner.run_testset(self.testset) class WebPageUser(HttpLocust): host = '' task_set = WebPageTasks min_wait = 1000 max_wait = 5000 testcase_file_path = os.path.join(os.getcwd(), 'skypixel.yml') testsets = utils.load_testcases_by_path(testcase_file_path) testset = testsets[0] 可以看出，整个文件中，只有测试用例文件的路径是与具体测试场景相关的，其它内容全都可以不变。 于是，针对不同的测试场景，我们只需要将testcase_file_path替换为接口测试用例文件的路径，即可实现对应场景的接口性能测试。 ➜ ApiTestEngine git:(master) ✗ locust -f locustfile.py [2017-08-27 11:30:01,829] bogon/INFO/locust.main: Starting web monitor at *:8089 [2017-08-27 11:30:01,831] bogon/INFO/locust.main: Starting Locust 0.8a2 后面的操作就完全是Locust的内容了，使用方式完全一样。 ","date":"2017-08-27","heading":"灵感初探","objectID":"/post/apitestengine-supersede-locust/:2:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#灵感初探"},{"categories":["OpenSource"],"collections":null,"content":"优化1：自动生成locustfile 通过前面的探索实践，我们基本上就实现了一份测试用例同时兼具接口自动化测试和接口性能测试的功能。 然而，在使用上还不够便捷，主要有两点： 需要手工修改模板文件中的testcase_file_path路径； locustfile.py模板文件的路径必须放在ApiTestEngine的项目根目录下。 于是，我产生了让ApiTestEngine框架本身自动生成locustfile.py文件的想法。 在实现这个想法的过程中，我想过两种方式。 第一种，通过分析Locust的源码，可以看到Locust在main.py中具有一个load_locustfile方法，可以加载Python格式的文件，并提取出其中的locust_classes（也就是Locust的子类）；后续，就是将locust_classes作为参数传给Locust的Runner了。 若采用这种思路，我们就可以实现一个类似load_locustfile的方法，将YAML/JSON文件中的内容动态生成locust_classes，然后再传给Locust的Runner。这里面会涉及到动态地创建类和添加方法，好处是不需要生成locustfile.py中间文件，并且可以实现最大的灵活性，但缺点在于需要改变Locust的源码，即重新实现Locust的main.py中的多个函数。虽然难度不会太大，但考虑到后续需要与Locust的更新保持一致，具有一定的维护工作量，便放弃了该种方案。 第二种，就是生成locustfile.py这样一个中间文件，然后将文件路径传给Locust。这样的好处在于我们可以不改变Locust的任何地方，直接对其进行使用。与Locust的传统使用方式差异在于，之前我们是在Terminal中通过参数启动Locust，而现在我们是在ApiTestEngine框架中通过Python代码启动Locust。 具体地，我在setup.py的entry_points中新增了一个命令locusts，并绑定了对应的程序入口。 entry_points={ 'console_scripts': [ 'ate=ate.cli:main_ate', 'locusts=ate.cli:main_locust' ] } 在ate/cli.py中新增了main_locust函数，作为locusts命令的入口。 def main_locust(): \"\"\" Performance test with locust: parse command line options and run commands. \"\"\" try: from locust.main import main except ImportError: print(\"Locust is not installed, exit.\") exit(1) sys.argv[0] = 'locust' if len(sys.argv) == 1: sys.argv.extend([\"-h\"]) if sys.argv[1] in [\"-h\", \"--help\", \"-V\", \"--version\"]: main() sys.exit(0) try: testcase_index = sys.argv.index('-f') + 1 assert testcase_index \u003c len(sys.argv) except (ValueError, AssertionError): print(\"Testcase file is not specified, exit.\") sys.exit(1) testcase_file_path = sys.argv[testcase_index] sys.argv[testcase_index] = parse_locustfile(testcase_file_path) main() 若你执行locusts -V或locusts -h，会发现效果与locust的特性完全一致。 $ locusts -V [2017-08-27 12:41:27,740] bogon/INFO/stdout: Locust 0.8a2 [2017-08-27 12:41:27,740] bogon/INFO/stdout: 事实上，通过上面的代码（main_locust）也可以看出，locusts命令只是对locust进行了一层封装，用法基本等价。唯一的差异在于，当-f参数指定的是YAML/JSON格式的用例文件时，会先转换为Python格式的locustfile.py，然后再传给locust。 至于解析函数parse_locustfile，实现起来也很简单。我们只需要在框架中保存一份locustfile.py的模板文件（ate/locustfile_template），并将testcase_file_path采用占位符代替。然后，在解析函数中，就可以读取整个模板文件，将其中的占位符替换为YAML/JSON用例文件的实际路径，然后再保存为locustfile.py，并返回其路径即可。 具体的代码就不贴了，有兴趣的话可自行查看。 通过这一轮优化，ApiTestEngine就继承了Locust的全部功能，并且可以直接指定YAML/JSON格式的文件启动Locust执行性能测试。 $ locusts -f examples/first-testcase.yml [2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 [2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 ","date":"2017-08-27","heading":"优化1：自动生成locustfile","objectID":"/post/apitestengine-supersede-locust/:3:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#优化1自动生成locustfile"},{"categories":["OpenSource"],"collections":null,"content":"优化2：一键启动多个locust实例 经过第一轮优化后，本来应该是告一段落了，因为此时ApiTestEngine已经可以非常便捷地实现接口自动化测试和接口性能测试的切换了。 直到有一天，在TesterHome论坛讨论Locust的一个回复中，@keithmork说了这么一句话。 期待有一天ApiTestEngine的热度超过Locust本身 看到这句话时我真的不禁泪流满面。虽然我也是一直在用心维护ApiTestEngine，却从未有过这样的奢望。 但反过来细想，为啥不能有这样的想法呢？当前ApiTestEngine已经继承了Locust的所有功能，在不影响Locust已有特性的同时，还可以采用YAML/JSON格式来编写维护测试用例，并实现了一份测试用例可同时用于接口自动化和接口性能测试的目的。 这些特性都是Locust所不曾拥有的，而对于使用者来说的确也都是比较实用的功能。 于是，新的目标在内心深处萌芽了，那就是在ApiTestEngine中通过对Locust更好的封装，让Locust的使用者体验更爽。 然后，我又想到了自己之前做的一个开源项目，debugtalk/stormer。当时做这个项目的初衷在于，当我们使用Locust进行压测时，要想使用压测机所有CPU的性能，就需要采用master-slave模式。因为Locust默认是单进程运行的，只能运行在压测机的一个CPU核上；而通过采用master-slave模式，启动多个slave，就可以让不同的slave运行在不同的CPU核上，从而充分发挥压测机多核处理器的性能。 而在实际使用Locust的时候，每次只能手动启动master，并依次手动启动多个slave。若遇到测试脚本调整的情况，就需要逐一结束Locust的所有进程，然后再重复之前的启动步骤。如果有使用过Locust的同学，应该对此痛苦的经历都有比较深的体会。当时也是基于这一痛点，我开发了debugtalk/stormer，目的就是可以一次性启动或销毁多个Locust实例。这个脚本做出来后，自己用得甚爽，也得到了Github上一些朋友的青睐。 既然现在要提升ApiTestEngine针对Locust的使用便捷性，那么这个特性毫无疑问也应该加进去。就此，debugtalk/stormer项目便被废弃，正式合并到debugtalk/ApiTestEngine。 想法明确后，实现起来也挺简单的。 原则还是保持不变，那就是不改变Locust本身的特性，只在传参的时候在中间层进行操作。 具体地，我们可以新增一个--full-speed参数。当不指定该参数时，使用方式跟之前完全相同；而指定--full-speed参数后，就可以采用多进程的方式启动多个实例（实例个数等于压测机的处理器核数）。 def main_locust(): # do original work if \"--full-speed\" in sys.argv: locusts.run_locusts_at_full_speed(sys.argv) else: locusts.main() 具体实现逻辑在ate/locusts.py中： import multiprocessing from locust.main import main def start_master(sys_argv): sys_argv.append(\"--master\") sys.argv = sys_argv main() def start_slave(sys_argv): sys_argv.extend([\"--slave\"]) sys.argv = sys_argv main() def run_locusts_at_full_speed(sys_argv): sys_argv.pop(sys_argv.index(\"--full-speed\")) slaves_num = multiprocessing.cpu_count() processes = [] for _ in range(slaves_num): p_slave = multiprocessing.Process(target=start_slave, args=(sys_argv,)) p_slave.daemon = True p_slave.start() processes.append(p_slave) try: start_master(sys_argv) except KeyboardInterrupt: sys.exit(0) 由此可见，关键点也就是使用了multiprocessing.Process，在不同的进程中分别调用Locust的main()函数，实现逻辑十分简单。 ","date":"2017-08-27","heading":"优化2：一键启动多个locust实例","objectID":"/post/apitestengine-supersede-locust/:4:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#优化2一键启动多个locust实例"},{"categories":["OpenSource"],"collections":null,"content":"最终实现效果 经过前面的优化，采用ApiTestEngine执行性能测试时，使用就十分便捷了。 安装ApiTestEngine后，系统中就具有了locusts命令，使用方式跟Locust框架的locust几乎完全相同，我们完全可以使用locusts命令代替原生的locust命令。 例如，下面的命令执行效果与locust完全一致。 $ locusts -V $ locusts -h $ locusts -f locustfile.py $ locusts -f locustfile.py --master -P 8088 $ locusts -f locustfile.py --slave \u0026 差异在于，locusts具有更加丰富的功能。 在ApiTestEngine中编写的YAML/JSON格式的接口测试用例文件，直接运行就可以启动Locust运行性能测试。 $ locusts -f examples/first-testcase.yml [2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 [2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 加上--full-speed参数，就可以同时启动多个Locust实例（实例个数等于处理器核数），充分发挥压测机多核处理器的性能。 $ locusts -f examples/first-testcase.yml --full-speed -P 8088 [2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8088 [2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm. [2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm. [2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm. [2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. 后续，ApiTestEngine将持续进行优化，欢迎大家多多反馈改进建议。 Enjoy! ","date":"2017-08-27","heading":"最终实现效果","objectID":"/post/apitestengine-supersede-locust/:5:0","tags":["HttpRunner","Locust"],"title":"ApiTestEngine 集成 Locust 实现更好的性能测试体验","uri":"/post/apitestengine-supersede-locust/#最终实现效果"},{"categories":["OpenSource"],"collections":null,"content":"Introduction to Sample Interface Service Along with this project, I devised a sample interface service, and you can use it to familiarize how to play with ApiTestEngine. This sample service mainly has two parts: Authorization, each request of other APIs should sign with some header fields and get token first. RESTful APIs for user management, you can do CRUD manipulation on users. As you see, it is very similar to the mainstream production systems. Therefore once you are familiar with handling this demo service, you can master most test scenarios in your project. ","date":"2017-08-20","heading":"Introduction to Sample Interface Service","objectID":"/post/apitestengine-quickstart/:1:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#introduction-to-sample-interface-service"},{"categories":["OpenSource"],"collections":null,"content":"Launch Sample Interface Service The demo service is a flask server, we can launch it in this way. $ export FLASK_APP=tests/api_server.py $ flask run * Serving Flask app \"tests.api_server\" * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) Now the sample interface service is running, and we can move on to the next step. ","date":"2017-08-20","heading":"Launch Sample Interface Service","objectID":"/post/apitestengine-quickstart/:2:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#launch-sample-interface-service"},{"categories":["OpenSource"],"collections":null,"content":"Capture HTTP request and response Before we write testcases, we should know the details of the API. It is a good choice to use a web debugging proxy tool like Charles Proxy to capture the HTTP traffic. For example, the image below illustrates getting token from the sample service first, and then creating one user successfully. After thorough understanding of the APIs, we can now begin to write testcases. ","date":"2017-08-20","heading":"Capture HTTP request and response","objectID":"/post/apitestengine-quickstart/:3:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#capture-http-request-and-response"},{"categories":["OpenSource"],"collections":null,"content":"Write the first test case Open your favorite text editor and you can write test cases like this. - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: POST headers: user_agent: iOS/10.3 device_sn: 9TN6O2Bn1vzfybF os_platform: ios app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - test: name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: device_sn: 9TN6O2Bn1vzfybF token: F8prvGryC5beBr4g json: name: \"user1\" password: \"123456\" validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} As you see, each API request is described in a test block. And in the request field, it describes the detail of HTTP request, includes url, method, headers and data, which are in line with the captured traffic. You may wonder why we use the json field other than data. That\u0026rsquo;s because the post data is in JSON format, when we use json to indicate the post data, we do not have to specify Content-Type to be application/json in request headers or dump data before request. Have you recalled some familiar scenes? Yes! That\u0026rsquo;s what we did in requests.request! Since ApiTestEngine takes full reuse of Requests, it inherits all powerful features of Requests, and we can handle HTTP request as the way we do before. ","date":"2017-08-20","heading":"Write the first test case","objectID":"/post/apitestengine-quickstart/:4:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#write-the-first-test-case"},{"categories":["OpenSource"],"collections":null,"content":"Run test cases Suppose the test case file is named as quickstart-demo-rev-0.yml and is located in examples folder, then we can run it in this way. ate examples/demo-rev-0.yml Running tests... ---------------------------------------------------------------------- get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 48 ms, response_length: 46 bytes OK (0.049669)s create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000 ERROR:root: Failed to POST http://127.0.0.1:5000/api/users/1000! exception msg: 403 Client Error: FORBIDDEN for url: http://127.0.0.1:5000/api/users/1000 ERROR (0.006471)s ---------------------------------------------------------------------- Ran 2 tests in 0.056s FAILED (Errors=1) Oops! The second test case failed with 403 status code. That is because we request with the same data as we captured in Charles Proxy, while the token is generated dynamically, thus the recorded data can not be be used twice directly. ","date":"2017-08-20","heading":"Run test cases","objectID":"/post/apitestengine-quickstart/:5:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#run-test-cases"},{"categories":["OpenSource"],"collections":null,"content":"Optimize test case: correlation To fix this problem, we should correlate token field in the second API test case, which is also called correlation. - test: name: get token request: url: http://127.0.0.1:5000/api/get-token method: POST headers: user_agent: iOS/10.3 device_sn: 9TN6O2Bn1vzfybF os_platform: ios app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 extractors: - token: content.token validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 200} - {\"check\": \"content.token\", \"comparator\": \"len_eq\", \"expected\": 16} - test: name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: device_sn: 9TN6O2Bn1vzfybF token: $token json: name: \"user1\" password: \"123456\" validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} As you see, the token field is no longer hardcoded, instead it is extracted from the first API request with extractors mechanism. In the meanwhile, it is assigned to token variable, which can be referenced by the subsequent API requests. Now we save the test cases to quickstart-demo-rev-1.yml and rerun it, and we will find that both API requests to be successful. ","date":"2017-08-20","heading":"Optimize test case: correlation","objectID":"/post/apitestengine-quickstart/:6:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#optimize-test-case-correlation"},{"categories":["OpenSource"],"collections":null,"content":"Optimize test case: parameterization Let\u0026rsquo;s look back to our test set quickstart-demo-rev-1.yml, and we can see the device_sn field is still hardcoded. This may be quite different from the actual scenarios. In actual scenarios, each user\u0026rsquo;s device_sn is different, so we should parameterize the request parameters, which is also called parameterization. In the meanwhile, the sign field is calculated with other header fields, thus it may change significantly if any header field changes slightly. However, the test cases are only YAML documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine Python scripts with YAML test cases in ApiTestEngine. To achieve this goal, we can utilize import_module_functions and variables mechanisms. To be specific, we can create a Python file (examples/utils.py) and implement the related algorithm in it. Since we want to import this file, so we should put a __init__.py in this folder to make it as a Python module. import hashlib import hmac import random import string SECRET_KEY = \"DebugTalk\" def get_sign(*args): content = ''.join(args).encode('ascii') sign_key = SECRET_KEY.encode('ascii') sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() return sign def gen_random_string(str_len): random_char_list = [] for _ in range(str_len): random_char = random.choice(string.ascii_letters + string.digits) random_char_list.append(random_char) random_string = ''.join(random_char_list) return random_string And then, we can revise our demo test case and reference the functions. Suppose the revised file named quickstart-demo-rev-2.yml - test: name: get token import_module_functions: - examples.utils variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' request: url: http://127.0.0.1:5000/api/get-token method: POST headers: user_agent: $user_agent device_sn: $device_sn os_platform: $os_platform app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extractors: - token: content.token validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 200} - {\"check\": \"content.token\", \"comparator\": \"len_eq\", \"expected\": 16} - test: name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: device_sn: $device_sn token: $token json: name: \"user1\" password: \"123456\" validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} In this revised test case, we firstly import module functions in import_module_functions block by specifying the Python module path, which is relative to the current working directory. To make fields like device_sn can be used more than once, we also bind values to variables in variables block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. When we want to reference a variable in the test case, we can do this with a escape character $. For example, $user_agent will not be taken as a normal string, and ApiTestEngine will consider it as a variable named user_agent, search and return its binding value. When we want to reference a function, we shall use another escape character ${}. Any content in ${} will be considered as function calling, so we should guarantee that we call functions in the right way. At the same time, variables can also be referenced as parameters of function. ","date":"2017-08-20","heading":"Optimize test case: parameterization","objectID":"/post/apitestengine-quickstart/:7:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#optimize-test-case-parameterization"},{"categories":["OpenSource"],"collections":null,"content":"Optimize test case: overall config block There is still one issue unsolved. The device_sn field is defined in the first API test case, thus it may be impossible to reference it in other test cases. Context separation is a well-designed mechanism, and we should obey this good practice. To handle this case, overall config block is supported in ApiTestEngine. If we define variables or import functions in config block, these variables and functions will become global and can be referenced in the whole test set. # examples/quickstart-demo-rev-3.yml - config: name: \"smoketest for CRUD users.\" import_module_functions: - examples.utils variables: - device_sn: ${gen_random_string(15)} request: base_url: http://127.0.0.1:5000 headers: device_sn: $device_sn - test: name: get token variables: - user_agent: 'iOS/10.3' - os_platform: 'ios' - app_version: '2.8.6' request: url: /api/get-token method: POST headers: user_agent: $user_agent os_platform: $os_platform app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extractors: - token: content.token validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 200} - {\"check\": \"content.token\", \"comparator\": \"len_eq\", \"expected\": 16} - test: name: create user which does not exist request: url: /api/users/1000 method: POST headers: token: $token json: name: \"user1\" password: \"123456\" validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} As you see, we import public Python modules and variables in config block. Also, we can set base_url in config block, thereby we can only specify relative path in each API request url. Besides, we can also set common fields in config request, such as device_sn in headers. Until now, the test cases are finished and each detail is handled properly. ","date":"2017-08-20","heading":"Optimize test case: overall config block","objectID":"/post/apitestengine-quickstart/:8:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#optimize-test-case-overall-config-block"},{"categories":["OpenSource"],"collections":null,"content":"Run test cases and generate report Finally, let\u0026rsquo;s run test set quickstart-demo-rev-4.yml once more. $ ate examples/quickstart-demo-rev-4.yml Running tests... ---------------------------------------------------------------------- get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token INFO:root: status_code: 200, response_time: 33 ms, response_length: 46 bytes OK (0.037027)s create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000 INFO:root: status_code: 201, response_time: 15 ms, response_length: 54 bytes OK (0.016414)s ---------------------------------------------------------------------- Ran 2 tests in 0.054s OK Generating HTML reports... Template is not specified, load default template instead. Reports generated: /Users/Leo/MyProjects/ApiTestEngine/reports/quickstart-demo-rev-0/2017-08-01-16-51-51.html Great! The test case runs successfully and generates a HTML test report. ","date":"2017-08-20","heading":"Run test cases and generate report","objectID":"/post/apitestengine-quickstart/:9:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#run-test-cases-and-generate-report"},{"categories":["OpenSource"],"collections":null,"content":"Further more This is just a starting point, see the advanced guide for the advanced features. templating data extraction and validation comparator ","date":"2017-08-20","heading":"Further more","objectID":"/post/apitestengine-quickstart/:10:0","tags":["HttpRunner"],"title":"ApiTestEngine QuickStart","uri":"/post/apitestengine-quickstart/#further-more"},{"categories":["Development"],"collections":null,"content":"最近在开发ApiTestEngine时遇到一个安装包依赖的问题，耗费了不少时间寻找解决方案，考虑到还算比较有普遍性，因此总结形成这篇文章。 ","date":"2017-08-05","heading":"","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:0:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#"},{"categories":["Development"],"collections":null,"content":"从 pip install 说起 先不那么简单地描述下背景。 ApiTestEngine作为一款接口测试工具，需要具有灵活的命令行调用方式，因此最好能在系统中进行安装并注册为一个CLI命令。 在Python中，安装依赖库的最佳方式是采用pip，例如安装Locust时，就可以采用如下命令搞定。 $ pip install locustio Collecting locustio Using cached locustio-0.7.5.tar.gz [...] Successfully installed locustio-0.7.5 但要想采用pip install SomePackage的方式，前提是SomePackage已经托管在PyPI。关于PyPI，可以理解为Python语言的第三方库的仓库索引，当前绝大多数流行的Python第三方库都托管在PyPI上。 但是，这里存在一个问题。在PyPI当中，所有的包都是由其作者自行上传的。如果作者比较懒，那么可能托管在PyPI上的最新版本相较于最新代码就会比较滞后。 Locust就是一个典型的例子。从上面的安装过程可以看出，我们采用pip install locustio安装的Locust版本是v0.7.5，而在Locust的Github仓库中，v0.7.5已经是一年之前的版本了。也是因为这个原因，之前在我的博客里面介绍Locust的图表展示功能后，已经有不下5个人向我咨询为啥他们看不到这个图表模块。这是因为Locust的图表模块是在今年（2017）年初时添加的功能，master分支的代码版本也已经升级到v0.8a2了，但PyPI上的版本却一直没有更新。 而要想使用到项目最新的功能，就只能采用源码进行安装。 大多数编程语言在使用源码进行安装时，都需要先将源码下载到本地，然后通过命令进行编译，例如Linux中常见的make \u0026amp;\u0026amp; make install。对于Python项目来说，也可以采用类似的模式，先将项目clone到本地，然后进入到项目的根目录，执行python setup.py install。 $ git clone https://github.com/locustio/locust.git $ cd locust $ python setup.py install [...] Finished processing dependencies for locustio==0.8a2 不过，要想采用这种方式进行安装也是有前提的，那就是项目必须已经实现了基于setuptools的安装方式，并在项目的根目录下存在setup.py。 可以看出，这种安装方式还是比较繁琐的，需要好几步才能完成安装。而且，对于大多数使用者来说，他们并不需要阅读项目源码，因此clone操作也实属多余。 可喜的是，pip不仅支持安装PyPI上的包，也可以直接通过项目的git地址进行安装。还是以Locust项目为例，我们通过pip命令也可以实现一条命令安装Github项目源码。 $ pip install git+https://github.com/locustio/locust.git@master#egg=locustio Collecting locustio from git+https://github.com/locustio/locust.git@master#egg=locustio [...] Successfully installed locustio-0.8a2 对于项目地址来说，完整的描述应该是： pip install vcs+protocol://repo_url/#egg=pkg\u0026subdirectory=pkg_dir 这里的vcs也不仅限于git，svn和hg也是一样的，而protocol除了采用SSH形式的项目地址，也可以采用HTTPS的地址，在此不再展开。 通过这种方式，我们就总是可以使用到项目的最新功能特性了。当然，前提条件也是一样的，需要项目中已经实现了setup.py。 考虑到ApiTestEngine还处于频繁的新特性开发阶段，因此这种途径无疑是让用户安装使用最新代码的最佳方式。 ","date":"2017-08-05","heading":"从 pip install 说起","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:1:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#从-pip-install-说起"},{"categories":["Development"],"collections":null,"content":"问题缘由 在ApiTestEngine中，存在测试结果报告展示这一部分的功能，而这部分的功能是需要依赖于另外一个托管在GitHub上的项目，PyUnitReport。 于是，问题就变为：如何构造ApiTestEngine项目的setup.py，可以实现用户在安装ApiTestEngine时自动安装PyUnitReport依赖。 对于这个需求，已经确定可行的办法：先通过pip安装依赖的库（PyUnitReport），然后再安装当前项目（ApiTestEngine）。 $ pip install git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine 这种方式虽然可行，但是需要执行两条命令，显然不是我们想要的效果。 经过搜索，发现针对该需求，可以在setuptools.setup()中通过install_requires和dependency_links这两个配置项组合实现。 具体地，配置方式如下： install_requires=[ \"requests\", \"flask\", \"PyYAML\", \"coveralls\", \"coverage\", \"PyUnitReport\" ], dependency_links=[ \"git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport\" ], 这里有一点需要格外注意，那就是指定的依赖包如果存在于PyPI，那么只需要在install_requires中指定包名和版本号即可（不指定版本号时，默认安装最新版本）；而对于以仓库URL地址存在的依赖包，那么不仅需要在dependency_links中指定，同时也要在install_requires中指定。 然后，就可以直接通过ApiTestEngine项目的git地址一键进行安装了。 $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine 虽然在寻找解决办法的过程中，看到大家都在说dependency_links由于安全性的问题，即将被弃用，而且在setuptools的官方文章中的确也没有看到dependency_links的描述。 DEPRECATION: Dependency Links processing has been deprecated and will be removed in a future release. 不过在我本地的macOS系统上尝试发现，该种方式的确是可行的，因此就采用这种方式进行发布了。 但是当我后续在Linux服务器上安装时，却无法成功，总是在安装PyUnitReport依赖库的时候报错： $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine [...] Collecting PyUnitReport (from ApiTestEngine) Could not find a version that satisfies the requirement PyUnitReport (from ApiTestEngine) (from versions: ) No matching distribution found for PyUnitReport (from ApiTestEngine) 另外，同时也有多个用户反馈了同样的问题，这才发现这种方式在Linux和Windows下是不行的。 然后，再次经过大量的搜索，却始终没有特别明确的答案，搞得我也在怀疑，dependency_links到底是不是真的已经弃用了，但是就算是弃用了，也应该有新的替代方案啊，但也并没有找到。 这个问题就这么放了差不多一个星期的样子。 ","date":"2017-08-05","heading":"问题缘由","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:2:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#问题缘由"},{"categories":["Development"],"collections":null,"content":"解决方案 今天周末在家，想来想去，不解决始终不爽，虽然只是多执行一条命令的问题。 于是又是经过大量搜索，幸运的是终于从pypa/pip的issues中找到一条issue，作者是Dominik Neise，他详细描述了他遇到的问题和尝试过的方法，看到他的描述我真是惊呆了，跟我的情况完全一模一样不说，连尝试的思路也完全一致。 然后，在下面的回复中，看到了Gary Wu和kbuilds的解答，总算是找到了问题的原因和解决方案。 问题在于，在dependency_links中指定仓库URL地址的时候，在指定egg信息时，pip还同时需要一个版本号（version number），并且以短横线-分隔，然后执行的时候再加上--process-dependency-links参数。 回到之前的dependency_links，我们应该写成如下形式。 dependency_links=[ \"git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0\" ] 在这里，短横线-后面我并没有填写PyUnitReport实际的版本号，因为经过尝试发现，这里填写任意数值都是成功的，因此我就填写为0了，省得后续在升级PyUnitReport以后还要来修改这个地方。 然后，就可以通过如下命令进行安装了。 $ pip install --process-dependency-links git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine 至此，问题总算解决了。 ","date":"2017-08-05","heading":"解决方案","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:3:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#解决方案"},{"categories":["Development"],"collections":null,"content":"后记 那么，dependency_links到底是不是要废弃了呢？ 从pip的GitHub项目中看到这么一个issue，--process-dependency-links之前废弃了一段时间，但是又给加回来了，因为当前还没有更好的可替代的方案。因此，在出现替代方案之前，dependency_links应该是最好的方式了吧。 最后再感叹下，老外提问时描述问题的专业性和细致程度真是令人佩服，大家可以再仔细看下这个issue好好感受下。 ","date":"2017-08-05","heading":"后记","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:4:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#后记"},{"categories":["Development"],"collections":null,"content":"阅读更多 http://setuptools.readthedocs.io/en/latest/setuptools.html#dependencies-that-aren-t-in-pypi https://pip.pypa.io/en/stable/reference/pip_install/ https://github.com/pypa/pip/issues/3610 https://github.com/pypa/pip/issues/4187 ","date":"2017-08-05","heading":"阅读更多","objectID":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/:5:0","tags":["Python","pip","GitHub","Git"],"title":"How to install a package from Github that has other github dependencies ?","uri":"/post/How-to-install-a-package-from-Github-that-has-other-github-dependencies/#阅读更多"},{"categories":["Development"],"collections":null,"content":"问题描述 对于测试报告来说，除了内容的简洁精炼，样式的美观也很重要。常用的做法是，采用HTML格式的文档，并搭配CSS和JS，实现自定义的样式和动画效果（例如展开、折叠等）。 在Jenkins中要展示HTML文档，通常采用的方式有两种： 使用HTML Publisher Plugin； 使用Files to archive功能，在Build Artifacts中显示HTML文档链接。 第一种方式配合插件，可以通过图形化操作实现简易配置，并且展示效果也不错；而第二种方式的优势在于使用Jenkins自带的功能，不依赖插件也能实现基本的需求。 然而，不管是采用哪种方式，都有可能会遇到一种情况，就是展示出来的HTML报告样式全无。在浏览器的Network中查看资源加载情况，会发现相关的CSS和JS都没法正常加载。 Refused to load the stylesheet 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css' because it violates the following Content Security Policy directive: \"style-src 'self'\". Refused to apply inline style because it violates the following Content Security Policy directive: \"style-src 'self'\". Either the 'unsafe-inline' keyword, a hash ('sha256-0EZqoz+oBhx7gF4nvY2bSqoGyy4zLjNF+SDQXGp/ZrY='), or a nonce ('nonce-...') is required to enable inline execution. Blocked script execution in 'http://10.13.0.146:8888/job/SkyPixel-SmokeTest/34/artifact/reports/SkyPixel-smoketest/34.html' because the document's frame is sandboxed and the 'allow-scripts' permission is not set. Refused to load the stylesheet 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css' because it violates the following Content Security Policy directive: \"style-src 'self'\". ","date":"2017-07-24","heading":"问题描述","objectID":"/post/solve-Jenkins-can-not-show-html-css/:1:0","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#问题描述"},{"categories":["Development"],"collections":null,"content":"问题分析 出现该现象的原因在于Jenkins中配置的CSP（Content Security Policy）。 简单地说，这是Jenkins的一个安全策略，默认会设置为一个非常严格的权限集，以防止Jenkins用户在workspace、/userContent、archived artifacts中受到恶意HTML/JS文件的攻击。 默认地，该权限集会设置为： sandbox; default-src 'none'; img-src 'self'; style-src 'self'; 在该配置下，只允许加载： Jenkins服务器上托管的CSS文件 Jenkins服务器上托管的图片文件 而如下形式的内容都会被禁止： JavaScript plugins (object/embed) HTML中的内联样式表（Inline style sheets），以及引用的外站CSS文件 HTML中的内联图片（Inline image definitions），以及外站引用的图片文件 frames web fonts XHR/AJAX etc. 可以看出，这个限制非常严格，在此限制下也就不难理解为什么我们的HTML没法正常展示样式了。 ","date":"2017-07-24","heading":"问题分析","objectID":"/post/solve-Jenkins-can-not-show-html-css/:2:0","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#问题分析"},{"categories":["Development"],"collections":null,"content":"解决方案 ","date":"2017-07-24","heading":"解决方案","objectID":"/post/solve-Jenkins-can-not-show-html-css/:3:0","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#解决方案"},{"categories":["Development"],"collections":null,"content":"临时解决方案 要解决该问题，方式也比较简单，就是修改Content Security Policy的默认配置。 修改方式为，进入Manage Jenkins-\u0026gt;Script console，输入如下命令并进行执行。 System.setProperty(\"hudson.model.DirectoryBrowserSupport.CSP\", \"\") 当看到如下结果后，则说明配置修改已经生效。 Result Result: 再次进行构建，新生成的HTML就可以正常展示样式了。需要说明的是，该操作对之前构建生成的HTML报告无效。 ","date":"2017-07-24","heading":"临时解决方案","objectID":"/post/solve-Jenkins-can-not-show-html-css/:3:1","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#临时解决方案"},{"categories":["Development"],"collections":null,"content":"永久解决方案 不过，该方法还存在一个问题：该配置只是临时生效，当重启Jenkins后，Content Security Policy又会恢复为默认值，从而HTML样式又没法展示了。 当前，Jenkins官方还没有相应的解决方法，我们只能在每次启动或重启Jenkins时，重新修改该安全策略。 如果手工地来重复这项工作，也是可行，但并不是一个好的解决方案。 回到刚才的Script console，会发现我们执行的命令其实就是一段Groovy代码；那么，如果我们可以实现在Jenkins每次启动时自动地执行该Groovy代码，那么也就同样能解决我们的问题了。 好在Jenkins已经有相应的插件： Startup Trigger: 可实现在Jenkins节点(master/slave)启动时触发构建； Groovy plugin: 可实现直接执行Groovy代码。 搜索安装startup-trigger-plugin和Groovy插件后，我们就可以进行配置了。 配置方式如下： 新建一个job，该job专门用于Jenkins启动时执行的配置命令； 在Build Triggers模块下，勾选Build when job nodes start； 在Build模块下，Add build step-\u0026gt;Execute system Groovy script，在Groovy Script中输入配置命令，System.setProperty(\u0026quot;hudson.model.DirectoryBrowserSupport.CSP\u0026quot;, \u0026quot;\u0026quot;)。 需要注意的是，添加构建步骤的时候，应该选择Execute system Groovy script，而不是Execute Groovy script。关于这两者之间的差异，简单地说，Groovy Script相当于是运行在master/slave系统JVM环境中，而system groovy script，则是运行在Jenkins master的JVM环境中，与前面提到的Jenkins Script Console功能相同。如需了解更多信息，可查看Groovy plugin的详细说明。 至此，我们就彻底解决HTML样式展示异常的问题了。 但还有一点需要格外注意，在本文的演示中，我们修改CSP（Content Security Policy）配置时关闭了的所有安全保护策略，即将hudson.model.DirectoryBrowserSupport.CSP设置为空，其实这是存在很大的安全隐患的。 正确的做法，我们应该是结合项目的实际情况，选择对应的安全策略。例如，如果我们需要开启脚本文件加载，但是只限于Jenkins服务器上托管的CSS文件，那么就可以采用如下配置。 System.setProperty(\"hudson.model.DirectoryBrowserSupport.CSP\", \"sandbox; style-src 'self';\") 除此之外，CSP可以实现非常精细的权限配置，详细配置可参考Content Security Policy Reference。 ","date":"2017-07-24","heading":"永久解决方案","objectID":"/post/solve-Jenkins-can-not-show-html-css/:3:2","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#永久解决方案"},{"categories":["Development"],"collections":null,"content":"阅读更多 Configuring Content Security Policy Content Security Policy Reference ","date":"2017-07-24","heading":"阅读更多","objectID":"/post/solve-Jenkins-can-not-show-html-css/:4:0","tags":["Jenkins"],"title":"解决 Jenkins 中无法展示 HTML 样式的问题","uri":"/post/solve-Jenkins-can-not-show-html-css/#阅读更多"},{"categories":["OpenSource"],"collections":null,"content":"在《测试用例中实现Python函数的定义》中，介绍了在YAML/JSON测试用例中实现Python函数定义的两种方法，以及它们各自适用的场景。 但是在YAML/JSON文本中要怎样实现函数的调用和传参呢？ variables: - TOKEN: debugtalk - json: {} - random: ${gen_random_string(5)} - authorization: ${gen_md5($TOKEN, $json, $random)} 例如上面的例子（YAML格式），gen_random_string和gen_md5都是已经定义好的函数，但${gen_random_string(5)}和${gen_md5($TOKEN, $json, $random)}终究只是文本字符串，程序是如何将其解析为实际的函数和参数，并实现调用的呢？ 本文将对此进行重点讲解。 ","date":"2017-07-17","heading":"","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:0:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#"},{"categories":["OpenSource"],"collections":null,"content":"函数的调用形式 在Python语言中，函数的调用形式包含如下四种形式： 无参数：func() 顺序参数：func(a, b) 字典参数：func(a=1, b=2) 混合类型参数：func(1, 2, a=3, b=4) 之前在《探索优雅的测试用例描述方式》中介绍过，我们选择使用${}作为函数转义符，在YAML/JSON用例描述中调用已经定义好的函数。 于是，以上四种类型的函数定义在YAML/JSON中就会写成如下样子。 无参数：${func()} 顺序参数：${func(a, b)} 字典参数：${func(a=1, b=2)} 混合类型参数：${func(1, 2, a=3, b=4)} 还是之前的例子： - test: name: create user which does not exist import_module_functions: - tests.data.custom_functions variables: - TOKEN: debugtalk - json: {\"name\": \"user\", \"password\": \"123456\"} - random: ${gen_random_string(5)} - authorization: ${gen_md5($TOKEN, $json, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json authorization: $authorization random: $random json: $json validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} 在这里面有一个variables模块，之前已经出现过很多次，也一直都没有讲解。但是，本文也不打算进行讲解，该部分内容将在下一篇讲解参数的定义和引用时再详细展开。 当前我们只需要知道，在该用例描述中，${gen_random_string(5)}和${gen_md5($TOKEN, $json, $random)}均实现了函数的传参和调用，而调用的函数正式之前我们定义的gen_random_string和gen_md5。 这里应该比较好理解，因为函数调用形式与在Python脚本中完全相同。但难点在于，这些描述在YAML/JSON中都是文本字符串形式，ApiTestEngine在加载测试用例的时候，是怎么识别出函数并完成调用的呢？ 具体地，这里可以拆分为三个需求点： 如何在YAML/JSON文本中识别函数？ 如何将文本字符串的函数拆分为函数名称和参数？ 如何使用函数名称和参数实现对应函数的调用？ ","date":"2017-07-17","heading":"函数的调用形式","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:1:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#函数的调用形式"},{"categories":["OpenSource"],"collections":null,"content":"正则表达式的妙用 对于第一个需求点，我们之前已经做好了铺垫，设计了${}作为函数的转义符；而当初之所以这么设计，也是为了在加载测试用例时便于解析识别，因为我们可以通过使用正则表达式，非常准确地将函数从文本格式的测试用例中提取出来。 既然Python函数的调用形式是确定的，都是函数名(参数)的形式，那么使用正则表达式的分组匹配功能，我们就可以很好地实现函数名称与参数的匹配，也就实现了第二个需求点。 例如，我们可以采用如下正则表达式，来对YAML/JSON中的每一个值（Value）进行匹配性检查。 r\"^\\$\\{(\\w+)\\((.*)\\)\\}$\" \u003e\u003e\u003e import re \u003e\u003e\u003e regex = r\"^\\$\\{(\\w+)\\((.*)\\)\\}$\" \u003e\u003e\u003e string = \"${func(3, 5)}\" \u003e\u003e\u003e matched = re.match(regex, string) \u003e\u003e\u003e matched.group(1) 'func' \u003e\u003e\u003e matched.group(2) '3, 5' \u003e\u003e\u003e \u003e\u003e\u003e string = \"${func(a=1, b=2)}\" \u003e\u003e\u003e matched = re.match(regex, string) \u003e\u003e\u003e matched.group(1) 'func' \u003e\u003e\u003e matched.group(2) 'a=1, b=2' 可以看出，通过如上正则表达式，如果满足匹配条件，那么matched.group(1)就是函数的名称，matched.group(2)就是函数的参数。 思路是完全可行的，不过我们在匹配参数部分的时候是采用.*的形式，也就是任意字符匹配，匹配的方式不是很严谨。考虑到正常的函数参数部分可能使用到的字符，我们可以采用如下更严谨的正则表达式。 r\"^\\$\\{(\\w+)\\(([\\$\\w =,]*)\\)\\}$\" 这里限定了五种可能用到的字符，\\w代表任意字母或数字，= ,代表的是等号、空格和逗号，这些都是参数中可能用到的。而\\$符号，大家应该还记得，这也是我们设计采用的变量转义符，$var将不再代表的是普遍的字符串，而是var变量的值。 有了这个基础，实现如下is_functon函数，就可以判断某个字符串是否为函数调用。 function_regexp = re.compile(r\"^\\$\\{(\\w+)\\(([\\$\\w =,]*)\\)\\}$\") def is_functon(content): matched = function_regexp.match(content) return True if matched else False 不过这里还有一个问题。通过上面的正则表达式，是可以将函数名称和参数部分拆分开了，但是在参数部分，还没法区分具体的参数类型。 例如，在前面的例子中，从${func(3, 5)}解析出来的参数为3, 5，从${func(a=1, b=2)}解析出来的参数为a=1, b=2，我们通过肉眼可以识别出这分别对应着顺序参数和字典参数两种类型，但是程序就没法自动识别了，毕竟对于程序来说它们都只是字符串而已。 所以，这里还需要再做一步操作，就是将参数字符串解析为对程序友好的形式。 什么叫对程序友好的形式呢？这里就又要用到上一篇文章讲到的可变参数和关键字参数形式了，也就是func(*args, **kwargs)的形式。 试想，如果我们可以将所有顺序参数都转换为args列表，将所有字典参数都转换为kwargs字典，那么对于任意函数类型，我们都可以采用func(*args, **kwargs)的调用形式。 于是，问题就转换为，如何将参数部分转换为args和kwargs两部分。 这就比较简单了。因为在函数的参数部分，顺序参数必须位于字典参数前面，并且以逗号间隔；而字典参数呢，总是以key=value的形式出现，并且也以逗号间隔。 那么我们就可以利用参数部分的这个特征，来进行字符串的处理。处理算法如下： 采用逗号作为分隔符将字符串进行拆分； 对每一部分进行判断，如果不包含等号，那么就是顺序参数，将其加入（append）到args列表； 如果包含等号，那么就是字典参数，采用等号作为分隔符进行进一步拆分得到key-value键值对，然后再加入到kwargs字典。 对应的Python代码实现如下： def parse_function(content): function_meta = { \"args\": [], \"kwargs\": {} } matched = function_regexp.match(content) function_meta[\"func_name\"] = matched.group(1) args_str = matched.group(2).replace(\" \", \"\") if args_str == \"\": return function_meta args_list = args_str.split(',') for arg in args_list: if '=' in arg: key, value = arg.split('=') function_meta[\"kwargs\"][key] = parse_string_value(value) else: function_meta[\"args\"].append(parse_string_value(arg)) return function_meta 可以看出，通过parse_function函数，可以将一个函数调用的字符串转换为函数的结构体。 例如，${func(1, 2, a=3, b=4)}字符串，经过parse_function转换后，就可以得到该函数的名称和参数信息： function_meta = { 'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4} } 这也就彻底解决了第二个需求点。 ","date":"2017-07-17","heading":"正则表达式的妙用","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:2:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#正则表达式的妙用"},{"categories":["OpenSource"],"collections":null,"content":"实现函数的调用 在此基础上，我们再看第三个需求点，如何使用函数名称和参数实现对应函数的调用，其实也就很简单了。 在上一篇文章中，我们实现了对函数的定义，并且将所有定义好的函数都添加到了一个字典当中，假如字典名称为custom_functions_dict，那么根据以上的函数信息（function_meta），就可以采用如下方式进行调用。 func_name = function_meta['func_name'] args = function_meta['args'] kwargs = function_meta['kwargs'] custom_functions_dict[func_name](*args, **kwargs) 具体的，在ApiTestEngine中对应的Python代码片段如下： def get_eval_value(self, data): \"\"\" evaluate data recursively, each variable in data will be evaluated. \"\"\" if isinstance(data, (list, tuple)): return [self.get_eval_value(item) for item in data] if isinstance(data, dict): evaluated_data = {} for key, value in data.items(): evaluated_data[key] = self.get_eval_value(value) return evaluated_data if isinstance(data, (int, float)): return data # data is in string format here data = \"\" if data is None else data.strip() if utils.is_variable(data): # variable marker: $var variable_name = utils.parse_variable(data) value = self.testcase_variables_mapping.get(variable_name) if value is None: raise exception.ParamsError( \"%s is not defined in bind variables!\" % variable_name) return value elif utils.is_functon(data): # function marker: ${func(1, 2, a=3, b=4)} fuction_meta = utils.parse_function(data) func_name = fuction_meta['func_name'] args = fuction_meta.get('args', []) kwargs = fuction_meta.get('kwargs', {}) args = self.get_eval_value(args) kwargs = self.get_eval_value(kwargs) return self.testcase_config[\"functions\"][func_name](*args, **kwargs) else: return data 这里还用到了递归的概念，当参数是变量（例如gen_md5($TOKEN, $json, $random)），或者为列表、字典等嵌套类型时，也可以实现正常的解析。 ","date":"2017-07-17","heading":"实现函数的调用","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:3:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#实现函数的调用"},{"categories":["OpenSource"],"collections":null,"content":"总结 到此为止，我们就解决了测试用例（YAML/JSON）中实现Python函数定义和调用的问题。 还记得《探索优雅的测试用例描述方式》末尾提到的用例模板引擎技术实现的三大块内容么？ 如何在用例描述（YAML/JSON）中实现函数的定义和调用 如何在用例描述中实现参数的定义和引用，包括用例内部和用例集之间 如何在用例描述中实现预期结果的描述和测试结果的校验 第一块总算是讲完了，下一篇文章将开始讲解如何在用例描述中实现参数的定义和引用的问题。 ","date":"2017-07-17","heading":"总结","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:4:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#总结"},{"categories":["OpenSource"],"collections":null,"content":"相关文章 《ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式》 《ApiTestEngine 演进之路（3）测试用例中实现Python函数的定义》 ApiTestEngine GitHub源码 ","date":"2017-07-17","heading":"相关文章","objectID":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/:5:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（4）测试用例中实现 Python 函数的调用","uri":"/post/ApiTestEngine-4-call-functions-in-yaml-testcases/#相关文章"},{"categories":["OpenSource"],"collections":null,"content":"在《ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式》中，我们臆想了一种简洁优雅的用例描述方式，接下来，我们就从技术实现的角度，逐项进行深入讲解，将臆想变成现实。 本文先解决第一个问题，“如何在用例描述（YAML/JSON）中实现函数的定义和调用”。 在写作的过程中，发现要将其中的原理阐述清楚，要写的内容实在是太多，因此将问题再拆分为“函数定义”和“函数调用”两部分，本文只讲解“函数定义”部分的内容。 ","date":"2017-07-11","heading":"","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:0:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#"},{"categories":["OpenSource"],"collections":null,"content":"实现函数的定义 在之前，我们假设存在gen_random_string这样一个生成指定位数随机字符串的函数，以及gen_md5这样一个计算签名校验值的函数，我们不妨先尝试通过Python语言进行具体的实现。 import hashlib import random import string def gen_random_string(str_len): return ''.join( random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) def gen_md5(*args): return hashlib.md5(\"\".join(args).encode('utf-8')).hexdigest() gen_random_string(5) # =\u003e A2dEx TOKEN = \"debugtalk\" data = '{\"name\": \"user\", \"password\": \"123456\"}' random = \"A2dEx\" gen_md5(TOKEN, data, random) # =\u003e a83de0ff8d2e896dbd8efb81ba14e17d 熟悉Python语言的人对以上代码应该都不会有理解上的难度。可能部分新接触Python的同学对gen_md5函数的*args传参方式会比较陌生，我也简单地补充下基础知识。 在Python中，函数参数共有四种，必选参数、默认参数、可变参数和关键字参数。 必选参数和默认参数大家应该都很熟悉，绝大多数编程语言里面都有类似的概念。 def func(x, y, a=1, b=2): return x + y + a + b func(1, 2) # =\u003e 6 func(1, 2, b=3) # =\u003e 7 在上面例子中，x和y是必选参数，a和b是默认参数。除了显示地定义必选参数和默认参数，我们还可以通过使用可变参数和关键字参数的形式，实现更灵活的函数参数定义。 def func(*args, **kwargs): return sum(args) + sum(kwargs.values()) args = [1, 2] kwargs = {'a':3, 'b':4} func(*args, **kwargs) # =\u003e 10 args = [] kwargs = {'a':3, 'b':4, 'c': 5} func(*args, **kwargs) # =\u003e 12 之所以说更灵活，是因为当使用可变参数和关键字参数时（func(*args, **kwargs)），我们在调用函数时就可以传入0个或任意多个必选参数和默认参数，所有必选参数将作为tuple/list的形式传给可变参数（args），并将所有默认参数作为dict的形式传给关键字参数（kwargs）。另外，可变参数和关键字参数也并不是要同时使用，只使用一种也是可以的。 在前面定义的gen_md5(*args)函数中，我们就可以将任意多个字符串传入，然后得到拼接字符串的MD5值。 现在再回到测试用例描述文件，由于是纯文本格式（YAML/JSON），我们没法直接写Python代码，那要怎样才能定义函数呢？ 之前接触过一些函数式编程，所以我首先想到的是借助lambda实现匿名函数。如果对函数式编程不了解，可以看下我之前写过的一篇文章，《Python的函数式编程\u0026ndash;从入门到⎡放弃⎦》。 ","date":"2017-07-11","heading":"实现函数的定义","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:1:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#实现函数的定义"},{"categories":["OpenSource"],"collections":null,"content":"方法一：通过lambda实现函数定义 使用lambda有什么好处呢？ 最简单直接的一点，通过lambda关键字，我们可以将函数写到一行里面。例如，同样是前面提到的gen_random_string函数和gen_md5函数，通过lambda的实现方式就是如下的形式。 gen_random_string = lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) gen_md5 = lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')) gen_random_string(5) # =\u003e A2dEx TOKEN = \"debugtalk\" data = '{\"name\": \"user\", \"password\": \"123456\"}' random = \"A2dEx\" gen_md5(TOKEN, data, random) # =\u003e a83de0ff8d2e896dbd8efb81ba14e17d 可以看出，采用lambda定义的函数跟之前的函数功能完全一致，调用方式相同，运算结果也完全一样。 然后，我们在测试用例里面，通过新增一个function_binds模块，就可以将函数定义与函数名称绑定了。 - test: name: create user which does not exist function_binds: gen_random_string: \"lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))\" gen_md5: \"lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')) variables: - TOKEN: debugtalk - random: ${gen_random_string(5)} - json: {\"name\": \"user\", \"password\": \"123456\"} - authorization: ${gen_md5($TOKEN, $json, $random)} 可能有些同学还是无法理解，在上面YAML文件中，即使将函数定义与函数名称绑定了，但是加载YAML文件后，函数名称对应的值也只是一个字符串而已，这还是没法运行啊。 这就又要用到eval黑科技了。通过eval函数，可以执行字符串表达式，并返回表达式的值。 gen_random_string = \"lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))\" func = eval(gen_random_string) func # =\u003e ","date":"2017-07-11","heading":"方法一：通过lambda实现函数定义","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:2:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#方法一通过lambda实现函数定义"},{"categories":["OpenSource"],"collections":null,"content":"方法二：自定义函数模块并进行导入 让我们再回归基础概念，当我们调用一个函数的时候，究竟发生了什么？ 简单的说，不管是调用一个函数，还是引用一个变量，都会在当前的运行环境上下文（context）中寻找已经定义好的函数或变量。而在Python中，当我们加载一个模块（module）的时候，就会将该模块中的所有函数、变量、类等对象加载进当前的运行环境上下文。 如果单纯地看这个解释还不清楚，想必大家应该都见过如下案例的形式。假设moduleA模块包含如下定义： # moduleA def hello(name): return \"hello, %s\" % name varA = \"I am varA\" 那么，我们就可以通过如下方式导入moduleA模块中所有内容，并且直接调用。 from moduleA import * print(hello(\"debugtalk\")) # =\u003e hello, debugtalk print(varA) # =\u003e I am varA 明确这一点后，既然我们之前都可以动态地导入（import）依赖库，那么我们不妨再进一步，我们同样也可以动态地导入已经定义好的函数啊。 只要我们先在一个Python模块文件中定义好测试用例所需的函数，然后在运行测试用例的时候设法将模块中的所有函数导入即可。 于是，问题就转换为，如何在YAML/JSON中实现from moduleA import *机制。 经过摸索，我发现了Python的vars函数，这也是Python的Built-in Functions之一。 对于vars，官方的定义如下： Return the __dict__ attribute for a module, class, instance, or any other object with a __dict__ attribute. 简言之，就是vars()可以将模块（module）、类（class）、实例（instance）或者任意对象的所有属性（包括但不限于定义的方法和变量），以字典的形式返回。 还是前面举例的moduelA，相信大家看完下面这个例子就清晰了。 \u003e\u003e\u003e import moduleA \u003e\u003e\u003e vars(moduleA) \u003e\u003e\u003e {'hello': , 'varA': 'I am varA'} 掌握了这一层理论基础，我们就可以继续改造我们的测试框架了。 我采取的做法是，在测试用例中新增一个import_module_functions模块，里面可填写多个模块的路径。而测试用例中所有需要使用的函数，都定义在对应路径的模块中。 我们再回到之前的案例，在测试用例中需要用到gen_random_string和gen_md5这两个函数函数，那么就可以将其定义在一个模块中，假设模块名称为custom_functions.py，相对于项目根目录的路径为tests/data/custom_functions.py。 import hashlib import random import string def gen_random_string(str_len): return ''.join( random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) def gen_md5(*args): return hashlib.md5(\"\".join(args).encode('utf-8')).hexdigest() 需要注意的是，这里的模块文件可以放置在系统的任意路径下，但是一定要保证它可作为Python的模块进行访问，也就是说在该文件的所有父目录中，都包含__init__.py文件。这是Python的语法要求，如不理解可查看官方文档。 然后，在YAML/JSON测试用例描述的import_module_functions栏目中，我们就可以写为tests.data.custom_functions。 新的用例描述形式就变成了如下样子。 - test: name: create user which does not exist import_module_functions: - tests.data.custom_functions variables: - TOKEN: debugtalk - json: {\"name\": \"user\", \"password\": \"123456\"} - random: ${gen_random_string(5)} - authorization: ${gen_md5($TOKEN, $json, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json authorization: $authorization random: $random json: $json validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} 现在函数已经定义好了，那是怎样实现动态加载的呢？ 首先，还是借助于importlib.import_module，实现模块的导入。 imported = importlib.import_module(module_name) 然后，借助于vars函数，可以获取得到模块的所有属性，也就是其中定义的方法、变量等对象。 vars(imported) 不过，由于我们只需要定义的函数，因此我们还可以通过进行过滤，只获取模块中的所有方法对象。当然，这一步不是必须的。 imported_functions_dict = dict(filter(is_function, vars(imported).items())) 其中，is_function是一个检测指定对象是否为方法的函数，实现形式如下： import types def is_function(tup): \"\"\" Takes (name, object) tuple, returns True if it is a function. \"\"\" name, item = tup return isinstance(item, types.FunctionType) 通过以上代码，就实现了从指定外部模块加载所有方法的功能。完整的代码如下： def import_module_functions(self, modules, level=\"testcase\"): \"\"\" import modules and bind all functions within the context \"\"\" for module_name in modules: imported = importlib.import_module(module_name) imported_functions_dict = dict(filter(is_function, vars(imported).items())) self.__update_context_config(level, \"functions\", imported_functions_dict) 结合到实际项目，我们就可以采取这种协作模式： 由测试开发或者开发人员将项目中所有依赖的逻辑实现为函数方法，统一放置到一个模块中； 在YAML/JSON测试用例中，对模块进行引用；（对于测试用例集的模式，只需要引用一次，以后再详细讲解） 业务测试人员只需要关注接口的业务数据字段，设计测试用例即可。 可以看出，这也算是软件工程和实际项目中的一种权衡之计，但好处在于能充分发挥各岗位角色人员的职能，有助于接口测试自动化工作的顺利开展。 ","date":"2017-07-11","heading":"方法二：自定义函数模块并进行导入","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:3:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#方法二自定义函数模块并进行导入"},{"categories":["OpenSource"],"collections":null,"content":"总结 本文介绍了在YAML/JSON测试用例中实现Python函数定义的两种方法： 通过lambda实现函数的定义：该种方式适用于函数比较简单的情况，并且函数最好没有依赖库；虽然复杂的函数也能采用这种方式进行定义，但可能会存在一定的局限性，而且看上去也比较累赘。 自定义函数模块并进行导入：该种方式通用性更强，所有类型的函数都可以通过这种方式进行定义和引用；但由于需要编写额外的Python模块文件，在函数比较简单的情况下反而会显得较为繁琐，此时采用lambda形式会更简洁。 到现在为止，我们已经清楚了如何在YAML/JSON测试用例中实现函数的定义，但是在YAML/JSON文本中要怎样实现函数的调用和传参呢？ variables: - TOKEN: debugtalk - json: {} - random: ${gen_random_string(5)} - authorization: ${gen_md5($TOKEN, $json, $random)} 例如上面的例子（YAML格式），gen_random_string和gen_md5都是已经定义好的函数，但${gen_random_string(5)}和${gen_md5($TOKEN, $json, $random)}终究只是文本字符串，程序是如何将其解析为真实的函数和参数，并实现调用的呢？ 下篇文章再详细讲解。 ","date":"2017-07-11","heading":"总结","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:4:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#总结"},{"categories":["OpenSource"],"collections":null,"content":"相关文章 《Python的函数式编程\u0026ndash;从入门到⎡放弃⎦》 《接口自动化测试的最佳工程实践（ApiTestEngine）》 《ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式》 ApiTestEngine GitHub源码 ","date":"2017-07-11","heading":"相关文章","objectID":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/:5:0","tags":["HttpRunner","lambda"],"title":"ApiTestEngine 演进之路（3）测试用例中实现 Python 函数的定义","uri":"/post/ApiTestEngine-3-define-functions-in-yaml-testcases/#相关文章"},{"categories":["OpenSource"],"collections":null,"content":"在《ApiTestEngine 演进之路（1）搭建基础框架》一文中，我们完成了ApiTestEngine基础框架的搭建，并实现了简单接口的测试功能。 接下来，我们就针对复杂类型的接口（例如包含签名校验等机制），通过对接口的业务参数和技术细节进行分离，实现简洁优雅的接口测试用例描述。 ","date":"2017-07-07","heading":"","objectID":"/post/ApiTestEngine-2-best-testcase-description/:0:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式","uri":"/post/ApiTestEngine-2-best-testcase-description/#"},{"categories":["OpenSource"],"collections":null,"content":"传统的测试用例编写方式 对于在自动化测试中将测试数据与代码实现进行分离的好处，我之前已经讲过多次，这里不再重复。 测试数据与代码实现分离后，简单的接口还好，测试用例编写不会有什么问题；但是当面对复杂一点的接口（例如包含签名校验等机制）时，我们编写自动化测试用例还是会比较繁琐。 我们从一个最常见的案例入手，看下编写自动化测试用例的过程，相信大家看完后就会对上面那段话有很深的感受。 以API接口服务（Mock Server）的创建新用户功能为例，该接口描述如下： 请求数据： Url: http://127.0.0.1:5000/api/users/1000 Method: POST Headers: {\u0026ldquo;content-type\u0026rdquo;: \u0026ldquo;application/json\u0026rdquo;, \u0026ldquo;Random\u0026rdquo;: \u0026ldquo;A2dEx\u0026rdquo;, \u0026ldquo;Authorization\u0026rdquo;: \u0026ldquo;47f135c33e858f2e3f55156ae9f78ee1\u0026rdquo;} Body: {\u0026ldquo;name\u0026rdquo;: \u0026ldquo;user1\u0026rdquo;, \u0026ldquo;password\u0026rdquo;: \u0026ldquo;123456\u0026rdquo;} 预期的正常响应数据： Status_Code: 201 Headers: {\u0026lsquo;Date\u0026rsquo;: \u0026lsquo;Fri, 23 Jun 2017 07:05:41 GMT\u0026rsquo;, \u0026lsquo;Content-Length\u0026rsquo;: \u0026lsquo;54\u0026rsquo;, \u0026lsquo;Content-Type\u0026rsquo;: \u0026lsquo;application/json\u0026rsquo;, \u0026lsquo;Server\u0026rsquo;: \u0026lsquo;Werkzeug/0.12.2 Python/2.7.13\u0026rsquo;} Body: {\u0026ldquo;msg\u0026rdquo;: \u0026ldquo;user created successfully.\u0026rdquo;, \u0026ldquo;success\u0026rdquo;: true, \u0026ldquo;uuid\u0026rdquo;: \u0026ldquo;JsdfwerL\u0026rdquo;} 其中，请求Headers中的Random字段是一个5位长的随机字符串，Authorization字段是一个签名值，签名方式为TOKEN+RequestBody+Random拼接字符串的MD5值。更具体的，RequestBody要求字典的Key值按照由小到大的排序方式。接口请求成功后，返回的是一个JSON结构，里面的success字段标识请求成功与否的状态，如果成功，uuid字段标识新创建用户的唯一ID。 相信只要是接触过接口测试的同学对此应该都会很熟悉，这也是后台系统普遍采用的签名校验方式。在具体的系统中，可能字符串拼接方式或签名算法存在差异，但是模式基本上都是类似的。 那么面对这样一个接口，我们会怎样编写接口测试用例呢？ 首先，请求的数据是要有的，我们会先准备一个可用的账号，例如{\u0026quot;password\u0026quot;: \u0026quot;123456\u0026quot;, \u0026quot;name\u0026quot;: \u0026quot;user1\u0026quot;}。 然后，由于接口存在签名校验机制，因此我们除了要知道服务器端使用的TOKEN（假设为debugtalk）外，还要准备好Random字段和Authorization字段。Random字段好说，我们随便生成一个，例如A2dEx；Authorization字段就会复杂不少，需要我们按照规定先将RequestBody根据字典的Key值进行排序，得到{\u0026quot;name\u0026quot;: \u0026quot;user1\u0026quot;, \u0026quot;password\u0026quot;: \u0026quot;123456\u0026quot;}，然后与TOKEN和Random字段拼接字符串得到debugtalk{\u0026quot;password\u0026quot;: \u0026quot;123456\u0026quot;, \u0026quot;name\u0026quot;: \u0026quot;user1\u0026quot;}A2dEx，接着再找一个MD5工具，计算得到签名值a83de0ff8d2e896dbd8efb81ba14e17d。 最后，我们才可以完成测试用例的编写。假如我们采用YAML编写测试用例，那么用例写好后应该就是如下样子。 - name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json authorization: a83de0ff8d2e896dbd8efb81ba14e17d random: A2dEx data: name: user1 password: 123456 response: status_code: 201 headers: Content-Type: application/json body: success: true msg: user created successfully. uuid: JsdfwerL 该测试用例可以在ApiTestEngine中正常运行，我们也可以采用同样的方式，对系统的所有接口编写测试用例，以此实现项目的接口自动化测试覆盖。 但问题在于，每个接口通常会对应多条测试用例，差异只是在于请求的数据会略有不同，而测试用例量越大，我们人工去准备测试数据的工作量也就越大。更令人抓狂的是，我们的系统接口不是一直不变的，有时候会根据业务需求的变化进行一些调整，相应地，我们的测试数据也需要进行同步更新，这样一来，所有相关的测试用例数据就又得重新计算一遍（任意字段数据产生变化，签名值就会大不相同）。 可以看出，如果是采用这种方式编写维护接口测试用例，人力和时间成本都会非常高，最终的结果必然是接口自动化测试难以在实际项目中得以开展。 ","date":"2017-07-07","heading":"传统的测试用例编写方式","objectID":"/post/ApiTestEngine-2-best-testcase-description/:1:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式","uri":"/post/ApiTestEngine-2-best-testcase-description/#传统的测试用例编写方式"},{"categories":["OpenSource"],"collections":null,"content":"理想的用例描述方式 在上面案例中，编写接口测试用例时之所以会很繁琐，主要是因为接口存在签名校验机制，导致我们在准备测试数据时耗费了太多时间在这上面。 然而，对于测试人员来说，接口的业务功能才是需要关注的，至于接口采用什么签名校验机制这类技术细节，的确不应耗费过多时间和精力。所以，我们的接口测试框架应该设法将接口的技术细节实现和业务参数进行拆分，并能自动处理与技术细节相关的部分，从而让业务测试人员只需要关注业务参数部分。 那要怎么实现呢？ 在开始实现之前，我们不妨借鉴BDD（行为驱动开发）的思想，先想下如何编写接口测试用例的体验最友好，换句话说，就是让业务测试人员写用例写得最爽。 还是上面案例的接口测试用例，可以看出，最耗时的地方主要是计算签名校验值部分。按理说，签名校验算法我们是已知的，要是可以在测试用例中直接调用签名算法函数就好了。 事实上，这也是各种模板语言普遍采用的方式，例如Jinja2模板语言，可以在{% raw %}{% %}{% endraw %}中执行函数语句，在{% raw %}{{ }}{% endraw %}中可以调用变量参数。之前我在设计[AppiumBooster][AppiumBooster]时也采用了类似的思想，可以通过${config.TestEnvAccount.UserName}的方式在测试用例中引用预定义的全局变量。 基于该思路，假设我们已经实现了gen_random_string这样一个生成指定位数的随机字符串的函数，以及gen_md5这样一个计算签名校验值的函数，那么我们就可以尝试采用如下方式来描述我们的测试用例： - test: name: create user which does not exist variables: - TOKEN: debugtalk - random: ${gen_random_string(5)} - json: {\"name\": \"user\", \"password\": \"123456\"} - authorization: ${gen_md5($TOKEN, $json, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json authorization: $authorization random: $random json: $json extractors: user_uuid: content.uuid validators: - {\"check\": \"status_code\", \"comparator\": \"eq\", \"expected\": 201} - {\"check\": \"content.success\", \"comparator\": \"eq\", \"expected\": true} 在如上用例中，用到了两种转义符： $作为变量转义符，$var将不再代表的是普遍的字符串，而是var变量的值； ${}作为函数的转义符，${}内可以直接填写函数名称及调用参数，甚至可以包含变量。 为什么会选择采用这种描述方式？（Why？） 其实这也是我经过大量思考和实践之后，才最终确定的描述方式。如果真要讲述这个思路历程。。。还是不细说了，此处可省下一万字。（主要的思路无非就是要实现转义的效果，并且表达要简洁清晰，因此必然会用到特殊字符；而特殊字符在YAML中大多都已经有了特定的含义，排除掉不可用的之后，剩下的真没几个了，然后再借鉴其它框架常用的符号，所以说最终选择$和${}也算是必然。） 可以确定的是，这种描述方式的好处非常明显，不仅可以实现复杂计算逻辑的函数调用，还可以实现变量的定义和引用。 除了转义符，由于接口测试中经常需要对结果中的特定字段进行提取，作为后续接口请求的参数，因此我们实现了extractors这样一个结果提取器，只要返回结果是JSON类型，就可以将其中的任意字段进行提取，并保存到一个变量中，方便后续接口请求进行引用。 另外，为了更好地实现对接口响应结果的校验，我们废弃了先前的方式，实现了独立的结果校验器validators。这是因为，很多时候在比较响应结果时，并不能简单地按照字段值是否相等来进行校验，除此之外，我们可能还需要检查某个字段的长度是否为指定位数，元素列表个数是否大于某个数值，甚至某个字符串是否满足正则匹配等等。 相信你们肯定会想，以上这些描述方式的确是很简洁，但更多地感觉是在臆想，就像开始说的gen_random_string和gen_md5函数，我们只是假设已经定义好了。就算描述得再优雅再完美，终究也还只是YAML/JSON文本格式而已，要怎样才能转换为执行的代码呢？ 这就要解决How？的问题了。 嗯，这就是用例模板引擎的核心了，也算是ApiTestEngine最核心的功能特性。 更具体的，从技术实现角度，主要分为三大块： 如何在用例描述（YAML/JSON）中实现函数的定义和调用 如何在用例描述中实现参数的定义和引用，包括用例内部和用例集之间 如何在用例描述中实现预期结果的描述和测试结果的校验 这三大块内容涉及到较多的技术实现细节，我们将在后续的文章中结合代码逐个深入进行讲解。 ","date":"2017-07-07","heading":"理想的用例描述方式","objectID":"/post/ApiTestEngine-2-best-testcase-description/:2:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式","uri":"/post/ApiTestEngine-2-best-testcase-description/#理想的用例描述方式"},{"categories":["OpenSource"],"collections":null,"content":"阅读更多 《接口自动化测试的最佳工程实践（ApiTestEngine）》 《ApiTestEngine 演化之路（0）开发未动，测试先行》 《ApiTestEngine 演进之路（1）搭建基础框架》 ApiTestEngine GitHub源码 ","date":"2017-07-07","heading":"阅读更多","objectID":"/post/ApiTestEngine-2-best-testcase-description/:3:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（2）探索优雅的测试用例描述方式","uri":"/post/ApiTestEngine-2-best-testcase-description/#阅读更多"},{"categories":["OpenSource"],"collections":null,"content":"在刚开始实现ApiTestEngine的时候，卡斯（kasi）提议做一个Java版的。对于这样的建议，我当然是拒绝的，瞬即回复了他，“人生苦短，回头是岸啊”。 当然，我没好意思跟他说的是，我不会Java啊。不过最主要的原因嘛，还是因为Python的语法简洁，可以采用很少的代码量实现丰富的功能。 有多简洁呢？ 刚在coveralls上看了下ApiTestEngine框架的代码统计行数，总行数只有268行，还不足300行。 当然，这个行数指的是框架本身的Python代码行数，不包括示例注释的行数。从上图可以看出来，LINES列是文件总行数，RELEVANT列是实际的Python代码行数。例如ate/runner.py文件，注释的行数是远多于实际代码行数的。 最极端的一个例子是，ate/testcase.py文件中的parse函数，示例注释行数35行，Python代码只有2行。 def parse(self, testcase_template): \"\"\" parse testcase_template, replace all variables with bind value. variables marker: ${variable}. @param (dict) testcase_template { \"request\": { \"url\": \"http://127.0.0.1:5000/api/users/${uid}\", \"method\": \"POST\", \"headers\": { \"Content-Type\": \"application/json\", \"authorization\": \"${authorization}\", \"random\": \"${random}\" }, \"body\": \"${data}\" }, \"response\": { \"status_code\": \"${expected_status}\" } } @return (dict) parsed testcase with bind values { \"request\": { \"url\": \"http://127.0.0.1:5000/api/users/1000\", \"method\": \"POST\", \"headers\": { \"Content-Type\": \"application/json\", \"authorization\": \"a83de0ff8d2e896dbd8efb81ba14e17d\", \"random\": \"A2dEx\" }, \"body\": '{\"name\": \"user\", \"password\": \"123456\"}' }, \"response\": { \"status_code\": 201 } } \"\"\" return self.substitute(testcase_template) 另外，如果算上单元测试用例的行数（731行），总的Python代码行数能达到1000行的样子。嗯，代码可以精简，但是单元测试覆盖率还是要保证的，不达到90%以上的单元测试覆盖率，真不好意思说自己做了开源项目啊。 那这不足300行的Python代码，实际实现了哪些功能呢？ 对比下《接口自动化测试的最佳工程实践（ApiTestEngine）》中规划的特性，已经实现了大半（前六项），至少已经算是一个有模有样的接口测试框架了。 支持API接口的多种请求方法，包括 GET/POST/HEAD/PUT/DELETE 等 测试用例与代码分离，测试用例维护方式简洁优雅，支持YAML/JSON 测试用例描述方式具有表现力，可采用简洁的方式描述输入参数和预期输出结果 接口测试用例具有可复用性，便于创建复杂测试场景 测试执行方式简单灵活，支持单接口调用测试、批量接口调用测试、定时任务执行测试 具有可扩展性，便于扩展实现Web平台化 测试结果统计报告简洁清晰，附带详尽日志记录，包括接口请求耗时、请求响应数据等 身兼多职，同时实现接口管理、接口自动化测试、接口性能测试（结合Locust） 后面剩下的特性还在实现的过程中，但是可以预见得到，最后框架本身总的Python代码行数也不会超过500行。 当然，单纯地比代码行数的确是没有什么意义，写得爽写得开心才是最重要的。 最后引用下Guido van Rossum的语录： Life is short, go Pythonic! ","date":"2017-06-28","heading":"","objectID":"/post/300-lines-python-code-api-test-framework/:0:0","tags":["HttpRunner"],"title":"300 行 Python 代码打造实用接口测试框架","uri":"/post/300-lines-python-code-api-test-framework/#"},{"categories":["OpenSource"],"collections":null,"content":"阅读更多 《接口自动化测试的最佳工程实践（ApiTestEngine）》 《ApiTestEngine 演化之路（0）开发未动，测试先行》 《ApiTestEngine 演进之路（1）搭建基础框架》 ApiTestEngine GitHub源码 ","date":"2017-06-28","heading":"阅读更多","objectID":"/post/300-lines-python-code-api-test-framework/:1:0","tags":["HttpRunner"],"title":"300 行 Python 代码打造实用接口测试框架","uri":"/post/300-lines-python-code-api-test-framework/#阅读更多"},{"categories":["OpenSource"],"collections":null,"content":"最后的最后 《ApiTestEngine 演进之路》系列文章还在继续写，只是前几天主要精力在编码实现上，博客方面没有同步更新，接下来我会整理好思路，继续完成余下的部分。 另外，如果大家对Python编程感兴趣，给大家推荐一个专注Python原创技术分享的公众号，⎡Python之禅⎦（VTtalk），里面关于Python的干货非常多，讲解也很通俗易懂，现在我如果有理解得不够透彻的概念，基本都会先到这个公众号里面去搜索下。 ","date":"2017-06-28","heading":"最后的最后","objectID":"/post/300-lines-python-code-api-test-framework/:2:0","tags":["HttpRunner"],"title":"300 行 Python 代码打造实用接口测试框架","uri":"/post/300-lines-python-code-api-test-framework/#最后的最后"},{"categories":["OpenSource"],"collections":null,"content":"在《ApiTestEngine 演进之路（0）开发未动，测试先行》一文中，我对ApiTestEngine项目正式开始前的准备工作进行了介绍，包括构建API接口服务（Mock Server）、搭建项目单元测试框架、实现持续集成构建检查机制（Travis CI）等。 接下来，我们就开始构建ApiTestEngine项目的基础框架，实现基本功能吧。 ","date":"2017-06-22","heading":"","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:0:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#"},{"categories":["OpenSource"],"collections":null,"content":"接口测试的核心要素 既然是从零开始，那我们不妨先想下，对于接口测试来说，最基本最核心的要素有哪些？ 事实上，不管是手工进行接口测试，还是自动化测试平台执行接口测试，接口测试的核心要素都可以概括为如下三点： 发起接口请求（Request） 解析接口响应（Response） 检查接口测试结果 这对于任意类型的接口测试也都是适用的。 在本系列文章中，我们关注的是API接口的测试，更具体地，是基于HTTP协议的API接口的测试。所以我们的问题就进一步简化了，只需要关注HTTP协议层面的请求和响应即可。 好在对于绝大多数接口系统，都有明确的API接口文档，里面会定义好接口请求的参数（包括Headers和Body），并同时描述好接口响应的内容（包括Headers和Body）。而我们需要做的，就是根据接口文档的描述，在HTTP请求中按照接口规范填写请求的参数，然后读取接口的HTTP响应内容，将接口的实际响应内容与我们的预期结果进行对比，以此判断接口功能是否正常。这里的预期结果，应该是包含在接口测试用例里面的。 由此可知，实现接口测试框架的第一步是完成对HTTP请求响应处理的支持。 ","date":"2017-06-22","heading":"接口测试的核心要素","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:1:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#接口测试的核心要素"},{"categories":["OpenSource"],"collections":null,"content":"HTTP客户端的最佳选择 ApiTestEngine项目选择Python作为编程语言，而在Python中实现HTTP请求，毫无疑问，Requests库是最佳选择，简洁优雅，功能强大，可轻松支持API接口的多种请求方法，包括GET/POST/HEAD/PUT/DELETE等。 并且，更赞的地方在于，Requests库针对所有的HTTP请求方法，都可以采用一套统一的接口。 requests.request(method, url, **kwargs) 其中，kwargs中可以包含HTTP请求的所有可能需要用到的信息，例如headers、cookies、params、data、auth等。 这有什么好处呢？ 好处在于，这可以帮助我们轻松实现测试数据与框架代码的分离。我们只需要遵循Requests库的参数规范，在接口测试用例中复用Requests参数的概念即可。而对于框架的测试用例执行引擎来说，处理逻辑就异常简单了，直接读取测试用例中的参数，传参给Requests发起请求即可。 如果还感觉不好理解，没关系，直接看案例。 ","date":"2017-06-22","heading":"HTTP客户端的最佳选择","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:2:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#http客户端的最佳选择"},{"categories":["OpenSource"],"collections":null,"content":"测试用例描述 在我们搭建的API接口服务（Mock Server）中，我们想测试“创建一个用户，该用户之前不存在”的场景 在上一篇文章中，我们也在unittest中对该测试场景实现了测试脚本。 def test_create_user_not_existed(self): self.clear_users() url = \"%s/api/users/%d\" % (self.host, 1000) data = { \"name\": \"user1\", \"password\": \"123456\" } resp = self.api_client.post(url, json=data) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()[\"success\"]) 在该用例中，我们实现了HTTP POST请求，api_client.post(url, json=data)，然后对响应结果进行解析，并检查resp.status_code、resp.json()[\u0026quot;success\u0026quot;]是否满足预期。 可以看出，采用代码编写测试用例时会用到许多编程语言的语法，对于不会编程的人来说上手难度较大。更大的问题在于，当我们编写大量测试用例之后，因为模式基本都是固定的，所以会发现存在大量相似或重复的脚本，这给脚本的维护带来了很大的问题。 那如何将测试用例与脚本代码进行分离呢？ 考虑到JSON格式在编程语言中处理是最方便的，分离后的测试用例可采用JSON描述如下： { \"name\": \"create user which does not exist\", \"request\": { \"url\": \"http://127.0.0.1:5000/api/users/1000\", \"method\": \"POST\", \"headers\": { \"content-type\": \"application/json\" }, \"json\": { \"name\": \"user1\", \"password\": \"123456\" } }, \"response\": { \"status_code\": 201, \"headers\": { \"Content-Type\": \"application/json\" }, \"body\": { \"success\": true, \"msg\": \"user created successfully.\" } } } 不难看出，如上JSON结构体包含了测试用例的完整描述信息。 需要特别注意的是，这里使用了一个讨巧的方式，就是在请求的参数中充分复用了Requests的参数规范。例如，我们要POST一个JSON的结构体，那么我们就直接将json作为request的参数名，这和前面写脚本时用的api_client.post(url, json=data)是一致的。 ","date":"2017-06-22","heading":"测试用例描述","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:3:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#测试用例描述"},{"categories":["OpenSource"],"collections":null,"content":"测试用例执行引擎 在如上测试用例描述的基础上，测试用例执行引擎就很简单了，以下几行代码就足够了。 def run_single_testcase(testcase): req_kwargs = testcase['request'] try: url = req_kwargs.pop('url') method = req_kwargs.pop('method') except KeyError: raise exception.ParamsError(\"Params Error\") resp_obj = requests.request(url=url, method=method, **req_kwargs) diff_content = utils.diff_response(resp_obj, testcase['response']) success = False if diff_content else True return success, diff_content 可以看出，不管是什么HTTP请求方法的用例，该执行引擎都是适用的。 只需要先从测试用例中获取到HTTP接口请求参数，testcase['request']： { \"url\": \"http://127.0.0.1:5000/api/users/1000\", \"method\": \"POST\", \"headers\": { \"content-type\": \"application/json\" }, \"json\": { \"name\": \"user1\", \"password\": \"123456\" } } 然后发起HTTP请求： requests.request(url=url, method=method, **req_kwargs) 最后再检查测试结果： utils.diff_response(resp_obj, testcase['response']) 在测试用例执行引擎完成后，执行测试用例的方式也很简单。同样是在unittest中调用执行测试用例，就可以写成如下形式： def test_run_single_testcase_success(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo.json') testcases = utils.load_testcases(testcase_file_path) success, _ = self.test_runner.run_single_testcase(testcases[0]) self.assertTrue(success) 可以看出，模式还是很固定：加载用例、执行用例、判断用例执行是否成功。如果每条测试用例都要在unittest.TestCase分别写一个单元测试进行调用，还是会存在大量重复工作。 所以比较好的做法是，再实现一个单元测试用例生成功能；这部分先不展开，后面再进行详细描述。 ","date":"2017-06-22","heading":"测试用例执行引擎","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:4:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#测试用例执行引擎"},{"categories":["OpenSource"],"collections":null,"content":"结果判断处理逻辑 这里再单独讲下对结果的判断逻辑处理，也就是diff_response函数。 def diff_response(resp_obj, expected_resp_json) diff_content = {} resp_info = parse_response_object(resp_obj) # 对比 status_code，将差异存入 diff_content # 对比 Headers，将差异存入 diff_content # 对比 Body，将差异存入 diff_content return diff_content 其中，expected_resp_json参数就是我们在测试用例中描述的response部分，作为测试用例的预期结果描述信息，是判断实际接口响应是否正常的参考标准。 而resp_obj参数，就是实际接口响应的Response实例，详细的定义可以参考requests.Response描述文档。 为了更好地实现结果对比，我们也将resp_obj解析为与expected_resp_json相同的数据结构。 def parse_response_object(resp_obj): try: resp_body = resp_obj.json() except ValueError: resp_body = resp_obj.text return { 'status_code': resp_obj.status_code, 'headers': resp_obj.headers, 'body': resp_body } 那么最后再进行对比就很好实现了，只需要编写一个通用的JSON结构体比对函数即可。 def diff_json(current_json, expected_json): json_diff = {} for key, expected_value in expected_json.items(): value = current_json.get(key, None) if str(value) != str(expected_value): json_diff[key] = { 'value': value, 'expected': expected_value } return json_diff 这里只罗列了核心处理流程的代码实现，其它的辅助功能，例如加载JSON/YAML测试用例等功能，请直接阅读阅读项目源码。 ","date":"2017-06-22","heading":"结果判断处理逻辑","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:5:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#结果判断处理逻辑"},{"categories":["OpenSource"],"collections":null,"content":"总结 经过本文中的工作，我们已经完成了ApiTestEngine基础框架的搭建，并实现了两项最基本的功能： 支持API接口的多种请求方法，包括 GET/POST/HEAD/PUT/DELETE 等 测试用例与代码分离，测试用例维护方式简洁优雅，支持YAML/JSON 然而，在实际项目中的接口通常比较复杂，例如包含签名校验等机制，这使得我们在配置接口测试用例时还是会比较繁琐。 在下一篇文章中，我们将着手解决这个问题，通过对框架增加模板配置功能，实现接口业务参数和技术细节的分离。 ","date":"2017-06-22","heading":"总结","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:6:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#总结"},{"categories":["OpenSource"],"collections":null,"content":"阅读更多 《接口自动化测试的最佳工程实践（ApiTestEngine）》 《ApiTestEngine 演进之路（0）开发未动，测试先行》 ApiTestEngine GitHub源码 ","date":"2017-06-22","heading":"阅读更多","objectID":"/post/ApiTestEngine-1-setup-basic-framework/:7:0","tags":["HttpRunner"],"title":"ApiTestEngine 演进之路（1）搭建基础框架","uri":"/post/ApiTestEngine-1-setup-basic-framework/#阅读更多"},{"categories":["OpenSource"],"collections":null,"content":"在《接口自动化测试的最佳工程实践（ApiTestEngine）》一文中，我详细介绍了ApiTestEngine诞生的背景，并对其核心特性进行了详尽的剖析。 接下来，我将在《ApiTestEngine演进之路》系列文章中讲解ApiTestEngine是如何从第一行代码开始，逐步实现接口自动化测试框架的核心功能特性的。 相信大家都有听说过TDD（测试驱动开发）这种开发模式，虽然网络上对该种开发模式存在异议，但我个人是非常推荐使用该种开发方式的。关于TDD的优势，我就不在此赘述了，我就只说下自己受益最深的两个方面。 测试驱动，其实也是需求驱动。在开发正式代码之前，可以先将需求转换为单元测试用例，然后再逐步实现正式代码，直至将所有单元测试用例跑通。这可以帮助我们总是聚焦在要实现的功能特性上，避免跑偏。特别是像我们做测试开发的，通常没有需求文档和设计文档，如果没有清晰的思路，很可能做着做着就不知道自己做到哪儿了。 高覆盖率的单元测试代码，对项目质量有充足的信心。因为是先写测试再写实现，所以正常情况下，所有的功能特性都应该能被单元测试覆盖到。再结合持续集成的手段，我们可以轻松保证每个版本都是高质量并且可用的。 所以，ApiTestEngine项目也将采用TDD的开发模式。本篇文章就重点介绍下采用TDD之前需要做的一些准备工作。 ","date":"2017-06-20","heading":"","objectID":"/post/ApiTestEngine-0-setup-CI-test/:0:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#"},{"categories":["OpenSource"],"collections":null,"content":"搭建API接口服务（Mock Server） 接口测试框架要运行起来，必然需要有可用的API接口服务。因此，在开始构建我们的接口测试框架之前，最好先搭建一套简单的API接口服务，也就是Mock Server，然后我们在采用TDD开发模式的时候，就可以随时随地将框架代码跑起来，开发效率也会大幅提升。 为什么不直接采用已有的业务系统API接口服务呢？ 这是因为通常业务系统的接口比较复杂，并且耦合了许多业务逻辑，甚至还可能涉及到和其它业务系统的交互，搭建或维护一套测试环境的成本可能会非常高。另一方面，接口测试框架需要具有一定的通用性，其功能特性很难在一个特定的业务系统中找到所有合适的接口。就拿最简单的接口请求方法来说，测试框架需要支持GET/POST/HEAD/PUT/DELETE方法，但是可能在我们已有的业务系统中只有GET/POST接口。 自行搭建API接口服务的另一个好处在于，我们可以随时调整接口的实现方式，来满足接口测试框架特定的功能特性，从而使我们总是能将注意力集中在测试框架本身。比较好的做法是，先搭建最简单的接口服务，在此基础上将接口测试框架搭建起来，实现最基本的功能；后面在实现框架的高级功能特性时，我们再对该接口服务进行拓展升级，例如增加签名校验机制等，来适配测试框架的高级功能特性。 幸运的是，使用Python搭建API接口服务十分简单，特别是在结合使用Flask框架的情况下。 例如，我们想实现一套可以对用户账号进行增删改查（CRUD）功能的接口服务，用户账号的存储结构大致如下： users_dict = { 'uid1': { 'name': 'name1', 'password': 'pwd1' }, 'uid2': { 'name': 'name2', 'password': 'pwd2' } } 那么，新增（Create）和更新（Update）功能的接口就可以通过如下方式实现。 import json from flask import Flask from flask import request, make_response app = Flask(__name__) users_dict = {} @app.route('/api/users/', methods=['POST']) def create_user(uid): user = request.get_json() if uid not in users_dict: result = { 'success': True, 'msg': \"user created successfully.\" } status_code = 201 users_dict[uid] = user else: result = { 'success': False, 'msg': \"user already existed.\" } status_code = 500 response = make_response(json.dumps(result), status_code) response.headers[\"Content-Type\"] = \"application/json\" return response @app.route('/api/users/', methods=['PUT']) def update_user(uid): user = users_dict.get(uid, {}) if user: user = request.get_json() success = True status_code = 200 else: success = False status_code = 404 result = { 'success': success, 'data': user } response = make_response(json.dumps(result), status_code) response.headers[\"Content-Type\"] = \"application/json\" return response 限于篇幅，其它类型的接口实现就不在此赘述，完整的接口实现可以参考项目源码。 接口服务就绪后，按照Flask官方文档，可以通过如下方式进行启动： $ export FLASK_APP=tests/api_server.py $ flask run * Serving Flask app \"tests.api_server\" * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 启动后，我们就可以通过请求接口来调用已经实现的接口功能了。例如，先创建一个用户，然后查看所有用户的信息，在Python终端中的调用方式如下： $ python Python 3.6.0 (default, Mar 24 2017, 16:58:25) \u003e\u003e\u003e import requests \u003e\u003e\u003e requests.post('http://127.0.0.1:5000/api/users/1000', json={'name': 'user1', 'password': '123456'}) \u003e\u003e\u003e resp = requests.get('http://127.0.0.1:5000/api/users') \u003e\u003e\u003e resp.content b'{\"success\": true, \"count\": 1, \"items\": [{\"name\": \"user1\", \"password\": \"123456\"}]}' \u003e\u003e\u003e 通过接口请求结果可见，接口服务运行正常。 ","date":"2017-06-20","heading":"搭建API接口服务（Mock Server）","objectID":"/post/ApiTestEngine-0-setup-CI-test/:1:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#搭建api接口服务mock-server"},{"categories":["OpenSource"],"collections":null,"content":"在单元测试用例中使用 Mock Server API接口服务（Mock Server）已经有了，但是如果每次运行单元测试时都要先在外部手工启动API接口服务的话，做法实在是不够优雅。 推荐的做法是，制作一个ApiServerUnittest基类，在其中添加setUpClass类方法，用于启动API接口服务（Mock Server）；添加tearDownClass类方法，用于停止API接口服务。由于setUpClass会在单元测试用例集初始化的时候执行一次，所以可以保证单元测试用例在运行的时候API服务处于可用状态；而tearDownClass会在单元测试用例集执行完毕后运行一次，停止API接口服务，从而避免对下一次启动产生影响。 # tests/base.py import multiprocessing import time import unittest from . import api_server class ApiServerUnittest(unittest.TestCase): \"\"\" Test case class that sets up an HTTP server which can be used within the tests \"\"\" @classmethod def setUpClass(cls): cls.api_server_process = multiprocessing.Process( target=api_server.app.run ) cls.api_server_process.start() time.sleep(0.1) @classmethod def tearDownClass(cls): cls.api_server_process.terminate() 这里采用的是多进程的方式（multiprocessing），所以我们的单元测试用例可以和API接口服务（Mock Server）同时运行。除了多进程的方式，我看到locust项目采用的是gevent.pywsgi.WSGIServer的方式，不过由于在gevent中要实现异步需要先monkey.patch_all()，感觉比较麻烦，而且还需要引入gevent这么一个第三方依赖库，所以还是决定采用multiprocessing的方式了。至于为什么没有选择多线程模型（threading），是因为线程至不支持显式终止的（terminate），要实现终止服务会比使用multiprocessing更为复杂。 不过需要注意的是，由于启动Server存在一定的耗时，因此在启动完毕后必须要等待一段时间（本例中0.1秒就足够了），否则在执行单元测试用例时，调用的API接口可能还处于不可用状态。 ApiServerUnittest基类就绪后，对于需要用到Mock Server的单元测试用例集，只需要继承ApiServerUnittest即可；其它的写法跟普通的单元测试完全一致。 例如，下例包含一个单元测试用例，测试“创建一个用户，该用户之前不存在”的场景。 # tests/test_apiserver.py import requests from .base import ApiServerUnittest class TestApiServer(ApiServerUnittest): def setUp(self): super(TestApiServer, self).setUp() self.host = \"http://127.0.0.1:5000\" self.api_client = requests.Session() self.clear_users() def tearDown(self): super(TestApiServer, self).tearDown() def test_create_user_not_existed(self): self.clear_users() url = \"%s/api/users/%d\" % (self.host, 1000) data = { \"name\": \"user1\", \"password\": \"123456\" } resp = self.api_client.post(url, json=data) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()[\"success\"]) ","date":"2017-06-20","heading":"在单元测试用例中使用 Mock Server","objectID":"/post/ApiTestEngine-0-setup-CI-test/:2:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#在单元测试用例中使用-mock-server"},{"categories":["OpenSource"],"collections":null,"content":"为项目添加持续集成构建检查（Travis CI） 当我们的项目具有单元测试之后，我们就可以为项目添加持续集成构建检查，从而在每次提交代码至GitHub时都运行测试，确保我们每次提交的代码都是可正常部署及运行的。 要实现这个功能，推荐使用Travis CI提供的服务，该服务对于GitHub公有仓库是免费的。要完成配置，操作也很简单，基本上只有三步： 在Travis CI使用GitHub账号授权登录； 在Travis CI的个人profile页面开启需要持续集成的项目； 在Github项目的根目录下添加.travis.yml配置文件。 大多数情况下，.travis.yml配置文件可以很简单，例如ApiTestEngine的配置就只有如下几行： sudo: false language: python python: - 2.7 - 3.3 - 3.4 - 3.5 - 3.6 install: - pip install -r requirements.txt script: - python -m unittest discover 具体含义不用解释也可以很容易看懂，其中install中包含我们项目的依赖库安装命令，script中包含执行构建测试的命令。 配置完毕后，后续每次提交代码时，GitHub就会调用Travis CI实现构建检查；并且更赞的在于，构建检查可以同时在多个指定的Python版本环境中进行。 下图是某次提交代码时的构建结果。 另外，我们还可以在GitHub项目的README.md中添加一个Status Image，实时显示项目的构建状态，就像下图显示的样子。 配置方式也是很简单，只需要先在Travis CI中获取到项目Status Image的URL地址，然后添加到README.md即可。 ","date":"2017-06-20","heading":"为项目添加持续集成构建检查（Travis CI）","objectID":"/post/ApiTestEngine-0-setup-CI-test/:3:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#为项目添加持续集成构建检查travis-ci"},{"categories":["OpenSource"],"collections":null,"content":"为项目添加单元测试覆盖率检查（coveralls） 对项目添加持续集成构建检查以后，就能完全保证我们提交的代码运行没问题么？ 答案是并不能。试想，假如我们整个项目中就只有一条单元测试用例，甚至这一条单元测试用例还是个假用例，即没有调用任何代码，那么可想而知，我们的持续集成构建检查总是成功的，并没有起到检查的作用。 因此，这里还涉及到一个单元测试覆盖率的问题。 怎么理解单元测试覆盖率呢？简单地说，就是我们在执行单元测试时运行代码的行数，与项目总代码数的比值。 对于主流的编程语言，都存在大量的覆盖率检查工具，可以帮助我们快速统计单元测试覆盖率。在Python中，用的最多的覆盖率检查工具是coverage。 要使用coverage，需要先进行安装，采用pip的安装方式如下： $ pip install coverage 然后，我们就可以采用如下命令执行单元测试。 $ coverage run --source=ate -m unittest discover 这里需要说明的是，--source参数的作用是指定统计的目录，如果不指定该参数，则会将所有依赖库也计算进去，但由于很多依赖库在安装时是没有包含测试代码的，因此会造成统计得到的单元测试覆盖率远低于实际的情况。在上面的命令中，就只统计了ate目录下的单元测试覆盖率；如果要统计当前项目的覆盖率，那么可以指定--source=.（即当前目录下的所有子文夹）。 采用上述命令执行完单元测试后，会在当前目录下生成一个统计结果文件，.coverage，里面包含了详细的统计结果。 cat .coverage !coverage.py: This is a private format, don't read it directly!{\"lines\":{\"/Users/Leo/MyProjects/ApiTestEngine/ate/__init__.py\":[1],\"/Users/Leo/MyProjects/ ApiTestEngine/ate/testcase.py\":[1,2,4,6,9,15,42,7,12,40,46,64,67,68,69,70,48,49,62,72,74,13,65,51,52,53,56,60,58,54,55],\"/Users/Leo/MyProjects/ApiTestEngi ne/ate/exception.py\":[2,4,5,9,12,15,16,6,7],\"/Users/Leo/MyProjects/ApiTestEngine/ate/utils.py\":[1,2,3,4,5,7,9,11,12,14,15,18,22,25,47,51,55,65,77,90,129,1 41,27,31,32,19,20,23,34,41,43,45,56,57,59,60,48,49,154,163,166,170,172,173,174,176,177,181,182,183,186,187,189,91,92,66,67,72,73,74,94,95,97,98,101,102,78 ,80,81,82,84,85,88,103,104,106,108,110,115,121,122,124,125,127,58,52,53,184,185,109,116,118,119,112,113,132,134,135,136,137,139,63,164,155,157,158,159,161 ,167,168,192,68,69],\"/Users/Leo/MyProjects/ApiTestEngine/ate/context.py\":[1,3,5,6,10,16,30,45,7,8,25,26,28,41,42,43,49,55,58,59,63,64,56,74,65,68,69,72,66 ,27,13,14,50,53,52,70],\"/Users/Leo/MyProjects/ApiTestEngine/ate/main.py\":[1,2,4,7,9,10,15,21,38,51,25,27,28,29,30,32,33,11,12,13,34,36,42,43,45,46,47,49], \"/Users/Leo/MyProjects/ApiTestEngine/ate/runner.py\":[1,3,4,5,8,10,15,46,68,97,135,11,12,13,35,36,38,39,41,42,44,82,63,65,66,84,86,87,88,92,93,94,95,124,12 6,127,128,129,130,131,133,154]}}% 但是，这个结果就不是给人看的。要想直观地看到统计报告，需要再执行命令coverage report -m，执行完后，就可以看到详细的统计数据了。 ➜ ApiTestEngine git:(master) ✗ coverage report -m Name Stmts Miss Cover Missing ------------------------------------------------ ate/__init__.py 0 0 100% ate/context.py 35 0 100% ate/exception.py 11 2 82% 10, 13 ate/main.py 34 7 79% 18-19, 54-62 ate/runner.py 44 2 95% 89-90 ate/testcase.py 30 0 100% ate/utils.py 112 8 93% 13, 29, 36-39, 178-179 ------------------------------------------------ TOTAL 266 19 93% 通过这个报告，可以看到项目整体的单元测试覆盖率为93%，并清晰地展示了每个源代码文件的具体覆盖率数据，以及没有覆盖到的代码行数。 那要怎么将覆盖率检查添加到我们的持续集成（Travis CI）中呢？ 事实上，当前存在多个可选服务，可以与Travis CI配合使用。当前，使用得比较广泛的是coveralls，针对Public类型的GitHub仓库，这也是一个免费服务。 coveralls的使用方式与Travis CI类似，也需要先在coveralls网站上采用GitHub账号授权登录，然后开启需要进行检查的GitHub仓库。而要执行的命令，也可以在.travis.yml配置文件中指定。 增加覆盖率检查后的.travis.yml配置文件内容如下。 sudo: false language: python python: - 2.7 - 3.3 - 3.4 - 3.5 - 3.6 install: - pip install -r requirements.txt - pip install coverage - pip install coveralls script: - coverage run --source=. -m unittest discover after_success: - coveralls 如上配置应该也很好理解，要使用coveralls的服务，需要先安装coveralls。在采用coverage执行完单元测试后，要将结果上报到coveralls网站，需要再执行coveralls命令。由于coveralls命令只有在测试覆盖率检查成功以后运行才有意义，因此可将其放在after_success部分。 配置完毕后，后续每次提交代码时，GitHub就会调用Travis CI实现构建检查，并同时统计得到单元测试覆盖率。 下图是某次提交代码时的覆盖率检查。 另外，我们在GitHub项目的README.md中也同样可以添加一个Status Image，实时显示项目的单元测试覆盖率。 配置方式也跟之前类似，在coveralls中获取到项目Status Image的URL地址，然后添加到README.md即可。 最后需要说明的是，项目的单元测试覆盖率只能起到参考作用，没有被单元测试覆盖到的代码我们不能说它肯定有问题，100%覆盖率的代码也并不能保证它肯定没有问题。归根结底，这还是要依赖于单元测试的策略实现，因此我们在写单元测试的时候也要尽可能多地覆盖到各种逻辑路径，以及兼顾到各种异常情况。 ","date":"2017-06-20","heading":"为项目添加单元测试覆盖率检查（coveralls）","objectID":"/post/ApiTestEngine-0-setup-CI-test/:4:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#为项目添加单元测试覆盖率检查coveralls"},{"categories":["OpenSource"],"collections":null,"content":"写在后面 通过本文中的工作，我们就对项目搭建好了测试框架，并实现了持续集成构建检查机制。从下一篇开始，我们就将开始逐步实现接口自动化测试框架的核心功能特性了。 ","date":"2017-06-20","heading":"写在后面","objectID":"/post/ApiTestEngine-0-setup-CI-test/:5:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#写在后面"},{"categories":["OpenSource"],"collections":null,"content":"阅读更多 《接口自动化测试的最佳工程实践（ApiTestEngine）》 ApiTestEngine GitHub源码 ","date":"2017-06-20","heading":"阅读更多","objectID":"/post/ApiTestEngine-0-setup-CI-test/:6:0","tags":["HttpRunner","Mock"],"title":"ApiTestEngine 演进之路（0）开发未动，测试先行","uri":"/post/ApiTestEngine-0-setup-CI-test/#阅读更多"},{"categories":["OpenSource"],"collections":null,"content":"背景 当前市面上存在的接口测试工具已经非常多，常见的如Postman、JMeter、RobotFramework等，相信大多数测试人员都有使用过，至少从接触到的大多数简历的描述上看是这样的。除了这些成熟的工具，也有很多有一定技术能力的测试（开发）人员自行开发了一些接口测试框架，质量也是参差不齐。 但是，当我打算在项目组中推行接口自动化测试时，搜罗了一圈，也没有找到一款特别满意的工具或框架，总是与理想中的构想存在一定的差距。 那么理想中的接口自动化测试框架应该是怎样的呢？ 测试工具（框架）脱离业务使用场景都是耍流氓！所以我们不妨先来看下日常工作中的一些常见场景。 测试或开发人员在定位问题的时候，想调用某个接口查看其是否响应正常； 测试人员在手工测试某个功能点的时候，需要一个订单号，而这个订单号可以通过顺序调用多个接口实现下单流程； 测试人员在开始版本功能测试之前，可以先检测下系统的所有接口是否工作正常，确保接口正常后再开始手工测试； 开发人员在提交代码前需要检测下新代码是否对系统的已有接口产生影响； 项目组需要每天定时检测下测试环境所有接口的工作情况，确保当天的提交代码没有对主干分支的代码造成破坏； 项目组需要定时（30分钟）检测下生产环境所有接口的工作情况，以便及时发现生产环境服务不可用的情况； 项目组需要不定期对核心业务场景进行性能测试，期望能减少人力投入，直接复用接口测试中的工作成果。 可以看到，以上罗列的场景大家应该都很熟悉，这都是我们在日常工作中经常需要去做的事情。但是在没有一款合适工具的情况下，效率往往十分低下，或者就是某些重要工作压根就没有开展，例如接口回归测试、线上接口监控等。 先说下最简单的手工调用接口测试。可能有人会说，Postman就可以满足需求啊。的确，Postman作为一款通用的接口测试工具，它可以构造接口请求，查看接口响应，从这个层面上来说，它是满足了接口测试的功能需求。但是在具体的项目中，使用Postman并不是那么高效。 不妨举个最常见的例子。 某个接口的请求参数非常多，并且接口请求要求有MD5签名校验；签名的方式为在Headers中包含一个sign参数，该参数值通过对URL、Method、Body的拼接字符串进行MD5计算后得到。 回想下我们要对这个接口进行测试时是怎么做的。首先，我们需要先参照接口文档的描述，手工填写完所有接口参数；然后，按照签名校验方式，对所有参数值进行拼接得到一个字符串，在另一个MD5计算工具计算得到其MD5值，将签名值填入sign参数；最后，才是发起接口请求，查看接口响应，并人工检测响应是否正常。最坑爹的是，我们每次需要调用这个接口的时候，以上工作就得重新来一遍。这样的实际结果是，面对参数较多或者需要签名验证的接口时，测试人员可能会选择忽略不进行接口测试。 除了单个接口的调用，很多时候我们也需要组合多个接口进行调用。例如测试人员在测试物流系统时，经常需要一个特定组合条件下生成的订单号。而由于订单号关联的业务较多，很难直接在数据库中生成，因此当前业务测试人员普遍采取的做法，就是每次需要订单号时模拟下单流程，顺序调用多个相应的接口来生成需要的订单号。可以想象，在手工调用单个接口都如此麻烦的情况下，每次都要手工调用多个接口会有多么的费时费力。 再说下接口自动化调用测试。这一块儿大多接口测试框架都支持，普遍的做法就是通过代码编写接口测试用例，或者采用数据驱动的方式，然后在支持命令行（CLI）调用的情况下，就可以结合Jenkins或者crontab实现持续集成，或者定时接口监控的功能。 思路是没有问题的，问题在于实际项目中的推动落实情况。要说自动化测试用例最靠谱的维护方式，还是直接通过代码编写测试用例，可靠且不失灵活性，这也是很多经历过惨痛教训的老手的感悟，甚至网络上还出现了一些反测试框架的言论。但问题在于项目中的测试人员并不是都会写代码，也不是对其强制要求就能马上学会的。这种情况下，要想在具体项目中推动接口自动化测试就很难，就算我可以帮忙写一部分，但是很多时候接口测试用例也是要结合业务逻辑场景的，我也的确是没法在这方面投入太多时间，毕竟对接的项目实在太多。所以也是基于这类原因，很多测试框架提倡采用数据驱动的方式，将业务测试用例和执行代码分离。不过由于很多时候业务场景比较复杂，大多数框架测试用例模板引擎的表达能力不足，很难采用简洁的方式对测试场景进行描述，从而也没法很好地得到推广使用。 可以列举的问题还有很多，这些也的确都是在互联网企业的日常测试工作中真实存在的痛点。 基于以上背景，我产生了开发ApiTestEngine的想法。 对于ApiTestEngine的定位，与其说它是一个工具或框架，它更多的应该是一套接口自动化测试的最佳工程实践，而简洁优雅实用应该是它最核心的特点。 当然，每位工程师对最佳工程实践的理念或多或少都会存在一些差异，也希望大家能多多交流，在思维的碰撞中共同进步。 ","date":"2017-06-18","heading":"背景","objectID":"/post/ApiTestEngine-api-test-best-practice/:1:0","tags":["HttpRunner"],"title":"接口自动化测试的最佳工程实践（ApiTestEngine）","uri":"/post/ApiTestEngine-api-test-best-practice/#背景"},{"categories":["OpenSource"],"collections":null,"content":"核心特性 ApiTestEngine的核心特性概述如下： 支持API接口的多种请求方法，包括 GET/POST/HEAD/PUT/DELETE 等 测试用例与代码分离，测试用例维护方式简洁优雅，支持YAML 测试用例描述方式具有表现力，可采用简洁的方式描述输入参数和预期输出结果 接口测试用例具有可复用性，便于创建复杂测试场景 测试执行方式简单灵活，支持单接口调用测试、批量接口调用测试、定时任务执行测试 测试结果统计报告简洁清晰，附带详尽日志记录，包括接口请求耗时、请求响应数据等 身兼多职，同时实现接口管理、接口自动化测试、接口性能测试（结合Locust） 具有可扩展性，便于扩展实现Web平台化 ","date":"2017-06-18","heading":"核心特性","objectID":"/post/ApiTestEngine-api-test-best-practice/:2:0","tags":["HttpRunner"],"title":"接口自动化测试的最佳工程实践（ApiTestEngine）","uri":"/post/ApiTestEngine-api-test-best-practice/#核心特性"},{"categories":["OpenSource"],"collections":null,"content":"特性拆解介绍 支持API接口的多种请求方法，包括 GET/POST/HEAD/PUT/DELETE 等 个人偏好，编程语言选择Python。而采用Python实现HTTP请求，最好的方式就是采用Requests库了，简洁优雅，功能强大。 测试用例与代码分离，测试用例维护方式简洁优雅，支持YAML 要实现测试用例与代码的分离，最好的做法就是做一个测试用例加载引擎和一个测试用例执行引擎，这也是之前在做AppiumBooster框架的时候总结出来的最优雅的实现方式。当然，这里需要事先对测试用例制定一个标准的数据结构规范，作为测试用例加载引擎和测试用例执行引擎的桥梁。 需要说明的是，测试用例数据结构必须包含接口测试用例完备的信息要素，包括接口请求的信息内容（URL、Headers、Method等参数），以及预期的接口请求响应结果（StatusCode、ResponseHeaders、ResponseContent）。 这样做的好处在于，不管测试用例采用什么形式进行描述（YAML、JSON、CSV、Excel、XML等），也不管测试用例是否采用了业务分层的组织思想，只要在测试用例加载引擎中实现对应的转换器，都可以将业务测试用例转换为标准的测试用例数据结构。而对于测试用例执行引擎而言，它无需关注测试用例的具体描述形式，只需要从标准的测试用例数据结构中获取到测试用例信息要素，包括接口请求信息和预期接口响应信息，然后构造并发起HTTP请求，再将HTTP请求的响应结果与预期结果进行对比判断即可。 至于为什么明确说明支持YAML，这是因为个人认为这是最佳的测试用例描述方式，表达简洁不累赘，同时也能包含非常丰富的信息。当然，这只是个人喜好，如果喜欢采用别的方式，只需要扩展实现对应的转换器即可。 测试用例描述方式具有表现力，可采用简洁的方式描述输入参数和预期输出结果 测试用例与框架代码分离以后，对业务逻辑测试场景的描述重任就落在测试用例上了。比如我们选择采用YAML来描述测试用例，那么我们就应该能在YAML中描述各种复杂的业务场景。 那么怎么理解这个“表现力”呢？ 简单的参数值传参应该都容易理解，我们举几个相对复杂但又比较常见的例子。 接口请求参数中要包含当前的时间戳； 接口请求参数中要包含一个16位的随机字符串； 接口请求参数中包含签名校验，需要对多个请求参数进行拼接后取md5值； 接口响应头（Headers）中要包含一个X-ATE-V头域，并且需要判断该值是否大于100； 接口响应结果中包含一个字符串，需要校验字符串中是否包含10位长度的订单号； 接口响应结果为一个多层嵌套的json结构体，需要判断某一层的某一个元素值是否为True。 可以看出，以上几个例子都是没法直接在测试用例里面描述参数值的。如果是采用Python脚本来编写测试用例还好解决，只需要通过Python函数实现即可。但是现在测试用例和框架代码分离了，我们没法在YAML里面执行Python函数，这该怎么办呢？ 答案就是，定义函数转义符，实现自定义模板。 这种做法其实也不难理解，也算是模板语言通用的方式。例如，我们将${}定义为转义符，那么在{}内的内容就不再当做是普通的字符串，而应该转义为变量值，或者执行函数得到实际结果。当然，这个需要我们在测试用例执行引擎进行适配实现，最简单方式就是提取出${}中的字符串，通过eval计算得到表达式的值。如果要实现更复杂的功能，我们也可以将接口测试中常用的一些功能封装为一套关键字，然后在编写测试用例的时候使用这些关键字。 接口测试用例具有可复用性，便于创建复杂测试场景 很多情况下，系统的接口都是有业务逻辑关联的。例如，要请求调用登录接口，需要先请求获取验证码的接口，然后在登录请求中带上获取到的验证码；而要请求数据查询的接口，又要在请求参数中包含登录接口返回的session值。这个时候，我们如果针对每一个要测的业务逻辑，都单独描述要请求的接口，那么就会造成大量的重复描述，测试用例的维护也十分臃肿。 比较好的做法是，将每一个接口调用单独封装为一条测试用例，然后在描述业务测试场景时，选择对应的接口，按照顺序拼接为业务场景测试用例，就像搭积木一般。如果你之前读过AppiumBooster的介绍，应该还会联想到，我们可以将常用的功能组成模块用例集，然后就可以在更高的层面对模块用例集进行组装，实现更复杂的测试场景。 不过，这里有一个非常关键的问题需要解决，就是如何在接口测试用例之前传参的问题。其实实现起来也不复杂，我们可以在接口请求响应结果中指定一个变量名，然后将接口返回关键值提取出来后赋值给那个变量；然后在其它接口请求参数中，传入这个${变量名}即可。 测试执行方式简单灵活，支持单接口调用测试、批量接口调用测试、定时任务执行测试 通过背景中的例子可以看出，需要使用接口测试工具的场景很多，除了定时地对所有接口进行自动化测试检测外，很多时候在手工测试的时候也需要采用接口测试工具进行辅助，也就是半手工+半自动化的模式。 而业务测试人员在使用测试工具的时候，遇到的最大问题在于除了需要关注业务功能本身，还需要花费很多时间去处理技术实现细节上的东西，例如签名校验这类情况，而且往往后者在重复操作中占用的时间更多。 这个问题的确是没法避免的，毕竟不同系统的接口千差万别，不可能存在一款工具可以自动处理所有情况。但是我们可以尝试将接口的技术细节实现和业务参数进行拆分，让业务测试人员只需要关注业务参数部分。 具体地，我们可以针对每一个接口配置一个模板，将其中与业务功能无关的参数以及技术细节封装起来，例如签名校验、时间戳、随机值等，而与业务功能相关的参数配置为可传参的模式。 这样做的好处在于，与业务功能无关的参数以及技术细节我们只需要封装配置一次，而且这个工作可以由开发人员或者测试开发人员来实现，减轻业务测试人员的压力；接口模板配置好后，测试人员只需要关注与业务相关的参数即可，结合业务测试用例，就可以在接口模板的基础上很方便地配置生成多个接口测试用例。 测试结果统计报告简洁清晰，附带详尽日志记录，包括接口请求耗时、请求响应数据等 测试结果统计报告，应该遵循简洁而不简单的原则。“简洁”，是因为大多数时候我们只需要在最短的时间内判断所有接口是否运行正常即可。而“不简单”，是因为当存在执行失败的测试用例时，我们期望能获得接口测试时尽可能详细的数据，包括测试时间、请求参数、响应内容、接口响应耗时等。 之前在读locust源码时，其对HTTP客户端的封装方式给我留下了深刻的印象。它采用的做法是，继承requests.Session类，在子类HttpSession中重写覆盖了request方法，然后在request方法中对requests.Session.request进行了一层封装。 request_meta = {} # set up pre_request hook for attaching meta data to the request object request_meta[\"method\"] = method request_meta[\"start_time\"] = time.time() response = self._send_request_safe_mode(method, url, **kwargs) # record the consumed time request_meta[\"response_time\"] = int((time.time() - request_meta[\"start_time\"]) * 1000) request_meta[\"content_size\"] = int(response.headers.get(\"content-length\") or 0) 而HttpLocust的每一个虚拟用户（client）都是一个HttpSession实例，这样每次在执行HTTP请求的时候，既可充分利用Requests库的强大功能，同时也能将请求的响应时间、响应体大小等原始性能数据进行保存，实现可谓十分优雅。 受到该处启发，要保存接口的详细请求响应数据也可采用同样的方式。例如，要保存Response的Headers、Body只需要增加如下两行代码： request_meta[\"response_headers\"] = response.headers request_meta[\"response_content\"] = response.content 身兼多职，同时实现接口管理、接口自动化测试、接口性能测试（结合Locust） 其实像接口性能测试这样的需求，不应该算到接口自动化测试框架的职责范围之内。但是在实际项目中需求就是这样，又要做接口自动化测试，又要做接口性能测试，而且还不想同时维护两套代码。 多亏有了locust性能测试框架，接口自动化和性能测试脚本还真能合二为一。 前面也讲了，HttpLocust的每一个虚拟用户（client）都是一个HttpSession实例，而HttpSession又继承自requests.Session类，所以HttpLocust的每一个虚拟用户（client）也是requests.Session类的实例。 同样的，我们在用Requests库做接口测试时，请求客户端其实也是requests.Session类的实例，只是我们通常用的是requests的简化用法。 以下两种用法是等价的。 resp = requests.get('https://debugtalk.com') # 等价于 client = requests.Session()","date":"2017-06-18","heading":"特性拆解介绍","objectID":"/post/ApiTestEngine-api-test-best-practice/:3:0","tags":["HttpRunner"],"title":"接口自动化测试的最佳工程实践（ApiTestEngine）","uri":"/post/ApiTestEngine-api-test-best-practice/#特性拆解介绍"},{"categories":["OpenSource"],"collections":null,"content":"写在后面 以上便是我对ApiTestEngine特性的详细介绍，也算是我个人对接口自动化测试最佳工程实践的理念阐述。 当前，ApiTestEngine还处于开发过程中，代码也开源托管在GitHub上，欢迎Star关注。 GitHub项目地址：https://github.com/debugtalk/ApiTestEngine ","date":"2017-06-18","heading":"写在后面","objectID":"/post/ApiTestEngine-api-test-best-practice/:4:0","tags":["HttpRunner"],"title":"接口自动化测试的最佳工程实践（ApiTestEngine）","uri":"/post/ApiTestEngine-api-test-best-practice/#写在后面"},{"categories":["OpenSource"],"collections":null,"content":"参考 《打造心目中理想的自动化测试框架（AppiumBooster）》 《告别robotframework》 《Advanced Guide For PyRestTest》 ","date":"2017-06-18","heading":"参考","objectID":"/post/ApiTestEngine-api-test-best-practice/:5:0","tags":["HttpRunner"],"title":"接口自动化测试的最佳工程实践（ApiTestEngine）","uri":"/post/ApiTestEngine-api-test-best-practice/#参考"},{"categories":["Growth"],"collections":null,"content":"近期公司新来了一批实习生，公司也组织了大量的人力物力对实习生的进行培训。不得不说，公司的确在朝”大公司“的方向发展，各项制度福利也在逐步完善。以前别说是实习生了，不管是社招还是校招来的人，都是入职后就直接上岗，哪有这么多培训机会。 分配给我的任务是对我们质量部的岗位进行介绍，给实习生们讲解下我们测试工程师的工作日常情况。由于面向的都是新人，而且各种岗位的都有，因此也只能是科普介绍了。当时也没有准备PPT，就是提前想了几个主题点，然后就简单展开讲了下。 培训结束后感觉这个科普可能对于刚毕业或者还没有毕业的同学还是有一些价值的，因此就将今天讲的内容整理为文字。老司机们就不用看了，我们后续再聊。 \u0026mdash;\u0026mdash;\u0026mdash; 分割线 \u0026mdash;\u0026mdash;\u0026mdash; 大家好，我是李隆，当前在互联网事业部的质量部做测试开发岗位。在之前的培训中，大家应该知道了，当前我们质量部共分为三类岗位，分别是QA、测试和测试开发。不知道大家有没有想过，为什么要这么划分？这三类岗位的区别和联系在哪里？ 这就要从质量部的名称说起了。在日常工作中，通常我们都是说产品、开发、测试、运维，那为什么我们不是叫做测试部，而是叫质量部呢？这是因为，站在软件工程的角度，测试只是手段，而保障项目质量才是我们的最终目的。 那么，”质量“这两个字要怎么理解呢？这是一个非常大的问题，我的确也没法单纯从字面上给大家解释它的完整含义。那我们不妨换个角度，先来看下日常工作中哪些情况会对项目质量造成影响，或者说当出现哪些现象时，就可以说明我们的项目质量存在问题。 开发提交的代码里面有bug。这个应该是最常见的，也是大家最熟悉的质量问题了。 产品的需求描述不清楚，或者需求里面有隐藏的业务逻辑问题，甚至压根就没有需求文档，完全是”一句话需求“。可以想象，这种情况通常造成的后果就是开发到后期才会发现与预期需求不符，严重的甚至造成返工的后果。 开发人员没有写单元测试，项目没有版本规划，提测比较随意并且没有自测。这种情况通常就会造成所有人员都很忙，天天加班，但是项目质量问题越来越多。 运维人员操作没有规范指引，也没有风险预案机制，说不定哪天某个运维人员没睡醒，一不小心就”删库“然后”跑路“了。 测试人员没有测试策略，设计的测试用例仅局限于UI功能层面，难以实现核心业务逻辑的覆盖，也无法发现深层次的问题。 测试人员需要做大量的重复性回归测试，也没有引入自动化测试机制，造成要么没有回归测试，要么测试耗费大量人力和时间成本。 还可以列出来的内容还有很多，不难看出，以上每一种情况都会造成项目质量出现问题，而且很多看似跟测试不直接相关的情况，往往造成的质量问题比单纯的代码bug更严重。而如果我们只是单纯地从软件测试的角度去考虑的话，我们会遗漏很多问题，项目整体质量也没法得到保障。相信你们这时应该会明白，质量部这个名称的真正含义了。 通过以上实例，可以看出质量部可以做的事情，以及必须要去做的事情非常多，但是这个范围又这么广，毕竟术业有专攻，不可能要求每个人都能做到那么多，所以我们质量部才有了岗位划分。 先来说下QA这个岗位，这个岗位主要是做什么的呢？还是回到前面提到的案例，当项目团队没有规范流程的时候，项目质量是很难保障的，往往都会有事倍功半的情况。当团队处于这种状态的时候，QA应该是第一个站出来，让项目组的所有人员认识到流程规范的重要性，并且牵头来制定和梳理流程，包括产品需求应该写成什么样才算规范，版本发版周期定为怎样才算合适，测试人员的用例规范，验收标准，以及运维人员应该有怎样的操作规范，等等。当规范建立起来后，QA也会参与到日常的测试工作中去，除了单纯的测试工作外，QA还需要对项目规范流程进行监督，发掘在实际工作中有哪些是不符合流程规范的，或者哪些流程规范是可以改进的，然后再协同各方人员来一起优化。可以看出，QA这个岗位可能并不是要特别精通于某项测试技术，但是TA一定是要对项目质量有非常灵敏的嗅觉，总是能及时地发现当前项目中可能会影响项目质量的”坏味道“，及时地暴露问题，并且跟进问题的解决。 然后再来说下测试岗位。测试这个岗位应该是大家从感觉上最直观，也是业界大众普遍认知里负责软件测试的岗位。可能有人会觉得，测试这个岗位是产品开发周期的末端，只有等到研发开发完毕，提测以后才开始工作。其实不是这样的。一个好的测试人员，应该是关注项目开发周期的整个流程的。在产品需求出来后，测试就应该要参与产品需求评审，并利用自身在业务方面的测试经验积累，充分挖掘新版本需求可能存在的业务逻辑漏洞。在开发设计技术方案的时候，测试人员也要参与到技术评审，从软件测试的角度，挖掘技术方案中可能存在的技术漏洞。评审完成后，在开发还在编码实现的时候，就需要根据当前版本需求设计测试策略，编写测试用例。等到开发提交测试以后，才是开始大家普遍认知上的测试工作。具体地，对于测试岗位来说，这本来就是一个技术性的岗位，除了需要掌握多种测试理论方法外，还需要根据系统类型的实际测试需要，熟练使用对应的测试工具。测试类型也会很多，除了最基础的功能测试外，也会根据实际情况，对Web前端进行页面兼容性测试、对app进行客户端性能专项测试（启动时间、启动内存、流畅度、包大小等），对后台服务器端进行性能测试（响应时间、吞吐量等），对系统安全进行测试，等等。 最后再来说下测试开发，也就是当前我所在的这个岗位。那么测试开发要做什么呢？可能我讲一个之前的经历大家就清楚了。 我上一家公司在阿里巴巴，当时也是做测试开发岗位。刚进公司的时候，组里的同事跟我说，欢迎来到”生促（畜）组“，看到我一脸懵逼，同事解释到，生产力促进组嘛。我说，这名字也太难听了吧。同事说，这还算好的了，知道以前其它组的叫我们什么吗？促（畜）生组，促进生产力组。 这么一对比，”生促组“听上去的确是优雅多了。 可以看到，不管是”生促“还是”促生“，测试开发这个岗位的核心都是围绕着如何提升生产力，也就是工作效率上面。当然，提高生产力也分为两个层面。第一个层面是提高质量部内部测试人员的测试效率，当前我们也主要还停留在这个层面。前面的例子也说道，当测试人员需要进行大量的重复性回归测试的时候，或者在日常工作中存在简单重复低效的工作内容的时候，这个时候就需要测试开发人员来辅助开发相应的工具平台，常见的包括实现接口测试自动化、UI自动化、持续集成等，将测试人员从重复性的工作解放出来，从而能有更多的时间精力投入到探索性测试当中去。第二个层面呢，也是我们后续的工作目标，那就是提高整个研发团队的工作效率。当然这可能不是测试开发岗位就能独立完成的，这也需要联合开发和运维同学，一起梳理整个研发测试运维流程，然后从技术的角度去开发实现一些工具平台，从而让整个项目团队能更高效的运作。 当然，虽然存在着QA、测试、测试开发的岗位划分，但是在实际工作中并不会有特别严格的界限，只是相对来说工作重心有所侧重而已。就比如说，QA和测试开发岗位也是会参与到测试工作中的，因为如果你不熟悉测试，你怎么知道测试过程中存在哪些不规范的地方，你怎么知道测试在哪些地方存在低效的痛点？同样的，测试人员要更好地理解流程和规范，也需要参与到流程规范的制订讨论工作中；而要更好地实现对系统的测试，必定需要用到一些技术手段，甚至自身具备足够的编码能力，来开展专项测试、性能测试等测试工作，从而能更好地应用技术手段来保障项目质量。 以上便是我对质量部各个岗位工作内容的介绍，也非常欢迎各位到质量部来轮岗。 其实也可以毫不夸张地说，测试岗位对于一个刚接触互联网行业的新人来说，的确是一个非常适合从整体上快速了解行业的岗位。为什么这么说？因为从前面的介绍也可以看出来，测试岗位需要参与项目的整个流程，也需要跟各种角色的人员进行沟通交流，只要用心，就能快速地得到成长，并对各个岗位能有一个较清晰的认识，这对你们后续选择职业方向和岗位的时候也是非常有帮助的。 \u0026mdash;\u0026mdash;\u0026mdash; 分割线 \u0026mdash;\u0026mdash;\u0026mdash; 最后再发点小感悟。这次也算是第一次大量接触”95后“，直观的印象，的确是一个非常活跃的群体。一方面的体现，就是参与度很强，在每个主题讲解完成后，都有大量的提问，交流十分活跃；而另一方面，就是思维活跃，虽然实习生大多都是才大三，但是从提问的问题来看，他们的知识面很广，很多问题都可以看出是经过认真思考的，而且有些问题还很有深度。 不得不佩服的同时，再想想自己的年龄，不禁黯然神伤，啥也不说了，搬砖去了。 ","date":"2017-06-15","heading":"","objectID":"/post/introduction-to-testing-engineer-daily-work/:0:0","tags":["演讲","大疆"],"title":"【科普】互联网测试岗位的工作日常","uri":"/post/introduction-to-testing-engineer-daily-work/#"},{"categories":["Development"],"collections":null,"content":"背景 从接触Python以来，一直都是采用virtualenv和virtualenvwrapper来管理不同项目的依赖环境，通过workon、mkvirtualenv等命令进行虚拟环境切换，很是愉快。 然而，最近想让项目能兼容更多的Python版本，例如至少同时兼容Python2.7和Python3.3+，就发现采用之前的方式行不通了。 最大的问题在于，在本地计算机同时安装Python2.7和Python3后，即使分别针对两个Python版本安装了virtualenv和virtualenvwrapper，也无法让两个Python版本的workon、mkvirtualenv命令同时生效。另外一方面，要想在本地计算机安装多个Python版本，会发现安装的成本都比较高，实现方式也不够优雅。 幸运地是，针对该痛点，已经存在一个比较成熟的方案，那就是pyenv。 如下是官方的介绍。 pyenv lets you easily switch between multiple versions of Python. It\u0026rsquo;s simple, unobtrusive, and follows the UNIX tradition of single-purpose tools that do one thing well. This project was forked from rbenv and ruby-build, and modified for Python. 本文就针对pyenv最核心的功能进行介绍。 ","date":"2017-03-25","heading":"背景","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:1:0","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#背景"},{"categories":["Development"],"collections":null,"content":"基本原理 如果要讲解pyenv的工作原理，基本上采用一句话就可以概括，那就是：修改系统环境变量PATH。 对于系统环境变量PATH，相信大家都不陌生，里面包含了一串由冒号分隔的路径，例如/usr/local/bin:/usr/bin:/bin。每当在系统中执行一个命令时，例如python或pip，操作系统就会在PATH的所有路径中从左至右依次寻找对应的命令。因为是依次寻找，因此排在左边的路径具有更高的优先级。 而pyenv做的，就是在PATH最前面插入一个$(pyenv root)/shims目录。这样，pyenv就可以通过控制shims目录中的Python版本号，来灵活地切换至我们所需的Python版本。 如果还想了解更多细节，可以查看pyenv的文档介绍及其源码实现。 ","date":"2017-03-25","heading":"基本原理","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:2:0","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#基本原理"},{"categories":["Development"],"collections":null,"content":"环境初始化 pyenv的安装方式包括多种，重点推荐采用pyenv-installer的方式，原因主要有两点： 通过pyenv-installer可一键安装pyenv全家桶，后续也可以很方便地实现一键升级； pyenv-installer的安装方式基于GitHub，可保证总是使用到最新版本的pyenv，并且Python版本库也是最新最全的。 ","date":"2017-03-25","heading":"环境初始化","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:3:0","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#环境初始化"},{"categories":["Development"],"collections":null,"content":"install \u0026amp;\u0026amp; config 通过如下命令安装pyenv全家桶。 $ curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash 内容除了包含pyenv以外，还包含如下插件： pyenv-doctor pyenv-installer pyenv-update pyenv-virtualenv pyenv-which-ext 安装完成后，pyenv命令还没有加进系统的环境变量，需要将如下内容加到~/.zshrc中，然后执行source ~/.zshrc。 export PATH=$HOME/.pyenv/bin:$PATH eval \"$(pyenv init -)\" eval \"$(pyenv virtualenv-init -)\" 完成以上操作后，pyenv就安装完成了。 $ pyenv -v pyenv 1.0.8 如果不确定pyenv的环境是否安装正常，可以通过pyenv doctor命令对环境进行检测。 $ pyenv doctor Cloning /Users/Leo/.pyenv/plugins/pyenv-doctor/bin/..... Installing python-pyenv-doctor... BUILD FAILED (OS X 10.12.3 using python-build 20160602) Last 10 log lines: checking for memory.h... yes checking for strings.h... yes checking for inttypes.h... yes checking for stdint.h... yes checking for unistd.h... yes checking openssl/ssl.h usability... no checking openssl/ssl.h presence... no checking for openssl/ssl.h... no configure: error: OpenSSL development header is not installed. make: *** No targets specified and no makefile found. Stop. Problem(s) detected while checking system. 通过检测，可以发现本地环境可能存在的问题，例如，从以上输出可以看出，本地的OpenSSL development header还没有安装。根据提示的问题，逐一进行修复，直到检测不再出现问题为止。 ","date":"2017-03-25","heading":"install \u0026amp;\u0026amp; config","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:3:1","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#install--config"},{"categories":["Development"],"collections":null,"content":"update 通过pyenv update命令，可以更新pyenv全家桶的所有内容。 $ pyenv update Updating /Users/Leo/.pyenv... From https://github.com/yyuu/pyenv * branch master -\u003e FETCH_HEAD Already up-to-date. Updating /Users/Leo/.pyenv/plugins/pyenv-doctor... From https://github.com/yyuu/pyenv-doctor * branch master -\u003e FETCH_HEAD Already up-to-date. Updating /Users/Leo/.pyenv/plugins/pyenv-installer... From https://github.com/yyuu/pyenv-installer * branch master -\u003e FETCH_HEAD Already up-to-date. Updating /Users/Leo/.pyenv/plugins/pyenv-update... From https://github.com/yyuu/pyenv-update * branch master -\u003e FETCH_HEAD Already up-to-date. Updating /Users/Leo/.pyenv/plugins/pyenv-virtualenv... From https://github.com/yyuu/pyenv-virtualenv * branch master -\u003e FETCH_HEAD Already up-to-date. Updating /Users/Leo/.pyenv/plugins/pyenv-which-ext... From https://github.com/yyuu/pyenv-which-ext * branch master -\u003e FETCH_HEAD Already up-to-date. ","date":"2017-03-25","heading":"update","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:3:2","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#update"},{"categories":["Development"],"collections":null,"content":"pyenv的核心使用方法 pyenv的主要功能如下： $ pyenv -h Usage: pyenv [] Some useful pyenv commands are: commands List all available pyenv commands local Set or show the local application-specific Python version global Set or show the global Python version shell Set or show the shell-specific Python version install Install a Python version using python-build uninstall Uninstall a specific Python version rehash Rehash pyenv shims (run this after installing executables) version Show the current Python version and its origin versions List all Python versions available to pyenv which Display the full path to an executable whence List all Python versions that contain the given executable See `pyenv help ' for information on a specific command. For full documentation, see: https://github.com/yyuu/pyenv#readme ","date":"2017-03-25","heading":"pyenv的核心使用方法","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:4:0","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#pyenv的核心使用方法"},{"categories":["Development"],"collections":null,"content":"查看所有可安装的Python版本 $ pyenv install --list Available versions: 2.1.3 ... 2.7.12 2.7.13 ... 3.5.3 3.6.0 3.6-dev 3.6.1 3.7-dev 需要注意的是，如果是采用brew命令安装的pyenv，可能会发现Python版本库中没有最新的Python版本。所以建议还是通过GitHub源码方式安装pyenv。 ","date":"2017-03-25","heading":"查看所有可安装的","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:4:1","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#查看所有可安装的python版本"},{"categories":["Development"],"collections":null,"content":"安装指定版本的Python环境 $ pyenv install 3.6.0 Downloading Python-3.6.0.tar.xz... -\u003e https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tar.xz Installing Python-3.6.0... Installed Python-3.6.0 to /Users/Leo/.pyenv/versions/3.6.0 ","date":"2017-03-25","heading":"安装指定版本的","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:4:2","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#安装指定版本的python环境"},{"categories":["Development"],"collections":null,"content":"查看当前系统中所有可用的Python版本 $ pyenv versions * system (set by /Users/Leo/.pyenv/version) 2.7.13 3.6.0 ","date":"2017-03-25","heading":"查看当前系统中所有可用的","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:4:3","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#查看当前系统中所有可用的python版本"},{"categories":["Development"],"collections":null,"content":"切换Python版本 pyenv可以从三个维度来管理Python环境，简称为：当前系统、当前目录、当前shell。这三个维度的优先级从左到右依次升高，即当前系统的优先级最低、当前shell的优先级最高。 如果想修改系统全局的Python环境，可以采用pyenv global PYTHON_VERSION命令。该命令执行后会在$(pyenv root)目录（默认为~/.pyenv）中创建一个名为version的文件（如果该文件已存在，则修改该文件的内容），里面记录着系统全局的Python版本号。 $ pyenv global 2.7.13 $ cat ~/.pyenv/version 2.7.13 $ pyenv version 2.7.13 (set by /Users/Leo/.pyenv/version) $ pyenv global 3.6.0 $ cat ~/.pyenv/version 3.6.0 $ pyenv version 3.6.0 (set by /Users/Leo/.pyenv/version) 通常情况下，对于特定的项目，我们可能需要切换不同的Python环境，这个时候就可以通过pyenv local PYTHON_VERSION命令来修改当前目录的Python环境。命令执行后，会在当前目录中生成一个.python-version文件（如果该文件已存在，则修改该文件的内容），里面记录着当前目录使用的Python版本号。 $ cat ~/.pyenv/version 2.7.13 $ pyenv local 3.6.0 $ cat .python-version 3.6.0 $ cat ~/.pyenv/version 2.7.13 $ pyenv version 3.6.0 (set by /Users/Leo/MyProjects/.python-version) $ pip -V pip 9.0.1 from /Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages (python 3.6) 可以看出，当前目录中的.python-version配置优先于系统全局的~/.pyenv/version配置。 另外一种情况，通过执行pyenv shell PYTHON_VERSION命令，可以修改当前shell的Python环境。执行该命令后，会在当前shell session（Terminal窗口）中创建一个名为PYENV_VERSION的环境变量，然后在当前shell的任意目录中都会采用该环境变量设定的Python版本。此时，当前系统和当前目录中设定的Python版本均会被忽略。 $ echo $PYENV_VERSION $ pyenv shell 3.6.0 $ echo $PYENV_VERSION 3.6.0 $ cat .python-version 2.7.13 $ pyenv version 3.6.0 (set by PYENV_VERSION environment variable) 顾名思义，当前shell的Python环境仅在当前shell中生效，重新打开一个新的shell后，该环境也就失效了。如果想在当前shell中取消shell级别的Python环境，采用unset命令重置PYENV_VERSION环境变量即可。 $ cat .python-version 2.7.13 $ pyenv version 3.6.0 (set by PYENV_VERSION environment variable) $ unset PYENV_VERSION $ pyenv version 2.7.13 (set by /Users/Leo/MyProjects/.python-version) ","date":"2017-03-25","heading":"切换","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:4:4","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#切换python版本"},{"categories":["Development"],"collections":null,"content":"管理多个依赖库环境 经过以上操作，我们在本地计算机中就可以安装多个版本的Python运行环境，并可以按照实际需求进行灵活地切换。然而，很多时候在同一个Python版本下，我们仍然希望能根据项目进行环境分离，就跟之前我们使用virtualenv一样。 在pyenv中，也包含这么一个插件，pyenv-virtualenv，可以实现同样的功能。 使用方式如下： $ pyenv virtualenv PYTHON_VERSION PROJECT_NAME 其中，PYTHON_VERSION是具体的Python版本号，例如，3.6.0，PROJECT_NAME是我们自定义的项目名称。比较好的实践方式是，在PROJECT_NAME也带上Python的版本号，以便于识别。 现假设我们有XDiff这么一个项目，想针对Python 2.7.13和Python 3.6.0分别创建一个虚拟环境，那就可以依次执行如下命令。 $ pyenv virtualenv 3.6.0 py36_XDiff $ pyenv virtualenv 2.7.13 py27_XDiff 创建完成后，通过执行pyenv virtualenvs命令，就可以看到本地所有的项目环境。 $ pyenv virtualenvs 2.7.13/envs/py27_XDiff (created from /Users/Leo/.pyenv/versions/2.7.13) * 3.6.0/envs/py36_XDiff (created from /Users/Leo/.pyenv/versions/3.6.0) py27_XDiff (created from /Users/Leo/.pyenv/versions/2.7.13) py36_XDiff (created from /Users/Leo/.pyenv/versions/3.6.0) 通过这种方式，在同一个Python版本下我们也可以创建多个虚拟环境，然后在各个虚拟环境中分别维护依赖库环境。 例如，py36_XDiff虚拟环境位于/Users/Leo/.pyenv/versions/3.6.0/envs目录下，而其依赖库位于/Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages中。 $ pip -V pip 9.0.1 from /Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages (python 3.6) 后续在项目开发过程中，我们就可以通过pyenv local XXX或pyenv activate PROJECT_NAME命令来切换项目的Python环境。 ➜ MyProjects pyenv local py27_XDiff (py27_XDiff) ➜ MyProjects pyenv version py27_XDiff (set by /Users/Leo/MyProjects/.python-version) (py27_XDiff) ➜ MyProjects python -V Python 2.7.13 (py27_XDiff) ➜ MyProjects pip -V pip 9.0.1 from /Users/Leo/.pyenv/versions/2.7.13/envs/py27_XDiff/lib/python2.7/site-packages (python 2.7) 可以看出，切换环境后，pip命令对应的目录也随之改变，即始终对应着当前的Python虚拟环境。 对应的，采用pyenv deactivate命令退出当前项目的Python虚拟环境。 如果想移除某个项目环境，可以通过如下命令实现。 $ pyenv uninstall PROJECT_NAME 以上便是日常开发工作中常用的pyenv命令，基本可以满足绝大多数依赖库环境管理方面的需求。 ","date":"2017-03-25","heading":"管理多个依赖库环境","objectID":"/post/use-pyenv-manage-multiple-python-virtualenvs/:5:0","tags":["Python","pyenv"],"title":"使用 pyenv 管理多个 Python 版本依赖环境","uri":"/post/use-pyenv-manage-multiple-python-virtualenvs/#管理多个依赖库环境"},{"categories":["Development"],"collections":null,"content":"背景介绍 在使用GitHub的过程中，假如某次提交代码时不小心将密码或SSH-key提交进了公共仓库。当然，希望这种事情永远也不会发生，但是如果真遇到了，该怎么办呢？ 如果发现得及时，本地提交后还没有推送到GitHub远程仓库的话，这种情况还好处理，直接修改代码后通过git commit --amend即可。 但如果发现时已经推送到了GitHub远程仓库，或者已过了许久，后续有了很多新的commits，这种情况就会比较复杂了。 错误的方式是，直接在当前代码中去除敏感信息，然后再提交到代码仓库中。这样的做法只能在最新的代码中去除了敏感信息，在git历史记录中仍然保存着敏感信息。 当然，也可以选择直接将整个仓库删除了。不过，看着昔日精心提交的代码记录，实在是难以下手。 要是可以只删除敏感信息部分，而不影响到其它提交记录就好了。事实上，GIT的确支持这种操作。 ","date":"2017-03-17","heading":"背景介绍","objectID":"/post/clean-sensitive-data-from-git-history-commits/:1:0","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#背景介绍"},{"categories":["Development"],"collections":null,"content":"处理方式 实现的方式有两种，一是通过git filter-branch命令，另一种是采用一款开源的工具，BFG Repo-Cleaner。前者是GIT官方的实现方法，后者是一款采用Scala编写的工具，号称比git filter-branch更简单、更快捷。 ","date":"2017-03-17","heading":"处理方式","objectID":"/post/clean-sensitive-data-from-git-history-commits/:2:0","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#处理方式"},{"categories":["Development"],"collections":null,"content":"git filter-branch 先来看下git filter-branch这种方式。假设要在所有历史提交记录中删除文件PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA，那么就可以采用如下命令： $ git filter-branch --force --index-filter \\ 'git rm --cached --ignore-unmatch PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA' \\ --prune-empty --tag-name-filter cat -- --all \u003e Rewrite 48dc599c80e20527ed902928085e7861e6b3cbe6 (266/266) \u003e Ref 'refs/heads/master' was rewritten 正常情况下，通过执行上面的命令，就可以在所有历史提交记录中彻底删除指定文件。如果要进一步确定的话，可以在.git目录中进行全局搜索，确保已彻底清理干净。 然后，就可以通过如下命令将本地代码推送到GitHub上，并强制覆盖掉所有历史记录。 $ git push origin --force --all 可以看出，采用git filter-branch的操作命令十分复杂（复杂到我也不想理会每个参数的具体含义），这还只是选择粗暴地将整个文件进行删除的情况。如果不想删除文件，而是单独修改特定文件特定内容的话，操作会更加复杂，如有兴趣可查看git官方文档。 ","date":"2017-03-17","heading":"git filter-branch","objectID":"/post/clean-sensitive-data-from-git-history-commits/:2:1","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#git-filter-branch"},{"categories":["Development"],"collections":null,"content":"BFG Repo-Cleaner 估计也是因为官方的git filter-branch太过复杂，于是Roberto Tyley开发了BFG Repo-Cleaner这款工具。该工具是专门针对移除历史记录的需求而产生的，这可以从其简介中看出来。 Removes large or troublesome blobs like git-filter-branch does, but faster. And written in Scala 使用BFG Repo-Cleaner之前，需要先下载BFG's jar(requires Java 7 or above)。 如果想实现前面例子中同样的功能，删除文件PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA，可以通过如下命令实现： $ java -jar bfg.jar --delete-files PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA my-repo.git 如果不想删除文件，而是单独修改特定文件特定内容的话，就可以通过如下命令实现： $ java -jar bfg.jar --replace-text replacements.txt my-repo.git 在replacements.txt文件中，应包含所有需要替换的内容，格式如下（不包含注释内容）： PASSWORD1 # Replace with '***REMOVED***' (default) PASSWORD2==\u003eexamplePass # replace with 'examplePass' instead PASSWORD3==\u003e # replace with the empty string regex:password=\\w+==\u003epassword= # Replace, using a regex regex:\\r(\\n)==\u003e$1 # Replace Windows newlines with Unix newlines 通过执行上述命令，BFG Repo-Cleaner就会扫描代码仓库的所有历史提交记录，并按照replacements.txt文件中的映射进行替换操作。 通过对比可以看到，BFG Repo-Cleaner的确是更加简洁，这也是GitHub官方推荐的方式。不过，在BFG Repo-Cleaner的介绍文档中也说了，该工具的优势在于简单和快捷，从功能强大的角度来讲，它是比不上git-filter-branch的，有些操作也只能通过git-filter-branch完成。 如需了解BFG Repo-Cleaner的更多用法，可详细阅读其文档。 ","date":"2017-03-17","heading":"BFG Repo-Cleaner","objectID":"/post/clean-sensitive-data-from-git-history-commits/:2:2","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#bfg-repo-cleaner"},{"categories":["Development"],"collections":null,"content":"写在末尾 不要问我为啥写了这么一篇博客，让我再哭一会儿。我也真心地希望大家永远不会用到这些工具。 小心驶得万年船，共勉！ ","date":"2017-03-17","heading":"写在末尾","objectID":"/post/clean-sensitive-data-from-git-history-commits/:3:0","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#写在末尾"},{"categories":["Development"],"collections":null,"content":"阅读更多 https://rtyley.github.io/bfg-repo-cleaner/ https://git-scm.com/docs/git-filter-branch https://help.github.com/articles/removing-sensitive-data-from-a-repository/ http://www-cs-students.stanford.edu/~blynn/gitmagic/ch05.html#_8230_and_then_some ","date":"2017-03-17","heading":"阅读更多","objectID":"/post/clean-sensitive-data-from-git-history-commits/:4:0","tags":["Git","GitHub"],"title":"【GIT技巧】清除历史提交记录中的敏感信息","uri":"/post/clean-sensitive-data-from-git-history-commits/#阅读更多"},{"categories":["Testing"],"collections":null,"content":"在《深入浅出开源性能测试工具Locust（使用篇）》一文中，罗列了编写性能测试脚本时常用的几类脚本增强的场景，本文是对应的代码示例。 ","date":"2017-02-22","heading":"","objectID":"/post/head-first-locust-advanced-script/:0:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#"},{"categories":["Testing"],"collections":null,"content":"关联 在某些请求中，需要携带之前从Server端返回的参数，因此在构造请求时需要先从之前的Response中提取出所需的参数。 from lxml import etree from locust import TaskSet, task, HttpLocust class UserBehavior(TaskSet): @staticmethod def get_session(html): tree = etree.HTML(html) return tree.xpath(\"//div[@class='btnbox']/input[@name='session']/@value\")[0] @task(10) def test_login(self): html = self.client.get('/login').text username = 'user@compay.com' password = '123456' session = self.get_session(html) payload = { 'username': username, 'password': password, 'session': session } self.client.post('/login', data=payload) class WebsiteUser(HttpLocust): host = 'https://debugtalk.com' task_set = UserBehavior min_wait = 1000 max_wait = 3000 ","date":"2017-02-22","heading":"关联","objectID":"/post/head-first-locust-advanced-script/:1:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#关联"},{"categories":["Testing"],"collections":null,"content":"参数化 ","date":"2017-02-22","heading":"参数化","objectID":"/post/head-first-locust-advanced-script/:2:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#参数化"},{"categories":["Testing"],"collections":null,"content":"循环取数据，数据可重复使用 所有并发虚拟用户共享同一份测试数据，各虚拟用户在数据列表中循环取值。 例如，模拟3用户并发请求网页，总共有100个URL地址，每个虚拟用户都会依次循环加载这100个URL地址；加载示例如下表所示。 \\ vuser1 vuser2 vuser3 iteration1 url1 url1 url1 iteration2 url2 url2 url2 \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; iteration100 url100 url100 url100 from locust import TaskSet, task, HttpLocust class UserBehavior(TaskSet): def on_start(self): self.index = 0 @task def test_visit(self): url = self.locust.share_data[self.index] print('visit url: %s' % url) self.index = (self.index + 1) % len(self.locust.share_data) self.client.get(url) class WebsiteUser(HttpLocust): host = 'https://debugtalk.com' task_set = UserBehavior share_data = ['url1', 'url2', 'url3', 'url4', 'url5'] min_wait = 1000 max_wait = 3000 ","date":"2017-02-22","heading":"循环取数据，数据可重复使用","objectID":"/post/head-first-locust-advanced-script/:2:1","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#循环取数据数据可重复使用"},{"categories":["Testing"],"collections":null,"content":"保证并发测试数据唯一性，不循环取数据 所有并发虚拟用户共享同一份测试数据，并且保证虚拟用户使用的数据不重复。 例如，模拟3用户并发注册账号，总共有9个账号，要求注册账号不重复，注册完毕后结束测试；加载示例如下表所示。 \\ vuser1 vuser2 vuser3 iteration1 account1 account2 account3 iteration2 account4 account6 account5 iteration3 account7 account9 account8 N/A - — - from locust import TaskSet, task, HttpLocust import queue class UserBehavior(TaskSet): @task def test_register(self): try: data = self.locust.user_data_queue.get() except queue.Empty: print('account data run out, test ended.') exit(0) print('register with user: {}, pwd: {}'\\ .format(data['username'], data['password'])) payload = { 'username': data['username'], 'password': data['password'] } self.client.post('/register', data=payload) class WebsiteUser(HttpLocust): host = 'https://debugtalk.com' task_set = UserBehavior user_data_queue = queue.Queue() for index in range(100): data = { \"username\": \"test%04d\" % index, \"password\": \"pwd%04d\" % index, \"email\": \"test%04d@debugtalk.test\" % index, \"phone\": \"186%08d\" % index, } user_data_queue.put_nowait(data) min_wait = 1000 max_wait = 3000 ","date":"2017-02-22","heading":"保证并发测试数据唯一性，不循环取数据","objectID":"/post/head-first-locust-advanced-script/:2:2","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#保证并发测试数据唯一性不循环取数据"},{"categories":["Testing"],"collections":null,"content":"保证并发测试数据唯一性，循环取数据 所有并发虚拟用户共享同一份测试数据，保证并发虚拟用户使用的数据不重复，并且数据可循环重复使用。 例如，模拟3用户并发登录账号，总共有9个账号，要求并发登录账号不相同，但数据可循环使用；加载示例如下表所示。 \\ vuser1 vuser2 vuser3 iteration1 account1 account2 account3 iteration2 account4 account6 account5 iteration3 account7 account9 account8 iteration4 account1 account2 account3 iteration5 account4 account5 account6 \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; 该种场景的实现方式与上一种场景基本相同，唯一的差异在于，每次使用完数据后，需要再将数据放入队列中。 from locust import TaskSet, task, HttpLocust import queue class UserBehavior(TaskSet): @task def test_register(self): try: data = self.locust.user_data_queue.get() except queue.Empty: print('account data run out, test ended.') exit(0) print('register with user: {}, pwd: {}'\\ .format(data['username'], data['password'])) payload = { 'username': data['username'], 'password': data['password'] } self.client.post('/register', data=payload) self.locust.user_data_queue.put_nowait(data) class WebsiteUser(HttpLocust): host = 'https://debugtalk.com' task_set = UserBehavior user_data_queue = queue.Queue() for index in range(100): data = { \"username\": \"test%04d\" % index, \"password\": \"pwd%04d\" % index, \"email\": \"test%04d@debugtalk.test\" % index, \"phone\": \"186%08d\" % index, } user_data_queue.put_nowait(data) min_wait = 1000 max_wait = 3000 ","date":"2017-02-22","heading":"保证并发测试数据唯一性，循环取数据","objectID":"/post/head-first-locust-advanced-script/:2:3","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（脚本增强）","uri":"/post/head-first-locust-advanced-script/#保证并发测试数据唯一性循环取数据"},{"categories":["Testing"],"collections":null,"content":"在《【LocustPlus序】漫谈服务端性能测试》中，我对服务端性能测试的基础概念和性能测试工具的基本原理进行了介绍，并且重点推荐了Locust这一款开源性能测试工具。然而，当前在网络上针对Locust的教程极少，不管是中文还是英文，基本都是介绍安装方法和简单的测试案例演示，但对于较复杂测试场景的案例演示却基本没有，因此很多测试人员都感觉难以将Locust应用到实际的性能测试工作当中。 经过一段时间的摸索，包括通读Locust官方文档和项目源码，并且在多个性能测试项目中对Locust进行应用实践，事实证明，Locust完全能满足日常的性能测试需求，LoadRunner能实现的功能Locust也基本都能实现。 本文将从Locust的功能特性出发，结合实例对Locust的使用方法进行介绍。考虑到大众普遍对LoadRunner比较熟悉，在讲解Locust时也会采用LoadRunner的一些概念进行类比。 ","date":"2017-02-22","heading":"","objectID":"/post/head-first-locust-user-guide/:0:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#"},{"categories":["Testing"],"collections":null,"content":"概述 先从Locust的名字说起。Locust的原意是蝗虫，原作者之所以选择这个名字，估计也是听过这么一句俗语，“蝗虫过境，寸草不生”。我在网上找了张图片，大家可以感受下。 而Locust工具生成的并发请求就跟一大群蝗虫一般，对我们的被测系统发起攻击，以此检测系统在高并发压力下是否能正常运转。 在《【LocustPlus序】漫谈服务端性能测试》中说过，服务端性能测试工具最核心的部分是压力发生器，而压力发生器的核心要点有两个，一是真实模拟用户操作，二是模拟有效并发。 在Locust测试框架中，测试场景是采用纯Python脚本进行描述的。对于最常见的HTTP(S)协议的系统，Locust采用Python的requests库作为客户端，使得脚本编写大大简化，富有表现力的同时且极具美感。而对于其它协议类型的系统，Locust也提供了接口，只要我们能采用Python编写对应的请求客户端，就能方便地采用Locust实现压力测试。从这个角度来说，Locust可以用于压测任意类型的系统。 在模拟有效并发方面，Locust的优势在于其摒弃了进程和线程，完全基于事件驱动，使用gevent提供的非阻塞IO和coroutine来实现网络层的并发请求，因此即使是单台压力机也能产生数千并发请求数；再加上对分布式运行的支持，理论上来说，Locust能在使用较少压力机的前提下支持极高并发数的测试。 ","date":"2017-02-22","heading":"概述","objectID":"/post/head-first-locust-user-guide/:1:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#概述"},{"categories":["Testing"],"collections":null,"content":"脚本编写 编写Locust脚本，是使用Locust的第一步，也是最为重要的一步。 ","date":"2017-02-22","heading":"脚本编写","objectID":"/post/head-first-locust-user-guide/:2:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#脚本编写"},{"categories":["Testing"],"collections":null,"content":"简单示例 先来看一个最简单的示例。 from locust import HttpLocust, TaskSet, task class WebsiteTasks(TaskSet): def on_start(self): self.client.post(\"/login\", { \"username\": \"test\", \"password\": \"123456\" }) @task(2) def index(self): self.client.get(\"/\") @task(1) def about(self): self.client.get(\"/about/\") class WebsiteUser(HttpLocust): task_set = WebsiteTasks host = \"https://debugtalk.com\" min_wait = 1000 max_wait = 5000 在这个示例中，定义了针对https://debugtalk.com网站的测试场景：先模拟用户登录系统，然后随机地访问首页（/）和关于页面（/about/），请求比例为2:1；并且，在测试过程中，两次请求的间隔时间为1~5秒间的随机值。 那么，如上Python脚本是如何表达出以上测试场景的呢？ 从脚本中可以看出，脚本主要包含两个类，一个是WebsiteUser（继承自HttpLocust，而HttpLocust继承自Locust），另一个是WebsiteTasks（继承自TaskSet）。事实上，在Locust的测试脚本中，所有业务测试场景都是在Locust和TaskSet两个类的继承子类中进行描述的。 那如何理解Locust和TaskSet这两个类呢？ 简单地说，Locust类就好比是一群蝗虫，而每一只蝗虫就是一个类的实例。相应的，TaskSet类就好比是蝗虫的大脑，控制着蝗虫的具体行为，即实际业务场景测试对应的任务集。 这个比喻可能不是很准确，接下来，我将分别对Locust和TaskSet两个类进行详细介绍。 ","date":"2017-02-22","heading":"简单示例","objectID":"/post/head-first-locust-user-guide/:2:1","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#简单示例"},{"categories":["Testing"],"collections":null,"content":"class HttpLocust(Locust) 在Locust类中，具有一个client属性，它对应着虚拟用户作为客户端所具备的请求能力，也就是我们常说的请求方法。通常情况下，我们不会直接使用Locust类，因为其client属性没有绑定任何方法。因此在使用Locust时，需要先继承Locust类，然后在继承子类中的client属性中绑定客户端的实现类。 对于常见的HTTP(S)协议，Locust已经实现了HttpLocust类，其client属性绑定了HttpSession类，而HttpSession又继承自requests.Session。因此在测试HTTP(S)的Locust脚本中，我们可以通过client属性来使用Python requests库的所有方法，包括GET/POST/HEAD/PUT/DELETE/PATCH等，调用方式也与requests完全一致。另外，由于requests.Session的使用，因此client的方法调用之间就自动具有了状态记忆的功能。常见的场景就是，在登录系统后可以维持登录状态的Session，从而后续HTTP请求操作都能带上登录态。 而对于HTTP(S)以外的协议，我们同样可以使用Locust进行测试，只是需要我们自行实现客户端。在客户端的具体实现上，可通过注册事件的方式，在请求成功时触发events.request_success，在请求失败时触发events.request_failure即可。然后创建一个继承自Locust类的类，对其设置一个client属性并与我们实现的客户端进行绑定。后续，我们就可以像使用HttpLocust类一样，测试其它协议类型的系统。 原理就是这样简单！ 在Locust类中，除了client属性，还有几个属性需要关注下： task_set: 指向一个TaskSet类，TaskSet类定义了用户的任务信息，该属性为必填； max_wait/min_wait: 每个用户执行两个任务间隔时间的上下限（毫秒），具体数值在上下限中随机取值，若不指定则默认间隔时间固定为1秒； host：被测系统的host，当在终端中启动locust时没有指定--host参数时才会用到； weight：同时运行多个Locust类时会用到，用于控制不同类型任务的执行权重。 测试开始后，每个虚拟用户（Locust实例）的运行逻辑都会遵循如下规律： 先执行WebsiteTasks中的on_start（只执行一次），作为初始化； 从WebsiteTasks中随机挑选（如果定义了任务间的权重关系，那么就是按照权重关系随机挑选）一个任务执行； 根据Locust类中min_wait和max_wait定义的间隔时间范围（如果TaskSet类中也定义了min_wait或者max_wait，以TaskSet中的优先），在时间范围中随机取一个值，休眠等待； 重复2~3步骤，直至测试任务终止。 ","date":"2017-02-22","heading":"class HttpLocust(Locust)","objectID":"/post/head-first-locust-user-guide/:2:2","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#class-httplocustlocust"},{"categories":["Testing"],"collections":null,"content":"class TaskSet 再说下TaskSet类。 性能测试工具要模拟用户的业务操作，就需要通过脚本模拟用户的行为。在前面的比喻中说到，TaskSet类好比蝗虫的大脑，控制着蝗虫的具体行为。 具体地，TaskSet类实现了虚拟用户所执行任务的调度算法，包括规划任务执行顺序（schedule_task）、挑选下一个任务（execute_next_task）、执行任务（execute_task）、休眠等待（wait）、中断控制（interrupt）等等。在此基础上，我们就可以在TaskSet子类中采用非常简洁的方式来描述虚拟用户的业务测试场景，对虚拟用户的所有行为（任务）进行组织和描述，并可以对不同任务的权重进行配置。 在TaskSet子类中定义任务信息时，可以采取两种方式，@task装饰器和tasks属性。 采用@task装饰器定义任务信息时，描述形式如下： from locust import TaskSet, task class UserBehavior(TaskSet): @task(1) def test_job1(self): self.client.get('/job1') @task(2) def test_job2(self): self.client.get('/job2') 采用tasks属性定义任务信息时，描述形式如下： from locust import TaskSet def test_job1(obj): obj.client.get('/job1') def test_job2(obj): obj.client.get('/job2') class UserBehavior(TaskSet): tasks = {test_job1:1, test_job2:2} # tasks = [(test_job1,1), (test_job1,2)] # 两种方式等价 在如上两种定义任务信息的方式中，均设置了权重属性，即执行test_job2的频率是test_job1的两倍。 若不指定执行任务的权重，则相当于比例为1:1。 from locust import TaskSet, task class UserBehavior(TaskSet): @task def test_job1(self): self.client.get('/job1') @task def test_job2(self): self.client.get('/job2') from locust import TaskSet def test_job1(obj): obj.client.get('/job1') def test_job2(obj): obj.client.get('/job2') class UserBehavior(TaskSet): tasks = [test_job1, test_job2] # tasks = {test_job1:1, test_job2:1} # 两种方式等价 在TaskSet子类中除了定义任务信息，还有一个是经常用到的，那就是on_start函数。这个和LoadRunner中的vuser_init功能相同，在正式执行测试前执行一次，主要用于完成一些初始化的工作。例如，当测试某个搜索功能，而该搜索功能又要求必须为登录态的时候，就可以先在on_start中进行登录操作；前面也提到，HttpLocust使用到了requests.Session，因此后续所有任务执行过程中就都具有登录态了。 ","date":"2017-02-22","heading":"class TaskSet","objectID":"/post/head-first-locust-user-guide/:2:3","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#class-taskset"},{"categories":["Testing"],"collections":null,"content":"脚本增强 掌握了HttpLocust和TaskSet，我们就基本具备了编写测试脚本的能力。此时再回过头来看前面的案例，相信大家都能很好的理解了。 然而，当面对较复杂的测试场景，可能有的同学还是会感觉无从下手；例如，很多时候脚本需要做关联或参数化处理，这些在LoadRunner中集成的功能，换到Locust中就不知道怎么实现了。可能也是这方面的原因，造成很多测试人员都感觉难以将Locust应用到实际的性能测试工作当中。 其实这也跟Locust的目标定位有关，Locust的定位就是small and very hackable。但是小巧并不意味着功能弱，我们完全可以通过Python脚本本身来实现各种各样的功能，如果大家有疑问，我们不妨逐项分解来看。 在LoadRunner这款功能全面应用广泛的商业性能测试工具中，脚本增强无非就涉及到四个方面： 关联 参数化 检查点 集合点 先说关联这一项。在某些请求中，需要携带之前从Server端返回的参数，因此在构造请求时需要先从之前请求的Response中提取出所需的参数，常见场景就是session_id。针对这种情况，LoadRunner虽然可能通过录制脚本进行自动关联，但是效果并不理想，在实际测试过程中也基本都是靠测试人员手动的来进行关联处理。 在LoadRunner中手动进行关联处理时，主要是通过使用注册型函数，例如web_reg_save_param，对前一个请求的响应结果进行解析，根据左右边界或其它特征定位到参数值并将其保存到参数变量，然后在后续请求中使用该参数。采用同样的思想，我们在Locust脚本中也完全可以实现同样的功能，毕竟只是Python脚本，通过官方库函数re.search就能实现所有需求。甚至针对html页面，我们也可以采用lxml库，通过etree.HTML(html).xpath来更优雅地实现元素定位。 然后再来看参数化这一项。这一项极其普遍，主要是用在测试数据方面。但通过归纳，发现其实也可以概括为三种类型。 循环取数据，数据可重复使用：e.g. 模拟3用户并发请求网页，总共有100个URL地址，每个虚拟用户都会依次循环加载这100个URL地址； 保证并发测试数据唯一性，不循环取数据：e.g. 模拟3用户并发注册账号，总共有90个账号，要求注册账号不重复，注册完毕后结束测试； 保证并发测试数据唯一性，循环取数据：模拟3用户并发登录账号，总共有90个账号，要求并发登录账号不相同，但数据可循环使用。 通过以上归纳，可以确信地说，以上三种类型基本上可以覆盖我们日常性能测试工作中的所有参数化场景。 在LoadRunner中是有一个集成的参数化模块，可以直接配置参数化策略。那在Locust要怎样实现该需求呢？ 答案依旧很简单，使用Python的list和queue数据结构即可！具体做法是，在WebsiteUser定义一个数据集，然后所有虚拟用户在WebsiteTasks中就可以共享该数据集了。如果不要求数据唯一性，数据集选择list数据结构，从头到尾循环遍历即可；如果要求数据唯一性，数据集选择queue数据结构，取数据时进行queue.get()操作即可，并且这也不会循环取数据；至于涉及到需要循环取数据的情况，那也简单，每次取完数据后再将数据插入到队尾即可，queue.put_nowait(data)。 最后再说下检查点。该功能在LoadRunner中通常是使用web_reg_find这类注册函数进行检查的。在Locust脚本中，处理就更方便了，只需要对响应的内容关键字进行assert xxx in response操作即可。 针对如上各种脚本增强的场景，我也通过代码示例分别进行了演示。但考虑到文章中插入太多代码会影响到阅读，因此将代码示例部分剥离了出来，如有需要请点击查看《深入浅出开源性能测试工具Locust（脚本增强）》。 ","date":"2017-02-22","heading":"脚本增强","objectID":"/post/head-first-locust-user-guide/:2:4","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#脚本增强"},{"categories":["Testing"],"collections":null,"content":"Locust运行模式 在开始运行Locust脚本之前，我们先来看下Locust支持的运行模式。 运行Locust时，通常会使用到两种运行模式：单进程运行和多进程分布式运行。 单进程运行模式的意思是，Locust所有的虚拟并发用户均运行在单个Python进程中，具体从使用形式上，又分为no_web和web两种形式。该种模式由于单进程的原因，并不能完全发挥压力机所有处理器的能力，因此主要用于调试脚本和小并发压测的情况。 当并发压力要求较高时，就需要用到Locust的多进程分布式运行模式。从字面意思上看，大家可能第一反应就是多台压力机同时运行，每台压力机分担负载一部分的压力生成。的确，Locust支持任意多台压力机（一主多从）的分布式运行模式，但这里说到的多进程分布式运行模式还有另外一种情况，就是在同一台压力机上开启多个slave的情况。这是因为当前阶段大多数计算机的CPU都是多处理器（multiple processor cores），单进程运行模式下只能用到一个处理器的能力，而通过在一台压力机上运行多个slave，就能调用多个处理器的能力了。比较好的做法是，如果一台压力机有N个处理器内核，那么就在这台压力机上启动一个master，N个slave。当然，我们也可以启动N的倍数个slave，但是根据我的试验数据，效果跟N个差不多，因此只需要启动N个slave即可。 ","date":"2017-02-22","heading":"Locust运行模式","objectID":"/post/head-first-locust-user-guide/:3:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#locust运行模式"},{"categories":["Testing"],"collections":null,"content":"脚本调试 Locust脚本编写完毕后，通常不会那么顺利，在正式开始性能测试之前还需要先调试运行下。 不过，Locust脚本虽然为Python脚本，但却很难直接当做Python脚本运行起来，为什么呢？这主要还是因为Locust脚本中引用了HttpLocust和TaskSet这两个类，如果要想直接对其进行调用测试，会发现编写启动脚本是一个比较困难的事情。因为这个原因，刚接触Locust的同学可能就会觉得Locust脚本不好调试。 但这个问题也能克服，那就是借助Locust的单进程no_web运行模式。 在Locust的单进程no_web运行模式中，我们可以通过--no_web参数，指定并发数（-c）和总执行次数（-n），直接在Terminal中执行脚本。 在此基础上，当我们想要调试Locust脚本时，就可以在脚本中需要调试的地方通过print打印日志，然后将并发数和总执行次数都指定为1，执行形式如下所示。 $ locust -f locustfile.py --no_web -c 1 -n 1 通过这种方式，我们就能很方便地对Locust脚本进行调试了。 ","date":"2017-02-22","heading":"脚本调试","objectID":"/post/head-first-locust-user-guide/:4:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#脚本调试"},{"categories":["Testing"],"collections":null,"content":"执行测试 Locust脚本调试通过后，就算是完成了所有准备工作，可以开始进行压力测试了。 Locust是通过在Terminal中执行命令进行启动的，通用的参数有如下两个： -H, --host：被测系统的host，若在Terminal中不进行指定，就需要在Locust子类中通过host参数进行指定； -f, --locustfile：指定执行的Locust脚本文件； 除了这两个通用的参数，我们还需要根据实际测试场景，选择不同的Locust运行模式，而模式的指定也是通过其它参数来进行控制的。 ","date":"2017-02-22","heading":"执行测试","objectID":"/post/head-first-locust-user-guide/:5:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#执行测试"},{"categories":["Testing"],"collections":null,"content":"单进程运行 no_web 如果采用no_web形式，则需使用--no-web参数，并会用到如下几个参数。 -c, --clients：指定并发用户数； -n, --num-request：指定总执行测试； -r, --hatch-rate：指定并发加压速率，默认值位1。 $ locust -H https://debugtalk.com -f demo.py --no-web -c1 -n2 [2017-02-21 21:27:26,522] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 [2017-02-21 21:27:26,523] Leos-MacBook-Air.local/INFO/locust.runners: Hatching and swarming 1 clients at the rate 1 clients/s... Name # reqs # fails Avg Min Max | Median req/s -------------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------- Total 0 0(0.00%) 0.00 [2017-02-21 21:27:27,526] Leos-MacBook-Air.local/INFO/locust.runners: All locusts hatched: WebsiteUser: 1 [2017-02-21 21:27:27,527] Leos-MacBook-Air.local/INFO/locust.runners: Resetting stats Name # reqs # fails Avg Min Max | Median req/s -------------------------------------------------------------------------------------------------------------------------------------- GET /about/ 0 0(0.00%) 0 0 0 | 0 0.00 -------------------------------------------------------------------------------------------------------------------------------------- Total 0 0(0.00%) 0.00 Name # reqs # fails Avg Min Max | Median req/s -------------------------------------------------------------------------------------------------------------------------------------- GET /about/ 1 0(0.00%) 17 17 17 | 17 0.00 -------------------------------------------------------------------------------------------------------------------------------------- Total 1 0(0.00%) 0.00 [2017-02-21 21:27:32,420] Leos-MacBook-Air.local/INFO/locust.runners: All locusts dead [2017-02-21 21:27:32,421] Leos-MacBook-Air.local/INFO/locust.main: Shutting down (exit code 0), bye. Name # reqs # fails Avg Min Max | Median req/s -------------------------------------------------------------------------------------------------------------------------------------- GET / 1 0(0.00%) 20 20 20 | 20 0.00 GET /about/ 1 0(0.00%) 17 17 17 | 17 0.00 -------------------------------------------------------------------------------------------------------------------------------------- Total 2 0(0.00%) 0.00 Percentage of the requests completed within given times Name # reqs 50% 66% 75% 80% 90% 95% 98% 99% 100% -------------------------------------------------------------------------------------------------------------------------------------- GET / 1 20 20 20 20 20 20 20 20 20 GET /about/ 1 17 17 17 17 17 17 17 17 17 -------------------------------------------------------------------------------------------------------------------------------------- web 如果采用web形式，，则通常情况下无需指定其它额外参数，Locust默认采用8089端口启动web；如果要使用其它端口，就可以使用如下参数进行指定。 -P, --port：指定web端口，默认为8089. $ locust -H https://debugtalk.com -f demo.py [2017-02-21 21:31:26,334] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 [2017-02-21 21:31:26,334] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 此时，Locust并没有开始执行测试，还需要在Web页面中配置参数后进行启动。 如果Locust运行在本机，在浏览器中访问http://localhost:8089即可进入Locust的Web管理页面；如果Locust运行在其它机器上，那么在浏览器中访问http://locust_machine_ip:8089即可。 在Locust的Web管理页面中，需要配置的参数只有两个： Number of users to simulate: 设置并发用户数，对应中no_web模式的-c, --clients参数； Hatch rate (users spawned/second): 启动虚拟用户的速率，对应着no_web模式的-r, --hatch-rate参数。 参数配置完毕后，点击【Start swarming】即可开始测试。 ","date":"2017-02-22","heading":"单进程运行","objectID":"/post/head-first-locust-user-guide/:5:1","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#单进程运行"},{"categories":["Testing"],"collections":null,"content":"多进程分布式运行 不管是单机多进程，还是多机负载模式，运行方式都是一样的，都是先运行一个master，再启动多个slave。 启动master时，需要使用--master参数；同样的，如果要使用8089以外的端口，还需要使用-P, --port参数。 $ locust -H https://debugtalk.com -f demo.py --master --port=8088 [2017-02-21 22:59:57,308] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8088 [2017-02-21 22:59:57,310] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 master启动后，还需要启动slave才能执行测试任务。 启动slave时需要使用--slave参数；在slave中，就不需要再指定端口了。 $ locust -H https://debugtalk.com -f demo.py --slave [2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 [2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.runners: Client 'Leos-MacBook-Air.local_980ab0eec2bca517d03feb60c31d6a3a' reported as ready. Currently 2 clients ready to swarm. 如果slave与master不在同一台机器上，还需要通过--master-host参数再指定master的IP地址。 $ locust -H https://debugtalk.com -f demo.py --slave --master-host= [2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 [2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.runners: Client 'Leos-MacBook-Air.local_980ab0eec2bca517d03feb60c31d6a3a' reported as ready. Currently 2 clients ready to swarm. master和slave都启动完毕后，就可以在浏览器中通过http://locust_machine_ip:8089进入Locust的Web管理页面了。使用方式跟单进程web形式完全相同，只是此时是通过多进程负载来生成并发压力，在web管理界面中也能看到实际的slave数量。 ","date":"2017-02-22","heading":"多进程分布式运行","objectID":"/post/head-first-locust-user-guide/:5:2","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#多进程分布式运行"},{"categories":["Testing"],"collections":null,"content":"测试结果展示 Locust在执行测试的过程中，我们可以在web界面中实时地看到结果运行情况。 相比于LoadRunner，Locust的结果展示十分简单，主要就四个指标：并发数、RPS、响应时间、异常率。但对于大多数场景来说，这几个指标已经足够了。 在上图中，RPS和平均响应时间这两个指标显示的值都是根据最近2秒请求响应数据计算得到的统计值，我们也可以理解为瞬时值。 如果想看性能指标数据的走势，就可以在Charts栏查看。在这里，可以查看到RPS和平均响应时间在整个运行过程中的波动情况。这个功能之前在Locust中一直是缺失的，直到最近，这个坑才被我之前在阿里移动的同事（网络IDmyzhan）给填上了。当前该功能已经合并到Locust了，更新到最新版即可使用。 除了以上数据，Locust还提供了整个运行过程数据的百分比统计值，例如我们常用的90%响应时间、响应时间中位值，该数据可以通过Download response time distribution CSV获得，数据展示效果如下所示。 ","date":"2017-02-22","heading":"测试结果展示","objectID":"/post/head-first-locust-user-guide/:6:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#测试结果展示"},{"categories":["Testing"],"collections":null,"content":"总结 通过前面对Locust全方位的讲解，相信大家对Locust的功能特性已经非常熟悉了，在实际项目中将Locust作为生产力工具应该也没啥问题了。 不过，任何一款工具都不是完美的，必定都会存在一些不足之处。但是好在Locust具有极强的可定制型，当我们遇到一些特有的需求时，可以在Locust上很方便地实现扩展。 还是前面提到的那位技术大牛（myzhan），他为了摆脱CPython的GIL和gevent的 monkey_patch()，将Locust的slave端采用golang进行了重写，采用goroutine取代了gevent。经过测试，相较于原生的Python实现，他的这套golang实现具有5~10倍以上的性能提升。当前，他已经将该实现开源，项目名称为myzhan/boomer，如果大家感兴趣，可以阅读他的博客文章进一步了解，《用 golang 来编写压测工具》。 如果我们也想在Locust的基础上进行二次开发，那要怎么开始呢？ 毫无疑问，阅读Locust的项目源码是必不可少的第一步。可能对于很多人来说，阅读开源项目源码是一件十分困难的事情，不知道如何着手，在知乎上也看到好多关于如何阅读开源项目源码的提问。事实上，Locust项目的代码结构清晰，核心代码量也比较少，十分适合阅读学习。哪怕只是想体验下阅读开源项目源码，或者说想提升下自己的Python技能，Locust也是个不错的选择。 在下一篇文章中，我将对Locust源码进行解析，《深入浅出开源性能测试工具Locust（源码篇）》，敬请期待！ ","date":"2017-02-22","heading":"总结","objectID":"/post/head-first-locust-user-guide/:7:0","tags":["性能测试","Locust"],"title":"深入浅出开源性能测试工具 Locust（使用篇）","uri":"/post/head-first-locust-user-guide/#总结"},{"categories":["Growth"],"collections":null,"content":" 今年春节没有像往常一样回家过年，而是到欧洲旅行（意大利和法国），算是蜜月游吧。第一次到欧洲，确切地说，第一次出国，新鲜感还是蛮大的，趁着返程飞机上的空闲时间，写篇博客记录下。 我这次旅行全程12天，游玩的城市有罗马、梵蒂冈、佛罗伦萨、米兰和巴黎，景点以历史名胜和城市风光为主。全程自助游，行程安排比较自由宽松。当然，这篇博客并不是旅行攻略，描述的内容也不会面面俱到，只是挑了几个感触较深的点，观点可能很是片面，如有偏颇还请留言讨论。 先说下总体印象吧。如果说要通过几个关键词来概括这次旅行的所见所闻，特别是跟国内（一二线城市）进行对比的话，我个人的印象是：自然环境优美、生活节奏舒缓、饮食类型单一、公共设施陈旧、公共安全紧张、工程师氛围稀缺。 自然环境优美没啥好说的，之前不管是通过网络，还是和朋友的聊天交流，对欧洲的印象就是自然环境特别好。实际情况也的确是这样的。空气质量这块儿我没啥特别的感觉，因为深圳的空气本来也挺好的。见过的河水湖泊很是清澈，完全没有污染的痕迹。绿化非常赞，除了街道的树木，在城市中有很多森林公园。印象最深的就是小动物了，虽然是在城市里，但是到处都能看到鸽子、海鸥，在公园里还能看到野鸡、野鸭、天鹅，而且好玩的是这些动物基本都不怕人，靠近它们的时候也不会躲避，我还趁天鹅把头钻进水里捕食时偷偷摸了它的屁股。想想在国内，能在城市中看见只麻雀就算稀罕了，这方面真是没法比。 在人文环境方面，最大的感触就是欧洲的生活节奏十分舒缓。店铺通常开得挺晚的，关得也特别早，特别是在罗马和佛罗伦萨，下午六七点天黑后，就基本很少看到人了。在我等天朝IT狗的眼里，他们的工作量真是极其的不饱和啊。可能也是这方面的原因，商家普遍的服务态度都挺好的，很有耐心。有一次在梵蒂冈的礼品店里，虽然语言交流不是很顺畅，但老太太还是很有耐心的一一介绍，最后由于感觉价格比较贵，不大好意思地说不买了，老太太也完全没有表现出任何不悦，而是笑盈盈地说了句Thank you, have a nice day。在欧洲街道上，如果是在没有红绿灯的情况下，司机都会挥手示意让行人先过马路。刚开始的时候还有些不习惯，站在路口等汽车先走，结果司机更有耐心，执意等行人先走。哦对，在欧洲马路上，基本上没有听到汽车喇叭声，有时候没有看到身后的车辆，司机会探出头来打招呼。另外，在欧洲街头经常会看见各种行为艺术，特别是在罗马斗兽场的一条街道上，一路走去，吹拉弹唱跳，形式各异，水准颇高，真是一道靓丽的风景。看到他们总会经不住感叹，人家这才是生活啊，我等只能叫活着。 说到语言交流，这跟我之前的预期不大一样。在没出国之前，原以为欧洲的英语应该普及度很高，结果到了这边后发现并非如此，好多本地人并不会英语，即使是警察这样的公务员，会说英语的也是少数，而且口音还特别重。不过语言也真是一个神奇的东西，可能同样的场景，做英语听力理解题时无法正确答题，但是在面对面交流时，加上简单的肢体动作和眼神，基本上只要不是太复杂的场景，都难很好的完成交流。最多为了保险起见，我再用英语复述一遍我的理解，跟对方进行确认即可。哦对了，在欧洲遇到的好多人虽然不会说英语，但是貌似听懂是没啥问题，只是可能有些词汇不理解，需要变换下说法。例如，在询问能不能飞无人机时，跟他们说drone他们都不知道是啥意思，但是说mini aircraft他们就会明白了。 然后说下差异最大的饮食吧。跟博大精深的中餐相比，欧洲的饮食真是单一乏味。当然，像法餐这样高逼格的不在讨论范围内，价格太贵，我相信即使是本地人也不至于天天这么吃吧。在意大利和法国，当地人吃得最多的应该就是披萨和汉堡了。在我看来，意大利人真是除了披萨意面就没啥别的了。虽然披萨被做出花儿了，各种馅儿的都有，但老是这么吃还是受不了。意面更别提了，我们吃了一次就不想再吃。有一次在披萨店惊奇地发现有米饭，结果还是半生的，完全没法吃，想想也是，微波炉哪能做出米饭来。因此我们后面每到一个城市，就到处找中餐馆。中餐的价格普遍比披萨店贵两倍，面条、炒饭、盖浇饭这类快餐，普遍在7~9欧的样子，如果点菜的话，人均消费差不多要十多欧，这还是在比较克制的情况下。即使是这样，中餐馆的生意还都特别好。在罗马的一家中餐馆给我们的印象尤其深刻，我们是通过大众点评找过去的，结果到了以后发现门口贴纸告知要五点半开门。我们还以为店家不会开门了，就先在旁边的一家披萨店先吃着。过一会儿后，发现那家店门口逐渐聚集了一波中国人，到了五点半的时候，还真准时开门了。进去之后，发现基本满座了，全是中国人。一会儿后，服务员领班说，要等一阵才能点菜，因为厨师们要先吃饭。然后，所有顾客就看着一大桌厨师和服务员在那儿吃。我们也真是长见识了，原来餐馆还能这么开的，牛逼，换成在国内试试？ 除了饮食，公共基础设施方面跟国内也没法比。首先是公交地铁，普遍比叫陈旧。特别是巴黎的RER线，简直难以相信这就是号称时尚浪漫之都的巴黎，刷票的门坏了好几个，各种电线暴露在外面垂在半空中，好多通道没有电梯，地铁车厢特别脏，地面积了厚厚的泥土和水渍，估计是好久都没有打扫过了，让我们一度以为我们到了假巴黎。然后说下欧洲的公厕，上一次要1欧，即使是在有的麦当劳店里，上厕所也是要收费的，每次想上厕所时换算下，要七块多人民币，真是尿不起啊有木有！不过公厕收费也比较好理解，因为欧洲的人力成本本来就比较高，特别是清洁工这类工种。除了硬件类的基础设施，软件类的服务跟国内相比也落后太多。在国内的时候，基本上带个手机出门就行了，吃饭、购物、叫车全电子化，但是在欧洲都基本还是用现金，大额消费可以刷信用卡，出去一趟回来兜里就好多找零的硬币。当然，虽然欧洲本土的互联网服务不咋地，但是人家能用Google的各项服务啊，光这一项我大天朝就完全被秒杀了。 在安全方面，感觉氛围比较紧张，没有在国内那么踏实。在机场、车站以及各个旅游景点，到处都是全副武装的大兵，警察也都是配枪的。本来我还打算出国后多拍下风景，特地在出国前买了一台Mavic Pro，结果到那儿以后看到这阵势，又在网上看到各种禁飞条令，因此也不敢造次，要是一不小心被当做恐怖分子击毙就不值了。另外，在街头随处可见流浪汉，甚至好多一家四五口一起睡在路边，估计是从其它国家过来避难的。如果政府没有救助措施的话，估计也会形成一定的安全隐患，之前也听朋友说最好晚上不要出门，抢劫、行窃比较多，还好我们没有遇到。 最后再说下跟我们软件工程师的相关的吧。不像在国内，上下班时满眼的笔记本电脑背包，在意大利和法国我真是一个都没看到过。我在想，这里是没有程序员的么，还是说程序员的电脑也是装在意大利皮革的手提包里了？不过，在艺术气息如此浓厚的地方，本地人应该也很难对写代码感兴趣吧。在巴黎的Airbnb民宿中，虽然没看到房东本人，但是从房屋装饰、陈列书籍和CD碟片来看，房东应该也是个搞绘画或雕塑的。之前听朋友说欧洲的技术工种稀缺，过去比较好找工作，当时我还颇为心动的；现在看来，是否稀缺不好说，但估计需求也比较少，即使过去，感觉从技术氛围的角度来看，也不是一个好的选择。所以去欧洲工作的想法先暂时搁置吧，后面再找机会去其它国家转转。 以上便是我这次欧洲意法之行的见闻和感受了，收获还是蛮大的。之前可能是一直处于天朝的大环境中，多多少少会变得有些功利和急躁，大至职业发展薪酬涨幅，小至绩效考核分数排名，很难做到不那么在意。但是当我在欧洲街头看到各类艺人专注于技艺表演时，在阿诺河旁看到白发老人沉浸于绘画写生时，我不禁在想，专注于自己喜爱的事情已经就足够幸福了，何必被世俗的眼光所左右，被眼前的蝇头小利所蒙蔽呢。虽说人生短暂，但是即使从现在开始，也还有好几十年的时光，足够自己折腾了。 嗯，前提是永远不要为自己设限。 Keep learning and programming. ","date":"2017-02-02","heading":"","objectID":"/post/my-impression-on-italy-and-france/:0:0","tags":["旅游","思考记录"],"title":"春节旅行之意法印象","uri":"/post/my-impression-on-italy-and-france/#"},{"categories":["Growth"],"collections":null,"content":"2016年于我而言，经历的事情挺多的，现在回想起来，很难相信这些都是在一年内发生的。 在5月份的时候，我从UC离职去了大疆，从移动互联网公司到了做无人机的硬件公司，从待了5年的广州到了深圳。很多朋友都很好奇换工作的原因，也许，是我还没有折腾够吧，再或者，是想到一个全新的环境去实践自己的一些想法吧。有种说法是，判断一个选择是否正确的方式，就是假设让你再回到之前选择的时刻，你还是否会做出同样的决定。如果让我回答这个问题，还真不好说，毕竟UC和大疆都是非常优秀的公司，只是作为成熟的上市公司和处于快速成长期的创业公司，各自有着不同的特质罢了。不过，我对现在的工作状态挺满意的，我想这就够了。 在技术方面，今年接触的领域挺多的。离开UC之前，主要是在Android客户端性能测试的持续集成方面，探索实践了几类专项测试，并将公司的测试平台任务成功率从不足70%提升到了95%以上。到了大疆以后，发现这边在测试这块儿基本上还处于蛮荒状态，于是便借此机会从零开始建设测试平台。刚开始接手的项目是DXX+Discover，于是新接触了iOS测试，从零开始做了iOS的UI自动化测试，并开源了一个基于Appium的测试框架AppiumBooster；然后搞了持续集成测试平台，将自动化打包构建和iOS自动化测试串了起来。后面因为项目调整，接手了商城性能测试，重新捡起了之前的老本行服务端性能测试，不过没有使用之前精通的LoadRunner，而是选择了Locust这款开源的测试工具，并在其基础上做了一些扩展。再到后来，公司成立了互联网事业部一级部门，划分了质量部二级部门，然后我就从业务测试工作中抽调出来，专职做测试开发的工作，主要内容暂时是在Web的接口自动化测试和流程自动化测试方面。 除了纯粹的技术工作，今年也开始做测试岗的技术面试工作，前前后后面试了三四十人。在面试工作中，收获也挺大的，最主要的还是可以借此了解到其它公司在软件测试方面开展的情况，以及其他同行在做的事情，这些都挺有借鉴意义的。其中，有一点感触特别深，在我们这个行业，干技术的如果工作年限与实际能力不匹配，真的是挺尴尬的。虽然听上去很残忍，但是站在招聘方的角度就很好理解了，一个工作七八年的人如果技术积累跟工作了一两年的人差不多，或者是只高了一点，那么公司不管是从薪酬成本还是从员工发展潜力的角度，肯定会选择后者了。因此，既然选择了技术这条路，要想以后不经历这种尴尬，我们能做的也只有多磨练多积累了。 在今年，我开始用心经营个人博客和微信公众号（DebugTalk）。虽然在2013年的时候，当时也有开通过博客和公众号（52test.org），但那时玩的性质更多一些，没写几篇就搁置了。今年重新开始写博客的原因也挺简单的，就是想对自己的工作和学习进行下总结和记录。而且自身也越来越认同一个观点，最好的学习方式就是去教授别人，因为要向别人将一个问题通俗易懂地讲解清楚，自己就必须对其中的原理和因果联系有足够清晰的认识，有了这层动力，学习也就更有方向性，学习效率和效果也就上去了。再延伸一点，其实这也跟TDD的开发模式挺类似的，现在我也挺喜欢这样的开发模式，并在实际开发工作中践行TDD。 截止当前，今年更新的博客大概有30篇左右，主要内容还是工作中对一些技术的感悟和对学习过程的记录。从数量上看，这个量还是挺少的，而且更新频率也从每周数篇，到每周一篇，再到半月一篇，一月一篇，工作繁忙是一方面的原因，更主要的还是拖延症导致的。而且一旦丢了写博客的感觉，再提笔写起来更是难上加难。还算欣慰的是，从留言反馈来看，写的东西至少还是给一部分人带去了一些价值，并且博客在搜索引擎中也有了不错的收录。特别是有时候同事搜索解决方案结果检索到我写的文章时，还是会有一些成就感的。另外，通过公众号和博客，我也认识了好多业界同行，这也是一个很大的收获。 因为个人博客的原因，今年第一次接了私活儿，挣到了第一笔工资以外的收入。当时深圳一所高校的老师看到我的博客后，觉得还不错，于是联系我想让我给他和他的同事培训，他们再将培训内容用到教学中。之前也没接过私活儿，只是觉得这也是一个不错的实践机会，所以就答应了。好在实际教学过程中，他们还挺满意，我的压力也就小了许多。这里再插入一件比较有趣的事情，第一次授课后一位老师问我是研究生还是博士，我不大好意思地说我是本科。一个本科生给博士和博士后培训，这也算是一份难得的人生经历了。不过，术业有专攻嘛，理论教学和工程实践毕竟存在一定的差异。我也挺佩服他们的，能在业余休息时间主动去学习，让学生能了解到当前工程界流行的技术，这比某些照着课本念几十年的\u0026quot;老教授\u0026quot;真的不知道强了多少倍。 除了博客分享，今年在公司内部也进行了几场技术分享，印象比较深的有：《Python的函数式编程\u0026ndash;从入门到⎡放弃⎦》，《从0到1搭建移动APP功能自动化测试平台（AppiumBooster）》，《漫谈服务端性能测试》，《基于有限状态机的流程自动化测试》等。在演讲方面，自我感觉在时间控制方面还比较欠缺，一不小心就超时，这个在明年得好好改进下。 当然，对我个人而言，2016年最大的一件事儿就是成家了。虽然在年初的时候还完全没有想过今年会结婚的，但我还是和女朋友在2016年结束了四年的恋爱长跑，在9.19领了证，在10.3办了婚礼。当然，不是奉子成婚，请不要邪恶。在婚礼这件事情上，我们没有请婚庆主持，而是找了我们从小玩到大的好朋友，我们自己设计的流程环节，自己选的背景音乐，自己做的视频，虽然在婚礼前一晚还因为设备故障忙得焦头烂额，但是经历这么一个过程本身也是挺有意义的。 最后，我再简单地展望下2017年吧。 1、坚持写博客。至于具体指标，盯着博客阅读量和微信公众号关注人数也没啥意义，更多的还是希望能通过写博客让自己的内心静下来，同时获得自我成长。 2、进行一场对外技术分享，毕竟，面向公众演讲和面向同事演讲，感觉还是不一样的。也希望通过这种方式，逼迫自己更快的成长。 3、经得住诱惑，做好手头的工作，将公司的软件测试技术提升一个层次。这一年来收到的工作邀请也挺多的，特别是华为和腾讯，开出的薪资也很是诱人。但我还是觉得不能这么浮躁，换一个地方如果还是做同样的事情，意义也不大，还不如跟着公司一起成长。 4、学会生活，锻炼身体，陪伴家人，工作是长跑，讲究的是可持续发展。 ","date":"2016-12-31","heading":"","objectID":"/post/my-2016-summary/:0:0","tags":["年终总结"],"title":"我的 2016 年终总结","uri":"/post/my-2016-summary/#"},{"categories":["Development"],"collections":null,"content":"在macOS中配置Web代理时，通常的做法是在控制面板中进行操作，System Preferences -\u0026gt; Network -\u0026gt; Advanced -\u0026gt; Proxies. 这种配置方式虽然可以实现需求，但缺点在于操作比较繁琐，特别是在需要频繁切换的情况下，效率极其低下。 基于该痛点，我们希望能避免重复操作，实现快速切换配置。 ","date":"2016-11-24","heading":"","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:0:0","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#"},{"categories":["Development"],"collections":null,"content":"Terminal中实现网络代理配置 要避免在GUI进行重复的配置操作，比较好的简化方式是在Terminal中通过命令实现同样的功能。事实上，在macOS系统中的确是存在配置网络代理的命令，该命令即是networksetup。 ","date":"2016-11-24","heading":"Terminal中实现网络代理配置","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:1:0","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#terminal中实现网络代理配置"},{"categories":["Development"],"collections":null,"content":"获取系统已有的网络服务 首先需要明确的是，macOS系统中针对不同网络服务（networkservice）的配置是独立的，因此在配置Web代理时需要进行指定。 而要获取系统中存在哪些网络服务，可以通过如下命令查看： $ networksetup -listallnetworkservices An asterisk (*) denotes that a network service is disabled. Wi-Fi iPhone USB Bluetooth PAN Thunderbolt Bridge 如果计算机是通过Wi-Fi上网的，那么我们设置网络代理时就需要对Wi-Fi进行设置。 ","date":"2016-11-24","heading":"获取系统已有的网络服务","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:1:1","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#获取系统已有的网络服务"},{"categories":["Development"],"collections":null,"content":"开启Web代理 通过networksetup命令对HTTP接口设置代理时，可以采用如下命令： $ sudo networksetup -setwebproxy # e.g. sudo networksetup -setwebproxy \"Wi-Fi\" 127.0.0.1 8080 执行该命令时，会开启系统的Web HTTP Proxy，并将Proxy设置为127.0.0.1:8080。 如果是对HTTPS接口设置代理时，命令为： $ networksetup -setsecurewebproxy # e.g. sudo networksetup -setsecurewebproxy \"Wi-Fi\" 127.0.0.1 8080 ","date":"2016-11-24","heading":"开启Web代理","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:1:2","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#开启web代理"},{"categories":["Development"],"collections":null,"content":"关闭Web代理 对应地，关闭HTTP和HTTPS代理的命令为： $ sudo networksetup -setwebproxystate # e.g. sudo networksetup -setwebproxystate \"Wi-Fi\" off $ networksetup -setsecurewebproxystate # e.g. sudo networksetup -setsecurewebproxystate \"Wi-Fi\" off ","date":"2016-11-24","heading":"关闭Web代理","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:1:3","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#关闭web代理"},{"categories":["Development"],"collections":null,"content":"结合Shuttle实现一键配置 现在我们已经知道如何通过networksetup命令在Terminal中进行Web代理切换了，但如果每次都要重新输入命令和密码，还是会很麻烦，并没有真正地解决我们的痛点。 而且在实际场景中，我们通常需要同时开启或关闭HTTP、HTTPS两种协议的网络代理，这类操作如此高频，要是还能通过点击一个按钮就实现切换，那就优雅多了。 幸运的是，这种优雅的方式还真能实现，只需要结合使用Shuttle这么一款小工具。 Shuttle，简而言之，它可以将一串命令映射到macOS顶部菜单栏的快捷方式。我们要做的很简单，只需要将要实现的任务拼接成一条串行的命令即可，然后就可以在系统菜单栏中点击按钮运行整条命令。 例如，在Terminal中，要想在不手动输入sudo密码的情况下实现同时关闭HTTP和HTTPS的网络代理，就可以通过如下串行命令实现。 $ echo | sudo -S networksetup -setwebproxystate 'Wi-Fi' off \u0026\u0026 sudo networksetup -setsecurewebproxystate 'Wi-Fi' off 类似地，我们还可以实现同时开启HTTP和HTTPS网络代理，更有甚者，我们还可以实现在同时开启HTTP和HTTPS网络代理后，启动mitmproxy抓包工具。 这一切配置都可以在Shuttle的配置文件~/.shuttle.json中完成。 \"hosts\": [ { \"mitmproxy\": [ { \"name\": \"Open mitmproxy\", \"cmd\": \"echo | sudo -S networksetup -setwebproxy 'Wi-Fi' 127.0.0.1 8080 \u0026\u0026 sudo networksetup -setsecurewebproxy 'Wi-Fi' 127.0.0.1 8080 \u0026\u0026 workon mitmproxy \u0026\u0026 mitmproxy -p 8080\" } ], \"HTTP(S) Proxy\": [ { \"name\": \"Turn on HTTP(S) Proxy\", \"cmd\": \"echo | sudo -S networksetup -setwebproxy 'Wi-Fi' 127.0.0.1 8080 \u0026\u0026 sudo networksetup -setsecurewebproxy 'Wi-Fi' 127.0.0.1 8080\" }, { \"name\": \"Turn off HTTP(S) Proxy\", \"cmd\": \"echo | sudo -S networksetup -setwebproxystate 'Wi-Fi' off \u0026\u0026 sudo networksetup -setsecurewebproxystate 'Wi-Fi' off\" }, ], }, ] 配置十分简洁清晰，不用解释也能看懂。完成配置后，在macOS顶部菜单栏中就会出现如下效果的快捷方式。 后续，我们就可以通过快捷方式实现一键切换HTTP(S)代理配置、一键启动mitmproxy抓包工具了。 说到mitmproxy这款开源的抓包工具，只能说相见恨晚，第一次使用它时就被惊艳到了，情不自禁地想给它点个赞！自从使用了mitmproxy，我现在基本上就不再使用Fiddler和Charles了，日常工作中HTTP(S)抓包任务全靠它搞定。 哦对了，mitmproxy不仅可以实现抓包任务，还可以跟locust性能测试工具紧密结合，直接将抓取的数据包生成locust脚本啊！ mitmproxy如此强大，本文就不再多说了，后续必须得写一篇博客单独对其详细介绍。 ","date":"2016-11-24","heading":"结合Shuttle实现一键配置","objectID":"/post/switch-macOS-web-proxy-in-elegant-way/:2:0","tags":["proxy","Shuttle","mitmproxy","Locust"],"title":"如何优雅地一键实现 macOS 网络代理切换","uri":"/post/switch-macOS-web-proxy-in-elegant-way/#结合shuttle实现一键配置"},{"categories":["Testing"],"collections":null,"content":"最近因为工作原因，我又拾起了老本行，开始做Web性能测试。之前虽然做过三四年的性能测试，但是在博客和开源项目方面都没有什么输出，一直是一个很大的遗憾。因此，近期打算围绕服务端性能测试的话题，将自己在这方面的经历进行整理。并且，最近使用的性能测试工具Locust感觉挺不错的，只是其功能比较单薄，特别是在性能指标监控和测试报告图表方面比较缺失，因此也打算在Locust的基础上做二次开发，打造一款自己用得顺手的性能测试工具，暂且将其命名为LocustPlus吧。 ","date":"2016-11-02","heading":"","objectID":"/post/locustplus-talk-about-performance-test/:0:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#"},{"categories":["Testing"],"collections":null,"content":"简述性能测试 提起性能测试，可能移动APP的从业人员会感觉比较混淆，因为在客户端（Android、iOS）中也有性能测试专项，主要涉及的是APP的启动时间、内存、包大小、帧率，流量等客户端相关的指标。在本博客之前的文章中，也包含了一些客户端性能测试的内容。需要说明的是，本文所讲解的性能测试都是针对服务器端，尤指Web系统的，与移动APP的性能测试完全是不同的领域。 那么，什么是服务端的性能测试呢？ 先从大家都熟悉的功能测试说起吧。例如，我们要测试一个搜索功能，那么我们测试时，就会输入搜索关键词，点击搜索按钮，然后再去查看搜索结果，看结果是否跟我们输入的搜索关键词匹配，如果匹配则说明搜索功能实现正确。 那如何对该功能进行性能测试呢？ 答案就是，N个人同时进行功能性操作的同时，在确保功能实现正确的前提下，考察服务端应用程序的各项性能指标，以及服务器硬件资源的使用情况。 当然，这个答案比较简单粗暴，但是它仍然包含了性能测试的基本特点： 以功能实现正确为前提 通常有一定的并发用户 重点考察服务器端在一定并发压力下的性能指标 最后，再明确下性能测试的目的。通常，对服务器端应用程序开展性能测试，是为了验证软件系统是否能够达到预期的性能指标，同时发现软件系统中存在的性能瓶颈，从而实现优化系统的目的。 ","date":"2016-11-02","heading":"简述性能测试","objectID":"/post/locustplus-talk-about-performance-test/:1:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#简述性能测试"},{"categories":["Testing"],"collections":null,"content":"性能测试方法的核心 根据不同的测试目的，性能测试可以分为多种类型，常见的有如下几类： 基准测试（Standard Testing） 负载测试（Load Testing） 压力测试（Stress Testing） 疲劳强度测试 首先说下基准测试。基准测试指的是模拟单个用户执行业务场景时，考察系统的性能指标。严格意义上来讲，基准测试并不能算作性能测试范畴，它跟功能测试并没有太大区别。差异在于，基准测试的目的更多地是关注业务功能的正确性，或者说验证测试脚本的正确性，然后，将基准测试时采集得到的系统性能指标，作为基准测试结果，为后续并发压力测试的性能分析提供参考依据。 负载测试，主要指的是模拟系统在正常负载压力场景下，考察系统的性能指标。这里说的正常负载，主要是指用户对系统能承受的最大业务负载量的期望值，即预计系统最大应该支持多大用户的并发量。通过负载测试，目的是验证系统是否能满足预期的业务压力场景。 和负载测试的概念比较接近的是压力测试。通俗地讲，压力测试是为了发现在多大并发压力下系统的性能会变得不可接受，或者出现性能拐点（崩溃）的情况。在加压策略上，压力测试会对被测系统逐步加压，在加压的过程中考察系统性能指标的走势情况，最终找出系统在出现性能拐点时的并发用户数，也就是系统支持的最大并发用户数。 最后再说下疲劳强度测试。其实疲劳强度测试的加压策略跟负载测试也很接近，都是对系统模拟出系统能承受的最大业务负载量，差异在于，疲劳强度测试更关注系统在长时间运行情况下系统性能指标的变化情况，例如，系统在运行一段时间后，是否会出现事务处理失败、响应时间增长、业务吞吐量降低、CPU/内存资源增长等问题。 通过对比可以发现，不同的性能测试类型，其本质的差异还是在加压策略上，而采用何种加压策略，就取决于我们实际的测试目的，即期望通过性能测试发现什么问题。明白了这一点，性能测试类型的差异也就不再容易混淆了。 结论要点1：性能测试手段的重点在于加压的方式和策略。 ","date":"2016-11-02","heading":"性能测试方法的核心","objectID":"/post/locustplus-talk-about-performance-test/:2:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#性能测试方法的核心"},{"categories":["Testing"],"collections":null,"content":"性能瓶颈定位的核心 在前面频繁地提到了性能指标，那性能指标究竟有哪些，我们在性能测试的过程中需要重点关注哪些指标项呢？ 从维度上划分，性能指标主要分为两大类，分别是业务性能指标和系统资源性能指标。 业务性能指标可以直观地反映被测系统的实际性能状况，常用的指标项有： 并发用户数 事务吞吐率（TPS/RPS） 事务平均响应时间 事务成功率 而系统资源性能指标，主要是反映整个系统环境的硬件资源使用情况，常用的指标包括： 服务器：CPU利用率、处理器队列长度、内存利用率、内存交换页面数、磁盘IO状态、网卡带宽使用情况等； 数据库：数据库连接数、数据库读写响应时长、数据库读写吞吐量等； 网络：网络吞吐量、网络带宽、网络缓冲池大小； 缓存（Redis）：静态资源缓存命中率、动态数据缓存命中率、缓存吞吐量等； 测试设备（压力发生器）：CPU利用率、处理器队列长度、内存利用率、内存交换页面数、磁盘IO状态、网卡带宽使用情况等。 对于以上指标的具体含义我就不在此进行逐一说明了，大家可以自行搜索，务必需要搞清楚每个指标的概念及其意义。可能有些指标在不同的操作系统中的名称有些差异，但是基本都会有对应的指标，其代表的意义也是相通的。例如，处理器队列长度这个指标，在Windows中的指标名称是System\\Processor Queue Length，而在Linux系统中则需要看load averages。 可能对于最后一项（测试设备）有些人不大理解，监控被测系统环境的相关硬件资源使用情况不就好了么，为什么还要关注测试设备本身呢？这是因为测试设备在模拟高并发请求的过程中，设备本身也会存在较高的资源消耗，例如CPU、内存、网卡带宽吃满，磁盘IO读写频繁，处理器排队严重等；当出现这类情况后，测试设备本身就会出现瓶颈，无法产生预期的并发压力，从而我们测试得到的数据也就不具有可参考性了。此处暂不进行展开，后面我会再结合实际案例，通过图表和数据对此详细进行说明。 需要说明的是，性能指标之间通常都是有密切关联的，单纯地看某个指标往往很难定位出性能瓶颈，这需要我们对各项性能指标的含义了然于胸，然后才能在实际测试的过程中对系统性能状况综合进行分析，找出整个系统真正的瓶颈。举个简单的例子，压力测试时发现服务器端CPU利用率非常高，那这个能说明什么问题呢？是服务端应用程序的算法问题，还是服务器硬件资源配置跟不上呢？光看这一个指标并不能定位出产生问题的真正原因，而如果仅因为这一点，就决定直接去优化程序算法或者升级服务器配置，最后也很难真正地解决问题。 结论要点2：性能瓶颈定位的重点在于性能指标的监控和分析。 ","date":"2016-11-02","heading":"性能瓶颈定位的核心","objectID":"/post/locustplus-talk-about-performance-test/:3:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#性能瓶颈定位的核心"},{"categories":["Testing"],"collections":null,"content":"引入性能测试工具 通过前面的讲解，我们已经知道性能测试的主要手段是通过产生模拟真实业务的压力对被测系统进行加压，与此同时监控被测系统的各项性能指标，研究被测系统在不同压力情况下的表现，找出其潜在的性能瓶颈。 那么，如何对系统进行加压，又如何对系统的指标进行监控呢？这里，就需要引入性能测试工具了。 当然，我们也可以先看下在不借助性能测试工具的情况下，如何手工地对系统进行性能测试。 假设现在我们要对前面提到的搜索功能进行负载测试，验证在20个并发用户下搜索功能的事务平均响应时间是否在3秒以内。 很自然地，我们可以想到测试的必要条件有如下几点： 20个测试人员，产生业务压力 1个指挥人员，对20个人员的协调控制，实现并发操作 1个结果记录人员，对每一个人员的操作耗时进行监控和记录 若干资源监控人员，实时查看被测系统的各项性能指标，对指标进行汇总、分析 1个结果统计人员，对20个用户各操作消耗的时长进行汇总，计算其平均值 可以看出，要通过人工来进行性能测试，操作上极为繁琐，需要投入的资源非常多，而这还仅仅是一个非常简单的场景。设想，如果要测试10000并发，服务器有好几十台，显然，这种情况下是完全不可能通过投入人力就能解决的。这也就是性能测试工具存在的必要性和诞生的背景。 ","date":"2016-11-02","heading":"引入性能测试工具","objectID":"/post/locustplus-talk-about-performance-test/:4:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#引入性能测试工具"},{"categories":["Testing"],"collections":null,"content":"性能测试工具的基本组成 当前，市面上已经有了很多性能测试工具，但不管是哪一款，基本都会包含如下几个核心的模块。 压力生成器（Virtual User Generator） 结果采集器（Result Collector） 负载控制器（Controller） 系统资源监控器（Monitor） 结果分析器（Analysis） 原理结构图如下所示： 对照前面手工进行性能测试的案例，不难理解，压力发生器对应的是众多测试人员，结果采集器对应的是结果记录人员，负载控制器对应的是指挥人员，资源监控器对应的是若干资源监控人员，结果分析器对应的是结果统计人员。 其中，压力发生器又是性能测试工具最核心的部分，它主要有两个功能，一是真实模拟用户操作，二是模拟有效并发。 然而，大多数性能测试工作人员可能都会忽略的是，当前市面上性能测试工具的压力发生器基本都是存在缺陷的。 先说下模拟真实用户操作。如果熟悉浏览器的工作原理，就会知道浏览器在加载网页的时候，是同时并发多个TCP连接去请求页面对应的HTTP资源，包括HTML、JS、图片、CSS，当前流行的浏览器普遍会并发6-10个连接。然而，性能测试工具在模拟单个用户操作的时候，基本上都是单连接串行加载页面资源。产生的差异在于，假如页面有100个资源，每个HTTP请求的响应时间约为100毫秒，那么浏览器采用6个连接并行加载网页时大概会需要1.7秒（100/6*100毫秒），而测试工具采用单连接串行加载就需要10秒（100*100毫秒），两者结果相差十分巨大。这也解释了为什么有时候我们通过性能测试工具测试得到的响应时间挺长，但是手动用浏览器加载网页时感觉挺快的原因。 再说下有效并发。什么叫有效并发？有效并发就是我们在测试工具中设置了1000虚拟用户数，实际在服务器端就能产生1000并发压力。然而现实情况是，很多时候由于测试设备自身出现了性能瓶颈，压力发生器产生的并发压力远小于设定值，并且通常测试工具也不会将该问题暴露给测试人员；如果测试人员忽略了这个问题，以为测试得到的结果就是在设定并发压力下的结果，那么最终分析得出的结论也就跟实际情况大相径庭了。不过，我们可以通过保障测试环境不存在瓶颈，使得实际生成的并发压力尽可能地与设定值一致；另一方面，我们也可以通过在测试过程中监控Web层（例如Nginx）的连接数和请求数，查看实际达到服务器端的并发数是否跟我们的设定值一致，以此来反推压力发生器的压力是否有效。 了解这些缺陷的意义在于，我们可以更清楚测试工具的原理，从而更准确地理解测试结果的真实含义。 ","date":"2016-11-02","heading":"性能测试工具的基本组成","objectID":"/post/locustplus-talk-about-performance-test/:5:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#性能测试工具的基本组成"},{"categories":["Testing"],"collections":null,"content":"性能测试工具推荐 经过充分的理论铺垫，现在总算可以进入正题，开始讲解工具部分了。 在性能测试工具方面，我重点向大家推荐Locust这款开源工具。目前阶段，该款工具在国内的知名度还很低，大多数测试人员可能之前都没有接触过。为了便于理解，我先将Locust与LoadRunner、Jmeter这类大众耳熟能详的性能测试工具进行简单对比。 \\ LoadRunner Jmeter Locust 授权方式 商业收费 开源免费 开源免费 开发语言 C/Java Java Python 测试脚本形式 C/Java GUI Python 并发机制 进程/线程 线程 协程 单机并发能力 低 低 高 分布式压力 支持 支持 支持 资源监控 支持 不支持 不支持 报告与分析 完善 简单图表 简单图表 支持二次开发 不支持 支持 支持 通过对比，大家可能会疑惑，Locust也不怎么样嘛，资源监控也不支持，报告分析能力也这么弱，那为啥还要选择它呢？ 授权方式这个就不说了。虽然LoadRunner是商业软件，价格极其昂贵，但是国内盗版横行，别说个人，就算是大型互联网公司，用正版的也没几个。 从功能特性的角度来讲，LoadRunner是最全面的，用户群体也是最多的，相应的学习资料也最为丰富。个人建议如果是新接触性能测试，可以先熟悉LoadRunner，借此了解性能测试工具各个模块的概念和功能，在此基础上再转到别的测试工具，也都比较好上手了。不过，LoadRunner只能在Windows平台使用，并且并发效率比较低，单台压力机难以产生较高的并发能力，这也是现在我弃用该款工具的主要原因。 同样地，Jmeter的并发机制也是基于线程，并发效率存在同样的问题；另外，Jmeter在脚本编写和描述方面是基于GUI操作，个人感觉操作比较繁琐（这个因人而异），因此不是很喜欢。 那么，我重点推荐的Locust有啥特别的地方呢？ 如果从整体功能上来看的话，Locust的功能的确比较单薄。不过，作为性能测试工具最核心的压力发生器部分，却是非常不错的。抛开官方文档的介绍，个人觉得最赞的有两点。 首先是模拟用户操作，也就是测试脚本描述方面。Locust采用Pure Python脚本描述，并且HTTP请求完全基于Requests库。用过Requests的都知道，这个库非常简洁易用，但功能十分强大，很多其它编程语言的HTTP库都借鉴了它的思想和模式，如果将其评选为最好用的HTTP库之一（不限语言），应该也不会有太大的争议。除了HTTP(S)协议，Locust也可以测试其它任意协议的系统，只需要采用Python调用对应的库进行请求描述即可。 另外一点就是并发机制了。Locust的并发机制摒弃了进程和线程，采用协程（gevent）的机制。采用多线程来模拟多用户时，线程数会随着并发数的增加而增加，而线程之间的切换是需要占用资源的，IO的阻塞和线程的sleep会不可避免的导致并发效率下降；正因如此，LoadRunner和Jmeter这类采用进程和线程的测试工具，都很难在单机上模拟出较高的并发压力。而协程和线程的区别在于，协程避免了系统级资源调度，由此大幅提高了性能。正常情况下，单台普通配置的测试机可以生产数千并发压力，这是LoadRunner和Jmeter都无法实现的。 有了一个不错的引擎，外表装饰简陋点也都是可以接受的了。不过虽然Locust功能单薄，特别是在性能指标监控和测试报告图表方面比较缺失，但是Locust的代码结构清晰，核心代码量也只有几百行，可扩展性也非常不错。换言之，Locust的可玩性（hackable）极强，对于一个想深入挖掘性能测试工具原理的人来说，Locust非常适合。 好了，Locust的介绍暂且到这儿，后续我会再对Locust的使用方法和二次开发进行详细介绍，也算是弥补官方文档的不足吧。 ","date":"2016-11-02","heading":"性能测试工具推荐","objectID":"/post/locustplus-talk-about-performance-test/:6:0","tags":["性能测试","Locust"],"title":"漫谈服务端性能测试","uri":"/post/locustplus-talk-about-performance-test/#性能测试工具推荐"},{"categories":["OpenSource"],"collections":null,"content":"前言 做过自动化测试的人应该都会有这样一种体会，要写个自动化demo测试用例很容易，但是要真正将自动化测试落地，对成百上千的自动化测试用例实现较好的可复用性和可维护性就很难了。 基于这一痛点，我开发了AppiumBooster框架。顾名思义，AppiumBooster基于Appium实现，但更简单和易于使用；测试人员不用接触任何代码，就可以直接采用简洁优雅的方式来编写和维护自动化测试用例。 原型开发完毕后，我将其应用在当前所在团队的项目上，并在使用的过程中，按照自己心目中理想的自动化测试框架的模样对其进行迭代优化，最终打磨成了一个自己还算用得顺手的自动化测试框架。 本文便是对AppiumBooster的核心特性及其设计思想进行介绍。在内容组织上，本文的各个部分相对独立，大家可直接选择自己感兴趣的部分进行阅读。 ","date":"2016-09-07","heading":"前言","objectID":"/post/build-ideal-app-automation-test-framework/:1:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#前言"},{"categories":["OpenSource"],"collections":null,"content":"UI交互基础 UI交互是自动化测试的基础，主要分为三部分内容：定位控件、操作控件、检测结果。 ","date":"2016-09-07","heading":"UI交互基础","objectID":"/post/build-ideal-app-automation-test-framework/:2:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#ui交互基础"},{"categories":["OpenSource"],"collections":null,"content":"控件定位 定位控件时，统一采用元素ID进行定位。这里的ID包括accessibility_id或accessibility_label，需要在iOS工程项目中预先进行设置。 另外，考虑到控件可能出现延迟加载的情况，定位控件时统一执行wait操作；定位成功后会立即返回控件对象，定位失败时会进行等待并不断尝试定位，直到超时（30秒）后抛出异常。 wait { id control_id } 源码路径：AppiumBooster/lib/pages/control.rb ","date":"2016-09-07","heading":"控件定位","objectID":"/post/build-ideal-app-automation-test-framework/:2:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#控件定位"},{"categories":["OpenSource"],"collections":null,"content":"控件操作 根据实践证明，UI的控件操作基本主要就是点击、输入和滑动，这三个操作基本上可以覆盖绝大多数场景。 scrollToDisplay: 根据指定控件的坐标位置，对屏幕进行上/下/左/右滑动操作，直至将指定控件展示在屏幕中 click: 通过控件ID定位到指定控件，并对指定控件进行click操作；若指定控件不在当前屏幕中，则先执行scrollToDisplay，再执行click操作 type(text): 在指定控件中输入字符串；若指定控件不在当前屏幕中，则先执行scrollToDisplay，再执行输入操作 tapByCoordinate: 先执行scrollToDisplay，确保指定控件在当前屏幕中；获取指定控件的坐标值，然后对坐标进行tap操作 scroll(direction): 对屏幕进行指定方向的滑动 源码路径：AppiumBooster/lib/pages/actions.rb ","date":"2016-09-07","heading":"控件操作","objectID":"/post/build-ideal-app-automation-test-framework/:2:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#控件操作"},{"categories":["OpenSource"],"collections":null,"content":"预期结果检查 每次执行一步操作后，需要对执行结果进行判断，以此来确定测试用例的各个步骤是否执行成功。 当前，AppiumBooster采用控件的ID作为检查对象，并统一封装到check_elements(control_ids)方法中。 在实际使用过程中，需要先确定当前步骤执行完成后的跳转页面的特征控件，即当前步骤执行前不存在该控件，但执行成功后的页面中具有该控件。然后在操作步骤描述的expectation属性中指定特征控件的ID。 具体地，在指定控件ID的时候还可以配合使用操作符（!,||,\u0026amp;\u0026amp;），以此实现多种复杂场景的检测。典型的预期结果描述形式如下： A: 预期控件A存在； !A: 预期控件A不存在； A||B: 预期控件A或控件B至少存在一个； A\u0026amp;\u0026amp;B: 预期控件A和控件B同时存在； A\u0026amp;\u0026amp;!B: 预期控件A存在，但控件B不存在； !A\u0026amp;\u0026amp;!B: 预期控件A和控件B都不存在。 源码路径：AppiumBooster/lib/pages/inner_screen.rb ","date":"2016-09-07","heading":"预期结果检查","objectID":"/post/build-ideal-app-automation-test-framework/:2:3","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#预期结果检查"},{"categories":["OpenSource"],"collections":null,"content":"测试用例引擎（YAML） 对于自动化测试而言，自动化测试用例的组织与管理是最为重要的部分，直接关系到自动化测试用例的可复用性和可维护性。 经过综合考虑，AppiumBooster从三个层面来描述测试用例，从低到高分别是step、feature和testcase；描述方式推荐使用YAML格式。 ","date":"2016-09-07","heading":"测试用例引擎（YAML）","objectID":"/post/build-ideal-app-automation-test-framework/:3:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#测试用例引擎yaml"},{"categories":["OpenSource"],"collections":null,"content":"steps（测试步骤描述） 首先是对于单一操作步骤的描述。 从UI层面来看，每一个操作步骤都可以归纳为三个方面：定位控件、操作控件和检查结果。 AppiumBooster的做法是，将App根据功能模块进行拆分，每一个模块单独创建一个YAML文件，并保存在steps目录下。然后，在每个模块中以控件为单位，分别进行定义。 现以如下示例进行详细说明。 --- AccountSteps: enter Login page: control_id: tablecellMyAccountLogin control_action: click expectation: btnForgetPassword input test EmailAddress: control_id: txtfieldEmailAddress control_action: type data: leo.lee@debugtalk.com expectation: sectxtfieldPassword check if coupon popup window exists(optional): control_id: inner_screen control_action: has_control data: btnViewMyCoupons expectation: btnClose optional: true 其中，AccountSteps是steps模块名称，用于区分不同的steps模块，方便在features模块中进行引用。 描述单个步骤时，有三项是必不可少的：步骤名称、控件ID（control_id）和控件操作方式（control_action）。当控件操作方式为输入（type）时，则还需指定data属性，即输入内容。 在检查步骤执行结果方面，可通过在expectation属性中指定控件ID进行实现，前面在预期结果检查一节中已经详细介绍了使用方法。该属性可以置空或不进行填写，相当于不对当前步骤进行检测。 另外还有一个optional属性，对步骤指定该属性并设置为true时，当前步骤的执行结果不影响整个测试用例。 ","date":"2016-09-07","heading":"steps（测试步骤描述）","objectID":"/post/build-ideal-app-automation-test-framework/:3:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#steps测试步骤描述"},{"categories":["OpenSource"],"collections":null,"content":"features（功能点描述） 各个模块的单一操作步骤定义完毕后，虽然可以直接将多个步骤进行组合形成对测试场景的描述，即测试用例，但是操作起来会过于局限细节；特别是当测试用例较多时，可维护性是一个很大的问题。 AppiumBooster的做法是，将App根据功能模块进行拆分，每一个模块单独创建一个YAML文件，并保存在features目录下。然后，在每个模块中以功能点为单位，通过对steps模块中定义好的操作步骤进行引用并组合，即可实现对功能点的描述。 以系统登录功能为例，功能点的描述可采用如下形式。 --- AccountFeatures: login with valid test account: - AccountSteps | enter My Account page - AccountSteps | enter Login page - AccountSteps | input test EmailAddress - AccountSteps | input test Password - AccountSteps | login - AccountSteps | close coupon popup window(optional) login with valid production account: - AccountSteps | enter My Account page - AccountSteps | enter Login page - AccountSteps | input production EmailAddress - AccountSteps | input production Password - AccountSteps | login - AccountSteps | close coupon popup window(optional) logout: - AccountSteps | enter My Account page - SettingsSteps | enter Settings page - AccountSteps | logout 其中，AccountFeatures是features模块名称，用于区分不同的features模块，方便在testcase中进行引用。 在引用steps模块的操作步骤时，需要同时指定steps模块名称和操作步骤的名称，并以|进行分隔。 ","date":"2016-09-07","heading":"features（功能点描述）","objectID":"/post/build-ideal-app-automation-test-framework/:3:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#features功能点描述"},{"categories":["OpenSource"],"collections":null,"content":"testcases（测试用例描述） 在功能点描述的基础上，AppiumBooster就可以在第三个层面，简单清晰地描述测试用例了。 具体做法很简单，针对每个测试用例创建一个YAML文件，并保存在testcases目录下。然后，通过对features模块中定义好的功能点描述进行引用并组合，即可实现对测试用例的描述。 同样的，在引用features模块的功能点时，也需要同时指定features模块名称和功能点的名称，并以|进行分隔。 如下示例便是实现了在商城中购买商品的整个流程，包括切换国家、登录、选择商品、添加购物车、下单完成支付等功能点。 --- Buy Phantom 4: - SettingsFeatures | initialize first startup - SettingsFeatures | Change Country to China - AccountFeatures | login with valid account - AccountFeatures | Change Shipping Address to China - StoreFeatures | add phantom 4 to cart - StoreFeatures | finish order - AccountFeatures | logout 另外，在某些测试场景中可能需要重复进行某一个功能点的操作。虽然可以将需要重复的步骤多写几次，但会显得比较累赘，特别是重复次数较多时更是麻烦。 AppiumBooster的做法是，在测试用例的步骤中可指定执行次数，并以|进行分隔，如下例所示。 --- Send random text messages: - SettingsFeatures | initialize first startup - AccountFeatures | login with valid test account - MessageFeatures | enter follower user message page - MessageFeatures | send random text message | 100 ","date":"2016-09-07","heading":"testcases（测试用例描述）","objectID":"/post/build-ideal-app-automation-test-framework/:3:3","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#testcases测试用例描述"},{"categories":["OpenSource"],"collections":null,"content":"测试用例引擎（CSV） 基本上，YAML测试用例引擎已经可以很好地满足组织和管理自动化测试用例的需求。 但考虑到部分用户会偏向于使用表格的形式，因为表格看上去更直观一些，AppiumBooster同时还支持CSV格式的测试用例引擎。 ","date":"2016-09-07","heading":"测试用例引擎（CSV）","objectID":"/post/build-ideal-app-automation-test-framework/:4:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#测试用例引擎csv"},{"categories":["OpenSource"],"collections":null,"content":"testcases（测试用例描述） 采用表格来编写测试用例时，只需要在任意表格工具，包括Microsoft Excel、iWork Numbers、WPS等，按照如下形式对测试用例进行描述。 然后，将表格内容另存为CSV格式的文件，并放置于testcases目录中即可。 可以看出，CSV格式的测试用例和YAML格式的测试用例是等价的，两者包含的信息内容完全相同。 在具体实现上，AppiumBooster在执行测试用例之前，也会将两个测试用例引擎的测试用例描述转换为相同的数据结构，然后再进行统一的操作。 统一转换后的数据结构如下所示： { \"testcase_name\": \"Login and Logout\", \"features_suite\": [ { \"feature_name\": \"login with valid account\", \"feature_steps\": [ {\"control_id\": \"btnMenuMyAccount\", \"control_action\": \"click\", \"expectation\": \"tablecellMyAccountSystemSettings\", \"step_desc\": \"enter My Account page\"}, {\"control_id\": \"tablecellMyAccountLogin\", \"control_action\": \"click\", \"expectation\": \"btnForgetPassword\", \"step_desc\": \"enter Login page\"}, {\"control_id\": \"txtfieldEmailAddress\", \"control_action\": \"type\", \"data\": \"leo.lee@debugtalk.com\", \"expectation\": \"sectxtfieldPassword\", \"step_desc\": \"input EmailAddress\"}, {\"control_id\": \"sectxtfieldPassword\", \"control_action\": \"type\", \"data\": 12345678, \"expectation\": \"btnLogin\", \"step_desc\": \"input Password\"}, {\"control_id\": \"btnLogin\", \"control_action\": \"click\", \"expectation\": \"tablecellMyMessage\", \"step_desc\": \"login\"}, {\"control_id\": \"btnClose\", \"control_action\": \"click\", \"expectation\": nil, \"optional\": true, \"step_desc\": \"close coupon popup window(optional)\"} ] }, { \"feature_name\": \"logout\", \"feature_steps\": [ {\"control_id\": \"btnMenuMyAccount\", \"control_action\": \"click\", \"expectation\": \"tablecellMyAccountSystemSettings\", \"step_desc\": \"enter My Account page\"}, {\"control_id\": \"tablecellMyAccountSystemSettings\", \"control_action\": \"click\", \"expectation\": \"txtCountryDistrict\", \"step_desc\": \"enter Settings page\"}, {\"control_id\": \"btnLogout\", \"control_action\": \"click\", \"expectation\": \"uiviewMyAccount\", \"step_desc\": \"logout\"} ] } ] } ","date":"2016-09-07","heading":"testcases（测试用例描述）","objectID":"/post/build-ideal-app-automation-test-framework/:4:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#testcases测试用例描述-1"},{"categories":["OpenSource"],"collections":null,"content":"测试用例转换器（yaml2csv） 既然CSV格式的测试用例和YAML格式的测试用例是等价的，那么两者之间的转换也就容易实现了。 当前，AppiumBooster支持将YAML格式的测试用例转换为CSV格式的测试用例，只需要执行一条命令即可。 $ ruby start.rb -c \"yaml2csv\" -f ios/testcases/login_and_logout.yml ","date":"2016-09-07","heading":"测试用例转换器（","objectID":"/post/build-ideal-app-automation-test-framework/:4:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#测试用例转换器yaml2csv"},{"categories":["OpenSource"],"collections":null,"content":"过程记录及结果存储 在自动化测试执行过程中，应尽量对测试用例执行过程进行记录，方便后续对问题根据定位和追溯。 ","date":"2016-09-07","heading":"过程记录及结果存储","objectID":"/post/build-ideal-app-automation-test-framework/:5:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#过程记录及结果存储"},{"categories":["OpenSource"],"collections":null,"content":"过程记录方式 当前，AppiumBooster已实现的记录形式有如下三种： logger模块：可指定日志级别对测试过程进行记录 截图功能：测试用例运行过程中，在每个步骤执行完成后进行截图 DOM source：测试用例运行过程中，在每个步骤执行完成后保存当前页面的DOM内容 ","date":"2016-09-07","heading":"过程记录方式","objectID":"/post/build-ideal-app-automation-test-framework/:5:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#过程记录方式"},{"categories":["OpenSource"],"collections":null,"content":"测试结果存储 由于Appium分为Server端和Client端，因此AppiumBooster在记录日志的时候也将日志分为了三份： appium_server.log: Appium Server端的日志，这部分日志是由Appium框架打印的 appium_booster.log: 包括测试环境初始化和测试用例执行记录，这部分日志是由AppiumBooster中采用logger模块打印的 client_server.log: 同时记录AppiumBooster和Appium框架的日志，相当于appium_server.log和appium_booster.log的并集，优点在于可以清晰地看到测试用例执行过程中Client端和Server端的通讯交互过程 另外，当测试用例执行失败时，AppiumBooster会将执行失败的步骤截图和日志提取出来，单独保存到errors文件夹中，方便问题追溯。 具体地，每次执行测试前，AppiumBooster会在指定的results目录下创建一个以当前时间（%Y-%m-%d_%H:%M:%S）命名的文件夹，存储结构如下所示。 2016-08-28_16:28:48 ├── appium_server.log ├── appium_booster.log ├── client_server.log ├── errors │ ├── 16_31_29_btnLogin.click.dom │ ├── 16_31_29_btnLogin.click.png │ ├── 16_32_03_btnMenuMyAccount.click.dom │ └── 16_32_03_btnMenuMyAccount.click.png ├── screenshots │ ├── 16_30_34_tablecellMyAccountLogin.click.png │ ├── 16_30_41_txtfieldEmailAddress.type_leo.lee@debugtalk.com.png │ ├── 16_30_48_sectxtfieldPassword.type_123456.png │ ├── 16_31_29_btnLogin.click.png │ └── 16_32_03_btnMenuMyAccount.click.png └── xmls ├── 16_30_34_tablecellMyAccountLogin.click.dom ├── 16_30_41_txtfieldEmailAddress.type_leo.lee@debugtalk.com.dom ├── 16_30_48_sectxtfieldPassword.type_123456.dom ├── 16_31_29_btnLogin.click.dom └── 16_32_03_btnMenuMyAccount.click.dom 对于每一个测试步骤的截图和DOM，存储文件命名格式为%H_%M_%S_ControlID.ControlAction。采用这种命名方式有两个好处： 文件通过时间排序，对应着测试用例执行的步骤顺序 可以在截图或DOM中直观地看到每一步操作指令对应的执行结果 ","date":"2016-09-07","heading":"测试结果存储","objectID":"/post/build-ideal-app-automation-test-framework/:5:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#测试结果存储"},{"categories":["OpenSource"],"collections":null,"content":"环境初始化 ","date":"2016-09-07","heading":"环境初始化","objectID":"/post/build-ideal-app-automation-test-framework/:6:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#环境初始化"},{"categories":["OpenSource"],"collections":null,"content":"Appium Server 在执行自动化测试时，某些情况下可能会造成Appium Server出现异常情况（e.g. 500 error），并影响到下一次测试的执行。 为了避免这类情况，AppiumBooster在每次执行测试前，会强制性地对Appium Server进行重启。方式也比较简单暴力，运行测试之前先检查系统是否有bin/appium的进程在运行，如果有，则先kill掉该进程，然后再启动Appium Server。 需要说明的是，由于Appium Server的启动需要一定时间，为了防止运行Appium Client时Appium Server还未初始化完毕，因此启动Appium Server后最好能等待一段时间（e.g. sleep 10s）。 ","date":"2016-09-07","heading":"Appium Server","objectID":"/post/build-ideal-app-automation-test-framework/:6:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#appium-server"},{"categories":["OpenSource"],"collections":null,"content":"iOS/Android模拟器 在模拟器中运行一段时间后，也会存在缓存数据和文件，可能会对下一次测试造成影响。 为了避免这类情况，AppiumBooster在每次执行测试前，会先删除已存在的模拟器，然后再用指定的模拟器配置创建新的模拟器。 对于iOS模拟器，AppiumBooster通过调用xcrun simctl命令的方式来对模拟器进行操作，基本原理如下所示。 # delete iOS simulator: xcrun simctl delete device_id $ xcrun simctl delete F2F53866-50A5-4E0F-B164-5AC1702AD1BD # create iOS simulator: xcrun simctl create device_type device_type_id runtime_id $ xcrun simctl create 'iPhone 5' 'com.apple.CoreSimulator.SimDeviceType.iPhone-5' 'com.apple.CoreSimulator.SimRuntime.iOS-9-3' 其中，device_id/device_type_id/runtime_id这些属性值可以通过执行xcrun simctl list命令获取得到。 $ xcrun simctl list == Device Types == iPhone 5s (com.apple.CoreSimulator.SimDeviceType.iPhone-5s) iPhone 6 (com.apple.CoreSimulator.SimDeviceType.iPhone-6) == Runtimes == iOS 8.4 (8.4 - 12H141) (com.apple.CoreSimulator.SimRuntime.iOS-8-4) iOS 9.3 (9.3 - 13E230) (com.apple.CoreSimulator.SimRuntime.iOS-9-3) == Devices == -- iOS 8.4 -- iPhone 5s (E1BD9CC5-8E95-408F-849C-B0C6A44D669A) (Shutdown) -- iOS 9.3 -- iPhone 5s (BAFEFBE1-3ADB-45C4-9C4E-E3791D260524) (Shutdown) iPhone 6 (F23B3F85-7B65-4999-9F1C-80111783F5A5) (Shutdown) == Device Pairs == ","date":"2016-09-07","heading":"","objectID":"/post/build-ideal-app-automation-test-framework/:6:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#iosandroid模拟器"},{"categories":["OpenSource"],"collections":null,"content":"增强特性 除了以上基础特性，AppiumBooster还支持一些辅助特性，可以增强测试框架的使用体验。 ","date":"2016-09-07","heading":"增强特性","objectID":"/post/build-ideal-app-automation-test-framework/:7:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#增强特性"},{"categories":["OpenSource"],"collections":null,"content":"Data参数化 在某些场景下，测试用例执行时需要动态获取数值。例如，注册账号的测试用例中，每次执行测试用例时需要保证用户名为未注册的，常见的做法就是在注册用户名中包含时间戳。 AppiumBooster的做法是，可以在测试步骤的data字段中，传入Ruby表达式，格式为${ruby_expression}。在执行测试用例时，会先对ruby_expression进行eval计算，然后用计算得到的值作为实际参数。 回到刚才的注册账号测试用例，填写用户名的步骤就可以按照如下形式指定参数。 input test EmailAddress: control_id: txtfieldEmailAddress control_action: type data: ${Time.now.to_i}@debugtalk.com expectation: sectxtfieldPassword 实际执行测试用例时，data就会参数化为1471318368@debugtalk.com的形式。 ","date":"2016-09-07","heading":"Data参数化","objectID":"/post/build-ideal-app-automation-test-framework/:7:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#data参数化"},{"categories":["OpenSource"],"collections":null,"content":"全局参数配置 对于某些配置参数，例如系统的登录账号密码等，虽然可以直接填写到测试用例的steps中，但是终究不够灵活。特别是当存在多个测试用例引用同一个参数时，涉及到参数改动时就需要同时修改多个地方。 更好的做法是，将此类参数提取出来，在统一的地方进行配置。在AppiumBooster中，可以在config.yml文件中配置全局参数。 --- TestEnvAccount: UserName: test@debugtalk.com Password: 123456 ProductionEnvAccount: UserName: production@debugtalk.com Password: 12345678 然后，在测试用例的steps就可以采用如下形式对全局参数进行引用。 --- AccountSteps: input test EmailAddress: control_id: txtfieldEmailAddress control_action: type data: ${config.TestEnvAccount.UserName} expectation: sectxtfieldPassword input test Password: control_id: sectxtfieldPassword control_action: type data: ${config.TestEnvAccount.Password} expectation: btnLogin ","date":"2016-09-07","heading":"全局参数配置","objectID":"/post/build-ideal-app-automation-test-framework/:7:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#全局参数配置"},{"categories":["OpenSource"],"collections":null,"content":"optional选项 在执行测试用例时，有时候可能会存在这样的场景：某个步骤作为非必要步骤，当其执行失败时，我们并不想将测试用例判定为不通过。 基于该场景，在测试用例设计表格中增加了optional参数。该参数值默认不用填写。但如果在某个步骤对应的optional栏填写了true值后，那么该步骤就会作为非必要步骤，其执行结果不会影响整个用例的执行结果。 例如，在电商类APP中，某些账号有优惠券，登录系统后，会弹出优惠券的提示框；而有的账号没有优惠券，登录后就不会有这样的弹框。那么关闭优惠券弹框的步骤就可以将其optional参数设置为true。 --- AccountSteps: close coupon popup window(optional): control_id: btnClose control_action: click expectation: !btnViewMyCoupons optional: true ","date":"2016-09-07","heading":"optional选项","objectID":"/post/build-ideal-app-automation-test-framework/:7:3","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#optional选项"},{"categories":["OpenSource"],"collections":null,"content":"命令行工具 AppiumBooster通过在命令行中进行调用。 $ ruby start.rb -h Usage: start.rb [options] -p, --app_path Specify app path -t, --app_type Specify app type, ios or android -f, --testcase_file Specify testcase file(s) -d, --output_folder Specify output folder -c, --convert_type Specify testcase converter, yaml2csv or csv2yaml --disable_output_color Disable output color ","date":"2016-09-07","heading":"命令行工具","objectID":"/post/build-ideal-app-automation-test-framework/:8:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#命令行工具"},{"categories":["OpenSource"],"collections":null,"content":"执行测试用例 指定执行测试用例时支持多种方式，常见的几种使用方式示例如下： $ cd ${AppiumBooster} # 执行指定的测试用例文件（绝对路径） $ ruby run.rb -p \"ios/app/test.zip\" -f \"/Users/Leo/MyProjects/AppiumBooster/ios/testcases/login.yml\" # 执行指定的测试用例文件（相对路径） $ ruby run.rb -p \"ios/app/test.zip\" -f \"ios/testcases/login.yml\" # 执行所有yaml格式的测试用例文件 $ ruby run.rb -p \"ios/app/test.zip\" -f \"ios/testcases/*.yml\" # 执行ios目录下所有csv格式的测试用例文件 $ ruby run.rb -p \"ios/app/test.zip\" -t \"ios\" -f \"*.csv\" ","date":"2016-09-07","heading":"执行测试用例","objectID":"/post/build-ideal-app-automation-test-framework/:8:1","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#执行测试用例"},{"categories":["OpenSource"],"collections":null,"content":"测试用例转换 将YAML格式的测试用例转换为CSV格式的测试用例： $ ruby start.rb -c \"yaml2csv\" -f ios/testcases/login_and_logout.yml ","date":"2016-09-07","heading":"测试用例转换","objectID":"/post/build-ideal-app-automation-test-framework/:8:2","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#测试用例转换"},{"categories":["OpenSource"],"collections":null,"content":"总结 什么才算是心目中理想的自动化测试框架？我也没有确切的答案。 为什么要登山？ 因为山在那里。 ","date":"2016-09-07","heading":"总结","objectID":"/post/build-ideal-app-automation-test-framework/:9:0","tags":["Appium","UI","AppiumBooster"],"title":"打造心目中理想的自动化测试框架（AppiumBooster）","uri":"/post/build-ideal-app-automation-test-framework/#总结"},{"categories":["Development"],"collections":null,"content":"在《使用Jenkins实现持续集成构建检查》一文中，写到了这么一段话： 在这里，我们还可以通过\u0026ndash;disable_output_color开关将输出日志的颜色关闭。之所以实现这么一个功能，是因为在Jenkins中本来也无法显示颜色，但是如果还将Terminal中有颜色的日志内容输出到Jenkins中，就会出现一些额外的字符，比较影响日志的美观。 非常感谢热心的读者，及时地为我纠正了这一点。事实上，当前在Jenkins中，是可以通过安装插件来实现在输出日志中显示颜色的。 这个插件就是AnsiColor。 ","date":"2016-08-15","heading":"","objectID":"/post/make-Jenkins-Console-Output-Colorful/:0:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#"},{"categories":["Development"],"collections":null,"content":"安装 \u0026amp;\u0026amp; 配置 安装的方式很简单，【Manage Jenkins】-\u0026gt;【Manage Plugins】，搜索AnsiColor进行安装即可。 安装完成后，在Jenkins Project的Configure页面中，Build Environment栏目下会多出Color ANSI Console Output配置项，勾选后即可开启颜色输出配置。 在ANSI color map的列表选择框中，存在多个选项，默认情况下，选择xterm即可。 保存配置后，再次执行构建时，就可以在Console中看到颜色输出了。 ","date":"2016-08-15","heading":"安装 \u0026amp;\u0026amp; 配置","objectID":"/post/make-Jenkins-Console-Output-Colorful/:1:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#安装--配置"},{"categories":["Development"],"collections":null,"content":"效果图 使用xctool命令编译iOS应用时，在Jenkins的Console output中会看到和Terminal中一样的颜色效果。 ","date":"2016-08-15","heading":"效果图","objectID":"/post/make-Jenkins-Console-Output-Colorful/:2:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#效果图"},{"categories":["Development"],"collections":null,"content":"补充说明 需要说明的是，在输出日志中显示颜色，依赖于输出的日志本身。也就是说，如果输出日志时并没有ANSI escape sequences，那么安装该插件后也没有任何作用，并不会凭空给日志加上颜色。 例如，如果采用xcodebuild命令编译iOS应用，那么输出日志就不会显示颜色。 说到这里，再简单介绍下ANSI escape sequences。 ","date":"2016-08-15","heading":"补充说明","objectID":"/post/make-Jenkins-Console-Output-Colorful/:3:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#补充说明"},{"categories":["Development"],"collections":null,"content":"ANSI escape sequences ANSI escape sequences，也叫ANSI escape codes，主要是用于对Terminal中的文本字符进行颜色的控制，包括字符背景颜色和字符颜色。 使用方式如下： 33[字符背景颜色;字符颜色m{String}33[0m 其中，33[字符背景颜色;字符颜色m是开始标识，33[0m是结束标识，{String}是原始文本内容。通过这种形式，就可以对输出的文本颜色进行控制。 具体地，字符颜色和字符背景颜色的编码如下： 字符颜色（foreground color）：30~37 30:黑 31:红 32:绿 33:黄 34:蓝色 35:紫色 36:深绿 37:白色 字符背景颜色（background color）：40~47 40:黑 41:深红 42:绿 43:黄色 44:蓝色 45:紫色 46:深绿 47:白色 需要说明的是，字符背景颜色和字符颜色并非必须同时设置，也可以只设置一项。 ","date":"2016-08-15","heading":"ANSI escape sequences","objectID":"/post/make-Jenkins-Console-Output-Colorful/:4:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#ansi-escape-sequences"},{"categories":["Development"],"collections":null,"content":"代码示例 掌握了以上概念后，我们就可以通过对打印日志的代码进行一点调整，然后就可以让输出的日志更加美观了。 以Ruby为例，在Sting基础类中添加一些展示颜色的方法。 class String # colorization def colorize(color_code) \"\\e[#{color_code}m#{self}\\e[0m\" end def red colorize(31) end def green colorize(32) end def yellow colorize(33) end end 然后，我们在打印日志时就可以通过如下方式来控制日志的颜色了。 # 步骤执行正常，输出为绿色 step_action_desc += \" ... ✓\" puts step_action_desc.green # 步骤执行异常，输出为红色 step_action_desc += \" ... ✖\" puts step_action_desc.red 展示效果如下图所示。 是不是好看多了？ ","date":"2016-08-15","heading":"代码示例","objectID":"/post/make-Jenkins-Console-Output-Colorful/:5:0","tags":["Jenkins"],"title":"Jenkins 的输出日志也可以变得色色的","uri":"/post/make-Jenkins-Console-Output-Colorful/#代码示例"},{"categories":["Development"],"collections":null,"content":"通过《使用Jenkins搭建iOS/Android持续集成打包平台》和《关于持续集成打包平台的Jenkins配置和构建脚本实现细节》两篇文章，我们已经在原理概念和实践操作两个层面掌握了如何搭建一个完整的持续集成打包平台。 不过，在实际使用过程中，发现有时候还会存在一个问题。研发同学提交新的代码后，Jenkins端可以成功执行构建，并生成安装包；然而在将安装包安装至移动设备时，却发现有时候会出现无法成功安装，或者安装后出现启动闪退的情况。 为了及时发现该类问题，我们还需要对每次构建生成的安装包进行检查。本文便是对构建检查涉及到的方法进行介绍。 ","date":"2016-08-14","heading":"","objectID":"/post/Jenkins-CI-Automation-Test/:0:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 实现持续集成构建检查","uri":"/post/Jenkins-CI-Automation-Test/#"},{"categories":["Development"],"collections":null,"content":"构建生成.app 为了降低问题的复杂度，我们可以选择在模拟器中运行构建生成的安装包。之前在《从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用》也讲解过，要在模拟器中运行iOS应用，需要在Xcode中编译时选择模拟器类型，并且编译生成的文件后缀为.app。 对应的，构建生成.app的命令如下： # build .app file from source code xcodebuild \\ # xctool -workspace ${WORKSPACE_PATH} \\ -scheme ${SCHEME} \\ -configuration ${CONFIGURATION} \\ -sdk ${SDK} \\ -derivedDataPath ${OUTPUT_FOLDER} xcodebuild/xctool参数说明： -workspace：需要打包的workspace，后面接的文件一定要是.xcworkspace结尾的； -scheme：需要打包的Scheme，一般与$project_name相同； -sdk：区分iphone device和Simulator，可通过xcodebuild -showsdks获取，例如iphonesimulator9.3。 -configuration：需要打包的配置文件，我们一般在项目中添加多个配置，适合不同的环境，Release/Debug； -derivedDataPath：指定编译结果文件的存储路径；例如，指定-derivedDataPath build_outputs时，将在项目根目录下创建一个build_outputs文件夹，生成的.app文件将位于build_outputs/Build/Products/${CONFIGURATION}-iphoneos中。 同样地，这里也可以使用xctool代替xcodebuild。 这里使用到的命令和参数基本上和《关于持续集成打包平台的Jenkins配置和构建脚本实现细节》一文中的大致相同，唯一需要特别注意的是-sdk参数。因为是要在模拟器中运行，因此-sdk参数要设置为iphonesimulator，而非iphoneos。 命令成功执行后，就会在指定的${OUTPUT_FOLDER}目录中生成${SCHEME}.app文件，这也就是我们构建生成的产物。另外，熟悉iOS的同学都知道，.app文件其实是一个文件夹，为了实现更好的存储，我们也可以额外地再做一步操作，将.app文件夹压缩转换为.zip格式，而且Appium也是支持读取.zip格式的安装包的。 至此，适用于iOS模拟器运行的构建产物已准备就绪。这里涉及到的脚本实现已更新至【debugtalk/JenkinsTemplateForApp】。 ","date":"2016-08-14","heading":"构建生成","objectID":"/post/Jenkins-CI-Automation-Test/:1:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 实现持续集成构建检查","uri":"/post/Jenkins-CI-Automation-Test/#构建生成app"},{"categories":["Development"],"collections":null,"content":"实现构建检查 那要怎样对构建生成的产物进行检查呢？ 最简单的方式，就是在iOS模拟器中运行构建生成的.app，并执行一组基本的自动化测试用例。在执行过程中，如果出现无法成功安装，或者安装成功后启动出现闪退，或者自动化测试用例执行失败等异常情况，则说明我们最新提交的代码存在问题，需要通知研发同学及时进行修复。 而这些实现方式，其实我在《从0到1搭建移动App功能自动化测试平台》系列文章中都已经进行了详细讲解，并形成了一套较为成熟的自动化测试框架，【debugtalk/AppiumBooster】。 在此基础上，我们无需再做其它工作，只需要按照debugtalk/AppiumBooster的要求在表格中编写一组基本的自动化测试用例，即可采用如下方式执行构建检查。 $ cd ${AppiumBooster_Folder} $ ruby run.rb -p \"${OUTPUT_FOLDER}/${SCHEME}.app.zip\" --disable_output_color \u003e test_result.log 在如上命令中，通过-p参数指定构建生成的安装包，然后就可以在iOS模拟器中运行事先编写好的自动化测试用例，从而实现构建检查。 在这里，我们还可以通过--disable_output_color开关将输出日志的颜色关闭。之所以实现这么一个功能，是因为在Jenkins中本来也无法显示颜色，但是如果还将Terminal中有颜色的日志内容输出到Jenkins中，就会出现一些额外的字符，比较影响日志的美观。 现在，我们已经分别实现了代码构建和构建检查这两个核心的操作环节，而要执行最终的持续集成，我们还需要做最后一项工作，即在Jenkins中将这两个环节串联起来。 ","date":"2016-08-14","heading":"实现构建检查","objectID":"/post/Jenkins-CI-Automation-Test/:2:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 实现持续集成构建检查","uri":"/post/Jenkins-CI-Automation-Test/#实现构建检查"},{"categories":["Development"],"collections":null,"content":"Jenkins配置 关于Jenkins的相关基础概念、实施流程和配置细节，我在之前的文章中已经讲解得非常详细了。在此我就只进行一点补充。 要实现在构建完成后再运行一些额外的脚本，例如我们的构建检查命令，需要使用到Jenkins的一个插件，Post-Build Script Plug-in。 安装完该插件后，在Jenkins配置界面的Post-build Actions栏目中，Add post-build action选项列表中就会多出Execute a set of scripts选项。选择该项后，会出现如下配置界面。 选择Execute shell后，会出现一个文本框，然后我们就可以将构建检查的命令填写到里面。 在这里我们用到了${AppiumBooster_Folder}参数，该参数也需要通过String Parameter来进行定义，用于指定AppiumBooster项目的路径。 最后，为了便于将执行自动化测试用例的日志和执行构建的日志分开，我们将执行自动化测试用例的日志写入到了test_result.log文件中。然后，在Archives build artifacts中就可以通过${AppiumBooster_Folder}/test_result.log将执行构建检查的日志收集起来，并展示到每次构建的页面中。 延续一贯的开箱即用原则，我将使用Jenkins实现持续集成构建检查涉及到的Jenkins配置也做成了一套模板，并更新到【debugtalk/JenkinsTemplateForApp】中了，供大家参考。 ","date":"2016-08-14","heading":"Jenkins配置","objectID":"/post/Jenkins-CI-Automation-Test/:3:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 实现持续集成构建检查","uri":"/post/Jenkins-CI-Automation-Test/#jenkins配置"},{"categories":["Development"],"collections":null,"content":"写在最后 至此，通过本系列的几篇文章，关于如何使用Jenkins实现移动APP持续集成的相关内容应该都已经覆盖得差不多了。 不过，由于我个人的近期工作主要集中在iOS部分，因此在讲解的过程中都是以iOS为主。后续在将工作重心移到Android部分后，我会再在DebugTalk的这几篇文章中更新Android部分的内容。 ","date":"2016-08-14","heading":"写在最后","objectID":"/post/Jenkins-CI-Automation-Test/:4:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 实现持续集成构建检查","uri":"/post/Jenkins-CI-Automation-Test/#写在最后"},{"categories":["OpenSource"],"collections":null,"content":"在本系列的上一篇文章中，我通过系统登录这一典型功能点，演示了编写自动化测试脚本的整个流程，并对测试脚本进行了初步优化。 在本文中，我将重点介绍如何对自动化测试脚本实现⎡工程化⎦的组织和管理。 ","date":"2016-07-28","heading":"","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:0:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#"},{"categories":["OpenSource"],"collections":null,"content":"测试脚本⎡工程化⎦ 首先说下什么是测试脚本的工程化。 通过之前的工作，我们已经可以让单个自动化测试用例正常运行起来了。然而，这还只算是一个demo，一切才刚刚开始。 试想，一个项目的自动化测试用例少则数百，多则成千上万。如何将这些自动化测试用例组织起来？如何实现更好的可重用机制？如何实现更好的可拓展机制？这些都还是我们当前的demo所不具备的，也是我们需要通过“工程化”手段进行改造的原因。 ","date":"2016-07-28","heading":"测试脚本⎡工程化⎦","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:1:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#测试脚本工程化"},{"categories":["OpenSource"],"collections":null,"content":"引入Minitest/RSpec 在Ruby中，说到测试首先就会想到Minitest或RSpec，这是Ruby中用的最多的两个测试框架。通过这些框架，我们可以很好地实现对Ruby测试用例的管理。 同样地，由于我们的自动化测试脚本是采用Ruby编写的，因此我们也可以使用Minitest/RSpec来管理我们的自动化测试用例。 基于该想法，我们采用RSpec对之前的系统登录测试用例进行工程结构初始化。对于熟悉Ruby编程，或者有一定代码基础的同学而言，很自然地，可以将测试用例框架初始化为如下结构。 ├── Gemfile ├── android │ └── appium.txt ├── common │ ├── requires.rb │ └── spec_helper.rb └── ios ├── appium.txt └── spec └── login_spec.rb 在Gemfile中，指定了项目依赖的库。 # filename: Gemfile source 'https://gems.ruby-china.org' gem 'rspec' gem 'appium_lib' gem 'appium_console' 在common/spec_helper.rb中，定义了模拟器和RSpec初始化相关的代码。 # filename: common/spec_helper.rb def setup_driver return if $driver appium_txt = File.join(Dir.pwd, 'ios', 'appium.txt') caps = Appium.load_appium_txt file: appium_txt Appium::Driver.new caps end def promote_methods Appium.promote_appium_methods RSpec::Core::ExampleGroup end setup_driver promote_methods RSpec.configure do |config| config.before(:each) do $driver.start_driver wait { alert_accept } end config.after(:each) do driver_quit end end 在common/requires.rb中，实现了对相关库文件的引用。 # filename: common/requires.rb # load lib require 'rspec' require 'appium_lib' # setup rspec require_relative 'spec_helper' 在ios/appium.txt中，对iOS模拟器信息和测试包路径进行了配置。 [caps] platformName = \"ios\" deviceName = \"iPhone 6s\" platformVersion = \"9.3\" app = \"/Users/Leo/MyProjects/AppiumBooster/ios/app/test.app\" 在ios/spec/目录中，则是测试用例的内容。例如，ios/spec/login_spec.rb对应的就是系统登录的测试用例。 # filename: ios/spec/login_spec.rb require_relative '../../common/requires' describe 'Login' do it 'with valid account' do wait { id('btnMenuMyAccount').click } wait { id 'uiviewMyAccount' } wait { id('tablecellMyAccountLogin').click } wait { id 'uiviewLogIn' } wait { id('txtfieldEmailAddress').type 'leo.lee@debugtalk.com' } wait { id('sectxtfieldPassword').type '123321' } wait { id('btnLogin').click } wait { id 'tablecellMyMessage' } end end 通过以上代码结构初始化，我们的测试用例框架的雏形就形成了。接下来，在Terminal中切换到项目根目录，然后通过rspec ios命令就可以执行ios目录中的测试用例了。 ➜ rspec ios . Finished in 2 minutes 7.2 seconds (files took 1.76 seconds to load) 1 example, 0 failures 完整的代码请参考debugtalk/AppiumBooster的1.FirstTest分支。 ","date":"2016-07-28","heading":"引入Minitest/RSpec","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:2:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#引入minitestrspec"},{"categories":["OpenSource"],"collections":null,"content":"添加第二条测试用例 现在，我们尝试往当前的测试框架中添加第二条测试用例。 例如，第二条测试用例要实现启动后从当前地区切换至中国。那么，就可以新增ios/spec/change_country_spec.rb。 # filename: ios/spec/change_country_spec.rb require_relative '../../common/requires' describe 'Change country' do it 'from Hong Kong to China' do wait { id('btnMenuMyAccount').click } wait { id 'uiviewMyAccount' } wait { id('tablecellMyAccountSystemSettings').click } wait { id 'txtCountryDistrict' } wait { id('txtCountryDistrict').click } wait { id 'uiviewSelectCountry' } wait { id('tablecellSelectCN').click } wait { id('btnArrowLeft').click } wait { id 'uiviewMyAccount' } end end 完整的代码请参考debugtalk/AppiumBooster的2.SecondTest分支。 现在我们凝视已经添加的两个测试用例，有发现什么问题么？ 是的，重复代码太多。在每一步操作中，都要用id来定位控件，还要用wait来实现等待机制。 除此之外，当前代码最大的问题就是测试用例与控件映射杂糅在一起。造成的后果就是，不管是控件映射发生变动，还是测试用例需要修改，都要来修改这一份代码，维护难度较大。 ","date":"2016-07-28","heading":"添加第二条测试用例","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:3:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#添加第二条测试用例"},{"categories":["OpenSource"],"collections":null,"content":"重构：测试用例与控件映射分离 基于以上问题，我们首要的改造任务就是将测试用例与控件映射进行分离。 考虑到常用的控件操作方法就只有几个（click，type），因此我们可以将控件操作方法单独封装为一个模块，作为公共模块。 module Actions def click wait { @found_cell.click } end def type(text) wait { @found_cell.type text } end end 然后，将APP中每一个页面封装为一个模块（module），将页面中的控件映射为模块的静态方法（method），并通过include机制引入方法模块。 例如，登录页面就可以封装为如下代码。 module Pages module Login class \u003c\u003c self include Actions def field_Email_Address @found_cell = wait { id 'txtfieldEmailAddress' } self end def field_Password @found_cell = wait { id 'sectxtfieldPassword' } self end def button_Login @found_cell = wait { id 'btnLogin' } self end end end end module Kernel def login Pages::Login end end 这里还用到了一点Ruby元编程技巧，就是将页面模块封装为一个方法，并加入到Kernel模块下。这样做的好处就是，我们可以在项目的任意地方直接通过login.button_Login.click这样的形式来对控件进行操作了。 完成以上改造后，系统登录测试用例就可以采用如下形式进行编写了。 describe 'Login' do it 'with valid account' do # switch to My Account page my_account.button_My_Account.click inner_screen.has_control 'uiviewMyAccount' # enter login page my_account.button_Login.click inner_screen.has_control 'uiviewLogIn' # login login.field_Email_Address.type 'leo.lee@debugtalk.com' login.field_Password.type '123321' login.button_Login.click inner_screen.has_control 'tablecellMyMessage' end end 完整的代码请参考debugtalk/AppiumBooster的3.RefactorV1分支。 ","date":"2016-07-28","heading":"重构：测试用例与控件映射分离","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:4:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#重构测试用例与控件映射分离"},{"categories":["OpenSource"],"collections":null,"content":"To be continued \u0026hellip; 经过这一轮重构，我们的测试用例与控件映射已经实现了分离，测试用例的可重用性与可扩展性也得到了极大的提升。 然而，在当前模式下，所有的测试用例仍然是以代码形式存在的，新增和修改测试用例时都需要到工程目录下编辑Ruby文件。 那有没有一种可能，我们只需要在表格中维护自动化测试用例（如下图），然后由代码来读取表格内容就可以自动执行测试呢？ 是的，这就是我们对测试框架进行⎡工程化⎦改造的下一个形态，也就是AppiumBooster现在的样子。 在下一篇文章中，我们再进行详细探讨。 ","date":"2016-07-28","heading":"To be continued \u0026hellip;","objectID":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/:5:0","tags":["Appium","iOS","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台 (4)：自动化测试代码⎡工程化⎦","uri":"/post/build-app-automated-test-platform-from-0-to-1-refactor-testcase-scripts/#to-be-continued-"},{"categories":["Development"],"collections":null,"content":"在《使用Jenkins搭建iOS/Android持续集成打包平台》一文中，我对如何使用Jenkins搭建iOS/Android持续集成打包平台的基础概念和实施流程进行了介绍。本文作为配套，对搭建持续集成打包平台中涉及到的执行命令、构建脚本（build.py），以及Jenkins的配置进行详细的补充说明。 当然，如果你不关心技术实现细节，也可以完全不用理会，直接参照【开箱即用】部分按照步骤进行操作即可。 ","date":"2016-07-26","heading":"","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:0:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#"},{"categories":["Development"],"collections":null,"content":"关于iOS的构建 对iOS源码进行构建，目标是要生成.ipa文件，即iOS应用安装包。 当前，构建方式主要包括两种： 源码 -\u0026gt; .archive文件 -\u0026gt; .ipa文件 源码 -\u0026gt; .app文件 -\u0026gt; .ipa文件 这两种方式的主要差异是生成的中间产物不同，对应的，两种构建方式采用的命令也不同。 ","date":"2016-07-26","heading":"关于iOS的构建","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:1:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#关于ios的构建"},{"categories":["Development"],"collections":null,"content":"源码 -\u0026gt; .archive -\u0026gt; .ipa # build archive file from source code xcodebuild \\ # xctool -workspace ${WORKSPACE_PATH} \\ -scheme ${SCHEME} \\ -configuration ${CONFIGURATION} \\ -sdk ${SDK} -archivePath ${archive_path} archive archive：对编译结果进行归档，会生成一个.xcarchive的文件，位于-archivePath指定的目录中。需要注意的是，对模拟器类型的sdk无法使用archive命令。 # export ipa file from .archive xcodebuild -exportArchive \\ -exportFormat format \\ -archivePath xcarchivepath \\ -exportPath destinationpath \\ -exportProvisioningProfile profilename \\ [-exportSigningIdentity identityname] [-exportInstallerIdentity identityname] ","date":"2016-07-26","heading":"","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:1:1","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#源码---archive---ipa"},{"categories":["Development"],"collections":null,"content":"源码 -\u0026gt; .app -\u0026gt; .ipa # build .app file from source code xcodebuild \\ # xctool -workspace ${WORKSPACE_PATH} \\ -scheme ${SCHEME} \\ -configuration ${CONFIGURATION} \\ -sdk ${SDK} -derivedDataPath ${OUTPUT_FOLDER} # convert .app file to ipa file xcrun \\ -sdk iphoneos \\ PackageApplication \\ -v ${OUTPUT_FOLDER}/Release-iphoneos/xxx.app \\ -o ${OUTPUT_FOLDER}/Release-iphoneos/xxx.ipa ","date":"2016-07-26","heading":"","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:1:2","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#源码---app---ipa"},{"categories":["Development"],"collections":null,"content":"参数说明 xcodebuild/xctool参数： -workspace：需要打包的workspace，后面接的文件一定要是.xcworkspace结尾的； -scheme：需要打包的Scheme，一般与$project_name相同； -sdk：区分iphone device和Simulator，可通过xcodebuild -showsdks获取，例如iphoneos和iphonesimulator9.3； -configuration：需要打包的配置文件，我们一般在项目中添加多个配置，适合不同的环境，Release/Debug； -exportFormat：导出的格式，通常填写为ipa； -archivePath：.xcarchive文件的路径； -exportPath：导出文件（.ipa）的路径； -exportProvisioningProfile：profile文件证书； -derivedDataPath：指定编译结果文件的存储路径；例如，指定-derivedDataPath ${OUTPUT_FOLDER}时，将在项目根目录下创建一个${OUTPUT_FOLDER}文件夹，生成的.app文件将位于${OUTPUT_FOLDER}/Build/Products/${CONFIGURATION}-iphoneos中。 除了采用官方的xcodebuild命令，还可以使用由Facebook开发维护的xctool。xctool命令的使用方法基本与xcodebuild一致，但是输出的日志会清晰很多，而且还有许多其它优化，详情请参考xctool的官方文档。 xcrun参数： -v：指定.app文件的路径 -o：指定生成.ipa文件的路径 ","date":"2016-07-26","heading":"参数说明","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:1:3","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#参数说明"},{"categories":["Development"],"collections":null,"content":"补充说明 1、获取Targets、Schemes、Configurations参数 在填写target/workspace/scheme/configuration等参数时，如果不知道该怎么填写，可以在项目根目录下执行xcodebuild -list命令，它会列出当前项目的所有可选参数。 ➜ Store_iOS git:(NPED) ✗ xcodebuild -list Information about project \"Store\": Targets: Store StoreCI Build Configurations: Debug Release If no build configuration is specified and -scheme is not passed then \"Release\" is used. Schemes: Store StoreCI 2、清除缓存文件 在每次build之后，工程目录下会遗留一些缓存文件，以便下次build时减少编译时间。然而，若因为工程配置错误等问题造成编译失败后，下次再编译时就可能会受到缓存的影响。 因此，在持续集成构建脚本中，比较好的做法是在每次build之前都清理一下上一次编译遗留的缓存文件。 # clean before build xctool \\ -workspace ${WORKSPACE_PATH} \\ -scheme ${SCHEME} \\ -configuration ${CONFIGURATION} \\ clean clean：清除编译产生的问题，下次编译就是全新的编译了 3、处理Cocoapod依赖库 另外一个需要注意的是，若项目是采用Cocoapod管理项目依赖，每次拉取最新代码后直接编译可能会报错。这往往是因为其他同事更新了依赖库（新增了第三方库或升级了某些库），而本地还采用之前的第三方库进行编译，从而会出现依赖库缺失或版本不匹配等问题。 应对的做法是，在每次build之前都更新一下Cocoapod。 # Update pod repository pod repo update # Install pod dependencies pod install 4、修改编译包的版本号 通过持续集成打包，我们会得到大量的安装包。为了便于区分，比较好的做法是在App中显示版本号，并将版本号与Jenkins的BUILD_NUMBER关联起来。 例如，当前项目的主版本号为2.6.0，本次构建的BUILD_NUMBER为130，那么我们就可以将本次构建的App版本号设置为2.6.0.130。通过这种方式，我们可以通过App中显示的版本号快速定位到具体到构建历史，从而对应到具体的代码提交记录。 要实现对App版本号的设置，只需要在打包前对Info.plist文件中的CFBundleVersion和CFBundleShortVersionString进行修改即可。在Python中，利用plistlib库可以很方便地实现对Info.plist文件的读写。 5、模拟器运行 如果持续集成测试是要运行在iOS模拟器上，那么就需要构建生成.app文件。 在前面讲解的两种构建方式中，中间产物都包含了.app文件。对于以.xcarchive为中间产物的方式，生成的.app文件位于output_dir/StoreCI_Release.xcarchive/Products/Applications/目录中。 不过，这个.app文件在模拟器中还无法直接运行，还需要在Xcode中修改Supported Platforms，例如，将iphoneos更改为iOS。详细原因请参考《从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用》 ","date":"2016-07-26","heading":"补充说明","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:1:4","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#补充说明"},{"categories":["Development"],"collections":null,"content":"关于Android的构建 待续 ","date":"2016-07-26","heading":"关于Android的构建","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:2:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#关于android的构建"},{"categories":["Development"],"collections":null,"content":"关于构建脚本 对于构建脚本（build.py）本身，源码应该是最好的说明文档。 在build.py脚本中，主要实现的功能就四点： 执行构建命令，编译生成.ipa文件，这部分包含了关于iOS的构建部分的全部内容； 构建时动态修改Info.plist，将编译包的版本号与Jenkins的BuildNumber关联起来； 上传.ipa文件至pyger/fir.im平台，并且做了失败重试机制； 解析pyger/fir.im平台页面中的二维码，将二维码图片保存到本地。 需要说明的是，对于构建任务中常用的可配置参数，例如BRANCH/SCHEME/CONFIGURATION/OUTPUT_FOLDER等，需要在构建脚本中通过OptionParser的方式实现可传参数机制。这样我们不仅可以命令行中通过传参的方式灵活地调用构建脚本，也可以在Jenkins中实现参数传递。 之所以强调常用的可配置参数，这是为了尽可能减少参数数目，降低脚本调用的复杂度。像PROVISIONING_PROFILE和pgyer/fir.im账号这种比较固定的配置参数，就可以写死在脚本中。因此，在使用构建脚本（build.py）之前，需要先在脚本中配置下PROVISIONING_PROFILE和pgyer/fir.im账号。 另外还想多说一句，pyger/fir.im这类第三方平台在为我们提供便利的同时，稳定性不可控也是一个不得不考虑的问题。在我使用pgyer平台期间，就遇到了平台服务变动、接口时而不稳定出现502等问题。因此，最好的方式还是自行搭建一套类似的服务，反正我是打算这么做了。 ","date":"2016-07-26","heading":"关于构建脚本","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:3:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#关于构建脚本"},{"categories":["Development"],"collections":null,"content":"Jenkins的详细配置 对于Jenkins的详细配置，需要补充说明的有四点。 ","date":"2016-07-26","heading":"Jenkins的详细配置","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:4:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#jenkins的详细配置"},{"categories":["Development"],"collections":null,"content":"1、参数的传递 在构建脚本中，我们已经对常用的可配置参数实现了可传参机制。例如，在Terminal中可以通过如下形式调用构建脚本。 $ python build.py --scheme SCHEME --workspace Store.xcworkspace --configuration CONFIGURATION --output OUTPUT_FOLDER 那么我们在Jenkins中要怎样才能指定参数呢？ 实际上，Jenkins针对项目具有参数化的功能。在项目的配置选项中，勾选This project is parameterized后，就可以为当前project添加多种类型的参数，包括： Boolean Parameter Choice Parameter Credentials Parameter File Parameter Multi-line String Parameter Password Parameter Run Parameter String Parameter 通常，我们可以选择使用String Parameter来定义自定义参数，并可对每个参数设置默认值。 当我们配置了BRANCH、SCHEME、CONFIGURATION、OUTPUT_FOLDER、BUILD_VERSION这几个参数后，我们就可以在Build配置区域的Execute shell通过如下形式来进行参数传递。 $ python ${WORKSPACE}/Build_scripts/build.py \\ --scheme ${SCHEME} \\ --workspace ${WORKSPACE}/Store.xcworkspace \\ --configuration ${CONFIGURATION} \\ --output ${WORKSPACE}/${OUTPUT_FOLDER} \\ --build_version ${BUILD_VERSION}.${BUILD_NUMBER} 可以看出，参数的传递方式很简单，只需要预先定义好了自定义参数，然后就可以通过${Param}的形式来进行调用了。 不过你也许会问，WORKSPACE和BUILD_NUMBER这两个参数我们并未进行定义，为什么也能进行调用呢？这是因为Jenkins自带部分与项目相关的环境变量，例如BRANCH_NAME、JOB_NAME等，这部分参数可以在shell脚本中直接进行调用。完整的环境变量可在Jenkins_Url/env-vars.html/中查看。 配置完成后，就可以在Build with Parameters中通过如下形式手动触发构建。 ","date":"2016-07-26","heading":"1、参数的传递","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:4:1","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#1参数的传递"},{"categories":["Development"],"collections":null,"content":"2、修改build名称 在Build History列表中，构建任务的名称默认显示为按照build次数递增的BUILD_NUMBER。有时候我们可能想在build名称中包含更多的信息，例如包含当次构建的SCHEME和CONFIGURATION，这时我们就可以通过修改BuildName实现。 Jenkins默认不支持BuildName设置，但可通过安装build-name-setter插件进行实现。安装build-name-setter插件后，在配置页面的Build Environment栏目下会出现Set Build Name配置项，然后在Build Name中就可以通过环境变量参数来设置build名称。 例如，要将build名称设置为上面截图中的StoreCI_Release_#130样式，就可以在Build Name中配置为${SCHEME}_${CONFIGURATION}_#${BUILD_NUMBER}。 除了在Build Name中传递环境变量参数，build-name-setter还可以实现许多更加强大的自定义功能，大家可自行探索。 ","date":"2016-07-26","heading":"2、修改build名称","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:4:2","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#2修改build名称"},{"categories":["Development"],"collections":null,"content":"3、展示二维码图片 然后再说下如何在Build History列表中展示每次构建对应的二维码图片。 需要说明的是，在上图中，绿色框对应的内容是BuildName，我们可以通过build-name-setter插件来实现自定义配置；但是红色框已经不在BuildName的范围之内，而是对应的BuildDescription。 同样地，Jenkins默认不支持在构建过程中自动修改BuildDescription，需要通过安装description setter plugin插件来辅助实现。安装description setter plugin插件后，在配置页面的Build栏目下，Add build step中会出现Set build description配置项，添加该配置项后就会出现如下配置框。 该功能的强大之处在于，它可以在构建日志中通过正则表达式来匹配内容，并将匹配到的内容添加到BuildDescription中去。 例如，我们想要展示的二维码图片是在每次构建过程中生成的，因此我们首先要获取到二维码图片文件。 我的做法是，在build.py中将蒲公英平台返回的应用下载页面地址和二维码图片地址打印到log中。 appDownloadPage: https://www.pgyer.com/035aaf10acf5dd7c279c4fe423a57674 appQRCodeURL: https://o1wjx1evz.qnssl.com/app/qrcodeHistory/fe7a8c9051f0c7fc0affc78f40c20a4b5e4bdb4c77b91a29501f55fd9039c659 Save QRCode image to file: /Users/Leo/.jenkins/workspace/DebugTalk_Plus_Store_iOS/build_outputs/QRCode.png 然后，在Set build description配置项的Regular expression就可以按照如下正则表达式进行匹配： appDownloadPage: (.*)$ 接下来，就可以在Description中对匹配到的结果进行引用。 \\n\bInstall Online 在这里，我们用到了HTML的标签，而Jenkins的Markup Formatter默认是采用Plain text模式，因此还需要对Jenkins对系统配置进行修改，在《使用Jenkins搭建iOS/Android持续集成打包平台》中已进行了详细说明，在此就不再重复。 通过以上方式，就可以实现前面图片中的效果。 ","date":"2016-07-26","heading":"3、展示二维码图片","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:4:3","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#3展示二维码图片"},{"categories":["Development"],"collections":null,"content":"4、收集编译成果物 在上面讲解的展示二维码图片一节中，用到了${BUILD_URL}artifact/build_outputs/QRCode.png一项，这里的URL就是用到了编译成果物收集后保存的路径。 Archives build artifacts是Jenkins默认自带的功能，无需安装插件。该功能在配置页面的Post-build Actions栏目下，在Add post-build action的列表中选择添加Archives build artifacts。 添加后的配置页面如下图所示： 通常，我们只需要配置Files to archive即可。定位文件时，可以通过正则表达式进行匹配，也可以调用项目的环境变量；多个文件通过逗号进行分隔。 例如，假如我们想收集QRCode.png、StoreCI_Release.ipa、Info.plist这三个文件，那么我们就可以通过如下表达式来进行指定。 ${OUTPUT_FOLDER}/*.ipa,${OUTPUT_FOLDER}/QRCode.png,${OUTPUT_FOLDER}/*.xcarchive/Info.plist 当然，目标文件的具体位置是我们在构建脚本（build.py）中预先进行处理的。 通过这种方式，我们就可以实现在每次完成构建后将需要的文件收集起来进行存档，以便后续在Jenkins的任务页面中进行下载。 也可以直接通过归档文件的URL进行访问。例如，上图中QRCode.png的URL为Jenkins_Url/job/JenkinsJobName/131/artifact/build_outputs/QRCode.png，而Jenkins_Url/job/JenkinsJobName/131/即是${BUILD_URL}，因此可以直接通过${BUILD_URL}artifact/build_outputs/QRCode.png引用。 ","date":"2016-07-26","heading":"4、收集编译成果物","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:4:4","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#4收集编译成果物"},{"categories":["Development"],"collections":null,"content":"总结 至此，《使用Jenkins搭建iOS/Android持续集成打包平台》一文中涉及到的Jenkins配置和构建脚本实现细节均已补充完毕了。相信大家结合这两篇文章，应该会对如何使用Jenkins搭建iOS/Android持续集成打包平台的基础概念和实现细节都有一个比较清晰的认识。 对于还未完善的部分，我后续将在博客中进行更新。 操作手册请参考文章末尾的【开箱即用】部分，祝大家玩得愉快！ ","date":"2016-07-26","heading":"总结","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:5:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#总结"},{"categories":["Development"],"collections":null,"content":"开箱即用 GitHub地址：https://github.com/debugtalk/JenkinsTemplateForApp ","date":"2016-07-26","heading":"开箱即用","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:6:0","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#开箱即用"},{"categories":["Development"],"collections":null,"content":"1、添加构建脚本 在构建脚本中配置PROVISIONING_PROFILE和pgyer/fir.im账号； 在目标构建代码库的根目录中，创建Build_scripts文件夹，并将build.py拷贝到Build_scripts中； 将Build_scripts/build.py提交到项目中。 除了与Jenkins实现持续集成，构建脚本还可单独使用，使用方式如下： $ python ${WORKSPACE}/Build_scripts/build.py \\ --scheme ${SCHEME} \\ --workspace ${WORKSPACE}/Store.xcworkspace \\ --configuration ${CONFIGURATION} \\ --output ${WORKSPACE}/${OUTPUT_FOLDER} ","date":"2016-07-26","heading":"1、添加构建脚本","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:6:1","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#1添加构建脚本"},{"categories":["Development"],"collections":null,"content":"2、运行jenkins，安装必备插件 $ nohup java -jar jenkins_located_path/jenkins.war \u0026 ","date":"2016-07-26","heading":"2、运行jenkins，安装必备插件","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:6:2","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#2运行jenkins安装必备插件"},{"categories":["Development"],"collections":null,"content":"3、创建Jenkins Job 在Jenkins中创建一个Freestyle project类型的Job，先不进行任何配置； 然后将config.xml文件拷贝到~/.jenkins/jobs/YourProject/中覆盖原有配置文件，重启Jenkins； 完成配置文件替换和重启后，刚创建好的Job就已完成了大部分配置； 在Job Configure中根据项目实际情况调整配置，其中Git Repositories是必须修改的，其它配置项可选择性地进行调整。 ","date":"2016-07-26","heading":"3、创建Jenkins Job","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:6:3","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#3创建jenkins-job"},{"categories":["Development"],"collections":null,"content":"4、done！ ","date":"2016-07-26","heading":"4、done！","objectID":"/post/iOS-Android-Packing-with-Jenkins-details/:6:4","tags":["Jenkins","iOS"],"title":"关于持续集成打包平台的 Jenkins 配置和构建脚本实现细节","uri":"/post/iOS-Android-Packing-with-Jenkins-details/#4done"},{"categories":["OpenSource"],"collections":null,"content":"AppiumBooster AppiumBooster helps you to write automation testcases in yaml format or csv tables, without writing a snippet of code. ","date":"2016-07-20","heading":"AppiumBooster","objectID":"/post/Introduction-to-AppiumBooster/:1:0","tags":["Appium","AppiumBooster"],"title":"Introduction to AppiumBooster","uri":"/post/Introduction-to-AppiumBooster/#appiumbooster"},{"categories":["OpenSource"],"collections":null,"content":"write testcases in yaml (recommended) Take DebugTalk+ Discover\u0026rsquo;s login and logout function as an example. In order to test these functions above, you can write testcases in yaml format like this. # ios/testcases/Account.yml --- AccountTestcases: login with valid account: - AccountSteps | enter My Account page - AccountSteps | enter Login page - AccountSteps | input EmailAddress - AccountSteps | input Password - AccountSteps | login - AccountSteps | close coupon popup window(optional) logout: - AccountSteps | enter My Account page - SettingsSteps | enter Settings page - AccountSteps | logout In the testcases, each step is combined with two parts, joined by a separator |. The former part indicates step file located in ios/steps/ directory, and the latter part indicates testcase step name, which is defined in steps yaml files like below. # ios/steps/AccountSteps.yml --- AccountSteps: enter My Account page: control_id: btnMenuMyAccount control_action: click expectation: tablecellMyAccountSystemSettings enter Login page: control_id: tablecellMyAccountLogin control_action: click expectation: btnForgetPassword input EmailAddress: control_id: txtfieldEmailAddress control_action: type data: leo.lee@debugtalk.com expectation: sectxtfieldPassword input Password: control_id: sectxtfieldPassword control_action: type data: 123456 expectation: btnLogin login: control_id: btnLogin control_action: click expectation: tablecellMyMessage ","date":"2016-07-20","heading":"write testcases in yaml (recommended)","objectID":"/post/Introduction-to-AppiumBooster/:2:0","tags":["Appium","AppiumBooster"],"title":"Introduction to AppiumBooster","uri":"/post/Introduction-to-AppiumBooster/#write-testcases-in-yaml-recommended"},{"categories":["OpenSource"],"collections":null,"content":"write testcases in tables You can also write testcases in any table tools, including MS Excel and iWork Numbers, and even in plain CSV format. In order to test the same functions above, you can write testcases in tables like this. After the testcases are finished, export to CSV format, and put the csv files under ios/testcases/ directory. ","date":"2016-07-20","heading":"write testcases in tables","objectID":"/post/Introduction-to-AppiumBooster/:3:0","tags":["Appium","AppiumBooster"],"title":"Introduction to AppiumBooster","uri":"/post/Introduction-to-AppiumBooster/#write-testcases-in-tables"},{"categories":["OpenSource"],"collections":null,"content":"run Once the testcases are done, you are ready to run automation test on your app. Run the automation testcases is very easy. You can execute ruby run.rb -h in the project root directory to see the usage. ➜ AppiumBooster git:(master) ✗ ruby run.rb -h Usage: run.rb [options] -p, --app_path APP_PATH Specify app path -t, --app_type APP_TYPE Specify app type, ios or android -f, --testcase_file TESTCASE_FILE Specify testcase file --disable_output_color Disable output color AppiumBooster will load all the csv test suites and then excute each suite sequentially. ➜ AppiumBooster git:(master) ✗ ruby run.rb -p \"ios/app/test.zip\" -f \"ios/testcases/Account.yml\" initialize appium driver ... load testcase yaml file: /Users/Leo/MyProjects/AppiumBooster/ios/testcases/Account.yml load steps yaml file: /Users/Leo/MyProjects/AppiumBooster/ios/steps/AccountSteps.yml load steps yaml file: /Users/Leo/MyProjects/AppiumBooster/ios/steps/SettingsSteps.yml start appium driver ... ======= start to run testcase suite: /Users/Leo/MyProjects/AppiumBooster/ios/testcases/Account.yml ======= B------ Start to run testcase: login with valid account step_1: enter My Account page btnMenuMyAccount.click ... ✓ step_2: enter Login page tablecellMyAccountLogin.click ... ✓ step_3: input EmailAddress txtfieldEmailAddress.type leo.lee@debugtalk.com ... ✓ step_4: input Password sectxtfieldPassword.type 123456 ... ✓ step_5: login btnLogin.click ... ✓ step_6: close coupon popup window(optional) btnClose.click ... ✓ E------ login with valid account B------ Start to run testcase: logout step_1: enter My Account page btnMenuMyAccount.click ... ✓ step_2: enter Settings page tablecellMyAccountSystemSettings.click ... ✓ step_3: logout btnLogout.click ... ✓ E------ logout ============ all testcases have been executed. ============ quit appium driver. ","date":"2016-07-20","heading":"run","objectID":"/post/Introduction-to-AppiumBooster/:4:0","tags":["Appium","AppiumBooster"],"title":"Introduction to AppiumBooster","uri":"/post/Introduction-to-AppiumBooster/#run"},{"categories":["OpenSource"],"collections":null,"content":"Source Code GitHub: https://github.com/debugtalk/AppiumBooster ","date":"2016-07-20","heading":"Source Code","objectID":"/post/Introduction-to-AppiumBooster/:5:0","tags":["Appium","AppiumBooster"],"title":"Introduction to AppiumBooster","uri":"/post/Introduction-to-AppiumBooster/#source-code"},{"categories":["Development"],"collections":null,"content":"背景描述 根据项目需求，现要在团队内部搭建一个统一的打包平台，实现对iOS和Android项目的打包。而且为了方便团队内部的测试包分发，希望在打包完成后能生成一个二维码，体验用户（产品、运营、测试等人员）通过手机扫描二维码后就能直接安装测试包。 该需求具有一定的普遍性，基本上所有开发APP的团队都可能会用到，因此我将整个需求实现的过程整理后形成此文，并且真正地做到了零基础上手，到手即飞、开箱即用，希望能对大家有所帮助。 首先，先给大家展示下平台建设完成后的整体效果： 该平台主要实现的功能有3点： 定期对GitHub仓库进行检测，若有更新则自动执行构建打包； 构建成功后根据ipa/apk生成二维码，并可在历史构建列表中展示各个版本的二维码，通过手机扫描二维码可直接安装对应版本； 在构建结果页面中展示当次构建的成果物（Artifact，如.ipa、.app、.apk、info.plist等文件），供有需要的用户进行下载。 接下来，本文就开始对平台建设的完整实现过程进行详细介绍。 ","date":"2016-06-28","heading":"背景描述","objectID":"/post/iOS-Android-Packing-with-Jenkins/:1:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#背景描述"},{"categories":["Development"],"collections":null,"content":"安装Jenkins Jenkins依赖于Java运行环境，因此需要首先安装Java。 安装Jenkins的方式有多种，可以运行对应系统类型的安装包，可以通过docker获取镜像，也可以直接运行war包。 我个人倾向于直接运行war包的形式，只需下载jenkins.war后，运行如下命令即可启动Jenkins。 $ nohup java -jar jenkins_located_path/jenkins.war --httpPort=88 \u0026 如果不指定httpPort，Jenkins的默认端口为8080。 ","date":"2016-06-28","heading":"安装Jenkins","objectID":"/post/iOS-Android-Packing-with-Jenkins/:2:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#安装jenkins"},{"categories":["Development"],"collections":null,"content":"Jenkins插件 Jenkins有非常多的插件，可以实现各种功能的扩展。 针对搭建的iOS/Android持续集成打包平台，我使用到了如下几个插件。 GIT plugin SSH Credentials Plugin Git Changelog Plugin: 获取仓库提交的commit log build-name-setter：用于修改Build名称 description setter plugin：用于在修改Build描述信息，在描述信息中增加显示QRCode（二维码） Post-Build Script Plug-in：在编译完成后通过执行脚本实现一些额外功能 Xcode integration: iOS专用（可选） Gradle plugin: Android专用（可选） 安装方式也比较简单，直接在Jenkins的插件管理页面搜索上述插件，点击安装即可。 ","date":"2016-06-28","heading":"Jenkins插件","objectID":"/post/iOS-Android-Packing-with-Jenkins/:3:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#jenkins插件"},{"categories":["Development"],"collections":null,"content":"创建项目（Job） 在Jenkins中，构建项目以Job的形式存在，因此需要针对每个项目创建一个Job。有时候，一个项目中可能有多个分支同时在进行开发，为了分别进行构建，也可以针对每个分支创建一个Job。 创建Job的方式有多种，本次只需要创建Freestyle project类型的即可。 Main page -\u0026gt; New Item -\u0026gt; Freestyle project 对于一个持续集成打包平台，每次打包都由4步组成：触发构建、拉取代码、执行构建、构建后处理。对应的，在每个Job中也对应了这几项的配置。 ","date":"2016-06-28","heading":"创建项目（Job）","objectID":"/post/iOS-Android-Packing-with-Jenkins/:4:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#创建项目job"},{"categories":["Development"],"collections":null,"content":"配置Git代码仓库 要对项目进行构建，配置项目的代码仓库是必不可少的。由于当前我们的项目托管在GitHub私有仓库中，因此在此需要对Git进行配置。 在【Source Code Management】配置栏目下，如果之前GIT plugin安装成功，则会出现Git选项。 配置Git代码仓库时，有三项是必须配置的：仓库URL地址（Repository URL）、仓库权限校验方式（Credentials），以及当前Job需要构建的代码分支（Branches to build）。 在配置Repository URL时，选择HTTPS URL或SSH URL均可。不过需要注意的是，Credentials要和Repository URL对应，也就是说： 如果Repository URL是HTTPS URL形式的，那么Credentials就要采用GitHub用户名密码的校验方式；而且，如果在GitHub中开启了2FA（two-factor authentication），那么还需要在GitHub中创建一个Personal access token，输入密码时将这个Personal access token作为密码进行输入。 如果Repository URL是SSH URL形式的，那么就需要先在Jenkins所在的服务器上创建一个SSH秘钥对，并将公钥添加到GitHub的SSH keys中，然后在填写Credentials时，选择SSH Username with private key的校验方式，填入GitHub Username、SSH私钥、以及创建SSH秘钥对时设置的Passphrase。 如果对Git权限校验的概念还比较模糊，可以参考《深入浅出Git权限校验》。 在配置Branches to build时，可以采用多种形式，包括分支名称（branchName）、tagName、commitId等。其中分支名称的形式用的最多，例如，若是构建master分支，则填写refs/heads/master，若是构建develop分支，则填写refs/heads/develop。 除了以上关于Git的必填配置项，有时根据项目的实际情况，可能还需要对Jenkins的默认配置项进行修改。 比较常见的一种情况就是对clone的配置进行修改。 在Jenkins的默认配置中，clone代码时会拉取所有历史版本的代码，而且默认的超时时限只有10分钟。这就造成在某些项目中，由于代码量本身就比较大，历史版本也比较多，再加上网络环境不是特别好，Jenkins根本没法在10分钟之内拉取完所有代码，超时后任务就会被自动终止了（错误状态码143）。 这种问题的解决方式也很简单，无非就是两种思路，要么少拉取点代码（不获取历史版本），要么提高超时时限。对应的配置在Advanced clone behaviours中： Shallow clone：勾选后不获取历史版本； Timeout (in minutes) for clone and fetch operation：配置后覆盖默认的超时时限。 ","date":"2016-06-28","heading":"配置Git代码仓库","objectID":"/post/iOS-Android-Packing-with-Jenkins/:5:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#配置git代码仓库"},{"categories":["Development"],"collections":null,"content":"配置构建触发器 代码仓库配置好了，意味着Jenkins具有了访问GitHub代码仓库的权限，可以成功地拉取代码。 那Jenkins什么时候执行构建呢？ 这就需要配置构建触发策略，即构建触发器，配置项位于【Build Triggers】栏目。 触发器支持多种类型，常用的有： 定期进行构建（Build periodically） 根据提交进行构建（Build when a change is pushed to GitHub） 定期检测代码更新，如有更新则进行构建（Poll SCM） 构建触发器的选择为复合选项，若选择多种类型，则任一类型满足构建条件时就会执行构建工作。如果所有类型都不选择，则该Jenkins Job不执行自动构建，但可通过手动点击【Build Now】触发构建。 关于定时器（Schedule）的格式，简述如下： MINUTE HOUR DOM MONTH DOW MINUTE: Minutes within the hour (0-59) HOUR: The hour of the day (0-23) DOM: The day of the month (1-31) MONTH: The month (1-12) DOW: The day of the week (0-7) where 0 and 7 are Sunday. 通常情况下需要指定多个值，这时可以采用如下operator（优先级从上到下）： *适配所有有效的值，若不指定某一项，则以*占位； M-N适配值域范围，例如7-9代表7/8/9均满足； M-N/X或*/X：以X作为间隔； A,B,C：枚举多个值。 另外，为了避免多个任务在同一时刻同时触发构建，在指定时间段时可以配合使用H字符。添加H字符后，Jenkins会在指定时间段内随机选择一个时间点作为起始时刻，然后加上设定的时间间隔，计算得到后续的时间点。直到下一个周期时，Jenkins又会重新随机选择一个时间点作为起始时刻，依次类推。 为了便于理解，列举几个示例： H/15 * * * *：代表每隔15分钟，并且开始时间不确定，这个小时可能是:07,:22,:37,:52，下一个小时就可能是:03,:18,:33,:48； H(0-29)/10 * * * *：代表前半小时内每隔10分钟，并且开始时间不确定，这个小时可能是:04,:14,:24，下一个小时就可能是:09,:19,:29； H 23 * * 1-5：工作日每晚23:00至23:59之间的某一时刻； ","date":"2016-06-28","heading":"配置构建触发器","objectID":"/post/iOS-Android-Packing-with-Jenkins/:6:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#配置构建触发器"},{"categories":["Development"],"collections":null,"content":"配置构建方式 触发策略配置好之后，Jenkins就会按照设定的策略自动执行构建。但如何执行构建操作，这还需要我们通过配置构建方式来进行设定。 常用的构建方式是根据构建对象的具体类型，安装对应的插件，然后采用相应的构建方式。例如，若是构建Android应用，安装Gradle plugin之后，就可以选择Invoke Gradle script，然后采用Gradle进行构建；若是构建iOS应用，安装Xcode integration插件之后，就可以选择Xcode，然后选择Xcode进行构建。 该种方式的优势是操作简单，UI可视化，在场景不复杂的情况下可以快速满足需求。不过缺点就是依赖于插件已有的功能，如果场景较复杂时可能单个插件还无法满足需求，需要再安装其它插件。而且，有些插件可能还存在一些问题，例如对某些操作系统版本或XCode版本兼容不佳，出现问题时我们就会比较被动。 我个人更倾向于另外一种方式，就是自己编写打包脚本，在脚本中自定义实现所有的构建功能，然后在Execute Shell中执行。这种方式的灵活度更高，各种场景的构建需求都能满足，出现问题后也能自行快速修复。 另外，对于iOS应用的构建，还有一个需要额外关注的点，就是开发者证书的配置。 如果是采用Xcode integration插件进行构建，配置会比较复杂，需要在Jenkins中导入开发证书，并填写多个配置项。不过，如果是采用打包脚本进行构建的话，情况就会简单许多。只要在Jenkins所运行的计算机中安装好开发者证书，打包命令在Shell中能正常工作，那么在Jenkins中执行打包脚本也不会有什么问题。 ","date":"2016-06-28","heading":"配置构建方式","objectID":"/post/iOS-Android-Packing-with-Jenkins/:7:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#配置构建方式"},{"categories":["Development"],"collections":null,"content":"构建后处理 完成构建后，生成的编译成果物（ipa/apk）会位于指定的目录中。但是，如果要直接在手机中安装ipa/apk文件还比较麻烦，不仅在分发测试包时需要将好几十兆的安装包进行传送，体验用户在安装时也还需要通过数据线将手机与计算机进行连接，然后再使用PP助手或豌豆荚等工具进行安装。 当前比较优雅的一种方式是借助蒲公英（pgyer）或fir.im等平台，将ipa/apk文件上传至平台后由平台生成二维码，然后只需要对二维码链接进行分发，体验用户通过手机扫描二维码后即可实现快速安装，效率得到了极大的提升。 ","date":"2016-06-28","heading":"构建后处理","objectID":"/post/iOS-Android-Packing-with-Jenkins/:8:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#构建后处理"},{"categories":["Development"],"collections":null,"content":"上传安装包文件，生成二维码 不管是蒲公英还是fir.im，都有对应的Jenkins插件，安装插件后可以在Post-build中实现对安装包的上传。 除了使用Jenkins插件，fir.im还支持命令上传的方式，蒲公英还支持HTTP Post接口上传的方式。 我个人推荐采用命令或接口上传的方法，并在构建脚本中进行调用。灵活是一方面，更大的好处是如果上传失败后还能进行重试，这在网络环境不是很稳定的情况下极其必要。 Jenkins成功完成安装包上传后，pgyer/fir.im平台会生成一个二维码图片，并在响应中将图片的URL链接地址进行返回。 ","date":"2016-06-28","heading":"上传安装包文件，生成二维码","objectID":"/post/iOS-Android-Packing-with-Jenkins/:8:1","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#上传安装包文件生成二维码"},{"categories":["Development"],"collections":null,"content":"展示二维码图片 二维码图片的URL链接有了，那要怎样才能将二维码图片展示在Jenkins项目的历史构建列表中呢？ 这里需要用到另外一个插件，description setter plugin。安装该插件后，在【Post-build Actions】栏目中会多出description setter功能，可以实现构建完成后设置当次build的描述信息。这个描述信息不仅会显示在build页面中，同时也会显示在历史构建列表中。 有了这个前提，要将二维码图片展示在历史构建列表中貌似就可以实现了，能直观想到的方式就是采用HTML的img标签，将\u0026lt;img src='qr_code_url'\u0026gt;写入到build描述信息中。 这个方法的思路是正确的，不过这么做以后并不会实现我们预期的效果。 这是因为Jenkins出于安全的考虑，所有描述信息的Markup Formatter默认都是采用Plain text模式，在这种模式下是不会对build描述信息中的HTML编码进行解析的。 要改变也很容易，Manage Jenkins -\u0026gt; Configure Global Security，将Markup Formatter的设置更改为Safe HTML即可。 更改配置后，我们就可以在build描述信息中采用HTML的img标签插入图片了。 另外还需要补充一个点。如果是使用蒲公英（pyger）平台，会发现每次上传安装包后返回的二维码图片是一个短链接，神奇的是这个短连接居然是固定的（对同一个账号而言）。这个短连接总是指向最近生成的二维码图片，但是对于二维码图片的唯一URL地址，平台并没有在响应中进行返回。在这种情况下，我们每次构建完成后保存二维码图片的URL链接就没有意义了。 应对的做法是，每次上传完安装包后，通过返回的二维码图片短链接将二维码图片下载并保存到本地，然后在build描述信息中引用该图片在Jenkins中的地址即可。 ","date":"2016-06-28","heading":"展示二维码图片","objectID":"/post/iOS-Android-Packing-with-Jenkins/:8:2","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#展示二维码图片"},{"categories":["Development"],"collections":null,"content":"收集编译成果物（Artifacts） 每次完成构建后，编译生成的文件较多，但是并不是所有的文件都是我们需要的。 通常情况下，我们可能只需要其中的部分文件，例如.ipa/.app/.plist/.apk等，这时我们可以将这部分文件单独收集起来，并在构建页面中展示出来，以便在需要时进行下载。 要实现这样一个功能，需要在【Post-build Actions】栏目中新增Archive the artifacts，然后在Files to archive中通过正则表达式指定成果物文件的路径。 设置完毕后，每次构建完成后，Jenkins会在Console Output中采用设定的正则表达式进行搜索匹配，如果能成功匹配到文件，则会将文件收集起来。 ","date":"2016-06-28","heading":"收集编译成果物（Artifacts）","objectID":"/post/iOS-Android-Packing-with-Jenkins/:8:3","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#收集编译成果物artifacts"},{"categories":["Development"],"collections":null,"content":"总结 本文主要是对如何使用Jenkins搭建iOS/Android持续集成打包平台的基础概念和实施流程进行了介绍。对于其中涉及到的执行命令、构建脚本（build.py），以及Jenkins的详细配置，出于篇幅长度和阅读体验的考虑，并没有在文中进行详细展开。 为了实现真正的开箱即用，我将Jenkins的配置文件和构建脚本抽离出来形成一套模板，只需要导入到Jenkins中，然后针对具体的项目修改少量配置信息，即可将这一套持续集成打包平台运行起来，实现和文章开头插图中完全相同的功能效果。 详细内容请阅读《关于持续集成打包平台的Jenkins配置和构建脚本实现细节》。 ","date":"2016-06-28","heading":"总结","objectID":"/post/iOS-Android-Packing-with-Jenkins/:9:0","tags":["Jenkins","iOS"],"title":"使用 Jenkins 搭建 iOS/Android 持续集成打包平台","uri":"/post/iOS-Android-Packing-with-Jenkins/#总结"},{"categories":["Development"],"collections":null,"content":"借助上次“掉坑”的经历，我对Git权限校验的两种方式重头进行了梳理，形成了这篇总结记录。 在本地计算机与GitHub（或GitLab）进行通信时，传输主要基于两种协议，HTTPS和SSH，对应的仓库地址就是HTTPS URLs和SSH URLs。 首先需要强调的是，HTTPS URLs和SSH URLs对应的是两套完全独立的权限校验方式，主要的区别就是HTTPS URLs采用账号密码进行校验，SSH URLs采用SSH秘钥对进行校验。平时使用的时候我们可以根据实际情况，选择一种即可。 ","date":"2016-06-15","heading":"","objectID":"/post/head-first-git-authority-verification/:0:0","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#"},{"categories":["Development"],"collections":null,"content":"HTTPS URLs GitHub官方推荐采用HTTPS URLs的方式，因为该种方式适用面更广（即使在有防火墙或代理的情况下也同样适用），使用更方便（配置更简单）。 采用HTTPS URLs地址clone/fetch/pull/push仓库时，事先无需对本地系统进行任何配置，只需要输入GitHub的账号和密码即可。不过如果每次都要手动输入账号密码，也是一件很繁琐的事情。 好在已经有多个机制可以让操作不用这么麻烦。 在Mac系统中，在启用Keychain机制的情况下，首次输入GitHub账号密码后，认证信息就会自动保存到系统的Keychain中，下次再次访问仓库时就会自动读取Keychain中保存的认证信息。 在非Mac系统中，虽然没有Keychain机制，但是Git提供了credential helper机制，可以将账号密码以cache的形式在内存中缓存一段时间（默认15分钟），或者以文件的形式存储起来（~/.git-credentials）。当然，Mac系统如果不启用Keychain机制，也可以采用这种方式。 # cache credential in memory $ git config --global credential.helper cache # store credential in ~/.git-credential $ git config --global credential.helper store 在credential.helper设置为store的情况下，首次输入GitHub账号密码后，就会自动保存到~/.git-credentials文件中，保存形式为https://user:pass@github.com；下次再次访问仓库时就会自动读取~/.git-credentials中保存的认证信息。 另一个需要说明的情况是，如果在GitHub中开启了2FA（two-factor authentication），那么在本地系统中输入GitHub账号密码时，不能输入原始的密码（即GitHub网站的登录密码），而是需要事先在GitHub网站中创建一个Personal access token，后续在访问代码仓库需要进行权限校验的时候，采用access token作为密码进行输入。 ","date":"2016-06-15","heading":"HTTPS URLs","objectID":"/post/head-first-git-authority-verification/:1:0","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#https-urls"},{"categories":["Development"],"collections":null,"content":"SSH URLs 除了HTTPS URLs，还可以采用SSH URLs的方式访问GitHub代码仓库。 采用SSH URLs方式之前，需要先在本地计算机中生成SSH keypair（秘钥对，包括私钥和公钥）。默认情况下，生成的秘钥位于$HOME/.ssh/目录中，文件名称分别为id_rsa和id_rsa.pub，通常无需修改，保持默认即可。不过，如果一台计算机中存在多个秘钥对，就需要修改秘钥文件名，名称没有强制的命名规范，便于自己辨识即可。 如下是创建秘钥对的过程。 ➜ ssh-keygen -t rsa -b 4096 -C \"mail@debugtalk.com\" Generating public/private rsa key pair. Enter file in which to save the key (/Users/Leo/.ssh/id_rsa): /Users/Leo/.ssh/debugtalk_id_rsa Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /Users/Leo/.ssh/debugtalk_id_rsa. Your public key has been saved in /Users/Leo/.ssh/debugtalk_id_rsa.pub. The key fingerprint is: SHA256:jCyEEKjlCU1klROnuBg+UH08GJ1u252rQMADdD9kYMo mail@debugtalk.com The key's randomart image is: +---[RSA 4096]----+ |+*BoBO+. | |o=oO=** | |++E.*+o. | |+ooo +o+ | |.o. ..+oS. . | | . o. . o | | . . | | . . | | .. | +----[SHA256]-----+ 在创建秘钥的过程中，系统还建议创建一个名为passphrase的东西，这是用来干嘛的呢？ 首先，单独采用密码肯定是不够安全的。如果密码太简单，那么就很容易被暴力破解，如果密码太复杂，那么用户就很难记忆，记录到小本子里面更不安全。 因此，SSH keys诞生了。SSH秘钥对的可靠性非常高，被暴力破解的可能性基本没有。不过，这要求用户非常谨慎地保管好私钥，如果别人使用你的计算机时偷偷地将你的私钥拷走了，那么就好比是别人拿到了你家里的钥匙，也能随时打开你家的门。 基于以上情况，解决办法就是在SSH keys之外再增加一个密码，即passphrase。只有同时具备SSH private key和passphrase的情况下，才能通过SSH的权限校验，这就大大地增加了安全性。当然，这个passphrase也不是必须的，在创建秘钥对时也可以不设置passphrase。 另外，如果每次权限校验时都要输入passphrase，这也是挺麻烦的。好在我们不用再担心这个问题，因为ssh-agent可以帮我们记住passphrase，Mac系统的Keychain也可以记住passphrase，这样我们在同一台计算机中就不用重新输入密码了。 秘钥对创建好以后，私钥存放于本地计算机（~/.ssh/id_rsa），将公钥（~/.ssh/id_rsa.pub）中的内容添加至GitHub账户。 # copy the contents of id_rsa.pub to the clipboard ➜ pbcopy \u003c ~/.ssh/id_rsa.pub # paste to GitHub # Login GitHub, 【Settings】-\u003e【SSH and GPG keys】-\u003e【New SSH Key】 不过，如果此时检测本地计算机与GitHub的连接状态，会发现系统仍提示权限校验失败。 ➜ ssh -T git@github.com Permission denied (publickey). 这是因为在本地计算机与GitHub建立连接的时候，实际上是本机计算机的ssh-agent与GitHub服务器进行通信。虽然本地计算机有了私钥，但是ssh-agent并不知道私钥存储在哪儿。因此，要想正常使用秘钥对，需要先将私钥加入到本地计算机的ssh-agent中（添加过程中需要输入passphrase）。 # start ssh-agent in the background ➜ eval \"$(ssh-agent -s)\" Agent pid 78370 ➜ ssh-add ~/.ssh/id_rsa Enter passphrase for /Users/Leo/.ssh/id_rsa: Identity added: /Users/Leo/.ssh/id_rsa (/Users/Leo/.ssh/id_rsa) 添加完成后，就可以查看到当前计算机中存储的密钥。 ➜ ssh-add -l 4096 SHA256:xRg49AgTxxxxxxxx8q2SPPOfxxxxxxxxRlBY /Users/Leo/.ssh/id_rsa (RSA) 再次检测本地计算机与GitHub的连接状态，校验就正常通过了。 ➜ ssh -T git@github.com Hi leolee! You've successfully authenticated, but GitHub does not provide shell access. 后续再进行clone/fetch/pull/push操作时，就可以正常访问GitHub代码仓库了，并且也不需要再重新输入账号密码。 而且，将私钥加入ssh-agent后，即使删除私钥文件，本地计算机仍可以正常访问GitHub代码仓库。 ➜ rm -rf ~/.ssh ➜ ssh-add -l 4096 SHA256:xRg49AgTxxxxxxxx8q2SPPOfxxxxxxxxRlBY /Users/Leo/.ssh/id_rsa (RSA) ➜ ssh -T git@github.com The authenticity of host 'github.com (192.30.252.130)' can't be established. RSA key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8. Are you sure you want to continue connecting (yes/no)? yes Warning: Permanently added 'github.com,192.30.252.130' (RSA) to the list of known hosts. Hi leolee! You've successfully authenticated, but GitHub does not provide shell access. 只有执行ssh-add -D或ssh-add -d pub_key命令，将私钥从ssh-agent删除后，认证信息才会失效。 ➜ ssh-add -d ~/.ssh/id_rsa.pub Identity removed: /Users/Leo/.ssh/id_rsa.pub (mail@debugtalk.com) ➜ ssh-add -l The agent has no identities. ➜ ssh -T git@github.com Permission denied (publickey). ","date":"2016-06-15","heading":"SSH URLs","objectID":"/post/head-first-git-authority-verification/:2:0","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#ssh-urls"},{"categories":["Development"],"collections":null,"content":"同时使用多个GitHub账号 熟悉了HTTPS URLs和SSH URLs这两种校验方式之后，我们再来看之前遇到的问题。要想在一台计算机上同时使用多个GitHub账号访问不同的仓库，需要怎么做呢？ 为了更好地演示，现假设有两个GitHub账号，debugtalk和leolee，在两个账号中各自有一个仓库，debugtalk/DroidMeter和DebugTalk/MobileStore（公司私有库）。 前面已经说过，HTTPS URLs和SSH URLs对应着两套独立的权限校验方式，因此这两套方式应该是都能单独实现我们的需求的。 不过在详细讲解Git权限校验的问题之前，我们先来回顾下Git配置文件的优先级。 ","date":"2016-06-15","heading":"同时使用多个GitHub账号","objectID":"/post/head-first-git-authority-verification/:3:0","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#同时使用多个github账号"},{"categories":["Development"],"collections":null,"content":"Git配置存储位置及其优先级 Unix-like系统中，保存Git用户信息的主要有3个地方（Mac系统多一个Keychain）： /etc/gitconfig：存储当前系统所有用户的git配置信息，使用带有--system选项的git config时，配置信息会写入该文件； ~/.gitconfig或~/.config/git/config：存储当前用户的git配置信息，使用带有--global选项的git config时，配置信息会写入该文件； Keychain Access：在开启Keychain机制的情况下，进行权限校验后会自动将账号密码保存至Keychain Access。 仓库的Git目录中的config文件（即repo/.git/config）：存储当前仓库的git配置信息，在仓库中使用带有--local选项的git config时，配置信息会写入该文件； 在优先级方面，以上4个配置项的优先级从上往下依次上升，即repo/.git/config的优先级最高，然后Keychain Access会覆盖~/.gitconfig中的配置，~/.gitconfig会覆盖/etc/gitconfig中的配置。 ","date":"2016-06-15","heading":"Git配置存储位置及其优先级","objectID":"/post/head-first-git-authority-verification/:3:1","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#git配置存储位置及其优先级"},{"categories":["Development"],"collections":null,"content":"基于SSH协议实现多账号共存 先来看下如何采用SSH URLs实现我们的需求。 在处理多账号共存问题之前，两个账号均已分别创建SSH秘钥对，并且SSH-key均已加入本地计算机的ssh-agent。 ➜ ssh-add -l 4096 SHA256:lqujbjkWM1xxxxxxxxxxG6ERK6DNYj9tXExxxxxx8ew /Users/Leo/.ssh/debugtalk_id_rsa (RSA) 4096 SHA256:II2O9vZutdQr8xxxxxxxxxxD7EYvxxxxxxbynx2hHtg /Users/Leo/.ssh/id_rsa (RSA) 在详细讲解多账号共存的问题之前，我们先来回想下平时在Terminal中与GitHub仓库进行交互的场景。 ➜ DroidMeter git:(master) git pull Already up-to-date. ➜ DroidMeter git:(master) touch README.md ➜ DroidMeter git:(master) ✗ git add . ➜ DroidMeter git:(master) ✗ git commit -m \"add README\" ➜ DroidMeter git:(master) git push Counting objects: 3, done. Delta compression using up to 4 threads. Compressing objects: 100% (2/2), done. Writing objects: 100% (3/3), 310 bytes | 0 bytes/s, done. Total 3 (delta 0), reused 0 (delta 0) To git@debugtalk:debugtalk/DroidMeter.git 7df6839..68d085b master -\u003e master 在操作过程中，本地计算机的ssh-agent与GitHub服务器建立了连接，并进行了账号权限校验。 当本地计算机只有一个GitHub账号时，这个行为并不难理解，系统应该会采用这个唯一的GitHub账号进行操作。那如果本地计算机中有多个Github账号时，系统是根据什么来判断应该选择哪个账号呢？ 实际情况是，系统没法进行判断。系统只会有一个默认的账号，然后采用这个默认的账号去操作所有的代码仓库，当账号与仓库不匹配时，就会报权限校验失败的错误。 那要怎样才能让系统正确区分账号呢？这就需要我们手动进行配置，配置文件即是~/.ssh/config。 创建~/.ssh/config文件，在其中填写如下内容。 # debugtalk Host debugtalk HostName github.com User git IdentityFile ~/.ssh/id_rsa # DT Host leolee HostName github.com User git IdentityFile ~/.ssh/dt_id_rsa 要理解以上配置文件的含义并不难，我们可以对比看下两个项目的SSH URLs： git@github.com:debugtalk/DroidMeter.git git@github.com:DTSZ/Store_Android.git 其中，git是本地ssh-agent与GitHub服务器建立SSH连接采用的用户名（即User），github.com是GitHub服务器的主机（即HostName）。 可以看出，如果采用原始的SSH URLs，由于User和HostName都相同，本地计算机并不知道应该采用哪个SSH-key去建立连接。 因此，通过创建~/.ssh/config文件，在Host中进行区分，然后经过CNAME映射到HostName，然后分别指向不同的SSH-key，即IdentityFile。由于HostName才是真正指定GitHub服务器主机的字段，因此这么配置不会对本地ssh-agent连接GitHub主机产生影响，再加上Host别名指向了不同的SSH-key，从而实现了对两个GitHub账号的分离。 配置完毕后，两个GitHub账号就可以通过Host别名来进行区分了。后续再与GitHub服务器进行通信时，就可以采用Host别名代替原先的github.com。例如，测试本地ssh-agent与GitHub服务器的连通性时，可采用如下方式： ➜ ssh -T git@debugtalk Hi debugtalk! You have successfully authenticated, but GitHub does not provide shell access. ➜ ssh -T git@leolee Hi leolee! You have successfully authenticated, but GitHub does not provide shell access. 可以看出，此时两个账号各司其职，不会再出现混淆的情况。 不过，我们还遗漏了很重要的一点。在本地代码仓库中执行push/pull/fetch等操作的时候，命令中并不会包含Host信息，那系统怎么知道我们要采用哪个GitHub账号进行操作呢？ 答案是，系统还是没法判断，需要我们进行配置指定。 显然，不同的仓库可能对应着不同的GitHub账号，因此这个配置不能配置成全局的，而只能在各个项目中分别进行配置，即repo/.git/config文件。 配置的方式如下： 在debugtalk/DroidMeter仓库中： ➜ git remote add origin git@debugtalk:debugtalk/DroidMeter.git 在DebugTalk/MobileStore.git仓库中： ➜ git remote add origin git@leolee:DebugTalk/MobileStore.git 配置的原理也很容易理解，就是将仓库的Host更换为之前设置的别名。添加完毕后，后续再在两个仓库中执行任何git操作时，系统就可以选择正确的SSH-key与GitHub服务器进行交互了。 ","date":"2016-06-15","heading":"基于","objectID":"/post/head-first-git-authority-verification/:3:2","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#基于ssh协议实现多账号共存"},{"categories":["Development"],"collections":null,"content":"基于HTTPS协议实现多账号共存 再来看下如何采用HTTPS URLs实现我们的需求。 有了前面的经验，我们的思路就清晰了许多。采用HTTPS URLs的方式进行Git权限校验后，系统会将GitHub账号密码存储到Keychain中（Mac系统），或者存储到~/.git-credentials文件中（Git credential helper）。 不管是存储到哪里，我们面临的问题都是相同的，即如何在代码仓库中区分采用哪个GitHub账号。 配置的方式其实也很简单： 在debugtalk/DroidMeter仓库中： ➜ git remote add origin https://debugtalk@github.com/debugtalk/DroidMeter.git 在DebugTalk/MobileStore.git仓库中： ➜ git remote add origin https://leolee@github.com/DebugTalk/MobileStore.git 配置的原理也很容易理解，将GitHub用户名添加到仓库的Git地址中，这样在执行git命令的时候，系统就会采用指定的GitHub用户名去Keychain或~/.git-credentials中寻找对应的认证信息，账号使用错乱的问题也就不复存在了。 Done! ","date":"2016-06-15","heading":"基于","objectID":"/post/head-first-git-authority-verification/:3:3","tags":["SSH","GitHub","HTTP(S)","Git"],"title":"深入浅出 Git 权限校验","uri":"/post/head-first-git-authority-verification/#基于https协议实现多账号共存"},{"categories":["Development"],"collections":null,"content":"背景描述 众所周知，在GitHub中，每个仓库都有两个地址，分别基于HTTPS协议和SSH协议，两个协议对应的URL地址（repository_url）形式如下所示： # HTTPS https://github.com/XY/MobileStore.git # SSH git@github.com:XY/MobileStore.git 正常情况下，只要在本地正确地配置好了git账号，采用这两个地址中的任意一个，都可以通过git clone repository_url获取代码。 但最近我在Macbook Air中clone公司托管在GitHub私有库中的代码时，发现无法通过HTTPS协议的地址clone代码，始终提示remote: Repository not found.的错误。 ➜ git clone https://github.com/XY/MobileStore.git Cloning into 'MobileStore'... remote: Repository not found. fatal: repository 'https://github.com/XY/MobileStore.git/' not found 首先，这个代码仓库是确实存在的，而且地址肯定也是没有问题的，通过URL地址也能在浏览器中访问到对应的GitHub仓库页面。 其次，在本地对git的配置也是没有问题的，通过SSH协议的地址是可以正常clone代码的。 ➜ git clone git@github.com:XY/MobileStore.git Cloning into 'MobileStore'... Warning: Permanently added the RSA host key for IP address '192.30.252.131' to the list of known hosts. remote: Counting objects: 355, done. remote: Compressing objects: 100% (3/3), done. 并且，如果在HTTPS协议的URL地址中加上GitHub账号，也是可以正常clone代码的。 ➜ git clone https://leolee@github.com/XY/MobileStore.git Cloning into 'MobileStore'... remote: Counting objects: 355, done. remote: Compressing objects: 100% (3/3), done. 更奇怪的是，在我的另一台Mac Mini中，采用同样的账号配置，两种协议的URL地址却都能正常clone代码，仔细地对比了两台电脑的git配置，都是一样的。 ➜ cat ~/.git-credentials https://leolee:340d247cxxxxxxxxf39556e38fe2b0baxxxxxxxx@github.com ➜ ➜ cat ~/.gitconfig [credential] helper = store 那问题出在哪儿呢？ ","date":"2016-06-14","heading":"背景描述","objectID":"/post/trap-in-GitHub-authority-verification/:1:0","tags":["SSH","Git","GitHub","HTTP(S)"],"title":"GitHub 权限校验失败给我的启发","uri":"/post/trap-in-GitHub-authority-verification/#背景描述"},{"categories":["Development"],"collections":null,"content":"定位分析 通过Google得知，产生remote: Repository not found.报错的原因主要有两个，一是仓库地址错误，二是权限校验不通过。显然，第一个原因可以直接排除，在Macbook Air中出现该问题应该就是账号权限校验失败造成的。 对背景描述中的现象进行整理，重点关注两个疑点： 通过HTTPS协议的URL地址进行git clone时，系统没有提示让输入用户名密码，就直接返回权限校验失败的异常； 在HTTPS协议的URL地址中加上GitHub用户名，就可以正常clone，而且，系统也没有提示输入密码。 这说明，在系统中的某个地方，应该是保存了GitHub账号密码的，所以在未指定账号的情况下，git clone时系统就不再要求用户输入账号密码，而是直接读取那个保存好的账号信息；但是，那个保存的GitHub账号密码应该是存在问题的，这就造成采用那个账号信息去GitHub校验时无法通过，从而返回异常报错。 基于以上推测，寻找问题根源的当务之急是找到保存GitHub账号密码的地方。 通过查看Git官方文档，存储Git用户信息的地方有三个： /etc/gitconfig：存储当前系统所有用户的git配置信息； ~/.gitconfig或~/.config/git/config：存储当前用户的git配置信息； 仓库的Git目录中的config文件（即repo/.git/config）：存储当前仓库的git配置信息。 这三个配置项的优先级从上往下依次上升，即repo/.git/config会覆盖~/.gitconfig中的配置，~/.gitconfig会覆盖/etc/gitconfig中的配置。 回到当前问题，由于还没有进入到具体的Git仓库，因此repo/.git/config可直接排除；然后是查看当前用户的git配置，在当前用户HOME目录下没有~/.config/git/config文件，只有~/.gitconfig，不过在~/.gitconfig中并没有账号信息；再去查看系统级的git配置信息，即/etc/gitconfig文件，但发现当前系统中并没有该文件。 找遍了Git用户信息可能存储的地方，都没有看到账号配置信息，那还可能存储在哪儿呢？ 这时基本上是毫无思路了，只能靠各种胡乱猜测，甚至尝试采用Wireshark分别在两台Mac上对git clone的过程进行抓包，对比通讯数据的差异，但都没有找到答案。 最后，无意中想到了Mac的Keychain机制。在Mac OSX的Keychain中，可以保存用户的账号密码等credentials，那git账号会不会也保存到Keychain中了呢？ 在Macbook Air中打开Keychain Access应用软件，搜索github，果然发现存在记录。 而且，github.com这一项还存在两条记录。一条是我的个人账号debugtalk，另一条是公司的工作账号leolee。 至此，真相大白！！！ 在我的Macbook Air中，Keychain Access中保存了我的GitHub个人账号（debugtalk），该账号是没有权限访问公司私有仓库的。但是在Terminal中执行git clone命令时，系统优先读取了我的个人账号，并用该账号向GitHub发起校验请求，从而造成读取公司私有仓库时权限校验失败。然而，在HTTPS协议的URL地址中加上GitHub工作账号（leolee）时，由于此时指定了账号名称，因此在Keychain中读取账号信息时就可以找到对应账号（包含密码），并且在无需输入密码的情况下就能成功通过GitHub的权限校验，进而成功clone得到代码。 原因弄清楚之后，解决方式就很简单了，在Keychain中删除个人账号，然后就正常了。 ","date":"2016-06-14","heading":"定位分析","objectID":"/post/trap-in-GitHub-authority-verification/:2:0","tags":["SSH","Git","GitHub","HTTP(S)"],"title":"GitHub 权限校验失败给我的启发","uri":"/post/trap-in-GitHub-authority-verification/#定位分析"},{"categories":["Development"],"collections":null,"content":"总结回顾 但是，问题真的解决了么？ 并没有！ 简单粗暴地在Keychain中将个人GitHub账号删除了，虽然再次访问公司代码仓库时正常了，那我要再访问个人仓库时该怎么办呢？ 貌似并没有清晰的思路。虽然网上也有不少操作指导教程，但是对于操作背后的原理，还是有很多不清晰的地方。 再回到前面的背景描述，以及定位问题的整个过程，不由地悲从中来。使用GitHub好歹也有好几年了，但是连最基本的概念都还一头雾水，所以遇到问题后只能靠瞎猜，东碰西撞，最后瞎猫碰到死耗子。 GitHub的HTTPS协议和SSH协议，这本来就对应着两套完全独立的权限校验方式，而我在HTTPS协议不正常的情况下还去查看SSH协议，这本来就实属多余。 借助这次“掉坑”的经历，我对Git权限校验的两种方式重头进行了梳理，并单独写了一篇博客，《深入浅出Git权限校验》，虽然花了些时间，但总算是扫清了萦绕多年的迷雾，感觉倍儿爽！ 如果你也对Git的权限校验没有清晰的了解，遇到权限校验出错时只能“换一种方法试试”，也不知道怎么让一台计算机同时支持多个GitHub账号，那么也推荐看下那篇博客。 在微信公众号debugtalk中输入Git权限校验，获取《深入浅出Git权限校验》。 ","date":"2016-06-14","heading":"总结回顾","objectID":"/post/trap-in-GitHub-authority-verification/:3:0","tags":["SSH","Git","GitHub","HTTP(S)"],"title":"GitHub 权限校验失败给我的启发","uri":"/post/trap-in-GitHub-authority-verification/#总结回顾"},{"categories":["Testing"],"collections":null,"content":"这篇文章来说说促销活动页面测试的那些事儿。 ","date":"2016-06-09","heading":"","objectID":"/post/promotion-page-test/:0:0","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#"},{"categories":["Testing"],"collections":null,"content":"什么是促销活动页面？ 通常电商平台在节假日会做一些促销活动，而活动的宣传方式，主要会采用H5静态页面的形式，也就是本文中要讲的促销活动页面。 这些活动页面的特点是元素构成很简单，在页面中只包含一些促销商品的图片及其价格，而且价格往往都是写死在页面中，不会涉及到从数据库中读取，完完全全的静态页面；当然，促销活动页面的目的是将用户流量导向电商平台，因此在页面的图片或购买按钮背后会配上URL链接，用户点击链接后会跳转至电商平台对应的购买页面，活动页面也就完成了使命，这往往就是活动页面的全部内容。 这么看来，促销活动页面跟街上发的传单非常相似，只是传单是纸质的，而活动页面是网页的罢了。 ","date":"2016-06-09","heading":"什么是促销活动页面？","objectID":"/post/promotion-page-test/:1:0","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#什么是促销活动页面"},{"categories":["Testing"],"collections":null,"content":"促销活动页面为什么要这么做呢？ 四方面原因。 第一，促销活动页面的目的性很强，就是为了主推几款特价商品，因此，商品和价格完全写死也不会有任何问题。 第二，促销活动页面的时效性很强，为促销活动而生，生命周期就那么几天，促销活动结束后这个活动页面也就作废了，因此也不用考虑复用的问题。 第三，从投入的人力成本和工作量考虑，促销活动页面的制作只需要设计师和前端工程师就能完成，无需后台开发人员进行配合，也无需对当前的电商平台进行任何功能调整。 第四，电商平台在做促销活动时，往往会通过各种渠道进行推广，因此活动页面的访问流量是非常巨大的；而采用静态页面的形式，不仅可以极大地提高页面加载速度（图片等静态资源可以通过CDN存储），而且可以极大地减轻流量对电商平台服务器端的压力（用户在浏览活动页面的时候并不会与电商平台进行任何交互）。至于点击链接进入电商平台的流量嘛，毕竟转换率总是存在的，转换以后的流量会小很多，而这部分流量才是真正有效的。 ","date":"2016-06-09","heading":"促销活动页面为什么要这么做呢？","objectID":"/post/promotion-page-test/:2:0","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#促销活动页面为什么要这么做呢"},{"categories":["Testing"],"collections":null,"content":"如何对活动页面进行测试 那么，就这么简单的一个静态页面，还需要对它进行测试么？ 咋一看，貌似还真没有可测试的内容，因为页面中就找不到一个真正意义上的功能点。 然而，历史经验表明，在软件工程中无论多简单的功能，都是有可能出现bug的。针对活动页面的形式，我们需要重点关注如下几点。 ","date":"2016-06-09","heading":"如何对活动页面进行测试","objectID":"/post/promotion-page-test/:3:0","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#如何对活动页面进行测试"},{"categories":["Testing"],"collections":null,"content":"1、商品信息一致性 促销活动页面中的商品信息都是由前端工程师写死的，而非从电商平台的数据库中读取后进行展示。因此，在实际操作中，活动页面上的信息，特别是价格数据，有可能和电商平台中的商品不一致，这个是我们在测试的时候需要重点关注的。 测试方式很简单，依次点击各个商品的链接，验证跳转的商品页面是否与促销活动页面中的商品信息一致即可。 ","date":"2016-06-09","heading":"1、商品信息一致性","objectID":"/post/promotion-page-test/:3:1","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#1商品信息一致性"},{"categories":["Testing"],"collections":null,"content":"2、页面跳转行为一致性 点击链接跳转页面，应该算是活动页面中唯一具有动作行为的功能了。而对于链接跳转而言，会存在两种形式，一种是在当前页面中加载商品页面，另一种是在新窗口中加载商品页面。 本来两种形式区别并不大，采用哪种形式都可以，但是从追求完美的角度出发，我们还是需要保证活动页面中的所有链接的跳转行为都是相同的。 因此，在测试时，逐一点击所有的链接，验证所有链接跳转行为是否一致即可。 ","date":"2016-06-09","heading":"2、页面跳转行为一致性","objectID":"/post/promotion-page-test/:3:2","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#2页面跳转行为一致性"},{"categories":["Testing"],"collections":null,"content":"3、页面兼容性 作为促销活动页面，虽然商品信息是最核心的内容，但是为了能吸引尽量多的用户，页面的设计往往花了很多功夫，力求精美。然而，用户访问活动页面的设备和浏览器五花八门，有可能是采用PC浏览器，有可能是采用iPhone设备，也可能是采用各种品牌和型号的Android设备，精心设计的活动页面在某些设备或浏览器上很有可能就出现样式混乱的情况。因此，促销活动页面的浏览器兼容性也是z在测试时需要重点关注的。 差异在哪儿呢？差异就是不同的浏览器内核，不同的设备操作系统，不同的屏幕分辨率。 当然，我们也不可能在所有类型的设备和浏览器上都测一遍，但是主流的浏览器内核和移动设备还是要尽量覆盖的。 推荐的测试方式如下： 针对不同浏览器内核的测试，在电脑上采用Chrome、IE、Firefox、Safari浏览器分别加载活动页面； 针对不同移动设备类型的测试，在iPhone和Android设备上加载活动页面，iPhone和Android设备均只选一款即可； 针对不同屏幕分辨率的测试，可以在PC浏览器中打开开发者工具，里面可以模拟不同分辨率的设备加载页面，前面提到的浏览器基本都支持这个功能。 ","date":"2016-06-09","heading":"3、页面兼容性","objectID":"/post/promotion-page-test/:3:3","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#3页面兼容性"},{"categories":["Testing"],"collections":null,"content":"4、文案准确性 对于促销活动而言，吸引眼球的文案肯定是必不可少的，这也是在测试时需要重点关注的。 对于文案方面的测试，可以重点从以下几个方面进行考核。 首先，由于文案通常是由需求方提供，而活动页面是由设计师或前端工程师制作，因此有可能在制作页面的过程中出现了偏差，这个需要测试时仔细核对。 另外，文案中出现错别字的情况也比较多，这个也需要格外注意，尽量杜绝这样的低级错误。 还有一种情况，活动促销页面是面向某个国家的用户，语言可能是非汉语也非英语，这个时候周围谁也看不懂文案里面到底写的是啥。这个时候，只能请需求方再三进行确认，文案描述正确是一方面，另一方面就是需要考虑到目标国家的地域文化，不要出现产生歧义和误解的情况。 ","date":"2016-06-09","heading":"4、文案准确性","objectID":"/post/promotion-page-test/:3:4","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#4文案准确性"},{"categories":["Testing"],"collections":null,"content":"总结 以上内容便是从促销活动页面进行展开，联想到的关于测试的一些内容。 可以看出，即使是再简单的东西，也是需要进行测试的，而且测试需要考虑的因素也非常多。另一方面，这也说明测试并不应该仅仅局限于技术层面，只有当我们站在业务和质量保障的角度，才会有更开阔的视野。 ","date":"2016-06-09","heading":"总结","objectID":"/post/promotion-page-test/:4:0","tags":["功能测试","H5","测试设计"],"title":"关于促销活动页面测试的那些事儿","uri":"/post/promotion-page-test/#总结"},{"categories":["Development"],"collections":null,"content":"0、Confluence简介 Confluence是一个企业级的Wiki软件，可用于在企业、部门、团队内部进行信息共享和协同编辑。 ","date":"2016-06-07","heading":"0、Confluence简介","objectID":"/post/confluence-simple-tutorial/:1:0","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#0confluence简介"},{"categories":["Development"],"collections":null,"content":"1、基础概念 Confluence的使用并不复杂，只需掌握如下几个基础概念。 ","date":"2016-06-07","heading":"1、基础概念","objectID":"/post/confluence-simple-tutorial/:2:0","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#1基础概念"},{"categories":["Development"],"collections":null,"content":"空间（Space） 空间是Confluence系统中的一个区域，用于存储wiki页面，并可实现对空间中的所有文档进行统一的权限管理。 通常，我们可以针对每个项目单独创建一个空间，然后将与该项目相关的文档信息放置到该空间中，并只对项目成员开设访问/编辑权限。 除了项目空间，每个成员都有一个个人空间。平时成员可以将工作总结或笔记等文档放置到自己的空间中；对于对团队有帮助的文档，就可以将文档移动至团队项目空间中。 ","date":"2016-06-07","heading":"空间（Space）","objectID":"/post/confluence-simple-tutorial/:2:1","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#空间space"},{"categories":["Development"],"collections":null,"content":"Dashboard Dashboard是Confluence系统的主页，在Dashboard界面中包含了Confluence站点中的所有空间列表，以及最近更新内容的列表。 ","date":"2016-06-07","heading":"Dashboard","objectID":"/post/confluence-simple-tutorial/:2:2","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#dashboard"},{"categories":["Development"],"collections":null,"content":"页面（Page） 在Confluence系统中，页面是存储和共享信息的主要方式。页面可以互相链接、连接、组织和访问，并以树状结构进行组织，放置于空间之中。 页面遵循所见即所得的编辑方式，操作上简单易用。更强大的地方在于，页面支持大量的内容展现形式，除了富文本文档外，还包括图表、视频、附件（可预览）、流程图、公式等等；如果还不够，还可以通过海量的第三方插件进行扩展。 在页面中可以通过@其它成员，通知相关成员查看文档。文档保存成功后，被@的成员就会收到邮件，并可根据邮件中的链接访问到该文档，然后进行评论或者协同编辑。 ","date":"2016-06-07","heading":"页面（Page）","objectID":"/post/confluence-simple-tutorial/:2:3","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#页面page"},{"categories":["Development"],"collections":null,"content":"模板（template） 创建页面时除了采用空白文档，也可以选择模板。模板是在空白文档的基础上，根据特定需求添加了一些文档要素，可辅助用户更好更快地创建文档。 Confluence内置了大量的模板，可辅助用于项目工作的各个环节，包括产品需求、会议记录、决策记录、指导手册（How-to）、回顾记录、工作计划、任务报告等等。并且由于Confluence和JIRA是同一家公司的产品，在Confluence中可以和JIRA进行无缝衔接，实现对产品质量实现更好的展现。 如果对Confluence自带的模板不满意，还可以对模板进行调整，或者根据自己的需求创建其它类型的模板。 ","date":"2016-06-07","heading":"模板（template）","objectID":"/post/confluence-simple-tutorial/:2:4","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#模板template"},{"categories":["Development"],"collections":null,"content":"权限（Permission） 在安全性方面，Confluence具有完善和精细的权限控制，可以很好地控制用户在Wiki中创建、编辑内容和添加注释。 权限控制分3个维度，分别是团队（Group），个人（Individual Users），匿名用户（Anonymous）。 使用团队级的权限控制时，需要在Confluence服务器中对公司员工进行分组，好处在于配置比较方便，只需要对整个团队进行统一的权限配置。 但在实际项目中，经常会存在同一个项目包含多个跨团队成员的情况，这个时候就不适合采用团队权限配置方式，只能采用逐个添加成员的方式，并对各个成员分别配置权限。 另外一种情况，就是对于未登录的用户，以及项目成员以外的用户，可以开设部分权限，例如只读（View）。 ","date":"2016-06-07","heading":"权限（Permission）","objectID":"/post/confluence-simple-tutorial/:2:5","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#权限permission"},{"categories":["Development"],"collections":null,"content":"2、常见操作 熟悉了Confluence的基础概念，基本上就可以摸索着对Confluence进行上手了。不过，为了减少摸索时间，在这里我再将Confluence中的常用操作进行说明。 ","date":"2016-06-07","heading":"2、常见操作","objectID":"/post/confluence-simple-tutorial/:3:0","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#2常见操作"},{"categories":["Development"],"collections":null,"content":"创建空间（Space） 新建一个项目时，首先要做的就是创建一个空间，并进行初始化配置。 创建空间的方式很简单，可以从顶部菜单进行创建：【Spaces】-\u0026gt;【Create Space】；也可以从Dashboard页面的Spaces页面中进行创建。 进入创建空间页面后，需要选择空间类型。这个需要根据空间的用途进行选择，对于团队协作的空间，推荐选择“Team Space”，如果实在不知道选择什么类型，选择“Blank Space”也是可以的。 然后是填写空间的基本信息。所有类型的空间都有两个必填字段，Space name和Space key。Space key可以理解为空间的ID，不同空间的Space key不能重复，但Space name是可以重复的。 另外，对于“Team Space”类型的空间，多了一个“Team members”字段，用于添加空间的成员。成员的名称是其公司邮箱的前缀。 需要说明的是，空间创建完成后，Space key字段是不能修改的，其它字段以及团队成员都可以进行修改。 ","date":"2016-06-07","heading":"创建空间（Space）","objectID":"/post/confluence-simple-tutorial/:3:1","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#创建空间space"},{"categories":["Development"],"collections":null,"content":"配置空间权限 创建空间后，根据项目需要，可以给空间设置权限。只有空间的管理员才能对空间权限进行配置。 操作方式如下：首先进入空间的页面，在空间左下角中，【Space tools】-\u0026gt;【Permissions】，进入权限管理页面。 Confluence的权限控制比较完善，可以根据团队规范进行较为精细粒度的设置。 ","date":"2016-06-07","heading":"配置空间权限","objectID":"/post/confluence-simple-tutorial/:3:2","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#配置空间权限"},{"categories":["Development"],"collections":null,"content":"添加文档 在Confluence中文件以树状结构进行组织。 推荐的创建方式是，先进入父目录的页面，然后再点击【Create】进行创建。在创建文档页面中，可以看到新建文档的“Parent”，表示新文档创建后将位于“Parent”文件的下一个层级中。 在新建文档时，需要选择文档模板。这个就根据文档的实际类型或用途进行选择即可，如果觉得都不合适，就选择“Blank page”。 ","date":"2016-06-07","heading":"添加文档","objectID":"/post/confluence-simple-tutorial/:3:3","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#添加文档"},{"categories":["Development"],"collections":null,"content":"编写文档 在编写文档时，页面遵循所见即所得的编辑方式，基本上跟在MS Word中的操作类似。 Confluence也集成了许多编辑工具，可以很方便地插入图表、链接、附件、代办列表等等。如果还不满足需求，可以点击【Insert】-\u0026gt;【Other macros】，查找更多的扩展插件。 例如，Confluence默认是不支持Markdown编辑模式的，如果想采用Markdown来编写文档，就可以通过上述方式到插件市场寻找Markdown的插件。 不过根据实践发现，当前Confluence的Markdown插件支持的还不够好，使用体验上不尽如人意。比较推荐的做法，还是在单独的Markdown编辑器上采用markdown语法进行编辑，编辑完成后进行预览，然后将渲染后的文档内容复制粘贴到Confluence中。 ","date":"2016-06-07","heading":"编写文档","objectID":"/post/confluence-simple-tutorial/:3:4","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#编写文档"},{"categories":["Development"],"collections":null,"content":"移动文档 很多时候我们需要调整目录结构，这就涉及到需要将文档移动到别的目录层级下。 操作方式如下：先进入到待移动的文档页面中，点击页面右上角的【\u0026hellip;】-\u0026gt;【Move】； 然后选择新的目录即可。 ","date":"2016-06-07","heading":"移动文档","objectID":"/post/confluence-simple-tutorial/:3:5","tags":["Confluence"],"title":"敏捷团队协作：Confluence简易教程","uri":"/post/confluence-simple-tutorial/#移动文档"},{"categories":["OpenSource"],"collections":null,"content":"通过前面三篇文章，我们已经将iOS自动化功能测试的开发环境全部准备就绪，也学习了iOS UI控件交互操作的一般性方法，接下来，就可以开始编写自动化测试脚本了。 在本文中，我将在M项目中挑选一个功能点，对其编写自动化测试脚本，演示编写自动化测试用例的整个流程。 ","date":"2016-05-30","heading":"","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:0:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#"},{"categories":["OpenSource"],"collections":null,"content":"语言的选择：Python or Ruby？ 之前介绍Appium的时候也提到，Appium采用Client-Server的架构设计，并采用标准的HTTP通信协议；Client端基本上可以采用任意主流编程语言编写测试用例，包括但不限于C#、Ruby、Objective-C、Java、node.js、Python、PHP。 因此，在开始编写自动化测试脚本之前，首先需要选定一门编程语言。 这个选择因人而异，并不涉及到太大的优劣之分，基本上在上述几门语言中选择自己最熟悉的就好。 但对我而言，选择却没有那么干脆，前段时间在Python和Ruby之间犹豫了很久，经过艰难的决定，最终选择了Ruby。为什么不考虑Java？不熟是一方面，另一方面是觉得采用编译型语言写测试用例总感觉太重，这活儿还是解释型语言来做更合适些。 其实，最开始本来是想选择Python的，因为Python在软件测试领域比Ruby应用得更广，至少在国内，不管是公司团队，还是测试人员群体，使用Python的会比使用Ruby的多很多。 那为什么还是选择了Ruby呢？ 我主要是基于如下几点考虑的： 从Appium的官方文档来看，Appium对Ruby的支持力度，或者说是偏爱程度，貌似会更大些；在Appium Client Libraries列表中将Ruby排在第一位就不说了，在Appium Tutorials中示例语言就只采用了Ruby和Java进行描述。 Appium_Console是采用Ruby编写的，在Console中执行的命令基本上可直接用在Ruby脚本中。 后续打算引入BDD（行为驱动开发）的测试模式，而不管是cucumber还是RSpec，都是采用Ruby开发的。 当然，还有最最重要的一点，身处于珠江三角洲最大的Ruby阵营，周围Ruby大牛云集，公司的好多业务系统也都是采用Rails作为后台语言，完全没理由不选择Ruby啊。 ","date":"2016-05-30","heading":"语言的选择：Python or Ruby？","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:1:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#语言的选择python-or-ruby"},{"categories":["OpenSource"],"collections":null,"content":"第一个测试用例：系统登录 在测试领域中，系统登录这个功能点的地位，堪比软件开发中的Hello World，因此第一个测试用例就毫无悬念地选择系统登录了。 在编写自动化测试脚本之前，我们首先需要清楚用例执行的路径，路径中操作涉及到的控件，以及被操作控件的属性信息。 对于本次演示的APP来说，登录时需要先进入【My Account】页面，然后点击【Login】进入登录页面，接着在登录页面中输入账号密码后再点击【Login】按钮，完成登录操作。 确定了操作路径以后，就可以在Appium Ruby Console中依次操作一遍，目的是确保代码能正确地对控件进行操作。 第一步要点击【My Account】按钮，因此先查看下Button控件属性。要是不确定目标控件的类型，可以直接执行page命令，然后在返回结果中根据控件名称进行查找。 [1] pry(main)\u003e page :button ...（略） UIAButton name, label: My Account id: My Account =\u003e My Account nil 通过返回结果，可以看到【My Account】按钮的name、label属性就是“My Account”，因此可以通过button_exact('My Account')方式来定位控件，并进行点击操作。 [2] pry(main)\u003e button_exact('My Account').click nil 执行命令后，观察iOS模拟器中APP的响应情况，看是否成功进入“My Account”页面。 第二步也是类似的，操作代码如下： [3] pry(main)\u003e button_exact('Login').click nil 进入到登录页面后，再次查看页面中的控件信息： [4] pry(main)\u003e page ...（略） UIATextField value: Email Address id: Email Address =\u003e Email Address UIASecureTextField value: Password (6-16 characters) id: Password (6-16 characters) =\u003e Password (6-16 characters) UIAButton name, label: Login id: Log In =\u003e Login 登录 =\u003e Login ...（略） 第三步需要填写账号密码，账号密码的控件属性分别是UIATextField和UIASecureTextField。由于这两个控件的类型在登录页面都是唯一的，因此可以采用控件的类型来进行定位，然后进行输入操作，代码如下： [5] pry(main)\u003e tag('UIATextField').type 'leo.lee@debugtalk.com' \"\" [6] pry(main)\u003e tag('UIASecureTextField').type '123456' \"\" 执行完输入命令后，在iOS模拟器中可以看到账号密码输入框都成功输入了内容。 最后第四步点击【Login】按钮，操作上和第二步完全一致。 [7] pry(main)\u003e button_exact('Login').click nil 执行完以上四个步骤后，在iOS模拟器中看到成功完成账号登录操作，这说明我们的执行命令没有问题，可以用于编写自动化测试代码。整合起来，测试脚本就是下面这样。 button_exact('My Account').click button_exact('Login').click tag('UIATextField').type 'leo.lee@debugtalk.com' tag('UIASecureTextField').type '12345678' button_exact('Login').click 将以上脚本保存为login.rb文件。 但当我们直接运行login.rb文件时，并不能运行成功。原因很简单，脚本中的button_exact、tag这些方法并没有定义，我们在文件中也没有引入相关库文件。 在上一篇文章中有介绍过，通过arc启动虚拟机时，会从appium.txt中读取虚拟机的配置信息。类似的，我们在脚本中执行自动化测试时，也会加载虚拟机，因此同样需要在脚本中指定虚拟机的配置信息，并初始化Appium Driver的实例。 初始化代码可以通过Appium Inspector生成，基本上为固定模式，我们暂时不用深究。 添加初始化部分的代码后，测试脚本如下所示。 require 'rubygems' require 'appium_lib' capabilities = { 'appium-version' =\u003e '1.0', 'platformName' =\u003e 'iOS', 'platformVersion' =\u003e '9.3', } Appium::Driver.new(caps: capabilities).start_driver Appium.promote_appium_methods Object # testcase: login button_exact('My Account').click button_exact('Login').click tag('UIATextField').type 'leo.lee@debugtalk.com' tag('UIASecureTextField').type '123456' button_exact('Login').click driver_quit ","date":"2016-05-30","heading":"第一个测试用例：系统登录","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:2:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#第一个测试用例系统登录"},{"categories":["OpenSource"],"collections":null,"content":"优化测试脚本：加入等待机制 如上测试脚本编写好后，在Terminal中运行ruby login.rb，就可以执行脚本了。 运行命令后，会看到iOS虚拟机成功启动，接着App成功进行加载，然后自动按照前面设计的路径，执行系统登录流程。 但是，在实际操作过程中，发现有时候运行脚本时会出现找不到控件的异常，异常信息如下所示： ➜ ruby login.rb /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/common/helper.rb:218:in `_no_such_element': An element could not be located on the page using the given search parameters. (Selenium::WebDriver::Error::NoSuchElementError) from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/ios/helper.rb:578:in `ele_by_json' from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/ios/helper.rb:367:in `ele_by_json_visible_exact' from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/ios/element/button.rb:41:in `button_exact' from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/driver.rb:226:in `rescue in block (4 levels) in promote_appium_methods' from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/driver.rb:217:in `block (4 levels) in promote_appium_methods' from login.rb:28:in `' 更奇怪的是，这个异常并不是稳定出现的，有时候能正常运行整个用例，但有时在某个步骤就会抛出找不到控件的异常。这是什么原因呢？为什么在Appium Ruby Console中单步操作时就不会出现这个问题，但是在执行脚本的时候就会偶尔出现异常呢？ 原来，在我们之前的脚本中，两条命令之间并没有间隔时间，有可能前一条命令执行完后，模拟器中的应用还没有完成下一个页面的加载，下一条命令就又开始查找控件，然后由于找不到控件就抛出异常了。 这也是为什么我们在Appium Ruby Console中没有出现这样的问题。因为手工输入命令多少会有一些耗时，输入两条命令的间隔时间足够虚拟机中的APP完成下一页面的加载了。 那针对这种情况，我们要怎么修改测试脚本呢？难道要在每一行代码之间都添加休眠（sleep）函数么？ 也不用这么麻烦，针对这类情况，ruby_lib实现了wait机制。将执行命令放入到wait{}中后，执行脚本时就会等待该命令执行完成后再去执行下一条命令。当然，等待也不是无休止的，如果等待30秒后还是没有执行完，仍然会抛出异常。 登录流程的测试脚本修改后如下所示（已省略初始化部分的代码）： wait { button_exact('My Account').click } wait { button_exact('Login').click } wait { tag('UIATextField').type 'leo.lee@debugtalk.com' } wait { tag('UIASecureTextField').type '123456' } wait { button_exact('Login').click } 对脚本添加wait机制后，之前出现的找不到控件的异常就不再出现了。 ","date":"2016-05-30","heading":"优化测试脚本：加入等待机制","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:3:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#优化测试脚本加入等待机制"},{"categories":["OpenSource"],"collections":null,"content":"优化测试脚本：加入结果检测机制 然而，现在脚本仍然不够完善。 我们在Appium Ruby Console中手工执行命令后，都是由人工肉眼确认虚拟机中APP是否成功进入下一个页面，或者返回结果是否正确。 但是在执行自动化测试脚本时，我们不可能一直去盯着模拟器。因此，我们还需要在脚本中加入结果检测机制，通过脚本实现结果正确性的检测。 具体怎么做呢？ 原理也很简单，只需要在下一个页面中，寻找一个在前一个页面中没有的控件。 例如，由A页面跳转至B页面，在B页面中会存在“Welcome”的文本控件，但是在A页面中是没有这个“Welcome”文本控件的；那么，我们就可以在脚本中的跳转页面语句之后，加入一条检测“Welcome”文本控件的语句；后续在执行测试脚本的时候，如果页面跳转失败，就会因为找不到控件而抛出异常，我们也能通过这个异常知道测试执行失败了。 当然，对下一页面中的控件进行检测时同样需要加入等待机制的。 登录流程的测试脚本修改后如下所示（已省略初始化部分的代码）： wait { button_exact('My Account').click } wait { text_exact 'System Settings' } wait { button_exact('Login').click } wait { button_exact 'Forget password?' } wait { tag('UIATextField').type 'leo.lee@debugtalk.com' } wait { tag('UIASecureTextField').type '12345678' } wait { button_exact('Login').click } wait { text_exact 'My Message' } 至此，系统登录流程的自动化测试脚本我们就编写完成了。 ","date":"2016-05-30","heading":"优化测试脚本：加入结果检测机制","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:4:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#优化测试脚本加入结果检测机制"},{"categories":["OpenSource"],"collections":null,"content":"To be continued \u0026hellip; 在本文中，我们通过系统登录这一典型功能点，演示了编写自动化测试用例的整个流程。 在下一篇文章中，我们还会对自动化测试脚本的结构进行进一步优化，并实现测试代码工程化。 ","date":"2016-05-30","heading":"To be continued \u0026hellip;","objectID":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/:5:0","tags":["Appium","iOS","Python","Ruby","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（3）：编写iOS自动化测试脚本","uri":"/post/build-app-automated-test-platform-from-0-to-1-write-iOS-testcase-scripts/#to-be-continued-"},{"categories":["OpenSource"],"collections":null,"content":"写在前面 前两天微信突然发来一条系统消息，提示DebugTalk可以开通原创标识了（同时也有了评论功能），虽然一直在期待，但没想到来得这么快，着实是个不小的惊喜。 另外，最近在公众号后台也收到好几个朋友的信息，有的是询问某某部分什么时候能发布，有的是希望能加快更新速度。说实话，收到这样的信息虽然会有压力，但真的挺开心的，因为这说明DebugTalk至少能给一部分人带去价值，这说明这件事本身还是值得坚持去做的。 不过，在更新频率这件事儿上，的确是要跟大家说抱歉了。因为DebugTalk发布的内容全都是原创，主题基本上都是来源于我日常测试工作的经验积累，或者我近期学习一些测试技术的收获总结，这也意味着，我写的东西很多时候并不是自己完全熟悉的（完全掌握的东西也没有足够的动力专门花时间去写）。 就拿最近连载的《从0到1搭建移动App功能自动化测试平台》系列来说，由于我也是边探索边总结，因此中途难免会遇到一些意想不到的坑，造成额外的耗时，而且为了保证文章能尽量通俗易通，我也需要对涉及到的内容充分进行理解，并且经过大量实践进行验证，然后才能站在半个初学者、半个过来人的角度，重新整理思路，最后以尽可能流畅的思路将主题内容讲解清楚。 基于这些原因，DebugTalk要做到每日更新是很难了，但是保证每周发布1~2篇还是可以的，希望大家能理解。 ","date":"2016-05-29","heading":"写在前面","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:1:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#写在前面"},{"categories":["OpenSource"],"collections":null,"content":"关于UI控件 在上一篇文章中，我们成功地通过Appium Inspector调用模拟器并运行iOS应用，iOS的自动化测试环境也已全部准备就绪了。 那么接下来，我们就可以开始实现自动化测试了么？ 貌似还不行。在开始之前，我们先想下什么是APP功能自动化测试。 APP的功能自动化测试，简单地来说，就是让功能测试用例自动地在APP上执行。具体到每一个测试用例，就是能模拟用户行为对UI控件进行操作，自动化地实现一个功能点或者一个流程的操作。再细分到每一步，就是对UI控件进行操作。 因此，在正式开始编写自动化测试用例之前，我们还需要熟悉如何与APP的UI控件进行交互操作。 在iOS系统中，UI控件有多种类型，常见的有按钮（UIAButton）、文本（UIAStaticText）、输入框（UIATextField）等等。但不管是对什么类型的UI控件进行操作，基本都可以分解为三步，首先是获取目标控件的属性信息，然后是对目标控件进行定位，最后是对定位到的控件执行动作。 ","date":"2016-05-29","heading":"关于UI控件","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:2:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#关于ui控件"},{"categories":["OpenSource"],"collections":null,"content":"获取UI控件信息 在Appium中，要获取iOS的UI控件元素信息，可以采用两种方式：一种是在前一篇文章中提到的Appium Inspector，另一种是借助Ruby实现的appium_console，在Terminal中通过命令进行查询。 ","date":"2016-05-29","heading":"获取UI控件信息","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:3:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#获取ui控件信息"},{"categories":["OpenSource"],"collections":null,"content":"Appium Inspector 运行Appium Server，并启动【Inspector】后，整体界面如下图所示。 现对照着这张图对Appium Inspector进行介绍。 在右边部分，是启动的模拟器，里面运行着我们的待测APP。我们可以像在真机中一样，在模拟器中执行任意功能的操作，当然，模拟器跟真机毕竟还是有区别的，跟传感器相关的功能，例如摄像头、重力感应等，是没法实现的。 在左边部分，就是Appium Inspector。Inspector主要由如下四个部分组成： 预览界面区：显示画面与模拟器界面一致；不过，当我们在模拟器中切换界面后，Inspector的预览区中显示图像并不会自动同步，若要同步，需要点击【Refresh】按钮，然后Inspector会将模拟器当前UI信息dump后显示到预览区；在预览区中，可以点击选择任意UI控件。 UI信息展示区：展示当前界面预览区中所有UI元素的层级关系和UI元素的详细信息；在预览区中点击选择任意UI控件后，在“Details”信息框中展示选中控件的详细信息，包括name、label、value、xpath等属性值；通过层级关系，我们也能了解选中控件在当前界面树状结构中所处的具体位置。 交互操作区：模拟用户在设备上的操作，例如单击（tap）、滑动（swipe）、晃动（shake）、输入（input）等；操作动作是针对预览界面区选中的控件，因此在操作之前，务必需要先在预览区点击选择UI元素。 脚本生成区：将用户行为转换为脚本代码；点击【Record】按钮后，会弹出代码区域；在交互操作区进行操作后，就会实时生成对应的脚本代码；代码语言可通过下拉框进行选择，当前支持的语言类型有：C#、Ruby、Objective-C、Java、node.js、Python。 在实践操作中，Inspector最大的用途就是在可以可视化地查看UI元素信息，并且可以将操作转换为脚本代码，这对初学者尤为有用。 例如，在预览区点击选中按钮“BUY NOW”，然后在UI信息展示区的Details窗口就可以看到该按钮的所有属性信息。在交互操作区点击【Tap】按钮后，就会模拟用户点击“BUY NOW”按钮，并且在脚本区域生成当次按钮点击的脚本（选择Ruby语言）： find_element(:name, \"BUY NOW \u003e\").click 如上就是使用Appium Inspector的一般性流程。 ","date":"2016-05-29","heading":"Appium Inspector","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:3:1","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#appium-inspector"},{"categories":["OpenSource"],"collections":null,"content":"Appium Ruby Console 有了Appium Inspector，为什么还需要Appium Ruby Console呢？ 其实，Appium Ruby Console也并不是必须的。经过与多个熟悉Appium的前辈交流，他们也从未用过Appium Ruby Console，这说明Appium Ruby Console并不是必须的，没有它也不会影响我们对Appium的使用。 但是，这并不意味着Appium Ruby Console是多余的。经过这些天对Appium的摸索，我越发地喜欢上Appium Ruby Console，并且使用的频率越来越高，现在已基本上很少使用Appium Inspector了。这种感觉怎么说呢？Inspector相比于Ruby Conosle，就像是GUI相比于Linux Terminal，大家应该能体会了吧。 Appium Inspector的功能是很齐全，GUI操作也很方便，但是，最大的问题就是使用的时候非常慢，在预览界面区切换一个页面常常需要好几秒，甚至数十秒，这是很难让人接受的。 在上一节中也说到了，Inspector最大的用途就是在可以可视化地查看UI元素信息，并且可以将操作转换为脚本代码。但是当我们对Appium的常用API熟悉以后，我们就不再需要由工具来生成脚本，因为自己直接写会更快，前提是我们能知道目标控件的属性信息（type、name、label、value）。 在这种情况下，如果能有一种方式可以供我们快速查看当前屏幕的控件属性信息，那该有多好。 庆幸的是，在阅读Appium官方文档时，发现Appium的确是支持命令行方式的，这就是Appium Ruby Console。 Appium Ruby Console是采用Ruby语言开发的，在使用方式上面和Ruby的irb很类似。 在使用Appium Ruby Console时，虚拟机的配置信息并不会从GUI中读取，而是要通过配置文件进行指定。 配置文件的名称统一要求为appium.txt，内容形式如下所示： [caps] platformName = \"ios\" platformVersion = '9.3', app = \"/path/to/UICatalog.app.zip\" deviceName = \"iPhone Simulator\" 其中，platformName指定虚拟机操作系统类型，“ios”或者\u0026quot;android\u0026quot;；platformVersion指定操作系统的版本，例如iOS的'9.3\u0026rsquo;，或者Android的'5.1\u0026rsquo;；app指定被测应用安装包的路径。这三个参数是必须的，与Inspector中的配置也能对应上。 在使用Appium Ruby Console时，首先需要启动Appium Server，通过GUI或者Terminal均可。 然后，在Terminal中，进入到appium.txt文件所在的目录，执行arc命令即可启动Appium Ruby Console。arc，即是appium ruby console首字母的组合。 ➜ ls appium.txt ➜ arc [1] pry(main)\u003e 接下来，就可以通过执行命令查询当前设备屏幕中的控件信息。 使用频率最高的一个命令是page，通过这个命令可以查看到当前屏幕中所有控件的基本信息。 例如，当屏幕停留在前面截图中的页面时，执行page命令可以得到如下内容。 [1] pry(main)\u003e page UIANavigationBar name: HomeView id: Home =\u003e Home 米 =\u003e m 去看看 =\u003e View UIAButton name, label: tabbar category gray UIAImage name: debugtalk_logo.png UIAButton name, label: tabbar cart gray UIATableView value: rows 1 to 4 of 15 UIAPageIndicator value: page 2 of 2 UIATableCell name: For the first time ever in a hand held camera, the Osmo brings professional, realtime cinema-quality stabilization. id: 米 =\u003e m UIAStaticText name, label, value: For the first time ever in a hand held camera, the Osmo brings professional, realtime cinema-quality stabilization. id: 米 =\u003e m UIAStaticText name, label, value: OSMO UIAButton name, label: SHOP NOW \u003e UIATableCell name: Ronin UIAStaticText name, label, value: Ronin UIAStaticText name, label, value: Phantom id: 米 =\u003e m ... (略) UIAButton name, label: Store value: 1 id: 门店 =\u003e Store ... (略) UIAButton name, label: My Account id: My Account =\u003e My Account nil 通过返回信息，我们就可以看到所有控件的type、name、label、value属性值。如果在某个控件下没有显示label或value，这是因为这个值为空，我们可以不予理会。 由于page返回的信息太多，可能不便于查看，因此在使用page命令时，也可以指定控件的类型，相当于对当前屏幕的控件进行筛选，只返回指定类型的控件信息。 指定控件类型时，可以通过string类型进行指定（如 page \u0026ldquo;Image\u0026rdquo;），也可通过symbol类型进行指定（如 page :cell）。指定的类型可只填写部分内容，并且不分区大小写。 [2] pry(main)\u003e page \"Image\" UIAImage name: debugtalk_logo.png nil [3] pry(main)\u003e page :cell UIATableCell name: DebugTalk’s smartest flying camera ever. id: 米 =\u003e m UIATableCell name: Ronin UIATableCell name: Phantom id: 米 =\u003e m nil 如果需要查看当前屏幕的所有控件类型，可以执行page_class命令进行查看。 [4] pry(main)\u003e page_class 14x UIAButton 8x UIAStaticText 4x UIAElement 4x UIATableCell 2x UIAImage 2x UIAWindow 1x UIAPageIndicator 1x UIATableView 1x UIAStatusBar 1x UIANavigationBar 1x UIATabBar 1x UIAApplication nil 基本上，page返回的控件信息已经足够满足绝大多数场景需求，但有时候情况比较特殊，需要enabled、xpath、visible、坐标等属性信息，这时就可以通过执行source命令。执行source命令后，就可以返回当前屏幕中所有控件的所有信息，以xml格式进行展现。 ","date":"2016-05-29","heading":"Appium Ruby Console","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:3:2","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#appium-ruby-console"},{"categories":["OpenSource"],"collections":null,"content":"定位UI控件 获取到UI控件的属性信息后，就可以对控件进行定位了。 首先介绍下最通用的定位方式，find。通过find命令，可以实现在控件的诸多属性值（name、label、value、hint）中查找目标值。查询时不区分大小写，如果匹配结果有多个，则只返回第一个结果。 [5] pry(main)\u003e find('osmo') # [6] pry(main)\u003e find('osmo').label \"Osmo\" 另一个通用的定位方式是find_element，它也可以实现对所有控件进行查找，但是相对于find，可以对属性类型进行指定。 [7] pry(main)\u003e find_element(:class_name, 'UIATextField') # [8] pry(main)\u003e find_element(:class_name, 'UIATextField').value \"Email Address\" 不过在实践中发现，采用find、find_element这类通用的定位方式并不好用，因为定位结果经常不是我们期望的。 经过反复摸索，我推荐根据目标控件的类型，选择对应的定位方式。总结起来，主要有以下三种方式。 针对Button类型的控件（UIAButton），采用button_exact进行定位： [9] pry(main)\u003e button_exact('Login') # 针对Text类型的控件（UIAStaticText），采用text_exact进行定位： [10] pry(main)\u003e text_exact('Phantom') # 针对控件类型进行定位时，采用tag；如下方式等价于find_element(:class_name, 'UIASecureTextField')。 [11] pry(main)\u003e tag('UIASecureTextField') # 基本上，这三种方式就已经足够应付绝大多数测试场景了。当然，这三种方式只是我个人经过实践后选择的定位方式，除了这三种，Appium还支持很多种其它定位方式，大家可自行查看Appium官方文档进行选择。 另外，除了对控件进行定位，有时候我们还想判断当前屏幕中是否存在某个控件（通常用于结果检测判断），这要怎么做呢？ 一种方式是借助于Appium的控件查找机制，即找不到控件时会抛出异常（Selenium::WebDriver::Error::NoSuchElementError）；反过来，当查找某个控件抛出异常时，则说明当前屏幕中不存在该控件。 [12] pry(main)\u003e button_exact('Login_invalid') Selenium::WebDriver::Error::NoSuchElementError: An element could not be located on the page using the given search parameters. from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/common/helper.rb:218:in `_no_such_element' 该种方式可行，但比较暴力，基本上不会采用这种方式。 另一种更好的方式是，查找当前屏幕中指定控件的个数，若个数不为零，则说明控件存在。具体操作上，将button_exact替换为buttons_exact，将text_exact替换为texts_exact。 [12] pry(main)\u003e buttons_exact('Login').count 1 [13] pry(main)\u003e buttons_exact('Login_invalid').count 0 除此之外，基于Ruby实现的appium_lib还支持exists方法，可直接返回Boolean值。 [14] pry(main)\u003e exists { button_exact('Login') } true [15] pry(main)\u003e exists { button_exact('Login_invalid') } false ","date":"2016-05-29","heading":"定位UI控件","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:4:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#定位ui控件"},{"categories":["OpenSource"],"collections":null,"content":"对控件执行操作 定位到具体的控件后，操作就比较容易了。 操作类型不多，最常用就是点击（click）和输入（type），这两个操作能覆盖80%以上的场景。 对于点击操作，才定位到的控件后面添加.click方法；对于输入操作，在定位到的输入框控件后面添加.type方法，并传入输入值。 例如，账号登录操作就包含输入和点击两种操作类型。 [16] pry(main)\u003e find_element(:class_name, 'UIATextField').type 'leo.lee@debugtalk.com' \"\" [17] pry(main)\u003e find_element(:class_name, 'UIASecureTextField').type '123456' \"\" [18] pry(main)\u003e button_exact('Login').click nil ","date":"2016-05-29","heading":"对控件执行操作","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:5:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#对控件执行操作"},{"categories":["OpenSource"],"collections":null,"content":"To be continued \u0026hellip; 在本文中，我们学习了对iOS UI控件进行交互操作的一般性方法，为编写自动化测试脚本打好了基础。 在下一篇文章中，我们就要正式开始针对iOS应用编写自动化测试脚本了。 ","date":"2016-05-29","heading":"To be continued \u0026hellip;","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/:6:0","tags":["Appium","iOS","Ruby"],"title":"从0到1搭建移动App功能自动化测试平台（2）：操作iOS应用的控件","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-interrogate-iOS-UI/#to-be-continued-"},{"categories":["OpenSource"],"collections":null,"content":"在上一篇文章中，我对本系列教程的项目背景进行了介绍，并对自动化测试平台的建设进行了规划。 在本文中，我将在已准备就绪的iOS自动化测试环境的基础上，通过Appium调用模拟器运行iOS应用。内容很是基础，熟悉的同学可直接略过。 ","date":"2016-05-21","heading":"","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:0:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#"},{"categories":["OpenSource"],"collections":null,"content":"iOS应用安装包的基础知识 作为完全的iOS新手，困惑的第一个问题就是iOS安装包文件。 在Android系统中，安装App的途径很多，除了各类应用市场，普通用户也经常直接下载apk安装包文件后手动进行安装，因此大家对Android的安装包文件都比较熟悉。 但是对于iOS系统就不一样了，由于我们普通用户在iOS上安装应用的时候基本上只能通过Apple Store进行安装（未越狱），没有机会接触原始的安装包文件，因此往往连iOS应用的安装包到底是什么格式后缀都不清楚。 现在我们想在Appium App中通过模拟器运行被测应用，需要指定iOS app的安装包路径，因此需要首先获得一个iOS app安装包。 那么iOS app的安装包长啥样呢？ 或者在这个问题之前，我们先来看下另一个问题：对于iOS设备来说，如果不通过Apple Store，我们可以怎样安装一个应用？ 针对这个问题，我搜了些资料，也请教了周围的同事，了解到的途径有如下几个： 企业证书：该种方式适用于企业内部；通过企业证书编译出的iOS应用，无需上传至Apple Store，即可无限制的安装到企业员工的iOS设备中。只是需要解决的一个问题是，由于iOS设备没有文件管理器，没法将安装包拷贝到iOS设备中，因此常用的做法是将安装包（.ipa文件）上传至一些下载服务器（例如fir.im），并生成二维码，然后用户扫描二维码后即可通过浏览器下载安装包并进行安装。由此联想到另外一个方法，通过微信文件传输助手将安装包（.ipa）传输至iOS设备，然后再进行安装应该也是可以的吧？这种方法不知在原理上是否可行，因为在试验时由于安装包大于30M，微信无法传输，所以没能进行验证。 Xcode：该种方式适用于iOS开发者；开发者在Xcode中连上iOS设备对源码进行编译，编译生成的应用会自动安装至iOS设备。当然，该种方式也是需要iOS开发者证书。 PP助手：该种方式适用于普通用户；PP助手是一个非苹果官方的设备资源管理工具，可以实现对未越狱的iOS设备进行应用管理，也可以安装本地.ipa文件，前提是.ipa文件具有合适的签名。 在上面列举的安装应用的途径中，反复提到了.ipa文件，那.ipa应该就是iOS应用程序的后缀了吧？暂且这么认为吧。 再回到前面的场景，要在iOS模拟器中运行iOS应用，我们是否可以找研发人员要一个.ipa安装包文件，然后就能在模拟器中加载运行应用呢？ 刚开始的时候我是这么认为的。于是我获取到.ipa文件后，在App Path中填写该文件的路径，然后启动Appium Server；接着我再打开Inspector时，发现iOS模拟器启动了，但是在应用启动的时候就出问题了，始终无法正常启动，感觉像是启动崩溃，反复尝试多次仍然如此。 再次经过Google，总算是明白出现问题的原因了，总结下来有如下几点： 不管是从Apple Store或iTunes上下载的应用，还是在Xcode中针对真机设备编译生成的.ipa文件，都是面向于ARM处理器的iOS设备，只能在真机设备中进行安装； 而在Mac OSX系统中运行的iOS模拟器，运行环境是基于Intel处理器的； 因此，若是针对真机设备编译生成的.ipa文件，是无法在iOS模拟器中正常运行的，毕竟处理器架构都不一样； 要想在iOS模拟器中运行应用，则必须在Xcode中编译时选择模拟器类型；编译生成的文件后缀为.app。 ","date":"2016-05-21","heading":"iOS应用安装包的基础知识","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:1:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#ios应用安装包的基础知识"},{"categories":["OpenSource"],"collections":null,"content":"准备.app文件 接下来，就说下如何获取.app文件。 虽然是测试人员，不会对被测iOS项目贡献代码，但是也不能总是找研发帮忙编译生成.app文件。所以，在本地搭建完整的iOS项目开发环境还是很有必要的。 对于iOS开发环境的搭建，当前社区中应该已经有了很多完整的教程，我在这儿就不详细描述了，只简单说下我搭建过程中涉及到的几个点。 首先，Mac OSX、Xcode、Apple Developer Tools这些基础环境的安装，在上一篇文章中已经进行说明了； 然后，申请项目源码的访问权限，git clone到本地； 接着是项目依赖环境的问题；通常一个较大型的iOS项目都会引用许多第三方库，而这些依赖库并不会直接保存到项目仓库中，通常是采用CocoaPods进行管理；简单地说，CocoaPods是针对Swift和Objective-C项目的依赖管理器，类似于Java中的Maven，Ruby中的Gem，Python中的pip。 当然，iOS项目的依赖管理工具也不是只有CocoaPods一个，如果是采用的别的依赖管理器，请自行查找对应的资料。 采用CocoaPods管理的项目，在项目根目录下会包含Podfile和Podfile.lock文件，里面记录了当前项目依赖的第三方库以及对应的版本号。 安装CocoaPods很简单，采用gem即可。 $ sudo gem install cocoapods 然后，进入到iOS项目的目录，执行pod install命令即可安装当前项目的所有依赖。 $ cd Project_Folder $ pod install Re-creating CocoaPods due to major version update. Analyzing dependencies .....（略） Downloading dependencies .....（略） Generating Pods project Integrating client project Sending stats Pod installation complete! There are 27 dependencies from the Podfile and 28 total pods installed. 关于CocoaPods的更多信息，请自行查看官方网站 在依赖安装完成后，正常情况下，就可以在Xcode中编译项目了。 没有别的需要注意的，将target选择为模拟器（iOS Simulator）即可。而且针对模拟器进行编译时，也不会涉及到开发者证书的问题，项目配置上会简单很多。待后续讲到真机上的自动化测试时，我再对证书方面的内容进行补充。 编译完成后，在Products目录下，就可以看到XXX.app文件，这里的XXX就是项目名称；然后，选中XXX.app文件，【Show in Finder】，即可在文件目录中定位到该文件。 接下来，将XXX.app文件拷贝出来，或者复制该文件的Full path，怎样都行，只要在Appium的App Path中能定位到该文件就行。 ","date":"2016-05-21","heading":"准备","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:2:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#准备app文件"},{"categories":["OpenSource"],"collections":null,"content":"模拟器中运行iOS应用 被测应用.app准备就绪后，接下来就可以在iOS模拟器中运行了。 回到前面的那张图。启动Appium app后，对于模拟器运行的情况，在iOS Settings中必须设置的参数项就3个，App Path、Force Device和Platform Version。对于真机运行的情况，后续再单独进行说明。 设置完毕后，点击【Launch】，启动Appium Server。 然后，点击图中红框处的按钮，即可通过Inspector启动模拟器，并在模拟器中加载iOS应用。 在模拟器中，我们可以像在真机中一样，体验被测应用的各项功能；并且，在Appium的日志台中，可以实时查看到日志信息。 ","date":"2016-05-21","heading":"模拟器中运行iOS应用","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:3:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#模拟器中运行ios应用"},{"categories":["OpenSource"],"collections":null,"content":"经历的一个坑 整个过程是挺简单的，不过，在探索过程中我还是有遇到一个坑。 通过Inspector启动模拟器时，总是弹框报错，报错形式如下。 刚开始出现这问题时百思不得其解，因为提示的信息并不明显，Google了好一阵也没找到原因。最后只有详细去看日志信息，才发现问题所在。 在日志中，发现的报错信息如下： [iOS] Error: Could not find a device to launch. You requested 'iPhone 6 (8.4)', but the available devices were: [\"Apple TV 1080p (9.2) [98638D25-7C82-48DF-BDCA-7F682F951533] (Simulator)\",\"iPad 2 (9.2) [5E22F53E-EAB3-45DF-A1DD-10F58E920679] (Simulator)\",\"iPad 2 (9.3) [4B2D2F9A-C099-4C13-8DE9-27C826A521C2] (Simulator)\",\"iPad Air (9.2) [825E4997-9CD8-4225-9977-4C7AE2C98389] (Simulator)\",\"iPad Air (9.3) [E4523799-E35F-4499-832B-12CF33F09144] (Simulator)\",\"iPad Air 2 (9.2) [8057039D-F848-453E-97EC-2F75CAEA2E77] (Simulator)\",\"iPad Air 2 (9.3) [0B8F49DA-832A-4248-BA1D-9DA5D11E31FD] (Simulator)\",\"iPad Pro (9.2) [AF1F2D06-3067-41B5-AC2B-4B0ED88BF5D9] (Simulator)\",\"iPad Pro (9.3) [C39617A6-9D91-4C0B-B25B-741BD57B016C] (Simulator)\",\"iPad Retina (9.2) [D3C694E1-E3B4-47BE-AB5E-80B3D4E22FC2] (Simulator)\",\"iPad Retina (9.3) [907C7B06-ED2C-48AC-AC46-04E4AD6E0CA3] (Simulator)\",\"iPhone 4s (9.2) [1A786195-94E3-4908-8309-7B66D84E4619] (Simulator)\",\"iPhone 4s (9.3) [3F76F34B-5A8F-4FD1-928D-56F84C192DDD] (Simulator)\",\"iPhone 5 (9.2) [0D79A4CA-71EB-48A6-9EE4-172BEF3EB4E0] (Simulator)\",\"iPhone 5 (9.3) [04270D44-F831-4253-95F2-3D205D2BC0D9] (Simulator)\",\"iPhone 5s (9.2) [13A16C07-3C5B-4B04-A94B-B40A63238958] (Simulator)\",\"iPhone 5s (9.3) [D30A7B34-BA01-4203-80DA-FAEA436725F9] (Simulator)\",\"iPhone 6 (9.2) [5D01650F-2A31-4D53-A47A-CCF7FD552ADD] (Simulator)\",\"iPhone 6 (9.3) [2F0810F6-C73B-4BA4-93BA-06D4B6D96BDA] (Simulator)\",\"iPhone 6 Plus (9.2) [9A840B78-E6CE-4D18-BE83-16B590411641] (Simulator)\",\"iPhone 6 Plus (9.3) [27C6557A-B09D-4D8A-9846-DA8FE0A8E8D5] (Simulator)\",\"iPhone 6s (9.2) [E7F5B8A5-0E85-404F-A4D4-191D63E7EC1B] (Simulator)\",\"iPhone 6s (9.3) [6F702911-13C2-472C-9ECD-BADD4385CB77] (Simulator)\",\"iPhone 6s (9.3) + Apple Watch - 38mm (2.2) [B63FFAA4-00A4-473B-9462-3664F41F9001] (Simulator)\",\"iPhone 6s Plus (9.2) [58837F78-511A-4F0B-9DDF-782E3B9935BD] (Simulator)\",\"iPhone 6s Plus (9.3) [C31003C6-DCE2-414D-AD7F-376F6FA995B0] (Simulator)\",\"iPhone 6s Plus (9.3) + Apple Watch - 42mm (2.2) [E3154768-CA23-45CC-90E5-2D0386A57B7D] (Simulator)\"] 问题在于，我设置iOS Settings时，将Force Device设置为\u0026quot;iPhone 6\u0026quot;，将Platform Version设置为“8.4”，但是经过组合，iPhone 6 (8.4)并不在可用的模拟器设备列表中。 再来看日志中提示的可用设备，发现“iPhone 6”设备对应的Platform Version只有“9.2”和“9.3”。然后回到iOS Settings，发现Platform Version的下拉框可选项就没有“9.2”和“9.3”，最新的一个可选版本也就是“8.4”。 这应该是Appium app的一个bug吧。不过好在Platform Version参数虽然是通过下拉框选择，但是也可以在框内直接填写内容。于是我在Platform Version设置框内填写为“9.3”，然后再次启动时，发现iOS模拟器就可以正常启动了。 ","date":"2016-05-21","heading":"经历的一个坑","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:4:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#经历的一个坑"},{"categories":["OpenSource"],"collections":null,"content":"To be continued \u0026hellip; 现在，我们已经成功地通过Appium Inspector调用模拟器并运行iOS应用，接下来，我们就要开始尝试编写自动化测试用例了。 在下一篇文章中，我们将对Appium Inspector的功能进行熟悉，通过Inspector来查看iOS应用的UI元素信息，并尝试采用脚本语言与UI进行交互操作。 ","date":"2016-05-21","heading":"To be continued \u0026hellip;","objectID":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/:5:0","tags":["Appium","iOS","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（1）：模拟器中运行iOS应用","uri":"/post/build-app-automated-test-platform-from-0-to-1-Appium-inspector-iOS-simulator/#to-be-continued-"},{"categories":["OpenSource"],"collections":null,"content":"背景 最近新加入某项目组（以下均已M指代），需要从零开始搭建功能自动化测试平台。 简单地说，M是一个典型的移动互联网产品，客户端包括iOS和Android，并在app中通过WebView嵌入了H5，后端基于Ruby on Rails实现。 当前阶段，M项目除了Rails Server端采用Jenkins+RSpec实现了部分的持续集成功能外，客户端部分的部署和测试工作都还是完全依赖于手工操作。 基于当前项目的开发模式，我对整个M项目实现持续集成自动化测试的架构流程进行了规划，初步计划的架构图如下图所示。最终的目标是希望能实现：不管是Rails Server，还是App(iOS/Android)，以及H5，当任意部分存在代码提交时，系统能自动拉取最新代码进行部署并执行自动化回归测试，及时地将执行情况反馈给开发人员。 目标确定后，便是分阶段进行实现，需要开发的模块包括： 自动化测试平台（Automated Test Platform）：满足iOS/Android/H5的自动化功能测试，包括模拟器和真机的测试； 测试管理平台（Test Management Platform）：实现自动化测试用例管理、手动下发测试任务、测试结果报表展现、Dashboard等功能； 打包平台（Pack System）：实现iOS/Android的自动化构建； 服务端自动化测试（Rails）：将服务端Rails的自动化测试接入测试管理平台； 持续集成流程打通：对Jenkins进行二次开发，与测试管理平台打通，实现全流程的持续集成自动化测试。 而本系列教程，《从0到1搭建移动App功能自动化测试平台》，便是对整个实践过程的一个记录。 需要说明的是，之前我个人的工作经历主要在服务端性能测试、Android客户端性能测试（测试开发）方向，对于客户端的自动化测试基本上没有经验积累，特别是iOS系统的测试，以前更是完全没有接触过。因此本系列教程只能算是个人在探索路上的学习总结和记录，可能会存在一些错误的观点，还请前辈们多多指教。 ","date":"2016-05-20","heading":"背景","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:1:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#背景"},{"categories":["OpenSource"],"collections":null,"content":"自动化测试框架的选择 在愿景图中，绿色方框（Automated Test Platform）负责移动应用客户端（iOS/Android/H5）自动化测试的调度和执行，是整个自动化测试平台的核心。 因此，在搭建自动化测试平台之前，首先需要选择一个合适的自动化测试框架。 对于移动应用的自动化测试框架，当前市面上已经有很多成熟的开源项目。针对当前项目的实际情况，我主要参考如下选择标准： 同时支持iOS、Android、H5，且尽量能保持接口统一，减少开发维护成本； 编程语言支持Python/Ruby； 用户量大，文档丰富。 经过筛选，Appium无疑是最佳的选择。 ","date":"2016-05-20","heading":"自动化测试框架的选择","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:2:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#自动化测试框架的选择"},{"categories":["OpenSource"],"collections":null,"content":"Appium 简介 对于Appium的详细介绍，大家可参考Appium官方文档，我就不再重复引用。 不过对于Appium，仍然有几点很赞的理念值得强调。 采用Appium时，无需对被测应用做任何修改，也无需嵌入任何东西； Appium对iOS和Android的原生自动化测试框架进行了封装，并提供了统一的API（WebDriver API），减少了自动化测试代码的维护工作量； Appium采用Client-Server的架构设计，并采用标准的HTTP通信协议；Server端负责与iOS/Android原生测试框架交互，无需测试人员关注细节实现；Client端基本上可以采用任意主流编程语言编写测试用例，减少了学习成本。 ","date":"2016-05-20","heading":"Appium 简介","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:3:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#appium-简介"},{"categories":["OpenSource"],"collections":null,"content":"环境准备（iOS） 在Appium中测试iOS时，依赖于Apple开发环境，因此，在运行Appium之前需要先确保如下环境安装正确。 Mac OS X \u0026gt;= 10.7 XCode \u0026gt;= 4.6.3 Apple Developer Tools (iPhone simulator SDK, command line tools) 如上几个环境安装比较简单，直接在Apple Store中安装即可。 在安装Appium之前，为了确保Appium的相关依赖已经准备就绪，可以使用appium-doctor来进行验证。 appium-doctor是一个用于验证appium安装环境的工具，可以诊断出Node/iOS/Android环境配置方面的常见问题。 appium-doctor采用node.js编写，采用npm即可在Terminal中进行安装： $ npm install appium-doctor -g 安装完毕后，执行appium-doctor命令即可对Appium的环境依赖情况进行检测；指定--ios时只针对iOS环境配置进行检测，指定--android参数时只针对Android环境配置进行检测，若不指定则同时对iOS和Android环境进行检测。 $ appium-doctor --ios info AppiumDoctor ### Diagnostic starting ### info AppiumDoctor ✔ Xcode is installed at: /Applications/Xcode.app/Contents/Developer info AppiumDoctor ✔ Xcode Command Line Tools are installed. info AppiumDoctor ✔ DevToolsSecurity is enabled. info AppiumDoctor ✔ The Authorization DB is set up properly. info AppiumDoctor ✔ The Node.js binary was found at: /usr/local/bin/node info AppiumDoctor ✔ HOME is set to: /Users/Leo info AppiumDoctor ### Diagnostic completed, no fix needed. ### info AppiumDoctor info AppiumDoctor Everything looks good, bye! info AppiumDoctor 若检测结果全部通过，则说明Appium的相关依赖已经准备就绪，接下来可以继续安装Appium。 ","date":"2016-05-20","heading":"环境准备（iOS）","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:4:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#环境准备ios"},{"categories":["OpenSource"],"collections":null,"content":"安装Appium 根据前面的介绍，Appium采用Client-Server的架构设计，因此安装Appium时需要分别安装Server部分和Client部分。 通常情况下，我们说的Appium都是指代的Server部分。Appium的安装有多种方式：可以通过源码编译安装，也可以在Terminal中通过npm命令安装，另一种是直接下载appium.dmg后安装应用程序。 在这里推荐运行Appium app的方式，除了GUI界面操作更直观以外，更重要的一个原因是，相比于命令行运行方式，Appium app多了一个Inspector模块，可以调用模拟器运行被测应用程序，并且可以很方便地在预览页面中查看UI元素的层级结构和详细控件属性，极大地提高编写测试脚本的效率。 至于Client部分，其实我们原本可以不安装任何东西，只需要任意选择一门开发语言，然后直接基于WebDriver的C/S协议（JSON Wire Protocol）即可编写自动化测试代码。但是这样做的话工作量会比较大，因为要去处理一些跟协议相关的工作。所幸Appium项目已经针对众多主流的编程语言，将底层协议处理相关的工作封装为Library，通过调用这些Library，可以极大地简化我们编写测试用例的工作量。 而说的需要安装的Client部分，其实也就是安装这些Library。选定编写测试用例的语言后，我们就可以针对性地进行安装。 例如，如果选择Ruby语言，那么需要安装的Library就是appium_lib，安装方式如下： $ gem install appium_lib 如果选择Python语言，那么需要安装的Library就是Appium-Python-Client，安装方式如下： $ pip install Appium-Python-Client 对于其它编程语言，请自行参考官方文档。 ","date":"2016-05-20","heading":"安装Appium","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:5:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#安装appium"},{"categories":["OpenSource"],"collections":null,"content":"To be continued \u0026hellip; iOS的自动化测试环境已基本准备就绪了，接下来我们想做的第一件事，就是在模拟器中运行iOS应用。 在下一篇文章中，我们将从clone项目源码为起点，编译生成iOS app，在Appium中调用模拟器中运行iOS app，并分享实践过程中遇到的一些坑。 ","date":"2016-05-20","heading":"To be continued \u0026hellip;","objectID":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/:6:0","tags":["Appium","AppiumBooster"],"title":"从0到1搭建移动App功能自动化测试平台（0）：背景介绍和平台规划","uri":"/post/build-app-automated-test-platform-from-0-to-1-backgroud-introduction/#to-be-continued-"},{"categories":["Testing"],"collections":null,"content":"本文对Android App的启动流量测试进行介绍。这里的启动流量指的是网络流量，即App在启动时发起网络请求和接收网络响应时传输的网络数据量。 说起流量，也许大家的第一反应就是tcpdump/wireshark这类网络抓包工具。的确，Android系统确实也支持tcpdump工具，通过tcpdump，我们可以实现非常精准的流量测试。但tcpdump也有个问题，就是它捕捉到的流量是系统层面的，我们很难区分捕捉得到的流量数据是否都是当前apk产生的。 其实，对于特定apk的整体流量数据，在Android系统中都会存储到对应文件中，我们完全可以通过读取对应文件来获得当前apk的流量信息。 ","date":"2016-05-03","heading":"","objectID":"/post/Android-performance-test-start-traffic-uid-stat/:0:0","tags":["Android","启动流量"],"title":"Android App持续集成性能测试：启动流量（1）","uri":"/post/Android-performance-test-start-traffic-uid-stat/#"},{"categories":["Testing"],"collections":null,"content":"get app UID 与流量相关的状态数据存储在/proc/uid_stat/\u0026lt;UID\u0026gt;/目录下，其中，\u0026lt;UID\u0026gt;表示apk对应的UID。 关于UID，简单地进行下说明。在Linux系统中，UID表示的是User Identifier，主要用于表示是哪位用户运行了该程序。但在Android系统中，由于Android系统本身就为单用户系统，这时UID就被赋予了新的使命，主要用于实现数据共享。具体地，Android系统为每个应用都分配了一个UID，不同apk的UID几乎都是互不相同的，而对于不同UID的apk，不能共享数据资源。之所以用“几乎”，是因为有时候同一厂家会存在多个产品，并且希望能在多个apk之间实现数据共享，这个时候，便可通过在menifest配置文件中指定相同的sharedUserId，然后在Android系统中安装应用时便会分配相同的UID。 获取app UID的方式有多种，最简单的方式应该还是从/data/system/packages.list中读取，并通过apk的\u0026lt;PKGNAME\u0026gt;找到对应的UID。 root@hammerhead:/ # cat /data/system/packages.list | grep com.UCMobile.trunk com.UCMobile.trunk 10084 0 /data/data/com.UCMobile.trunk default 3003,1028,1015 在这里，10084即是com.UCMobile.trunk的UID。 ","date":"2016-05-03","heading":"get app UID","objectID":"/post/Android-performance-test-start-traffic-uid-stat/:1:0","tags":["Android","启动流量"],"title":"Android App持续集成性能测试：启动流量（1）","uri":"/post/Android-performance-test-start-traffic-uid-stat/#get-app-uid"},{"categories":["Testing"],"collections":null,"content":"获取流量数据 流量数据分为接收流量（tcp_rcv）和发送流量（tcp_snd）两部分，这两个状态数值我们可以通过读取/proc/uid_stat/\u0026lt;UID\u0026gt;目录下的两个文件得到。 shell@hammerhead:/ $ cat /proc/uid_stat/10084/tcp_rcv 3446837 shell@hammerhead:/ $ cat /proc/uid_stat/10084/tcp_snd 134366 通过这种方式，我们就可以读取得到指定apk在当前时刻的累计流量数值。 ","date":"2016-05-03","heading":"获取流量数据","objectID":"/post/Android-performance-test-start-traffic-uid-stat/:2:0","tags":["Android","启动流量"],"title":"Android App持续集成性能测试：启动流量（1）","uri":"/post/Android-performance-test-start-traffic-uid-stat/#获取流量数据"},{"categories":["Testing"],"collections":null,"content":"获得启动流量数据 有了前面的基础，我们要测试启动流量就很好实现了。只需要在启动前采集下累计流量数值，然后启动应用，完成启动后再采集一次累计流量数值，前后两次累计数值的差值便是当次启动耗费的流量数。需要注意的是，由于很多时候apk在启动后，会在系统后台异步加载一些数据资源，因此为了保证我们采集到当次启动耗费的全部流量数值，我们在启动应用后最好能等待一段时间。 root@hammerhead:/ # cat /proc/uid_stat/10084/tcp_snd 15068 root@hammerhead:/ # cat /proc/uid_stat/10084/tcp_rcv 98021 # start app activity, sleep 10s root@hammerhead:/ # cat /proc/uid_stat/10142/tcp_snd 23268 root@hammerhead:/ # cat /proc/uid_stat/10142/tcp_rcv 965651 采集到前后两次流量数值后，即可计算得到当次启动耗费的总流量。 当次启动总流量 = (23268 + 965651) - (15068 + 98021) = 875830 bytes 当然，这里的启动还分为好几种，包括首次安装启动、非首次安装启动、覆盖安装启动等。具体的启动方式可根据实际场景来定，但在统计流量的方法方面都是相同的。 ","date":"2016-05-03","heading":"获得启动流量数据","objectID":"/post/Android-performance-test-start-traffic-uid-stat/:3:0","tags":["Android","启动流量"],"title":"Android App持续集成性能测试：启动流量（1）","uri":"/post/Android-performance-test-start-traffic-uid-stat/#获得启动流量数据"},{"categories":["Testing"],"collections":null,"content":"总结 本文讲解了Android App启动流量测试的一种方法。然而，本次介绍的方法也存在一定局限性，因为/proc/uid_stat/\u0026lt;UID\u0026gt;/目录下的tcp_rcv和tcp_snd文件中都只记录了总值，如果我们只关注总体的流量数值还好，但要是我们希望能测试得到更细化的数据，该方法就没法满足我们的测试需求了。 举个例子，UC浏览器国际版在启动后，会和美国的服务器进行通讯交互。现在，我们想测试UC浏览器国际版在启动后与美国服务器的通讯流量。 显然，本文中介绍的方法是没法实现上述例子中的测试需求的。那例子中的场景要怎么测呢？这就还是得用到tcpdump，在下一篇文章中我会再详细进行介绍。 ","date":"2016-05-03","heading":"总结","objectID":"/post/Android-performance-test-start-traffic-uid-stat/:4:0","tags":["Android","启动流量"],"title":"Android App持续集成性能测试：启动流量（1）","uri":"/post/Android-performance-test-start-traffic-uid-stat/#总结"},{"categories":["Testing"],"collections":null,"content":"在上一篇文章中，介绍了一种测试Android App启动流量的方法。当时也提到了，通过读取/proc/uid_stat/\u0026lt;UID\u0026gt;/目录下的tcp_rcv和tcp_snd文件，只能得到App的流量总值，无法得到更细化的数据。 例如，UC浏览器国际版在启动后，会和美国的服务器进行通讯交互，如果我们想测试浏览器在启动后与美国服务器的通讯流量，要怎么实现呢？。 本文便是针对这类场景的测试需求，讲解如何采用tcpdump测试得到更细化的流量数据。 ","date":"2016-05-03","heading":"","objectID":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/:0:0","tags":["Android","启动流量","tcpdump","wireshark"],"title":"Android App持续集成性能测试：启动流量（2）","uri":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/#"},{"categories":["Testing"],"collections":null,"content":"tcpdump tcpdump，是一个在Unix-like系统中通用的网络抓包工具，当然，这个工具在Android系统中也是可以使用的。 对于工具本身，本文不做过多介绍。为了防止有读者之前完全没有tcpdump的使用经验，在这里我只简单地进行几点说明： 大多Android系统默认未集成tcpdump工具，我们需要事先将专门针对Android系统编译好的的tcpdump导入到Android系统，例如/data/local/tmp/tcpdump；当然，我们也不用自己编译，在androidtcpdump网站就可以下载到编译好的tcpdump二进制文件。 运行tcpdump工具时需要root权限。 tcpdump命令支持许多参数，常见的有： -i指定网卡（interface），any表示不限网卡； -c指定接收的packets数量，接收完成后自动停止抓包； -w指定输出文件，输出文件的格式为pcap； -s(--snapshot-length)指定在每个packet中最多截取的字节数，设置为0时表示截取上限取默认值262144； -v/-vv/-vvv，指定输出的详细程度，针对流量测试，我们不需要非常详尽的输出数据，取-v即可。 基于以上对tcpdump的介绍，我们要测试浏览器在启动后与美国服务器的通讯流量，就只需要先启动浏览器，然后在adb shell中执行以下命令即可。 1|shell@hammerhead:/ $ su -c /data/local/tmp/tcpdump -v -i any -s 0 -c 2000 -w /sdcard/us.pcap tcpdump: listening on any, link-type LINUX_SLL (Linux cooked), capture size 262144 bytes 2000 packets captured 2024 packets received by filter 0 packets dropped by kernel 在这里之所有指定接收packets数为2000，是因为浏览器启动后并不是立即与美国服务器进行通讯。所以在这里设置了一个比较大的值，确保浏览器与美国服务器的异步通讯数据能包含在这2000packets之中。当然，这个2000只是一个工程实践得到的经验值，具体取多少还是要依赖于具体场景。 经过一段时间的抓包后，就生产了抓包结果，即/sdcard/us.pcap。 ","date":"2016-05-03","heading":"tcpdump","objectID":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/:1:0","tags":["Android","启动流量","tcpdump","wireshark"],"title":"Android App持续集成性能测试：启动流量（2）","uri":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/#tcpdump"},{"categories":["Testing"],"collections":null,"content":"人工分析pcap文件 拿到pcap文件只是第一步，我们必须要对这个文件进行解析才能得到我们想要的结果。 那么，通过什么方法解析pcap文件呢？ 先简单介绍下pcap。pcap，即packet capture的缩写，是一种通用的网络抓包数据存储格式。既然是通用，因此它除了可以被tcpdump解析外，还支持被多种网络工具解析，其中，就包括大家熟知的wireshark。 至于为什么有了tcpdump还要用wireshark来解析，这主要还是因为wireshark是图形界面，操作和使用上更友好一些。 在wireshark中分析pcap文件十分简单，只需要直接打开文件，就可以看到抓包过程中捕获的所有网络通讯数据。不过，由于我们抓包获得的数据是系统层面的，除了我们关注的与美国服务器的通讯交互外，还包含了非常多的其它通讯信息。 好在wireshark有非常强大的筛选功能。对于本案例，我们可以先确定出美国服务器的host或IP，例如host为ucus.ucweb.com，那么我们就可以在筛选器中通过http.host == \u0026quot;ucus.ucweb.com\u0026quot;语句，即可筛选出所有本地与美国服务器的通讯交互数据。 对于更丰富的筛选功能，大家可以根据实际需求查询wireshark的帮助文档，在此就不再进行展开。 从上图的筛选结果中可以看到，美国服务器的地址为168.235.199.134。那接下来如何查看通讯的流量大小呢？ 首先，找出该次请求的TCP Stream。 在筛选出的TCP Stream中，将各条记录的Length进行求和，即可得到总的大小。 例如，发送流量的总和，即100.84.126.160-\u0026gt;168.235.199.134的总和，加和总值为3722bytes；接收流量的总和，即168.235.199.134-\u0026gt;100.84.126.160的总和，加和总值为6300bytes。 当然，这里只是为了讲解计算流量的原理，实际上，我们并不需要去进行这个计算，可以直接读取得到总值。 【Statistics】-\u0026gt;【Endpoints】 在Endpoints界面中，选择TCP tab，勾选“Limit to display filter”，即可看到通讯流量汇总数据。 可以看出，这个的汇总数值与前面计算得到的数值完全相同。 ","date":"2016-05-03","heading":"人工分析pcap文件","objectID":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/:2:0","tags":["Android","启动流量","tcpdump","wireshark"],"title":"Android App持续集成性能测试：启动流量（2）","uri":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/#人工分析pcap文件"},{"categories":["Testing"],"collections":null,"content":"自动化测试脚本 通过前面的人工分析，我们已经掌握了分析特定流量的一般性方法。然而，要想将此种场景的流量测试加入持续集成自动化测试系统，采用以上方法显然是不行的。 那么，要怎样才能在代码中完成对pcap文件的分析呢？ 好在已经有前辈做了相应的工作，在GitHub上就找到了一个开源项目pcap2har，可以实现对pcap文件的解析。 pcap2har项目的详细介绍请大家自行查看项目文档。 针对本文中的测试场景，解析pcap文件的代码实现如下。 #!/usr/bin/env python #coding=utf-8 import dpkt from pcap2har import pcap from pcap2har import http def parsePcapFile(pcap_file, target_host): # parse pcap file dispatcher = pcap.EasyParsePcap(filename=pcap_file) traffic_total = 0 traffic_receive_total = 0 traffic_send_total = 0 url_list = [] # stream为tcp数据流，当为长链接时一个tcp流里面可以有多个http请求 for stream in dispatcher.tcp.flows(): # fwd为请求大小，如果小于200则肯定不是正常的HTTP请求，忽略 if stream.fwd.caplen \u003c 200: continue pointer = 0 while pointer \u003c len(stream.fwd.data): try: myrequest = http.Request(stream.fwd, pointer) #解析请求头 except dpkt.Error as error: # if the message failed break except: raise pointer += myrequest.data_consumed myhead = myrequest.msg.headers # 请求头大小\u003c200时忽略 if myrequest.data_consumed \u003c 200: continue if myhead[\"host\"] == target_host: traffic_receive_total += stream.rev.caplen traffic_send_total += stream.fwd.caplen traffic_total += stream.streamlen url_list.append(myrequest.fullurl) traffic_data = { 'total': traffic_total, 'tcp_snd': traffic_send_total, 'tcp_rcv': traffic_receive_total, 'url_list': url_list } return traffic_data if __name__ == '__main__': pcap_file = \"\" target_host = \"ucus.ucweb.com\" print parsePcapFile(pcap_file, target_host) # output: {'url_list': ['http://ucus.ucweb.com/usquery.php'], 'total': 10022, 'tcp_rcv': 6300, 'tcp_snd': 3722} ","date":"2016-05-03","heading":"自动化测试脚本","objectID":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/:3:0","tags":["Android","启动流量","tcpdump","wireshark"],"title":"Android App持续集成性能测试：启动流量（2）","uri":"/post/Android-performance-test-start-traffic-tcpdump-wireshark/#自动化测试脚本"},{"categories":["Development"],"collections":null,"content":"背景介绍 最近接到一个需求，需要对公司内部的Android性能测试平台的分支管理模块进行改造。 为了更好地说明问题，在下图中展示了一个精简的持续集成测试系统。 在该系统中，Jenkins负责定时检测代码库（Code Repository）的代码更新情况，当检测到有新的代码提交时，自动采用最新的代码进行构建，并采用构建得到的包（apk）触发自动化测试平台（DroidTestbed）执行测试任务。 然后再说下分支管理模块。 由于我们的持续集成平台通常不止监控一个产品，而每个产品又不止监控一个tag（例如/trunk，/projects/cn/10.9.8），因此，我们的持续集成平台需要有分支管理的功能，即针对每一个产品的每一个tag，单独创建一个分支，并针对各个分支单独指定测试用例集合测试设备。 具体实现方面，出于单一职责的原则，我们对功能进行了如下划分： 在Jenkins端针对每一个分支创建一个Job； 在DroidTestbed端配置测试资源，针对每一个分支分别绑定测试用例集和测试设备，每一个分支会存在一个单独的branch_id； 在Jenkins端的Job配置中，保存该分支在DroidTestbed中对应的branch_id，实现Jenkins与DroidTestbed的关联。 整个过程看上去并没有什么问题，那为什么需要对分支管理模块进行改造呢？ 问题就出现在分支配置上面。 试想一下，每次要新增或修改一个分支的时候，由于Jenkins端和DroidTestbed端的配置是独立的，那么我们就只能在两个平台上分别进行配置。 另一方面，配置工作本身也较为复杂，例如，在Jenkins端就需要设置的参数包括：repository_url，tag，ref_tag，ref_revision，branch_id，schedule，user_name等；而这其中的大部分参数同样也要在DroidTestbed端进行配置。 根据历史经验，但凡涉及到复杂且重复的手工操作时，就容易出错。实际情况的确是这样的。在该功能上线后，由于配置复杂，业务组的同学每次要新增一个监控分支时，都需要找到管理员来帮忙配置（说实话，管理员对业务同学能配置正确也没信心）；即使是管理员，也出现过好几次因为疏忽造成配置错误的情况。 那么，这个问题要怎么解决呢？ ","date":"2016-05-02","heading":"背景介绍","objectID":"/post/manage-Jenkins-via-remote-api/:1:0","tags":["Jenkins"],"title":"通过 API 远程管理 Jenkins","uri":"/post/manage-Jenkins-via-remote-api/#背景介绍"},{"categories":["Development"],"collections":null,"content":"Jenkins Remote API 的简介 绕了这么大一个圈子，终于引出本文的主题，Jenkins Remote API。 实际上，Jenkins本身支持丰富的API接口，我们通过远程调用接口，基本上可以实现所有需要的功能，例如： 从Jenkins获取Job状态信息 触发Jenkins执行构建 创建、复制、修改、删除Job 回到前面的案例，我们就可以将配置操作全部放在DroidTestbed中，只需要在保存配置项时，由DroidTestbed自动调用Jenkins的Remote API，即可实现配置的同步。 ","date":"2016-05-02","heading":"Jenkins Remote API 的简介","objectID":"/post/manage-Jenkins-via-remote-api/:2:0","tags":["Jenkins"],"title":"通过 API 远程管理 Jenkins","uri":"/post/manage-Jenkins-via-remote-api/#jenkins-remote-api-的简介"},{"categories":["Development"],"collections":null,"content":"Jenkins Remote API 的调用 现在我们来看下如何调用Jenkins的Remote API。 Jenkins的Remote API以REST-like的形式进行提供，通过对特定的API执行POST请求即可实现对Jenkins的操作。 例如，我们搭建的Jenkins站点为http://jenkins.debugtalk.com:8080，那么，访问http://jenkins.debugtalk.com:8080/api即可查看到该站点所有可用的API；若想若某个具体的Job进行操作，如job名称android_core_dashboard_trunk，它的管理页面为http://jenkins.debugtalk.com:8080/job/android_core_dashboard_trunk，那么我们访问http://jenkins.debugtalk.com:8080/job/android_core_dashboard_trunk/api/即可查看到该job可用的API。 更详细的POST调用方式的介绍可以参考Jenkins的官方wiki，在此就不过多进行介绍。 可以看出，通过对特定API执行POST请求操作较为原始，因为我们需要关注过多底层细节。事实上，当前已经有前辈针对这一痛点，对底层的POST操作细节进行了封装，形成了一些wrapper方便我们从上层进行更便捷的操作。 这类wrapper实现的功能类似，都可以方便我们在代码中通过更简洁的方式调用Jenkins API，实现对Jenkins的远程管理，我们只需要根据我们采用的具体编程语言来选择对应的wrapper即可。当然，如果没有找到合适的，我们也可以参照已有的开源wrapper，自己再造一个轮子，原理都是相同的。 在Jenkins的官方wiki中，推荐了两个较为成熟的API wrapper，一个是基于Python实现的salimfadhley/jenkinsapi，另一个是基于Ruby实现的arangamani/jenkins_api_client。 以salimfadhley/jenkinsapi为例，通过使用jenkinsapi，我们在Python中就可以很方便地管理Jenkins。常见的操作方式示例如下。 \u003e\u003e\u003e import jenkinsapi \u003e\u003e\u003e from jenkinsapi.jenkins import Jenkins # 指定Jenkins实例 \u003e\u003e\u003e J = Jenkins('http://jenkins.debugtalk.com:8080') # 查看Jenkins版本 \u003e\u003e\u003e J.version 1.542 # 查看Jenkins的所有jobs \u003e\u003e\u003e J.keys() ['foo', 'test_jenkinsapi'] # 查看指定job的配置信息 \u003e\u003e\u003e J['test_jenkinsapi'].get_config() # 创建Jenkins job \u003e\u003e\u003e jobName = 'test_job' \u003e\u003e\u003e EMPTY_JOB_CONFIG = ''' \u003c?xml version='1.0' encoding='UTF-8'?\u003e jkkjjk false true false false false false ''' \u003e\u003e\u003e new_job = J.create_job(jobName, EMPTY_JOB_CONFIG) # 更新Jenkins job的配置 \u003e\u003e\u003e import xml.etree.ElementTree as et \u003e\u003e\u003e new_conf = new_job.get_config() \u003e\u003e\u003e root = et.fromstring(new_conf.strip()) \u003e\u003e\u003e builders = root.find('builders') \u003e\u003e\u003e shell = et.SubElement(builders, 'hudson.tasks.Shell') \u003e\u003e\u003e command = et.SubElement(shell, 'command') \u003e\u003e\u003e command.text = \"ls\" \u003e\u003e\u003e J[jobName].update_config(et.tostring(root)) # 删除Jenkins job \u003e\u003e\u003e J.delete_job(jobName) 更多的使用方法可参考项目文档。 有些同学在认真研究了这些开源库后也许会说，官方文档已经翻遍了，但是文档中对用法的描述太少了，也没给出API调用的示例，还是不知道怎么使用啊。这个问题在开源库中的确也是普遍存在的。 介绍个技巧，通常优秀的开源库都会很重视测试，作者可能在文档中没有针对每一个API接口的调用方式进行说明，但通常会针对各个接口编写较为完整的测试代码。我们通过阅读测试代码，就可以充分了解API接口的使用方法了，这也比直接阅读文档有效率得多。 ","date":"2016-05-02","heading":"Jenkins Remote API 的调用","objectID":"/post/manage-Jenkins-via-remote-api/:3:0","tags":["Jenkins"],"title":"通过 API 远程管理 Jenkins","uri":"/post/manage-Jenkins-via-remote-api/#jenkins-remote-api-的调用"},{"categories":["Development"],"collections":null,"content":"Read More \u0026hellip; 最后，如果感觉上面给的示例还不够，还想看看在实际项目中如何远程管理Jenkins，那么可以关注我最近在做的一个开源项目。 先看下整体的系统架构图。 整个系统实现的功能是Android App的性能持续集成测试平台，主要由DroidTestbed和DroidMeter两部分组成。 其中，DroidTestbed部分采用Ruby on Rails编写，核心角色为测试任务管理，可实现测试资源（测试用例、测试设备等）配置，根据代码提交自动触发执行测试任务、测试设备自动化调度、测试任务手动下发，测试结果报表查看等。DroidMeter负责具体的性能测试执行，采用Python编写，可实现控制Android设备执行测试场景，采集性能测试数据，包括内存、启动时间、帧率、包大小、网速、流量等等。 本文暂时不对该系统进行过多介绍，我后续会单独对各个模块涉及到的技术展开进行详细介绍。如果感兴趣，可关注我的GitHub或微信公众号【DebugTalk】。 ","date":"2016-05-02","heading":"Read More \u0026hellip;","objectID":"/post/manage-Jenkins-via-remote-api/:4:0","tags":["Jenkins"],"title":"通过 API 远程管理 Jenkins","uri":"/post/manage-Jenkins-via-remote-api/#read-more-"},{"categories":["Development"],"collections":null,"content":"问题描述 今天在跟进公司内部测试平台线上问题的时候，发现一个忽略已久的问题。 为了简化问题描述，将其进行了抽象。 有一张数据表qms_branch，里面包含了一批形式如下所示的数据： id name types 1 dashboard_trunk dashboard 2 monkey_trunk monkey 3 dashboard_projects_10_9_9 dashboard 4 performance_trunk 5 performance_projects_10_9_8 performance 在系统的某个页面中，需要展示出所有dashboard类型以外的分支，于是就采用如下方式进行查询（Rails）。 branches = Qms::Branch.where(\"types!='dashboard'\") 这个方式有问题么？ 之前我是觉得没什么问题。但是在代码上线后，实际使用时发现部分分支没有加载出来，这就包括了performance_trunk分支。 然后就是问题定位，到MySQL的控制台采用SQL语句进行查询： SELECT * FROM qms_branch WHERE types != 'dashboard' 发现在查询结果中的确没有包含performance_trunk分支。 这是什么原因呢？为什么在第4条数据中，types属性的值明明就不是dashboard，但是采用types!='dashboard'就无法查询得到结果呢？ ","date":"2016-04-25","heading":"问题描述","objectID":"/post/traps-in-sql-null/:1:0","tags":["SQL"],"title":"SQL语句中关于NULL的那些坑","uri":"/post/traps-in-sql-null/#问题描述"},{"categories":["Development"],"collections":null,"content":"原因追溯 查看数据表qms_branch的结构，看到types字段的属性为：DEFAULT NULL。 经过查询资料，在w3schools上找到了答案。 NULL is used as a placeholder for unknown or inapplicable values, it is treated differently from other values. It is not possible to test for NULL values with comparison operators, such as =, \u0026lt;, or \u0026lt;\u0026gt;. We will have to use the IS NULL and IS NOT NULL operators instead. 也就是说，在SQL中，NULL并不能采用!=与数值进行比较，若要进行比较，我们只能采用IS NULL或IS NOT NULL。 于是，我们将SQL语句改为如下形式： SELECT * FROM qms_branch WHERE types IS NULL or types != 'dashboard' 再次查询时，结果集就包含performance_trunk分支了。 ","date":"2016-04-25","heading":"原因追溯","objectID":"/post/traps-in-sql-null/:2:0","tags":["SQL"],"title":"SQL语句中关于NULL的那些坑","uri":"/post/traps-in-sql-null/#原因追溯"},{"categories":["Development"],"collections":null,"content":"问题延伸 通过上面例子，我们知道在对NULL进行判断处理时，只能采用IS NULL或IS NOT NULL，而不能采用=, \u0026lt;, \u0026lt;\u0026gt;, !=这些操作符。 那除此之外，还有别的可能存在的坑么？ 再看一个例子： 有一张数据表table_foo，其中有一个字段value_field，我们想从这张表中筛选出所有value_field为\u0026rsquo;value1\u0026rsquo;，\u0026lsquo;value2\u0026rsquo;或NULL的记录。 那么，我们采用IN操作符，通过如下SQL语句进行查询。 SELECT * FROM table_foo WHERE value_field IN ('value1', 'value2', NULL) 这会存在问题么？我们并没有采用=, \u0026lt;, \u0026lt;\u0026gt;, !=对NULL进行比较哦。 答案是同样存在问题！ 因为在SQL中，IN语句会被转换为多个=语句。例如，上面例子中的SQL在执行时就会被转换为如下SQL语句： SELECT * FROM table_foo WHERE value_field = 'value1' OR value_field = 'value2' OR value_field = NULL 而这个时候，执行value_field = NULL时就会出现问题了。 正确的做法应该是将NULL相关的判断独立出来，如下SQL才是正确的写法。 SELECT * FROM table_foo WHERE value_field IN ('value1', 'value2') OR value_field IS NULL ","date":"2016-04-25","heading":"问题延伸","objectID":"/post/traps-in-sql-null/:3:0","tags":["SQL"],"title":"SQL语句中关于NULL的那些坑","uri":"/post/traps-in-sql-null/#问题延伸"},{"categories":["Testing"],"collections":null,"content":"本文对Android设备CPU的状态查看方法和锁频（lock frequency）方法进行详细介绍。这有什么用？作为测试工程师，你值得了解。 ","date":"2016-04-18","heading":"","objectID":"/post/Android-CPU-lock-frequency/:0:0","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#"},{"categories":["Testing"],"collections":null,"content":"CPU频率 首先说下CPU的频率。我们都知道，CPU的工作频率越高，运算就越快，但能耗也更高。然而很多时候，设备并不需要那么高的计算性能，这个时候，我们就希望能降低CPU的工作频率，追求较低的能耗，以此实现更长的待机时间。 基于此需求，当前电子设备的CPU都会存在多个工作频率，并能根据实际场景进行CPU频率的自动切换，以此达到平衡计算性能与能耗的目的。 ","date":"2016-04-18","heading":"CPU频率","objectID":"/post/Android-CPU-lock-frequency/:1:0","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#cpu频率"},{"categories":["Testing"],"collections":null,"content":"锁频的用途 那么为什么需要锁频呢？ 对于普通用户来说，可能对这些场景比较熟悉： 在家用笔记本电脑玩游戏的时候，电脑连着电源，不在乎能耗，只想要尽可能高的性能，这个时候就选择高性能模式，即保持CPU在最高频率工作。 旅行途中使用笔记本电脑，靠电池供电，希望电脑能待机尽可能久，这时就选择省电模式，即CPU保持在最低频率运行。 作为一名测试工程师，我们在进行软件测试的时候，为了让测试结果真实反映软件本身的效率，从控制变量法的角度，我们希望测试结果尽量不受到硬件本身的影响。这个时候，我们就可以尝试对设备的CPU进行锁频，即保证在测试的过程中，硬件设备的CPU运行在一个恒定的频率。 说到这里先埋个伏笔，在chromium官方测试库中，部分测试场景在初始化测试环境时，就会将设备所有CPU的频率调到最高状态，后续我会单独以一篇博客的形式对那部分的源码进行分析。对于等不及的朋友，可以先去看下源码，源码路径为pylib/perf/PerfControl.SetHighPerfMode。 ","date":"2016-04-18","heading":"锁频的用途","objectID":"/post/Android-CPU-lock-frequency/:2:0","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#锁频的用途"},{"categories":["Testing"],"collections":null,"content":"查看CPU状态信息 在修改CPU的状态之前，我们需要先查看CPU的属性和状态信息，这样才能有针对性地进行正确的设置。 对于CPU的状态，我们通常会关注两类信息，一是整体层面的，即CPU运行的核数；二是细节层面的，即各个CPU的工作状态，包括所处工作模式、频率大小等。 在Android系统中，CPU相关的信息存储在/sys/devices/system/cpu目录的文件中，我们可以通过读取该目录下的特定文件获得当前设备的CPU状态信息，也可以通过对该目录下的特定文件进行写值，实现对CPU频率等状态信息的更改。 本文以Nexus 5（系统版本5.1.1）为例，后面的例子均以该设备为例。不同的机型和Android系统版本可能会存在一些差异，请知悉。 在/sys/devices/system/cpu目录中，文件结构如下所示。 shell@hammerhead:/sys/devices/system/cpu $ ll drwxr-xr-x root root 2016-01-20 01:36 cpu0 drwxr-xr-x root root 2016-01-20 21:06 cpu1 drwxr-xr-x root root 2016-01-20 21:07 cpu2 drwxr-xr-x root root 2016-01-20 21:07 cpu3 -rw------- root root 4096 1970-01-17 10:27 cpuctl drwxr-xr-x root root 1970-01-17 10:27 cpufreq drwxr-xr-x root root 1970-01-17 10:27 cpuidle -r--r--r-- root root 4096 1970-01-17 10:27 kernel_max -r--r--r-- root root 4096 1970-01-17 10:27 offline -r--r--r-- root root 4096 1970-01-17 10:27 online -r--r--r-- root root 4096 1970-01-17 10:27 possible drwxr-xr-x root root 1970-01-17 10:27 power -r--r--r-- root root 4096 1970-01-17 10:27 present -rw-r--r-- root root 4096 1970-01-17 10:27 uevent ","date":"2016-04-18","heading":"查看CPU状态信息","objectID":"/post/Android-CPU-lock-frequency/:3:0","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#查看cpu状态信息"},{"categories":["Testing"],"collections":null,"content":"1、view overall cpu info 在possible文件中，存储的是当前设备可用的CPU，显示形式以数字的形式。例如0-3代表的就是当前设备总共有4个核，编号分别为0，1，2，3。 shell@hammerhead:/sys/devices/system/cpu $ cat possible 0-3 在online文件中，存储的是当前设备正在运行的CPU。因为有时候设备不需要很高的性能，就可以将部分CPU关闭。不过需要注意的是，不管什么时候，CPU0始终都会处于运行状态。online文件的存储格式与possible类似，如果只有部分CPU运行，且CPU编号不连续的时候，会以逗号进行隔开；例如，0,2表示当前CPU0和CPU2处于运行状态。 shell@hammerhead:/sys/devices/system/cpu $ cat online 0,2 对应的，offline文件标示的是当前设备处于关闭状态的CPU，这和online作为互补，并集刚好就是设备的所有CPU，即possible文件中的内容。 shell@hammerhead:/sys/devices/system/cpu $ cat offline 1,3 ","date":"2016-04-18","heading":"1、view overall cpu info","objectID":"/post/Android-CPU-lock-frequency/:3:1","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#1view-overall-cpu-info"},{"categories":["Testing"],"collections":null,"content":"2、view specified cpu info 接下来，我们要获取到特定CPU的信息，就需要进入到对应的文件夹，例如，cpu0/对应的就是CPU0的信息。 在/sys/devices/system/cpu/cpu0目录中，文件结构如下所示。 shell@hammerhead:/sys/devices/system/cpu $ ll cpu0 drwxr-xr-x root root 2016-01-20 01:37 cpufreq drwxr-xr-x root root 1970-01-17 10:27 cpuidle -r-------- root root 4096 1970-01-17 10:27 crash_notes -rw-r--r-- root root 4096 2016-01-20 01:36 online drwxr-xr-x root root 1970-01-17 10:27 power drwxr-xr-x root root 1970-01-17 10:27 rq-stats lrwxrwxrwx root root 1970-01-17 10:27 subsystem drwxr-xr-x root root 1970-01-17 10:27 topology -rw-r--r-- root root 4096 1970-01-17 10:27 uevent 其中，online文件的内容表示当前CPU是否处于运行状态，若处于运行状态，则内容为1，否则为0；这个和上面讲到的/sys/devices/system/cpu/online能进行对应。 shell@hammerhead:/sys/devices/system/cpu $ cat cpu0/online 1 在cpu0/cpufreq/目录下，存储的就是与CPU0的频率相关的信息，文件结构如下所示。 shell@hammerhead:/sys/devices/system/cpu $ ll cpu0/cpufreq/ -rw-r--r-- root root 4096 2016-01-20 01:57 UV_mV_table -r--r--r-- root root 4096 2016-01-20 01:57 affected_cpus -r--r--r-- root root 4096 2016-01-20 01:57 cpu_utilization -r-------- root root 4096 2016-01-20 01:57 cpuinfo_cur_freq -r--r--r-- root root 4096 2016-01-20 02:00 cpuinfo_max_freq -r--r--r-- root root 4096 2016-01-20 01:39 cpuinfo_min_freq -r--r--r-- root root 4096 2016-01-20 01:57 cpuinfo_transition_latency -r--r--r-- root root 4096 2016-01-20 01:57 related_cpus -r--r--r-- root root 4096 2016-01-20 01:39 scaling_available_frequencies -r--r--r-- root root 4096 2016-01-20 01:57 scaling_available_governors -r--r--r-- root root 4096 2016-01-20 01:50 scaling_cur_freq -r--r--r-- root root 4096 2016-01-20 01:57 scaling_driver -rw-r--r-- root root 4096 2016-01-20 01:50 scaling_governor -rw-r--r-- root root 4096 2016-01-20 08:29 scaling_max_freq -rw-r--r-- root root 4096 2016-01-20 08:29 scaling_min_freq -rw-r--r-- root root 4096 2016-01-20 02:52 scaling_setspeed 在这个目录中，我们需要关注的文件比较多。 首先是scaling_available_governors和scaling_governor。这里的governor大家可以理解为CPU的工作模式，scaling_available_governors中存储了当前CPU支持的所有工作模式，而scaling_governor存储的是CPU当前所处的工作模式。 shell@hammerhead:/sys/devices/system/cpu $ cat cpu0/cpufreq/scaling_available_governors impulse dancedance smartmax interactive conservative ondemand userspace powersave Lionheart bioshock performance shell@hammerhead:/sys/devices/system/cpu $ cat cpu0/cpufreq/scaling_governor performance 可以看到，Nexus 5支持非常多的工作模式，这里只对几个常见的模式进行简单说明。 performance：最高性能模式，即使系统负载非常低，cpu也在最高频率下运行。 powersave：省电模式，与performance模式相反，cpu始终在最低频率下运行。 ondemand：CPU频率跟随系统负载进行变化。 userspace：可以简单理解为自定义模式，在该模式下可以对频率进行设定。 对于各种模式对应的含义和策略，在此不进行展开，大家有兴趣的可以自行搜索。 然后是CPU的工作频率范围，对应的文件有cpuinfo_max_freq、cpuinfo_min_freq、scaling_max_freq、scaling_min_freq。 以cpuinfo_为前缀的表示CPU硬件支持的频率范围，反映的是CPU自身的特性，与CPU的工作模式无关。而以scaling_为前缀的表示CPU在当前工作模式下的频率范围。 那么，当前CPU工作的频率是多少，我们要怎么查看呢？ 查看cpuinfo_cur_freq或scaling_cur_freq即可。cpuinfo_cur_freq代表通过硬件实际上读到的频率值，而scaling_cur_freq则是软件当前的设置值，多数情况下这两个值是一致的，但是也有可能因为硬件的原因，有微小的差异。 root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # cat cpuinfo_cur_freq 1574400 root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # cat scaling_cur_freq 1574400 ","date":"2016-04-18","heading":"2、view specified cpu info","objectID":"/post/Android-CPU-lock-frequency/:3:2","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#2view-specified-cpu-info"},{"categories":["Testing"],"collections":null,"content":"更改CPU状态信息 最后回到我们本文的主题，如何对CPU的频率进行设定呢？ 这也和CPU信息查看对应，分为对CPU整体运行情况的设置，和对特定CPU工作模式的设定。 在此，有两点需要特别进行说明。 首先，对于高通的CPU，存在一个系统服务，叫作mpdecision service。当这个系统服务处于运行状态时，我们无法对CPU的状态信息进行更改。因此，如果我们要更改高通CPU的工作模式，第一步要做的就是终止mpdecision系统服务。 操作起来也很简单，在Android shell里面执行如下命令即可。 stop mpdecision 第二点需要注意的是，如果我们想要实现对特定CPU的工作状态进行设置，就必须将scaling_governor设置为userspace，只有这样，我们才能对scaling_setspeed进行设置。 root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # cat scaling_setspeed root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # echo userspace \u003e scaling_governor root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # cat scaling_setspeed 1574400 ","date":"2016-04-18","heading":"更改CPU状态信息","objectID":"/post/Android-CPU-lock-frequency/:4:0","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#更改cpu状态信息"},{"categories":["Testing"],"collections":null,"content":"1、set overall cpu info 从宏观层面，我们可以对CPU运行的核数进行设置，即可实现对特定CPU的开启和关闭。当然，我们在前面已经说过，CPU0始终会处于运行状态，因此我们无法将CPU0进行关闭。 设置的方式很简单，就是往/sys/devices/system/cpu/cpu[i]/online文件中写值即可，写1时开启指定CPU，写0时关闭指定CPU。 # turn off cpu1 root@hammerhead:/sys/devices/system/cpu/cpu1 # echo 0 \u003e online root@hammerhead:/sys/devices/system/cpu/cpu1 # cat online 0 ","date":"2016-04-18","heading":"1、set overall cpu info","objectID":"/post/Android-CPU-lock-frequency/:4:1","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#1set-overall-cpu-info"},{"categories":["Testing"],"collections":null,"content":"2、set specified cpu info 在对特定CPU的频率进行设定前，我们需要知道的是，CPU并不能工作在任意频率下，我们只能将CPU的频率设定为它支持的数值。 通过查看scaling_available_frequencies，我们可以获得当前CPU支持的频率值。 root@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq # cat scaling_available_frequencies 300000 422400 652800 729600 883200 960000 1036800 1190400 1267200 1497600 1574400 1728000 1958400 2265600 接下来，我们就可以对CPU的工作频率进行设置了。 如何进行设置呢？刚开始的时候，我觉得将特定的频率值写入scaling_setspeed或scaling_cur_freq就可以了，通过Google搜索得到的方法中也是这种方式。 但经过尝试，发现并不可行。为什么会这样？我也还没有找到答案，希望知道原因的朋友能告诉我。 最后经过尝试，发现通过同时将scaling_max_freq和scaling_min_freq设置为目标频率值，就可以成功地对CPU频率完成设置。 # before setting shell@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq $ cat scaling_cur_freq 1574400 # setting shell@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq $ echo 1728000 \u003e scaling_min_freq shell@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq $ echo 1728000 \u003e scaling_max_freq # after setting shell@hammerhead:/sys/devices/system/cpu/cpu0/cpufreq $ cat scaling_cur_freq 1728000 ","date":"2016-04-18","heading":"2、set specified cpu info","objectID":"/post/Android-CPU-lock-frequency/:4:2","tags":["Android"],"title":"对 Android 设备 CPU 进行锁频","uri":"/post/Android-CPU-lock-frequency/#2set-specified-cpu-info"},{"categories":["Testing"],"collections":null,"content":"在进行Android App持续集成性能测试的时候，需要自动化实现UI层面的一些操作，常见的几种场景包括： 测试帧率时，需要模拟滑屏、拖拽操作； 初次安装app后启动应用时，需要点击按钮跳过协议页面； 从Android M(6.0)开始，首次启动应用时会进行系统权限校验，需要勾选checkbox以及点击按钮； 模拟点击按钮登录用户账号； 这些场景虽然看上去互不相关，但是从测试的角度，UI层面的操作应该都可以归为两类：控件定位和执行动作。 本文将从测试的角度出发，介绍Android UI实现自动化测试的基本方法，并着重讲解通过Python操作Android UI的一般性流程。后续，我会在单独的博客文章中介绍UI操作在Android App持续集成性能测试中的应用。 ","date":"2016-04-17","heading":"","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:0:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#"},{"categories":["Testing"],"collections":null,"content":"先说uiautomator 要对Android的UI实现自动化操作，首先想到的就是Google官方的UI Automator，通过这个工具，可以很好地实现Android UI自动化。 UI Automator是一个从Android 4.3 (API level 18) 引入的测试框架，它提供了一套丰富的API，可以在不依赖于目标app内部实现机制的基础上，方便地创建自动化测试用例，实现用户对Android UI各种界面交互操作的模拟。 对于UI Automator的使用介绍，我从创建测试用例和执行测试两部分进行。 首先是创建测试用例，流程大致如下： 采用JUnit Library创建Java Project 将Android-sdk中的android.jar和uiautomator.jar添加进项目 使用UI Automator提供的API编写测试用例，实现对UI界面操作的定制场景，例如点击按钮、滑动屏幕等 将项目编译生成jar文件，例如memorytest.jar 创建好了测试用例，那要怎样执行呢？ 从Android 4.3开始，系统中就自带了uiautomator命令，命令的路径为/system/bin/uiautomator。 由于uiautomator命令是运行在Android设备中的，因此需先要将编译好的jar文件push到Android设备中，导入目录为/data/local/tmp/。 ➜ adb push memorytest.jar /data/local/tmp/ 完成以上准备工作后，就可以在Android的Terminal中执行了uiautomator命令了。 详细的uiautomator命令用法可参考官方文档，这里只列出最常用的一种方式： ➜ adb shell shell@hammerhead:/ $ uiautomator runtest memorytest.jar -c com.uc.util.TestCases#slideScreen -e pkgName com.UCMobile 在如上示例中，memorytest.jar是我们之前编译好的测试用例jar文件名，com.uc.util.TestCases#slideScreen是Java工程中的类名和方法名，-e后面是传入测试类的name-value参数。 这里就不再对UI Automator进行过多介绍，后续我会再针对UI Automator单独写一篇更加详细的教程。 ","date":"2016-04-17","heading":"先说uiautomator","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:1:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#先说uiautomator"},{"categories":["Testing"],"collections":null,"content":"Python调用uiautomator 通常，我们的持续集成性能测试代码是采用Python编写的，那如何通过Python调用uiautomator呢？ 如果沿用上面介绍的流程，Python调用uiautomator实现自动化测试应该也会采用同样的思路： 首先，需要在Java Project使用UI Automator API编写UI测试场景，编译生成jar文件，并将这个文件导入到Python项目中。 然后，在Python测试代码中，调用uiautomator命令前需要先将jar文件push到Android设备。 jar_file_path = os.path.join(_project_root_path, \"resource/jar/memorytest.jar\") cmdexec.push(jar_file_path, '/data/local/tmp/') 接下来，就可以在Python中组装测试命令，并将命令传到Android设备中进行执行。 cmd = \"uiautomator runtest memorytest.jar -c com.uc.util.TestCases#slideScreen -e pkgName com.UCMobile\" cmdexec.sendShellCommand(cmd, timeout_time=None) 需要说明的是，上面代码中的cmdexec是一个封装类的实例，主要实现的是通过adb与Android设备进行交互，例如push/pull文件、执行Android shell命令等。 经过这么一个流程，可能大家都会感觉到实现起来太过复杂。特别地，如果需要增加一个测试场景，就又要到Java项目中添加测试代码，重新编译为jar文件，并将新的jar文件添加到Python项目中，或者替换原有jar文件。这还不算完，同样地，在Python项目中也需要针对新增的测试场景进行相应的编码。 难道就没有更便捷的方式么？ 幸运的是，之前已经有人针对这个痛点填了坑，并在GitHub上进行了开源，项目名称是xiaocong/uiautomator（为了便于与Google官方的uiautomator进行区分，后面统一采用pyuiautomator进行描述）。它实现的功能很明确，从项目简介就一目了然。 Python wrapper of Android uiautomator test tool. 该工具以Python package的形式存在，可通过pip在测试机（PC）上进行安装。 pip install uiautomator 安装完毕后，无需在Android设备上安装任何东西，只要设备通过adb与主机相连，就可以在主机中通过Python操作Android设备的UI控件。 如下是简单的示例： from uiautomator import device as d # Turn on screen d.screen.on() # press back key d.press.back() # click (x, y) on screen d.click(x, y) # check unchecked checkbox checkbox = d(className='android.widget.CheckBox', checked='false') checkbox.click() # click button with text 'Next' d(text=\"Clock\").click() button = d(className='android.widget.Button', text='Next') button.click() # swipe from (sx, sy) to (ex, ey) d.swipe(sx, sy, ex, ey) 更详细的使用方法可参考项目文档。 通过这种方式，我们就可以极大地简化Python操作UI控件的方式，不用再对照着UI Automator API使用Java编写测试用例，也不用再反复编译jar文件，省去了同一个测试场景需要维护两套代码的麻烦，整个过程也更加Pythonic。 当然，pyuiautomator也并非完美，毕竟它不是Google官方维护的，从我实际使用的经历来看，有时候也会存在一些问题。这里先不展开，后续会单独写一篇博客。不过，总的来说，pyuiautomator还是非常值得使用的，它的确可以大大简化测试工作量。 现在继续本文的主题，我们怎么采用pyuiautomator来进行UI控件操作呢？ 其实从上面的示例代码中就可以看到，UI控件的操作分为两步，首先是对目标控件进行定位，然后是对定位的控件执行动作。 ","date":"2016-04-17","heading":"Python调用uiautomator","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:2:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#python调用uiautomator"},{"categories":["Testing"],"collections":null,"content":"定位控件 在UI中，每个控件都有许多属性，例如class、text、index等等。我们要想对某一个控件进行操作，必然需要先对目标控件进行选择。 在上面的pyuiautomator用法示例中，已经包含了控件选择的代码： checkbox = d(className='android.widget.CheckBox', checked='false') button = d(className='android.widget.Button', text='Next') 在这两行代码中，分别实现了对checkbox和button的选择。基本原则就是，通过指定的控件属性，可以唯一确定目标控件。 那么，我们怎么知道目标控件有哪些属性，以及对应的属性值是什么呢？ Google官方提供了两个工具，hierarchyviewer和uiautomatorviewer，这两个工具都位于\u0026lt;android-sdk\u0026gt;/tools/目录下。关于这两个工具的区别及其各自的特点，本文不进行详细介绍，我们当前只需要知道，在查看控件属性方面，这两个工具实现的功能完全相同，界面也完全相同，我们任选其一即可。 {: .center} 通过这个工具，我们可以查看到当前设备屏幕中的UI元素信息： 当前Android设备屏幕中显示的UI元素的详细属性信息 当前Android设备屏幕中所有UI元素的层级关系结构 需要强调的是，工具每执行一次dump，获取到的UI信息仅限于当前屏幕中前端（foreground）显示的内容。 获得UI元素的信息后，由于UI控件是以树形结构进行存储，而且每个控件都存在index属性值，因此，理论上讲，通过层级结构和index属性就能唯一指定任意UI控件。 然而，这并不是最佳实践。因为通常情况下，UI布局的树形结构层级较多，通过层级关系进行指定时会造成书写极为复杂，而且从代码中很难一眼看出指定的是哪个控件。不信？看下这个例子就能体会了。如下代码对应的就是上图中红色方框的控件，可以看出，要是寻找每个控件都要从顶级节点开始，要将根节点到目标控件的路径找出来，这也是一个很大的工作量，而且很容易出错。 d(className='android.widget.FrameLayout') .child(className='android.widget.LinearLayout') .child(className='android.widget.FrameLayout') .child(className='android.widget.FrameLayout') .child(className='android.widget.FrameLayout') .child(className='android.widget.FrameLayout') .child(className='android.widget.FrameLayout') .child(className='android.view.View') .child(className='android.widget.FrameLayout') .child(className='android.widget.FrameLayout') .child(className='android.view.View') .child(className='android.widget.FrameLayout') .child(className='android.widget.TextView') 在实际应用中，我们更多地是采用控件的属性信息来定位控件，一般情况下，采用属性值text就能唯一确定目标控件了。例如同样是对上图中的红色方框进行定位，如下代码就足够了。 d(text='UC头条有新消息，点击刷新') 不过，经常会出现目标控件的text属性值为空的情况，这个时候我们一般就会采用class属性和部分层级关系组合的方式。同样是上图中的红色方框，我们也可以使用如下方式进行定位。 d(className='android.widget.FrameLayout').child(className='android.widget.TextView') 可以看出，同一个控件，我们可以采用多种方式进行定位。具体选择何种定位方式，可以参考如下准则： 定位方式应保证定位准确 定位方式应尽可能简洁 定位方式应尽可能稳定，即使屏幕界面出现变化，定位方式也不会失效 这里说到了定位方式的准确性，那要如何进行验证呢？技巧是，采用.count和.info属性值即可。 \u003e\u003e\u003e d(text='UC头条有新消息，点击刷新').count 1 \u003e\u003e\u003e d(text='UC头条有新消息，点击刷新').info {u'contentDescription': None, u'checked': False, u'clickable': True, u'scrollable': False, u'text': u'UC\\u5934\\u6761\\u6709\\u65b0\\u6d88\\u606f\\uff0c\\u70b9\\u51fb\\u5237\\u65b0', u'packageName': u'com.UCMobile.projectscn1098RHEAD', u'selected': False, u'enabled': True, u'bounds': {u'top': 1064, u'left': 42, u'right': 1038, u'bottom': 1136}, u'className': u'android.widget.TextView', u'focusable': False, u'focused': False, u'checkable': False, u'resourceName': None, u'longClickable': False, u'visibleBounds': {u'top': 1064, u'left': 42, u'right': 1038, u'bottom': 1136}, u'childCount': 0} 当.count属性值为1，.info属性值的内容与目标控件的属性值一致时，就可以确定我们采用的定位方式是准确的。 ","date":"2016-04-17","heading":"定位控件","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:3:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#定位控件"},{"categories":["Testing"],"collections":null,"content":"控件操作 定位到具体的控件后，操作就比较容易了。 在pyuiautomator中，对UI Automator的UI操作动作进行了封装，常用的操作动作有： .click() .long_click() .swipe .drag 更多的操作可根据我们测试场景的实际需求，查询pyuiautomator文档找到合适的方法。 ","date":"2016-04-17","heading":"控件操作","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:4:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#控件操作"},{"categories":["Testing"],"collections":null,"content":"总结 看到这里，相信大家对Android UI自动化测试已经有了基本的认识。由于篇幅关系，我没有将所有内容都包含进来，而是打算后续分多个专题以单独博文的形式进行展开（不知不觉就给自己埋下了坑^_^）。 ","date":"2016-04-17","heading":"总结","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:5:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#总结"},{"categories":["Testing"],"collections":null,"content":"参考文档 http://developer.android.com/tools/testing-support-library/index.html#UIAutomator https://github.com/xiaocong/uiautomator ","date":"2016-04-17","heading":"参考文档","objectID":"/post/Android-performance-test-UI-Automation-with-Python/:6:0","tags":["Android","UI","Python"],"title":"Android App持续集成性能测试：使用Python实现UI自动化","uri":"/post/Android-performance-test-UI-Automation-with-Python/#参考文档"},{"categories":["Development"],"collections":null,"content":"很早以前就听说过了函数式编程，印象中是一种很晦涩难懂的编程模式，但却一直没有去进行了解。 恰好这周组内的周会轮到我主持，一时也没想到要分享什么。灵光一闪，就选定函数式编程这个主题吧，反正组里的同事都没有学过，只需要讲解入门方面的知识就好，也正好可以借这个机会逼迫自己去学习下这种新的编程方式。 经过初步了解，发现支持函数式编程的语言挺多的，除了像Lisp、Scheme、Haskell、Erlang这样专用的函数式编程语言，我们常用的好多通用型编程语言（如Java、Python、Ruby、Javascript等）都支持函数式编程模式。考虑了下实际情况，最终还是选择Python作为函数式编程的入门语言，因为组内同事都熟悉Python，以此作为切入点不会产生太大困难。 经过查询资料和初步学习，对函数式编程有了些概念，经过整理，便形成了分享PPT。 以下便是这次分享的内容。 ","date":"2016-04-08","heading":"","objectID":"/post/python-functional-programming-getting-started/:0:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#"},{"categories":["Development"],"collections":null,"content":"目标 通常，我们在新学习一门技术或者编程语言的时候，通常都会先从相关概念和特性入手。对于新接触函数式编程的人来说，可能会想知道如下几点： 什么是函数式编程？ 函数式编程的特点？ 函数式编程的用途？ 函数式编程相比于命令式编程和面向对象编程的优缺点？ 但是我这次分享却没有按照这个思路，因为我感觉在一开始就向听众灌输太多概念性的东西，反倒会让听众感到迷糊。因为经过查询资料发现，对于什么是函数化编程，很难能有一个协调一致的定义。而且由于我也是新接触，自身的理解可能会存在较大的偏差。 因此，我决定分享内容尽量从大家熟悉的命令式编程切入，通过大量实例来向听众展现函数式编程思维方式的不同之处。在这之后，再回过头看这几个问题，相信听众应该都会有更深刻的理解。 考虑到实际情况，本次分享希望能达成的目标是： 了解函数式编程与命令式编程的主要区别 掌握Python语言函数式编程的基本函数和算子 会将简单的命令式编程语句转换为函数式编程 ","date":"2016-04-08","heading":"目标","objectID":"/post/python-functional-programming-getting-started/:1:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#目标"},{"categories":["Development"],"collections":null,"content":"命令式编程 \u0026amp; 函数式编程 首先从大家熟悉的命令式编程开始，我们先回顾下平时在写代码时主要的情景。 其实，不管我们的业务代码有多复杂，都离不开以下几类操作： 函数定义：def 条件控制：if, elif, else 循环控制：for, break, continue, while 当然，这只是部分操作类型，除此之外还应该有类和模块、异常处理等等。但考虑到是入门，我们就先只关注上面这三种最常见的操作。 对应地，函数式编程也有自己的关键字。在Python语言中，用于函数式编程的主要由3个基本函数和1个算子。 基本函数：map()、reduce()、filter() 算子(operator)：lambda 令人惊讶的是，仅仅采用这几个函数和算子就基本上可以实现任意Python程序。 当然，能实现是一回事儿，实际编码时是否这么写又是另外一回事儿。估计要真只采用这几个基本单元来写所有代码的话，不管是在表达上还是在阅读上应该都挺别扭的。不过，尝试采用这几个基本单元来替代上述的函数定义、条件控制、循环控制等操作，对理解函数式编程如何通过函数和递归表达流程控制应该会很有帮助。 在开始尝试将命令式编程转换为函数式编程之前，我们还是需要先熟悉下这几个基本单元。 ","date":"2016-04-08","heading":"命令式编程 \u0026amp; 函数式编程","objectID":"/post/python-functional-programming-getting-started/:2:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#命令式编程--函数式编程"},{"categories":["Development"],"collections":null,"content":"Python函数式编程的基本单元 ","date":"2016-04-08","heading":"Python函数式编程的基本单元","objectID":"/post/python-functional-programming-getting-started/:3:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#python函数式编程的基本单元"},{"categories":["Development"],"collections":null,"content":"lambda lambda这个关键词在很多语言中都存在。简单地说，它可以实现函数创建的功能。 如下便是lambda的两种使用方式。 func1 = lambda : func2 = lambda x : func3 = lambda x,y : 在第一条语句中，采用lambda创建了一个无参的函数func1。这和下面采用def创建函数的效果是相同的。 def func1(): 在第二条和第三条语句中，分别采用lambda创建了需要传入1个参数的函数func2，以及传入2个参数的函数func3。这和下面采用def创建函数的效果是相同的。 def func2(x): def func3(x,y): 需要注意的是，调用func1的时候，虽然不需要传入参数，但是必须要带有括号()，否则返回的只是函数的定义，而非函数执行的结果。这个和在ruby中调用无参函数时有所不同，希望ruby程序员引起注意。 \u003e\u003e\u003e func = lambda : 123 \u003e\u003e\u003e func ","date":"2016-04-08","heading":"lambda","objectID":"/post/python-functional-programming-getting-started/:3:1","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#lambda"},{"categories":["Development"],"collections":null,"content":"map() map()函数的常见调用形式如下所示： map(func, iterable) map()需要两个必填参数，第一个参数是一个函数名，第二个参数是一个可迭代的对象，如列表、元组等。 map()实现的功能很简单，就是将第二个参数（iterable）中的每一个元素分别传给第一个参数（func），依次执行函数得到结果，并将结果组成一个新的list对象后进行返回。返回结果永远都是一个list。 简单示例如下： \u003e\u003e\u003e double_func = lambda s : s * 2 \u003e\u003e\u003e map(double_func, [1,2,3,4,5]) [2, 4, 6, 8, 10] 除了传入一个可迭代对象这种常见的模式外，map()还支持传入多个可迭代对象。 map(func, iterable1, iterable2) 在传入多个可迭代对象的情况下，map()会依次从所有可迭代对象中依次取一个元素，组成一个元组列表，然后将元组依次传给func；若可迭代对象的长度不一致，则会以None进行补上。 通过以下示例应该就比较容易理解。 \u003e\u003e\u003e plus = lambda x,y : (x or 0) + (y or 0) \u003e\u003e\u003e map(plus, [1,2,3], [4,5,6]) [5, 7, 9] \u003e\u003e\u003e map(plus, [1,2,3,4], [4,5,6]) [5, 7, 9, 4] \u003e\u003e\u003e map(plus, [1,2,3], [4,5,6,7]) [5, 7, 9, 7] 在上面的例子中，之所以采用x or 0的形式，是为了防止None + int出现异常。 需要注意的是，可迭代对象的个数应该与func的参数个数一致，否则就会出现异常，因为传参个数与函数参数个数不一致了，这个应该比较好理解。 \u003e\u003e\u003e plus = lambda x,y : x + y \u003e\u003e\u003e map(plus, [1,2,3]) Traceback (most recent call last): File \"\", line 1, in TypeError: () takes exactly 2 arguments (1 given) 另外，map()还存在一种特殊情况，就是func为None。这个时候，map()仍然是从所有可迭代对象中依次取一个元素，组成一个元组列表，然后将这个元组列表作为结果进行返回。 \u003e\u003e\u003e map(None, [1,2,3,4]) [1, 2, 3, 4] \u003e\u003e\u003e map(None, [1,2,3,4], [5,6,7,8]) [(1, 5), (2, 6), (3, 7), (4, 8)] \u003e\u003e\u003e map(None, [1,2,3,4], [5,6,7]) [(1, 5), (2, 6), (3, 7), (4, None)] \u003e\u003e\u003e map(None, [1,2,3,4], [6,7,8,9], [11,12]) [(1, 6, 11), (2, 7, 12), (3, 8, None), (4, 9, None)] ","date":"2016-04-08","heading":"map()","objectID":"/post/python-functional-programming-getting-started/:3:2","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#map"},{"categories":["Development"],"collections":null,"content":"reduce() reduce()函数的调用形式如下所示： reduce(func, iterable[, initializer]) reduce()函数的功能是对可迭代对象（iterable）中的元素从左到右进行累计运算，最终得到一个数值。第三个参数initializer是初始数值，可以空置，空置为None时就从可迭代对象（iterable）的第二个元素开始，并将第一个元素作为之前的结果。 文字描述可能不大清楚，看下reduce()的源码应该就比较清晰了。 def reduce(function, iterable, initializer=None): it = iter(iterable) if initializer is None: try: initializer = next(it) except StopIteration: raise TypeError('reduce() of empty sequence with no initial value') accum_value = initializer for x in it: accum_value = function(accum_value, x) return accum_value 再加上如下示例，对reduce()的功能应该就能掌握了。 \u003e\u003e\u003e plus = lambda x, y : x + y \u003e\u003e\u003e reduce(plus, [1,2,3,4,5]) 15 \u003e\u003e\u003e reduce(plus, [1,2,3,4,5], 10) 25 ","date":"2016-04-08","heading":"reduce()","objectID":"/post/python-functional-programming-getting-started/:3:3","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#reduce"},{"categories":["Development"],"collections":null,"content":"filter() filter()函数的调用形式如下： filter(func, iterable) filter()有且仅有两个参数，第一个参数是一个函数名，第二个参数是一个可迭代的对象，如列表、元组等。 filter()函数的调用形式与map()比较相近，都是将第二个参数（iterable）中的每一个元素分别传给第一个参数（func），依次执行函数得到结果；差异在于，filter()会判断每次执行结果的bool值，并只将bool值为true的筛选出来，组成一个新的列表并进行返回。 \u003e\u003e\u003e mode2 = lambda x : x % 2 \u003e\u003e\u003e filter(mode2, [1,2,3,4,5,6,7,8,9,10]) [1, 3, 5, 7, 9] 以上便是Python函数式编程基本单元的核心内容。 接下来，我们就开始尝试采用新学习到的基本单元对命令式编程中的条件控制和循环控制进行转换。 ","date":"2016-04-08","heading":"filter()","objectID":"/post/python-functional-programming-getting-started/:3:4","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#filter"},{"categories":["Development"],"collections":null,"content":"替换条件控制语句 在对条件控制进行替换之前，我们先来回顾下Python中对布尔表达式求值时进行的“短路”处理。 什么叫“短路”处理？简单地讲，就是如下两点： 在f(x) and g(y)中，当f(x)为false时，不会再执行g(y)，直接返回false 在f(x) or g(y)中，当f(x)为true时，不会再执行g(y)，直接返回true 结论是显然易现的，就不再过多解释。 那么，对应到条件控制语句，我们不难理解，如下条件控制语句和表达式是等价的。 # flow control statement if : func1() elif : func2() else: func3() # Equivalent \"short circuit\" expression ( and func1()) or ( and func2()) or (func3()) 通过这个等价替换，我们就去除掉了if/elif/else关键词，将条件控制语句转换为一个表达式。那这个表达式和函数式编程有什么关系呢？ 这时我们回顾上面讲过的lambda，会发现lambda算子返回的就是一个表达式。 基于这一点，我们就可以采用lambda创建如下函数。 \u003e\u003e\u003e pr = lambda s:s \u003e\u003e\u003e print_num = lambda x: (x==1 and pr(\"one\")) \\ .... or (x==2 and pr(\"two\")) \\ .... or (pr(\"other\")) \u003e\u003e\u003e print_num(1) 'one' \u003e\u003e\u003e print_num(2) 'two' \u003e\u003e\u003e print_num(3) 'other' 通过函数调用的结果可以看到，以上函数实现的功能与之前的条件控制语句实现的功能完全相同。 到这里，我们就实现了命令式条件控制语句向函数式语句的转换。并且这个转换的方法是通用的，所有条件控制语句都可以采用这种方式转换为函数式语句。 ","date":"2016-04-08","heading":"替换条件控制语句","objectID":"/post/python-functional-programming-getting-started/:4:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#替换条件控制语句"},{"categories":["Development"],"collections":null,"content":"替换循环控制语句 接下来我们再看循环控制语句的转换。在Python中，循环控制是通过for和while这两种方式实现的。 ","date":"2016-04-08","heading":"替换循环控制语句","objectID":"/post/python-functional-programming-getting-started/:5:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#替换循环控制语句"},{"categories":["Development"],"collections":null,"content":"替换for循环 for循环语句的替换十分简单，采用map()函数就能轻松实现。这主要是因为for语句和map()原理相同，都是对可迭代对象里面的每一个元素进行操作，因此转换过程比较自然。 # statement-based for loop for e in lst: func(e) # Equivalent map()-based loop map(func, lst) \u003e\u003e\u003e square = lambda x : x * x \u003e\u003e\u003e for x in [1,2,3,4,5]: square(x) ... 1 4 9 16 25 \u003e\u003e\u003e map(square, [1,2,3,4,5]) [1, 4, 9, 16, 25] ","date":"2016-04-08","heading":"替换for循环","objectID":"/post/python-functional-programming-getting-started/:5:1","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#替换for循环"},{"categories":["Development"],"collections":null,"content":"替换while循环 while循环语句的替换相比而言就复杂了许多。 下面分别是while循环语句及其对应的函数式风格的代码。 # statement-based while loop while : if : break else: # Equivalent FP-style recursive while loop def while_block(): if : return 1 else: return 0 while_FP = lambda: and (while_block() or while_FP()) while_FP() 这里的难点在于，函数式while_FP循环采用了递归的概念。当\u0026lt;condition\u0026gt;为true时，进入循环体，执行while_block()；若\u0026lt;break_condition\u0026gt;为true时，返回1，while_FP()调用结束；若\u0026lt;break_condition\u0026gt;为false时，返回0，会继续执行or右侧的while_FP()，从而实现递归调用；若\u0026lt;break_condition\u0026gt;始终为false，则会持续递归调用while_FP()，这就实现了while语句中同样的功能。 为了对函数式的while循环有更深刻的理解，可以再看下如下示例。这个例子是在网上找的，实现的是echo功能：输入任意非\u0026quot;quit\u0026quot;字符时，打印输入的字符；输入\u0026quot;quit\u0026quot;字符时，退出程序。 ➜ PythonFP python pyecho.py IMP -- 1 1 IMP -- 2 2 IMP -- abc abc IMP -- 1 + 1 1 + 1 IMP -- quit quit ➜ PythonFP 如下便是分别采用过程式和函数式语句实现的\u0026quot;echo\u0026quot;功能。 # imperative version of \"echo()\" def echo_IMP(): while 1: x = raw_input(\"IMP -- \") print x if x == 'quit': break echo_IMP() def monadic_print(x): print x return x # FP version of \"echo()\" echo_FP = lambda: monadic_print(raw_input(\"FP -- \"))=='quit' or echo_FP() echo_FP() ","date":"2016-04-08","heading":"替换while循环","objectID":"/post/python-functional-programming-getting-started/:5:2","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#替换while循环"},{"categories":["Development"],"collections":null,"content":"更多示例 到此为止，我们对函数式编程总算有了点认识，到达之前设定的目标应该是没有问题了，看来函数式编程也并没有想象中的那么难懂。 然而，这都只是函数式编程的皮毛而已，不信？再看下如下示例。 这个示例也是在网上找的，实现的是两个列表笛卡尔积的筛选功能，找出笛卡尔积元组集合中两个元素之积大于25的所有元组。 bigmuls = lambda xs,ys: filter(lambda (x,y):x*y \u003e 25, combine(xs,ys)) combine = lambda xs,ys: map(None, xs*len(ys), dupelms(ys,len(xs))) dupelms = lambda lst,n: reduce(lambda s,t:s+t, map(lambda l,n=n: [l]*n, lst)) print bigmuls([1,2,3,4],[10,15,3,22]) [(3, 10), (4, 10), (2, 15), (3, 15), (4, 15), (2, 22), (3, 22), (4, 22)] 虽然这个例子中lambda/map/reduce/filter都是我们已经比较熟悉了的基本单元，但是经过组合后，理解起来还是会比较吃力。 ","date":"2016-04-08","heading":"更多示例","objectID":"/post/python-functional-programming-getting-started/:6:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#更多示例"},{"categories":["Development"],"collections":null,"content":"总结 看到这里，有的同学就开玩笑说我这标题名称非常贴切，《Python的函数式编程\u0026ndash;从入门到⎡放弃⎦》，因为以后在工作中应该也不会再尝试使用函数式编程了，_。 不过，我还是觉得函数式编程挺有意思的，更高级的特性后面值得再继续学习。即使代码不用写成pure函数式风格，但在某些时候局部使用lambda/map/reduce/filter也能大大简化代码，也是一个不错的选择。 另外，通过此次分享，再次切身体会到了教授是最好的学习方式，只有当你真正能将一个概念讲解清楚的时候，你才算是掌握了这个概念。 ","date":"2016-04-08","heading":"总结","objectID":"/post/python-functional-programming-getting-started/:7:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#总结"},{"categories":["Development"],"collections":null,"content":"参考链接 http://www.ibm.com/developerworks/linux/library/l-prog/index.html ","date":"2016-04-08","heading":"参考链接","objectID":"/post/python-functional-programming-getting-started/:8:0","tags":["Python","函数式编程"],"title":"Python 的函数式编程 -- 从入门到⎡放弃⎦","uri":"/post/python-functional-programming-getting-started/#参考链接"},{"categories":["Testing"],"collections":null,"content":"现阶段，在微信朋友圈举办的投票活动层出不穷，相信已经有不少同学对此不胜其烦，因为总会时不时地冒出个人（亲戚、朋友、or whatever）来请你帮TA投票。 本文倒没有打算从道德或者情感层面来探讨这个问题，我所感兴趣的是，当前大多数投票活动其实都是存在明显漏洞的，通过简单的技术手段就可以实现“刷票”。 ","date":"2016-04-04","heading":"","objectID":"/post/cheat-in-wechat-moments-voting-activity/:0:0","tags":["案例分析"],"title":"微信朋友圈投票活动的刷票案例分析","uri":"/post/cheat-in-wechat-moments-voting-activity/#"},{"categories":["Testing"],"collections":null,"content":"案例描述 这里就有一个案例。 某美发网上商城（以下简称S商城）在微信平台上举办了一场在线投票活动，微信用户可通过活动链接访问到投票页面，对喜欢的发型师作品进行投票；每个微信帐号每天只能给单个作品投1张选票。 投票活动页面如下图所示： ","date":"2016-04-04","heading":"案例描述","objectID":"/post/cheat-in-wechat-moments-voting-activity/:1:0","tags":["案例分析"],"title":"微信朋友圈投票活动的刷票案例分析","uri":"/post/cheat-in-wechat-moments-voting-activity/#案例描述"},{"categories":["Testing"],"collections":null,"content":"漏洞分析 表面上看，S商城已经对投票活动进行了反作弊处理，因为限制了每个微信用户每天只能投一张票。如果用户都是正常地通过微信访问这个投票服务进行投票的话，的确是能起到预期效果的。 然而，如果查看投票页面的原始地址，即按住页面向下拖动，会发现屏幕顶端显示为\u0026quot;本网页由XXX提供\u0026quot;，需要注意的是，这里的\u0026quot;XXX\u0026quot;并不是\u0026quot;mp.weixin.qq.com\u0026quot;，而是S商城的域名。也就是说，这个投票活动的程序是运行在S商城的服务器上面的。 基于以上分析，可以推断出用户投票操作的网络拓扑结构示意图应该是这样的： 微信用户访问投票页面时，微信服务器只是进行了请求转发，具体的投票计数与校验都是在S商城的服务器上的。 那么，S商城是怎么来区分投票用户的呢？ 这里就涉及到微信公众平台OpenID的概念了。官方对OpenID的解释是：加密后的微信号，每个用户对每个公众号的OpenID是唯一的。 要验证这一点也很容易，只需要通过采用多个微信账号进行投票，并对投票过程进行网络抓包，查看POST中的参数就可以证实。 基于这一点，微信公众平台在转发投票请求时，会在POST参数中包含用户的OpenID；S商城在接收到投票的POST请求后，通过查询当前OpenID是否在当天已经投过票，就可以阻止单一用户重复投票的行为了。 然而，这里面却存在一个很大的漏洞！ S商城只能判断OpenID是否出现了重复，但是却无法校验OpenID的有效性，因为它是无法调用微信服务器来对这个OpenID进行校验的。 ","date":"2016-04-04","heading":"漏洞分析","objectID":"/post/cheat-in-wechat-moments-voting-activity/:2:0","tags":["案例分析"],"title":"微信朋友圈投票活动的刷票案例分析","uri":"/post/cheat-in-wechat-moments-voting-activity/#漏洞分析"},{"categories":["Testing"],"collections":null,"content":"VoteRobot实现 明确了这个漏洞后，要实现刷票就很简单了。 采用微信用户正常地进行一次投票操作，对设备进行网络抓包，获取到投票过程中HTTP层面的请求参数和响应内容； 使用Fiddler（或Python脚本）构造投票的HTTP POST请求，保持各参数与真实投票时抓取到的参数内容一致； 随机生成不同的OpenID参数，重复进行POST请求。 如果要实现批量刷票，或者刷票自动化操作，那么就可以将刷票请求通过Python脚本来实现；甚至，采用LoadRunner也是可以的。 运行VoteRobot.py，输出日志如下： ======== Start to vote zpid(38), Total votes: 3 1 tickets has been voted, the next ticket will be voted after 35 seconds. 2 tickets has been voted, the next ticket will be voted after 31 seconds. 3 tickets has been voted, the next ticket will be voted after 10 seconds. ======== Voting Ended! 需要注意的是，通常自动化刷票时最好有个随机的时间间隔，并且，最好能动态模拟不同的设备，即修改User-Agent，否则，服务端可以较为容易地识别作弊行为。 ","date":"2016-04-04","heading":"VoteRobot实现","objectID":"/post/cheat-in-wechat-moments-voting-activity/:3:0","tags":["案例分析"],"title":"微信朋友圈投票活动的刷票案例分析","uri":"/post/cheat-in-wechat-moments-voting-activity/#voterobot实现"},{"categories":["Testing"],"collections":null,"content":"作弊与反作弊？ 看到这里，也许有的同学心中窃喜，以后投票都可以采用这种方式“刷票”了么？ 很遗憾，当然不是。 其实本文中案例的漏洞是很低级的，只是，当前的确还存在不少比例的投票活动是采用这种模式。 要判断一个投票活动是否可以采用这种方式来作弊也很简单。采用本文中的方法，若查看到活动的网址是非微信官方的，而且整个投票过程也没有额外的校验，那么实现作弊的可能性就很大了；再通过抓包看下通讯交互过程，并用网络请求工具修改参数后重新请求下，即可验证是否真的可以作弊了。 另外，也许有人想问了，网络中的投票活动就没法杜绝“刷票”行为了么？ 答案是，完全杜绝的确很难。听说过12306的黄牛党没？听说过Apple Store专业给应用刷榜单的没？听说过“网络水军”、“五毛党”没？ 不过，活动举办方可以通过一些手段，大大提高作弊的门槛。例如，当前有不少活动就采用了如下方式： 要求投票用户先关注活动举办方的公众号，然后调用微信官方的投票功能； 要求投票用户在投票活动举办方的网站上进行注册（手机号验证、实名验证） 不管采用这两种方式中的哪一种，本文中的“刷票”方法就完全失效了。 ","date":"2016-04-04","heading":"作弊与反作弊？","objectID":"/post/cheat-in-wechat-moments-voting-activity/:4:0","tags":["案例分析"],"title":"微信朋友圈投票活动的刷票案例分析","uri":"/post/cheat-in-wechat-moments-voting-activity/#作弊与反作弊"},{"categories":["Development"],"collections":null,"content":"背景描述 在Android性能测试中，每一个测试任务都对应了1个测试用例、1台测试设备、一个测试包，并且在测试结果中包含了多个指标项。通常，我们希望能对两个不同版本测试包的测试结果进行对比，并能在Web页面上以表格的形式进行展现。 很自然地，我们会想到采用如下形式展现对比结果。 图1 三层表格 对应地，采用如下数据结构存储结果数据。 data_hash = { \"pkg_array\": [\"pkg1\", \"pkg2\"], \"data\": { \"testcase1\": { \"device1\": { \"indicator1\": [\"value1\", \"value2\"], \"indicator2\": [\"value3\", \"value4\"], }, \"device2\": { \"indicator1\": [\"value5\", \"value6\"], \"indicator2\": [\"value7\", \"value8\"], }, }, \"testcase2\": { \"device1\": { \"indicator1\": [\"value9\", \"value10\"], \"indicator2\": [\"value11\", \"value12\"], }, \"device2\": { \"indicator1\": [\"value13\", \"value14\"], \"indicator2\": [\"value15\", \"value16\"], }, }, } } 想法明确了，那要怎么实现呢？ 对于像我这样没学过前端的人来说，最难的就是如何通过代码绘制层级表格的问题。 ","date":"2016-03-20","heading":"背景描述","objectID":"/post/render-multi-level-table-in-webpage/:1:0","tags":["报表","JavaScript"],"title":"测试结果报表展现：Web页面绘制多层级表格","uri":"/post/render-multi-level-table-in-webpage/#背景描述"},{"categories":["Development"],"collections":null,"content":"第一次尝试：绘制2层表格 为了简化问题分析过程，先尝试对两层表格进行绘制。 图2 两层表格 简化后的数据结构如下： two_level_data_hash = { \"pkg_array\": [\"pkg1\", \"pkg2\"], \"data\": { \"device1\": { \"indicator1\": [\"value1\", \"value2\"], \"indicator2\": [\"value3\", \"value4\"], }, \"device2\": { \"indicator1\": [\"value5\", \"value6\"], \"indicator2\": [\"value7\", \"value8\"], }, } } 如上表格对应的html代码如下。 Device Indicator Pkg1 Pkg2 device1 indicator1 value1 value2 indicator2 value3 value4 可以看出，绘制层级表格的关键在于tr和rowspan的控制上。 因此，我们可以尝试采用如下JavaScript代码进行生成。 function render_two_level_table(two_level_data_hash){ pkg_array = two_level_data_hash[\"pkg_array\"]; table_header = \"\"; table_header += \"DeviceIndicator\"; for(var pkg_index in pkg_array){ table_header += \"\" + pkg_array[pkg_index] + \"\" } table_header += \"\"; data = two_level_data_hash[\"data\"]; table_body = \"\"; for(var device in data){ is_first_indicator_row = true; table_body += \"\" + device + \"\"; for(var indicator in data[device]){ if(!is_first_indicator_row){ table_body += \"\"; } table_body += \"\" + indicator + \"\"; value_list = data[device][indicator]; for(var index in value_list){ table_body += \"\" + value_list[index] + \"\"; } table_body += \"\"; is_first_indicator_row = false; } table_body += \"\"; } table_content = \"\" + table_header + table_body + \"\"; $(\"#table\").html(table_content); } 在绘制indicator单元格的时候，为了判断当前indicator是否是当前device对应的第一个，即是否需要添加\u0026lt;tr\u0026gt;格式符，我们引入了is_first_indicator_row变量；is_first_indicator_row初始为true，绘制完第一个indicator以后变为false；绘制当前device剩余indicator的时候，由于is_first_indicator_row为false，因此每次都会加上\u0026lt;tr\u0026gt;格式符。 在判断device单元格的行跨度(rowspan)时，由于indicator是device的key，因此我们可以通过当前device中key的数量来得到rowspan，即two_level_data_hash[device].length。 绘制下一个device对应的数据时，再重复以上流程。 可以看出，为了正确打印\u0026lt;tr\u0026gt;格式符，我们做了不少工作。两层表格的绘制方法解决了，那如何绘制三层表格呢？ ","date":"2016-03-20","heading":"第一次尝试：绘制2层表格","objectID":"/post/render-multi-level-table-in-webpage/:2:0","tags":["报表","JavaScript"],"title":"测试结果报表展现：Web页面绘制多层级表格","uri":"/post/render-multi-level-table-in-webpage/#第一次尝试绘制2层表格"},{"categories":["Development"],"collections":null,"content":"第二次尝试：绘制3层表格 回到背景描述里面的需求，若按照上面的思路，我们要绘制三层表格时，就需要引入两个变量，is_first_device_row和is_first_indicator_row，分别用于标记device和indicator是否第一次出现。 那对于rowspan呢？这貌似就有点麻烦了。因为我们在绘制testcase单元格的时候，rowspan的取值应该是当前testcase包含的所有device各自包含的indicator的数量总和，而我们并不能像之前的方式那样直接得到这个数值。 那要怎么处理呢？我们可以尝试写一个函数row_num，来计算得到给定JSON数据里面包含的子节点的总数。 实现方式如下： function render_three_level_table(data_hash){ pkg_array = data_hash[\"pkg_array\"]; table_header = \"\"; table_header += \"TestCaseDeviceIndicator\"; for(var pkg_index in pkg_array){ table_header += \"\" + pkg_array[pkg_index] + \"\"; } table_header += \"\"; data = data_hash[\"data\"]; table_body = \"\"; for(var testcase in data){ is_first_row_device = true; table_body += \"\" + testcase + \"\"; for(var device in data[testcase]){ if(!is_first_row_device){ table_body += \"\"; } table_body += \"\" + device + \"\"; is_first_row_indicator = true; for(var indicator in data[testcase][device]){ if(!is_first_row_indicator){ table_body += \"\"; } table_body += \"\" + indicator + \"\"; value_list = data[testcase][device][indicator]; for(var index in value_list){ table_body += \"\" + value_list[index] + \"\"; } table_body += \"\"; } table_body += \"\"; is_first_row_indicator = false; } table_body += \"\"; is_first_row_device = false; } table_content = \"\" + table_header + table_body + \"\"; $(\"#table\").html(table_content); } function row_num(data){ var counter = 0; if(data.constructor == Array){ return 1; } for(key in data){ var tmp = data[key]; if(tmp.constructor == Array) { counter += 1; }else{ counter += row_num(tmp); } } return counter; } 在计算rowspan时，我们用到了递归的方法，实现了对当前testcase或当前device所对应的indicator总数的计算。 通过以上方式，我们实现了三层表格的绘制。可以看出，三层表格的判断逻辑比两层表格复杂了很多，那如果我们还想绘制更多层次的表格呢？显然，这种方法已不再适用，我们不可能每增加一层就新增加一个标识变量，而且对于数据层级不固定的情况，采用这种方式是完全无法实现自适应的。 ","date":"2016-03-20","heading":"第二次尝试：绘制3层表格","objectID":"/post/render-multi-level-table-in-webpage/:3:0","tags":["报表","JavaScript"],"title":"测试结果报表展现：Web页面绘制多层级表格","uri":"/post/render-multi-level-table-in-webpage/#第二次尝试绘制3层表格"},{"categories":["Development"],"collections":null,"content":"重构：递归！ 回顾上面的代码，我们不难发现，三层表格的代码相比于两层表格的代码，存在着不少重复，而且可以预见，如果我们采用同样的方式去绘制更多层次表格的话，重复的代码会出现得更多。 一定有更简洁的方法！对，递归！ 其实刚才我们在计算rowspan时已经体会到了递归的好处，它可以自适应多层次的数据结构。我们也完全可以将这个思想应用到表格层级的绘制上面。 观察背景描述中的数据结构，不难发现，对比数据存储于Array中，而中间层的value都是Hash结构，因此，我们可以通过这个区别，编写递归调用方法。 function render_table(data_hash){ var res = \"\"; for(var key in data_hash){ var tmp = data_hash[key]; if(tmp.constructor == Array) { res += \"\" + key + \"\"; for(value_index in tmp){ var value = tmp[value_index]; res += \"\" + value + \"\"; } res += \"\"; }else{ res += \"\" + key + \"\" + \"\"; res += render_table(tmp); } } return res; } function row_num(data){ var counter = 1; if(data.constructor == Array){ return counter; } for(key in data){ var tmp = data[key]; if(tmp.constructor == Array) { counter += 1; }else{ counter += row_num(tmp); } } return counter; } table_body = \"\"; table_body += render_table(data_hash); 采用了递归的方式以后，我们就不用再关注表格的层级了，只要是传入数据的数据结构与背景描述里面的类似，那么就可以自动绘制出任意层级的表格。 ","date":"2016-03-20","heading":"重构：递归！","objectID":"/post/render-multi-level-table-in-webpage/:4:0","tags":["报表","JavaScript"],"title":"测试结果报表展现：Web页面绘制多层级表格","uri":"/post/render-multi-level-table-in-webpage/#重构递归"},{"categories":["Development"],"collections":null,"content":"背景 不管是是研发同学还是测试同学，日常跟进线上问题时对如下两个场景应该都很熟悉： 生产环境上出现了一个bug，想在测试环境或者本地开发环境复现这个bug，而这个bug又与特定数据相关； 在本地开发环境修复了线上bug，想用线上最新特定场景的数据验证下bug是否修复成功。 在这两种情况下，都需要将生产环境的数据导入到本地环境。那么怎么进行导入呢？ 说到这里，也许已经勾起了你的一把辛酸泪。通常对于这种情况都是这么做的： 同步整个生产环境数据库到本地 利用某些数据库管理软件（如Navicat）同步指定场景相关的几张数据表到本地 不管是采用以上哪种方式，由于线上数据库数据通常都很大，因此都会导致如下几个痛点： 同步时间非常长，每同步一次都要耗费很多时间进行等待，效率极其低下 同步数据量非常大，但绝大多数的数据都是不关注的 对于像我这样本地笔记本电脑总共只有128GB SSD，空闲容量不足5GB的屌丝，想同步都做不到啊 ","date":"2016-03-08","heading":"背景","objectID":"/post/developing-idea-production-data-picker/:1:0","tags":["灵感"],"title":"【灵感】线上数据采摘工具（ProductionDataPicker）","uri":"/post/developing-idea-production-data-picker/#背景"},{"categories":["Development"],"collections":null,"content":"解决方案 要解决以上问题，其实从原理上讲很简单，就是只同步我们想要的数据，不多一条，也不少一条，刚刚好！ 以UC浏览器的性能测试平台UCTC为例，某个测试任务出现异常后，我们想要拿到这个任务相关的所有数据在本地开发环境进行复现调试。单个测试任务相关的数据分布在各个数据库表中，包括任务信息、测试用例、测试设备、测试包、原始测试数据等等。 不难看出，虽然只是一个测试任务，但其相关的数据分布在近10张表中，各张表的数据量从几百到几十万不等。而我们要刚好同步相关数据的意思就是，在测试任务相关的每张表里面，只将特定测试任务的相关数据提取出来，并导入到我们的本地开发环境。这样的话，我们每次只需要同步很少的数据，同步操作基本上可以实现瞬间完成。 如果能有这样的工具，那么前面提到的几个痛点都将不复存在，我们的工作效率必将得到极大的提升。 然而遗憾的是，经过漫长的搜索，发现目前市面上都还没有这样的工具。大多数数据库管理工具只能提供数据库的手动导出和导入整张数据表的功能，即使有些做得更便捷的，也仅限于实现两个数据库的数据同步功能，这些都不能满足我们的期望。 痛点是实实在在的，而又没有可用的工具，那么就这样忍了么？当然不行，We build things! 线上数据采摘工具（ProductionDataPicker）就此诞生了！ to be continued \u0026hellip; ","date":"2016-03-08","heading":"解决方案","objectID":"/post/developing-idea-production-data-picker/:2:0","tags":["灵感"],"title":"【灵感】线上数据采摘工具（ProductionDataPicker）","uri":"/post/developing-idea-production-data-picker/#解决方案"},{"categories":["Development"],"collections":null,"content":"背景描述 有时候Python应用的代码在本地开发环境运行十分正常，但是放到线上以后却出现了莫名其妙的异常，经过再三排查以后还是找不到问题原因，于是就在想，要是可以在服务器环境中进行单步跟踪调试就好了。 然而，在服务器系统上安装一个IDE肯定是不现实的；通过SSH远程到服务器端，采用pdb进行调试虽然可行，但是操作还是较为繁琐，而且也不够直观。 那么，是否可以将开发环境中的IDE与服务器环境相连，实现利用开发环境的IDE调试服务器环境中运行的程序呢？ 答案是肯定的，这就是远程调试（Remote Debug）。 ","date":"2015-07-13","heading":"背景描述","objectID":"/post/remote-debugging-with-pycharm/:1:0","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#背景描述"},{"categories":["Development"],"collections":null,"content":"远程调试的工作原理 远程调试的功能在Eclipse、IntelliJ IDEA等大型IDE中均有支持，实现原理都基本相同，这里采用PyCharm进行说明。 在远程调试的模式下，PyCharm（IDE）扮演服务端（Server）的角色，而运行在远程计算机上的应用程序扮演客户端（Client）的角色。正因如此，进行远程调试时，需要先在本地开发环境中设定端口并启动IDE，IDE会对设定的端口开始监听，等待客户端的连接请求；那远程计算机中的应用程序又是怎样与IDE建立通讯连接的呢？ 针对远程调试功能，PyCharm提供了pydevd模块，该模块以pycharm-debug.egg的形式存在于PyCharm的安装路径中。远程计算机安装该库文件后，然后就可以调用pydevd.settrace方法，该方法会指定IDE所在机器的IP地址和监听的端口号，用于与IDE建立连接；建立连接后，便可在IDE中对远程在远程计算机中的程序进行单步调试。 ","date":"2015-07-13","heading":"远程调试的工作原理","objectID":"/post/remote-debugging-with-pycharm/:2:0","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#远程调试的工作原理"},{"categories":["Development"],"collections":null,"content":"远程调试的配置方法 ","date":"2015-07-13","heading":"远程调试的配置方法","objectID":"/post/remote-debugging-with-pycharm/:3:0","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#远程调试的配置方法"},{"categories":["Development"],"collections":null,"content":"1、在远程计算机上安装pydevd模块 首先，在本地开发环境的PyCharm安装路径中找到pycharm-debug.egg文件（若远程计算机运行的是Python3，则需要pycharm-debug-py3k.egg）； 然后，将pycharm-debug.egg文件拷贝至远程计算机，在远程计算机中将pycharm-debug.egg添加至引用路径，可以采用多种方式： 采用easy_install pycharm-debug.egg命令进行安装（pip命令无法安装，只能使用easy_install） 将pycharm-debug.egg添加至PYTHONPATH或sys.path: import sys; sys.path.append('/home/leo/app-dependancies/pycharm-debug.egg') 解压pycharm-debug.egg，将其中的pydev文件夹拷贝至远程应用程序目录下 最后，在远程计算机的Python命令行中输入import pydevd，若没有报错则说明pydevd模块安装成功。 ","date":"2015-07-13","heading":"1、在远程计算机上安装","objectID":"/post/remote-debugging-with-pycharm/:3:1","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#1在远程计算机上安装pydevd模块"},{"categories":["Development"],"collections":null,"content":"2、在本地开发环境的PyCharm中进行监听配置 在PyCharm中配置说明如下： 【Run】-\u0026gt;【Edit Configurations】 【Add New Configuration】-\u0026gt;【Python Remote Debug】 填写Local host name和Port，其中Local host name指的是本机开发环境的IP地址，而Port则随便填写一个10000以上的即可；需要注意的是，由于远程计算机需要连接至本地开发环境，因此本地IP地址应该保证远程可以访问得到 【Apply】and【OK】 ","date":"2015-07-13","heading":"2、在本地开发环境的PyCharm中进行监听配置","objectID":"/post/remote-debugging-with-pycharm/:3:2","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#2在本地开发环境的pycharm中进行监听配置"},{"categories":["Development"],"collections":null,"content":"3、在本地开发环境的PyCharm中配置Mapping映射 ","date":"2015-07-13","heading":"3、在本地开发环境的PyCharm中配置Mapping映射","objectID":"/post/remote-debugging-with-pycharm/:3:3","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#3在本地开发环境的pycharm中配置mapping映射"},{"categories":["Development"],"collections":null,"content":"4、在远程计算机的应用程序中插入代码 将如下代码插入至远程计算机的应用程序中。 import pydevd pydevd.settrace('100.84.48.156', port=31235, stdoutToServer=True, stderrToServer=True) 其中，IP地址和端口号要与PyCharm中的监听配置保持一致。 ","date":"2015-07-13","heading":"4、在远程计算机的应用程序中插入代码","objectID":"/post/remote-debugging-with-pycharm/:3:4","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#4在远程计算机的应用程序中插入代码"},{"categories":["Development"],"collections":null,"content":"5、在PyCharm中启动Debug Server 【Run】-\u0026gt;【Debug\u0026hellip;】，选择刚创建的远程调试配置项，在Debug Console中会显示如下信息： Starting debug server at port 31235 Waiting for process connection... Use the following code to connect to the debugger: import pydevd pydevd.settrace('100.84.48.156', port=31235, stdoutToServer=True, stderrToServer=True) 这说明Debug Server已经启动并处于监听状态。 ","date":"2015-07-13","heading":"5、在PyCharm中启动","objectID":"/post/remote-debugging-with-pycharm/:3:5","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#5在pycharm中启动debug-server"},{"categories":["Development"],"collections":null,"content":"6、在远程计算机中启动应用程序 在远程计算机中启动应用程序，当执行到pydevd.settrace语句时，便会与本地开发环境中的PyCharm建立通讯连接，接下来便可以在本地IDE中进行单步调试了。 需要注意的是，本地开发环境必须保证IP地址和端口号可从远程计算机访问得到，否则会无法建立连接。 $ telnet 100.84.48.156 31235 Trying 100.84.48.156... telnet: Unable to connect to remote host: Connection refused $ python devicedectector.py Could not connect to 100.84.48.156: 31236 Traceback (most recent call last): File \"/usr/local/lib/python2.7/dist-packages/pycharm-debug.egg/pydevd_comm.py\", line 478, in StartClient s.connect((host, port)) File \"/usr/lib/python2.7/socket.py\", line 224, in meth return getattr(self._sock,name)(*args) error: [Errno 111] Connection refused ","date":"2015-07-13","heading":"6、在远程计算机中启动应用程序","objectID":"/post/remote-debugging-with-pycharm/:3:6","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#6在远程计算机中启动应用程序"},{"categories":["Development"],"collections":null,"content":"参考链接 http://stackoverflow.com/questions/6989965/how-do-i-start-up-remote-debugging-with-pycharm https://www.jetbrains.com/pycharm/help/remote-debugging.html ","date":"2015-07-13","heading":"参考链接","objectID":"/post/remote-debugging-with-pycharm/:4:0","tags":["Python","pycharm"],"title":"利用 PyCharm 进行 Python 远程调试","uri":"/post/remote-debugging-with-pycharm/#参考链接"},{"categories":["Testing"],"collections":null,"content":"对于用于持续集成性能测试（或者自动化测试）的Android设备，在刷机之后需要进行一些配置。不同的Android系统版本在设置上会有些差异，但基本上都应包含的设置项都类似，如下是以Nexus 5为例进行说明。 【Settings】-\u0026gt;【About Phone】，连续点击7次【Build number】，开启开发者模式； 【Settings】-\u0026gt;【Developer options】，开启【stay awake】和【USB debugging】； 【Settings】-\u0026gt;【Security】，开启【Unknown sources】(Allow installation of apps from sources other than the Play Store)，因为实验室的包还未获得Play Store的签名，因此必须开启这个开关，才能正常安装开发包。 【Settings】-\u0026gt;【Security】，将【Screen lock】设置为None，防止设备出现锁屏的情况。 【Settings】-\u0026gt;【Display】，将【Sleep】设置为最长时限。 【Settings】-\u0026gt;【Display】，将【Auto-rotate screen】关闭，因为某些测试场景时会用到屏幕分辨率。 ","date":"2015-07-10","heading":"","objectID":"/post/android-development-environment-device-configuration/:0:0","tags":["Android"],"title":"Android 开发环境配置4：配置Android设备","uri":"/post/android-development-environment-device-configuration/#"},{"categories":["Testing"],"collections":null,"content":"What is root ","date":"2015-07-05","heading":"What is root","objectID":"/post/android-development-environment-root/:1:0","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#what-is-root"},{"categories":["Testing"],"collections":null,"content":"root的原理 破解Android root权限的本质是： 在系统中加入一个任何用户都可能用于登陆的su命令。或者说替换掉系统中的su程序，因为系统中的默认su程序需要验证实际用户权限，只有root和shell用户才有权运行系统默认的su程序，其他用户运行都会返回错误。而破解后的su将不检查实际用户权限，这样普通的用户也将可以运行su程序，也可以通过su程序将自己的权限提升。 ","date":"2015-07-05","heading":"root的原理","objectID":"/post/android-development-environment-root/:1:1","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#root的原理"},{"categories":["Testing"],"collections":null,"content":"判断手机是否root 通过adb shell连接至手机以后，执行su命令，如果无法执行，则说明该手机无root权限。 $ adb shell shell@hammerhead:/ $ shell@hammerhead:/ $ su su command not found ","date":"2015-07-05","heading":"判断手机是否root","objectID":"/post/android-development-environment-root/:1:2","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#判断手机是否root"},{"categories":["Testing"],"collections":null,"content":"How to root 本文中用到的操作系统为Linux（Debian）；Android设备型号为Nexus 5（HAMMERHEAD），Android版本为4.4.4（Build Num KTU84P） ","date":"2015-07-05","heading":"How to root","objectID":"/post/android-development-environment-root/:2:0","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#how-to-root"},{"categories":["Testing"],"collections":null,"content":"准备工作（Prerequisites） 配置Android调试环境：安装adb和fastboot工具，开启USB调试模式（USB debugging），建立手机与电脑之间的USB调试连接。 Android设备的Bootloader已完成解锁（unlocked）。 下载已经完成root的boot.img，例如，CF-Auto-Root-hammerhead-hammerhead-nexus5.img 下载第三方TWRP Recovery镜像包，例如，openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"准备工作（Prerequisites）","objectID":"/post/android-development-environment-root/:2:1","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#准备工作prerequisites"},{"categories":["Testing"],"collections":null,"content":"方法1：flash rooted boot.img 进行root操作，最便捷方式的便是刷入已经root过的boot.img对原有boot区域进行擦写覆盖。 对于Nexus设备，可以在CF-Auto-Root Repository网站上下载对应设备型号的镜像文件，该镜像中包含了SuperSU程序。 在flash操作前，先将设备切换至fastboot模式。 $ adb reboot bootloader 在fastboot模式下，在电脑的命令终端中执行如下命令。 $ fastboot flash boot CF-Auto-Root-hammerhead-hammerhead-nexus5.img sending 'boot' (9222 KB)... OKAY [ 0.525s] writing 'boot'... OKAY [ 0.787s] finished. total time: 1.313s $ fastboot reboot rebooting... finished. total time: 0.511s 采用fastboot flash boot XXX_boot.img进行操作时，手机会将XXX_boot.img下载至设备并对boot区域进行擦写覆盖，从而使手机获得root权限。即使手机再次重启，root权限仍然存在。 再次通过adb shell连接至手机，可以看见，现在已经获取到了root权限。 $ adb shell root@hammerhead:/ # ","date":"2015-07-05","heading":"方法1：flash","objectID":"/post/android-development-environment-root/:2:2","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#方法1flash-rooted-bootimg"},{"categories":["Testing"],"collections":null,"content":"方法2、在Recovery环境下安装SuperSU 该种方法的实现思路在于，给手机安装Custom Recovery，然后在recovery模式下手工安装SuperSU。 具体的操作方式在《[详解Recovery]({% post_url 2015-07-04-Android开发环境配置2：详解Recovery %})》一文中进行了介绍。 ","date":"2015-07-05","heading":"方法2、在","objectID":"/post/android-development-environment-root/:2:3","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#方法2在recovery环境下安装supersu"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"方法3、采用","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#方法3采用twrp-recovery的openrecoveryscript引擎执行命令"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"1、传输root文件至Android设备","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#1传输root文件至android设备"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"2、获取临时root权限","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#2获取临时root权限"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"3、创建","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#3创建openrecoveryscript文件"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"4、写入","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#4写入openrecoveryscript文件"},{"categories":["Testing"],"collections":null,"content":"方法3、采用TWRP Recovery的OpenRecoveryScript引擎执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 1、传输root文件至Android设备 在Android设备未获得root权限之前，具有Write权限的文件夹并不多，通常可将要写入的文件传输至/data/local/tmp/路径下。 $ adb push UPDATE-SuperSU-v2.46.zip /data/local/tmp/UPDATE-SuperSU-v2.46.zip $ adb push busybox-signed.zip /data/local/tmp/busybox-signed.zip 2、获取临时root权限 由于后续操作中要向/cache/recovery/目录写入文件，需要用到root权限。针对这种情况，可以采用已经root过的boot.img进行启动，临时获得root权限。 首先，将设备切换至fastboot模式 $ adb reboot bootloader 然后，采用已经root过的boot.img进行启动。 $ fastboot boot modified_boot_hammerhead_4.4.4_KTU84P.img downloading 'boot.img'... OKAY [ 0.463s] booting... OKAY [ 0.110s] finished. total time: 0.573s 此处与方法1的区别在于，这里只是临时地采用已经root过的boot.img进行启动，但并没有对boot分区进行写入。当手机重启后，仍然是采用原有的kernel进行启动。 3、创建openrecoveryscript文件 $ touch openrecoveryscript 在openrecoveryscript文件中，写入如下内容。其中，zip软件包的路径要求是存在于Android设备中的完整路径。 # openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip 4、写入openrecoveryscript文件 此处就是需要临时用到root权限的地方。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript 2 KB/s (169 bytes in 0.057s) 如果没有root权限，写入文件时会提示Permission denied。 $ adb push openrecoveryscript /cache/recovery/openrecoveryscript failed to copy 'openrecoveryscript' to '/cache/recovery/openrecoveryscript': Permission denied 5、启动TWRP Recovery环境 启动TWRP Recovery时，会执行/cache/recovery/openrecoveryscript文件中的命令，即安装SuperSU等root软件。 如果手机之前并未安装TWRP Recovery，也可以直接采用TWRP Recovery的镜像包进行启动，同样可以完成/cache/recovery/openrecoveryscript文件中命令的执行。 fastboot boot openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-05","heading":"5、启动","objectID":"/post/android-development-environment-root/:2:4","tags":["Android"],"title":"Android 开发环境配置3：Root","uri":"/post/android-development-environment-root/#5启动twrp-recovery环境"},{"categories":["Testing"],"collections":null,"content":"不管是尝试对Android手机进行刷机的普通用户，还是刚接触Android应用开发的开发者，都会接触到Recovery。本文将从什么是Recovery，怎么在Android设备中安装Recovery环境，如何使用Recovery几个方面对Android Recovery进行介绍。 ","date":"2015-07-04","heading":"","objectID":"/post/android-development-environment-recovery/:0:0","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#"},{"categories":["Testing"],"collections":null,"content":"What is Recovery 通常，在Android设备中会预装Recovery环境，可以用于还原出厂设置、升级操作系统、以及进行问题诊断等。 从类别上，Recovery环境分为两种，Google官方的（the Stock Recovery）Recovery环境和第三方的Recovery环境（the Custom Recovery）。 ","date":"2015-07-04","heading":"What is Recovery","objectID":"/post/android-development-environment-recovery/:1:0","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#what-is-recovery"},{"categories":["Testing"],"collections":null,"content":"1、the Stock Recovery Google官方的Recovery环境提供的功能十分有限，主要包括： 重置出厂设置，清除所有数据 擦除cache分区 刷入官方的升级文件来升级系统 ","date":"2015-07-04","heading":"1、the Stock Recovery","objectID":"/post/android-development-environment-recovery/:1:1","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#1the-stock-recovery"},{"categories":["Testing"],"collections":null,"content":"2、the Custom Recovery 针对Google官方Recovery环境功能的不足，许多组织、机构或个人定制了功能更为强大的Recovery环境，统称为第三方Recovery环境。当前最为流行的有两个：ClockworkMod Recovery (CWM)、Team Win Recovery Project (TWRP)。 第三方Recovery环境除了具有Google官方Recovery环境的功能外，还增加了其它特性，主要包括： 创建、恢复系统备份 安装自定义的ROM 安装、删除应用程序 ","date":"2015-07-04","heading":"2、the Custom Recovery","objectID":"/post/android-development-environment-recovery/:1:2","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#2the-custom-recovery"},{"categories":["Testing"],"collections":null,"content":"How to Install Recovery 通常，Android设备出厂时都会带有官方的Recovery环境。如果我们需要获取更多的功能，就需要自己安装第三方Recovery环境来替换官方的Recovery环境。 ","date":"2015-07-04","heading":"How to Install Recovery","objectID":"/post/android-development-environment-recovery/:2:0","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#how-to-install-recovery"},{"categories":["Testing"],"collections":null,"content":"1、准备工作（Prerequisites） 配置Android调试环境：安装adb和fastboot工具，开启USB调试模式（USB debugging），建立手机与电脑之间的USB调试连接。 Android设备的Bootloader已完成解锁（unlocked）。 下载第三方Recovery镜像包，例如，openrecovery-twrp-2.8.7.1-hammerhead.img ","date":"2015-07-04","heading":"1、准备工作（Prerequisites）","objectID":"/post/android-development-environment-recovery/:2:1","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#1准备工作prerequisites"},{"categories":["Testing"],"collections":null,"content":"2、切换至fastboot模式 在flash操作前，先将设备切换至fastboot模式。 $ adb reboot bootloader 通过fastboot devices命令可以查看到手机与电脑直接的连接状态。 $ fastboot devices 03f7fc7ad0081a10 fastboot ","date":"2015-07-04","heading":"2、切换至fastboot模式","objectID":"/post/android-development-environment-recovery/:2:2","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#2切换至fastboot模式"},{"categories":["Testing"],"collections":null,"content":"3、安装Custom Recovery 在fastboot模式下，在电脑的命令终端中执行如下命令，写入recovery.img，并进行重启。 $ fastboot flash recovery openrecovery-twrp-2.8.7.1-hammerhead.img sending 'recovery' (14694 KB)... OKAY [ 0.709s] writing 'recovery'... OKAY [ 1.133s] finished. total time: 1.842s $ fastboot reboot rebooting... finished. total time: 0.408s ","date":"2015-07-04","heading":"3、安装Custom Recovery","objectID":"/post/android-development-environment-recovery/:2:3","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#3安装custom-recovery"},{"categories":["Testing"],"collections":null,"content":"How to Use Recovery ","date":"2015-07-04","heading":"How to Use Recovery","objectID":"/post/android-development-environment-recovery/:3:0","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#how-to-use-recovery"},{"categories":["Testing"],"collections":null,"content":"进入Recovery环境 在手机开机状态下，可以通过如下命令进入Recovery环境。 $ adb reboot recovery $ adb shell ~ # 从上可以看出，在Recovery环境下，用户具有root权限。 ","date":"2015-07-04","heading":"进入Recovery环境","objectID":"/post/android-development-environment-recovery/:3:1","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#进入recovery环境"},{"categories":["Testing"],"collections":null,"content":"在Recovery环境的手机资源管理器中安装zip格式的软件 如果要安装某些root权限的软件，例如SuperSU.zip，而手机却没有root权限，就可以在Recovery环境下进行安装。 在Recovery环境下，将要安装的应用程序安装包push至手机任意文件夹。 $ adb push UPDATE-SuperSU-v2.46.zip /sdcard/!ReadyToFlash/Root_Files/UPDATE-SuperSU-v2.46.zip 4604 KB/s (4017098 bytes in 0.852s) 然后在手机Recovery环境的资源管理器中找到该文件，点击安装即可。 ","date":"2015-07-04","heading":"在Recovery环境的手机资源管理器中安装","objectID":"/post/android-development-environment-recovery/:3:2","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#在recovery环境的手机资源管理器中安装zip格式的软件"},{"categories":["Testing"],"collections":null,"content":"采用sideload方式安装zip格式的软件 在Recovery环境下，在手机端开启ADB Sideload，开启后，可以看到手机处于sideload模式。 $ adb devices List of devices attached 03f7fc7ad0081a10 sideload 采用adb sideload \u0026lt;filename.Zip\u0026gt;命令，即可进行zip软件包的安装。 $ adb sideload busybox-signed.zip Total xfer: 1.24x 在手机端可以看到如下日志信息。 Undating partition details... ...done Full SELinux support is present. MTP enabled Starting ADB sideload feature ... Installing '/sideload/package.zip'... ************************ Install Busybox apk ************************ - Mounting file systems ... - Installing Busybox free ... - Unmounting file system ... Done! ","date":"2015-07-04","heading":"采用sideload方式安装zip格式的软件","objectID":"/post/android-development-environment-recovery/:3:3","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#采用sideload方式安装zip格式的软件"},{"categories":["Testing"],"collections":null,"content":"在Recovery启动过程中执行命令 对于2.1及以上版本的TWRP Recovery中，可以使用OpenRecoveryScript引擎，在TWRP Recovery启动时执行命令。 具体的方式为，在Android设备的/cache/recovery/目录下创建openrecoveryscript文件，并在其中写入执行命令。 例如： # /cache/recovery/openrecoveryscript set tw_signed_zip_verify 0 install /data/local/tmp/perm-recovery-signed.zip install /data/local/tmp/UPDATE-SuperSU-v2.46.zip install /data/local/tmp/busybox-signed.zip ","date":"2015-07-04","heading":"在Recovery启动过程中执行命令","objectID":"/post/android-development-environment-recovery/:3:4","tags":["Android","Recovery"],"title":"Android 开发环境配置2：详解Recovery","uri":"/post/android-development-environment-recovery/#在recovery启动过程中执行命令"},{"categories":["Testing"],"collections":null,"content":"在刚接触Android开发时，通常需要对设备进行root操作，而在root之前，必须要做的一步就是先对设备的Bootloader进行解锁。那么什么是Bootloader？为什么必须要对Bootloader进行解锁？如何对Bootloader进行解锁？本文便围绕着几个问题展开进行介绍。 ","date":"2015-07-03","heading":"","objectID":"/post/android-development-environment-bootloader/:0:0","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#"},{"categories":["Testing"],"collections":null,"content":"About Bootloader 顾名思义，Bootloader是操作系统在启动之前需要执行的一段小程序。通过这段小程序，我们可以初始化硬件设备、建立内存空间的映射表，从而建立适当的系统软硬件环境，为最终调用操作系统内核做好准备。 虽然Android系统是开源的，但是设备制造商为了保证系统的稳定性，以及维护自身某些方面的利益，并不希望用户更换为其它厂商的ROM。因此，通常各家设备制造商都会对Bootloader进行加锁。在设备Bootloader处于锁定状态时，无法对ROM进行更改，不能进行root操作，也无法刷机为别的ROM。而要解除这些限制，就需要对Bootloader进行解锁。 ","date":"2015-07-03","heading":"About Bootloader","objectID":"/post/android-development-environment-bootloader/:1:0","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#about-bootloader"},{"categories":["Testing"],"collections":null,"content":"How to Unlock Bootloader 不同机型以及不同Android版本的Bootloader的解锁方式略有不同，但基本思路和步骤都是类似的。 本文以设备机型Nexus 5、ROM版本4.4.4为例，讲解解锁Bootloader的具体步骤；PC机的操作系统为Linux（Debian）。 ","date":"2015-07-03","heading":"How to Unlock Bootloader","objectID":"/post/android-development-environment-bootloader/:2:0","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#how-to-unlock-bootloader"},{"categories":["Testing"],"collections":null,"content":"1、准备工作（Prerequisites） 配置Android调试环境：安装adb和fastboot工具，开启USB调试模式（USB debugging），建立手机与电脑之间的USB调试连接。 数据备份：需要注意的是，bootloader解锁操作会将手机恢复至出厂设置，并清空所有用户数据。因此，在解锁前需要进行必要的数据备份。 ","date":"2015-07-03","heading":"1、准备工作（Prerequisites）","objectID":"/post/android-development-environment-bootloader/:2:1","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#1准备工作prerequisites"},{"categories":["Testing"],"collections":null,"content":"2、将手机切换至fastboot模式 不管是要查看Bootloader是否锁定，还是要对Bootloader进行解锁、锁定操作，均需要将手机切换至fastboot模式。切换至fastboot模式可以采用如下两种方式中的任意一种。 采用adb命令：将手机开机后连接至电脑，在命令终端中输入adb reboot bootloader。 开机时采用组合按键：将手机关机，同时按住组合按键数秒；不同机型的组合按键略有不同，Nexus 5的组合按键为Volume Down和Power。 进入到fastboot模式后，可以看到如下信息。 FASTBOOT MODE PRODUCT_NAME - hammerhead VARIANT - hammerhead D821(E) 16GB HW VERSION - rev_11 BOOTLOADER VERSION - HHZ11k BASEBAND VERSION - M897aA-2.0.50.1.16 CARRIER INFO - None SERIAL NUMBER - 03f7fc7ad0081a10 SIGNING - production SECURE BOOT - enabled LOCK STATE - locked 在LOCK STATE一项中，可以看到手机当前处于锁定（locked）状态，若需要进行root操作，则必须先进行解锁（unlock）。如果手机已经处于解锁（unlocked）状态了，则不用再进行unlock操作。 ","date":"2015-07-03","heading":"2、将手机切换至","objectID":"/post/android-development-environment-bootloader/:2:2","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#2将手机切换至fastboot模式"},{"categories":["Testing"],"collections":null,"content":"3、对锁定（locked）设备进行解锁（unlock）操作 解锁操作需要将手机通过USB数据线与电脑连接，在电脑的命令终端中通过fastboot工具命令进行解锁操作。 首先，保持手机与电脑的USB连接，在命令终端中查看手机在fastboot模式下与电脑的连接状态。若连接正常，则可以看到如下信息。 $ fastboot devices 03f7fc7ad0081a10 fastboot 然后，在电脑的命令终端中输入如下命令。在手机上会显示Unblock bootloader?警告提示信息，用手机的音量键选择Yes后，按下手机的电源键进行确认。接下来手机便会进行unlock操作。 $ fastboot oem unlock ... OKAY [ 14.907s] finished. total time: 14.907s 出现如上响应信息后，则表明手机已经解锁成功。在手机屏幕上，也可以看到fastboot模式页面中，LOCK STATE的属性值已经变为unlocked。 $ fastboot reboot 手机unlock完成后，会恢复到出厂设置，USB调试模式设置也已失效，需要重新进行设置。 ","date":"2015-07-03","heading":"3、对锁定（locked）设备进行解锁（unlock）操作","objectID":"/post/android-development-environment-bootloader/:2:3","tags":["Android","BootLoader"],"title":"Android 开发环境配置1：详解Bootloader","uri":"/post/android-development-environment-bootloader/#3对锁定locked设备进行解锁unlock操作"},{"categories":["Testing"],"collections":null,"content":"1、安装adb和fastboot 在对Android进行调试时，常用的工具有adb和fastboot。 ","date":"2015-07-02","heading":"1、安装adb和fastboot","objectID":"/post/android-development-environment-adb-and-fastboot/:1:0","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#1安装adb和fastboot"},{"categories":["Testing"],"collections":null,"content":"Install Android SDK adb和fastboot包含在Android SDK的Platform-tools package里面，安装Android SDK后也就具备了adb和fastboot工具。 下载并安装Android SDK tools，打开SDK Manager并安装Android SDK Platform-tools package。 安装完成后，adb和fastboot的存放路径为\u0026lt;path-to-sdk\u0026gt;/platform-tools。为了方便后续使用，可对其配置环境变量。 vim ~/.bashrc: if [ -d \"/platform-tools\" ] ; then export PATH=\"/platform-tools:$PATH\" fi ","date":"2015-07-02","heading":"Install Android SDK","objectID":"/post/android-development-environment-adb-and-fastboot/:1:1","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#install-android-sdk"},{"categories":["Testing"],"collections":null,"content":"Install from the command line 如果只是使用adb和fastboot工具，而不想安装完整的Android SDK，可以采用如下方式单独进行安装。 for debian-based Linux distributions: $ sudo apt-get install android-tools-adb $ sudo apt-get install android-tools-fastboot for rpm-based Linux distributions(Fedora/Centos/RHEL): $ sudo yum install android-tools 在Mac OS X中，可以采用homebrew进行安装： $ brew install android-platform-tools ","date":"2015-07-02","heading":"Install from the command line","objectID":"/post/android-development-environment-adb-and-fastboot/:1:2","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#install-from-the-command-line"},{"categories":["Testing"],"collections":null,"content":"2、开启USB调试模式（USB debugging） 对手机进行unlock或root操作需要开启手机的USB调试模式，该设置在手机设置菜单的开发者选项（Developer Options）里面。 对于3.2及以前的Android版本，开发者选项的设置位置为：Settings \u0026gt; Applications \u0026gt; Development； 对于4.0及以上的Android版本，开发者选项的设置位置为：Setting -\u0026gt; Developer Options；需要注意的是，对于4.2及以上的Android版本，开发者选项默认是隐藏的，开启方式为：进入Setting -\u0026gt; About Phone，连续点击7次Build number。 进入开发者选项设置页面，开启开发者选项，并勾选USB调试模式。 ","date":"2015-07-02","heading":"2、开启USB调试模式（USB debugging）","objectID":"/post/android-development-environment-adb-and-fastboot/:2:0","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#2开启usb调试模式usb-debugging"},{"categories":["Testing"],"collections":null,"content":"3、建立手机与电脑之间的USB调试连接 对于Windows/Mac OSX的开发环境，安装好adb的USB驱动即可。 对于Linux（Debian based）系统的开发环境，若要使用adb或者fastboot工具对手机进行USB调试，则需要配置udev规则。 ","date":"2015-07-02","heading":"3、建立手机与电脑之间的USB调试连接","objectID":"/post/android-development-environment-adb-and-fastboot/:3:0","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#3建立手机与电脑之间的usb调试连接"},{"categories":["Testing"],"collections":null,"content":"创建并配置51-android.rules文件 创建51-android.rules文件，并设置Read权限。 $ sudo touch /etc/udev/rules.d/51-android.rules $ sudo chmod a+r /etc/udev/rules.d/51-android.rules $ sudo vim /etc/udev/rules.d/51-android.rules 在51-android.rules文件中，按照如下格式进行配置。 #HTC SUBSYSTEM==\"usb\", ATTR{idVendor}==\"0bb4\", MODE=\"0664\", GROUP=\"plugdev\" 在该配置文件中： ATTR{idVendor}：设备厂商的编号，每个厂商都有专属的唯一编号，例如HTC的venderID为0bb4 MODE：指定了对该类设备的操作权限，通常设置为0664 GROUP：指定了该类设备的用户组，通常设置为plugdev 对于venderID，可以在此处查看到主流厂商的编号：all vendors listed by Google 除此之外，也可以将手机通过USB连接至电脑，采用lsusb命令进行查看。该命令会列出连接至电脑的所有设备，ID属性值即为设备厂商的venderID。例如，在如下命令执行返回结果中可以看到，HTC的venderID为0bb4 $ lsusb Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub Bus 001 Device 004: ID 0e0f:0002 VMware, Inc. Virtual USB Hub Bus 001 Device 003: ID 0e0f:0002 VMware, Inc. Virtual USB Hub Bus 001 Device 033: ID 0bb4:0cd6 HTC (High Tech Computer Corp.) Bus 001 Device 002: ID 0e0f:0003 VMware, Inc. Virtual Mouse Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub Bus 004 Device 002: ID 0e0f:0002 VMware, Inc. Virtual USB Hub Bus 004 Device 001: ID 1d6b:0001 Linux Foundation 1.1 root hub 对udev配置完成后还不能即时生效，需要重启系统，或者重启udev才能使其生效。 $ sudo /etc/init.d/udev restart 或者 $ sudo sh -c \"(udevadm control --reload-rules \u0026\u0026 udevadm trigger --action=change)\" 再次将手机通过USB连接至电脑后，系统便能检测到设备。 ","date":"2015-07-02","heading":"创建并配置","objectID":"/post/android-development-environment-adb-and-fastboot/:3:1","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#创建并配置51-androidrules文件"},{"categories":["Testing"],"collections":null,"content":"配置用户组 如果当前登录用户不在plugdev用户组内，采用adb或者fastboot工具进行调试的时候需要sudo权限。为了避免每次都这么麻烦，可以将当前登录用户添加至plugdev用户组。 leo@debian8:~$ groups leo cdrom floppy audio dip video netdev scanner lpadmin # 可以看出，当前用户leo并不在用户组plugdev里面 leo@debian8:~$ sudo gpasswd -a username plugdev # 将用户leo添加至用户组plugdev leo@debian8:~$ groups leo cdrom floppy audio dip video plugdev netdev scanner lpadmin # 用户leo已属于用户组plugdev ","date":"2015-07-02","heading":"配置用户组","objectID":"/post/android-development-environment-adb-and-fastboot/:3:2","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#配置用户组"},{"categories":["Testing"],"collections":null,"content":"RSA指纹密钥认证 对于4.2.2及以上的Android版本，将手机开启USB调试模式并连接至电脑以后，手机屏幕上会出现电脑的RSA指纹密钥验证确认请求，这是Android的一套安全机制，只有点击确认以后才能从电脑上通过adb命令与手机建立调试连接。通常，勾选【Always allow from this computer】并点击确认后，手机便与电脑建立了认证连接，之后即可在电脑上采用adb、fastboot工具对手机进行操作。 $ adb devices List of devices attached HT259W103035 unauthorized # 若为进行RSA指纹密钥验证确认，则会出现unauthorized提示 $ adb devices List of devices attached HT259W103035 device # 显示device，则说明已经建立了调试连接 需要说明的是，对于4.2.2及以上的Android版本，adb版本要求1.0.31及以上，对应的Android SDK的版本要求为r16.0.1及以上。 ","date":"2015-07-02","heading":"RSA指纹密钥认证","objectID":"/post/android-development-environment-adb-and-fastboot/:3:3","tags":["Android"],"title":"Android 开发环境配置0：adb和fastboot","uri":"/post/android-development-environment-adb-and-fastboot/#rsa指纹密钥认证"},{"categories":["Testing"],"collections":null,"content":"本文通过案例的形式，详细讲解黑盒测试用例设计技术中的边界值分析法。 无数的测试实践表明，大量的故障往往发生在输入定义域或输出值域的边界上，而不是在其内部。因此，针对各种边界情况设计测试用例，通常会取得很好的测试效果。 边界值分析法就是对输入或输出的边界值进行测试的一种黑盒测试方法，通常作为对等价类划分法的补充，其测试用例来自等价类的边界。 边界值分析使用与等价类划分法相同的划分，只是边界值分析假定错误更多地存在于划分的边界上，因此在等价类的边界上以及两侧的情况设计测试用例。 如果你对等价类划分法还不了解，可先阅读[【黑盒测试用例设计技术\u0026ndash;等价类划分法】]({% post_url 2015-03-18-黑盒测试用例设计技术\u0026ndash;等价类划分法 %})。 ","date":"2015-03-19","heading":"","objectID":"/post/blackbox-testing-method-boundary-value-analysis/:0:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--边界值分析法","uri":"/post/blackbox-testing-method-boundary-value-analysis/#"},{"categories":["Testing"],"collections":null,"content":"对边界值设计测试用例的原则 （1）如果输入条件规定了值的范围，则应取刚达到这个范围的边界值以及刚刚超过这个范围边界的值作为测试输入数据。 （2）如果输入条件规定了值的个数，则用最大个数、最小个数和比最大个数多1个、比最小个数少1个的数作为测试数据。 （3）根据程序规格说明的每个输出条件，使用原则（1）。 （4）根据程序规格说明的每个输出条件，使用原则（2）。 （5）如果程序的规格说明给出的输入域或输出域是有序集合（如有序表、顺序文件等），则应选取集合中的第一个和最后一个元素作为测试用例。 （6）如果程序中使用了一个内部数据结构，则应当选择这个内部数据结构的边界上的值作为测试用例。 （7）分析程序规格说明，找出其它可能的边界条件。 ","date":"2015-03-19","heading":"对边界值设计测试用例的原则","objectID":"/post/blackbox-testing-method-boundary-value-analysis/:1:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--边界值分析法","uri":"/post/blackbox-testing-method-boundary-value-analysis/#对边界值设计测试用例的原则"},{"categories":["Testing"],"collections":null,"content":"Example 某程序具有如下功能：文本框要求输入日期信息，日期限定在1990年1月~2049年12月，并规定日期由6位数字字符组成，前4位表示年，后2位表示月；程序需对输入的日期有效性进行校验。 用等价类划分方法和边界值分析法为该程序的“日期检查功能”设计测试用例。 ","date":"2015-03-19","heading":"Example","objectID":"/post/blackbox-testing-method-boundary-value-analysis/:2:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--边界值分析法","uri":"/post/blackbox-testing-method-boundary-value-analysis/#example"},{"categories":["Testing"],"collections":null,"content":"划分等价类 \u0026amp; 选取边界值 步骤一、要求输入6个数字字符yyyynn；参照等价类划分法规则5，划分为一个有效等价类和三个无效等价类。 有效等价类（1）：输入6个数字字符 无效等价类（2）：输入6个字符，存在非数字的情况 采用边界值，6个字符全为非数字：abcdef 采用边界值，6个字符中有1个为非数字：19930m 无效等价类（3）：输入少于6个数字字符 采用边界值，输入5个数字字符 无效等价类（4）：输入多于6个数字字符 采用边界值，输入7个数字字符 步骤二、在有效等价类（1）的基础上，参照等价类划分法规则6，对该等价类进行细分；考察6个数是否满足日期格式要求，1990\u0026lt;=yyyy\u0026lt;=2049，01\u0026lt;=nn\u0026lt;=12，参照规则，划分为一个有效等价类和四个无效等价类。 有效等价类（5）：日期格式满足要求，1990\u0026lt;=yyyy\u0026lt;=2049，01\u0026lt;=nn\u0026lt;=12 采用边界值，[yyyy,nn]取值为：[1990,06]，[1991,06]，[2020,06]，[2020,11]，[2020,12] 无效等价类（6）：yyyy不满足要求，yyyy\u0026lt;1990 采用边界值，[yyyy,nn]取值为：[1989,06] 无效等价类（7）：yyyy不满足要求，yyyy\u0026gt;2049 采用边界值，[yyyy,nn]取值为：[2050,06] 无效等价类（8）：nn不满足要求，nn\u0026lt;01 采用边界值，[yyyy,nn]取值为：[2020,00] 无效等价类（9）：nn不满足要求，nn\u0026gt;12 采用边界值，[yyyy,nn]取值为：[2020,13] ","date":"2015-03-19","heading":"划分等价类 \u0026amp; 选取边界值","objectID":"/post/blackbox-testing-method-boundary-value-analysis/:2:1","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--边界值分析法","uri":"/post/blackbox-testing-method-boundary-value-analysis/#划分等价类--选取边界值"},{"categories":["Testing"],"collections":null,"content":"设计测试用例 序号 yyyynn 覆盖等价类 预期输出结果 \u0026ndash; \u0026ndash; 覆盖有效等价类和边界值 \u0026ndash; 1 199006 （1）（5） 日期格式有效 2 199106 （1）（5） 日期格式有效 3 202006 （1）（5） 日期格式有效 4 202011 （1）（5） 日期格式有效 5 202012 （1）（5） 日期格式有效 \u0026ndash; \u0026ndash; 覆盖无效等价类和边界值 \u0026ndash; 6 abcdef （2） 日期格式无效 7 19930m （2） 日期格式无效 8 19935 （3） 日期格式无效 9 1993050 （4） 日期格式无效 10 198906 （6） 日期格式无效 11 205006 （7） 日期格式无效 12 202000 （8） 日期格式无效 13 202013 （9） 日期格式无效 ","date":"2015-03-19","heading":"设计测试用例","objectID":"/post/blackbox-testing-method-boundary-value-analysis/:2:2","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--边界值分析法","uri":"/post/blackbox-testing-method-boundary-value-analysis/#设计测试用例"},{"categories":["Testing"],"collections":null,"content":"本文通过案例的形式，详细讲解黑盒测试用例设计技术中的等价类划分法。 等价类划分是一种典型的黑盒测试方法，其原理是把程序的输入域划分成若干部分（子集），然后从每一个子集中选取少数具有代表性的数据作为测试用例。 通过等价类划分，可以在尽可能覆盖所有测试路径的前提下，大幅度减少测试用例的数目。 本文的主要内容有： 等价类的概念介绍 划分等价类的原则 根据等价类设计测试用例的方法 案例演示 ","date":"2015-03-18","heading":"","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:0:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#"},{"categories":["Testing"],"collections":null,"content":"划分等价类 等价类是指某个输入域的子集合。在该子集合中，各个输入数据对于揭露程序中的错误都是等效的。并合理的假设，测试某等价类的代表值就等于对这一类其它值的测试。 等价类划分有两种不同的情况： 有效等价类：指对于程序的规格说明来说是合理的、有意义的输入数据构成的集合。 无效等价类：指对程序的规格说明是不合理的或无意义的输入数据所构成的集合。对于具体的问题，无效等价类至少应有一个，也可能有多个。 在设计测试用例时，要同时考虑有效等价类和无效等价类，以此验证软件在正常操作和异常操作时是否都能正常运行。 确定等价类的6条原则： １、在输入条件规定了取值范围或取值的个数的情况下，可以确立一个有效等价类和两个无效等价类。 例1：输入值是学生成绩，输入形式为文本框，要求的输入范围是0～100 有效等价类：0\u0026lt;=输入成绩\u0026lt;=100； 无效等价类1：输入成绩\u0026lt;0； 无效等价类2：输入成绩\u0026gt;100 ２、在输入条件规定了输入值的集合或者规定了“必须如何”的条件的情况下，可以确立一个有效等价类和一个无效等价类。 例2：输入值是人员性别，输入形式为文本框，要求输入的内容必须在集合{男,女}中 有效等价类：性别＝\u0026lsquo;男\u0026rsquo; 或者 \u0026lsquo;女\u0026rsquo; 无效等价类：性别＝\u0026lsquo;人妖\u0026rsquo; ３、在输入条件是一个布尔量的情况下，可以确立一个有效等价类和一个无效等价类。 例3：输入值是状态标识位“是否完成”，输入形式为单选下拉框，选择范围为{是,否} 有效等价类：选项＝\u0026lsquo;是\u0026rsquo; 或者 \u0026lsquo;否\u0026rsquo; 无效等价类：未进行选择操作 ４、在规定了输入数据的一组值（假设Ｎ个），并且程序要对每一个输入值进行处理的情况下，可以确立Ｎ个有效等价类和一个无效等价类。 例4：输入值是人员性别，输入形式为文本框，要求输入的内容必须在集合{男,女}中；不同的性别选择将跳转至不同的处理页面 有效等价类1：性别＝\u0026lsquo;男\u0026rsquo; 有效等价类2：性别＝\u0026lsquo;女\u0026rsquo; 无效等价类：性别＝\u0026lsquo;人妖\u0026rsquo; ５、在规定了输入数据必须遵守的规则的情况下，可以确立一个有效等价类（符合条件）和若干无效等价类（从各个角度违反规则）。 例5：输入值是人员性别，输入形式为单选下拉框，要求输入的内容必须在集合{男,女}中； 有效等价类：性别＝\u0026lsquo;男\u0026rsquo; 或者 \u0026lsquo;女\u0026rsquo; 无效等价类1：未选择人员性别 无效等价类2：在浏览器开发工具中将人员性别的属性值更改为\u0026rsquo;人妖\u0026rsquo; ６、在确知已划分的等价类中各元素在程序处理中的方式不同的情况下，则应再将该等价类划分为更小的等价类。 例6：在例2（输入值是人员性别，输入形式为文本框，要求输入的内容必须在集合{男,女}中）的基础上，不同的性别选择将跳转至不同的处理页面 有效等价类：性别＝\u0026lsquo;男\u0026rsquo; 或者 \u0026lsquo;女\u0026rsquo; 有效等价类细分1：性别＝\u0026lsquo;男\u0026rsquo; 有效等价类细分2：性别＝\u0026lsquo;女\u0026rsquo; 无效等价类：性别＝\u0026lsquo;人妖\u0026rsquo; ","date":"2015-03-18","heading":"划分等价类","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:1:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#划分等价类"},{"categories":["Testing"],"collections":null,"content":"列出等价类表 输入条件 有效等价类 无效等价类 \u0026hellip; \u0026hellip; \u0026hellip; ","date":"2015-03-18","heading":"列出等价类表","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:2:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#列出等价类表"},{"categories":["Testing"],"collections":null,"content":"确定测试用例 根据已列出的等价类表，按照如下步骤确定测试用例： 1）为每个等价类规定一个唯一的编号 2）设计一个新的测试用例，使其尽可能多地覆盖尚未覆盖的有效等价类。重复这一步，最后使得所有有效等价类均被测试用例所覆盖。 3）设计一个新的测试用例，使其只覆盖一个无效等价类。重复这一步，使所有无效等价类均被覆盖。 ","date":"2015-03-18","heading":"确定测试用例","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:3:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#确定测试用例"},{"categories":["Testing"],"collections":null,"content":"Example1 某程序具有如下功能：输入3个正数a、b、c，分别作为三边的边长构成三角形，输出这3个数所构成的三角形类型。 用等价类划分方法为该程序进行测试用例设计。 ","date":"2015-03-18","heading":"Example1","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:4:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#example1"},{"categories":["Testing"],"collections":null,"content":"划分等价类 分析思路： 步骤一、要求输入3个数，且3个数都为正数；参照规则5，划分为一个有效等价类和三个无效等价类。 有效等价类（1）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; 无效等价类（2）：a\u0026lt;=0 无效等价类（3）：b\u0026lt;=0 无效等价类（4）：c\u0026lt;=0 步骤二、在有效等价类（1）的基础上，参照规则6，对该等价类进行细分；考察3个数能否构成三角形，参照规则5，划分为一个有效等价类和三个无效等价类。 有效等价类（5）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a 无效等价类（6）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026lt;=c 无效等价类（7）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; b+c\u0026lt;=a 无效等价类（8）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+c\u0026lt;=b 步骤三、在有效等价类（5）的基础上，参照规则6，对该等价类进行细分；考察3个数能否构成等边三角形，参照规则2，划分为一个有效等价类和一个无效等价类。 有效等价类（9）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; a=b=c 无效等价类（10）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; a!=b 或 b!=c 或 c!=a 步骤四、在无效等价类（10）的基础上，参照规则6，对该等价类进行细分；考察3个数能否构成等腰三角形，参照规则4，划分为三个有效等价类和一个无效等价类。 有效等价类（11）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; a=b!=c 有效等价类（12）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; b=c!=a 有效等价类（13）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; c=a!=b 无效等价类（14）：a\u0026gt;0; b\u0026gt;0; c\u0026gt;0; a+b\u0026gt;c; a+c\u0026gt;b; b+c\u0026gt;a; a!=b; a!=c; b!=c ","date":"2015-03-18","heading":"划分等价类","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:4:1","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#划分等价类-1"},{"categories":["Testing"],"collections":null,"content":"设计测试用例 序号 [a,b,c] 覆盖等价类 预期输出结果 \u0026ndash; \u0026ndash; 覆盖有效等价类 \u0026ndash; 1 [6,6,6] （1）（5）（9） 等边三角形 2 [3,3,5] （1）（5）（10）（11） 等腰三角形 3 [3,4,4] （1）（5）（10）（12） 等腰三角形 4 [4,5,4] （1）（5）（10）（13） 等腰三角形 \u0026ndash; \u0026ndash; 覆盖无效等价类 \u0026ndash; 5 [3,4,5] （1）（5）（14） 一般三角形 6 [-1,3,2] （2） 不能构成三角形 7 [3,-1,2] （3） 不能构成三角形 8 [3,2,-1] （4） 不能构成三角形 9 [1,2,3] （1）（6） 不能构成三角形 10 [3,1,2] （1）（7） 不能构成三角形 11 [1,3,2] （1）（8） 不能构成三角形 ","date":"2015-03-18","heading":"设计测试用例","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:4:2","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#设计测试用例"},{"categories":["Testing"],"collections":null,"content":"Example2 某程序具有如下功能：文本框要求输入日期信息，日期限定在1990年1月~2049年12月，并规定日期由6位数字字符组成，前4位表示年，后2位表示月；程序需对输入的日期有效性进行校验。 用等价类划分方法为该程序的“日期检查功能”设计测试用例。 ","date":"2015-03-18","heading":"Example2","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:5:0","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#example2"},{"categories":["Testing"],"collections":null,"content":"划分等价类 步骤一、要求输入6个数字字符yyyynn；参照规则5，划分为一个有效等价类和三个无效等价类。 有效等价类（1）：输入6个数字字符 无效等价类（2）：输入6个字符，存在非数字的情况 无效等价类（3）：输入少于6个字符 无效等价类（4）：输入多于6个字符 步骤二、在有效等价类（1）的基础上，参照规则6，对该等价类进行细分；考察6个数是否满足日期格式要求，1990\u0026lt;=yyyy\u0026lt;=2049，01\u0026lt;=nn\u0026lt;=12，参照规则，划分为一个有效等价类和四个无效等价类。 有效等价类（5）：日期格式满足要求，1990\u0026lt;=yyyy\u0026lt;=2049，01\u0026lt;=nn\u0026lt;=12 无效等价类（6）：yyyy不满足要求，yyyy\u0026lt;1990 无效等价类（7）：yyyy不满足要求，yyyy\u0026gt;2049 无效等价类（8）：nn不满足要求，nn\u0026lt;01 无效等价类（9）：nn不满足要求，nn\u0026gt;12 ","date":"2015-03-18","heading":"划分等价类","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:5:1","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#划分等价类-2"},{"categories":["Testing"],"collections":null,"content":"设计测试用例 序号 yyyynn 覆盖等价类 预期输出结果 \u0026ndash; \u0026ndash; 覆盖有效等价类 \u0026ndash; 1 199307 （1）（5） 日期格式有效 \u0026ndash; \u0026ndash; 覆盖无效等价类 \u0026ndash; 2 19June （2） 日期格式无效 3 19Jun （3） 日期格式无效 4 19June2 （4） 日期格式无效 5 198805 （6） 日期格式无效 6 205005 （7） 日期格式无效 7 198800 （8） 日期格式无效 8 199513 （9） 日期格式无效 ","date":"2015-03-18","heading":"设计测试用例","objectID":"/post/blackbox-testing-method-equivalence-partitioning/:5:2","tags":["功能测试","测试设计"],"title":"黑盒测试用例设计技术--等价类划分法","uri":"/post/blackbox-testing-method-equivalence-partitioning/#设计测试用例-1"},{"categories":["Testing"],"collections":null,"content":"在《基于WSDL或SOAP的WebService测试方法\u0026ndash;对原理的思考》一文中写道： 从通讯协议层面上来看，SOAP报文只是对传输的内容进行了格式封装，具体传输实现还是依赖于其它应用层协议（如HTTP）。因此我们在测试WebService时，完全可以抛开WSDL和SOAP，直接从HTTP协议层面获取请求和响应的内容，然后采用测试工具构造HTTP请求，实现对WebService的调用。 在本篇文章中，52test.org仍将在测试案例天气预报WebService服务的基础上，详细介绍如何通过HTTP协议测试WebService。 ","date":"2015-02-13","heading":"","objectID":"/post/WebService-Test-Based-On-HTTP/:0:0","tags":["LoadRunner","WebService","HTTP(S)"],"title":"基 HTTP 的 WebService 测试方法","uri":"/post/WebService-Test-Based-On-HTTP/#"},{"categories":["Testing"],"collections":null,"content":"获取HTTP请求 在天气预报WebService服务的各个接口介绍页面中，均包含一个测试工具，可对接口进行调用测试。 例如，接口getWeatherbyCityName的测试工具如下图所示。在theCityName参数框内输入城市的名称，点击【调用】按钮，即可实现对getWeatherbyCityName接口的调用，并获得返回结果。 在WebService的调用过程中，我们无需关注它具体是采用什么样的通讯协议，因为不管是何种通讯协议，具体传输实现还是会依赖于HTTP。因此，我们可以通过HTTP抓包工具对WebService调用过程中的通讯交互数据包进行捕捉。 在这里我们采用Fiddler Web Debugger进行演示。在浏览器中调用接口getWeatherbyCityName的测试工具时，在Fiddler中抓取到对应的HTTP请求，如下图所示。 从该HTTP请求可以获得如下关键信息： HTTP请求类型为POST，HTTP版本为1.1 请求的URL为：http://webservice.webxml.com.cn/WebServices/WeatherWebService.asmx/getWeatherbyCityName POST的数据包为：theCityName=%E5%B9%BF%E5%B7%9E；POST数据包中对中文进行了URL转码 使用获取到的HTTP信息，可构造HTTP请求，从HTTP协议层面对WebService进行调用。 ","date":"2015-02-13","heading":"获取HTTP请求","objectID":"/post/WebService-Test-Based-On-HTTP/:1:0","tags":["LoadRunner","WebService","HTTP(S)"],"title":"基 HTTP 的 WebService 测试方法","uri":"/post/WebService-Test-Based-On-HTTP/#获取http请求"},{"categories":["Testing"],"collections":null,"content":"在Fiddler中构造HTTP请求 在Fiddler中，可使用Composer对HTTP请求进行构造，如下图所示。 在Request Body中，修改请求参数theCityName为不同的城市（例如，重庆），Execute请求，查看返回结果。 ","date":"2015-02-13","heading":"在Fiddler中构造HTTP请求","objectID":"/post/WebService-Test-Based-On-HTTP/:2:0","tags":["LoadRunner","WebService","HTTP(S)"],"title":"基 HTTP 的 WebService 测试方法","uri":"/post/WebService-Test-Based-On-HTTP/#在fiddler中构造http请求"},{"categories":["Testing"],"collections":null,"content":"在LoadRunner中构造HTTP请求 若要进行性能测试，则需在性能测试工具中构造HTTP请求，再通过多进程或多线程机制实现并发压力测试。 在LoadRunner中，可在Web(HTTP/HTML)虚拟用户协议中，采用web_custom_request函数来构造HTTP请求。 具体的代码实现及回放结果如下图所示。 虽然在LoadRunner中返回的中文显示为乱码，但是从城市编码（57516）可以看出，脚本执行后返回了正确的结果。 ","date":"2015-02-13","heading":"在LoadRunner中构造HTTP请求","objectID":"/post/WebService-Test-Based-On-HTTP/:3:0","tags":["LoadRunner","WebService","HTTP(S)"],"title":"基 HTTP 的 WebService 测试方法","uri":"/post/WebService-Test-Based-On-HTTP/#在loadrunner中构造http请求"},{"categories":["Testing"],"collections":null,"content":"写在后面 通过这篇文章可以看出，在通讯协议层面上对应用服务进行测试时，可以采用更底层的协议。当然，由于HTTP协议是基于Socket的，在测试WebService时也可以从Socket协议层面进行测试，但估计没人会那么去做。 至此，我们已经对主流的WebService测试方法完成了总结，相关的文章如下： [测试工程师的自我修养\u0026ndash;理解WebService]({% post_url 2013-08-05-测试工程师的自我修养\u0026ndash;理解WebService %}) [LoadRunner基于WSDL的WebService测试方法]({% post_url 2013-08-02-LoadRunner基于WSDL的WebService测试方法 %}) [LoadRunner基于SOAP的WebService测试方法]({% post_url 2013-08-06-LoadRunner基于SOAP的WebService测试方法 %}) [基于WSDL或SOAP的WebService测试方法\u0026ndash;对原理的思考]({% post_url 2015-02-12-基于WSDL或SOAP的WebService测试方法\u0026ndash;对原理的思考 %}) [基于HTTP的WebService测试方法]({% post_url 2015-02-13-基于HTTP的WebService测试方法 %}) ","date":"2015-02-13","heading":"写在后面","objectID":"/post/WebService-Test-Based-On-HTTP/:4:0","tags":["LoadRunner","WebService","HTTP(S)"],"title":"基 HTTP 的 WebService 测试方法","uri":"/post/WebService-Test-Based-On-HTTP/#写在后面"},{"categories":["Testing"],"collections":null,"content":"截止至今，我们已经总结了两种WebService测试方法，分别对应了两种应用场景： 在只获知WSDL的情况下，如何采用LoadRunner测试WebService 在具有SOAP报文的情况下，如何采用LoadRunner测试WebService 在文章《LoadRunner基于WSDL的WebService测试方法》和《LoadRunner基于SOAP的WebService测试方法》中，已经详细地描述了两种测试方法的具体实现方式，即使对于一个新接触WebService的测试人员，按照文章中的方法也基本都能完成测试任务。 但是，读到这里你是否会感到困惑：这两种测试方法之间的主要区别在哪儿？两者之间是否存在什么内在联系呢？ 没错，虽然从LoadRunner的操作方式（导入WSDL和导入SOAP）与最终生成的封装函数（web_service_call和soap_request）来看，两者完全是两种不同的测试方法，但是从原理层面上讲，他们本质上都是一样的。 别惊讶，我们先来回顾一下WebService的概念。可参考《测试工程师的自我修养\u0026ndash;理解WebService》。 在WebService中，涉及到的技术名词主要有包括WSDL和SOAP。其中，WSDL就如同WebService的规格说明书，它详细描述了WebService提供的服务接口，包括每个接口的方法名称、接受的参数类型，以及返回的数据结构。除此之外，WSDL还描述了网络传输协议，即WebService支持以什么协议进行通讯交互。而SOAP作为一种XML编码格式的通讯协议，在WebService中应用得最为广泛，基本上绝大多数WebService都支持SOAP协议，以至于谈到WebService时，会不由自主地联想到SOAP，甚至非常多的人混淆了WSDL和SOAP的概念，常在网上提问两者的区别。 事实上，对于WebService而言，SOAP并不是必须的，我们完全可以采用别的通讯协议来代替它。 现在再回到前面的问题。 在基于WSDL的WebService测试方法中：LoadRunner导入WSDL后，对WSDL进行解析，分析出里面包含的接口以及支持的通讯协议，并以友好的交互界面展示给测试人员。测试人员在界面中选择接口（Operation）和通讯协议（Port Name）后，LoadRunner进行封装，生成了web_service_call函数。 而在基于SOAP的WebService测试方法中：给定的SOAP报文已经限定了特定的接口和输入参数，当然，既然是SOAP报文，采用的通讯协议当然就是SOAP协议，并且在报文中指明了特定的SOAP版本。LoadRunner导入SOAP报文后，经过封装，生成了soap_request函数。 说到这里大家应该比较清楚了，不管是基于WSDL还是基于SOAP，最终无非都是指定WebService接口函数和通讯协议，然后，输入参数，获取响应结果。至于LoadRunner采用了什么样的封装函数，那也只是不同的表现形式而已。 其实，从通讯协议层面上来看，SOAP报文只是对传输的内容进行了格式封装，具体传输实现还是依赖于其它应用层协议（如HTTP）。因此我们在测试WebService时，完全可以抛开WSDL和SOAP，直接从HTTP协议层面获取请求和响应的内容，然后采用测试工具构造HTTP请求，实现对WebService的调用。 在下一篇文章中，52test.org将详细介绍如何通过HTTP协议测试WebService，敬请关注。 ","date":"2015-02-12","heading":"","objectID":"/post/Introspection-on-WebService-Test/:0:0","tags":["LoadRunner","WebService","WSDL","SOAP"],"title":"基于 WSDL 或 SOAP 的 WebService 测试方法--对原理的思考","uri":"/post/Introspection-on-WebService-Test/#"},{"categories":["Testing"],"collections":null,"content":"背景知识介绍 ","date":"2014-05-11","heading":"背景知识介绍","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:1:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#背景知识介绍"},{"categories":["Testing"],"collections":null,"content":"IP电话 IP电话是指在IP网络上打电话。所谓“IP网络”就是“使用IP协议的分组交换网”的简称。 常见的IP电话有VoIP（Voice over IP），Internet Telephony 和 VON（Voice over Net）。 ","date":"2014-05-11","heading":"IP电话","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:1:1","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#ip电话"},{"categories":["Testing"],"collections":null,"content":"IP电话网关 IP电话网关（IP Telepathy Gateway），是公用电话网（即公用电路交换电话网，又称传统电话网或电信网）与IP网络的接口设备，其作用包含两个方面： 在电话呼叫阶段和呼叫释放阶段进行电话信令的转换 在通话期间进行话音编码的转换 IP电话网关的出现，实现了PC机用户之间的IP电话通话（无需经过IP电话网关），PC机用户与固定电话用户的IP电话通话（仅需经过IP电话网关1次），以及固定电话用户之间的IP电话通话（需要经过IP电话网关2次）。 ","date":"2014-05-11","heading":"IP电话网关","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:1:2","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#ip电话网关"},{"categories":["Testing"],"collections":null,"content":"IP电话所需要的几种应用协议 在IP电话的通信中，至少需要两种应用协议，即信令协议和传送协议。 其中，信令协议的作用是帮助主叫者在因特网上找到被叫用户。在公用电话网中，电话交换机根据用户所拨打的号码就能够通过合适的路由找到被叫用户，并在主叫和被叫之间建立一条电路连接。这些都是依靠电话信令（Signaling）实现的。我们听到的振铃音、忙音或一些录音提示，以及打完电话挂机释放连接，都是由电话信令来处理的。现在电话网使用的信令是7号信令SS7。利用IP网络打电话同样也需要IP网络能够识别某种信令。但由于IP电话往往要经过已有的公用电话网，因此IP电话的信令必须在功能上与原有的7号信令相兼容，这样才能使IP网络和公用电话网上的两种信令能够互相转换，从而做到互操作。现有的与信令有关的协议为H.323和SIP，本文只介绍SIP协议。 话音分组的传送协议，其作用是使用来进行电话通信的话音数据能够以时延敏感属性在因特网中传送。常见的传送协议为RTP。 ","date":"2014-05-11","heading":"IP电话所需要的几种应用协议","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:1:3","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#ip电话所需要的几种应用协议"},{"categories":["Testing"],"collections":null,"content":"SIP协议 SIP协议，即会话发起协议（Session Initiation Protocol），是使用文本方式的客户服务器协议。 SIP系统只有两种构件，即用户代理（user agent）和网络服务器（network server）。 用户代理包含两个程序，即用户代理客户端UAC（User Agent Client）和用户代理服务器UAS（User Agent Server）；前者用来发起呼叫，后者用来接受呼叫。 网络服务器分为代理服务器（proxy server）和重定向服务器（redirect server）。代理服务器接受来自主叫用户的呼叫请求（实际上是来自用户代理客户端UAC的呼叫请求），并将其转发给被叫用户或下一跳代理服务器；若转发给下一跳代理服务器，则下一跳代理服务器再把呼叫请求转发给被叫用户（实际上是转发给用户代理服务器UAS）。重定向服务器不接受呼叫，它通过响应告诉客户下一跳代理服务器的地址，由客户按此地址向下一跳代理服务器重新发送呼叫请求。 SIP的会话共有三个阶段：建立会话、通信和终止会话。图1给出了一个简单的SIP会话的例子。其中建立会话阶段和终止会话阶段都是使用SIP协议，而中间的通信阶段是使用如RTP这样的传送实时话音分组的协议。 {: .center} 图1 SIP的简单会话过程 在图1中，主叫方（Tesla）先向被叫方（Marconi）发出INVITE报文，这个报文中含有双方的地址信息以及一些其它信息（如通话时话音编码方式等）；被叫方收到请求后，会返回180 Ringing的状态码，并处于振铃状态；若被叫方接受呼叫请求，则返回200 OK的状态码；主叫方再发送ACK报文作为确认（类似于TCP建立连接时的三次握手），然后双方便完成连接，可以进行通话了；通话过程中，双方中的任何一方都可以发送BYE报文以终止会话。如上便是SIP会话的全过程。 ","date":"2014-05-11","heading":"SIP协议","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:1:4","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sip协议"},{"categories":["Testing"],"collections":null,"content":"SIPp工具介绍 ","date":"2014-05-11","heading":"SIPp工具介绍","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:2:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp工具介绍"},{"categories":["Testing"],"collections":null,"content":"SIPp软件简介 如下是SIPp官方网站上关于SIPp的介绍。 SIPp is a free Open Source test tool / traffic generator for the SIP protocol. It includes a few basic SipStone user agent scenarios (UAC and UAS) and establishes and releases multiple calls with the INVITE and BYE methods. It can also reads custom XML scenario files describing from very simple to complex call flows. It features the dynamic display of statistics about running tests (call rate, round trip delay, and message statistics), periodic CSV statistics dumps, TCP and UDP over multiple sockets or multiplexed with retransmission management and dynamically adjustable call rates. SIPp can be used to test various real SIP equipment like SIP proxies, B2BUAs, SIP media servers, SIP/x gateways, SIP PBX, \u0026hellip; It is also very useful to emulate thousands of user agents calling your SIP system. ","date":"2014-05-11","heading":"SIPp软件简介","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:2:1","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp软件简介"},{"categories":["Testing"],"collections":null,"content":"SIPp的安装（Windows） SIPp几乎可以运行在所有UNIX平台上，HPUX、Tru64、Linux (RedHat, Debian, FreeBSD)和Solaris/SunOS。并且，SIPp已被移植到Windows平台。 在Linux/Unix平台上，SIPp采用源代码的形式进行提供，需要通过编译源代码的方式进行安装。 在Windows平台上，SIPp提供了预编译的可执行文件，可以直接进行安装；也可以通过在Windows系统中安装CYGWIN模拟Linux环境，然后通过编译源代码的方式进行安装。 通常情况下，在Windows平台中通过预编译可执行文件可以快速完成安装，操作简单且成功率高，但该种方式存在局限性，主要有如下几点： Windows预编译版本的功能存在删减，不支持PCAP（语音）和密码验证功能； Windows的预编译版本较为滞后，例如当前SIPp的最新版本为3.4，而Windows的最新预编译版本为3.2； 相比于Linux/Unix平台，SIPp在Windows平台上运行不够稳定，特别是并发量较大时可能会出现问题，因此不适宜进行压力测试。 ","date":"2014-05-11","heading":"SIPp的安装（Windows）","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:2:2","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp的安装windows"},{"categories":["Testing"],"collections":null,"content":"SIPp的工作原理 对于电话而言，每台电话机既是客户端（可拨打电话）也是服务端（可接听电话）。同理，SIPp模拟用户代理，也有两种工作模式：UAC和UAS。 SIPp中具有场景（scenario）的概念，它是一个XML格式的文件，其作用是用来描述SIP协议通讯过程。具体地，UAC和UAS分别对应一个场景文件：uac.xml用于描述客户端的呼叫，uas.xml用于描述服务端的响应。在SIPp工具中，已经内置了许多场景文件，可直接使用；测试人员也可参照场景文件的格式要求，自定义编写场景文件。 另外，SIPp采用了数据与场景分离的设计，从而使工具的使用更加灵活。其中，数据包括IP地址、端口号、电话号码、用户名等内容。通常，测试数据保存在CSV文件中，如data.csv。 SIPp是通过命令行的方式操作调用的。例如，在利用SIPp模拟UAC发起呼叫时，可在cmd窗口中运行如下命令： sipp -sn uac 172.31.89.4:5060 -r 1 -rp 3000 -inf data.csv -p 7098 -i 172.31.89.242 -s 8001 -sf uac_onecall.xml –m 1000 –l 900 可以看出，如果每次都在命令行中输入命令进行运行，效率将十分低下；因此，可以将配置好的命令保存在bat批处理文件中，方便测试时直接调用。 ","date":"2014-05-11","heading":"SIPp的工作原理","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:2:3","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp的工作原理"},{"categories":["Testing"],"collections":null,"content":"SIPp的常用工作模式 SIPp具有三种常用工作模式： 当SIPp作为UAC时，可向已有电话终端（电话机或运行在电脑上的虚拟终端）发起呼叫； 当SIPp作为UAS时，可接收到现有电话终端（电话机或运行在电脑上的虚拟终端）发起的呼叫； SIPp还可同时作为UAC和UAS，并使用UAC向UAS发起呼叫，以此来对代理服务器进行测试。 图2是对第三种模式的描述。 {: .center} 图2 SIPp工作原理 在该种模式下，需先运行服务端UAS，然后再运行客户端UAC。接下来，UAC便参照uac.xml场景文件的描述，发起呼叫；而UAS则参照uas.xml场景文件的描述，对呼叫进行响应；整个通讯过程遵循SIP协议，在本文的1.4节已经进行了介绍。 ","date":"2014-05-11","heading":"SIPp的常用工作模式","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:2:4","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp的常用工作模式"},{"categories":["Testing"],"collections":null,"content":"SIPp的使用 ","date":"2014-05-11","heading":"SIPp的使用","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:3:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp的使用"},{"categories":["Testing"],"collections":null,"content":"SIPp的配置文件 通过本文2.3节的介绍可知，使用SIPp时会涉及到5个文件：uac.xml, uas.xml, data.csv, uac.bat, uas.bat。 其用途简要说明如下： uac.xml：用于描述客户端UAC的SIP信号流程； uas.xml：用于描述服务端UAS的SIP信号流程； data.csv：存储数据的文件，方便在uac.xml和uas.xml中引入的相应数据； uac.bat：存储SIPp命令及参数，便于执行时直接调用，模拟UAC的呼叫； uas.bat：存储SIPp命令及参数，便于执行时直接调用，模拟UAS的响应。 由于uac.xml和uas.xml类似，uac.bat和uas.bat类似，本文只对uac.xml、uac.bat和data.csv三个文件进行讲解。 ","date":"2014-05-11","heading":"SIPp的配置文件","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:3:1","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#sipp的配置文件"},{"categories":["Testing"],"collections":null,"content":"uac.xml SIPp默认自带的uac.xml内容如下： \u003c?xml version=\"1.0\" encoding=\"ISO-8859-1\" ?\u003e \u003c!DOCTYPE scenario SYSTEM \"sipp.dtd\"\u003e \u003c![CDATA[ INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: sipp ;tag=[call_number] To: sut Call-ID: [call_id] CSeq: 1 INVITE Contact: sip:sipp@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Type: application/sdp Content-Length: [len] v=0 o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] s=- c=IN IP[media_ip_type] [media_ip] t=0 0 m=audio [media_port] RTP/AVP 0 a=rtpmap:0 PCMU/8000 ]]\u003e \u003c![CDATA[ ACK sip:[service]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: sipp ;tag=[call_number] To: sut [peer_tag_param] Call-ID: [call_id] CSeq: 1 ACK Contact: sip:sipp@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Length: 0 ]]\u003e \u003c![CDATA[ BYE sip:[service]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: sipp ;tag=[call_number] To: sut [peer_tag_param] Call-ID: [call_id] CSeq: 2 BYE Contact: sip:sipp@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Length: 0 ]]\u003e 如该文件所示，UAC首先发送INVITE请求，按照预期，UAC将依次接收100、180和200状态码，然后UAC再次发送ACK进行确认，从而完成通信连接的建立；最后，UAC发送BYE请求，结束通话，从而断开通信连接。 ","date":"2014-05-11","heading":"uac.xml","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:3:2","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#uacxml"},{"categories":["Testing"],"collections":null,"content":"data.csv 在data.csv文件中，第一行描述数据的读取方式，如顺序读取（SEQUENTIAL）、随机读取（RANDOM），或者自定义方式（USER）。 从第二行开始，每行描述一次呼叫时使用的数据；如果每次呼叫时调用的参数有多个，那么就可用分号（\u0026quot;;\u0026quot;）进行分离，并可在XML文件中进行调用，每行的数据从左到右依次为[field0], [field1], \u0026hellip;。 csv文件的编写示例如下： SEQUENTIAL #注释将被忽略 Sarah;sipphone32 Bob;sipphone12 #This line too Fred;sipphone94 XML调用CSV的具体方式如图3所示。 {: .center} 图3 XML调用CSV的原理 ","date":"2014-05-11","heading":"data.csv","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:3:3","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#datacsv"},{"categories":["Testing"],"collections":null,"content":"uac.bat sipp -sn uac 172.31.89.4:5060 -r 1 -rp 3000 -inf data.csv -p 7098 -i 172.31.89.242 -s 8001 -sf uac_onecall.xml –m 1000 –l 900 各个参数说明： 172.31.89.4:5060：远端地址和端口（在xml脚本中用[remote_ip]，[remote_port]引用） -r 1 -rp 3000：每三秒钟（3000毫秒）发起一个呼叫 -inf data.csv：引入数据配置文件 -p 7098：本地端口（在xml脚本中用[local_port]引用） -i 172.31.89.242：本地地址（在xml脚本中用[local_ip]引用） -s 8001：被叫号码（在xml脚本中用[service]引用） -sf uac_onecall.xml：引用xml脚本文件，根据需要模拟的呼叫流程编写 -sn uac :执行默认的uac流程，如需执行自己编写的流程文件，命令中应不含此参数 -m 1000:发送1000次呼叫后停止并退出。 -l 900 :最大同时保持呼叫量，默认值为3*caps值呼叫时长，当因种种原因导致现存呼叫总数达到此值时，SIPp将停止产生新的呼叫，等待现存呼叫总数低于此值时才继续产生呼叫。 -trace_err跟踪所有错误消息，并把错误消息保存到文件场景文件描述的 {fileName}_{pid}_errors.log 文件中 -trace_screen 当程序结束时候打印统计信息并弹出屏幕（如果在后台运行的话） ","date":"2014-05-11","heading":"uac.bat","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:3:4","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#uacbat"},{"categories":["Testing"],"collections":null,"content":"使用SIPp对基于SIP协议的网络电话进行性能测试 ","date":"2014-05-11","heading":"使用SIPp对基于SIP协议的网络电话进行性能测试","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:4:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#使用sipp对基于sip协议的网络电话进行性能测试"},{"categories":["Testing"],"collections":null,"content":"测试内容 测试某通信服务器在400组并发呼叫过程中的CPU负载峰值和内存占用率。 ","date":"2014-05-11","heading":"测试内容","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:4:1","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#测试内容"},{"categories":["Testing"],"collections":null,"content":"测试场景分析 呼叫发起端：湖北省公安厅的两台测试机，每台测试机模拟出等量的电话终端，向十堰市公安局的物理电话机发起呼叫。 电话接收端：十堰市公安局的400台物理电话机。 网络环境：湖北省公安信息网。 测试环境的网络拓扑图如图4所示。 {: .center} 图4 被测系统的网络拓扑图 ","date":"2014-05-11","heading":"测试场景分析","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:4:2","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#测试场景分析"},{"categories":["Testing"],"collections":null,"content":"测试方法 利用SIPp测试工具模拟电话终端，向物理电话机终端发起呼叫，保持呼叫16秒后，SIPp终止呼叫。 在测试过程中，通过命令的方式监控并记录通信服务器的CPU负载峰值和内存占用率。 ","date":"2014-05-11","heading":"测试方法","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:4:3","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#测试方法"},{"categories":["Testing"],"collections":null,"content":"测试步骤 （1）配置SIPp测试工具：在湖北省公安厅的两台测试机上分别配置SIPp的场景文件（uacTest.xml，详见附录），数据文件（CallNumber.csv，详见附录）和测试控制文件（test.bat，详见附录）。 （2）开启通信服务器的资源监控：远程登录至十堰市公安局网络电话通信服务器，执行资源监控命令，开启对通信服务器CPU负载和内存的监控。 （3）执行测试：在湖北省公安厅的两台测试机上同时执行SIPp控制文件（bat文件）。 （4）获取测试结果：测试完毕后，导出被测通信服务器的资源监控结果，提取出并发测试期间中的资源监控结果。 ","date":"2014-05-11","heading":"测试步骤","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:4:4","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#测试步骤"},{"categories":["Testing"],"collections":null,"content":"参考资料 电子工业出版社 计算机网络（第5版）谢希仁 编著。 http://sipp.sourceforge.net/ ","date":"2014-05-11","heading":"参考资料","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:5:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#参考资料"},{"categories":["Testing"],"collections":null,"content":"附录 test.bat sipp -sf uac.xml -inf CallNumber.csv -i 10.XX.XX.151 -p 5061 -s 96018702 -m 1 -r 1 10.XX.XX.114:5060 -trace_err uacTest.xml \u003c?xml version=\"1.0\" encoding=\"ISO-8859-1\" ?\u003e \u003c!DOCTYPE scenario SYSTEM \"sipp.dtd\"\u003e \u003c![CDATA[ INVITE sip:[field0]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: \"[service]\" ;tag=[call_number] To: \"[field0]\" Call-ID: [call_id] CSeq: 1 INVITE Contact: sip:[service]@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Type: application/sdp Content-Length: [len] v=0 o=- 1 2 IN IP[local_ip_type] [local_ip] s=- c=IN IP[media_ip_type] [media_ip] t=0 0 m=audio [media_port] RTP/AVP 0 8 18 101 a=rtpmap:18 G729/8000 ]]\u003e \u003c![CDATA[ CANCEL sip:[field0]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: \"[service]\" ;tag=[call_number] To: \"[field0]\" Call-ID: [call_id] CSeq: 1 CANCEL ]]\u003e \u003c![CDATA[ ACK sip:[service]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: sipp ;tag=[call_number] To: sut [peer_tag_param] Call-ID: [call_id] CSeq: 1 ACK Contact: sip:sipp@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Length: 0 ]]\u003e \u003c![CDATA[ BYE sip:[service]@[remote_ip]:[remote_port] SIP/2.0 Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] From: sipp ;tag=[call_number] To: sut [peer_tag_param] Call-ID: [call_id] CSeq: 2 BYE Contact: sip:sipp@[local_ip]:[local_port] Max-Forwards: 70 Subject: Performance Test Content-Length: 0 ]]\u003e CallNumber.csv SEQUENTIAL 96038000; 96038001; 96038002; ...... ","date":"2014-05-11","heading":"附录","objectID":"/post/SIPp-IP-Telephone-Server-Performance-Testing/:6:0","tags":["性能测试","SIP","IP 电话"],"title":"使用 SIPp 对基于 SIP 协议的 IP 电话通信服务器进行性能测试","uri":"/post/SIPp-IP-Telephone-Server-Performance-Testing/#附录"},{"categories":["Testing"],"collections":null,"content":"引言 在测试场景中，常会遇到需要提交系统时间的情况。本文对使用LoadRunner获取系统时间的几种方法进行探讨。 常用的方法有如下四种： 方法一：使用LR的参数化功能 方法二：使用LR函数lr_save_datetime() 方法三：使用C语言标准函数库中的time()和ctime() 方法四：使用C语言的tm结构，把时间分解成若干元素，再根据需求进行重组 ","date":"2013-10-28","heading":"引言","objectID":"/post/loadrunner-get-system-time/:1:0","tags":["性能测试","LoadRunner"],"title":"LoadRunner 中获取当前系统时间","uri":"/post/loadrunner-get-system-time/#引言"},{"categories":["Testing"],"collections":null,"content":"方法一：使用LR的参数化功能 操作步骤： 1、在Parameter List窗口中新建一个参数localtime_now，Parameter type选择为【Date/Time】 2、设置Date/Time format，具体格式可参照帮助手册，例如%Y-%m-%d %H:%M:%S对应的是2013-10-28 16:43:06 3、在脚本中，利用函数lr_eval_string将参数localtime_now转换为变量tt_1 4、在脚本中引用变量tt_1 对应的脚本如下： char *tt_1; tt_1 = lr_eval_string(\"{localtime_now}\"); lr_message(\"系统当前的时间为：%s\", tt_1); 运行结果： 系统当前的时间为：2013-10-28 16:43:06 ","date":"2013-10-28","heading":"方法一：使用LR的参数化功能","objectID":"/post/loadrunner-get-system-time/:2:0","tags":["性能测试","LoadRunner"],"title":"LoadRunner 中获取当前系统时间","uri":"/post/loadrunner-get-system-time/#方法一使用lr的参数化功能"},{"categories":["Testing"],"collections":null,"content":"方法二：使用LR函数lr_save_datetime() 在LoadRunner中，函数lr_save_datetime可以将当前时间赋值给指定参数，并可在赋值时选择时间样式。 char *tt_2; //获得当前系统时间，并根据设置的格式将当前时间赋值给 times lr_save_datetime(\"%Y-%m-%d %H:%M:%S\", DATE_NOW+TIME_NOW, \"localtime_2\"); tt_2 = lr_eval_string(\"{localtime_2}\"); lr_message(\"系统当前的时间为：%s\", tt_2); 运行结果： 系统当前的时间为：2013-10-28 17:43:16 ","date":"2013-10-28","heading":"方法二：使用LR函数lr_save_datetime()","objectID":"/post/loadrunner-get-system-time/:3:0","tags":["性能测试","LoadRunner"],"title":"LoadRunner 中获取当前系统时间","uri":"/post/loadrunner-get-system-time/#方法二使用lr函数lr_save_datetime"},{"categories":["Testing"],"collections":null,"content":"方法三：使用C语言标准函数库中的time()和ctime() 对应的脚本如下： long tt_3; time(\u0026tt_3); lr_message(\"系统当前的时间为：%s\", ctime(\u0026tt_3)); 运行结果： 系统当前的时间为：Mon Oct 28 17:43:16 2013 ","date":"2013-10-28","heading":"方法三：使用C语言标准函数库中的time()和ctime()","objectID":"/post/loadrunner-get-system-time/:4:0","tags":["性能测试","LoadRunner"],"title":"LoadRunner 中获取当前系统时间","uri":"/post/loadrunner-get-system-time/#方法三使用c语言标准函数库中的time和ctime"},{"categories":["Testing"],"collections":null,"content":"方法四：tm结构分解 说明：tm结构即是一个结构体，将时间分解为9个部分，将时间的各个部分赋值给不同的变量，然后根据实际需求，将各个部分进行重组后使用。 对应的脚本如下： Action() { struct tm { int second; //取得当前秒数(在分钟后)；取值区间为[0,59] int minute; //取得当前分钟数(在小时后)；取值区间为[0,59] int hour; //取得当前小时数(从凌晨0点开始)；取值区间为[0,23] int day; //取得当前天数(从上月结束开始)；取值区间为[1,31] int month; //取得当前月份数(从1月开始)；取值区间为[0,11] int year; //取得当前年份数(从1900年开始) int weekday; //取得当前日期数(为了获取星期几，从上个星期日开始)；取值区间为[0,6] int yearday; //取得当前年份天数(从1月1日开始)；取值区间为[0,365] int daylight; //取得当前夏令时标识符，实行夏令时的时候，daylight取得一个正数 //不实行夏令时的进候，daylight为0； //不了解情况时，daylight为负数 }; long timenow; //定义保存时间的变量 struct tm *now; //定义结构指针 int year, month, day, weekday, hour, minute, second; char *week; time(\u0026timenow); //获取当前时间 now = (struct tm *)localtime(\u0026timenow); //把当前时间的结构指针赋值给now year = now-\u003eyear; month = now-\u003emonth; day = now-\u003eday; hour = now-\u003ehour; //获取hour值 minute = now-\u003eminute; //获取minute值 second = now-\u003esecond; //获取second值 weekday = now-\u003eweekday;//获取week值 switch(weekday)//判断得到中文的星期 { case 1:week =\"星期一\"; break; case 2:week =\"星期二\"; break; case 3:week =\"星期三\"; break; case 4:week =\"星期四\"; break; case 5:week =\"星期五\"; break; case 6:week =\"星期六\"; break; case 0:week =\"星期日\"; break; }; lr_message(\"使用tm结构获取的当前时间为：%d-%d-%d %s %d:%d:%d\",year,month,day,week,hour,minute,second); return 0; } 运行结果： 使用tm结构获取的当前时间为：113-9-28 星期一 17:43:16 ","date":"2013-10-28","heading":"方法四：tm结构分解","objectID":"/post/loadrunner-get-system-time/:5:0","tags":["性能测试","LoadRunner"],"title":"LoadRunner 中获取当前系统时间","uri":"/post/loadrunner-get-system-time/#方法四tm结构分解"},{"categories":["Development"],"collections":null,"content":"字符实体的由来 在HTML源文件中，有些字符（如\u0026lt;，\u0026gt;，\u0026amp;等）作为特殊字符不能直接使用，如果直接使用的话HTML将不能被正确解析。但如果HTML内容中需要展示这些特殊字符时该怎么处理呢？这就需要使用转义字符串来对其进行转义。 转义字符串（Escape Sequence）也称字符实体(Character Entity)。在HTML中，定义转义字符串的原因有两个： 第一个原因：像“\u0026lt;”和“\u0026gt;”这类符号已经用来表示HTML标签，因此就不能直接当作文本中的符号来使用。为了在HTML文档中使用这些符号，就需要定义它的转义字符串。当解释程序遇到这类字符串时就把它解释为真实的字符。在输入转义字符串时，要严格遵守字母大小写的规则。 第二个原因：有些字符在ASCII字符集中没有定义，因此需要使用转义字符串来表示。 ","date":"2013-10-04","heading":"字符实体的由来","objectID":"/post/html-character-entity/:1:0","tags":["字符编码","HTML"],"title":"HTML 字符实体(Character Entity)","uri":"/post/html-character-entity/#字符实体的由来"},{"categories":["Development"],"collections":null,"content":"转义字符串的组成 转义字符串（Escape Sequence），即字符实体（Character Entity）分成三部分： 第一部分是一个\u0026amp;符号，英文叫ampersand； 第二部分是字符实体名称（character entity names）或者是#加上实体编号； 第三部分是一个分号。 对于实体编号，采用的是字符的Unicode编码序号，并且可以采用其对应的十进制编码或者十六进制编码进行表示，具体形式为#nnnn或#xhhhh。 例如，要显示小于号（\u0026lt;），如果采用字符实体名称进行表示，就可以写成\u0026amp;lt;；如果采用字符实体编号的形式，由于其Unicode编码为60（对应16进制为x3c），因此可以写成\u0026amp;#60;或者\u0026amp;#x3c;。 同一个符号，可以用“实体名称”和“实体编号”两种方式进行表示，“实体名称”的优势在于便于记忆，但不能保证所有的浏览器都能顺利识别它；而“实体编号”，各种浏览器都能处理，其劣势在于不便于记忆。 提示：实体名称（Entity）是区分大小写的。 如何显示空格？ 通常情况下，HTML会自动截去多余的空格，多个空格都将被看做一个空格。为了在网页中增加空格，可以使用\u0026amp;nbsp;表示空格。 ","date":"2013-10-04","heading":"转义字符串的组成","objectID":"/post/html-character-entity/:2:0","tags":["字符编码","HTML"],"title":"HTML 字符实体(Character Entity)","uri":"/post/html-character-entity/#转义字符串的组成"},{"categories":["Development"],"collections":null,"content":"HTML Encode \u0026amp; Decode 对于HTML字符实体的Encode和Decode，即是对字符（character）和其对应的字符实体（Character Entity）进行相互转换。 HTML Encode: 字符(Character) =\u0026gt; 字符实体(Character Entity) HTML Decode: 字符实体(Character Entity) =\u0026gt; 字符(Character) 例如，对于汉字“测试”，由于其Unicode编码为\\u6D4B\\u8BD5（对应的十进制为27979 35797），因此Encode后得到\u0026amp;#27979;\u0026amp;#35797;；反向地，对\u0026amp;#27979;\u0026amp;#35797;进行Decode后便得到“测试”。当然，对\u0026amp;#x6D4B;\u0026amp;#x8BD5;进行Decode同样可以得到“测试”。 ","date":"2013-10-04","heading":"HTML Encode \u0026amp; Decode","objectID":"/post/html-character-entity/:3:0","tags":["字符编码","HTML"],"title":"HTML 字符实体(Character Entity)","uri":"/post/html-character-entity/#html-encode--decode"},{"categories":["Testing"],"collections":null,"content":"背景介绍 在性能测试工作中，有时需要对业务系统所能支持的最大在线用户数目进行评估。这与我们接触最多的压力测试不一样，因为用户在线时只是与服务器保持连接，并不一定对服务器有业务请求，从而对服务器不一定会产生压力。然而，在线用户数目并非可以无限增长，当在线用户数目达到应用服务器（或者WebLogic等中间件，或者数据库连接池等）的连接数设置的极限时，业务系统同样可能会发生异常，出现新用户无法登录，或者老用户被挤出系统，甚至业务系统宕机的情况。因此，对业务系统的最大在线用户数指标进行测试是极其必要的。 现有一OA系统，需要测试其支持的最大在线用户数目。已知当使用浏览器登录该系统后，登录用户可持续地保持登录状态，即使长时间不做任何操作也不会自动退出系统；通过该OA系统的在线用户数统计模块可以详细地查看到当前在线的用户。 ","date":"2013-08-22","heading":"背景介绍","objectID":"/post/Evaluate-Largest-Online-Users/:1:0","tags":["性能测试","LoadRunner"],"title":"测试最大在线用户数","uri":"/post/Evaluate-Largest-Online-Users/#背景介绍"},{"categories":["Testing"],"collections":null,"content":"测试方法分析 为了测试被测系统所能支持的最大在线用户数，需要不断地使用新用户帐号进行登录操作，在此同时查看被测系统的在线用户数目以及系统的响应情况。 在新增登录用户时需要注意，由于考察的是系统在正常情况下所能支持的在线用户数目，而不是系统在并发压力下的性能响应情况，因此登录用户时最好采用单个用户或少量并发用户（如两个或三个）逐步登录的形式，不同登录批次之间最好能有一定时间间隔，务必使新增登录用户的操作对服务器产生尽可能小的业务压力。 在新增登录用户的过程中，需要对被测系统的在线用户数目进行查看，并着重关注以下几个方面： 持续新增登录用户的同时，业务系统中的在线用户数目是否相应地进行增长 持续新增登录用户的过程中，系统登录操作是否产生连接超时的情况，事务的响应时间是否出现大幅度上升的情况，系统登录事务是否出现失败的情况（这需要在脚本中对登录事务做检查点设置） 持续新增登录用户的过程中，定期地在浏览器中手动刷新业务系统界面，查看业务系统是否出现不可访问的情况（如内部服务器错误、宕机等） 相应地，测试脚本需符合如下形式： lr_start_transaction(\"进入登录页面\"); /*此处为进入系统登录界面的脚本*/ lr_end_transaction(\"进入登录页面\", LR_AUTO); lr_start_transaction(\"登录系统\"); /*登录脚本--部分1*/ /*对系统登录进行校验 *若成功，即新页面中包含\"重新登录\"，reLogin_Count\u003e0 *若失败，reLogin_Count=0 */ web_reg_find(\"Text=重新登录\", \"SaveCount=reLogin_Count\", LAST ); /*登录脚本--部分2（包含检查点内容部分）*/ //对系统登录结果进行检查 if (atoi(lr_eval_string(\"{reLogin_Count}\")) == 0) { //登录失败 lr_error_message(\"登录失败!!!--UserName：%s\",lr_eval_string(\"{UserName}\")); //勾选Fail Open Transations on lr_error_message //当执到该lr_error_message时，\"登录系统\"的Transaction失败 } lr_end_transaction(\"登录系统\", LR_AUTO); 以上脚本调试成功后，通过检查点函数及日志信息可以判断出系统登录操作已通过脚本回放成功完成。 理论上，只要VuGen采用不同帐号迭代运行该脚本，由于只是进行系统登录操作而未进行系统注销或退出操作，业务系统中的在线用户数将持续增加。 然而在该OA系统中采用此方法时发现，虽然脚本成功运行，但业务系统中的在线用户数并未增长。这说明LoadRunner与浏览器在访问系统的过程中存在差异性。 ","date":"2013-08-22","heading":"测试方法分析","objectID":"/post/Evaluate-Largest-Online-Users/:2:0","tags":["性能测试","LoadRunner"],"title":"测试最大在线用户数","uri":"/post/Evaluate-Largest-Online-Users/#测试方法分析"},{"categories":["Testing"],"collections":null,"content":"脚本及场景设计 针对上面的问题，通过抓包工具Fiddler2对系统进行网络流量抓包 分析可知：用户登录系统后，在未进行任何操作的情况下，浏览器与服务器会定期（间隔30秒）进行通讯交互。如下图所示： 这就解释了为什么用户在浏览器中登录系统后可以长期地保持在线，而通过脚本成功地进行系统登录后却无法保持在线状态；因为VuGen不会像浏览器那样定期地与服务器进行通讯交互。 找出其中的差异后，我们便可在VuGen中用脚本模拟浏览器的定期交互功能，简单的实现方法如下所示。 while(1){ web_url(\"userAction.struts\", \"URL=http://10.147.15.28:9001/userAction.struts?actionType=refreshDynaInfo\", \"Resource=0\", \"RecContentType=text/html\", \"Referer=http://10.147.15.28:9001/jsp/oa/infocomm/sms/sysShortMsg/sysShortMsgReflesh.jsp\", \"Snapshot=t4.inf\", \"Mode=HTML\", LAST); web_url(\"userAction.struts_2\", \"URL=http://10.147.15.28:9001/userAction.struts?actionType=refreshDynaInfo\u0026time=Mon%20Aug%2012%2015:42:04%20UTC+0800%202013\", \"Resource=0\", \"RecContentType=text/html\", \"Referer=http://10.147.15.28:9001/jsp/oa/infocomm/sms/sysShortMsg/reflesh.jsp\", \"Snapshot=t5.inf\", \"Mode=HTML\", LAST); lr_think_time(30); //模拟浏览器与服务器30秒间隔的通讯交互 } 那么，将系统登录脚本和循环函数都放入Action中，在VuGen中采用迭代运行的方式可行吗？ 虽然新增登录用户可以逐个进行，即使用VuGen通过Action迭代的形式采用不同帐号逐个地进行登录操作。但由于用户登录后需要持续间隔地与服务器进行通讯交互才能保持在线，而单线程脚本运行至while(1)后便进入死循环，从而使得Action迭代无效。因此，采用VuGen进行Action迭代的方式是不可行的。 正确的做法是，在Controller中采用逐步加载的方式，使各个虚拟用户独立地运行，从而保证了各虚拟用户登录成功后保持在线状态。 在Controller中的具体配置如下所示： Run Logic：Number of Iterations设置为1 Think Time：Replay think time As recorded Continue on error：False Fail open transactions on lr_error_message：True Schedule：Initialize each Vuser just before it runs Schedule：Start 1000 Vusers \u0026ndash; 1 Vuser every 3 Seconds Schedule：Run for 5 minutes 通过以上配置，可以达到如下效果： 每个Vuser只登录一次，然后定期30秒与服务器进行一次交互，保持在线状态 Vuser的登录操作与定期刷新操作不会对服务器造成并发压力，且符合真实业务场景 逐步缓慢地增加在线用户数，当系统出现异常时即可查看到当前的在线用户数目 ","date":"2013-08-22","heading":"脚本及场景设计","objectID":"/post/Evaluate-Largest-Online-Users/:3:0","tags":["性能测试","LoadRunner"],"title":"测试最大在线用户数","uri":"/post/Evaluate-Largest-Online-Users/#脚本及场景设计"},{"categories":["Testing"],"collections":null,"content":"测试方法优化 通过以上方法可以测试得到业务系统所能承受的“初略的”最大在线用户数目。为什么说是“初略的”呢？因为该方法仍存在缺陷，主要体现在如下两个方面： 该方法只适用于测试期间无他人使用系统的情况。如果测试期间同时有其他用户登录系统，或者系统中本身已存在在线用户，则会造成测试得到的结果不准确。 该方法忽略了系统稳定性对在线用户数的影响。举例来说，也许逐步增加在线用户数至500时，系统并没有发生异常，但这并不意味着500个用户长时间处于在线状态时系统不会出现异常。 针对以上两方面缺陷，可以做出如下改进： 在逐步增加在线用户数的时候，定期（比如间隔3秒）查看业务系统自身统计的在线用户数目，并以该数据为测试结果。 利用之前的方法测试得到业务系统“初略的”最大在线用户数后，使系统长时间保持该数量的在线用户数目，观察系统在长时间运行期间是否会出现异常；若出现异常后，适当减少在线用户数目后重复地进行测试，直到系统可以保持长时间地稳定运行为止，此时对应的在线用户数目即为业务系统所能承受的最大在线用户数目。 在本文提到的OA系统中，对链接[http://10.147.15.28:9001/countAction.struts?actionType=listOnlineUser]进行请求可以返回得到当前系统在线用户数目的统计信息。对应地，设计如下脚本，即可实现对系统实时在线用户数目的查看。 Action() { web_add_cookie(\"USERORGID=db93a2M11f6719ff92Mf528764d624db129b32c21fbca0cb8d6; DOMAIN=10.147.15.28\"); while(1){ lr_start_transaction(\"查看在线用户数目\"); web_reg_save_param(\"OnlineUsers\", \"LB=[在线用户数/总用户数：\", \"RB=]\", \"ORD=2\", \"Notfound=error\", \"Search=Body\", LAST); web_url(\"countAction.struts\", \"URL=http://10.147.15.28:9001/countAction.struts?actionType=listOnlineUser\", \"Resource=0\", \"RecContentType=text/html\", \"Referer=\", \"Snapshot=t1.inf\", \"Mode=HTML\", LAST); lr_output_message(\"当前在线用户数目：%s\", lr_eval_string(\"{OnlineUsers}\")); lr_end_transaction(\"查看在线用户数目\", LR_AUTO); lr_think_time(3); } return 0; } ","date":"2013-08-22","heading":"测试方法优化","objectID":"/post/Evaluate-Largest-Online-Users/:4:0","tags":["性能测试","LoadRunner"],"title":"测试最大在线用户数","uri":"/post/Evaluate-Largest-Online-Users/#测试方法优化"},{"categories":["Testing"],"collections":null,"content":"在LoadRunner脚本开发中，经常会遇到参数与变量相互转换的情况，本文对常见的转换情形进行了方法总结。 ","date":"2013-08-12","heading":"","objectID":"/post/loadrunner-parameter-variable/:0:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#"},{"categories":["Testing"],"collections":null,"content":"变量的赋值 //将字符串赋值给变量 char strTemp[30]; strcpy(strTemp, \"Hello World!!\"); //错误的字符串赋值方式 strTemp = \"Hello World!!\"; /* 注： * 在LR中若直接将字符串赋值给变量，编译时将会报错 * 报错信息：operands of = have illegal types `char' and `pointer to char' */ //将数值赋值给变量 int x = 10; /* 注： * 在LR中，变量的声明一定要放在脚本的最前面，且声明的语句中不要有其他的脚本代码 * 若将以上申明放置在脚本中部，将会产生如下形式的报错信息 * illegal statement termination * skipping 'int' * undeclared identifier 'x' */ ","date":"2013-08-12","heading":"变量的赋值","objectID":"/post/loadrunner-parameter-variable/:1:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#变量的赋值"},{"categories":["Testing"],"collections":null,"content":"参数的赋值 //将字符串赋值给参数 lr_save_string(\"Hello World!!\",\"paraStr\"); //将变量中的值赋值给参数 char strTemp[30]; strcpy(strTemp, \"Hello World\") ; lr_save_string(strTemp, \"paraStr\"); //将数值直接赋值给参数 lr_save_int(123, \"paraNum\"); //将变量中的数值赋值给参数 int num = 10; lr_save_int(num*2, \"paraNum\"); ","date":"2013-08-12","heading":"参数的赋值","objectID":"/post/loadrunner-parameter-variable/:2:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#参数的赋值"},{"categories":["Testing"],"collections":null,"content":"参数的取值 //从参数中进行取值，不管参数是字符串还是数值 lr_eval_string(\"{paraStr}\"); lr_eval_string(\"{paraNum}\"); //取出的值均为字符串类型，因此输出时格式需为\"%s\" lr_output_message(\"%s\", lr_eval_string(\"{paraNum}\")); ","date":"2013-08-12","heading":"参数的取值","objectID":"/post/loadrunner-parameter-variable/:3:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#参数的取值"},{"categories":["Testing"],"collections":null,"content":"参数=\u0026gt;变量 //将参数转换为字符串变量，参数paraStr中的值为\"Hello World!!\" char strTemp[30]; strcpy(strTemp, lr_eval_string(\"{paraStr}\")); lr_output_message(\"%s\", strTemp); //将参数转换为数值变量，参数paraNum中的值为\"246\" int num; num = atoi(lr_eval_string(\"{paraNum}\")); //将字符串转换为数值 lr_output_message(\"%d\", num); //将参数格式化输出到变量 SeatPrefListCount = atoi( lr_eval_string(\"{SeatPrefList_count}\") ); sprintf(varRandomSeatPref, \"{SeatPrefList_%d}\", 1+rand()%SeatPrefListCount); //将格式化的随机日期写入变量varRandomDepartDate sprintf(varRandomDepartDate, \"%d/%d/%d\", 1+rand()%12, 1+rand()%28, 2009+rand()%6); ","date":"2013-08-12","heading":"参数=\u0026gt;变量","objectID":"/post/loadrunner-parameter-variable/:4:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#参数变量"},{"categories":["Testing"],"collections":null,"content":"参数=\u0026gt;参数 //参数的复制：将参数paraStr_1的值复制到参数paraStr_2 lr_save_string(lr_eval_string(\"{paraStr_1}\"),\"paraStr_2\"); ","date":"2013-08-12","heading":"参数=\u0026gt;参数","objectID":"/post/loadrunner-parameter-variable/:5:0","tags":["性能测试","LoadRunner"],"title":"LoadRuner 中的参数与变量","uri":"/post/loadrunner-parameter-variable/#参数参数"},{"categories":["Testing"],"collections":null,"content":"在《LoadRunner基于WSDL的WebService测试方法》一文中，52test.org基于案例天气预报WebService服务，详细讲解了在只获悉WSDL的情况下，如何采用LoadRunner对WebService进行测试。 然而，在实际项目中基于WSDL来测试WebService的情况并不多，WSDL并不是WebService测试的最佳选择。 最主要的原因还是因为WSDL文档过于复杂。在案例（天气预报WebService服务）中，WeatherWebService虽然只包含5个接口，但是其WSDL对应的XML文档多达近500行；而实际项目中，被测系统往往包含上百个WebService接口，其WSDL文档的规模可想而知。 而且，WSDL文档包含的信息过于全面，其中大部分信息对于WebService测试是没有必要的。虽然采用LoadRunner导入WSDL后可以清晰地看见所有接口函数，但是每次都要在上百个接口中选择被测接口也是一件很麻烦的事情。特别是对WebService进行性能测试时，往往只需要选择少数典型的接口。 那么，除了WSDL，还有什么更好的方式呢？ 答案就是SOAP。 SOAP是Simple Object Access Protocol的缩写，从字面上就可以知道它是一种通信协议。在这里我们不对SOAP进行详细讲解，大家有兴趣的话可以参看之前发布的《测试工程师的自我修养\u0026ndash;理解WebService》一文。 采用SOAP对WebService进行测试时，大家只需要知道它以WSDL定义的形式对WebService的调用方式进行了具体描述，包括调用的具体WebService接口名称、参数名称和参数数值。每一个SOAP报文对应了一个WebService接口的调用方式，在其中包含相应的测试数据后，完全可以把它等同于WebService测试用例。 这对于测试人员来说真是再适合不过了。特别是对于新接触WebService的测试人员来说，对WebService的调用方式及其合法参数可能不是很清楚，这时如果能获取到被测接口的SOAP报文，那么测试工作将可以大大简化了。正因如此，在实际项目中，基于SOAP对WebService进行测试的方法应用得更为普遍。 本文将继续在天气预报WebService服务的案例基础上，详细讲解如何采用LoadRunner基于SOAP对WebService进行测试。 需要说明的是，本文只针对测试脚本的开发展开描述，对测试场景的设计暂不进行讨论。 本文中采用的LoadRunner版本为V11.0，不同版本可能会存在一定差异。 ","date":"2013-08-06","heading":"","objectID":"/post/LoadRunner-SOAP-WebService/:0:0","tags":["性能测试","LoadRunner","WebService","SOAP"],"title":"LoadRunner 基于 SOAP 的 WebService 测试方法","uri":"/post/LoadRunner-SOAP-WebService/#"},{"categories":["Testing"],"collections":null,"content":"获取SOAP报文 基于SOAP对WebService进行测试，第一步当然是要先获取到被测接口的SOAP报文，通常可在被测系统的接口设计说明文档（如果有的话）中查询得到，也可直接找开发人员获取。 从测试WebService接口的角度来说，SOAP报文应该至少包含哪些要素呢？ 在本文的演示案例中，以接口getWeatherbyCityName为例，大家在getWeatherbyCityName的介绍页面中可以看到，getWeatherbyCityName支持SOAP 1.1和SOAP 1.2。本文采用SOAP 1.1进行演示。 采用SOAP 1.1的请求报文如下所示： POST /WebServices/WeatherWebService.asmx HTTP/1.1 Host: webservice.webxml.com.cn Content-Type: text/xml; charset=utf-8 Content-Length: length SOAPAction: \"http://WebXml.com.cn/getWeatherbyCityName\" \u003c?xml version=\"1.0\" encoding=\"utf-8\"?\u003e string 将上面这段xml代码保存至一个xml文件，如getWeatherbyCityName.xml，即得到测试所需的SOAP报文。 在报文头中，我们还获取到两个重要信息： Service的URL为/WebServices/WeatherWebService.asmx，加上被测系统的域名后得到完整的URL为http://webservice.webxml.com.cn/WebServices/WeatherWebService.asmx SOAPAction: \u0026ldquo;","date":"2013-08-06","heading":"获取SOAP报文","objectID":"/post/LoadRunner-SOAP-WebService/:1:0","tags":["性能测试","LoadRunner","WebService","SOAP"],"title":"LoadRunner 基于 SOAP 的 WebService 测试方法","uri":"/post/LoadRunner-SOAP-WebService/#获取soap报文"},{"categories":["Testing"],"collections":null,"content":"在LoadRunner中导入SOAP报文 在LoadRunner的Web Services协议中，点击【Import SOAP】，加载之前准备好的SOAP报文，即xml文件；加载完成后，在URL和SOAP Action中分别填入获取得到的地址信息；在Response Parameter中填写存储返回内容的参数名称；如下图所示。 点击【OK】后，便能在脚本界面中生成一个soap_request函数，如下图所示。 通过上图可知，SOAP报文中的全部内容已成功转换为LoadRunner的soap_request函数。 ","date":"2013-08-06","heading":"在LoadRunner中导入SOAP报文","objectID":"/post/LoadRunner-SOAP-WebService/:2:0","tags":["性能测试","LoadRunner","WebService","SOAP"],"title":"LoadRunner 基于 SOAP 的 WebService 测试方法","uri":"/post/LoadRunner-SOAP-WebService/#在loadrunner中导入soap报文"},{"categories":["Testing"],"collections":null,"content":"回放脚本，查看结果 将脚本中的字段theCityName赋值为“广州”；在“Run-time Settings”中打开日志“Extended log”，勾选“Parameter substitution”和“Data returned by server”。运行脚本后，查看“Replay Log”，如下图所示。 在这里如果将脚本回放得到的结果与在浏览器中调用返回的结果进行对比，会发现内容并不一致。在LoadRunner脚本中将theCityName更改为“深圳”、“上海”等城市后重新回放脚本，会发现内容仍然不一致，且LoadRunner每次回放得到的结果都相同。 回顾报文中的Content-Type信息可知，请求报文与响应报文的编码均为UTF-8。因此可以猜想该问题是由于LoadRunner脚本中的编码不为UTF-8造成的，从而使得脚本中的设置的汉字theCityName不被服务商所识别。 对LR脚本中需传送的汉字进行编码转换，即将脚本中的汉字转换为UTF-8，转换方法如下图所示： 重新回放脚本，查看Replay Log。再次对比LoadRunner的Replay Log和浏览器的返回页面可知，LoadRunner对Web Service实现了正确的调用。 通过该案例可知，调用WebService时需要保证输入参数的编码与WebService服务的编码一致，只有这样才能返回正确的结果，这一点需要在调试脚本时格外注意。 ","date":"2013-08-06","heading":"回放脚本，查看结果","objectID":"/post/LoadRunner-SOAP-WebService/:3:0","tags":["性能测试","LoadRunner","WebService","SOAP"],"title":"LoadRunner 基于 SOAP 的 WebService 测试方法","uri":"/post/LoadRunner-SOAP-WebService/#回放脚本查看结果"},{"categories":["Testing"],"collections":null,"content":"随着WebService技术的广泛应用，项目中对WebService进行测试的需求越来越多，而对WebService的性能测试更是重中之重。 作为测试人员，虽然不需要参与WebService的编码实现，但是对WebService相关概念的掌握仍然必不可少，只有这样，才能在测试工作中游刃有余。 本文将对WebService的概念及其相关名词进行阐述，后续将在此基础上，对如何采用LoadRunner测试WebService进行详细介绍。 ","date":"2013-08-05","heading":"","objectID":"/post/concept-about-webservice/:0:0","tags":["WebService","WSDL","SOAP"],"title":"测试工程师的自我修养--理解WebService","uri":"/post/concept-about-webservice/#"},{"categories":["Testing"],"collections":null,"content":"什么是WebService WebService，顾名思义就是基于Web的服务。WebService是一种构建应用程序的普遍模型，可以在任何支持网络通信的操作系统中实施运行，并作为应用组件，采用Web的方式接收和响应外部系统的请求，逻辑性地为其它应用程序提供数据与服务。 无论是简单还是复杂的业务处理，都可以将其封装为WebService，部署成功以后，其它应用程序就可以发现并调用部署的服务。而且，应用程序并不需要关注WebService是基于什么样的系统平台和架构开发实现的，它只需要通过通用的网络协议（如Http）和标准的数据格式（如XML、Soap）来访问WebService，即可通过WebService内部执行得到所需结果。 在实际应用场景中，很多企业用户经过多年的积累，已经部署了很多应用系统，这些应用系统在企业运营中分担着不同的功能或任务。随着企业的发展壮大，由于种种原因，这些企业用户逐渐开始考虑如何对原有的这些旧系统进行整合。使用WebService方式将这些旧的应用系统整合起来，对外部提供一致的接口，不仅可以达到整合已有旧系统的目的，还可以避开因为完全构建一个新系统而产生的风险，这样就大大降低了项目的成本和风险。这也是SOA得以被客户广泛采纳的主要原因。 ","date":"2013-08-05","heading":"什么是WebService","objectID":"/post/concept-about-webservice/:1:0","tags":["WebService","WSDL","SOAP"],"title":"测试工程师的自我修养--理解WebService","uri":"/post/concept-about-webservice/#什么是webservice"},{"categories":["Testing"],"collections":null,"content":"WebService的相关名词解释：WSDL和SOAP 在对WebService进行测试时，接触到最多的两个名词就是WSDL和SOAP。测试人员在跟开发人员沟通时，可能常常会听到开发人员说： “WebService已经部署好了，详细的服务描述你可以参照WSDL” “SOAP报文我已经准备好了，你帮我测下这几个接口的性能吧” 对于不了解WebService的测试人员，可能刚开始的时候会一头雾水，不明白WSDL和SOAP的含义，对于WebService测试任务如何开展就更是不知所措了。 经过在网上一番搜索，看见好多帖子都说使用LoadRunner测试WebService可以采用基于WSDL的【Add Service Call】和基于SOAP的【Import SOAP】的方法。看到这里，新手可能会感到更加迷惑了，WSDL和SOAP到底有啥关联，这两种测试方法到底有啥区别？ 实际上，WSDL与SOAP只是WebService的两大标准，它们之间并没有必然的联系。我们可以先比较一下较为官方的名词解释。 如下是W3C上关于WSDL的解释。 WSDL is an XML format for describing network services as a set of endpoints operating on messages containing either document-oriented or procedure-oriented information. The operations and messages are described abstractly, and then bound to a concrete network protocol and message format to define an endpoint. Related concrete endpoints are combined into abstract endpoints (services). WSDL is extensible to allow description of endpoints and their messages regardless of what message formats or network protocols are used to communicate. 如下是wikipedia上关于SOAP的解释。 SOAP, originally defined as Simple Object Access Protocol, is a protocol specification for exchanging structured information in the implementation of Web Services in computer networks. It relies on XML Information Set for its message format, and usually relies on other Application Layer protocols, most notably Hypertext Transfer Protocol (HTTP) or Simple Mail Transfer Protocol (SMTP), for message negotiation and transmission. 对比两者的详细解释，可以得出： WSDL描述的是服务本身，它以machine-readable的形式对外界描述了该Web Service提供的服务接口，包括Service的名称，调用Service时需要传入的参数类型和格式，以及返回的数据结构。另外，它还以message formats和network protocols无关的形式对网络传输进行了描述。 SOAP本身就是一种通信协议，利用它以WSDL定义的形式对Service的调用方式进行描述，包括调用的具体Service名称、参数名称和参数数值。 对于WebService来说，WSDL是必须的，而SOAP只是通信协议中较为常用的一种，可以用其它通信协议代替；例如可以直接采用HTTP GET/POST的形式对WebService进行调用。 ","date":"2013-08-05","heading":"WebService的相关名词解释：WSDL和SOAP","objectID":"/post/concept-about-webservice/:2:0","tags":["WebService","WSDL","SOAP"],"title":"测试工程师的自我修养--理解WebService","uri":"/post/concept-about-webservice/#webservice的相关名词解释wsdl和soap"},{"categories":["Testing"],"collections":null,"content":"演示案例 为了便于直观理解，本文选取互联网上常用的天气预报WebService服务作为案例进行讲解。 从介绍页面可知，该WeatherWebService一共提供了5项服务：getSupportCity、getSupportDataSet、getSupportProvince、getWeatherbyCityName和getWeatherbyCityNamePro。 对于该WeatherWebService，服务提供商通过WSDL对服务进行了完整的定义，大家可通过这个链接了解一下WSDL文档的结构。 对于每一项服务，服务商提供了4种调用方式：SOAP 1.1、SOAP 1.2、HTTP GET、HTTP POST，并对每一种调用方式都给出了请求和响应示例。 不管是有了WSDL，还是有了SOAP或HTTP的请求和响应示例，就可以对WebService开展测试工作了。而且，这三者分别对应了3种不同的测试方法，可在项目中根据实际情况进行选择。 后续，52test.org将基于WeatherWebService天气服务，分3篇文章分别对采用LoadRunner测试WebService的方法进行详细介绍，并进行案例演示。 《LoadRunner基于WSDL的WebService测试方法》 《LoadRunner基于SOAP的WebService测试方法》 《LoadRunner基于HTTP的WebService测试方法》 敬请期待！ ","date":"2013-08-05","heading":"演示案例","objectID":"/post/concept-about-webservice/:3:0","tags":["WebService","WSDL","SOAP"],"title":"测试工程师的自我修养--理解WebService","uri":"/post/concept-about-webservice/#演示案例"},{"categories":["Testing"],"collections":null,"content":"在《测试工程师的自我修养\u0026ndash;理解WebService》一文中，52test.org对WebService的概念及其相关名词进行了阐述，并引入了一个测试案例：天气预报WebService服务。 作为测试人员的你，假设现在接到一个测试任务，需要对WeatherWebService中的getWeatherbyCityName接口进行性能测试。 而开发人员只给你提供了WeatherWebService的WSDL的URL链接（ http://webservice.webxml.com.cn/WebServices/WeatherWebService.asmx?WSDL ），然后啥也没说就消失不见了。 那么，采用测试工具LoadRunner该怎样对指定接口进行测试呢？ 本文将围绕如上测试需求，对LoadRunner基于WSDL的WebService测试方法进行详细介绍。需要说明的是，本文只针对测试脚本的开发展开描述，对测试场景的设计暂不进行讨论。 本文中采用的LoadRunner版本为V11.0，不同版本可能会存在一定差异。 ","date":"2013-08-02","heading":"","objectID":"/post/LoadRunner-WSDL-WebService/:0:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#"},{"categories":["Testing"],"collections":null,"content":"选择Web Services协议 采用Loadrunner测试WebService时，在单协议里面选择Web Services即可。当然，这并不意味着Loadrunner测试WebService只能采用Web Services协议，在后续的文章中将向大家介绍如何通过HTTP协议来测试WebService。 ","date":"2013-08-02","heading":"选择Web Services协议","objectID":"/post/LoadRunner-WSDL-WebService/:1:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#选择web-services协议"},{"categories":["Testing"],"collections":null,"content":"导入WebService的描述信息WSDL WSDL 是基于 XML 的用于描述 WebService 以及如何访问 WebService 的语言，它对具体的 WebService 进行了描述，规定了服务的位置，以及此服务所提供的操作（或方法，或服务调用接口API）。如果你熟悉WSDL的文档结构，可以直接阅读WSDL获取相关信息。 然而，当你尝试直接去阅读WSDL文档时，你会发现这是一件十分痛苦的事情，毕竟WSDL的设计出发点是供程序阅读的，其文档结构对人员的阅读体验不是很好。 值得庆幸的是，采用LoadRunner测试WebService时，测试人员无需和原始的WSDL文档打交道，只需要在LoadRunner中导入WSDL后，即可对其中定义的函数接口进行调用。 导入WSDL主要采用两种方式： 通过WSDL的URL地址导入 直接导入本地WSDL文件 通过WSDL的URL地址进行导入时，操作方式如下图所示。 需要说明的是，填写的URL地址末尾必须包含?WSDL。换句话说，只有在以?WSDL结尾时才能对应到WSDL文件的路径。大家可以在浏览器中对WSDL的URL地址进行访问，查看WSDL当前是否有效。 如果选择直接导入本地WSDL文件的方式，则需要先将WebService对应的WSDL文件下载至本地。下载时，只需将WebService的地址末尾加上 \u0026ldquo;?WSDL\u0026rdquo; 后在浏览器中进行访问，然后对网页进行保存时将文件另存为\u0026quot;.wsdl\u0026quot;的文件即可。如下图所示。 获取到WSDL文件以后，便可在LoadRunner中以文件的进行导入，操作方式如下图所示。 两种导入方式效果都是一样的，采用任意一种方式都能将WebService的描述信息导入至LoadRunner供其调用。 当然，两种导入方式也存在一定的差异。 采用Import URL的方式可以方便本地获取到最新的WebService描述，当远程服务器端的WebService发生变动以后，本地端可直接对WSDL进行更新，而不需对WSDL进行重新导入。在LoadRunner中，甚至可以通过设置使LoadRunner每次打开脚本的时候自动更新WSDL，如下图所示。 采用Import File方式的优点在于，可以对下载到本地的WSDL文件进行手工编辑后再使用；而缺点则是无法获取到最新的WebService的描述信息，若要更新则需重新下载WSDL文件并重新导入。 明白了两种导入方式的特点之后，大家可以根据实际需求进行选择。 ","date":"2013-08-02","heading":"导入WebService的描述信息WSDL","objectID":"/post/LoadRunner-WSDL-WebService/:2:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#导入webservice的描述信息wsdl"},{"categories":["Testing"],"collections":null,"content":"查看WebService服务接口 在成功导入WSDL以后，在【Operation】栏目下即可看到所有可供调用的接口。值得注意的是，在本测试案例中，每个接口均包含2个Port Name，这是因为该WebService为每个服务接口提供了SOAP1.1和SOAP1.2两个版本的SOAP调用方式。 对比下图可知，这和网页上展示的接口是一致的。 ","date":"2013-08-02","heading":"查看WebService服务接口","objectID":"/post/LoadRunner-WSDL-WebService/:3:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#查看webservice服务接口"},{"categories":["Testing"],"collections":null,"content":"创建调用函数web_service_call 在LoadRunner中导入WSDL之后，便可以对WebService接口进行调用。 LoadRunner提供的调用函数为web_service_call。调用该函数时，可以根据其说明文档直接在Editor里面进行编辑，不过更简单且更不易出错的方式还是通过【Add Service Call】进行可视化编辑。帮助文档里对此也有进行说明。 _ web_service_call is a high-level function that lets you modify all the SOAP arguments intuitively. Because editing the arguments is likely to be error-prone, it is recommended that the function be modified in the tree view of Service Test rather than in the script editor._ 点击【Add Service Call】后进入Web Service Call的可视化编辑界面，如下图所示。 在【Add Service Call】的可视化界面中，对所需调用的Service、Port Name和Operation进行选择。在Operation列表中，可以看到存在5个可供调用的方法，对于每一个Operation，在Port Name下拉框中均可以选择WeatherWebServiceSoap和WeatherWebServiceSoap12，这和上一步骤在【Operations】中查看到的完全一致。 根据本文首部的测试需求，我们在Operation中选择接口getWeatherbyCityName；而由于开发人员未交代SOAP版本信息，因此我们需要对两个版本分别进行测试；在这里我们先选择WeatherWebServiceSoap。 在【Add Service Call】的可视化界面中可以看出，接口getWeatherbyCityName只有一个输入参数，即theCityName。而该接口则是通过城市名来获取指定城市的天气预报信息。 因此，使用getWeatherbyCityName函数接口时我们需对其传入参数theCityName。具体操作时，选中Input Arguments中的参数名theCityName，勾选其右侧的“Include argument in call”，在Value中输入城市名称即可，此处我们输入的是“广州”，如下图所示。 若需要调用getWeatherbyCityName函数的返回结果，则需要事先将其返回结果保存至参数里面。具体操作时，选中Output Arguments中的参数名getWeatherbyCityNameResult[1]，勾选其右侧的“Save returned value in parameter”，在Parameter中输入参数名即可。如下图所示。 完成对Input Arguments和Output Arguments的设置后，点击【OK】按钮，便可看见脚本中新增了一个web_service_call函数，如下图所示。 通过上图可知，之前我们在可视化界面的所有设置均已转换至web_service_call函数。 ","date":"2013-08-02","heading":"创建调用函数web_service_call","objectID":"/post/LoadRunner-WSDL-WebService/:4:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#创建调用函数web_service_call"},{"categories":["Testing"],"collections":null,"content":"回放脚本，查看结果 在“Run-time Settings”中打开日志“Extended log”，勾选“Parameter substitution”和“Data returned by server”。运行脚本后，查看“Replay Log”，如下图所示。 详细结果如下所示。 theWeatherInfo = 广东广州5928759287.jpg2013-8-2 21:50:1224℃/30℃8月3日 大雨转中雨无持续风向微风9.gif8.gif今日天气实况：气温：26℃；风向/风力：东风 2级；湿度：96%；空气质量：优；紫外线强度：弱穿衣指数：热，适合穿T恤、短薄外套等夏季服装。\\n过敏指数：极不易发，无需担心过敏，可放心外出，享受生活。\\n运动指数：较不宜，有较强降水，请在室内进行休闲运动。\\n洗车指数：不宜，今天有雨，雨水和泥水会弄脏爱车。\\n晾晒指数：不宜，有较强降水会淋湿衣物，不适宜晾晒。\\n旅游指数：较不宜，有强降雨，建议您最好还是在室内活动。\\n路况指数：湿滑，路面湿滑，车辆易打滑，减慢车速。\\n舒适度指数：较不舒适，白天有雨，气温较高，闷热。\\n空气污染指数：优，气象条件非常有利于空气污染物扩散。\\n紫外线指数：弱，辐射较弱，涂擦SPF12-15、PA+护肤品。25℃/32℃8月4日 阵雨转晴无持续风向微风3.gif0.gif25℃/34℃8月5日 晴无持续风向微风0.gif0.gif广州是广东省的省会,是中国南方最大的海滨城市，广州位于东经113。17`，北纬23。8`，地处中国大陆南部，广东省南部，珠江三角洲北缘。广州临南海，邻近香港特别行政区，是中国通往世界的南大门，广州属丘陵地带。中国的第三大河----珠江从广州市中心穿流而过。广州是一座历史文化名城。相传在远古时候，曾有五位仙人，身穿五色彩服、骑着嘴衔稻穗的五色仙羊降临此地，把稻穗赠给百姓，祝愿这里永无饥荒。从此，广州便有“羊城”、“穗城”的美称，“五羊”也成为广州的象征。广州既是中国也是世界名城，又是一座古城，因历史上有五羊仙子降临献稻穗的故事，广州又称为“羊城”和“穗城”，简称“穗”；广州一年四季如春、繁花似锦，除夕迎春花市闻名海内外，故又有“花城”的美誉。广州地处低纬,属南亚热带季风气候区。地表接受太阳辐射量较多，同时受季风的影响,夏季海洋暖气流形成高温、高湿、多雨的气候；冬季北方大陆冷风形成低温、干燥、少雨的气候。年平均气温为21.4-21.9度，年降雨量平均为1623.6-1899.8mm，北部多于南部。1982年，广州被国务院选定为全国首批历史文化名城之一，是我国重点旅游城市。1999年1月，广州被评为优秀旅游城市。景观：白云山、莲花山、南海神庙、佛山祖庙、广州动物园等。 在浏览器访问该WebService，查询“广州”时得到结果如下图所示。 通过对比LoadRunner的Replay Log和浏览器的返回页面可知，LoadRunner对Web Service进行了正确的调用。 ","date":"2013-08-02","heading":"回放脚本，查看结果","objectID":"/post/LoadRunner-WSDL-WebService/:5:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#回放脚本查看结果"},{"categories":["Testing"],"collections":null,"content":"完善脚本 脚本虽已调试成功，可以得到正确的结果。但若要进行性能测试，我们还需对脚本进行参数化，如下图所示。 或者，如果我们是只想利用返回报文的一小部分，而不是全部。在这种情况下，我们可以指定将某部分保存至参数，以便后续的使用。 例如，我们只想获得某个城市当天的最低温度和最高温度。通过返回报文可知，该字段是输出结果中的第6个字段。那么，我们便可以将该字段保存至一个参数，这里指定为Lowest_Highest_Temperature，如下图所示。 生成脚本如下所示： 运行结果如下图所示。 当然，此处只是列举了一个简单的例子。通过对web_service_call函数的灵活应用，可以实现更多复杂、强大的功能。 ","date":"2013-08-02","heading":"完善脚本","objectID":"/post/LoadRunner-WSDL-WebService/:6:0","tags":["性能测试","LoadRunner","WebService","WSDL"],"title":"LoadRunner 基于 WSDL 的 WebService 测试方法","uri":"/post/LoadRunner-WSDL-WebService/#完善脚本"},{"categories":["Testing"],"collections":null,"content":"并发用户数是指：在某一时间点，与被测目标系统同时进行交互的客户端用户的数量。 并发用户数有以下几种含义： ","date":"2013-07-31","heading":"","objectID":"/post/performance-index-concurrent-users/:0:0","tags":["性能测试","性能指标"],"title":"性能指标--并发用户数（Concurrent Users）","uri":"/post/performance-index-concurrent-users/#"},{"categories":["Testing"],"collections":null,"content":"并发虚拟用户数（Concurrent Virtual Users，Users_CVU） 在使用专用的测试工具（如Loadrunner、Jmeter）时用于模拟客户端用户的进程或线程的数量；该参数是针对客户端（generator）而言的。 ","date":"2013-07-31","heading":"并发虚拟用户数（Concurrent Virtual Users，Users_CVU）","objectID":"/post/performance-index-concurrent-users/:0:1","tags":["性能测试","性能指标"],"title":"性能指标--并发用户数（Concurrent Users）","uri":"/post/performance-index-concurrent-users/#并发虚拟用户数concurrent-virtual-usersusers_cvu"},{"categories":["Testing"],"collections":null,"content":"有效并发虚拟用户数（Effective Concurrent Virtual Users，Users_ECVU） 被评估目标系统实际感受到的等效于业务请求压力的无思考时间的并发用户数；该参数是针对被评估的目标系统（Target System）而言的。 如果使用测试工具对目标系统进行压力加载时设定了思考时间（Think Time），那么实际有效的并发虚拟用户数可使用如下公式计算得出： Users_ECVU=Users_CVU*Time_ART/(Time_ART+Time_TotalThinkTime) 其中： Time_ART \u0026mdash; 目标系统实际运行时的平均响应时间 Time_TotalThinkTime \u0026mdash; 虚拟用户执行一次该交易过程中使用的思考时间的总和 由此可见： 增加思考时间意味着减少对目标系统的业务请求压力； 当思考时间为零时，有效并发虚拟用户数与并发虚拟用户数相等。 ","date":"2013-07-31","heading":"有效并发虚拟用户数（Effective Concurrent Virtual Users，Users_ECVU）","objectID":"/post/performance-index-concurrent-users/:0:2","tags":["性能测试","性能指标"],"title":"性能指标--并发用户数（Concurrent Users）","uri":"/post/performance-index-concurrent-users/#有效并发虚拟用户数effective-concurrent-virtual-usersusers_ecvu"},{"categories":["Testing"],"collections":null,"content":"内在并发用户数（Limited Concurrent Users，Users_LCU） 目标系统内部能够同时并行处理的客户端用户数。 该参数体现了目标系统的内在并发度，因此当对目标系统进行任何有效的优化和调整之后，其内在并发用户数即内在并发度就会发生变化，通常来讲是指改变目标系统的第一瓶颈后会发生变化。 当 Users_ECVU\u0026lt;=Users_LCU 时，目标系统可以真正地并行处理所有被加载用户的任务请求，此时交易的响应时间会相对保持不变，即交易的实际响应时间，也是交易在目标系统中处理的最快时长； 当 Users_ECVU\u0026gt;Users_LCU 时，目标系统会利用内部的请求调度机制将多出的请求进行排队并在所有的用户请求之间进行任务切换处理，外在表现就是被加载交易的响应时间开始延长。 ","date":"2013-07-31","heading":"内在并发用户数（Limited Concurrent Users，Users_LCU）","objectID":"/post/performance-index-concurrent-users/:0:3","tags":["性能测试","性能指标"],"title":"性能指标--并发用户数（Concurrent Users）","uri":"/post/performance-index-concurrent-users/#内在并发用户数limited-concurrent-usersusers_lcu"},{"categories":["Testing"],"collections":null,"content":"并发在线用户数（Concurrent Online Users，Users_COU） 一般是指实际生产系统中已经和目标系统建立了会话连接的用户总数，并发在线用户数通常是指实际的客户端操作员的数量，是人工发起的业务会话的数量。 并发在线用户数产生的请求压力可以通过公式计算出目标系统感受到的实际业务请求压力，即有效并发虚拟用户数，公式如下： Users_ECVU=Users_COU*Time_ART/Time_AverageIntervalRequestTime 其中： Time_ART \u0026mdash; 目标系统实际运行时的平均响应时间 Time_AverageIntervalRequestTime \u0026mdash; 每个操作员用户发起该交易请求的平均间隔时间 ","date":"2013-07-31","heading":"并发在线用户数（Concurrent Online Users，Users_COU）","objectID":"/post/performance-index-concurrent-users/:0:4","tags":["性能测试","性能指标"],"title":"性能指标--并发用户数（Concurrent Users）","uri":"/post/performance-index-concurrent-users/#并发在线用户数concurrent-online-usersusers_cou"},{"categories":["Testing"],"collections":null,"content":"在某个业务场景中，包含数据创建和数据查询两项业务；现需考察数据创建和数据查询两项业务在并发比例为2:1、总并发量为100用户情况下的混合响应时间。 ","date":"2013-07-22","heading":"","objectID":"/post/scenario-design-scripts-percent-controll/:0:0","tags":["性能测试","LoadRunner","测试设计"],"title":"性能测试场景设计--混合业务场景下的脚本比例控制","uri":"/post/scenario-design-scripts-percent-controll/#"},{"categories":["Testing"],"collections":null,"content":"在Vugen端实现 对混合比例的设置，可直接在脚本中进行，即通过随机函数rand实现，脚本设计如下所示。 int num; Action() { num = rand()%3; lr_start_transaction(\"综合业务--数据创建与数据查询\"); if(num\u003c2){ Data_Create(); //数据创建 } else{ Data_Search(); //数据查询 } lr_end_transaction(\"综合业务--数据创建与数据查询\", LR_AUTO); return 0; } 该种方式的优缺点对比： 优点： 脚本本身实现了比例控制的功能，Controller端的设置较为简单，即在Controller中只需将该混合业务作为单一业务对待，设置也跟单一业务场景的设置方法完全相同； 测试得到响应时间即为混合业务的响应时间。 缺点： 在已有数据创建和数据查询脚本的情况下，针对混合业务场景需要单独创建一个混合业务脚本，且混合比例改变时需要重新修改脚本； 当需要考察混合业务场景中不同业务类型各自的响应时间时，通过该种方式无法实现。 ","date":"2013-07-22","heading":"在Vugen端实现","objectID":"/post/scenario-design-scripts-percent-controll/:1:0","tags":["性能测试","LoadRunner","测试设计"],"title":"性能测试场景设计--混合业务场景下的脚本比例控制","uri":"/post/scenario-design-scripts-percent-controll/#在vugen端实现"},{"categories":["Testing"],"collections":null,"content":"在Controller端实现 在业务类型较多，混合业务场景较为复杂的情况下，采用修改脚本的方式会比较麻烦。例如，若共有5种业务类型，现需要对其任意两种业务的混合场景进行压力测试，如果仍采用第一种方式，那么我们就必须得针对两两业务的混合情况，创建10个混合业务脚本。当业务类型更多，或者混合场景更为复杂（如需考虑任意三种、任意四种业务等的混合情况）时，脚本的创建量会大大增加，且均为乏味的重复性工作。 针对这种情况，直接在Controller端进行设置会简单得多，只需要加载各个业务脚本，并设置不同脚本的并发数即可。对于本文中的案例，在Controller中的设置方法如下所示。 该种方式的优缺点对比： 优点： 无需单独创建混合业务脚本，特别是在业务类型较多的情况时优势更为明显； 测试得到的响应时间为各个业务独自的响应时间，可以实现对混合业务场景下各个业务的单独分析。 缺点： 计算混合业务的响应时间时，需要提取原始测试数据进行计算（不能直接对各个业务的平均响应时间取平均值来作为混合业务的平均响应时间），计算较为复杂。 ","date":"2013-07-22","heading":"在Controller端实现","objectID":"/post/scenario-design-scripts-percent-controll/:2:0","tags":["性能测试","LoadRunner","测试设计"],"title":"性能测试场景设计--混合业务场景下的脚本比例控制","uri":"/post/scenario-design-scripts-percent-controll/#在controller端实现"},{"categories":null,"collections":null,"content":"关于我 中文名 李隆，英文名 Leo Lee，网络 ID debugtalk。 2011 年西电自动化专业毕业后进入中国赛宝实验室（工信部电子五所）软件评测中心，从事第三方软件测评工作。经过 4 年时间，参与的测评项目覆盖了电子政务、电网、互联网、交通、石化、民爆等多个行业，走过很多路，见过很多人，尝试过多种角色，最终发现，在墙角安静地写代码才是我的最爱。 遂于 2015 年初加入阿里巴巴移动事业群（UC），从事测试开发岗位，从此开启了程序猿之路。在此期间，经历了艰难的职业转型，痛苦与快乐并存，个人的技术能力得到了较大的提升，也更加坚定了自身在技术方向的职业发展路线。 2016 年 4 月，猎头推荐了大疆创新（DJI），被其创新产品和极客文化深深吸引，也感觉是一个不错的机会，于是选择跳槽加入，在互联网事业部从事软件质量保障的工作。2016 年 10 月，开始担任测试开发负责人的角色，技术成长与技术管理并行发展。得益于测试开发岗位性质以及部门领导的信任，工作内容和方式具有较大的自由性，有幸可以尝试运用自身积累，探索一个软件工程师的无限可能。 2017 年 6 月，业余时间开启了开源项目 HttpRunner 的迭代，同时也开始持续在 TesterHome 社区和行业技术会议上进行了主题分享，借此机会结识到众多行业前辈。 2019 年 5 月，期望在技术视野和技术深度上有更大的提升，经过慎重思考和艰难地选择，最终告别大疆创新，加入字节跳动飞书（深圳），重新做回一线测试开发人员，主导建设了飞书全球化出海和 KA 私有化部署的测试工具链。 2020 年 6 月，综合考虑家庭因素和职业发展，从公司内部转岗到抖音（成都）专项测试团队。从零搭建团队，探索过 ToB 商业化方向，尝试新的技术领域，经过两年的折腾，终于在 2022 年中开始明确个人及团队的工作方向，兜兜转转又回到了行业评测领域，不断拓展出了性能体验、带宽成本、播放器、音画质、内容体验、交互体验等评测方向。 2024 年 9 月，彻底结束了多年来在技术管理路线发展方向的纠结与内耗，坚定了技术专家的发展路线。有幸赶上了 AI 大模型的浪潮，持续探索 AI 大模型在评测领域的落地场景。 2026 年 7 月，基于团队的需要，开始重新专注于 GUI 自动化架构及工具链的建设。同时个人也逐渐转型成为一名 AI 全栈工程师，借助 AI 编程工具实现了围绕评测领域 Agent 工具产品 RemoBox。 兴趣爱好不多，除了编码，闲暇之余喜欢看点科技人文方面的书籍，写点技术博客。同时在 2026 年 8 月开始自学钢琴，发现也能乐在其中。 ","date":"0001-01-01","heading":"关于我","objectID":"/about/:1:0","tags":null,"title":"About","uri":"/about/#关于我"},{"categories":null,"collections":null,"content":"技术方向 近期工作方向： GUI 自动化架构 \u0026amp; 工具链 Agent 评测工作台 数据采集（抓包/逆向） 之前做过的方向： 接口自动化测试 \u0026amp; 服务端性能测试（HttpRunner） 数字体验监测（DEM） ToB 商业化探索（QuickRunner） UI 自动化（Web \u0026amp; iOS \u0026amp; Android） 性能体验评测（耗时/流畅度/包大小） 带宽成本评测 播放器 \u0026amp; 音画质评测 版权治理（反盗版/反抄袭） 广告风控治理（裸聊/赌博） ","date":"0001-01-01","heading":"技术方向","objectID":"/about/:2:0","tags":null,"title":"About","uri":"/about/#技术方向"},{"categories":null,"collections":null,"content":"成长轨迹 年终总结： 我的 2526 年终总结（TBD） 我的 2324 年终总结 我的 2022 年终总结 我的 2021 年终总结 我的 2020 年终总结 我的 2019 年终总结 我的 2018 年终总结 我的 2017 年终总结 我的 2016 年终总结 我的 2015 年终总结 演讲经历： 2022.06 MTSC 2022 2019.06 MTSC 2019，TTF 开源专场明星讲师（1/10） 2018.07 MTSC 2018，服务端专场明星讲师（1/11） 2018.10 PyCon China 2018 北京千人峰会主会场；深圳分场 2018.09 第41届·MPD 深圳站 2017.08 TesterHome 深圳沙龙第三期 ","date":"0001-01-01","heading":"成长轨迹","objectID":"/about/:3:0","tags":null,"title":"About","uri":"/about/#成长轨迹"},{"categories":null,"collections":null,"content":"Contact 微信公众号: DebugTalk Email: debugtalk@gmail.com 个人微信号: leolee023（添加时请注明来意） ","date":"0001-01-01","heading":"Contact","objectID":"/about/:4:0","tags":null,"title":"About","uri":"/about/#contact"}]