Python自动化测试详解
一、软件测试概述
随着软件和网站的业务功能越来越多样化和复杂化,常规的测试方法已经难以满足实际的工作需求和快节奏的开发迭代。特别是在一些敏捷开发团队里,QA(Quality Assurance,质量保证)成为工作中极为重要的一环。测试工程师不仅需要掌握必要的人工测试手段,而且还要学习和掌握自动化测试技术,从而提高工作效率和测试质量。
自动化测试如同AI无人车,无须人工干预,便可以通过程序和预设配置实现自动化测试,完全覆盖人工测试中较为复杂甚至难以实现的测试点。如今讲究“斜杠青年”(拥有多重职业身份的人),测试人员也应该了解一些必要的编程知识,以丰富自己的技术栈,从研发角度剖析测试会有意想不到的收获。
1、测试分类
关于测试,维度不同,分类方式也多种多样。根据内容来划分,可以将其分为如下4类:
- 功能性测试:测试软件的功能是否如预期一样正常,也包含兼容性测试。
- 性能测试:对系统的各项性能指标进行测试,如页面的响应和渲染速度等。
- 特性测试:测试不同平台的差异,如PC端和移动端的兼容性差异。
- 安全测试:测试数据传输和存储的安全性及访问资源的权限。
针对整个开发周期部署,则可以把测试融入一种V型流程中。
RAD(Rap Application Development,快速应用开发)模型是软件开发中的一个重要模型,由于该模型的构图形似字母V,所以又称为软件测试的V模型。RAD模型大体可以划分为以下几个阶段:需求分析、概要设计、详细设计、软件编码、单元测试、集成测试、系统测试和验收测试。
如图所示:
根据项目开发阶段来划分,可以将测试分为以下5类:
- 单元测试:是指在研发初期开始的针对单一接口或单元级别功能进行的测试。
- 集成测试:是指在迭代过程中每次集成后就进行的测试,以保证每次小幅迭代的功能点都能被测试并通过验证。
- 接口测试:顾名思义,就是针对系统接口进行的测试,可以使用Mock数据来做冒烟测试。
- 系统测试:是指根据系统设计书的指导对系统的功能点进行测试,以发现软件潜在的问题,从而保证系统的正常运行。
- 验收测试:是指根据功能说明书的功能点进行的测试,以保证产品顺利交付给用户(客户),也称为交付测试。
以上测试分类也可以视为一个完整的测试流程,即完成单元测试后再进行集成测试,以此类推。
其中,单元测试并非测试工程师独有,研发工程师在开发过程中也需要对自己的代码进行单元测试,以保证最小可用单元的功能满足需求。除此之外,近几年国内也开始流行TDD(测试驱动开发)的开发模式,即在开发实际功能之前,要求开发者先编写测试代码,以满足测试代码为要求来编写业务代码。这种新的开发方式让测试和开发之间的边界变得模糊,甚至让测试工作的开展优先于实际编程,直接指导最终的编码。
进行单元测试需要有一定的编程能力,在不同的技术栈中都有类似的开源框架可以使用。
其中,Python推荐使用的框架是pytest和unittest。PHP的开源单元测试框架PHPUnit。通过编写单元测试代码,可以提前发现业务功能中存在的问题,从而避免更大问题的产生,岂不美哉?
按测试执行的类型,可以将测试分为4类:
- 功能测试:是指通过测试来检测系统每个功能是否都能正常使用,主要关注外部结构,不考虑系统内部逻辑结构,主要针对软件界面和软件功能进行测试。
- 自动化测试:是利用软件测试工具自动实现全部或部分测试。它是软件测试的一个重要组成部分,能完成许多手工测试无法实现或难以实现的测试。
- 性能测试:是通过自动化的测试工具模拟多种正常、峰值以及异常负载条件来对系统的各项性能指标进行的测试。
- 安全测试:大多数情况下,测试人员都是将安全结合在单元、集成、系统测试中进行的。应用程序级安全测试的主要目的是查找软件自身程序设计中存在的安全隐患,并检查应用程序对非法侵入的防范能力。
常见的性能测试有负载测试和压力测试,两者可以结合进行。
(1)负载测试用来确定在各种工作负载下系统的性能,目标是测试当负载逐渐增加时,系统各项性能指标的变化情况。
(2)压力测试是通过确定一个系统的瓶颈或者不能接收的性能点,来获得系统能提供的最大服务级别的测试。
性能测试的常用指标为:事务响应时间、TPS、并发用户数、吞吐量、点击率、资源利用率等。
常用的安全性测试方法有静态的代码安全测试、动态的渗透测试和程序数据扫描。
在实际项目中,安全性测试基本是用工具完成的,常用的工具有RSAS、AWVS、Appscan、jsky、burpsuite等。
安装测试技术的不同,可以将测试分为3类:
- 黑盒测试:把测试对象看作一个黑盒子,测试人员不用考虑程序内部的逻辑结构和内部特性,只需依据程序的需求规格说明书,检查程序的功能是否符合它的功能说明。界面测试、功能测试就是属于黑盒测试。
- 白盒测试:测试人员利用程序内部的逻辑结构及有关信息,设计或选择测试用例,对程序所有逻辑路径进行测试,通过在不同点检查程序状态,确定实际状态是否与预期的状态一致。单元测试就属于白盒测试。
- 灰盒测试:可以理解为关注输入数据后的系统输出数据是否正确,同时也关注内部表现,但这种关注不像白盒那样详细,只是通过一些表征性的现象来判断内部的运行状态。
黑盒测试的常用方法包括:等价类划分、边界值分析、因果图分析、错误推测法等。
白盒测试方法包括:语句覆盖、判定/条件覆盖、条件组合覆盖、路径覆盖等。
接口测试需要验证的是输入不同的数据,对应输出是否正确。它就是典型的灰盒测试过程。就像在上文提到的,当测试人员在做功能测试时,界面输出是正确的,但内部其实已经错误了。灰盒测试就是避免这种问题的有效手段。
2、并发测试
1. 并发大多分两种情况
点层面上的并发,例如,在中午12点这个时间点,大家同时订午饭。
线层面上的并发,例如,在中午12点至13点这个时间段内,大家可能干不同的事,但同时对服务器产生压力。
第二种情况不要和在线人数混淆了,在线数和并发数是两个不同的概念。
2. 并发测试不等于性能测试
说到并发很多测试工程师的第一反应就是性能测试。性能测试中把并发又分为负载和压力测试,但是并发只能被用于性能测试中吗?这个问题的答案肯定是否定的。
为大家拨乱反正一个观点,并发测试不仅存在于性能测试中。虽然并发测试与性能测试有部分交集,但并不是全包含的关系。希望测试人员在考虑是否要做并发测试时,不单单局限于考虑系统性能需求的角度。并发测试更多被运用于其他领域。
3. 并发测试的分类
并发测试不仅仅是性能测试,它存在于各个测试阶段中,并且测试目的各不相同。
- 对于功能并发测试,要先进行测试单业务功能场景的并发测试,再进行混合业务功能场景的并发测试。
- 功能并发测试目的为验证系统功能是否符合需求规格说明书的要求。
- 对于性能并发测试,通常是满足某些系统性能指标的前提下,让被测对象承担不同的工作量,以评估被测对象的最大处理能力及是否存在缺陷。
- 性能并发测试目的为验证系统性能指标是否符合需求规格说明书的要求。
- 对于稳定性并发测试,通常是判断测试系统的长期稳定运行的能力。
- 稳定性并发测试目的为验证系统稳定性是否符合需求规格说明书的要求。
- 对于异常性并发测试,模拟系统在较差、异常资源配置下运行,如人为降低系统工作环境所需要的资源,网络带宽、系统内存、数据锁等,以评估被测对象在资源不足的情况下的工作状态。
异常并发测试目的为验证系统的异常响应机制是否满足需求规格说明书的要求。
所以并发测试被运用在不同的测试阶段中并且测试目的各不相同。
3、自动化测试
1. 什么是自动化测试
自动化测试是指利用软件测试工具自动实现全部或部分测试,它是软件测试的一个重要组成部分,能完成许多手工测试无法实现或难以实现的测试。能够正确、合理地实施自动测试,可以快速、全面地对软件进行测试,从而提高软件质量,节省经费,缩短软件发布周期。
自动化测试一般分为UI 自动化测试和接口自动化测试。
UI自动化测试是指基于界面元素的自动化测试。需要先定位界面元素的路径,然后通过脚本实现自动化。这种方法因为界面需求的变更频繁,脚本更新频繁,不利于后期的维护工作,造成自动化工作的成本巨大,已经慢慢被各大公司所淘汰。
随即演变出的就是接口自动化了。接口自动化是指模拟程序接口层面的自动化,由于接口不易变更维护成本小,所以它深受各大公司喜爱。接口自动化包含两个部分,功能性的接口自动化测试和并发接口自动化测试。
2. 自动化测试与手工测试区别
自动化测试和手工测试并没有高低贵贱之分,虽然划分在不同的阶层,但只是出于对测试人员个人的价值评判而已。以下详细解析这两者的区别。
1)测试目的不同
虽然都是测试,但这2种测试的目的却是截然相反的。
手工测试的目的在于通过“破坏”发现系统有bug。
自动化测试的目的在于“验证”系统没有bug。
当测试系统处于前期不稳定的时候,做自动化测试将毫无意义,因为程序运行到一半就会因为某个bug而停止的,而当这个bug未被修复之前所有的自动化测试都会卡在这里无法往下执行。而当测试系统处于稳定的时候,通过手工测试重复着一样的操作也会变得烦琐和枯燥,所以这两者在不同的测试阶段都有着不可替代的作用。
2)覆盖范围不同
除了目的的不同,覆盖范围也是不同的。
手工测试可以尽可能地覆盖测试系统的各个角落。
自动化测试只能覆盖测试系统的主要功能。
试想把所有的测试用例都弄成自动化是一件多么美好的事情,但代价实在太大了,投入的时间和产出完全不成正比,不夸张地说如果要做到完全自动化测试,所需要的代码量会远远超过开发编写程序的代码量。所以自动化测试只能挑一些重要和稳定的功能来做,而更多的一些细节的测试还需要手工测试来完成。
3)智能判断不同
自动化和手工测试还有一个最大的区别是智能判断方面。
计算机程序对于人而言是绝对的服从和诚实的。
举个浪漫的例子,用计算机程序去计算1+1,结果必然等于2(除非你的程序本身写的有bug,这不是计算机程序的问题),而如果问一个人1+1等于几,可能会有一个浪漫答案“1+1等于我们”,那这个结果是对还是错呢?如果交给程序判断必然是错的。因此智能判断是自动化测试的瓶颈,一个操作出现多种结果可能都是对的,但又可能都是错的。
再举个电商的例子,比如有个特价产品只有一份,需要秒杀,有可能抢到,也有可能抢不到。对于能抢到来说,只有“他”1 个人抢到是对的,如果多个人都能抢到那就是错的。对于不能抢到来说,已经有1个人抢到就是对的,如果没有一个人抢到的话就是错的,这个时候自动化测试程序该如何判断结果的对错呢?这样的情况比比皆是,虽然有办法通过程序去预置各种条件让结果唯一化,但需要花大量的时间和精力去优化自动化测试代码,并且还需要分多个自动化测试程序完成,这个时候还不如人工介入测试进行判断来得方便。
这样看来其实自动化测试能做的还是非常有限的,而更多的时候还是需要手工测试,利用工具也好,逻辑判断也好,又或者让开发修改程序来配合测试也好,总之能达到测试的最终目的就好,从这个意义上来说手工测试也并非没有技术含量,而自动化测试也没有那么无所不能。
3. 自动化测试的困境
自动化测试具有很大的优势,一劳永逸地用程序代替人力,人力干活8小时,而程序可以24小时不停止地干活。但是自动化测试还有一个很大的困境,即由于自动化测试很难持续维护,导致在大多数公司无法普及这种测试方式。
IT行业的竞争日益激烈,产品要保持自身的竞争力就需要不断高速迭代新版本、新功能。这就意味了原来写的自动化测试程序变得不可用了(其中的部分程序),而留给测试人员的时间又往往是很少的,于是只能手工测试保证按时上线,等上完线之后可能过几天又有新的功能要测试。留给测试的时间不够完成自动化测试程序的维护更新,周而复始,久而久之,原来的自动化测试程序已经和当前版本相去甚远了,最后自动化测试就不了了之了。我想这就是人们常说的“愿望是美好的,但现实总是残酷的”。
既然知道是困境,必然就是很难解决的,那有没有折中的办法来减少一定的维护成本,又可以达到一定的自动化测试的目的呢?回答这个问题之前先要看透自动化测试的核心本质,就是元素识别+元素操作+验证结果,大多数自动化测试工具都会提供元素识别和元素操作(鼠标点击、键盘输入、屏幕 touch 等),只有在验证结果的时候需要写代码提取实际结果,然后和预期结果进行比较,最后得出测试通过或者不通过的结论。
其实对于写代码的部分来说都是通用的,不同的地方在于获取实际结果的方式变更或者预期结果的变更,工作量并不多。真正烦琐之处在于元素的识别,每个元素其实都由唯一标识来识别,这样才能保证不会操作错元素,好处在于如果元素不变,那唯一的标识也永远不会识别错,这是自动化测试可以实施的基础。但有利自然有弊,一旦元素变了,原来的标识就不可用了,那自动化测试就无法实施了。说到这里如果可以绕过元素识别这一步,将元素操作以接口的形式通过脚本完成,就可以抛弃重量级的自动化测试工具,而通过测试脚本直接实现接口自动化测试。
正是因为这样的想法,然后通过大量的实践,发现这条路虽然不能完全解决困境,但能达到一定的平衡,让自动化测试有一条新的出口。
4、Python自动化测试
自动化测试可以使用的编程语言很多,例如针对单元测试,Java有JUnit和TestNG框架,PHP有PHPUnit框架,Python有unittest和pytest框架等。在众多的编程语言中,Python凭借学习成本较低,以及强大的社区和生态,成为最适合进行自动化测试的编程语言。
使用Python做自动化测试的优势如下:
- 编写自动化测试脚本非常简单和方便,相较于其他编程语言更易入门。
- 拥有成熟的自动化框架。Selenium框架自开源以来已经成为最受欢迎的测试框架,它能帮助测试人员加速测试进度,从而顺利交付项目。
- 丰富的类库支持。无论是HTTP(Hyper Text Transfer Protocol,超文本传输协议)网络请求和文件流处理,还是Socket编程及多线程,Python都有强大的工具库可以开箱即用,不用“重复造轮子”,效率非常高。
- Python程序结构简洁、易读,可方便迭代及文档化管理。
- 和人工测试相比,Python编程能让测试人员有机会转型为研发型测试,这对职业发展也有帮助。
Python在软件测试中的实际应用:
5、接口测试和UI测试的比较
首先谈谈接口测试。接口测试和日常的人工测试不同,它往往不是一个对完整功能的测试,而是对某个服务的函数或者对外暴露的访问接口进行测试,测试的目的是检测该接口是否稳定可靠以及是否符合预设的用例测试结果。
一般来说,接口测试可以分为下面三种情况。
(1)基于HTTP的接口测试:例如对用户中心个人数据详情接口进行测试,会使用GET方式向服务器发出请求,获取数据后进行解析,最终与预设期望进行对比,如图所示。
(2)基于Web服务的接口测试:例如,支付中心对外暴露SOAP服务,可以编写Python程序对Web服务进行远程调用,并传入相应参数,解析返回数据,如图所示。
由于远程调用通常使用XML方式,对入参的构造复杂度高于HTTP方式,并且接口的返回结果也需要特别解析。
(3)基于其他通信协议的接口测试:例如WebSocket协议,需要Python通过客户端连接到WebSocket服务器进行双向通信,发送测试数据,测试相关接口响应是否正常,并解析返回的数据。该方式相比传统的轮询方式更加高效。
使用WebSocket协议时,前端可以利用HTML 5技术通过WebSocket客户端调用服务器回调接口,如图所示。
接口自动化测试用程序或者封装好的工具对测试全过程进行模拟,并收集结果进行自动分析,从而有效地解决人工测试的低效问题并减少了可能造成的误差。接口自动化测试类似于黑盒测试,测试人员基于已有的接口说明文档对用例进行测试。
UI测试和接口测试不同,它是基于用户界面进行测试,需要针对页面的特定内容和功能进行。根据平台的不同,UI测试可以分为Web端UI测试和移动端UI测试。
Web端UI测试分为以下几类:
- Web整体页面测试;
- Web内容测试;
- Web导航测试;
- Web图形测试;
- Web表单测试;
- Web兼容性测试(多平台兼容性)。
移动端UI测试分为以下几类。
- 基础功能测试:基础功能的相关测试要特别注意边界值、异常数据等问题。应分析需求和功能要求,对流程进行梳理,以“跑通”基础的功能为主,针对边界值和特殊情况做重点测试。
- 数据交互测试:在完成了基础功能测试之后,针对页面上的数据流进行测试,也需要针对边界和特殊值进行测试,以保证功能可靠。
- 性能测试:包括对页面响应速度、资源加载、流量消耗、CPU占有率、电量的变化及App稳定性(卡屏或闪退等问题)的测试。
移动端的测试情况比PC端(Web端)复杂得多,测试难度倍增。相对PC端而言,移动端的设备屏幕尺寸多,许多操作非常精细、复杂,不同的平台有不同的操作特性,增加了人工测试的工作量和难度。
而自动化测试可以通过工具模拟用户在不同移动设备上的操作,快捷、精准地完成测试。Python体系中有跨平台测试框架Appium,它通过使用WebDriver协议来测试iOS、Android和Windows三大主流平台的应用。
接口测试和UI测试的差异对比如下表所示:
由此可以看出,接口测试更加具有程序化的可能性,只需要基于特定的协议进行接口请求即可,不涉及页面和复杂的操作,非常适合进行自动化测试。
基于UI的测试其实也可以自动化进行,但需要借助第三方工具,如可以模拟页面操作的Selenium。利用这个开源框架可以通过相应的浏览器驱动来操作浏览器,编写模拟鼠标单击、填充文本框、前进或后退页面、定位页面元素等操作的程序,最终完成UI的自动化测试工作。
通过自动化测试,可以有效地避免重复性的测试及人工测试可能带来的低效和错误。除此之外,通过学习相关知识,可以让测试工程师提高编程能力,拓展新的职业发展空间——测试开发工程师。如今,越来越多的工程师具有复合型能力,既可以进行测试工作,也可以进行一些开发工作。能力圈的扩展,使个人的技能树更加圆满,不仅对就业有好处,也给个人职业发展提供了更多的可能性。
随着以新基建为代表的5G技术的日渐成熟,人工智能时代也越来越近,如果一个人躺在舒适区,一直处于低效的人工工作中,那么工作价值会随着时间变得低廉,不利于个人技术能力和职场竞争力的提高,特别是在这个需要终生学习的时代
二、Python环境准备
1、Python简介
Python和C、Java、Php等语言一样,是一种计算机程序设计语言。
Python 是一个高层次的结合了解释性、编译性、互动性和面向对象的脚本语言。这样说的确还是有点抽象,有句流传很广的话“人生苦短,我用Python”,很好地形容了Python的特点,比如,完成同一个任务,C语言要写1000行代码,Java只需要写200行,而Python可能只要50行。
当然代码少的代价是运行速度较之其他语言要慢一些,但仅仅作为测试来用的话,性价比又是非常高,因为Python的优点远远大过它的缺点。
- 易于学习:结构简单,而且有一个明确定义的语法,学习起来更加简单。
- 易于阅读:代码清晰,这也是由语法特点决定的。
- 易于维护:代码清晰,必然维护起来更加方便。
- 一个广泛的标准模块:Python最大的优势之一是丰富的模块,跨平台的,与Linux、Windows和OS兼容很好。
- 可移植:基于其开放源代码的特性,Python已经被移植(也就是使其工作)到许多平台。
以上的特点都非常适合测试,一方面起步容易,不需要本身太强的技术背景,另外一方面又易于维护和兼容(这往往是测试最头疼的事情)。
2、Python安装配置
Python代码要运行,必须要有Python解释器。
目前,Python有两个版本,一个是2.x版,一个是3.x版,这两个版本是不兼容的。
Python3.x的版本是没有什么区别的,这里以3.6版本来演示安装的过程。
这里只介绍Windows环境下的安装。
1. 下载安装包
首先我们打开python官网(https://www.python.org/),官方网站下载python解释器。
鼠标放在Download上,点击下面对应的型号:
因为一些兼容性问题,最新版不一定是最好的,所以我们选择下载python3.6.x,然后点击windows。
然后往下拉,一直到Files部分。选择对应自己电脑型号,我电脑是X86 64位的。
2. 安装
下载成功,双击python-3.6.3-amd64.exe进行安装。
注意:勾选Add Python3.6 to PATH (勾选之后会自动设置python变量。可直接在命令行输入python命令)。
勾选之后点击Install Now:
进度条到底安装成功~打开cmd 查看python是否安装成功以及版本:
python -V(v大写)
3. 配置环境变量
为了便于管理,我们需要在安装文件夹创建一个python36.exe文件,因为python是可以多个版本共存的。
创建一个副本,就是复制python.exe文件,然后粘贴。
将副本重命名为python36.exe,并复制路径所在路径。
接下来将python36添加到环境变量中。
点击此电脑—》右键—》属性:
点击高级系统设置:
点击高级——》环境变量:
点击系统变量(s),找到path,双击进入:
选择新建:
复制Python安装文件的路径:
最后点击确定即可。
4. Python包管理工具配置
众所周知,python有着强大的第三方库。
pip是Python包管理工具,对Python包的查找、下载、安装、卸载的功能。
所以安装完python之后,还需要设置环境变量——手动添加环境变量:
首先打开python的安装路径,pip软件管理包在默认在Scripts
文件夹下面。
接下来复制pip所在文件夹路径:
然后右击我的电脑->属性->高级系统设置->点击环境变量->点击PATH->新建->加入pip所在文件夹路径。
系统环境变量,对所有用户起作用,而用户环境变量只对当前用户起作用。
例如你要用python,那么你把python的bin目录加入到path变量下面(添加方法),那么它就是系统环境变量,所有用户登陆,在命令行输入python都会有python的帮助信息出来。而如果你在某个用户的变量下面新建一个变量,那么它就只对这个用户有用,当你以其他用户登陆时这个变量就和不存在一样。
所以我们在之这里选择系统变量(S),找到Path后双击->新建->加入pip所在文件夹路径。
接下来一路确定。
到此环境变量就配置完毕了。
3、Python模块安装
Python本身虽然自带了很多标准的模块,但如果要实现更多的功能,需要安装很多第三方模块,而Python最强大的一点也在于有丰富的第三方模块,那么如何安装这些模块呢?
Python自带了安装工具,只需要在命令行输入:
pip install 模块名称
比如需要安装一个datetime模块,只需要在命令行输入:
pip install datetime
等待安装完成之后就可以在代码中通过引用导入该模块了。
因为要使用 webdriver ,必须要安装 request 。于是使用命令pip install requests
,但是报如下错误:
使用where pip来寻找对应的pip服务路径。
进入要使用的 pip 服务的路径,再执行之前要执行的命令:
pip install requests
4、Python IDE开发工具
正所谓“工欲善其事,必先利其器”。一款好的工具可以让编码过程变得简单,包括编码过程中间的debug调试、代码的自动补全、参数的提供选择等,可以在编码的时候更多地考虑编码的结构,而非在意语法和输入是否错误,从而提高编码的效率。
简单介绍几个 Python的常用开发工具。
有很多的 Python 开发人员,前期都有使用 Java 等语言的经验。那么对于这类开发者,pydev+eclipse会是很好的选择。
对于编程初学者,建议安装activepython,自带的IDE 非常不错。它是纯粹的Python用TK写的,可以完成所有的单文件任务。
当成为中级开发人员后,我们可以选择更专业的IDE,比如pycharm,专业的django开发IDE。
sublime text也是一款非常有质感,而且功能非常有吸引力(如它的多行修改功能、插件功能)的IDE。它可以为多种语言服务,并且对于Python的以空格为区分语句的风格,sublime非常不错。
经过实际使用对比,发现pycharm相对来说功能更为强大,易用性和专业性都兼备,很多开发人员都会选择这款工具作为开发的工具。
1. Pycharm安装
PyCharm是一款Python IDE,其带有一整套可以帮助用户在使用Python语言开发时提高其效率的工具,比如, 调试、语法高亮、Project管理、代码跳转、智能提示、自动完成、单元测试、版本控制等等。此外,该IDE提供了一些高级功能,以用于支持Django框架下的专业Web开发。
进入PyCharm官方下载地址:链接
找到你下载PyCharm的路径,双击.exe文件进行安装:
点击 Next 后,我们进行选择安装路径页面(尽量不要选择带中文和空格的目录)。
进入 Installation Options(安装选项)页面,全部勾选上。
点击 Next:
进入 Choose Start Menu Folder 页面,直接点击 Install 进行安装:
等待安装完成后出现下图界面,我们点击 Finish 完成。
2. Pycharm使用
双击桌面上的 Pycharm 图标,进入到Pycharm中。
我们勾选 I confirm 后,点击 Continue:
现在进入到创建项目界面,我们选择 New Project 新建项目:
我们修改 Location (项目目录路径),自己起个名my_pythonProject
选择interpreter(解释器):我的版本是python3.6:
创建.py 文件,选择项目点击 New-> Python File,然后输入文件名为 test:
写入代码,右键选择Run‘test’:
3. Pycharm实用功能
1)字体设置
点击PyCharm界面左上角的File->Settings,在搜索栏中输入incresse回车,在右侧的Editor Action下对Increase Font Size单击,选中Add Mouse Shortcut设置Ctrl+滚轮向上实现增大字体。
减小字体输入decrease同理操作。
2)汉化
同样的File->Settings->Plugins(插件)在搜索栏中输入Chinese(Simplified)下载中文插件并安装重启PyCharm即出现汉化。
三、Python基础
1、Python语法
说起Python的语法,就两个字“简单”。不夸张地说,一天就能入门。相比于其他语言的繁文缛节,一丝不苟,Python可谓相当不拘一格。
1. 行与缩进
Python的语法其实总结一下就两条。
(1)一条代码作为一行:比如一个赋值、一个运算、一个请求、一条打印等。
(2)对齐和缩进:同一级别的代码只需要保持对齐就可,对于不同级别的代码进行缩进,这样就能区分开代码执行的逻辑。
第一条其实很好理解,类似于近体诗,一句代表一个完整的意思,最后组成一个整体的代码。
悄悄的我走了
正如我悄悄的来
我挥一挥衣袖
不带走一片云彩
对应的代码如下:
a = 1
b = 2
c = a+b
print (c)
一目了然可以看懂代码,给变量a赋值为1,给变量b赋值为2,变量c等于a+b,打印变量c的值。
在理解第一条的基础上,再来理解第二条,上面的每一句诗都是同一个级别的,所以没有高低之分,只有顺序的先后。而需要表达层级关系的时候,可以用缩进来表示上下的层级,这个类似于Word的目录结构。
1 Python基础
1.1 Python语法1
1.2 Python语法2
1.2.1 Python语法2.1
实例代码:
a = 1
if a < 0:
print("负数")
else:
if a == 0:
print ("零")
else:
print ("正数")
给变量a赋值为1,然后判断a是否小于0,如果小于0,打印负数,如果不小于,则进入else的执行模块(if和else是平级的,即如果执行了if,就不执行else,反之也一样),然后进入else模块,继续判断a是否等于0,如果等于0,打印零,否则打印正数(else中的if和else是平级的,但隶属于else,只有当判断执行到了else才会再继续执行if和else的判断),这里只是为了说明缩进和层级的关系。
明白了这些,就已经对Python的语法入门了。
2. 模块导入
如果在Python程序中需要调用某些方法,而这些方法又存在于Python的标准模块或者第三方模块之中,那就需要对模块进行导入,只有将模块导入后才能调用相应的方法。
import 语句
先以导入Python自带的random模块为例,需要生成一个0~100的随机数并打印随机数的结果。
实例代码:
1 import random
2 a = random.randint(0,100)
3 print (a)
代码说明:
- 导入random模块。
- 调用random模块中的randint()函数,这个函数会随机生一个0~100的整数,并将结果赋值给变量a。
- 打印产生的随机数变量a。
由此可见,调用模块的方法就是先导入,然后以(模块.方法)的方式调用,当然有些方法里面还可以嵌套方法(模块.子模块1.方法a),即调用模块下的子模块1下的方法a,这是一个从属关系,一层层往下调用。
实例代码:
1 import sys
2 a = sys.modules.keys()
3 print (a)
代码说明:
- 导入sys模块。
- 调用sys模块中的modules模块下的keys()函数,这个函数返回所有已经导入的模块列表,并将结果赋值给变量a。
也许从代码的角度来说,这种写法比较冗余,当大量调用子模块下的各种方法时,需要写很长的代码,不过这种方法适用于初学者,因为初学者对于模块内的方法并不十分了解,所以导入整个模块,然后一层层往下找适用的方法,直到选择出最适合的方法。
但对于已经熟练知道需要调用什么方法的人来说,就可以更精准地调用子模块,从而减少代码量。这时候需要使用form … import …来实现调用。从语法的语句来说很好理解,即从哪个模块中导入它的子模块。
实例代码:
1 from sys import modules
2 a = modules.keys()
3 print (a)
代码说明:
- 从sys模块下导入modules模块。
- 直接调用 modules 模块下的 keys()函数,这个函数返回所有已经导入的模块列表,并将结果赋值给变量a。
这两段代码运行的结果是一致的,只是导入的模块层级不同,所以调用的层级也不同。值得注意的是如果使用第二种导入方式的话,只有modules这个模块下的方法可用,sys下的其他模块是不可用的。
还有一种导入模块的方式与第二种类似,只是不再指定某个子模块,而是模块下的所有子模块。
实例代码:
1 from sys import *
2 a = modules.keys()
3 print (a)
运行结果也是一样的,区别于第二种的是只能使用modules模块下的方法,其他子模块一样可用,所以3种方式可以根据个人喜好或者实际需要灵活选择使用。
3. 注释
Python 的注释与其他编程语言的注释是不一样的,一般主流的编程语言都是以//来注释一行代码,或者/* */注释一段代码,而Python却是特有的用#来注释。
Python单行的注释以#开头,所有#后的这一行代码或者中文注释都不会被执行。
实例代码:
1 a = 1
2 b = 2
3 # a = b
4 print (a+b)
执行这段代码,打印结果是3,因为执行到b = 2之后后面的注释行不会被执行,所以第三行a = b不会被执行,所以a+b的结果是1+2=3,而不是2+2=4。
如果要注释一段代码,则有一点不同,把3个单引号或者3个双引号当作一行,分别加到需要注释代码的上一行和下一行。
实例代码:
1 """
2 a = 1
3 print(a)
4 """
5 b = 4
6 print (b)
执行这段代码,运行结果是4,因为第二行和第三行代码被注释了,所以不会被执行。
2、变量与运算
1. 变量
变量可以由字母、数字或者下画线任意组合而成,唯一的约束就是变量的第一个字符必须是字母或者下画线,而不可以是数字。
1 a = 1
2 a_1 = 2
3 _b = 3
4 1_b =4
根据规则,第四行的变量定义是不符合规范的,所以该程序会报错。
从上面的例子中你会发现在定义变量和赋值时,Python与其他编程语言最大的不同就是赋值不需要类型声明,而且变量可以存储任何值。
每个变量在内存中创建,都包括变量的标识、名称和数据这些信息。
每个变量在使用前都必须赋值,变量赋值以后该变量才会被创建。
等号(=)用来给变量赋值。
等号(=)运算符左边是一个变量名,等号(=)运算符右边是存储在变量中的值。
实例代码:
1 a = 100
2 b = "John"
3 c = 1.11
4 print (type(a))
5 print (type(b))
6 print (type(c))
代码说明:
- 给变量a赋值为100的整数,a就是整数类型。
- 给变量b赋值为1.11的浮点数,b就是字符串。
- 给变量c赋值为John的字符串,c就是浮点型。
变量不仅仅可以赋值各种类型,而且还可以随意改变类型。
实例代码:
1 a = 1
2 print (type(a))
3 a = 3.22
4 print (type(a))
5 a = "John"
6 print (type(a))
代码说明:
分别打印3次a的类型,可以发现变量a的类型随着赋值的改变而不断地改变。
不要小看这么一个小小的语法特点,随着不断深入学习,这个优势可以被无限放大,尤其对于不懂编程的初学者来说,数据类型往往是最头疼的一点。
再说变量分为全局变量和局部变量,顾名思义,全局变量就是对于整个文件产生作用,也就是说该变量直到程序结束才会被回收,而局部变量只能对某一部分代码产生作用,一旦这部分代码结束,这个变量就会被回收。
下面来对比一下两者的差别:
实例代码:
1 def d():
2 a = 1
3 if __name__ == "__main__":
4 d()
5 print (a)
代码说明:
- 定义一个d的函数。
- 将变量a赋值为1。
- 主函数调用。
- 调用自定义函数d。
- 打印变量a。
因为a在执行完第四行d函数的时候就被回收了,第五行执行时发现不存在a这个变量,所以就报错了,如果要打印变量需要在d函数中执行。
实例代码:
1 def d():
2 a = 1
3 print (a)
4 if __name__ == "__main__":
5 d()
这样a就在回收之前执行了打印,就不会报错了,通过这个例子应该可以理解局部变量的作用范围了吧。
接着再说全局变量,全局变量一般定义在文件开头,不能放在某个函数之中,这样的话就可以作为全局变量被外部文件或者文件内其他函数调用,但调用时需要通过一个global+变量的方式引用全局变量。
实例代码:
1 a = 1
2 def d():
3 global a
4 print (a)
5 if __name__ == "__main__":
6 d()
7 print (a)
代码说明:
1 定义全局变量a并赋值1。
3 函数d中引用全局变量a。
4 打印函数内引用的变量a。
7 打印全局变量a。
此时代码是不会报错的,因为变量a是全局变量,可以理解为公用的变量。
那么也许你要问全局变量是否可以被函数所改变,答案是可以的。
实例代码:
1 a = 1
2 def d():
3 global a
4 a = 2
5 if __name__ == "__main__":
6 d()
7 print (a)
代码说明:
4 将引用的全局变量重新赋值为2。7 打印全局变量a。
可见全局变量也是可以被引用后改变的,并以最后一次改变的值作为最终的值。
2. 运算
1)算术运算
常用的算术运算就是加减乘除,看个实例就完全明白了。
实例代码:
1 a = 2
2 b = 1
3 print (a-b)
4 print (a+b)
5 print (a*b)
6 print (a/b)
代码说明:
3-6 分别是加减乘除的运算,并打印运算结果。
当出现复杂的运算时遵循两个原则:
- 括号内的运算优先运算;
- 先乘除后加减,从左往右依次运算;
实例代码:
1 a = 1
2 b = 2
3 c = 3
4 print (a+b*c)
5 print ((a+b)/c)
代码说明:
4 遵循第二条原则,先运算b*c,再运算加法。
5 遵循第一条原则,先运算括号内的a+b,再运算除。
通过这2个例子应该可以对算术运算有所了解了。
2)关系运算
关系运算就是对2个对象进行比较,通过比较符判断进行比较,有6种比较方式。
- x>y 大于
- x>=y 大于等于
- x<y 小于
- x<=y 小于等于
- x==y 等于
- x!=y 不等于
这6种比较的结果只有2个,True或者False,比较的结果与事实一致就是True,反之就是False。
实例代码:
1 a = 1
2 b = 2
3 c = 3
4 print (a<b)
5 print (b==c)
代码说明:
4 打印判断a是否小于b的结果。
5 打印判断b和c是否相等的结果。
另外关系运算的优先级低于算术运算,当两者同时存在时优先进行算术运算,再进行关系运算,把代码改一下。
实例代码:
1 a = 1
2 b = 2
3 c = 3
4 print (a+b==c)
5 print (c>b-a)
代码说明:
4 打印判断a+b是否等于c的结果。
5 打印判断c是否大于b-a的结果。
3)逻辑运算
逻辑运算都分3种,逻辑与、逻辑或、逻辑非,但这里又和其他语言有着不同。
- x and y 逻辑与
- x or y 逻辑或
- not x 逻辑非
在理解这3种逻辑运算之前先要明白True和False的判断,值不为0或者不为空,程序均判断为True。
对于逻辑与来说,只有当x为True的时候,才会去计算y值。对于逻辑或来说,只有当x为False的时候,才会去计算y值。
对于逻辑非来说,当x为True的时候,返回False,反之就是返回True。
实例代码:
1 a = 3
2 b = 4
3 print (a and b)
4 print (a or b)
5 print (not a)
代码说明:
3 打印a and b的运算结果,由于a等于3是True,所以才会处理到b,那么结果就是b的值。
4 打印a or b的运算结果,由于a等于3是True,所以不会处理到b,那么结果就是a的值。
5 打印not a的运算结果,由于a等于3是True,所以运行会返回False。
相信通过这2个例子已经能够理解逻辑运算的意思了,另外逻辑运算的优先级低于关系运算,当两者同时存在的时候先进行关系运算,再进行逻辑运算。
实例代码:
1 a = 0
2 b = 4
3 print (a>b and a==b)
4 print (a<b and a==b)
5 print (not a!=b)
代码说明:
3 先计算a大于b是False,所以不进行后面的判断。
4 先计算a小于b是True,所以继续判断a等于b是False。
5 先计算a不等于b是True,所以返回反的结果。
运算主要就是以上3种,其他的复杂运算也是在这个基础上进行拓展的,测试脚本不需要写复杂的运算,主要是运用于结构化设计的循环和判断。
3、数据结构
数据结构是将数据按照某种逻辑存储起来,Python内置了几种数据结构、元组、列表、字典。这是Python语言很重要的一部分。
1. 元祖
元组可以由不同的元素组成,每个元素也可以是不同的数据类型,字符串、数字或者元组,所有元素通过圆括号包含起来,并通过逗号隔开,有点类似数学中的集合,创建元组的语法格式如下。
变量名 = (元素1,元素2,…)
初始化示例:
a = (1,2,"d",(1,"dd"))
这样就完成了元组的创建,元组有个特点是一旦被创建就不能被修改,所以可以将元组理解为只读。那么元组的元素要如何读取呢?这里要引入一个索引的概念,即把元素按照编号排序,每一个元素会有一个编号。其不仅可以正序读取,还可以倒序读取,这二者的区别在于正序从 0开始依次往后加,而倒序从-1依次往前减,元组的索引如图所示。
实例代码:
1 a = (1,2,"d",(1,"dd"))
2 print (a[0])
3 print (a[-1])
4 print (a[3])
代码说明:
1 定义一个元组a,a包括四个元素,第一个和第二个是数字,第三个是字符串,第四个是元组。
2 打印元组的第一个元素。
3 打印元组的倒数第一个元素,就是最后一个元组。
4 打印元组的第四个元素,就是最后一个元组。
可见一个元素可以有多个索引,就像多个标签对同一个东西。那么如果要读取第四个元素(元组)中的数据怎么办呢?由于元组可以用索引,所以可以通过a[3]读取第四个元素,再通过第四个元素(元组)的索引读取其中的数据,把代码改一下。
实例代码:
1 a = (1,2,"d",(1,"dd"))
2 print (a[-1][-1])
3 print (a[3][0])
代码说明:
2 打印元组的倒数第一个元素中的倒数第一个元素,就是最后一个元素元组中的第二个元素。
3 打印元组的第四个元素中的第一个元素,就是最后一个元素元组中的第一个元素。
上面介绍了如何读取单个元素,那么如果要读取多个元素呢?只需要用“元组[m:n]”的方式就可以读取了,m和n就是索引的序号,代表读取元组从m到n的元素,但不包括n这个元素本身。
实例代码:
1 a = (1,2,"d",(1,"dd"))
2 print (a[0:2])
3 print (a[1:-1])
4 print (a[2:4])
代码说明:
2 打印元组的第一个元素到第二个元素,不包括第三个元素。
3 打印元组的第二个元素到倒数第二个元素,不包括倒数第一个元素。
4 打印元组的第三个元素到第四个元素,不包括倒数第五个元素(虽然不存在第五个元素,但为了读取第四个元素,必须这样写)。
2. 列表
其实列表和元组有很多相似之处,都是一组元素的集合,主要的差别在于列表是可以增删改的,而元组是不可以的,其他就没什么太大的差别了,创建列表的语法格式如下。
变量名 =[元素1,元素2,….]
语法上的差别就是把()改成了[],列表读取元素和元组都是一样使用索引的,不再多做介绍。由于列表的特性还可以增删改,那就重点需要介绍一下相关语法。
- list.append(元素)调用列表的添加方法加入元素,并将元素添加到列表最后。
- list.insert(索引位置,元素)调用列表的插入方法加入元素到指定的索引位置,之后的元素索引位置依次向后顺移。
- list.remove(元素)调用列表的移除方法删除元素,之后的元素索引位置依次向前顺移。
- list[n]=元素(新)读取列表中的某一个元素并重新赋值就完成了修改,索引位置不变,只是元素值被替代了。
实例代码:
1 a = [1,2,"d",(1,"dd")]
2 a.append("x")
3 print (a)
4 a.insert(2,"c")
5 print (a)
6 a.remove("d")
7 print (a)
8 a[-2] = 3
9 print (a)
代码说明:
2 增加一个字符串"x"到列表a最后。
3 打印新增元素后的列表a。
4 增加一个字符串"c"到列表的第三个位置。
5 打印新增元素后的列表a。
6 从列表中删除字符串"d"。
7 打印删除元素后的列表a。
8 修改列表a的倒数第二个元素为3。
9 打印修改元素后的列表a。
除了增删改,列表与列表之间也是可以合并的,有2种方法可以连接。
- list1.extend(list2)调用列表 1 的扩展方法加入列表2,并将列表2的元素放到列表1元素的后面。
- list1 = list1+list2 直接通过列表相加的方法并重新赋值到列表1之中。
实例代码:
1 a = [1,2,"d",(1,"dd")]
2 b = [4,["a",3],"ddd"]
3 a.extend(b)
4 print (a)
5 b = b + a
6 print (b)
代码说明:
3 通过列表a的extend方法加列表b的元素加到列表a后面。
4 打印合并后的列表a。
5 将列表b加上列表a的值赋予列表b,列表a的元素加到列表b后面。
6 打印合并后的列表b。
其实理解了元组也就等于理解了列表,除了增删改有不同之外,其他都是非常相似的。
3. 字典
字典是Python中最重要的数据类型,相对于元组和列表更为复杂,同时也是使用最为广泛的数据结构。字典由一系列“键-值”成对组成,每一组可以理解为元组和列表的一个元素,并通过{}包含起来,创建字典语法格式如下。
dictionary = {键1: 值1,键2: 值2,……}
字典的读取也是有索引这个概念的,只是与元组和列表不同的地方在于:字典不是通过数字,而是通过键来作为索引的,所以字典其实是没有位置先后概念的。
实例代码:
1 d = {"a":1,"b":2,"c":"ok"}
2 print (d["a"])
3 print (d["c"])
代码说明:
1 定义一个字典d,有3组“键-值”。
2 打印字典d中键为a对应的值。
3 打印字典d中键为c对应的值。
字典增加和修改的语法一样,都是通过给某个键进行对应的赋值,当键对应的值存在时将原值替换为新的赋值,当键不存在时创建一个新的“键-值”。
dictionary[键] = 值
字典的删除直接用内置的del()函数,删除字典中的键也等于删除了对应的值。
Del(dictionary[键])
实例代码:
1 d = {"a":1,"b":2,"c":"ok"}
2 d["a"] = 3
3 print (d)
4 d["d"] = "hi"
5 print (d)
6 del(d["c"])
7 print (d)
代码说明:
2 将字典d中的键为a对应的值改成3。
3 打印修改元素后的字典d。
4 由于字典d中没有键为d,所以新增一个键为d,值为"hi"的元素。
5 打印新增元素后的字典d。
6 删除字典d中键为c的值。
7 打印删除元素后的字典d。
之前说到列表的合并,字典也是可以合并的,方式是使用字典的update方法。
dict1.update(dict2)
但与列表的合并区别在于,列表可以合并重复的数据并且不会被替代,而字典中如果有重复的键,那就会被新的键对应的值所取代。
实例代码:
1 d1 = {"a":1,"b":2,"c":"ok"}
2 d2 = {"a":3,"y":"hi"}
3 d1.update(d2)
4 print (d1)
代码说明:
3 将字典d2合并到字典d1之中,由于d2中键a和d1中键a相同,所以d2的键a对应的值会替代d1的键a的值。
4 打印合并后的字典d1。
4. 函数
函数就是一段代码的集合,并且可以被重复调用,也可以理解为处理事务的方法。
1)函数的定义
Python内置了很多函数,可以直接调用,当然多数情况下还需要自定义函数,自定义函数的语法如下。
def 函数名():
…
…
函数名可以由数字、字母或者下画线组合而成,但不能以数字开头,冒号以下的代码是函数的主体,换行的缩进表示代码属于该函数。
实例代码:
1 def add():
2 a = 1
3 b = a+3
4 print (b)
5 add()
代码说明:
1 自定义一个名叫add的函数。
2 在add函数中定义变量a,并将a赋值为1。
3 在add函数中定义变量b,并将b赋值为a+3的运算结果。
4 打印b的值。
5 调用执行add函数,等于执行了2到4这三行代码。
通过这个例子应该能够理解函数的定义和调用,如果没有第五行的调用,那么add()函数就不会执行了。
2)函数的参数
自定义函数的时候还可以加上参数,参数可以是一个,也可以是多个,参数放在()里面。加上的参数只是一个形式参数,而非真的实际参数,怎么理解形式参数呢?简单来讲可以将其理解成通过一本书占座,实际人过来的时候对应这本书的名字来坐上这个座位。
实例代码:
1 def add(a,b):
2 c = a+b
3 print (c)
4 add(1,2)
代码说明:
1 定义一个add函数,并创建形式参数a和b。
2 将a+b的结果赋值给变量c。
3 打印c的值。
4 调用执行add函数,并给形式参数a和b分别赋值1和2。
对于add函数来说,函数内的a和b只是一个空壳,a和b可以代表任何数据,而c就是a和b任意数据的和,所以c是随着a和b的值变化而变化的,等式只是建立c与a和b之间的关系而已。
当然除了用书本占座之外,人也是可以占座的,这个座位既可以自己坐,也可能让给其他人来坐。因此在加形式参数的时候也可以给形式参数赋值,产生一个默认值。当调用函数不加实际参数时,就以定义函数时参数的值作为实际值;当调用函数加上实际参数时,则新的实际参数值代替原来的参数值。
实例代码:
1 def add(a = 1,b = 2):
2 c = a+b
3 print (c)
4 add()
5 add(2,3)
6 add()
代码说明:
1 定义一个add函数,并创建形式参数a和b,分别赋值1和2。
4 调用执行add函数,由于创建参数时已经赋予了默认值,所以可以不用再填入实际参数,会以默认值作为实际参数运行。
5 调用执行add函数,并给形式参数a和b分别赋值2和3,此时a和b的值将被改变。
6 调用执行add函数,虽然第5行调用时改变了a和b的值,但对于函数本身默认值是不会变的,调用时改变的值只对调用时生效,并不会影响函数本身的默认值。
通过以上2个例子应该对函数的参数有了一定的认识,之后的测试脚本中会大量用到参数传递,以达到自动化的目的。
3)函数的返回值
之前定义函数只是用来执行某些操作,并没有将最后执行的结果保存。上面的例子中只是将c的值打印了出来,并未将c的值保存和返回,导致c的值只能在函数内被使用,改一下代码。
实例代码:
1 def add(a = 1 ,b = 2):
2 c = a+b
3 print (add())
由于print在add函数之外,所以它无法获取函数内c的值,而Python又区别于其他语言的地方在于并不会报错,当没有返回值时会返回一个None。那么要如何返回保存c的值呢?只需要在函数中加return+返回值,把上面的代码改动一下。
实例代码:
1 def add(a = 1 ,b = 2):
2 c = a+b
3 return c
4 print (add())
代码说明:
3 在执行完成add函数时返回c的值。
4 打印执行add函数的结果,由于add函数返回了c的值,所以打印出来的就是c的值。
当然有时候函数需要返回的值不止一个,同样可以用return来返回,区别于单个返回值,多个返回值需要用逗号隔开,数据以元组形式返回,同样也可以把返回值按照顺序赋值给多个变量,再改一下代码。
实例代码:
1 def add(a = 1 ,b = 2):
2 c = a+b
3 return a,b,c
4 print (add())
5 x,y,z = add()
6 print (x,y,z)
代码说明:
3 在add函数最后返回a,b,c的值。
4 打印执行add函数的结果,由于返回了3个值,所以返回的是3个元素的元组。
5 再次执行add函数,将结果返回的3个值分别赋值给x,y,z。
4) 函数的嵌套
除了自定义和调用执行函数之外,还可以在函数中相互嵌套调用,即在某个函数的代码中调用其他函数,这就是函数的嵌套。
函数的嵌套多种多样,对于同样完成一个实例,既可以在函数 a 中嵌套函数b,也可以在函数b中嵌套函数a,也可以在函数c中嵌套函数a和函数b。比如现在要完成一个a+b-c的运算,虽然可以用一行代码完成,但为了演示嵌套,将其拆分成 3 个函数,a+b 通过一个函数完成,c的赋值通过一个函数完成,最后减去c通过一个函数完成。
实例代码:
1 def a1(a ,b):
2 return a+b
3 def a2(c):
4 return c
5 def calculate (x,y,z):
6 result = a1(x,y)-a2(z)
7 return result
8 print (calculate (1,2,3))
代码说明:
- 自定义a1()函数,并创建形式参数a和b。
- 函数a1返回a+b的结果。
- 自定义a2()函数,并创建形式参数c。
- 函数a2返回c的结果。
- 自定义calculate ()函数,并创建形式参数x,y,z。
- 将函数a1的运行结果减去函数a2的运行结果,并把结果赋值给变量result。这里a1(x,y)等于形式参数的传递,即把x,y的实际值当作实际参数放进函数a1中运行,a2(z)也是一样的原理,所以x,y,z本身作为形式参数对应形式参数a,b,c,当给x,y,z赋值后即赋值到了a,b,c。
- 函数calculate返回运算结果result的值。
- 打印函数calculate(1,2,3)的运行结果,由于对参数进行了实际赋值,所以带入函数之中,等于运行了1+2-3的实际运算。
上面这个例子函数都是在其他函数之外自定义的,所以任意函数都能调用任意函数,没有上下等级关系,但这不利于代码的可读性。为了让代码更清晰,利于对代码进行修改,于是设立一个主函数,并在这个函数下定义它需要调用的子函数,这样主次关系就会更加明确,而且子函数不会被其他函数调用,避免了代码调用混乱的结果。
实例代码:
1 def calculate (x,y,z):
2 def a1(a ,b):
3 return a+b
4 def a2(c):
5 return c
6 result = a1(x,y)-a2(z)
7 return result
8 print (calculate (1,2,3))
代码说明:
1 自定义calculate ()函数,并创建形式参数x,y,z。
2 在函数calculate内自定义a1()函数,并创建形式参数a和b。
4 在函数calculate内自定义a2()函数,并创建形式参数c。
其实结果和上面是一致的,区别只在于定义函数的位置不同而已。
上面的例子如果细心的话会发现其实写得很烦琐,2步计算操作定义了3个函数,其实定义2个函数就能完成。再举一个例子,计算(a+b)的平方,与之前的嵌套不同,这次拆成2个函数,a+b通过一个函数完成,(a+b)的平方通过一个函数完成,那要完成计算就要 2 个函数进行嵌套,因为要先计算a+b,然后将a+b的结果通过参数传递给计算平方的函数。
实例代码:
1 def a1(a ,b):
2 c = a+b
3 def a2(x):
4 x = x*x
5 return x
6 return a2(c)
7 print (a1(1,2))
代码说明:
1 自定义a1()函数,并创建形式参数a和b。
2 将a+b的结果赋值给变量c。
3 在函数a1()内自定义a2()函数,并创建形式参数x。
4 将x*x的结果赋值给变量x。
5 函数a2返回x的结果。
6 返回函数a2(c)的运行结果,这里要说明一下c是a+b的计算结果,当给a和b赋值之后c的值也就确定了,此时把c的实际值传给函数a2,等于把c的值带入平方的运算。
7 打印函数a1(1,2)的运行结果,由于对参数进行了实际赋值,c 的值是 3,然后将 c 的值传递给函数a2,再执行函数a2中的平方运算并返回运算结果。
5. 字符串处理
字符串是Python中最常见和重要的数据类型,而Python也提供了强大的字符串处理功能,接下来就重点介绍字符串的处理。
1)字符串的转换
对于数据来说,由于应用场景不同,使用的数据类型也不同,但每种类型的数据都有其特性和局限性,不方便数据的处理,所以常常需要将类型转化成字符串,Python内置了str()函数可以将任何类型的数据转换成字符串,语法如下。
s = str(任意类型数据)
将需要转换类型的数据当作str的参数放入执行,运行的结果就是转换成的字符串。
实例代码:
1 a = 1
2 print (type(a))
3 b = str(a)
4 print (type(b))
代码说明:
1 给变量a赋值为1的整数。
3 将变量a的数据类型(整型)转换成字符串,并赋值给b。
4 打印b的数据类型。
其他类型的转换方式也是一样的,包括元组、列表、字典等,都可以通过str()转换成字符串,唯一需要注意的是只转换数据类型,并不会改变数据打印出来的结果。
实例代码:
1 a = {"dd":1}
2 print (type(a))
3 b = str(a)
4 print (type(b))
5 print (b)
代码说明:
3 将变量a的数据类型(字典)转换成字符串,并赋值给b。
4 打印b的数据类型。
5 打印b的实际值。
可见类型转换后的数据打印结果和变量的赋值是一致的。
2)字符串的合并
字符串的合并非常简单,只需要通过“+”连接即完成了合并,也许有人要问“+”不是运算符吗?其实Python会根据“+”两侧的数据类型决定是连接操作还是运算,而不同类型的数据是无法合并的,上面讲了如何转换成字符串,为什么要转换成字符串呢?为了便于数据的合并。举个例子,需要将a和b数据合并,而a是整型,b是字符串,无法完成合并。
实例代码:
1 a = 1
2 b = "xxx"
3 c = a+b
4 print (c)
通过错误信息就能看出由于类型不同无法操作,这时候就需要把a转换成字符串,然后就能完成数据的合并。
实例代码:
1 a = 1
2 a = str(a)
3 b = "xxx"
4 c = a+b
5 print (c)
代码说明:
2 将变量a的数据类型(整型)转换成字符串,并重新赋值给a。
5 合并字符串a和b。
6 打印c的值。
3)字符串的截取
字符串除了合并之外,最常用的就是截取,很多时候获取全部数据之后仅仅只有一小部分的数据是有用和需要的。而截取的方式有很多种,既可以用索引的方式,也可以用 split()函数,又或者使用正则表达式。相对来说正则表达式是最优的选择,但需要一定的编码基础和强大的分析能力,这里先不介绍了。
字符串的索引和元组、列表、字典相似,字符串也是有索引的,最大的不同在于字符串是给每一个字符一个位置,而不是以元素为单位的。
实例代码:
1 a = "xyz123"
2 print (a[1])
3 print (a[2:4])
4 print (a[-3:-1])
代码说明:
2 打印字符串的第二个字符,运行结果为y。
3 打印字符串的第三个字符到第四个字符,不包括倒数第五个字符。
4 打印字符串的倒数第三个字符到倒数第二个字符,不包括倒数第一个字符。
其实上面这种方法效率并不高,当字符串很长时不可能一个个去数,还有一种办法就是使用字符串自带的 split()函数将数据分割成一段一段,并以元素的形式放入列表之中,然后再通过索引截取相应的字符串,split中的参数就是分隔符,而且分隔符是会被去掉的,先来看语法格式。
split(字符/字符串,分割次数)
分割次数一般可以不用填写,默认情况下会根据字符串出现的次数进行分割,如果填写了分割次数,就只分割指定的次数。
实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.split(","))
3 print (type(a.split(",")))
4 print (a.split(",")[2])
代码说明:
1 给变量a赋值一个字符串,字符串中有多个数据,用逗号分隔。
2 将字符串以逗号为界限分割字符串,由于逗号出现了3次,而没有指定分割次数,所以默认将数据分割成了4段,打印分割后的结果。
3 打印分割后的数据类型。
4 打印分割后列表的第三个字符串。
改一下代码,实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.split(",",2))
代码说明:
2 将字符串以逗号为界限分割字符串,指定的分割次数为2次,所以只会将数据分割成三段,只默认分割前两个匹配的逗号分隔符,打印分割后的结果,运行结果为["a=abc", "b=123","cddd,(1,2) "]。
split()还可以多次使用,比如只想获取a对应的abc,就可以通过多次分割来完成。
实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.split(",")[0].split("=")[1])
代码说明:
2 将字符串以逗号为界限分割字符串,形成一个列表,然后取列表中的第一组字符串a=abc,接着再通过等号为界限继续分割字符串,形成一个新的列表["a", "abc"],最后再取列表中的第二组字符串。
split()还可以多次使用,比如只想获取a对应的abc,就可以通过多次分割来完成。
实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.split(",")[0].split("=")[1])
代码说明:
2 将字符串以逗号为界限分割字符串,形成一个列表,然后取列表中的第一组字符串a=abc,接着再通过等号为界限继续分割字符串,形成一个新的列表["a", "abc"],最后再取列表中的第二组字符串。
这种处理方式用于有固定格式的多个字符串组成的长字符串,包括正则也是一样,都是通过某种特定规则来匹配需要截取的数据,只是相对来说正则更为强大,但其规则也过于复杂,如果只是处理有固定格式的字符串,建议使用 split()函数对数据执行截取。
4)字符串的替换
除了合并和截取之外,还能对指定的字符串进行替代,这时候使用字符串自带的 replace()函数,其语法格式如下。
replace(原字符串,替换的字符串,替换次数)
替换次数一般也不用填写,默认是全部,如果指定替换次数就按照顺序替换,达到替换次数之后就不再做替换了。
实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.replace(",","and"))
代码说明:
2 将逗号替换成and,默认全部替换,打印替换后的结果。
改一下代码,实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.replace(",","and",2))
代码说明:
2 将逗号替换成and,只替换2次,打印替换后的结果。
值得注意的是replace()函数只是替换其副本,并不会改变原来a的值,如果需要改变的话可以将替换后的副本重新赋值给新的变量。
实例代码:
1 a = "a=abc,b=123,cddd,(1,2)"
2 print (a.replace(",","and",2))
3 print (a)
4 b = a.replace(",","and",2)
5 print (b)
代码说明:
2 在替换过字符串的情况下打印a的值。
3 打印原来a的值。
4 将替换后字符串赋值给变量b。
5 打印替换字符串后b的值。
还有可能出现匹配不到的情况,Python是不会报错的,只是处理失败,不会改变原字符串的值,即返回原来的字符串。
五、网络请求
测试人员对于接口测试的理解总是停留在工具使用层面。很多情况下,测试人员会花很大的代价去学习一个工具。而测试工具本身的局限性,又导致测试人员陷入想直接用现成的测试框架却又无法进行扩展的僵局。最后由于项目的特殊性等客观因素,测试人员只能放弃工具,脱离了工具的可视化界面友好操作,发现自己连接口是什么都不明白,更不要说自行完成接口自动化测试了。随即接口自动化测试由于项目成本及人员能力问题宣告失败。所有客观原因导致的结果都有其主观原因存在,急于求成、依赖工具就是测试人员在这个问题上的错误。
1、网络传输基础
测试人员开始学习接口测试时,总会先关注HTTP协议。这点没有错,理解HTTP协议的确是绝大多数接口测试的基础。但大家在学习HTTP协议的过程中会发现一个问题,RFC 2616 官方给出的 HTTP协议的定义为以下内容。
超文本传输协议(HTTP)是一种分布式、合作式超媒体信息系统。它是一种通用的、无状态(stateless)的协议,除了应用于超文本传输外,它也可以应用于诸如名称服务器和分布对象管理系统之类的系统,这可以通过扩展它的请求方法、错误代码和报头来实现。HTTP 的一个特点是数据表现形式是可输入的和可协商性的,这就允许系统能被建立而独立于数据传输。
看了这么大一段文字后,测试人员肯定对 HTTP 协议是什么还是一知半解。什么是无状态?什么是请求方式和报头?这些基础知识的缺失造成测试人员无法通过教材式的理论清晰理解网络协议,甚至在测试任务中也无法灵活运用,除了影响测试的本职工作,也造成与开发等技术人员的沟通不便。所以在具体讲解 HTTP 协议前,测试人员需要先掌握网络传输的基础知识。
1. 协议
在接口测试中,从客户端发送request至服务器反馈response,网络传输的数据就是接口测试中最主要的部分,而数据传输的本质就是基于网络传输协议。网络传输很显而易见,是指数据在网络上的传输过程。我会以工作场景故事的形式帮助大家理解网络传输相关的知识。
每个协议可能需要获取不同的信息,即格式及传输的数据内容不同。所以协议可以分为很多种类,网络传输协议可以分为HTTP协议、HTTPS协议以及WebSocket协议等。
2. Cache
一个优秀的缓存策略可以为网络传输带来以下两方面好处。
- 减少延迟:因为所发出的网页请求是指向更接近客户端的缓存,而不再是源服务器端,因此请求所花费时间更短,这让网站看上去反应更快,提高了用户体验。
- 降低网络负荷:因为缓存文件可以重复使用,节省不少的带宽,降低了网络负荷。同时站在用户的角度,这也省了不少流量,妈妈再也不用担心流量不够用了。
从以上例子可以得出缓存的概念:使用缓存Cache的站点会监听客户端向服务器端发出的请求,并根据相应的缓存设置保存服务器端反馈的数据,比如HTML页面、图片等文件。如果用户再次使用相同 URL 发送请求,请求不会直接发向服务器,而是通过缓存策略先行判断是否能够使用之前已经保存下来的反馈文件,从而降低服务器的负载及提高数据的响应时间。
在结合具体的网络协议来解释缓存的用法前,希望大家先明白关于缓存的基本概念。首先缓存根据其类型可以分为以下几种。
1)浏览器缓存
每一次项目忙碌时,加班总是特别多。在加班人数或者订餐类型不变的情况下,订餐者就可以不修改上一次订餐申请单中菜系套餐等内容直接进行提交。例如,完全从本地缓缓存中提取数据。
浏览器(比如 IE、Chrome、Firefox 等)都有缓存设置选项,它可以将你浏览过的网页全部保存到你本地计算机的硬盘中。最常用的场景就是当你点击浏览器的“后退”或者曾经浏览过的页面的链接时就能直接使用这种缓存。同样的,当你不希望使用浏览器缓存时,可以通过删除浏览历史记录的操作对其进行删除。
删除后,下一次访问同样的网站,浏览器会将请求直接发往服务器获取返回数据。
2)代理缓存
在酒店A和公司之间建立一个外卖管理平台,平台上存在酒店A与公司协商决定的所有菜式菜品及套餐内容。每一次公司向酒店A提交订餐申请时,先由外卖平台确定这些内容是否已经有过配餐记录。如果有,那么外卖平台不通过酒店A平台自主进行配菜及配送服务。这就缓解了酒店A的配餐压力,同时缩短了用户等待用餐的时间,而这个外卖平台就体现了代理缓存的作用,如图所示。
浏览器缓存由于客户端内存的限制不能存放过多的数据,否则会降低本机的性能。在实际应用中,开发者需要存储大规模的数据及面向更广泛的用户群时,可以使用代理缓存,它使用相同的原理,但可以用相同的方法为几百甚至几千的使用者服务。
因为代理缓存既不属于客户端,也不属于服务器端,而是利用网络路由请求信息。有以下两种最常用的场景。
- 用户手动设置浏览器的代理。
- 使用网页代理,网页代理将你的URL请求通过它潜在的网络定向到代理,所以用户甚至无需手动配置它们。
代理缓存是共享缓存的一种,不是只有一个人正在使用它们,而是同时存在大量的用户使用。
3)网关缓存
就像前面提到的“外卖平台”,网关缓存同样是充当一种代理的角色。从作用上来说,它同样起到了代理缓存的用处。从协议的角度来说,网关缓存在受用领域上与代理缓存有区别。举个例子,代理缓存就像是“外卖平台”中的配餐服务,而网关缓存就是“外卖平台”中的配送服务。配餐服务可以使用订餐协议,而配送服务就需要使用与配餐不同的配送协议了。网关可以为通信线路上的服务器提供不同的协议服务。
网关缓存也是中间人,但不是由系统网络管理员出于节省带宽而部署,它们通常是由网站站长自己部署的,这样可以让自己的网站更具可扩展性,可靠性和性能更好。可以通过许多方法将请求路由到网关高速缓存,它就类似于负载平衡器。交付网络(CDN)通过互联网分发网关缓存,把缓存卖给网站就是网关缓存的具体行为。
在缓存的概念中,还有一项比较重要的内容,就是按缓存策略来分,缓存分为以下两种情况。
- 强缓存
缓存策略:直接从本地缓存中取资源,不会和服务器通信。
- 协商缓存
缓存策略:通过服务器来告知是否能用本地缓存。先和服务器通信,如果返回可以使用本地缓存的指示,再从本地缓存中取;如果不可以使用本地缓存,就会返回最新的资源。
服务器处理这两种缓存的流程如图:
浏览器发起第二次相同的请求时,会先判断能不能使用强缓存,如果不能,再判断能不能使用协商缓存(如果没有设置强缓存,协商缓存也不会生效)。在这个流程中,很重要的一个环节就是如何进行判断。服务器与客户端之间以什么样的标准去判定使用哪种缓存策略,这就是网络协议定义的报文规范所建立的标准。
3. Cookie
Cookie的内容是保存的一小段文本信息,这些文本信息组成一份通行证。它是客户端对于无状态协议的一种解决方案。举个例子,用户使用浏览器访问一个支持Cookie的网站,会经过以下传输过程,如图所示。
Cookie的使用原理如下。
- 用户会提供包括用户名在内的订餐信息并且将其提交至服务器。
- 服务器向客户端回传相应数据的同时,也会发回这些订餐信息(Cookie S-001)。
- 当客户端接收到来自服务器的响应之后,浏览器会将Cookie S-001存放在一个统一的位置。
- 客户端再向服务器发送请求的时候,会把Cookie S-001再次发回至服务器。
有了 Cookie 这样的技术实现,服务器在接收到来自客户端的请求之后,就能够通过分析Cookie的内容得到客户端特有的信息,从而动态生成与该客户端相对应的内容。通常,我们可以从很多网站的登录界面中看到“保存用户名”这样的选项,如果你勾选了它之后再登录,那么在下一次访问该网站的时候,页面会自动填补用户名,而这个功能就是通过Cookie实现的。
既然协议的无状态性可以通过使用Cookie技术来解决,那么浏览器会在本地保存Cookie信息。对于测试人员来说,如果要模拟有状态的请求行为,就可以通过直接向服务器提交已保存的Cookie信息以便绕过身份认证,优化测试步骤。要利用Cookie作为测试的手段,在理解了Cookie的实现原理后需要掌握以下内容。
1)获取Cookie的途径
(1)使用浏览器的开发者工具或专业抓包工具获取
测试人员对抓包工具应该是相当熟悉的了。不论你使用浏览器(F12),还是专业抓包工具,能达到目的即可。以fiddler抓包工具为例,如图所示。
可以点击“Raw”页直接保存Cookie至记事本。
也可以自己点开fiddler上cookie区域:
(2)从本地文件中获取
既然Cookie信息被浏览器保存至本地,那么测试人员就可以在本地文件中寻找Cookie信息了。
举几个常用浏览器的存放地址。
- IE浏览器Cookie 数据位于:%APPDATA%\Microsoft\Windows\Cookies\ 目录中的xxx.txt文件(里面可能有很多个.txt Cookie文件)。
- Firefox 的 Cookie 数据位于:%APPDATA%\Mozilla\Firefox\Profiles\ 目录中的 xxx.default目录名为cookies.sqlite的文件。
- Chrome的Cookie数据位于:%APPDATA%\Google\Chrome\User Data\Default\目录中,名为Cookies的文件。
在IE浏览器中,IE将各个站点的Cookie分别保存为一个XXX.txt这样的纯文本文件(文件个数可能很多,但文件容量都较小)。而Firefox和Chrome是将所有的Cookie都保存在一个文件中(文件容量较大),该文件的格式为SQLite3数据库格式的文件。为了查看及运用方便,一般浏览器都会保存多域名的Cookie信息。可以通过使用Chrome和Firefox 浏览器的设置,直观地拷贝Cookie信息。
以Chrome浏览器为例:
- 进入Chrome设置。
- 找到【隐私设置】。
- 进入【内容设置】。
- 在Cookie选项中可以查看及搜索所有保存的Cookie
- 根据不同的域名查看该域名下具体的 Cookie 内容,例如,要查看微信公众号平台的Cookie信息
- 查看每个Cookie项里的具体内容
对于Firefox浏览器可以选择如下操作:
- 进入Firefox设置。
- 找到【工具设置】。
- 进入【选项设置】。
- 进入【隐私设置】。
- 显示Cookie。
- 在Firefox浏览器的设置中查看Cookie的具体信息。
(3)通过前端技术获取
测试人员可以通过前端技术查看某个网站颁发的Cookie。
例如,在浏览器地址栏输入:
Javascript:alert (document.cookie)
就可以查看Cookie信息。JavaScript脚本会弹出一个对话框,显示本网站颁发的所有Cookie的内容。
弹出的对话框中显示的为Baidu网站的Cookie。可以看到为了信息的安全性,Baidu使用特殊的方法将Cookie信息加密了。
测试人员可以通过多种方法获取 Cookie 信息,所以 Cookie 存储的信息容易被窃取。假如Cookie中所传递的内容比较重要,那么就要求使用加密的数据传输。但加密只是传输信息的加密,对于本地信息除了用户自己加密是不会自行加密的,所以一般敏感的信息不会放入Cookie中,如密码等信息。
2)Cookie的生命周期
一般大家都会认为浏览器关闭时就会自动清除Cookie。其实这个说法不正确,Cookie的生命周期是可以设置的,所以当你在创建测试场景时,可以根据需求进行相应的设置。
Cookie的生存时间是整个会话期间:浏览器会将Cookie保存在内存中,浏览器关闭时就会自动清除这个Cookie。
Cookie 的生存时间是长久有效:Cookie 保存在客户端的硬盘中,浏览器关闭的话,该Cookie也不会被清除,下次打开浏览器访问对应网站时,这个Cookie就会自动再次发送到服务器端。
测试人员可以通过对浏览器的设置修改Cookie的生命周期。
除了通过浏览器修改,可以运用开发手段在服务器端的代码层面对Cookie的生命周期进行修改。
以下举几个Cookie的常用属性,便于测试人员根据测试场景进行修改。
- Name:该Cookie的名称。Cookie一旦创建,名称便不可更改。
- Value:该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用Base64编码。
Unicode编码:保存中文。
中文与英文字符不同,中文属于Unicode字符,在内存中占4个字符,而英文属于ASCII字符,内存中只占2个字节。Cookie中使用Unicode字符时需要对Unicode字符进行编码,否则会乱码。
Base64编码:保存二进制图片。
Cookie不仅可以使用ASCII字符与Unicode字符,还可以使用二进制数据。例如,在Cookie中使用数字证书,提供安全度。使用二进制数据时需要进行Base64编码。
小知识点:Base64 是一种基于64 个可打印字符来表示二进制数据的表示方法。由于 2 的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符。3个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示。Base64编码后是可以被翻译回原来的样子的,所以它并不是一种加密过程。
- MaxAge:该Cookie失效的时间,单位为秒。如果为正数,则该Cookie在MaxAge秒之后失效。如果为负数,该 Cookie 为临时 Cookie,关闭浏览器即失效,浏览器也不会以任何形式保存该Cookie。如果为0,表示删除该Cookie。默认为−1。
- Secure:该Cookie是否仅被使用安全协议传输。安全协议有HTTPS、SSL等,在网络上传输数据之前先将数据加密。默认为False。
- Path:该 Cookie 的使用路径。如果设置为“/sessionWeb/”,则只有 contextPath 为“/sessionWeb”的程序可以访问该Cookie。如果设置为“/”,则本域名下contextPath都可以访问该Cookie。注意最后一个字符必须为“/”。
- Domain:可以访问该Cookie的域名。如果设置为“.google.com”,则所有以“google.com”结尾的域名都可以访问该Cookie。注意第一个字符必须为“.”。
3)Cookie不可跨域名以及跨浏览器使用
一般Cookie是不可跨域名的,这是由Cookie的隐私安全机制决定的。隐私安全机制能够禁止网站非法获取其他网站的Cookie。
Cookie除了不能跨域名使用,也不能跨浏览器使用。大家可能会遇到一种情况,既然Cookie不能在不同的浏览器中使用,为什么使用IE登录了腾讯网站后,使用Firefox能保持登录状态呢?不同浏览器使用不同的Cookie管理机制,无法实现公用Cookie。如果使用IE登录腾讯网站,使用Firefox也能登录,这是由于在安装腾讯 QQ软件时,你的计算机上同时安装了针对这两个浏览器的插件,可以识别本地已登录QQ号码进而自动登录。本质上,这个现象并不是由Cookie造成的。
以上详解了关于Cookie的原理及基本概念。或许在日常测试执行中,测试人员只需要运用模拟工具获取Cookie发送报文至服务器即可完成接口测试工作。但还是希望大家能够真正理解技术的本质,做到不单单懂得怎么用,而且要掌握为什么要这么用。这样测试人员不仅可以完成测试工作,并且可以有充足的技术基础优化测试工作,提高测试效率。
4. Session
Session 是另一种记录用户状态的机制,不同的是 Cookie保存在客户端浏览器中,而 Session 保存在服务器上。它是服务器端对于无状态协议的一种解决方案。客户端访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是Session。客户端再次访问时只需要从该Session中查找该用户的状态即可。
Session的传输步骤如下。
- 服务器端程序运行的过程中创建Session,并且为该Session生成唯一的Session ID。这个Session ID在随后的请求中会被用来重新获得已经创建的Session。在Session被创建之后,就可以调用Session相关的方法向Session中增加内容,这些内容只会保存在服务器中。
- 服务器将Session ID发到客户端。
- 当客户端再次发送请求的时候,会将这个Session ID带上。
- 服务器接收到请求之后就会依据Session ID找到相应的Session,完成请求响
1)Session传输媒介
(1)通过Cookie传输
Session的信息是保存在服务器端的。测试人员只需要运用抓包工具从Cookie中获取Session ID的值用于模拟用户请求。虽然Session保存在服务器,对客户端是透明的,但它的正常运行仍然需要客户端浏览器的支持。这是因为Session需要使用Cookie作为识别标志。因此服务器向客户端浏览器发送一个名为 JSESSIONID 的 Cookie,它的值为该 Session 的 ID。测试人员获取JSESSIONID的值即可。
Session的有效期与会话有关。存储JSESSIONID的Cookie是服务器自动生成的,它的maxAge属性一般为−1,表示仅当前浏览器内有效,并且各浏览器窗口间不共享,关闭浏览器就会失效,因此同一机器的两个浏览器窗口访问服务器时,会生成两个不同的Session,但是由浏览器窗口内的链接、脚本等打开的新窗口(不是双击桌面浏览器图标等打开的窗口)使用同一个Session。这类子窗口会共享父窗口的Cookie,因此会共享一个Session。
注意:
新开的浏览器窗口会生成新的 Session,但子窗口除外。子窗口会共用父窗口的Session。例如,在链接上右击,在弹出的快捷菜单中选择“在新窗口中打开”时,子窗口便可以访问父窗口的Session。
那么,如果客户端浏览器将Cookie功能禁用,或者不支持Cookie怎么办?例如,绝大多数的手机浏览器都不支持Cookie。对于Session的传输方法存在另一种解决方案:URL地址重写。
(2)URL地址重写
URL地址重写是对客户端不支持Cookie的解决方案。它的原理是将该用户Session的ID信息重写到URL地址中。服务器能够解析重写后的URL,获取Session的ID。这样即使客户端不支持Cookie,也可以使用Session来记录用户状态。
服务器代码会先自动判断客户端是否支持Cookie。如果客户端支持Cookie,会将URL原封不动地输出。如果客户端不支持Cookie,则会将用户Session的ID重写到URL中。重写后的输出可能是这样的。
https://mp.weixin.qq.com/s?jsessionid=ByOK3vjF7C2HmdnV6QZcEbzWoWiBYE-145
用户单击这个链接的时候会把Session的ID通过URL提交到服务器上,服务器通过解析URL地址获得Session的ID。
如果测试人员要构造特殊测试场景,需要获取非Cookie 传输的Session,方法是要找开发人员帮忙获取Session ID或者自行从服务端代码中获取。
2)Session的生命周期
在谈论Session机制的时候,常常听到这样一种误解“只要关闭浏览器,Session就消失了”。想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对Session来说也是一样的,除非程序通知服务器删除一个Session,否则服务器会一直保留,程序一般都是在用户做log off的时候发个指令去删除Session。
然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分Session机制都使用会话Cookie来保存Session ID,而关闭浏览器后这个Session ID就消失了,再次连接服务器时也就无法找到原来的Session。如果服务器设置的Cookie 被保存到硬盘上,或者使用模拟发包工具改写浏览器发出的请求头,把原来的Session ID发送给服务器,则再次打开浏览器仍然能够找到原来的Session。
关闭浏览器不会导致Session被删除,迫使服务器为Seesion设置了一个失效时间,当距离客户端上一次使用Session的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把Session删除,以节省存储空间。例如,Tomcat中Session的默认超时时间为20分钟,可以通过setMaxInactiveInterval(int seconds)方法修改Session的默认超时时间。
以上详解了关于Session的原理及基本概念,Session与Cookie有着千丝万缕的联系。在对测试人员关于接口方面的笔试与面试中,经常被问到Session与Cookie的区别,答案总是千奇百怪,很少有人能够掌握充分,给出清晰的解答。
以下就详细说一下这两者的区别,给测试人员作为参考。
- 存储位置不同
通常情况:
Cookie的数据信息存放在客户端浏览器上。
Session的数据信息存放在服务器上。
- 存储容量不同
通常情况:
单个Cookie保存的数据≤4KB,一个站点最多保存20个Cookie。
对于Session并没有上限,但出于对服务器端的性能考虑,Session内不要存放过多的东西,并且设置Session删除机制。
- 3存取方式的不同
Cookie中只能保管ASCII字符串,需要通过编码的方式存取Unicode字符或者二进制数据。运用Cookie难以实现存储略微复杂的信息。
Session中能够存取任何类型的数据,包括且不限于String、Integer、List、Map等。
- 隐私策略的不同
Cookie 对客户端是可见的,别有用心的人可以分析存放在本地的 Cookie 并进行 Cookie欺骗,所以它是不安全的。
Session存储在服务器上,对客户端是透明的,不存在敏感信息泄露的风险。
假如选用Cookie,比较好的方法是:敏感的信息,如账号密码等,尽量不要写到Cookie中。可以将Cookie信息加密,提交到服务器后再进行解密。存储在本地的Cookie 就需要自行加密了。
- 有效期上的不同
开发可以通过设置Cookie的属性,达到使Cookie长期有效的效果。
由于Session依赖于名为JSESSIONID的Cookie,而Cookie JSESSIONID的过期时间默认为−1,只需关闭窗口该Session就会失效,因而Session不能达到长期有效的效果。就算不依赖于Cookie,运用URL地址重写也不能完成,因为假如设置Session的超时时间过长,服务器累计的Session就会越多,越容易导致内存溢出。
- 服务器压力的不同
Session是保管在服务器端的,每个用户都会产生一个Session。假如并发访问的用户十分多,会产生十分多的Session,耗费大量的内存。
Cookie保管在客户端,不占用服务器资源。对于并发用户十分多的网站,Cookie是很好的选择。
- 浏览器支持的不同
假如客户端浏览器不支持Cookie。
Cookie是需要客户端浏览器支持的。假如客户端禁用了Cookie,或者不支持
Cookie,则会话跟踪会失效。关于WAP上的应用,常规的Cookie就派不上用场了。
运用Session需要使用URL地址重写的方式。一切用到Session程序的URL都要进行URL地址重写,否则Session会话跟踪还会失效。关于WAP应用来说,Session+URL地址重写或许是它唯一的选择。
假如客户端支持Cookie。
Cookie 既能够设为本浏览器窗口以及子窗口内有效(把过期时间设为−1),也能够设为一切窗口内有效(把过期时间设为某个大于0的整数)。
Session 只能在本窗口以及其子窗口内有效。假如两个浏览器窗口互不相干,它们将运用两个不同的Session。(IE8下不同窗口Session相干。)
- 跨域支持上的不同
Cookie 支持跨域名访问,例如,将 domain 属性设置为“.biaodianfu.com”,则以“.biaodianfu.com”为后缀的一切域名均能够访问该Cookie。跨域名Cookie如今被普遍用在网络中,例如,Google、Baidu、Sina等。
Session则不会支持跨域名访问。Session仅在它所在的域名内有效。
关于Cache、Cookie与Session的知识是许多测试人员所忽视的内容,但这些运行机制往往就是请求能够被正确传输的最核心的内容,除了测试人员在执行测试任务时会运用到,其更是如何和开发人员有效沟通的技术基础。不要认为这些是开发基础就忽视它,必须做到知其然,并知其所以然。
在掌握以上基础知识后,测试人员还需要了解由它们引出的Token和JWT。
5. Token
假如一个酒店,由于酒店A的订餐流程制定了合理高效的处理方式,酒店A的订餐需求日益增长。
合理化方式为以下几项。
- 酒店和公司协商,制定了详细的订餐协议,保证和公司交互信息的正确性。
- 为解决每一次订餐的信息不流通,为公司制定了通行证,保证数据的状态可传递性。
- 同样为保证交互数据的有状态性,酒店建立了数据库服务器创建“公司订餐明细表”,用于保存及验证公司的身份。
随着酒店A业务的突飞猛涨,依靠手工及查询数据库的方式已经不再满足日益增长的订餐需求。酒店决定放弃人工辅助,建立订餐用户认证及管理系统,运用自动化的方式实现公司与酒店的订餐行为。
虽然运用系统实现功能可以节约人力成本、提高效率,但机器不如人灵活,不能随机应变,需要制定一系列规则,使它根据确定的模式进行运作。为此,订餐用户认证及管理系统的需求及设计人员进行了以下讨论。
- 每个公司需要保存自己的通行证,但对于酒店系统需要保存所有人的 Session ID。再加上Session的生命周期是在会话内,如果订餐申请过多,这无疑是一笔巨大的开销。
- 如果为了减轻负载而使用集群,如何保证机器与机器间的 Session 复制。假设集群由两台机器组成,公司A登录机器A,那么公司A的Session ID保存在机器A上。如果下一次公司A的登录申请被转发到机器B上了怎么处理?如果机器A挂了,又怎么处理?
- 如果运用Memcached把Session ID集中存储到一个地方,所有的机器都来访问这个地方的数据,这样一来,就不用复制Session ID了,但是增加了单点失败的可能性,要是那个负责Session的机器挂了,则还是需要所有人重新登录。如果把单点的机器也搞出集群增加可靠性,那又是一个巨大的负担。
- 对于过多的订单申请请求,反复查询数据库会造成性能的问题,直接影响用户体验及功能使用。
- 当用户的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的,如何解决?
- 为了更有利于CDN的运用,服务端是否可以只提供API即可?
- 当在做用户的身份认证时,是否可以制定标准化的模式运行?
以上问题都是由服务器端需要保存 Session 信息造成的,如果服务器端不用保存 Session信息就好了。
考虑至此,设计人员想出了一种以时间换取空间的方式。给所有用户发送一个令牌(Token),其是签名后的用户身份信息,每一次用户需要发送订餐请求把用户信息和这个令牌给酒店系统进行验证即可。酒店系统不保存令牌,只保留用户签名过程中的加密算法及密钥即可。
Token的原理如图所示:
- 当客户端第一次请求时,发送用户信息至服务器。服务器对用户信息使用HS256算法及密钥进行签名,再将这个签名和数据一起作为Token,返回给客户端。
- 服务器端不保存Token,客户端保存Token。
- 当客户端再次发送请求时,在请求信息中将Token一起发给服务器。
- 服务器用同样的HSA256 算法和同样的密钥,对数据再计算一次签名,和Token中的签名做比较。
如果相同,服务器就知道客户端已经登录过了,并且可以直接取到客户端的user ID。
如果不相同,数据部分肯定被人篡改过,服务器就返回客户端:认证不通过。
运用Token服务器就不再需要保存Session ID,只负责生成Token,然后验证Token。这就是服务器用CPU计算时间换取Session存储空间的方式。Token的传递通常被放在Cookie中,如果客户端不支持Cookie,Token也可以放置在请求头中。和Cookie一样,为了数据安全性Token中不应该放如密码等敏感信息。可以通过抓包工具获取 Token 值,具体操作就不反复说了。Token通常被用于一种轻巧的规范下,这种规范就叫作JSON Web Token(JWT) 。
6. JSON Web Token
当酒店满足了用户订餐的基本功能后,为了提升用户活跃度,以及增强用户黏性,需要加入一系列的交互功能。
- 用户之间是否可以免登录互加好友,推荐美食。
- 直接通过系统购物车的方式下订单,而不是提交订餐申请单。
- 实现Web应用的单点登录。
假设订餐用户有个人主页,上面展示各种好吃的菜肴。我在浏览对方页面时发现和他的口味相同,希望加其为好友,以后一起交流吃货的经验。页面上有个 button(按钮)为添加好友,我复制链接后,发现这条链接与众不同。
链接后面包含了一串jwt=LCJhbiOiJIUzI1NiJ9.eyJmcm9widVzZXIiOmyAYwugd1oRp字符串。
这里的jwt就是JSON Web Token的简称。它可以完成我在不登录系统的情况下,点击链接后自动登录加对方为好友的功能。
JSON Web Token(JWT)是一种开放标准(RFC 7519),它定义了一种紧凑且安全的标准,用于将各方之间的信息传输为JSON对象。该信息通过数字签名进行验证。使用HMAC算法或使用RSA的公钥/私钥对JWT进行签名,它是rest接口的一种安全策略。
首先,了解一下JWT的组成部分。实际上它就是一串字符串,由三个部分组成:头部、载荷与签名。
- 头部(Header)
头部用于描述关于JWT的最基本的信息,例如,其类型以及签名所用的算法等。它被表示成一个JSON对象。
{
"typ": "JWT",
"alg": "HS256"
}
在这里,我们说明了这是一个JWT,并且我们所用的签名算法是HS256算法。
对它进行Base64编码后形成的字符串就成了JWT的header(头部)。
- 载荷(Payload)
将添加好友的操作描述成一个JSON对象。其中添加了一些其他的信息,帮助今后收到这个JWT的服务器理解这个JWT。
{
"iss": "Kathy Yang JWT",
"iat": 1455624102,
"exp": 1475632522,
"aud": "www.example.com",
"sub": " jrocket@example.com ",
"Givename": "Smark",
"Surname": "Kathy"
}
这里面的前五个字段都是由JWT的标准所定义的。
- iss:该JWT的签发者。
- sub:该JWT所面向的用户。
- aud:接收该JWT的一方。
- exp(expires):什么时候过期,这里是一个UNIX时间戳。
- iat(issued at):在什么时候签发的。
将上面的JSON 对象进行Base64 编码可以得到一串字符串。这个字符串我们将它称作 JWT的Payload(载荷)。
- 签名(Signature)
将上面的两个编码后的字符串都用符号“.”连接在一起(头部在前),就形成了一串新的字符串。最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。通过密钥和加密算法加密后的部分就叫作签名。
最后将这一部分签名也拼接在被签名的字符串后面,我们就得到了完整的 JWT,也就是https://restaurant-app.com/make-friend/?jwt=LCJhbiOiJIUzI1NiJ9.eyJmcm9widVzZXIiOmyAYwugd1 oRp\链接中jwt的值了。
下面我们再通过一个实例了解JWT机制实现认证的过程。
当用户第一次登录系统时,如图所示。
创建JWT的步骤如下:
- 第一次登录,用户从客户端输入用户名/密码,提交后到服务器的登录处理Action层(Login Action)。
- Login Action调用认证服务进行用户名密码认证。
- 如果认证通过,Login Action 层调用用户信息服务获取用户信息(包括完整的用户信息及对应权限信息)。
- 返回用户信息后,Login Action从配置文件中获取Token签名生成的密钥信息,进行Token的生成。
- 在生成Token的过程中可以调用第三方JWT Lib生成签名后的JWT数据。
- 完成JWT数据签名后,将其设置到Cookie对象中,并重定向到首页,完成登录过程。
经历过第一次登录,以后的每一次该客户端的请求都会经历请求认证,如图所示。
请求认证的步骤如下:
- 客户端(App客户端或浏览器)通过get或post请求访问资源(页面或调用API)。
- 认证服务作为一个Middleware HOOK对请求进行拦截。基于Token的认证机制会在每一次请求中都带上完成签名的Token信息,这个Token信息可能在Cookie中,也可能在HTTP的Authorization 头中。首先认证服务在 Cookie 中查找 Token 信息,如果没有找到,则在 HTTP Authorization Head中查找。
- 如果找到Token信息,则根据配置文件中的签名加密密钥,调用JWT Lib对Token信息进行解密和解码。
- 完成解码并验证签名通过后,对Token中的exp、nbf、aud等信息进行验证。
- 全部通过后,根据获取的用户的角色权限信息,对请求的资源的权限进行逻辑判断。
- 如果权限逻辑判断通过,则通过response对象返回,否则返回未授权,登录失败。
JWT是目前项目中使用最多的处理请求无状态性的方式。由于Token是被签名的,所以我们可以认为一个可以解码认证通过的 Token 是由系统发放的,其中带的信息是合法有效的。使用Token很大程度上解决了Session的弊端,让数据在网络传输中更精确并且保障了数据在传输过程中的安全性。
本质上,测试人员是以找漏洞或许可以说以“破坏”作为技术手段实行测试过程的。例如,我们的安全性测试工程师就可以运用XSS Attacks、Replay Attacks以及MITM Attacks跨站脚本攻击等手段获取Token模拟正常请求发送场景,服务器是不会发现这是假请求的。
我们掌握了数据传输的基础知识,对请求的本质就有了清晰的认识。
2、HTTP协议
1. 网络协议简介
就如同在上一节中举的例子。公司和酒店签订了订餐协议,订餐协议中又包括配菜协议和配送协议。当然生活中的协议有很多种,比如工作协议、出境协议等。而订餐协议就属于工作协议中的一种。在网络中,计算机之间要进行通信交互行为。就如同我们通过说话进行信息交互通信,不同国家的人还使用不同的语言,计算机与计算机间的通信语言就叫作网络协议,不同的计算机间只有使用相同的协议才能通信。所以网络协议就是为计算机网络中进行数据交换而建立的规则、标准或约定的集合。
百科中有个例子举得很好,网络中一个微机用户和一个大型主机的操作员进行通信,这两个数据终端所用字符集不同,因此操作员所输入的命令彼此不认识。为了能进行通信,规定每个终端都要将各自字符集中的字符先变换为标准字符集的字符后,才进入网络传送,到达目的终端之后,再变换为该终端字符集的字符。这个规定就是网络协议,在双方都达成同一协议的基础上,客户端之间才能正常通信。
说到网络协议,就需要大家回忆一下大学课本中所学习的网络基础了。为了使不同计算机厂家生产的计算机能够相互通信,以便在更大的范围内建立计算机网络,国际标准化组织(ISO)在1978年提出了“开放系统互联参考模型”,即著名的OSI/RM模型(Open System Interconnection/Reference Model)。
它将计算机网络体系结构的通信协议划分为七层,自下而上依次为:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。
每一层都有其不同的含义及网络协议:
- 物理层:以太网、调制解调器、电力线通信(PLC)、SONET/SDH、光导纤维、同轴电缆、双绞线等。
- 数据链路层:Wi-Fi(IEEE 802.11)、WiMAX(IEEE 802.16)、ATM、令牌环、PPP、L2TP、PPTP等。
- 网络层协议:IP(IPv4、IPv6)、ICMP、ICMPv6、IGMP、IS-IS、IPsec、ARP、RARP等。
- 传输层协议:TCP、UDP、TLS、DCCP、SCTP、RSVP、OSPF等。
- 应用层协议:DNS、FTP、Gopher、HTTP、IMAP4、POP3、SIP、SSH、TELNET、RPC、SDP、SOAP、GTP等。
这里我们讲的应用层中被应用最多的协议:HTTP协议。
2. HTTP协议
RFC2616给出的官方定义为以下内容。
超文本传输协议(HTTP)是一种为分布式、协作式的,面向应用层的超媒体信息系统。它是一种通用的、无状态(stateless)的协议,除了应用于超文本传输外,它也可以应用于如名称服务器和分布对象管理系统之类的系统,这可以通过扩展它的请求方法、错误代码和消息头来实现。HTTP 是建立在统一资源标识符(URI)的约束上的,作为一个地址(URL)或名称(URN),以指定被一个方法使用的资源。消息是以一种类似于互联网邮件消息格式来传输的,互联网消息格式定义于多目的互联网邮件扩展(MIME)里。它也是用于用户代理(user agents)和其他互联网系统的代理/网关之间通信的通信协议,HTTP 允许不同的应用程序对资源进行基本的超媒体访问。
HTTP/1.0没有充分考虑到分层代理、缓存以及持久连接和虚拟主机的需求的影响。并且随着不完善的 HTTP/1.0 应用程序的激增,迫切需要一个新的版本,以便使两个通信程序能够确定彼此的真实能力。此规范定义的协议叫作“HTTP/1.1”,这个协议与 HTTP/1.0 相比,更为严格,以确保各个协议的特征得到可靠实现。
以上官网定义会显得非常难以理解。要明白HTTP协议,测试人员必须要先理解一些术语的含义。它们是HTTP通信中各参与者和对象扮演不同角色的说明。
- 连接(connection)
为通信而在两个程序间建立的传输层虚拟电路。
- 消息(message)
HTTP通信中的基本单元。它由一个结构化的八比特字节序列组成,通过连接得到传送。
- 请求(request)
一种HTTP请求消息,从客户端到服务器的消息叫请求消息。
- 响应(response)
一种HTTP响应消息,从服务器返回到客户端的消息叫响应消息。
- 资源(resource)
一种网络数据对象或服务。资源可以有多种表现方式(如多种语言、数据格式、大小和分辨率)或者是根据其他方面而不同的表现形式。
- 实体(entity)
实体是请求或响应的有效承载信息。一个实体包含元信息和内容,元信息以实体头域(entityheaderfield)形式表示,内容以消息主体(entity-body)形式表示。
- 表现形式(representation)
一个响应包含的实体是由内容协商(content negotiation)决定的。有可能存在一个特定的响应状态码对应多个表现形式。
- 内容协商(content negotiation)
当服务一个请求时选择资源的一种适当的表示形式的机制(mechanism)。任何响应里实体的表现形式都是可协商的(包括错误响应)。
- 变量(variant)
在某个时刻,一个资源对应的表现形式(representation)可以有一个或多个(译注:一个URI请求一个资源,但返回的是此资源对应的表现形式,这根据内容协商决定)。每个表现形式(representation)被称作一个变量。“变量”这个术语的使用并不意味着资源(resource)是由内容协商决定的。
- 客户端(client)
为发送请求建立连接的程序。
- 用户代理(user agent)
初始化请求的客户端程序。常见的如浏览器、编辑器、蜘蛛(可网络穿越的机器人),或其他的终端用户工具。
- 服务器(Server)
服务器是这样一个应用程序,它同意请求端的连接,并发送响应(response)。任何给定的程序都有可能既做客户端,又做服务器。我们使用这些术语是为了说明特定连接中应用程序所担当的角色,而不是指通常意义上应用程序的能力。同样,任何服务器都可以基于每个请求的性质扮演源服务器、代理、网关,或者隧道等角色之一。
- 源服务器(Origin server)
存在资源或者资源在其上被创建的服务器(Server)被称为源服务器(Origin server)。
- 代理(Proxy)
代理是一个中间程序,它既可以担当客户端的角色,也可以担当服务器的角色。代理代表客户端向服务器发送请求。客户端的请求经过代理,会在代理内部得到服务或者经过一定的转换转至其他服务器。一个代理必须能同时实现本规范中对客户端和服务器所做的要求。
透明代理(transparent proxy)需要代理认证和代理识别,而不修改请求或响应。非透明代理(nontransparent proxy)需修改请求或响应,以便为用户代理(user agent)提供附加服务,附加服务包括组注释服务、媒体类型转换、协议简化或者匿名过滤等。除非透明行为或非透明行为被显式地声明,否则,HTTP 代理既是透明代理,也是非透明代理。
- 网关(gateway)
网关其实是一个服务器,扮演着代表其他服务器为客户端提供服务的中间者。与代理(proxy)不同,网关接收请求,仿佛它就是请求资源的源服务器。请求的客户端可能觉察不到它正在同网关通信。
- 隧道(tunnel)
隧道也是一个中间程序,它是一个在两个连接之间充当盲目中继(blind relay)的中间程序。一旦隧道处于活动状态,它不能被认为是这次HTTP通信的参与者,虽然HTTP请求可能已经把它初始化了。当两端的中继连接都关闭的时候,隧道不再存在。
- 缓存(cache)
缓存是程序响应消息的本地存储。缓存是一个子系统,控制消息的存储、获取和删除。缓存里存放可缓存的响应(cacheable response),为的是减少对将来同样请求的响应时间和网络带宽消耗。任一客户端或服务器都可能含有缓存,但缓存不能存在于一个充当隧道(tunnel)的服务器里。
- 可缓存的(cacheable)
响应(response)是可缓存的是指可以将这个响应缓存(cache)保存为副本,之后能用此副本继续响应后续的请求。但即使一个资源(resource)是可缓存的,也可能会出现由于请求本身的约束造成缓存副本不能被使用的情况。
- 第一手的(first-hand)
如果一个响应直接从源服务器或经过若干代理(proxy),并且没有不必要的延时,最后到达客户端,那么这个响应就是第一手的(first-hand)。如果响应通过源服务器(origin Server)验证是有效性(validity)的,那么这个响应也同样是第一手的。
- 显式过期时间(explicit expiration time)
显式过期时间是源服务器认为实体(entity)在没有被进一步验证(validation)的情况下,缓存(cache)不应该利用其去响应后续请求的时间(译注:也就是说,当响应的显式过期时间达到后,缓存必须要对其缓存的副本进行重验证,否则就不能利用此副本去响应后续请求)。
- 启发式过期时间(heuristic expiration time)
当没有显式过期时间(explicit expiration time)可利用时,由缓存指定过期时间。
- 年龄(age)
一个响应的年龄是从被源服务器发送或被源服务器成功验证到现在的时间。
- 保鲜寿命(freshness lifetime)
一个响应产生到过期之间的时间。
- 保鲜(Fresh)
如果一个响应的年龄还没有超过保鲜寿命(freshness lifetime),那么它就是保鲜的。
- 陈旧(Stale)
一个响应的年龄已经超过了它的保鲜寿命(freshness lifetime),那么它就是陈旧的。
- 语义透明(semantically transparent)
缓存(cache)可能会以一种语意透明(semantically transparent)的方式工作。这时,对于一个特定的响应,使用缓存既不会对请求客户端产生影响,也不会对源服务器产生影响,缓存的使用只是为了提高性能。当缓存(cache)具有语意透明时,客户端从缓存接收的响应跟直接从源服务器接收的响应完全一致(除了使用hop-by-hop头域)。
- 验证器(Validator)
验证器其实是协议元素如实体标签(entity tag)或最后修改时间(last-modified time)等,这些协议元素被用于识别缓存里保存的副本(缓存项)是否等价于源服务器的实体的副本。
- 上游/下游(upstream/downstream)
上游和下游描述了消息的流动:所有消息都是从上游流到下游的。
- 内向/外向(inbound/outbound)
内向和外向指的是消息的请求和响应路径:“内向”即“移向源服务器”,“外向”即“移向用户代理(user agent)”。
把HTTP协议的官方定义进行归纳,得出HTTP协议的4个关键点。
- HTTP是建立在TCP/IP协议之上,面向应用层的超文本传输协议。
- 它由请求和响应组成,完全符合标准的客户端服务器的请求响应模型。
- 协议很轻便简单,并且请求与请求间没有关联,是无状态性的协议。
- 为了弥补这种无状态性就需要使用HTTP协议的扩展Cookie等方式建立关联。
3. HTTP协议的原理
如同订餐协议的工作建立在公司端-酒店端的架构上,HTTP 协议工作于客户端-服务端的架构上。客户端通过URL向服务器发送所有请求。服务器根据接收到的请求,向客户端发送响应信息。HTTP 协议定义客户端如何向服务器发送请求,以及服务器如何将响应请求传送给客户端,所以HTTP协议采用了请求/响应模型,如图所示。
图中有以下几点需要我们理解。
1)客户端
客户端主要有两个职能。
- 一个向服务器发送请求。
- 接收服务器返回的报文并解释成友善的信息供我们阅读。
客户端大概有以下几类:浏览器、应用程序(桌面应用和app应用)等。
我们在地址栏输入网址并回车,浏览器会为我们做如下的处理。
- 解析出协议(HTTP)、域名(www.qq.com)。
- 使用HTTP协议并创建请求报文向服务器端发送请求。
- 接收到服务器返回的内容并经过渲染后展示给用户。
在日常生活中,大部分客户端的工作都由客户端软件进行了包装和处理。测试人员日常使用的截包工具大多数都有模拟请求发送的功能。例如,postman、fiddler 等。我们就以使用 fiddler工具为例,模拟客户端发送请求给服务器,以加深对客户端职能的理解。
首先我们要安装fiddler。
安装完成后,切换到Composer 选项卡下:
以get请求为例:
- 选择get请求。
- 填写请求地址。
- 填写请求头,这里要注意的是将User-Agent替换成真实浏览器的值。避免有些网站防爬虫屏蔽工具发送请求。
- 如果是post请求,则会有请求主体,此部分填写请求主体的内容。
- 点击Execute按钮,发送请求。
- 服务器返回的响应头:状态码为200,请求成功。
点击响应的内容,查看响应详细信息:
也可以点WebView,查看返回的web页面数据:
2)服务器
服务器端在接收到客户端发送的请求后会开始处理请求。
服务端的处理过程如下。
服务器软件一直在监听端口是否有新的请求到达,如iis或tomcat在建立Web站点后,默认会一直监听80端口等待HTTP请求到达服务器。
(1)建立连接:如果客户端已经打开一条到服务器的持久连接,则可以直接使用,否则,客户端需要在服务器打开一条新的连接。
(2)接收请求报文:连接上有数据到时,Web服务器会从网络连接中读取数据,并将请求报文中的内容解析出来。
请求报文如下:
GET www.qq.com HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64)AppleWebKit/537.36 (KHTML, like Gecko)Chrome/61.0.3163.100 Safari/537.36
Host: www.qq.com
(3)接收后会被如下表示
Name:Method Value:GET
Name:Version 1.1
Name:Host Value:www.qq.com
Name: User-Agent Value: Mozilla/5.0 (Windows NT 6.1; Win64; x64)AppleWebKit/537.36 (KHTML, like Gecko)Chrome/61.0.3163.100 Safari/537.36
(4)处理请求:当请求被接收和表示后,服务器便可以根据请求报文进行处理了。例如,post方法中提出报文主体的数据并插入到数据库中。
(5)访问资源:请求处理完成后,比如Web会根据数据生成一系列的HTML页面或图片等信息,此步骤将访问这些存储在服务器上的物理文件。
(6)构建响应:Web服务器在识别资源后,构造响应报文。响应报文中包含状态码、响应头、主体等内容。
(7)发送响应:服务器将响应的数据发送给客户端机器。
(8)记录日志:请求结束,Web服务器会在日志文件中添加一条请求记录。
3)报文
客户端与服务器端之间的信息传递使用的载体叫作报文。
报文分为请求与响应两部分:
(1)客户端向服务器发送一个请求报文。
请求报文包含请求的方法、URL、协议版本、请求头部和请求数据。
(2)服务器反馈给客户端一个响应报文。
响应的内容包括协议的版本、成功或者错误响应码、服务器信息、响应头部和响应数据。
4. URL(Uniform Resource Locator)
假设请求发送地址为:
www.example.com/index.html
浏览器会将地址解析为:
Host: www.example.com
Name:Uri Value:index.html
其中的URI就是HTTP所使用的统一资源标识符(Uniform Resource Identifiers),用来传输数据和建立连接。而我们日常使用的URL则是一种特殊类型的URI,包含了用于查找某个资源的足够的信息。
URL的全称是Uniform Resource Locator,是互联网上用来标识某一处资源的地址。
它的基本格式如下:
schema://host[:port#]/path/……/[;url-params][?query-string][#anchor]
<方案>://<用户名>:<密码>@主机:端口/路径;参数?查询#片段
URL主要有三个作用。
- HTTP是URL的方案,方案告诉客户端使用什么样的协议去访问服务器。
- Host:www.example.com,指服务器的位置。
- /index.html是资源路径,说明了请求的是服务器上哪个特定的本地资源。
我们以链接http://www.kath2.com/news/index.asp?ID=210&page=1#name为例,对URL进行详解。
URL一般分为以下几个部分:
- 协议部分:该URL的协议部分为“HTTP:”,这代表网页使用的是HTTP协议。在Internet中可以使用多种协议,如HTTPS、Ftp等。在“HTTP”后面的“//”为分隔符。
- 域名部分:该URL的域名部分为“www.kath2.com”。一个URL中,也可以使用IP地址作为域名使用。
- 端口部分:跟在域名后面的是端口,域名和端口之间使用“:”作为分隔符。端口不是一个URL必需的部分,如果省略端口部分,将采用默认端口。例如,HTTP的默认端口为80,HTTPS的默认端口为443。
- 虚拟目录部分:从域名后的第一个“/”开始到最后一个“/”为止,是虚拟目录部分。虚拟目录也不是一个URL必需的部分。本例中的虚拟目录是“/news/”。
- 文件名部分:从域名后的最后一个“/”开始到“?”为止,是文件名部分。
- 如果没有“?”,则是从域名后的最后一个“/”开始到“#”为止,是文件名部分。
- 如果没有“?”和“#”,那么从域名后的最后一个“/”开始到结束,都是文件名部分。本例中的文件名是“index.asp”。文件名部分也不是一个URL必需的部分,如果省略该部分,则使用默认的文件名。
- 锚部分:从“#”开始到最后,都是锚部分。本例中的锚部分是“name”。锚部分也不是一个URL必需的部分。
- 参数部分:从“?”开始到“#”为止之间的部分为参数部分,又称搜索部分、查询部分。本例中的参数部分为“ID=210&page=1”。可以允许有多个参数,参数与参数之间用“&”作为分隔符。
5. 请求报文(request)
1)报文格式
request报文的结构分为3部分:
- 请求行(request line)
- 请求头部(header)
- 主体(body)
header和body之间有个空行。
HTTP的请求报文结构如图所示:
真实数据:
请求行以一个方法符号开头,以空格分开,后面跟着请求的URI和协议的版本。
get请求例子,使用Chrome抓取访问QQ首页的请求报文。
1.GET / HTTP/1.1
2.Host: www.qq.com
3.Connection: keep-alive
4.User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64)AppleWebKit/537.36 (KHTML, like Gecko)Chrome/61.0.3163.100 Safari/537.36
5.Upgrade-Insecure-Requests: 1
6.Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
7.Accept-Encoding: gzip, deflate
8.Accept-Language: zh-CN,zh;q=0.8
9.Cookie: pgv_pvi=5652430848;
get请求报文说明如下:
第一部分:第1行请求行,用来说明请求类型、要访问的资源以及所使用的HTTP版本。
第二部分:第2行至第9行请求头部,紧接着请求行之后的部分,用来说明服务器要使用的附加信息。
第三部分:第10行空行,请求头部后面的空行是必需的。即使第四部分的请求数据为空,也必须有空行。
第四部分:第11行请求数据也叫主体,可以添加任意的其他数据。这个例子的get请求数据为空。
post请求例子,使用Chrome抓取访问QQ首页的请求报文。
1.POST /v2/api/?login HTTP/1.1
2.Host: passport.baidu.com
3.Connection: keep-alive
4.Content-Length: 2843
5.Cache-Control: max-age=0
6.Origin: https://www.baidu.com
7.Upgrade-Insecure-Requests: 1
8.Content-Type: application/x-www-form-urlencoded
9.User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64)AppleWebKit/537.36 (KHTML, like Gecko)Chrome/61.0.3163.100 Safari/537.36
10.Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
11.Referer: https://www.baidu.com/
12.Accept-Encoding: gzip, deflate, br
13.Accept-Language: zh-CN,zh;q=0.8
14.Cookie: BAIDUID=E14AA6F6CF70EFA26956:FG=1;
15.
16.tpl=mn&subpro=&apiver=v3&tt=1510129420956&codestring=jxG7706c10ef5bce2f702c7145a 4301af7e537498076d047b4f&safeflg=0
post请求报文说明如下:
第一部分:第1行请求行,说明是post请求以及HTTP1.1版本。
第二部分:第2行至第14行请求头部。
第三部分:第15行的空行。
第四部分:第16行请求数据。
2)请求报文的headers属性
以fiddler截获的报文为例,点击Inspectors tab -> Request tab -> headers,查看请求报文的 headers属性,如图所示。
header的属性有很多,比较难以记忆。Fiddler把header进行了分类,这样比较清晰,也容易记忆。
header的属性包含以下几部分:
(1)Cache头域
在Cache头域中,通常会出现以下属性。
① Cache-Control
用来指定Response-Request遵循的缓存机制。各个指令含义如下。
- Cache-Control:Public:可以被任何缓存所缓存。
- Cache-Control:Private:指示响应信息的全部或部分用于单个用户,而不能用一个共享缓存来缓存。这可以让源服务器指示,响应的特定部分只用于一个用户,而对其他用户的请求则是一个不可靠的响应。
- Cache-Control:no-cache:所有内容都不会被缓存,请求头里的no-cache表示浏览器不想读缓存,并不是说没有缓存。一般在浏览器按Ctrl+F5键强制刷新时,请求头里也会有这个no-cache,也就是跳过强缓存和协商缓存阶段,直接请求服务器。
- Cache-Control:max-age=0:指示客户端愿意接收其绝对时间不大于指定的时间,以秒计。如果直接按F5键的话,请求头是max-age=0,只跳过强缓存,但进行协商缓存。
② If-Modified-Since
把浏览器端缓存页面的最后修改时间发送到服务器去,服务器会把这个时间与服务器上实际文件的最后修改时间进行对比。如果时间一致,那么返回304,客户端就直接使用本地缓存文件。如果时间不一致,就会返回200和新的文件内容。客户端接到之后,会丢弃旧文件,把新文件缓存起来,并显示在浏览器中。
本地文件的修改时间和服务器上的文件修改时间一致,说明文件没有被更新。HTTP 服务器返回304,告诉客户端使用本地缓存文件。
③ If-None-Match
If-None-Match和ETag一起工作,工作原理是在HTTP response中添加ETag信息。当用户再次请求该资源时,将在HTTP request中加入If-None-Match信息(ETag的值)。如果服务器验证资源的ETag没有改变(该资源没有更新),将返回一个304状态告诉客户端使用本地缓存文件,否则将返回 200 状态和新的资源和 Etag。使用这样的机制将提高网站的性能。
If-None-Match和ETag的值一致,说明文件没有被更新。服务器将返回304,告诉客户端使用本地缓存文件。
④ Pragma
防止页面被缓存,在HTTP/1.1版本中,它和Cache-Control:no-cache的作用一模一样。
Pargma只有一个用法,例如,Pragma: no-cache。
(2)Client头域
① Accept
浏览器端可以接收的媒体类型。
- Accept: text/html
代表浏览器可以接收服务器发回的类型为text/html也就是我们常说的HTML文档,如果服务器无法返回text/html类型的数据,服务器应该返回一个406错误(non acceptable)。
- Accept: */*
通配符 * 代表任意类型。代表浏览器可以处理所有类型。
- Accept-Encoding
浏览器声明自己接收的编码方法,通常指定压缩方法,是否支持压缩,支持什么压缩方法(gzip,deflate)。
例如,Accept-Encoding: gzip, deflate。
- Accept-Language
浏览器声明自己接收的语言。语言跟字符集的区别是:中文是语言,中文有多种字符集,比如big5、gb2312、gbk等。
② User-Agent
告诉HTTP服务器客户端使用的操作系统和浏览器的名称和版本。我们上网登录论坛的时候,往往会看到一些欢迎信息,其中列出了你的操作系统的名称和版本、你所使用的浏览器的名称和版本,这往往让很多人感到很神奇,实际上,服务器应用程序就是从User-Agent这个请求报头域中获取到这些信息的。User-Agent 请求报头域允许客户端将它的操作系统、浏览器和其他属性告诉服务器。
③ Accept-Charset
浏览器声明自己接收的字符集,如gb2312、utf-8等。
(3)Cookie头域
- Cookie
最重要的header,将Cookie的值发送给HTTP服务器。
(4)Miscellaneous头域
- Referer
提供了request的上下文信息的服务器,告诉服务器我是从哪个链接过来的。有些统计数据需要用到此头域。比如从我的主页上链接到统计服务器那里,该服务器就能够从HTTP Referer中统计出每天有多少用户点击我主页上的链接访问他的网站。
(5)Entity头域
① Content-Length
发送给HTTP服务器数据的长度。
② Content-Type
表示具体请求中的媒体类型信息。
常见的媒体格式类型如下:
- text/html:HTML格式
- text/plain:纯文本格式
- text/xml:XML格式
- image/gif:gif图片格式
- image/jpeg:jpg图片格式
- image/png:png图片格式
- 以application开头的媒体格式类型如下
- application/xhtml+xml:XHTML格式
- application/xml:XML数据格式
- application/atom+xml:Atom XML聚合格式
- application/json:JSON数据格式
- application/pdf:pdf格式
- application/msword:Word文档格式
- application/octet-stream:二进制流数据
- application/x-www-form-urlencoded:<form encType="">中默认的encType,form表单数据被编码为key/value格式发送到服务器(表单默认的提交数据的格式)。
③ 上传文件时媒体格式
- multipart/form-data:需要在表单中进行文件上传时,就需要使用该格式。
(6)Transport头域
- Connection
- Connection: keep-alive:当一个网页打开完成后,客户端和服务器之间用于传输HTTP 数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。
- Connection: close:代表一个request完成后,客户端和服务器之间用于传输HTTP数据的TCP连接会关闭,当客户端再次发送request时,需要重新建立TCP连接。
Host(发送请求时,该报头域是必需的)
请求报头域主要用于指定被请求资源的Internet主机和端口号,它通常从HTTP URL中提取出来。
3)请求报文的方法
请求的起始行以方法作为开始,方法用来告诉服务器要如何做。
在开发中通常有两种请求方式:
- get方式:是以实体的方式得到由请求URI所指定资源的信息,如果请求URI只是一个数据产生过程,那么最终要在响应实体中返回的是处理过程的结果所指向的资源,而不是处理过程的描述。
- post方式:用来向目的服务器发出请求,要求它接收被附在请求后的实体,并把它当作请求队列中请求URI所指定资源的附加新子项,所以post请求可能会导致新的资源的建立和/或已有资源的修改。
可以通过对post及get的区别一节,加深对这两种请求方式的理解。
除了以上两种常用请求方式,还有另外五种请求方法。
说明如下:
- HEAD:类似于get请求,只不过返回的响应中没有具体的内容,用于获取报头。
- DELETE:请求服务器删除指定的页面。
- CONNECT:HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。
- OPTIONS:允许客户端查看服务器的性能。
- TRACE:回显服务器收到的请求,主要用于测试或调试。
get与post的区别:
(1)get是从服务器上获取数据,post是向服务器传送数据。
反驳:
先看看HTTP RFC文档对这两种请求的定义。
根据HTTP规范,get用于信息获取,而且应该是安全的和幂等的。
post表示可能会修改服务器上资源的请求。
用通俗的话来说,我们可以从两个角度看待get请求。
- 从发送请求的角度,get请求相当于我们在数据库中做了查询的操作,这样的操作不影响数据库本身的数据。
- 从接收返回数据的角度,幂等的含义简单地说就是发送get请求不改变返回数据的内容。
这样说似乎还是有点抽象,举个例子。
例如,你发送 get 请求获取测试论坛的首页信息。首页信息并不会因为你发了请求而改变。因为get用于不改变返回信息内容的请求,所以HTTP规范定义它是安全的,就像对数据库不做增删改操作,只查询数据一样。
而post的用途呢,表示会改变服务器上所返回资源信息的请求,相当于我们在数据库中做了修改的操作,会影响数据库本身的数据。
例如,你在测试论坛网上发了帖子,做了评论,得到了积分等。这种情况下,资源状态被改变了,所以HTTP规范定义post请求是可能会改变服务器资源的请求。
通过HTTP规范的定义,我们可以看出get和post都是用作传递及获取服务器信息的请求。get请求也可以向服务器传递数据,post请求也需要服务器返回数据,所以百度的这个答案让人有点摸不着头脑。
(2)get把参数数据队列加到提交表单的ACTION属性所指的URL中,值和表单内各个字段一一对应,在URL中可以看到。post通过HTTP post机制,将表单内各个字段与其内容放置在HTML Header内一起传送到Action属性所指的URL地址,用户看不到这个过程。
反驳:
这个答案是在叙述get和post表现形式的不同。
我们来看一下两种请求的方法实例:
get请求的数据会附在URL之后(就是把数据放置在HTTP协议头中),以?分割URL和传输数据,多个参数用&连接。post请求就是把提交的数据放置在HTTP包的body中。如图4.29所示,get请求实例中的/fish/?sex=man&name=Smark以及post请求实例中的name= Smark%20Ajax&publisher=fish就是实际的传输数据。
这种形式是HTML标准对HTTP协议用法的约定,并不能作为post和get请求的区别。现在我们也有很多的Web Server支持get中包含body的表现形式。所以这个答案又是错的。
(3)对于get方式,服务器端用Request.QueryString获取变量的值,对于post方式,服务器端用Request.Form获取提交的数据。
反驳:
这个答案有点不知所云,获取请求变量的值是由服务器端的配置所决定的。这些与post和get请求的区别完全没有关系。
(4)get 传送的数据量较小,不能大于 2KB。post 传送的数据量较大,一般被默认为不受限制。但理论上,IIS4中最大量为80KB,IIS5中为100KB。
反驳:
在HTTP协议规范中,没有对传输的数据大小进行限制,也没有对URL长度进行限制。
如果说对于用get请求URL上有限制的话,取决于以下两方面。
① HTTP客户端浏览器自己的限制:如IE限定URL长度为2083字节,opera是4050,Netscape是8192等。
② Web服务器为了效率及安全的考虑,所以修改Apache、IIS的配置对post提交数据大小进行的限制。
限制并不能作为post和get请求的区别,所以这个答案继续是错的。
(5)get安全性非常低,post安全性较高,但是执行效率却比post方法好。
反驳:
让我们猜测一下这个答案的作者为什么会这么说,应该是由于post请求在URL上看不到传输数据吧。
但是在浏览器地址栏上看不到并不能说明它安全,如果你使用截数据包的工具查看没有加密过的post请求,同样可以清晰地看到post报文的内容。
所以说,安全不安全根本不是get和post的区别,只和两者是否加密有关。
看完以上,我们引申出去,不单单从技术的层面看待这个问题。不要人云亦云,盲目地相信所谓的权威,那样很可能会误导你走上错误的道路。而只有从提升自身的角度出发,不断努力具备自学能力,一步步脚踏实地地走,才是真正正确的学习之道。
6. 响应报文(response)
1)报文格式
response消息的结构,和request消息的结构基本一样,同样也分为三部分。
- 响应状态(response code)
- 响应头(response header)
- 响应主体(response body)
header和body之间也有个空行。
用Fiddler捕捉response报文,然后分析下它的结构,在Inspectors tab下以Raw的方式可以看到完整的response的消息。
2)响应报文的headers属性
同样使用fiddler查看response headers,点击Inspectors tab ->Response tab-> headers
(1)Cache头域
① Cache-Control
用于设置缓存的属性,此例浏览内容不被缓存。
② Date
生成消息的具体时间和日期。
③ Expires
浏览器会在指定过期时间内使用本地缓存。
④ Pragma
浏览内容不会被缓存。
(2)Cookie/Login头域
① P3P
用于跨域设置Cookie,这样可以解决iframe跨域访问Cookie的问题。
② Set-Cookie
用于把Cookie发送到客户端浏览器,每一个写入Cookie都会生成一个Set-Cookie。
(3)Entity头域
① ETag
和If-None-Match 配合使用。
② Last-Modified
用于指示资源的最后修改日期和时间。
③ Content-Type
Web服务器告诉浏览器自己响应的对象的类型和字符集。
④ Content-Length
指明实体正文的长度,以字节方式存储的十进制数字来表示。在数据下行的过程中,ContentLength的方式要预先在服务器中缓存所有数据,然后所有数据再一股脑儿地发给客户端。
⑤ Content-Encoding
Web服务器表明自己使用了什么压缩方法(gzip,deflate)压缩响应中的对象。
⑥ Content-Language
Web服务器告诉浏览器自己响应的对象的语言。
(4)Miscellaneous头域
① Server
指明HTTP服务器的软件信息。
② X-AspNet-Version
如果网站是用ASP.NET开发的,这个header用来表示ASP.NET的版本。
③ X-Powered-By
表示网站是用什么技术开发的。
(5)Transport头域
- Connection
- Connection: keep-alive
当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。
- Connection: close
代表一个request完成后,客户端和服务器之间用于传输HTTP数据的TCP连接会关闭,当客户端再次发送Request时,需要重新建立TCP连接。
一个传输层的实际环流,它是建立在两个相互通信的应用程序之间。
在HTTP1.1,request和reponse头中都有可能出现一个Connection的头,此header的含义是当Client和Server通信时对于长链接如何进行处理。
在HTTP1.1中,Client和Server都是默认对方支持长链接的。如果Client使用HTTP1.1协议,但又不希望使用长链接,则需要在header中指明Connection的值为close;如果Server方也不想支持长链接,则在response中也需要明确说明Connection的值为close。不论request,还是response的header中包含了值为close的Connection,都表明当前正在使用的TCP链接在当天请求处理完毕后会被断掉。以后Client再进行新的请求时就必须创建新的TCP链接了。
在此需要为测试人员明确一个容易混淆的概念:HTTP 协议的无状态和 Connection: keep-alive是两个不同的概念。无状态是指协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。从另一方面讲,打开一个服务器上的网页和你之前打开这个服务器上的网页之间没有任何联系。HTTP是一个无状态的、面向连接的协议,无状态不代表HTTP不能保持TCP连接,更不能代表HTTP使用的是UDP协议(无连接)。
从HTTP/1.1起,默认都开启了Keep-Alive,保持连接特性,简单地说,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。
(6)Location头域
- Location
用于重定向一个新的位置,包含新的URL地址。
(7)Security头域
- Strict-Transport-Security: max-age=31536000
网站通过HTTP Strict Transport Security(HSTS)通知浏览器,这个网站禁止使用HTTP方式加载,浏览器应该自动把所有尝试使用HTTP的请求自动替换为HTTPS请求。
当你的网站第一次发送HTTPS请求,服务器响应Strict-Transport-Security头,浏览器记录下这些信息。
然后后面尝试访问这个网站的请求都会自动把HTTP替换为HTTPS。当HSTS头设置的过期时间到了,后面通过HTTP的访问恢复到正常模式,不会再自动跳转到HTTPS。每次浏览器接收到Strict-Transport-Security头,它都会更新这个网站的过期时间,所以网站可以刷新这些信息,防止过期发生。
3)响应报文的状态码
当客户端发起一次 HTTP 请求后,服务器会返回一个包含 HTTP 状态码的信息头(server header)用以响应客户端的请求。response消息中的第一行叫作状态行,由HTTP协议版本号、状态码、状态消息三部分组成。
状态码用来告诉HTTP客户端HTTP服务器是否产生了预期的response。状态码总共只有三位,第一位表示状态类别,共分为五种。
- 1xx:是进度通知类状态,意思就是说“请求我已经收到了,或你的请求我正在处理”。
- 2xx:表示“你的请求我已经成功处理了”。
- 3xx:即重定向,也就是服务器告诉客户端“你要的资源搬家了,你到某某地方再去找它吧”。
- 4xx:客户端发来的响应报文里有些错误,比如语法错误或请求的资源不存在等。
- 5xx:服务器端有些问题,已经无法处理完成你的请求了。
常用的状态码并不多,用服务器与客户端对话的形式把常见的状态码含义列举如下。
- 200 OK:客户端,你的请求处理成功,你要的东西就在响应报文里了。
- 301 Moved Permanently:客户端,你要请求的资源已经永久地搬家了,我把它的新地址放到了Location头部域中了。
- 02 Moved Temporarily:客户端,你要请求的资源临时有事去别的地方了,我把它的位置放到Location头部域中了,你可以先去那里找它,不过它应该会回到它原来的家的。
- 304 Not Modified:客户端,你要请求的资源自从上次你请求之后,就再也没有改动过。我想你应该早就有这个资源了,所以在响应报文的数据部分我就没有再放这个资源。
- 400 Bad Request:客户端,你发来的请求报文里有语法错误,服务器端实在看不懂。
- 401 Unauthorized:客户端,你发来的请求不是合法来源的请求,你是没有被授权的客户端吧。
- 403 Forbidden:服务器端顺利收到了客户端的请求,但因为某些理由,服务器端拒绝为你提供服务。
- 404 Not Found:客户端,你请求的资源不存在,八成是资源地址写错了。
- 500 Internal Server Error:很遗憾,服务器不能给你提供服务了,服务器内部出现了不可预知的问题。
- 502 Bad Gateway:客户端你好,我是请求报文的代理服务器,持有资源的那个服务器在给我发送资源时出问题了。
- 503 Server Unavailable:服务器现在可能是太忙了,暂时不能给你这个客户端提供服务,或许稍后会恢复的。
除了以上几个常用的状态码,还有许多其他含义的状态码。完整状态码含义表如下。
(1)请求收到,继续处理
- HTTP 100—客户必须继续发出请求
- HTTP 101—客户要求服务器根据请求转换HTTP协议版本
(2)操作成功收到,分析、接受
- HTTP 200—交易成功
- HTTP 201—提示知道新文件的URL
- HTTP 202—接受和处理,但处理未完成
- HTTP 203—返回信息不确定或不完整
- HTTP 204—请求收到,但返回信息为空
- HTTP 205—服务器完成了请求,用户代理必须复位当前已经浏览过的文件
- HTTP 206—服务器已经完成了部分用户的get请求
(3)完成此请求必须进一步处理
- HTTP 300—请求的资源可在多处得到
- HTTP 301—删除请求数据
- HTTP 302—在其他地址发现了请求数据
- HTTP 303—建议客户访问其他URL或访问方式
- HTTP 304—客户端已经执行了get,但文件未变化
- HTTP 305—请求的资源必须从服务器指定的地址得到
- HTTP 306—前一版本HTTP中使用的代码,现行版本中不再使用
- HTTP 307—申明请求的资源临时性删除
(4)请求包含一个错误语法或不能完成
- HTTP 400—错误请求,如语法错误
- HTTP 401—未授权
- HTTP 401.1—未授权:登录失败
- HTTP 401.2—未授权:服务器配置问题导致登录失败
- HTTP 401.3—未授权:禁止访问资源
- HTTP 401.4—未授权:授权被筛选器拒绝
- HTTP 401.5—未授权:ISAPI 或 CGI 授权失败
- HTTP 402—保留有效ChargeTo头响应
- HTTP 403—禁止访问
- HTTP 403.1—禁止访问:禁止可执行访问
- HTTP 403.2—禁止访问:禁止读访问
- HTTP 403.3—禁止访问:禁止写访问
- HTTP 403.4—禁止访问:要求 SSL
- HTTP 403.5—禁止访问:要求 SSL 128
- HTTP 403.6—禁止访问:IP 地址被拒绝
- HTTP 403.7—禁止访问:要求客户证书
- HTTP 403.8—禁止访问:禁止站点访问
- HTTP 403.9—禁止访问:连接的用户过多
- HTTP 403.10—禁止访问:配置无效
- HTTP 403.11—禁止访问:密码更改
- HTTP 403.12—禁止访问:映射器拒绝访问
- HTTP 403.13—禁止访问:客户证书已被吊销
- HTTP 403.14—禁止访问:Web服务器被配置不列出此目录的内容
- HTTP 403.15—禁止访问:客户访问许可过多
- HTTP 403.16—禁止访问:客户证书不可信或者无效
- HTTP 403.17—禁止访问:客户证书已经到期或者尚未生效
- HTTP 404—没有发现文件、查询或URl
- HTTP 405—用户在Request-Line字段定义的方法不允许
- HTTP 406—无法接受用户发送的请求,请求资源不可被访问
- HTTP 407—类似401,用户必须首先在代理服务器上得到授权
- HTTP 408—客户端没有在用户指定的时间内完成请求
- HTTP 409—对当前资源状态,请求不能完成
- HTTP 410—服务器上不再有此资源且无进一步的参考地址
- HTTP 411—服务器拒绝用户定义的Content-Length属性请求
- HTTP 412—一个或多个请求头字段在当前请求中错误
- HTTP 413—请求的资源大于服务器允许的大小
- HTTP 414—请求的资源URL长于服务器允许的长度
- HTTP 415—请求资源不支持请求项目格式
- HTTP 416—请求中包含Range请求头字段,在当前请求资源范围内没有range指示值,请求也不包含If-Range请求头字段
- HTTP 417—在请求头Expect中指定的预期内容无法被服务器满足。或者这个服务器是一个代理服务器,它在当前路由的下一个节点上,Expect的内容无法被满足。
(5)服务器执行一个完全有效请求失败
- HTTP 500—内部服务器错误
- HTTP 500.11—服务器关闭
- HTTP 500.12—应用程序重新启动
- HTTP 500.13—服务器太忙
- HTTP 500.14—应用程序无效
- HTTP 500.15—不允许请求
- HTTP 501—未实现
- HTTP 502—网关错误
7. HTTP扩展
1)cache的运用
这里将缓存的含义与HTTP协议相结合,看一下cache在HTTP协议中是如何运用的。
与缓存相关的HTTP扩展消息头有以下几种:
- Expires:指示响应内容过期的时间,格林威治时间GMT。
- Cache-Control:更细致的控制缓存的内容。
- Last-Modified:响应中资源最后一次修改的时间。
- ETag:响应中资源的校验值,在服务器上某个时段是唯一标识的。
- Date:服务器的时间。
- If-Modified-Since:客户端存取的该资源最后一次修改的时间,同Last-Modified。
- If-None-Match:客户端存取的该资源的检验值,同ETag。
服务器收到请求时,会在响应中回送该资源的Last-Modified和ETag头,客户端将该资源保存在Cache中,并记录这两个属性。当客户端需要发送相同的请求时,会在请求中携带If-Modified-Since和If-None-Match两个头。两个头的值分别是响应中Last-Modified和ETag头的值。服务器通过这两个头判断本地资源未发生变化,客户端不需要重新下载,返回304响应。
缓存的目的是为了在很多情况下减少发送请求,同时在许多情况下可以不需要发送完整响应。前者减少了网络回路的数量,HTTP 利用一个“过期(expiration)”机制来实现此目的。后者减少了网络应用的带宽,HTTP用“验证(validation)”机制来实现此目的。
我们在生活中经常使用如迅雷等下载工具,在下载中经常需要用到断点续传和多线程下载的功能。
断点续传的原理就是使用了GET方法的属性。
HTTP协议的get方法,支持只请求某个资源的某一部分:
- 206 Partial Content:部分内容响应。
- Range:请求的资源范围。
- Content-Range:响应的资源范围。
在连接断开重连时,客户端只请求该资源未下载的部分,而不是重新请求整个资源,来实现断点续传。
分块请求资源实例如下:
- Range: bytes=407802-:请求这个资源从407802个字节到末尾的部分。
- Content-Range: bytes 407802-713545/713546:响应中指示携带的是该资源的 407802~713545的字节,该资源共713546个字节。
客户端通过并发的请求相同资源的不同片段,来实现对某个资源的并发分块下载,从而达到快速下载的目的。
多线程下载的原理如下:
- 下载工具开启多个发出HTTP请求的线程。
- 每个HTTP请求只请求资源文件的一部分:Content-Range: bytes407802-713545/713546。
- 合并每个线程下载的文件。
Cache-Control指定请求和响应遵循的缓存机制。在请求消息或响应消息中设置Cache-Control并不会修改另一个消息处理过程中的缓存处理过程。请求时的缓存指令包括no-cache、no-store、max-age、max-stale、min-fresh、only-if-cached,响应消息中的指令包括public、private、no-cache、no-store、no-transform、must-revalidate、proxy-revalidate、max-age。
通常缓存分为强缓存和协商缓存。
- 强缓存
强缓存是由headers中的Expires和Cache-Control决定的,后者优先级高于前者。
Expires是HTTP1.0提出的,表示失效时间(GMT格式),只有在这个时间之前的请求才可以用强缓存。
第一次向服务器请求一个资源后,浏览器不仅会把资源保存起来,也会保存Response Headers,包括其中的 Expires。第二次发请求时先去缓存中寻找这个资源,取到 Expires,与当前的请求时间做对比,如果在Expires之前,则从缓存中取,否则重新向服务器请求,Expires在重新加载时被更新。
Cache-Control可以有多个值:
- Cache-Control: max-age=111000 ——> 表示自第一次收到响应后的111000ms以后可以用缓存。
- Cache-Control: no-cache ——> 禁止使用强缓存。
- Cache-Control: no-store ——> 禁止使用缓存,每次都要去服务器重新请求。
- Cache-Control: private ——> 只允许被终端用户的浏览器端缓存。
- Cache-Control: public ——> 可以被所有用户缓存,保存终端用户和CDN等代理服务器。
由于Expires是个绝对时间,各个客户端之间有时差就会导致缓存不一致的问题,所以HTTP 1.1 提出了 Cache-Control,其是个相对时间,在第二次发请求时取到缓存中的 max-age 和第一次的请求时间,计算出资源过期时间,与当前的请求时间对比,决定是否使用缓存。
- 协商缓存
有两组headers值:Last-Modified / If-Modified-Since 和 Etag/If-None-Match,后者优先级高于前者。
① Last-Modified / If-Modified-Since
第一次请求时返回的Response Headers中用Last-Modified表示请求的资源在服务器上最新的修改时间,第二次请求时在Request Headers中用If-Modified-Since带上这个值发到服务器,服务器对比这个值和这个资源实际上的最新修改时间决定是否直接返回403,还是返回资源。当返回403时,表示资源没有更新,所以浏览器缓存中的Last-Modified也就不用更新了。
但是 Last-Modified 的问题在于有时服务器上资源其实有变化,但是最后修改时间却没有变化,所以有了Etag/If-None-Match来管理协商缓存。
② Etag/If-None-Match
Etag是服务器根据被请求资源生成的一个唯一标识字符串,只要资源发生变化,Etag就会变,与资源的最新修改时间没有关系,能弥补 Last-Modified 的不足。与 Last-Modified 类似,第二次请求时请求头会带上If-None-Match标识的Etag值,区别是由于服务器每次会根据资源重新生成一个Etag,再拿它跟浏览器传过来的Etag对比,如果一致,则返回403,由于每次Etag都会重新生成,所以浏览器缓存中的Etag也必须每次都更新。
一般Last-Modified和Etag是同时启用的,但是对于分布式系统多台机器间文件的Last-Modified必须一致,以免因为负载均衡到不同机器导致比对不一致,分布式系统尽量关闭Etag,因为每台机器生成的Etag也不一致。
另外使用 F5 键刷新会跳过强缓存,当强制刷新时,强缓存和协商缓存都会跳过。其他操作行为,如前进后退、地址栏回车,都会按正常流程走。
浏览器默认都会缓存图片、js、css等静态文件,也可以在响应头中设置是否要启用缓存,或是通过服务器专门的配置文件统一设置Expires、Cache-control等。
2)Cookie的运用
将Cookie的含义与HTTP协议相结合,看一下Cookie在HTTP协议中是如何运用的。
HTTP协议中与Cookie相关的属性如下:
- Cookie:客户端将服务器设置的Cookie返回到服务器。
- Set-Cookie:服务器向客户端设置Cookie。
- Cookie2 (RFC2965)):客户端指示服务器支持Cookie的版本。
- Set-Cookie2 (RFC2965):服务器向客户端设置Cookie。
服务器在响应消息中用Set-Cookie头将Cookie的内容回送给客户端,客户端在新的请求中将相同的内容携带在Cookie头中发送给服务器,从而实现会话的保持。
3)Session的运用
将Session的含义与HTTP协议相结合,看一下Session在HTTP协议中是如何运用的。
当程序需要为某个客户端的请求创建一个 Session 的时候,服务器首先检查这个客户端的请求里是否已包含了一个Session标识,称为Session ID。如果已包含一个Session ID,则说明以前已经为此客户端创建过Session,服务器就按照Session ID把这个Session检索出来使用(如果检索不到,可能会新建一个),如果客户端请求不包含Session ID,则为此客户端创建一个Session,并且生成一个与此Session相关联的Session ID。Session ID的值应该是一个既不会重复,又不容易被找到规律以仿造的字符串,这个Session ID将被在本次响应中返回给客户端保存。
Session有两种实现方式:
- 使用Cookie来实现
服务器给每个Session分配一个唯一的JSESSIONID,并通过Cookie发送给客户端。
当客户端发起新的请求的时候,将在Cookie头中携带这个JSESSIONID。这样服务器能够找到这个客户端对应的Session。
- 使用URL回写来实现
URL回写是指服务器在发送给浏览器页面的所有链接中都携带JSESSIONID的参数,这样客户端点击任何一个链接都会把JSESSIONID带回服务器。
如果直接在浏览器输入服务端资源的url来请求该资源,那么Session是匹配不到的。
Tomcat对Session的实现,是一开始同时使用Cookie和URL回写机制,如果发现客户端支持Cookie,就继续使用Cookie,停止使用URL回写。如果发现Cookie被禁用,就一直使用URL回写。jsp开发处理到Session的时候,对页面中的链接会使用response.encodeURL()。
4)Token的运用
将令牌的含义与HTTP协议相结合,看一下Token在HTTP协议中是如何运用的。
Token的值一般会放在Cookie中进行传输,如图所示。
运用 Token,服务器就不用再保存 Session ID,只负责生成 Token,然后验证 Token。Token的传递通常被放在Cookie中,如果客户端不支持Cookie,Token也可以放置在请求头中。和Cookie一样,为了数据安全性,Token中不应该放如密码等敏感信息。
3、HTTPS协议
HTTP协议通常承载于TCP协议之上,有时也承载于TLS或SSL协议层之上,这个时候,其就成了我们常说的HTTPS。HTTPS(Hypertext Transfer Protocol over Secure Socket Layer),是以安全为目标的HTTP通道,简单讲是HTTP的安全版。
还是通过一个故事给大家讲解一下HTTPS协议吧。
由于最近的高强度工作,测试部成员小王和小徐对此有诸多不满。他们认为是测试总监没有合理安排测试资源导致的疯狂加班。中午休息时间,两个人私下通过QQ聊天,抱怨总监的工作失误。一来一去说了十几句话后,小王突然一拍脑袋大叫一声,啊呀我们都是在用明文聊天啊,这样信息会不会被总监截取看到了(总监呵呵一笑,其实我早就看到了,居然这么明目张胆地说我坏话,你们等着瞧),那岂不是尴尬了?顿时小王和小徐感觉整个人都不好了,前途要满是荆棘了。于是小王提议,我们给数据信息加密吧,每次传输都用加密算法加密,发给你后你再解密,这样就不怕被人截取了。
1. 加密算法
1)对称加密
小徐一下子就领会了小王的意思。加密和解密算法是公开的,但密钥是保密的,只有两人才知道。这样生成的加密消息(密文)别人就无法得知了。小王要求小徐生成一个密钥,然后把密钥发给他。小徐刚要将密钥发送出去时,一想不对啊,这密钥通过网络发送给你,不是还是会被窃取吗?
听了小徐的顾虑,小王陷入了沉思,密钥需要两个人都知道,但又不能通过网络传输,那怎么办呢?
采用单钥密码系统的加密方法,同一个密钥可以同时用作信息的加密和解密,这种加密方法就称为对称加密,也称为单密钥加密。对称加密的密钥只有一个,加密解密为同一个密码,且加解密速度快,典型的对称加密算法有DES、AES等。
2)非对称加密
小王思考了两天后,兴奋地告诉小徐,他找到了解决方法。这个解决方法不用双方必须协商一个保密的密钥,而是有一对钥匙,一个是保密的,称为私钥,另外一个是公开的,称为公钥。用私钥加密的数据,只有对应的公钥才能解密,用公钥加密的数据,只有对应的私钥才能解密。当小王给小徐发消息的时候,就可以先用小徐的公钥去加密(反正小徐的公钥是公开的,地球人都知道),等到消息被小徐收到后,他就可以用自己的私钥去解密(只有小徐才能解开,私钥是保密的)。反过来也是如此,当小徐想给小王发消息的时候,就用小王的公钥加密,小王收到后,就用自己的私钥解密。
这就是非对称加密,密钥成对出现(根据公钥无法推知私钥,根据私钥也无法推知公钥),加密解密使用不同密钥(公钥加密需要私钥解密,私钥加密需要公钥解密),相对对称加密速度较慢,典型的非对称加密算法有RSA、DSA等。
由于非对称加密速度较慢,小王每次都要用很长时间等待小徐的消息,终于忍无可忍希望能改善加密速度进行快速传输。又过了两天,小王对加密速度进行了优化,他结合了对称加密与非对称加密的优缺点。首先生成一个对称加密算法的密钥,用非对称加密的方式安全发给小徐。随后就不用非对称加密了,只用这个密钥,利用对称加密算法来通信。于是两人又开始愉快地聊天,内容可以海阔天空,百无禁忌。
2. 数字签名
过了一段时间,小王和小徐总感觉测试总监看自己的眼神怪怪的。小王心虚地想,不会是我们的信息还是被看到了吧?总监呵呵一笑道,你们的加密策略早就被我发现并且破解了。小王再次陷入沉思,问题到底出在哪里呢?晚上睡觉前小王突然灵光一闪发现了问题的根源所在。假如小王给小徐发公钥的时候,有个中间人,截取了小徐的公钥,然后把自己的公钥发给了小王,冒充小徐,小王发的消息就用中间人的公钥加了密,那中间人不就可以解密看到消息了?想到这个问题后小王一身冷汗,是啊,这个中间人解密以后,还可以用小徐的公钥加密,发给小徐,小徐和小王根本都意识不到,以为在安全传输呢。要解决这个问题就必须确定那个公钥是只属于小徐的,而不是他人可以杜撰的。两天后小王又拿出了新的方案。
小王把他的公钥和个人信息用一个Hash算法生成一个消息摘要,这个Hash算法有个极好的特性,只要输入数据有一点点变化,那生成的消息摘要就会有巨变,这样就可以防止别人修改原始内容。随后让有公信力的认证中心(简称 CA)用它的私钥对消息摘要加密,形成签名:把原始信息和数据签名合并,形成一个全新的东西,叫作“数字证书”。当小徐把他的证书发给小王的时候,小王就用同样的Hash 算法,再次生成消息摘要,然后用 CA的公钥对数字签名解密,得到CA创建的消息摘要,两者进行对比就知道有没有被篡改了。
如此一来,信息安全终于万无一失了,小王如释重负(总监欣慰地笑了,原来我的员工还是很聪明的,以后要给他们多派点活),继续与小徐愉快地聊天了。
3. 传输过程
对数据的加密解密问题处理好了,就剩下数据传输的原理了。如果小王是客户端,小徐是服务器,传输的原理如图所示。
(1)客户端发起HTTPS请求
用户在浏览器里输入一个HTTPS网址连接到服务器端口。
(2)服务器端初步响应
采用 HTTPS 协议的服务器必须有一套数字证书,这套证书其实就是一对公钥和私钥。将证书发回给客户端,证书包含证书的颁发机构、过期时间等。
(3)客户端解析证书
客户端首先会验证证书是否有效,比如颁发证书的机构是否合法,证书中包含的网站地址是否与正在访问的地址一致、过期时间等,如果发现异常,则提示证书存在问题。如果证书没有问题,那么客户端就生成一个随机值,然后用证书对该随机值进行加密。
(4)客户端发送加密信息
客户端发送的是用证书加密后的公钥。
(5)服务器解密信息
服务器端用私钥解密后,得到了客户端传过来的公钥,然后把内容通过该值进行对称加密。
(6)服务器发送加密后的信息
服务器发送用公钥进行对称加密后的信息。
(7)客户端解密信息
客户端用之前生成的私钥解密服务器端传过来的信息,客户端就获取了解密后的内容。
4、WebSocket协议
1. WebSocket简介
HTTP会存在以下三个关键缺点:
- HTTP协议是符合请求响应模型基本特征的。即它的生命周期是一个request一个response。在HTTP1.0中,当有了一个request和一个response后这次HTTP请求就结束了。如再请求就需要再建立连接。这个问题在HTTP1.1中进行了改进,连接定义为keep-alive在一个HTTP连接中,可以发送多个request,接收多个response。但一个request也只能有一个response,不存在一个request对应多个response的情况,反之亦然。
- HTTP协议的服务器端不能主动给客户端发送请求,一次连接的建立只能由客户端发起。所以HTTP协议对于服务器端来说是被动发起请求的。
- HTTP 协议的无状态性,请求与请求间是没有关联的。通俗地说,服务器是个健忘鬼,你一挂电话,它就把你的东西全忘光了,把你的东西全丢掉了。你第二次还得将之前的信息再告诉服务器一遍,它才能识别你的身份。
随着推送通知等功能对请求传输需求的日益更新,HTTP 因为这些缺点已经无法满足开发需求。或许你会说,可以用HTTP long poll或者Ajax轮询实现实时信息传递。但这又会产生另外两个问题,要说明这些我们必须先掌握HTTP long poll及Ajax轮询的原理是什么。
HTTP long poll的实现原理如下:
long poll是指客户端发起连接后,如果没消息,就一直不返回response给客户端。直到有消息才返回,返回之后,客户端再次建立连接,周而复始。
传输场景再现:
客户端:哈喽,服务亲,有没有新信息,没有的话就等有了再返回给我吧(request)
服务端:没有消息不理你,等到有新消息的时候再给你(response)
客户端:哈喽,服务亲,有没有新信息,没有的话就等有了再返回给我吧(request)
服务端:没有消息不理你,等到有新消息的时候再给你(response)
客户端:哈喽,服务亲,有没有新信息,没有的话就等有了再返回给我吧(request)-loop
可以看出long poll其实是很消耗服务器资源的,需要长时间的响应和处理并发的能力。
ajax轮询的实现原理如下:
ajax轮询就是让浏览器隔个几秒就发送一次请求,询问服务器是否有新信息。
传输场景再现:
客户端:哈喽,服务亲,有没有新信息(request)
服务端:没有(response)
客户端:哈喽,服务亲,有没有新信息(request)
服务端:没有(response)
客户端:哈喽,服务亲,有没有新信息(request)
服务端:你怎么那么烦都说了没有,没有啊(response)
客户端:哈喽,服务亲,有没有新消息(request)
服务端:好啦好啦,有啦给你。(response)
客户端:哈喽,服务亲,有没有新消息(request)
服务端:又来了吐血中,没有*10086(response)-loop
从以上我们可以看出,ajax轮询需要服务器有很快的处理速度和资源。而long poll需要有很高的并发,也就是说同时接待客户的能力。那么问题来了,怎么解决这种过度消耗资源的问题呢?人类的智慧是伟大的,随着HTML5的推出一种崭新的协议出现在世人面前。它就是WebSocket协议。
在HTML5规范中,定义了WebSocket API。WebSocket API是下一代客户端-服务器的异步通信方法。该通信取代了单个的TCP套接字,使用ws或wss协议,可用于任意的客户端和服务器程序。这个新的 API 提供了一个方法,从客户端使用简单的语法有效地推动消息到服务器。WebSocket API最伟大之处在于服务器和客户端可以在给定的时间范围内的任意时刻,相互推送信息。
WebSocket并不限于以Ajax(或XHR)方式通信,因为Ajax技术需要客户端发起请求,而WebSocket服务器和客户端可以彼此相互推送信息;XHR受到域的限制,而WebSocket允许跨域通信。
2. WebSocket的属性
既然WebSocket如此好,那客户端如何通知服务器端它需要使用WebSocket协议呢?
首先WebSocket是基于HTTP协议的,它借用HTTP的协议来完成一部分握手工作。
我们来看一个典型的WebSocket握手:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-websocket-Key: k3JJerggdh9GBkiolw==
Sec-websocket-Protocol: chat, superchat
Sec- websocket -Version: 17
Origin: http://example.com
学习过HTTP属性的同学可能发现了,这段类似HTTP协议的握手请求中,多了几个东西。
Upgrade: websocket
Connection: Upgrade
这个就是WebSocket的核心,它告诉Apache、Nginx等服务器:注意啦,我发起的是WebSocket协议,快点帮我找到对应的助理处理,不是那个老土的HTTP。
Sec-websocket-Key: k3JJerggdh9GBkiolw ==
Sec-websocket-Protocol: chat, superchat
Sec-websocket-Version: 17
Sec-websocket-Key
是一个 Base64 encode 的值,这个是浏览器随机生成的。它就像要告诉服务器:我要验证你是不是真的是WebSocket助理。
- Sec-websocket-Protocol
是一个用户定义的字符串,用来区分同一个URL下不同的服务所需要的协议。
- Sec-websocket-Version
是告诉服务器所使用的WebSocket Draft(协议版本)。
然后服务器会返回以下响应,表示已经接收到请求,成功建立WebSocket。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-websocket-Accept: k3JJerggdh9GBkiolw =
Sec-websocket-Protocol: chat
这里开始就是HTTP最后负责的区域了,告诉客户端,我已经成功切换协议啦。
- Upgrade: WebSocket Connection
Upgrade 依然是固定的,告诉客户端即将升级的是 WebSocket 协议,而不是 mozillasocket,lurnarsocket或者shitsocket。
- Sec-websocket-Accept
是经过服务器确认,并且加密后的 Sec-WebSocket-Key。类似于服务器说:好啦好啦,知道啦,给你看我的ID CARD来证明行了吧。
- Sec-WebSocket-Protocol
表示最终使用的协议。
至此,HTTP已经完成它所有的工作了,接下来就是完全按照WebSocket协议进行了。
3. WebSocket的原理
WebSocket解决了HTTP的缺点。当服务器完成协议升级后(HTTP->websocket),服务器端就可以主动推送信息给客户端啦。
WebSocket实现原理场景如下:
客户端:哈喽,服务亲,我要建立WebSocket协议,需要的服务:chat,WebSocket协议版本:17(HTTP Request)
服务端:ok,确认,已升级为WebSocket协议(HTTP Protocols Switched)
客户端:亲,麻烦你有信息的时候推送给我噢
服务端:ok,有的时候会告诉你的
服务端:消息1
服务端:消息2
服务端:消息3
服务端:消息n
只需要经过一次HTTP请求,就可以做到源源不断的信息传送。(在程序设计中,这种设计叫作回调,即你有信息了再来通知我,而不是我傻乎乎地每次跑来问你。)
这样的协议也同时解决了langloop以及Ajax轮询的同步有延迟非常消耗资源的问题。我们所用的程序是要经过两层代理的,即HTTP协议在Nginx等服务器的解析下,然后再传送给相应的Handler来处理。简单地说,我们有一个非常快速的接线员(Nginx),它负责把问题转交给相应的客服(Handler)。
接线员基本上速度是足够的,但是每次都卡在客服(Handler),老有客服处理速度太慢,导致客服不够。WebSocket 就解决了这样一个难题,建立后,可以直接跟接线员建立持久连接。有信息的时候客服想办法通知接线员,然后接线员再统一转交给客户。这样就可以解决客服处理速度过慢的问题了。
同时,在传统的方式上,要不断地建立、关闭HTTP协议。由于HTTP是无状态性的,每次都要重新传输identity info告诉服务器端你是谁。虽然接线员很快速,但是每次都要听这么一堆,效率也会有所下降,同时还得不断把这些信息转交给客服,不但浪费客服的处理时间,而且还会在网络传输中消耗过多的流量/时间。但是WebSocket只需要一次HTTP握手,所以说整个通信过程是建立在一次连接/状态中,也就避免了HTTP的无状态性。服务端会一直知道你的信息,直到你关闭请求,这样就解决了接线员要反复解析HTTP协议,还要查看identity info的信息的问题。
同时由客户主动询问,转换为服务器(推送)有信息的时候就发送(当然客户端还是等主动发送信息过来的),没有信息的时候就交给接线员(Nginx),不需要占用本身速度就慢的客服(Handler)。
六、Python网络请求
1、Python HTTP请求
Python有很多模块都可以发送HTTP请求,包括原生的模块http.client,urllib2等,第三方模块requests等,都封装了发送HTTP请求的方法。由于原生的模块过于复杂,不推荐使用,之后所有的请求都是基于第三方模块 requests 进行的,该模块的好处在于简单,把请求的框架都搭建好了,只需要填入相应的参数数据,就能发送网络请求了。
1. 使用requests
使用requests类库发起请求非常简单、方便。其易用性非常强,基本封装了urllib库的所有功能。它有以下两种安装方法。
requests模块非原生模块,所以要使用还需要先进行安装,按照之前介绍的安装模块的方法,打开命令行(在运行中输入cmd),然后在命令行输入:
pip install requests
easy_install方式:
easy_install requests
接着就会自动安装 requests 模块以及其相关模块,然后就可以引入该模块使用其提供的方法了。
import requests
HTTP 就是发送请求和获取响应的一个过程,而 requests 模块只需要一步就能完成这样的一个过程,并且requests支持所有的HTTP请求的方法和响应数据,先进行语法介绍。
r = requests.方法(url,headers,data,…)
其中URL参数是必填的,毕竟HTTP请求就是对指定的URL进行发送,其他各种参数可根据实际请求的需要选择性使用。
发送请求后会获取响应结果,然后把结果赋值给变量,最后通过变量的属性值取出需要的结果,下面是常用返回结果。
r.headers 获取返回的头信息
r.text 获取返回的主体
r.cookies 获取返回的cookie
r.status_code获取返回的状态码
学会了 requests 的请求方法和获取响应的结果,就可以开始网络请求的测试了,下面就通过代码实例来熟悉requests的模块吧。
使用requests发起一个HTTP请求的实现代码如下:
#-*-coding:utf-8-*-
import requests # 引入requests库
# 使用GET方式请求百度首页
response = requests.get("https://www.baidu.com/")
print(response) # 打印响应对象
打印结果为:
<Response [200]>
200为HTTP请求结果的状态码,表示成功。
之后应对返回的响应对象中的文本内容进行解析,打印相关属性,代码如下:
# 查看响应内容,response.text为Unicode格式的数据
print(response.text)
# 查看响应内容,response.content为字节流数据
print(response.content)
# 查看完整的URL地址
print(response.url)
# 查看响应头部字符编码
print(response.encoding)
# 查看响应码
print(response.status_code)
输出结果为:
'<html>....jianyi.baidu.com/ </html>
整个请求过程全被封装在方法之中,对于没有编码基础的测试人员来说再适合不过了。
请求参数详解
1)URL参数
URL是唯一的必填参数,既然是网络请求,必须要有URL地址才能发送,就像快递一定要写目的地才能发件一样。先从最简单的 HTTP 请求讲起,请求一个静态页面,比如访问网易首页,通过抓包可以看到该请求是用get方式发送的,所以要调用requests的get()方法来发送请求。
以网易首页作为请求例子,实例代码:
1 import requests
2 test_url = "http://www.163.com"
3 response = requests.get(test_url)
4 print (response.status_code)
5 print (response.headers)
6 print (response.text)
代码说明:
- 导入requests模块。
- 将网易首页的URL赋值到变量test_url中(这样的好处在于看起来清晰,也方便代码今后的维护,只需要修改test_url对应的URL就行了)。
- 在get()方法中将变量test_url直接传入,即完成了带着URL的get请求(结果会赋值到变量response中,response中包含了返回结果的所有数据,可以根据需要获取想要的数据)。
- 获取并打印返回结果的状态码。
- 获取并打印返回结果的头信息。
- 获取并打印返回结果的内容。
2)headers参数
headers是最常用的参数之一,前面那个例子只是最简单的请求,没有带其他参数信息,而很多时候需要带入headers发送请求,才可以获取相应的请求结果。
还是以网易首页作为例子,如果是pc端的请求,会返回pc端的页面,如果是手机端的请求,则返回手机端的页面,这时候就要用带上 headers 参数的请求,通过浏览器的抓包工具可以看到headers有个字段“User-Agent”,而服务器就是根据这个字段来判断访问的来源,如果需要模拟手机端请求,需要将“User-Agent”改为请求的手机型号。
实例代码:
1 import requests
2 test_url = "http://www.163.com"
3 h = {"User-Agent":"Android/H60-L01/4.4.2/"}
4 response = requests.get(test_url,headers = h)
5 print (response.status_code)
6 print (response.headers)
7 print (response.text)
代码说明:
3 将需要发送的headers赋值到变量h之中。
4 在 get()方法中加一个 headers 的参数,然后将变量 h 赋值给 headers 参数,即完成了带着header信息的get请求。
运行后会发现请求的网页是手机网易网,而非pc端的网易网。
3)cookies参数
cookies也是最常用的参数之一,因为只要涉及登录后数据获取,都需要用到cookies参数,那么如何获取cookie呢?
一种是通过post发送登录请求,获取返回值的cookies属性(这个会在后面的实例中讲解),还有一种就是通过浏览器的网络抓包的方式获取,在还不会熟练使用 requests模块的前提下,就先介绍如何通过抓包获取cookies吧。
首先打开chrome浏览器,然后登录网易邮箱,接着按住F12键打开开发者工具,抓取cookie的界面。
选择application中的cookies,点击对应的测试的URL域名,再找到Session属性并且Domain是对应测试请求的域名,然后这条信息name和value对应的就是cookie了。
cookies参数以字典的形式发送,只需要对应地将name和value传入即可。
以网易邮箱登录后的请求为例,实例代码:
1 import requests
2 c = {"JSESSIONID":"6272E7C3762FA26619780AF615A75A0B"}
3 test_url = "http://mail.163.com/js6/main.jsp?sid=xCsXkCUUCJYwVBCjouUUtnRzyRTfyHOs&df=163nav_icon#module=welcome.WelcomeModule%7C%7B%7D"
4 response=requests.get(test_url,cookies=c)
5 print (response.status_code)
6 print (response.headers)
7 print (response.text)
代码说明:
2 将浏览器的抓取的cookies信息以字典的形式赋值到变量c之中。
4 在get方法中加一个cookies的参数,然后将变量c赋值给cookies参数,即完成了带着cookie信息的get请求。
其实cookie也是可以通过headers参数传递的,只是不同之处在于cookie是以字典的形式发送的,而在headers之中cookie只是其中一个键,所以需要把cookie放到该键对应的值里面,而对应的值是以key=value的形式传入的,改一下代码。
实例代码:
1 import requests
2 c = "JSESSIONID=6272E7C3762FA26619780AF615A75A0B"
3 test_url ="https://mail.163.com/js6/main.jsp?sid=xCsXkCUUCJYwVBCjouUUtn RzyRTfyHOs&df=163nav_icon#module=welcome.WelcomeModule%7C%7B%7D"
4 response = requests.get(test_url ,headers={"cookie":c})
5 print (response.status_code)
6 print (response.headers)
7 print (response.text)
代码说明:
2 将浏览器的抓取的cookies信息以字符串的形式赋值到变量c之中。
4 在get方法中将变量c赋值给headers参数中的cookie字段,即完成了带着cookie信息的get请求。
可以看到运行结果与上面代码一致,只是使用的参数不一样,然后对应数据类型不一样而已。这2种方法其实都可以用,但还是推荐使用cookies参数。一方面把cookies单独分离出来,不用与其他headers的字段放在一起,让代码更清晰;另外一方面通过post请求返回的cookies是可以直接赋值到cookies参数之中的,不需要再做转换。
4)params参数
对于params参数可以存放请求的表单,并会以key1=value1&key2=values的形式跟在URL之后发送,为了区分URL和参数,最好不要把表单放在URL之中,可以通过params参数进行发送,上面网易邮箱的URL也是带着参数的,直接放到URL之中,如果使用params参数就可以把后面的参数和URL分离。
还以网易邮箱为例,只是URL部分把参数进行分离,实例代码:
1 import requests
2 c = "JSESSIONID=6272E7C3762FA26619780AF615A75A0B"
3 test_url = "http://mail.163.com/js6/main.jsp"
4 p = {"sid":"xCsXkCUUCJYwVBCjouUUtnRzyRTfyHOs","df":"163nav_icon#module=welcome.WelcomeModule%7C%7B%7D"}
5 response = requests.get(test_url ,headers = {"cookie":c},params = p )
6 print (response.status_code)
7 print (response.headers)
8 print (response.text)
代码说明:
4 将需要发送表单以字典的形式赋值到变量p之中。
5 在get方法中加一个params的参数,然后将变量p赋值给params参数,即完成了带着params信息的get请求。
运行结果还是和上面一致的,params的使用还是比较简单的,如果不使用这个参数的话,也可以直接把表单加到url之中,只是代码不清晰,也不容易维护,所以不推荐这种方法。
5)data参数
data参数也是用于存放请求的表单,是request模块中最重要的参数之一。
在使用data之前,先来了解一下post提交数据类型,区别于params,后者只有一种类型,就是字符串,而post可以提交4种类型的数据,至于需要提交什么类型取决于服务器接收的数据类型,post的数据类型需要和服务器接收的一致,不然服务器就无法正确识别post的数据,导致测试结果报错。就像协议一样,接收方约定接收哪种类型的表单,然后发送方按照接收方指定的协议发送表单,这样就完成了一个表单的提交。
在 request headers 中有一个 content-tpye 的字段,这个字段表示了 post发送数据的类型,一般分为以下4种类型。
- Content-Type: application/json
实际上,现在越来越多的人把application/json作为请求头,用来告诉服务器端消息主体是序列化后的JSON字符串。由于JSON规范的流行,除了低版本IE之外的各大浏览器都原生支持JSON.stringify,服务端器语言也都有处理JSON的函数,并且JSON格式支持比键值对更加复杂的结构化数据。
- Content-Type: application/x-www-form-urlencoded
这是最常见的POST提交数据的方式,浏览器的原生form表单,如果不设置enctype属性,那么最终就会以 application/x-www-form-urlencoded 方式提交数据。提交的数据按照 key1=value1&key2=value2的方式进行编码,key和value都进行了URL转码,然后打包发送到服务器。
- Content-Type: multipart/form-data
Content-Type 为multipart/form-data方式,主要用于上传文件。需要注意的是同时form的enctype属性也要设置为multipart/form-data,才能正确提交并解析所传输的数据。
- Content-Type: text/xml
它是一种使用HTTP作为传输协议,XML作为编码方式的远程调用规范。考虑到XML结构还是过于臃肿,一般场景用JSON会更灵活方便,所以这种提交方式在我们的工作中实际使用的不多,仅了解一下就可以了。
最常见的是application/x-www-form-urlencoded这种类型,直接将数据以{key:value}的字典形式赋值给表单,然后通过request.post()中的data参数传递就可以了。
以网易音乐为例,搜索一首歌曲,通过抓包content-Type是application/x-www-formurlencoded
搜索一首nothing to lose,通过抓包会发现有进行加密,那就直接把加密后的内容放到form之中发送出去。
实例代码:
1 import requests
2 test_url = "http://music.163.com/weapi/search/suggest/web?csrf_token="
3 form = {"params":"BDqibj+HQR5hWEEPGxP6oD2T6wbbVWqQSMxemz7MHmMF2472SBpOqK/rQlpPry w2o4IZVSrK96yAA480wMbx7vX1eHi+Z+6iKzzTdaNsR1r4N9PGhAzYCBnJLmSu73Ih","encSecKey":"b39b6bef59f01a4072ec0314b45f0f1bfd263949cd64a10f0e9f104856c102 127deb2df53d8abe83ff5a23588771db03b2fea96833b8ee6c079216cbbc238835ccafa1e9bea39b912 697ca0bb4cd9044843545cb37c46f004114437ca1ec6b5c6dce4ba236cfaca2ed290ef6ca0f5319cc7e 388cc8c49b77a8c4c753e4c1102"}
4 response = requests.post(test_url,data = form)
5 print (response.status_code)
6 print (response.headers)
7 print (response.text)
代码说明:
3 将需要发送的表单(抓包获取的参数)以字典的形式赋值到变量form之中。
4 在post方法中加一个data参数,然后将变量form赋值给data参数,即完成了带着表单信息的post请求。
2. 使用urllib
urllib是Python内置的HTTP请求类库,可直接使用,它包含以下4个模块。
- request:最基本的HTTP请求模块,用来模拟发送请求。
- error:异常处理模块,如果出现错误可以捕获异常。
- parse:工具模块,对URL进行拆分解析。
- robotparser:主要用来识别网站的robots.txt文件,然后判断哪些网站会被爬虫处理。
使用urllib发起一个HTTP请求的实现代码如下:
import urllib.request
# 获取一个HTTP响应对象
response=urllib.request.urlopen('https://www.baidu.com)
相比requests包来说,urllib的使用更复杂,传入的参数也更多,所以在日常工作中建议选择使用requests包来发起HTTP请求。
3. 利用Python处理响应对象
获取响应对象后需要处理,先判断状态码,然后对text属性设置编码,最后获取所需的文本。实现代码如下:
if response.status_code == 200:
print(response,text, '\n{}\n'.format('*'*79), response.encoding)
response.encoding = 'GBK' #
# 存储结果或者对比结果
else:
print(“fail”)
Python处理code的方式很简单,就是用响应对象的status_code属性和状态码进行比较,对不同的状态码用对应的逻辑处理即可。
例如下面的代码:
import requests
response = requests.get("https://www.baidu.com")
if response.status_code == 200:
print("Request is ok")
elif response.status_code == 404:
print("The page is not found")
elif response.status_code >= 500:
print("Server has something wrong!")
对于以上代码中返回的JSON格式的数据,可以使用response.json()解析成Python对象。熟练掌握response对象的各种属性,有助于快速迭代开发,相关属性的使用可以通过官网文档进行查阅,涉及的状态码判断可以分得更细致,本例只对常见的404、500和200状态码进行判断。如果接口返回的是自定义的错误码,还需要对相应的错误码进行逻辑判断。
4. 利用Python处理业务码
顾名思义,业务码就是用于描述业务状态的编码。例如在微信支付中,Python处理后续业务和普通程序类似,针对一些业务码,取得数据后可以进行逻辑判断,然后进行持久化存储等操作。
常规的处理流程如图所示,其包含如下几个操作:
- 判断业务码:根据不同业务进行分支和逻辑判断。
- 断言判断:如果业务码判断成功的话,则进入该流程,根据断言来判断是否符合预期。
- 错误处理:如果业务码判断失败,则进入错误处理,进行相关错误信息的记录,或抛出错误信息等操作。
- 持久化存储结果:包含错误信息和断言信息,可以选择MySQL作为持久化存储的数据库,它的使用非常方便,类库驱动也非常多。
- 输出结果报表:将结果导出为电子表格或者文本文件。
由于Python本身没有提供switch/case用法,所以自定义一个枚举业务码的字典来模拟switch/case的效果。
实现代码如下:
#-*-coding:utf-8-*-
import time
import requests
def deal(str code):
switcher = {
"40001": "param is invalid",
"40002": "param lost verify part",
"40003": "not permission param",
}
msg = switcher.get(code, "ok")
if msg != ok:
with open('error.txt','w') as f: #设置文件对象
# 格式化成2016-03-20 11:45:39的形式
date_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
f.write(date_str + msg) #将错误信息写入文件中
else:
# 业务码正确,则说明逻辑正确,进行正常的处理流程
print("Success!")
response = requests.get("https://localhost:8082/getStock")
json_data = response.text.json()
deal(json_data.code)
2、Python HTTPS请求
随着对安全性的要求越来越高,HTTP渐渐被HTTPS所取代,而requests的库也在不断地完善,最新的requests模块中自带了一个certifi包,requests会试图使用它里边的证书。这样用户就可以在不修改代码的情况下更新它们的可信任证书。
不需要修改请求的代码,只需要将URL替换成HTTPS,就可以发送HTTPS的请求了。
实例代码:
1 import requests
2 headers = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; WOW64)AppleWebKit/537.36 (KHTML, like Gecko)Chrome/55.0.2883.87 Safari/537.36")
3 test_url = "https://www.zhihu.com/"
4 r=requests.get(test_url,headers=headers)
5 print (r.status_code)
6 print (r.headers)
7 print (r.text)
当然也会遇到一些特有的证书不在certifi包中,这时候就需要先通过浏览器找到需要的证书名称,然后通过浏览器设置中的证书选项导出对应的证书和密钥,加上一个cert参数指定证书和密钥的路径就可以了。
requests.post (url, cert=("/path/client.cert", "/path/client.key"))
3、Python Get请求
GET和POST请求平时经常使用,它们是HTTP中的两种请求方法,而HTTP是基于TCP/IP的应用层协议实现的。
无论GET还是POST,用的都是同一个传输层协议,所以在传输上二者没有区别。它们的不同之处在于传输数据的方式,在约定中,GET方法的参数应该放在URL中,而POST方法的参数应该放在Body中。HTTP没有Body和URL的长度限制,对URL进行限制的大多是浏览器和服务器本身。
1. HTTP请求方式
根据HTTP标准,HTTP请求可以使用多种请求方法:
- HTTP 1.0中定义了3种请求方法:GET、POST和HEAD。
- HTTP 1.1中新增了6种请求方法:OPTIONS、PUT、PATCH、DELETE、TRACE和CONNECT。
根据复杂程度,请求的类型可以分为下面两种:
- 简单请求,HTTP 1.0中的3种请求方法(HEAD、GET和POST)默认都属于简单请求。
- 没有自定义的报头,类型为MIME Type in text/plain、multipart/form-data、application/x-www-form-urlencoded。
GET请求方法主要用于获取资源,POST请求方法常用于表单提交,对资源进行增加或修改。在RESTful的最佳设计中,建议使用POST来增加资源,使用PUT来修改资源。
2. 利用Python发起GET请求
GET方式的简单请求已经在前面介绍过,这里将介绍它的高级用法。
1)带参数的请求
可以用字典形式传递参数。当网页采用gzip压缩的时候,读取text属性可能会出现乱码,所以建议使用content属性。
示例代码如下:
#-*-coding:utf-8-*-
import requests
'''
最终拼接效果为:
https://www.baidu.com/s?wd=Python
'''
param = {"wd":"Python"}
get_url = 'https://www.baidu.com'
response = requests.get(get_url, params=param)
print(response.content)
2)携带Session参数
有时候需要保持登录状态或用户状态,可以在发起请求的时候传递cookie参数,该会话对象在同一个Session实例发出的所有请求之间保存cookie信息。
具体代码如下:
#-*-coding:utf-8-*-
import requests
s = requests.Session()
r = s.get('http://httpbin.org/cookies', cookies={'from-my': 'browser'})
print(r.text)
# '{"cookies": {"from-my": "browser"}}'
r = s.get('http://httpbin.org/cookies')
print(r.text)
# '{"cookies": {}}'
除此之外,如果要手动添加cookie信息,可以使用Cookie Utility来操作。
具体代码如下:
with requests.Session() as s:
s.get('http://httpbin.org/cookies/set/sessioncookie/123456789')
3)请求Prepared Request
如果在发送请求之前还需要一些个性化设置,可以传入header参数,例如:
from requests import Request, Session
s = Session()
url = 'https://www.cnblog.com'
data = {"s":"Golang"}
header = {'Accept-Encoding': 'identity, deflate, compress, gzip',
'Accept': '*/*', 'User-Agent': 'python-requests/0.13.1'}
req = Request('GET', url,
data=data,
headers=header
)
prepare_obj = req.prepare()
resp = s.send(prepare_obj,
stream=stream,
verify=verify,
cert=cert,
timeout=timeout
)
print(resp.status_code)
4)SSL支持
SSL是HTTPS的证书服务,requests库也可以在发起请求的时候携带SSL证书,默认SSL验证是开启的。
下面这段代码就是指定使用的证书地址:
requests.get('https://github.com', verify='/path/to/certfile')
5)设置代理
代理的设置很简单,具体代码如下:
import requests
proxies = {
"http": "http://10.10.1.10:3128",
"https": "http://10.10.1.10:1080",}
requests.get("http://example.org", proxies=proxies)
4、Python Post请求
POST请求是HTTP请求的一种,也是建立在TCP/IP基础上的应用规范。POST提交的数据必须放在消息主体(entity-body)中,编码没有严格限制,但是一般使用的是application/x-www-form-urlencoded、multipart/form-data或raw。
在Python中也可以自己用字典构造参数,使用post()函数即可。语法如下:
requests.post(url, post_data)
第一个参数为请求的URL,第二个参数为字典类型的提交数据。
1)常规用法
直接使用post()函数,代码如下:
#-*-coding:utf-8-*-
import requests,json
url = 'http://httpbin.org/post'
data = {'key1':'value1','key2':'value2'}
r =requests.post(url,data)
print(r)
print(r.text)
print(r.content)
2)JSON形式
如果用JSON形式发送请求,代码可以改为:
#-*-coding:utf-8-*-
import requests,json
url_json = 'http://httpbin.org/post'
#dumps:可以将Python对象解码为JSON数据
data_json = json.dumps({'stock_no':'600585','price':'52.12'})
res = requests.post(url_json,data_json)
print(res)
print(res.text)
print(res.content)
运行结果如下:
python post_json.py
<Response [200]>
{
"args": {},
"data": "{\"stock_no\": \"600585\", \"price\": \"52.12\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Content-Length": "40",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.22.0"
},
"json": {
"price": "52.12",
"stock_no": "600585"
},
"origin": "171.221.254.72, 171.221.254.72",
"url": "https://httpbin.org/post"
}
b'{\n "args": {}, \n "data": "{\\"stock_no\\": \\"600585\\", \\"price\\":
\\"52.12\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n
"Accept": "*/*", \n "Accept-Encoding": "gzip, deflate", \n "Content-
Length": "40", \n "Host": "httpbin.org", \n "User-Agent": "python-
requests/2.22.0"\n }, \n "json": {\n "price": "52.12", \n "stock_
no": "600585"\n }, \n "origin": "171.221.254.72, 171.221.254.72", \n
"url": "https://httpbin.org/post"\n}\n'
3)文件流形式
以multipart形式发送POST请求,具体代码如下:
#-*-coding:utf-8-*-
import requests,json
url = 'http://httpbin.org/post'
files = {'file':open('./report.txt','rb')} # 设置要被打开的文件
res = requests.post(url_mul,files=files) # 发送POST请求
print(res)
print(res.text)
print(res.content)
还可以通过POST请求来传输多个分块编码的文件,只需要把文件放到一个元组的列表中即可,其中元组结构为(form_field_name, file_info)。具体代码如下:代码2.7 2.3/2.3.3/post_multi_files.py
#-*-coding:utf-8-*-
import requests,json
url = 'http://httpbin.org/post'
multiple_files = [('images', ('test1.png', 'test1.png', 'rb'), 'impage/
png'), ('images', ('test1.png', 'test2.png', 'rb'), 'impage/png')]
response = requests.post(url, files=multiple_files)
print(response.text)
学会了GET和POST请求后,可以进一步编程实现自动化登录。
在日常测试工作中经常需要登录后台进行一些操作,每次都手动输入账号和密码进行登录十分麻烦,特别是有多个项目的时候。使用Python可以实现自动登录,从而减少重复性操作。
实现的思路如下:
- 使用浏览器模拟库获取页面元素。
- 填写账号和密码。
- 提交数据,完成登录。
操作浏览器的模拟库很多,这里介绍一款简单且高度封装的轻量级库splinter。它是一个由Python开发的开源Web应用测试工具,可以轻松实现自动浏览站点并进行交互,它封装了对浏览器的操作,形成了一个上层应用API,让相关编程变得更加简单,从而专注于业务实现本身。
其安装方式如下:
pip install splinter
例如,想登录163邮箱进行后续操作,实现代码如下:
#coding=utf-8
import time
from splinter import Browser
def login_mail(url):
browser = Browser()
#登录163邮箱
browser.visit(url)
# 设置账号和密码
browser.find_by_id('username').fill('你的用户名称')
browser.find_by_id('password').fill('你的密码')
#模拟单击登录按钮
browser.find_by_id('loginBtn').click()
time.sleep(3)
#close the window of brower
browser.quit()
if __name__ == '__main__':
mail_addr ='http://reg.163.com/'
login_mail(mail_addr)
5、Python WebSocket请求
之前介绍的requests模块是基于HTTP协议的,所以如果要发送WebSocket请求就需要安装第三方的WebSocket模块。
通过pip安装就可以了:
pip install websocket
pip install websocket-client
然后同理导入该模块就可以使用WebSocket模块的功能发送请求了。
import websocket
WebSocket语法也是相当简单的,先发送一个WebSocket的请求连接,通道连接成功之后可以发送消息,也可接受消息。
1. WebSocket语法
语法格式如下:
变量 = websocket.create_connection(url)
建立连接,只不过此时的URL不是以HTTP开头的,而是以ws开头的。
变量.send(发送内容)
发送带内容的消息,可以是字符串,也可以是列表字典,具体需要看服务器端接收什么数据。
变量.recv()
接收服务器发送的消息,这个区别于HTTP的response的地方在于:接收消息是没有超时机制的。也就是说一旦建立连接通道之后,就会一直处于等待待命状态,直到接收到数据才会关闭recv()方法,但如果要一直接收数据,则需要通过循环执行recv()方法,当收到一条消息时,关闭recv()结束一次循环。
所以WebSocket相对HTTP还是简单很多,建立连接,然后发送消息和接收消息。
2. 请求实例
通过一个实例来介绍WebSocket吧,这是一个聊天室系统,就是通过WebSocket实现的发送消息和接收消息,而需要测试的就是发送消息和接受消息是否成功。
实例代码:
1 import websocket
2 url = "ws://www.xxxx.com/xxxx"
3 ws = websocket.create_connection(url)
4 ws.send("{"request":1111,"service":1001,"name":"xxxx"}")
5 new_msg = ws.recv()
6 print (new_msg)
7 ws.send("{"request":"1111,"service":1003,"name":"x","message":"1111111"}")
8 new_msg1= ws.recv()
9 print (new_msg1)
代码说明:
3 使用WebSocket的create_connection方法向聊天室的URL发起连接,并赋值给变量ws。
4 连接完成之后通过send()方法发送请求的内容,即登录所需要的信息。
5 将服务器返回的内容赋值给new_msg,即登录成功/失败的返回信息,一般正常情况都是成功的。
7 再次通过send()方法,发送聊天室内容“a”到服务器。
8 将服务器返回的内容赋值给 new_msg1,即发送消息成功/失败的返回信息,一般也是会成功的。
这样就完成了一次登录聊天室,并发送聊天内容的WebSocket的实例,当然这个还可以测试并发。
6、Python Mock模拟数据
在日常工作中,测试人员需要对测试的功能进行数据模拟,以便让测试过程更加直观。Python技术栈提供了进行数据模拟的Mock库。
1. 模拟测试简介
在测试过程中,数据的流转往往和系统的复杂程度有关,功能越复杂的系统,数据流转的过程越烦琐。有时候会经过数十个子系统,其上下游业务相互依赖,使得测试过程十分漫长、复杂。例如,在电商网站进行购物,需要经过选择商品,加入购物车,支付订单,领取积分,退款等环节,环环相扣,缺一不可,支付订单的前提必须是已经生成订单,生成订单的前提又必须是已选择好商品。
针对这样的场景,“跑通”全流程进行冒烟测试虽然可行,但是在某次小型迭代中测试人员需要针对某一中间环节进行测试,此时使用模拟数据的方式来代替上游流程将会更加高效。
1)模拟测试的定义和使用场景
模拟测试通过模拟数据和调用函数来实现测试目的。开发者可以使用Python的第三方Mock库进行一些模拟测试活动。Mock库是Python中一个用于支持单元测试的库,主要功能是使用Mock对象替代指定的Python对象,以达到模拟对象的行为。
模拟测试主要用于如下场景:
- 相互依赖的函数调用过程,例如A依赖于B,B又调用C。
- 相互依赖的上下游服务之间的测试,通过模拟数据来源,达到精准测试特定环境或功能的作用。
- 在测试环境不够稳定或还处于开发阶段时,可以通过模拟接口返回相关结果,达到加快测试进度的作用。服务可以模拟,接口可以模拟,一些特定环境也可以模拟。
2)安装Mock库
Mock库的安装方法根据Python版本的不同而不同,主要分为下面两种情况:
(1)如果使用的是Python 2.x,可以使用如下命令在终端(Linux系统或Mac OS系统)或者cmd(Windows系统下)下进行安装:
pip install -U mock
在代码中的引用方式如下:
from mock import mock
# other codes
(2)如果使用的是Python 3.x,则无须单独安装。从Python 3.3之后,Mock库已经被集成到unittest模块中了,可以直接引入使用。
引入方式十分简单,代码如下:
from unittest import mock
3)Mock对象简介
Mock对象是Mock库最基础且核心的概念,它可以被用来代替开发者想要替换的任何对象,可以是一个类,一个函数,或者一个类的实例。
Mock对象在代码中的定义如下:
class Mock(CallableMixin, NonCallableMock):
# 中间代码省略
pass
# 第一个参数的类定义
class CallableMixin(Base):
def __init__(self, spec=None, side_effect=None, return_value=DEFAULT,
wraps=None, name=None, spec_set=None, parent=None,
_spec_state=None, _new_name='', _new_parent=None, **kwargs):
# 第二个参数的类定义
class NonCallableMock(Base):
def __init__(
self, spec=None, wraps=None, name=None, spec_set=None,
parent=None, _spec_state=None, _new_name='', _new_parent=None,
_spec_as_instance=False, _eat_self=None, unsafe=False, **kwargs
):
简化定义为:
class Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None,
name=None, spec_set=None, **kwargs)
下面从以上众多参数中挑选出几个重要的参数具体解释:
- spec:可以在初始化的时候设置的Mock对象属性,可以是字符串、列表或者对象,该值也可以不传递。
- wraps:如果设置了该参数,那么会把调用结果传递给Mock对象,对模拟属性的访问将返回包装后的对象属性。如果试图访问一个不存在的属性,将引发一个属性错误。
- spec_set:该参数算是严格版的spec,只能传递set类型的参数。
- return_value:设置预期的返回值给Mock对象,后续在测试逻辑判断中可以使用该参数。
Mock类的定义设计非常精巧,它可以对多种类型的参数进行转换,以达到动态处理的效果,相关源码值得大家去学习和借鉴。
使用Mock对象非常简单,下面举出一个计算两个正数之和的测试用例。
具体代码如下:
import unittest
from unittest import mock
# 计算两个正数之和
class SimpleCaculator(object):
def sum(num1: int, num2: int) -> int:
return num1 + num2
# 测试用例类
class SumTest(unittest.TestCase):
def test(self):
s = SimpleCaculator()
num1 = 10
num2 = 30
sum_result = mock.Mock(return_value=40)
s.sum = sum_result # 替换sum()函数为Mock对象
self.assertEqual(s.sum(), 40)
通过分析上面的代码,总结出模拟一个方法的步骤如下:
- 找到要替换的函数。
- 实例化Mock对象,并设置它的属性或者行为。在这个例子中设置的返回值是40。
- 替换想要替换的函数或者对象。这里替换sum()函数本身。
- 使用单元测试unittest进行断言判断。
4)简单用例
经常遇到需要对某个页面进行可用性测试,利用访问某些页面的机会,看一下它是否返回200状态码。
i# -*- coding: utf-8 -*-
import requests
# 发出GET请求
def send_request(url):
r = requests.get(url)
return r.status_code
# 访问百度
def visit_baidu():
return send_request("https://www.baidu.com")
上面这个client类的测试类也很简单,针对这个访问请求函数进行测试即可。
完整代码如下:
# -*- coding: utf-8 -*-
"""
用于测试client类的测试类
@author freePHP
@version v1.1.0
"""
import unittest
from unittest import mock
from . import client
class TestOne(unittest.TestCase):
def test_success_request(self):
success_send = mock.Mock(return_value='200')
client.send_request = success_send
self.assertEqual(client.visit_baidu(), '200')
def test_fail_request(self):
forbidden_send = mock.Mock(return_value='403')
client.send_request = forbidden_send
self.assertEqual(client.visit_baidu(), '403')
if __name__ == '__main__':
unittest.main()
2. Mock库高级用法
1)Patch简介
Patch是Mock库提供的一种函数装饰器,用法非常灵活。Patch可以创建模拟并将其传递给装饰函数。对于复杂情况的模拟测试,也可以使用Patch方式进行模拟,开发者利用Patch装饰器的方式给模拟对象打补丁,该用法更加灵活,但要特别注意作用域。
Patch装饰器函数的定义如下:
unittest.mock.patch(target,new = DEFAULT,spec = None,create = False,
spec_set = None,autospec = None,new_callable = None,** kwargs )
需要注意以下几个参数:
- target参数必须是一个字符串类型,格式为package.module.ClassName。要特别注意格式,如果你的函数或类写在包名称为a1下的b1.py脚本中,这个b1.py的脚本中定义了一个c1的函数(或类),那么传递给unittest.mock.patch()函数的参数就写为a1.b1.c1。
- new参数可以选择初始化的Mock类型,默认是MagicMock。
- spec=True或spec_set=True,用于将Mock对象设置为spec / spec_set对象。
- new_callable参数用于将Mock对象设置为可调用对象,默认情况下也使用MagicMock类型。
2)Patch的简单用例
如果测试人员面对的一个数据来源于网络请求服务,该服务还在开发阶段,不能直接进行测试,但是返回结果的结构用例是清楚的,那么可以针对这个返回的函数进行Patch处理。具体代码如下,共包含3个文件,均位于同一目录下。
代码1:
# -*- coding: utf-8 -*-
def load_cheers():
# 这个函数会被Mock的Patch对象替换,并不会真正调用,这个函数在实际工程中可能是
调用复杂的栈业务块或者复杂的网络请求服务
# 这里只是为了演示,所以看起来只是个简单返回,实际工程中会复杂得多
return "Come on,Chengdu!"
代码2:
# -*- coding: utf-8 -*-
from tool import load_cheers
def create_cheers():
result = load_cheers()
return result
代码3:
import unittest
from unittest import mock
from get_cheer_data import create_cheers
class GetCheerDataTest(unittest.TestCase):
@mock.patch('get_cheer_data.load_cheers')
def test_get_cheer_data(self, mock_load):
# Patch模拟了load_cheers()函数对象,并设置了返回值,这样就不用真正调用该
函数了
mock_load.return_value = "Ha Ha,CDC"
self.assertEqual(create_cheers(), "Ha Ha,CDC")
if __name__ == '__main__':
unittest.main()
在IDE中执行测试用例,输出结果如下:
Ran 1 test in 0.002s
OK
Process finished with exit code 0
对上面例子的总结如下:
Patch主要是为了修饰替换多重嵌套调用的类方法或者函数,可以指定为定义域内的任意函数方法,解决在后续依赖的调用函数发生变化的时候,如果只是用Mock简单模拟替换上层调用函数或者类,将不能通过单元测试和相关测试的问题。
例如,在本例中的load_cheers()函数的返回值发生了变化,如果我们是对它的上一层调用函数create_cheers()进行模拟的话,则执行单元测试无法通过。这就是直接使用Mock和Patch的不同之处,Patch可以模拟定位到更深层次的调用函数或者类。
3)利用Patch测试购物车类
大部分人可能都有网购的经历,其中购物车功能是整个购物环节中的关键。针对电商网站的测试也非常重要,因为涉及金融支付,一旦出错将会带来严重的损失和不良影响。
电商购物车的下单流程如图所示:
可以看出,首先是添加商品形成订单,当订单支付后会在相关库存表中减少库存并形成支付凭证记录。为简化核心流程和功能,编写购物车类,代码如下:
# -*- coding: utf-8 -*-
'''
购物车
@author freePHP
@version 1.0.0
包含 CURD(增、删、改、查)商品方法,下单、支付订单和退款等功能。
'''
class ShoppingCart(object):
__products = {}
__pay_state = ''
# 根据商品名查看是否存在于购物车中
def has_product(self, product_name: str) -> bool:
if product_name in self.__products:
return True
else:
return False
# 在购物车中增加商品,包括商品名和数量
def addProduct(self, product_name: str, num: int) -> str:
if self.has_product(product_name):
self.__products[product_name] += num
return "add successfully"
else:
self.__products[product_name] = num
return "add successfully, and init it"
# 修改商品,主要修改数量
def editProduct(self, product_name: str, num: int) -> str:
if self.has_product(product_name):
self.__products[product_name] += num
return "update successfully"
else:
return "not have this kind of product!"
# 删除商品
def deleteProduct(self, product_name: str) -> str:
if self.has_product(product_name):
self.__products.pop(product_name)
return "delete successfully"
else:
return "The product dosen't exist, so it can not be deleted."
# 创建订单
def createOrder(self) -> str:
self.__pay_state = "waitingForPay"
return self.__pay_state
# other codes
# 支付订单,改变订单状态为已支付
def payOrder(self) -> str:
self.__pay_state = "payed"
return self.__pay_state
# other codes
# 退款
def refund(self) -> str:
self.__pay_state = "refund"
return self.__pay_state
# other codes
例如,想测试从下单到退款的整个逻辑处理和完整流程,则需要根据每一个接口编写对应的单元测试接口用例,具体代码如下:
import unittest
from unittest import mock
from shopping_cart import ShoppingCart
'''
购物车测试类
@author freePHP
@version 1.0.0
'''
class ShoppingCartTest(unittest.TestCase):
@mock.patch('shopping_cart.ShoppingCart.addProduct')
def test_add_product(self, mock_opt):
mock_opt.return_value = "add successfully"
self.assertEqual(ShoppingCart.addProduct("earring"), "add successfully")
@mock.patch('shopping_cart.ShoppingCart.editProduct')
def test_edit_product(self, mock_opt):
# 先添加,再修改数量
shopping_cart = ShoppingCart.addProduct('plush_bear', 2)
mock_opt.return_value = "edit successfully"
self.assertEqual(ShoppingCart.editProduct('plush_bear', 3))
@mock.patch('shopping_cart.ShoppingCart.deleteProduct')
def test_delete_product(self, mock_opt):
mock_opt.return_value = "delete successfully"
self.assertEqual(ShoppingCart.deleteProduct("funny"), "delete successfully")
@mock.patch('shopping_cart.ShoppingCart.payOrder')
def test_pay_order(self, mock_opt):
mock_opt.return_value = "payed"
self.assertEqual(ShoppingCart.payOrder(), "payed")
@mock.patch('shopping_cart.ShoppingCart.refund')
def test_refund(self, mock_opt):
mock_opt.return_value = "refund"
self.assertEqual(ShoppingCart.refund(), "refund")
if __name__ == '__main__':
unittest.main()
执行该文件后输出结果如下:
Ran 4 tests in 0.016s
OK
Process finished with exit code 0
编写这类单元测试用例的关键在于找到需要Mock替换的接口,然后试图通过测试来达到简化流程的作用。
3. 测试留言板功能
留言板是网站中最常见的功能模块,大多数网站都有留言板,用于用户反馈问题。留言板的功能比较简单,容易进行测试,具体步骤如下:
- 输入留言。
- 数据验证,如字数限制、违禁词限制等。
- 提交数据到后台。
- 在后台显示留言并处理。
1)测试新增功能
首先对新增一条留言信息的功能进行测试,可以用Mock准备好测试数据,以此来测试。为了方便我们更完整地了解整个功能模块,使用Flask框架搭建了一个最简化的BBS留言系统,只包含表单页面和新增留言功能。
Flask是Python的一个Web框架,其最大的特征是简洁,让开发者可以自由、灵活地兼容要开发的功能特性。Flask非常适合新手学习,用Flask搭建各种自用系统或者用例非常便捷,也可以让测试人员更加了解Web开发,从开发角度补充自己的知识和测试技能树。
搭建的BBS留言系统的代码如下:
from flask import Flask, jsonify
from flask import request
app = Flask(__name__)
board = '''
<html>
<head>
<title>message board</title>
</head>
<body>
<form action="/add" method="post">
Message: <input type="text" name="message"><br>
<input type="submit" value="Submit">
</form>
</body>
</html>
'''
# 留言板页面
@app.route('/', methods=['GET', 'POST'])
def bbs_index():
return board
# 新增留言接口
@app.route('/add', methods=['POST'])
def add_comment():
forbidden_dict = ['sex', 'shit', 'party']
if request.method == 'POST':
message = request.form['message']
# 数据验证判断
if len(message) < 20:
return jsonify({'data': [], 'result': False, 'errorMsg': 'The
message is too short,min is 20 character'})
elif len(message) > 140:
return jsonify({'data': [], 'result': False, 'errorMsg': 'The
message is too long,max is 140 character'})
elif message in forbidden_dict:
return jsonify({'data': [], 'result': False,
'errorMsg': 'The message includes forbidden words,
which might be livid or policitic'})
with open('message.txt', 'a') as f:
f.write(message + '\n')
json_data = [
{'id': 1, 'value': message}
];
return jsonify({'data': json_data, 'result': True, 'errorMsg': ''})
else:
return "Invalid request!!!"
if __name__ == '__main__':
app.run(debug=True) # 正式环境可以不设置debug模式
启动该Web服务可以在终端(或者Windows系统的cmd窗口)中执行下面的命令:
python index.py
如果正常启动,则会有如下输出:
* Serving Flask app "index" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 895-183-209
在浏览器中访问http://127.0.0.1:5000/
针对这个页面和功能进行分析,可以编写如下代码进行自动化提交。
虽然说“条条大路通罗马”,这里直接模拟提交数据,针对提交接口直接进行测试,并使用Mock库进行结果模拟测试。
# -*- coding: utf-8 -*-
import unittest, json
from unittest import mock
import requests
'''
用于测试tinyBBS系统的测试类
@author freePHP
@version 1.0.0
'''
class TestBBS(unittest.TestCase):
# 用于测试新增的留言接口
def test_add(self):
url = 'http://127.0.0.1:5000/add'
data = {"message": "井底点灯深烛伊,共郎长行莫围棋。玲珑骰子安红豆,入骨相
思知不知。"}
# Mock数据返回结果
mock_return_data = {"data": [], "errorMsg": "", "result": True}
mock_data = mock.Mock(return_value=mock_return_data)
print(mock_data)
res = requests.post(url, data=data)
print(res.text)
try:
return_data = json.loads(res.text)
self.assertEqual(return_data['result'], True)
except:
print("json loads error")
打印结果如下:
Process finished with exit code 0
<Mock id='4515068688'>
Ran 1 test in 0.025s
OK
{
"data": [
{
"id": 1,
"value": "\u4e95\u5e95\u70b9\u706f\u6df1\u70db\u4f0a\uff0c\u5171\u90ce\
u957f\u884c\u83ab\u56f4\u68cb\u3002\u73b2\u73d1\u9ab0\u5b50\u5b89\u7ea2\
u8c46\uff0c\u5165\u9aa8\u76f8\u601d\u77e5\u4e0d\u77e5\u3002"
}
],
"errorMsg": "",
"result": true
}
工欲善其事,必先利其器。使用智能IDE,如PyCharm,可以很方便地调试单元测试,断点调试也是测试研发的利器。
2)对测试失败的情况进行处理
针对测试失败的情况进行逻辑处理,可能触发的是数据有效性验证。
具体代码如下:
# 新增留言功能测试失败的单元测试用例
def test_add_fail_case(self):
url = 'http://127.0.0.1:5000/add'
data = {"message": "onetwo"} # 设置长度小于20个字符的message
mock_return_data = {"data": [], "errorMsg": "The message is too
short,min is 20 character", "result": False} # 模拟数据返回结果
mock_data = mock.Mock(return_value=mock_return_data)
print(mock_data)
res = requests.post(url, data=data)
print(res.text)
return_data = json.loads(res.text)
self.assertEqual(return_data['result'], False)
self.assertEqual(return_data['errorMsg'], "The message is too short,
min is 20 character")
打印结果如下:
<Mock id='4321451024'>
{
"data": [],
"errorMsg": "The message is too short,min is 20 character",
"result": false
}
Ran 1 test in 0.018s
OK
Process finished with exit code 0
如果将断言改为:
self.assertEqual(return_data['errorMsg'], "The message is too short,
min is 20 character 2333")
则会报错,表示没有通过测试,输出结果如下:
The message is too short,min is 20 character 2333 != The message is too
short,min is 20 character
Expected :The message is too short,min is 20 character
Actual :The message is too short,min is 20 character 2333
<Click to see difference>
Traceback (most recent call last):
..... (堆栈报错信息省略)
类似的错误判断如违禁词和超过140个字符限制的测试和上述测试用例类似,只需要改变测试数据的内容即可,这里不再赘述。
七、接口测试
1、接口测试简介
要说接口测试是什么,我们就必须先了解接口是什么。用通俗的话来说,比如你到书城买书,书城对你来说就相当于一个接口,你只需要按照书城定下的购书规则为自己买喜欢的书,而无需在乎书城自己是如何引进书的,所以就相当于你通过书城这个接口实现了购买书的需求。而在计算机领域接口就是指两个不同事物之间交互的地方,大到可以是两个完整的不同系统,小到可以是两段程序。
接口测试是指对系统组件间接口进行的一种测试行为。接口测试主要用于检测外部系统与系统之间以及内部各个子系统之间,又或者是系统内的模块与模块间的接口是否正常工作。测试的重点是检查数据的交换、传递和控制管理过程以及系统间的相互逻辑依赖关系是否符合需求标准。
2、接口测试工具
这里要重点推荐的是一款叫作postman的接口测试工具。
接着通过几个实例来介绍,postman的使用界面如图所示。
- 这个是选择HTTP的方法,包括get、post等常用方法,还有其他不常用的方法都可以选择使用(前提是你要知道这个接口的方法是什么,然后选择对应的方法)。
- 这个是接口的地址,也就是请求的URL,一般就是域名+路径文件。
- 这个顾名思义就是设置URL传的参数,该参数会以aa=11&bb=22这样的形式跟在URL后面。
- 这个是发送请求,即把所有接口需要传的数据设置好,点击send即可以模拟URL的请求完成接口的测试。
以上只是做了最简单的功能介绍,接着通过几个实例来讲解。
(1)不带params的get请求
这种就是常用的静态页面的请求,get方法请求静态页面,然后下方body返回请求的结果。这种应用场景一般只用来测试页面是否存在,返回数据是否正常。
(2)带params的get请求
这种一般就是查询的请求,将查询的条件在params中以key:value的形式配置,比如查询内容包含HTTP的信息。然后发送请求,就能返回查询的所有相关HTTP的信息。
(3)带form的post请求
这个需要先了解清楚接口接收的form类型,即可以在request header中的Content-Type中看到,然后在body中选择相应的form类型,最后再输入key:value。
比如饿了么登录时用的JSON格式,只能选择第三种方式,然后选择JSON格式,再以JSON格式输出账号和密码发送请求,即完成了登录的请求(输入错误的密码是为了看得更清楚接口返回的内容)。
3、接口文档
在执行接口测试前,测试人员肯定会先拿到开发给予的接口文档。测试人员可以根据这个文档编写接口测试用例。所以,我们要先了解接口文档的主要构成及含义。
完整的接口文档有公共信息说明、请求响应及加签DEMO、加签代码示例(Python)、接口功能说明、接口参数详细说明5部分组成。接下来我们一一说明。
1. 公共信息说明
公共信息说明页分为公共参数说明及请求受理结果代码两部分。
公共参数说明填写多个接口提取的通用参数,这里可以分为请求参数及响应参数。需要填写参数名称、类型、最大长度、描述和用法。请求受理结果代码就是响应码的说明。公共信息说明页如图所示。
2. 请求、响应及加签DEMO
请求、响应及加签DEMO页,如图所示。
一般此页会描述加签的过程,例如分为rsa加签私钥值和服务参数说明。
服务参数说明如下。
- 对参数名进行从小到大排序。
- 将参数及参数值拼接成字符串。
- 用RSA对参数串进行加签后用base64编码,获得签名串。
- 对各个参数值进行参数值特殊字符的转义。
- 请求体说明。
3. 加签代码示例(Python)
加签代码示例部分会填写加签的代码实例,测试人员可以根据加签代码编写测试代码。
4. 接口功能说明
接口功能说明填写各接口的主要信息,分为接口名称、接口类型、接口服务代码、接口版本号、备注5部分。
接口功能说明如图所示:
5. 接口参数详细说明
接口参数详细说明填写接口的主要信息及参数信息。主要信息分为服务名称、服务代码、服务版本号、服务功能描述、服务提供方系统、服务消费方系统。参数说明又分为中文描述、英文描述、类型、字段长度、是否必填、说明。
接口详细说明如图所示:
4、接口测试实例
1. 接口文档解析
要做好接口测试,首先要学会解析接口文档,一般接口文档会包含接口的地址、使用的方法(get/post/put等)、必填参数、非必填参数、参数长度、返回结果。只有知道了这些信息才能设计测试用例,以完成对接口的测试。
举个抽奖接口的例子,接口文档如图所示。
从接口文档可以得到信息如下:
- 接口的URL地址。
- 接口的方法是post。
- 接口有2个必填的参数,一个是手机号,一个是活动ID。
- 对于手机号参数数据类型是数字,且限定为11个数字。
- 对于活动ID参数数据类型也是数字,且小于1000的数字。
- 接口返回3个参数,用户手机号码、剩余抽奖次数、抽奖结果。
- 返回的用户手机号码就是参与抽奖的用户手机号码。
- 每天只有3 次抽奖机会,抽一次少一次,当没有抽奖次数时,返回 Number 是0,并且抽奖结果不能为True。
- 抽奖结果只能是True或者False。
解析完接口文档后,基本上可以明确测试点和预期结果,以便为之后测试用例做准备。
2. 测试用例
可以把接口测试当作一个单元测试,测试各种输入情况,然后根据输出情况来判断是否符合预期结果。那在编写代码之前就要先准备测试用例,接口只是单一功能,不需要很复杂的测试用例,只要准备正常数据和异常数据,然后对应各种结果即可。
先列一个大致的测试用例:
完成了输入测试数据准备工作,接着就要开始编写代码了。
3. 测试代码编写
对于已经学会 requests 用法的人来说,其实只需要几行代码就能完成对这个接口的请求,然后手动将需要输入的代码赋值到对应的变量中,最后运行代码发送请求,并打印输出结果,再与之前的预期结果做对比,就完成了通过Python测试接口的目的。
实例代码:
1 import requests
2 phone = 13211111111
3 id = 1001
4 form = {"mobilePhone":phone,"activityGuid":id}
5 url = "http://www.xxx.com/management/winningrecord/newluckDraw"
6 response = requests.post(url,data = form)
7 print (response.text)
代码说明:
2 将需要输入的手机号赋值到变量phone之中,这个值是需要手动改的。
3 将需要输入的活动ID赋值到变量ID之中,这个值也是需要手动改的。
4 将需要发送的form以字典的形式赋值到form这个参数之中,由于有2个key,然后对应的2个value通过变量赋值,这样的好处只需要改变量而不需要改form,就能完成不同数据的测试了。
相对使用接口测试工具来说,编写测试代码的优势并不是很大,还是通过手动的方式去输入数据,然后人工地去判断是否符合预期结果,和使用测试工具没有什么区别。
如果仅仅是对于单接口来说,的确是没什么很大的区别,但如果有多个接口相互关联,需要通过一个接口的返回数据传到另外一个接口的参数之中,又或者需要将接口测试做成自动化(之后自动化测试会介绍如何通过读取文件数据作为测试参数自动带入程序执行),这时候编写代码的优势就能体现出来了。
八、Selenium模块
1、Selenium简介
Selenium是一款用于Web应用程序自动化测试的工具。它可以通过不同的方式唤起浏览器中,测试代码可以运行在这个浏览器中,就像真实的用户在操作一样。该工具支持的浏览器包括IE 7/8/9/10/11,以及Mozilla Firefox、Safari、Google Chrome和Opera等。
Selenium的特点如下:
- 开源,免费。
- 多浏览器支持,如Firefox、Chrome、IE、Opera和Edge。
- 多平台支持,如Linux、Windows和Mac OS。
- 多语言支持,如Java、Python、Ruby、C#、JavaScript和C++。
- 对前端页面有良好的支持。
- API使用简单,可以通过编程进行定制化。
- 支持分布式测试用例的执行。
当前最新的版本是Selenium 3,其新加入的特性如下:
- 增加了对Edge和Safari原生驱动的支持。Edge驱动由微软提供,Safari原生驱动由Apple提供。
- 在最新的Firefox中,Selenium开始支持Mozilla的Geckodriver驱动,使用Geckodriver驱动对Firefox进行控制。Geckodriver扩展需要单独下载,并将其加入系统环境变量中。
2、Selenium环境搭建
1. 安装Selenium包
安装Selenium的常见方法有两种,都是使用命令行模式,十分方便、快捷。
1)pip方式安装
pip安装命令如下(默认安装最新版):
pip install selenium
如果遇到提示pip版本过低,需要升级pip,则命令如下:
pip install --upgrade pip
2)easy_install方式安装
easy_install安装方式如下:
easy_install selenium
验证是否安装成功,可以使用交互式界面,命令如下:
python -i
进入交互命令行后,输入以下命令:
>>> from selenium import webdriver
如不报错,则说明安装成功。
2. 安装不同浏览器的驱动包
针对不同的浏览器,需要安装对应版本的驱动包,以此实现Selenium调用浏览器进行模拟测试。
Chrome浏览器的驱动包安装步骤如下:
- 查看当前系统安装的Chrome浏览器版本。
- 下载对应版本的驱动包。
- 将驱动包添加到对应的系统PATH中,方法是在Linux系统中将解压后的文件复制到/usr/local/bin/目录下即可。
Firefox的安装步骤与Chrome类似,根据对应的操作系统和浏览器版本下载对应的驱动包并安装即可。
IE浏览器的驱动种类比较多,一定要注意操作系统的配置。
在IE官网的下载页面选择IEDriverServerxxx.zip包,这个安装包需要区分计算机是32位还是64位的操作系统,根据自己的操作系统下载即可。需要注意的是,如果要打开IE浏览器,则在需要在浏览器的“Internet选项”对话框的“安全”选项卡中分别选中Internet、本地Internet、受信任的站点和受限制的站点这4个选项的“启用保护模式”复选框,并且还需要把驱动的路径加入环境变量中。
安装完毕后可以在交互式命令行中输入如下代码:
>>> d=webdriver.Chrome()
>>> d.get("https://sogou.com")
此时会看到一个新的Chrome浏览器页面自动跳转到搜狗官网首页。通过Selenium的WebDriver驱动库,不用手动便可打开浏览器或输入网址等,这些操作使用程序即可自动实现。
3、在Selenium中选择元素对象
操作页面上的元素是自动化测试的关键。页面上的标签元素如按钮、输入框等,都是在测试中需要操作的元素,只有选择元素对象后才能进一步对它进行操作。
1. 根据id和name选择元素对象
在页面的HTML代码中,id和name属性是比较常见的,id属性在同一个页面中具有唯一性,方便开发者根据此属性精准定位到指定的元素。
<div id="nav">
<ul>
<li>Home</li>
<li>News</li>
<li>Contac Us</li>
</ul>
</div>
<form action="/show.php">
<input type="button" name="Trick" value="run">
</form>
根据id和name定位对应元素的代码如下:
id="nav"
find_element_by_id(id)
name="Trick"
find_element_by_name(name)
除此之外还可以根据tag和class属性定位元素,方法类似。例如:
<div class="dom_test">
<p>Know it more just like a artist.<p>
</div>
也可以用如下代码选择class为dom_test的元素和p标签:
class_name = "dom_test"
find_element_by_class_name(class_name)
tag_name = "p"
find_element_by_tag_name(tag_name)
2. 根据link text选择元素对象
页面中一般会有很多超链接标签,有时需要定位到a标签元素,提取a标签里的链接地址以备后续操作使用。
链接分为link text(链接对象)和partial link text(部分链接对象)两种,可以通过find_element_by_link_text()和find_element_by_partial_link_text()函数来定位元素。
例如以下HTML代码:
<a href="https://jd.com" name="tj_news">京东</a>
<a href="https://cd.jumei.com" name="tj_tieba">聚美</a>
<a href="http://taobao.com" name="tj_zhidao">淘宝是一个神奇的网站哦</a>
如果通过link text方式定位元素,方法如下:
find_element_by_link_text("聚美")
find_element_by_link_text("京东")
find_element_by_link_text("淘宝是一个神奇的网站哦")
对于最后一个链接元素,也可以用partial link text方式获取,方法如下:
find_element_by_partial_link_text("淘宝")
由此可见,find_element_by_partial_link_text()函数可以通过截取部分文字来定位元素,但截取的部分必须与元素唯一匹配。
3. 根据XPath选择元素对象
XPath是一种基于XML文档定位元素的方法。HTML可以看作是XML的一种特例,而Selenium也可以使用XPath方式来选择或定位元素。
XPath的语法比较简单,只是扩展了很多新的特性和实现,如对id和name的定位方式增加了新的写法。例如,想定位第二个按钮,方法如下:
<div class="simple_wrap" name=”simple_wrap_obj”>
<form target="_self" action="http://baidu.com">
<span id="my_container">
<input id="input" type="text" type=”button”name=”firstBtn”>
<input id="input2" type="button" name=”secondBtn”>
<!-- 还有其他代码在此省略-->
针对这种div嵌套结构的页面,可以定位该元素的方法很多。
(1)利用自身的id属性定位的方法如下:
find_element_by_xpath("//input[@id='input2']")
(2)利用上一级目录的id属性元素定位的方法如下:
find_element_by_xpath("//span[@id='my_container']/input[1]")
# 下标1表示第二个input元素,默认从0开始算下标
(3)利用上两级目录的id属性元素定位的方法如下:
find_element_by_xpath("//div[@id='simple_wrap']/form/span/input")
(4)利用上两级目录的name属性定位方法如下:
find_element_by_xpath("//div[@name=simple_wrap_obj/form/span/input[1]")
(5)使用绝对路径定位元素的方法如下:
find_element_by_xpath("/html/body/div[1]/form/span/input[1]")
(6)利用自身name属性定位的方法如下:
find_element_by_xpath("//input[@name='secondBtn']")
#通过自身的name属性定位
通过上面的例子可以看出,XPath的使用非常灵活,并且有自己的使用规则。XPath还可以做一些逻辑运算,但是性能不太好,需要通过XML进行解析和判断。此外,XPath对不同浏览器的兼容性不高,因此不推荐使用。
4. CSS选择器
CSS(Cascading Style Sheets)用于渲染、美化HTML页面,也称为层叠样式表。在定位元素的时候,可以使用CSS作为定位策略。CSS在性能上比XPath更优,但在语法和使用方面对初学者来说不易掌握。
下面列举一些常用的选择策略:
- 标签元素,如div、table、form等。
- class选择器,如.search对应HTML代码的“<div class="search">...</div>”。
- ID选择器,如#helper,对应HTML代码中的“<div id="helper">”。
- 多元素匹配,具体情况如下表所示。
下面举一个具体的例子:
<div class="SeekDiv">
<div id="headerPart1">
header part 1
</div>
<div id="divForm">
<form class="my_form">
<ul>
<li>Red</li>
<li>Blue</li>
<li>Pink</li>
</ul>
Your email:<input name="email" type="text" value="">
<input type="submit" name="my_submit" value="submit">
<input type="hidden" name="superid" value="TWS3003">
</form>
</div>
<form>
</div>
通过CSS语法进行匹配:
- 元素属性和包含
元素属性使用att元素进行定位:
使用元素属性进行定位:
CSS结构性定位。根据相对位置来定位元素,利用CSS 3的特性进行定位,如下表所示。
- CSS结构性定位表
例如下面的代码:
<div class="smallDiv">
<ul id="dataList">
<a href="https://cd.jumei.com">聚美优品</a>
<li>Bag</li>
<li>Neeckle</li>
<li>Ring</li>
<li>Pearl</li>
</ul>
</div>
下面以上面的HTML代码为例,使用CSS定位方法进行元素定位。
CSS定位实例见表:
除此之外还可以使用一些伪类,如input、text、checkout、file、password和submit。
4、使用Selenium完成自动登录
Selenium完全可以操作浏览器进行自动登录,这和用Python程序实现的思路类似,脚本可以结合Selenium工具完成相关的操作。
完成登录的基本思路为:
- 对登录页面的页面结构进行分析。
- 定位账号、密码和登录按钮元素。
- 输入账号和密码,并进行简单的逻辑判断。
- 模拟单击“提交”按钮。
1. 自动登录百度网盘
百度网盘是目前最常用且功能强大的网盘,它的登录页面结构也比较清晰,适合进行自动化测试。
首先编写Selenium操作封装类,将最基础的查找元素、填充文本内容、判断页面元素是否存在等功能进行封装,代码如下:
#-*-coding:utf-8-*-
'''
基于Selenium的操作封装类
@Author freePHP(我的艺名)
@Created at 2019
'''
from selenium.webdriver.support.ui import WebDriverWait
from selenium import webdriver
class Tool():
def __init__(self, driver):
self.driver = driver
#查找指定定位的元素
def find(self, locator) -> obj:
# lambda方式匹配元组
element = WebDriverWait(self.driver, 10, 1).until(lambda x: x.find_element(*locator))
return element
# 填充文本到指定的文本元素
def fill(self, locator, text):
self.find(locator).send_keys(text)
# 检查该元素是否存在
def element_exists(self, locator) -> bool:
ones=self.finds(locator)
# 如果存在则返回True
if len(ones) >= 1:
return True
else:
return False
# 触发单击事件
def click(self, locator):
# 定位元素被单击
self.find(locator).click()
可以继续编写测试用例调用上述代码中封装好的Tool类,使用单元测试驱动开发和保证最小逻辑单元的严格测试(测试驱动开发是一种开发方式,即先编写测试代码,然后根据测试预期来编写业务逻辑代码,与传统的开发方式完全相反。这种测试先行的开发模式也称为TDD)。
在编写代码之前,首先应分析页面结构,整理思路如下:
- 选择账号和密码登录方式,触发单击选项。
- 填写账号和密码,注意要使用time.sleep()函数保持时间间隔,让自动化操作更真实。
- 触发登录按钮。
下面使用Tool工具类编写程序自动登录百度网盘,具体代码如下:
#-*-coding:utf-8-*-
from selenium_tool import Tool
import time
from selenium import webdriver
# 测试自动化登录百度网盘
def testLogin():
# 生成Chrome驱动
driver = webdriver.Chrome()
# 生成工具类
client = Tool(driver)
# 访问网盘官网
client.driver.get("https://pan.baidu.com")
# 声明登录框显示对象
show_login_form = ("id", "TANGRAM__PSP_4__footerULoginBtn")
# 单击显示账号登录方式,显示登录输入框
client.click(show_login_form)
# 休眠0.5s
time.sleep(0.5)
#element = client.find(("name", "wd"))
# 账号
account = ("id", "TANGRAM__PSP_4__userName")
# 密码
password = ("id", "TANGRAM__PSP_4__password")
# 设置账号
client.fill(account, "你的账号名")
# 休眠2.5s,让操作更真实
time.sleep(2.5)
# 填充密码
client.fill(password, "你的密码")
# 休眠3s,让操作更真实
time.sleep(3)
click_obj = ("id", "TANGRAM__PSP_4__submit")
# 单击登录按钮
client.click(click_obj)
#休眠10s
time.sleep(10)
if __name__ == '__main__':
testLogin()
实际运行过程中百度可能会对Selenium自动化程序进行检测,从而触发多种类型的验证码(如文字、数字或者图片翻转类)或者短信验证码。
2. 自动登录QQ空间
QQ空间也是用户经常使用的功能,在爬取QQ空间数据(如日志、图片、评论、用户头像等)的时候往往需要保持登录状态。可以使用Selenium原始的API接口实现自动化登录元素定位和模拟操作(如使用find_element_by_id()函数通过ID定位指定的元素),具体代码如下:
#-*-coding:utf-8-*-
from selenium import webdriver
import time
def auto_login():
driver = webdriver.Chrome()
#设置浏览器窗口的位置和大小
driver.set_window_position(20, 40)
driver.set_window_size(1100,700)
# 访问QQ空间登录页
driver.get("http://qzone.qq.com")
# 切换到登录表单框架
driver.switch_to_frame('login_frame')
# 分别设置登录账号和密码,使用find_element_by_id()函数
driver.find_element_by_id('switcher_plogin').click()
driver.find_element_by_id('u').clear()
driver.find_element_by_id('u').send_keys('401112769')
driver.find_element_by_id('p').clear()
driver.find_element_by_id('p').send_keys('tonytang!2019')
driver.find_element_by_id('login_button').click()
time.sleep(5)
# 关闭窗口
driver.quit()
if __name__ == '__main__':
auto_login()
5、鼠标事件
前面已经提到过鼠标单击可以使用click()函数,而实际上Web产品测试中不仅需要鼠标单击,有时候还需要双击、右击和拖动等操作,这些操作包含在ActionChains类中。
下面介绍ActionChains类中鼠标操作的常用方法。
(1)鼠标双击操作:double_click(on_element)
该操作是指双击页面元素。
例如:
#引入 ActionChains 类
from selenium.webdriver.common.action_chains import ActionChains
#定位到要双击的元素
double =driver.find_element_by_xpath("xxx")
#对定位到的元素执行鼠标双击操作
ActionChains(driver).double_click(double).perform()
对于操作系统来说,鼠标双击的操作相当频繁,但对于Web应用来说双击操作较少,实际使用场景是地图程序(如百度地图),可以通过双击鼠标放大地图。
(2)鼠标右击操作:context_click(right)
假如一个Web应用的列表文件提供了右击弹出快捷菜单的操作,则可以通过context_click()方法模拟鼠标右击的操作,参考代码如下:
#引入 ActionChains 类
from selenium.webdriver.common.action_chains import ActionChains
#定位要右击的元素
right =driver.find_element_by_xpath("xx")
#对定位的元素执行鼠标右击操作
ActionChains(driver).context_click(right).perform()
(3)鼠标拖放操作:drag_and_drop(source, target)
该操作是指在指定元素上按下鼠标左键,然后移动到目标元素上释放鼠标。其中,source为鼠标按下的源元素,target为鼠标释放的目标元素。
鼠标拖放操作的参考代码如下:
#引入 ActionChains 类
from selenium.webdriver.common.action_chains import ActionChains
#定位元素的原位置
element = driver.find_element_by_name("xxx")
#定位元素移动的目标位置
target = driver.find_element_by_name("xxx")
#执行元素的移动操作
ActionChains(driver).drag_and_drop(element, target).perform()
(4)鼠标光标悬停在元素上:move_to_element()
该操作用于模拟鼠标光标悬停在一个元素上,参考代码如下:
#引入 ActionChains 类
from selenium.webdriver.common.action_chains import ActionChains
#定位光标要在其上悬停的元素
above = driver.find_element_by_xpath("xxx")
#对定位的元素执行光标在其上面的悬停操作
ActionChains(driver).move_to_element(above).perform()
(5)单击鼠标左键:click_and_hold()
该操作用于在一个元素上单击,参考代码如下:
#引入 ActionChains 类
from selenium.webdriver.common.action_chains import ActionChains
#定位要单击的元素
left=driver.find_element_by_xpath("xxx")
#对定位的元素执行单击操作
ActionChains(driver).click_and_hold(left).perform()
为了日常使用更加高效、便捷,将上述常用的函数封装成一个工具类,代码如下:
#-*-coding:utf-8-*-
from selenium.webdriver.common.action_chains import ActionChains
from selenium import webdriver
'''
#针对使用ActionChains类实现的鼠标操作,封装成工具类
double_click() 双击
context_click() 右击
drag_and_drop() 拖动鼠标
move_to_element() 光标悬停在一个元素上
click_and_hold() 在一个元素上单击
'''
class MouseOperator:
def __init__(self, driver):
self.driver = driver
# 处理双击事件
def double_click(self, locator):
# 定位要双击的元素
double =self.driver.find_element_by_xpath(locator)
# 对定位的元素执行双击操作
ActionChains(self.driver).double_click(double).perform()
# 处理鼠标右键
def right_click(self, locator):
# 定位要右击的元素
right = self.driver.find_element_by_xpath(locator)
# 对定位的元素执行右击操作
ActionChains(self.driver).context_click(right).perform()
# 处理拖放元素
def drag_drop(self, source_locator, target_locator):
# 定位元素的原位置
element = self.driver.find_element_by_name(source_locator)
# 定位元素移动的目标位置
target = self.driver.find_element_by_name(target_locator)
# 执行元素的移动操作
ActionChains(self.driver).drag_and_drop(element, target).perform()
def hover_one_element(self, locator):
# 定位光标悬停的元素
above = self.driver.find_element_by_xpath(locator)
# 对定位的元素执行光标悬停操作
ActionChains(self.driver).move_to_element(above).perform()
def left_click_hover(self, locator):
# 定位单击的元素
left = self.driver.find_element_by_xpath(locator)
# 对定位的元素执行单击操作
ActionChains(self.driver).click_and_hold(left).perform()
上述代码是将WebDriver作为一种依赖注入操作类中,使用find_element_by_xpath()函数来定位元素,从而触发对应的鼠标操作。
6、键盘事件
除了鼠标操作以外,测试工作中还需要一些键盘操作。例如,在测试中使用Tab键将焦点转移到下一个页面元素上。
WebDriver的Keys类可以提供所有键盘按键的操作,这些知识点需要重点掌握。
常用的键盘操作如下:
- send_keys(Keys.BACK_SPACE):退格键(BackSpace);
- send_keys(Keys.SPACE):空格键(Space);
- send_keys(Keys.TAB):制表键(Tab);
- send_keys(Keys.ESCAPE):退出键(Esc);
- send_keys(Keys.ENTER):回车键(Enter);
- send_keys(Keys.CONTROL,'c'):复制(Ctrl+C);
- send_keys(Keys.CONTROL,'a'):全选(Ctrl+A);
- send_keys(Keys.CONTROL,'x'):剪切(Ctrl+X);
- send_keys(Keys.CONTROL,'v'):粘贴(Ctrl+V)。
在使用这一系列函数之前需要先导入Keys类包,导入语句如下:
from selenium.webdriver.common.keys import Keys
和鼠标事件一样,为了日常工作中方便使用,将键盘的常规操作封装成工具类,具体实现代码如下:
#-*-coding:utf-8-*-
from selenium import webdriver
# 引入Keys类包
from slenium.webdriver.common.keys import Keys
import time
'''
针对使用Keys类包实现键盘操作,封装成工具类。
包含输入、删除内容,输入带空格的内容,输入带Tab键的内容,剪切输入框中的内容,在输入框
中重新输入内容,回车键的使用
'''
class KeyBoardOperator:
# 将driver注入
def __init__(self, driver):
self.driver = driver
# 输入一段文字
def input_words(self, locator, text):
self.driver.find_element_by_xpath(locator).send_keys(text)
time.sleep(3)
# 删除一个字符
def del_some_word_with_backspace(self, locator):
self.driver.find_element_by_xpath(locator).send_keys(Keys.BACK_SPACE)
time.sleep(2.5)
# 全选
def select_all(self, locator):
self.driver.find_element_by_xpath(locator).send_keys(Keys.CONTROL, 'a')
time.sleep(3)
# 剪切内容
def cut_content(self, locator):
self.driver.find_element_by_id(locator).send_keys(Keys.CONTROL, 'x')
time.sleep(2)
# 粘贴内容
def paste_content(self, locator):
self.driver.find_element_by_id(locator).send_keys(Keys.CONTROL,'v')
time.sleep(2)
# 回车代替单击操作
def enter_click(self, locator):
self.driver.find_element_by_id(locator).send_keys(Keys.ENTER)
time.sleep(2.5)
7、对一组对象定位
前面介绍的是使用find_element系列函数来定位某个特定页面对象,在实际工作中往往需要定位多个对象,如一组对象(同一个父元素集合里的子类对象)。
主要应用场景如下:
- 批量操作对象,如所有单元框。
- 从一组对象里筛选出需要的对象。例如,定位所有的文本框,然后选择其中倒数第二个文本框。
例如有如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My text inputs</title>
<script type="text/javascript" async="
" src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js">
</script>
<!-- 最新版本的 Bootstrap 核心 CSS 文件 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/
dist/css/bootstrap.min.css"
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
crossorigin="anonymous">
</head>
<body>
<h2>Text Input</h2>
<form action="" class="navbar-form">
<div class="form-group">
<label class="control-label" for="search1">OneCondtionForSearch</label>
<input type="text" class="form-control" placeholder="Search" id="search1">
</div>
<div class="form-group">
<label class="control-label" for="search2">SecondCondtionForSearch</label>
<input type="text" class="form-control" placeholder="Other Search" id=
"search2">
</div>
<div class="form-group">
<label class="control-label" for="search3">ThirdCondtionForSearch</label>
<input type="text" class="form-control" placeholder="Extra Search" id=
"search3">
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
</body>
</html>
为练习定位一组对象,在同级目录下编写Python脚本对该页面中的文本框进行选择,并分别在对应的文本框中输入auto、test和Python。WebDriver的get方法可以读取3.7/textinput.html文件的页面,因为从本质上来说磁盘路径上的文件也是一段URI,具体实现代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
# 引入系统os模块,方便操作文件
import os
import time
driver = webdriver.Chrome()
file_path = 'file://' + os.path.abspath('textinput.html')
driver.get(file_path)
# 选择页面上所有的input tag对象
input_objs = driver.find_elements_by_tag_name('input')
add_texts = ['auto', 'test', 'python']
# 从中筛选出type为text的元素并分别赋值
index = 0
for input in input_objs:
if input.get_attribute('type') == 'text':
input.send_keys(add_texts[index])
index += 1
time.sleep(3)
driver.quit()
简单总结一下,find_elements_by_xx('xx')就是用于获取一组元素的方法。俗话说“条条大路通罗马”,定位一组具有相同特性元素的方法不只一种,还可以使用CSS定位的方法,代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
import os
driver = webdriver.Chrome()
file_path = 'file://' + os.path.abspath('textinput.html')
driver.get(file_path)
# 直接选择所有type为text的元素并给文本框赋值
text_inputs = driver.find_elements_by_css_selector('input[type=text]')
add_texts = ['auto', 'test', 'python']
# 从中筛选出type为text的元素并分别赋值
index = 0
for input in text_inputs:
input.send_keys(add_texts[index])
index += 1
time.sleep(3)
driver.quit()
8、对层级对象定位
有时候需要针对一些属性相同的元素进行定位,此时可以先定位其父元素,然后再通过父元素定位子元素或更下一级的子元素。这就如同责任链一样,直接去某个人口密集的学校里找某个学生比较难,但是可以先找到该学生所在班级的班主任,然后再通过班主任联系到该学生,效率会事半功倍。
下面是编写的一个用于演示的下拉列表页面,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.well {
margin-bottom: 10px;
}
</style>
<title>下拉菜单页面</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/
4.3.1/css/bootstrap.min.css">
<script src="https://cdn.staticfile.org/jquery/3.2.1/jquery.min.js">
</script>
<script src="https://cdn.staticfile.org/popper.js/1.15.0/umd/popper.min.
js"></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/4.3.1/js/bootstrap.
min.js"></script>
</head>
<body>
<div class="container">
<h2>下拉菜单</h2>
<div class="well">
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle=
"dropdown">
Dropdown button1
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#">Event1</a>
<a class="dropdown-item" href="#">Event2</a>
<a class="dropdown-item" href="#">Event3</a>
</div>
</div>
</div>
<div class="well">
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle=
"dropdown">
Dropdown button2
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#">Event1</a>
<a class="dropdown-item" href="#">Event2</a>
<a class="dropdown-item" href="#">Event3</a>
</div>
</div>
</div>
<div class="well">
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown">
Dropdown button3
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#">Event1</a>
<a class="dropdown-item" href="#">Event2</a>
<a class="dropdown-item" href="#">Event3</a>
</div>
</div>
</div>
</div>
<div class="well">
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle=
"dropdown">
Dropdown button4
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#">Event1</a>
<a class="dropdown-item" href="#">Event2</a>
<a class="dropdown-item" href="#">Event3</a>
</div>
</div>
</div>
</body>
</html>
从页面结构方面分析,在class为well的div中,下拉列表结构完全一样,并且link text等属性也完全一样,无法使用之前讲的一系列函数来定位元素,因此考虑使用层级定位方式来解决。
经过认真思考后,整理思路如下:
- 单击第一个button下拉按钮。
- 定位某个具体的链接对象,即link text对象。
可以利用涉及UI部分的类库及ActionChain类操作页面元素,自动完成所有操作,具体代码实现如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.action_chains import ActionChains
import time
import os
driver = webdriver.Chrome()
file_path = 'file:///' + os.path.abspath('dropList.html')
driver.get(file_path)
# 单击button下拉按钮,弹出下拉列表框
driver.find_element_by_link_text('Event1').click()
# 在父元素下找到链接为Event3的子元素
targets = driver.find_element_by_id('super1').find_element_by_link_text
("Event3")
# 将光标移动到该子元素上
ActionChains(driver).move_to_element(menu).perform()
time.sleep(4)
driver.quit()
9、iframe中的对象定位
嵌套iframe是经常会遇到的复杂页面。例如,一个页面上有4个iframe:A、B、C、D。A里面有B,B里面有C,C里面有D,定位的顺序应该是先定位A,从而找到B,再找到C,最后找到D。
Selenium也提供了切换iframe的功能,让开发者对这种嵌套关系的元素也能轻松定位,其中,主体页面的代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>iframe结构</title>
<link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/
4.3.1/css/bootstrap.min.css">
<script src="https://cdn.staticfile.org/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.staticfile.org/popper.js/1.15.0/umd/popper.min.
js"></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/4.3.1/js/bootstrap.
min.js"></script>
</head>
<body>
<div class="row">
<div class="span11">
<h2>frame1</h2>
<iframe src="in.html" id="if1" width="800" height="500"></iframe>
</div>
</div>
</body>
</html>
in.html为内嵌页面,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Inner page</title>
</head>
<body>
<div class="row">
<div class="span8">
<h2>frame1</h2>
<iframe src="http://soso.com" id="if2" width="600" height="400"></iframe>
</div>
</div>
</body>
</html>
可以通过switch_to_frame的方式定位相关元素,代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
import time
import os
driver = webdriver.Chrome()
file_path = 'file:///' + os.path.abspath('iframe.html')
driver.get(file_path)
driver.implicitly_wait(30)
# 先找到最外层的iframe(if1)
driver.switch_to_frame("if1")
# 再找到内层的iframe(id=if2)
driver.switch_to_frame("if2")
# 操作元素
driver.find_element_by_id("query").send_keys("Python")
driver.find_element_by_id("stb").click()
time.sleep(3)
driver.quit()
10、调试方法
对Selenium的自动化测试程序进行调试也是一门学问,开发者常常需要记录一些参数和结果,此时写入日志文件或者持久化数据是比较合适的手段。开发者可以封装一个日志工具类,基于Log类库进行二次封装,只要符合自己的业务需求即可。
参考代码如下:
# -*- coding: utf-8 -*-
'''
使用logging模块自定义封装一个日志类
@author freePHP
@version 1.0.0
'''
import logging
import os.path
import time
class Logger(object):
def __init__(self):
self.logger = logging.getLogger("")
# 设置输出的等级
LEVELS = {'NOSET': logging.NOTSET,
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL}
# 创建文件目录
logs_dir="logs2"
if os.path.exists(logs_dir) and os.path.isdir(logs_dir):
pass
else:
os.mkdir(logs_dir)
# 修改log保存位置
timestamp=time.strftime("%Y-%m-%d",time.localtime())
logfilename='%s.txt' % timestamp
logfilepath=os.path.join(logs_dir,logfilename)
rotatingFileHandler = logging.handlers.RotatingFileHandler
(filename =logfilepath,
maxBytes = 1024 * 1024 * 50,
backupCount = 5)
# 设置输出格式
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s]
%(message)s', '%Y-%m-%d %H:%M:%S')
rotatingFileHandler.setFormatter(formatter)
# 控制台句柄
console = logging.StreamHandler()
console.setLevel(logging.NOTSET)
console.setFormatter(formatter)
# 添加内容到日志句柄中
self.logger.addHandler(rotatingFileHandler)
self.logger.addHandler(console)
self.logger.setLevel(logging.NOTSET)
def info(self, message):
self.logger.info(message)
def debug(self, message):
self.logger.debug(message)
def warning(self, message):
self.logger.warning(message)
def error(self, message):
self.logger.error(message)
调用方式如下:
# -*- coding: utf-8 -*-
# 调用logger类
import logging
import logger
mylogger = logger.Logger()
mylogger.debug("Start to debug it")
mylogger.info ("Start to info it")
mylogger.warning("Start to warning")
mylogger.error("Something is wrong")
总体来说,学习调试的第一步就是使用日志去跟踪并记录程序执行过程中发生的变化,这是编程开发时进行测试的良好习惯。后续学习中,会在关键逻辑中输出并记录日志,以检测程序是否按照预定计划在执行。
记录日志是非常有效的调试手段,对于排查一些疑难问题有很大的帮助,可以使用这个封装好的日志工具类对如数据库操作进行记录。
11、对话框处理
日常工作中常常会看到一些浮动的弹出对话框,如登录框或者其他功能的iframe。在之前的学习中我们已经处理过类似的登录框,如百度网盘的账号登录框。下面以登录百度贴吧账号为例进行分析,这是一个比较常用且步骤较多的用例。
操作步骤如下:
- 单击“登录”链接,弹出登录框。
- 在登录框中单击“用户名登录”选项,显示真正的登录界面。
- 填充账号和密码,单击“登录”按钮。
相比之前的百度网盘账号登录和QQ空间登录,本例增加了第一个步骤。
具体实现代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
import time
driver = webdriver.Chrome()
driver.get('https://tieba.baidu.com/index.html')
# 单击登录链接
first_btn = driver.find_element_by_css_selector("#com_userbar > ul >
li.u_login > div > a")
first_btn.click()
time.sleep(3)
# 单击“用户名登录”选项
show_account_login = driver.find_element_by_id('TANGRAM__PSP_10__footer
ULoginBtn')
show_account_login.click()
time.sleep(2)
# 填充账号和密码
# TANGRAM__PSP_10__userName
# TANGRAM__PSP_10__password
driver.find_element_by_id('TANGRAM__PSP_10__userName').send_keys('your_
account')
time.sleep(2)
driver.find_element_by_id('TANGRAM__PSP_10__password').send_keys('you
password')
time.sleep(2)
driver.find_element_by_id('TANGRAM__PSP_10__submit').click()
执行以上程序即可完成百度贴吧账号登录操作,整个过程中会自然停顿几秒,是为了让操作更加真实,如同人工操作。本例的关键在于定位tab选项,以及填充账号和密码,通过使用find_ element_by_id()方法,传递ID名称来定位对应的元素。
12、跨浏览器的窗口处理
测试人员有时需要在多个窗口之间来回切换从而测试一些功能。例如,在手机注册页面、邮箱注册页面及其他社交账号注册页面分别进行测试,可以同时打开多个窗口进行注册及登录。这个过程烦琐且步骤单一,非常适合用自动化测试来完成。
Selenium针对跨浏览器的窗口处理应注意以下几点:
- 在Selenium中每个窗口被当作一个会话句柄。
- WebDriver的window_handles可以控制多个窗口,也就是多个会话句柄。
- Window_handle代表当前会话窗口。
- switch_to.window()函数可以进行窗口切换,类似之前的iframe操作。注意原switch_ to_window函数已被废弃,不能在Selenium 3.x中使用。
下面以腾讯首页为例,做一个简单的使用展示。
# -*- coding: utf-8 -*-
from selenium import webdriver
import time
driver = webdriver.Chrome()
# 访问腾讯首页
driver.get('https://www.qq.com/?fromdefault')
# 获得当前窗口
nowhandle = driver.current_window_handle
# 打开新窗口
driver.find_element_by_css_selector("[bosszone=dh_1]").click()
time.sleep(3)
# 获取所有窗口
all_handles = driver.window_handles
for handle in all_handles:
if handle != nowhandle:
print("need to switch to nowhandle")
driver.switch_to.window(handle)
time.sleep(3)
driver.quit()
13、分页处理
有时经常需要对列表进行翻页操作,Selenium同样提供了相应的API。
分页处理逻辑大致可分为以下3个步骤:
- 获取总页数。
- 获取所有分页并循环翻页。
- 针对每一次分页进行后续逻辑处理。
下面以百度贴吧Python吧为例,具体讲解怎么样使用Selenium处理分页数据。
分页列表部分的HTML代码如下:
<div id="frs_list_pager" class="pagination-default clearfix"><span class=
"pagination-current pagination-item ">1</span>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=50" class=
" pagination-item ">2</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=100" class=
" pagination-item ">3</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=150" class=
" pagination-item ">4</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=200" class=
" pagination-item ">5</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=250" class=
" pagination-item ">6</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=300" class=
" pagination-item ">7</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=350" class=
" pagination-item ">8</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=400" class=
" pagination-item ">9</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=450" class=
" pagination-item ">10</a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=50
" class="next pagination-item ">下一页></a>
<a href="//tieba.baidu.com/f?kw=python&ie=utf-8&pn=48700
" class="last pagination-item ">尾页</a>
</div>
可以通过元素的id属性来定位分页列表所在的div,然后不断模拟单击下一页按钮的操作来获取新一页的内容,而总页数可以通过单击尾页后分析页面上的页码按钮来确定。
经过上述分析,编写代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
import time
driver = webdriver.Chrome()
# 访问Python吧首页
index_url = 'http://tieba.baidu.com/f?ie=utf-8&kw=python'
# frs_list_pager
driver.get(index_url)
# 定位到分页div
pagination_div = driver.find_element_by_id('frs_list_pager')
print(pagination_div)
# 计算最后一页的页码
# 先单击尾页按钮
driver.find_element_by_css_selector('.last.pagination-item').click()
time.sleep(3)
# 获取尾页的页码数字
last_page_no = driver.find_element_by_css_selector('.pagination-current.
pagination-item').text
time.sleep(2)
print(last_page_no)
# 跳回首页
driver.get(index_url)
# 循环last_page_no次获取每一页的数据
for index in last_page_no:
# 一些收集数据的代码,省略
time.sleep(2)
driver.find_element_by_css_selector('.next.pagination-item').click()
driver.quit()
14、控制浏览器的滚动条
百度贴吧的页面比较大,需要使用滚动条才能看到页面底部的分页。在日常测试中,滚动到底部再进行测试也是非常常见的。
Selenium提供了调用JS代码的能力,可以使用execute_script()函数执行JS代码实现移动滚动条的效果。
代码如下:
# -*- coding: utf-8 -*-
from selenium import webdriver
import time
driver = webdriver.Chrome()
driver.get("https://www.baidu.com")
# 查询golang
driver.find_element_by_id("kw").send_keys("golang")
driver.find_element_by_id("su").click()
time.sleep(2)
# 将页面滚动条拖动到页面底部
js = "var q=document.documentElement.scrollTop=10000"
driver.execute_script(js)
time.sleep(3)
# 将滚动条拖动到页面顶部
js_back = "var q=document.documentElement.scrollTop=0"
driver.execute_script(js_back)
time.sleep(3)
driver.quit()
15、模拟登陆
1. 登录完整用例
假设整个过程还是发送POST请求到登录接口(URL),然后获取Token数据,则服务端的相关代码如下(也是使用Flask快速搭建的服务):
from flask import Flask, jsonify
from flask import request
import time, hashlib
app = Flask(__name__)
login_html = '''
<html>
<head>
<title>Login Page</title>
</head>
<body>
<form action="/doLogin" method="post">
Account: <input type="text" name="account"><br>
Password: <input type="text" name="password"><br>
<input type="submit" value="Submit">
<input type="reset" value="reset">
</form>
</body>
</html>
'''
@app.route('/', methods=['GET', 'POST'])
def login_index():
return login_html
@app.route('/doLogin', methods=['POST'])
def do_login():
if request.method == 'POST':
account = request.form['account']
password = request.form['password']
if account == 'freePHP' and password == 'lovePython':
timestamp = time.time()
prev_str = account + password + str(timestamp)
token = hashlib.md5(prev_str.encode(encoding='UTF-8')).
hexdigest()
json_data = [
{'token': token, 'user_id': 101}
];
return jsonify({'data': json_data, 'result': True, 'errorMsg': ''})
else:
return jsonify({'data':[], 'result': False, 'errorMsg': 'Account
and password is not matched!'})
if __name__ == '__main__':
app.run(debug=True)
运行该脚本后,首页的URL为http://127.0.0.1:5000/.
根据服务端的代码,我们编写它对应的单元测试类,主要用来替换“/doLogin”接口的返回值,具体实现代码如下:
import unittest
from unittest import mock
def do_login_directly():
url = "http://127.0.0.1:5000/doLogin"
data = {"account": "freePHP", "password": "2323243"}
return ''
class LoginTest(unittest.TestCase):
@mock.patch('test_login.do_login_directly')
def test_do_login(self, mock_opt):
json_data = [
{'token': "dsdfsdcsdfsdfaadsfa", 'user_id': 101}
];
json_result = {'data': json_data, 'result': True, 'errorMsg': ''}
mock_opt.return_value = json_result
self.assertEqual(do_login_directly(), json_result)
if __name__ == '__main__':
unittest.main()
2. 通过面向对象的方式实现登录
通过面向对象的方式实现登录,即把do_login_directly()方法封装到一个类里,然后通过类的Patch方式引入到单元测试类中即可。具体改动代码如下,可以看出,面向对象的实现方式更加清晰。
# -*- coding: utf-8 -*-
import requests
class OriLoginObj(object):
def __init(self):
self.url = "http://127.0.0.1:5000/doLogin"
self.data = {"account": "freePHP", "password": "2323243"}
def do_login_directly(self):
res = requests.post(self.url, data=self.data)
return res.text
在上面代码中的OriLoginObj类和方法前面添加Patch(补丁装饰器)注解,以达到模拟测试的作用,改动代码如下:
@mock.patch('ori_login_obj.OriLoginObj.do_login_directly'
def test_login(self, mock_opt):
# 其他代码
self.assertEqual(oOriLoginObj.do_login_directly(), json_result)
九、接口自动化测试
1、自动化测试基础
1. 项目选择
并非所有的项目都适合做自动化测试,那么哪些项目适合做自动化测试呢。
- 稳定的项目系统
所谓稳定的项目系统是指已经在线上运行过一定时间,并且未来不会频繁迭代更新,而且会持续维护的项目。其实这一条就已经过滤了大部分小公司的项目了,在竞争日趋激烈的现在,争取项目上线时间甚至比质量更重要,于是后期需要频繁迭代以完善项目,运气不好的话,竞争失败就要转型做其他项目,那就根本不适合做自动化测试,全是浪费成本和资源。
- 可预计结果的项目系统
可预计结果的项目系统可以理解为结果是固定的,可以从程序判断是否能通过的项目系统。虽然大多数项目还是可以预计结果的,但其实也有部分项目结果是可变的,比如抓取数据和分析数据类,数据的不确定性决定了结果的不确定性,作为程序来说无法根据唯一的预期结果做判断,还是需要人工介入对结果进行判断。
- 逻辑判断简单的项目系统
逻辑判断简单的项目系统,即自动化测试的代码量不能太大,与其花大量时间在写各种复杂的判断和验证代码上,还不如人工测试有效率。而现在很多项目都开始往智能化发展,综合各种条件得出最后的结果,这种项目的自动化测试的编码难度已经不低于开发了,所以也是不适合做自动化测试的。
即便符合了以上的一些要求,实际还有各种限制的自动化测试情况,从而也往往只有部分功能可以做自动化测试,因此自动化测试现阶段还主要用于冒烟测试和简单的回归测试,既能达到部分减少重复人工测试,也不会耗费大量的精力在自动化测试上,算是达到某种程度的平衡吧。
2. 结构化设计
所谓的自动化测试就是把一个个测试点通过一定的方式组合起来,而组合的方式一般分为 3种,顺序结构,判断结构和循环结构。
1)顺序结构
顺序结构顾名思义就是按照顺序一步步执行,当其中某一步出错后可以继续执行下一步,如图所示。
某一步无法执行或者执行错误,则继续进行下一个步骤,步骤之间只有先后关系,没有逻辑关系,当然数据之间可能存在相互依赖的关系,但最多一步执行错误,后面其他步骤也会跟着错误而已,步骤还是会执行的,下面举例就能更好理解了。
步骤1:登录某个网站,登录完成后将cookie取出来。
步骤2:将步骤1的cookie传递过来,并带着cookie发送请求去获取登录后的个人信息。
当步骤1登录失败时是取不到cookie的,所以步骤2获取不到步骤1的cookie,那自然也获取不到个人信息,所以从数据关系来说步骤2的操作依附于步骤1的操作结果,但从逻辑关系来说步骤2并不会因为步骤1的操作结果失败而不执行。这是顺序结构最大的局限性,所以用的相对比较少。
2)判断结构
判断结构就是根据表达式判断结果的不同,而去执行不同的代码,因此弥补了顺序结构在逻辑关系上的缺陷,让测试脚本更具有灵活性。
单个判断结构如图所示:
这是最简单的判断流程,执行步骤1,然后进入判断,当判断结果是True时,执行步骤2,当判断结果是False时,执行步骤3,因此步骤2和步骤3是互斥的,两者只能执行一个,具体语法如下。
if (判断表达式):
执行实体1
else:
执行实体2
还是以网易首页的请求作为例子,请求成功,则输出结果“请求成功”,请求失败,则输出结果“请求失败”。
实例代码:
# -*- coding: utf8
1 import requests
2 test_url = "http://www.163.com"
3 response = requests.get(test_url)
4 if (response.status_code == 200):
5 print ("请求成功")
6 else:
7 print ("请求失败")
代码说明:
3 发送一个HTTP请求到网易首页。
4 判断返回数据的状态码是否等于200,当返回码等于200时执行第5行代码。
5 打印“请求成功”。
6 当状态码不等于200时,则执行第7行代码。
7 打印“请求失败”。
第五行和第七行不会同时执行,也就是说两者只可能执行一个,因为测试的目的本来就是测试结果是否正常,所以需要根据返回的结果来判断测试的结果并输出。这种只是2选1的判断,有时候结果未必只是2种,这时候需要引入elif来实现多种判断。
先介绍下语法:
if(判断表达式1):
执行实体1
elif(判断表达式2):
执行实体2
elif(判断表达式3):
执行实体3
else:
执行实体4
还是上面那个例子,其实对于返回的状态码来说不只是200,可能是404、500、502等,前面一个代码例子只能区分成功和不成功,并不能区分失败的原因,所以通过if和elif的方式对每种情况进行判断,并打印出每种返回结果的注解。来改一下代码。
实例代码:
# -*- coding: utf8
1 import requests
2 test_url = "http://www.163.com"
3 response = requests.get(test_url)
4 if (response.status_code == 200):
5 print ("请求成功")
6 elif (response.status_code == 404):
7 print ("请求页面不存在")
8 elif (response.status_code == 502):
9 print ("连接服务器失败")
10 else:
11 print ("其他未知错误")
代码说明:
4判断返回数据的状态码是否等于200,当返回码等于200时执行第5行代码。
6判断返回数据的状态码是否等于404,当返回码等于404时执行第7行代码。
8判断返回数据的状态码是否等于502,当返回码等于502时执行第9行代码。
10当状态码不等于以上3种,则执行第11行代码。
如果想要看异常情况的话,可以将URL改成不存在的地址或者断开网络,来改一下代码。
把test_url改成 "http://www.addf.com/2222.html"
第一个例子一样,最终能执行的只有一个,区别在于可以对多种情况进行判断,并执行对应的代码。这4种情况属于平行关系,从结果上来说可以达到多种判断的目的,但代码逻辑不够清晰,不利于代码可读性,因为404和502都属于失败的结果,而200属于成功的结果,并没有通过代码区分开,所以代码可以继续优化,让其逻辑结构更清晰。这里要介绍判断结构的嵌套,通过多层的缩进来区分主次关系。
先介绍一下语法:
if(判断表达式1):
if(判断表达式2):
执行实体1
else:
执行实体2
else:
if(判断表达式4):
执行实体3
elif(判断表达式5):
执行实体4
else:
执行实体5
通过嵌套来对代码逻辑进行分层,这种虽然不适合新手,但对于已经熟练掌握判断结构的人来说,能写出更好的测试代码,把上面的例子改一下。
实例代码:
# -*- coding: utf8
1 import requests
2 test_url = "http://www.163.com"
3 response = requests.get(test_url)
4 if (response.status_code == 200):
5 print ("请求成功")
6 else:
7 if(response.status_code==404):
8 print ("请求页面不存在")
9 elif (response.status_code==502):
10 print ("连接服务器失败")
11 else:
12 print ("其他未知错误")
代码说明:
这样逻辑结构就很清晰了,当状态码等于200的时候就是请求成功的情况,而不等于200的时候就是请求失败的情况,然后再在请求失败的情况下判断各种失败的状态码,并对其做出解释,虽然运行结果是一致的,但处理逻辑就一目了然了。
我们通过以上代码对登录情况的不同返回进行了相应操作。在工作中,它被更多应用在验证测试结果是否符合预期结果,这些会在之后的实践篇中详细介绍。
3. 循环结构
循环结构就是重复执行某一段代码n次,即可以指定循环的次数,执行到指定次数后停止,也可以不指定循环的次数,通过判断直到满足某个条件后停止。
循环结构如图所示:
最常用的循环可以用for循环来实现,先介绍语法。
for 变量 in 集合:
执行实体
怎么来理解变量和集合的关系呢?就是每次循环从集合内取一个值赋值给变量,并且不会重复赋值,集合可以是元组、列表、字典等各种各样的数据结构,而循环次数就是集合中元素的个数。
实例代码:
1 x = [1,2,3,4]
2 for i in x:
3 print (i)
代码说明:
1 给变量x赋值一个包含1,2,3,4的列表。
2 将列表x的4个值分4次赋值给变量i,每赋值一次,则执行第3行代码一次。
3 循环打印i的值。
这只是一个介绍变量和集合之间的关系的例子,实际运用中不会去数元素的个数,那么如果要指定循环次数的话可以引入range()函数,该函数会产生一个正整数列表,其语法如下。
range(起始值,结束值,递增值)
参数起始值和递增值可以不写,起始值默认是0,而递增值默认是1,仅当有需要的时候可以填写。
实例代码:
1 for i in range(100):
2 print (i)
代码说明:
1起始值默认为0,递增值默认为1,结束值是100,所以range(100)是0~99的整数列表,并分100次赋值给变量i。
2 打印i的值。
通过range()函数可以指定循环次数,其原理就是生成n个整数的列表,那就可以指定运行n次。
当然 for 其实也可以用于不固定的循环次数,只是必须满足不固定实际循环次数小于指定循环次数的条件,并且嵌套之前判断结构加上break跳出循环,来实现不固定次数的循环。
举个例子,登录一个网站,一共执行10次登录,如果登录成功、退出循环,如果登录失败、再重试登录,直到完成10次登录后结束循环。先定义一个登录的login()函数,并return返回的状态码。
实例代码:
# -*- coding: utf8
1 for i in range(10):
2 x = login()
3 if (x == 200):
4 print ("登录成功")
5 break
6 else:
7 print("登录失败")
代码说明:
1 循环10次下面的代码。
2 执行login()函数进行登录,返回状态码并赋值给变量x。
3 判断x是否等于200,如果是200,则执行第4行和第5行代码,如果不是200,则执行第7行代码。
4 打印“登录成功”。
5 跳出循环,不再执行循环内的代码。
7 打印“登录失败”,并重新进行下一次循环的代码执行。
如果想要看异常情况下的循环机制,可以先断一下网络,然后在中间执行过程中再连接网络。
上面的例子其实也可以通过while循环来替代,因为while循环的语法是支持判断表达式的,所以上面的例子更适合用while循环,先介绍语法。
while(判断表达式):
执行实体
可以把if判断条件加到while的判断表达式中。
实例代码:
# -*- coding: utf8
1 x = 0
2 while(x!=200):
3 x = login()
4 print ("登录成功")
代码说明:
1 自定义变量x并赋值为0。
2 判断x是否等于200,如果不等于200,则执行第三行代码。因为初始变量x是0,所以第一次必然会执行下面的代码。
3执行login()函数进行登录,返回状态码并赋值给变量x。
4 当程序跳出循环体后,即登录成功x=200,打印登录成功。
这相对于 for 循环其实是有差异的,由于没有固定的循环次数,也就是说直到登录成功才会退出循环,一旦一直无法登录成功,就会无限地死循环,除非手动终止程序。这也是 while 循环相对for循环好用,但又不推荐的原因。当然while循环也是可以指定循环次数的,只是没有for循环那么灵活,那就修改一下代码,通过while循环实现10次循环。
实例代码:
# -*- coding: utf8
1 i = 0
2 while(i!=10):
3 x = login()
4 if (x == 200):
5 print ("登录成功")
6 break
7 else:
8 print("登录失败")
9 i = i+1
代码说明:
1 自定义变量i并赋值为0。
2 判断i是否等于10,如果不等于10,则执行下面行的代码。因为初始变量i是0,所以第一次必然会执行下面的代码。
9 每次执行完代码给i的值加1,直到循环到第11次,i等于10的时候退出循环。
从代码层面来说for和while能实现的功能还是类似的,主要还是需要根据测试场景的特性来选择适合的循环方式,以减少测试代码量。
2、自动化测试难点突破
自动化测试中会遇到种种困难,下面介绍几个常见的自动化测试的难点。
比如做一个注册的自动化测试,每次的注册账号都不能重复,如何让每次注册账号不重复呢?
比如发送请求时需要带上当前时间的参数,如何自动生成当前时间呢?
比如为了安全性,程序会对post的内容进行加密,如何对数据进行加密?
比如有时候对同一个功能输入不同数据进行自动化测试,如何自动传入不同的数据?
其实自动化测试重点就在于其智能性,不需要人工介入完成测试工作,所以不仅逻辑的框架需要通过程序结构设计搭建好,还有很多细节上的困难需要处理,而Python有很多内置模块相当实用,合理利用好这些模块可以解决很多问题。
1. 随机产生数据
内置的 random 模块,顾名思义,是一个随机的模块,不要小看这个模块在自动化测试中的作用,它能解决大部分测试数据重复的问题,可以说是自动化测试中需要导入的重要模块之一,先看一下random模块所提供的一些常用函数方法。
random.random()
用于生成一个0到1的随机浮点数。
random.randint(a,b)
用于生成一个最小值是a,最大值是b区间的整数。
random.randrange(a,b,c)
用于生成一个最小值是a,最大值是b区间,并且指定递增为c的整数,比如要取一个100内的双数,则可以写成random.randrange(0,100,2),即在范围[2,4,6…100]的列表中随机产生一个整数。
random.choice(a)
从集合a中获取一个随机元素,a可以是字符串、元组、列表。
这些都是random模块中常用的函数,接着就是如何运用到实际的自动化测试之中了。
注册需要用到手机号码,可以通过random.randint(a,b)来随机生成一个手机号,手机号的特征有2个,即13开头并且是11位数字。
实例代码:
1 import random
2 mobile = random.randint(13000000000,13999999999)
3 print (mobile)
代码说明:
1 导入random随机模块。
2 在13000000000~13999999999的范围内随机一个整数。
当然随机也不保证一定不重复,但重复的概率还是很小的,几乎可以忽略不计,所以可以放心地使用随机函数来完成不同数据的生成。
除了随机手机号码,还可以随机指定的字段,比如性别的男女、学历等。
实例代码:
# -*- coding: utf8
1 import random
2 e = ("小学","初中","高中","大学")
3 education = random.choice(e)
4 print (education)
代码说明:
3从元组s之中随机选择任意一个元素。
通过这样随机选取特定元素也可以让测试数据更多,而不是重复用单一的数据作为测试数据,从而让自动化测试更智能,而非机械化的重复测试,因此熟练运用 random 模块的各种函数,对于自动化测试来说是非常有用的。
2. 日期的获取和计算
内置的datetime模块是一个时间处理模块,这也是一个重要的模块,既可以获取当前的日期,也可以获取当前的时间,还能计算日期和时间,在测试中用处也非常大,下面就来介绍这个模块。
datetime模块下有好几个子模块,主要的是以下2个。
- datetime.date:表示日期的模块。
- datetime.datetime:表示日期、时间的模块。
1)date模块
对于日期来说,一般常用的就是date模块,通过其下面的today()函数,获取当前日期。
实例代码:
1 from datetime import *
2 now = date.today()
3 print (now)
代码说明:
1 从datetime模块下导入所有子模块。
2 通过date模块的today()函数获取当前日期,并赋值给变量now。
获取日期后还可以通过获取日期的属性得到对应的值。
实例代码:
1 from datetime import *
2 now = date.today()
3 now_year = now.year
4 now_month = now.month
5 now_date = now.day
6 now_weekday = now.isoweekday()
7 print (now)
8 print (now_year)
9 print (now_month)
10print (now_date)
11print (now_weekday)
代码说明:
3 将当前日期的年份值赋值给变量now_year。
4 将当前日期的月份值赋值给变量now_month。
5 将当前日期的日期值赋值给变量now_date。
6 将当前日期的星期值赋值给变量now_weekday。
date模块能对日期做加减,以达到填入特定日期的目的,这时候需要引入timedelta()函数,其作用在于将间隔时间做加减,这个函数可以指定间隔时间的单位,可以是天,也可以是小时、分钟、秒等,只需要在参数中加上指定的单位以及对应的值即可对时间做计算。
实例代码:
1 from datetime import *
2 now = date.today()
3 tomorrow = now+timedelta(days=1)
4 yesterday = now-timedelta(days=1)
5 next_week = now+timedelta(days=7)
6 print (now)
7 print (tomorrow)
8 print (yesterday)
9 print (next_week)
代码说明:
3 将当前日期加上1天,并赋值给变量tomorrow。
4 将当前日期减去1天,并赋值给变量yesterday。
5 将当前日期加上7天,并赋值给变量next_week。
2)datetime模块
date模块只能对日期进行获取和操作,那么如果要对时间进行获取和操作呢?这时候就要用到datetime模块(虽然和主模块重名,但由于属于子模块,根据导入的模块层级不同是不会冲突的),这个模块是date模块的引申,模块下也可以通过today()函数获取时间,区别在于datetime获取的是完整的时间,而date获取的仅仅是日期。
实例代码:
1 from datetime import *
2 now = datetime.today()
3 print (now)
代码说明:
2 通过datetime模块的today()函数获取当前时间,并赋值给变量now。
虽然today()支持时间获取,但其命名也容易引发歧义,所以建议使用dateime下面的now()函数,其功能和today()一样,区别只是在于可以支持不同时区的时间获取,默认可以不填,就是当前时区。
实例代码:
1 from datetime import *
2 now = datetime.now()
3 print (now)
代码说明:
2 通过datetime模块的now()函数获取当前时间,并赋值给变量now。
同样获取时间后也可以根据其属性获取相应的指定数据,比如只需要获取当前日期或者获取不带日期的时间。
实例代码:
1 from datetime import *
2 now = datetime.now()
3 now_date = now.date()
4 now_time = now.time()
5 print (now)
6 print (now_date)
7 print (now_time)
代码说明:
3 将当前时间的日期值赋值给变量now_date。
4 将当前时间的时间值赋值给变量now_time。
datetime 模块在自动化测试中可以用的地方非常多,除了能获取时间,还能代替 random 模块让数据不重复,具体就要看实际情况灵活运用了。
3. 数据加密
内置的Hashlib模块是一个加密模块,主要提供 SHA1、SHA224、SHA256、SHA384、SHA512、MD5 算法。http请求post的内容其实可以通过抓包获取,极为不安全,所以往往会将post的数据加密后再发送,其用户密码或者比较隐私的信息都会以加密的方式存到数据库。下面就来介绍如何对数据进行加密。
一般最常用的就是通过md5对数据进行加密。
实例代码:
1 import hashlib
2 md5 = hashlib.md5(b"123456")
3 password_md5 = md5.hexdigest()
4 print (password_md5)
代码说明:
1 导入hashlib模块。
2 通过hashlib下的md5()函数对密码进行加密,并将加密结果赋值给变量md5(因为计算 md5 哈希时,需要使用字节形式的字符串,而非普通字符串,所以需要通过bytes函数将字符串再次转换)。
3 通过hexdigest()获取加密后的结果,并赋值给变量password_md5。
这样就完成了对密码的加密,然后通过变量带入表单,实现被加密密码的表单生成。
除了md5算法,最常用的还有sha512算法,其用法和md5是类似的。
实例代码:
1 import hashlib
2 sha512 = hashlib.sha512(b"123456")
3 password_sha512 = sha512.hexdigest()
4 print (password_sha512)
代码说明:
2 通过hashlib下的sha512()函数对密码进行加密,并将加密结果赋值给变量sha512。
3 通过hexdigest()获取加密后的结果,并赋值给变量password_sha512。
通过hashlib模块可以完成对数据的加密,以便传送需要加密的数据,这在关键数据上的应用是非常广泛的。
4. txt文件的读写
1)文件打开
无论是读文件,还是写文件,都需要先将文件打开,通过Python内置的open方式打开,语法如下:
变量 = open(文件路径,文件打开模式)
文件打开模式有以下几种:
- r 以只读的方式打开。
- r+ 以读写的方式打开。
- w 以写入的方式打开,会覆盖原有文件的所有内容,如果不存在,则新建文件。
- w+ 以读写的方式打开,会覆盖原有文件的所有内容,如果不存在,则新建文件。
- a 以写入的方式打开,不会覆盖原有文件的内容,追加写入到文件的最后,如果不存在,则新建文件。
- a+ 以读写的方式打开,不会覆盖原有文件的内容,追加写入到文件的最后,如果不存在,则新建文件。
- b以二进制的方式打开,可以结合r、w、a使用。
- U支持所有的换行符号。
可以根据实际情况选择对应的模式或者组合模式,先新建一个txt文件,随便输入一些内容,然后在读写文件的实例代码中介绍如何使用。
2)文件读取
文件读取是Python内置的函数,不需要导入模块就可以直接调用了。这个功能对于自动化测试来说其意义在于测试用例的数据读取,读取文件有3种方法。
- read() 读取文件的所有内容。
- readline() 读取文件的第一行内容。
- readlines() 读取文件的每一行内容。
通过实例来看一下这3种的差异,read()方法实例代码:
1 file = open("G:\\python\\shuju.txt", "r")
2 result = file.read()
3 print (result)
4 file.close()
代码说明:
1 选择txt文件对应的路径,以只读方式打开,并实例化赋值给变量file。
2 通过file对象的read()方法把文件结果读出,并赋值给变量result。
4 关闭文件释放资源。
再来看readline()方法的实例代码:
1 file = open("G:\\python\\shuju.txt", "r")
2 result = file.readline()
3 print (result)
4 file.close()
最后来看readlines()方法的实例代码:
1 file = open("G:\\python\\shuju.txt", "r")
2 result = file.readlines()
3 print (result)
4 file.close()
通过运行结果,可以一目了然地看到3种方法的不同,也许有人对read()和readlines()的差异有所困惑,不都是读取文件的全部吗?这里有个关键的差异,这二者读取后的数据类型是不一样的,read()读取出来的数据类型是字符串,而readlines()读取出来的数据类型是列表,每一行作为列表的一个元素,这就导致在不同情况下需要使用不同的方法读取文件数据。
一般来说推荐使用 readlines()方法,尤其对于测试数据来说,一行作为一组数据等同于列表的一个元素,这样在使用数据的时候可以通过循环的方法读取每一行数据,以达到一组数据作为一个测试用例执行的目的。
实例代码:
1 file = open("G:\\python\\shuju.txt", "r")
2 result = file.readlines()
3 for i in result:
4 print (i)
5 file.close()
代码说明:
3 通过for循环将列表result中的元素依次赋值给变量i。
通过结果看到,每一次循环打印一次列表中的元素,用这样的方式就可以输入不同的数据作为自动化测试的测试参数了,只需要在新建文件的时候按照一行一组数据的方式编写,再通过循环依次赋值来执行测试。
知道了读取一组数据的方式,那如何才能把一组数据分离出来分别赋值给不同的参数呢?因为输入的内容每一行都是一组字符串,所以需要通过 split()方法把字符串元素通过“,”分离成列表,最后再通过列表的索引依次赋值。
实例代码:
1 file = open("G:\\python\\shuju.txt", "r")
2 result = file.readlines()
3 for i in result:
4 x = i.split(",",2)
5 print (type(x))
6 a = x[0]
7 b = x[1]
8 c = x[2]
9 print (a,b,c)
10file.close()
代码说明:
4 把变量i用split()用逗号分隔成包含3个元素的列表,并赋值给变量x。
6~9分别把列表的三个元素赋值给变量a、b、c。
用上面的方法读取文件数据,就可以完成数据驱动的自动化测试了。
3)文件写入
文件写入也是Python的内置函数,这个功能对于自动化测试来说意义在于测试报告的输出。
文件写入的方式有以下两种:
- write() 将全部内容一起写入文件。
- writelines() 把列表内容逐行写入文件。
一般情况下用write()就可以了,除非写入内容是列表类型,对于测试来说用write()方式就足够了,以追加写入为例。
实例代码:
1 file = open("G:\\python\\shuju.txt", "a+")
2 file.write("xxx"+"\n")
还有一种是替换的方式写入,实例代码:
1 file = open("G:\\python\\shuju.txt", "w+")
2 file.write("yyy"+"\n")
3 file.write("zzz"+"\n")
通过运行结果可以看到2种写入方式的差异,对于测试报告的输出也需要根据实际需求来选择使用哪种方式。
3、自动化测试脚本编写
结构化设计也好,内置模块的应用也好,都是为了自动化测试做准备的,自动化测试其实就是把一个个测试点通过结构化设计串联起来,然后通过第三方模块也好,内置的模块也好,解决每一个测试点遇到的问题,以完成一个完整的自动化测试的脚本。
下面就通过之前做过的一个外卖的自动化测试项目,来介绍如何完成一个自动化测试的脚本。
1. 测试需求分析
外卖平台其实不仅仅是用户订个外卖这一个简单的系统,其中涉及订餐餐厅的管理、餐厅管理者的订单处理、用户的外卖配送物流等多个其他系统,而每个系统又有相互的数据关联,并非想象中那么简单,所以对于接口自动化测试的系统和用例选择就非常重要了,并不是每个系统或者功能都适合做接口自动化的,这里就先挑选一个最主要的订外卖系统作为实例。
相信订过外卖的人应该都知道订外卖的步骤。
- 登录外卖平台。
- 选择餐厅的范围(手机端可以通过定位直接获取周边的餐厅,计算机端则需要手动输入某一个点并根据这个点寻找一定范围内的餐厅)。
- 选择餐厅进入。
- 选择菜品达到起送金额并确认提交。
- 选择送餐时间、送餐地址、货到付款/在线支付。
- 提交外卖订单。
了解大致的测试场景之后就要分析哪些测试是可以通过自动化完成的,并不是所有的测试点都适合自动化测试,自动化的目的在于验证测试点的正确性,所以对于测试点的选择决定了自动化测试的难易程度以及可持续的测试性。
那就一个个来分析吧。
- 登录平台
这是必不可少的一步(但前提是登录不能有图片验证码,不然自动测试是很难完成的),也是最容易实现的一步,只需要用指定的测试账号和密码登录,一般是不会出错的。
- 选择餐厅范围
这一步其实可以跳过,因为自动化要保证运行的正确性,需要指定餐厅订餐,每个餐厅的菜品、起送费都是不一样的,如果随机选择餐厅是不可行的。如果指定餐厅的话只需要知道餐厅的ID就行了,而不再需要通过搜索餐厅范围获取了。
- 选择餐厅
上面已经说了需要指定餐厅订餐,所以这一步也不再需要,只要提交订单的时候带上餐厅ID就行了。
- 选择菜品
这是不可或缺的一步,毕竟订餐的核心就是点菜,其实和选择餐厅一样,也需要指定菜品,不然达不到起送费是没法订餐成功的。只要在提交订单的时候带上菜品的 ID 和数量保证达到起送费就行了。
- 提交订单
这是最核心的一步,2~4的步骤都直接在提交订单的时候体现出来,包括用户信息,选择餐厅、菜品和数量,这些都在这一步完成。
- 确认订单
这是最后一步,也是必需的,这一步不但需要提交订单ID(第5 步返回的ID),还需要选择配送地址、配送时间、支付方式等,对于配送地址可以直接写死,对于配送时间需要获取当前时间并处理,对于支付方式只能选择餐到付款,因为在线支付涉及其他支付公司的回调以及安全考虑,比较难实现。
通过以上分析基本上明确了大致的可以做自动化测试的测试点,但自动化测试遇到的困难往往比想象中的来得多,这也是在这个阶段需要考虑的。比如最重要的就是防刷单机制,即订餐达到一定次数之后会需要通过短信验证才能继续订餐,以防止有人恶意刷单,而自动化测试从某种程度来说也是一种恶意刷单,处理的方式有2种。
- 让开发人员在测试环境关闭短信验证码功能。
- 通过后台获取短信验证码,并通过请求完成短信验证,然后继续订餐。
第一种方式虽然可行,可以降低自动化测试的难度,但自动化是一种持续做的测试,不能一直关闭这个功能,以防上线后该功能也被关闭。当然如果要做性能测试的话,必须在测试的时候关闭一下,不然就没法做性能测试了,完成测试后再打开。
第二种方式会加大自动化测试难度,但却是一劳永逸的,如果时间允许,可以尝试采取这种方式,但如果在当时时间不允许,可以选择第一种方式去做。
要以第二种方式去做,需要先了解程序的机制,可以通过抓包看短信验证码的判断和验证过程,也可以询问开发处理的逻辑。
那先来介绍一下短信验证的逻辑:
- 提交订单后会再发送一个带有用户名的请求,然后后台程序通过数据库查询出该用户当天的订单量,如果未超过3笔订单,则不需要发送短信验证码,直接进入确认订单的步骤,超过3笔订单,则会弹出短信验证的界面。
- 这时候需要发送带手机号的请求来获取验证码。
- 获取验证码提交进行验证,验证通过后进入确认订单步骤。
那么还有问题就在于如何获取验证码。一般后台都会有查询验证码的功能,需要登录后台调用查询接口,并指定查询对应的手机号的验证码,把测试用例都分析清楚了,下一步就可以构建测试流程设计了。
2. 测试流程设计
分析完测试场景,接着就要把测试流程梳理清楚,就像产品设计的流程图一样。
首先要进行登录,然后将餐厅ID和菜品ID以及菜品价格等参数传入提交订单的请求,提交完订单结果获取订单号,接着就是发送一个请求询问是否需要验证码。当不需要短信验证码的时直接跳过,进入确定订单的请求;当需要短信验证码的时,进入请求短信验证码的步骤,请求完短信验证码需要通过后台获取该验证码,获取完毕后将验证码传入验证码的请求(由于是后台获取的验证码,所以是不会出错的,本来就是用于验证,则不需要判断错误的情况),完成验证后进入提交订单的请求。需要将之前的订单号以及配送地址、预定时间等参数传入,最后获取订餐结果完成整个自动化的测试,设计流程图如图所示。
3. 测试环境准备
自动化测试需要一个干净的测试环境,不然很难重复运行起来。自动化测试要做到绝对智能也是不可能的,尤其运行过程中会遇到脏数据或者异常就会中断。
首先需要在测试环境做一次正常的订餐测试,中间过程通过抓包获取订餐餐厅的ID、菜品ID、菜品数量、起送费、送餐地址等信息。一方面需要确定这些数据作为自动化测试的数据基础,另一方面需要了解数据结构是什么,可能是包含列表的字典,也可能是一个纯元组。当然前提是可以测试通过一套流程,不然获取这些数据就没有意义了。
一旦这些数据获取并确定之后,就要开始测试环境的准备了。
餐厅营业状态的修改。一定要保证订餐的餐厅一直是营业状态,这就需要将餐厅属性设置成24小时营业,不然对于不营业状态的餐厅是无法订餐的。
餐厅的菜品、价格、起送费、配送范围等属性的不修改。订餐涉及很多因素,只要有一个因素不符合就会失败,所以很多数据是很难随机选择的,要满足起送费和配送范围才能订餐成功。
短信验证码功能的开启。由于测试环境不仅仅是做自动化测试,很多时候还是需要手动测试,而短信验证码是会影响测试效率,一般情况下都会选择关闭,而在调试程序和运行程序的时候必须将其打开,不然那部分的自动化测试就做得没有意义了。
准备好测试环境和测试数据,就可以开始自动化测试脚本的编写了。
4. 测试代码编写
根据之前的流程设计,首先要把每一步操作分别放到一个函数完成,然后将函数之间的数据进行关联,最后通过特定的结构组合起来,这样即完成了一个完整的自动化测试脚本。下面就一步步来进行代码的编写吧。
首先要做的就是登录功能,常规的思维肯定是先定义一个登录的函数,然后通过 requests 模块发送账号密码进行登录,最后获取登录成功后的cookie。但这样做的弊端就是获取的cookie值只是局部变量,并不能作为全局变量给后面的其他函数调用,所以这里不用函数的形式,直接发送一个登录请求,并将cookie保存下来放到全局变量之中,以便其他函数的调用。
1 username = "13999999999"
2 password = hashlib.md5(b"123456").hexdigest()
3 url = "http://www.xxx.com/ajax/user_login/"
4 form_data = {"username":username,"password":password}
5 login_response = requests.post(url, data = form_data)
6 assert login_response.text == "success"
7 c = login_response.cookies
代码说明:
2 对密码做md5加密并赋值给变量password。
5 用requests的post方法带着账号和加密后的密码发送登录的请求。
6 增加一个断言,当返回的主体等于 success 继续执行后面的代码,如果返回主体不等于success,则终止后面的代码执行,并在运行结果报错。
7 获取登录成功后返回的cookie,赋值给全局变量c。
登录的代码很简单就完成了,登录并获取返回的cookie,接着就是提交订单的处理。
1 def make_order():
2 global c
3 url = "http://www.xxx.com/ajax/create_order/"
4 form_data = {"restaurant_id":11196, "menu_items_total":"12.00",
"menu_items_data":"[{'id':1653196,'p':'2','q':6 }]","delivery_fee":"3.00"}
5 make_response = requests.post(url, data = form_data,cookies = c)
6 res = make_response.text
7 id=json.loads(res)["order_id"]
8 assert id!= ""
9 return id
代码说明:
1 自定义make_order()函数。
2 引用全局变量c。
4 将提交订餐的信息抓取后以字典的形式赋值给 form_data(包括餐厅 ID、菜品总价、配送费、菜品ID、菜品数量)。
5 用requests的post方法带着订单需要的信息和cookies发送提交订单请求。
6 获取请求返回的主体并赋值给变量res。
7 由于返回的主体是JSON格式数据,需要将其转换成字典形式,并取出order_id对应的value值赋值给变量ID(此ID需要在之后的确认订单中使用)。
8 增加一个断言,ID获取成功必然不会为空,则继续执行后面的代码,如果ID为空,说明提交订单未成功,则终止后面的代码执行,并在运行结果报错。
9返回提交订餐成功后创建ID的值。
完成了提交订单这个功能之后,先不急着处理短信验证码,毕竟这不是主要的功能。
接着继续做确认订单的处理:
1 def place_order(id):
2 global c
3 global username
4 time = datetime.now()+timedelta(hours=1)
5 url = "http://www.xxx.com/ajax/place_order/"
6 form_data = {"order_id":id,"customer_name":"xxxx",
"mobile_number":username,"delivery_address":"xxxxxxx",
"preorder":"yes", "preorder_time":time,"pay_type":"cash"}
7 place_response = requests.post(url,data = form_data,cookies = c)
8 res = place_response.text
9 assert res == "success"
10 print ("订餐成功")
代码说明:
1 自定义place_order()函数,并带上参数ID,把make_order()返回ID通过形式参数传递进来。
2~3 引用全局变量c和username。
4 将当前时间加上1小时并赋值给变量time。
6 将确认订餐的信息抓取后以字典的形式赋值给 form_data(包括订单 ID、订餐人姓名、订餐人手机号、送餐地址、预定到达时间、支付方式),其中订单 ID 是之前提交订单获取的,手机号则是直接取自用户的账号,预定时间是将当前时间加上1小时计算出来的。
7 用requests的post方法带着订单需要的信息和cookies发送确认订单请求。
8 获取请求返回的主体并赋值给变量res。
9 增加一个断言,如果返回代码的主体为success,则继续执行后面的代码,如果返回代码的主体不为success,说明确定订单未成功,则终止后面的代码执行,并在运行结果报错。
10 打印结果“订餐成功”。
完成了主要的订单流程之后,就可以开始解决短信验证码的问题了,为了代码的清晰和便于理解,用一个函数来处理整个短信验证码模块,然后再通过判断结构嵌套每个具体功能函数。
1 def sms():
2 result = ask_sms()
3 if result == "{'status': 'ok','need_sms':False}":
4 return
5 else:
6 request_sms()
7 code = get_sms()
8 validate_sms(code)
代码说明:
1 自定义sms()函数。
2 通过ask_sms()来获取是否需要用短信验证码,并将结果赋值给变量result。
3~4 if判断当result的need_sms字段是False时,说明不需要短信验证码,则退出sms()函数并执行其他代码。
5~8 判断当result的need_sms字段不是False时,说明需要短信验证码,则执行request_sms()请求发送短信到手机,再通过get_sms()函数获取短信验证码并赋值给变量code,最后将code传入validate_sms()函数中发送验证请求。
完成了短信验证码处理的结构搭建,剩下的就是完成每个函数所要处理的事情。
1 def ask_sms():
2 global c
3 global username
4 url = "http://www.xxx.com/ajax/is_order_need"
5 form_data = {"mobile":username}
6 ask_response = requests.post(url,data = form_data,cookies = c)
7 res = ask_response.text
8 return res
代码说明:
1 自定义ask_sms()函数。
5 将询问是否需要发送短信的手机号信息赋值给form_data。
6 用requests的post方法带着手机号以及cookie发送是否需要发送短信验证码的请求。
7 获取请求返回的主体并赋值给变量res。
8 返回是否需要发送短信验证码res的值。这里无法增加断言,因为返回的结果是不固定的,没法做判断。
接着处理请求发送验证码到手机的请求:
1 def request_sms():
2 global c
3 global username
4 url = "http://www.xxx.com/ajax/common_sms_code/"
5 form_data = {"mobile":username}
6 sms_response = requests.post(url,data = form_data,cookies = c)
7 res = sms_response.text
8 assert res == "True"
代码说明:
1 自定义request_sms()函数。
5 将需要发送短信的手机号信息赋值给form_data。
6 用requests的post方法带着手机号以及cookie发送短信验证码的请求。
7 获取请求返回的主体并赋值给变量res。
8 增加一个断言,如果返回代码的主体为True,则继续执行后面的代码,如果返回代码的主体不为True,说明确定短信验证码未发送成功,则终止后面的代码执行,并在运行结果报错。
接着再来处理获取短信验证码的请求,一般会有后台可以查询短信验证码,所以获取的方式就是登录后台,并通过接口查询指定手机号的短信验证码,这里把登录和获取短信放在一个函数里面,以方便cookie的传递。
1 def get_sms():
2 global username
3 url = "http://www.xxx.com/manager/login.action"
4 form_data = {"user":"admin","pwd":000000}
5 login_response = requests.post(url,data = form_data)
6 cookie = login_response.cookies
7 url2 = "http://www.xxx.com/manager/smsmanager"
8 form_data2 = {"phone":username}
9 code_response = requests.post(url2,data = form_data2,cookies = cookie)
10 code = code_response.text
11 assert code!= ""
12 return code
代码说明:
1 自定义get_sms()函数。
4 将后台的登录账号和密码赋值给form_data。
5 用requests的post方法带着账号和密码以及Cookie发送后台登录的请求。
6 获取登录后的Cookie并赋值给变量Cookie。
8 将需要查询短信验证码的手机号码赋值给form_data2。
9 用requests的post方法带着手机号码发送查询短信验证码的请求。
10获取查询结果并赋值给变量code。
11增加一个断言,如果查询的code不为空,则继续执行后面的代码;如果查询的code为空,说明未查到短信验证码,则终止后面的代码执行,并在运行结果报错。
12 返回短信验证码code的值。
得到了验证码之后就是验证功能的处理:
1 def validate_sms(code):
2 global c
3 global username
4 url = "http://www.xxx.com/ajax/validate_sms_code/"
5 form = {"mobile":username,"sms_code":code}
6 validate_response = requests.post(url,data = form,cookies = c)
7 res = validate_response.text
8 assert code == "True"
代码说明:
1 自定义validate_sms ()函数,并带上参数code,把get_sms()返回code通过形式参数传递进来。
5 将手机号码和对应的验证码赋值给form_data。
6 用requests的post方法带手机号和验证码以及Cookie发送短信验证码校验请求。
7 获取校验结果并赋值给变量res。
8 增加一个断言,如果校验结果res为True,则继续执行后面的代码;如果校验结果res不为True,说明未查到短信验证码,则终止后面的代码执行,并在运行结果报错。
5. 实例完整代码
完成了各个功能处理的函数,最后将其拼接起来,就可以组成一个完整的自动化测试,这里是完整的代码。
# -*- coding: utf8
import requests
import hashlib
from datetime import *
import json
username = "13999999999"
password = hashlib.md5(b"123456").hexdigest()
url = "http://www.xxx.com/ajax/user_login/"
form_data = {"username":username,"password":password}
login_response = requests.post(url, data = form_data)
assert login_response.text == "success"
c = login_response.cookies
def make_order():
global c
url = "http://www.xxx.com/ajax/create_order/"
form_data = {"restaurant_id":11196, "menu_items_total":"12.00",
"menu_items_data":"[{'id':1653196,'p':'2','q':6 }]","delivery_fee":"3.00"}
make_response = requests.post(url, data = form_data,cookies = c)
res = make_response.text
id=json.loads(res)["order_id"]
assert id!= ""
return id
def place_order(id):
global c
global username
time = datetime.now()+timedelta(hours=1)
url = "http://www.xxx.com/ajax/place_order/"
form_data = {"order_id":id,"customer_name":"xxxx",
"mobile_number":username,"delivery_address":"xxxxxxx",
"preorder":"yes", "preorder_time":time,"pay_type":"cash"}
place_response = requests.post(url,data = form_data,cookies = c)
res = place_response.text
assert res == "success"
print ("订餐成功")
def sms():
result = ask_sms()
if result == "{'status': 'ok','need_sms': False}":
return
else:
request_sms()
code = get_sms()
validate_sms(code)
def ask_sms():
global c
global username
url = "http://www.xxx.com/ajax/is_order_need"
form_data = {"mobile":username}
ask_response = requests.post(url,data = form_data,cookies = c)
res = ask_response.text
return res
def request_sms():
global c
global username
url = "http://www.xxx.com/ajax/common_sms_code/"
form_data = {"mobile":username}
sms_response = requests.post(url,data = form_data,cookies = c)
res = sms_response.text
assert res == "True"
def get_sms():
global username
url = "http://www.xxx.com/manager/login.action"
form_data = {"user":"admin","pwd":000000}
login_response = requests.post(url,data = form_data)
cookie = login_response.cookies
url2 = "http://www.xxx.com/manager/smsmanager"
form_data2 = {"phone":username}
code_response = requests.post(url2,data = form_data2,cookies = cookie)
code = code_response.text
assert code!= ""
return code
def validate_sms(code):
global c
global username
url = "http://www.xxx.com/ajax/validate_sms_code/"
form = {"mobile":username,"sms_code":code}
validate_response = requests.post(url,data = form,cookies = c)
res = validate_response.text
assert code == "True"
if __name__ == "__main__":
id = make_order()
sms()
place_order(id)
几点注意事项如下:
- 函数的定义没有先后顺序,只有执行有先后顺序。
- 断言即判断实际结果是否和预期结果一致,如果不一致,则中断整个程序的执行,因为后面的程序都没有执行的必要了,而且会打印出不一致的地方,以便可以快速定位到哪一步执行结果出错,方便查询问题。
- 函数之间参数传递,需要将函数执行结果赋值给某一个参数,并在另外的函数之中定义形式参数,并最终将实际参数赋值到函数内的参数之中。
4、接口自动化测试实战
基于接口的自动化测试可以分为两类。
1. 没有关联性的多个测试用例的自动化测试,即多个独立接口的测试,可以利用 UnitTest自动化框架,把多个接口测试放在一个测试套件中逐个执行,其中每个接口的测试结果不会影响到其他接口的测试结果,最后统计出成功和失败的数量。
2. 有关联性的多个测试用例的自动化测试,这涉及数据的关联和传递,需要自己通过结构化设计把每个测试用例有效地串联起来,但其中只要有任意一步出错,即无法继续往下一步测试,相对来说对代码的编写要求较高。
实战1:
1. 测试接口选择
项目有几十个或者几百个接口。一方面,不可能对每一个接口都做自动化测试,所以要分主要接口和非主要接口;另一方面,也不是每一个接口都适合自动化测试,所以要分稳定接口和不稳定接口;还有一方面,有些接口只能调用一次,所以要分可重复执行接口和不可重复执行接口。当然还会有一些其他的因素影响接口的选择,要根据实际情况进行筛选。
由于实际接口太多,先筛选出主要的接口,然后对这些接口逐个分析,来确定最后需要自动化测试的接口。
1)注册接口
这是一个重要的接口,但却是不可重复执行的接口,因为同样的数据第二次是无法注册成功的,所以这个接口不适合做这类自动化。
2)登录接口
这个接口是可以重复执行的,而且相对来说也是不太会改动的接口,所以这个接口适合做这类自动化。
3)用户信息查询
这个接口可以反复请求,适合做这类自动化。
4)充值接口
这个接口涉及银行的金钱出入,而在测试环境一般不会提供这样的功能,都是通过一个虚拟银行流水来完成充值的,所以这个接口测试也没有任何意义。
5)提现接口
这个接口同上一个接口一样没有测试意义。
6)购买理财产品接口
这个接口虽然也很重要,但在于只能购买同一个产品一次,所以也不适合做这类自动化。
7)赎回理财产品接口
由于不能重复买也就不能重复赎回,所以不适合做这类自动化。
8)用户流水查询接口
这个接口可以反复请求,适合做这类自动化。
9)平台产品查询接口
这个接口可以反复请求,适合做这类自动化。
10)用户余额查询接口
这个接口可以反复请求,适合做这类自动化。
通过上面对各个主要接口的分析,会发现大多数能做这类自动化的接口都是查询接口,因为不涉及对数据库的改动,仅仅是查询是可以重复操作的,而且返回的数据也是相同的,便于对返回结果的判断验证。虽然有些接口不适合做这类自动化,但可以通过第二类自动化测试完成
2. 测试框架构建
完成了接口的选择,就可以套用UnitTest的测试框架了,之前已经对UnitTest做过介绍,因此直接在框架中定义测试接口就行了。
class port_test(unittest.TestCase):
def setUp(self):
测试固件:
def test_login(self):
用户登录接口的测试代码:
def test_product(self):
理财产品查询接口的测试代码:
def test_personinfo(self):
用户信息查询接口的测试代码:
def test_flow(self):
用户流水查询接口的测试代码:
def test_amount(self):
用户余额查询接口的测试代码:
def suite():
loginTestCase = unittest.makeSuite(port_test,"test")
return loginTestCase
if __name__ == "__main__":
runner = unittest.TextTestRunner()
runner.run(suite())
通过UnitTest自动化框架的模板套用,只需要在class中定义测试函数就可以了,如果还需要再增加测试接口,即可以直接在class中加测试函数,也可以再加一个class并在这个class中增加测试函数就行了。二者唯一的差别在于需要在 suite 中再增加一个测试的 class,其他框架代码都是通用的。
3. 测试代码编写
完成了UnitTest的自动化框架套用,因为该项目的接口地址都是一个,只是通过post的参数不同来区分调用哪个接口,所以在测试固件中可以初始化接口公用的地址。
def setUp(self):
self.url = "http://www.xxx.com/server.html
然后就可以开始接口测试的函数编写了。
首先是用户登录接口的测试函数,先不考虑测试异常情况,只需要输入正确的账号密码来测试接口是否能正常登录即可。
1 def test_login(self):
2 password = hashlib.md5(b"123456").hexdigest()
3 form = {"username":13111111111,"password":password,"serverid":1100001}
4 r = requests.post(self.url,data = form)
5 self.assertEqual(r.text["flag"],"success")
代码说明:
1自定义登录接口测试函数test_login()。
2对密码做md5加密并赋值给变量password。
3将登录接口所需要的参数赋值给变量form。
4 通过post方法向登录接口发送登录请求。
5 通过断言方法验证结果,当返回的主体中的key为 flag所对应的value等于success时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。
继续编写理财产品查询接口的测试函数,这个接口可以查询各种类型产品,先不考虑各种类型产品的单独查询或者带查询条件的查询,只查询全部产品。
1 def test_product(self):
2 form = {"SERVERID":13000005,"PRODUCT_TYPE_ID":0,"PRODUCT_STATE":1,APPLY_INTEREST":"","APPLY_INTEREST":"","APPLY_AMOUT":""}
3 r = requests.post(self.url,data = form)
4 self.assertEqual(r.text["total"],9)
代码说明:
1自定义理财产品查询接口的测试函数test_product()。
2将登录接口所需要的参数赋值给变量form。
3通过post方法向理财产品查询接口发送查询请求。
4通过断言方法验证结果,当返回的主体中的key为total所对应的value等于9时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。(返回的结果包括所有产品信息,其实只需要验证某一个字段即可,这里验证的是产品总数这个字段,而且测试的时候要保证测试环境的产品数量保持不变。)
再来编写用户信息查询接口测试函数,这个只需要指定某一个账号能查询到用户个人信息即可,可以任意取一个字段作为验证,就以名字作为验证的字段。
1 def test_personinfo(self):
2 form = {"SERVERID":999999,"PHONE":13111111111}
3 r = requests.post(self.url,data = form)
4 self.assertEqual(r.text["REALNAME"],"张三")
代码说明:
1自定义用户信息查询接口测试函数test_personinfo()。
2将用户信息查询接口所需要的参数赋值给变量form。
3通过post方法向用户信息查询接口发送查询请求。
4通过断言方法验证结果,当返回的主体中的key为REALNAME所对应的value等于张三时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。(返回的结果包括所有用户信息,传入某个账号其对应的用户信息都是固定的,这里也只需要验证真实姓名字段即可。)
接着编写用户流水查询接口的测试函数,也是指定某一个账号查询用户的流水记录,指定验证一个流水总数的字段即可。
1 def test_flow(self):
2 form = {"SERVERID":60000001,"FLOW_TPYE":0}
3 r = requests.post(self.url,data = form)
4 self.assertEqual(r.text["total"],75)
代码说明:
1定义用户余额查询接口测试函数test_flow()。
2将用户流水查询接口所需要的参数赋值给变量form。
3通过post方法向用户流水查询接口发送查询请求。
4通过断言方法验证结果,当返回的主体中的key为total所对应的value等于75时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。(返回的结果包括所有流水信息,其实只需要验证某一个字段即可,这里验证的是流水记录总数这个字段,而且测试的时候要保证测试环境的该用户的流水记录总数保持不变。)
最后编写用户余额查询接口的测试函数,也是指定某一个账号查询其平台余额,验证金额字段即可。
1 def test_amount(self):
2 form = {"username":60000007,"PHONE":13111111111}
3 r = requests.post(self.url)
4 self.assertEqual(r.text["KTAMOUNT"],1340.2)
代码说明:
1定义用户余额查询接口测试函数test_amount()。
2将用户余额查询接口所需要的参数赋值给变量form。
3通过post方法向用户余额查询接口发送查询请求。
4通过断言方法验证结果,当返回的主体中的key为KTAMOUNT所对应的value等于1340.2时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。(测试的时候要保证测试环境的该用户的余额保持不变。)
4. 输出测试报告
可以使用UnitTest自动化框架的HTMLTestRunner模块来输出测试报告。自动化测试会重复执行,测试报告的名字却不能重复,因此在输出测试报告的名字时需要用到datetime模块,通过取当前时间作为测试报告的名字,这样就不会出现测试报告名字重复而覆盖的问题。
1 now = date.today()
2 report = open(str(now)+".html","wb")
3 runner = HTMLTestRunner.HTMLTestRunner(stream=report,title="测试报告",description="测试报告详情")
4 runner.run(suite())
代码说明:
1 获取当前日期。
2 新建一个测试报告文件,并将日期转化成字符串作为输出报告的名字,赋值给变量report。
3 通过HTMLTestRunner方法建立一个测试套件,并将结果输出到以report定义的文件上。
4运行测试套件。
随着要测试的接口越来越多,最好在测试报告中对每一个测试用例加上说明,这样可以让测试报告展示得更详尽,哪个测试函数代表了哪个功能的测试,只需要通过注释功能对函数进行说明。
def xxx():
"""注释"""
测试代码
在测试用例中加了断言的判断,因此当测试用例通过时会在详情中显示pass,而当有一个测试用例未通过时,则会显示fail,并将断言中的预期结果和实际结果打印出来。
通过这样的测试报告就能很直观地看到测试结果,通过多少,未通过多少,未通过的测试用例预期结果是什么都可以一目了然,这样就得到了接口自动化测试所想要的结果。
5. 实例完整代码
#-*- coding: UTF-8 -*
import unittest
import requests
import json
import HTMLTestRunner
import hashlib
from datetime import *
class port_test(unittest.TestCase):
def setUp(self):
self.url = "http://www.xxx.com/server.html "
def test_login(self):
"""用户登录接口的测试"""
password = hashlib.md5(b”123456”).hexdigest()
form = {"username":13111111111,"password":password,"serverid":1100001}
r = requests.post(self.url,data = form)
self.assertEqual(r.text["flag"],"success")
def test_product(self):
"""理财产品查询接口测试"""
form = {"SERVERID":13000005,"PRODUCT_TYPE_ID":0,"PRODUCT_STATE":1,
APPLY_INTEREST":"","APPLY_INTEREST":"","APPLY_AMOUT":""}
r = requests.post(self.url,data = form)
self.assertEqual(r.text["total"],9)
def test_personinfo(self):
"""个人信息查询接口的测试"""form = {"SERVERID":999999,"PHONE":13111111111}
r = requests.post(self.url,data = form)
result = r.text["REALNAME"]
self.assertEqual(result,"张三")
def test_flow(self):
"""平台流水查询接口的测试"""
form = {"SERVERID":60000001,"FLOW_TPYE":0}
r = requests.post(self.url,data = form)
result = r.text["total"]
self.assertEqual(result,75)
def test_amount(self):
"""用户余额查询接口的测试"""
form = {"username":60000007,"PHONE":13111111111}
r = requests.post(self.url)
result = r.text["KTAMOUNT"]
self.assertEqual(r.status_code,1340.2)
def suite():
loginTestCase = unittest.makeSuite(port_test,"test")
return loginTestCase
if __name__ == "__main__":
now = date.today()
report = open(str(now)+".html","wb")
runner = HTMLTestRunner.HTMLTestRunner(stream=report,title="测试报告",description="测试报告详情")
runner.run(suite())
运行完成之后找到对应日期的HTML:
几点注意事项如下。
- 测试用例的执行顺序是按照定义函数名的字母先后顺序执行的,并非按照定义的函数顺序执行,通过测试报告可以看到这个差异。
- UnitTest框架中的断言是独立存在的,即有一个断言失败也不会影响程序的继续执行,这是区别于在自动化测试中用Python自带的断言功能的地方。
- 实例中只是简单地介绍用UnitTest自动化框架完成接口测试,只做了正常接口的测试,未加入异常参数的输入,实际编写代码中还需要将每个接口的各种情况都加进去,最好的方式就是一个接口一个class,然后接口的各种情况分别定义不同的测试函数,最后通过测试套件组合将每一个class加入执行,就完成了完整的这类接口自动化测试。
实战2:
这个实例完全是通过自己的结构设计、自己的编码完成自动化测试,这类自动化测试自由度很高,但对测试人员的设计和编码要求也相对要高,是一种更高级的自动化测试。
1. 测试用例选择
不同于之前独立接口的自动化测试,对于这种自动化测试的用例选择余地可以更大,因此可以用主要功能测试的角度去选择自动化测试用例。
理财项目的主要功能如下:
(1)登录理财平台。
(2)绑定银行卡。
(3)通过银行卡充值。
(4)购买理财产品。
(5)理财产品的利息计算。
(6)赎回理财产品。
(7)平台金额提现到银行卡。
(8)平台交易流水查询。
那么就一个个来分析对于这些功能用例的选择。
- 登录理财平台
这个是必不可少的功能,因为大多数功能都是在登录之后才能操作的,只需要选择正确的账号密码登录,获取登录完成后的cookie用于其他接口的调用。
- 绑定银行卡
虽然这是一个很重要的功能,但由于测试环境无法与实际的银行接口进行数据传输,即便可以模拟银行接口绑定结果,也不是真实的场景,做了这个功能也没有实际测试意义,所以这个没必要加入自动化测试。
- 通过银行卡充值
和绑定银行卡功能一样,由于模拟的充值并非真实的场景,失去了测试的意义,所以这个功能也没必要加入自动化测试。
- 购买理财产品
这是平台最重要的功能,之前实例中因为涉及同一产品只能购买一次,所以不能被选择,但是可以通过另外一种方式去规避这种限制,就是每次新建一个理财产品,然后去购买新建的理财产品,所以这个功能是可以通过这种自动化实现的。
- 理财产品的利息计算
这其实也是相当重要的功能,因为利息的计算也是理财产品的核心,但由于利息的计算是需要跨天的,而自动化测试不可能去执行多天,再加上利息的计算也相对比较复杂,不同的产品有不一样的利息计算方式,从验证难度上不太适合做自动化测试。
- 赎回理财产品
这个功能和购买理财产品同样重要,由于产品的特性是可以随买随赎的,而且利息的计算不需要加入自动化测试,就可以在购买完产品之后再进行赎回,所以这个功能也可以通过这种自动化实现。
- 平台金额提现到银行卡
和充值功能一样,由于模拟的提现并非真实的场景,失去了测试的意义,所以这个功能也没必要加入自动化测试。
- 平台交易流水查询
这也是平台对账的重要功能,由于不做银行卡的充值和提现到银行卡的功能,能测试的查询流水只能局限于购买理财产品和赎回理财产品,准确来说只能完成部分的流水查询。
通过分析已经筛选出可以做自动化测试的用例,下一步就可以构建测试流程设计了。
2. 测试流程设计
流程设计可以分为2个,一个用于后台新建产品,另外一个用于前台购买/赎回产品,两者是需要相互联系的,需要获取后台建完产品的ID号,然后前台购买/赎回产品的时候填入该产品号,这样就能完成对指定产品的购买/赎回,从而保证每次只处理新建产品,规避了每个产品只能买一个的限制。
先看后台的新建产品的机制。首先要登录后台,然后通过创建理财产品的接口传入产品需要的参数,新建产品成功后获取产品ID,由于为了确保产品的正确性,还有产品审核机制,所以还需要对产品审核,只有在通过后产品才可以在前台显示并购买。
后台设计如图所示。
接着来看前台功能。首先还是要登录前台,然后把后台新建产品的 ID 传递过来放入购买产品的参数之中,购买成功后查看购买产品的流水,因为产品可以随买随赎,所以只需要确保购买成功后就能赎回,通过把之前的产品 ID 传递过来放入赎回产品的参数之中,赎回成功后查看赎回产品的流水。这样就完成了整个自动化所需要一套测试流程,前台设计如图所示。
这2个流程可以分别用2个测试脚本完成,然后通过一个主脚本导入这2个测试脚本,最后调用脚本中定义的测试函数进行组合。
product.py 后台新建产品脚本
operate.py 前台功能脚本
test.py 主测试程序
3. 测试环境准备
这是一个顺序结构的自动化测试,都是有相互关联的,因此有一个测试点出错就会中断整个测试,导致测试不成功,所以在编写代码之前先要准备测试数据以及测试环境。
首先需要在测试环境做一次完整的手动测试,然后通过抓包获取每个请求的所需要的参数以及对应的数据结构,接着还要分析请求返回的结果,一方面用于参数的传递,另一方面用于最后断言的判断,这样就将自动化测试的数据准备完成了。
一旦这些数据获取并确定之后,就要开始测试环境的准备了。
1)后台的测试环境
保证没有人新建产品和审核产品,以及涉及产品的其他操作,比如产品状态的修改等。这样保证了产品在购买时不会因为产品某些限制而不能购买。
2)前台的测试环境
首先要准备一个有足够金额的账号,并且该账号不能用于其他测试,保证金额足够购买产品,并且不会有无关的流水插入影响最后的断言判断。
然后就是关闭图片验证码功能,这个功能不同于短信验证码可以获取,一旦开启了,则自动化测试就无法执行下去。
不要执行购买产品的测试,因为购买产品会占有产品的剩余额度,当购买金额超过剩余额度就会导致无法购买产品。
这些就是测试数据和测试环境的准备工作,接着就可以开始编写测试代码了。
4. 测试代码编写
首先编写新建理财产品的代码product.py。
先做的自然还是后台的登录:
1 url = "http://www.xxx.com/manager/login.action"
2 form_data={"userId":"admin","pwd":000000}
3 login_response= requests.post(url,data=form_data)
4 c =login_response.cookies
代码说明:
1~4这就是常规的登录代码,不再重复说明了。
接着就是后台新建产品的功能了,审核产品的数据需要复用,因此为了方便就写在一个函数之中,先来新建产品。
1 def make_product():
2 global c
3 now = date.today()
4 userid = "9999I99" #额度id
5 apply_no = "BF7I170929001" #额度编号
6 apply_borrow_acount = 100000000.00 #额度金额
7 apply_interest = 10.00 #产品收益利率
8 borrow_amount = 300 #产品总额
9 interest_start = 100 #产品最低金额
10 touzilimit = 300 #产品投资金额上限
11 ownedcorrected = 100.00 #产品递增金额
12 p_name = "理财"+str(now) #产品名字
13 p_type = 1 #产品类型
14 p_date = str(now) #产品申请时间
15 p_raise_day = 10 #购买产品的天数
16 p_day = 30 #产品的到期的天数
17 p_raise_hour = p_day*24 #购买产品的小时数
18 raise_enddate = str(now+timedelta(days = p_raise_day)) #购买产品截止时间
19 apply_enddate = str(now+timedelta(days = p_raise_day+p_day)) #产品的到期时间
20 huodong = -1 #是否产参加活动-1无活动
21 hb = 1 #是否可用红包1可用 2不可用
22 url = "http://www.xxx.com/pytManager/service.html?SERVERID=7000009"
23 form_data = {"USERID":userid,"APPLY_NO":apply_no,"JieKuanRen":"理财—借款
1","JKRIdCard":"","APPLY_ENDDATE":"2020-09-24","APPLY_ENDDATE1":apply_enddate,"PPL Y_BORROW_AMOUNT":apply_borrow_acount,"LOWEST_BORROW_AMOUNT":10000,"PRODUCT_TYPE_CODE":"X","PRODUCT_LIMIT_TYPE_ID":2,"PRODUCT_LIMIT_TYPE_NAME":"固定期限","PAYBACK_ TYPE":1,"PAYBACK_TYPE_NAME":"按年付息到期","FORRISK_RATE":0,"SERVICEFEE_RATE":0,"BAOZHENGJIN_MONTHS":0,"EXPECTED_YIELD":0,"EXPECTED_YIELD_NOTE":"","DANBAOJIN_RATE":4,"PRODUCT_TYPE_CODE":"X","PRODUCT_TYPE_ID":p_type,"APPLY_INTEREST":apply_interest,"PRODUCT_ID":"","P RODUCT_NAME":p_name,"BORROW_AMOUNT":borrow_amount,"BORROW_AMOUNT_CN":"","BORROW_ENDDAY":p_day,"BORROW_MONTH_TYPE":1,"INVEST_START":interest_start,"INVEST_START_CN":"","TouZ hiLimit":touzilimit,"TouZhiLimit_CN":"","Product_pub_date":p_date,"ProductValideDate":p_raise_hour,"RAISE_END_TIME":raise_enddate ,"OwnedCorrected":ownedcorrected,"OwnedCor rected_CN":"","PAY_ACCOUNT_NO":"F1000621","ZHUANRANG_LIMIT":0,"ZHUANRANG_ENDDAY":0,"BA OHAN_NO":111,"BD_NAME":111,"TOUZIXIANZHI":"","HUODONG":huodong,"SELECTTIONRANGE":"","T OUZHI_GUANXI":2,"CAPITAL_FROM":"","DANBAO_ORGID":4,"HB_USE":hb,"PRODUCT_DESC":""}
24 r = requests.post(url,data = form_data,cookies=c)
25 msg = json.loads(r.text)
26 assert msg["MSG"] == "产品录入成功,请尽快审核!"
27 pid = msg["PRODUCT_ID"]
代码说明:
1自定义新建产品的函数make_product()。
2引用全局变量c。
3取当前日期赋值给变量now,之后在新建产品参数之中需要用到。
4~21 给产品需要的参数进行变量的赋值,其中用到了产品的名称,产品的开始日期需要通过now这个变量来赋值,而产品的结束日期需要对日期做加法来决定。因为参数指定要传入字符串类型,所以还需要通过str函数把日期转换成字符串。
23将新建产品URL地址赋值给变量URL。
22将新建产品所需要的参数赋值给变量form。
24通过post方法发送新建产品的请求。
25获取请求结果,并将结果主体转换成字典赋值给变量msg。
26通过断言方法验证结果,当返回的主体中的key为MSG所对应的value等于"产品录入成功,请尽快审核!"时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。
27获取请求结果中的产品ID,并赋值给变量pid。
然后将获取的 pid 参数放到审核产品的请求参数之中,并将部分产品的参数传入作为审核的依据。
1 url2 = "http://www.xxx.com//pytManager/service.html"
2 form_data2 = {"SERVERID":7000117,"PRODUCT_AUDIT_DESC":"t","USERID":userid, "PRODUCT_ID":pid,"APPLY_BORROW_AMOUNT":apply_borrow_acount,"APPLY_NO":apply_no,"BORROW_AMOUNT":borrow_amount,"BAOZHENGJIN_MONTHS":0,"PRODUCT_STATE":1}
3 r2 = requests.post(url2,data=form_data2,cookies=c)
4 assert r2.text == "操作成功!"
5 return pid
代码说明:
1将审核产品URL地址赋值给变量URL。
2将审核产品所需要的参数赋值给变量form,由于部分参数可以重复调用,不需要重新增加变量,而产品id已经获取,直接放到form之中。
3通过post方法发送审核产品的请求。
4通过断言方法验证结果,当返回的主体中等于"操作成功"时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。
5函数最后返回产品ID,以便在后续的测试之中调用。
完成了后台新建产品之后会把产品ID返回,之后购买产品的时候再传入产品ID这个参数。
接着编写前台操作的代码operate.py。
先做的自然还是前台的登录:
1 url = "http://www.xxx.com/login.action"
2 form_data = {"username":"13511111111","pwd":"zeronicky336"}
3 login_response = requests.post(url,data=form_data)
4 c = login_response.cookies
代码说明:
1~4这就是常规的登录代码,不再重复说明了。
然后就可以定义后续操作的代码编写了,首先做的是购买理财产品。
1 def buy_product(pid):
2 global c
3 url = "http://www.xxx.com/service.html"
4 form = {"SERVERID":76000001,"PRODUCT_ID":pid,"INVEST_AMOUNT":100.00}
5 r = requests.post(url,data = form,cookies = c)
6 msg = json.loads(r.text)
7 assert msg["MSG"] == "投资成功!"
代码说明:
1自定义购买产品的函数buy_product(),并定义pid参数,这是一个形式参数,用于赋值到函数内部使用,主要用于购买产品的接口参数。
2引用全局变量c。
3将购买产品URL地址赋值给变量URL。
4将购买产品所需要的参数赋值给变量form,其中包括产品ID以及购买的产品的金额。
5通过post方法发送购买产品的请求。
6获取请求结果,并将结果主体转换成字典赋值给变量msg。
7 通过断言方法验证结果,当返回的主体中的 key为 MSG所对应的 value 等于"投资成功"时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。
买完理财产品之后就可以通过接口查询到购买的流水:
1 def buy_record(pid):
2 global c
3 url = "http://www.xxx.com/service.html"
4 form = {"SERVERID":100021,"FLOW_TYPE_ID":1}
5 r = requests.post(url,data = form,cookies = c)
6 msg = json.loads(r.text)
7 assert msg["rows"][0]["PRODUCT_ID"]==pid
代码说明:
1自定义购买产品的函数buy_record (),并定义pid参数,这是一个形式参数,用于赋值到函数内部使用,主要用于结果的验证。
2引用全局变量c。
3将查询购买产品流水的URL地址赋值给变量URL。
4将查询购买产品流水所需要的参数赋值给变量form,其中包括需要查询的流水类型。
5通过post方法发送查询购买产品流水的请求。
6获取请求结果,并将结果主体转换成字典赋值给变量msg。
7通过断言方法验证结果,返回的结果会是所有的购买产品流水,但一般会以倒叙排列,因此可以取流水的第一条。而返回的数据是一个复合的数据结构,通过一层层的数据获取最后得到产品ID,只需要验证流水的产品ID等于购买的产品ID就可以认为产生流水并且正常,如果不等于,则测试不通过,并在测试结果中显示错误原因。
只要购买理财产品成功后就可以进行赎回操作:
1 def redeem_product(pid):
2 global c
3 url = "http://www.xxx.com/service.html"
4 form = {"SERVERID":100017,"PRODUCT_ID":pid,"SHMONEY":100.00,"shFlag":"all"}
5 r = requests.post(url,data = form,cookies = c)
6 msg = json.loads(r.text)
7 assert msg["FLAG"] == "T"
代码说明:
1自定义购买产品的函数buy_record(),并定义pid参数,这是一个形式参数,用于赋值到函数内部使用,主要用于赎回产品的接口参数。
2引用全局变量c。
3将赎回产品的URL地址赋值给变量URL。
4将赎回产品所需要的参数赋值给变量form,其中包括需要对应的产品ID以及赎回金额,赎回金额不能大于购买金额,这里为了环境的干净就赎回全部的金额。
5通过post方法发送赎回产品的请求。
6获取请求结果,并将结果主体转换成字典赋值给变量msg。
7通过断言方法验证结果,当返回的主体中的key为FLAG所对应的value等于"T"时测试通过,如果不等于,则测试不通过,并在测试结果中显示错误原因。
最后就是查询赎回产品的流水,同购买流水一样的接口,只是换一个查询参数而已。
1 def redeem_record(pid):
2 global c
3 url = "http://www.xxx.com/service.html"
4 form = {"SERVERID":100021," FLOW_TYPE_ID":2}
5 r = requests.post(url,data = form,cookies = c)
6 msg = json.loads(r.text)
7 assert msg["rows"][0]["PRODUCT_ID"] == pid
代码说明:
1 自定义购买产品的函数redeem _record (),并定义pid参数,这是一个形式参数,用于赋值到函数内部使用,主要用于结果的验证。
2 引用全局变量c。
3 将查询赎回产品流水的URL地址赋值给变量URL。
4 将查询赎回产品流水所需要的参数赋值给变量form,其中包括需要查询的流水类型。
5 通过post方法发送查询购买产品流水的请求。
6 获取请求结果,并将结果主体转换成字典赋值给变量msg。
7 通过断言方法验证结果,返回的结果会是所有的赎回产品流水,但一般会以倒叙排列,因此可以取流水的第一条。而返回的数据是一个复合的数据结构,通过一层层的数据获取最后得到产品ID,只需要验证流水的产品ID等于购买的产品ID就可以认为产生流水并且正常,如果不等于,则测试不通过,并在测试结果中显示错误原因。
完成了每个部分的代码编写,最后是代码的整合了,新建一个py文件用于整合前面2个脚本文件。
#-*- coding: UTF-8 -*
1 import product
2 import operate
3 from time import sleep
4 from datetime import *
5 if __name__ == "__main__":
6 pid = product.make_product()
7 operate.buy_product(pid)
8 sleep(10)
9 operate.buy_record(pid)
10 operate.redeem_product(pid)
11 sleep(10)
12 operate.redeem_record(pid)
13 logfile = open("G:\\python\\res.txt", "a")
14 logfile.write(str(date.today()):"+"测试成功"+"\n")
15 logfile.close()
代码说明:
1导入之前编写的后台新建产品的脚本文件。
2导入之前编写的前台操作测试的脚本文件。
3导入time模块的sleep函数。
4导入datetime模块的所有函数。
6执行新建产品的函数,并把返回的结果赋值给变量pid,这个pid和函数内的pid是2个变量,只是为了代码的易读性,因为这是局部变量,只在函数内生效,所以不存在冲突。
7执行购买产品的测试函数,并将pid的实际值传入到函数之中。
8 因为购买产品之后需要后台队列的异步处理,等待一会才能产生流水,如果即刻去查询未必会有流水,因此增加10秒的等待确保后台的队列处理完成。
9执行查询购买产品流水的测试函数,并将pid的实际值传入到函数之中。
10执行购买产品的测试函数,并将pid的实际值传入到函数之中。
11同样赎回产品之后也是后台队列的异步处理,增加 10秒的等待确保后台的队列处理完成。
12执行查询购买产品流水的测试函数,并将pid的实际值传入到函数之中。
13~15将测试结果以及测试日期写入txt文件之中。
这样一个自动化代码就整合完成了,因为断言会中断程序运行,所以当测试报告文件为空时就说明中间有地方测试不通过,当然如果需要把测试不通过的地方也放入结果之中,则需要在断言之前将测试结果和预期结果写入报告之中,这样哪一步出错的话报告的输出就会停在那一步上,这个可以自行尝试,这里就不再做具体介绍了。
5. 实例完整代码
product.py:
-*- coding: UTF-8 -*
import json
import requests
from datetime import *
url = "http://www.xxx.com/manager/login.action"
form_data={"userId":"admin","pwd":000000}
login_response= requests.post(url,data=form_data)
c =login_response.cookies
def make_product():
global c
now = date.today()
userid = "9999I99" #额度id
apply_no = "BF7I170929001" #额度编号
apply_borrow_acount = 100000000.00 #额度金额
apply_interest = 10.00 #产品收益利率
borrow_amount = 300 #产品总额
interest_start = 100 #产品最低金额
touzilimit = 300 #产品投资金额上限
ownedcorrected = 100.00 #产品递增金额
p_name = "理财"+str(now) #产品名字
p_type = 1 #产品类型
p_date = str(now) #产品申请时间
p_raise_day = 10 #购买产品的天数
p_day = 30 #产品的到期的天数
p_raise_hour = p_day*24 #购买产品的小时数
raise_enddate = str(now+timedelta(days = p_raise_day)) #购买产品截止时间
apply_enddate = str(now+timedelta(days = p_raise_day+p_day)) #产品的到期时间
huodong = -1 #是否产参加活动-1无活动
hb = 1 #是否可用红包1可用 2不可用
url = "http://uatlc.ouyeelf.com/pytManager/service.html?SERVERID=7000009"
form_data = {"USERID":userid,"APPLY_NO":apply_no,"JieKuanRen":"理财—借款1","JKRIdCard":"","APPLY_ENDDATE":"2020-09-24","APPLY_ENDDATE1":apply_enddate,"PPLY_BORROW_AMOUNT":apply_borrow_acount,"LOWEST_BORROW_AMOUNT":10000,"PRODUC T_TYPE_CODE":"X","PRODUCT_LIMIT_TYPE_ID":2,"PRODUCT_LIMIT_TYPE_NAME":"固定期限","PAYBACK_TYPE":1,"PAYBACK_TYPE_NAME":"按年付息到期","FORRISK_RATE":0,"SERVICEFEE_RATE":0,"BAOZHENGJIN_MONTHS":0,"EXPECTED_YIELD":0,"EXPECTED_YIELD_NOTE":"","DANBAOJIN_RATE":4,"PRODUCT_TYPE_CODE":"X","PRODUCT_TYPE_ID":p_type,"APPLY_INTEREST":apply_interest,"PRODUCT_ID":"","PRODUCT_NAME":p_name,"BORROW_AMOUNT":borrow_amount,"BORROW_AMOUNT_CN":"","BORROW_ENDDAY":p_day,"BORROW_MONTH_TYPE":1,"INVEST_START":interest_start,"INVEST_START_CN":"","TouZhiLimit":touzilimit,"TouZhiLimit_CN":"","Product_pub_date":p_date,"ProductValideDate":p_raise_hour,"RAISE_END_TIME":raise_enddate ,"OwnedCorrected":ownedcorrected,"OwnedCorrected_CN":"","PAY_ACCOUNT_NO":"F1000621","ZHUANRANG_LIMIT":0,"ZHUANRANG_ENDDAY":0,"BAOHAN_NO":111,"BD_NAME":111,"TOUZIXIANZHI":"","HUODONG":huodong,"SELECTTIONRANGE":"","TOUZHI_GUANXI":2,"CAPITAL_FROM":"","DANBAO_ORGID":4,"HB_USE":hb,"PRODUCT_DESC":""}
r = requests.post(url,data = form_data,cookies=c)
r.encoding = "utf-8"
msg = json.loads(r.text)
print (msg["MSG"])
assert msg["MSG"] == "产品录入成功,请尽快审核!"
pid = msg["PRODUCT_ID"]
url2 = “http://www.xxx.com//pytManager/service.html”
form_data2 = {"SERVERID":7000117,"PRODUCT_AUDIT_DESC":"t","USERID":userid,"PRODUCT_ID":
pid,"APPLY_BORROW_AMOUNT":apply_borrow_acount,"APPLY_NO":apply_no,"BORROW_AMOUNT":
borrow_amoun,"BAOZHENGJIN_MONTHS":0,"PRODUCT_STATE":1}
r2 = requests.post(url2,data=form_data2,cookies=c)
assert r2.text == "操作成功!"
return pid
operate.py:
#-*- coding: UTF-8 -*
import requests
import json
url = "http://www.xxx.com/login.action"
form_data={"username": "13511111111", "pwd":"zeronicky336"}
login_response= requests.post(url,data=form_data)
c =login_response.cookies
def buy_product(pid):
global c
url = "http://www.xxx.com/service.html"
form = {"SERVERID":76000001,"PRODUCT_ID":pid,"INVEST_AMOUNT":100.00}
r = requests.post(url,data = form,cookies = c)
msg = json.loads(r.text)
assert msg["MSG"]=="投资成功!"
def buy_record(pid):
global c
url = "http://www.xxx.com/service.html"
form = {"SERVERID":100021,"PRODUCT_TYPE_ID":1}
r = requests.post(url,data = form,cookies = c)
msg = json.loads(r.text)
assert msg["rows"][0]["PRODUCT_ID"]==pid
def redeem_product(pid):
global c
url = "http://www.xxx.com/service.html"
form = {"SERVERID":100017,"PRODUCT_ID":pid,"SHMONEY":100.00,"shFlag":"part"}
r = requests.post(url,data = form,cookies = c)
msg = json.loads(r.text)
assert msg["FLAG"]=="T"
def redeem_record(pid):
global c
url = "http://www.xxx.com/service.html"
form = {"SERVERID":100022,"PRODUCT_TYPE_ID":1}
r = requests.post(url,data = form,cookies = c)
msg = json.loads(r.text)
assert msg["rows"][0]["SHUHUI_STATE_NAME"]=="申请中"
test.py:
#-*- coding: UTF-8 -*
import product
import operate
from time import sleep
from datetime import *
if __name__ == "__main__":
pid = product.make_product()
operate.buy_product(pid)
sleep(10)
operate.buy_record(pid)
operate.redeem_product(pid)
sleep(10)
operate.redeem_record(pid)
result = open("G:\\python\\res.txt", "a")
result.write(str(date.today()):"+"测试成功"+"\n")
result.close()
十、Python爬虫测试接口
爬虫技术是一门看似简单却可以很深入的技术,甚至在测试工作中也可用到。例如,Google、百度等公司的搜索引擎就是使用大规模分布式爬虫技术来采集网页和收录网站的,我们的搜索结果都是从爬虫爬取的数据中检索出来的。
1、爬虫测试简介
爬虫技术也可以用于测试,例如通过爬虫对测试页面进行采集和分析,对功能点进行冒烟测试。网络爬虫可以爬取Web站点的内容,对爬虫爬取的对应接口添加断言,便可进行自动化测试。通过循环不同的URL来抓取多个页面,便可将结果持久化以便进一步分析。
1. 爬虫测试的思路和流程
爬虫测试的核心在于爬虫,其流程大致如图所示。
(1)访问页面。可以使用requests库进行GET或者POST请求,访问页面资源。
(2)筛选元素和内容。针对返回的页面数据进行元素定位,可以使用BeautySoap 4或者正则匹配方式匹配出特定元素。例如,针对股票详情页收集该股票的开盘价、收盘价和量比等数据。
(3)持久化数据。根据收集到的数据,选择适合的持久化手段,如写入本地文件,或者使用关系型数据库写入相关表。持久化有利于后续的分析工作,使结果可视化有了数据基础。
(4)测试和断言。使用断言来判断爬取到的数据是否和预期的一致。根据断言结果来判断测试是否通过,以此发现功能缺陷和存在的逻辑问题。
以上步骤中的第(1)步可能遇到的问题是反爬虫策略。网站会对这种固定IP和浏览器信息高频访问某一页面的行为产生警觉,从而判断是非法爬虫并阻止爬虫爬取页面。反爬虫的策略非常多,如SSL证书验证、Reference(源地址)验证、IP限制、浏览器头信息验证,甚至还有MAC地址的限制等。
2. urllib库的使用
针对访问页面的实现方式有很多,最简单、易用的requests库,而urllib库可以提供更强大的定制化API功能。
urllib是Python 2.7自带的模块,无须下载,直接引入即可使用。Python 3.x后将urllib改为了urllib.request,它是Python内置的HTTP请求库,可以直接使用。urllib分为4个模块,即请求模块urllib.request、异常处理模块urllib.error、URL解析模块urllib.parse和解析robot.txt文件模块urllib.robotparser,如图所示。
可能有些同学不太熟悉robot.txt文件,它是每个网站给搜索引擎爬虫提供的进行爬取的文件,该文件会记录相关网站的特性信息。
下面介绍常用的函数和功能,首先介绍urllib.request模块。
urllib.request模块最常用的函数是urllib.request.urlopen(),其定义如下:
urllib.request.urlopen(url, data=None, [timeout, ]*, cafile=None, capat
h=None, cadefault=False, context=None)
其中:
- url:请求的网址。
- data:发送到服务器的数据。
- timeout:设置网站的访问超时时间。
- urlopen()函数对返回的对象提供read()、readline()、fileno()和close()方法进行解析。这4种方法对HTTPResponse对象的相关数据进行操作。
- Info():返回HTTPResponse对象,表示远程服务器返回的头信息。
- Getcode():返回HTTP的状态码。一般来说,我们会根据返回的状态码数值进行判断,200是正常请求,404代表未找到页面或者资源,403表示禁止访问。
- getUrl():返回请求的URL。
编写一个访问聚美优品网站的GET请求脚本,如下:
# -*- coding: utf-8 -*-
from urllib import request
response = request.urlopen('https://cd.jumei.com')
print(response.read().decode())
打印结果如下:
# ......前面有很多错误堆栈信息......
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate
(_ssl.c:1076)>
这是因为聚美优品网站对访问者采取了证书验证,即SSL认证。验证方法有两种。
(1)使用SSL创建未经验证的上下文,在urlopen中传入上下文参数。
可以使用SSL库生成一个用于自测的证书(不是真正经过CA证书中心认证的证书),以便通过SSL认证。
具体的代码改动如下:
# -*- coding: utf-8 -*-
from urllib import request
#response = request.urlopen('https://www.jumei.com')
#print(response.read().decode())
import ssl
context = ssl._create_unverified_context()
response = request.urlopen('https://cd.jumei.com', context=context)
print(response.read())
(2)全局方式取消证书验证。
代码如下:
# 全局方式取消证书验证
import ssl
ssl._create_default_https_context = ssl._create_unverified_context
print(request.urlopen('https://cd.jumei.com').read())
以上任意一种方式都可以通过SSL认证获取真正的页面数据,脚本的最终输出结果如下:
‘<html>...p0y.cn\')+\'/j/adv.js\';\n }(document);\n\n}\n</script>\n<n
oscript><img src="//stats.ipinyou.com/adv.gif?a=_d..wY1itoZJBOFwMNeSVmL
boP&e=" style="display:none;"/></noscript>\n<!-- \xe5\x85\xac\xe5\x85\
xb1JS end -->\n</body>\n</html>
urllib的POST请求也是使用urlopen()函数发起的,最简单的写法如下:
# -*- coding: utf-8 -*-
from urllib import request
data = b'word=Wuhan&slogan=comeOn'
url = 'http://httpbin.org/post'
response = request.urlopen(url, data=data)
print(response.read().decode())
执行脚本后的输出结果如下:
{
"args": {},
"data": "",
"files": {},
"form": {
"slogan": "comeOn",
"word": "Wuhan"
},
"headers": {
"Accept-Encoding": "identity",
"Content-Length": "24",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.7",
"X-Amzn-Trace-Id": "Root=1-5e3142a9-8fba13045d91637c8ee0ce48"
},
"json": null,
"origin": "182.139.20.60",
"url": "http://httpbin.org/post"
}
在urlopen()函数中,默认的访问方式是GET,当在urlopen()函数中传入data参数时,会发起POST请求。注意,传递的data数据需要是bytes格式。它的本质是发起一个带urlencode的GET请求,所以也可以使用urllib.parse组装一个urlencode的字符串作为post data数据。
代码如下:
# -*- coding: utf-8 -*-
from urllib import request,parse
url = 'http://httpbin.org/post'
data = bytes(parse.urlencode({'star': 'Kobe', 'wish': 'God want to see the
star plays basketball in the heaven.'}), encoding='utf8')
response = request.urlopen(url, data=data)
print(response.read().decode('utf-8'))
还可以设置的请求参数很多,例如:
- headers:请求头信息,如浏览器内核版本等。
- timeout:超时时间设置。针对一些请求接口设置超时时间,以避免长时间的无效等待。
- proxy:使用代理服务器访问。
- cookies:携带用户信息访问一些需要通过用户信息认证的页面。
请求头的设置代码如下:
headers = {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'}
# 需要使用url和headers生成一个Request对象,然后将其传入urlopen()方法中
req = request.Request(url, headers=headers)
resp = request.urlopen(req)
print(resp.read().decode())
以上代码可以很容易地获得页面信息。
使用代理服务器IP访问页面的代码如下:
from urllib import request
url = 'http://httpbin.org/ip'
proxy = {'http': '218.18.232.26:80', 'https': '218.18.232.26:80'}
proxies = request.ProxyHandler(proxy) # 创建代理处理器
opener = request.build_opener(proxies) # 创建opener对象
resp = opener.open(url)
print(resp.read().decode())
使用cookie方式访问页面也很简单,自己生成cookie对象即可,这里会用到新的包cookiejar。
代码如下:
# -*- coding: utf-8 -*-
from urllib import request
from http import cookiejar
url = 'http://www.soso.com'
# 创建一个cookiejar对象
cookie = cookiejar.CookieJar()
# 使用HTTPCookieProcessor创建cookie处理器
cookies = request.HTTPCookieProcessor(cookie)
# 以cookie为参数创建Opener对象
opener = request.build_opener(cookies)
# 发起带cookie的请求
response = opener.open(url)
for i in cookie:
print(i)
打印出来的cookie信息如下:
<Cookie IPLOC=CN5101 for .soso.com/>
<Cookie SUID=3C148BB63320910A000000005E3160FA for .soso.com/>
<Cookie ABTEST=0|1580294394|v17 for www.soso.com/>
关于错误处理,可以使用urllib.error模块。urllib.error有两个类:URLError和HTTP- Error。其中,HTTPError是URLError的子类。
错误对象有以下3个元素:
- code:错误码。
- reason:错误原因。
- headers:错误报头。
例如,下面的代码可以处理异常。
# -*- coding: utf-8 -*-
from urllib import request
from urllib.error import HTTPError
try:
request.urlopen('https://www.soso.com')
except HTTPError as e:
print(e.reason)
3. urllib 3简介
通过前面的举例和讲解,对urllib库应该有了一定的了解。可能注意到了urllib库的使用中有个特殊的地方:POST请求依然使用GET请求外加拼接urlencode字符串来实现。有没有设计更加合理的模块来实现POST请求呢?答案是可以使用urllib3库来完成。
urllib3库的功能非常强大,对SAP的支持也非常健全。
urllib3.poolmanager.PoolManager()函数的定义如下:
class urllib3.poolmanager.PoolManager(num_pools = 10,headers = None,**
connection_pool_kw )
可以使用PoolManager对象的request()方法进行HTTP请求。
其中,GET请求的代码如下:
import urllib3
http = urllib3.PoolManager(num_pools=5, headers={'User-Agent': 'ABCDE'})
resp1 = http.request('GET', 'http://www.baidu.com', body=data)
# resp2 = http.urlopen('GET', 'http://www.baidu.com', body=data)
print(resp2.data.decode())
由此可见,urllib3库的使用十分方便,而POST请求可以按如下方式实现:
import urllib3
import json
data = json.dumps({'author': 'freePHP’})
http = urllib3.PoolManager(num_pools=5, headers={'User-Agent': 'super'})
resp1 = http.request('POST', 'http://www.httpbin.org/post', body=data,
timeout=5,retries=5)
print(resp1.data.decode())
开发者可以自行先组装需要传递的参数和头信息。需要特别注意的是:urllib3库不能单独设置cookie,如果要使用cookie的话,需要将cookie放入headers中。
使用urllib3的代理服务器访问页面也非常简单,代码如下:
import urllib3
import json
data = {'abc': '123'}
proxy = urllib3.ProxyManager('http://52.233.137.33:80', headers={'connection':
'keep-alive'})
resp1 = proxy.request('POST', 'http://www.httpbin.org/post', fields=data)
print(resp1.data.decode())
使用urllib3的代现服务器访问页面的调用方式类似于前面介绍的urllib2,但是在POST请求中提供了显式参数设置,让调用的语义性更强。
4. BeautifulSoup的使用
在完成了页面的爬取后,需要对返回的HTML字符串(或者列表)进行处理,匹配出需要的一部分数据,这时候可以考虑使用诸如BeautifulSoup等解析工具。
BeautifulSoup和lxml一样,也是一个HTML/XML的解析器,其主要功能是解析和提取HTML/XML数据。到目前为止,最新的同时也是最流行的版本是BeautifulSoup 4.x。
BeautifulSoup支持Python标准库中的HTML解析器,还支持一些第三方解析器,如果不安装BeautifulSoup,则Python会使用默认的解析器,即xml.dom.minidom。但是BeautifulSoup解析器使用更加便捷且用法灵活,所以推荐使用BeautifulSoup解析器来处理HTML和XML数据。
BeautifulSoup 4的安装方式为:
pip install beautifulsoup4
pip install lxml
pip install html5lib
除此之外还需要安装其他依赖。
BeautifulSoup 4将复杂的HTML文档转换成一个复杂的树形结构,每个节点都是Python对象,所有对象可以归纳为4种:Tag、NavigableString、BeautifulSoup和Comment。
在Python命令行中输入下面的代码:
>>> from bs4 import BeautifulSoup
>>> soup = BeautifulSoup("<html><body><p>data</p></body></html>")
>>> soup<html><body><p>data</p></body></html>
>>> soup('p')[<p>data</p>]
可见,BeautifulSoup库非常好用,对于不同元素都可以进行语义化解析。
- 同类标签元素的定位
下面用360搜索首页来做演示,前端导航栏代码如下:
<nav class="skin-text skin-text-tab">
<a href="http://hao.360.cn/" target="_blank" data-linkid="hao">360导航</a>
<a href="http://news.so.com/?src=tab_web" data-s="http://news.so.com/ns?
ie=utf-8&tn=news&src=tab_web&q=%q%" data-linkid="news">资讯</a>
<a href="https://www.so.com/link?m=aHV%2BSjDKF6tIPAHxs1FJ9ZqSgskKtejz8a
XEG%2FS7DQjzhHaq%2FmyfN9ShU08spH5eS1HJJKrt0fUAS7mU1Qa7f9r6nsCUkgrgEahTY
DQyqgqsRelurhr8kOflOKis%3D" data-mdurl="http://video.360kan.com/?src=tab_
web" data-s="http://video.360kan.com/v?ie=utf-8&q=%q%&src=tab_web"
data-linkid="video">视频</a>
<a href="http://image.so.com/?src=tab_web" data-s="http://image.so.com/
i?src=www_home&ie=utf-8&q=%q%&src=tab_web" data-linkid="image">
图片</a>
<a href="http://ly.so.com/?src=tab_web" data-s="http://ly.so.com/s?q=%q
%&src=tab_web" data-linkid="liangyi">良医</a>
<a href="http://ditu.so.com/?src=tab_web" data-s="http://ditu.so.com/?ie=
utf-8&t=map&k=%q%&src=tab_web" data-linkid="map">地图</a>
<a href="http://baike.so.com/?src=tab_web" data-s="http://baike.so.com/
search?ie=utf-8&q=%q%&src=tab_web" data-linkid="baike">百科</a>
<a href="http://wenku.so.com/?src=tab_web" data-s="http://wenku.so.com/
s?q=%q%&src=tab_web" data-linkid="wenku">文库</a>
<a href="http://en.so.com/?src=tab_web" data-s="http://en.so.com/s?ie=
utf-8&q=%q%&src=tab_web" data-linkid="en">英文</a>
<a href="javascript:void(0);" id="so_tabs_more" onclick="return false">
更多<span class="skin-tab-ico pngfix"></span></a>
</nav>
如果要匹配出所有的a标签元素并提取链接地址,则代码如下:
# -*- coding: utf-8 -*-
from urllib import request
import ssl
from bs4 import BeautifulSoup
ssl._create_default_https_context = ssl._create_unverified_context
url = 'https://www.so.com/?src=pclm&ls=safarimac'
headers = {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'}
req = request.Request(url, headers=headers)
response = request.urlopen(req)
html = response.read()
bs = BeautifulSoup(html,"html.parser", from_encoding="utf8")
#print(bs.nav) # 定位到nav标签内容
nav = bs.nav
a_links = nav.find_all("a")
#print(a_links)
links = []
for link in a_links:
links.append(link.get("href"))
print(links)
上述代码中使用了BeautifulSoup的find_all()方法,该方法会返回所有的指定标签,例如上面的代码会返回所有的a标签元素。
find_all(name, attrs, recursive, string, **kwargs)方法返回一个列表类型,存储查找的结果,其参数含义如下:
- name:对标签名称的检索字符串。
- attrs:对标签属性值的检索字符串,可标注属性检索。
- recursive:是否对子孙全部检索,默认是True。
- string:<>…</>中字符串区域的检索字符串。
iterator.get(property)方法可以对单个定位元素获取其指定属性,如link.get('data-linkid'),即获取a标签的data-linkid属性值。如果要获取text属性,则使用get_text()方法。
- 父子节点的定位
针对嵌套的div结构又该如何定位呢?
下面是一个简单的测试页面,前端代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Simple Page</title>
</head>
<body>
<div id="main-container">
<p>
<h1>武汉加油,中国加油</h1>
<p class="show">
<span>Everyone want to have a good future, and is assiduous about doing well.
<a href="https://www.jd.com">Buy something</a>
</span>
</p>
</p>
</div>
</body>
</html>
例如,定位到a标签,然后向上找它的父节点和父节点的父节点,代码如下:
# -*- coding: utf-8 -*-
# from urllib import request
# import ssl
from bs4 import BeautifulSoup
# 由于是本地HTML文件,因此需要打开文件,然后读取
file_path = './simple01.html'
html_file = open(file_path, 'r', encoding='utf-8')
html_handle = html_file.read()
# 使用BeautifulSoup解析器
soup = BeautifulSoup(html_handle, 'lxml')
print(soup.a)
print(soup.a.parent.name)
print(soup.a.parent.parent.name)
打印结果如下,可以看到符合HTML本身的层级结构。
<a href="https://www.jd.com">Buy something</a>
span
P
5. 使用BeautifulSoup爬取BOSS直聘网站上的信息
通常情况下,普通人会通过浏览招聘网站来寻找好的工作机会,而有研发能力的工程师一般会自己手写一个爬虫程序批量收集JD(job description)。测试工程师也会经常接到需要收集某些数据的前置任务,因此会以BOSS直聘网站为例,采集该网站中与测试工程师相关的高薪岗位招聘信息。
在BOSS直聘的官方网站上搜索“测试工程师”并选择月薪“20-30K”。
搜索结果是一个列表,存在分页的情况。
假设模板是收集所有高薪岗位信息,包括薪酬范围、工作要求年限、学历要求、公司名称和公司行业类型,并要将这些信息全部写入一个Excel表格中。
经过分析,总体思路如下:
(1)根据页面结构进行分析,可以考虑先用urllib.request爬取页面。
(2)使用BeautifulSoup对需要的内容进行匹配。
(3)使用panda库对数据进行处理。
(4)写入Excel文件。
本例采用分步讲解,使用面向对象编程方式先定义一个爬虫类,代码如下:
class ZhipinSpider(object):
def __init__(self):
user_agent = self.get_random_user_agent()
ssl._create_default_https_context = ssl._create_unverified_context
self.url = 'https://www.zhipin.com/c101270100/y_6/?query=测试工程师&ka=sel-salary-6'
self.headers = {
'User-Agent': user_agent
}
其中,get_random_user_agent()方法用来随机获取headers中的User-Agent参数,具体实现代码如下:
def get_random_user_agent(self):
user_agents = [
'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like
Gecko) Chrome/14.0.835.163 Safari/535.1',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
like Gecko) Chrome/73.0.3683.103 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11
(KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11',
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/
534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',
'User-Agent:Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en)
Presto/2.8.131 Version/11.11',
'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler
4.0)'
]
num = len(user_agents)
random_num = random.randint(0, num -1)
return user_agents[random_num]
使用requests库发起HTTP请求,定义爬取页面内容的方法,代码如下:
def get_page_html(self):
response = requests.get(self.url, headers=self.headers)
print(response.content)
BOSS直聘网站具有反爬虫机制,因此我们的请求会被识别成爬虫脚本,无法真正获取列表数据,请求会返回一个未包含任何数据的HTML字符串。尝试添加cookie后可以正常抓取到完整页面的HTML。
代码改动如下:
def __init__(self):
user_agent = self.get_random_user_agent()
ssl._create_default_https_context = ssl._create_unverified_context
self.url = 'https://www.zhipin.com/c101270100/y_6/?query=测试工程师&ka=
sel-salary-6'
self.headers = {
'user-agent': user_agent,
'cookie': "xsfdsdfsdfdsfsdfsfds",
'referer': 'https://www.zhipin.com/c101270100/y_6/?query=%E6%B5%8B%E8%AF%95%E5%B7%A5%E7%A8%8B%E5%B8%88&ka=sel-salary-6'
}
其中,headers中需要设置cookie参数,该参数可以从浏览器的network(网络)面板中找到请求头信息,粘贴过来使用即可。组装好参数后发起的请求就能通过反爬虫限制了。但是这种方式的cookie值有一定的有效时间,一旦失效,请求会再次被反爬虫策略拦截,因此并不是长久之计。
通过cookie获取用户信息,更好地伪装成常规浏览器请求从而绕过反爬虫机制是一件比较麻烦的事情。一些反爬虫限制网站,如知乎、BOSS直聘网站等,已经有非常成熟的反爬虫监控规则,如cookie必须是有效的,并且访问权限有时间限制等。
遇到这种问题时,可以从两个方面考虑:
(1)能否获取登录后的cookie,并添加到请求头部或者请求对象中?
(2)能否从浏览器本身存储的cookie中读取信息?这样每次读取出的都是最新的cookie,就能伪装成和真实浏览器请求一致的headers请求参数及携带的用户信息。
关于本地浏览器读取cookie,可以考虑使用browsercookie模块。browsercookie模块用于一个从浏览器中提取保存的cookies的工具。它是一个很有用的爬虫工具,通过加载用户浏览器的cookies到一个cookiejar对象里,可以让用户轻松下载需要登录的网页内容。
browsercookie的安装方式也非常简单,命令如下:
pip install browsercookie
下面先熟悉一下browsercookie的基本用法,编写如下代码,目的是读取所有存储在Chrome浏览器中的BOSS直聘网站的cookie。
import browsercookie
chrome_cookie = browsercookie.chrome()
for cookie in chrome_cookie:
if '__zp_stoken_' in str(cookie):
tmp_cookie = str(cookie)
tmp_cookie = tmp_cookie.replace("<Cookie ", "")
tmp_cookie = tmp_cookie.replace(" for .zhipin.com/>", "")
print(tmp_cookie)
执行脚本,输出结果如下:
python try_get_cookie.py
__zp_stoken__=726d6JH4uHW2SPg33RZ0FfGKhLGxGcylkUjK%2B%2FFNrwgFcd3%2Bm6l
IY1IHT4OWOZczGuj%2B5I6WOYp1BA7ivGstsLvaSIKZWGvvcclZa9GO4oPI4lLEJE4pwRXP
6DsE9nsnA6FG
在for…in循环里如果不加任何判断条件,可以遍历出所有存储在Chrome中的cookie。当然,如果想读取Fireforx浏览器中保存的cookie,可以将代码改为:
import browsercookie
firefox_cookie = browsercookie.firefox()
调整上面的代码,可以对ZhipinSpider类添加如下方法并在__init__()方法中添加对应的调用:
def __init__(self):
self.headers = {
'user-agent': user_agent,
'cookie': self.get_cookie(),
'referer': 'https://www.zhipin.com/c101270100/y_6/?query=%E6
%B5%8B%E8%AF%95%E5%B7%A5%E7%A8%8B%E5%B8%88&ka=sel-salary-6'
}
# other codes
def get_cookie(self) -> str:
chrome_cookie = browsercookie.chrome()
# 筛选出zhipin.com的有效cookie
for cookie in chrome_cookie:
if '__zp_stoken_' in str(cookie):
real_cookie = str(cookie)
real_cookie = real_cookie.replace("<Cookie ", "")
real_cookie = real_cookie.replace(" for .zhipin.com/>", "")
return real_cookie
return ''
下一步是使用BeautifulSoup进行数据匹配,目标是匹配出月薪、工作地点、公司名称等信息。根据页面列表每一条数据的层级结构,编写如下方法来获取:
def deal_html(self, html):
soup = BeautifulSoup(html, "html.parser")
data_list = []
# 岗位名称、月薪、工作地点、公司名称
job_areas = soup.select('.job-area')
new_job_areas = []
for job in job_areas:
new_job_areas.append(job.get_text())
salary_ranges = soup.select('.job-limit > .red')
# 每页30条数据
new_salary_ranges = []
for salary in salary_ranges:
new_salary_ranges.append(salary.get_text())
company_names = []
# 在循环中自己组织拼接分类
for i in range (1, 31):
search_list_tag = 'search_list_company_' + str(i) + '_custompage'
item = soup.find('a', attrs={'ka': search_list_tag})
company_names.append(item.get_text())
item_num = len(job_areas)
for index in range(item_num):
tmp_row = {'job_name': '测试工程师', 'salary': new_salary_ranges
[index], 'job_area': new_job_areas[index], 'company_name': company_names
[index]}
data_list.append(tmp_row)
return data_list
打印返回值data_list,输出信息如下:
[{'job_name': '测试工程师', 'salary': '15-30K', 'job_area': '成都·武侯区',
'company_name': '平安城科'}, {'job_name': '测试工程师', 'salary': '15-30K',
'job_area': '成都·武侯区', 'company_name': '美测试工程师', 'salary': '15-30K',
'job_area': '成都·武侯区', 'company_name': '美团新零售业务部'}, {'job_name':
'测试工程师', 'salary': '15-30K', 'job_area': '成都', 'company_name': '华为
成都研究所'}: '15-30K', 'job_area': '成都·武侯区', 'company_name': '美团点评'},
{'job_name': '测试工程师', 'salary': '15-30K', 'job_area': '成都·武侯区',
'company_name': '客如云'}, {'job_name': '测试工程师', 'a': '成都·武侯区',
'company_name': '支付宝'}, {'job_name': '测试工程师', 'salary': '15-30K',
'job_area': '成都', 'company_name': '华为成都IT硬件'}, {'job_name': '测试工
程师', 'salary': '12-22K', 'j成都·武侯区', 'company_name': '西瓜创客'},
{'job_name': '测试工程师', 'salary': '25-35K·18薪', 'job_area': '成都·郫都
区', '...]
下一步是使用pandas库对数据进行加工,方便后面导入Excel文件。
这里先简单介绍一下pandas库。pandas库是Python的一个数据分析包,为解决数据分析任务而创建。pandas库中纳入了大量标准的数据模型,为高效地操作数据集提供所需的工具,使用非常方便。
pandas的安装方式也很简单,使用如下命令:
pip install pandas
在需要用到的脚本文件最开始的位置引入pandas库,代码如下:
import pandas as pd
取得data_list数据后,可以使用如下代码让list数据变成结构更清晰的DataFrame型数据。
def data_to_table(self, data):
# 使用pandas组织数据
df = pd.DataFrame(data)
return df
最后一步是将数据写入csv文件,具体代码如下:
def data_to_execl(self, data):
df = pd.DataFrame(data)
df.to_csv('job.csv', mode='a',encoding='utf_8_sig')
至此分解过程已经讲解完毕,整个爬虫类的完整代码如下:代码5.7 5/5.1.5/zhipin_spinder.py
# -*- coding: utf-8 -*-
'''
Boss直聘爬虫类
@author freePHP
@version 1.0.0
'''
import ssl
import requests
import random
from bs4 import BeautifulSoup
import browsercookie
import pandas as pd
# for cookie in chrome_cookie:
# if '__zp_stoken_' in str(cookie):
# tmp_cookie = str(cookie)
# tmp_cookie = tmp_cookie.replace("<Cookie ", "")
# tmp_cookie = tmp_cookie.replace(" for .zhipin.com/>", "")
class ZhipinSpider(object):
def __init__(self):
user_agent = self.get_random_user_agent()
ssl._create_default_https_context = ssl._create_unverified_context
self.url = 'https://www.zhipin.com/c101270100/y_6/?query=测试工程
师&ka=sel-salary-6'
self.headers = {
'user-agent': user_agent,
'cookie': self.get_cookie(),
'referer': 'https://www.zhipin.com/c101270100/y_6/?query=%E6
%B5%8B%E8%AF%95%E5%B7%A5%E7%A8%8B%E5%B8%88&ka=sel-salary-6'
}
def get_cookie(self) -> str:
chrome_cookie = browsercookie.chrome()
# 筛选出zhipin.com的有效cookie
for cookie in chrome_cookie:
if '__zp_stoken_' in str(cookie):
real_cookie = str(cookie)
real_cookie = real_cookie.replace("<Cookie ", "")
real_cookie = real_cookie.replace(" for .zhipin.com/>", "")
return real_cookie
return ''
def get_page_html(self):
response = requests.get(self.url, headers=self.headers)
return response.text
def deal_html(self, html):
soup = BeautifulSoup(html, "html.parser")
data_list = []
# 岗位名称、月薪、工作地点、公司名称
job_areas = soup.select('.job-area')
new_job_areas = []
for job in job_areas:
new_job_areas.append(job.get_text())
salary_ranges = soup.select('.job-limit > .red')
# 每页30条数据
new_salary_ranges = []
for salary in salary_ranges:
new_salary_ranges.append(salary.get_text())
company_names = []
# 在循环中自己组织拼接分类
for i in range (1, 31):
search_list_tag = 'search_list_company_' + str(i) + '_custompage'
item = soup.find('a', attrs={'ka': search_list_tag})
company_names.append(item.get_text())
item_num = len(job_areas)
for index in range(item_num):
tmp_row = {'job_name': '测试工程师', 'salary': new_salary_ranges
[index], 'job_area': new_job_areas[index], 'company_name': company_names
[index]}
data_list.append(tmp_row)
return data_list
def data_to_table(self, data):
# 使用pandas来组织数据
df = pd.DataFrame(data)
return df
def data_to_execl(self, data):
df = pd.DataFrame(data)
df.to_csv('job.csv', mode='a',encoding='utf_8_sig')
def get_random_user_agent(self):
user_agents = [
'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML,
like Gecko) Chrome/14.0.835.163 Safari/535.1',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/
535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11',
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us)
AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',
'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/
2.8.131 Version/11.11',
'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/
11.11',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Tencent
Traveler 4.0)'
]
num = len(user_agents)
random_num = random.randint(0, num -1)
return user_agents[random_num]
if __name__ == '__main__':
spider = ZhipinSpider()
html = spider.get_page_html()
data = spider.deal_html(html)
spider.data_to_execl(data)
#df_data = spider.data_to_table(data)
6. 正则表达式简介
正则表达式是一个特殊的字符序列,能帮助开发者方便地检查一个字符串是否与某种模式匹配,类似于SQL语句,是一种通用型描述语言,针对不同的编程语言都有类似的语法和对应的封装库。在Python中,re模块就是用于正则匹配的模块,其功能强大,这里会详细介绍。
re模块是Python的内置模块,提供了Perl风格的正则表达式模式。下面简单介绍一下常用的正则表达式的匹配模式,如下表所示。
除了这些基本匹配模式的元字符和语法之外,还要理解两个概念:贪婪模式和非贪婪模式。贪婪模式是指尽可能匹配更多的字符,反之,非贪婪模式是指尽可能匹配更少的字符。Python默认采用的是贪婪模式。例如,正则表达式“ac*”如果用于查找accccd,将找到acccc;如果使用非贪婪的数量词“ac*?”,将找到a。
示例1:使用re模块编写一个简单的用例程序,查询是否包含某个字符串。
具体代码如下:
# -*- coding: utf-8 -*-
import re
# 将正则表达式编译成Pattern对象
pattern = re.compile(r'Catch')
# 使用Pattern匹配文本,获得匹配结果,无法匹配时将返回None
match = pattern.match('Catch PHP and Python !')
if match:
# 使用Match获得分组信息
print(match.group())
执行该脚本,输出结果如下:
Catch
示例2:检查邮箱格式的有效性。
我们在注册账号时经常需要填写注册邮箱,因此对于邮箱需要检查其格式的有效性。re模块也可以通过正则表达式进行检测,例如检测163邮箱(网易邮箱)的有效性,代码如下:
#-*- coding:utf-8 -*-
__author__ = 'freePHP'
import re
text = input("Please input your Email address:\n")
if re.match(r'[0-9a-zA-Z_]{0,19}@163.com',text):
print('Email address is Right!')
else:
print('Please reset your right Email address!')
执行以上脚本并输入下列邮箱,验证结果如下:
Please input your Email address:
freephp@163.com
Email address is Right!
这里只做了格式检查,没有对邮箱的可达性进行判断。如果要做可达性判断,可以使用测试邮箱发送测试邮件。
请思考一下,有没有更通用型的邮箱检查呢?
我们可以对邮箱的后缀进行or判断,具体代码如下:
#-*- coding:utf-8 -*-
__author__ = 'freePHP'
import re
text = input("Please input your Email address:\n")
if re.match(r'^[0-9a-zA-Z_]{0,19}@[0-9a-zA-Z]{1,13}\.[com,cn,net]{1,3}
$',text):
print('Email address is Right!')
else:
print('Please reset your right Email address!')
这次尝试检测以.com结尾的邮箱地址,执行结果如下:
Please input your Email address:
233232@qq.com
Email address is Right!
示例3:获取“内涵段子”的段子数据。
“内涵段子”是一个有图文也有视频的综合性网站。在分析了“内涵段子”网站页面列表数据的结构后发现,每个div的标签都有一个属性class="f18 mb20",由此可以推理出如下正则表达式来匹配:
<div.*?class="f18 mb20">(.*?)</div>
使用re模块的代码如下:
pattern = re.compile(r'<div.*?class="f18 mb20">(.*?)</div>', re.S)
item_list = pattern.findall(gbk_html)
代码中使用了re模块的findall()方法,该方法会匹配出所有符合条件的元素,以列表形式将结果返回。
7. 封装一个强大的爬虫工具类
对于一个爬虫开发者,如果针对不同的网站都从零开始编写爬虫程序将会是一项非常费时、费力且低效的工作。对于爬虫的常规工作流程,前面已经梳理过了,因此可以思考封装一个功能健全且强大的工具类,提高工作效率。
具体实现也比较符合面向对象的规范,代码如下:
#-*- coding:utf-8 -*-
import random
import requests
class SpiderTool(object):
def __init__(self, url):
self.url = url
user_agent = self.get_random_user_agent()
self.headers = {
'user-agent': user_agent
}
def get_html(self, method_type='get', data={}):
if method_type == 'get':
response = requests.get(self.url)
elif method_type == 'post':
response = requests.post(self.url, data=data)
else:
print("It is not correct http method!")
exit(1)
html = response.text
return html
def parse(self):
pass
# 这里只写入本地文件,如果要写入数据库,请自行做适当修改
def store(self, data):
with open('data.txt', 'w') as f:
f.write(data)
def get_random_user_agent(self):
user_agents = [
'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML,
like Gecko) Chrome/14.0.835.163 Safari/535.1',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/
535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11',
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us)
AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',
'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/
2.8.131 Version/11.11',
'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/
11.11',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Tencent
Traveler 4.0)'
]
num = len(user_agents)
random_num = random.randint(0, num - 1)
return user_agents[random_num]
if __name__ == '__main__':
spider = SpiderTool('https://zhihu.com')
比较多变的部分就是parse部分,因为每个网站的列表页或者内容详情页有不同的结构,需要不同的解析策略和实现。其他部分,如访问页面、存储数据是可以标准化的。除此之外,日志记录也是必要的,这里不再赘述。
2、Scrapy简介
前面我们学习了人工编写爬虫脚本,但是因为每个人的编程能力不同,最终封装出来的工具类不一定具有非常好的通用性。实际上已经有开发者编写出了强大的爬虫框架,如Scrapy框架。
1. Scrapy简介
Scrapy是一个功能强大的爬虫框架,对数据处理和挖掘提供了非常友好的支持,可以用于数据分析、监测和自动化测试等工作中。
根据官方描述整理出的Scrapy流程图,如图所示。
主要包括以下组件:
- 爬虫引擎(Scrapy):用于整个系统的数据流处理,触发事务(框架核心)。
- 调度器(Scheduler):用来接收引擎发送的请求并压入队列中,在引擎再次请求的时候返回。可以想象成一个URL(抓取网页的网址或者链接)的优先队列,由它来决定下一个要抓取的网址,同时去除重复的网址。
- 下载器(Downloader):用于下载网页内容,并将网页内容返回给蜘蛛(Scrapy下载器是建立在twisted这个高效的异步模型上的)。
- 爬虫(Spiders):用于从特定的网页中提取自己需要的信息,即所谓的实体(Item)。用户也可以从中提取出链接,让Scrapy继续抓取下一个页面。
- 对象管道(Pipeline):负责处理爬虫从网页中抽取的数据,主要功能是持久化数据、验证数据的有效性,以及清除不需要的信息。当页面被爬虫解析后,解析的数据将被发送到对象管道,并经过几个特定的次序来处理数据。
- 下载器中间件(Downloader Middlewares):介于Scrapy引擎和下载器之间的框架,主要是处理Scrapy引擎与下载器之间的请求及响应。
- 爬虫中间件(Spider Middlewares):介于Scrapy引擎和爬虫之间的框架,主要工作是处理蜘蛛的输入响应和输出请求。
- 调度中间件(Scheduler Middewares):介于Scrapy引擎和调度器之间的中间件,主要工作是处理从Scrapy引擎发送到调度的请求和响应。
Scrapy的运行流程大概如下:
(1)爬虫引擎从调度器中取出一个链接用于抓取。
(2)爬虫引擎把URL封装成一个请求传送给下载器。
(3)下载器获取资源并封装成应答包。
(4)解析结果对象(Item)。
(5)提取出链接再次交给调度器去抓取。
Scrapy的安装方式也十分简单,还是使用如下pip命令行:
pip install scrapy
2. Scrapy的基本用法
Scrapy的使用并不复杂,需要先用命令行创建一个属于自己的项目,具体如下:
scrapy startproject myproject
其中,myproject代表自己的项目名,可以自定义,如scrapy、starttoproject或StudyScrapy都可以。然后再使用cd命令进入新创建的项目根目录下即可开始正式的开发工作。
项目的目录如下:
scrapy.cfg
myproject/
__init__.py
items.py
pipelines.py
settings.py
spiders/
__init__.py
spider1.py
spider2.py
...
其中,对应的文件及文件夹的作用如下:
- scrapy.cfg:项目的配置文件;
- myproject/:该项目的python模块。之后将在此加入代码。
- myproject/items.py:需要提取的数据结构定义文件。
- myproject/middlewares.py:和Scrapy的请求/响应处理相关联的框架。
- myproject/pipelines.py:用来对items里提取的数据做进一步处理,如保存等。
- myproject/settings.py:项目的配置文件。
- myproject/spiders/:放置spider代码的目录。
下面以百度贴吧为例,分析整理需求后编写代码。
(1)编写需要最终爬取的数据结构(Item),代码如下:
class DetailItem(scrapy.Item):
# 抓取内容:(1)帖子标题;(2)帖子作者;(3)帖子回复数
title = scrapy.Field()
author = scrapy.Field()
reply = scrapy.Field()
上面类中的title、author和reply就像是字典中的“键”,爬取的数据就像是字典中的“值”。
(2)定义Spider类。可以继承框架的Spider类,然后编写自定义需求和逻辑,代码如下:
import scrapy
from hellospider.items import DetailItem
import sys
class MySpider(scrapy.Spider):
"""
name:scrapy唯一定位实例的属性,必须是唯一的
allowed_domains:允许爬取的域名列表,不设置表示允许爬取所有列表
start_urls:起始爬取列表
start_requests:它就是从start_urls中读取链接,然后使用make_requests_from_
url生成Request,这意味着我们可以在start_requests方法中根据自己的需求向start_
urls中写入自定义的规律的链接
parse:回调函数,处理response并返回处理后的数据和需要跟进的URL
log:打印日志信息
closed:关闭spider
"""
# 设置name
name = "spidertieba"
# 设定域名
allowed_domains = ["baidu.com"]
# 填写爬取地址
start_urls = [
"https://tieba.baidu.com/f?kw=%E6%AD%A6%E5%8A%A8%E4%B9%BE%E5%9D%A4&ie=
utf-8",
]
# 编写爬取方法
def parse(self, response):
for line in response.xpath('//li[@class=" j_thread_list clearfix"]'):
# 初始化item对象保存爬取的信息
item = DetailItem()
# 这部分是爬取部分,使用XPath方式选择信息,具体方法根据网页结构而定
item['title'] = line.xpath('.//div[contains(@class,"threadlist_
title pull_left j_th_tit ")]/a/text()').extract()
item['author'] = line.xpath('.//div[contains(@class,"threadlist_
author pull_right")]//span[contains(@class,"frs-author-name-wrap")]/a/
text()').extract()
item['reply'] = line.xpath('.//div[contains(@class,"col2_left
j_threadlist_li_left")]/span/text()').extract()
yield item
其中使用了XPath方式进行元素匹配,同样也可以使用CSS方式进行定位。这两种方式可以任意选择。XPath使用起来更加灵活,适合复杂情况下的元素定位。
(3)使用命令行执行脚本并存储到文件中。
使用Scrapy内置的命令runspider可以直接执行该脚本,并可以设置输出到JSON格式的文件中,具体如下:
scrapy runspider tieba_spider.py -o item.json
生成的item.json文件的内容如图所示:
Scrapy可以使用高度封装的API,更加专注于业务逻辑,而不是重复去造轮子实现底层API。
3. Scrapy爬虫实践
根据前面的例子,可以总结出爬虫实践的大致流程,一个典型的Scrapy项目开发需要以下几步:
(1)编写Item。
(2)自定义Spider类。
(3)设置爬取配置,如起始爬取的URL。
(4)编写解析页面的方法。
(5)完成后续逻辑,如持久化存储结果数据。
这里以爬取一个动漫网站为例,创建一个完整的Scrapy项目案例。
目标网站是腾讯动漫站点,关于页面,需要具体分析,对采集的字段进行定位。使用Chrome浏览器中的页面工具可以很容易地找到定位元素的XPath,方法很简单,具体如下:
(1)选择需要定位的元素后右击,选择“检查(N)”命令。
(2)在HTML标签上右击,然后选择Copy | Copy XPath命令即可。
页面结构的上半部分是作品介绍,下半部分是一个章节列表。需要采集该作品的名称、作者名、作品简介、章节标题和对应的详情链接。
使用Scrapy命令生成名为comic的Scrapy项目,命令如下:
scrapy startproject comic
自定义的spider文件可以编写在comic/cominc/spider文件夹下,而comic/comic文件夹下的items.py文件就是Item部分的文件。
下面编写Item,代码如下:
# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# https://docs.scrapy.org/en/latest/topics/items.html
import scrapy
class ComicItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
name = scrapy.Field() # 作品名称
author = scrapy.Field() # 作者名
desc = scrapy.Field() # 动漫简介
chapters = scrapy.Field() # 章节和对应的链接
接着编写Spider类,代码如下:
# -*- coding: utf-8 -*-
import scrapy
from comic.items import ComicItem
class ComicSpider(scrapy.Spider):
name = "comic"
allowed_domains = ['ac.qq.com']
start_urls = ['https://ac.qq.com/Comic/comicInfo/id/635188']
def parse(self, response):
# link_urls = response.xpath('//dd/a[1]/@href').extract()
# 动漫名称
comic_name = response.xpath('/html/body/div[3]/div[3]/div[1]/div[1]/
div[2]/div[1]/div[1]/h2/strong/text()').extract()[0]
# //*[@id="special_bg"]/div[3]/div[1]/div[1]/div[2]/div[1]/p[1]/
span[1]/em
# 动漫作者姓名
author_name = response.xpath('//*[@id="special_bg"]/div[3]/div[1]/
div[1]/div[2]/div[1]/p[1]/span[1]/em/text()').extract()[0]
# 动漫作品简介
desc = response.xpath('//*[@id="special_bg"]/div[3]/div[1]/div[1]/
div[2]/div[1]/p[2]/text()').extract()[0]
comic_item = ComicItem()
##print(desc)
# 章节列表获取
//*[@id="chapter"]/div[2]/ol[2]
//*[@id="chapter"]/div[2]/ol[2]/li/p[1]/span[1]/a
//*[@id="chapter"]/div[2]/ol[2]/li/p[2]/span[1]/a
chapters = response.xpath('//*[@id="chapter"]/div[2]/ol[2]/li').
extract()[0]
# >>> response.xpath('//a[contains(@href, "image")]/@href').getall()
hrefs = response.xpath('//a[contains(@href, "ComicView")]/@href').
getall()
valid_hrefs = []
# 去重
for href in hrefs:
if href not in valid_hrefs:
valid_hrefs.append(href)
chapter_names = response.xpath('//a[contains(@href, "ComicView")]/@title').getall()
# 筛选出有效标题
valid_chapter_names = []
for chapter in chapter_names:
if "迷都奇点:" in chapter:
pure_name = chapter.replace("迷都奇点:", "")
if pure_name not in valid_chapter_names:
valid_chapter_names.append(pure_name)
item_num = len(valid_chapter_names)
# 章节结构
chapters = []
for index in range(item_num):
tmp_row = {"name": valid_chapter_names[index], "url": valid_hrefs[index]}
chapters.append(tmp_row)
comic_item['chapters'] = chapters
comic_item['name'] = comic_name
comic_item['author'] = author_name
comic_item['desc'] = desc
print(comic_item)
上面这段代码较长,最核心的逻辑就是利用XPath技术筛选出需要的数据,并赋值给上一步定义好的数据Item,执行命令scrapy crawl comic,输出结果如下:
.....
2020-02-12 21:29:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://ac.qq.com/Comic/comicInfo/id/635188> (referer: None)
{'author': '厚脸\xa0',
'chapters': [{'name': '第一话:膜王大赛', 'url': '/ComicView/index/id/635188/
cid/1'},
{'name': '第二话:老街的力量', 'url': '/ComicView/index/id/635188/
cid/48'},
{'name': '第三话:再遇咕咕鸡', 'url': '/ComicView/index/id/635188/
cid/2'},
{'name': '第四话:拆迁', 'url': '/ComicView/index/id/635188/
cid/3'},
{'name': '第五话:马头', 'url': '/ComicView/index/id/635188/
cid/4'},
{'name': '第六话:袖子', 'url': '/ComicView/index/id/635188/
cid/5'},
{'name': '第七话:老李?', 'url': '/ComicView/index/id/635188/
cid/6'},
{'name': '第八话:老李的梦魇', 'url': '/ComicView/index/id/635188/
cid/7'},
{'name': '第九话:过生日', 'url': '/ComicView/index/id/635188/
cid/8'},
{'name': '第十话:爸爸的画', 'url': '/ComicView/index/id/635188/
cid/9'},
{'name': '十一话:过敏', 'url': '/ComicView/index/id/635188/
cid/10'},
{'name': '第十二话:拳黄大赛', 'url': '/ComicView/index/id/635188/
cid/11'},
{'name': '第十三话:我要赢!', 'url': '/ComicView/index/id/635188/
cid/12'},
{'name': '第十四话:冠军之战', 'url': '/ComicView/index/id/635188/
cid/13'},
.....
{'name': '第四十二话:汇合', 'url': '/ComicView/index/id/635188/
cid/47'}],
'desc': '\r\n'
' '
'在麓山市旧城区老街经营一家手机店的孤儿申海,无意间发现杀害父亲的犯罪组织的下落,随着调查的深入,他发现还有着更深一层的谜团等着他…… ',
'name': '迷都奇点'}
可以进一步利用获取的章节URL去获取章节详情页的图片内容,然后进行相应存储,真正完成这个基于动漫网站的爬虫采集工作。
3、测试商品列表页面
在对电商网站的测试中也会遇到需要针对商品列表页的爬虫分析,用于采集商品的名称、种类、库存和价格等基础信息。下面以京东的“3C产品”(计算机类、通信类、消费电子类产品的统称)列表页为例,分析和编写程序。
我们学习了Scrapy的用法,对于商品列表页可以做同样的处理。先生成该项目,执行以下命令:
scrapy startproject jdshop
然后定义数据模型Item,代码如下:代码5.14 5/5.3/jdshop/jdshop/item.py
# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# https://docs.scrapy.org/en/latest/topics/items.html
import scrapy
class JdshopItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
product_name = scrapy.Field()
product_price = scrapy.Field()
product_type = scrapy.Field()
product_stock = scrapy.Field()
product_desc = scrapy.Field()
根据需求编写的Spider类如下:
# -*- coding: utf-8 -*-
import scrapy
from jdshop.items import JdshopItem
class JdProductSpider(scrapy.Spider):
name = "JdShop"
allowed_domains = ['list.jd.com']
start_urls = ['https://list.jd.com/list.html?cat=670,671,672&page=
1&sort=sort_totalsales15_desc&trans=1&JL=6_0_0#J_main']
def parse(self, response):
# product_name = response.xpath()
# product_price = response.xpath()
# product_sale_num = response.xpath()
# product_type = response.xpath()
# product_detail_link = response.xpath()
product_list = response.xpath('//li[contains(@class,"gl-item")]')
print(product_list)
for item in product_list:
# //*[@id="plist"]/ul/li[1]/div/div[2]/strong[1]/i
print(item.xpath('div/div[@class="p-price"]/strong/i/text()'))
def store(self, data):
pass
在调试过程中会发现打印出的价格一直是空的,这是因为京东的反爬虫策略将商品信息以JS动态加载形式进行加载。可以考虑使用其他方式绕过这种检查。
由此可见,同一种方法不能适用于所有的网站。经过认真思考和尝试,可以使用Selenium进行请求模拟,编写代码如下:
'''
爬取京东商品信息:
请求URL:
https://www.jd.com/
提取商品信息:
1.商品详情页
2.商品名称
3.商品价格
4.评价人数
5.商品商家
'''
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
def get_good(driver):
try:
# 通过JS控制滚轮滑动获取所有商品信息
js_code = '''
window.scrollTo(0,5000);
'''
driver.execute_script(js_code) # 执行JS代码
# 等待数据加载
time.sleep(2)
# 查找所有商品div
# good_div = driver.find_ele Type equation here. ment_by_id('J_goodsList')
good_list = driver.find_elements_by_class_name('gl-item')
n = 1
for good in good_list:
# 根据属性选择器查找
# 商品链接
good_url = good.find_element_by_css_selector(
'.p-img a').get_attribute('href')
# 商品名称
good_name = good.find_element_by_css_selector(
'.p-name em').text.replace("\n", "--")
# 商品价格
good_price = good.find_element_by_class_name(
'p-price').text.replace("\n", ":")
# 评价人数
good_commit = good.find_element_by_class_name(
'p-commit').text.replace("\n", "")
good_content = f'''
商品链接: {good_url}
商品名称: {good_name}
商品价格: {good_price}
评价人数: {good_commit}
\n
'''
print(good_content)
with open('jd.txt', 'a', encoding='utf-8') as f:
f.write(good_content)
next_tag = driver.find_element_by_class_name('pn-next')
next_tag.click()
time.sleep(2)
# 递归调用函数
get_good(driver)
time.sleep(10)
finally:
driver.close()
if __name__ == '__main__':
good_name = input('请输入爬取商品信息:').strip()
driver = webdriver.Chrome()
driver.implicitly_wait(10)
# 向京东主页发送请求
driver.get('https://www.jd.com/')
# 输入商品名称并按Enter键搜索
input_tag = driver.find_element_by_id('key')
input_tag.send_keys(good_name)
input_tag.send_keys(Keys.ENTER)
time.sleep(2)
get_good(driver)
4、多线程爬虫用例
在爬虫程序中,往往是一个脚本开启一个进程去循环爬取每一页的数据。如果数据很多,消耗的运行时间也会很长。这时可以考虑使用多线程运行爬虫程序,提高运行效率。
多线程,顾名思义就是一次性产生多个线程同时进行爬虫抓取活动,就如同一个人雇用了几十个人同时搬运货物,比让一个人搬运要快得多。
Python的多线程实现也比较方便,可以使用threading模块。下面使用threading模块编写一个简单的用例程序。
# -*- coding: utf-8 -*-
import threading
import time
def writing_novel():
for x in range(3):
print('%s正在写小说' % x)
time.sleep(1)
def running():
for x in range(3):
print('%s正在跑步' % x)
time.sleep(1)
def playing_game():
for x in range(3):
print('%s正在玩游戏' % x)
time.sleep(1)
def single_thread():
writing_novel()
running()
playing_game()
def multi_thread():
t1 = threading.Thread(target=writing_novel)
t2 = threading.Thread(target=running)
t3 = threading.Thread(target=playing_game)
t1.start()
t2.start()
t3.start()
if __name__ == '__main__':
multi_thread()
下面将多线程技术应用于爬虫程序,并且导入queue模块,使多线程的稳定性更强。
import threading # 导入threading模块
from queue import Queue # 导入queue模块
import time # 导入time模块
# 爬取文章详情页
def get_detail_html(detail_url_list, id):
while True:
# Queue队列的get()方法用于从队列中提取元素
url = detail_url_list.get()
time.sleep(2) # 延时2s 模拟请求的耗时
print("thread {id}: get {url} detail finished".format(id=id,url=url))
# 爬取文章列表页
def get_detail_url(queue):
for i in range(10000):
time.sleep(1) # 延时1s
queue.put("http://testedu.com/{id}".format(id=i))# 从队列中获取URL
# 打印出得到了哪些文章的URL
print("get detail url {id} end".format(id=i))
# 主函数
if __name__ == "__main__":
# 用Queue构造一个线程数量为1000的线程队列
detail_url_queue = Queue(maxsize=1000)
# 先创造4个线程
thread = threading.Thread(target=get_detail_url, args=(detail_url_
queue,)) # A线程负责抓取列表URL
html_thread= []
for i in range(3):
thread2 = threading.Thread(target=get_detail_html, args=(detail_
url_queue,i))
html_thread.append(thread2) # B、C、D线程负责抓取文章详情
start_time = time.time()
# 启动4个线程
thread.start()
for i in range(3):
html_thread[i].start()
# 其父进程一直处于阻塞状态
thread.join()
for i in range(3):
html_thread[i].join()
print("last time: {} s".format(time.time()-start_time)) # 计算总共耗时
除此之外,还可以使用多线程对一些特别的网站进行爬取。
5、反爬虫安全策略
只有绕过这些反爬虫限制,开发者才能真正获取想要的数据,访问完整的页面结构。
- user-agent检查
有些网站通过检查请求头参数里是否包含user-agent参数,来判断是否是真实的浏览器发送的请求,从而阻止一部分爬虫程序的爬取行为。
解决方法是在请求头参数里增如下代码(假设使用的是requests库):
headers = {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'}
req = request.Request(url, headers=headers)
- referer检查
有些网站通过检查请求头参数里的referer参数的有效性,来判断是否是真实的浏览器发送的请求,从而阻止一部分爬虫程序的爬取行为。通常,referer参数的值都是同域名或者与同域名相关的网站地址,可以从真实的浏览器的请求中找到该值。
解决方案是在请求头参数里增如下代码(假设使用requests库发起HTTP请求):
headers = { 'referer': 'https://www.zhipin.com/c101270100/y_6/
?query=%E6%B5%8B%E8%AF%95%E5%B7%A5%E7%A8%8B%E5%B8%88&ka=sel-salary-6'
}
- cookie检查
有些网站通过检查请求头参数里cookie参数的有效性,来判断是否是真实的浏览器发送的请求,从而阻止一部分爬虫程序的爬取行为。cookie参数里包含了一些用户信息和访问记录,是登录浏览器期间的重要依据,代表网站有知乎网等。
解决方案有多种,一种是利用模拟登录请求获得cookie,然后将获取的这个cookie写入CookieJar对象中;另外一种方案是读取存储在真实浏览器中的cookie文件,需要的cookie从本地文件中获得。推荐使用第二种方法获取cookie,因为这种方法能够准确且自动获得cookie,使用browsercookie模块就可以方便地管理和读取cookie。
例如,获取知乎网的cookie,可以封装如下函数:
def get_cookie(self) -> str:
chrome_cookie = browsercookie.chrome()
# 筛选出zhipin.com的有效cookie
for cookie in chrome_cookie:
if '__zp_stoken_' in str(cookie):
real_cookie = str(cookie)
real_cookie = real_cookie.replace("<Cookie ", "")
real_cookie = real_cookie.replace(" for .zhipin.com/>", "")
return real_cookie
return ''
如果是管理后台那种需要权限的cookie,推荐使用第一种方法来获取。因为这种通过模拟登录的方法获取的cookie是最直接、最真实的,方便后续在后台管理其他请求的操作。
- 验证码
除了前面所讲的常规的反爬虫策略之外,网站还会根据访问的IP和频率等判断是否是真实的用户,对于疑似机器人的访问,将增加验证码验证。
常见的验证码有:
- 文字、数字类验证;
- 计算题类验证;
- 图片滑块验证;
- Google选择图片类型;
- 手机动态验证码;
- 多段验证码。
对于文字和数字验证码,可以通过图像识别的办法来解决,只要识别出里面的内容,然后输入文本框中即可。这种识别技术叫作OCR。推荐使用Python的第三方库tesserocr,对于没有背景图片影响的验证码,直接通过这个库来识别即可。对于有杂乱背景的验证码,直接识别的话,识别率会很低,需要先对图片进行灰度处理,然后再进行二值化处理,之后再进行识别,识别率会大大提高。
1. tesserocr库简介
tesserocr是Python的一个OCR识别库。它是基于tesseract的封装API,因此在安装tesserocr之前,需要先安装tesseract。根据操作系统不同,安装方式也不同。如果是使用Windows系统,可以前往tesseract的官方网站下载最新版本的压缩包并安装即可。
如果是Mac OS系统,则需要先使用brew命令安装依赖,命令如下:
brew install imagemagick
brew install tesseract
之后再使用pip命令安装tesserocr,具体命令如下:
pip install tesserocr pillow
tesserocr封装了很多实用的API,下面举一个识别图片的小例子,具体代码如下:
# -*- coding: utf-8 -*-
import tesserocr
from PIL import Image
image=Image.open('./yzm02.png')
image=image.convert("L") # 将图片转换为灰度图
threshold=100 # 阈值设定为100
table=[]
# 进行二值化
for i in range(256):
if i < threshold:
table.append(0)
else:
table.append(1)
image=image.point(table,'1')
image.show()
print(tesserocr.image_to_text(image))
对于简单的图片识别,可以直接使用image_to_text()函数,对于一些不容易辨别的图片,需要同本例一样,先转灰度,然后设置阈值,最后再进行二值化处理。需要注意的是,设置阈值的数据可以根据弹出的临时文件图层的清晰度适当调整,如果很模糊,则可以把阈值数值调得大一些。
运行程序,输出结果如下:
OFXo
还有一种情况是图片有边框,这会干扰识别结果,因此需要先去掉边框。方法是:遍历像素点,找到4个边框上的所有点,将它们都改为白色即可。
之后还需要降噪处理,具体实现代码如下:
# -*- coding: utf-8 -*-
import tesserocr
from PIL import Image
# 清除边框
def clear_border(image):
image = image.convert('RGB')
width = image.size[0]
height = image.size[1]
noise_color = get_noise_color(image)
for x in range(width):
for y in range(height):
# 清除边框和干扰色
rgb = image.getpixel((x, y))
if (x == 0 or y == 0 or x == width - 1 or y == height - 1
or rgb == noise_color or rgb[1] > 100):
image.putpixel((x, y), (255, 255, 255))
return image
# 降噪
def get_noise_color(image):
for y in range(1, image.size[1] - 1):
# 获取非白的颜色
(r, g, b) = image.getpixel((2, y))
if r < 255 and g < 255 and b < 255:
return (r, g, b)
if __name__ == '__main__':
img_name = 'bother.png'
image = Image.open(img_name)
image = clear_border(image)
# 转换为灰度图
imgry = image.convert('L')
code = tesserocr.image_to_text(imgry)
print(code)
运行程序,输出结果如下:
libpng warning: iCCP: profile 'ICC Profile': 'RGB ': RGB color space not
permitted on grayscale PNG
NFJP
结果符合预期,成功处理了带有干扰性的字符串验证码。
2. 图片滑块验证码
滑块验证码是近几年流行的一种验证方式,如知乎、Bilibili等网站采取的就是这种验证方式。
对于图片滑块验证,解决办法有所不同,比较简单的方式就是利用Selenium破解这种验证。
简单的滑块验证示例代码如下:
import time
from selenium import webdriver
from selenium.webdriver import ActionChains
# 新建Selenium浏览器对象,后面是geckodriver.exe下载后的本地路径
browser = webdriver.Firefox()
# 网站登录页面
url = 'http://admin.emaotai.cn/login.aspx'
# 浏览器访问登录页面
browser.get(url)
browser.maximize_window()
browser.implicitly_wait(5)
draggable = browser.find_element_by_id('nc_1_n1z')
# 滚动指定的位置
browser.execute_script("arguments[0].scrollIntoView();", draggable)
time.sleep(2)
ActionChains(browser).click_and_hold(draggable).perform()
# 拖动滑块
ActionChains(browser).move_by_offset(xoffset=247, yoffset=0).perform()
ActionChains(browser).release().perform()
复杂的滑块验证示例代码如下:
import time
import cv2
import canndy_test
from selenium import webdriver
from selenium.webdriver import ActionChains
# 创建webdriver对象
browser = webdriver.Chrome()
# 登录页面URL
url = 'https://www.om.cn/login'
# 访问登录页面
browser.get(url)
handle = browser.current_window_handle
# 等待3s用于加载脚本文件
browser.implicitly_wait(3)
# 单击登录按钮,弹出滑动验证码
btn = browser.find_element_by_class_name('login_btn1')
btn.click()
# 获取iframe元素
frame = browser.find_element_by_id('tcaptcha_iframe')
browser.switch_to.frame(frame)
# 延时1s
time.sleep(1)
# 获取背景图的src
targetUrl = browser.find_element_by_id('slideBg').get_attribute('src')
# 获取拼图的src
tempUrl = browser.find_element_by_id('slideBlock').get_attribute('src')
# 新建标签页
browser.execute_script("window.open('');")
# 切换到新标签页
browser.switch_to.window(browser.window_handles[1])
# 访问背景图的src
browser.get(targetUrl)
time.sleep(3)
# 截图
browser.save_screenshot('temp_target.png')
w = 680
h = 390
img = cv2.imread('temp_target.png')
size = img.shape
top = int((size[0] - h) / 2)
height = int(h + ((size[0] - h) / 2))
left = int((size[1] - w) / 2)
width = int(w + ((size[1] - w) / 2))
cropped = img[top:height, left:width]
# 裁剪尺寸
cv2.imwrite('temp_target_crop.png', cropped)
# 新建标签页
browser.execute_script("window.open('');")
browser.switch_to.window(browser.window_handles[2])
browser.get(tempUrl)
time.sleep(3)
browser.save_screenshot('temp_temp.png')
w = 136
h = 136
img = cv2.imread('temp_temp.png')
size = img.shape
top = int((size[0] - h) / 2)
height = int(h + ((size[0] - h) / 2))
left = int((size[1] - w) / 2)
width = int(w + ((size[1] - w) / 2))
cropped = img[top:height, left:width]
cv2.imwrite('temp_temp_crop.png', cropped)
browser.switch_to.window(handle)
# 模糊匹配两张图片
move = canndy_test.matchImg('temp_target_crop.png', 'temp_temp_crop.png')
# 计算出拖动距离
distance = int(move / 2 - 27.5) + 2
draggable = browser.find_element_by_id('tcaptcha_drag_thumb')
ActionChains(browser).click_and_hold(draggable).perform()
# 拖动
ActionChains(browser).move_by_offset(xoffset=distance, yoffset=0).perform()
ActionChains(browser).release().perform()
time.sleep(10)
3. IP限制
有些网站为了防止被爬取,会针对IP做出访问限制。当发现有固定的IP高频访问一些页面或者接口时,会对这些IP进行限制,甚至对一些IP进行“封杀”,从而阻止爬虫程序。
针对这样的情况,可以通过IP代理采用随机IP的方式访问需要爬取的网站或者资源。国内免费的IP代理资源较多,其中,西刺代理网站比较常用。
示例如下:
from bs4 import BeautifulSoup
import requests
import random
url = 'http://www.xicidaili.com/nn/'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) Apple
WebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.106 Safari/537.36'
}
# 获取IP列表
def get_ip_list(url, headers):
web_data = requests.get(url, headers=headers)
soup = BeautifulSoup(web_data.text, 'lxml')
ips = soup.find_all('tr')
ip_list = []
for i in range(1, len(ips)):
ip_info = ips[i]
tds = ip_info.find_all('td')
ip_list.append(tds[1].text + ':' + tds[2].text)
return ip_list
# 从IP列表获取随机IP
def get_random_ip(ip_list):
proxy_list = []
for ip in ip_list:
proxy_list.append('http://' + ip)
proxy_ip = random.choice(proxy_list)
proxies = {'http': proxy_ip}
return proxies
if __name__ == '__main__':
ip_list = get_ip_list(url, headers=headers)
proxies = get_random_ip(ip_list)
print(proxies)
执行程序,输出结果如下:
{'http': 'http://183.166.96.154:9999'}
使用的时候只需要在调用的地方加入proxies即可,代码如下:
response = requests.get(url=url,headers=headers,params=params,proxies=proxy)
实际应用时应该考虑先验证IP的有效性,然后再将有效的代理IP写入持久化数据库。有效性的判断可以使用requests包请求该代理服务器(IP+Port),通过检查请求返回的状态码是否是200来判断该代理是否可用。而持久化数据的解决方案也很多,可以将有效的IP记录存储在关系型数据库如MySQL、PG、Oracle和DB2中,也可以存储在非关系型数据库如Redis和MongoDB中。
工欲善其事,必先利其器。维护一套长期可用的IP池有助于爬虫的开发工作。免费的IP代理网站的IP往往不太稳定,因此需要编写定期抓取IP的脚本来维护可用的IP池。在经济允许或者有更高要求的情况下,可以选择购买商用付费版的IP代理服务。
文章来自于网络,如果侵犯了您的权益,请联系站长删除!