先行声明:
1、下面展示的内容多源于 TesterHome 各位前辈的经验总结,我只是按照我的想法进行了简单拼接(目前只适用于 Win 平台下的 Android 自动化测试)
2、主要参考了@tongshanshanshan老师的https://testerhome.com/topics/6810
3、所用语言为 Python,测试报告模板使用了 HTMLTestRunner.py,并稍事修改以方便截图、日志打印。所下载的 HTMLTestRunner.py(已在原作者之上做了修改)来源于何处不明
4、DEMO 工程已上传 GitHub“https://github.com/Hualiner/UI-auto-test-base-macaca
5、若直接使用需自行安装 “金惠家” App,且需要在 Account.py 中添加正确的用户名和密码

当前工作内容有自动化测试的需要,先期准备开展 UI 自动化测试。通过对比,最终选择了@xdf达峰老师团队的 Macaca。受之前单位的测试总工的影响,准备这样使用 Macaca 做 UI 自动化测试,还请各位前辈看看这种形式是否合适。

工程目录如下:
1、其中 CarInsurance 为某一特定的业务线
2、Public 为所有业务线通用的东西

UI 自动化测试用例:
1、采用 PageObject 模式,将测试用例、页面定位进行分离(TestCase、PageObject)
2、采用测试数据参数化,将测试用例、测试数据分离(TestCase、TestData)
3、自动化测试用例放在 TestSuite_xxxx 下的 TestCase 里面,通过 run_all_cases.bat 执行特定业务线下所有的 TestSuite_xxxx 中的用例,通过 run_cases.bat 执行对应的 TestSuite_xxxx 中的用例

Public:
1、为了使自动化用例编写者编写自动化用例时只需关注用例逻辑、元素定位、测试数据,所以写了一个 Public
2、其中,PageObject 中包含了 BasePage(后面介绍它的作用)以及公用的页面
3、Public 里面的 py 文件则包含了 macaca server 启动、多设备、测试报告、日志打印、错误截图等功能

上面介绍了我准备开展的 UI 自动化测试的一个总的结构,下面简单介绍一下流程:
1、通过类似 run_cases.bat 的批处理作为入口进行 UI 自动化测试

if __name__ == '__main__':
    cs = CaseStrategy()
    cases = cs.collect_cases(suite=True)

    # in future, cases_list may be used for testing strategy in multi devices
    Drivers().run(cases)

2、还没考虑好多设备时用例分配策略,所以目前的实现是,同一台 PC 连接的所有设备跑一样的用例(CaseStrategy())
3、macaca server 的开启、以及 driver 的初始化是通过 Drivers() 来完成的,如下代码:

class Drivers:
    @staticmethod
    def _run_cases(server_url, run, cases):
        log = Log()
        log.set_logger(run.get_device()['udid'], run.get_path() + '\\' + 'client.log')

        log.i('platformName: %s', run.get_device()['platformName'])
        log.i('udid: %s', run.get_device()['udid'])
        log.i('package: %s\n', run.get_device()['package'])

        log.i('macaca server port: %d\n', run.get_port())

        # init driver
        driver = WebDriver(run.get_device(), server_url)
        driver.init()

        # set cls.path, it must be call before operate on any page
        path = ReportPath()
        path.set_path(run.get_path())

        # set cls.driver, it must be call before operate on any page
        base_page = BasePage()
        base_page.set_driver(driver)

        # skip wizard
        if skip_wizard_to_home():
            # run cases
            run.run(cases)

        # quit driver
        driver.quit()

    def run(self, cases):
        # read all devices on this PC
        devices = Devices().get_devices()

        # read free ports on this PC
        ports = Ports().get_ports(len(devices))

        if not len(devices):
            print('there is no device connected this PC')
            return

        runs = []
        for i in range(len(devices)):
            runs.append(RunCases(devices[i], ports[i]))

        # start macaca server
        macaca_server = MacacaServer(runs)
        macaca_server.start_server()
        for port in ports:
            while not macaca_server.is_running(port):
                print('wait macaca server all ready...')
                time.sleep(1)
        print('macaca server all ready')

        # run on every device
        pool = Pool(processes=len(runs))
        for run in runs:
            pool.apply_async(self._run_cases,
                             args=(macaca_server.server_url(run.get_port()), run, cases,))

            # fix bug of macaca, android driver can not init in the same time
            time.sleep(2)

        pool.close()
        pool.join()

4、上面的多设备是采用的@tongshanshanshan老师的https://testerhome.com/topics/6810中的代码
5、Drivers().run() 中有一个 RunCases(),它主要是设置了每个设备 UI 自动化时设备信息、macaca server 端口、存放测试报告/日志/截图的路径,以及最终通过 HTMLTestRunner 来执行用例,如下代码:

class RunCases:
    def __init__(self, device, port):
        self.test_report_root = '.\\TestReport'
        self.device = device
        self.port = port

        if not os.path.exists(self.test_report_root):
            os.mkdir(self.test_report_root)

        date_time = time.strftime('%Y-%m-%d_%H_%M_%S', time.localtime(time.time()))
        self.test_report_path = self.test_report_root + '\\' + date_time + '-%s' % self.device['udid']
        if not os.path.exists(self.test_report_path):
            os.mkdir(self.test_report_path)

        self.file_name = self.test_report_path + '\\' + 'TestReport.html'

    def get_path(self):
        return self.test_report_path

    def get_device(self):
        return self.device

    def get_port(self):
        return self.port

    def run(self, cases):
        with open(self.file_name, 'wb') as file:
            runner = HTMLTestRunner(stream=file, title='自动化测试报告', description='用例执行情况:')
            runner.run(cases)

6、每个设备(进程)跑的用例是由 Driver()._run_cases() 完成的,在这里面,主要做了每个设备 UI 自动化测试的准备工作,包括每个 macaca client 的 log 配置、测试报告路径的配置、BasePage 中的 driver 配置,以及真正开始自动化测试
7、第 6 点中提到的配置之所以要在这里进行,是因为我采用了@classmethod下的 cls,这样可以解决一个进程中该类只需配置一次,后面的该类的对象可以不用再做配置,如下代码:

class Log:
    @classmethod
    def set_logger(cls, udid, file):
        logger = logging.getLogger('MACACA')
        logger.setLevel(logging.INFO)

        fh = logging.FileHandler(file)
        fh.setLevel(logging.INFO)

        ch = logging.StreamHandler()
        ch.setLevel(logging.INFO)

        formatter = logging.Formatter('%(asctime)s'
                                      + ' - %s' % udid
                                      + ' - %(levelname)s'
                                      + ' - %(message)s')

        fh.setFormatter(formatter)
        ch.setFormatter(formatter)

        logger.addHandler(fh)
        logger.addHandler(ch)

        cls.logger = logger
class ReportPath:
    @classmethod
    def set_path(cls, ps):
        cls.path = ps

    def get_path(self):
        return self.path
class BasePage(object):
    @classmethod
    def set_driver(cls, dri):
        cls.driver = dri

    def get_driver(self):
        return self.driver

8、通过第 7 点中的方法,就解决了一个进程中(但设备)每个 Page 继承 BasePage 后都有一个特定的 driver,如下代码:

class MyCarInsurancePage(BasePage):
    @teststep
    def wait_page(self):
        """以“我的车辆”的XPATH为依据"""
        try:
            self.driver\
                .wait_for_element_by_xpath('//android.widget.FrameLayout[1]/android.widget.LinearLayout[1]'
                                           '/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]'
                                           '/android.widget.FrameLayout[1]/android.widget.LinearLayout[1]'
                                           '/android.view.ViewGroup[1]/android.webkit.WebView[1]'
                                           '/android.webkit.WebView[1]/android.view.View[12]')
            return True
        except WebDriverException:
            return False

9、每条用例头带有@testcase这个装饰器,以便在 console 以及 client.log 中显示与记录以及错误后的截图等操作,如下代码:

@testcase
def test_Car_MyCarInsurEntry_Func_010(self):
    """我的车险入口验证"""
    self.home_page.click_my()

    login = LoginPage()
    if login.wait_page():
        login.input_account(VALID_ACCOUNT.account())
        login.input_password(VALID_ACCOUNT.password())
        login.login()

        gesture = GesturePasswordPage()
        if gesture.wait_page():
            gesture.skip()

        if self.home_page.wait_page():
            self.home_page.click_my()

    my_page = PlatformAppMyPage()
    my_page.wait_page()
    my_page.click_my_car_insurance()

    my_car_insurance = MyCarInsurancePage()
    self.assertTrue(my_car_insurance.wait_page())

10、每个测试步骤都带有这样的@teststep装饰器,以便在 console 以及 client.log 中显示与记录以及错误后的截图等操作,如下代码:

@teststep
def input_account(self, account):
    """以“请输入手机号码”的TEXT为依据"""
    self.driver\
        .element_by_name('请输入手机号码')\
        .clear()\
        .send_keys(account)

11、在 Decorator() 中有@testcase@teststep这样的装饰器用例执行日志打印、错误后的处理(当前只有截图),代码如下:

def _screenshot(name):
    date_time = time.strftime('%Y%m%d%H%M%S', time.localtime(time.time()))
    screenshot = name + '-' + date_time + '.PNG'
    path = ReportPath().get_path() + '\\' + screenshot

    driver = BasePage().get_driver()
    driver.save_screenshot(path)

    return screenshot

def teststep(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            log.i('\t--> %s', func.__qualname__)
            ret = func(*args, **kwargs)
            return ret
        except WebDriverException:
            log.e('\t<-- %s, %s', func.__qualname__, 'Error')
            raise WebDriverException(message=flag + _screenshot(func.__qualname__))

    return wrapper

def testcase(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            log.i('--> %s', func.__qualname__)
            ret = func(*args, **kwargs)
            log.i('<-- %s, %s\n', func.__qualname__, 'Success')
            return ret
        except WebDriverException:
            log.e('<-- %s, %s\n', func.__qualname__, 'Error')
            raise WebDriverException
        except AssertionError:
            log.e('<-- %s, %s\n', func.__qualname__, 'Fail')
            raise AssertionError(flag + _screenshot(func.__qualname__))

    return wrapper

执行过程的日志如下图:

某个特定设备的测试报告路径下的内容如下图:

HTMLTestRunner 生成的测试报告如下图:


↙↙↙阅读原文可查看相关链接,并与作者交流