1.功能需求:
- 支持灵活配置:因为本身已有用例执行失败的截图功能,所以需要支持针对单条测试用例的配置;
- 支持testng框架xml多线程的执行;
- 录制内容文件小、支持调整录制每帧间隔、每条用例录制最大时长(避免用例元素未定位到时长时间录制)。
2.灵活配置实现
创建注解,通过在测试用例上方添加生成GIF注解控制每条用例的开关。
- 创建注解,设置默认值为true
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 创建GenerateGif自定义注解
* 控制测试方法中是否需要录制GIF
*/
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时可见
@Target(ElementType.METHOD) // 注解适用于方法
public @interface GenerateGif {
boolean value() default true; // 默认为 true,表示需要截图
}
- 在监听器TestListenerAdapter相关的onTestStart、onTestSuccess、onTestFailure相关方法中通过获取测试用例的注解判断是否执行相关录制。
@Override
public void onTestStart(ITestResult tr) {
// 获取测试方法上的 @GenerateGif 注解
GenerateGif GenerateGif = tr.getMethod().getConstructorOrMethod().getMethod().getAnnotation(GenerateGif.class);
boolean generateGifStatus = GenerateGif != null && GenerateGif.value(); // 根据注解决定是否捕捉截图
}
3.多线程及录制时长和每帧间隔实现
在每条用例开始执行前创建一个线程,负责按照播放间隔去截取浏览器屏幕,用例成功、失败时停止线程合成GIF图片并添加到报告当中。用例跳过时停止线程。
- 创建相关变量及常量
private static final String PATH = System.getProperty("user.dir")+File.separator+"screenshot"+File.separator;//存储截图根目录
private static final Map<String, Thread> threadMap = new ConcurrentHashMap<>();//线程列表
private static final Map<String,Boolean> threadConditionMap = new HashMap<>();//线程状态列表
private static final Map<String, List<File>> imageMap = new HashMap<>();// 存储截图文件的列表
private static final long MAX_CAPTURE_TIME_MS = 20000; // 最大捕捉时间 20 秒
private static final int MAX_FARAME_INTERVAL_MS = 1000; // 每帧播放间隔 1000毫秒
- 创建启动线程截图方法
// 启动截图线程
private void startGenerateGif(WebDriver driver,String testName,String gifPath){
Thread screenshotThread = new Thread(() -> {
boolean threadStatus = true;
long startTime = System.currentTimeMillis(); // 记录开始时间
// 创建 List<File> 作为 Map 的值
List<File> fileList = new ArrayList<>();
while (threadStatus) {
if ( !threadConditionMap.get(testName) || (System.currentTimeMillis() - startTime >= MAX_CAPTURE_TIME_MS)){
break;
}else {
try {
File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); // 捕捉浏览器视口的截图
File destination = new File(gifPath+ System.currentTimeMillis() + ".png"); // 截图保存的目标文件
FileUtils.copyFile(screenshot, destination); // 复制截图到目标文件
fileList.add(destination); // 将目标文件添加到 List 中
Thread.sleep(MAX_FARAME_INTERVAL_MS); // 固定时间秒捕捉一次截图
}catch (InterruptedException e) {
e.printStackTrace();
}catch (IOException e) {
e.printStackTrace(); // 捕捉并打印异常
}
}
}
imageMap.put(testName,fileList);
});
threadConditionMap.put(testName,true);
threadMap.put(testName,screenshotThread);
screenshotThread.start(); // 启动线程
}
- 创建停止线程添加GIF到报告中方法
// 停止截图并生成 GIF 动图
private void stopGenerateGif(String testName,String gifPath) {
Thread thread = threadMap.get(testName);
threadConditionMap.put(testName,false);
if (thread != null && thread.isAlive()){
try{
thread.join(); // 等待截图线程完成
} catch ( InterruptedException e) {
e.printStackTrace();
}
}
try {
InputStream content;
takeGif(testName,gifPath);//生成GIF
File f= new File(gifPath + "screenshot.gif") ;
content = new FileInputStream(f);
Allure.addAttachment("用例执行过程如下:", content);
content.close();
}catch (IOException e) {
e.printStackTrace(); // 捕捉并打印异常
}
}
- 创建截图合成GIF方法
// 将截图合成 GIF 动图
public void takeGif(String testName,String gifPath){
try {
List<File> fileList = imageMap.get(testName);
AnimatedGifEncoder encoder = new AnimatedGifEncoder();
encoder.setRepeat(0); // 设置 GIF 循环次数,0 表示无限循环
encoder.start(gifPath+ "screenshot.gif");
for (File file : fileList) {
BufferedImage image = ImageIO.read(file); // 读取截图
encoder.setDelay(MAX_FARAME_INTERVAL_MS); // 设置帧延迟,单位为毫秒
encoder.addFrame(image); // 添加帧
}
encoder.finish(); // 完成动画
}catch (IOException|Error e){
System.err.println("合成gif图失败");
e.printStackTrace();
}
}
4.监听器完整代码
import com.madgag.gif.fmsware.AnimatedGifEncoder;
import io.qameta.allure.Allure;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.*;
import org.testng.ITestContext;
import org.testng.ITestResult;
import org.testng.TestListenerAdapter;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
//@Attachment 注解必须放在test文件夹下面
public class ShotListener extends TestListenerAdapter {
private static final String PATH = System.getProperty("user.dir")+File.separator+"screenshot"+File.separator;//存储截图根目录
private static final Map<String, Thread> threadMap = new ConcurrentHashMap<>();//线程列表
private static final Map<String,Boolean> threadConditionMap = new HashMap<>();//线程状态列表
private static final Map<String, List<File>> imageMap = new HashMap<>();// 存储截图文件的列表
private static final long MAX_CAPTURE_TIME_MS = 20000; // 最大捕捉时间 20 秒
private static final int MAX_FARAME_INTERVAL_MS = 1000; // 每帧播放间隔 1000毫秒
@Override
public void onTestStart(ITestResult tr) {
//判断测试方法是否有gif注解,启动gif截图线程
if (isGenerateGif(tr)) {
Map<String, String> map = getUiName(tr);
rmdir(map.get("gifPath")); // 失败重试时清除上一次的截图
startGenerateGif(getDriver(tr),map.get("testName"),map.get("gifPath")); // 启动截图线程
}
}
@Override
public void onTestSuccess(ITestResult tr) {
//判断测试方法是否有gif注解,生成gif
if (isGenerateGif(tr)){
Map<String, String> map = getUiName(tr);
stopGenerateGif( map.get("testName"),map.get("gifPath"));// 停止截图线程,并生成 GIF 动图
}
}
@Override
public void onTestSkipped(ITestResult tr) {
if(isGenerateGif(tr)){
Map<String, String> map = getUiName(tr);
Thread thread = threadMap.get(map.get("testName"));
threadConditionMap.put(map.get("testName"),false);
if (thread != null && thread.isAlive()) {
try {
thread.join(); // 等待截图线程完成
}
catch (InterruptedException e) {}
}
rmdir(map.get("gifPath")); // 用例跳过时或用例失败重试时(失败重试会进入跳过)删除该测试的截图
}
}
@Override
public void onConfigurationFailure(ITestResult tr){
//请求失败截图
screenshot(getDriver(tr));
}
@Override
public void onTestFailure(ITestResult tr) {
//请求失败截图
screenshot(getDriver(tr));
//判断测试方法是否有gif注解,生成gif
if (isGenerateGif(tr)){
Map<String, String> map = getUiName(tr);
stopGenerateGif(map.get("testName"),map.get("gifPath"));// 停止截图线程,并生成 GIF 动图
}
}
@Override
public void onFinish(ITestContext context) {
rmdir(PATH); // 删除总screenshot文件夹
}
private boolean isGenerateGif(ITestResult tr){
// 获取测试方法上的 @GenerateGif 注解
GenerateGif GenerateGif = tr.getMethod().getConstructorOrMethod().getMethod().getAnnotation(GenerateGif.class);
return GenerateGif != null && GenerateGif.value(); // 根据注解决定是否捕捉截图
}
private WebDriver getDriver(ITestResult tr){
//获取浏览器driver
CaseBase bt = (CaseBase) tr.getInstance();
return bt.driverBase.getDriver();
}
private Map<String, String> getUiName(ITestResult tr) {
// 获取当前测试类的完整路径
String fullClassName = tr.getTestClass().getName();
// 获取当前测试类的简单名称(不包含包路径)
String className = fullClassName.substring(fullClassName.lastIndexOf('.') + 1);
// 获取当前执行的测试方法名
String methodName = tr.getMethod().getMethodName();
String gifPath = PATH + className + File.separator + methodName + File.separator;
Map<String, String> map = new HashMap<>();
map.put("testName",className+methodName);
map.put("gifPath",gifPath);
return map;
}
// 启动截图线程
private void startGenerateGif(WebDriver driver,String testName,String gifPath){
Thread screenshotThread = new Thread(() -> {
boolean threadStatus = true;
long startTime = System.currentTimeMillis(); // 记录开始时间
// 创建 List<File> 作为 Map 的值
List<File> fileList = new ArrayList<>();
while (threadStatus) {
if ( !threadConditionMap.get(testName) || (System.currentTimeMillis() - startTime >= MAX_CAPTURE_TIME_MS)){
break;
}else {
try {
File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); // 捕捉浏览器视口的截图
File destination = new File(gifPath+ System.currentTimeMillis() + ".png"); // 截图保存的目标文件
FileUtils.copyFile(screenshot, destination); // 复制截图到目标文件
fileList.add(destination); // 将目标文件添加到 List 中
Thread.sleep(MAX_FARAME_INTERVAL_MS); // 固定时间秒捕捉一次截图
}catch (InterruptedException e) {
e.printStackTrace();
}catch (IOException e) {
e.printStackTrace(); // 捕捉并打印异常
}
}
}
imageMap.put(testName,fileList);
});
threadConditionMap.put(testName,true);
threadMap.put(testName,screenshotThread);
screenshotThread.start(); // 启动线程
}
// 停止截图并生成 GIF 动图
private void stopGenerateGif(String testName,String gifPath) {
Thread thread = threadMap.get(testName);
threadConditionMap.put(testName,false);
if (thread != null && thread.isAlive()){
try{
thread.join(); // 等待截图线程完成
} catch ( InterruptedException e) {
e.printStackTrace();
}
}
try {
InputStream content;
takeGif(testName,gifPath);//生成GIF
File f= new File(gifPath + "screenshot.gif") ;
content = new FileInputStream(f);
Allure.addAttachment("用例执行过程如下:", content);
content.close();
}catch (IOException e) {
e.printStackTrace(); // 捕捉并打印异常
}
}
// 将截图合成 GIF 动图
public void takeGif(String testName,String gifPath){
try {
List<File> fileList = imageMap.get(testName);
AnimatedGifEncoder encoder = new AnimatedGifEncoder();
encoder.setRepeat(0); // 设置 GIF 循环次数,0 表示无限循环
encoder.start(gifPath+ "screenshot.gif");
for (File file : fileList) {
BufferedImage image = ImageIO.read(file); // 读取截图
encoder.setDelay(MAX_FARAME_INTERVAL_MS); // 设置帧延迟,单位为毫秒
encoder.addFrame(image); // 添加帧
}
encoder.finish(); // 完成动画
}catch (IOException|Error e){
System.err.println("合成gif图失败");
e.printStackTrace();
}
}
public void rmdir (String path) {
// 创建File对象
File file = new File(path);
// 判断目录是否存在
if (file.exists()) {
// 删除目录
try {
FileUtils.forceDelete(file);
} catch (IOException e) {
System.err.println("删除文件目录 " + path + " 失败");
e.printStackTrace();
}
}
}
}