Selenium 我所喜欢的 PageModel

lyu · 2016年05月20日 · 最后由 丫丫 回复于 2017年03月28日 · 3318 次阅读
本帖已被设为精华帖!

背景

起初知道 PageModel,我是看了这篇文章

个人很喜欢这种思路——不管是 APP 还是 Web 的框架,对于产品都有足够立体化的封装。一个长期维护同一产品的测试&测试开发工程师会比较喜欢这种模式。在这篇文章中我不会谈关键字驱动和数据维护,只说一下我觉得可以怎样去利用关键字和数据愉快的执行 UI 自动化用例。在此我以 Webdriver + java 为例子,App 的原理也一样,其他编程语言的原理也差不多,重要的是思路。


具体细节

首先我们要封装一个页面,这是最基本的步骤。
BasePage.java

public class BasePage {
    WebDriver driver; 

    public BasePage() {
        //默认构造方法
    }

    /**
     * 
     * @param driver
     *            带driver的构造方法
     */
    public BasePage(WebDriver driver) {

        this.driver = driver;
    }

    /**
     * 
     * @param driver
     *   初始化页面 - 利用反射
     *  *此处为了简单易懂,就用allRequiredData代替所有需要的数据。数据是怎么来的我们不讨论,反正能得到就行了。
     */
    public void initPage(Object allRequiredData) {
        //获取当前的类
        Class clazz = this.getClass();
        List<Field> fList = new ArrayList<Field>();
        //由于具体的页面之间可能还有继承关系,所以先拿到所有的Fields,直到父类为Object ,多取一点也不要钱
        for (; clazz != Object.class; clazz = clazz.getSuperclass()) {
            ConvertUtils.appendArrayToList(fList, clazz.getDeclaredFields());
        }
        //这里埋下伏笔,总之我取到了我当前操作所需要初始化的所有fileds
        List<String> workingElements = Reflections.getUsedFiledsName(
                this.getClass(), allRequiredData.keyword);

        final Field[] f = fList.toArray(new Field[fList.size()]);

        for (int i = 0; i < f.length; i++) {
            //如果在所有fileds的列表中,发现当前遍历到的并不是我所需要的,就不执行初始化
            if (!workingElements.contains(f[i].getName())) {
                    continue;
                }

            //这里就执行元素的初始化,省略一些代码细节
            WebElement element = new WebDriverWait(driver, 10)
                            .until(new ExpectedCondition<WebElement>() {
                                @Override
                                public WebElement apply(WebDriver d) {
                                    //此处可以随意扩展用xpath或是其他方式初始化。由于我只用xpath,就不写了。
                                    try {
                                        WebElement e = d.findElement(By
                                                .xpath(allRequiredData.xpath));
                                        return e;
                                    } catch (TimeoutException e) {
                                        logger.error("某元素未被找到 "
                                                + e.getMessage());
                                        return null;
                                    } catch (NoSuchElementException e) {
                                        // DO NOTHING. 继续循环
                                        return null;
                                    }
                                }

                            });
            //....此处省略具体的细节

            //我们利用反射,将当前成功初始化而得到的WebElement的对象赋值,交付给当前的页面
            Reflections.setFieldValue(this, f[i].getName(),
                                    element);
        }
    }
}
//接下来还可以在当前基类中定义一些公共的方法,让客户端不经过初始化就可以直接调用。这些方法直接通过driver操作
//比如ClickByxpath,byName等
public void commonMethod1() {
    driver.click(By.xpath("//xxxxxx"));
    //..省略具体细节
}
public void commonMethod2() {
    //..省略具体细节
}
}

页面的基类就算定义完成了,接下来写一个子类的例子,看一下运作方式。

SubPage.java

public class SubPage extends BasePage {

    public SubPage(WebDriver driver) throws Exception {
        super();
        this.driver = driver;
    }

    //一系列WebElement
    WebElement element1;
    WebElement element2;
    WebElement element3;

    //一系列页面具体方法
    @ContainsElements(values = {"element1"})
    public void click_element1() {
        element1.click();
    }

    //..省略具体方法若干

}

子页面就是如此简洁,只要你写好注释,很容易维护。要注意的是@ContainsElements(values = {"element1"}) 中 ContainsElements 是一个自定义注解,这个注解在初始化的时候告诉初始化方法,我的这次执行要用到哪些元素(上面埋伏笔的地方 Reflections.getUsedFiledsName(this.getClass(),allRequiredData.keyword))。想象一下如果每次初始化都涉及全部元素而不去指定要用的元素,那么当一个页面高度封装后,随便做一个简单的点击操作就要让 WebDriver 去加载上百个元素,那你这个自动化也就别做了,没什么意思。

这样一来,PageModel 的最重要部分——Page 基本上就做好了。我们还要解决 driver 的启动问题。没错,我们首先需要一个接口来定义规则。试想一下,在生产环境中,我们的 driver 可能会是 FirefoxDriver, ChromeDriver,IEDriver,AndroidDriver,iOSDriver.如果没有一个接口统一定义规范,那就不能愉快的创建 driver 了。所以:

Resolver.java

public interface Resolver {

    //由于我使用的是Selenium Grid,所以连hub就Ok了
    public void connectHub();
    //当然啦,Resolver最重要的是要给我们创建好的driver
    public WebDriver getDriver();
}

随后就要定义浏览器实现

ChromeResolver.java

//这里是自定义注释,我用了一些工厂方法方便扩展,读者可以忽略
@BrowserCode(value = "2")
public class ChromeResolver implements Resolver{
    WebDriver driver;

    @Override
    public void connectHub() {
        DesiredCapabilities dc = DesiredCapabilities.chrome();
        dc.setBrowserName("chrome");
        dc.setPlatform(Platform.WINDOWS);
        //你可以做一些你想做的全局的事情,比如在运行的同时去抓取Server端的js错误等。你能想得到的都可以做.
        LoggingPreferences loggingprefs = new LoggingPreferences();
        loggingprefs.enable(LogType.BROWSER, Level.SEVERE);
        dc.setCapability(CapabilityType.LOGGING_PREFS, loggingprefs);   
        try {
            URL url = new URL(BrowserFactory.getHub(osNum));
            try { 
                driver = new RemoteWebDriver(url, dc);
            } catch(Exception e) {
                LogUtils.postContentAsFile(Exceptions.getStackTraceAsString(e));
            } catch(Throwable t) {
                LogUtils.postContentAsFile(Exceptions.getStackTraceAsString((Exception) t));
            }

            //可以加隐式等待,也可以不加,全看你的业务需求
            driver.manage().timeouts().implicitlyWait(2, TimeUnit.SECONDS);


        } catch (MalformedURLException e) {
            log.error("Chrome创建hub URL出错 " + e.getMessage());
            e.printStackTrace();
        }

    }

    @Override
    public WebDriver getDriver() {
        return driver;
    }
}

其他的浏览器也是类似的。这里我省略了一些业务逻辑没贴出来,比如你不同的操作系统跑不同的浏览器这些。总之,driver 到手了。接下来就是执行者了:

MainRunner.java

public class MainRunner implements Runnable {
    //同样,用一个对象代替所有的数据,不管他是怎么来的
    Object AllData;

    WebDriver driver;

    //省略构造方法


    @Override
    public void run() {
        Resolver r = null;
        //这里是浏览器工厂。总之是得到了对应的Resolver,比如ChromeResolver的实体
        r = BrowserFactory.getInstance()
                        .getBrowserResolver(os, browser);
        //链接hub
        r.connectHub();

        //得到driver
        driver = r.getDriver();

        //最初的操作
        driver.get(AllData.startUrl);
        driver.manage().window().maximize();

        //得到我们要用的步骤,其实就是数据列表了,json也好,xml也好, excel也好。 反正弄成个List了.
        RuntimeStepList runtimeStepList = AllData.runtimeStepList;

        for (i = 0; i < runtimeStepList.size(); i++) {

            //前后省略由于业务需要的各种细节操作,就执行这个步骤

            performSingleStep(runtimeStepList.get(i));
        }

        //后续处理,省略...
    }

    //执行的具体逻辑
    private Object performSingleStep(RunTimeStep current)
            throws ClassNotFoundException, NoSuchMethodException,
            SecurityException, InstantiationException, IllegalAccessException,
            IllegalArgumentException, InvocationTargetException, ValidationException {

        // 反射需要类全名,给个包前缀
        String packageName = "com.xxx.xxx.xxx";

        // 1.获取具体Page类
        Class page = Class.forName(packageName + current.getPageObject());

        // 2.调用具体类构造函数创建实例,并调用父类的初始化方法
        Constructor con = page.getConstructor(WebDriver.class);
        //o也就是当前页面的实体,通过反射创建
        Object o = con.newInstance(driver);
        Integer pid = current.getPageObjectId();

        // 如果是调用公共方法,则不用初始化任何page.页面方法才初始化
        if (!current.getPageObject().equals("BasePage")) {
            //这里就是通过反射去调用刚才的初始化方法了,参数太多,反正就是用数据去初始化Page.
            Reflections.invokeMethod(o, "initPage", new Class[] {
                    Integer.class, XPathService.class, String.class, String.class, Boolean.class},
                    new Object[] { pid, xps, current.getKeyword(), judgeSite() , isMobile});
        }

        // 3.反射调用具体方法
        String keyword = current.getKeyword();
        String paramString = current.getParameters();

        //处理参数
        Object[] parameters = handleParameter(paramString);
        boolean np = current.getParameters().equals("");

        //根据关键字去调用具体的方法,也就是上面的click_element1等。
        Object result = Reflections.invokeMethod(o, keyword, composeParamArray(np ? 0
                : parameters.length), np ? null : parameters);

        //这下面的不用在意了,是某些对结果的处理
        // 4.作单步结果处理
        current.setPass(1);
        rtss.save(current);

        //5.若runtimeStep的ExtraFlag为1, 则截图并保存
        if(current.getExtraFlag() == 1) {
            byte[] sByte = ((TakesScreenshot) driver)
                .getScreenshotAs(OutputType.BYTES);
            rtss.savePic(current, sByte); 
        }

        return result;
    }
}

你们就将就看一下吧

我的 PageModel 的核心框架也就这样了。最近硬是把移动端和 PC 端的执行全部揉到一起了,揉的时候才发现以前写的东西扩展性太一般。这个轮子肯定有不少的缺点,毕竟编程能力有限。大家愉快地看一下就好,如果你能从中有点收获,也请写出来让大家都体会一下。共同进步!

共收到 35 条回复 时间 点赞

我喜欢用 pagefactory + annotation

代码的设计思路比较正统,体现了抽象和工厂的灵活性,作为同行先点个赞。
我的疑问是,UI 自动化设计成单例模式比较好,即便是分布式执行不同浏览器或 os 的脚本,每个执行机跑一个单例也比较稳定。
MainRunner 放在线程里面我有点不解,相互讨论也是成长的过程,所以想听听你的设计初衷和思路

楼主,我就想看看数据怎么来的

#3 楼 @lihuazhang 额 我也想知道。 我还想知道什么时候调用的 initpage 什么时候解析的@ContainsElements。代码不全不是很明白

#1 楼 @lihuazhang 你是指org.openqa.selenium.support.PageFactory静态工厂模式和org.openqa.selenium.support.FindBy注解吗?这个确实方便,我也喜欢用。

public class SomePage {
    @FindBy(id="some_id") private WebElement someElement1;
    @FindBy(name="some_name") private WebElement someElement2;
    ......
    public boolean doSomething() {
        return someElementx.blah();
    }
    ......
}

从一个 PageObject 到另一个 PageObject 只需要

PageFactory.initElements(driver, page);
lyu #6 · 2016年05月23日 Author

#2 楼 @quqing 我想先听一下,如果不是单例,会造成怎样的不稳定?目前我还未遇到不稳定的情况,单机器并发 Case 暂时还没出过问题。

lyu #7 · 2016年05月23日 Author

#3 楼 @lihuazhang 数据全在 DB,用 Web 界面维护的。数据这一块我这也没什么新意,放 DB 也好,xml 也好,excel 也好,最终都是拿来用,而我在存储,组织或者提取数据方面都是中规中矩的东西,也没什么设计,最多做点类似项目全局变量的玩意儿增加一下数据的复用。比如我要执行用例 A,就根据 A 的 id 去 mysql 数据库取他的页面模型 (Homepage),操作关键字 (click_button),取参数(由于我们是多语言的,所以某些时候取参数要过一下语言包)。提取后封装,然后拿给 MainRunner 用就行。用 Web 界面有一定好处,比如封装 Page 后,可以直观的在前端展示有哪些页面,做一个可以过滤筛选的下拉;又比如可以顺便展示测试报告,截图或者统计等。

lyu #8 · 2016年05月23日 Author

#4 楼 @ycwdaaaa

// 如果是调用公共方法,则不用初始化任何page.页面方法才初始化
      if (!current.getPageObject().equals("BasePage")) {
          //这里就是通过反射去调用刚才的初始化方法了,参数太多,反正就是用数据去初始化Page.
          Reflections.invokeMethod(o, "initPage", new Class[] {
                  Integer.class, XPathService.class, String.class, String.class, Boolean.class},
                  new Object[] { pid, xps, current.getKeyword(), judgeSite() , isMobile});
      }

initPage 上面有提到,在运行的时候会根据传入的页面模型 ID 去初始化。

//这里埋下伏笔,总之我取到了我当前操作所需要初始化的所有fileds
        List<String> workingElements = Reflections.getUsedFiledsName(
                this.getClass(), allRequiredData.keyword);

getUsedFiledsNames 上面也有提到。就是取 ContainsElements 的列表的。

getUsedFiledsNames 我没写出来,也就是简单的用注解取一个元素字符串的 List。 这个方法大概是这样:

public static List<String> getUsedFiledsName(Class clazz, String methodName) {

        List<String> elements = new ArrayList<String>();

        Method[] methods = clazz.getMethods();

        for(int i=0;  i < methods.length; i++) {
            if(methods[i].getName().equals(methodName)) {
                ContainsElements ce = methods[i].getAnnotation(ContainsElements.class);

                if(ce == null) {
                    break;
                }

                String[] es = ce.values();

                for(int j = 0; j < es.length; j++) {
                    elements.add(es[j]);
                }
            }
        }
        return elements;
    }
}
lyu #9 · 2016年05月23日 Author

#5 楼 @watman Pagefactory+findBy 我也考虑过,挺方便适用,但是用注解描述查找路径只能写死在代码里,维护的时候可能会困难一些。

#6 楼 @jet app 是不影响,web 也不影响吗,因为多个实例打开多个窗口,取窗口句柄的场景不好取,你如何实现的?

lyu #35 · 2016年05月23日 Author

#10 楼 @quqing 多个实例多个 driver 哦

lyu #11 · 2016年05月23日 Author

#10 楼 @quqing 相当于一个实例对应 Grid 中一个 node. 比如你启动 40 个 node,互相之间是完全没瓜葛的。

#11 楼 @jet 每个 driver 可能会打开多个浏览器窗口,你如何获取?

lyu #22 · 2016年05月23日 Author

#13 楼 @quqing 我觉得这是两个问题。首先一次执行,启动一个 node,相当于一个 WebDriver A,那么这个 driver A 肯定是独立的,再启动一个 driver B 不会对 A 构成影响; 其次,你说的一个 driver 打开多个浏览器窗口,取 set 遍历到你想要的就行了哇
Set set = driver.getWindowHandles();

#14 楼 @jet 单个 driver 获取窗口句柄简单,多个就不是那回事了。。。
还有,你的意思一个线程对应一个 node?

lyu #20 · 2016年05月23日 Author

#15 楼 @quqing 你例举一个业务场景来说说看吧。 我是一个线程对应一个 node。我觉得什么合适什么不合适还是看具体的业务场景和公司产品的情况吧。

#16 楼 @jet appiumdriver 用过多线程的,webdriver 还没用过多线程,web 兼容性测试也是每个 node 对应一台执行机,跑一个实例,可能你的业务场景和我接触的不一样吧,我也只是好奇问问

lyu #18 · 2016年05月23日 Author

#17 楼 @quqing 你一共有多少台机器。

每种浏览器一台机器

lyu #19 · 2016年05月23日 Author

#19 楼 @quqing 嗯,很合理。我手里就一台机器。。我也没办法。 那你如果要在 Firefox 上并发多个任务,怎么弄?

#20 楼 @jet 我是这样考虑的,如果起 2 个线程,线程 A 和线程 B,每个线程都起了个 Firefox 的实例并运行 case,如果遇到这种场景:
driverA 打开了 N 个浏览器窗口,driverB 也打开了 M 个窗口,并且这 N+M 个窗口可能存在重复的,在线程内如何判断自己想要的一个或某几个窗口?

lyu #14 · 2016年05月23日 Author

#21 楼 @quqing 兄弟,你可以写份简单的代码,我看一下。比如像如下这种代码,启动 N 个 driver 后,必定互相不干涉。你说的那种 driverA 打开 N 个浏览器窗口是什么场景,我不太理解,给点代码看看吧

public class Runner implements Runnable{

    @Override
    public void run() {
        System.setProperty("webdriver.chrome.driver", "D:\\driver\\chromedriver.exe");

        WebDriver driver = new ChromeDriver();

        driver.get("http://www.baidu.com");

        String searchBox = "//input[@id='kw']";

        driver.findElement(By.xpath(searchBox)).sendKeys("这是我要搜索的内容");


    }


    public static void main(String[] args) {
        Runner r = new Runner();

        for(int i=0; i< 5; i ++) {
            new Thread(r).start();
        }
    }

#22 楼 @jet 不需要代码实现,web 页面跳转有一种是在新窗口中打开,表单属性 target="_blank",这种情况可能有以下需求:
1.进入指定窗口;
2.只保留某个窗口,关闭其他窗口;
3.判断失效的窗口句柄;

lyu #23 · 2016年05月23日 Author

#23 楼 @quqing 你看下是不是这意思。如果是,你可以用我这个代码去测试下。反正无论并发多少都不会出一点点问题。

public class Runner implements Runnable {

    /**
     * 
     * @param driver
     * @param windowName
     * 
     * 根据窗口名称切换窗口
     */
    public void SwitchTo(WebDriver driver, String windowName) {

        String currentHandle = driver.getWindowHandle();
        Set<String> set = driver.getWindowHandles();
        for (String s : set) {
            if (currentHandle.equals(s)) {
                continue;
            }
            driver.switchTo().window(s);
            if (driver.getTitle().equals(windowName)) {
                break;
            }
        }
    }

    @Override
    public void run() {
        System.setProperty("webdriver.chrome.driver",
                "D:\\driver\\chromedriver.exe");
        WebDriver driver = new ChromeDriver();

        /**
         * 访问百度,搜索百度
         */
        driver.get("https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&tn=baidu&wd=baidu&oq=target%3D%26quot%3B_blank%26quot%3B&rsv_pq=93fced2300007201&rsv_t=834bm%2B0f8imPuN4KHbbM%2B0TSEsXx%2B6VbKEccPsk9XSbHOF6PefPDYbGodZA&rqlang=cn&rsv_enter=0&inputT=1018&rsv_sug3=19&rsv_sug1=17&rsv_sug7=100&rsv_sug2=0&rsv_sug4=1018");
        String newWindow = "//em[text()='百度']"; // 这是一个新窗口跳转 target='_blank'



        //保证稳定性,人工加一些等待 2s
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            //什么都不做
        }

        /**
         * 打开另一个百度
         */
        driver.findElement(By.xpath(newWindow)).click();


        //保证稳定性,人工加一些等待 2s
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    //什么都不做
                }

        /**
         * 在第一个百度窗口点击Logo,也回到了首页。现在当前driver有两个完全一样的百度窗口在运行。
         */
        driver.findElement(By.xpath("//a[@id='result_logo']")).click();

        /**
         * 百度首页的title
         */
        String title = "百度一下,你就知道";
        /**
         * 输入框
         */
        String input_xpath = "//input[@id='kw']";



        //保证稳定性,人工加一些等待 2s
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    //什么都不做
                }


        String wHandler1 = driver.getWindowHandle();
        System.out.println("第一个窗口句柄 " + wHandler1);
        /**
         * 在当前窗口的输入框先输入"第一个窗口"
         */

        driver.findElement(By.xpath(input_xpath)).sendKeys("第一个窗口");


        //保证稳定性,人工加一些等待 2s
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    //什么都不做
                }

        /**
         * 切换到第二个窗口
         */
        SwitchTo(driver, "百度一下,你就知道");

        String wHandler2 = driver.getWindowHandle();
        System.out.println("第二个窗口句柄 " + wHandler1);


        //保证稳定性,人工加一些等待 2s
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    //什么都不做
                }

        /**
         * 在当前窗口的输入框先输入"第二个窗口"。这部分完了你可以去检查一下,是不是N个driver都执行正确。
         */
        driver.findElement(By.xpath(input_xpath)).sendKeys("第二个窗口");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            //什么都不做
        }

        /**
         * 切换回到第一个窗口
         */
        driver.switchTo().window(wHandler1);

        /**
         * 关闭第一个窗口
         */
        driver.close();

        /**
         * 切换到第二个窗口
         */
        driver.switchTo().window(wHandler2);

        driver.findElement(By.xpath(input_xpath)).sendKeys(" 你看看是不是只剩下我一个窗口了??");


    }

    public static void main(String[] args) {
        Runner r = new Runner();

        for (int i = 0; i < 5; i++) {
            new Thread(r).start();
        }
    }

#24 楼 @jet 我看了下你的代码可能处理不了以下场景的问题:
1.线程 A 和线程 B 的 driver 都要访问同一个窗口;
2.线程 A 要关闭窗口 3,线程 B 要使用窗口 3;
可能还有我曾经未遇到过的场景等等

lyu #26 · 2016年05月24日 Author

#25 楼 @quqing 你说的同一个窗口我不能理解,driverA 干嘛要去处理 driverB 启动的浏览器窗口?我觉得你最好就是写个简单的 demo,让我运行出问题,比这些文字表述容易交流的多。

#26 楼 @jet 对于 webdriver 来说多线程,复杂场景肯定存在这种问题,因为是在同一个桌面打开多个窗口,而且不同线程打开,每个线程对窗口的操作会出现冲突,虽然问题还是能解决的,代价比较大没必要,所以我后来做 webdriver 的 case,除非比较简单的固定场景,基本都用单例模式了。
我只是说下以前自己踩过的坑,如果你觉得这不是问题,那么听过算了。多线程编程,学问很大,我觉得自己太渺小了,目前的功力还吼不住太复杂的场景

#26 楼 @jet 再补充一下,不是 driverA 特意要处理 driverB 启动的浏览器窗口,而是各自的业务场景可能对某些窗口的使用有交集

#26 楼 @jet 纠正下我的观点,在线程内如果启动的都是远程不同 hub 的 remote driver 是 ok 的,但是在同一台机器上跑多个 driver 还是会出现如我说的冲突问题。之前讨论的都是假定在同一台机器跑多个实例的情况,有点以偏概全了。

很多人喜欢写框架,喜欢所谓配置和代码分离,事实上页面元素本来就应该和 PageObject 对应,这种对应关系越近,理解就越容易,而且修改代码有 ide 的 navigation,维护代码有 git 的 branch。就好比写个 orm 框架,数据库字段的配置还要存到数据库,大部分时候,真的好累

lyu #30 · 2016年06月17日 Author

#30 楼 @cosyman 祢衡击鼓

@jet 楼主,有完整的代码学习一下吗?

扫地僧 回复

您好,想咨询一个问题,我在用 appinumg 和 testng 做移动端的并发测试的时候,出现获取到的 session id 重复了导致其中一台手机停止运行了,需要如何解决呢?

丫丫 回复

多线程处理

扫地僧 回复

不是很明白?testng 不就是支持多线程吗?

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册