最近公司有一套页面需要使用 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。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于