301
社区成员
发帖
与我相关
我的任务
分享@
在测试中,把程序看作一个不能打开的黑盒子,在完全不考虑程序内部结构和内部特性的情况下,在程序接口进行测试,它只检查程序功能是否按照需求规格说明书的规定正常使用,程序是否能适当地接收输入数据而产生正确的输出信息。例如各种评测机,便是一种黑盒测试,即只给出输入和期待输出,并不考虑程序的内部结构。
白盒测试是一种测试用例设计方法,盒子指的是被测试的软件,白盒指的是盒子是可视的,即清楚盒子内部的东西以及里面是如何运作的。“白盒”法全面了解程序内部逻辑结构、对所有逻辑路径进行测试。
白盒测试大体可以分为5个程度:
语句覆盖 -> 判定覆盖 -> 条件覆盖 -> 多条件覆盖 -> 修正条件判定覆盖
其发现错误的能力由弱到强,需要根据实际需要选择具体的测试方法,例如在一些国家军工产业,均采用的是覆盖能力最强的修正条件判定覆盖,此种方法测试代码繁琐,测试时间长,但发现错误能力很强。
单元测试是针对软件的最小功能模块进行测试的方法。在这种测试中,开发人员会编写针对每个函数或方法的测试用例,以验证其在各种输入条件下的正确性。单元测试的目的是尽早发现和解决代码中的错误和缺陷,以确保软件的各个功能模块能够独立地正常工作。例如在oopre中为每个函数编写的junit测试,即为单元测试
集成测试是在单元测试之后,将各个功能模块组装在一起进行测试的方法。在集成测试中,测试人员会验证各个模块之间的接口和交互是否正常,并且整个系统能够正常协作。集成测试的目的是确保各个模块之间的集成能够正常工作,不会产生冲突和错误。
功能测试是对整个软件系统的功能进行测试的方法。在功能测试中,测试人员会根据需求规格说明书编写测试用例,并通过输入不同的数据或操作软件的不同功能来验证软件是否满足设计要求。功能测试的目的是确保软件能够按照用户需求的要求正常运行,并能够正确地处理各种情况。我的理解就是对软件根据不同功能分类,分别用黑盒测试的方法测试各个功能。比如HW11强测的测试点2024_unit3_hw11_strong_1
压力测试是对软件系统在高负载和大并发情况下进行测试的方法。在压力测试中,测试人员会模拟大量用户同时访问系统,并且增加系统负载,以评估系统的性能和稳定性。压力测试的目的是找出系统的瓶颈和性能问题,并且确定系统在高负载情况下的性能指标。比如HW11强测点的2024_unit3_hw11_strong_9和2024_unit3_hw11_strong_10
回归测试是在软件系统进行修改或升级后,重新运行之前的测试用例以验证修改是否引入新的错误或导致原有功能出现问题的方法。回归测试的目的是确保在对软件进行修改或升级后,原有的功能和性能没有受到影响。回归测试一般会在每次修改或升级后进行,以确保软件的稳定性和质量。对于我们的作业而言,每次在作业迭代之后,我都会跑一下上一次作业的评测机,保证没有影响之前的功能
我按照两种方式构造测试数据,分别进行不同的测试。
此种构造方法,追求的是尽量覆盖所有指令,尽量覆盖代码的各个分支,去测试代码的基本功能是否实现有误,在数据生成时,我会采用随机策略生成指令。
此种构造方法,追求的是尽量让关系图更复杂,让查询更困难,让运行时间尽量长,去测试代码的方法实现的复杂度是否过高,确保代码在高压下还能在规定时间内运行出结果。
一些问题及解决方法
数据生成器的代码中可以同时也维护一个保存已生成的人之间的二维关系数组和tag数组,在生成ar和att类的指令时,根据保存的关系,去生成一定不报异常的指令
这种查询由于报出了异常或者去查询一个空图或空tag,很难真的测试出功能的正确性,所以在生成此类指令时,我也会根据保存的关系,尽量去查询一些tagSize大的,或是结点和关系比较多的子图
我的架构优化过程如下
其实现的功能如下:
方法复杂度和优势分析:
问题分析:
操作指令:ar,mr
查询指令:qbs,qci
**person.getAncestor()**:函数:返回person的祖先
这种方法对于每一次操作指令,都去维护并查集,保证了任意时刻block_sum的值和person.getAncestor()的都正确。但这种维护并不是必要的,只有当我们遇到查询指令的时候,我们才需要获得上述两个值。
举个例子,假如指令序列如下
ap 1 1 1
ap 2 2 2
ap 3 3 3
ar 1 2 1
ar 2 3 1
ar 1 3 1
mr 2 3 -2
qbs
我们可以发现,第4-6条指令都是操作指令,我们维护了三次并查集,但其实只有到第8条指令时,我们才需要获得一个正确的并查集,完全可以延迟维护,由此,便引出了带“脏位”的并查集2.0
这个算法很好理解, 在Mynetwork类中增加属性:boolean dirty
方法优势分析:
缺点分析:
所以,此算法并不推荐,因为我们不能臆想指令都是按照我们期望的顺序来的,但并不代表“脏位”的思想不可行,由此便引出了本篇分享的重点:精确“脏位”并查集3.0
通过分析,并查集2.0的最大的问题是重建并查集的复杂度过高,且完全没有必要。其根源是因为我们每次遇到操作指令就无脑置脏位,且该脏位为全局脏位(指的是对整个network都有效,就好像整个网都被“污染”了)
举个例子,假设network有两个block,其并查集如下图,其中@代表结点名为a;**->代表儿子节点指向父亲节点;---代表两个结点互为acquaintance**(---并没有画全)
在这张图的基础上(假设现在脏位为 false,整个网都是”干净“的,即person.getAncestor()和block_sum均有效),其后的指令序列如下
ar 2 3 1
mr 1 2 -200
qci 4 5
qbs
qbs
mr 2 7 -200
qci 2 6
前两条为操作指令,第一条指令连接了结点2和结点3,第二条指令切断了结点1和结点2。从第一条指令结束后,按照并查集2.0的算法,应当置脏位,代表整个网均被污染。第三条查询指令来临时,由于网被污染,所以不得不重建并查集。
但是,我们分析可以发现,对于第一条指令而言,由于结点2和3的祖先相同,那么对于这两个结点的加边操作不会污染这张网,即block_sum不需修改,对于第二条指令而言,删除了结点1和结点2的关系,仅对所有祖先为结点为ancestor1的结点有影响,换句话说,只有一个祖先和其子孙所构成的子图被污染了,我们可以为network新增一个集合容器,名为dirtyAncestor,把此时被污染的祖先加入集合。
对于第三条查询操作,由于结点4和5的祖先为ancestor2,不在dirtyAncestor中,所以并不需要重建并查集,直接和并查集1.0一样,比较两个结点的ancestor是否相同即可
第四条qbs,由于询问的是block_sum的值,所以不得不重建并查集,但同样的,我们只需要重建祖先为ancestor1的结点即可,并不需要遍历所有的点。此时,所有的网又恢复到干净的状态,清空dirtyAncestor
那么第五条的qbs,由于网是干净的,直接返回block_sum的值即可
第六条, 删除结点2和7的联系,污染了祖先为ancestor1的子图,将ancestor1结点加入dirtyAncestor集合中
第七条,询问2和6结点是否有联系,此时我们发现,2的祖先为ancestor1,位于dirtyAncestor中,代表2处于污染状态(即此时2的祖先有可能不是ancestor1),而6的祖先为ancestor2,处于干净状态,此时我们可以直接认为,2和6一定没有关系!不需要重建并查集!(此处可以思考一下原因)
总结而言,对于qbs操作,需要重建所有祖先结点处于dirtyAncestor中的结点,对于qci操作:
算法分析:
缺点:
查询复杂度为O(1)的指令:qv,qsv,qrm,qp,qm,qci,qbs
对于这类指令,由于原本的查询复杂度就是O(1),所以不做任何维护,直接查询,值得一说的是,qci和qbs原本的复杂度为O(n),由于使用了并查集,所以复杂度降为O(1)
查询复杂度为O(n)的指令:qtav,qba,qsp
private final TreeMap<Integer, TreeSet<Integer>> ids
Treemap的key为该person的熟人的value值,Treemap的value值为这个熟人里的value值等于刚刚提到的key的所有熟人id,由于treeMap和treeset的有序性,用如下方式取得的一定是该person的bestAcquaintance
int bestAcquaintanceId = ids.lastEntry().getValue().first();
其复杂度为O(logN)
查询复杂度为O(n2)的指令:qtvs,qcs
public void addPerson(Person person) {
//...
for (MyPerson myPerson1 : persons.values()) {
valueSum += 2 * myPerson.queryValue(myPerson1);
}
}
public void delPerson(Person person) {
//...
for (MyPerson myPerson1 : persons.values()) {
valueSum -= 2 * myPerson.queryValue(myPerson1);
}
}
public void addAcquaintance(MyPerson myPerson, int value) {
//...
for (Pair<Integer, MyTag> fatherTag : fatherTags) {
if (myPerson.containsFatherTag(fatherTag.getKey(), fatherTag.getValue())) {
fatherTag.getValue().addValueSum(value);
}
}
}
public void delAcquaintance(MyPerson myPerson) {
//...
for (Pair<Integer, MyTag> fatherTag : fatherTags) {
if (myPerson.containsFatherTag(fatherTag.getKey(), fatherTag.getValue())) {
fatherTag.getValue().addValueSum(-value);
}
}
}
查询复杂度为O(n3)的指令:qts
qts的维护复杂度为O(n) 远低于查询复杂度,果断维护。方法很简单,addRelation和modifyRelation时查找两个person的共同熟人数量,让tripleSum的值加(减)这个数量即可
我认为将这二者分离的最大优势便在于,当我的代码出现bug,或者出现性能问题时,我只需要考虑如何修改我的实现,而规格是绝对不变的,很利于重构。
再者,规格相对于自然语言,能更准确的描述每个功能的细节,有效避免了自然语言所带来的歧义。
最后,规格的语法和java的语法也很类似,为我们在编写代码的时候也提供了参考,加快了我们的编码速度
如何利用规格信息来更好的设计实现Junit测试,以及Junit测试检验代码实现与规格的一致性的效果
规格包含三个部分即前置条件,副作用范围,后置条件
构造测试用例的时候,样例要尽量覆盖所有满足前置条件的用例
检查正确性的时候,既要检查后置条件,也要检查副作用有没有超出影响范围
只要保证了以上两点,就能构造出较为完备的测试数据和检测程序
使用规格来写代码具有多重优势。首先,规格提供了清晰的指导,使得代码的预期行为更易于理解和阅读,从而减少了歧义,提高了准确性和一致性。其次,规格作为代码的文档,有助于提高代码的可维护性,降低了后续维护成本,并减少了引入新bug的风险。此外,规格可以用作测试用例的基础,帮助开发人员编写更有效的测试,从而提高了代码的可靠性和稳定性。最后,规格促进了团队之间的协作和沟通,使得团队成员能够更好地理解彼此的工作并共同实现目标。综上所述,使用规格来写代码是提高代码质量、可维护性和可靠性的重要方法之一。
感谢同学提供的灵感和帮助,感谢助教们悉心维护课程网站,随时回答同学的问题,和认真讲解的老师