使用Xamarin.UITest和Page对象模式进行自动化的UI测试

移动应用程序的UI测试至关重要,以便在最终用户手中准确测量给定应用程序的可用性。 当应用程序的功能集增长时,手动测试应用程序的UI可能会效率低下,如果需要测试多个设备配置,则几乎不可能。 因此,自动化的UI测试已成为开发人员监视其移动应用程序行为的宝贵工具。 使用Xamarin.UITest框架,我们能够快速而轻松地完成此任务。

Xamarin.UITest,Calabash和NUnit概述

Xamarin.UITest是基于Calabash的自动化测试框架,允许开发人员编写以NUnit编写的验收测试。 Calabash是用Ruby编写的核心框架,它使开发人员能够编写自动UI接受测试。 最后,NUnit是用于基于.NET的语言的单元测试框架。 开发人员可以使用NUnit和Xamarin.UITests对iOS和Android应用程序运行测试。 从那里,开发人员还可以通过Visual Studio App Center(以前称为Xamarin测试云)提交这些测试,以使其在数百个物理设备上运行。

为什么要编写自动化的UI测试?

自动化的UI测试允许快速开发健壮的代码库,同时能够系统地测试多个设备目标上的常见用户方案。 如果团队决定重构其核心体系结构,它还允许用户快速测试应用程序。 这使团队可以清晰地了解可能需要检修的工作项目以及按预期工作的项目。 通常,行为驱动开发还具有隐藏的好处,例如难以发现代码库中的错误以及允许专家和开发人员更好地协作来构建用户故事。

使用Xamarin.UITest

Xamarin.UITest框架需要依靠一些概念才能正常工作。 框架中的每个自动UI测试都以[Test]属性进行修饰。 在每个测试中,存在测试逻辑的类称为测试夹具 。 给定的测试套件中可以有一个或多个测试夹具。 这些测试应该按照最终用户根据Xamarin自己的“安排-行为-声明”模式与应用程序进行交互的方式进行逻辑安排。 此模式要求设置和初始化测试条件(安排),测试将像用户一样操作并与应用程序交互(执行),最后,测试将检查这些交互的结果(声明)。 下面是一个示例。 我们将对包括本文在内的所有示例使用Xamarin.iOS。

初始化

要在iOS上初始化Xamarin.UITest,请使用静态ConfigureApp类:

 var config = ConfigureApp 
.iOS
.InstalledApp("package.identifier")
.EnableLocalScreenshots();

这允许UITest库将应用程序部署到物理设备上以运行测试。 使用InstalledApp允许在物理设备上以编程方式启动,而调用AppBundle将强制在模拟器上运行。 在物理设备上进行测试时,务必将应用程序的捆绑软件ID放置在package.identifier参数的位置。 您可以使用应用程序的相对或绝对路径在模拟器上进行测试。

在我们的情况下,如果没有用户首先通过身份验证应用程序登录,该应用程序将无法成功运行。 登录后,用户将被重定向到原始应用程序。 这可以通过框架以与上述相同的方式实例化应用程序来完成。 重要的是要注意,为了使它在iOS上成功运行,还必须已经构建了该应用程序。

调试测试

在编写UI测试时,可能有时需要手动浏览UI或快速测试一些应用程序命令。 幸运的是,Xamarin.UI测试框架配备了repl(read-eval-print-loop)来处理此问题。 该工具使开发人员可以与应用程序的UI进行交互,从而使他们可以测试单个命令,遍历界面,并清晰地了解哪些视图处于活动状态以及与每个元素相关联的元数据标签。

为了使用repl,我们只需要从测试内部调用IApp.repl()方法。 当执行repl()方法时,它将暂停当前测试,并在新的终端窗口中打开repl。 在这里,我们可以直接与该应用进行交互,测试命令和查询,并进一步了解我们的UI。 在编写测试时,我们经常需要检查屏幕上实际存在的视图。 为此,repl有一个名为tree的漂亮命令,该命令提供了屏幕上当前可见的所有视图的层次结构列表。

由于Xamarin.UI测试框架的许多方法都依赖AppQuery对象在屏幕上找到正确的视图,因此我们需要知道实际上是在获取正确的元素。 为了帮助我们解决这个问题,UI框架提供了IApp.Flash()方法,该方法将在屏幕上突出显示查询结果并为您提供有关它们的元数据。 这为确认查询针对正确的视图提供了极大的视觉帮助。 当您想测试单个命令而不必运行测试时,这特别方便。

在repl中,您可以访问所有IApp方法,例如IApp.EnterText()和IApp.Tap()。 通过将查询结果与这些命令结合在一起,可以逐步模拟测试。 repl的另一个便利功能是尝试了命令之后,您可以使用copy命令将它们全部复制到剪贴板。 这样可以轻松地根据repl会话的结果快速更新测试。

供应

运行自动UI测试时,必须在每个测试或一组测试之前执行一些操作。 主要是,您必须至少启动应用程序,然后您的测试才能与之交互。 安装程序可能与BeforeEachTest()方法调用相关联,并带有[Setup]属性进行注释。 对于我们的测试,我们会跟踪每次测试期间收集的日志和屏幕截图。 为了使日志保持逻辑顺序,我们将跟踪设置步骤中正在运行的测试。 我们还使用了拆解方法,该方法将测试日志保存到相应的目录中。

在其他情况下,更改设置方法中的逻辑会很有用。 例如,在开发跨平台应用程序时,可以使用单个测试夹具来测试Android和iOS部署。 BeforeEachTest()方法将包含选择目标平台的逻辑。 这是摘自XamarinUIText文档的一个小片段,它说明了这一点:

 public class AppInitializer 
{
public static IApp StartApp(Platform platform)
{
if(platform == Platform.Android)
{
return ConfigureApp.Android.StartApp();
}
return ConfigureApp.iOS.StartApp();
}
}

[TestFixture(Platform.Android)]
[TestFixture(Platform.iOS)]
public class Tests
{
IApp app;
Platform platform;

public Tests(Platform platform)
{
this.platform = platform;
}

[SetUp]
public void BeforeEachTest()
{
app = AppInitializer.StartApp(platform);
}
}

源自Xamarin文档

如果在测试过程中记录移动设备的屏幕截图很重要,则可以通过将EnableLocalScreenshots方法添加到ConfigureApp语句中来启用它们。 下面是一个示例:

Android:

 ConfigureApp.Android.EnableLocalScreenshots.StartApp(); 

iOS:

 ConfigureApp.iOS.EnableLocalScreenshots.StartApp(); 

在为更复杂的应用程序进行测试时,应用程序可能必须经过一些初始设置过程才能运行测试。 例如,一个要求用户每次登录以提高安全性的应用程序。 这是这些设置和拆卸方法变得有价值的地方。

使用页面对象模式构建测试

使用repl为开发人员提供了巨大的优势,使其能够实时测试某些命令是否可以使它们的应用程序自动化。 这促进了快速发展,并且为了制作既可读又可重用的Teast,创建了Page Object Pattern来对其进行补充。 另外,这有助于跨平台共享代码的能力。 这种测试方法将应用程序中的每个页面都像一个对象一样对待,并且以声明式的方式编写测试,使开发人员可以自由地对基础平台特定的实现进行重新设计。 例如,着陆页对象将如下所示:

 public class LandingPage 
{
protected abstract Query Trait { get; }
public static readonly Query Header = x => x.Class("UINavigationBar").Id("Landing Page");

protected LandingPage()
{
AssertOnPage(TimeSpan.FromSeconds(30));
app.Screenshot("On " + this.GetType().Name);
}
  readonly Query menuButton = x => x.Button("Menu"); 
readonly Query selectButton = x => x.Button("Select");
}

在这种情况下,我们需要一种简单的方法来将页面与应用程序中的其他页面区分开,因此我们创建了一个查询,该查询使用值为"Landing Page"的类"UINavigationBar" "Landing Page" 。 通过构造函数实例化页面对象后,我们保证当前的UI与构造函数中等待的特征匹配。 使用页面对象模式,开发人员可以隐藏有关如何与UI交互的实现细节。 就编写测试本身而言,诸如测试按钮,选择菜单项或从选择器枚举值之类的细节都存在于其他地方,而Test则涉及断言和预期结果。

 public void OpenMenu() 
{
app.Tap(menuButton);
app.Screenshot("Opened menu");
}

下一步是在实际测试中参考该页面。

 public class ActiveTourTests : TourTestFixture 
{
[Test]
public void TestLandingPage()
{
  new LandingPage() 
.OpenMenu();

var menuPage = new MenuPage();
var expectedOptions = new[]
{
"My Profile",
"History",
"Calendar",
};
  Assert.IsTrue(expectedOptions.SequenceEqual(menuPage.MenuItems)); 
Assert.IsTrue(menuPage);
  menuPage.Close(); 

}

其中MenuPage定义为:

 public class MenuPage 
{
static readonly Query menuCell = x => x.Class("Sample_Namespace_MenuCell");

readonly Query closeButton = x => x.Button("Close");

protected override Query Trait => menuCell;

public void Close()
{
app.Tap(closeButton);
app.Screenshot("Closed menu");
}
  public class MenuItems 
{
public readonly Query Query;
MenuItem(string text) => this.Query = x => menuCell(x).Descendant().Marked(text);

public static MenuItem MyActivity => new MenuItem("My Profile");
public static MenuItem History => new MenuItem("History");
public static MenuItem FlyNotes => new MenuItem("Calendar");

}
}
}

上面是创建两个页面( LandingPage和随后的MenuPage的测试MenuPage 。 它旨在通过单击menuButton并导航到MenuPage来模拟用户行为。 从那里开始,测试断言MenuPage上的条目就是开发人员期望的条目。 如果Assert失败,则测试也将失败。

Xamarin.UITest库公开的一项强大功能是能够为给定测试创建多个TestCases 。 TestCases提供了使用不同参数多次运行同一测试的灵活性。 每个TestCase都为传递到测试中并依次运行的参数获取一个值。 例如,如果要以某种形式测试一系列不同的字符串以测试不同的情况,则可以使用TestCases进行测试:

 public class EntryFormPageTests 
{
[TestCase("string1")]
[TestCase("string2")]
[TestCase("string3")]
public void FormTest(string entryText)
{
app.EnterText("CommentsEntry", entryText);
}
}

当您具有一组使用相同UI遵循相同模式但仅在几个条目上有所不同的测试时,此功能很有用。 例如,使用页面测试一组值可能很重要,这些页面可能会基于这些值给出不同的结果。

平台限制

尽管Xamarin.UITest库相当广泛,并且应在Xamarin之上编写的任何生产移动应用程序都考虑使用Xamarin.UITest库,但在使用该框架之前,应了解一些限制:

声明UI元素的相对位置不是框架中实现的功能。

尽管开发人员确实可以访问给定UI元素的(x,y)值,但是创建一个依赖于一个元素与另一个元素的关系的测试会更可靠。 例如,一个工作项可能显示为“’提交’按钮始终位于’密码’条目下。 尽管可以将其添加为帮助程序,但最好在UITest库中具有该测试功能。 当前,为此,必须像下面这样实现逻辑:

在Page类本身中,假定具有以下逻辑的Button和Label获取其中心位置:

 public class SamplePage 
{
  public float LabelLocaleX => GetDateFieldLocationX(); 
public float LabelLocaleY => GetDateFieldLocationY();
public float ButtonLocaleY => GetButtonFieldLocationY();
public float ButtonLocaleX => GetFButtonFieldLocationX();

float GetFButtonFieldLocationX()
{
var locationX = app.Query(x => x.Button("ButtonToCompare"))[0].Rect.CenterX;
return locationX;
}

float GetButtonFieldLocationY()
{
var locationY = app.Query(x => x.Button("ButtonToCompare"))[0].Rect.CenterY;
return locationY;
}

float GetDateFieldLocationX()
{
var locationX = app.Query(x => x.Class("UILabel").Index(0))[0].Rect.CenterX;
return locationX;
}

float GetDateFieldLocationY()
{
var locationY = app.Query(x => x.Class("UILabel").Index(0))[0].Rect.CenterY;
return locationY;
}
}

为了测试这些值,我们在Test类本身中调用它们:

 public class SamplePageTests 
{
public void TestSamplePage()
{
[Test]
var samplePage = new SamplePage();
  //X-Value of button is greater than label 
Assert.Greater(activityLogPage.ButtonLocaleX, activityLogPage.LabelLocaleX);

//Y-values of button and label are equal - must be on the same line
Assert.AreEqual(activityLogPage.LabelLocaleY, activityLogPage.ButtonLocaleY);
}
}

使用Picker类

在用户必须向上或向下滚动以查看隐藏的值之前,在iOS选择器中选择值一直有效。 我们也不能简明地从iOS选择器类中枚举值来断言内容是否正确。 当前,我们有一个BaseFormPage ,可以处理这种常见的UI交互方案。

 公共抽象类BaseFormPage 
{
只读查询选择器= x => x.Class(“ UIPickerView”);
只读查询pickerDoneButton = x => x.Class(“ UIToolbar”)。Descendant(“ UIButtonLabel”)。Marked(“ Done”);
只读查询pickerView = x => x.Class(“ UIPickerView”);
只读查询pickerRow = x => x.Class(“ UIPickerTableViewTitledCell”)。Class(“ UILabel”);
void ChooseField(字符串字段)
{
// TODO:使查询更健壮
查询查询= x => x.Marked(field);
app.ScrollDownTo(query,strategy:ScrollStrategy.Gesture,withInertia:false);
app.Tap(查询);
}
 公共IEnumerable  GetPickerValues(字符串字段) 
{
返回GetPickerValuesCore(field).Distinct();
}
 查询namedPickerRow(string text)=> x => x.Class(“ UIPickerTableViewTitledCell”)。Child()。Marked(text); 
  IEnumerable  GetPickerValuesCore(字符串字段) 
{
ChooseField(field);
app.WaitForElement(picker);
  ScrollToTopOfPicker(); 
  var lastRow = default(string); 
var pickerRows = app.Query(pickerRow);
while(pickerRows.LastOrDefault()?. Text!= lastRow)
{
foreach(pickerRows中的var行)
{
收益回报row.Text;
lastRow = row.Text;
}
  app.Tap(namedPickerRow(lastRow)); 
Thread.Sleep(500); //等待几百毫秒,以便选择器旋转
pickerRows = app.Query(pickerRow);
}
  ScrollToTopOfPicker(); 
  app.Tap(pickerDoneButton); 
  app.WaitForNoElement(inputView); 
}
 无效ScrollToTopOfPicker() 
{
var firstRow = default(string);
var pickerRows = app.Query(pickerRow);
while(pickerRows.FirstOrDefault()?. Text!= firstRow)
{
firstRow = pickerRows.FirstOrDefault()?. Text;
app.Tap(namedPickerRow(firstRow));
Thread.Sleep(500); //等待几百毫秒,以便选择器旋转
pickerRows = app.Query(pickerRow);
}
}
}

评估切换状态

我们发现,在不依赖Xamarin.iOS MonoTouch库的情况下,不可能断言是否已在UITest库中切换了swtich。 看到这是一个测试框架,我们不想让任何依赖项泄漏到特定平台中。 我们实施了一种相对简单的方法来规避以下代码:

  bool IsSwitchEnabled(查询switchQuery) 
{
var value = app.Query(x => switchQuery(x).Invoke(“ isOn”)));
var isOn = value!= null && value.Length> 0? 值[0] .ToString():空;
返回isOn ==“ 1”;
}

为了确定是否切换开关,我们通过app.Query方法查询值“ isOn”。 然后,我们可以使用Tap方法切换开关本身,如下所示。

 Query switchElement = x => x.Class("UISwitch").Marked(field); 
app.ScrollDownTo(switchElement, strategy: ScrollStrategy.Gesture, withInertia: false);
if (IsSwitchEnabled(switchElement) != enabled)
{
app.Tap(switchElement);
}

这不是最优雅的解决方案,但是它消除了对MonoTouch的依赖。 这最终使我们的测试更加灵活。

结论

测试一直是开发高质量软件的关键部分。 自动化的UI测试是在前端执行此操作的有效且可靠的方法。 借助Xamarin.UITest框架以及Appium等框架,我们可以自动执行针对不同设备配置文件,屏幕尺寸甚至操作系统的测试方案,从而节省了开发时间,并使团队可以专注于核心优先级。 这篇文章涵盖了Xamarin.UITest带来的实质。 有关更多信息,我们建议您深入了解Xamarin开发人员文档。