测试过程
黑箱测试与白箱测试
-
黑箱测试是指在不知道程序内部结构和实现细节的情况下,对软件的功能进行测试。测试者只关心输入数据和预期输出,不关注程序的内部逻辑。白箱测试是指在了解程序内部逻辑和结构的情况下,对软件的内部结构进行测试。测试者需要具备代码层面的知识,并通过代码来设计测试用例。
-
我们在OO第三单元以及OOpre课程中使用的测试方法分别是黑箱测试与白箱测试。在形式上,OO第三单元主要关注规格的正确实现,对于不同的错误代码都需要根据生成的数据和测试逻辑进行正确性判别。因此,测试数据的覆盖率是一个重要的指标,而采用随机生成的策略可以更好地满足这一指标的要求。相比于白箱测试,黑箱测试的特点是不需要关注方法的具体实现,只要知道方法的功能,我就可以进行测试。从这一角度上来说,黑箱测试显然更加符合工程要求,编写代码与功能测试两个环节可以由不同的人来完成,甚至他们之间都可以不用沟通,只要事先拿到相同的规格即可。而白箱测试需要我们对代码的内部逻辑有清晰的理解,甚至从某种程度上来说,白箱测试就是照着原有方法写一份几乎相同的代码,各个分支最好都要覆盖,然后通过检验状态转换过程中其他指标的期望值与真实值来对程序正确性进行判别。这种测试方法显然不如黑箱测试简便,普适性强,但力求覆盖方法所有分支的特点使其在一些边界情况(随机数据很难覆盖到的情况)的检验中往往表现更好。不过,过于依赖原有代码的逻辑分支也有其弊端,比如说我在编写方法时本身就少考虑了部分情况,而我测试的分支完全与原有分支重合,反而会使原有错误无法得到检验。
各种类型测试
-
单元测试:单元测试是对软件中的最小可测试单元进行验证的过程。通常,这个单元是一个函数、方法或类。单元测试粒度最小,从细节的角度把握了程序的正确性。但是,为每一个方法都编写测试显然是非常繁琐的,并且考虑到方法之间的交互,所有方法通过测试也并不一定意味着整个程序的绝对正确。
-
功能测试:功能测试是对软件的功能和特性进行验证的过程,确保软件能按照规格正确执行。这种测试类似于我们上面提到的黑盒测试,从整体与用户的角度对程序的正确性进行检验,可以在极大程度上确保程序的正确性。但是,由于不知道方法的具体实现,即使测试得再充分我们也不敢保证程序的绝对正确性,毕竟谁也不敢担保用户会给出什么样的奇怪输入(。
-
集成测试:集成测试是对软件各个模块之间的接口和交互进行验证的过程。通常来说,我们可以将集成测试看成是基于单元测试之上的进阶测试,不仅关注于每个模块本身的正确性,还可以验证这些模块之间的配合是否正确,从而在更高维度上审视程序的正确性。但是,什么模块之间会产生配合?彼此配合的模块之间工作的时序是否会对配合的正确性产生影响?一组配合关系的正确与否会不会影响其他配合关系的正确性?这些思考相比简单粗暴的随机数据生成显然是更为复杂的,或许会对测试的效率造成些许影响。
-
压力测试:压力测试是对软件在极限条件下的表现进行测试,验证系统在超负荷情况下的稳定性和可靠性。对应到这一单元的作业中,也就是对时间复杂度最高的方法重复执行上千次,观察程序是否超时。例如第二次作业载入100人的完全图,然后重复上千次qtvs指令,如果采用的方法是O(n^2)时间复杂度,就有很大的可能性超时。这通常是在确保了程序的基本正确性的前提下对于边界情况、极端条件下正确性与性能的考察。当然,确保程序在极端情况下的正确执行会不会反而使普遍情况的执行受到影响?例如将每个查询变量进行维护当然可以减小查询操作的复杂度,但万一我要模拟的系统会有频繁的属性修改,却只有极少的状态查询呢?这就是我们要根据实际情况进行权衡的问题了。
-
回归测试:回归测试是对软件进行重新测试,确保修改或更新后的软件未引入新的错误,且原有功能正常。就像代码成型之后,我们每次对代码作出修改都应该将原本通过的数据点再跑一遍,防止修改之后对代码的原有功能造成影响。对于更新十分频繁的程序,回归测试或许会使测试负担太重,但在某种程度上又是必要的。或许我们可以考虑进行若干次修改之后再进行回归测试,只要修改过程不会频繁出错,应该就可以在一定程度上减小测试负担。
数据构造策略
-
第一、二次作业的数据构造没有太大区别,都只关注人的关系。因此添加人、再随机生成他们之间的关系就是数据构造的基本想法。不过,考虑到数据的全面性,我们要对稀疏图、稠密图,一般图这三种情况都进行模拟,才能基本实现测试的全面覆盖。因此,我也通过调整两人之间边的生成概率分别模拟了这三种图的情况,以下是我第二次作业的数据构造代码(第二次作业中,我分别用两点之间生成边概率为0.9,0.1来模拟稠密图与稀疏图的生成,结果还是无法通过JUnit。一气之下,我直接选择生成了完全图与空图,结果居然过了。因此,我猜测有一个测试点应该是针对非常接近完全图或空图的图的。不过,这也导致我的前十组测试与后十组测试几乎都是重复的,也算是一个缺点。)
@Parameters public static Collection prepareData() { int testNum = 60; Object[][] object = new Object[testNum][]; for (int i = 0; i < testNum; i++) { if (i % 3 == 0) { object[i] = createNetwork1(); } else if (i % 3 == 1) { object[i] = createNetwork2(); } else { object[i] = createNetwork3(); } } return Arrays.asList(object); } public static Network[] createNetwork1() { Network[] networks = new Network[2]; int personNum = new Random().nextInt(20) + 20; Network network = new MyNetwork(); Network shadowNetwork = new MyNetwork(); Person[] persons = new Person[personNum]; Person[] shadowPersons = new Person[personNum]; for (int i = 0; i < personNum; i++) { persons[i] = new MyPerson(i, "Tom" + i, 20 + i); shadowPersons[i] = new MyPerson(i, "Tom" + i, 20 + i); } try { for (int i = 0; i < personNum; i++) { network.addPerson(persons[i]); shadowNetwork.addPerson(shadowPersons[i]); } for (int i = 0; i < personNum; i++) { for (int j = i + 1; j < personNum; j++) { if (new Random().nextInt(10) < 10) { int value = new Random().nextInt(20) + 1; network.addRelation(i, j, value); shadowNetwork.addRelation(i, j, value); } } } } catch (Exception e) { e.printStackTrace(); } networks[0] = network; networks[1] = shadowNetwork; return networks; } public static Network[] createNetwork2() { Network[] networks = new Network[2]; int personNum = new Random().nextInt(20) + 20; Network network = new MyNetwork(); Network shadowNetwork = new MyNetwork(); Person[] persons = new Person[personNum]; Person[] shadowPersons = new Person[personNum]; for (int i = 0; i < personNum; i++) { persons[i] = new MyPerson(i, "Tom" + i, 20 + i); shadowPersons[i] = new MyPerson(i, "Tom" + i, 20 + i); } try { for (int i = 0; i < personNum; i++) { network.addPerson(persons[i]); shadowNetwork.addPerson(shadowPersons[i]); } for (int i = 0; i < personNum; i++) { for (int j = i + 1; j < personNum; j++) { if (new Random().nextInt(10) < 5) { int value = new Random().nextInt(20) + 1; network.addRelation(i, j, value); shadowNetwork.addRelation(i, j, value); } } } } catch (Exception e) { e.printStackTrace(); } networks[0] = network; networks[1] = shadowNetwork; return networks; } public static Network[] createNetwork3() { Network[] networks = new Network[2]; int personNum = new Random().nextInt(20) + 20; Network network = new MyNetwork(); Network shadowNetwork = new MyNetwork(); Person[] persons = new Person[personNum]; Person[] shadowPersons = new Person[personNum]; for (int i = 0; i < personNum; i++) { persons[i] = new MyPerson(i, "Tom" + i, 20 + i); shadowPersons[i] = new MyPerson(i, "Tom" + i, 20 + i); } try { for (int i = 0; i < personNum; i++) { network.addPerson(persons[i]); shadowNetwork.addPerson(shadowPersons[i]); } for (int i = 0; i < personNum; i++) { for (int j = i + 1; j < personNum; j++) { if (new Random().nextInt(10) < 0) { int value = new Random().nextInt(20) + 1; network.addRelation(i, j, value); shadowNetwork.addRelation(i, j, value); } } } } catch (Exception e) { e.printStackTrace(); } networks[0] = network; networks[1] = shadowNetwork; return networks; }
-
第三次数据生成的基本思想与第一、二次也没有太大区别,无非是多了生成和发送Message的过程,更加繁琐而已。不过,虽然需要测试的方法是清除NoticeMessage,但另外两种类型的Message也要生成,毕竟还需要验证其他种类的Message没有被删掉。
@Parameters
public static Collection prepareData() {
int testNum = 20;
Object[][] object = new Object[testNum][];
for (int i = 0; i < testNum; i++) {
object[i] = createNetwork();
}
return Arrays.asList(object);
}
public static Network[] createNetwork() {
Network[] networks = new Network[2];
Network network = new MyNetwork();
Network shadowNetwork = new MyNetwork();
//add person and relation
int personNum = new Random().nextInt(10) + 10;
Person[] persons = new Person[personNum];
Person[] shadowPersons = new Person[personNum];
for (int i = 0; i < personNum; i++) {
persons[i] = new MyPerson(i, "Tom" + i, 20 + i);
shadowPersons[i] = new MyPerson(i, "Tom" + i, 20 + i);
}
try {
for (int i = 0; i < personNum; i++) {
network.addPerson(persons[i]);
shadowNetwork.addPerson(shadowPersons[i]);
}
for (int i = 0; i < personNum; i++) {
for (int j = i + 1; j < personNum; j++) {
if (new Random().nextInt(10) < 9) {
int value = new Random().nextInt(20) + 1;
network.addRelation(i, j, value);
shadowNetwork.addRelation(i, j, value);
}
}
}
} catch (EqualPersonIdException | EqualRelationException | PersonIdNotFoundException ignored) {
}
//create emojiIdList and emojiHeatList
for (int i = 0; i < 5; i++) {
try {
network.storeEmojiId(i);
shadowNetwork.storeEmojiId(i);
} catch (EqualEmojiIdException ignored) {
}
}
//create and add message
int emojiMessageNum = 50;
int noticeMessageNum = 20;
int redEnvelopeMessageNum = 20;
EmojiMessage[] emojiMessages = new EmojiMessage[emojiMessageNum];
NoticeMessage[] noticeMessages = new NoticeMessage[noticeMessageNum];
RedEnvelopeMessage[] redEnvelopeMessages = new RedEnvelopeMessage[redEnvelopeMessageNum];
for (int i = 0; i < emojiMessageNum; i++) {
int person1Id = new Random().nextInt(personNum);
int person2Id = new Random().nextInt(personNum);
int emojiId = new Random().nextInt(5);
emojiMessages[i] = new MyEmojiMessage(i, emojiId, persons[person1Id], persons[person2Id]);
try {
network.addMessage(emojiMessages[i]);
shadowNetwork.addMessage(emojiMessages[i]);
} catch (EqualMessageIdException | EmojiIdNotFoundException | EqualPersonIdException ignored) {
}
}
for (int i = 0; i < noticeMessageNum; i++) {
int person1Id = new Random().nextInt(personNum);
int person2Id = new Random().nextInt(personNum);
String string = "NoticeMessage" + i;
noticeMessages[i] = new MyNoticeMessage(i + emojiMessageNum, string, persons[person1Id], persons[person2Id]);
try {
network.addMessage(noticeMessages[i]);
shadowNetwork.addMessage(noticeMessages[i]);
} catch (EqualMessageIdException | EmojiIdNotFoundException | EqualPersonIdException ignored) {
}
}
for (int i = 0; i < redEnvelopeMessageNum; i++) {
int person1Id = new Random().nextInt(personNum);
int person2Id = new Random().nextInt(personNum);
int value = new Random().nextInt(100);
redEnvelopeMessages[i] = new MyRedEnvelopeMessage(i + noticeMessageNum + emojiMessageNum, value, persons[person1Id], persons[person2Id]);
try {
network.addMessage(redEnvelopeMessages[i]);
shadowNetwork.addMessage(redEnvelopeMessages[i]);
} catch (EqualMessageIdException | EmojiIdNotFoundException | EqualPersonIdException ignored) {
}
}
//send message
int sendNum = 60;
for (int i = 0; i < sendNum; i++) {
int messageId = new Random().nextInt(emojiMessageNum + noticeMessageNum + redEnvelopeMessageNum);
try {
network.sendMessage(messageId);
shadowNetwork.sendMessage(messageId);
} catch (RelationNotFoundException | MessageIdNotFoundException | TagIdNotFoundException ignored) {
}
}
networks[0] = network;
networks[1] = shadowNetwork;
return networks;
}
架构设计
-
本单元的基础架构应该没有太大区别,规格的限制就决定了有哪些类,类里有哪些方法,这些方法的作用,只是在具体实现的角度会有一定的区别。这里就不再做过多的阐述。
-
图的模型可以看作数据结构中所学的邻接链表,不过使用了HashMap来存储关系,这样可以使得关系的查询更快。图的维护也只是在加边或删边的时候修改HashMap而已,没有什么特别的地方。但是,图内属性的维护却是有很大实现自由度的地方,我想详细阐述一下,具体如下。
-
首先我们明确一下,图内属性的维护是将O(n^2)的查询方法优化到O(n)甚至O(1)以防止超时,因此本来复杂度就是O(n)的方法我就没有进行优化,直接按照规格编写。
-
使用并查集优化路径查找算法。并查集的本质是路径压缩,通过设置统一父节点压缩路径查找过程。比较复杂的是删边的时候并查集的更新。这里我采用的是对删去边的两点分别进行dfs,确定与他们联通的点有哪些。既然是dfs,在完全图的时候自然时间复杂度还是O(n^2),不过这也或许是难以规避的缺憾吧。注意,这里的dfs最好写成栈模拟,递归有爆栈风险。
public void deUnion(int id1, int id2) { parent.put(id1, id1); parent.put(id2, id2); dfs(id1); if (parent.get(id2) != id1) { dfs(id2); } } public void dfs(int root) { Set<Integer> visited = new HashSet<>(); Stack<Integer> stack = new Stack<>(); stack.push(root); visited.add(root); while (!stack.isEmpty()) { int currentId = stack.pop(); parent.put(currentId, root); MyPerson currentPerson = (MyPerson) personMap.get(currentId); HashMap<Integer, Person> currentAcquaintance = currentPerson.getAcquaintance(); for (int key : currentAcquaintance.keySet()) { if (!visited.contains(key)) { stack.push(key); visited.add(key); } } } }
-
blockSum统计连通子图个数,使用动态维护,加点时值加1,加边时若形成连通子图则减1,删边时若破坏连通子图则加1。
-
tripleSum统计形成三角形个数。加边时遍历与两点均相连的点,统计多形成了几个三角形;删边时也遍历与两边均相邻的点,统计破坏了几个三角形。
-
coupleSum统计最好关系个数,这里使用TreeMap和TreeSet里元素有序存储的特点,以value为键,该value对应的人所形成的TreeSet集合为值,每次取最后一个TreeSet的第一个值即可。至于coupleSum,鉴于找最好关系的方法被简化为了O(1),该方法的复杂度自然也就降到了O(n)。
-
TagValueSum维护Tag内value之和。这个值的动态维护就比较麻烦,具体来说如下:加关系则维护两人同在的Tag,加人进Tag与把人从Tag删掉则维护该Tag,改value维护两人同在的Tag,删关系不仅要维护两人同在的Tag,还要将对方从自己的Tag中删去,再维护这些Tag的值。部分方法如下:
public void sameTagsAddRelation(MyPerson person1, MyPerson person2) { HashSet<Tag> sameTags = new HashSet<>(person1.getInvolvedTags()); sameTags.retainAll(person2.getInvolvedTags()); for (Tag tag : sameTags) { ((MyTag) tag).valueSumAddRelation(person1, person2); } } public void sameTagsModifyRelation(MyPerson person1, MyPerson person2, int value) { HashSet<Tag> sameTags = new HashSet<>(person1.getInvolvedTags()); sameTags.retainAll(person2.getInvolvedTags()); for (Tag tag : sameTags) { ((MyTag) tag).valueSumModifyRelation(person1, person2, value); } } public void sameTagsRemoveRelation(MyPerson person1, MyPerson person2, int value) { HashSet<Tag> sameTags = new HashSet<>(person1.getInvolvedTags()); sameTags.retainAll(person2.getInvolvedTags()); for (Tag tag : sameTags) { ((MyTag) tag).valueSumRemoveRelation(person1, person2, value); } for (Tag tag : person1.getTags().values()) { if (tag.hasPerson(person2)) { tag.delPerson(person2); person2.getInvolvedTags().remove(tag); ((MyTag) tag).valueSumDelPersonFromTag(person2); } } for (Tag tag : person2.getTags().values()) { if (tag.hasPerson(person1)) { tag.delPerson(person1); person1.getInvolvedTags().remove(tag); ((MyTag) tag).valueSumDelPersonFromTag(person1); } } }
-
性能问题,规格与实现分离
-
很难蚌的是我第一次作业用栈实现的dfs写错了导致超时,改后就通过了。希望学弟学妹们数据结构不要学得和我一样烂,警钟长鸣!
-
需要注意的是,规格给我们的限制仅仅是你有哪些先决条件以及你要给我哪些结果。具体实现当然是百花齐放,力求性能优异。当然,规格为了编写容易以及理解简单,采用的通常是最暴力的做法,直接按规格写是肯定会超时的!
JUnit
-
有了规格,编写Junit测试代码就变得十分简单,无非是照着规格翻译而已。以第三次作业为例,规格中有大量的ensures确保结果正确性,我们只要把期望结果按照规格编写出来,再用assertEquals与已有结果进行比较即可。具体代码如下:
@Test public void deleteColdEmojiTest() { int limit = 4; MyNetwork network = (MyNetwork) this.network; MyNetwork shadowNetwork = (MyNetwork) this.shadowNetwork; Message[] oldMessages = shadowNetwork.getMessages(); int[] oldEmojiIdList = shadowNetwork.getEmojiIdList(); int[] oldEmojiHeatList = shadowNetwork.getEmojiHeatList(); int res = network.deleteColdEmoji(limit); Message[] newMessages = network.getMessages(); int[] newEmojiIdList = network.getEmojiIdList(); int[] newEmojiHeatList = network.getEmojiHeatList(); //验证>=limit的emojiMessage没有被删除 for (int i = 0; i < oldEmojiIdList.length; i++) { if (oldEmojiHeatList[i] >= limit) { boolean flag = false; for (int j = 0; j < newEmojiIdList.length; j++) { if (oldEmojiIdList[i] == newEmojiIdList[j]) { flag = true; break; } } assertTrue(flag); } } //验证现在的id和heat都可以在以前的list中找到且索引对应 for (int i = 0; i < newEmojiIdList.length; i++) { boolean flag = false; for (int j = 0; j < oldEmojiIdList.length; j++) { if (newEmojiIdList[i] == oldEmojiIdList[j] && newEmojiHeatList[i] == oldEmojiHeatList[j]) { flag = true; break; } } assertTrue(flag); } //验证idList的长度为满足>=limit的emojiMessage的个数 int count = 0; for (int i = 0; i < oldEmojiHeatList.length; i++) { if (oldEmojiHeatList[i] >= limit) { count++; } } assertEquals(count, newEmojiIdList.length); //验证idList与heatList的长度相等 assertEquals(newEmojiIdList.length, newEmojiHeatList.length); //验证未被删除的emojiMessage未被修改且仍然存在于现在的总message中 for (int i = 0; i < oldMessages.length; i++) { if (oldMessages[i] instanceof MyEmojiMessage && contains(newEmojiIdList, ((MyEmojiMessage) oldMessages[i]).getEmojiId())) { boolean flag = false; for (int j = 0; j < newMessages.length; j++) { if (allEquals(newMessages[j], oldMessages[i])) { flag = true; break; } } assertTrue(flag); } } //验证所有不是emojiMessage的message未被修改且仍然存在于现在的总message中 for (int i = 0; i < oldMessages.length; i++) { if (!(oldMessages[i] instanceof MyEmojiMessage)) { boolean flag = false; for (int j = 0; j < newMessages.length; j++) { if (allEquals(newMessages[j], oldMessages[i])) { flag = true; break; } } assertTrue(flag); } } //验证message长度为不是emojiMessage的message的个数加上>=limit的emojiMessage的个数 int count1 = 0; for (int i = 0; i < oldMessages.length; i++) { if (!(oldMessages[i] instanceof MyEmojiMessage)) { count1++; } else if (contains(newEmojiIdList, ((MyEmojiMessage) oldMessages[i]).getEmojiId())) { count1++; } } assertEquals(count1, newMessages.length); //验证返回值 assertEquals(count, res); } public boolean contains(int[] list, int target) { for (int i = 0; i < list.length; i++) { if (list[i] == target) { return true; } } return false; } public boolean allEquals(Message message1, Message message2) { boolean flag1 = (message1.getId() == message2.getId()); boolean flag2 = (message1.getSocialValue() == message2.getSocialValue()); boolean flag3 = (message1.getType() == message2.getType()); boolean flag4 = (message1.getPerson1().equals(message2.getPerson1())); boolean flag5 = (message1.getPerson2().equals(message2.getPerson2())); boolean flag6 = (message1.getTag() == null) ? message2.getTag() == null : (message1.getTag().equals(message2.getTag())); return flag1 && flag2 && flag3 && flag4 && flag5 && flag6; } }
-
Junit测试检验代码实现与规格的一致性的效果如何?在我看来,测试不过是规格的翻译,只要足够细心,数据生成足够强,这二者可以几乎等价,至于代码还可能出现的错误就是规格编写的疏漏了。
-
本单元学习体会
-
如果说电梯给我带来的是焦虑,那么JML给我带来的就是折磨。我宁愿看英语阅读理解也不愿意看这鬼都看不懂的JML。不过除了读JML的折磨,修改动态维护带来的bug也是折磨,每次发现自己修改一个bug之后又冒出了新的bug我就有一种直接变成照着规格写的冲动。第三单元终于结束,希望我能抵达一个没有JML的世界。
-
JML有什么用呢,我想这是一种将自然语言规范化的尝试,避免需求表达过程中的歧义。但是人类本身就是用自然语言交流的,在某种程度上说,程序是实现人类需求的工具,而人类需求本身就是用自然语言表达的,天生具有二义性。如果我根据你自然语言写出来的程序不符合你的需求,有没有一种可能是你本来就没有表述清楚?因此,我们需要关注的应该是规范化自然语言的方法,也就是怎么将需求翻译成JML,而不是怎么将JML翻译成java代码。如果JML已经是一种规范化的语言了,为什么我不直接使用chatGPT将它翻译成java代码呢(当然,我没有用GPT)?换句话说,如果这单元是给出要求程序的C代码,要我们将其翻译成java代码,是不是可以达到同样的目的?难道我在这单元扮演的就是一个优化规格中提供算法的角色吗?不过客观来讲,这单元还是让我收获了许多,不管是不是JML带来的,也都无关紧要了吧。