8小时外的成长 基于百度脑图的用例增量保存 + diff 展示整体设计

陈恒捷 · 2021年06月14日 · 最后由 陈恒捷 回复于 2021年06月18日 · 14989 次阅读
本帖已被设为精华帖!

背景

现在所在公司用上了滴滴出品的 agileTC ,整体上非常好用。但有个功能大家都在呼唤:支持测试任务中修改用例集内容,并同步修改到完整用例集中。所以有了这篇文章。

先说明下具体使用的场景,让大家更理解为什么要做这个功能,目的是什么:

首先,测试集、测试任务这块是本身 agileTC 的已有设计,测试集用于存储测试用例,测试任务用于执行用例。测试任务可以在用例集中筛选/全选用例,进行每个用例的测试结果登记。但测试用例不能修改用例,要修改用例必须到用例集编辑界面(此界面无筛选功能)

在我司的实际项目使用中,常见用法是:

1、项目前期主要是少数 1-2 人参与,他们根据需求及技术方案,完成初版的完整用例,并在里面标记开发自测用例。用例评审用的也是这一版。
2、提测前开发自测时,测试会创建对应的开发自测用例并给开发参照执行、登记结果。
3、提测后,用例会分给多人执行(人数可能不止前期的 1-2 人),会通过自定义标签形式记录这批用例是谁负责。此时会通过测试任务的筛选条件来筛选到只剩下此人负责的用例,单独进行执行和登记。

实际执行中,可能会由于需求变更、部分需求细节需要补充等原因,在 2、3 步经常需要更新用例集。虽然也可以到测试集进行更新,但测试集合计会有过千个用例,而测试任务则只有数百个,因此在测试任务中就进行更新,相对而言会更为方便。

由于总用例集需要用于进行一些数据统计和二轮测试用,所以此时也需要更新。基于此,所以做这个增量保存的功能。

这块功能之前自己其实也有大致想过,但一直没有想得特别透彻,这次做的过程中也是一开始想着直接用现成的 json-patch 直接就可以满足,但自己随机测试一下就出现用例被覆盖且没有任何冲突提示的问题,所以后期干脆彻底梳理了一遍整体设计思路,再重新写代码、加单测。

这块可能其他有做基于百度脑图的 xmind 用例管理平台相关的同学也有遇到,所以在此分享一下,也期望大家有更好的思路,可以下面评论交流下。

全文较长,建议可以先看目录了解大概,再具体看内容

设计方案思考

现有技术方案是这样的:

1、现在用例集和测试任务,涉及 2 个表,test_case 和 exec_record。

2、test_case 表存储每个完整的用例集 json 内容,包括节点、标签、优先级。这个完整的 json 可以直接被脑图组件完整加载和展示。

3、exec_record 表存储每个测试任务的信息,包括筛选条件、各节点的执行结果。其中各节点执行结果存储方式是节点 id+ 测试结果,示例:


{"bv8nxhi3c800":"9","c8tws927cpc0":"9","c8tws7dgbm80":"9"}

4、在前端界面展示的测试任务内容,实际是经过 用例集表 json 根据筛选条件筛选节点->筛选后节点 json 和测试任务的测试结果进行合并 两个步骤得出。这个合并和筛选是实时的,每次刷新加载测试任务的脑图编辑界面,都会做一遍。

5、对于用例集的多人协作,滴滴本身也自带多人同时编辑用例的功能(集成在前端编辑器中,通过 websocket 实时存储 diff 和更新),但因为之前使用时发现会出现用例丢失、用例重复之类的问题,原因猜测和一些网络不稳定导致同步可能不够实时有关。由于前端编辑器没有开源,无法真正寻找到根源及修复,加上一些对脑图编辑器二次调整的需要,所以改用了另一个基于 kityminder + vue 改的脑图组件,也因此无法使用这个自带的多人同时编辑用例功能(这个功能要求编辑器实时上报用户的每一次操作改动,这个功能只有 agileTC 的脑图编辑器组件才具备)。

以前用过的另一个用例管理平台,模型会简单很多:

1、不区分用例和任务,用例本身就带有登记测试结果功能,数据库只需要存用例内容。

2、保存时,会自动根据服务端内容计算出本次保存和打开界面时版本的 diff ,然后把这个 diff 和最新用例内容进行自动合并存储。若合并发现冲突,则反馈冲突内容,让用户在前端界面手动解决冲突后存储。

在实际实践中,会出现合并冲突的情况极少,绝大部分情况都是可以直接自动合并存储的。所以大家的实际用法,也基本都是主测先创建一个简单的 xmind 并分好每个人负责的一级节点,然后各负责人员再去往这个一级下面扩展具体的用例内容。

基于上面的这些历史经验和方案,整体设计方案有两个大方向:

方向一:最小改动原则。用例集每次保存都是增量保存(包括任务中编辑、用例集中编辑),由服务端自动通过 base 版本和保存版本得出 diff ,再应用到最新的用例集中。

方向二:简化整体模型原则。直接去掉测试任务概念,回归之前用过的直接用例集保存测试结果,然后在此基础上,再应用增量保存功能。

考虑到目前大家已经有测试任务的使用习惯,且提出任务可以改用例需求的组,也比较认可测试任务这个工具。因此决定,采用方向一。

整体方案设计

整体方案看起来比较简单,改动点主要有:

1、保存从全量保存变为增量保存

2、保存时可以检测冲突

3、类似 git ,不冲突的部分可以直接保存,冲突部分再单独引导用户手动处理。

但这里面的增量保存、冲突检测、diff 展示,都是一些技术难点。

技术难点及解决

难点一:如何检测生成两个版本 json 的 diff 

分析

针对 json 的增量保存,刚好业内也有其他业务场景用到(通过增量同步 json 变更,减少网络带宽占用),目前已有两种官方正式协议:RFC 6902 (JSON Patch) 和 RFC 7396 (JSON Merge Patch)

json-patch 格式说明:https://atbug.com/json-patch/https://datatracker.ietf.org/doc/html/rfc6902(官方协议定义文档)

json-merge-patch 格式说明:https://datatracker.ietf.org/doc/html/rfc7386(官方协议定义文档)

两者对比:https://erosb.github.io/post/json-patch-vs-merge-patch/

相关 java 实现库:

https://github.com/flipkart-incubator/zjsonpatch ——仅支持 json-patch 格式

https://nicedoc.io/java-json-tools/json-patch ——支持 json-patch + json-merge-patch 格式

简单总结下,两者的区别:

  • json-patch :生成两个 json 间的变化,并把每个变化点通过操作记录的方式来记录。如:
{ "op": "replace", "path": "/baz/1", "value": "boo" }
{ "op": "move", "from": "/biscuits", "path": "/cookies" }
  • op 代表操作。支持 add、remove、replace、copy、move、test 共 6 种操作。其中 test 仅作为校验用,不表达 json 变化。
  • 针对 add、replace、test,会带上 path 和 value 字段。示例:{ "op": "replace", "path": "/baz", "value": "boo" }
    • 其中 path 内容遵循另一个叫做 json-pointer 的规范。这个规范简单的说,就是所在对象为 object 的用 key 定位,为 array 的用下标定位,父子之间用 / 间隔。举例:"/biscuits" 、"/biscuits/0/name" 、""(代表整个 json )
    • value 字段则直接就是对应的 value ,可以是单个值、json object 或者 json array 。
  • 针对 remove ,只有 path ,没有 value 
  • 针对 copy、move,用 from 指代源位置,path 指代目标位置。示例:{ "op": "move", "from": "/biscuits", "path": "/cookies" }

  • json-merge-patch:直接指示新的 json 中,各个 key 对应 value 变成的结果。无变化的不出现。如:

// 这个 patch 会把根节点下 key 为 a 的值替换为 z ,再把 c 下面的 f 删掉
{
    "a":"z",
    "c": {
        "f": null
    }
}

key 代表要应用的位置。如果有嵌套,则 patch 内也要对应嵌套。
value 代表要改为的新值。其中 null 表示删除,非 null 表示要改的值

如果遇到某个对象是 array ,由于 key 不具备指代 array 中单个元素的能力,所以 patch 中必须完整地把新的 array 完整记录进来,直接进行完整的替换。

解决方案

json-merge-patch

特点一:不会出现冲突,因为指代的就是要改成什么样了
特点二: array 需要完整记录,脑图的 children 节点是 array 类型的,而且很可能很庞大,用这个基本相当于把一级节点外的所有其他节点都全量更新了,不符合场景需要。

json-patch

特点一:原子化,每个改动对应一个 op
特点二:对 array 也可以支持(难点二会提到,实际还是要废掉这个支持,筛选后脑图 json 的下标和原始下标会有很大差异)

因此,最终选择 json-patch ,选用 zjsonpatch 这个库。

关键代码如下:

ObjectMapper mapper = new ObjectMapper();

String convertedBaseContent = convertChildrenArrayToObject(baseContent);
String convertedTargetContent = convertChildrenArrayToObject(targetContent);

JsonNode base = mapper.readTree(convertedBaseContent);
JsonNode result = mapper.readTree(convertedTargetContent);

// OMIT_COPY_OPERATION: 每个节点的 id 都是不一样的,界面上的 copy 到 json-patch 应该是 add ,不应该出现 copy 操作。
// ADD_ORIGINAL_VALUE_ON_REPLACE: replace 中加一个 fromValue 表达原来的值
// 去掉了默认自带的 OMIT_VALUE_ON_REMOVE ,这样所有 remove 会带上原始值,在 value 字段中
EnumSet<DiffFlags> flags = EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE);
JsonNode originPatch = JsonDiff.asJson(base, result, flags);

难点二:如何在应用 diff 时发现冲突,并尽可能应用无冲突的部分

分析

针对难点一使用了 json-patch ,意味着每个改动点都会有一个原子的 patch 进行记录,整体改动会是一个数组模式,每个元素对应一次原子改动

解决方案

那这个方案就变得比较简单了:一次只应用整体 patch 数组中的一次改动,如果出错,则跳过并记录为冲突,不出错,则应用并更新用例内容

关键代码:

/**
     * 逐个应用 patch 到目标 json 中,并自动跳过无法应用的 patch 。
     * @param patch patch json
     * @param baseContent 需要应用到的 json
     * @param flags EnumSet,每个元素为 ApplyPatchFlagEnum 枚举值。用于指代应用 patch 过程中一些特殊操作
     * @return ApplyPatchResultDto 对象,包含应用后的 json 、应用成功的 patch 和跳过的 patch
     * @throws IOException json 解析错误时,抛出此异常
     */
    public static ApplyPatchResultDto batchApplyPatch(String patch, String baseContent, EnumSet<ApplyPatchFlagEnum> flags) throws IOException {
        baseContent = convertChildrenArrayToObject(baseContent);

        ApplyPatchResultDto applyPatchResultDto = new ApplyPatchResultDto();

        ObjectMapper mapper = new ObjectMapper();
        JsonNode patchJson = mapper.readTree(patch);
        JsonNode afterPatchJson = mapper.readTree(baseContent);
        List<String> conflictPatch = new ArrayList<>();
        List<String> applyPatch = new ArrayList<>();

        for (JsonNode onePatchOperation : patchJson) {
            try {
                if (onePatchOperation.isArray()) {
                    afterPatchJson = JsonPatch.apply(onePatchOperation, afterPatchJson);
                } else { // 外面包一个 array
                    afterPatchJson = JsonPatch.apply(mapper.createArrayNode().add(onePatchOperation), afterPatchJson);
                }
                applyPatch.add(mapper.writeValueAsString(onePatchOperation));
            } catch (JsonPatchApplicationException e) {
                conflictPatch.add(mapper.writeValueAsString(onePatchOperation));
            }
        }

        String afterPatch = mapper.writeValueAsString(afterPatchJson);
        afterPatch = convertChildrenObjectToArray(afterPatch);

        applyPatchResultDto.setJsonAfterPatch(afterPatch);
        applyPatchResultDto.setConflictPatch(conflictPatch);
        applyPatchResultDto.setApplyPatch(applyPatch);

        return applyPatchResultDto;
    }

难点三:怎么保障无冲突的部分应用后正确

分析

某个角度来说,这个才是最难的。冲突的有人工兜底,没冲突就真的纯靠系统识别了,等到人工发现可能已经过了好多个版本,不好追溯和恢复。

需要先尽可能穷举所有可能的改动场景,并一一分析是否有问题。

首先,每次改动,从原子操作角度,可能产生的情况有:

  1. 增加新节点(包括从零编辑和通过复制粘贴得到的,因 id 值会不一样,从 json object 角度都认为是新增节点)
  2. 修改已有节点内容(文字、标签、优先级等属性)、
  3. 删除已有节点
  4. 移动已有节点(节点的 id 不变,只是位置变了)

四种场景。对应 json-patch 里面的 op :add、replace、remove、move(特别留意,这里也有暗坑,实际实现库有可能用 remove + add 来取代 move 操作,这样 add 就带上了内容的绝对值且无法比对是否和数据库一致)

考虑到多人协作,有可能 base 版本实际非数据库中实际最新版,因此每个原子操作进行分析时,增加 path 或 value 的 base 值,和数据库当前最新版一致/不一致的场景

  • add

    • 影响因素:path + value
      • path:
      • 和数据库不一致:直接提示冲突,没问题。
      • 和数据库一致(问题一):object 时 key 都是唯一的,如果被其他人删掉导致无 key 会直接产生冲突,没问题。但 array 时根据下标定位,测试任务筛选条件可能会导致 children 节点在完整用例里有 3 个,任务里只有 1 个(实际对应完整用例第三个),引起下标指向错误。
      • value:
      • 和数据库不一致:value 只会是此用户修改得出,数据库原来无值,此场景不存在。
      • 和数据库一致:只有单人修改,value 属于独占内容,不会缺失或受其他人影响,可直接应用。无问题。
  • replace

    • 影响因素:path + value
      • path(问题一):同 add 中的 path
      • value(问题二):
      • 和数据库一致:无从知道是否和数据库一致,无原有值记录
      • 和数据库不一致:可修改的有 text(文字)、priority(优先级)、resource(自定义标签)等,本质上都是节点 object 下 data 字段的子属性。因为 replace 并不会记录原值,所以可能存在 replace 后的新值覆盖了中途某人修改过的值,且不产生冲突的问题。
  • remove

    • 影响因素:path + value(原 json-path 不考虑 value ,但为了保障删除内容和删除者意愿一致,需要校验一下)
      • path(问题一):同 add 的问题。
      • value:
        • 和数据库一致:无问题,直接删除即可。
        • 和数据库不一致(问题二):可能原因是别人有改动过且先保存,或者处于测试任务筛选条件导致内容和用例全集不一致。此时可能出现错删除了作者见不到,但实际存在的子节点。
  • move

    • 影响因素:from、path。value 因为本身只是想表达移动节点意愿,可以无需校验。
      • from(问题一):同 add 的问题。
      • path(问题一):同 add 的问题。

总结起来,存在两个问题:

问题一:path 描述 array 时,数组下标由于用例可能被筛选过,只是子集,很可能不准

问题二:replace 及 remove 时,并没有记录原来的值,而是直接操作。有可能出现其实作者改动的源值和实际数据库最新值不一致的问题。

补充一个测试 java 库自动生成 patch 的规则时发现的问题:

问题三:自动生成的 patch ,可能会使用 remove + add 取代 move 。此时 add 带有的绝对值,可能会出现类似问题二的直接覆盖导致缺失问题。

解决方案

问题一:path 描述 array 时,数组下标由于用例可能被筛选过,只是子集,很可能不准

生成 patch 时,把 array 改为 object ,object 中每个子元素的 key 都为这个节点本身的 id 属性(脑图中每个节点的 id 属性会保证整个 json 全部节点中绝对的唯一)。生成完 patch 再改回来。

示例:

// 原脑图格式:
  {"root": {"data": {"id": "nodeA"}, "children": [{"data": {"id": "nodeAa"}, "children": []}, {"data": {"id": "nodeAb"}, "children": []}]}}

// 把 array 改为 object 后格式:
 {"root": {"data": {"id": "nodeA"}, "childrenObject": {"nodeAa": {"data": {"id": "nodeAa"}, "childrenObject": {}, "order": 0}}, {"nodeAb": {"data": {"id": "nodeAb"}, "childrenObject": {}, "order": 1}}}}

关键代码:

/**
     * 把 children 从 array 改为 object (array中每个元素外面多加一个 key ,key 的值为元素中的 data.id ),解决 json-pointer 针对数组用下标定位,会不准确问题
     * 示例:
     * 转换前:   {"root": {"data": {"id": "nodeA"}, "children": [{"data": {"id": "nodeAa"}, "children": []}, {"data": {"id": "nodeAb"}, "children": []}]}}
     * 转换后:    {"root": {"data": {"id": "nodeA"}, "childrenObject": {"nodeAa": {"data": {"id": "nodeAa"}, "childrenObject": {}, "order": 0}}, {"nodeAb": {"data": {"id": "nodeAb"}, "childrenObject": {}, "order": 1}}}}
     * @param caseContent 完整用例 json ,需包含 root 节点数据
     * @return 转换后 children 都不是 array 的新完整用例 json
     */
    public static String convertChildrenArrayToObject(String caseContent) {
        return convertChildrenArrayToObject(caseContent, true);
    }

    private static String convertChildrenArrayToObject(String caseContent, Boolean withOrder) {
        JSONObject caseContentJson = JSON.parseObject(caseContent);
        JSONObject rootData = caseContentJson.getJSONObject("root");

        rootData.put("childrenObject", convertArrayToObject(rootData.getJSONArray("children"), withOrder));

        // 把旧数据直接删掉,换成新数据
        rootData.remove("children");

        return JSON.toJSONString(caseContentJson);
    }


    // 递归把 array 改为 object ,key 为原来子元素的 id
    private static JSONObject convertArrayToObject(JSONArray childrenArray, Boolean withOrder) {

        // 把 children 这个 array 换成 Object
        JSONObject childrenObject = new JSONObject();

        // children 中每个子元素都变为 object
        for (int i=0; i<childrenArray.size(); i++) {
            JSONObject child = childrenArray.getJSONObject(i);
            String childId = child.getJSONObject("data").getString("id");

            if (withOrder) {
                // 加一个 order 字段,用于转回 array 时保证内部顺序一致。
                child.put("order", i);
            }
            childrenObject.put(childId, child);

            // 对 child 进行递归,把它的 children 再变成 object
            JSONArray childrenArrayInChild = child.getJSONArray("children");
            child.put("childrenObject", convertArrayToObject(childrenArrayInChild, withOrder));

            // 删掉已经不需要的 children 字段
            child.remove("children");
        }

        return childrenObject;
    }

问题二:replace 及 remove 时,并没有记录原来的值,而是直接操作。有可能出现其实作者改动的源值和实际数据库最新值不一致的问题。

解决思路:

patch 中增加原值校验相关字段。原值一致才允许应用,原值不一致则认为冲突不允许应用。

考虑到改动 json-patch 的实现库比较麻烦且容易埋坑,改为使用 test 这个 op 字段来进行校验,即原来单纯的 replace/remove 变为 test + replace/remove ,test 用于校验原有字段值。至于 test 原字段值,则通过生成的 patch 内容拿

相关代码:

 /**
 * 给所有 replace 或 remove 的 patch ,能校验原始值的,都加上 test
 * @param allPatch ArrayNode 形式的所有 patch 内容
 * @return 添加完 test 后的所有 patch 内容
 */
private static ArrayNode addTestToAllReplaceAndRemove(ArrayNode allPatch) {
    ObjectMapper mapper = new ObjectMapper();
    ArrayNode result = mapper.createArrayNode();

    for (JsonNode onePatch : allPatch) {
        // 实际应用 patch 时,不会管 replace 本身的 fromValue 字段。得手动前面加一个 test 的校验应用前的原内容是否一致,并在外面再用一个 array 包起来。
        // 即 [.., {op: replace, fromValue: .., path: .., value: ..}] 改为 [.., [{op: test, path: .., value: <fromValue>}, {op: replace, path: .., value: <value>}]]
        // 如果没有 fromValue 字段,那无法校验,直接按原来样子记录即可
        if ("replace".equals(onePatch.get("op").asText()) && onePatch.get("fromValue") != null) {
            ArrayNode testAndReplaceArray = mapper.createArrayNode();
            ObjectNode testPatch = mapper.createObjectNode();
            testPatch.put("op", "test");
            testPatch.put("path", onePatch.get("path").asText());
            testPatch.set("value", onePatch.get("fromValue"));

            testAndReplaceArray.add(testPatch);
            testAndReplaceArray.add(onePatch);

            result.add(testAndReplaceArray);
            continue;
        }

        // remove 同理,有 value 的前面都加一个 test
        if ("remove".equals(onePatch.get("op").asText()) && onePatch.get("value") != null) {
            ArrayNode testAndRemoveArray = mapper.createArrayNode();
            ObjectNode testPatch = mapper.createObjectNode();
            testPatch.put("op", "test");
            testPatch.put("path", onePatch.get("path").asText());
            testPatch.set("value", onePatch.get("value"));

            testAndRemoveArray.add(testPatch);
            testAndRemoveArray.add(onePatch);

            result.add(testAndRemoveArray);
            continue;
        }

        result.add(onePatch);
    }

    return result;
}

问题三:自动生成的 patch ,可能会使用 remove + add 取代 move 。此时 add 带有的绝对值,可能会出现类似问题二的直接覆盖导致缺失问题。

经过查看 zjsonpatch 库里 move 的实现,原理还是确认 add 和 remove 的 value 是否有完全一样,如果有,则两者合并成 move 。

之所以会无法合并,原因是前面的 array 转 object 里面加入的 order 字段会变化。

所以,可以做一次不带有 order 字段的转换,先得出 move 字段。然后再把带 order 字段转换中 path 和 move 的 from 或者 path 重合的去掉。

衍生问题:order 位置未被更新(比如原来位置 order 为 5 ,新位置 order 为 3 ,但因为 move 是原版直接挪,所以 move 完内容的 order 还是 5)。放到问题四单独分析解决

相关代码:

// OMIT_COPY_OPERATION: 每个节点的 id 都是不一样的,界面上的 copy 到 json-patch 应该是 add ,不应该出现 copy 操作。
// ADD_ORIGINAL_VALUE_ON_REPLACE: replace 中加一个 fromValue 表达原来的值
// OMIT_MOVE_OPERATION: 所有 move 操作,都还是维持原来 add + remove 的状态,避免一些类似 priority 属性值的一增一减被认为是 move 。
// 去掉了默认自带的 OMIT_VALUE_ON_REMOVE ,这样所有 remove 会在 value 字段中带上原始值
JsonNode originPatch = JsonDiff.asJson(base, result,
        EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE, OMIT_MOVE_OPERATION));

// 借助去掉 order 的内容,正确生成 move 操作
JsonNode baseWithoutOrder = mapper.readTree(convertChildrenArrayToObject(baseContent, false));
JsonNode targetWithoutOrder = mapper.readTree(convertChildrenArrayToObject(targetContent, false));

List<String> allFromPath = new ArrayList<>();
List<String> allToPath = new ArrayList<>();
List<JsonNode> allMoveOprations = new ArrayList<>();

// 需要生成 move 操作,去掉原有 flags 里面的忽略 move 标记
JsonNode noOrderPatch = JsonDiff.asJson(baseWithoutOrder, targetWithoutOrder,
        EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE));
for (JsonNode oneNoOrderPatch: noOrderPatch) {
    if ("move".equals(oneNoOrderPatch.get("op").asText())) {
        allFromPath.add(oneNoOrderPatch.get("from").asText());
        allToPath.add(oneNoOrderPatch.get("path").asText());
        allMoveOprations.add(oneNoOrderPatch);
    }
}

ArrayNode finalPatch = mapper.createArrayNode();
// 先把所有 move 加进这个最终的 patch 中
for (JsonNode movePatch : allMoveOprations) {
    finalPatch.add(movePatch);
}

for (JsonNode onePatch : originPatch) {
    // 和 move 匹配的 add 中,根节点 order 字段需要变为 replace 存下来,避免丢失顺序
    if ("add".equals(onePatch.get("op").asText()) && allToPath.contains(onePatch.get("path").asText())) {
        // 获取 add 中 value 第一层的 order 值。此时 value 实际是移动的整体 object ,order 就在第一层
        int newOrder = onePatch.get("value").get("order").asInt();
        ObjectNode replaceOrderPatch = mapper.createObjectNode();
        replaceOrderPatch.put("op", "replace");
        replaceOrderPatch.put("path", onePatch.get("path").asText() + "/order");
        replaceOrderPatch.put("value", newOrder);
        // 这种情况下就不用管 replace 的原来值是什么了,所以不设定 fromValue
        finalPatch.add(replaceOrderPatch);

        // 这个 add 的作用已经被 move + replace 达成了,所以不需要记录这个 add
        continue;
    }

    // move 的源节点删除操作,需要忽略,因为 move 已经起到相应的作用了
    if ("remove".equals(onePatch.get("op").asText()) && allFromPath.contains(onePatch.get("path").asText())) {
        continue;
    }

    // 如果 order 没变,那不去除 order 的 patch 有可能也有 move 。这个时候这个 move 需要去掉,避免重复
    if ("move".equals(onePatch.get("op").asText()) && allMoveOprations.contains(onePatch)) {
        continue;
    }

    // 其他不需要调整的,直接加进去就可以了
    finalPatch.add(onePatch);
}

问题三解决方案的衍生问题四:move 操作的元素,因为是整体内容挪过来的,会导致 order 位置未被更新(比如原来位置 order 为 5 ,新位置 order 为 3 ,但因为 move 是原版直接挪,所以 move 完内容的 order 还是 5)。

如果不用 move 操作,则会出现 add + replace(如果 order 有变更)+ remove 。

所以,解决方法只需要重新应用 replace 操作即可,并且要保障 replace 放在 move 后,避免节点已经被 move 应用失败。

由于生成的 replace 操作有可能作用在原有位置,因此匹配的 path 需要改为新位置。

相关代码:

... 前面是问题三中生成了 move patch 的相关逻辑,其中 allToPath 指代所有 move 中的 path 路径,即移动到的新位置 path
for (JsonNode onePatch : originPatch) {
    // 和 move 匹配的 add 中,根节点 order 字段需要变为 replace 存下来,避免丢失顺序
    if ("add".equals(onePatch.get("op").asText()) && allToPath.contains(onePatch.get("path").asText())) {
        // 获取 add 中 value 第一层的 order 值。此时 value 实际是移动的整体 object ,order 就在第一层
        int newOrder = onePatch.get("value").get("order").asInt();
        ObjectNode replaceOrderPatch = mapper.createObjectNode();
        replaceOrderPatch.put("op", "replace");
        replaceOrderPatch.put("path", onePatch.get("path").asText() + "/order");
        replaceOrderPatch.put("value", newOrder);
        // 这种情况下就不用管 replace 的原来值是什么了,所以不设定 fromValue
        finalPatch.add(replaceOrderPatch);
        continue;
    }

    // move 的源节点删除操作,可以忽略
    if ("remove".equals(onePatch.get("op").asText()) && allFromPath.contains(onePatch.get("path").asText())) {
        continue;
    }



    // 其他不需要调整的,直接加进去就可以了
    finalPatch.add(onePatch);
}

难点四:测试任务带有筛选条件,有可能只是完整用例集的子集。对子集的修改应用到全集时,可能部分内容会对不上引起冲突。

分析

首先,筛选条件目前只有两类:优先级/自定义标签。筛选的的子集和全集相比,在节点 data 内容层面不会有任何不同,只有在节点 children 这个数组层面会减少内容(数量上的减少,子元素内容不会少)。

内容减少,只会引起数组下标的变化,即上一个问题解决方案中 childrenObject 子元素的 order 值不正确,进而引起如果增量改动里有改动 order 会引起冲突(子集的原始值和全集里的原始值不一致)。

举例:

全集:root 节点下一级,依次有 A、B、C 节点。只有 A、C 符合筛选条件

子集:root 节点下一级,只有 A、C 两个节点

操作 1:在 C 后面增加节点。新节点会以 add 操作增加到 root 下面的 children 中,order 会为 3 甚至更大的值。因为是新增的,不会有冲突,但因为 order 可能大于原有 array 的 size ,只需要转换回 array 时只要把没应用上的都在后面补回去即可。

操作 2:在 A、C 之间增加节点。新节点 add 和操作 1,但会引起 C 节点的 replace ,order 从 2 变 3 。由于全集里 C 的 order 其实是 3,这个 replace 会在验证原始值时失败认为冲突。这个冲突其实无关紧要,加一个忽略即可。

解决方案

1、操作 1:在 C 后面增加节点。新节点会以 add 操作增加到 root 下面的 children 中,order 会为 3 甚至更大的值。因为是新增的,不会有冲突,但因为 order 可能大于原有 array 的 size ,只需要转换回 array 时只要把没应用上的都在后面补回去即可。

相关代码:

// 递归把每个 object 改回 array ,去掉 object 中第一层的 key
private static JSONArray convertObjectToArray(JSONObject childrenObject, Boolean withOrder) {
    JSONArray childrenArray = new JSONArray();
    List<String> keyMoved = new ArrayList<>();

    // object 中每个子元素,重新放回到 array 中
    for (int i=0; i<childrenObject.keySet().size(); i++) {
        for (String key : childrenObject.keySet()) {

            JSONObject child = childrenObject.getJSONObject(key);
            if (withOrder) {
                // 需要根据 order 判定原来的顺序,按顺序加进去,避免顺序错误
                if (Integer.valueOf(i).equals(child.getInteger("order"))) {
                    childrenArray.add(child);
                    keyMoved.add(key);
                } else {
                    continue;
                }
            } else {
                // 不用管 order ,直接一个一个 key 加进去就是了
                childrenArray.add(child);
                keyMoved.add(key);
            }

            // 对添加的 child 进行递归,把它的 childrenObject 再变回 array
            JSONObject childrenObjectInChild = child.getJSONObject("childrenObject");
            child.put("children", convertObjectToArray(childrenObjectInChild, withOrder));

            if (withOrder) {
                // 去掉排序用的临时字段
                child.remove("order");
            }
            child.remove("childrenObject");
        }
    }

    // ** 重点:有可能通过 move 过来的 order 值很大,最后要把剩余的 childrenObject 元素继续放到 array 里面
    for (String key : childrenObject.keySet()) {
        if (!keyMoved.contains(key)) {
            childrenArray.add(childrenObject.getJSONObject(key));
        }
    }

    return childrenArray;
}

2、操作 2:在 A、C 之间增加节点。新节点 add 和操作 1,但会引起 C 节点的 replace ,order 从 2 变 3 。由于全集里 C 的 order 其实是 3,这个 replace 会在验证原始值时失败认为冲突。这个冲突其实无关紧要,加一个忽略即可。

相关代码:

for (JsonNode onePatchOperation : patchJson) {
    try {
        if (onePatchOperation.isArray()) {
            afterPatchJson = JsonPatch.apply(onePatchOperation, afterPatchJson);
        } else { // 外面包一个 array
            afterPatchJson = JsonPatch.apply(mapper.createArrayNode().add(onePatchOperation), afterPatchJson);
        }
        applyPatch.add(mapper.writeValueAsString(onePatchOperation));
    } catch (JsonPatchApplicationException e) {
        // 检查是否是对 order 的操作。如果是,那就忽略这个冲突
        if (flags.contains(IGNORE_REPLACE_ORDER_CONFLICT) &&
                onePatchOperation.isArray() &&
                onePatchOperation.get(0).get("path").asText().endsWith("/order")) {
            continue;
        }
        conflictPatch.add(mapper.writeValueAsString(onePatchOperation));
    }
}

难点五:如何在出现冲突后进行友好标识,提高解决冲突效率

分析

首先,需要存储存在冲突的变更。从难点二的解决可知,只要从冲突 patch 列表就可以得到。只要备份里增加这个字段即可。

然后,就是怎么根据这个 patch 列表,以及冲突副本完整脑图内容,呈现变更了。

git 标记 diff 的方法,是给增加的内容(+)加上绿色底色,删除的内容(-)加上红色底色,重命名或移动文件则直接通过文件名位置, 以 old -> new 的格式标识。修改内容(replace)从底层上就直接是 删除 + 增加 来表示。

同样的方式放到脑图,增加没问题,删除只要把被删除内容加回来也没问题。没有重命名或移动文件机制,但有修改节点内容及移动节点机制。

由于脑图非纯文本文件,而是以 json 形式记录数据,脑图编辑器呈现数据的形式。diff 内容基本是 path + value 的形式记录,通过 path 不好直观看出改动位置,因此需要直接在冲突副本上通过添加标记的方式进行展示。

按照相对直觉的方式,设定如下标识:

1、增加的节点:加上绿色底色

2、删除的节点:加上红色底色

3、修改的节点(包括移动节点、修改节点自身的文字、优先级、自定义标签等):加上蓝色底色

解决方案

由于实际 json-patch 的操作,并不会认识 “节点” 这个概念,只知道 json 里的 object 及 array 。

所以,需要先判断 patch 的操作对象,是一个节点还是非节点。判断条件为操作的 path 属性。如果是节点,一定会以类似 /childrenObject/xxx 的形式结尾

相关代码:

/**
     * 根据 jsonPatch 内容,在脑图中标记变更。以节点为单位,增加的加绿色背景,删除的加红色背景,修改的加蓝色背景。
     * 特别注意,移动节点(move)因为实际节点 id 未有变化,所以也会被标记为修改
     *
     * @param minderContent
     * @param jsonPatch
     * @return
     */
    public static String markJsonPatchOnMinderContent(String jsonPatch, String minderContent) throws IOException, IllegalArgumentException {
        String green = "#67c23a";
        String blue = "#409eff";
        String red = "#f56c6c";

        ObjectMapper objectMapper = new ObjectMapper();
        // 因为 jsonPatch 是针对已经把 children 数组变为对象的 json 格式,所以要先转换下
        ObjectNode convertedMinderContentJson = objectMapper.readTree(convertChildrenArrayToObject(minderContent)).deepCopy();

        ArrayNode jsonPatchArray = (ArrayNode) objectMapper.readTree(jsonPatch);

        for (JsonNode onePatch : jsonPatchArray) {
            JsonNode operation;
            if (onePatch.isArray() && onePatch.size() == 2) {
                // 只可能是 replace 或 remove 的。前面多加了 test ,会是一个带有两个子元素的 array 。第二个才是 replace 或 remove
                operation = onePatch.get(1);
                if (!("replace".equals(operation.get("op").asText()) || "remove".equals(operation.get("op").asText()))) {
                    throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," +
                                    "正常格式在双元素 array 的第二个,应该是 replace 或 remove 操作" +
                                    "不符合的 patch 内容: %s",
                            objectMapper.writeValueAsString(onePatch)));
                }
            } else if (onePatch.isObject()) {
                operation = onePatch;
            } else {
                // 目前不会生成不符合这两种格式的 patch ,抛异常
                throw new IllegalArgumentException(String.format("此单个 patch 格式不正常,正常格式应该是双元素array或单个object" +
                                "请确认 patch 内容是通过此工具类提供的获取 patch 方法生成。不符合的 patch 内容: %s",
                        objectMapper.writeValueAsString(onePatch)));
            }

            // 先判定是否为整个节点的内容变更
            if (isNodePath(operation.get("path").asText())) {
                // 节点级别,只支持 add 、 remove 、move 。因为 replace 只改值不改key,不可能在节点级别产生 replace 操作
                switch (operation.get("op").asText()) {
                    case "add":
                        addAddNodeMark(convertedMinderContentJson, operation, green);
                        break;
                    case "move":
                        addMoveNodeMark(convertedMinderContentJson, operation, blue);
                        break;
                    case "remove":
                        addRemoveNodeMark(convertedMinderContentJson, operation, red);
                        break;
                    default:
                        throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," +
                                        "正常的节点级别 patch ,op 应该是 add、move、remove 其中一个" +
                                        "不符合的 patch 内容: %s",
                                objectMapper.writeValueAsString(operation)));
                }
            } else {
                // 非节点级别变更,都将它标记为 修改内容 即可。不应该出现 move 节点属性的动作
                switch (operation.get("op").asText()) {
                    case "add":
                        addAddAttrMark(convertedMinderContentJson, operation, blue);
                        break;
                    case "replace":
                        addReplaceAttrMark(convertedMinderContentJson, operation, blue);
                        break;
                    case "remove":
                        addRemoveAttrMark(convertedMinderContentJson, operation, blue);
                        break;
                    default:
                        throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," +
                                        "正常的非节点级别 patch ,op 应该是 add、replace、remove 四个其中一个" +
                                        "不符合的 patch 内容: %s",
                                objectMapper.writeValueAsString(operation)));
                }
            }
        }

        return convertChildrenObjectToArray(objectMapper.writeValueAsString(convertedMinderContentJson));
    }

总结

由于篇幅所限,其实里面有些小的问题解决并没有列在上面的技术难点里面(比如应用变更时,如果 replace order 操作出错,可以忽略)。整体改动大概花了 4 人天左右,而且中途也写了不少单测代码来保障每次改动都不会影响已有功能(行覆盖率达到 94%,只有少量格式不对抛异常的逻辑没有覆盖)。

此次场景比较复杂,已经尽自己所能,用相对靠谱的分析方法列举出所有可能的场景,并进行对应处理。但是否靠谱还需要靠实践检验,预计节后会上线此功能,届时再看看实际使用的效果。

如果有其它同学也做过类似的功能,有更好的算法或者思路,也欢迎直接评论分享交流下

开源

目前服务端相关的代码改动及配套单测,已提交 PR 给官方。地址:https://github.com/didi/AgileTC/pull/93

增量生成、应用、标记的逻辑全部在 case-server/src/main/java/com/xiaoju/framework/util/MinderJsonPatchUtil.java 这个工具类

配套单测在 case-server/src/test/java/com/xiaoju/framework/util/MinderJsonPatchUtilTest.java

如果有需要的,可以按需自取哈。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 33 条回复 时间 点赞

解决多个连带问题,辛苦辛苦~

有个功能大家都在呼唤:支持测试任务中修改用例集内容,并同步修改到完整用例集中

这一系列的问题是怎么引起的呢?考虑下,是不是因为这个需求不合理?

恒温 将本帖设为了精华贴 06月14日 21:09
Thirty-Thirty 回复

好问题,我这里补充说明下吧。

首先,测试集、测试任务这块是本身 agileTC 的已有设计,测试集用于存储测试用例,测试任务用于执行用例。测试任务可以在用例集中筛选/全选用例,进行每个用例的测试结果登记。但测试用例不能修改用例,要修改用例必须到用例集编辑界面(此界面无筛选功能)

在我司的实际项目使用中,常见用法是:

1、项目前期主要是少数 1-2 人参与,他们根据需求及技术方案,完成初版的完整用例,并在里面标记开发自测用例。用例评审用的也是这一版。
2、提测前开发自测时,测试会创建对应的开发自测用例并给开发参照执行、登记结果。
3、提测后,用例会分给多人执行(人数可能不止前期的 1-2 人),会通过自定义标签形式记录这批用例是谁负责。此时会通过测试任务的筛选条件来筛选到只剩下此人负责的用例,单独进行执行和登记。

实际执行中,可能会由于需求变更、部分需求细节需要补充等原因,在 2、3 步经常需要更新用例集。虽然也可以到测试集进行更新,但测试集合计会有过千个用例,而测试任务则只有数百个,因此在测试任务中就进行更新,相对而言会更为方便。

由于总用例集需要用于进行一些数据统计和二轮测试用,所以此时也需要更新。基于此,所以做这个增量保存的功能。

以前也想过这些,结果想出来这个用 Git 、自动化测试框架和报告管理手工用例,因为觉得这些问题已经在其他 IT 技术领域有很好的处理方式了,如果使用者有技术背景、或者在学习上愿意投入,这是个非常大的可利用的优势。

黑水 回复

好奇问下,你们现在手动测试用例是怎么编写和记录测试结果的?我们这边基本都是 xmind 为主,少数无界面可以直接 api/单测测试的,就直接写代码/上对应测试平台写用例。

Thirty-Thirty 回复

哈哈,其实我 title 确实是测试开发。

非常赞同你对 “测试开发” 和 “自动化测试” 两个概念的阐述。在我们公司,自动化测试要求是社招的所有 title 带有 测试 的岗位都需要具备的。

最后这个 “而且要求具备对测试流程的理解”,也非常认同。确实这个需求其实不是一开始就按这个需求方案做的,甚至因为这个方案的复杂性,想过各种 “绕过” 的手段。但最后发现确实没有更好的手段,所以才最终选择啃下这块硬骨头。

学习下~
之前我也准备转到 agileTC,不过由于离职的太多,搁浅了。
另外由于项目要求必须向下兼容,所以目前 git 管理用例还行。

黑水 回复

大概看了下这篇文章,好像没提及 xmind 这种格式?

非常认同你的观点,能用已有成熟方案的话,尽量用,避免自己造轮子。但 git 可能在这个领域,经过我的思考,不一定适用。git 适用的领域主要是纯文本,对二进制文件相对比较弱,基本就是全量更新。

xmind 文件本质上比较接近于 xml ,但实际存储格式是非纯文本,在 git 里面基本都是全量更新。而在线脑图 kityminder 可以将其以纯文本 json 格式存储,理论上具备可行性。但实际上,这个 json 和脑图看到的内容,直觉上还不大能直接关联起来。

举个例子,脑图 json 如下:

{
    "root": {
        "data": {
            "text": "百度产品"
        },
        "children": [{
            "data": {
                "text": "1",
                "resource": ["自定义标签"],
                "priority": 1
            },
            "children": [{
                "data": {
                    "id": "cc3eeotpqsg0",
                    "created": 1623679673934,
                    "text": "1.1"
                },
                "children": []
            }, {
                "data": {
                    "id": "cc3eepzptug0",
                    "created": 1623679676474,
                    "text": "1.2"
                },
                "children": []
            }]
        }, {
            "data": {
                "id": "cc3eerciow00",
                "created": 1623679679425,
                "text": "2",
                "layout_mind_offset": {
                    "x": 405,
                    "y": 80
                }
            },
            "children": []
        }]
    },
    "template": "default",
    "theme": "fresh-blue",
    "version": "1.4.43"
}

对应实际脑图是这样的:

这还只是一个很简单只有 3 层、5 节点的脑图。如果是日常过千个用例,数千个节点的脑图,出现冲突看着 git 里面的 diff ,真的会一脸懵逼呀。

同时还有一个问题,就是测试任务场景里,很可能用户看到的只是子集而非全集,这个时候可以理解为看到的版本和实际用例版本不一样。git diff 是基于行号 + 改动内容前后数行内容来定位的,如果是测试任务的场景,很容易因为改动内容前后数行和全集不一致,直接理解为冲突。

陈恒捷 回复

没有用脑图,层级和节点是用单元测试框架和 HTML 报告体现的,测试过程每个人本地有一个版本。

为恒捷点赞,支持测试任务中修改用例集内容,并同步修改到完整用例集中(也提了这个单向同步的需求)。看来所有测试都有这个需求。
学习了,我们那边是做了一个表单展示,模块层级也就是树,未来可能也要支持脑图,正好看看。

Ouroboros 回复

我们之前也是直接 git 管理 xmind,但发现很容易后面改动后的用例就没再同步了,而且也不好直接统计通过率、用例数什么的,所以用了 agileTC

黑水 回复

手工的测试用例也是用单元测试框架来做吗?

是的,虽然用单元测试框架但可以没有单元测试~

如果有副标题的话,或许可以叫做 “测试开发工程师的 4 天”。当然,楼主未必就是这个职位。
还没弄懂 “测试开发” 跟 “自动化测试” 的同学,通过本文可以加强理解。测试开发就是按需求开发 (侧重开发) 测试框架/工具,或对现有测试框架/工具进行二次开发以便更贴合需求,本文属于后者。自动化测试就是使用 (侧重使用) 自动化框架/工具 (可以来自第三方,可以来自测试开发,也可以是自己开发) 将测试手工执行转化 (侧重转化) 为自动执行。
写本文需要耐心,读本文也需要些耐心,读完会明白测试开发工程师不但要求具备开发技能,而且要求具备对测试流程的理解,同时兼备人员的缺口决定了这个职位的工资。

陈恒捷 回复

就帖子里写的那样

下午体验了一波,能花式编写用例,几乎覆盖了 xmind/excel 的优点, 大赞。 无论是用来用例评审还是指定任务执行都不错,但任务系统稍微单一了些,只能用例集下新建任务,无法单任务关联多用例集,没有看板/用户权限差异化... @ 楼主的公司是只拿来做用例编写使用嘛

小怪兽 回复

嗯嗯,我们主要用来写用例、评审用例和执行用例,作为测试平台的用例管理模块,也用于作为测试过程数据中测试进度、用例数量的量化指标来源。

不同项目组在整个测试流程上还是有一些差异的,暂时还没统一,所以暂时也不需要太复杂的,我们更崇尚 less is more ,专注做好一个功能就足够了。用户权限这个官方的最新版已经加上了,看板这个,我们内部有用另外的项目流程管理平台,看板用那里的足够了。

至于单任务关联多用例集,以前有想过,但其实维护成本和使用成本都很高(要不用例集拆得很碎方便组合,要不任务关联能力要很强能任意选择部分用例)。实际上,只要有历史用例集和复制粘贴,就可以完成类似目的了。

和我的解法基本一致,握手。

最后展示标记这里,由于我是做纯粹的格式化 diff 工具,最后放在 monaco 编辑器上进行染色标记,大体思路是:

zjsonpatch 是支持例如 jackson 里面的 jsonnode 进行对比,但是 jackson 在解析的过程中没有携带每一个 node 的坐标信息,所以这里首先通过继承,给所有的 jsonNode 带上 start end 的坐标字段,然后直接用 antlr 重写了一个 json parser,自动造一套 jackson 的 jsonnode(相当于重写了 jackson 的 parser),在解析过程中带上每一个节点在字符串上的 [start,end] 坐标,这样最后拿到 diff 之后给的 jsonPointer 就可以获取到每一个节点的坐标信息

而且这样还有一个好处,就是支持 jsonpath 语法,如果要做例如去噪对比,可以通过 jsonpath 进行过滤掉一些节点

eryi 回复

哈哈,看来大家都殊途同归。

下面介绍的 start end 坐标字段,有点没太理解具体是什么的坐标。可以写个示例么?目前节点坐标信息我是通过 json-patch 里面的 jsonPointer 直接拿的。对于子节点数组型用下标会不准,我是先把 array 都转成 object ,key 是节点里 data.id 的值。

服务端相关的代码改动及配套单测,已提交 PR 给官方。地址:https://github.com/didi/AgileTC/pull/93

增量生成、应用、标记的逻辑全部在 case-server/src/main/java/com/xiaoju/framework/util/MinderJsonPatchUtil.java 这个工具类

配套单测在 case-server/src/test/java/com/xiaoju/framework/util/MinderJsonPatchUtilTest.java

刚好,我做的功能也是这块的,只不过我的实现思路不是这个样子的,讲一下我这边的实现方案:
我这边开始用的也不是百度脑图
一开始我们这边由于背景原因选择的是 xmind 编写用例,所以最初的版本是使用 xmind 导入进去的,所以用例的格式并不是 json,是解析了 Xmind 之后再存储的一个自定义的 testCase 对象,存储到数据库中就是一行记录,所以在最开始的部分就和楼主设计不同,后面使用线上思维导图的库是开源的 jsMind,改了改源码,加了部分功能。
整体思路就是先解析,然后保存成自己的数据格式,这样就可以兼容很多方式的用例,因为每个人习惯不同嘛。
这样就可以想怎么改就怎么改了

陈恒捷 回复

start end 用来定位某个节点在原始字符串中的 index 位置,前端就可以根据 index 对差异节点进行染色,最后会有这样的一套展示效果

晚点单独开个帖子说一说

eryi 回复

哦哦,明白。

和我这块确实不大一样。我这里的 diff 除了展示,还需要具备给用户直接复制粘贴,重新应用的能力,所以展示方式使用了脑图,而不是 json 之类的纯文本展示方式。

我的展示效果大概是这样的:

我也是用 jsMind 改的 ,比较轻量,和 XMIND 一样,保持脑图本真的简洁,叶子作为用例,路径当模块,没有优先级等


快捷键同 xmind
https://www.oschina.net/news/146318/itest-10-0-0-rc-released

codes 回复

不同团队可能需求不一样。我们这的优先级、自定义标签,用得还挺多的。

比如写用例的时候,有些地方需要用例评审时特别关注,就打个【待确认】标签。
比如给开发自测用例的时候,一般就直接给 P0 优先级的

我 tapd 用了比这个香啊

开普敦人 回复

tapd 也支持 xmind 测试用例?看介绍好像没提及。

我们也居于 百度脑图做了大量二开。做了 1 年多了。我们跟滴滴脑图存储上不同,滴滴直接全 json 保存,我们是每个节点都存储 1 条数据,并存储节点的关系,父节点 ID 等。 同样的我们也想做增量保存,但一直没找到好的方式。但我们的规则 json diff 等其实都无法满足我们的真实需求。原因,我们增量 1 来不想每次给到后端的 json 都是整个脑图 2 来我们想通过增量做一些统计如某个 QA 今天新建多少用例,修改多少用例等。 最后我们用了一个不算增量的增量,就是每个节点编辑新建等时加上更新的时间和用户,然后还是全 Json 给后端,后端一个一个节点保存。

米阳MeYoung 回复

好奇问下,你们当时数据存储设计,把用例拆那么细的初衷是?每次读取完整脑图都需要各种连表查询重新组合,会不会对数据库造成比较大的负载?

文章中的思路应用到你提到的 “不想每次给到后端的 json 都是整个脑图”,其实也是可以的,json-patch 的生成改为由由前端来做就行了,这样就可以只提交 patch 内容给服务端(json-patch 和 json-merge-patch 两个格式设计初衷就是做 json 数据增量保存,节省大型 json 修改保存时的带宽用的)。至于统计每个人每天新建、修改了多少用例,个人理解已经不是增量保存的范畴了,应该用你说的加更新时间和更新用户信息会更好。

我当时整个 patch 生成的逻辑全部放服务端,主要 2 个原因。一个是找到的库是 java 的,另一个是冲突时后端有全量 json ,方便做整体备份避免丢失用例。从我们引入 agileTC 进行落地的整个经历看,对于用例平台来说,丢失写过的内容(哪怕是引起冲突无法保存的内容),是用户最无法容忍的问题,所以要尽一切努力去存储用户期望保存的内容。

陈恒捷 回复

每个节点单独存储,目的是便于扩展字段。 用例评审和测试计划都是用例的子集,我们也可以根据所选的节点来拼接处当前的整条链路等,获取规划用例时,根据选择的父节点就能把该父节点下所有子节点选中等等。 我们也会测试报告中去展示,哪条链路测试失败等的记录。 这种显示局部或者某条链路就很方便。

我们一开始弄自动保存,也出现过用例丢失、切换过快时互相覆盖等,我们后面改了非常简单的一种思路,保留最新历史 20 份的保存数据,丢失时,直接从历史恢复。

米阳MeYoung 回复

明白。

避免用例丢失思路我们是一致的,都是保存历史记录,并支持一键恢复。

可以随时选择任意父节点进行拼接这个挺好的,可以像前面其中一位同学说的,可以任意组合用例库里的用例形成自己的测试计划。从目前大家使用上来说,这种场景比较少,偶尔遇到直接在脑图里复制粘贴也基本可以满足。后面如果需要这方面的特性,我们也参考下这个思路。感谢分享。

米阳MeYoung 回复

也特别感谢你之前开源的脑图编辑器,我们内部的 vue 脑图编辑器就是基于你开源的版本进行调整的,省了很多力。

目前已经封装成了独立的 vue 组件,方便各个 vue 工程直接接入。目前在内部申请开源中。

12楼 已删除
陈子昂 回复

谢谢

26楼 已删除
陈恒捷 回复

EN 是这个理,后续我们也加上 优先级

陈恒捷 将本帖设为了精华贴 02月23日 07:38
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册