2024 BUAA OO Unit3 总结

一、测试方案

测试方式

白箱测试

白箱测试是一种测试方法,测试人员需要了解内部实现和代码结构。测试重点在于代码的内部逻辑、路径覆盖、分支覆盖等,通过检查代码的内部运行来发现潜在的问题。

我进行的白箱测试主要有两点,一是跟别人分析自己的代码实现,这样如果在对题目理解或者不同情况考虑不完全很容易发现,二是将测试数据用覆盖率运行,查看各个分支的覆盖情况,对于那些没有覆盖到的分支考虑特意构造或者进行代码走查。

黑箱测试

黑箱测试是一种测试方法,测试人员不需要了解内部实现和代码结构,而是根据软件的功能规格和需求进行测试。测试重点在于输入和输出,而非代码的内部逻辑。

黑箱测试我通过自己搭建评测机与别人对拍完成,通过大量的测试数据,实现正确性的检验。这较大程度地依赖于测试数据强度,对于那些在不同情况考虑不充分、算法实现存在的问题,黑箱测试比较容易发现。

单元测试

单元测试是一种测试方法,针对软件中的最小可测试部分(通常是单个函数或方法)进行测试。目的是确保每个单元都能独立地正常工作。单元测试通常由开发人员编写并执行。

在这个单元的作业中,对大多数查询操作,都需要动态维护,暴力地通过遍历验证正确性很简单,因此可以为那些涉及到比较复杂的动态维护的数据进行单元测试,比如 qts, qbs, qcs, qtvs 这四个查询指令。对于单元测试强度的评价指标可以是覆盖率,对于一个方法,应该要做到所有的语句都可以被覆盖到。

Junit 是很好的单元测试工具,在第一单元多项式化简时就发挥了很大的作用,在第一单元,主要通过手动构造数据计算出答案,完成一些关键算法类方法的检验,在第三单元,JML 语言为进行单元测试提供了很好的标准答案计算方式,对于那些操作比较复杂,实际实现方式与 JML 相差较大的方法进行单元测试很有效。

功能测试

功能测试是一种测试方法,验证软件的各个功能是否按照需求和规格正确运行。功能测试通常是黑箱测试,通过模拟用户操作和输入来检查系统的功能。

集成测试

集成测试是一种测试方法,测试软件模块之间的交互和集成是否正确。在单元测试之后,多个单元模块集成在一起进行测试,以确保它们在一起工作时没有问题。

在作业中,所有的核心逻辑都体现在 Network,因此只需要对 Network 进行测试就实现了集成测试,也可以通过 Junit 完成。我的实现方式是,在对图进行修改后,就将所有的数量查询类指令都进行一次。

压力测试

压力测试是一种性能测试,旨在确定软件在高负载情况下的表现。通过增加负载(如用户数量、数据量、请求频率等)来观察系统的稳定性和响应能力,找出软件的性能瓶颈和极限。

在作业中,不涉及并发,因此值得测试的主要是程序的时间性能,这与图的复杂度直接相关,一类是构拥有大量节点的稀疏图,另一类是节点数量在200左右的稠密图。这些数据可以特意进行构造。

回归测试

回归测试是一种测试方法,用于验证在软件修改或更新后,之前的功能仍然正常工作。回归测试通常在修复缺陷、功能增强或系统升级后进行,以确保新代码没有引入新的问题或破坏已有功能。

在本次作业中,对于一些重要的算法进行更改后我会进行回归测试,一般进行两类:如果涉及修改的已经完成 Junit ,对方法进行单元测试,并通过评测机进行强度较大的随机数据测试。


数据构造

为了保证代码核心逻辑的覆盖率,减少无效指令并增加图的复杂度,不能完全随机地生成数据,需要进行一定的模拟来保证数据强度。

可以对 Person 的行为进行模拟,简单记录输入后 Person 内 tag, acquaintances 的变化:

class Person:
    def __init__(self, m_id: int):
        self.id = m_id
        self.acquaintances = set()
        self.tags = {}

    def add_acquaintance(self, person_id: int) -> None:
        self.acquaintances.add(person_id)

    def remove_acquaintance(self, person_id: int) -> None:
        if person_id in self.acquaintances:
            self.acquaintances.remove(person_id)

    def add_tag(self, tag_id: int) -> None:
        if tag_id not in self.tags:
            self.tags[tag_id] = set()
        
        def remove_tag(self, tag_id: int) -> None:
        if tag_id in self.tags:
            self.tags.pop(tag_id)

    def add_person_to_tag(self, tag_id: int, person_id: int) -> None:
        if tag_id in self.tags:
            self.tags[tag_id].add(person_id)

    def remove_person_from_tag(self, tag_id: int, person_id: int) -> None:
        if tag_id in self.tags:
            self.tags[tag_id].remove(person_id)

为了数据生成的可扩展性,为每类输入进行注册,通过指令类型映射到产生指令的函数:

function_dict = {
    'ap': generate_add_person_instr,
    'ar': generate_add_relation_instr,
    'mr': generate_modify_relation_instr,
    'at': generate_add_tag_instr,
    'dt': generate_delete_tag_instr,
    'att': generate_add_person_to_tag_instr,
    'dft': generate_delete_person_from_tag_instr,
    'qtvs': generate_query_tag_instr,
    'qtav': generate_query_tag_instr,
    'qsp': generate_query_shortest_path_instr,
    'qba': generate_query_best_instr,
    'qbs': lambda: 'qbs',
    'qts': lambda: 'qts',
    'qcs': lambda: 'qcs',
    'qv': lambda: f"qv {generate_person_id(0.1)} {generate_person_id(0.1)}",
    'qci': lambda: f"qci {generate_person_id(0.1)} {generate_person_id(0.1)}"
}

在具体的数据生成上,需要考虑每类指令的特点,以 mr 为例,在大多数情况下,需要选择已经有关系的两个人的 id,这要求尽量模拟 network 运行过程中图的变化。同时,边的修改操作在大多数代码中有较大的时间开销,特别是删除操作,为了更好测性能,需要可以灵活控制删除出现的概率。

def generate_modify_relation_instr(valid_probability: float, delete_probability: float) -> str:
    def edge_exist(id1: int, id2: int) -> bool:
        if id1 not in persons or id2 not in persons:
            return False
        return persons[id1].has_acquaintance(id2) and persons[id2].has_acquaintance(id1)

    person_id1, person_id2 = generate_person_id(0.05), generate_person_id(0.05)
    if random.random() < 0.1:
        person_id1 = generate_person_id(0.0)
        person_id2 = persons[person_id1].get_a_person_in_tag()
    else:
        chance = int(valid_probability * 5.0) + 1
        if not edge_exist(person_id1, person_id2) and chance > 0:
            person_id1, person_id2 = generate_person_id(0.05), generate_person_id(0.05)
            chance -= 1
    if random.random() < delete_probability:
        if person_id1 in persons and person_id2 in persons:
            persons[person_id1].remove_acquaintance(person_id2)
            persons[person_id2].remove_acquaintance(person_id1)
        return f"mr {person_id1} {person_id2} -200"
    else:
        return f"mr {person_id1} {person_id2} {random.randint(-5, 15)}"

除此之外,还需要进行特殊情况测试,比如在产生 id 时特别产生 -1, 0, INT_MAX, INT_MIN 等边缘数据,检查是否出现了 int 类型数据运算超出范围,错误地使用合法的 id 作为非法情况的判断等问题。


DEBUG

为了获取好的时间性能,作业中大多数的查询操作我都使用了动态维护的方式,如果出现了问题,只有在查询指令出现时才会出现,这带来了两个问题:

  1. 如果查询覆盖范围不广,可能出现在动态维护过程中出现了问题,但是多个问题叠加后在查询时出现正确的结果,或者是错误没有被查询到
  2. 一旦出现 BUG,由于查询操作只有一条返回语句,面对大量输入非常难以定位 BUG 出现位置

面对这些问题,我认为在每次进行动态维护时,在程序中进行检验,一旦与期望值(通过暴力做法获得)不同,就抛出异常,并在异常中输出一些关键信息,在调试时可以通过条件断点的方式快速定位现场。

query_tag_value_sum 中对每个 Tag 内 valueSum 的维护为例,在进行每次进行更新时都做检查,一旦出现问题,就抛出异常,输出异常出现的方法以及相关信息。

public void updateValueSum(int personId1, int personId2, int deltaValue) {
    if (persons.containsKey(personId1) && persons.containsKey(personId2)) {
        valueSum += 2 * deltaValue;
    }
    if (valueSum != getExpectedValueSum()) {
    	throw new RuntimeException("ValueSum Wrong\n" + 
    					"tagId: " + id + ", id1: " + personId1 + ", id2: " + personId2);
    }
}

二、架构设计

在这里插入图片描述
本单元作业的核心逻辑是构建一个无向有环带权的关系图,可以使用邻接表的方式存储。每个节点是一个 Person,临界的节点是 Person 的 Acquaintances,对应的权值是 Person 的 values。

涉及的图操作有:增加一个节点,增加/删除一条边,修改边权,查询两个节点是否连通以及不带权最短路径的长度,查询连通块的个数等等。

采用 HashMap 实现邻接表,对图的修改操作是 O ( 1 ) O(1) O(1) 的,对图连通性的查询和寻找最短路我都使用了 bfs,可以进行封装。

/**
 * @param root    the start of the search
 * @param target  the target of the search
 * @param dist    the distance from root to node
 * @param isSearch if true, return when target is found
 * @return the distance between root and target, if target is not found, return -1
 */
private int bfs(int root, int target, HashMap<Integer, Integer> dist, boolean isSearch) {
    Queue<Integer> queue = new LinkedList<>();
    queue.add(root);
    dist.put(root, 0);
    if (isSearch && root == target) { return 1; }
    while (!queue.isEmpty()) {
        int u = queue.poll();
        for (int v : persons.get(u).getAcquaintances().keySet()) {
            if (!dist.containsKey(v)) {
                queue.add(v);
                dist.put(v, dist.get(u) + 1);
                if (isSearch && v == target) { return dist.get(v); }
            }
        }
    }
    return -1;
}

三元组数目的查询需要动态维护,当增加/删除一条边的时候会改变图中的三元组数目:

if (op == ADD || op == DELETE) {
    int tripleSumDelta = (int) persons.values().stream()
            .filter(p -> !p.equals(p1) && !p.equals(p2) && p.isLinked(p1) && p.isLinked(p2))
            .count();
    tripleSum += (op == ADD ? tripleSumDelta : -tripleSumDelta);
    lastBlockSum = -1;
}

三、方法实现

首先我对时间复杂度进行了分析,由于指令数量是 10000,因此只需要将每个方法平均的复杂度控制在 O ( n ) O(n) O(n) 即可。

为了维护图的连通性,起初我使用了并查集,但对于删除操作需要进行重建,每次重建的代价是 O ( n + m ) O(n+m) O(n+m),这里虽然看起来是线性的,但具有比较大的常数而且可能被大量调用,可能会超时,虽然可以通过设计重建标记位,对并查集进行精细的分析重建来降低平均复杂度,但我认为这在迭代上并不优雅,于是我选择了使用 bfs 计算连通性。这里时间的开销主要集中在 qbs 上,观察到只有对图进行了增加/删除边的操作才会改变 qbs 的结果,因此可以对 qbs 的结果进行复用,重新计算的次数至多为 5000,是安全的做法。

对 Tag 内两个查询,qtvs, qtav 也可以动态维护,方差的计算可以展开:
∑ i = 1 n ( age i − mean ) 2 = ∑ i = 1 n age i 2 + mean 2 − 2 ⋅ mean ⋅ ∑ i = 1 n age i \sum_{i=1}^{n}(\text{age}_i - \text{mean})^2 = \sum_{i=1}^{n} \text{age}_i^{2} + \text{mean}^2 - 2 \cdot \text{mean} \cdot \sum_{i=1}^{n} \text{age}_i i=1n(ageimean)2=i=1nagei2+mean22meani=1nagei
∑ i = 1 n age i 2 \sum_{i=1}^{n} \text{age}_i^{2} i=1nagei2 ∑ i = 1 n age i \sum_{i=1}^{n} \text{age}_i i=1nagei 可以通过每次对 tag 增加/删除人时维护实现 O ( 1 ) O(1) O(1) 的获取。

对于 qtav,需要分析会导致变化的情况:

  1. 向 Tag 内加人,valueSum 需要加上已有人跟新加人的 value 之和
  2. 从 Tag 内减人,valueSum 需要减去其他人跟删除人的 value 之和
  3. 修改 Tag 内两人的关系,valueSum 需要更新

实现与规格的分离为上述实现的优化提供了空间,同时规定了实现的效果,有助于提高软件的灵活性、可维护性和可扩展性。

在作业中,使用了下面两种方式来做到实现与规格的分离:

  • 接口与实现类::通过接口定义规格,通过实现类实现具体功能。

    // 规格(接口)
    public interface Network {
        void addPerson(Person person);
    }
    
    // 实现类
    public class MyNetwork implements Network {
        @Override
        public void addPerson(Person person) throws EqualPersonIdException {
            if (containsPerson(person.getId())) { 
            	throw new MyEqualPersonIdException(person.getId());
            }
            persons.put(person.getId(), (MyPerson) person);
            if (lastBlockSum != -1)	lastBlockSum++;
        }
    }
    
  • 抽象类与具体类:通过抽象类定义公共的规格,通过具体类实现抽象类中的方法。

    public abstract class EqualPersonIdException extends Exception {
    	public abstract void print();
    }
    
    
    public class MyEqualPersonIdException extends EqualPersonIdException {
        private static int count = 0;
        private static final HashMap<Integer, Integer> record = new HashMap<>();
        private final int id;
    
        public MyEqualPersonIdException(int id) {
            super();
            this.id = id;
            record.put(id, record.getOrDefault(id, 0) + 1);
            count++;
        }
    
        @Override
        public void print() {
            System.out.printf("epi-%d, %d-%d\n", count, id, record.get(id));
        }
    }
    

四、JUnit 使用

JML 为 JUnit 单元测试提供了很好的指示。一方面,需要测试一个方法的返回值(如果有)是否满足 JML 描述的 \result\assignable 的对象是否被正确赋值,另一方面,需要检查一个方法内那些不可修改的属性是否被错误的修改(由于对象引用等问题)。

以第二次作业中对 queryCoupleSum 方法测试为例,通过阅读下面的 JML 我们可以获得测试时需要检测的内容:

/*@ ensures \result ==
  @         (\sum int i, j; 0 <= i && i < j && j < persons.length
  @                         && persons[i].acquaintance.length > 0 && queryBestAcquaintance(persons[i].getId()) == persons[j].getId()
  @                         && persons[j].acquaintance.length > 0 && queryBestAcquaintance(persons[j].getId()) == persons[i].getId();
  @                         1);
  @*/
public /*@ pure @*/ int queryCoupleSum();
  1. 方法的返回值应该等于一个二重循环遍历的结果
  2. 不应该对 network 中的任何属性(persons)进行修改

据此,可以编写测试代码:

 public void queryCoupleSum() throws RelationNotFoundException, PersonIdNotFoundException, EqualPersonIdException, AcquaintanceNotFoundException {
    Person[] old = network.getPersons();
    assertEquals(getExpectedCoupleSum(network.getPersons()), network.queryCoupleSum());
    assertEquals(old.length, network.getPersons().length);
    for (Person person : old) {
        assertTrue(((MyPerson) person).strictEquals(network.getPerson(person.getId())));
    }
}

接下来的问题是,如何构造测试数据,也就是构建 network,这个过程主要通过调用 addPerson 和 addRelation 完成,但是,这样构造的数据存在覆盖率不足的问题。由于我们对方法实现是通过动态维护 network 中 coupleSum,因此,需要检验所有维护的代码,在我的代码中,addRelation,modifyRelation 都会修改 coupleSum,因此,构造 network 的过程还需要调用 modifyRelation。

五、心得体会

相比于第二单元诡异的多线程,第三单元在实现上的难度有了很大的降低,主要学习的还是规格和实现相分离的设计思想。JML 为提高复杂软件系统的安全性、可靠性提供了一种思路,如果可以实现 JML 规格的自动编写,也许会起到很大的作用。

在学习过程中,我初步体会了“契约式编程”这一思想,在本单元中,我不需要了解业务上的逻辑,只需要根据规格选择合适的数据结构和算法进行实现,虽然初读 JML 这种类似于离散数据的形式化表述比较痛苦,但是从第二次作业开始也适应了这种表述方式,对于它在实现规格和实现相分离上的作用有了一点感受,我认为,如果同时可以有对方法的 JML 表述和自然语言描述,那么可以很大地提高实现效率,减少出现需求理解错误的概率。

  • 8
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ChenxuanRao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值