从0到1搭建Web端的Ui自动化测试框架
一、为何要做Ui自动化
很多公司都把自动化的测试重心放在了接口自动化上,这无可厚非的,因为它效率快,覆盖率基本能做到100%以上,能把人力从重复的黑盒测试中解脱开来,让我们能把更多的人力花在用户交互体验,功能失败与异常,以及探索性上的测试,从而能更好的保证产品质量。
但是它的缺点也是显而易见的,因为它并未涉及 到 ui 层面的测试,在前后端分离,且ui 交互功能变得复杂的情况下,有些页面特别是有地图或动画的渲染,是存在着性能上的问题导致无法加载,另外也可能存在接口数据量大导致页面无法正常加载的,还有经常遇到的就是数据的不一致性等等。
这些问题直接暴露在客户面前,而且是无法通过接口自动化巡检来发现,这样我们就需要把Ui自动化测试也加入测试体系,在产品质量的保障上进行双重提供,并且也支持上线后对产品进行巡检和监控的需求。
二、实现Ui自动化的痛点与它的解决思路
痛点无非就是Ui的频繁修改导致控件的定位维护成本高,执行的稳定性差,对测试工程师的技术能力是要求会语言编程,投入和产出比例低。但这几个痛点有的是可以避免和解决。
Ui频繁修改导致维护成本高,是因为代码上没有做到良好的分层设计,包括数据与代码的分离,如果能做到很好的分层设计和数据和代码分离,那么即便是Ui频繁变动,那么改的也只是控件元素这层的代码,其他代码不受影响,维护成本相对应降低。
执行的稳定性差,这基本是体现在本地机器上跑的自动化,机器内存不足,或者网络因素都有可能影响,所以需要在linux服务器上部署同样一套UI自动化环境,在Linux上面跑相对于本地更稳定。
对测试工程师能力要求高,首先需要定期持续的对工程师进行相关的语言编程基础的进行培训,学习和考虑,让工程师能尽快上手基本的语言编程,其次在搭建好Ui自动化框架的过程中,尽可能的对常用的方法进行封装,继承,更重要的是使用分层设计思想让代码简洁易读,并让测试数据和代码分离,增加注释,减低普通测试工程师上手的难度。
投入与产出比例低,可以对核心业务的页面,核心数据的校验,或者是经常暴露问题的页面上,都有针对性的进行Ui自动化上的投入,这样相对于投入,它产出的效果就会极大。
三、框架搭建

四、环境准备与搭建

五、建立maven工程
上面准备的是本地的环境,需要配置好java环境和maven,我们使用的是java语言,所以可用IDEA开发代码平台,其他ecplise也可,配置好maven就行。
最后还需要下载浏览器的驱动工具配置在本地,是对应本地浏览器的版本号,我这边使用的是谷歌浏览器。
同样的操作在linux环境也需要配置和部署,包括java环境,maven工具,浏览器的驱动包。
使用maven工程的好处就是,它能打包下载好我们所需要的插件,包括我们本人的男主角Selenium,单元测试框架TestNg,日志系统Log4j,测试报告reportNG等等。
1.新建一个项目 Project name: 填写项目名称; Project location: 填写项目在硬盘上的路径;
2.将项目转成 maven 工程 项目上右键点击 Add Framework Support,然后勾选 Maven,点击 ok。
3.打开 pom.xml 进行配置
<dependency> //加入selenium做webUI测试,选用selenium <groupId>org.seleniumhq.selenium</groupId><artifactId>selenium-java</artifactId><version>2.53.1</version></dependency><dependency> //依赖Guice <groupId>com.google.inject</groupId><artifactId>guice</artifactId><version>3.0</version><scope>test</scope></dependency> <dependency>//引入testng单元测试框架 <groupId>org.testng</groupId><artifactId>testng</artifactId><version>7.0.0-beta7</version><scope>compile</scope> </dependency> <dependency>//引入日志系统 <groupId>log4j</groupId><artifactId>log4j</artifactId><version>1.2.17</version> </dependency> <plugin>//添加插件,添加ReportNg的监听器,修改最后的TestNg的报告 <groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>2.5</version><configuration><properties><property><name>usedefaultlisteners</name><value>false</value></property><property><name>listener</name><value>org.uncommons.reportng.HTMLReporter</value></property></properties><workingDirectory>target</workingDirectory><!-- <forkMode>always</forkMode> --></configuration> </plugin>
六、分层设计
通常 ui 自动化的设计模式是:元素层+操作层+业务层,我们这边则采取的是 PageFactory 进行分层设计,基类层+页面层+业务操作层+测试用例层,再加上工具层+日志 系统+配置文件,扩展功能丰富,设计如下:

从表可知,需要经常维护的是页面类,比如页面元素变换等。至于业务测试数据文件,操作类和测试用例类则要看是否有变动才确定是否需要维护,下面是实现项目结构图:

七、工具类的封装和解析
正所谓磨刀不误砍柴工,在开始各目录进行编码之前,我们需要先开发好相关工具类,方便代码上的引用。
1.DriverUtil 类
DriverUtil 在 util 目录,是用于获取本地或服务器上的浏览器的驱动插件位置,并定 义驱动的属性。
public class DriverUtil { public static WebDriver getDriver(){ WebDriver driver=null; if (System.getProperty("os.name").contains("Windows")){ System.setProperty("webdriver.chrome.driver", "c://chromedriver.exe"); driver= new ChromeDriver(); driver.manage().window().maximize(); }else{ System.setProperty("webdriver.chrome.driver", "/usr/bin/chromedriver"); ChromeOptions chromeOptions=new ChromeOptions(); chromeOptions.addArguments("headless"); chromeOptions.addArguments("no-sandbox");//#解决DevToolsActivePort文件不存在的报错 chromeOptions.addArguments("window-size=1920x3000"); //#指定浏览器分辨率 chromeOptions.addArguments("disable-gpu"); //#谷歌文档提到需要加上这个属性来规避bug chromeOptions.addArguments("hide-scrollbars"); //#隐藏滚动条, 应对一些特殊页面 chromeOptions.addArguments("blink-settings=imagesEnabled=false");// #不加载图片, 提升速度 driver = new ChromeDriver(chromeOptions); } return driver; } }
2.Assertion 类
Assertion 在 util 目录,用于断言预期的结果与测试的结果。
public class Assertion { static boolean flag=true; public static void verifyAssert(Object actual, Object expected)throws InterruptedException{ try{ Thread.sleep(200); Assert.assertEquals(actual,expected); }catch (Error e){ flag=false; } } public static void verifyAssert(Object actual, Object expected, String message)throws InterruptedException{ try{ Thread.sleep(200); Assert.assertEquals(actual,expected,message); Log.info(message+"的断言成功了"); }catch (Error e){ flag=false; Log.error(message+"的断言失败了"); } } public static void verifyAssertNotNull(Object actual)throws InterruptedException{ try{ Thread.sleep(200); Assert.assertNotNull(actual); }catch (Error e){ flag=false; } } public static void verifyAssertNotNull(Object actual,String message)throws InterruptedException{ try{ Thread.sleep(200); Assert.assertNotNull(actual,message); Log.info(message+"不为空的断言成功了"); }catch (Error e){ flag=false; Log.error(message+"不为空的断言失败了"); } } public static void verifyAssertNotEquals(Object obj1,Object obj2,String message)throws InterruptedException{ try{ Thread.sleep(200); Assert.assertNotEquals(obj1,obj2); Log.info(message+"不匹配的断言成功了"); }catch (Error e){ flag=false; Log.error(message+"不匹配的断言失败了"); } } }
3.PathUtils 类
PathUtils 在 util 目录,辨识项目是存在本地 window 还是服务器 linux 系统。
public class Pathutils { public static String getProjectRootPath(String windowsProjectPath, String linuxProjectPath) { if (System.getProperty("os.name").contains("Windows")) { return windowsProjectPath; } else { return linuxProjectPath; } } }
4.ReadProperties 类
ReadProperties 在 util 目录类,用于读取配置文件信息.
public class ReadProperties { private String filePath; private Properties properties; /** 构造方法 创建对象时自动返回pro对象 在new该对象的时候会自动加载readProperties()方法 */public ReadProperties(String filePath){ this.filePath = filePath; //在new该对象的时候会自动加载readProperties()方法this.properties = readProperties(); } /** 返回已经加载properties文件的pro对象 */public Properties readProperties(){ //创建对象 Properties pro = new Properties(); //读取properties文件到缓存try { BufferedInputStream in = new BufferedInputStream(new FileInputStream(filePath)); //加载缓存到pro对象 pro.load(in); } catch (Exception e) { e.printStackTrace(); } //返回pro对象return pro; } /** 使用全局的properties对象获取key对应的value值 @return * */public String getValue(String key){ return properties.getProperty(key); } }
6.RootPathUtil 类
RootPathUtil 在 util 目录类,用于封装window系统环境和linux系统环境的配置文件路径和日志路径。
public class RootPathUtil { public static String getDataPath(){ String window = "D:\\evsmc\\rbtp-web\\configs\\testData.properties"; String linux = "/var/lib/jenkins/workspace/rbtp-web-ui-test/configs/testData.properties"; String rootPath=Pathutils.getProjectRootPath(window, linux); return rootPath; } public static String getLogPath(){ String window1 = "D:\\evsmc\\rbtp-web\\configs\\log4j.properties"; String linux1 = "/var/lib/jenkins/workspace/rbtp-web-ui-test/configs/log4j.properties"; String logPath=Pathutils.getProjectRootPath(window1, linux1); return logPath; } }
八、configs目录
Configs 目录目前配置了 log4j.properties 和 testData.properties。其中 log4j.properties 设置日志打印配置, testData.properties 设置测试数据信息配置。
## log4j.properties log4j.rootLogger = info,stdout,FILE org.apache.log4j.DailyRollingFileAppender log4j.appender.stdout = org.apache.log4j.ConsoleAppender log4j.appender.stdout.Target = System.out log4j.appender.stdout.layout = org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern =%d{yyyy-MM-dd HH:mm:ss.sss} - [ %p ] %m%n log4j.appender.FILE = org.apache.log4j.DailyRollingFileAppender log4j.appender.FILE.File = logs/log4j.log log4j.appender.FILE.Append = true log4j.appender.FILE.Threshold = info log4j.appender.FILE.layout = org.apache.log4j.PatternLayout log4j.appender.FILE.layout.ConversionPattern =%d{yyyy-MM-dd HH:mm:ss.sss} - [ %p ] %m%n log4j.appender.dailyRollingFile = org.apache.log4j.DailyRollingFileAppender log4j.appender.dailyRollingFile.File = logs/log4j.log log4j.appender.dailyRollingFile.Append = true log4j.appender.dailyRollingFile.Threshold = info log4j.appender.dailyRollingFile.layout = org.apache.log4j.PatternLayout log4j.appender.dailyRollingFile.layout.ConversionPattern =%d{yyyy-MM-dd HH:mm:ss} - [ %p ] %m%n
测试数据文件配置
##testData.properties buyer=15992667873>feihong##### buyer2=14585852323>abc123 seller=14500007788>abc123 wronguser=15992667873>abc456 url=http://xx.xx.x.xxx/home/home .... .... .... ....
九、DriverBase 类
DriverBase 类在 base 目录,单例模式实现,用于传递 WebDriver,设置系统变量,测试的浏览器,并定 义常用的浏览器操作方法,比如浏览器打开,关闭,后退,刷新,前进。另外还封装了各 page 的方法调用,注意,如新增的 page 类不在这里进行封装,则在业 务类无法给 driverBase 进行调用 page 的方法。
public class DriverBase { /*** * 读取配置文件并获取测试数据 * @return */ String rootPath = RootPathUtil.getDataPath(); ReadProperties properties = new ReadProperties(rootPath); String url = properties.getValue("url"); /*** * 用来传递WebDriver * @return */private static WebDriver driver; public static WebDriver driver() { return driver; } /*** * 设置系统变量,并设置chromedriver的路径为系统属性值 * @return */ public DriverBase() { driver = DriverUtil.getDriver(); driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS);//隐式等待10秒 driver.manage().deleteAllCookies(); PageFactory.initElements(driver, this); } /** * 打开浏览器 */ public void open() throws InterruptedException { driver.get(url); Log.info("打开xxx首页"); Thread.sleep(500); } /** * 关闭浏览器 */ public void close() throws InterruptedException { driver.quit(); Log.info("退出浏览器"); } /** * 后退浏览器 */ public void back() throws InterruptedException { driver.navigate().back(); Thread.sleep(1000); Log.info("返回上一级页面"); } /** * 浏览器刷新 */ public void refresh() throws InterruptedException { driver.navigate().refresh(); Log.info("刷新页面"); Thread.sleep(1000); } public void getScreen(String filepath) { WebDriver augmentedDriver = new Augmenter().augment(driver()); TakesScreenshot ts = (TakesScreenshot) augmentedDriver; File screenShotFile = ts.getScreenshotAs(OutputType.FILE); try { FileUtils.copyFile(screenShotFile, new File(filepath)); } catch (IOException e) { e.printStackTrace(); } } /** * 切换到最新的标签页面 */ public void switchNewPage() { String currentHandle = driver.getWindowHandle(); for (String handles : driver.getWindowHandles()) { if (handles.equals(currentHandle)) continue; driver.switchTo().window(handles); } Log.info("切换最新页面成功!"); } public LoginPage loginPage() { LoginPage loginPage = new LoginPage(); return loginPage; } public HomePage homePage() { HomePage homePage = new HomePage(); return homePage; }
十、element 目录
所有 element 类都存放在 element 目录,以 LoginElement 类为例,主要是封装了登录页的元素定义,调用元素的操作方法的接口,给页面类继承使用。
public interface LoginElement { /** * 获取界面元素 * */String tel_input ="//input[contains(@placeholder,'请输入手机号')]"; String password_input ="//input[contains(@placeholder,'请输入密码')]"; String login_button ="//*[@id=\"__layout\"]/div/div[2]/div/div/div[2]/form/div[6]/div/button"; String accout_Button ="//span[contains(text(),'进入账号中心')]"; String login_wrong_msg ="//div[contains(text(),'账号或密码错误')]"; void enterBuyerTel(); void enterBuyer2Tel(); void enterSellerTel(); void enterWrongTel(); void enterBuyerPassword() ; void enterSellerPassword() ; void sellerLogin() ; void buyerLogin() ; void buyer2Login() ; void clickLoginButtonRight() ; void clickLoginButtonWrong() ; }
十一、page目录
所有 page 类都存放在 page 目录,以 LoginPage 类为例,主要是继承了元素接口类的元素和操作方法并实现,以及对操作结果的断言判断。
public class LoginPage implements LoginElement { /** * 读取配置文件获取测试数据 * */ String rootPath = RootPathUtil.getDataPath(); ReadProperties properties = new ReadProperties(rootPath); String buyer = properties.getValue("buyer"); String buyer2 = properties.getValue("buyer2"); String seller = properties.getValue("seller"); String wrong_user=properties.getValue("wronguser"); /** * 手机号输入框 */ @FindBy(xpath=tel_input) @CacheLookup WebElement telphoneInput; /** * 密码输入框 */ @FindBy(xpath=password_input) @CacheLookup WebElement passwordInput; /** * 登录按钮 */ @FindBy(xpath=login_button) @CacheLookup WebElement loginButton; /** * 首页的进入账号按钮 */ @FindBy(xpath=accout_Button) @CacheLookup WebElement enter_accout_Button; /** * 登录错误提示 */ @FindBy(xpath=login_wrong_msg) @CacheLookup WebElement wrong_msg; public LoginPage(){ PageFactory.initElements(DriverBase.driver(), this); } /** * 输入正确的买家的手机号 * */ @SneakyThrows public void enterBuyerTel() { telphoneInput.clear(); Thread.sleep(500); telphoneInput.sendKeys(buyer.split(">")[0]); Log.info("输入正确买家的手机号"); } /** * 输入正确的买家14585852323的手机号 * */ public void enterBuyer2Tel() { telphoneInput.clear(); telphoneInput.sendKeys(buyer2.split(">")[0]); Log.info("输入正确买家14585852323的手机号"); } /** * 输入正确的卖家的手机号 * */ public void enterSellerTel() { telphoneInput.clear(); telphoneInput.sendKeys(seller.split(">")[0]); Log.info("输入正确的卖家手机号"); } /** * 输入错误的手机号 * */ public void enterWrongTel() { telphoneInput.clear(); telphoneInput.sendKeys(wrong_user.split(">")[0]); Log.info("输入错误的手机号"); } /** * 输入买家的密码 * */ @SneakyThrows public void enterBuyerPassword() { passwordInput.clear(); passwordInput.sendKeys(buyer.split(">")[1]); Log.info("输入买家的密码"); } /** * 输入买家14585852323的密码 * */ @SneakyThrows public void enterBuyer2Password() { passwordInput.clear(); passwordInput.sendKeys(buyer2.split(">")[1]); Log.info("输入买家的密码"); } /** * 输入卖家的密码 * */ @SneakyThrows public void enterSellerPassword() { passwordInput.clear(); passwordInput.sendKeys(seller.split(">")[1]); Log.info("输入买家的密码"); } /** * 成功登录买家 * */ @SneakyThrows public void sellerLogin() { telphoneInput.clear(); Thread.sleep(500); telphoneInput.sendKeys(seller.split(">")[0]); Log.info("输入正确的卖家手机号"); passwordInput.clear(); passwordInput.sendKeys(seller.split(">")[1]); Log.info("输入买家的密码"); loginButton.click(); Log.info("点击登录"); String text=enter_accout_Button.getText(); Assertion.verifyAssertNotNull(text,"【点击登录按钮】"); } /** * 成功登录买家 * */ @SneakyThrows public void buyerLogin() { telphoneInput.clear(); Thread.sleep(500); telphoneInput.sendKeys(buyer.split(">")[0]); Log.info("输入正确买家的手机号"); passwordInput.clear(); passwordInput.sendKeys(buyer.split(">")[1]); Log.info("输入买家的密码"); loginButton.click(); Log.info("点击登录"); String text=enter_accout_Button.getText(); Assertion.verifyAssertNotNull(text,"【点击登录按钮】"); } /** * 成功登录买家 * */ @SneakyThrows public void buyer2Login() { telphoneInput.clear(); telphoneInput.sendKeys(buyer2.split(">")[0]); Log.info("输入正确买家2的手机号"); passwordInput.clear(); passwordInput.sendKeys(buyer2.split(">")[1]); Log.info("输入买家2的密码"); loginButton.click(); Log.info("点击登录"); String text=enter_accout_Button.getText(); Assertion.verifyAssertNotNull(text,"【点击登录按钮】"); } /** * 成功登录 * */ @SneakyThrows public void clickLoginButtonRight() { loginButton.click(); String text=enter_accout_Button.getText(); Assertion.verifyAssertNotNull(text,"【点击登录按钮】"); } /** * 失败登录 * */ @SneakyThrows public void clickLoginButtonWrong() { loginButton.click(); String msg=wrong_msg.getText(); Assertion.verifyAssert(msg,"账号或密码错误。","错误登录"); } }
十二、operation 目录
所有业务操作类都存放在 operarion 目录,以 LoginOperarion 类为例,主要是封装了 登录页面的业务操作场景,包括成功的买家登录,卖家登录,失败的买家登录等。
public class LoginOperation { final static Log log= Log.getlogger(LoginOperation.class); DriverBase driverBase = new DriverBase(); public static LoginOperation getInstance() { LoginOperation d=new LoginOperation(); return d; } public void buyerRightLogin() throws InterruptedException{ log.startTestCase("成功登录买家15992667873账号"); driverBase.open(); driverBase.homePage().clickLoginLinkText(); driverBase.loginPage().buyerLogin(); driverBase.close(); log.endTestCase("成功登录买家15992667873账号"); } public void buyer2RightLogin() throws InterruptedException{ log.startTestCase("成功登录买家14585852323账号"); driverBase.open(); driverBase.homePage().clickLoginLinkText(); driverBase.loginPage().buyer2Login(); driverBase.close(); log.endTestCase("成功登录买家14585852323账号"); } public void sellerRightLogin() throws InterruptedException{ log.startTestCase("成功登录卖家14500007788账号"); driverBase.open(); driverBase.homePage().clickLoginLinkText(); driverBase.loginPage().sellerLogin(); driverBase.close(); log.endTestCase("成功登录卖家14500007788账号"); } public void wrong_Login() throws InterruptedException{ log.startTestCase("失败登录"); driverBase.open(); driverBase.homePage().clickLoginLinkText(); driverBase.loginPage().enterWrongTel(); driverBase.loginPage().enterBuyerPassword(); driverBase.loginPage().clickLoginButtonWrong(); driverBase.close(); log.endTestCase("失败登录"); } }
十三、testcase 目录
所有测试用例类都存放在 testcase 目录,以 LoginTest 类为例,主要是封装了登录的 测试用例,包括成功的买家登录,卖家登录,失败的买家登录等。LoginTest 类
@Epic("登录页面测试") public class LoginTest { /** * 买家登录 */@Test@Description("成功的买家登录测试") @TmsLink("TestLogin_1") public void BuyerRightLogin() throws InterruptedException { LoginOperation.getInstance().buyerRightLogin(); } /** * 卖家登录 */ @Test @Description("卖家登录测试") @TmsLink("TestLogin_2") public void SellerRightLogin() throws InterruptedException { LoginOperation.getInstance().sellerRightLogin(); } /** * 错误登录 */ // @Test public void WrongLogin() throws InterruptedException { LoginOperation.getInstance().wrong_Login(); } }
十四、testng.xml
testng.xml 文件主要用以管理测试用例。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <suite name="All Test Suite"> <test verbose="2" preserve-order="true" name="rbtp-web-ui-test"><classes> <class name="testcase.LoginTest"></class><class name="testcase.HomePageTest"></class><class name="testcase.AddBookingTest"></class><class name="testcase.AccountCenterTest"> </class> <class name="testcase.BuyerCenterTest"></class><class name="testcase.BuyerPurchaseListTest"></class><class name="testcase.BuyerMyCollectTest"></class><class name="testcase.GoodsDetailTest"></class><class name="testcase.GoodsListTest"></class><class name="testcase.MessageCenterTest"></class><class name="testcase.OrderSuccessTest"></class><class name="testcase.PurchaseDetailTest"></class><class name="testcase.PurchaseListTest"></class><class name="testcase.SellerCenterTest"></class><class name="testcase.SellerOrderTest"></class><class name="testcase.SellerGoodsTest"></class><class name="testcase.SellerPurchaseCollectTest"></class><class name="testcase.ShopDetailTest"></class><class name="testcase.MyCartTest"></class></classes></test> </suite>
十五、整体项目完成如图

十六、上传代码到gitlab

十七、jenkins拉取代码自动运行测试

十八、Allure测试报告展示

十九、邮件通知

二十、总结
各页面也是从元素层,到页面层,再到业务层,最后到用例层逐步实现。整体架构也实现了数据与代码分离,另外可维护变动的基本只限于元素层上,其他上变动不大,整体代码的阅读性比较好,很容易就能找到对应的页面对应的元素和操作方法。
由于篇幅关系,本文这里没有讲解如何上传到代码到gitlab,并通过jenkins来拉取代码打包部署运行测试,后续有空可以再开一篇文来讲解linux这块的环境的配置+gitlab代码仓库的管理+jenkins的部署运行+Allure测试报告展示。