前端测试 在 Windows 上,使用 C# + UI Automation + WinAPI 进行 UI 测试的方法

SinDynasty · 2016年11月15日 · 最后由 SinDynasty 回复于 2017年02月13日 · 6541 次阅读
本帖已被设为精华帖!

一、获取待测程序主窗体的句柄

[DllImport("user32.dll ", EntryPoint = "FindWindow")]
static extern IntPtr FindWindow_AppFormHandle(string lpClassName, string lpWindowName);

/// <summary>
/// 通过WinAPI,获取待测程序主窗体的句柄
/// </summary>
/// <param name="AppFormClassName">待测程序主窗体类名</param>
/// <param name="AppFormWindowName">待测程序主窗体名</param>
/// <returns></returns>
public IntPtr AppFormHandle(string AppFormClassName, string AppFormWindowName)
{
    IntPtr AppFormHandle = IntPtr.Zero;
    bool FormFound = false;
    int Attempts = 0;
    do
    {
        if (AppFormHandle == IntPtr.Zero)
        {
            Thread.Sleep(100);
            Attempts = Attempts + 1;
            AppFormHandle = FindWindow_AppFormHandle(AppFormClassName, AppFormWindowName);
        }
        else
        {
            FormFound = true;
        }
    } while (!FormFound && Attempts < 99);//为防止因FindWindow无法遍历到待测主窗体,而陷入无限循环之中,因此增加一个遍历次数Attempts < 99的条件
    if (AppFormHandle == IntPtr.Zero)
    {
        new Exception("获取待测程序主窗体 失败");
    }
    return AppFormHandle;
}//AppFormHandle()

获取待测程序主窗体句柄的方法有两种:

第一种方法是使用 Process.Start() 启动待测程序后,再用 Process.MainWindowHandle 来获取,但是这种方法有一定的局限性,因为如果 Process.Start() 所启动的 exe 程序可能会去启动另一个 exe 程序,并关闭自身进程,这样的话该方法就不再适用了

第二种方法是我们先手动将待测程序的主窗体打开,然后通过调用 WinAPI 中的 FindWindow() 函数去遍历窗口,从而来获取获取待测程序主窗体句柄,其中 FindWindow() 函数中需要 2 个变量,一个是窗体类名,一个是窗体名,这些变量我们可以使用 VS 自带的工具 Spy++ 来获取

二、获取待测程序主窗体的 AutomationElement

/// <summary>
/// 通过主窗体句柄,获取待测程序主窗体的AutomationElement
/// </summary>
/// <param name="AppFormHandle"></param>
/// <returns></returns>
public AutomationElement AppAE(IntPtr AppFormHandle)
{
    //获取待测程序主窗体的AutomationElement
    AutomationElement AppAE = AutomationElement.FromHandle(AppFormHandle);
    if (AppAE == null)
    {
        new Exception("获取待测程序窗体的AutomationElement 失败");
    }
    return AppAE;
}//AppAE()

当我们获取到待测程序主窗体的句柄后,我们就可以通过 UI Automation 中的 AutomationElement.FromHandle() 函数获取到待测程序主窗体的 AutomationElement(下面就简称 AE)。在 UI Automation 中,所有的窗体或控件都表现为一个 AE,AE 中包含了此窗体或控件的相关属性,我们可以通过这些属性从而来实现对窗体或控件的自动化操作。

三、获取待测程序子窗体或子控件的 AutomationElement

/// <summary>
/// 通过Name,获取DescendantsAE
/// </summary>
/// <param name="ScopeAE"></param>
/// <param name="TargetName"></param>
/// <returns></returns>
public AutomationElement DescendantsAE_Name(AutomationElement ScopeAE, string TargetName)
{
    PropertyCondition Condition = new PropertyCondition(AutomationElement.NameProperty, TargetName);
    AutomationElement DescendantsAE = ScopeAE.FindFirst(TreeScope.Descendants, Condition);
    if (DescendantsAE == null)
    {
        new Exception("获取DescendantsAE 失败");
    }
    return DescendantsAE;
}//DescendantsAE_Name()

/// <summary>
/// 通过AutomationID,获取DescendantsAE
/// </summary>
/// <param name="ScopeAE"></param>
/// <param name="AutomationID"></param>
/// <returns></returns>
public AutomationElement DescendantsAE_AutomationID(AutomationElement ScopeAE, string AutomationID)
{
    PropertyCondition Condition = new PropertyCondition(AutomationElement.AutomationIdProperty, AutomationID);
    AutomationElement DescendantsAE = ScopeAE.FindFirst(TreeScope.Descendants, Condition);
    if (DescendantsAE == null)
    {
        new Exception("获取DescendantsAE 失败");
    }
    return DescendantsAE;
}//DescendantsAE_AutomationID()

/// <summary>
/// 通过ControlType类中的字段,获取所有ChildAE的集合ChildAEC,再通过索引值Index,从中获取相应的ChildAE
/// </summary>
/// <param name="ScopeAE"></param>
/// <param name="ControlType"></param>
/// <param name="Index"></param>
/// <returns></returns>
public AutomationElement ChildAE_ControlType(AutomationElement ScopeAE, object ControlType, int Index)
{
    PropertyCondition Condition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType);
    AutomationElementCollection ChildAEC = ScopeAE.FindAll(TreeScope.Children, Condition);
    AutomationElement ChildAE = ChildAEC[Index];
    if (ChildAE == null)
    {
        new Exception("获取ChildAE 失败");
    }
    return ChildAE;
}//ChildAE_ControlType()

我们可以通过窗体或控件的 Name 属性或 AutomationID 属性,并使用 ScopeAE.FindFirst(TreeScope.Descendants, Condition) 函数来获取窗体或控件 AE,其中 ScopeAE 是指父 AE,TreeScope 是指搜索的范围,Descendants 是 TreeScope 枚举的一个成员,表示的是父 AE 下的所有子 AE,PropertyCondition Condition 表示的是查找条件,我们可以通过 new PropertyCondition(AutomationProperty, Object) 来设置该查找条件,其中 AutomationProperty 表示的是需要搜索的属性,Object 表示的是需要搜索的属性值

ScopeAE.FindAll(TreeScope.Children, Condition);是另一种获取获取窗体或控件 AE 的方法,使用该方法,其返回的是所有符合条件的窗体或控件 AE 的集合 AutomationElementCollection(AutomationElementCollection 简称 AEC,其就相当于一个 AutomationElement[ ]),因此我们可以像数组那样使用 Index 从 AEC 中取出我们所需的窗体或控件 AE。当然由于 FindAll 这样的返回方式,因此我建议使用另一个 TreeScope 枚举成员 Children,Children 是 TreeScope 枚举的另一个成员,其表示的是父 AE 下的直接子 AE(即其不包含子 AE 的子 AE)

备注:对于我们查找所需要知道的 Name、AutomationID、ControlType 等属性,我们可以通过使用 UISpy 或 Inspect 工具来查看,当然这两个工具还能查看其它窗口或控件的属性

四、使用 WinAPI 中的 PostMessage() 函数,向主窗体发送消息,从而模拟鼠标左键点击控件

Windows 的消息机制:当我们按下鼠标或键盘的按钮时,其动作会被系统捕捉到,然后系统会根据捕捉到的动作产生出一个对应的消息,并将该消息发送到消息队列中,然后应用程序会根据消息中所包含的句柄从消息队列中获取消息,从而完成相应的处理。而对于 DirectUI 这类的窗体,虽然其控件是没有句柄的,但是其不可能不遵守这 Windows 底层的消息机制,DirectUI 这类窗体的做法实际上是通过主窗体句柄获取消息队列中的消息,然后主窗体在根据该消息的内容,将消息发给相应的控件,因此我们在进行 UI 测试时,完全可以利用此机制,直接对应用程序的主窗体句柄发送消息,从而直接进行对窗体或控件的相关自动化操作。

[DllImport("user32.dll ", EntryPoint = "PostMessage")]
static extern bool PostMessage(IntPtr hWnd, uint Msg, int wParam, int lParam);

PostMessage() 是发送消息的方式之一,该函数是指将一个消息放入消息队列后,不等待线程处理消息就返回。由于在 C# 中 PostMessage() 会随着消息内容 Msg 的变化,其所需的 wParam 和 lParam 的变量类型也会随之发生变化,因此我们可以在导入 user32.dll 时,使用 EntryPoint =来指明所需调用的 WinAPI 的函数名,这样就可以将该 WinAPI 函数名定义为一个 C# 的方法别名,例如我在上面利用 FindWindow() 来获取主窗体句柄时,我将 FindWindow() 这个 WinAPI 函数名定义为了本程序中 C# 的方法别名 FindWindow_AppFormHandle() 。

PostMessage() 中有 4 个参数,第一个参数代表的是我们发送消息的对象的窗体或控件句柄,在这里就是指主窗体句柄,第二个参数代表的是我们发给窗体或控件的 Windows 消息码,第三个和第四个参数为一般参数,其含义及数据类型取决于第二个参数,即 Windows 消息码。而现在我们是要通过 PostMessage() 函数,向主窗体发送消息,来模拟鼠标左键点击控件,因此根据我们即将使用到的 Windows 消息码,wParam 的数据类型设为 int 型,其含义是指当鼠标按键被点击时,其他功能键是否被按下,在这里我们只需将其设为 0 即可。我们将 lParam 的数据类型也设为 int 型,其含义是指鼠标单击的位置,其中 X 坐标为低字节,Y 坐标为高字节,由于 int 型为 32 位,因此我们只需将 Y 坐标左移 16 位,然后再加上 X 坐标即可,即 X + (Y << 16) 。

const uint WM_LBUTTONDOWN = 0x0201;//表示按下鼠标左键
const uint WM_LBUTTONUP = 0x0202;//表示鼠标左键抬起

上面就是我们模拟鼠标左键点击控件时所需要用到的 Windows 消息码,这些消息码是微软给定的,我们只需直接使用即可。

/// <summary>
/// 通过WinAPI,模拟鼠标左键点击TargetAE
/// </summary>
/// <param name="AppHandle">待测程序主窗体句柄</param>
/// <param name="TargetAE">目标控件AE</param>
/// <param name="ClickCount">点击次数</param>
public void MouseLeftClick(IntPtr AppFormHandle, AutomationElement TargetAE, int ClickCount)
{
     //由于WM_LBUTTONDOWN和WM_LBUTTONUP消息中的lParam是指控件相对于主窗体的坐标
     //因此我们需要将 控件中心的相对于桌面的坐标 减去 主窗体左上角的相对桌面的坐标

     //重新利用传入主窗体句柄来获取主窗体的AE
    AutomationElement AppAE = AppAE(AppFormHandle);

    //通过AutomationElement.Current.BoundingRectangle来获取该对象的尺寸及位置
    Rect AppRect = AppAE.Current.BoundingRectangle; //获取待测主窗体的宽度、高度、X坐标、Y坐标
    Rect TargetRect = TargetAE.Current.BoundingRectangle; //获取目标控件的宽度、高度、X坐标、Y坐标

    int X = (int)(TargetRect.Left + TargetRect.Width / 2 - AppRect.Left); //目标控件的X坐标+目标控件的宽度/2-待测主窗体的X坐标
    int Y = (int)(TargetRect.Top + TargetRect.Height / 2 - AppRect.Top); //目标控件的Y坐标+目标控件的高度/2-待测主窗体的Y坐标

    int I = 0;
    do
    {
        Thread.Sleep(250);
        PostMessage(AppFormHandle, WM_LBUTTONDOWN, 0, X + (Y << 16));//发送鼠标左键按下的消息
        PostMessage(AppFormHandle, WM_LBUTTONUP, 0, X + (Y << 16));//发送鼠标左键抬起的消息
        I = I + 1;
    } while (I < ClickCount); //ClickCount是指点击次数,我们可以通过控制循环次数来控制点击次数
}//MouseLeftClick()

五、使用 WinAPI 中的 SendMessage() 函数,向主窗体发送消息,从而模拟键盘输入

[DllImport("user32.dll ", EntryPoint = "SendMessage")]
static extern void SendMessage(IntPtr hWnd, uint Msg, int wParam, int lParam);

const uint WM_CHAR = 0x0102;

SendMessage() 是发送消息的方式之一,SendMessage() 与 PostMessage() 不同的是 SendMessage() 函数是指把消息放入消息队列后,并等待消息处理完后才返回。SendMessage() 有 4 个参数,这 4 个参数与 PostMessage() 的 4 个参数一样,因此不在多说。在这里我们需要发送的是 WM_CHAR 消息,因此我们将 wParam 定义为 int 型,其代表的是按键扫描码,其范围在 0x00-0xFFFF 之间(0x00-0x7f 为 ASCII 码,0x80-0xFFFF 为拓展 ASCII 码),而对于一个字符串 string 来说,其汉字的编码是以 Unicode 码进行存储的,因此我们需要将其转换成 ANSI 码。我们也将 lParam 设为 int 型,其代表的是按键状态的掩码,在这里我们只需将其设为 0 即可。

/// <summary>
/// 通过WinAPI,模拟键盘在TargetAE上输入信息
/// </summary>
/// <param name="AppFormHandle"></param>
/// <param name="Message"></param>
public void SendMessage(IntPtr AppFormHandle, string Message)
{
    //将字符串string Message的Unicode码转换成ANSI码
    byte[] B = Encoding.Default.GetBytes(Message);
    foreach (char C in B)
    {
        SendMessage(AppFormHandle, WM_CHAR, C, 0);
    }
}//SendMessage()

六、使用 WinAPI 中的 mouse_event() 函数,操纵鼠标来点击控件

[DllImport("user32.dll",EntryPoint= "mouse_event")]
extern static void mouse_event(uint dwFlags, int dX, int dY, uint dwData, IntPtr dwExtralnfo);

这是我们所使用到的 mouse_event() 函数,第一个参数是我们所需要使用的消息码,备注:该消息码与之前我用 PostMessage() 模拟鼠标单击时的的消息码的定义是不同的,我们需要使用的是另一个消息码,下面将会写到;第二个和第三个参数是控件相对于桌面的 X、Y 坐标,也是我们操纵鼠标所需点击的位置;第四个参数与鼠标滚轮有关,在这里我们只需要设为 0 即可;第五个参数是与鼠标事件相关的一个附加值,在这里我们只需要设为 IntPtr.Zero 即可。

mouse_event() 函数与我们之前使用 PostMessage() 函数模拟鼠标单击的方法有着本质的区别,前者是通过操纵鼠标来完成点击动作,因此其还需要使用另一个 WinAPI 函数 SetCursorPos() 来移动鼠标从而完成点击,而后者则是发消息直接告诉应用程序 鼠标点击控件的事件,无需操纵鼠标。

[DllImport("user32.dll",EntryPoint = "SetCursorPos")]
extern static bool SetCursorPos(int x, int y);

SetCursorPos() 函数是将鼠标移至坐标 (X,Y) 点上,其中两个参数表示的是控件相对于桌面的 (X,Y) 坐标。

const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
const uint MOUSEEVENTF_LEFTUP = 0x0004;

这是用于 mouse_event() 函数的消息码的定义。

public void MouseLeftClick(AutomationElement TargetAE, int ClickCount)
{
    Rect TargetRect = TargetAE.Current.BoundingRectangle;
    int dX = (int)(TargetRect.Left + TargetRect.Width / 2);//目标控件的X坐标+目标控件的宽度/2
    int dY = (int)(TargetRect.Top + TargetRect.Height / 2);//目标控件的Y坐标+目标控件的高度/2

    //将鼠标移至控件中心(dx,dY)
    SetCursorPos(dX, dY);

    int I = 0;
    do
    {
        Thread.Sleep(250);
        mouse_event(MOUSEEVENTF_LEFTDOWN, dX, dY, 0, IntPtr.Zero);//鼠标左键按下
        mouse_event(MOUSEEVENTF_LEFTDOWN, dX, dY, 0, IntPtr.Zero);//鼠标左键抬起
        I = I + 1;
    } while (I < ClickCount);//ClickCount是指点击次数,我们可以通过控制循环次数来控制点击次数
}

七、利用 UI Automation 中的各种 Pattern 模式来实现对控件的相关自动化操作

由于现在应用程序中的控件基本上都不支持接受键盘焦点,而同时微软并未对其当初所推出的 UI Automation 框架中的各种 Pattern 模式进行优化,导致其中有许多 Pattern 模式无法使用,因此在这里我就只说一些目前仍可以使用的 Pattern 模式。

7.1、ValuePattern

/// <summary>
/// 获取TargetAE的Value值
/// </summary>
/// <param name="TargetAE"></param>
/// <returns></returns>
public string Value(AutomationElement TargetAE)
{
    try
    {
        //通过AutomationElement.GetCurrentPropertyValue()的方法可以直接获得控件的属性值
        string TargetVP = (string)TargetAE.GetCurrentPropertyValue(ValuePattern.ValueProperty);
        return TargetVP;
    }
    catch
    {
        Console.WriteLine("获取TargetAE的Text值 失败");
        return null;
    }
}//Value()

7.2、TextPattern

/// <summary>
/// 获取TargetAE的Text值
/// </summary>
/// <param name="TargetAE"></param>
/// <returns></returns>
public string Text(AutomationElement TargetAE)
{
    try
    {
        //通过AutomationElement.GetCurrentPattern()的方法获得控件的Pattern模式对象
        TextPattern TargetTP = (TextPattern)TargetAE.GetCurrentPattern(TextPattern.Pattern);
        //通过TextPattern.DocumentRange来获取该控件中的文本对象TextPatternRange
        TextPatternRange TargetTPR = TargetTP.DocumentRange;
        //使用TextPatternRange.GetText(int maxLength)获取该控件的文本内容,maxLength表示要返回的字符串的最大长度, -1表示全文本
        string Text = TargetTPR.GetText(-1);
        return Text;
    }
    catch
    {
        Console.WriteLine("获取TargetAE的Text值 失败");
        return null;
    }
}//Text()
共收到 8 条回复 时间 点赞

你好,看完你的帖子后我有一个疑问想请教一下,对于文中写的客户端的 UI 自动化,如果是一些 DX 绘的非标控件,比如说一个登陆框,当 Spy++ 只能探测到整体的外部控件而无法识别登陆框内部的用户名及密码的输入框以及登陆按钮时,文中的方法还能处理吗?
见下图:

#1 楼 @CwXwWw Spy++ 是给你看主窗体的类名和窗口名的,而对于无句柄控件是看不了的,你需要用 UISpy 或 Inspect 工具来查看控件的相关属性,然后通过这些属性来获取控件的 AE

#1 楼 @CwXwWw 比如 QQ 聊天界面的输入框,你用 Spy++ 是看不到这个控件的,因为这个控件是无句柄控件,而像我图中用 UISpy 查看,却可以查看到他,还能从中看到他的 Name、ControlType 等属性

4楼 已删除

#3 楼 @sindynasty get it,thank you,let me hava a try.

UISpy 或 Inspect 工具一般都在 C:\Program Files (x86)\Windows Kits\路径下,至于到底是哪个就要看你装的 VS 版本(确切地说是 WinSDK 的版本),新的是 Inspect,这个查看控件属性比较蛋疼,因为他显示的信息太多了,看上去眼花缭乱的

我装的是 VS2015,Inspect 是在 C:\Program Files (x86)\Windows Kits\10\bin\x64 路径下

恒温 将本帖设为了精华贴 02月12日 16:39

微软的 uiautomation

#9 楼 @Lihuazhang 恩,是微软的 UIAutomation

SinDynasty Appium Windows APP UI 自动化 中提及了此贴 04月07日 20:54
老马 Windows UI Auto 工具选型 中提及了此贴 03月29日 11:48
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册