理想国

我要看尽这世间繁华


  • 首页

  • 归档

  • 标签

  • 分类

  • 关于

  • 搜索

从0到1搭建移动App功能自动化测试平台(2):操作iOS应用的控件

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

写在前面

前两天微信突然发来一条系统消息,提示DebugTalk可以开通原创标识了(同时也有了评论功能),虽然一直在期待,但没想到来得这么快,着实是个不小的惊喜。

另外,最近在公众号后台也收到好几个朋友的信息,有的是询问某某部分什么时候能发布,有的是希望能加快更新速度。说实话,收到这样的信息虽然会有压力,但真的挺开心的,因为这说明DebugTalk至少能给一部分人带去价值,这说明这件事本身还是值得坚持去做的。

不过,在更新频率这件事儿上,的确是要跟大家说抱歉了。因为DebugTalk发布的内容全都是原创,主题基本上都是来源于我日常测试工作的经验积累,或者我近期学习一些测试技术的收获总结,这也意味着,我写的东西很多时候并不是自己完全熟悉的(完全掌握的东西也没有足够的动力专门花时间去写)。

就拿最近连载的《从0到1搭建移动App功能自动化测试平台》系列来说,由于我也是边探索边总结,因此中途难免会遇到一些意想不到的坑,造成额外的耗时,而且为了保证文章能尽量通俗易通,我也需要对涉及到的内容充分进行理解,并且经过大量实践进行验证,然后才能站在半个初学者、半个过来人的角度,重新整理思路,最后以尽可能流畅的思路将主题内容讲解清楚。

基于这些原因,DebugTalk要做到每日更新是很难了,但是保证每周发布1~2篇还是可以的,希望大家能理解。

关于UI控件

在上一篇文章中,我们成功地通过Appium Inspector调用模拟器并运行iOS应用,iOS的自动化测试环境也已全部准备就绪了。

那么接下来,我们就可以开始实现自动化测试了么?

貌似还不行。在开始之前,我们先想下什么是APP功能自动化测试。

APP的功能自动化测试,简单地来说,就是让功能测试用例自动地在APP上执行。具体到每一个测试用例,就是能模拟用户行为对UI控件进行操作,自动化地实现一个功能点或者一个流程的操作。再细分到每一步,就是对UI控件进行操作。

因此,在正式开始编写自动化测试用例之前,我们还需要熟悉如何与APP的UI控件进行交互操作。

在iOS系统中,UI控件有多种类型,常见的有按钮(UIAButton)、文本(UIAStaticText)、输入框(UIATextField)等等。但不管是对什么类型的UI控件进行操作,基本都可以分解为三步,首先是获取目标控件的属性信息,然后是对目标控件进行定位,最后是对定位到的控件执行动作。

获取UI控件信息

在Appium中,要获取iOS的UI控件元素信息,可以采用两种方式:一种是在前一篇文章中提到的Appium Inspector,另一种是借助Ruby实现的appium_console,在Terminal中通过命令进行查询。

Appium Inspector

运行Appium Server,并启动【Inspector】后,整体界面如下图所示。

Appium inspector introduction

现对照着这张图对Appium Inspector进行介绍。

在右边部分,是启动的模拟器,里面运行着我们的待测APP。我们可以像在真机中一样,在模拟器中执行任意功能的操作,当然,模拟器跟真机毕竟还是有区别的,跟传感器相关的功能,例如摄像头、重力感应等,是没法实现的。

在左边部分,就是Appium Inspector。Inspector主要由如下四个部分组成:

  • 预览界面区:显示画面与模拟器界面一致;不过,当我们在模拟器中切换界面后,Inspector的预览区中显示图像并不会自动同步,若要同步,需要点击【Refresh】按钮,然后Inspector会将模拟器当前UI信息dump后显示到预览区;在预览区中,可以点击选择任意UI控件。
  • UI信息展示区:展示当前界面预览区中所有UI元素的层级关系和UI元素的详细信息;在预览区中点击选择任意UI控件后,在“Details”信息框中展示选中控件的详细信息,包括name、label、value、xpath等属性值;通过层级关系,我们也能了解选中控件在当前界面树状结构中所处的具体位置。
  • 交互操作区:模拟用户在设备上的操作,例如单击(tap)、滑动(swipe)、晃动(shake)、输入(input)等;操作动作是针对预览界面区选中的控件,因此在操作之前,务必需要先在预览区点击选择UI元素。
  • 脚本生成区:将用户行为转换为脚本代码;点击【Record】按钮后,会弹出代码区域;在交互操作区进行操作后,就会实时生成对应的脚本代码;代码语言可通过下拉框进行选择,当前支持的语言类型有:C#、Ruby、Objective-C、Java、node.js、Python。

在实践操作中,Inspector最大的用途就是在可以可视化地查看UI元素信息,并且可以将操作转换为脚本代码,这对初学者尤为有用。

例如,在预览区点击选中按钮“BUY NOW”,然后在UI信息展示区的Details窗口就可以看到该按钮的所有属性信息。在交互操作区点击【Tap】按钮后,就会模拟用户点击“BUY NOW”按钮,并且在脚本区域生成当次按钮点击的脚本(选择Ruby语言):

1
find_element(:name, "BUY NOW >").click

如上就是使用Appium Inspector的一般性流程。

Appium Ruby Console

有了Appium Inspector,为什么还需要Appium Ruby Console呢?

其实,Appium Ruby Console也并不是必须的。经过与多个熟悉Appium的前辈交流,他们也从未用过Appium Ruby Console,这说明Appium Ruby Console并不是必须的,没有它也不会影响我们对Appium的使用。

但是,这并不意味着Appium Ruby Console是多余的。经过这些天对Appium的摸索,我越发地喜欢上Appium Ruby Console,并且使用的频率越来越高,现在已基本上很少使用Appium Inspector了。这种感觉怎么说呢?Inspector相比于Ruby Conosle,就像是GUI相比于Linux Terminal,大家应该能体会了吧。

Appium Inspector的功能是很齐全,GUI操作也很方便,但是,最大的问题就是使用的时候非常慢,在预览界面区切换一个页面常常需要好几秒,甚至数十秒,这是很难让人接受的。

在上一节中也说到了,Inspector最大的用途就是在可以可视化地查看UI元素信息,并且可以将操作转换为脚本代码。但是当我们对Appium的常用API熟悉以后,我们就不再需要由工具来生成脚本,因为自己直接写会更快,前提是我们能知道目标控件的属性信息(type、name、label、value)。

在这种情况下,如果能有一种方式可以供我们快速查看当前屏幕的控件属性信息,那该有多好。

庆幸的是,在阅读Appium官方文档时,发现Appium的确是支持命令行方式的,这就是Appium Ruby Console。

Appium Ruby Console是采用Ruby语言开发的,在使用方式上面和Ruby的irb很类似。

在使用Appium Ruby Console时,虚拟机的配置信息并不会从GUI中读取,而是要通过配置文件进行指定。

配置文件的名称统一要求为appium.txt,内容形式如下所示:

1
2
3
4
5
[caps]
platformName = "ios"
platformVersion = '9.3',
app = "/path/to/UICatalog.app.zip"
deviceName = "iPhone Simulator"

其中,platformName指定虚拟机操作系统类型,“ios”或者”android”;platformVersion指定操作系统的版本,例如iOS的’9.3’,或者Android的’5.1’;app指定被测应用安装包的路径。这三个参数是必须的,与Inspector中的配置也能对应上。

在使用Appium Ruby Console时,首先需要启动Appium Server,通过GUI或者Terminal均可。

然后,在Terminal中,进入到appium.txt文件所在的目录,执行arc命令即可启动Appium Ruby Console。arc,即是appium ruby console首字母的组合。

1
2
3
4
➜ ls
appium.txt
➜ arc
[1] pry(main)>

接下来,就可以通过执行命令查询当前设备屏幕中的控件信息。

使用频率最高的一个命令是page,通过这个命令可以查看到当前屏幕中所有控件的基本信息。

例如,当屏幕停留在前面截图中的页面时,执行page命令可以得到如下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
[1] pry(main)> page
UIANavigationBar
name: HomeView
id: Home => Home
米 => m
去看看 => View
UIAButton
name, label: tabbar category gray
UIAImage
name: debugtalk_logo.png
UIAButton
name, label: tabbar cart gray
UIATableView
value: rows 1 to 4 of 15
UIAPageIndicator
value: page 2 of 2
UIATableCell
name: For the first time ever in a hand held camera, the Osmo brings professional, realtime cinema-quality stabilization.
id: 米 => m
UIAStaticText
name, label, value: For the first time ever in a hand held camera, the Osmo brings professional, realtime cinema-quality stabilization.
id: 米 => m
UIAStaticText
name, label, value: OSMO
UIAButton
name, label: SHOP NOW >
UIATableCell
name: Ronin
UIAStaticText
name, label, value: Ronin
UIAStaticText
name, label, value: Phantom
id: 米 => m
... (略)
UIAButton
name, label: Store
value: 1
id: 门店 => Store
... (略)
UIAButton
name, label: My Account
id: My Account => My Account
nil

通过返回信息,我们就可以看到所有控件的type、name、label、value属性值。如果在某个控件下没有显示label或value,这是因为这个值为空,我们可以不予理会。

由于page返回的信息太多,可能不便于查看,因此在使用page命令时,也可以指定控件的类型,相当于对当前屏幕的控件进行筛选,只返回指定类型的控件信息。

指定控件类型时,可以通过string类型进行指定(如 page “Image”),也可通过symbol类型进行指定(如 page :cell)。指定的类型可只填写部分内容,并且不分区大小写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[2] pry(main)> page "Image"
UIAImage
name: debugtalk_logo.png
nil
[3] pry(main)> page :cell
UIATableCell
name: DebugTalk’s smartest flying camera ever.
id: 米 => m
UIATableCell
name: Ronin
UIATableCell
name: Phantom
id: 米 => m
nil

如果需要查看当前屏幕的所有控件类型,可以执行page_class命令进行查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[4] pry(main)> page_class
14x UIAButton
8x UIAStaticText
4x UIAElement
4x UIATableCell
2x UIAImage
2x UIAWindow
1x UIAPageIndicator
1x UIATableView
1x UIAStatusBar
1x UIANavigationBar
1x UIATabBar
1x UIAApplication
nil

基本上,page返回的控件信息已经足够满足绝大多数场景需求,但有时候情况比较特殊,需要enabled、xpath、visible、坐标等属性信息,这时就可以通过执行source命令。执行source命令后,就可以返回当前屏幕中所有控件的所有信息,以xml格式进行展现。

定位UI控件

获取到UI控件的属性信息后,就可以对控件进行定位了。

首先介绍下最通用的定位方式,find。通过find命令,可以实现在控件的诸多属性值(name、label、value、hint)中查找目标值。查询时不区分大小写,如果匹配结果有多个,则只返回第一个结果。

1
2
3
4
[5] pry(main)> find('osmo')
#<Selenium::WebDriver::Element:0x..febd52a30dcdfea32 id="2">
[6] pry(main)> find('osmo').label
"Osmo"

另一个通用的定位方式是find_element,它也可以实现对所有控件进行查找,但是相对于find,可以对属性类型进行指定。

1
2
3
4
[7] pry(main)> find_element(:class_name, 'UIATextField')
#<Selenium::WebDriver::Element:0x31d87e3848df8804 id="3">
[8] pry(main)> find_element(:class_name, 'UIATextField').value
"Email Address"

不过在实践中发现,采用find、find_element这类通用的定位方式并不好用,因为定位结果经常不是我们期望的。

经过反复摸索,我推荐根据目标控件的类型,选择对应的定位方式。总结起来,主要有以下三种方式。

针对Button类型的控件(UIAButton),采用button_exact进行定位:

1
2
[9] pry(main)> button_exact('Login')
#<Selenium::WebDriver::Element:0x..feaebd8302b6d77cc id="4">

针对Text类型的控件(UIAStaticText),采用text_exact进行定位:

1
2
[10] pry(main)> text_exact('Phantom')
#<Selenium::WebDriver::Element:0x1347e89100fdcee2 id="5">

针对控件类型进行定位时,采用tag;如下方式等价于find_element(:class_name, 'UIASecureTextField')。

1
2
[11] pry(main)> tag('UIASecureTextField')
#<Selenium::WebDriver::Element:0x..fc6f5efd05a82cdca id="6">

基本上,这三种方式就已经足够应付绝大多数测试场景了。当然,这三种方式只是我个人经过实践后选择的定位方式,除了这三种,Appium还支持很多种其它定位方式,大家可自行查看Appium官方文档进行选择。

另外,除了对控件进行定位,有时候我们还想判断当前屏幕中是否存在某个控件(通常用于结果检测判断),这要怎么做呢?

一种方式是借助于Appium的控件查找机制,即找不到控件时会抛出异常(Selenium::WebDriver::Error::NoSuchElementError);反过来,当查找某个控件抛出异常时,则说明当前屏幕中不存在该控件。

1
2
3
[12] pry(main)> button_exact('Login_invalid')
Selenium::WebDriver::Error::NoSuchElementError: An element could not be located on the page using the given search parameters.
from /Library/Ruby/Gems/2.0.0/gems/appium_lib-8.0.2/lib/appium_lib/common/helper.rb:218:in `_no_such_element'

该种方式可行,但比较暴力,基本上不会采用这种方式。

另一种更好的方式是,查找当前屏幕中指定控件的个数,若个数不为零,则说明控件存在。具体操作上,将button_exact替换为buttons_exact,将text_exact替换为texts_exact。

1
2
3
4
[12] pry(main)> buttons_exact('Login').count
1
[13] pry(main)> buttons_exact('Login_invalid').count
0

除此之外,基于Ruby实现的appium_lib还支持exists方法,可直接返回Boolean值。

1
2
3
4
[14] pry(main)> exists { button_exact('Login') }
true
[15] pry(main)> exists { button_exact('Login_invalid') }
false

对控件执行操作

定位到具体的控件后,操作就比较容易了。

操作类型不多,最常用就是点击(click)和输入(type),这两个操作能覆盖80%以上的场景。

对于点击操作,才定位到的控件后面添加.click方法;对于输入操作,在定位到的输入框控件后面添加.type方法,并传入输入值。

例如,账号登录操作就包含输入和点击两种操作类型。

1
2
3
4
5
6
[16] pry(main)> find_element(:class_name, 'UIATextField').type 'leo.lee@debugtalk.com'
""
[17] pry(main)> find_element(:class_name, 'UIASecureTextField').type '123456'
""
[18] pry(main)> button_exact('Login').click
nil

To be continued …

在本文中,我们学习了对iOS UI控件进行交互操作的一般性方法,为编写自动化测试脚本打好了基础。

在下一篇文章中,我们就要正式开始针对iOS应用编写自动化测试脚本了。

从0到1搭建移动App功能自动化测试平台(1):模拟器中运行iOS应用

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

在上一篇文章中,我对本系列教程的项目背景进行了介绍,并对自动化测试平台的建设进行了规划。

在本文中,我将在已准备就绪的iOS自动化测试环境的基础上,通过Appium调用模拟器运行iOS应用。内容很是基础,熟悉的同学可直接略过。

iOS应用安装包的基础知识

作为完全的iOS新手,困惑的第一个问题就是iOS安装包文件。

在Android系统中,安装App的途径很多,除了各类应用市场,普通用户也经常直接下载apk安装包文件后手动进行安装,因此大家对Android的安装包文件都比较熟悉。

但是对于iOS系统就不一样了,由于我们普通用户在iOS上安装应用的时候基本上只能通过Apple Store进行安装(未越狱),没有机会接触原始的安装包文件,因此往往连iOS应用的安装包到底是什么格式后缀都不清楚。

现在我们想在Appium App中通过模拟器运行被测应用,需要指定iOS app的安装包路径,因此需要首先获得一个iOS app安装包。

Appium initialize iOS Settings

那么iOS app的安装包长啥样呢?

或者在这个问题之前,我们先来看下另一个问题:对于iOS设备来说,如果不通过Apple Store,我们可以怎样安装一个应用?

针对这个问题,我搜了些资料,也请教了周围的同事,了解到的途径有如下几个:

  • 企业证书:该种方式适用于企业内部;通过企业证书编译出的iOS应用,无需上传至Apple Store,即可无限制的安装到企业员工的iOS设备中。只是需要解决的一个问题是,由于iOS设备没有文件管理器,没法将安装包拷贝到iOS设备中,因此常用的做法是将安装包(.ipa文件)上传至一些下载服务器(例如fir.im),并生成二维码,然后用户扫描二维码后即可通过浏览器下载安装包并进行安装。由此联想到另外一个方法,通过微信文件传输助手将安装包(.ipa)传输至iOS设备,然后再进行安装应该也是可以的吧?这种方法不知在原理上是否可行,因为在试验时由于安装包大于30M,微信无法传输,所以没能进行验证。
  • Xcode:该种方式适用于iOS开发者;开发者在Xcode中连上iOS设备对源码进行编译,编译生成的应用会自动安装至iOS设备。当然,该种方式也是需要iOS开发者证书。
  • PP助手:该种方式适用于普通用户;PP助手是一个非苹果官方的设备资源管理工具,可以实现对未越狱的iOS设备进行应用管理,也可以安装本地.ipa文件,前提是.ipa文件具有合适的签名。

在上面列举的安装应用的途径中,反复提到了.ipa文件,那.ipa应该就是iOS应用程序的后缀了吧?暂且这么认为吧。

再回到前面的场景,要在iOS模拟器中运行iOS应用,我们是否可以找研发人员要一个.ipa安装包文件,然后就能在模拟器中加载运行应用呢?

刚开始的时候我是这么认为的。于是我获取到.ipa文件后,在App Path中填写该文件的路径,然后启动Appium Server;接着我再打开Inspector时,发现iOS模拟器启动了,但是在应用启动的时候就出问题了,始终无法正常启动,感觉像是启动崩溃,反复尝试多次仍然如此。

再次经过Google,总算是明白出现问题的原因了,总结下来有如下几点:

  • 不管是从Apple Store或iTunes上下载的应用,还是在Xcode中针对真机设备编译生成的.ipa文件,都是面向于ARM处理器的iOS设备,只能在真机设备中进行安装;
  • 而在Mac OSX系统中运行的iOS模拟器,运行环境是基于Intel处理器的;
  • 因此,若是针对真机设备编译生成的.ipa文件,是无法在iOS模拟器中正常运行的,毕竟处理器架构都不一样;
  • 要想在iOS模拟器中运行应用,则必须在Xcode中编译时选择模拟器类型;编译生成的文件后缀为.app。

准备.app文件

接下来,就说下如何获取.app文件。

虽然是测试人员,不会对被测iOS项目贡献代码,但是也不能总是找研发帮忙编译生成.app文件。所以,在本地搭建完整的iOS项目开发环境还是很有必要的。

对于iOS开发环境的搭建,当前社区中应该已经有了很多完整的教程,我在这儿就不详细描述了,只简单说下我搭建过程中涉及到的几个点。

首先,Mac OSX、Xcode、Apple Developer Tools这些基础环境的安装,在上一篇文章中已经进行说明了;

然后,申请项目源码的访问权限,git clone到本地;

接着是项目依赖环境的问题;通常一个较大型的iOS项目都会引用许多第三方库,而这些依赖库并不会直接保存到项目仓库中,通常是采用CocoaPods进行管理;简单地说,CocoaPods是针对Swift和Objective-C项目的依赖管理器,类似于Java中的Maven,Ruby中的Gem,Python中的pip。

当然,iOS项目的依赖管理工具也不是只有CocoaPods一个,如果是采用的别的依赖管理器,请自行查找对应的资料。

采用CocoaPods管理的项目,在项目根目录下会包含Podfile和Podfile.lock文件,里面记录了当前项目依赖的第三方库以及对应的版本号。

安装CocoaPods很简单,采用gem即可。

1
$ sudo gem install cocoapods

然后,进入到iOS项目的目录,执行pod install命令即可安装当前项目的所有依赖。

1
2
3
4
5
6
7
8
9
10
11
$ cd Project_Folder
$ pod install
Re-creating CocoaPods due to major version update.
Analyzing dependencies
.....(略)
Downloading dependencies
.....(略)
Generating Pods project
Integrating client project
Sending stats
Pod installation complete! There are 27 dependencies from the Podfile and 28 total pods installed.

关于CocoaPods的更多信息,请自行查看官方网站

在依赖安装完成后,正常情况下,就可以在Xcode中编译项目了。

没有别的需要注意的,将target选择为模拟器(iOS Simulator)即可。而且针对模拟器进行编译时,也不会涉及到开发者证书的问题,项目配置上会简单很多。待后续讲到真机上的自动化测试时,我再对证书方面的内容进行补充。

编译完成后,在Products目录下,就可以看到XXX.app文件,这里的XXX就是项目名称;然后,选中XXX.app文件,【Show in Finder】,即可在文件目录中定位到该文件。

接下来,将XXX.app文件拷贝出来,或者复制该文件的Full path,怎样都行,只要在Appium的App Path中能定位到该文件就行。

模拟器中运行iOS应用

被测应用.app准备就绪后,接下来就可以在iOS模拟器中运行了。

回到前面的那张图。启动Appium app后,对于模拟器运行的情况,在iOS Settings中必须设置的参数项就3个,App Path、Force Device和Platform Version。对于真机运行的情况,后续再单独进行说明。

设置完毕后,点击【Launch】,启动Appium Server。

Appium inspector button

然后,点击图中红框处的按钮,即可通过Inspector启动模拟器,并在模拟器中加载iOS应用。

Appium iOS Simulator Console

在模拟器中,我们可以像在真机中一样,体验被测应用的各项功能;并且,在Appium的日志台中,可以实时查看到日志信息。

经历的一个坑

整个过程是挺简单的,不过,在探索过程中我还是有遇到一个坑。

通过Inspector启动模拟器时,总是弹框报错,报错形式如下。

Appium Inspector Error

刚开始出现这问题时百思不得其解,因为提示的信息并不明显,Google了好一阵也没找到原因。最后只有详细去看日志信息,才发现问题所在。

在日志中,发现的报错信息如下:

1
[iOS] Error: Could not find a device to launch. You requested 'iPhone 6 (8.4)', but the available devices were: ["Apple TV 1080p (9.2) [98638D25-7C82-48DF-BDCA-7F682F951533] (Simulator)","iPad 2 (9.2) [5E22F53E-EAB3-45DF-A1DD-10F58E920679] (Simulator)","iPad 2 (9.3) [4B2D2F9A-C099-4C13-8DE9-27C826A521C2] (Simulator)","iPad Air (9.2) [825E4997-9CD8-4225-9977-4C7AE2C98389] (Simulator)","iPad Air (9.3) [E4523799-E35F-4499-832B-12CF33F09144] (Simulator)","iPad Air 2 (9.2) [8057039D-F848-453E-97EC-2F75CAEA2E77] (Simulator)","iPad Air 2 (9.3) [0B8F49DA-832A-4248-BA1D-9DA5D11E31FD] (Simulator)","iPad Pro (9.2) [AF1F2D06-3067-41B5-AC2B-4B0ED88BF5D9] (Simulator)","iPad Pro (9.3) [C39617A6-9D91-4C0B-B25B-741BD57B016C] (Simulator)","iPad Retina (9.2) [D3C694E1-E3B4-47BE-AB5E-80B3D4E22FC2] (Simulator)","iPad Retina (9.3) [907C7B06-ED2C-48AC-AC46-04E4AD6E0CA3] (Simulator)","iPhone 4s (9.2) [1A786195-94E3-4908-8309-7B66D84E4619] (Simulator)","iPhone 4s (9.3) [3F76F34B-5A8F-4FD1-928D-56F84C192DDD] (Simulator)","iPhone 5 (9.2) [0D79A4CA-71EB-48A6-9EE4-172BEF3EB4E0] (Simulator)","iPhone 5 (9.3) [04270D44-F831-4253-95F2-3D205D2BC0D9] (Simulator)","iPhone 5s (9.2) [13A16C07-3C5B-4B04-A94B-B40A63238958] (Simulator)","iPhone 5s (9.3) [D30A7B34-BA01-4203-80DA-FAEA436725F9] (Simulator)","iPhone 6 (9.2) [5D01650F-2A31-4D53-A47A-CCF7FD552ADD] (Simulator)","iPhone 6 (9.3) [2F0810F6-C73B-4BA4-93BA-06D4B6D96BDA] (Simulator)","iPhone 6 Plus (9.2) [9A840B78-E6CE-4D18-BE83-16B590411641] (Simulator)","iPhone 6 Plus (9.3) [27C6557A-B09D-4D8A-9846-DA8FE0A8E8D5] (Simulator)","iPhone 6s (9.2) [E7F5B8A5-0E85-404F-A4D4-191D63E7EC1B] (Simulator)","iPhone 6s (9.3) [6F702911-13C2-472C-9ECD-BADD4385CB77] (Simulator)","iPhone 6s (9.3) + Apple Watch - 38mm (2.2) [B63FFAA4-00A4-473B-9462-3664F41F9001] (Simulator)","iPhone 6s Plus (9.2) [58837F78-511A-4F0B-9DDF-782E3B9935BD] (Simulator)","iPhone 6s Plus (9.3) [C31003C6-DCE2-414D-AD7F-376F6FA995B0] (Simulator)","iPhone 6s Plus (9.3) + Apple Watch - 42mm (2.2) [E3154768-CA23-45CC-90E5-2D0386A57B7D] (Simulator)"]

问题在于,我设置iOS Settings时,将Force Device设置为”iPhone 6”,将Platform Version设置为“8.4”,但是经过组合,iPhone 6 (8.4)并不在可用的模拟器设备列表中。

再来看日志中提示的可用设备,发现“iPhone 6”设备对应的Platform Version只有“9.2”和“9.3”。然后回到iOS Settings,发现Platform Version的下拉框可选项就没有“9.2”和“9.3”,最新的一个可选版本也就是“8.4”。

Appium iOS Settings bug

这应该是Appium app的一个bug吧。不过好在Platform Version参数虽然是通过下拉框选择,但是也可以在框内直接填写内容。于是我在Platform Version设置框内填写为“9.3”,然后再次启动时,发现iOS模拟器就可以正常启动了。

To be continued …

现在,我们已经成功地通过Appium Inspector调用模拟器并运行iOS应用,接下来,我们就要开始尝试编写自动化测试用例了。

在下一篇文章中,我们将对Appium Inspector的功能进行熟悉,通过Inspector来查看iOS应用的UI元素信息,并尝试采用脚本语言与UI进行交互操作。

从0到1搭建移动App功能自动化测试平台(0):背景介绍和平台规划

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

背景

最近新加入某项目组(以下均已M指代),需要从零开始搭建功能自动化测试平台。

简单地说,M是一个典型的移动互联网产品,客户端包括iOS和Android,并在app中通过WebView嵌入了H5,后端基于Ruby on Rails实现。

当前阶段,M项目除了Rails Server端采用Jenkins+RSpec实现了部分的持续集成功能外,客户端部分的部署和测试工作都还是完全依赖于手工操作。

基于当前项目的开发模式,我对整个M项目实现持续集成自动化测试的架构流程进行了规划,初步计划的架构图如下图所示。最终的目标是希望能实现:不管是Rails Server,还是App(iOS/Android),以及H5,当任意部分存在代码提交时,系统能自动拉取最新代码进行部署并执行自动化回归测试,及时地将执行情况反馈给开发人员。

目标确定后,便是分阶段进行实现,需要开发的模块包括:

  • 自动化测试平台(Automated Test Platform):满足iOS/Android/H5的自动化功能测试,包括模拟器和真机的测试;
  • 测试管理平台(Test Management Platform):实现自动化测试用例管理、手动下发测试任务、测试结果报表展现、Dashboard等功能;
  • 打包平台(Pack System):实现iOS/Android的自动化构建;
  • 服务端自动化测试(Rails):将服务端Rails的自动化测试接入测试管理平台;
  • 持续集成流程打通:对Jenkins进行二次开发,与测试管理平台打通,实现全流程的持续集成自动化测试。

而本系列教程,《从0到1搭建移动App功能自动化测试平台》,便是对整个实践过程的一个记录。

需要说明的是,之前我个人的工作经历主要在服务端性能测试、Android客户端性能测试(测试开发)方向,对于客户端的自动化测试基本上没有经验积累,特别是iOS系统的测试,以前更是完全没有接触过。因此本系列教程只能算是个人在探索路上的学习总结和记录,可能会存在一些错误的观点,还请前辈们多多指教。

自动化测试框架的选择

在愿景图中,绿色方框(Automated Test Platform)负责移动应用客户端(iOS/Android/H5)自动化测试的调度和执行,是整个自动化测试平台的核心。

因此,在搭建自动化测试平台之前,首先需要选择一个合适的自动化测试框架。

对于移动应用的自动化测试框架,当前市面上已经有很多成熟的开源项目。针对当前项目的实际情况,我主要参考如下选择标准:

  • 同时支持iOS、Android、H5,且尽量能保持接口统一,减少开发维护成本;
  • 编程语言支持Python/Ruby;
  • 用户量大,文档丰富。

经过筛选,Appium无疑是最佳的选择。

Appium 简介

对于Appium的详细介绍,大家可参考Appium官方文档,我就不再重复引用。

不过对于Appium,仍然有几点很赞的理念值得强调。

  • 采用Appium时,无需对被测应用做任何修改,也无需嵌入任何东西;
  • Appium对iOS和Android的原生自动化测试框架进行了封装,并提供了统一的API(WebDriver API),减少了自动化测试代码的维护工作量;
  • Appium采用Client-Server的架构设计,并采用标准的HTTP通信协议;Server端负责与iOS/Android原生测试框架交互,无需测试人员关注细节实现;Client端基本上可以采用任意主流编程语言编写测试用例,减少了学习成本。

环境准备(iOS)

在Appium中测试iOS时,依赖于Apple开发环境,因此,在运行Appium之前需要先确保如下环境安装正确。

  • Mac OS X >= 10.7
  • XCode >= 4.6.3
  • Apple Developer Tools (iPhone simulator SDK, command line tools)

如上几个环境安装比较简单,直接在Apple Store中安装即可。

在安装Appium之前,为了确保Appium的相关依赖已经准备就绪,可以使用appium-doctor来进行验证。

appium-doctor是一个用于验证appium安装环境的工具,可以诊断出Node/iOS/Android环境配置方面的常见问题。

appium-doctor采用node.js编写,采用npm即可在Terminal中进行安装:

1
$ npm install appium-doctor -g

安装完毕后,执行appium-doctor命令即可对Appium的环境依赖情况进行检测;指定--ios时只针对iOS环境配置进行检测,指定--android参数时只针对Android环境配置进行检测,若不指定则同时对iOS和Android环境进行检测。

1
2
3
4
5
6
7
8
9
10
11
12
$ appium-doctor --ios
info AppiumDoctor ### Diagnostic starting ###
info AppiumDoctor ✔ Xcode is installed at: /Applications/Xcode.app/Contents/Developer
info AppiumDoctor ✔ Xcode Command Line Tools are installed.
info AppiumDoctor ✔ DevToolsSecurity is enabled.
info AppiumDoctor ✔ The Authorization DB is set up properly.
info AppiumDoctor ✔ The Node.js binary was found at: /usr/local/bin/node
info AppiumDoctor ✔ HOME is set to: /Users/Leo
info AppiumDoctor ### Diagnostic completed, no fix needed. ###
info AppiumDoctor
info AppiumDoctor Everything looks good, bye!
info AppiumDoctor

若检测结果全部通过,则说明Appium的相关依赖已经准备就绪,接下来可以继续安装Appium。

安装Appium

根据前面的介绍,Appium采用Client-Server的架构设计,因此安装Appium时需要分别安装Server部分和Client部分。

通常情况下,我们说的Appium都是指代的Server部分。Appium的安装有多种方式:可以通过源码编译安装,也可以在Terminal中通过npm命令安装,另一种是直接下载appium.dmg后安装应用程序。

在这里推荐运行Appium app的方式,除了GUI界面操作更直观以外,更重要的一个原因是,相比于命令行运行方式,Appium app多了一个Inspector模块,可以调用模拟器运行被测应用程序,并且可以很方便地在预览页面中查看UI元素的层级结构和详细控件属性,极大地提高编写测试脚本的效率。

至于Client部分,其实我们原本可以不安装任何东西,只需要任意选择一门开发语言,然后直接基于WebDriver的C/S协议(JSON Wire Protocol)即可编写自动化测试代码。但是这样做的话工作量会比较大,因为要去处理一些跟协议相关的工作。所幸Appium项目已经针对众多主流的编程语言,将底层协议处理相关的工作封装为Library,通过调用这些Library,可以极大地简化我们编写测试用例的工作量。

而说的需要安装的Client部分,其实也就是安装这些Library。选定编写测试用例的语言后,我们就可以针对性地进行安装。

例如,如果选择Ruby语言,那么需要安装的Library就是appium_lib,安装方式如下:

1
$ gem install appium_lib

如果选择Python语言,那么需要安装的Library就是Appium-Python-Client,安装方式如下:

1
$ pip install Appium-Python-Client

对于其它编程语言,请自行参考官方文档。

To be continued …

iOS的自动化测试环境已基本准备就绪了,接下来我们想做的第一件事,就是在模拟器中运行iOS应用。

在下一篇文章中,我们将从clone项目源码为起点,编译生成iOS app,在Appium中调用模拟器中运行iOS app,并分享实践过程中遇到的一些坑。

通过 API 远程管理 Jenkins

发表于 2016-05-02 | 更新于 2019-04-03 | 分类于 Development

背景介绍

最近接到一个需求,需要对公司内部的Android性能测试平台的分支管理模块进行改造。

为了更好地说明问题,在下图中展示了一个精简的持续集成测试系统。

Jenkins DroidTestbed

在该系统中,Jenkins负责定时检测代码库(Code Repository)的代码更新情况,当检测到有新的代码提交时,自动采用最新的代码进行构建,并采用构建得到的包(apk)触发自动化测试平台(DroidTestbed)执行测试任务。

然后再说下分支管理模块。

由于我们的持续集成平台通常不止监控一个产品,而每个产品又不止监控一个tag(例如/trunk,/projects/cn/10.9.8),因此,我们的持续集成平台需要有分支管理的功能,即针对每一个产品的每一个tag,单独创建一个分支,并针对各个分支单独指定测试用例集合测试设备。

具体实现方面,出于单一职责的原则,我们对功能进行了如下划分:

  • 在Jenkins端针对每一个分支创建一个Job;
  • 在DroidTestbed端配置测试资源,针对每一个分支分别绑定测试用例集和测试设备,每一个分支会存在一个单独的branch_id;
  • 在Jenkins端的Job配置中,保存该分支在DroidTestbed中对应的branch_id,实现Jenkins与DroidTestbed的关联。

整个过程看上去并没有什么问题,那为什么需要对分支管理模块进行改造呢?

问题就出现在分支配置上面。

试想一下,每次要新增或修改一个分支的时候,由于Jenkins端和DroidTestbed端的配置是独立的,那么我们就只能在两个平台上分别进行配置。

另一方面,配置工作本身也较为复杂,例如,在Jenkins端就需要设置的参数包括:repository_url,tag,ref_tag,ref_revision,branch_id,schedule,user_name等;而这其中的大部分参数同样也要在DroidTestbed端进行配置。

根据历史经验,但凡涉及到复杂且重复的手工操作时,就容易出错。实际情况的确是这样的。在该功能上线后,由于配置复杂,业务组的同学每次要新增一个监控分支时,都需要找到管理员来帮忙配置(说实话,管理员对业务同学能配置正确也没信心);即使是管理员,也出现过好几次因为疏忽造成配置错误的情况。

那么,这个问题要怎么解决呢?

Jenkins Remote API 的简介

绕了这么大一个圈子,终于引出本文的主题,Jenkins Remote API。

实际上,Jenkins本身支持丰富的API接口,我们通过远程调用接口,基本上可以实现所有需要的功能,例如:

  • 从Jenkins获取Job状态信息
  • 触发Jenkins执行构建
  • 创建、复制、修改、删除Job

回到前面的案例,我们就可以将配置操作全部放在DroidTestbed中,只需要在保存配置项时,由DroidTestbed自动调用Jenkins的Remote API,即可实现配置的同步。

Jenkins Remote API 的调用

现在我们来看下如何调用Jenkins的Remote API。

Jenkins的Remote API以REST-like的形式进行提供,通过对特定的API执行POST请求即可实现对Jenkins的操作。

例如,我们搭建的Jenkins站点为http://jenkins.debugtalk.com:8080,那么,访问http://jenkins.debugtalk.com:8080/api即可查看到该站点所有可用的API;若想若某个具体的Job进行操作,如job名称android_core_dashboard_trunk,它的管理页面为http://jenkins.debugtalk.com:8080/job/android_core_dashboard_trunk,那么我们访问http://jenkins.debugtalk.com:8080/job/android_core_dashboard_trunk/api/即可查看到该job可用的API。

更详细的POST调用方式的介绍可以参考Jenkins的官方wiki,在此就不过多进行介绍。

可以看出,通过对特定API执行POST请求操作较为原始,因为我们需要关注过多底层细节。事实上,当前已经有前辈针对这一痛点,对底层的POST操作细节进行了封装,形成了一些wrapper方便我们从上层进行更便捷的操作。

这类wrapper实现的功能类似,都可以方便我们在代码中通过更简洁的方式调用Jenkins API,实现对Jenkins的远程管理,我们只需要根据我们采用的具体编程语言来选择对应的wrapper即可。当然,如果没有找到合适的,我们也可以参照已有的开源wrapper,自己再造一个轮子,原理都是相同的。

在Jenkins的官方wiki中,推荐了两个较为成熟的API wrapper,一个是基于Python实现的salimfadhley/jenkinsapi,另一个是基于Ruby实现的arangamani/jenkins_api_client。

以salimfadhley/jenkinsapi为例,通过使用jenkinsapi,我们在Python中就可以很方便地管理Jenkins。常见的操作方式示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
>>> import jenkinsapi
>>> from jenkinsapi.jenkins import Jenkins

# 指定Jenkins实例
>>> J = Jenkins('http://jenkins.debugtalk.com:8080')

# 查看Jenkins版本
>>> J.version
1.542

# 查看Jenkins的所有jobs
>>> J.keys()
['foo', 'test_jenkinsapi']

# 查看指定job的配置信息
>>> J['test_jenkinsapi'].get_config()

# 创建Jenkins job
>>> jobName = 'test_job'
>>> EMPTY_JOB_CONFIG = '''
<?xml version='1.0' encoding='UTF-8'?>
<project>
<actions>jkkjjk</actions>
<description></description>
<keepDependencies>false</keepDependencies>
<properties/>
<scm class="hudson.scm.NullSCM"/>
<canRoam>true</canRoam>
<disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<triggers class="vector"/>
<concurrentBuild>false</concurrentBuild>
<builders/>
<publishers/>
<buildWrappers/>
</project>
'''
>>> new_job = J.create_job(jobName, EMPTY_JOB_CONFIG)

# 更新Jenkins job的配置
>>> import xml.etree.ElementTree as et
>>> new_conf = new_job.get_config()
>>> root = et.fromstring(new_conf.strip())
>>> builders = root.find('builders')
>>> shell = et.SubElement(builders, 'hudson.tasks.Shell')
>>> command = et.SubElement(shell, 'command')
>>> command.text = "ls"
>>> J[jobName].update_config(et.tostring(root))

# 删除Jenkins job
>>> J.delete_job(jobName)

更多的使用方法可参考项目文档。

有些同学在认真研究了这些开源库后也许会说,官方文档已经翻遍了,但是文档中对用法的描述太少了,也没给出API调用的示例,还是不知道怎么使用啊。这个问题在开源库中的确也是普遍存在的。

介绍个技巧,通常优秀的开源库都会很重视测试,作者可能在文档中没有针对每一个API接口的调用方式进行说明,但通常会针对各个接口编写较为完整的测试代码。我们通过阅读测试代码,就可以充分了解API接口的使用方法了,这也比直接阅读文档有效率得多。

Read More …

最后,如果感觉上面给的示例还不够,还想看看在实际项目中如何远程管理Jenkins,那么可以关注我最近在做的一个开源项目。

先看下整体的系统架构图。

DroidTestbed DroidMeter

整个系统实现的功能是Android App的性能持续集成测试平台,主要由DroidTestbed和DroidMeter两部分组成。

其中,DroidTestbed部分采用Ruby on Rails编写,核心角色为测试任务管理,可实现测试资源(测试用例、测试设备等)配置,根据代码提交自动触发执行测试任务、测试设备自动化调度、测试任务手动下发,测试结果报表查看等。DroidMeter负责具体的性能测试执行,采用Python编写,可实现控制Android设备执行测试场景,采集性能测试数据,包括内存、启动时间、帧率、包大小、网速、流量等等。

本文暂时不对该系统进行过多介绍,我后续会单独对各个模块涉及到的技术展开进行详细介绍。如果感兴趣,可关注我的GitHub或微信公众号【DebugTalk】。

SQL语句中关于NULL的那些坑

发表于 2016-04-25 | 更新于 2019-04-03 | 分类于 Development

问题描述

今天在跟进公司内部测试平台线上问题的时候,发现一个忽略已久的问题。

为了简化问题描述,将其进行了抽象。

有一张数据表qms_branch,里面包含了一批形式如下所示的数据:

id name types
1 dashboard_trunk dashboard
2 monkey_trunk monkey
3 dashboard_projects_10_9_9 dashboard
4 performance_trunk
5 performance_projects_10_9_8 performance

在系统的某个页面中,需要展示出所有dashboard类型以外的分支,于是就采用如下方式进行查询(Rails)。

1
branches = Qms::Branch.where("types!='dashboard'")

这个方式有问题么?

之前我是觉得没什么问题。但是在代码上线后,实际使用时发现部分分支没有加载出来,这就包括了performance_trunk分支。

然后就是问题定位,到MySQL的控制台采用SQL语句进行查询:

1
SELECT * FROM qms_branch WHERE types != 'dashboard'

发现在查询结果中的确没有包含performance_trunk分支。

这是什么原因呢?为什么在第4条数据中,types属性的值明明就不是dashboard,但是采用types!='dashboard'就无法查询得到结果呢?

原因追溯

查看数据表qms_branch的结构,看到types字段的属性为:DEFAULT NULL。

经过查询资料,在w3schools上找到了答案。

  • NULL is used as a placeholder for unknown or inapplicable values, it is treated differently from other values.
  • It is not possible to test for NULL values with comparison operators, such as =, <, or <>. We will have to use the IS NULL and IS NOT NULL operators instead.

也就是说,在SQL中,NULL并不能采用!=与数值进行比较,若要进行比较,我们只能采用IS NULL或IS NOT NULL。

于是,我们将SQL语句改为如下形式:

1
SELECT * FROM qms_branch WHERE types IS NULL or types != 'dashboard'

再次查询时,结果集就包含performance_trunk分支了。

问题延伸

通过上面例子,我们知道在对NULL进行判断处理时,只能采用IS NULL或IS NOT NULL,而不能采用=, <, <>, !=这些操作符。

那除此之外,还有别的可能存在的坑么?

再看一个例子:

有一张数据表table_foo,其中有一个字段value_field,我们想从这张表中筛选出所有value_field为’value1’,’value2’或NULL的记录。

那么,我们采用IN操作符,通过如下SQL语句进行查询。

1
SELECT * FROM table_foo WHERE value_field IN ('value1', 'value2', NULL)

这会存在问题么?我们并没有采用=, <, <>, !=对NULL进行比较哦。

答案是同样存在问题!

因为在SQL中,IN语句会被转换为多个=语句。例如,上面例子中的SQL在执行时就会被转换为如下SQL语句:

1
SELECT * FROM table_foo WHERE value_field = 'value1' OR value_field = 'value2' OR value_field = NULL

而这个时候,执行value_field = NULL时就会出现问题了。

正确的做法应该是将NULL相关的判断独立出来,如下SQL才是正确的写法。

1
SELECT * FROM table_foo WHERE value_field IN ('value1', 'value2') OR value_field IS NULL

Python 的函数式编程 -- 从入门到⎡放弃⎦

发表于 2016-04-08 | 更新于 2019-04-03 | 分类于 Development

很早以前就听说过了函数式编程,印象中是一种很晦涩难懂的编程模式,但却一直没有去进行了解。

恰好这周组内的周会轮到我主持,一时也没想到要分享什么。灵光一闪,就选定函数式编程这个主题吧,反正组里的同事都没有学过,只需要讲解入门方面的知识就好,也正好可以借这个机会逼迫自己去学习下这种新的编程方式。

经过初步了解,发现支持函数式编程的语言挺多的,除了像Lisp、Scheme、Haskell、Erlang这样专用的函数式编程语言,我们常用的好多通用型编程语言(如Java、Python、Ruby、Javascript等)都支持函数式编程模式。考虑了下实际情况,最终还是选择Python作为函数式编程的入门语言,因为组内同事都熟悉Python,以此作为切入点不会产生太大困难。

经过查询资料和初步学习,对函数式编程有了些概念,经过整理,便形成了分享PPT。

以下便是这次分享的内容。

目标

通常,我们在新学习一门技术或者编程语言的时候,通常都会先从相关概念和特性入手。对于新接触函数式编程的人来说,可能会想知道如下几点:

  • 什么是函数式编程?
  • 函数式编程的特点?
  • 函数式编程的用途?
  • 函数式编程相比于命令式编程和面向对象编程的优缺点?

但是我这次分享却没有按照这个思路,因为我感觉在一开始就向听众灌输太多概念性的东西,反倒会让听众感到迷糊。因为经过查询资料发现,对于什么是函数化编程,很难能有一个协调一致的定义。而且由于我也是新接触,自身的理解可能会存在较大的偏差。

因此,我决定分享内容尽量从大家熟悉的命令式编程切入,通过大量实例来向听众展现函数式编程思维方式的不同之处。在这之后,再回过头看这几个问题,相信听众应该都会有更深刻的理解。

考虑到实际情况,本次分享希望能达成的目标是:

  • 了解函数式编程与命令式编程的主要区别
  • 掌握Python语言函数式编程的基本函数和算子
  • 会将简单的命令式编程语句转换为函数式编程

命令式编程 & 函数式编程

首先从大家熟悉的命令式编程开始,我们先回顾下平时在写代码时主要的情景。

其实,不管我们的业务代码有多复杂,都离不开以下几类操作:

  • 函数定义:def
  • 条件控制:if, elif, else
  • 循环控制:for, break, continue, while

当然,这只是部分操作类型,除此之外还应该有类和模块、异常处理等等。但考虑到是入门,我们就先只关注上面这三种最常见的操作。

对应地,函数式编程也有自己的关键字。在Python语言中,用于函数式编程的主要由3个基本函数和1个算子。

  • 基本函数:map()、reduce()、filter()
  • 算子(operator):lambda

令人惊讶的是,仅仅采用这几个函数和算子就基本上可以实现任意Python程序。

当然,能实现是一回事儿,实际编码时是否这么写又是另外一回事儿。估计要真只采用这几个基本单元来写所有代码的话,不管是在表达上还是在阅读上应该都挺别扭的。不过,尝试采用这几个基本单元来替代上述的函数定义、条件控制、循环控制等操作,对理解函数式编程如何通过函数和递归表达流程控制应该会很有帮助。

在开始尝试将命令式编程转换为函数式编程之前,我们还是需要先熟悉下这几个基本单元。

Python函数式编程的基本单元

lambda

lambda这个关键词在很多语言中都存在。简单地说,它可以实现函数创建的功能。

如下便是lambda的两种使用方式。

1
2
3
func1 = lambda : <expression()>
func2 = lambda x : <expression(x)>
func3 = lambda x,y : <expression(x,y)>

在第一条语句中,采用lambda创建了一个无参的函数func1。这和下面采用def创建函数的效果是相同的。

1
2
def func1():
<expression()>

在第二条和第三条语句中,分别采用lambda创建了需要传入1个参数的函数func2,以及传入2个参数的函数func3。这和下面采用def创建函数的效果是相同的。

1
2
3
4
5
def func2(x):
<expression(x)>

def func3(x,y):
<expression(x,y)>

需要注意的是,调用func1的时候,虽然不需要传入参数,但是必须要带有括号(),否则返回的只是函数的定义,而非函数执行的结果。这个和在ruby中调用无参函数时有所不同,希望ruby程序员引起注意。

1
2
3
4
5
>>> func = lambda : 123
>>> func
<function <lambda> at 0x100f4e1b8>
>>> func()
123

另外,虽然在上面例子中都将lambda创建的函数赋值给了一个函数名,但这并不是必须的。从下面的例子中大家可以看到,很多时候我们都是直接调用lambda创建的函数,而并没有命名一个函数,这也是我们常听说的匿名函数的由来。

map()

map()函数的常见调用形式如下所示:

1
map(func, iterable)

map()需要两个必填参数,第一个参数是一个函数名,第二个参数是一个可迭代的对象,如列表、元组等。

map()实现的功能很简单,就是将第二个参数(iterable)中的每一个元素分别传给第一个参数(func),依次执行函数得到结果,并将结果组成一个新的list对象后进行返回。返回结果永远都是一个list。

简单示例如下:

1
2
3
>>> double_func = lambda s : s * 2
>>> map(double_func, [1,2,3,4,5])
[2, 4, 6, 8, 10]

除了传入一个可迭代对象这种常见的模式外,map()还支持传入多个可迭代对象。

1
map(func, iterable1, iterable2)

在传入多个可迭代对象的情况下,map()会依次从所有可迭代对象中依次取一个元素,组成一个元组列表,然后将元组依次传给func;若可迭代对象的长度不一致,则会以None进行补上。

通过以下示例应该就比较容易理解。

1
2
3
4
5
6
7
>>> plus = lambda x,y : (x or 0) + (y or 0)
>>> map(plus, [1,2,3], [4,5,6])
[5, 7, 9]
>>> map(plus, [1,2,3,4], [4,5,6])
[5, 7, 9, 4]
>>> map(plus, [1,2,3], [4,5,6,7])
[5, 7, 9, 7]

在上面的例子中,之所以采用x or 0的形式,是为了防止None + int出现异常。

需要注意的是,可迭代对象的个数应该与func的参数个数一致,否则就会出现异常,因为传参个数与函数参数个数不一致了,这个应该比较好理解。

1
2
3
4
5
>>> plus = lambda x,y : x + y
>>> map(plus, [1,2,3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: <lambda>() takes exactly 2 arguments (1 given)

另外,map()还存在一种特殊情况,就是func为None。这个时候,map()仍然是从所有可迭代对象中依次取一个元素,组成一个元组列表,然后将这个元组列表作为结果进行返回。

1
2
3
4
5
6
7
8
>>> map(None, [1,2,3,4])
[1, 2, 3, 4]
>>> map(None, [1,2,3,4], [5,6,7,8])
[(1, 5), (2, 6), (3, 7), (4, 8)]
>>> map(None, [1,2,3,4], [5,6,7])
[(1, 5), (2, 6), (3, 7), (4, None)]
>>> map(None, [1,2,3,4], [6,7,8,9], [11,12])
[(1, 6, 11), (2, 7, 12), (3, 8, None), (4, 9, None)]

reduce()

reduce()函数的调用形式如下所示:

1
reduce(func, iterable[, initializer])

reduce()函数的功能是对可迭代对象(iterable)中的元素从左到右进行累计运算,最终得到一个数值。第三个参数initializer是初始数值,可以空置,空置为None时就从可迭代对象(iterable)的第二个元素开始,并将第一个元素作为之前的结果。

文字描述可能不大清楚,看下reduce()的源码应该就比较清晰了。

1
2
3
4
5
6
7
8
9
10
11
def reduce(function, iterable, initializer=None):
it = iter(iterable)
if initializer is None:
try:
initializer = next(it)
except StopIteration:
raise TypeError('reduce() of empty sequence with no initial value')
accum_value = initializer
for x in it:
accum_value = function(accum_value, x)
return accum_value

再加上如下示例,对reduce()的功能应该就能掌握了。

1
2
3
4
5
>>> plus = lambda x, y : x + y
>>> reduce(plus, [1,2,3,4,5])
15
>>> reduce(plus, [1,2,3,4,5], 10)
25

filter()

filter()函数的调用形式如下:

1
filter(func, iterable)

filter()有且仅有两个参数,第一个参数是一个函数名,第二个参数是一个可迭代的对象,如列表、元组等。

filter()函数的调用形式与map()比较相近,都是将第二个参数(iterable)中的每一个元素分别传给第一个参数(func),依次执行函数得到结果;差异在于,filter()会判断每次执行结果的bool值,并只将bool值为true的筛选出来,组成一个新的列表并进行返回。

1
2
3
>>> mode2 = lambda x : x % 2
>>> filter(mode2, [1,2,3,4,5,6,7,8,9,10])
[1, 3, 5, 7, 9]

以上便是Python函数式编程基本单元的核心内容。

接下来,我们就开始尝试采用新学习到的基本单元对命令式编程中的条件控制和循环控制进行转换。

替换条件控制语句

在对条件控制进行替换之前,我们先来回顾下Python中对布尔表达式求值时进行的“短路”处理。

什么叫“短路”处理?简单地讲,就是如下两点:

  • 在f(x) and g(y)中,当f(x)为false时,不会再执行g(y),直接返回false
  • 在f(x) or g(y)中,当f(x)为true时,不会再执行g(y),直接返回true

结论是显然易现的,就不再过多解释。

那么,对应到条件控制语句,我们不难理解,如下条件控制语句和表达式是等价的。

1
2
3
4
# flow control statement
if <cond1>: func1()
elif <cond2>: func2()
else: func3()
1
2
# Equivalent "short circuit" expression
(<cond1> and func1()) or (<cond2> and func2()) or (func3())

通过这个等价替换,我们就去除掉了if/elif/else关键词,将条件控制语句转换为一个表达式。那这个表达式和函数式编程有什么关系呢?

这时我们回顾上面讲过的lambda,会发现lambda算子返回的就是一个表达式。

基于这一点,我们就可以采用lambda创建如下函数。

1
2
3
4
5
6
7
8
9
10
>>> pr = lambda s:s
>>> print_num = lambda x: (x==1 and pr("one")) \
....                 or (x==2 and pr("two")) \
....                 or (pr("other"))
>>> print_num(1)
'one'
>>> print_num(2)
'two'
>>> print_num(3)
'other'

通过函数调用的结果可以看到,以上函数实现的功能与之前的条件控制语句实现的功能完全相同。

到这里,我们就实现了命令式条件控制语句向函数式语句的转换。并且这个转换的方法是通用的,所有条件控制语句都可以采用这种方式转换为函数式语句。

替换循环控制语句

接下来我们再看循环控制语句的转换。在Python中,循环控制是通过for和while这两种方式实现的。

替换for循环

for循环语句的替换十分简单,采用map()函数就能轻松实现。这主要是因为for语句和map()原理相同,都是对可迭代对象里面的每一个元素进行操作,因此转换过程比较自然。

1
2
3
4
5
# statement-based for loop
for e in lst: func(e)

# Equivalent map()-based loop
map(func, lst)
1
2
3
4
5
6
7
8
9
10
>>> square = lambda x : x * x
>>> for x in [1,2,3,4,5]: square(x)
...
1
4
9
16
25
>>> map(square, [1,2,3,4,5])
[1, 4, 9, 16, 25]

替换while循环

while循环语句的替换相比而言就复杂了许多。

下面分别是while循环语句及其对应的函数式风格的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# statement-based while loop
while <condition>:
    <pre-suite>
    if <break_condition>:
        break
    else:
        <suite>

# Equivalent FP-style recursive while loop
def while_block():
    <pre-suite>
    if <break_condition>:
        return 1
    else:
        <suite>
    return 0
 
while_FP = lambda: <condition> and (while_block() or while_FP())
while_FP()

这里的难点在于,函数式while_FP循环采用了递归的概念。当<condition>为true时,进入循环体,执行while_block();若<break_condition>为true时,返回1,while_FP()调用结束;若<break_condition>为false时,返回0,会继续执行or右侧的while_FP(),从而实现递归调用;若<break_condition>始终为false,则会持续递归调用while_FP(),这就实现了while语句中同样的功能。

为了对函数式的while循环有更深刻的理解,可以再看下如下示例。这个例子是在网上找的,实现的是echo功能:输入任意非”quit”字符时,打印输入的字符;输入”quit”字符时,退出程序。

1
2
3
4
5
6
7
8
9
10
11
12
➜  PythonFP python pyecho.py
IMP -- 1
1
IMP -- 2
2
IMP -- abc
abc
IMP -- 1 + 1
1 + 1
IMP -- quit
quit
➜ PythonFP

如下便是分别采用过程式和函数式语句实现的”echo”功能。

1
2
3
4
5
6
7
8
9
# imperative version of "echo()"
def echo_IMP():
while 1:
x = raw_input("IMP -- ")
print x
if x == 'quit':
break

echo_IMP()
1
2
3
4
5
6
7
def monadic_print(x):
print x
return x

# FP version of "echo()"
echo_FP = lambda: monadic_print(raw_input("FP -- "))=='quit' or echo_FP()
echo_FP()

更多示例

到此为止,我们对函数式编程总算有了点认识,到达之前设定的目标应该是没有问题了,看来函数式编程也并没有想象中的那么难懂。

然而,这都只是函数式编程的皮毛而已,不信?再看下如下示例。

这个示例也是在网上找的,实现的是两个列表笛卡尔积的筛选功能,找出笛卡尔积元组集合中两个元素之积大于25的所有元组。

1
2
3
4
5
6
7
bigmuls = lambda xs,ys: filter(lambda (x,y):x*y > 25, combine(xs,ys))
combine = lambda xs,ys: map(None, xs*len(ys), dupelms(ys,len(xs)))
dupelms = lambda lst,n: reduce(lambda s,t:s+t, map(lambda l,n=n: [l]*n, lst))

print bigmuls([1,2,3,4],[10,15,3,22])

[(3, 10), (4, 10), (2, 15), (3, 15), (4, 15), (2, 22), (3, 22), (4, 22)]

虽然这个例子中lambda/map/reduce/filter都是我们已经比较熟悉了的基本单元,但是经过组合后,理解起来还是会比较吃力。

总结

看到这里,有的同学就开玩笑说我这标题名称非常贴切,《Python的函数式编程–从入门到⎡放弃⎦》,因为以后在工作中应该也不会再尝试使用函数式编程了,^_^。

不过,我还是觉得函数式编程挺有意思的,更高级的特性后面值得再继续学习。即使代码不用写成pure函数式风格,但在某些时候局部使用lambda/map/reduce/filter也能大大简化代码,也是一个不错的选择。

另外,通过此次分享,再次切身体会到了教授是最好的学习方式,只有当你真正能将一个概念讲解清楚的时候,你才算是掌握了这个概念。

参考链接

http://www.ibm.com/developerworks/linux/library/l-prog/index.html

微信朋友圈投票活动的刷票案例分析

发表于 2016-04-04 | 更新于 2019-04-03 | 分类于 Testing

现阶段,在微信朋友圈举办的投票活动层出不穷,相信已经有不少同学对此不胜其烦,因为总会时不时地冒出个人(亲戚、朋友、or whatever)来请你帮TA投票。

本文倒没有打算从道德或者情感层面来探讨这个问题,我所感兴趣的是,当前大多数投票活动其实都是存在明显漏洞的,通过简单的技术手段就可以实现“刷票”。

案例描述

这里就有一个案例。

某美发网上商城(以下简称S商城)在微信平台上举办了一场在线投票活动,微信用户可通过活动链接访问到投票页面,对喜欢的发型师作品进行投票;每个微信帐号每天只能给单个作品投1张选票。

投票活动页面如下图所示:

vote activity overview

漏洞分析

表面上看,S商城已经对投票活动进行了反作弊处理,因为限制了每个微信用户每天只能投一张票。如果用户都是正常地通过微信访问这个投票服务进行投票的话,的确是能起到预期效果的。

然而,如果查看投票页面的原始地址,即按住页面向下拖动,会发现屏幕顶端显示为”本网页由XXX提供”,需要注意的是,这里的”XXX”并不是”mp.weixin.qq.com”,而是S商城的域名。也就是说,这个投票活动的程序是运行在S商城的服务器上面的。

基于以上分析,可以推断出用户投票操作的网络拓扑结构示意图应该是这样的:

vote system network structure

微信用户访问投票页面时,微信服务器只是进行了请求转发,具体的投票计数与校验都是在S商城的服务器上的。

那么,S商城是怎么来区分投票用户的呢?

这里就涉及到微信公众平台OpenID的概念了。官方对OpenID的解释是:加密后的微信号,每个用户对每个公众号的OpenID是唯一的。

要验证这一点也很容易,只需要通过采用多个微信账号进行投票,并对投票过程进行网络抓包,查看POST中的参数就可以证实。

基于这一点,微信公众平台在转发投票请求时,会在POST参数中包含用户的OpenID;S商城在接收到投票的POST请求后,通过查询当前OpenID是否在当天已经投过票,就可以阻止单一用户重复投票的行为了。

然而,这里面却存在一个很大的漏洞!

S商城只能判断OpenID是否出现了重复,但是却无法校验OpenID的有效性,因为它是无法调用微信服务器来对这个OpenID进行校验的。

VoteRobot实现

明确了这个漏洞后,要实现刷票就很简单了。

  • 采用微信用户正常地进行一次投票操作,对设备进行网络抓包,获取到投票过程中HTTP层面的请求参数和响应内容;
  • 使用Fiddler(或Python脚本)构造投票的HTTP POST请求,保持各参数与真实投票时抓取到的参数内容一致;
  • 随机生成不同的OpenID参数,重复进行POST请求。

如果要实现批量刷票,或者刷票自动化操作,那么就可以将刷票请求通过Python脚本来实现;甚至,采用LoadRunner也是可以的。

运行VoteRobot.py,输出日志如下:

1
2
3
4
5
======== Start to vote zpid(38), Total votes: 3
1 tickets has been voted, the next ticket will be voted after 35 seconds.
2 tickets has been voted, the next ticket will be voted after 31 seconds.
3 tickets has been voted, the next ticket will be voted after 10 seconds.
======== Voting Ended!

需要注意的是,通常自动化刷票时最好有个随机的时间间隔,并且,最好能动态模拟不同的设备,即修改User-Agent,否则,服务端可以较为容易地识别作弊行为。

作弊与反作弊?

看到这里,也许有的同学心中窃喜,以后投票都可以采用这种方式“刷票”了么?

很遗憾,当然不是。

其实本文中案例的漏洞是很低级的,只是,当前的确还存在不少比例的投票活动是采用这种模式。

要判断一个投票活动是否可以采用这种方式来作弊也很简单。采用本文中的方法,若查看到活动的网址是非微信官方的,而且整个投票过程也没有额外的校验,那么实现作弊的可能性就很大了;再通过抓包看下通讯交互过程,并用网络请求工具修改参数后重新请求下,即可验证是否真的可以作弊了。

另外,也许有人想问了,网络中的投票活动就没法杜绝“刷票”行为了么?

答案是,完全杜绝的确很难。听说过12306的黄牛党没?听说过Apple Store专业给应用刷榜单的没?听说过“网络水军”、“五毛党”没?

不过,活动举办方可以通过一些手段,大大提高作弊的门槛。例如,当前有不少活动就采用了如下方式:

  • 要求投票用户先关注活动举办方的公众号,然后调用微信官方的投票功能;
  • 要求投票用户在投票活动举办方的网站上进行注册(手机号验证、实名验证)

不管采用这两种方式中的哪一种,本文中的“刷票”方法就完全失效了。

测试结果报表展现:Web页面绘制多层级表格

发表于 2016-03-20 | 更新于 2019-04-03 | 分类于 Development , 前端

背景描述

在Android性能测试中,每一个测试任务都对应了1个测试用例、1台测试设备、一个测试包,并且在测试结果中包含了多个指标项。通常,我们希望能对两个不同版本测试包的测试结果进行对比,并能在Web页面上以表格的形式进行展现。

很自然地,我们会想到采用如下形式展现对比结果。

render table three levels
图1 三层表格

对应地,采用如下数据结构存储结果数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
data_hash = {
"pkg_array": ["pkg1", "pkg2"],
"data": {
"testcase1": {
"device1": {
"indicator1": ["value1", "value2"],
"indicator2": ["value3", "value4"],
},
"device2": {
"indicator1": ["value5", "value6"],
"indicator2": ["value7", "value8"],
},
},
"testcase2": {
"device1": {
"indicator1": ["value9", "value10"],
"indicator2": ["value11", "value12"],
},
"device2": {
"indicator1": ["value13", "value14"],
"indicator2": ["value15", "value16"],
},
},
}
}

想法明确了,那要怎么实现呢?

对于像我这样没学过前端的人来说,最难的就是如何通过代码绘制层级表格的问题。

第一次尝试:绘制2层表格

为了简化问题分析过程,先尝试对两层表格进行绘制。

render table two levels
图2 两层表格

简化后的数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
two_level_data_hash = {
"pkg_array": ["pkg1", "pkg2"],
"data": {
"device1": {
"indicator1": ["value1", "value2"],
"indicator2": ["value3", "value4"],
},
"device2": {
"indicator1": ["value5", "value6"],
"indicator2": ["value7", "value8"],
},
}
}

如上表格对应的html代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<table border="1">
<tr>
<th>Device</th>
<th>Indicator</th>
<th>Pkg1</th>
<th>Pkg2</th>
</tr>
<tr>
<td rowspan="2">device1</td>
<td>indicator1</td>
<td>value1</td>
<td>value2</td>
</tr>
<tr>
<td>indicator2</td>
<td>value3</td>
<td>value4</td>
</tr>
</table>

可以看出,绘制层级表格的关键在于tr和rowspan的控制上。
因此,我们可以尝试采用如下JavaScript代码进行生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function render_two_level_table(two_level_data_hash){
pkg_array = two_level_data_hash["pkg_array"];
table_header = "<tr>";
table_header += "<th>Device</th><th colspan='1'>Indicator</th>";
for(var pkg_index in pkg_array){
table_header += "<th>" + pkg_array[pkg_index] + "</th>"
}
table_header += "</tr>";

data = two_level_data_hash["data"];
table_body = "";
for(var device in data){
is_first_indicator_row = true;
table_body += "<tr><td rowspan='" + data[device].length + "'>" + device + "</td>";

for(var indicator in data[device]){
if(!is_first_indicator_row){
table_body += "<tr>";
}
table_body += "<td>" + indicator + "</td>";
value_list = data[device][indicator];
for(var index in value_list){
table_body += "<td>" + value_list[index] + "</td>";
}
table_body += "</tr>";
is_first_indicator_row = false;
}
table_body += "</tr>";
}

table_content = "<table border='1'>" + table_header + table_body + "</table>";
$("#table").html(table_content);
}

在绘制indicator单元格的时候,为了判断当前indicator是否是当前device对应的第一个,即是否需要添加<tr>格式符,我们引入了is_first_indicator_row变量;is_first_indicator_row初始为true,绘制完第一个indicator以后变为false;绘制当前device剩余indicator的时候,由于is_first_indicator_row为false,因此每次都会加上<tr>格式符。

在判断device单元格的行跨度(rowspan)时,由于indicator是device的key,因此我们可以通过当前device中key的数量来得到rowspan,即two_level_data_hash[device].length。

绘制下一个device对应的数据时,再重复以上流程。

可以看出,为了正确打印<tr>格式符,我们做了不少工作。两层表格的绘制方法解决了,那如何绘制三层表格呢?

第二次尝试:绘制3层表格

回到背景描述里面的需求,若按照上面的思路,我们要绘制三层表格时,就需要引入两个变量,is_first_device_row和is_first_indicator_row,分别用于标记device和indicator是否第一次出现。

那对于rowspan呢?这貌似就有点麻烦了。因为我们在绘制testcase单元格的时候,rowspan的取值应该是当前testcase包含的所有device各自包含的indicator的数量总和,而我们并不能像之前的方式那样直接得到这个数值。

那要怎么处理呢?我们可以尝试写一个函数row_num,来计算得到给定JSON数据里面包含的子节点的总数。

实现方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
function render_three_level_table(data_hash){
pkg_array = data_hash["pkg_array"];
table_header = "<tr>";
table_header += "<th>TestCase</th><th>Device</th><th colspan='1'>Indicator</th>";
for(var pkg_index in pkg_array){
table_header += "<th>" + pkg_array[pkg_index] + "</th>";
}
table_header += "</tr>";

data = data_hash["data"];
table_body = "";
for(var testcase in data){
is_first_row_device = true;
table_body += "<tr><td rowspan='" + row_num(data[testcase]) + "'>" + testcase + "</td>";

for(var device in data[testcase]){
if(!is_first_row_device){
table_body += "<tr>";
}
table_body += "<td rowspan='" + row_num(data[testcase][device]) + "'>" + device + "</td>";

is_first_row_indicator = true;
for(var indicator in data[testcase][device]){
if(!is_first_row_indicator){
table_body += "<tr>";
}
table_body += "<td>" + indicator + "</td>";

value_list = data[testcase][device][indicator];
for(var index in value_list){
table_body += "<td>" + value_list[index] + "</td>";
}
table_body += "</tr>";
}
table_body += "</tr>";
is_first_row_indicator = false;
}
table_body += "</tr>";
is_first_row_device = false;
}

table_content = "<table border='1'>" + table_header + table_body + "</table>";
$("#table").html(table_content);
}

function row_num(data){
var counter = 0;
if(data.constructor == Array){
return 1;
}

for(key in data){
var tmp = data[key];
if(tmp.constructor == Array)
{
counter += 1;
}else{
counter += row_num(tmp);
}
}
return counter;
}

在计算rowspan时,我们用到了递归的方法,实现了对当前testcase或当前device所对应的indicator总数的计算。

通过以上方式,我们实现了三层表格的绘制。可以看出,三层表格的判断逻辑比两层表格复杂了很多,那如果我们还想绘制更多层次的表格呢?显然,这种方法已不再适用,我们不可能每增加一层就新增加一个标识变量,而且对于数据层级不固定的情况,采用这种方式是完全无法实现自适应的。

重构:递归!

回顾上面的代码,我们不难发现,三层表格的代码相比于两层表格的代码,存在着不少重复,而且可以预见,如果我们采用同样的方式去绘制更多层次表格的话,重复的代码会出现得更多。

一定有更简洁的方法!对,递归!

其实刚才我们在计算rowspan时已经体会到了递归的好处,它可以自适应多层次的数据结构。我们也完全可以将这个思想应用到表格层级的绘制上面。

观察背景描述中的数据结构,不难发现,对比数据存储于Array中,而中间层的value都是Hash结构,因此,我们可以通过这个区别,编写递归调用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function render_table(data_hash){
var res = "";
for(var key in data_hash){
var tmp = data_hash[key];
if(tmp.constructor == Array)
{
res += "<tr><td rowspan='" + row_num(tmp)+ "'>" + key + "</td>";
for(value_index in tmp){
var value = tmp[value_index];
res += "<td>" + value + "</td>";
}
res += "</tr>";
}else{
res += "<tr><td rowspan='" + row_num(tmp) + "'>" + key + "</td>" + "</tr>";
res += render_table(tmp);
}
}
return res;
}

function row_num(data){
var counter = 1;
if(data.constructor == Array){
return counter;
}

for(key in data){
var tmp = data[key];
if(tmp.constructor == Array)
{
counter += 1;
}else{
counter += row_num(tmp);
}
}
return counter;
}

table_body = "";
table_body += render_table(data_hash);

采用了递归的方式以后,我们就不用再关注表格的层级了,只要是传入数据的数据结构与背景描述里面的类似,那么就可以自动绘制出任意层级的表格。

利用 PyCharm 进行 Python 远程调试

发表于 2015-07-13 | 更新于 2019-04-03 | 分类于 Development

背景描述

有时候Python应用的代码在本地开发环境运行十分正常,但是放到线上以后却出现了莫名其妙的异常,经过再三排查以后还是找不到问题原因,于是就在想,要是可以在服务器环境中进行单步跟踪调试就好了。

然而,在服务器系统上安装一个IDE肯定是不现实的;通过SSH远程到服务器端,采用pdb进行调试虽然可行,但是操作还是较为繁琐,而且也不够直观。

那么,是否可以将开发环境中的IDE与服务器环境相连,实现利用开发环境的IDE调试服务器环境中运行的程序呢?
答案是肯定的,这就是远程调试(Remote Debug)。

远程调试的工作原理

远程调试的功能在Eclipse、IntelliJ IDEA等大型IDE中均有支持,实现原理都基本相同,这里采用PyCharm进行说明。

在远程调试的模式下,PyCharm(IDE)扮演服务端(Server)的角色,而运行在远程计算机上的应用程序扮演客户端(Client)的角色。正因如此,进行远程调试时,需要先在本地开发环境中设定端口并启动IDE,IDE会对设定的端口开始监听,等待客户端的连接请求;那远程计算机中的应用程序又是怎样与IDE建立通讯连接的呢?

针对远程调试功能,PyCharm提供了pydevd模块,该模块以pycharm-debug.egg的形式存在于PyCharm的安装路径中。远程计算机安装该库文件后,然后就可以调用pydevd.settrace方法,该方法会指定IDE所在机器的IP地址和监听的端口号,用于与IDE建立连接;建立连接后,便可在IDE中对远程在远程计算机中的程序进行单步调试。

远程调试的配置方法

1、在远程计算机上安装pydevd模块

首先,在本地开发环境的PyCharm安装路径中找到pycharm-debug.egg文件(若远程计算机运行的是Python3,则需要pycharm-debug-py3k.egg);

然后,将pycharm-debug.egg文件拷贝至远程计算机,在远程计算机中将pycharm-debug.egg添加至引用路径,可以采用多种方式:

  • 采用easy_install pycharm-debug.egg命令进行安装(pip命令无法安装,只能使用easy_install)
  • 将pycharm-debug.egg添加至PYTHONPATH或sys.path: import sys; sys.path.append('/home/leo/app-dependancies/pycharm-debug.egg')
  • 解压pycharm-debug.egg,将其中的pydev文件夹拷贝至远程应用程序目录下

最后,在远程计算机的Python命令行中输入import pydevd,若没有报错则说明pydevd模块安装成功。

2、在本地开发环境的PyCharm中进行监听配置

在PyCharm中配置说明如下:

  • 【Run】->【Edit Configurations】
  • 【Add New Configuration】->【Python Remote Debug】
  • 填写Local host name和Port,其中Local host name指的是本机开发环境的IP地址,而Port则随便填写一个10000以上的即可;需要注意的是,由于远程计算机需要连接至本地开发环境,因此本地IP地址应该保证远程可以访问得到
  • 【Apply】and【OK】

3、在本地开发环境的PyCharm中配置Mapping映射

4、在远程计算机的应用程序中插入代码

将如下代码插入至远程计算机的应用程序中。

1
2
import pydevd
pydevd.settrace('100.84.48.156', port=31235, stdoutToServer=True, stderrToServer=True)

其中,IP地址和端口号要与PyCharm中的监听配置保持一致。

5、在PyCharm中启动Debug Server

【Run】->【Debug…】,选择刚创建的远程调试配置项,在Debug Console中会显示如下信息:

1
2
3
4
5
Starting debug server at port 31235
Waiting for process connection...
Use the following code to connect to the debugger:
import pydevd
pydevd.settrace('100.84.48.156', port=31235, stdoutToServer=True, stderrToServer=True)

这说明Debug Server已经启动并处于监听状态。

6、在远程计算机中启动应用程序

在远程计算机中启动应用程序,当执行到pydevd.settrace语句时,便会与本地开发环境中的PyCharm建立通讯连接,接下来便可以在本地IDE中进行单步调试了。

需要注意的是,本地开发环境必须保证IP地址和端口号可从远程计算机访问得到,否则会无法建立连接。

1
2
3
4
5
6
7
8
9
10
11
12
$ telnet 100.84.48.156 31235
Trying 100.84.48.156...
telnet: Unable to connect to remote host: Connection refused

$ python devicedectector.py
Could not connect to 100.84.48.156: 31236
Traceback (most recent call last):
File "/usr/local/lib/python2.7/dist-packages/pycharm-debug.egg/pydevd_comm.py", line 478, in StartClient
s.connect((host, port))
File "/usr/lib/python2.7/socket.py", line 224, in meth
return getattr(self._sock,name)(*args)
error: [Errno 111] Connection refused

参考链接

http://stackoverflow.com/questions/6989965/how-do-i-start-up-remote-debugging-with-pycharm
https://www.jetbrains.com/pycharm/help/remote-debugging.html

黑盒测试用例设计技术--边界值分析法

发表于 2015-03-19 | 更新于 2019-04-03 | 分类于 Testing , 功能测试

本文通过案例的形式,详细讲解黑盒测试用例设计技术中的边界值分析法。

无数的测试实践表明,大量的故障往往发生在输入定义域或输出值域的边界上,而不是在其内部。因此,针对各种边界情况设计测试用例,通常会取得很好的测试效果。
边界值分析法就是对输入或输出的边界值进行测试的一种黑盒测试方法,通常作为对等价类划分法的补充,其测试用例来自等价类的边界。
边界值分析使用与等价类划分法相同的划分,只是边界值分析假定错误更多地存在于划分的边界上,因此在等价类的边界上以及两侧的情况设计测试用例。

如果你对等价类划分法还不了解,可先阅读【黑盒测试用例设计技术–等价类划分法】。

对边界值设计测试用例的原则

(1)如果输入条件规定了值的范围,则应取刚达到这个范围的边界值以及刚刚超过这个范围边界的值作为测试输入数据。
(2)如果输入条件规定了值的个数,则用最大个数、最小个数和比最大个数多1个、比最小个数少1个的数作为测试数据。
(3)根据程序规格说明的每个输出条件,使用原则(1)。
(4)根据程序规格说明的每个输出条件,使用原则(2)。
(5)如果程序的规格说明给出的输入域或输出域是有序集合(如有序表、顺序文件等),则应选取集合中的第一个和最后一个元素作为测试用例。
(6)如果程序中使用了一个内部数据结构,则应当选择这个内部数据结构的边界上的值作为测试用例。
(7)分析程序规格说明,找出其它可能的边界条件。

Example


某程序具有如下功能:文本框要求输入日期信息,日期限定在1990年1月~2049年12月,并规定日期由6位数字字符组成,前4位表示年,后2位表示月;程序需对输入的日期有效性进行校验。
用等价类划分方法和边界值分析法为该程序的“日期检查功能”设计测试用例。

划分等价类 & 选取边界值

步骤一、要求输入6个数字字符yyyynn;参照等价类划分法规则5,划分为一个有效等价类和三个无效等价类。

  • 有效等价类(1):输入6个数字字符
  • 无效等价类(2):输入6个字符,存在非数字的情况
    • 采用边界值,6个字符全为非数字:abcdef
    • 采用边界值,6个字符中有1个为非数字:19930m
  • 无效等价类(3):输入少于6个数字字符
    • 采用边界值,输入5个数字字符
  • 无效等价类(4):输入多于6个数字字符
    • 采用边界值,输入7个数字字符

步骤二、在有效等价类(1)的基础上,参照等价类划分法规则6,对该等价类进行细分;考察6个数是否满足日期格式要求,1990<=yyyy<=2049,01<=nn<=12,参照规则,划分为一个有效等价类和四个无效等价类。

  • 有效等价类(5):日期格式满足要求,1990<=yyyy<=2049,01<=nn<=12
    • 采用边界值,[yyyy,nn]取值为:[1990,06],[1991,06],[2020,06],[2020,11],[2020,12]
  • 无效等价类(6):yyyy不满足要求,yyyy<1990
    • 采用边界值,[yyyy,nn]取值为:[1989,06]
  • 无效等价类(7):yyyy不满足要求,yyyy>2049
    • 采用边界值,[yyyy,nn]取值为:[2050,06]
  • 无效等价类(8):nn不满足要求,nn<01
    • 采用边界值,[yyyy,nn]取值为:[2020,00]
  • 无效等价类(9):nn不满足要求,nn>12
    • 采用边界值,[yyyy,nn]取值为:[2020,13]

设计测试用例

序号 yyyynn 覆盖等价类 预期输出结果
– – 覆盖有效等价类和边界值 –
1 199006 (1)(5) 日期格式有效
2 199106 (1)(5) 日期格式有效
3 202006 (1)(5) 日期格式有效
4 202011 (1)(5) 日期格式有效
5 202012 (1)(5) 日期格式有效
– – 覆盖无效等价类和边界值 –
6 abcdef (2) 日期格式无效
7 19930m (2) 日期格式无效
8 19935 (3) 日期格式无效
9 1993050 (4) 日期格式无效
10 198906 (6) 日期格式无效
11 205006 (7) 日期格式无效
12 202000 (8) 日期格式无效
13 202013 (9) 日期格式无效
1…5678
solomiss

solomiss

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