一种很轻松的Excel关键字方式进行Android端APP自动化测试(Java+Appium+TestNG+Excel)

120 阅读15分钟

说明


本次分享Android端APP自动化测试Excel执行逻辑,整体逻辑与Web端类似,没看过的读者可看这里 =========>>
一种很轻松的Excel关键字方式进行网页Web自动化测试(Java+Selenium+TestNG+Excel)

当然也存在差异的部分,本文会详细介绍该部分------------------(PS:作者使用版本appium框架 java版本8.0.0)

可能小伙伴会有疑问,整体框架看着可能有点臃肿(cn.nhdc.cloud.testscripts包下为测试脚本部分),代码不够简洁。

  • 那是因为:
    *设计之初,为提供测试人员一种只专注业务逻辑测试代码,快捷开发测试脚本(testcase)又同时加强代码可读性,case高可维护性的一种方案,同时考虑不同应用设备端、不同类型的测试,提供统一的一套框架。但前景美好,现实很残酷。。。跑题了 ,总而言之,为了能快速落地,形成了目前这套框架,即不是专门为了Excel测试设计的,是为了多种形式调用测试及扩展开发设计的,so... **

  总之,就在此基础上,提供Excel执行测试的一种方案,同时结合EasyExcel工具提供用户快速编辑测试用例,整体形成一个闭环且相对便利的App/Web自动化测试解决方案

  - 文章作者:@随心自然fqc , 转载时,请注明来源,注明作者,这是对文章作者的尊重,也是对知识的尊重。

整体图解


总体逻辑.jpg

主要内容

1、测试调用方式

与Web类型调用一致,同时testUseBaseMethod(username,password)调用基类关键字操作方法。

import cn.nhdc.cloud.modules.casemanage.model.vo.TestCaseVo;
import cn.nhdc.cloud.testscripts.config.AppiumCapabilities;
import cn.nhdc.cloud.testscripts.config.LocateType;
import cn.nhdc.cloud.testscripts.listener.ExtentTestNGIReporterListener;
import cn.nhdc.cloud.testscripts.testcase.base.AndroidTestBase;
import org.testng.annotations.Listeners;
import org.testng.annotations.Optional;
import org.testng.annotations.Test;

import java.util.List;

/**
 * Android类型 Excel执行方式 自动化测试demo <br>
 * Method 1 :  {@link #testExcelExcute}  正向逻辑执行  <br>
 * Method 2 : {@link #testExcelInvokeExcute}  反射逻辑执行  <br>
 * Method 3 : {@link #testUseBaseMethod}  同时可调用基类方法  <br>
 *
 * ps: 以上2种方式 原理不同 但执行效果类似 具体选择哪种取决于调用者意愿
 * 
 * @author Fan QingChuan
 */

@Test(description = "Excel方式自动化测试示例-正向逻辑&反射执行逻辑")
@Listeners(value = ExtentTestNGIReporterListener.class)
public class ExcelAndroidTestDemo extends AndroidTestBase {

    @Test(description = "正向逻辑: 操作方法封装在 AndroidTestBase 中, 测试用例中可直接调用  解析Excel -> caseSteps 根据遍历操作编码switch执行各项操作")
    void testExcelExcute(
            @Optional("C:\\Users\\allen\\Desktop\\自动化测试_测试用例_2022_07_04_150350_623.xlsx") String fileName,
            @Optional("testcase")String sheetName,@Optional("null")Integer headerRowNumber) {

        List<TestCaseVo> caseVoList = analysisExcelUiCase(fileName, sheetName, headerRowNumber);
        excuteExcelUiTest(caseVoList);
    }


    @Test(description = "反射逻辑: 操作封装在 AndroidCommon 通过 AndroidTestBase 中反射获取 AndroidBasePage.class 并实例化 再根据操作编码+分类枚举 获取对应方法 并反射(invoke)执行测试")
    void testExcelInvokeExcute(
            @Optional("C:\\Users\\allen\\Desktop\\自动化测试_安卓APP类型测试用例_2022_07_08_133321_663.xlsx") String fileName,
            @Optional("testcase")String sheetName,@Optional("null")Integer headerRowNumber)
            throws  IllegalAccessException, InstantiationException {

        List<TestCaseVo> caseVoList = analysisExcelUiCase(fileName, sheetName, headerRowNumber);
        invokeExcelUiTest(caseVoList);
    }


    @Test
    void testUseBaseMethod(@Optional("ATE002")String username,@Optional("a123456")String password) {
        //开启Appium服务
        startDefaultBaseService();
        //初始化AndroidDriver
        initBaseAndroidDriver(AppiumCapabilities.getHarmonyJx());
        //点击同意隐私协议
        clickNegatively(LocateType.ID,"cn.newhope.qc:id/tv_privacy_agree");
        //输入用户名
        inputText(LocateType.ID,"cn.newhope.qc:id/etAccount",username);
        //输入密码
        inputText(LocateType.ID,"cn.newhope.qc:id/etPassword",password);
        //点击勾选同意隐私政策
        click(LocateType.ID,"cn.newhope.qc:id/protocolCb");
        //点击登录
        click(LocateType.ID,"cn.newhope.qc:id/btnLogin");
        //断言toast
        assertToastHasAppeared(10,"toast消息文本");
    }

}
2、执行测试逻辑

整体执行逻辑与Web一致,只是在App自动化方面,Appium ,执行测试前需开启Appium服务(AppiumDriverLocalService),并实例化具体不同测试端的Driver。
强烈建议:看过Web端相关逻辑再来看Android端 传送门 >> 网页Web端Excel自动化测试(Java+Selenium+TestNG+Excel)

  • 先来App通用的测试基类--AppTestBase,    主要提供创建/关闭Appium服务
import cn.hutool.core.util.ObjectUtil;
import cn.nhdc.cloud.modules.casemanage.model.vo.TestCaseVo;
import cn.nhdc.cloud.testscripts.config.ReportLog;
import cn.nhdc.cloud.testscripts.testcase.base.common.AppCommon;
import io.appium.java_client.service.local.AppiumDriverLocalService;
import io.appium.java_client.service.local.AppiumServerHasNotBeenStartedLocallyException;
import io.appium.java_client.service.local.flags.ServerArgument;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;

import java.net.URL;
import java.util.List;
import java.util.Map;

/**
 * App相关测试通用Base <br>
 * 继承该类需实现抽象方法-Excel执行测试方法 {@link AppTestBase#excuteExcelUiTest(List)} {@link AppTestBase#invokeExcelUiTest(List)}  <br>
 * 该类只提供AppiumDriverLocalService {@link AppTestBase#service} 不提供具体类型baseDriver  <br>
 * 即:
 * 测试case应早已明确该从事具体类型Driver的测试,应选择继承该类子类 {@link AndroidTestBase} 或 {@link IOSTestBase} 或其他
 *
 * @author Fan QingChuan
 * @since 2022/6/7 16:53
 */

public abstract class AppTestBase extends WebTestBase implements AppCommon {

    private static final ReportLog reportLog = new ReportLog(AppTestBase.class);

    public static final int TIME_OUT = 5;
    public static final int SLEEP_TIME = 300;
    public static final int DEFAULT_PORT = 4723;
    public static final String DEFAULT_IP = "127.0.0.1";

    protected AppiumDriverLocalService service;

    @Override
    public abstract void excuteExcelUiTest(List<TestCaseVo> caseVoList);

    @Override
    public abstract void invokeExcelUiTest(List<TestCaseVo> caseVoList) throws InstantiationException, IllegalAccessException;

    public void startDefaultBaseService() {

        try {
            service = buildDefaultAppiumService();      //创建默认的AppiumDriverLocalService
            checkPortAndKillTask(DEFAULT_PORT);         //检查并KILL端口进程任务 默认4723
            service.start();                            //开启服务
            reportLog.info("开启Appium本地服务 ========== >>  URL: {}",service.getUrl());
        } catch (AppiumServerHasNotBeenStartedLocallyException e) {
            e.printStackTrace();
        }finally {
            reportLog.info("检查服务是否正常运行 ========== >>  [{}]",service.isRunning());
        }
    }

    public void startCustomBaseService(int port, String ipAddress, Map<ServerArgument,String> arguments) {

        try {
            service = buildCustomAppiumService(port,ipAddress,arguments);               //根据配置创建AppiumDriverLocalService
            checkPortAndKillTask(port);
            service.start();
            reportLog.info("开启Appium本地服务 ========== >>  URL: [{}]",service.getUrl());
        } catch (AppiumServerHasNotBeenStartedLocallyException e) {
            e.printStackTrace();
        }finally {
            reportLog.info("服务是否正常开启 ========== >>  [{}]",service.isRunning());
        }
    }

    public URL getServiceUrl() {
        if (service.isRunning()) {
            return service.getUrl();
        }else {
            throw new RuntimeException("AppiumDriverLocalService service 未启动!");
        }
    }

    protected void closeBaseAppiumService() {
        closeAppiumService(service);
    }


注意此处创建/关闭的服务是AppTestBase的AppiumDriverLocalService,如果测试中需要创建自己定义的Appium服务,则看后续 AppCommon 

  • App层面测试通用--AppCommon

      initOrConnectAppiumService(String initInfos)   //根据初始化配置JSON信息(initInfos) 初始化创建AppiumServiceEntity * 注意此处不开启服务,由具体测试端去开启服务并实例化Driver
    buildDefaultAppiumService()           //创建默认的AppiumDriverLocalService
    buildCustomAppiumService(int port, String ipAddress, Map<ServerArgument,String> arguments)  //创建自定义的AppiumDriverLocalService
    closeAppiumService(AppiumDriverLocalService service) //关闭AppiumDriverLocalService

import cn.hutool.core.util.ObjectUtil;
import cn.nhdc.cloud.common.utils.*;
import cn.nhdc.cloud.testscripts.config.AppiumServiceEntity;
import cn.nhdc.cloud.testscripts.config.ReportLog;
import cn.nhdc.cloud.testscripts.testcase.base.AppTestBase;
import cn.nhdc.common.util.CollectionUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import io.appium.java_client.service.local.AppiumDriverLocalService;
import io.appium.java_client.service.local.AppiumServiceBuilder;
import io.appium.java_client.service.local.flags.GeneralServerFlag;
import io.appium.java_client.service.local.flags.ServerArgument;

import java.io.File;
import java.util.Map;

public interface AppCommon extends WebCommon{

    int APP_COMMON_TIME_OUT = AppTestBase.TIME_OUT;
    int APP_COMMON_SLEEP_TIME = AppTestBase.SLEEP_TIME;
    int APP_COMMON_DEFAULT_PORT = AppTestBase.DEFAULT_PORT;
    String APP_COMMON_DEFAULT_IP = AppTestBase.DEFAULT_IP;
    ReportLog reportLog = new ReportLog(AppCommon.class);

    default void checkPortAndKillTask(int port) {
        if (CommonUtils.isWindows()) {
            if (SocketUtils.isPortBeUsed(port)) {
                String rs = CommonUtils.stopWindowsAppiumService(port);
                reportLog.info(" ========== >> 端口[{}]被占用 taskkill {} ", port, rs);
            }
        }else {
            if (SocketUtils.isPortBeUsed(port)) {
                String rs = CommonUtils.stopLinuxAppiumService(port);
                reportLog.info(" ========== >> 端口[{}]被占用 taskkill {} ", port, rs);
            }
        }
    }

    default AppiumDriverLocalService buildCustomAppiumService(int port, String ipAddress, Map<ServerArgument,String> arguments) {
        AppiumServiceBuilder builder = getAppiumServiceBuilder(port, ipAddress, arguments);
        return AppiumDriverLocalService.buildService(builder).withBasePath("/wd/hub/");
    }

    default AppiumDriverLocalService buildDefaultAppiumService() {
        return AppiumDriverLocalService.buildDefaultService().withBasePath("/wd/hub/");
    }

    default AppiumServiceBuilder getAppiumServiceBuilder(int port, String ipAddress, Map<ServerArgument,String> arguments) {
        if (!MathUtils.rangeInDefined(port,4700,5000)) {
            throw new RuntimeException("端口限定范围 4700-5000");
        }
        if (!IpUtils.internalIp(ipAddress)) {
            throw new RuntimeException("IP地址格式不合法!");
        }

        AppiumServiceBuilder builder = new AppiumServiceBuilder();
        builder.withIPAddress(ipAddress);
        builder.usingPort(port);

        //默认设置 session覆盖 日志级别info 日志位置 调用者可覆盖
        builder.withArgument(GeneralServerFlag.SESSION_OVERRIDE);
        builder.withArgument(GeneralServerFlag.LOG_LEVEL,"info");
        builder.withLogFile(new File(CommonUtils.getOutPutRootPath() + "nhdc-cloud-test-platform"
                + CommonUtils.SEPARATOR + "appium-log" + CommonUtils.SEPARATOR + DateUtils.getTimeSuffix() + "_info.log"));

        if (CollectionUtils.isNotEmpty(arguments)) {
            arguments.forEach((k,v) -> {
                if (ObjectUtil.isEmpty(v)) {
                    builder.withArgument(k);
                }else {
                    String argName = k.getArgument();
                    switch (argName) {
                        case "--port":
                        case "-p":
                            builder.usingPort(Integer.parseInt(v));
                            break;
                        case "--address":
                        case "-a":
                            builder.withIPAddress(v);
                            break;
                        case "--log":
                        case "-g":
                            builder.withLogFile(new File(v));
                            break;
                        default:
                            builder.withArgument(k,v);
                            break;
                    }
                }
            });
        }

        return builder;
    }

    default void closeAppiumService(AppiumDriverLocalService service) {
        try {
            if (service.isRunning()) {
                service.stop();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            reportLog.info(" ========== >> Appium服务是否正常关闭 [{}]",!service.isRunning());
        }
    }

    default AppiumServiceEntity initOrConnectAppiumService(String initInfos) {
        if (ObjectUtil.isEmpty(initInfos)) {
            throw new IllegalArgumentException("initInfos 配置信息不能为空!");
        }

        if (CommonUtils.isJSONString(initInfos)) {
            throw new IllegalArgumentException("initInfos配置信息JSON 格式不合法! 请检查!");
        }

        JSONObject initJson = JSON.parseObject(initInfos);
        if (!initJson.containsKey("isStartDefaultService")) {
            throw new IllegalArgumentException("initInfos 中未配置 isStartDefaultService  true/false");
        }

        Boolean isStartDefaultService = initJson.getBoolean("isStartDefaultService");

        if (ObjectUtil.isEmpty(isStartDefaultService)) {
            throw new IllegalArgumentException("initInfos 中未正确配置 isStartDefaultService  true/false");
        }

        reportLog.info(" ========== >> isStartDefaultService [{}]",isStartDefaultService);

        AppiumServiceEntity serviceEntity = new AppiumServiceEntity();
        if (isStartDefaultService) {
            AppiumServiceBuilder builder = getAppiumServiceBuilder(APP_COMMON_DEFAULT_PORT, APP_COMMON_DEFAULT_IP, null);

            AppiumDriverLocalService service = AppiumDriverLocalService.buildService(builder).withBasePath("/wd/hub/");

            reportLog.info(" ========== >> 构建AppiumDriverLocalService 等待启动服务 [{}]",service.getUrl());
            serviceEntity.setService(service);
        }else {
            int port = initJson.getIntValue("port");
            String ipAddress = initJson.getString("ipAddress");
            String basePath = initJson.getString("basePath");
            String url = "http://" + ipAddress + ":" + port + basePath;
            serviceEntity.setUrl(url);

            reportLog.info(" ========== >> 用户指定Appium服务地址 [{}]",url);
        }

        return serviceEntity;
    }

  • AppiumServiceEntity类
import io.appium.java_client.service.local.AppiumDriverLocalService;
import lombok.Data;

/**
 * @author Fan QingChuan
 */

@Data
public class AppiumServiceEntity {
    private String url;
    private AppiumDriverLocalService service;
}

自此,无论是使用AppTestBase还是自创的,Appium服务已创建好,下一步开启具体Android测试

  • 测试基类--AndroidTestBase
import cn.hutool.core.util.ObjectUtil;
import cn.nhdc.cloud.modules.casemanage.model.vo.TestCaseVo;
import cn.nhdc.cloud.testscripts.config.AppiumServiceEntity;
import cn.nhdc.cloud.testscripts.config.ReportLog;
import cn.nhdc.cloud.testscripts.enums.AndroidActionTypeEnum;
import cn.nhdc.cloud.testscripts.page.base.AndroidBasePage;
import cn.nhdc.cloud.testscripts.testcase.base.common.AndroidCommon;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.service.local.AppiumDriverLocalService;
import io.appium.java_client.touch.offset.PointOption;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.testng.ITestResult;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

/**
 * Android相关测试基类 <br>
 * = Android相关的操作方法调用方式与 {@link WebTestBase}一致 区别在该基类提供特殊于Android端的操作  示例 <br>
 * {@link cn.nhdc.cloud.testscripts.testcase.demo.JxAppLoginTestDemo} <br>
 * {@link cn.nhdc.cloud.testscripts.testcase.demo.JxWithLocalServiceTestDemo} <br>
 * {@link cn.nhdc.cloud.testscripts.testcase.demo.JxAndroidSummaryTestDemo} <br>
 * = 同样提供Excel执行方式 示例 <br>
 * {@link cn.nhdc.cloud.testscripts.testcase.demo.ExcelAndroidTestDemo} <br>
 * = 同时进行 Web与App测试 示例 <br>
 * {@link cn.nhdc.cloud.testscripts.testcase.demo.WebAndAppTestDemo} <br>
 * @author Fan QingChuan
 * @since 2022/7/3 18:11
 */

public class AndroidTestBase extends AppTestBase implements AndroidCommon {

    private static final ReportLog reportLog = new ReportLog(AndroidTestBase.class);

    protected AndroidDriver baseAndroidDriver;

    @Override
    public void excuteExcelUiTest(List<TestCaseVo> caseVoList) {
        //此处省略N行代码,作者懒,switch case方式要写贼多代码  丑得YP 作者已经放弃此种方式了
    }

    @Override
    public void invokeExcelUiTest(List<TestCaseVo> caseVoList) throws InstantiationException, IllegalAccessException {
        Class<?> clazz = null;
        try {
            clazz = Class.forName("cn.nhdc.cloud.testscripts.page.base.AndroidBasePage");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        assert clazz != null;

        AndroidBasePage instance = (AndroidBasePage) clazz.newInstance();
        Class<?> finalClazz = clazz;
        AtomicReference<AppiumServiceEntity> serviceEntity = new AtomicReference<>();
        caseVoList.forEach(caseVo -> {

            List<TestCaseVo.UiCaseStepVo> caseSteps = caseVo.getCaseSteps().stream().sorted(Comparator.comparingInt(TestCaseVo.UiCaseStepVo::getSort)).collect(Collectors.toList());

            caseSteps.forEach(step -> {
                try {
                    reportLog.info("测试用例编号[{}] 步骤序号:[{}],测试步骤描述 [{}],操作编码:[{}],元素定位方式[{}],定位值[{}],输入参数值[{}]",step.getCaseCode(),step.getSort(),step.getDescription(),step.getActionKeyword(),step.getElementLocateType(),step.getElementLocateValue(),step.getParameter());
                    if (step.getActionKeyword().equalsIgnoreCase(AndroidActionTypeEnum.InitOrConnectAppiumService.getActionKeyword())) {    
                        
                        //根据用户配置初始化创建AppiumServiceEntity

                        Method method = finalClazz.getMethod(step.getActionKeyword(),String.class);
                        serviceEntity.set((AppiumServiceEntity) method.invoke(instance, step.getParameter()));

                    }else if (step.getActionKeyword().equalsIgnoreCase(AndroidActionTypeEnum.InitBaseDriverAndStartService.getActionKeyword())){
                        
                        //获取上文AppiumServiceEntity启动AppiumDriverLocalService,根据用户配置APP相关参数初始化AndroidDriver

                        Method method = finalClazz.getMethod(step.getActionKeyword(), AppiumServiceEntity.class, String.class);
                        this.baseAndroidDriver = (AndroidDriver) method.invoke(instance,serviceEntity.get(),step.getParameter());

                    }else if (step.getActionKeyword().equalsIgnoreCase(AndroidActionTypeEnum.CloseAppiumService.getActionKeyword())){

                        //关闭AppiumDriverLocalService(先关闭AndroidDriver)

                        Method method = finalClazz.getMethod(step.getActionKeyword(),AppiumDriverLocalService.class);
                        if (serviceEntity.get().getService().isRunning()) {
                            reportLog.info("准备关闭Appium服务 ========== >>  [{}]",serviceEntity.get().getService().getUrl());
                            this.baseAndroidDriver.quit();
                            reportLog.info(" ========== >>  [已关闭baseAndroidDriver]");
                            method.invoke(instance,serviceEntity.get().getService());
                        }else {
                            throw new RuntimeException("CloseAppiumService失败! 不存在已启动的AppiumService");
                        }

                    }else {

                        //执行Android端操作关键字方法

                        invokeMethod(finalClazz, instance, this.baseAndroidDriver, step.getActionKeyword(), step.getElementLocateType(), step.getElementLocateValue(), step.getParameter());
                    }
                } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
                    e.printStackTrace();
                }
            });
        });
    }

    private void invokeMethod(Class<?> clazz, Object instance, AndroidDriver driver, String actionKeyword, String elementLocateType, String elementLocateValue, String parameter) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        AndroidActionTypeEnum androidActionTypeEnum = AndroidActionTypeEnum.getTargetType(actionKeyword);
        if (ObjectUtil.isNull(androidActionTypeEnum)) {
            throw new IllegalStateException("操作编码 actionKeyword 不合法");
        }
        Method method;
        switch (androidActionTypeEnum.getType()) {
            case 0:
                method = clazz.getMethod(actionKeyword,WebDriver.class);
                method.invoke(instance,driver);
                break;
            case 1:
                method = clazz.getMethod(actionKeyword,WebDriver.class,String.class);
                method.invoke(instance,driver,parameter);
                break;
            case 2:
                method = clazz.getMethod(actionKeyword,WebDriver.class,String.class,String.class);
                method.invoke(instance,driver,elementLocateType,elementLocateValue);
                break;
            case 3:
                method = clazz.getMethod(actionKeyword,WebDriver.class,String.class,String.class,String.class);
                method.invoke(instance,driver,elementLocateType,elementLocateValue,parameter);
                break;
            case 4:
                method = clazz.getMethod(actionKeyword,String.class);
                method.invoke(instance,parameter);
                break;
            default:
                break;
        }
    }


    /**
     * 在屏幕上画图标记(滑动操作)
     * @param type 1-勾 2-叉 3-圆形
     */
    protected void drawMarkInScreen(int type) {
        drawMarkInScreen(baseAndroidDriver,type);
    }

    //此处省略各种具体关键字操作方法代码 与drawMarkInScreen(int type)类似 传入本基类baseAndroidDriver,调用AndroidCommon中方法执行具体关键字操作

    /**
     * 初始化 baseAndroidDriver
     * @param capabilities
     */
    protected void initBaseAndroidDriver(DesiredCapabilities capabilities) {

        if (service == null) {
            throw new RuntimeException("AppiumDriverLocalService service  未实例化!");
        }
        if (!service.isRunning()) {
            throw new RuntimeException("AppiumDriverLocalService service  未运行!");
        }

        baseAndroidDriver = buildNewAndroidDriver(capabilities);
    }

    private AndroidDriver buildNewAndroidDriver(DesiredCapabilities capabilities) {
        return new AndroidDriver(getServiceUrl(),capabilities);
    }
}
  • Android测试通用-AndroidCommon

      initBaseDriverAndStartService(AppiumServiceEntity serviceEntity, String androidInitInfos)     //拿到App层创建的AppiumServiceEntity,开启服务,并初始化创建AndroidDriver
    drawMarkInScreen(AndroidDriver driver,int type)          //Android具体各自自处操作方法 此为屏幕上画标记 类似其他的方法 此处没展示,例如 点击、滑动、自定义滑动、缩放、扩放、拖拽等

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import cn.nhdc.cloud.common.utils.CommonUtils;
import cn.nhdc.cloud.testscripts.config.AppiumServiceEntity;
import cn.nhdc.cloud.testscripts.config.Assertion;
import cn.nhdc.cloud.testscripts.config.ReportLog;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import io.appium.java_client.MultiTouchAction;
import io.appium.java_client.TouchAction;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.AndroidTouchAction;
import io.appium.java_client.android.nativekey.AndroidKey;
import io.appium.java_client.android.nativekey.KeyEvent;
import io.appium.java_client.service.local.AppiumDriverLocalService;
import io.appium.java_client.service.local.AppiumServerHasNotBeenStartedLocallyException;
import io.appium.java_client.touch.WaitOptions;
import io.appium.java_client.touch.offset.PointOption;
import org.openqa.selenium.By;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.Point;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.util.Arrays;

public interface AndroidCommon extends AppCommon{

    ReportLog reportLog = new ReportLog(AndroidCommon.class);

    //以下为Excel自动化测试可调用

    /**
     * 在屏幕上画图标记(滑动操作)
     * @param driver AndroidDriver
     * @param type 1-勾 2-叉 3-圆形
     */
    default void drawMarkInScreen(AndroidDriver driver,int type) {
        AndroidTouchAction touchAction = new AndroidTouchAction(driver);
        int width = driver.manage().window().getSize().width;
        int height = driver.manage().window().getSize().height;
        if (type < 1 || type > 3) type = RandomUtil.randomInt(1,4);
        switch (type) {
            case 1:
                //打勾
                touchAction.longPress(PointOption.point(width/4, height/2)).moveTo(PointOption.point(width/2,height*3/4)).moveTo(PointOption.point(width*3/4,height/4)).release().perform();
                reportLog.info(" ======== >> 屏幕标记->[打勾]");
                break;
            case 2:
                //画叉
                touchAction.longPress(PointOption.point(width/4, height/3)).moveTo(PointOption.point(width*3/4,height*2/3)).release().perform();
                touchAction.longPress(PointOption.point(width*3/4, height/3)).moveTo(PointOption.point(width/4,height*2/3)).release().perform();
                reportLog.info(" ======== >> 屏幕标记->[画叉]");
                break;
            case 3:
                //画圆
                drawCircle(driver,new Point(width/2, height/2),200,30);
                reportLog.info(" ======== >> 屏幕标记->[画圆]");
                break;
            default:
                throw new IllegalArgumentException("标记类型不合法! ");
        }

    }


    /**
     * 绘制圆形
     * @param driver
     * @param origin 中心点位置
     * @param radius 半径长度
     * @param steps
     */
    default void drawCircle (AndroidDriver driver, Point origin, double radius, int steps) {
        Point firstPoint = getPointOnCircle(0, steps, origin, radius);

        PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
        Sequence circle = new Sequence(finger, 0);
        circle.addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), firstPoint.x, firstPoint.y));
        circle.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));

        for (int i = 1; i < steps + 1; i++) {
            Point point = getPointOnCircle(i, steps, origin, radius);
            circle.addAction(finger.createPointerMove(Duration.ofMillis(100), PointerInput.Origin.viewport(), point.x, point.y));
        }

        circle.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
        driver.perform(Arrays.asList(circle));
    }


    default Point getPointOnCircle (int step, int totalSteps, Point origin, double radius) {
        double theta = 2 * Math.PI * ((double)step / totalSteps);
        int x = (int)Math.floor(Math.cos(theta) * radius);
        int y = (int)Math.floor(Math.sin(theta) * radius);
        return new Point(origin.x + x, origin.y + y);
    }

    //此处省略各种具体关键字操作方法代码 与drawMarkInScreen(AndroidDriver driver,int type)类似 具体各自实现


    default AndroidDriver initBaseDriverAndStartService(AppiumServiceEntity serviceEntity, String androidInitInfos) {

        if (ObjectUtil.isEmpty(serviceEntity)) {
            throw new IllegalArgumentException("serviceEntity 不能为空!");
        }

        if (ObjectUtil.isEmpty(androidInitInfos)) {
            throw new IllegalArgumentException("androidInitInfos 配置信息不能为空!");
        }

        if (!CommonUtils.isJSONString(androidInitInfos)) {
            throw new IllegalArgumentException("androidInitInfos JSON 格式不合法! 请检查!");
        }

        DesiredCapabilities capabilities = new DesiredCapabilities();
        JSONObject initJson = JSON.parseObject(androidInitInfos);
        initJson.keySet().forEach(key -> capabilities.setCapability(key,initJson.get(key)));

        URL url = null;
        if (ObjectUtil.isNotEmpty(serviceEntity.getService())) {
            AppiumDriverLocalService service = serviceEntity.getService();
            try {
                checkPortAndKillTask(APP_COMMON_DEFAULT_PORT);
                service.start();
                reportLog.info(" ========== >> 开启Appium本地服务 URL: [{}]",service.getUrl());
            } catch (AppiumServerHasNotBeenStartedLocallyException e) {
                e.printStackTrace();
            }finally {
                reportLog.info(" ========== >> Appium本地服务是否正常开启 [{}]",service.isRunning());
            }
            url = service.getUrl();
        }else {
            try {
                url = new URL(serviceEntity.getUrl());
                reportLog.info(" ========== >> 连接远程服务地址 [{}]",url);
            } catch (MalformedURLException e) {
                e.printStackTrace();
                reportLog.info(" ========== >>  远程地址解析失败![{}]",serviceEntity);
            }
        }
        return new AndroidDriver(url,capabilities);
    }

    

    

以上核心逻辑,以及与Web端测试的区别已经基本介绍完了,很多关键字操作的具体实现代码没展示出来,因具体测试需求不同,实现方式不同,代码太杂乱不利于本次逻辑分享,有机会其他文章分享。

3、其他相同的以及相似不同但不重要得说明
  • 完全相同的  TestCaseVo对象,解析Excel用例方法仍是analysisExcelUiCase(String filePath, String sheetName, Integer headerRowNumber)
  • 相似的: 以下:
    • Android操作关键字编码枚举
import io.appium.java_client.android.nativekey.AndroidKey;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.openqa.selenium.Keys;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Getter
@AllArgsConstructor
public enum AndroidActionTypeEnum {

    //类型-0  无需定位 无需输入参数
    ClosePage("关闭页面","closePage",0,"关闭当前所在页面窗口"),
    CloseBrowser("关闭浏览器浏览器","closeBrowser",0,"关闭整个浏览器(所有页面)"),
    PageBack("页面返回","pageBack",0,"页面返回"),
    PageForward("页面前进","pageForward",0,"页面前进"),
    PageRefresh("刷新页面","pageRefresh",0,"页面刷新"),
    CloseAppiumService("关闭AppiumService","closeAppiumService",0,"关闭AppiumService 无输入值"),
    ScaleScreen("双指触控缩放屏幕","scaleScreen",0,"双指触控缩放屏幕"),
    EnlargeScreen("双指触控扩放屏幕","enlargeScreen",0,"双指触控扩放屏幕"),
    SwipeDown("页面向下滑动","swipeDown",0,"页面向下滑动"),
    SwipeUp("页面向上滑动","swipeUp",0,"页面向上滑动"),
    SwipeLeft("页面向左滑动","swipeLeft",0,"页面向左滑动"),
    SwipeRight("页面向右滑动","swipeRight",0,"页面向右滑动"),



    //类型-1  无需定位 需输入参数
    OpenUrl("打开网络地址","openUrl",1,"打开网址(输入值-网址)"),
    NavigateToUrl("跳转网络地址","navigateToUrl",1,"(同一个页面窗口下)跳转至网址  (输入值-网址)"),
    NavigateToWindows("跳转窗口至目标窗口","navigateToWindows",1,"跳转窗口至目标窗口(输入值-页面标题)"),
    Pause("暂停N秒","pause",1,"暂停N秒(输入值-秒值 支持小数)"),
    OpenUrlBlank("Blank方式(新开页面)打开网址","openUrlBlank",1,"Blank方式(新开页面)打开网址 (输入值-网址)"),
    KeyBoard("输入键盘","keyBoard",1,"输入键盘(输入值 org.openqa.selenium.Keys枚举中的键盘值)  例如"+ Arrays.stream(Keys.values()).map(o -> o.name()).collect(Collectors.toList())),
    Javascript("执行js脚本","javascript",1,"执行js脚本(输入值-js脚本)"),
    PressKey("输入手机按键","pressKey",1,"输入手机按键(输入值-io.appium.java_client.android.nativekey.AndroidKey枚举中的按键值) 例如"+ Arrays.stream(AndroidKey.values()).map(o -> o.name()).collect(Collectors.toList())),
    ClickText("点击目标文本元素","clickText",1,"点击目标文本元素(输入值-目标文本)"),
    AssertToastHasAppeared("断言目标toast信息是否出现 (检查频率0.1秒) 供Excel自动化使用","assertToastHasAppeared",1,"断言目标toast信息是否出现 (检查频率0.1秒) 输入值格式:  等待时间:等待消息内容 例如  3:提交成功"),
    DrawMarkInScreen("在屏幕上画图标记(滑动操作)","drawMarkInScreen",1,"在屏幕上画图标记(滑动操作) 输入值- 1-勾 2-叉 3-圆形"),
    ClickRandomPointInCustomArea("给定区域内随机点击1次","clickRandomPointInCustomArea",1,"在给定区域内随机点击某点,输入值-左上角坐标:右下角坐标 坐标格式-X坐标,Y坐标  例如 200,200:300,300"),
    ClickPoint("点击目标坐标点","clickPoint",1,"点击目标坐标点,输入值-X坐标:Y坐标 例如 200,200"),
    SetTimeOut("设置全局隐式等待时间","setTimeOut",1,"设置全局隐式等待时间,输入值-秒值"),


    //类型-2  需定位 无需输入参数
    Click("点击","click",2,"点击(元素)"),
    ClickNegatively("消极等待点击","clickNegatively",2,"消极等待点击(元素)"),
    RightClick("右键单击元素","rightClick",2,"右键单击元素(元素)"),
    MoveToElement("移动至元素","moveToElement",2,"焦点移动至(元素)"),
    ClickAndHold("点击并保持按住","clickAndHold",2,"点击并保持按下状态(元素)"),
    DoubleClick("双击","doubleClick",2,"双击(元素)"),
    Release("释放元素","release",2,"释放(元素)"),
    NavigateToFrame("跳转至Frame","navigateToFrame",2,"跳转Frame(元素)"),

    //类型-3  需定位 需输入参数
    ClickCustomWait("自定义等待点击","clickCustomWait",3,"自定义等待点击(元素)+ 输入值(时间配置 格式: 等待时间(单位秒):检查频率(单毫秒) 例如: 5:300 等待5秒每300毫秒检查一次)"),
    ClickNegativelyCustomWait("自定义消极等待点击","clickNegativelyCustomWait",3,"自定义消极等待点击(元素)+ 输入值(时间配置 格式: 等待时间(单位秒):检查频率(单毫秒) 例如: 10:500 等待10秒每500毫秒检查一次)"),
    InputText("输入文本","inputText",3,"输入文本(元素 + 输入值)"),
    AssertElementText("断言类型-元素文本","assertElementText",3,"断言元素上文本是否符合预期 (元素 + 输入值-预期文本)"),
    DragAndDropToPoint("拖拽某元素至目标坐标位置并释放","dragAndDropToPoint",3,"拖拽某元素至目标坐标位置并释放 (元素 +输入值-格式: x坐标:y坐标 例如:300:400 )"),
    DragAndDropToElement("拖拽某元素至目标元素位置并释放","dragAndDropToElement",3,"拖拽某元素至目标元素位置并释放 (元素 + 输入值 定位方式|定位值   例如: xpath|//*[@id=\"pane-third\"]"),
    ClickInElementsByText("点击组元素中文本符合预期的元素","clickInElementsByText",3,"点击组元素中文本符合预期的元素-(元素)"),

    //类型-4  特殊类型 不需要WebDriver(其他类型需要WebDriver) 不需定位 传入需输入参数
    ThreadSleep("线程休眠","threadSleep",4,"线程休眠(输入值 秒值-支持小数)"),
    InitOrConnectAppiumService("根据用户配置 创建AppiumService或连接自定义的服务地址","initOrConnectAppiumService",4,"根据用户配置 是否创建AppiumService(输入值-JSON配置 配置说明: {\"isStartDefaultService\": true, #是否由服务器开启默认服务\"port\": 4721,  # 如果不由服务器开启,则必须指定port\"ipAddress\": \"127.0.0.1\",# 如果不由服务器开启,则必须指定IP地址\"basePath\": \"/wd/hub/\"  # 如果不由服务器开启,则必须指定basePath} "),
    InitBaseDriverAndStartService("开启AppiumService并实例化Driver(打开APP)","initBaseDriverAndStartService",4,"开启AppiumService并实例化Driver(打开APP) (输入值-APP JOSN格式的DesiredCapabilities配置) 配置示例: {\"deviceName\":\"XXXXXXXXX\",\"platformName\":\"Android\",\"platformVersion\":\"10\",\"appPackage\":\"cn.*****.**\",\"appActivity\":\".ui.login.LaunchActivity\",\"automationName\":\"Appium\",\"chromedriverExecutable\":\"D:\\\\1111\\\\2222\\\\nhdc-222-test-platform\\\\src\\\\main\\\\resources\\\\driver\\\\chromedriver88.exe\",\"noReset\":\"false\",\"fastReset\":\"false\"}"),
    ;

    private String name;
    private String actionKeyword;
    private Integer type;
    private String description;

    public static AndroidActionTypeEnum getTargetType(String actionKeyword) {
        for (AndroidActionTypeEnum value : AndroidActionTypeEnum.values()) {
            if (value.getActionKeyword().equalsIgnoreCase(actionKeyword)) {
                return value;
            }
        }
        return null;
    }

    public static List<String> getAllKeyword() {
        return Arrays.stream(AndroidActionTypeEnum.values()).map(AndroidActionTypeEnum::getActionKeyword).collect(Collectors.toList());
    }

    public static List<String> getAllDescription() {
        return Arrays.stream(AndroidActionTypeEnum.values()).map(AndroidActionTypeEnum::getDescription).collect(Collectors.toList());
    }
}
  • AndroidBasePage
import cn.nhdc.cloud.testscripts.config.ReportLog;
import cn.nhdc.cloud.testscripts.testcase.base.common.AndroidCommon;
import io.appium.java_client.android.Activity;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;


public class AndroidBasePage extends BasePage implements AndroidCommon {

    private static final ReportLog reportLog = new ReportLog(AndroidBasePage.class);

    public AndroidDriver driver;

    public AndroidBasePage() {
    }

    public AndroidBasePage(AndroidDriver driver) {
        this.driver = driver;
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(AndroidCommon.APP_COMMON_TIME_OUT));
    }

    public AndroidBasePage(AndroidDriver driver, String appPackage, String appActivity) {
        this.driver = driver;
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(AndroidCommon.APP_COMMON_TIME_OUT));
        this.driver.startActivity(new Activity(appPackage,appActivity));
    }

    public AndroidBasePage(AndroidDriver driver, String title) {
        this.driver = driver;
        WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(AndroidCommon.APP_COMMON_TIME_OUT));
        try {
            boolean flag = wait.until((ExpectedCondition<Boolean>) arg0 -> arg0.getTitle().equals(title));
        } catch (TimeoutException te) {
            throw new IllegalStateException("当前不是预期页面,当前页面title是:" + driver.getTitle());
        }
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(AndroidCommon.APP_COMMON_TIME_OUT));
    }

}

6、其他相关
  • 测试用例Excel: )

测试用例Excel

  • Excel关键字描述:

Excel关键字描述