BUAA OO 2024 第三单元总结

李卓远-21377153 学生 2024-05-17 00:02:56

前言

在本单元中,我们主要学习了如何编写规格并根据具体规格进行程序设计。这要求我们能够依据软件的功能需求来制定方法规格和类规格,并据此编写代码以及进行后续的测试工作。

虽然课程重点让我们掌握解析JML规格并据此准确编程的技能,然而,经过三次作业的拷打也会发现仅仅满足JML规定的功能并不足以完成作业要求。在满足基本功能的前提下,优化选择数据结构和算法以提升程序性能也是我们作业中极为关键的部分。接下来,我将从测试策略、系统架构、作业中遇到的问题以及JUnit测试等多个方面,对本单元的学习成果进行梳理和总结。

测试过程分析

本单元的作业主要是依照JML规格书来进行,因此,准确理解这些规格并根据它们来构建测试数据是确保程序正确运行的关键。下面我将讨论我对不同测试技术的理解。

黑箱测试与白箱测试

黑箱测试和白箱测试是软件测试领域中的两种常见测试方法,各有其特点和适用场景。

黑箱测试

黑箱测试,也称为功能测试或数据驱动测试,主要关注于软件的功能性能,而不关心内部实现的具体细节。测试者在不了解程序内部结构和工作原理的情况下,通过输入数据检查程序是否能输出正确的结果。这种测试方式适用于验证软件的功能需求是否得到满足。

主要特点:

  • 测试者不需要了解代码、内部结构或程序逻辑。
  • 侧重于输入和输出,确保软件行为符合预期。
  • 通常由非开发人员(如QA测试人员)执行。

适用场景:

  • 功能验证:确保软件满足用户需求。
  • 系统级测试:如系统集成测试和验收测试。
  • 非功能测试:如性能测试、安全性测试等。

白箱测试

白箱测试,也称为结构测试或逻辑驱动测试,是指在完全了解程序的内部逻辑和代码结构的前提下进行的测试。测试者利用对内部代码的知识来设计测试用例,目的是验证代码的内部结构和逻辑是否正确。

主要特点:

  • 测试者需要对应用程序的代码有深入了解。
  • 测试覆盖代码的每一个分支、循环和内部结构。
  • 侧重于代码级的详细检查。

适用场景:

  • 单元测试:开发人员通常在代码级别进行单元测试。
  • 集成测试:检查模块之间的接口是否按预期工作。
  • 路径测试:确保所有的代码路径都被测试到,查找隐藏的代码缺陷。

对比总结

黑箱测试和白箱测试各自侧重不同的测试方面,通常在软件开发过程中同时使用,以确保软件的质量从多个层面得到保障。黑箱测试更多的是从用户的角度出发,检验功能性和行为是否符合预期;而白箱测试则是从开发者的角度出发,确保代码逻辑的正确性和健売。两者相辅相成,为软件的稳定性和可靠性提供了全面的保障。

单元测试、功能测试、集成测试、压力测试、回归测试

这些测试类型是软件测试过程中非常重要的组成部分,各有其独特的关注点和目的。

单元测试

单元测试是针对软件中的最小可测试单元(通常是函数或方法)进行的测试。它主要关注每个部分的功能是否按照预期执行。这种测试通常由开发者进行,目的是确保每个部分的代码在继续构建更大的应用程序之前是正确的。我们在本单元使用JUnit进行的测试就属于单元测试。

特点:

  • 通常由开发者在编码阶段进行。
  • 使用自动化测试框架(如JUnit, NUnit, pytest等)来执行。
  • 有助于早期发现错误,减少后续的修改成本。

功能测试

功能测试主要验证软件的功能是否符合用户需求和规格说明。它关注的是用户界面和用户交互流程是否正确,以及软件是否能完成预定的功能任务。

特点:

  • 通常由QA团队进行,不需要深入了解程序的内部实现。
  • 测试用例基于用户需求和使用案例。
  • 包括用户界面测试、数据库交互测试等。

集成测试

集成测试关注多个模块或组件合并后的功能。当单独的模块被测试完毕,集成测试将它们合并起来测试它们作为一个组合的行为。这有助于发现模块间接口的问题。

特点:

  • 可以手动执行,也可通过自动化工具进行。
  • 关注点在于数据传递和模块间的控制流程。

压力测试

压力测试是指在超出正常操作条件或极限状态下测试软件的稳定性和可靠性。它用于确保软件在高负载或资源限制下的表现。本单元作业中的强测就是一种压力测试,我们的程序需要在大量数据输入的情况下保持稳定并有较好的性能。

特点:

  • 重点测试软件的稳定性和错误处理能力。
  • 通常模拟高用户负载、系统资源匮乏等极端条件。

回归测试

回归测试确保在软件修改或更新后,已有功能仍然按照预期工作。这种测试是必要的,因为新的变更可能会影响到原有的功能。

特点:

  • 可以是全面的也可以是选择性的,依赖于变更的范围。
  • 通常通过自动化测试执行,以保证每次软件更新后的稳定性。

数据构造策略

本单元作业中构造数据时,需要保证能够验证程序的正确性以及性能。对于程序正确性的测试,我们需要构造各种边界条件、特殊情况和一般情况的测试数据,以确保程序能够正确处理各种情况。我们可以利用给出的JML规格,针对每个方法的前置条件和后置条件构造测试数据。例如对于第三次作业中的sendMessage方法,笔者在互测阶段看源代码时(白箱测试),发现房间里有两位同学都忽略了对于tag为空时发红包的处理,导致程序无法通过测试。为此构造了如下数据取得双杀:

ap 1 jack 20
at 1 88
ap 2 jj 12
add_red_envelope_message 99 5 1 1 88
send_message 99
qm 1

对于程序性能的测试,可以借助数据生成器来构造具有大量成员和复杂关系的社交网络。在互测阶段测试性能时,笔者先用数据生成器生成随机数据进行初筛,接着对于一些时间复杂度较高的指令进行压力测试。比如第二次作业中的query_couple_sum指令,笔者就构造了一个包含1000个成员的网络并添加复杂的关系,并输入大量的query_couple_sum指令,以测试程序的性能。

架构设计

JML规格为我们搭建好了大致的框架,不过在具体的代码实现中考虑到性能需求,进行了一些架构上的改进。

为了更高效地实现第一次作业中的query_block_sum和query_circle指令,笔者采用了并查集的结构来进行优化。具体来说,笔者在MyNetwork中维护了一个ArrayList<HashMap<Integer, Person>> trees,其中的每个HashMap为一个连通块,我们将有关系(直接或间接)的个体放在同一个连通块中,并在添加关系和删除关系的时候对连通块进行合并和拆分。这样一来query_block_sum和query_circle的复杂度就可以降到O(1)级别,只需要分别返回trees包含的HashMap的个数,以及两人是否在同一个连通块中。对于并查集的维护,仅需在添加关系时判断两人是否已经在同一个连通块中,若不在则将两人各自所处的连通块进行合并;删除关系时的维护则复杂一些,需要删除关系后从person1出发遍历现在所有的可达点,判断其中是否仍然包含person2,如果不包含则说明删除的边是两个连通块的唯一连接边,就需要将原来的连通块拆分为两个新的连通块。具体的方法实现如下:

// 添加关系时的并查集维护
public void updateTrees(int id1, int id2) {
    HashMap<Integer, Person> tree1 = null;
    HashMap<Integer, Person> tree2 = null;
    boolean flag1 = false;
    boolean flag2 = false;
    for (HashMap<Integer, Person> tree : trees) {
        if (tree.containsKey(id1)) {
            tree1 = tree;
            flag1 = true;
        }
        if (tree.containsKey(id2)) {
            tree2 = tree;
            flag2 = true;
        }
        if (flag1 && flag2) {
            break;
        }
    }
    if (tree1 != tree2) {
        tree1.putAll(tree2);
        trees.remove(tree2);
    }
}

// 删除关系时的并查集维护
public void splitTrees(int id1, int id2) {
    HashMap<Integer, Person> tree = null;
    for (HashMap<Integer, Person> t : trees) {
        if (t.containsKey(id1)) {
            tree = t;
            break;
        }
    }
    HashMap<Integer, Person> updatedTree = new HashMap<>();
    search(id1, tree, updatedTree);
    if (!updatedTree.containsKey(id2)) {
        tree.keySet().removeAll(updatedTree.keySet());
        trees.add(updatedTree);
    }
}

此外,为了提高性能,对可查询量进行动态维护是必要的,也是本单元保证性能的一个重要方法。这要求程序在对社交网络进行改变的时候,同步维护一些可查询量,这样在查询时直接返回其值即可。这样的代价是在改变网络结构的过程中引入了额外的开销,但大多数情况下查询的次数远大于对网络进行改变的次数,因此采用动态维护是合理的。事实上,上文提到的并查集的维护就是一种动态维护的方法。具体来说,在本单元作业中,我们还动态维护了Network的coupleSum和tripleSum,每个Person的bestAcquaintance,每个Tag的valueSum, ageMean和ageVar。动态维护的具体行为则根据每个值的含义不同而涉及不同的操作。例如对于tripleSum的维护,我们在每次添加关系时我们遍历persons,如果这个人和person1和person2都有关系,就将tripleSum加一;删除关系时则同理减一。具体实现如下:

// 添加关系时的tripleSum维护
public void updateTripleSum(int id1, int id2) {
    for (Person p : persons.values()) {
        if (p.getId() != id1 && p.getId() != id2) {
            MyPerson p3 = (MyPerson) p;
            if (getPerson(id1).isLinked(p3) && getPerson(id2).isLinked(p3)) { tripleSum++; }
        }
    }
}

// 删除关系时的tripleSum维护
public void deleteTriple(int id1, int id2) {
    MyPerson p1 = (MyPerson) getPerson(id1);
    MyPerson p2 = (MyPerson) getPerson(id2);
    for (Person p : persons.values()) {
        if (p.getId() != id1 && p.getId() != id2) {
            MyPerson p3 = (MyPerson) p;
            if (p1.isLinked(p3) && p2.isLinked(p3)) { tripleSum--; }
        }
    }
}

对于coupleSum的维护,每次关系的添加或者删除涉及到至多4人,只需要讨论这四个人变动前后的bestAcquaintance情况,就能计算出coupleSum的变化。具体实现如下:

public void updateCoupleSum(int id1, int id2, int oldCouple1, int oldCouple2) {
    int couple1 = ((MyPerson) getPerson(id1)).getBestId();
    int couple2 = ((MyPerson) getPerson(id2)).getBestId();
    if (oldCouple1 == couple1 && oldCouple2 == couple2) { return; }
    if (couple1 == id2 && couple2 == id1) { coupleSum++; }
    if (oldCouple1 == id2 && oldCouple2 == id1) { coupleSum--; }
    changeCouple(id1, id2, oldCouple1, couple1);
    changeCouple(id2, id1, oldCouple2, couple2);
}

private void changeCouple(int id1, int id2, int old1, int cp1) {
    if (cp1 == old1) { return; }
    if (cp1 == id2) {
        if (old1 != id1 && ((MyPerson) getPerson(old1)).getBestId() == id1) { coupleSum--; }
    } else {
        if (cp1 != id1 && ((MyPerson) getPerson(cp1)).getBestId() == id1) { coupleSum++; }
    }
}

性能提升

本单元的作业完成的还是比较顺利的,没有出现性能问题,在三次作业中都没有出现bug。事实上为了达到足以通过强测的性能,就不能仅仅对JML进行照搬式的翻译,尽管这样可能能够保证程序的正确性,但规格的逻辑大多是基于遍历,会使得时间复杂度很高从而导致性能低下。

因此我们需要掌握规格与实现相分离的设计原则,这对于程序性能的提高是十分重要的。其核心思想是将一个系统或软件的功能需求(规格)与这些功能如何被实现的细节(实现)分开。在实际的软件开发过程中,这种分离看似冗余,其实带来了多个重要的好处:

  1. 提高可维护性:当规格和实现分离时,开发者可以专注于接口的设计,而不必担心实现细节干扰设计思路。这样,即使实现细节发生变化,只要接口保持一致,系统的其余部分不需要做出相应的改变。

  2. 增强可扩展性:通过定义清晰的接口,新的实现可以轻松地替换旧的实现,而不会影响到系统的其他部分。这样便于添加新的功能或改进现有功能,同时保持系统的稳定性。

  3. 促进团队合作:在大型项目中,不同的团队可以同时工作在不同的模块上。如果规格清晰且稳定,各团队可以独立地开发他们负责的部分,只需要确保它们符合共同的接口规范。

  4. 便于测试:规格与实现分离使得可以单独对接口进行测试,而不必担心具体的实现细节。这样可以更早地发现设计中的问题,并确保各部分的正确集成。

在实际应用中,规格通常体现为软件开发中的API(应用程序编程接口)文档或者类库的公共接口。实现则是指这些接口背后的具体代码。比如,在Java编程语言中,一个接口定义了一系列方法,具体的类则提供了这些方法的具体实现。通过这种方式,调用者只需关注接口提供的功能,而不需了解具体的实现细节。

总的来说,规格与实现的分离是实现高质量软件的关键策略之一,它不仅帮助管理复杂性,还提高了软件的灵活性和稳定性。

当然在作业中,为了达到足够好的性能,独立于规格进行更高效的代码实现更是必须的。除了并查集的使用和动态维护,笔者还应用了一些策略以提升性能。比如选取更为高效的数据结构,没有采用JML规格中的数组,而是用了ArrayList和HashMap等容器来代替,以达到更高的读取效率。另外对于query_shortest_path指令,笔者采用了双向BFS算法,以减少搜索的时间复杂度,具体代码如下:

public int queryShortestPath(int id1, int id2)
        throws PersonIdNotFoundException, PathNotFoundException {
    if (containsPerson(id1) && containsPerson(id2)) {
        MyPerson p1 = (MyPerson) getPerson(id1);
        MyPerson p2 = (MyPerson) getPerson(id2);
        if (id1 == id2 || p1.isLinked(p2)) {
            return 0;
        } else {
            boolean flag = false;
            for (HashMap<Integer, Person> tree : trees) {
                if (tree.containsKey(id1) && tree.containsKey(id2)) {
                    flag = true;
                    break;
                }
            }
            if (flag) {
                // 初始化队列和访问记录
                Queue<Person> queue1 = new LinkedList<>();
                Queue<Person> queue2 = new LinkedList<>();
                Person start = persons.get(id1);
                Person end = persons.get(id2);
                queue1.add(start);
                queue2.add(end);
                Map<Integer, Integer> visited1 = new HashMap<>();
                Map<Integer, Integer> visited2 = new HashMap<>();
                visited1.put(id1, 0);
                visited2.put(id2, 0);
                while (!queue1.isEmpty() && !queue2.isEmpty()) {
                    int result;
                    // 从队列1向前搜索
                    result = visitLevel(queue1, visited1, visited2);
                    if (result != -1) {
                        return result - 1;
                    }
                    // 从队列2向前搜索
                    result = visitLevel(queue2, visited2, visited1);
                    if (result != -1) {
                        return result - 1;
                    }
                }
                return -1;  // 如果队列为空还没相遇,返回-1
            } else {
                throw new MyPathNotFoundException(id1, id2);
            }
        }
    } else {
        if (!containsPerson(id1)) {
            throw new MyPersonIdNotFoundException(id1);
        } else {
            throw new MyPersonIdNotFoundException(id2);
        }
    }
}

private int visitLevel(Queue<Person> queue, Map<Integer, Integer> currentLevel,
                        Map<Integer, Integer> oppositeLevel) {
    int size = queue.size();
    for (int i = 0; i < size; i++) {
        Person current = queue.poll();
        int currentDepth = currentLevel.get(current.getId());
        for (Person neighbor : ((MyPerson) current).getAcquaintance().values()) {
            if (!currentLevel.containsKey(neighbor.getId())) {
                currentLevel.put(neighbor.getId(), currentDepth + 1);
                queue.add(neighbor);
                if (oppositeLevel.containsKey(neighbor.getId())) {
                    return currentDepth + 1 + oppositeLevel.get(neighbor.getId());
                }
            }
        }
    }
    return -1;  // 没有在这一层找到相遇点
}

Junit测试

本单元中我们实现了JUnit进行单元测试。在编写测试单元时,利用JML规格信息能帮助我们实现更加全面有效的测试。具体过程可以概括为如下步骤:

  1. 理解JML规格

    • 首先,需要对JML规格的要求有深入的理解,包括不变式、前置条件、后置条件。
    • 理解这些规格是如何定义操作的正确性的,以及它们是如何约束方法行为的。
  2. 从JML规格中提取测试条件

    • 根据JML注释中的前置条件和后置条件,可以定义测试用例。例如,如果一个方法有一个需要非空参数的前置条件,那么你的JUnit测试应该包括处理空参数的情况。
    • 后置条件可以帮助确定方法执行后应该达到的状态,这直接转化为JUnit测试中的断言。
  3. 设计测试用例

    • 对每个方法,基于其JML规格,设计一系列测试用例。这包括正常的用例(符合前置条件)和边界或异常用例(可能违反前置条件)。
    • 使用JUnit框架提供的断言方法(如assertEquals, assertTrue等)来验证后置条件和不变式是否被保持。

以第三次作业中对deleteColdEmoji方法的测试为例。方法的JML规格如下,笔者对每个ensures进行了编号:

/*@ public normal_behavior
    @ assignable emojiIdList, emojiHeatList, messages;
    @ 1 ensures (\forall int i; 0 <= i && i < \old(emojiIdList.length);
    @          (\old(emojiHeatList[i] >= limit) ==>
    @          (\exists int j; 0 <= j && j < emojiIdList.length; emojiIdList[j] == \old(emojiIdList[i]))));
    @ 2 ensures (\forall int i; 0 <= i && i < emojiIdList.length;
    @          (\exists int j; 0 <= j && j < \old(emojiIdList.length);
    @          emojiIdList[i] == \old(emojiIdList[j]) && emojiHeatList[i] == \old(emojiHeatList[j])));
    @ 3 ensures emojiIdList.length ==
    @          (\num_of int i; 0 <= i && i < \old(emojiIdList.length); \old(emojiHeatList[i] >= limit));
    @ 4 ensures emojiIdList.length == emojiHeatList.length;
    @ 5 ensures (\forall int i; 0 <= i && i < \old(messages.length);
    @          (\old(messages[i]) instanceof EmojiMessage &&
    @           containsEmojiId(\old(((EmojiMessage)messages[i]).getEmojiId()))  ==> \not_assigned(\old(messages[i])) &&
    @           (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i])))));
    @ 6 ensures (\forall int i; 0 <= i && i < \old(messages.length);
    @          (!(\old(messages[i]) instanceof EmojiMessage) ==> \not_assigned(\old(messages[i])) &&
    @           (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i])))));
    @ 7 ensures messages.length == (\num_of int i; 0 <= i && i < \old(messages.length);
    @          (\old(messages[i]) instanceof EmojiMessage) ==>
    @           (containsEmojiId(\old(((EmojiMessage)messages[i]).getEmojiId()))));
    @ 8 ensures \result == emojiIdList.length;
    @*/
public int deleteColdEmoji(int limit);

在编写单元测试时,需要覆盖到对每个ensures的检查,例如对于ensures8的检验:

int output = network.deleteColdEmoji(limit);

Message[] messagesBefore = network1.getMessages();
int[] emojiIdListBefore =  network1.getEmojiIdList();
int[] emojiHeatListBefore = network1.getEmojiHeatList();
int res = 0;

for (int heat : emojiHeatListBefore) {
    if (heat >= limit) {
        res++;
    }
}
assertEquals(res, output);

对于ensures1的检验:

        for (int i = 0; i < emojiIdListBefore.length; i++) {
            if (emojiHeatListBefore[i] >= limit) {
                boolean flag = false;
                for (int j = 0; j < emojiIdListAfter.length; j++) {
                    if (emojiIdListAfter[j] == emojiIdListBefore[i]) {
                        flag = true;
                        break;
                    }
                }
                assertTrue(flag);
            }
        }

在此不再一一列举。

另外单元测试中也要求我们设计出合适的测试用例,包括正常的用例(符合前置条件)和边界或异常用例(可能违反前置条件)。在作业中需要生成不同规模的数据,以覆盖尽量多的情况以及边界情况例如作业3中的case2需要覆盖到messages为空的情况,作业2中对queryCoupleSum方法的测试需要覆盖到稠密图和稀疏图的情况(因为没有看到具体的错误代码实现所以还不清楚是为什么)。

学习体会

与之前两个单元主要关注算法效率和创新性不同,本单元作业主要考察根据JML规格来编写代码和执行测试的能力。

在编写JML规格时,需要确保规格的严谨性,能够准确无误地定义方法的前提条件、副作用和后置条件,并且尽量覆盖全面的情况。还可以通过构造中间数据,或者辅助方法(不需要实现,仅用于描述规格),以利用提取共性和组合机制等策略来优化规格书,增强可读性。

在基于JML规格实现代码时,需要了解规格包含的数据,以及对这些数据进行的操作,以及这些操作的目的。掌握这些后,我们还需要做到规格与实现分离,在规格的框架外寻找最优的数据结构和算法,以满足规格的要求。

实现代码之后,我们需要重新按照规格,根据规格提出的要求和限制制定测试数据,以进一步检验代码的正确性和性能。

总的来说,通过本单元的学习,我了解并实践了基于JML规格的程序设计流程,学会了如何撰写JML规格。同时,我也掌握了根据规格实现代码以及进行正确性检验的技能。

...全文
60 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧