第三单元博客
测试过程分析
黑箱测试与白箱测试
-
黑箱测试:具体到本单元的测试中,我通过
生成数据 -> 交给程序运行 -> 得到输出进行对拍
的流程来实践了黑箱测试,也就是不涉及程序的内部逻辑,仅按照同一个测试用例下输出是否一致来测试程序的功能和行为是否符合预期,显然在课程组的评测系统中,也使用的是黑箱测试(中测、强测)。这样的测试方法依赖于测试数据的强度,若测试数据强度不足则无法发现程序的全部内部错误。 -
白箱测试:具体到本单元的测试中,我通过使用
JUnit
来实践了白箱测试。白箱测试用于发现程序错误的方式与黑箱测试稍有不同,区别在于白箱测试需要对程序的内部实现有严格的测试,包括对相关规格的逐条验证、对分支结构的充分覆盖等,因而需要测试程序对期望程序的规格结构有充分的覆盖。这样的测试方法如果设计得好,则可以最大程度地测试出程序的所有不符合规格的地方;然而设计这样的测试方法,需要同等甚至多于编写原程序的时间和精力。
对一系列测试的理解
- 单元测试:对软件中最小的可测试部分(如函数和方法)进行测试,旨在验证这些最小代码单元按照预期工作,例子是本单元中每次与中测一同出现的特殊
JUnit
测试,均要求对特定的方法进行测试。 - 功能测试:验证程序的功能是否满足需求的测试。在本单元中,我认为每行合法的输入其实都对应一种要求的功能,所以功能测试包含在了我的黑箱测试当中,因为每个功能是否正确实现不仅表现于对应行的输出是否正确,也可以通过后续有耦合的功能的对应输出进行判断。即如果实现不当,错误功能的外在表现会体现在此条及之后的输出之中。但正如上文叙述的,黑箱测试并不能保证发现功能的所有错误。
- 集成测试:在单元测试之后进行,目的是验证不同模块或组件如何协同工作。其实我实践的黑箱测试更偏向于一种系统测试,其在一定程度上也能完成集成测试的目的,即从结果上验证了功能模块之间协同工作的效果。要完整地实践集成测试,应该首先全面地完成单元测试,然后再仅针对模块间的交互行为进行测试的设计。然而本单元的作业交互程度其实并不高,即交互行为较少、各功能之间较为独立,实现集成测试的性价比不如系统测试。
- 压力测试:验证软件在极端工作条件下的行为,如高负载或有限资源。在本单元中,内存空间较为宽裕,对软件性能的验证主要在于运行时间,需要压力测试来验证程序的实现是否足够高效。就具体实现而言,我利用
Python
的Subprocess
库进行了多进程测试,并对测试数据生成的逻辑和规模进行了设计。然而课程组的评测机对计算性能的限制有点超乎想象了,在hw9
中的一个小功能处,我未能注意到写了一个二重循环,使得方法的复杂度来到了O(n^2)
,遗憾的挂掉了一个点。然而要复刻到课程组的硬件环境是相当困难的,因此在程序设计的过程中还是尽量要多卷卷性能,多注意方法的复杂度。实际上对于不多于 10000 条的数据,需要保证复杂度低于O(n^2)
。 - 回归测试:在软件发生更改后进行的测试,以确保现有功能仍然按预期工作。在本单元中,我对回归测试的实践在于将写完的程序打包为 jar 文件,再次投入到前一次作业的评测机中进行测试,以确保没有修改原有的规格。
数据构造策略
由于主要采用了黑箱测试的方法,本单元的评测机对数据构造的依赖性较强。
- hw9:主要采用了随机生成指令,仅保证了数据生成符合数据限制,所以数据强度较低。
- hw10:参考了课程组的数据生成,使用
ln
指令指定生成了稀疏图或稠密图;同时还设计了一些数据生成分支,对诸如qtvs
这样的指令进行了功能测试;然而本单元未考虑到像第一单元一样为int
型数据设计常量池,导致未测试出int
型比较时直接使用减法导致溢出的问题;而且后来发现对 JML 规格的测试不够全面,比如未考虑到att
指令中对tagSize
不大于 1111 与否时不同的行为,导致其实我们评测机没有覆盖到强测考虑到的所有情况。 - hw11:添加了对消息类指令的支持,对生成方式采用了大量数据(10w+条)+少量测试的做法,并主要在有效指令筛选部分下了点功夫。据测试,这样的生成方式有更大的概率检验出程序错误,原因是往往构造了更加复杂的图,或者随机指令命中了特定的关系。虽然这样的生成方式会造成大量无效的指令,但是可以通过一种二分广搜的方法大概确定有效但稀疏指令的位置,从而将总指令数在几分钟内降低至一百余条,其实也很有利于互测数据构造和故障定位。
def bfs_search(lines, take_01):
queue = deque([(0, len(lines) - 1)]) # 初始区间为[0, len - 1]
while queue:
start, end = queue.popleft()
if start + 16 > end: # 不必精确到每条指令,故最小区间可以进行设置
continue # 如果区间无效,跳过
print(f"line num: {sum(take_01)}, queue num: {len(queue)}")
# 创建一个临时的take_01列表,用于检查当前区间
assert check_constraints(take_01, lines)
temp_take_01 = take_01.copy()
for i in range(start, end):
temp_take_01[i] = 0
# 检查当前区间是否满足约束条件,即代码对拍是否出错
if check_constraints(temp_take_01, lines):
print(f"[{start}, {end}] deleted")
# 如果满足,将对应的take_01设置为负数
for i in range(start, end + 1):
take_01[i] = 0
# 如果当前区间不满足约束条件,将其二分并加入到队列中
elif start != end:
mid = (start + end) // 2
queue.append((start, mid))
queue.append((mid + 1, end))
return take_01
with open('filtered_output.txt', 'r') as input_file:
lines = input_file.readlines()
take_01 = [1] * len(lines)
# 执行广度优先搜索
take_01 = bfs_search(lines, take_01)
# 使用更新后的take_01列表来写入新的input.txt
with open('input.txt', "w") as out:
for i, line in enumerate(lines):
if take_01[i] > 0:
out.write(line.rstrip() + "\n")
架构设计分析
社交网络图
使用并查集的数据结构作为主体,实现了并查集的查找、路径压缩、启发式合并,以支持如下几个功能:
- 查找:动态维护
blockSum
,直接支持isCircle
方法 - 合并:对应
addRelation
方法 - 染色删边:并查集并不包含复杂度低的删边方法,所以借鉴学长的博客,我采用了一种形式上像染色的使用广搜的删边方法:
private final HashMap<Integer, Integer> pa = new HashMap<>();
public void unlink(int x, int y) {
pa.replace(x, x);
paMark(y);
if (pa.get(x) != y) {
paMark(x);
}
}
private void paMark(int id) {
ArrayDeque<Integer> queue = new ArrayDeque<>();
HashSet<Integer> marked = new HashSet<>();
queue.add(id);
while (!queue.isEmpty()) {
int currId = queue.remove();
if (!marked.contains(currId)) {
marked.add(currId);
pa.replace(currId, id);
queue.addAll(((MyPerson) persons.get(currId)).getAcquaintance());
}
}
}
这种做法的好处在于不需要对并查集进行全局的重建,一般情况下仅需要进行局部的重建。据我所知有同学对于并查集删边的处理为延迟的全局重建,但考虑到最坏情况可能超时,我最终还是采用了这种实时的局部重建,并加入到 modifyRelation
方法中。
维护策略
对并查集的维护不再赘述。
对简单值的维护
在本单元的社交网络中,我对 tripleSum
、 blockSum
、 coupleSum
都进行了维护,但显然维护亦分两种情况:
- 实时更新,即动态维护,在每个可能修改值的内容的地方直接将值进行更新,往往涉及到一些判断,在我的实现中
tripleSum
、blockSum
为动态维护 - 脏位维护,即在每个可能修改值的内容的地方进行记录,在查询时根据脏位来决定是否需要更新值。在我的实现中,例子为
coupleSum
和coupleCached
使用观察者模式进行维护
在 hw10
加入的 tag
系统中,我使用了观察者模式来维护关系发生变化时 tag
包含的 persons
,如使用了 followerToTags
容器:
private final HashMap<Integer, HashMap<Integer, HashSet<Tag>>> followerToTags = new HashMap<>();
利用这个容器,我在 delTag
、 delPersonFromTag
、 addPersonToTag
、 followerToTags
和一些辅助方法中,得以使用观察者模式而不是遍历来对 tag
进行维护,提升了 qtvs
方法的性能。
使用 TreeSet
进行维护
在 hw10
加入的 queryBestAcquaintance
方法中,要求查询满足条件的 bestId
,所以我对 MyPerson
类的 acquaintance
容器进行了改造:
private final TreeSet<Person> acquaintance = new TreeSet<>(new Comparator<Person>() {
@Override
public int compare(Person p1, Person p2) {
int id1 = p1.getId();
int id2 = p2.getId();
int cmp = Integer.compare(idToValue.getOrDefault(id2, Integer.MAX_VALUE),
idToValue.getOrDefault(id1, Integer.MAX_VALUE));
if (cmp == 0) {
return Integer.compare(id1, id2);
} else {
return cmp;
}
}
}); // 集合性
private final HashMap<Integer, Integer> idToValue = new HashMap<>();
利用 TreeSet
进行维护使查询的复杂度降低到了 O(1)
,提升了性能,也使得代码更加优雅。
性能问题及其修复
千里之堤,溃于蚁穴
hw9
在维护 tripleSum
的时候写出了如下的代码:
for (int acId1: p1.getAcquaintance()) {
for (int acId2: p2.getAcquaintance()) {
if (acId1 == acId2) {
tripleSum += 1;
}
}
}
然后悲剧地超时了。回头看了一眼只有这个地方写了一个二重循环,不过由于是写在了调用比较频繁的 addRelation
和 modifyRelation
中,所以超时也没什么好说的。后面改成了如下的写法:
Set<Integer> interSet = new HashSet<>(p1.getAcquaintance());
interSet.retainAll(p2.getAcquaintance());
tripleSum += interSet.size();
时间就少了不少。可惜我在别的地方花了那么多时间,最后还挂了一个点 :(
hw10
TreeSet
与爆 int
在重写容器 acquaintance
时写出了如下的代码:
private final TreeSet<Person> acquaintance = new TreeSet<>(new Comparator<Person>() {
@Override
public int compare(Person p1, Person p2) {
int id1 = p1.getId();
int id2 = p2.getId();
int cmp = idToValue.getOrDefault(id2, Integer.MIN_VALUE) -
idToValue.getOrDefault(id1, Integer.MIN_VALUE); // 降序
return cmp == 0 ? id1 - id2 : cmp; // 升序
}
}); // 集合性
写完觉得自己的写法真是简洁优雅啊,然而爆 int 了。这里的 bug 在于比较 int 型数据的时候不能直接相减,会造成溢出。所以改成了如下的安全的写法:
private final TreeSet<Person> acquaintance = new TreeSet<>(new Comparator<Person>() {
@Override
public int compare(Person p1, Person p2) {
int id1 = p1.getId();
int id2 = p2.getId();
int cmp = Integer.compare(idToValue.getOrDefault(id2, Integer.MIN_VALUE),
idToValue.getOrDefault(id1, Integer.MIN_VALUE));
if (cmp == 0) {
return Integer.compare(id1, id2);
} else {
return cmp;
}
}
}); // 集合性
Integer.compare()
大法好,如果早点知道有这个用法就更好了 :(
关于最短路径
关于最短路径的查询,考虑到本单元社交网络无权图的特性,显然应该采用广搜这样的算法而不是迪杰斯特拉这种适用于带权图的算法。为了进一步的性能优化,我还采用了双向BFS的实现,这种写法相比简单BFS的优势主要在于,考虑到了两个点之间的通路呈纺锤形的情况。一个小优化在于,可以优先遍历相邻节点更少的那一侧。
@Override
public int queryShortestPath(int id1, int id2) throws
PersonIdNotFoundException, PathNotFoundException {
findPersonId(id1, id2);
if (pa.find(id1) != pa.find(id2)) { throw new MyPathNotFoundException(id1, id2); }
else if (id1 == id2) { return 0; }
int dis = -1;
ArrayDeque<Integer> queue1 = new ArrayDeque<>();
queue1.add(id1);
ArrayDeque<Integer> queue2 = new ArrayDeque<>();
queue2.add(id2);
Set<Integer> visited1 = new HashSet<>();
visited1.add(id1);
Set<Integer> visited2 = new HashSet<>();
visited2.add(id2);
while (!queue1.isEmpty() && !queue2.isEmpty()) {
dis++;
if (queue1.size() <= queue2.size()) {
if (updateBiBfs(queue1, visited1, visited2)) { break; }
} else { if (updateBiBfs(queue2, visited2, visited1)) { break; } }
}
return dis;
}
private boolean updateBiBfs(ArrayDeque<Integer> q, Set<Integer> visited, Set<Integer> other) {
ArrayDeque<Integer> thisFloor = new ArrayDeque<>(q);
while (!thisFloor.isEmpty()) {
Integer curr = q.pollFirst();
thisFloor.removeFirst();
for (int ac : ((MyPerson) persons.get(curr)).getAcquaintance()) {
if (visited.contains(ac)) { continue; }
if (other.contains(ac)) { return true; }
visited.add(ac);
q.add(ac);
}
}
return false;
}
hw11
关于 Person
类中的 messages
容器,相关方法要求返回一个 List<Message>
对象,所以自然地我们会想到用 ArrayList
或者 LinkedList
来实现。然而注意到 addMessage
方法总是在开头插入,而 ArrayList
对象执行 add
方法在头部插入时会对底层数组进行整体复制,影响整体效率,所以这里应该采用 LinkedList
来实现。
关于规格与实现分离
JML 的规格包括前置条件、后置条件、可能的异常,这种写法的规范性无需多言。设想现在有甲方和乙方,甲方负责以 JML 的形式来提出要求,乙方负责进行实现,有助于实现软件开发过程中的清晰沟通和责任分工。在我进行冯如杯项目开发的时候,其实碰到过几次组员以意向不到的方式修改了代码的实现、从而影响到了别的模块的情况,所以刚接触 JML 这个概念的时候就觉得这样的规格说明是一种不错的做法。
另一方面,规格与实现分离也对程序性能没有造成损害。虽然在规格的说明中,JML 往往会采用多重循环等复杂度较高的方式来说明前后置条件,但是我们的实现仅需要满足规范,而不是完全逐字翻译 JML。在以往的开发中,我对函数的认识仅在于接受什么样的输入、应该给出什么样的输出,而 JML 对应该改变哪些内容、不应该改变哪些内容也做出了限制,在规范化的同时,保证了核心功能容器和辅助功能容器的分离,其实也有利于解耦。
规格与 JUnit
测试
本单元中同学们实现了Junit测试方法,总结分析如何利用规格信息来更好的设计实现Junit测试,以及Junit测试检验代码实现与规格的一致性的效果
本单元提出了一种特殊的测试方法,即编写 JUnit
测试,对课程组提供的错误代码按照规格进行检测。这类测试保证其他方法均正确实现,故可以调用其他正确方法来进行社交网络的构造和相关规格检验对象的获取。在实现 JUnit
测试的过程中,我认为最重要的就是两个相辅相成的要点:保证数据生成的覆盖率和断言的全面性。
- 数据生成的覆盖率:没有全面的数据,就难以触发错误代码对规格的违反。所以在
JUnit
测试中,应该注意对要测试方法的所有有关属性进行充分的改变,在足够多样的环境中尝试进行断言测试。 - 断言的全面性:要保证对 JML 规格进行全面的翻译,不能落下任何一条语句。而且相比于使用
if-else
来对一些规格中的条件进行判断,我还看到了一种很合适的实现方法,即
assertTrue((!(oldMessages[j] instanceof MyEmojiMessage && myNetwork.containsEmojiId(((EmojiMessage) oldMessages[j]).getEmojiId()))) || (hasMessage(oldMessages[j], nowMessages)));
可以看到这种实现是最大程度保留了 JML 的原味,尽管我并不了解测试岗位应该写出怎么样的测试程序,但对于 JML 规格的实现,我觉得这种直接的翻译是最好看的。
学习体会
据不完全统计,本单元一共出现 14 次更正,其中 3 次为评测机错误, 4 次为 JML 代码的非合并修复,与前两单元相比可谓是遥遥领先。[1]
- 初次接触前置条件与后置条件,我觉得很正确,并会将其加入到以后的软件工程开发中,以避免
Post-Effect
。 - 希望对
JUnit
的测试中可以提供更多信息的反馈,或者设计单独的评测,我觉得靠同学间的探索和交流来猜测错误代码有什么问题的现象对于学习如何进行测试没有什么意义。 - 课程组对 JML 进行了一定程度的魔改,但也因此失去了检验其正确性的工具,希望早日开发相应的自动化工具,让规格也更加规范化,避免热修复,毕竟每周作业时间也是很紧张的。