使用Selenium进行Web UI的自动化测试是很好的选择,它支持多种语言来实现你的测试代码,也支持多种浏览器。我选择的是Selenium Web Dirver + C# + FireFox来进行开发,并且采用PageObject design pattern来组织代码,每个page对象使用page factory工厂方法生成。
下面的示例是描述登录页面的LoginPage类:
1 class LoginPage 2 { 3 private IWebDriver driver; 4 5 [FindsBy(How = How.XPath, Using = "//input[@type='text' and @name='userName']")] 6 private IWebElement txtUserName; 7 8 [FindsBy(How = How.Name, Using = "userName")] 9 private IWebElement txtPassword; 10 11 [FindsBy(How = How.Name, Using = "login")] 12 private IWebElement btnLogin; 13 14 public LoginPage(IWebDriver driver) 15 { 16 this.driver = driver; 17 } 18 19 public FindFlightsPage Do(string UserName, string Password) 20 { 21 txtUserName.SendKeys(UserName); 22 txtPasswowrd.SendKeys(Password); 23 btnLogin.Click(); 24 25 return new FindFlightsPage(driver); 26 } 27 }
可以看到,每个IWebElement私有成员都描述一个LoginPage中的元素,并且使用“FindsBy”特性来描述如何定位当前元素在页面中的位置。这样,我们就可以通过Selenium提供的PageFactory类来生成对应的Page对象了:
1 class Program 2 { 3 static void Main() 4 { 5 IWebDriver driver = new FirefoxDriver(); 6 driver.Navigate().GoToUrl("http://newtours.demoaut.com"); 7 8 LoginPage Login = new LoginPage(driver); 9 10 // initialize elements of the LoginPage class 11 PageFactory.InitElements(driver, Login); 12 // all elements in the 'WebElements' region are now alive! 13 // FindElement or FindElements no longer required to locate elements 14 15 FindFlightsPage FindFlights = Login.Do("User", "Pass"); 16 driver.Quit(); 17 } 18 }
但是这样实现的LoginPage类,如果被测系统在UI上面有变化,比如元素的ID或Name有变化,页面的结构有变化,我们就需要更改LoginPage类中的“FindsBy”特性的内容,并且重新编译测试代码,重新部署测试代码到测试环境,而且这种情况在项目初期会经常出现,势必会导致每天花费大量时间来重新更换测试环境。
下面介绍我使用的方法,来解开页面UI的描述信息和Page类之间的耦合。
这是我写的LoginPage类:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Threading; 6 7 using OpenQA.Selenium; 8 using OpenQA.Selenium.Interactions; 9 using OpenQA.Selenium.Support.PageObjects; 10 using Selenium.Tools; 11 12 namespace WebTest.TestFramework 13 { 14 public class LoginPage : PageBase 15 { 16 [NeedRefresh] 17 public IWebElement UserNameInput { get; set; } 18 [NeedRefresh] 19 public IWebElement PassWordInput { get; set; } 20 public IWebElement SelectLanguageLinkBar { get; set; } 21 public IWebElement EnglisghLanguageLink { get; set; } 22 23 public LoginPage(IWebDriver driver) 24 { 25 this.webDriver = driver; 26 } 27 28 public void Login(string userName, string passwd) 29 { 30 this.UserNameInput.SendKeys(userName); 31 this.PassWordInput.SendKeys(passwd); 32 this.UserNameInput.Submit(); 33 } 34 35 public void SelectLanguage(LanguageType type) 36 { 37 Actions actions = new Actions(this.webDriver); 38 actions.MoveToElement(SelectLanguageLinkBar); 39 actions.MoveToElement(EnglisghLanguageLink); 40 actions.Click(); 41 actions.Perform(); 42 } 43 } 44 }
抽象出一个PageBase类:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using OpenQA.Selenium; 6 7 namespace AFPWebTest.TestFramework 8 { 9 public class PageBase 10 { 11 protected IWebDriver webDriver; 12 } 13 }
其中,NeedRefresh特性是自定义的:
1 using System; 2 3 namespace Selenium.Tools 4 { 5 [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] 6 public class NeedRefreshAttribute : Attribute 7 { } 8 }
还有一个自定义的Ignore特性:
1 using System; 2 3 namespace Selenium.Tools 4 { 5 [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] 6 public class IgnoreAttribute : Attribute 7 {} 8 }
使用NeedRefreshAttribute描述页面元素来表示在初始化Page类之后,这个页面元素的相关属性和值素会有更改。IgnoreAttribute是指在使用PageFactory初始化页面对象时需要略过,不需要实例化的元素。
下面,我们设计一个xml结构来描述页面中的元素:
1 <?xml version="1.0"?> 2 <Map> 3 <UIMaps> 4 <UIMap Id="UserNameInput"> 5 <By>Id</By> 6 <ToFind>userName</ToFind> 7 </UIMap> 8 <UIMap Id="PassWordInput"> 9 <By>Id</By> 10 <ToFind>password</ToFind> 11 </UIMap> 12 <UIMap Id="SelectLanguageLinkBar"> 13 <By>XPath</By> 14 <ToFind>//div[@id='formContainer']//div[@class='header']</ToFind> 15 </UIMap> 16 <UIMap Id="EnglisghLanguageLink"> 17 <By>XPath</By> 18 <ToFind>//div[@id='formContainer']//a[contains(., 'English')]</ToFind> 19 </UIMap> 20 </UIMaps> 21 </Map>
其中,每个UIMap节点描述一个页面元素,属性Id的值需要和Page类中Public的IWebElement属性名字相同,XML By节点的值和Selenium提供的By类的方法名相同,有这些值:By.ClassName, By.CssSelector, By.Id, By.LinkText, By.Name,By.TagName和By.XPath.XML ToFind节点的值是使用以上By提供的方法所传入的参数。
下面是xml文件对应的UIMap和Map类的实现:
1 using System.Xml.Serialization; 2 3 namespace Selenium.Tools.xml 4 { 5 public class UIMap 6 { 7 [XmlAttribute] 8 public string Id 9 { get; set; } 10 11 [XmlElement] 12 public string By 13 { get; set; } 14 15 [XmlElement] 16 public string ToFind 17 { get; set; } 18 } 19 } 20 21 using System.Xml.Serialization; 22 23 namespace Selenium.Tools.xml 24 { 25 [XmlRoot] 26 public class Map 27 { 28 [XmlArray] 29 public UIMap[] UIMaps 30 { get; set; } 31 } 32 }
我们就是通过这个描述Web Page的XML文件,来达到解耦测试代码和UI描述的目的。
好了,下面就是具体实现PageFactory和解析UIMaps的代码:
1 using System; 2 using System.Linq; 3 using System.Reflection; 4 5 using System.Xml.Serialization; 6 using System.IO; 7 using System.Collections.Generic; 8 using Castle.DynamicProxy; 9 using Selenium.Tools.xml; 10 using OpenQA.Selenium; 11 using OpenQA.Selenium.Support.PageObjects; 12 using OpenQA.Selenium.Internal; 13 14 namespace Selenium.Tools 15 { 16 public static class PageFactory 17 { 18 public static string UIMapFilePath { get; set; } 19 20 private static bool DoesHasAttribute(PropertyInfo propertyInfo, IList<Type> attributes) 21 { 22 foreach (Type attribute in attributes) 23 { 24 var customAttributes = propertyInfo.GetCustomAttributes(attribute, true); 25 if (customAttributes.Length != 0) 26 { 27 return true; 28 } 29 } 30 return false; 31 } 32 33 private static Tpage ConstructPage<Tpage>(IWebDriver driver, IList<Type> ignoreAttributes, IList<Type> fetchAttributes) where Tpage : class 34 { 35 Type type = typeof(Tpage); 36 ConstructorInfo constructorInfo = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, 37 null, 38 CallingConventions.HasThis, 39 new Type[] { driver.GetType() }, 40 null); 41 Tpage pageObject = constructorInfo.Invoke(new object[] { driver }) as Tpage; 42 43 var properties = type.GetProperties(BindingFlags.Instance | 44 BindingFlags.Public | 45 BindingFlags.ExactBinding | 46 BindingFlags.SetProperty 47 ); 48 49 foreach (var property in properties) 50 { 51 if (property.PropertyType.Name != "IWebElement" || DoesHasAttribute(property, ignoreAttributes)) 52 { 53 //Do not init webelement that marked as Ignore 54 continue; 55 } 56 //When fetchAttributes==null, all property without ignore attributes need to be set 57 if (fetchAttributes == null || DoesHasAttribute(property, fetchAttributes)) 58 { 59 property.SetValue( 60 pageObject, 61 driver.FindElement( 62 ParseUIMaps(type.Name, 63 property.Name)), 64 null); 65 } 66 } 67 return pageObject; 68 } 69 70 public static Tpage InitPage<Tpage>(IWebDriver driver) where Tpage : class 71 { 72 return ConstructPage<Tpage>(driver, new List<Type>() { typeof(IgnoreAttribute) }, null); 73 } 74 75 public static Tpage RefreshPage<Tpage>(IWebDriver driver) where Tpage : class 76 { 77 return ConstructPage<Tpage>(driver, new List<Type>() { typeof(IgnoreAttribute) }, new List<Type>() { typeof(NeedRefreshAttribute) }); 78 } 79 80 private static By ParseUIMaps(string pageName, string uimapId) 81 { 82 XmlSerializer serializer = new XmlSerializer(typeof(Map)); 83 Map map = null; 84 using (FileStream fileStream = new FileStream(Path.Combine(UIMapFilePath, pageName + ".xml"), FileMode.Open)) 85 { 86 map = serializer.Deserialize(fileStream) as Map; 87 } 88 if (map == null) 89 { 90 throw new Exception("Fail to deserialize UIMap xml file!!"); 91 } 92 93 var uimap = (from i in map.UIMaps where i.Id == uimapId select i).Single(); 94 return ConstructBy(uimap); 95 } 96 97 private static By ConstructBy(UIMap uimap) 98 { 99 By by = null; 100 switch (uimap.By) 101 { 102 case "ClassName": 103 return By.ClassName(uimap.ToFind); 104 case "CssSelector": 105 return By.CssSelector(uimap.ToFind); 106 case "Id": 107 return By.Id(uimap.ToFind); 108 case "LinkText": 109 return By.LinkText(uimap.ToFind); 110 case "Name": 111 return By.Name(uimap.ToFind); 112 case "PartialLinkText": 113 return By.PartialLinkText(uimap.ToFind); 114 case "TagName": 115 return By.TagName(uimap.ToFind); 116 case "XPath": 117 return By.XPath(uimap.ToFind); 118 } 119 return null; 120 } 121 } 122 }
上面的代码通过使用反射技术,读取与Page类的名字相同的xml文件,并从中取得信息来实例化Page类的对象。
再实现一个扩展方法,简化代码:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using Selenium.Tools; 6 using OpenQA.Selenium; 7 8 namespace WebTest.TestFramework 9 { 10 public static class UIMapperHelper 11 { 12 public static Tpage Refresh<Tpage>(this Tpage page, IWebDriver driver) where Tpage : class 13 { 14 return PageFactory.RefreshPage<Tpage>(driver); 15 } 16 17 public static Tpage Init<Tpage>(this Tpage page, IWebDriver driver) where Tpage : class 18 { 19 return PageFactory.InitPage<Tpage>(driver); 20 } 21 } 22 }
好了,testcase的代码如下(使用Nunit实现):
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 using OpenQA.Selenium; 7 using OpenQA.Selenium; 8 using OpenQA.Selenium.Firefox; 9 using OpenQA.Selenium.IE; 10 using OpenQA.Selenium.Support.UI; 11 using OpenQA.Selenium.Interactions; 12 using OpenQA.Selenium.Remote; 13 using OpenQA.Selenium.Support.PageObjects; 14 15 using NUnit.Framework; 16 using WebTest.TestFramework; 17 using PageObjectFactory = Selenium.Tools.PageFactory; 18 19 namespace WebTest.TestCases 20 { 21 [TestFixture] 22 public class BVT : WebTestBase 23 { 24 [Test] 25 [TestCase("camel", "123456")] 26 public void LoginTest(string username, string passwd) 27 { 28 IWebDriver driver = new FirefoxDriver(); 29 driver.Navigate().GoToUrl("http://172.16.1.123:8080"); 30 31 PageObjectFactory.UIMapFilePath = @"E:\src_test\WebTest\TestFramework\UIMaps"; 32 LoginPage loginPage = PageObjectFactory.InitPage<LoginPage>(driver); 33 loginPage.SelectLanguage(LanguageType.English); 34 35 loginPage = loginPage.Refresh(driver); 36 loginPage.Login(username, passwd); 37 38 this.AddTestCleanup("Close Browser", 39 () => { driver.Close(); }); 40 } 41 } 42 }
其中,”E:\src_test\WebTest\TestFramework\UIMaps”目录包含的是描述LoginPage页面的LoginPage.xml文件。
好了,以后如果LoginPage的UI有变化时,只需要修改LoginPage.xml文件,不会影响到测试代码。