演练:创建Windows自动化测试

本文演示如何使用Node.js为Windows中的记事本(Notepad)创建自动化脚本。这个常见的简单应用是你入门桌面自动化的最好导师。你将了解到如何为Windows应用程序创建对象模型,以及如何使用CukeTest提供的API完成桌面应用自动化。

在本演练中,我们将:

  • 创建描述自动化步骤的BDD场景
  • 侦测记事本控件并将测试对象添加到对象模型
  • 实现自动化脚本
  • 调试并运行脚本以获取报告

环境要求

  • 操作系统: Windows 7及以上
  • 被测应用:Windows 自带记事本

新建项目

1.打开CukeTest,单击“文件” ->“新建项目”

2.将“NotepadTesting”设置为项目名称,并选择“Windows”项目模板,同时指定项目路径,然后单击“创建”以创建项目。

image.png

编辑用例

打开 feature1.feature 文件,在【可视】界面中输入场景描述内容。

image.png

有关如何使用“Visual”视图编辑要素文件的更多信息,可以参考演练:编辑Feature文件

对应的【文本】视图内容为:

  1. # language: zh-CN
  2. 功能: 自动化记事本应用
  3. 以记事本为例,讲解在自动化测试Windows桌面应用的时候,如何解决菜单下拉问题。
  4. 比如: 记事本的【格式】--【字体】,【文件】--【保存】
  5. 场景: 编辑内容并保存
  6. 假如打开Windows记事本应用
  7. 当在记事本中输入文本"hello world"
  8. 并且点击【文件】--【保存】
  9. 同时在文件对话框中保存为项目路径中的"helloworld.txt"
  10. 那么文件应该保存成功
  11. 场景: 更改记事本字体
  12. 当点击【格式】--【字体】
  13. 并且从【字体】下拉框中选择"Arial"
  14. 并且从【字形】下拉框中选择"粗体"
  15. 并且从【大小】下拉框中选择"五号"
  16. 同时单击【确定】按钮以关闭【字体...】对话框
  17. 那么字体应该设置成功

侦测应用控件

自动化Windows应用程序时,“测试对象”用于标识窗口控件。每个测试对象都包含一组用于定位控件的属性。测试对象信息存储在对象模型文件中(或缩写为“模型文件”),以.tmodel结尾。CukeTest中的模型管理器用于创建和管理模型文件。以下是有关如何侦测并向模型添加对象的步骤:

点击【添加对象】按钮演练:创建Windows自动化测试 - 图3,开始识别元素,在要操作的对象上点击鼠标,识别元素控件。如果控件成功侦察,则显示要添加的控件及其属性。然后单击【添加到模型】演练:创建Windows自动化测试 - 图4 按钮将其添加到对象模型中。

现在请按照以下步骤创建模型:

侦测普通控件

  1. 点击step_definitions\model1.tmodel,会使用模型管理器打开此文件。
    image.png

  2. 打开记事本,侦测以下控件列表,并将它们添加到模型中: 控件 | 控件类型 —-|—- 菜单”文件(F)” | MenuItem 菜单”格式(O)” | MenuItem
    文本编辑器 | Document

侦测派生控件

接着要识别的控件不在当前窗口中,需要先打开这些窗口才能够开始识别,比如: 点击菜单后展开的下拉框、保存文件用的文件选择器、修改格式的修改界面这三个。

展开菜单中的控件

展开的菜单是一种特殊的控件,它在常规状态下是不可见的,只有当菜单栏中的目标按钮被触发以后才会出现,因此我们称其为派生控件。那么侦测的时候鼠标左键单击会被当作侦测控件对象的话,该如何使菜单展开呢?答案是——CTRL键。
这里涉及到一个侦测的技巧,假设我们要侦测“文件“菜单中的”保存”一项,单击“文件”菜单时先按住CTRL键,这样点击就不会触发侦测,然后单击“保存”时释放CTRL键,这时候会变回正常的侦测。

在点击”保存“菜单项的时候,可以适当的点久一点儿,直到侦测窗口出现。因为在应用比较复杂的时候,计算出被侦测控件的模型需要时间,而菜单在完成点击(也就是按下左键到松开)后会消失,可能会导致计算出来的模型不正确,因此可以按住左键等到计算完成后再松开。

  1. 单击“文件” ->“保存”菜单打开“字体”对话框,侦测并添加以下控件列表,并将它们添加到模型中。
    控件 | 控件类型 —-|—- 菜单项”保存(S)” | MenuItem

  2. 单击“格式” ->“字体…”菜单打开“字体”对话框,侦测并添加以下控件列表,并将它们添加到模型中。

控件控件类型
菜单项”字体(S)…”MenuItem

菜单中的控件侦测以后被标红是正常的,CukeTest对于侦测后无法找到的控件会标红并尝试为其添加索引值index,但菜单中的控件此时的确是不可见的,无法通过添加index来解决。但这并不影响我们编写自动化的操作。

弹出窗口中的控件

派生的控件除了菜单这种展开的形式,一个应用中通常会有多个弹出窗口,这些窗口只有在特定的情况才会出现,接下来要识别的就是这些窗口中的控件。别紧张,就跟识别普通控件一样。

  1. 点击保存按钮后,在文件保存对话框,侦测并添加以下控件列表,并将它们添加到模型中,这两个控件在我们保存文件时会用到:
控件控件类型
文件名输入框””Edit
按钮”保存”Button

识别到文件名输入框的时候,它的对象会被命名为Edit:文件名:1,后缀的1是防止控件重名做的处理。因为这个输入框其实是一个可编辑的下拉框,所以这个输入框还有一个父控件叫做ComboBox:文件名:,和它名字冲突了。

  1. 点击字体按钮,会弹出字体样式的修改窗口,窗口中的三个列表经过识别以后可以发现的都是下拉输入框ComboBox,将三个都添加到模型管理器中。
控件控件类型
输入框”字体(F):”ComboBox
输入框”大小(S):”ComboBox
输入框”字形(Y):”ComboBox

由于ComboBox控件通常会连带着识别到它的子控件,比如ListListItem,或者Edit,但是没关系,子控件不会对控件识别尝试影响。

调整属性

侦测完成后,有些属性需要修改以合自动化的场景。例如侦测的时候添加的记事本窗口对象会自动加上Title识别属性,因为Title随着文件名变化而变化,甚至只要文件修改了但是未保存,都会带上表示未保存的星号*,所以它不适合作为记事本窗口的唯一性标识,在它的属性页面选择Title属性并删除:

演练:创建Windows自动化测试 - 图6

删除后它会作为其它属性存在:

演练:创建Windows自动化测试 - 图7

可以将缺省对象名改为有意义的名字,虽然这不会影响使用,但是可以让对象更易于管理,可读性更强。例如可以将记事本窗口设为“记事本”,文件对话框窗口设为“文件选择器”,字体对话框设为“Font Dialog”。

添加所有这些元素后,您的模型应如下所示:

image.png

完善自动化脚本

从剧本文件生成脚本模版

打开 step_definations\definitions1.js 文件,点击 step 后面的灰色按钮,生成自动化脚本模版。

演练:创建Windows自动化测试 - 图9

请注意,现在步骤文本旁边的按钮是橙色,这意味着这些步骤具有匹配的步骤定义功能,但它们尚未实现。

您可能还注意到,脚本顶部的let model = AppModel.loadModel(__dirname + "/model1.tmodel")代表模型文件已加载到此文件中,因此在创建项目时的代码中,您可以直接使用此模型变量model来访问这些对象。

现在您可以打开model1.tmodel文件,从模型树中选择一个对象,选择右侧的“控件操作”选项卡,然后从列表中复制该方法并将它们添加到脚本中。

此外您还可以通过拖拽某个方法或者某个测试对象到代码编辑器,来生成对应的脚本。

  1. 要实现步骤定义,首先要实现“打开给定记事本应用程序”步骤,您可以使用“Util.launchProcess”API来启动记事本过程:

    ```javascript Given(/^打开Windows记事本应用$/, async function () { Util.launchProcess(‘c:\Windows\notepad.exe’); });

  2. 编写脚本后,可以通过右键单击该步骤来测试它,然后单击步骤工具栏上的“运行此步骤”按钮:

    演练:创建Windows自动化测试 - 图10

    这只是为了调试目的而运行的步骤,如果成功,你的记事本应该启动。

加入Hook

生命周期函数Hook指的是在函数运行的特定节点中运行的函数,比如运行前后、场景前后、步骤前后运行的函数的,通常在实现其余步骤过程中,根据需要添加对应的Hook,我们添加Hook函数,为了在脚本启动时最小化CukeTest窗口,并在运行完成时恢复CukeTest窗口,我们可以在BeforeAllAfterAll钩子中实现这一点。在脚本定义文件的头部引入以下函数:

  1. const {BeforeAll, AfterAll, setDefaultTimeout} = require('cucumber')
  2. const cuketest = require('cuketest');

以上除了引用BeforeAlAfterAll函数外,还引入了设置步骤默认超时时间的函数setDefaultTimeout()来设置超时时间,通常项目中默认会自行设定,缺省值为5秒具体可以查阅超时时间

接着输入以下脚本:

  1. setDefaultTimeout(20 * 1000);
  2. BeforeAll(async function() {
  3. await cuketest.minimize();
  4. })
  5. AfterAll(async function () {
  6. await cuketest.maximize();
  7. })

根据剧本文件实现步骤

从上面写的剧本文件可以确认需要选哟编写的步骤如下:

  1. 场景: 编辑内容并保存
  2. 假如打开Windows记事本应用
  3. 当在记事本中输入文本"hello world"
  4. 并且点击【文件】--【保存】
  5. 同时在文件对话框中保存为项目路径中的"helloworld.txt"
  6. 那么文件应该保存成功
  7. 场景: 更改记事本字体
  8. 当点击【格式】--【字体】
  9. 并且从【字体】下拉框中选择"Arial"
  10. 并且从【字形】下拉框中选择"粗体"
  11. 并且从【大小】下拉框中选择"五号"
  12. 同时单击【确定】按钮以关闭【字体...】对话框
  13. 那么字体应该设置成功

那么就可以针对一个一个步骤进行步骤定义的脚本编写了。

场景一: 编辑内容并保存

打开Windows记事本应用

第一步就是启动应用,CukeTest提供的启动应用的脚本可以从工具箱的“常用”一栏中拖拽launchProcess()方法到脚本编辑器中,路径的话可以直接用notepad.exe,因为记事本的可执行文件的路径默认就是在环境变量PATH中的所以可以直接用。因此脚本如下:

  1. When("打开Windows记事本应用", async function () {
  2. Util.launchProcess('notepad.exe');
  3. });

在记事本中输入文本{string}

记事本中的编辑器会被识别为Document对象,这代表的是多行输入框,操作其实与普通的文本输入框类似,使用set()方法设置该输入框的值即可。可以打开模型管理器调试并运行一下set()方法,选中Document对象“文本编辑器”,在该对象的右侧“控件操作”选项卡中找到并选中set()方法,点击上方的调用方法调用方法按钮打开运行窗口。

方法测试

在Value一列中输入要写入的值,接着点击运行,就可以看到记事本窗口中的内容修改为了该值,证明set()方法在记事本应用上能够正常运行。因此可以编写脚本,或是直接从运行窗口底部点击“复制运行代码”接着粘贴到脚本编辑器中即可。

脚本如下:

  1. When("在记事本中输入文本{string}", async function (text) {
  2. await model.getDocument("文本编辑器").set(text);
  3. this.text = text;
  4. });

this.text = text一句是为了将输入文本作为变量保存下来,作为后续验证运行结果需要用到的期望值(Expected)

点击【文件】—【保存】

接着点击菜单中的“保存”按钮,当然直接使用pressKeys()方法输入快捷键CTRL + S也可以实现相同的效果。

  1. When("点击【文件】--【保存】", async function () {
  2. await model.getMenuItem("文件(F)").click();
  3. await model.getMenuItem("保存(S)").click();
  4. });

如果使用pressKeys()方法应该写作如下,^s代表的是CTRL + S,组合键的输入方式可以查看附录:输入键对应表:

  1. await model.getWindow("记事本").pressKeys("^s")

在文件对话框中保存为项目路径中的{string}

接着是在点击“保存”按钮后弹出来的文件选择界面,在这里选择文件要保存的路径和文件名,传统的思路应该是先切换到目标路径/文件夹中,再编辑保存的文件名。但是实际上可以直接在文件名输入框中输入保存文件的完整路径+文件名来保存到目标路径,如下:

  1. When("在文件对话框中保存为项目路径中的{string}", async function (filename) {
  2. let filepath = process.cwd() + '\\features\\data\\' + filename;
  3. this.filepath = filepath;
  4. await model.getEdit("文件名:1").set(filepath);
  5. await model.getButton("保存(S)1").click();
  6. await Util.delay(2000);
  7. });

这里在脚本最后加入等待的原因是因为:点击“保存”按钮后就算作完成操作,进入下一步,但是保存的文件可能还未生成,那么我们在下一步验证“保存的文件是否生成”这一结果就会不正确,因此手动的加入这段等待。

this.filepath = filepath是为了将保存路径作为变量传递,在验证操作结果时会用到。

文件应该保存成功

这一步是场景一的最后一步,因为先前的操作都已经结束,那么这一步应该对操作结果进行验证,分为两个部分的验证:

  1. 验证保存的文件已创建;
  2. 验证文件中的内容为期望的内容。

因为需要读取和验证文件内容,我们在脚本顶部引入文件系统库fs与断言库assert:

  1. const fs = require('fs');
  2. const assert = require('assert');

接着编写脚本:

  1. Then("文件应该保存成功", async function () {
  2. let filepath = this.filepath;
  3. let exist = fs.existsSync(filepath);
  4. assert.strictEqual(exist, true);
  5. console.log(filepath + "文件已创建");
  6. let filecontent = fs.readFileSync(filepath, { encoding: 'utf-8' });
  7. assert.strictEqual(filecontent, this.text);
  8. console.log(`文件内容为: ${filecontent}`);
  9. });

可以看到this.前缀的两个变量,正是前文中传递出来的,在验证的环节又用上了,断言库assert的作用也很简单,其本质就是一个比较器,如果比较结果为False时会抛错,从而终止该步骤。

到这里第一个场景就完成了,可以尝试运行整个场景来查看整个场景是否能够正常运行。

添加配置环境的Hook

由于这个场景运行结束时会生成一个文本文件,所以在下一次运行时会引起干扰,比如本次运行明明没有运行成功,但是却通过了验证;或者在保存文件时会弹出“文件已存在”的提示框,会阻碍下一步操作。因此在运行前都会把这个文本文件删除后再运行,但是这么做又很麻烦,特别是在调试的时候,可能运行没花多少时间,全在删除文件上了。因此,为了节省调试的时间,我们可以把这个操作加到Hook中,这里选择在BeforeAllHook中加入删除文本文件的脚本,即在每次运行前把上次运行的结果清除。

至于为什么不是使用AfterAll将本次运行的结果清除呢?其实也是可以的,但是那样的话运行结束我们也看不到实际的文件生成,可能会引起困扰。

回顾保存文件的脚本中,保存的路径如下:

  1. let filepath = process.cwd() + '\\features\\data\\' + filename;

文本文件其实就是保存在项目目录下的feature/data文件夹中,因此我们可以删除并重建这个文件夹来清除所有已有的文本文件。为了让Hook得到这一文件夹的路径,我们将这个路径当作常量放在脚本顶部:

  1. const projectPath = process.cwd() + '\\features\\data\\';

保存文件的步骤定义相应的改为:

  1. When("在文件对话框中保存为项目路径中的{string}", async function (filename) {
  2. let filepath = projectPath + filename;
  3. this.filepath = filepath;
  4. await model.getEdit("文件名:1").set(filepath);
  5. await model.getButton("保存(S)1").click();
  6. await Util.delay(2000);
  7. });

接着使用文件系统库fs提供的rmdirmkdir方法在BeforeAll Hook函数中删除和重建文件夹:

  1. BeforeAll(async function () {
  2. fs.rmdirSync(projectPath, { recursive: true });
  3. fs.mkdirSync(projectPath);
  4. await cuketest.minimize();
  5. })

场景二: 更改记事本字体

在这个场景中,可以进一步熟悉应用中复合控件——下拉框控件ComboBox的操作,以及验证样式可以使用的图像比较方法——imageCompare()

点击【格式】—【字体】

类似于“点击【文件】—【保存】”步骤,但是这里操作的是另一个菜单。

  1. When("点击【格式】--【字体】", async function () {
  2. await model.getMenuItem("格式(O)").click();
  3. await model.getMenuItem("字体(F)...").click();
  4. });

修改样式

在这一节中要编写四个步骤的脚本,来完成修改样式的操作:

  • 从【字体】下拉框中选择”Arial”
  • 从【字形】下拉框中选择”粗体”
  • 从【大小】下拉框中选择”五号”
  • 单击【确定】按钮以关闭【字体…】对话框

我们要做的是在三个下拉框中选择指定的选项完成记事本的样式设置,有三个下拉框分别控制记事本中的:文字字体、文字样式(普通/粗体/斜体)以及字体大小。CukeTest为下拉框ComboBox提供了简便的操作方法——select()方法,可以直接选中符合名称的下拉框选项。因此脚本写作如下:

  1. When("从【字体】下拉框中选择{string}", async function (font) {
  2. await model.getComboBox("字体(F):").select(font);
  3. });
  4. When("从【字形】下拉框中选择{string}", async function (weight) {
  5. await model.getComboBox("字形(Y):").select(weight);
  6. });
  7. When("从【大小】下拉框中选择{string}", async function (size) {
  8. await model.getComboBox("大小(S):").select(size);
  9. });
  10. When("单击【确定】按钮以关闭【字体...】对话框", async function () {
  11. await model.getButton("确定").click();
  12. });

因为这种方式只需要在模型管理器中识别到三个样式下拉框也就是ComboBox控件即可,不需要识别到下拉框中的内容,因为是通过调用ComboBox提供的select()方法完成选择,从而只需要知道选项的名称就可以选中,如果使用传统的方法实现这种灵活性,那可能要将所有的选项都添加到模型管理器中才可以做到。当然借助模型管理器的批量添加功能,也不会很费力。

字体应该设置成功

同样的作为场景的最后一个步骤,执行的也是对以上操作结果的验证。这个场景做了哪些事呢?主要就是修改了记事本中的文本样式,那么验证的文本样式有没有被成功修改就要用到图像比较功能。将修改样式成功的文本编辑器截图保存下来,在之后每次修改都这个截图比较,如果比较结果是相同就是成功运行;反之则不成功。

当然也可以将运行结果截图后直接作为附件添加到报告中,人工的判断结果是否正确。

下面的验证稍微有些复杂,难点主要在于取得控件截图以及图片比较的配置。

  1. 首先控件提供了一个modelImage()的方法,可以取得控件识别时缓存在本地的控件截图文件,可以在step_definition项目文件夹中的model*_file中看到这些图片文件。因此我们只要把控件的截图更新为成功运行时的截图,就可以用来当作期望图片验证运行结果了。
  2. 接着我们根据上一步中修改的样式手动的修改记事本中的样式,再点击模型管理器中的文本编辑控件Document: 文本编辑器的“控件截屏”选项栏,选择“截屏”并重新点击一下记事本中的编辑部分,就可以更新控件截图了。
  3. 最后在右侧看到控件的新截图后,就可以开始编写代码。

图像比较的函数可以从工具箱“图像”->“ImageCompare()”拖拽到脚本编辑器中生成。除此之外,由于图片比较所使用的格式并不是base64tabkeScreenshot()modelImage()返回的类型)或者Buffer,而是Image对象,因此要手动的调用Image.fromData构造。

而图片比较的imageCompare()方法除了被比较的两个Image对象外,还可以传入一个options对象用于指定各种配置,详见图像操作API。这里为了使比较更严格调低了阈值选项pixelPercentTolerance,并且为了忽略不同屏幕间截图的差异,使用了ignoreExtraPart选项对比较图片进行自动裁剪。

  1. Then("字体应该设置成功", async function () {
  2. let expectedImage = await Image.fromData(await model.getDocument("文本编辑器").modelImage());
  3. let actualImage = await Image.fromData(screenshot);
  4. let result = await Image.imageCompare(expectedImage, actualImage, {
  5. pixelPercentTolerance: 0.1,
  6. ignoreExtraPart: true
  7. });
  8. this.attach(await result.diffImage.getData(), 'image/png');
  9. assert.strictEqual(result.equal, true);
  10. });

运行

点击项目运行按钮,既可以自动执行我们定义的操作。运行完成后会自动打开测试报告视图。

report_html.png report_html.png

我们看到,我们可以截屏,观察字体调整的状况。