实现Jira上测试用例的下载和执行结果的回填

  • 测试流程

    1. 从Jira上面自动下载测试用例,标记需要执行的用例;
    2. 执行自动化测试;
    3. 回填自动化测试的结果;
  • 面临的困难

    • 之前用QC管理用例,现在切换到Jira,先前的工具无法使用,需要从新开发一套自动化工具;
    • 目前可以通过访问公司Jira的RESTful接口,可以获取Json格式的数据;
    • 尽量使其模块化、并易于维护和扩展;

  • 数据结构设计
    • TestCase:为基本的测试单元;

      • key:可以唯一标识用例;
      • folder: 用例所属的模块;
      • environment:执行环境;手工或者自动化;
      • name:用例的名字;
      • status: 目前的执行状态;Pass/Fail等;
    • TestRun:

      • key:可以唯一标识一个模块,这里使用模块的名字表示,即TestCase中的folder字段;
      • testCaseList: 同属一个模块的用例;
    • TestPlan:

      • key:可以唯一标识一个测试计划;
      • testRunList: 同属一个测试计划的模块集;
    • 可以看出,他们是层层包含的关系,一个TestPlan中包含多个TestRun,而一个TestRun中有包含一个或多个TestCase;


  • 代码实现

    • 首先定义使用到的数据字段类型,这么做是为了以后和数据库结合;
    class Field(object):
        def __init__(self, name, f_type):
            self.name = name
            self.f_type = f_type
    
        def __str__(self):
            return f"<{self.__class__.__name__}: {self.name}>"
    
    class StringField(Field):
        '''
        字符串
        '''
        def __init__(self, name):
            super(StringField, self).__init__(name, 'String')
    
    class ListField(Field):
        '''
        集合
        '''
        def __init__(self, name):
            super(ListField, self).__init__(name, 'List')
    
    • 定义模型的元类,使用它来创建所有的模型,也是为了以后和数据库结合;
    class ModelMetaclass(type):
        def __new__(cls, clsname, bases, attrs):
            # Ingore 'Model' class
            if clsname == 'Model':
                return type.__new__(cls, clsname, bases, attrs)
            Logger.info(f"Found model: {clsname}")
            mappings = dict()
            # Save all data field to one named '__mappings__'
            for k, v in attrs.items():
                if isinstance(v, Field):
                    mappings[k] = v
                    Logger.info(f"Found mapping: {k}===={v}")
            # Delete them in original class
            for k in mappings.keys():
                attrs.pop(k)
            attrs['__mappings__'] = mappings
            return type.__new__(cls, clsname, bases, attrs)
    
    • 定义TestPlan/TestRun/TestCase的父类
    class Model(dict, metaclass=ModelMetaclass):
        '''
        Subclass of dict;
        '''
        def __init__(self, **kwargs):
            super(Model, self).__init__(**kwargs)
    
        def __getattr__(self, key):
            try:
                return self[key]
            except KeyError as e:
                raise e
    
        def __setattr__(self, key, value):
            self[key] = value
    
        def __str__(self):
            return f"{self.__class__.__name__}:" + "".join([
                f"{k}:{self[k]} " for k in self.__mappings__.keys()
            ])
    
        def __len__(self):
            '''
            复写__len__()方法,使其返回TestCase的数量;
            '''
            if(isinstance(self, TestRun)):
                return self["testCaseList"].__len__()
            elif(isinstance(self, TestPlan)):
                try:
                    return reduce(lambda x,y: x+y, [v.__len__() for v in self["testRunList"]])
                except TypeError:
                    return self["testRunList"].__len__()
            else:
                return self.__len__()
    
    
    • 为Model类增加方法,使TestPlan/TestRun/TestCase继承到;
    def _save_to_excel(self, ws, header):
        if(isinstance(self, TestCase)):
            ws.append([self.get(v) for v in header])
        elif(isinstance(self, TestRun)):
            for v in self["testCaseList"]:
                v._save_to_excel(ws, header)
    
    def to_xlsx(self, folder, backup=False):
        '''
        保存到xlsx文件中;
        :param folder:
        :return:
        '''
        if os.path.isdir(folder):
            if isinstance(self, TestPlan):
                file = os.path.join(folder, f"{self['testPlanKey']}.xlsx")
            elif isinstance(self, TestRun):
                file = os.path.join(folder, f"{self['testRunKey']}.xlsx")
            elif isinstance(self, TestCase):
                file = os.path.join(folder, f"{self['testCaseKey']}.xlsx")
            # Header for xlsx file.
            file_header = list(TestCase.__mappings__.keys())
            if os.path.isfile(file):
                if backup:
                    suffix = time.strftime(r'%Y_%m_%d_%H_%M_%S')
                    copyfile(file,
                             file.split(r'.xlsx')[0].__add__(f'_{suffix}.xlsx'))
                os.remove(file)
            # new
            wb = Workbook()
            ws = wb.active
            ws.append(file_header)
            # write
            for item in self.walk():
                item._save_to_excel(ws, file_header)
            wb.save(file)
            wb.close()
        else:
            raise Exception(r'Parameter MUST be a existed folder.')
    
    def to_json(self):
        '''
        转化为json格式
        :return:
        '''
        return json.dumps(self, default=lambda obj:obj.__dict__)
    
    def filter(self, environment=('Automation', 'Manual'), caseType=None, status=('Not Executed'), owner=None):
        '''
        过滤TestCase
        :param environment: 'Automation', 'Manual'
        :param CaseType: 'GUI' or 'CLI'
        :param Status: 'Pass', 'Fail' .etc
        :param owner:
        :return: The filtered object.
        '''
        if(isinstance(self, TestCase)):
            envirFlag = self.get("environment") in environment if environment else True
            stateFlag = self.get("status") in status if status else True
            ownerFlag = self.get("owner") in owner if owner else True
            GUIType = True if(re.search(r"by gui", self.get("testCaseName").strip(), re.IGNORECASE)) else False
            CLIType = True if(re.search(r"by cli", self.get("testCaseName").strip(), re.IGNORECASE)) else False
            TypeFlag = ((CLIType and caseType == "CLI") or
                        (GUIType and caseType == "GUI") or
                        (not GUIType and not CLIType and caseType == "GUI")) if caseType else True
            if(envirFlag and stateFlag and TypeFlag and ownerFlag):
                return self
            else:
                return None
        elif(isinstance(self, TestRun)):
            return TestRun(testRunKey=self.get("testRunKey"), testCaseList= list(filter(
                lambda x:x is not None, map(
                lambda y:y.filter(environment, caseType, status, owner), self.get("testCaseList")
            ))))
        elif(isinstance(self, TestPlan)):
            return TestPlan(testPlanKey=self.get("testPlanKey"), testRunList= list(filter(
                lambda x:len(x)>0, map(
                lambda y:y.filter(environment, caseType, status, owner), self.get("testRunList")
            ))))
    
    def walk(self):
        '''
        遍历所有的TestCase;
        :return: A iterator of TestCase
        '''
        if isinstance(self, TestCase):
            return self
        elif isinstance(self, TestRun):
            for v in self.get("testCaseList"):
                yield v
        elif isinstance(self, TestPlan):
            for run in self.get("testRunList"):
                for case in run.walk():
                    yield case
    
    • 定义TestPlan/TestRun/TestCase
    class TestCase(Model):
        '''
        Basic test object
        '''
        testRunKey = StringField("testRunKey")
        testCaseKey = StringField("testCaseKey")
        folder = StringField("folder")
        environment = StringField("environment")
        testCaseName = StringField("testCaseName")
        status = StringField("status")
        owner = StringField("owner")
    
    class TestRun(Model):
        """
        Obtain a series of TestCase
        """
        testRunKey = StringField("testRunKey")
        testCaseList = ListField("testCaseList")
    
    class TestPlan(Model):
        """
        Obtain a series of TestRun
        """
        testPlanKey = StringField("testPlanKey")
        testRunList = ListField("testRunList")
    
    
    • 实现和Jira交互的部分
    class NJira(requests.Session):
        '''
        Init
        '''
        def __init__(self, username, password):
            super(NtgrJira, self).__init__()
            self.auth = (username, password)
            self.verify = False
            self._login()
    
        def close(self):
            super(NtgrJira, self).close()
            self._logout()
    
        def _login(self):
            pass
    
        def _logout(self):
            pass
    
        @classmethod
        def read_xlsx(self, file):
            '''
            从xlsx中读取一个TestPlan对象
            :param file: named after TestPlanKey
            :return:
            '''
            wb = load_workbook(file, read_only=True)
            ws = wb.active
            # Get header
            file_header = [v.value for v in ws[1]]
            # walk all test cases
            testCasesList = []
            for row in ws.iter_rows(min_row=2, max_col=file_header.__len__()):
                kw = dict(zip(file_header, [v.value for v in row]))
                testCasesList.append([TestCase(**kw)])
            testCasesList = tools.classify(testCasesList, identifier="folder")
            # Return a TestPlan
            tp = {
                'testPlanKey': os.path.basename(file).split(r'.')[0],
                'testRunList': []
            }
            for tr in testCasesList:
                kw = {
                    'testRunKey': tr[0]['folder'],
                    'testCaseList': tr
                }
                tp['testRunList'].append(TestRun(**kw))
            return TestPlan(**tp)
    
        @tools.consumingTime
        def get_test_plan(self, key):
            '''
            从Jira上下载一个TestPlan
            '''
            pass
    
        @tools.consumingTime
        def update_result_to_jira(self, testObj):
            '''
            更新结果到Jira上面
            '''
            pass
    
    • 使用到的一些自定义公共方法
    class tools:
        @classmethod
        def consumingTime(self, func):
            '''
            计算一个方法执行的消耗时间
            '''
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                sTime = time.time()
                f = func(*args, **kwargs)
                cTime = time.time() - sTime
                Logger.info(f"Execute {func.__name__} consuming {cTime} seconds.")
                return f
            return wrapper
    
        @classmethod
        def classify(self, caseList, identifier="folder"):
            '''
            为元素分类
            :param caseList: [[{'folder': 'IP', 'key': '1'}], [{'folder': 'IP', 'key': '2'}], [{'folder': 'Mac', 'key': '3'}]]
            :param identifier:
            :return: [[{'folder': 'IP', 'key': '1'}, {'folder': 'IP', 'key': '2'}], [{'folder': 'Mac', 'key': '3'}]]
            '''
            retList = []
            while len(caseList) > 0:
                # Same identifier with first case.
                sameIdentifierList =  functools.reduce(lambda x,y: x + y if x[0][identifier] == y[0][identifier] else x, caseList)
                # Delete them in basic list.
                caseList = list(filter(lambda x: not x[0] in sameIdentifierList, caseList))
                # Add to return list
                retList.append(sameIdentifierList)
            return retList
    
        @classmethod
        def bytes_to_file(self, bytes_string):
            '''
            将一个字节型的字符串转化成一个类文件对象;
            :param string:
            :return:
            '''
            fileLikeObj = tempfile.NamedTemporaryFile()
            fileLikeObj.write(bytes_string)
            fileLikeObj.flush()
            fileLikeObj.seek(0)
            return  fileLikeObj
    
    • 将其打包成一个第三方包,命名为nJira,就可以在其他地方引用了;
    • 常见的使用方法;
    import nJira
    
    # 1. 建立和Jira的连接;
    nj = nJira(username, password)
    
    # 2. 获取一个指定的测试计划的用例, 返回的是一个TestPlan对象;
    tp = nj.get_test_plan("tp1")
    
    # 3. 过滤出自己想要的测试用例,比如说:还未执行的自动化用例,这里返回一个新的TestPlan
    tp1 = tp.filter(environment=('Automation',), status=('Not Executed'))
    
    # 4. 保存到xlsx文件中,文件名为TestPlan的Key值;
    tp.to_xlsx('D:/')
    
    # 5. 测试的过程中可以把最新的自动化测试结果更新到xlsx文件中;
    
    # 6. 读取xlsx中最新的结果,选取已经执行的,更新到Jira上;
    tp2 = nJira.read_xlsx('D:/tp1.xlsx').filter(status=('Pass', 'Fail', 'N/A'))
    nJra.update_result_to_jira(tp2)
    
    # 7. 上述就是一个典型的自动化执行的流程;
    
    # 8. 还有一些其他的功能;
    #   8.1. 获取第一个测试模块;
        tr = tp['testRunList'][0]
    #   8.2. 遍历这个测试模块, 将结果标记为失败;
        for tc in tr.walk():
            tc['status'] = 'Fail'
    #   8.3. 单独更新这个模块的结果
        nJra.update_result_to_jira(tr)
    #   8.4. 单独更新某个测试用例的结果
        nJra.update_result_to_jira(tr['testCaseList'][0])
    #   8.5. 单独把这个模块保存到xlsx文件中,文件名字是模块名
        tr.to_xlsx('D:/')
    #   8.6. 统计一个TestPlan有多少个TestCase;
        len(tp)
    #   8.7. 统计一个TestRun有多少个TestCase;
        len(tr)
    #   8.8 基本上所有的方法都在TestPlan/TestRun/TestCase上通用,根据对象不同,返回的东西不同;
    #   8.9. 支持上下文管理器的写法
        with nJira(username, password) as nj:
            tp = nj.get_test_plan("tp1")   
            tp1 = tp.filter(environment=('Automation',), status=('Not Executed'))
            tp.to_xlsx('D:/')
            tp2 = nJira.read_xlsx('D:/tp1.xlsx').filter(status=('Pass', 'Fail', 'N/A'))
            nJra.update_result_to_jira(tp2)
    
    • 部分代码隐藏实现,本文主要记录大体思路;
    • 欢迎交流;
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,734评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,931评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,133评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,532评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,585评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,462评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,262评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,153评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,587评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,792评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,919评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,635评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,237评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,855评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,983评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,048评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,864评论 2 354

推荐阅读更多精彩内容