文章目录
0. 作业简述
利用给出的JML规格,维护一个简单的社交网络。
1. 测试过程
黑盒与白盒
(我曾经以为白盒就是读代码,黑盒就是随机。)
黑盒与白盒在表现上的区别是是否已知代码的实现。而二者的设计角度不同:黑盒测试是面向用户的,用户只关心程序期望的功能和不期望的副作用,而设计者则会更多的关注自己的实现逻辑上是否有漏洞。
我认为,本单元和OO-pre中的Junit对评测效果的两种检查思路,就是“黑盒”与“白盒”的区别。
在本单元中,由于使用JML,开发者可以逐字依照规格设计评测用例,达到对规格的100%覆盖,是“黑盒”。而在OO-pre中,开发者依照自己的设计,构造测试保证每个分支能够正确地进入,且分支内部逻辑正确,是“白盒”。
这三次作业中,我对公测对Junit的检查不适应,主要原因是我以设计者而非用户的身份进行设计。例如,在第九次作业的queryTripleSum测试中,我非常清楚地知道自己的设计不可能会对网络中的某个Person进行改变,因此设计测试时根本不会逐一比对方法调用前后的每个Person的id、年龄等是否被篡改。但对于黑盒测试,用户不关心实现,因此就需要依照规格进行相应检查。
二者各有利弊,但基本上是相互补足的关系,相关分析如下表:
| 分析 | 黑盒测试 | 白盒测试 |
|---|---|---|
| 特点 | 面向需求,但对于代码覆盖强度不够 | 个性化,但缺少规格边界情况检查 |
| 适用bug | 设计与需求不符 | 细节或逻辑漏洞 |
测试过程
单元测试、集成测试、功能测试
这是一个自底向上的测试过程。单元测试是对单一模块进行测试,主要是白盒测试;集成测试则是对模块间的交互进行测试;而功能测试则是对整个系统进行黑盒测试。
这一测试流程主要应用于复杂的系统上。由于本单元的架构并不复杂且都有JML的约束,并且类与类的交互基本上就是调用关系,因此这一测试流程体现地并不明显。
但是,不按照自底向上的测试流程**会对后续的debug工作带来麻烦。**例如,我设计了一个数据结构(泛型)“UndirectedGraph”作为persons的容器,并且在hw11提供了一个无权最短路径的算法。由于bfs逻辑写错了导致出错,且没有做单元测试,后续在随机生成的数据测试中发现错误,再反向缩小范围保证前序操作正确,定位到这个bug,付出的代价要比事先单元测试发现bug要大。因此得到的教训是,对于较复杂的设计,要阶段性地对相应层次进行检查,避免bug范围扩大。
压力测试
压力测试针对性能和边缘性的错误,在这三个单元中都有体现。测试思路是构造达到或略微超出数据限制的数据集,评估程序能否在相应时间完成。(在多线程中还要查看是否出现竞争冒险或是饥饿等现象)
压力测试也和实现有关。例如,我使用的并查集,加边复杂度O(1),而删边是O(|V|),那么需要大概估算是用时达到峰值的数据分布:每加入一条边(ar操作)需要同时加入一个点(ap操作),占用2条指令。假设进行
x
x
x次插入操作和
y
y
y次删除操作,通过求解规划问题:(易知
∣
V
∣
∼
∣
E
∣
|V|\sim|E|
∣V∣∼∣E∣,所以估算
∣
E
∣
∼
x
−
y
2
|E|\sim\frac{x-y}{2}
∣E∣∼2x−y)
m
a
x
{
x
⋅
1
+
y
⋅
x
−
y
2
}
max \lbrace x \cdot 1 + y \cdot \frac{x-y}{2} \rbrace
max{x⋅1+y⋅2x−y}
s
.
t
.
2
⋅
x
+
y
=
10000
s.t. \space\space\space\space 2 \cdot x + y = 10000
s.t. 2⋅x+y=10000
解得
x
≈
4167
x\approx 4167
x≈4167,即大约4000次插入(人和关系)操作即可达到峰值。
回归测试
回归测试是在修改后重新测试之前的测试点。我认为,这类测试针对修改和重构。在OO-pre中,由于设计不充分,迭代时经常修改架构,有一次改了一个数据结构(将HashSet改成ArrayList)导致出现重复元素,结果强测中在上一次的功能中爆点。在OO课程中,由于吸取教训,我考虑到了修改带来的风险以及回归测试的必要。在修改前一次的代码后,我都会跑一下前一次的强测点和出过错误的点。
数据构造策略
由于Junit测试已经在前文和后文中分析,这里的数据构造针对的是功能和性能测试。
理想的数据构造分为如下阶段,但有时出于时间限制,可能中间某些步骤不是特别全面。

总体思路就是先具体后泛化。注意的有如下几点:
- 数据的正交化。进行图修改后要即时通过查询指令(qbs,qcs等)返回操作结果,避免错误的累积或者“负负得正”导致bug被隐藏。
- 随机数据的重要性。虽然随机数据可能不够强,但是能够发现一些意想不到的错误。例如对于
best acquaintance的维护,我采用TreeMap维护并定义了比较器:
public int compare(Person p1, Person p2) {
int valueCmp = value.get(p2.getId()) - value.get(p1.getId());
if (valueCmp != 0) {
return valueCmp;
}
return p1.getId() - p2.getId();
}
这里比较时直接将id相减但没考虑溢出的情况。这里如果让开发者自己构造数据是很难发现bug的,而数据生成器使用random()在整数范围内生成id,则有概率使bug暴露。这里要感谢同学的评测机。
2. 架构设计
架构与算法主要依据时间复杂度。只需要保证单次操作的时间复杂度小于 O ( n α ) , α > 1 O(n^\alpha), \alpha > 1 O(nα),α>1即可。
图的设计
由于Java没有图容器,我仿照Java数组等容器的实现,自己实现了一个泛型UndirectedGraph<K, V>,能提供加点、加边、删边、判断连通性、求三阶完全图个数、连通分支个数、无权最短路径等方法,并且配备了一个遍历所有可达点的迭代器。
利用泛型的好处是解耦,提高可扩展性,使代码思路清晰,简化Network的设计。但是风险是如果需要在遍历person时对它进行某些操作,那么泛型就无法实现这种功能。
UML类图如下:

不相交集的实现
基于《数据结构与算法分析:C语言实现》一书,实现了按秩合并、路径压缩。
但是,标准的并查集没有实现删除某对关系,而一般竞赛中对于删边的实现仅限于删除最近加入的关系,且不允许路径压缩(路径压缩是利用等价关系的传递性,改变了原来图中的连接关系),这会给性能带来较大的影响。因此,只能自行设计删边算法。
相关伪代码如下:(CSDN不能渲染带包的LaTeX,只能粘截图)

这样删边的同时也进行了数据压缩,时间复杂度
O
(
n
)
O(n)
O(n)。
连通分支和K3子图数目的维护
在加边和删边时维护即可。对于K3的维护,我们定义getCountOfPotentialK3,用于返回两个结点中长度为2的路径个数。在加边时加上这个数,删边时减去这个数即可,时间复杂度
O
(
n
)
O(n)
O(n)。
queryTagValueSum的实现
如果维护TagValueSum会极大增加设计复杂度,带来风险,所以我使用动态查询。但是与JML不同,我使用bfs算法对边遍历:
//JML版本,O(|V|^2)
for (Person person1: persons.values()) {
for (Person person2: persons.values()) {
if (person1.isLinked(person2)) {
res += friend.queryValue(person1);
}
}
}
//我的版本,O(|V|+|E|)
for (Person person: persons.values()) {
for (Person friend: ((MyPerson) person).friends()) {
if (persons.containsKey(friend.getId())) {
res += friend.queryValue(person);
}
}
}
虽然都是二重循环,但是由于指令条数不大于10000,且load_Network指令最多100各结点,决定了点和边都不超过10000数量级,即不会出现大稠密图。因此 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)可以接受。
bestAcquaintance和coupleSum的实现
由于对每人连接的边都要支持插入、删除、修改和查询最大值四项操作,常用数据结构中只有平衡树能够是所有操作都在
O
(
l
o
g
n
)
O(log n)
O(logn)以内。因此使用TreeSet维护每个好友的value排名,并且自定义比较函数,以value,id作为第一、第二关键字。
coupleSum的查询可以使用延迟访问的优化。即设置一个脏位,如果修改了任意一条边(包括加入、删除),就置位。如果脏位为0,表示可以使用上一次查询的结果。这可以优化大量连续查询的情况。
3. 性能问题与修复
性能优化
由于设计算法时已经考虑了时间复杂度,因此没有出现性能问题。
相关的优化在上一章“架构设计”中已经阐明了,此处不再赘述。
规格与实现分离
规格只是对方法行为的约束,没有要求方法的具体实现。
这带来了开发的灵活性和高效性。一方面,用户可以拿定义好的接口实现更上层的逻辑,而不必关心底层实现,另一方面而开发者可以在规格限定的条件下实现多个版本不断优化。
在实现规格时,不能无脑翻译规格,否则用户有定义规格的精力,完全可以自己去实现。因此,实现者从规格中获取的只是行为的约束,具体的实现还要自行设计算法。
规格与实现的分离也方便了验证。验证需要完全依照规格,往往和实现形成两种不同的思路,这样能够相互印证。
4. Junit测试
Junit测试分为两个逻辑:构造样例和验证正确性,二者实现了解耦。
正确性检验
正确性检验需要完全依照规格,考虑期望发生的和不期望发生的。
“期望发生的”较为简单,翻译规格即可。而“不期望发生的”主要针对pure,not_assigned等。这里,不仅要检验容器内相应句柄没有变化,也要保证引用的对象内部没有改变,例如检查改变某个person的年龄、社交关系等。具体操作如下
- 首先保证每个数据生成函数能够稳定生成相同的数据。
- 检验函数是否正常返回,并对返回结果进行正确性检验,直接按照JML提供的算法即可,不考虑时间。
- 利用生成函数同时生成待操作的Network和对照的Network,对实验组操作
queryTripleSum,然后再拿这两个组的persons数组逐一比对,判断是否进行了不期望的修改:
Person[] p1 = network.getPersons();
Person[] p0 = refNetwork.getPersons(); //对照
assertEquals(p0.length, p1.length);
for (int i = 0; i < p0.length; i++) {
assertTrue(((MyPerson) p1[i]).strictEquals(p0[i]));
}
数据生成
Junit数据不在于多,而要保证数据的正交性和全面性。正交性即每个数据都只走一条JML路径,且只走一次,全面性是覆盖所有路径。
正如上文所述,这里的Junit测试数据不能面向自己的实现,而应该面向JML。
在第十一次作业中由于疏忽,我没有考虑规格中的一段话,导致一个测试点没有过:
/*
@ 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])))));
*/
这里的数据构造较为复杂。由于Emoji发出去了之后才能增加热度,但是会从消息队列中清除。因此需要在消息发送后再添加相同EmojiId的表情信息,才能生成符合上述条件的数据。
5. 学习体会
JML规格
JML规格是模块、方法的行为描述方式,正确的规格能消除歧义,保证约束符合预期。但是它对于定义者和实现者都有一定的挑战。一方面,定义者需要准确表述意图,并要限制一切不符合预期的情况发生例如,想要制定一个排序的规格,不仅要保证有序性,还要保证“不加、不删、不改、对象内部不变”。
另一方面,实现者需要耐心阅读规格,理解相关约束的意图,把JML再翻译成自然语言(毕竟人不是机器)。稍有不慎,就会忽略某种边界情况。同时必须要明白,规格并没有指定算法,必须要自行设计符合要求的实现。
JML规格为测试提供了便利。依照规格,测试有了明确的标准。
Junit测试
本单元中,面向规格的测试使得白盒变黑盒,必须要面对用户设计用例和检验,即要求不信任开发者。这类测试对测试对规格的理解有很大帮助,但我感觉,基于规格和基于覆盖率的测试二者不是能够相互替代的关系,所以在实现基于规格的设计中,同时也要考虑自己代码的覆盖率。
1002

被折叠的 条评论
为什么被折叠?



