selenium 自动化监控实践

本贴最后更新于 1960 天前,其中的信息可能已经时过境迁

最近公司有一套页面需要使用 selenium 来监控,确保页面功能在频繁发布之后从使用上正常的。经过近两周的迭代,目前已经基本成形了,监控了近 30 个页面的近 60 个功能点。这里为这个项目做个总结。

业务梳理

接到任务的时候,业务方要求提供的报警信息是这样的:

页面 http://www.xx.com/xxx.html 访问异常,状态码:xxxx @所有人

一个资深的研发人员,接到需求,必须结合业务场景进行全面的分析与设计。针对这个需求,我觉得至少需要考虑以下几点:

1. 什么情况下才算正常?
2. 测试使用的信息是否有限制?尤其像手机号、邮箱这种,怎么规定测试的资源?
3. 这个报警信息充分么?是否有充足的信息方便问题诊断?
4. 一个页面频繁访问是否会对正常业务造成影响?

接下来,我们进行逐个分析:

测试通过标准

这个很重要,需要根据这个标准来设计程序,必须跟业务方针对功能点进行逐个确认。这里由于涉及到公司业务,就不深入展开,总体来说,使用了以下 4 种方式:

1. 访问URL成功
2. 表单提交,是否跳转至成功页面
3. 页面是否展示了成功元素
4. 页面某个元素是否已经不存在

测试资源限制

这个也需要跟业务方确认,因为有些页面必须用到手机号、邮箱、用户名、身份证号等业务数据。这些数据如果不与正常的业务加以区分,很有可能会涉及到短信的推送、客服的跟进等,会对业务产生负面的影响。测试用的手机号可以规定某一个号码或者某一个号段。前期我们使用某一个不常见的号段来进行区分,经过后续的业务评估,觉得小概率事件仍然存在。所以最终,还是选择了一个不太可能使用的手机号来进行测试,避免问题。

异常通知模板

一旦页面发现问题,究竟需要提供多少信息,这是个需要仔细考虑的问题。一个状态码肯定是不够的,程序也很难精确定义各种状态码。根据 Web 开发多年的经验来看,遇到一个页面问题,我们一般会需要去看这个页面当前展示的形式,也会去看控制台的输出,还会看网络传输的情况,还有页面的源码。 一般情况下,页面当前的截图、控制台输出、网络传输日志、页面源码、故障描述可以基本确定问题的原因。所以我们确定了异常通知模样如下:

监控页面: http://xxxxxx/xxx.html
监控功能: 抢红包
错误描述: 抢红包后,20秒内未跳转至成功页面/未提示失败!
截图链接: http://xxxxxxxxx/snapshot/32356b27f5.zip 
页面源码: http://xxxxxxxxx/source/1544090935036.txt 
详细日志: http://xxxxxxxxx/log/1544090935036.log 
监控耗时: 27522ms. @所有人 

注意到,截图是一个打包的形式,里面包含了多张图片。在监控逻辑执行过程中,每一帧的截图会保存下来,方便定位。当然也可以使用录屏的方式,但是开发起来会比较麻烦。后续会讲一下监控过程中截图是如何实现的。

监控速度控制

一开始我们没有对监控做速度上的控制,5 分钟跑一遍用例,给应用一天带来了将近 1w 多的监控数据。有部分业务处理的比较慢,导致一些这些数据没能及时处理,一直在处理测试数据。一些需要当天通知客户的短信、邮件也没能及时发出去,对业务产生了比较大的影响。最后,我们降低了监控速度,改成了 1 小时 1 次,降低了访问频率。

Java or Python

为什么会有这个困扰呢?因为我在之前公司做这方面的工作时,采用的是 python,开发起来效率比较高,但新公司的技术栈基本都是 Java。考虑到以下因素,最终采用了 Java 来操作:

1. 技术栈统一,可以多人协作开发,交流也比较顺畅。
2. Java本身的优势,拥有Spring Boot/Maven等强大的工具与库,开发效率并不逊色于Python。
3. 有IDEA,Java的重构也非常方便。
4. 考虑过用Go,但是觉得应用场景上Go并不太适合。

其实,无论哪种语言最终都可以实现页面监控的功能,所以必须根据团队的实际情况选择对应的技术栈。

集成 spring

Selenium 其实并不需要与 Spring 做集成,网上找的很多类似的文章,更多的是利用 SpringBootTest 来做测试集成。这个项目并没有利用 SpringBootTest 来做,而是基于 Quartz 定时任务来做的,主要还是因为需要定时去执行脚本。我这里说的与 Spring 集成,主要还是将 ChromeDriver 与 WebDriverWait 两个实例交给 Spring 来托管,以实现一些特殊功能,后面会阐述。核心的类主要包括两个 Proxy 与一个 Holder,如下:

public class ChromeDriverProxy extends ChromeDriver {

    private String uuid;
    private Boolean snapshotWhenPossible;
    private Boolean cleanSnapshotWhenQuit;

    private Logger logger = Logger.getLogger(ChromeDriverProxy.class);

    public ChromeDriverProxy(ChromeOptions options) {
        super(options);
        uuid = UUID.randomUUID().toString().replace("-", "").substring(22);
    }
    
    // ...
}
// 每一个用例需要新的wait与driver对象,所以scope必须是SCOPE_PROTOTYPE
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class WebDriverWaitProxy extends WebDriverWait {

    private static final Integer WAIT_TIMEOUT = 20;

    private ChromeDriverProxy driver;

    public WebDriverWaitProxy(ChromeDriverProxy driver) {
        super(driver, WAIT_TIMEOUT);
        this.driver = driver;
    }
    
    // ...
}

// 每一个用例需要新的wait与driver对象,所以scope必须是SCOPE_PROTOTYPE
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class WebDriverHolderProxy {

    @Autowired
    private WebDriverHolderProxy wait;


    public WebDriverHolderProxy getWait() {
        return wait;
    }

    public ChromeDriverProxy getDriver() {
        return wait.getDriver();
    }
}

注意到上面的 ChromeDriverProxy 没有加注解,是因为 ChromeDriverProxy 不能直接用 Spring 来创建,因为其构造方法需要传一个特定的 ChromeOptions,怎么办呢?Spring 还有使用 FactoryBean 来创建实例。下面是 ChromeDriverProxyFactoryBean:

@Component
public class ChromeDriverProxyFactoryBean implements FactoryBean<ChromeDriverProxy>, BeanPostProcessor {
    // chromeDriver默认延时
    public static final Integer DEFAULT_WAIT_TIMEOUT = 10;

    @Value("${webdriver.chrome.driver}")
    private String driverPath;

    @Value("${browser.maximize}")
    private Boolean shouldMaximize;

    @Value("${snapshot.when.possible}")
    private Boolean snapshotWhenPossible;

    private static ChromeOptions DEFAULT_OPTIONS = new ChromeOptions();

    static {
        LoggingPreferences preference = new LoggingPreferences();
        // 开始浏览器与性能日志
        preference.enable(LogType.BROWSER, Level.ALL);
        preference.enable(LogType.PERFORMANCE, Level.ALL);
        
        DEFAULT_OPTIONS.setCapability(CapabilityType.LOGGING_PREFS, preference);
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        //factoryBean初始化完成后,设置chrome.driver路径
        System.setProperty("webdriver.chrome.driver", driverPath);
        // 加上,否则无法截图
        System.setProperty("java.awt.headless", "false");
        return bean;
    }

    /**
     * 初始化一个chromeDriver,主要包括:窗口最大化、日志配置、默认超时时间
     * @return
     */
    @Override
    public ChromeDriverProxy getObject() throws Exception {
        ChromeDriverProxy driver = new ChromeDriverProxy(DEFAULT_OPTIONS);

        driver.setSnapshotWhenPossible(snapshotWhenPossible);

        driver.setLogLevel(Level.ALL);
        driver.manage().timeouts().implicitlyWait(DEFAULT_WAIT_TIMEOUT, TimeUnit.SECONDS);
        if(shouldMaximize) {
            driver.manage().window().maximize();
        }

        return driver;
    }

    @Override
    public Class<?> getObjectType() {
        return ChromeDriverProxy.class;
    }
    // 这里指定了Scope,每次引用需要单独创建
    @Override
    public boolean isSingleton() {
        return false;
    }
}

然后就是用 Quartz 执行定时任务:

@Component
public class MonitorTask {

    @Autowired
    private MonitorExecute monitorExecute;
    private static Logger logger = Logger.getLogger(MonitorTask.class);
    // 重试次数
    private static final Integer MONITOR_RETRY_LIMIT = 2;

    // 用例之间间隔
    @Value("60")
    private Integer waitSecondsAfterCase;

    /**
     * 每小时执行一次监控关键业务
     */
    @Scheduled(initialDelay = 10 * 1000L, fixedRateString= "3600000")
    public void monitorPage() {
        logger.info("monitorPage start.");
        long startTime = System.currentTimeMillis();

        for(Map.Entry<String, Class<? extends MonitorCase>> entry : MonitorUrls.ZXBJ_URL_CASE_MAP.entrySet()) {
            Class<? extends MonitorCase> executeClass = entry.getValue();
            String url = entry.getKey();

            try {
                // 执行测试用例,如果发现异常,则重试,重试次数达到阈值后仍然失败,则发送通知
                monitorExecute.doExecuteCase(executeClass, url, MONITOR_RETRY_LIMIT);

                TimeUnit.SECONDS.sleep(waitSecondsAfterCase);
            } catch (Exception e) {
                logger.error("url:" + url + ",executeClass:" + executeClass + " execute error!", e);
            }
        }

        logger.info("monitorPage end.cost:" + (System.currentTimeMillis() - startTime) / 1000 + "s.");
    }
}

/**
 * 用例执行服务
 * User: francis.xjl@qq.com
 * Create Time: 2018/12/5 13:57
 */
@Component
public class MonitorExecute {

    private static Logger logger = Logger.getLogger(MonitorExecute.class);

    private static final Integer SLEEP_SECONDS = 5;

    @Autowired
    private DingService dingService;

    @Autowired
    private ApplicationContext applicationContext;

    // 执行测试用例,如果发现异常,则重试,重试次数达到阈值后仍然失败,则发送通知
    public void doExecuteCase(Class<? extends MonitorCase> executeClass, String url, int retryLimit) {
        MonitorCase monitorCase = applicationContext.getBean(executeClass); // 这里必须prototype,否则driver会出问题
        MonitorResultMessage message = monitorCase.monitor(url);
        // 用例执行正常直接返回
        if(message.isSuccess()) {
            return;
        }

        retryLimit--;

        logger.warn(String.format("url:%s, retryLimit:%s failed. message:%s", url, retryLimit, message));
        // 重试次数达到阈值后仍然失败,则通知
        if(retryLimit <= 0) {
            dingService.notifyUrlUnusualWithSnapshot(message);
            return;
        }
        // 5秒后才重试
        try {
            TimeUnit.SECONDS.sleep(SLEEP_SECONDS);
        } catch (InterruptedException e) {}

        doExecuteCase(executeClass, url, retryLimit);
    }

}

开发测试用例的人员只要去实现 MonitorCase 就可以了,MonitorCase 接口与实现如下所示:

/**
 * 监控脚本的抽象接口
 * User: francis.xjl@qq.com
 * Create Time: 2018/12/3 9:59
 */
public interface MonitorCase {

    /**
     * 一个监控脚本的主要运行逻辑在这里面,监控的页面通过参数传入,以便一个脚本能够支持多个页面。
     * @param url 监控的URL
     */
    MonitorResultMessage monitor(String url);
}


@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class MonitorCaseDemo implements MonitorCase {

    private Logger logger = Logger.getLogger(MonitorCaseDemo.class);

    @Autowired
    private WebDriverHolderProxy holder;

    @Override
    public MonitorResultMessage monitor(String url) {
        String mobile = Mobiles.instanceTestMobile();
        ChromeDriverProxy driver = holder.getDriver();
        WebDriverWait wait = holder.getWait();
        MonitorResultMessage message = new MonitorResultMessage(url, "领红包");

        try {
            // ...操作
            isSuccess = ...;

            if(!isSuccess) {
                message.setErrMsg("XXX功能测试未成功");
                Browsers.saveSceneData(driver, message);
            } else {
                driver.setCleanSnapshotWhenQuit(true);
            }
            message.setSuccess(isSuccess);
        } catch (Exception e) {
            message.setException(e);
            Browsers.saveSceneData(driver, message);
        } finally {
            Browsers.quitQuietly(driver);
        }

        return message;
    }
}

以上就是全部的 Spring 与 Selenium 整合内容,已经达到了预期。要特别注意 ChromeDriver 每次都必须重新创建,否则测试之间会存在依赖,如果觉得 ChromeDriver 的频繁启动比较慢的话,可以使用 ChromeDriverService 来实现对 ChromeDriver 的启停。

截图的处理

上面说到过,我们希望在执行过程中截图,又不想在每段代码前后加入截图的逻辑,那怎么操作呢?有了上面 Spring 与 Selenium 整合的基础,方法就多了,可以使用 AOP,也可以直接在对应的 Proxy 中对特定方法执行前后来截图。本项目中使用的是后面的方法,在 ChromeDriverProxy 的 findElement 与 WebDriverWaitProxy 的 until 方法前后执行截图逻辑,且在 ChromeDriverProxy 的 quit 方法里完成对截图的清理。可以满足大部分场景要求:

public class ChromeDriverProxy extends ChromeDriver {
    //...
    @Override
    public WebElement findElement(By by) {
        try {
            if(snapshotWhenPossible) {
                Browsers.saveSnapshotQuietly(this, uuid + "-" + System.currentTimeMillis() + "-FE1");
            }
            return super.findElement(by);
        } finally {
            Browsers.saveSnapshotQuietly(this, uuid + "-" + System.currentTimeMillis() + "-FE2");
        }
    }
    //...    
    
    @Override
    public void quit() {
        super.quit();
        if(cleanSnapshotWhenQuit) {
            this.cleanSnapshot();
        }
    }

    /**
     * 清理driver自动生成的截图
     */
    private void cleanSnapshot() {
        String uuid = this.getUuid();
        String folder = MonitorApplication.CONTEXT.getEnvironment().getProperty("snapshot.save.dir");
        String snapshotTmpDir = MonitorApplication.CONTEXT.getEnvironment().getProperty("snapshot.tmp.dir");

        for(File file : FileUtils.listFiles(new File(folder), null, false)) {
            if(file.getName().contains(uuid)) {
                FileUtils.deleteQuietly(file);
            }
        }

        // clean windows的临时文件,否则会撑满硬盘
        for(File file : FileUtils.listFiles(new File(snapshotTmpDir), new String[]{"png"}, false )) {
            if(file.getName().startsWith("screenshot")) {
                FileUtils.deleteQuietly(file);
            }
        }

        logger.warn(uuid + " snapshots was cleaned!");
    }
}

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class WebDriverWaitProxy extends WebDriverWait {
    //...
    @Override
    public <V> V until(Function<? super WebDriver, V> isTrue) {
        String name = driver.getUuid();

        try {
            if(driver.getSnapshotWhenPossible()) {
                Browsers.saveSnapshotQuietly(driver, name + "-" + System.currentTimeMillis() + "-U1");
            }
            return super.until(isTrue);
        } finally {
            if(driver.getSnapshotWhenPossible()) {
                Browsers.saveSnapshotQuietly(driver, name + "-" + System.currentTimeMillis() + "-U2");
            }
        }
    }
    //...
}

功能是 OK 了,但这种方式由于所有场景都会截图,对 IO 性能影响还是会有的,谨慎使用,由于本项目并发不高,所以压力不太大。也可以考虑再对重复的截图做一些优化,避免不必要的 IO。

下面是具体的截图逻辑,其实很简单:优先使用 Robot 截图,判断图片是黑屏后,再改用 getScreenshotAs 进行截图。采用这样的策略主要是因为:getScreenshotAs 方式有时会因为弹出框报错,为了尽可能保证这时候也能截图,所以优先使用 Robot 截图。但 Robot 截图在屏幕灰掉的时候截取的是黑屏,所以需要再做进一步判断。(如果监控的服务器没有屏幕,可以考虑反过来的逻辑,以避免过多的分支。)

public static String saveSnapshotQuietly(ChromeDriver driver, String folder, String fileNamePrefix) {
    if(driver == null) {
        logger.error("驱动为空,无法截图!");
        return "";
    }

    String fileName = fileNamePrefix + ".png";
    try {
        Dimension dimension = driver.manage().window().getSize();
        Point point = driver.manage().window().getPosition();
        Rectangle rect = new Rectangle(point.x, point.y, dimension.width, dimension.height);
        // 能用robot就用robot,因为getScreenshotAs不能绕过弹出框截图,可能存在问题
        // 但是如果没有桌面开着,robot也可能会截出黑屏;如果是黑屏,再尝试用getScreenshotAs截图
        BufferedImage img = new Robot().createScreenCapture(rect);
        if(!isAllBlack(img)) { //判断是否全黑,以确认截图是否正常
            ImageIO.write(img, "png", new File(folder + "/" + fileName));
            return fileName;
        }

        // 使用robot截图异常,则尝试使用chrome自身的截图功能
        logger.error("使用robot截图显示为全黑,尝试使用chrome自身的截图功能!");
        fileName = "chrome-" + fileName;

        // 如果有弹出框,暂时将弹出框内容记录在日志里
        switchToAlertIfExist(driver);

        File srcFile = driver.getScreenshotAs(OutputType.FILE);// 执行屏幕截取
        FileUtils.copyFile(srcFile, new File(folder, fileName));
    } catch (Exception e) {
        logger.error("截图时出现异常:" + e.getMessage(), e);
        return "";
    }

    return fileName;
}

尽量不要使用 sleep

这个是刚开始接触 selenium 很容易犯的问题。那为什么不能这么做?直接的原因是为了尽可能避免问题,所以 sleep 的时间伊往往设计得很长,这会延长脚本执行的时间,执行效率会低。间接的,很多情况下,你是不需要使用 sleep 的,是因为不熟悉 selenium,其实很多情况往往都有很多替代的方式。以下是一些可以使用的常见方案:

1. wait.until(ExpectedConditions.visibilityOfElementLocated 等待元素可见
2. wait.until(ExpectedConditions.elementToBeClickable 等待元素可以点击(可用)
3. wait.until(ExpectedConditions.urlContains 等待URL包含
4. wait.until(ExpectedConditions.or 等待多个条件中出现一个
5. wait.until(ExpectedConditions.invisibilityOfElementLocated 等待元素不可见

其中第 2 点,也可用于判断输出框是否可填。刚开始接触的时候,我只知道 1,不知道 2,所以有些元素可见了,但点击不了,用了 sleep,部署到服务器上,由于网络原因,还是一直会出现明明元素已经可见了,但是触发不了点击事件。还是要多去熟悉一下 API。

  • B3log

    B3log 是一个开源组织,名字来源于“Bulletin Board Blog”缩写,目标是将独立博客与论坛结合,形成一种新的网络社区体验,详细请看 B3log 构思。目前 B3log 已经开源了多款产品:SymSoloVditor思源笔记

    1083 引用 • 3461 回帖 • 286 关注
  • Selenium
    16 引用 • 13 回帖
  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    941 引用 • 1458 回帖 • 153 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...