一、测试方案
测试方式
白箱测试
白箱测试是一种测试方法,测试人员需要了解内部实现和代码结构。测试重点在于代码的内部逻辑、路径覆盖、分支覆盖等,通过检查代码的内部运行来发现潜在的问题。
我进行的白箱测试主要有两点,一是跟别人分析自己的代码实现,这样如果在对题目理解或者不同情况考虑不完全很容易发现,二是将测试数据用覆盖率运行,查看各个分支的覆盖情况,对于那些没有覆盖到的分支考虑特意构造或者进行代码走查。
黑箱测试
黑箱测试是一种测试方法,测试人员不需要了解内部实现和代码结构,而是根据软件的功能规格和需求进行测试。测试重点在于输入和输出,而非代码的内部逻辑。
黑箱测试我通过自己搭建评测机与别人对拍完成,通过大量的测试数据,实现正确性的检验。这较大程度地依赖于测试数据强度,对于那些在不同情况考虑不充分、算法实现存在的问题,黑箱测试比较容易发现。
单元测试
单元测试是一种测试方法,针对软件中的最小可测试部分(通常是单个函数或方法)进行测试。目的是确保每个单元都能独立地正常工作。单元测试通常由开发人员编写并执行。
在这个单元的作业中,对大多数查询操作,都需要动态维护,暴力地通过遍历验证正确性很简单,因此可以为那些涉及到比较复杂的动态维护的数据进行单元测试,比如 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
为了获取好的时间性能,作业中大多数的查询操作我都使用了动态维护的方式,如果出现了问题,只有在查询指令出现时才会出现,这带来了两个问题:
- 如果查询覆盖范围不广,可能出现在动态维护过程中出现了问题,但是多个问题叠加后在查询时出现正确的结果,或者是错误没有被查询到
- 一旦出现 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=1∑n(agei−mean)2=i=1∑nagei2+mean2−2⋅mean⋅i=1∑nagei
∑
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
,需要分析会导致变化的情况:
- 向 Tag 内加人,valueSum 需要加上已有人跟新加人的 value 之和
- 从 Tag 内减人,valueSum 需要减去其他人跟删除人的 value 之和
- 修改 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();
- 方法的返回值应该等于一个二重循环遍历的结果
- 不应该对 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 表述和自然语言描述,那么可以很大地提高实现效率,减少出现需求理解错误的概率。