一、测试过程
1.黑箱测试
不看代码,进行输入数据然后看输出的结果进而验证软件功能是否正确。
在这单元作业中,我们自行构造数据,针对结果进行对拍判断正确性,就是在进行黑箱测试。
黑箱测试简单,已于实施,但是并不能很好地发现错误出现的原因,当出现结果的不一致时,我们还需要去研究代码来确定错误的具体方法位置。
2.白箱测试
白箱测试就是对软件的代码进行测试,寻找代码内部的错误。主要包括代码里面的模块接口测试,局部数据结构测试、路径覆盖测试、错误处理测试、边界溢出测试。白箱测试的思想体现在单元测试,集成测试等测试过程中。
3.单元测试
单元测试是对软件组成单元进行测试。其目的是检验软件基本组成单位的正确性。测试的对象是软件设计的最小单位:模块。
对于java来说,模块指的是每个类中的方法。我们写的junit,就是对类的每个方法进行单元测试。根据指导书给出的JML规格约束,我们可以对每个方法中的行为进行正确性判断,判断方法是否会有超出规格的行为,从而导致错误的出现。
4.功能测试
功能测试就是对产品的各功能进行验证,根据功能测试用例,逐项测试,检查产品是否达到用户要求的功能,功能测试其实就是黑箱测试。
在本单元的作业中,针对单一一条指令的测试都是功能测试,一条指令就可以看作一种功能。
5.集成测试
集成测试也称联合(联调)测试、组装测试,将程序模块采用适当的集成策略组装起来,对系统的接口及集成后的功能进行正确性检测的测试工作。集成主要目的是检查软件单位之间的接口是否正确。
本单元的测试感觉用到集成测试的地方很少,每个功能的实现所用的方法都较少,而且功能之间没有耦合或组合实现更高级功能的情况。
6.压力测试
压力测试是模拟实际应用的软硬件环境及用户使用过程的系统负荷,长时间或超大负荷地运行测试软件,来测试被测系统的性能、可靠性、稳定性等。
压力测试主要体现在强测数据上,相信不少同学都在强测中出现过ctle的bug,这正是大家在课程组的压力测试中没有过关。对于这单元的作业,时间限制是必须要考虑的点,而想要判断自己的时间性能是否出色,就要进行压力测试,对一些时间复杂度高的方法进行重复、大量测试。一组数据10000条指令,大部分都是相同指令,就是为了通过这样的压力测试来测试程序的时间性能。。
7.回归测试
回归测试是指修改了旧代码后,重新进行测试以确认没有引入新的错误或导致其他代码产生错误。自动回归测试将大幅降低系统测试、维护升级等阶段的成本。
在本单元的作业中,在第十次以及第十一次作业中,我们需要去测试程序在前一(两)次的命令的运行结果,判断其对于之前指令的正确性,防止强测修bug时修出了新的bug。这个问题在第十一次作业的互测中体现的尤为突出。
8.数据构造
这单元极其考验我们的数据构造能力,随机生成的数据常常不能很有效地测出自己程序的bug,必须针对各种方法进行构造。
相信不少同学的前两次作业都依靠DPO来测试自己程序的正确性,在上边跑个几十组数据就交上去不管了。这单元的DPO构造的数据较为随机,对于一些简单的bug,DPO的几十组也有可能测不出。所以我们需要自己构造数据来测试自己的程序。在junit实现中,我们实现了随机构造数据,因此,我们可以更进一步来实现一个有侧重的数据生成器,实现对于一些关键方法的充分测试。
二、架构设计
由于各个方法的JML已经给出,我们按照官方包JML以及指导书的描述就可以得到整个代码架构。在这三次作业中,我们主要关注一些关键方法的实现,确保每个方法的实现不会出现问题。值得一提的是,除了第三次作业中MyPerson中的messages的存储用ArrayList,其他的都采用HashMap来存储,因为ArrayList的获取特定位置的元素的复杂度是O(n),而HashMap则是O(1),所以,为了降低访问元素的复杂度,使用HahsMap无疑是更好的选择。
第九次作业
这次作业的目标是实现并维护一个社交网络(部分代码及要实现的接口已给出),并对其中的用户和他们之间的关系进行管理。
qci
qci是判断两个点是否成环,即两个人之间是否直接或间接有关系。如果按照JML中的规格来写,复杂度为O(n^2),强测中大概率是会CTLE的。阅读往届学长的博客,发现大家都采用了并查集来处理这个问题,我采用并查集来处理。我单独实现了一个并查集类,如下图。
并查集采用HashMap存储,一开始,我只是实现了一个“子-父”的键值对,后来发现复杂度仍然很高,这个我会在bug修复部分说明。
并查集需要动态维护,每次加点需要firstAdd,每次加边需要unite实现两个不相关并查集的合并,每次删边都有可能破坏并查集的结构,因此需要reconstruct重构。最后,当isCircle中想要判断两个点是否直接或间接相连时,只需find两个点的祖宗节点,判断两个点是否有相同的祖宗节点即可。
qbs
qbs是获取关系图中极大联通块的数量。观察JML规格不难发现,如果按规格实现,这又是一个复杂度爆炸的方法,所以,我们要采用动态维护的方法,在netWork中维护一个blockNum的属性,将复杂度降低并分摊到各个方法中。
在加点时,新加的点不会与任何边相连,因此产生了一个极大连通块。加边时,可以先用并查集判断两个点是否相连,如果不相连,在这个方法后会相连,因此blockNum会减一。删边时,我们也需要看删掉这条边后,两个点是否还会相连,如果不再相连,一个连通块被拆成了两个,blockNum加一。
qts
qts是获取图中三角形的数量。与qbs同理,我们需要进行动态维护。
如图,我们以一条边为基础,去寻找两个端点共同的acquaintance。加边时triNum加,删边时减。这样,我们将O(n^3)的复杂度分摊到了两个方法中,复杂度也变成了O(n)。
mr
mr的实现是第九次作业中最麻烦的,我们需要根据输入的参数来实现两人之间value的减少,当value小于0时,要删除两人之间的关系。麻烦主要体现在删边后并查集的维护上,首先,我们需要判断两个点在删边之后是否相连。对于这个问题,我实现了一个link方法,用dfs来判断删边之后两个点是否相连。如果不相连,则进行并查集的reconstruct,reconstruct也采用dfs遍历所有点,实现了并查集的重新构建。
link方法如图,时间复杂度不会超过O(n^2)。reconstruct方法类似。
第十次作业
此次作业升级已有的社交网络,为用户添加标签功能(类似于微信的群组)。
qba
qba获取一个人的熟人中value最大的熟人id(value相同则id取最小的那个),一看JML发现复杂度就知道,这个我们又需要动态维护了。这个动态维护也不难,就在每次ar、mr后fresh一下自己的bestAcqId即可。
qsp
qsp获取从一个点到另一个点的最短路径长度。这个方法纯粹考验我们对图相关方法的实现,采用迪杰斯特拉或bfs都能得到结果。一开始我尝试沿用上一次作业的link,但发现dfs在实现这个方法上并不容易,因此最后又改用了bfs。如图为我实现的bfs
qtvs
tag的valueSum定义为tag中所有的person,其acquaintance也在tag中,所有满足这个条件的二人组的value之和。如果按照规格中的实现进行遍历,复杂度为O(n^2),复杂度较高。我一开始按照规格来写,结果强测CTLE,至于采用何种方式进行优化,我会在bug修复部分说明。
第十一次作业
升级社交网络,模拟实现类似微信的消息的功能。
sm
sm实现message的发送,这是三次作业中JML最长的一个方法。真正理解了逻辑之后其实实现并不难,关键就是分类型操作,不过也是有一些小坑,像是发红包中int的精度问题,我后边会在bug修复中说明。
三、性能问题及bug修复
第九次作业
这次作业的bug主要是mr中并查集维护导致的ctle。一开始,我的并查集只实现了“子-父”的键值对,当最坏情况时,并查集的find的复杂度会变成O(n),如果reconstrcut中再调用find方法,就会导致复杂度超过O(n^2),导致CTLE。对于这个的更改,我们需要对并查集中的键值对进行实时维护,find中得到了祖宗节点后,立即将键值对改为“子-祖宗”,这样会降低后边find方法的复杂度,即使是最坏情况,也不会出现由于复杂度过高导致CTLE的情况。
第十次作业
qsp实现出问题。前边提到过,我的qsp采用bfs,但是一开始里边的visited和distance我用数组存。数组过大会导致CTLE,数组过小就会导致一些过大id的person的distance存不了,最后导致了RE。解决方法也很简单,就是将visited与distance用HashMap存储即可。
各种查询类方法用for遍历。这是上一次作业就存在的问题,结果上一次竟然没有发现!?前边我提到,用HashMap存储可以将访问复杂度降至O(1),而我没有利用这个优势,还憨憨地用for循环遍历来判断contains以及get,这导致了时间的浪费,最后导致了CTLE。
qtvs的bug算是这次作业一个比较大的坑,不少同学都在第十个强测点的114514中栽了跟头(好臭的数据...)。如果按照JML的规格进行两重for循环,时间复杂度O(n^2),会导致CTLE。对于这个问题,有两种解决方式。第一种是对valueSum进行动态维护,但是这个很复杂,需要在netWork的多种方法进行维护,在person中也要额外存储该person所在的tag。第二种方式依然进行两重for循环遍历,不过第二重循环只遍历第一重中person的acquaintance,这也可以在一定程度上降低时间复杂度。我选择的前者,但我考虑的不够周全。虽然这次的修改成功通过强测,但是没有充分进行回归测试,这就导致了我下一次作业的bug。
第十一次作业
红包的整除问题。如下的代码块,发红包的逻辑过程应该是从发送者减去message中的money,接收者接受money/tag.size。但是,在这个方法的实现中,我们先用一个int i存money/tag.size,发送者应该减去i*tag.size。看起来和前边的没有区别,但是我们用int来存储一个除法的结果,有很大概率会出现精度问题。例如,5块钱,分给两个人,这里的i应该是2(向下取整),所以,如果发送者原本有10块钱,最后应该剩余6块钱而不是5块钱。
if (m instanceof RedEnvelopeMessage && m.getTag().getSize() > 0) {
getPerson(m.getPerson1().getId()).addMoney(-((RedEnvelopeMessage) m).getMoney());
for (MyPerson p: ((MyTag) m.getTag()).getPersons().values()) {
int i = ((RedEnvelopeMessage) m).getMoney() / m.getTag().getSize();
getPerson(p.getId()).addMoney(i);
}
}
if (m instanceof RedEnvelopeMessage && m.getTag().getSize() > 0) {
int i = ((RedEnvelopeMessage) m).getMoney() / m.getTag().getSize();
getPerson(m.getPerson1().getId()).addMoney(-(i * m.getTag().getSize()));
for (MyPerson p: ((MyTag) m.getTag()).getPersons().values()) {
getPerson(p.getId()).addMoney(i);
}
}
回归测试不通过,qtvs的修改有误。在上一次作业的bug修复中,我提到了qtvs的改进方式,我选择的动态维护,但我只在addPersonToTag以及删边的时候进行了动态维护,少考虑了很多方法,最后导致强测出问题。修复时我发现还有许多方法要进行动态维护,对这几个方法添加了动态维护valueSum的代码后可以通过之前的错误点。
规格与实现分离
对于规格与实现分离,我谈一下我的理解。规格是我们在进行程序编写时的约束,对于代码给出条件约束,我们所实现的代码的功能必须严格与规格相符,否则就会出现错误。而我们在实现代码时,不是直接翻译规格,而是对在满足规格的前提下写出性能更好,更出色的代码。对于如何实现,这并不是规格所关注的,这是程序员所关注的。做个比喻,规格就是项目中的甲方,而程序员是乙方,甲方提出需求,乙方需要满足甲方的需求,至于如何去满足,采用什么方式满足,这是乙方所头疼的问题,与甲方无关。在我们的代码中,我们经常采用动态维护等方式来维护一些属性而并非按照规格中的循环来得到属性,以及采用HashMap等容器灵活存储变量而非选择规格中的数组,上述两个点可以很充分地体现规格与实现相分离的特点。
采用规格与实现分离,能够明确职责,从而促进更好地团队合作,增加代码的模块化与可维护性,同时便于针对规格编写测试,在工程上来说,是好处多多。在前几周,我们的企业导师来给我们讲解工程中的各个流程,各个流程分工明确,从需求确定,到实现代码,再到编写测试,每个部分都是独立的,而在其中起到关键链接作用的,正是规格,规格从需求确定开始,被编写代码者用来实现代码,被测试工程师来编写测试,足可见规格的重要性。
四、有关junit测试
JUnit 4引入了一项名为参数化测试的新功能。参数化测试允许开发人员使用不同的值反复运行相同的测试。创建参数化测试需要遵循五个步骤:
-
使用@RunWith(Parameterized.class)注释测试类。
-
创建一个使用@Parameters注释的公共静态方法,该方法返回一个对象集合作为测试数据集。
-
创建一个公共构造函数,它接受相当于一行“测试数据”的内容。
-
为测试数据的每个“列”创建一个实例变量。
-
使用实例变量作为测试数据的来源创建测试用例。
对于数据,我们可以在setUp中随机生成,对于方法的测试,我们需要根据规格进行编写代码,按照规格的逻辑写for循环等,通过Assert判断require,ensure assignable等语句的正确性。
对于规格实现分离的检查,由于我们只关注require ensure assignable语句,而这些语句中的属性是类共有的,并不是方法中的,所以,我们在测试时不会关注方法内部的代码,只会判断方法的正确性,以及类中的属性是否符合规格,实现了规格实现分离的检查,即按照规格进行检查,不关注具体实现。
五、学习体会
第一单元我们培养面向对象的思想,第二单元我们探究多线程的工作,这两个终归只是代码层面的知识。而这一单元的JML,让我们真正接触到实际软件工程中要应用的东西。有了规格限制后我所考虑的事情确实有所改变,从原来两个单元的由整体到局部,经常关心自己的架构有没有问题,到这个单元的只关注一个方法的实现或一个属性的维护,这让我感到JML在工程中明确职责的重要作用。JML正如老师所说,一开始感觉没什么用,学习之后感觉用处巨大,这也让我认识到,我们在学校编写的程序和实际中实现的大型工程还是有很大区别的,想要更好地为以后工作做准备,了解学习像JML这些工程中常用的工具是很重要的。