BUAA OO Unit3 单元总结
文章目录
测试过程
黑箱测试与白箱测试
通过查阅资料,我暂且给出黑箱测试与白箱测试的定义和方法。
- 黑箱测试也称功能测试、数据驱动测试或基于规格说明的测试。测试者只知道程序的输入、输出和系统的功能,这是从使用者的角度针对软件的接口、功能及外部结构进行的测试,不考虑程序内部实现逻辑。
- 白箱测试也称结构测试、逻辑驱动测试或基于程序本身的测试,测试程序内部结构或运行。在白箱测试时,从程序设计语言的角度来设计测试样例。测试者输入数据并验证数据在程序中的流动路径,并确定适当的输出,类似测试电路中的节点。
了解了黑箱测试和白箱测试的定义后,我认识到虽然之前从未接触过这两个概念,但已经使用类似的方法开始测试了。
首先是白箱测试,从C语言程序设计课开始,我似乎就已经开始使用白箱测试了。对我来说,白箱测试的用处是在写完代码后确定程序中的每一步是否都是按照设想执行的,具体的实现方法往往是在每一行可能改变变量值的代码后都加上printf()
或System.out.println()
语句输出想要查看的变量值。我认为这种方法的效果与单步调试的效果相当,但更加简单直接、更加灵活且可用于多线程情况。
之后是黑箱测试,仅关注在给定功能要求和输入下的输出。这个也不陌生,各种课程组的评测机、自己写的评测机和对拍器以及本单元作业中出现的OKTest都是黑箱测试。
在日常使用中,对代码的测试往往是黑箱和白箱相结合的。以OS上机为例,在自认为完成要求后,我会先使用make xxxx && make run
跑一下课程组的测试。测试代码往往是单独的一个文件,并在其内调用我写好的函数,通过assert
语句检查函数的返回值是否符合预期,这是黑箱测试。在这测试后,如果出现了问题,便需要具体检查函数的实现。这个过程既需要用人脑模拟代码的执行,又可以在多条关键语句前后加上printk()
, debugf()
检查变量值的变化是否符合逻辑预期,碰到不符合预期的地方便是发现bug,可以开始修改了,改完之后便可以使用黑箱测试继续测试最终结果,若结果不对就继续白箱测试…
单元测试等多种测试手段
- 单元测试:单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作,对面向过程的语言来说是测试函数,对面向对象的语言来说是测试类的行为。在第10次作业中,我构造大数据完全图对
qsp, qtvs, qtav, qba, qcs
等指令进行了测试,这便是单元测试的应用。 - 功能测试:通过上面黑箱测试的介绍可知功能测试大概就是黑箱测试,不考虑程序内部结构而仅测试程序执行的结果。
- 集成测试:集成测试是指把多个程序模块放在一起测试的方法。由于每个模块都正确不一定能保证整个程序正确,在单元测试后把多个单元结合在一起测试的集成测试必不可少。从实际效果上来说,在第三单元的功能不断增加的迭代开发过程中,前一次作业中全面的强测就相当于下一次作业的集成测试。
- 压力测试:压力测试是针对系统或是组建,为确定其稳定性而特意进行的严格测试。我感觉压力测试的意义在于低压测试对不同情况的覆盖不完全,从而可能检查不出程序的潜藏问题,而高压测试通过大数据、高并发(如果是多线程),可以尽可能枚举出各种情况,增大发现问题的概率。距离而言,在电梯单元中,自己造出的弱数据不一定能发现像是轮询、死锁之类的问题,而助教出的压力测试强数据以及评测机超多线程同时跑多份代码的情况则几乎可以发现绝大多数问题。
- 回归测试:回归测试旨在测试软件原有功能在修改后是否保持完整,这也是纪老师在课上多次提到的。举例而言,在OO每个单元的迭代中,在测试完某一次作业后,要保留好自己构造的测试样例,下次迭代开发之后继续使用。
数据构造策略
在三次作业中,我尝试写了两种数据生成器,一个是针对各种指令的集成测试数据生成器,一个是针对qtvs, qbs, qcs
等指令的单元测试/压力测试生成器。
单元测试生成器的逻辑比较简单,首先通过ln
构建一个完全图,然后加入一定数量的tag
,同时不断ar, mr
并且qtvs
,最后再一直qtvs
总计10000条数据来进行压力测试,考虑到OO
评测机的玄学属性,对于一切本地运行时间超过1.6s
的数据,进行分析,并且对于代码进行相应的优化。
对于集成测试数据生成器,我原本想采用全部随机生成的方式,但发现这样生成的数据会有大量的异常,对于各种计数指令几乎起不到测试效果。于是我在生成器中维护了几个id
池以及关系池用来存放已经生成的数据,并在具体生成指令时,通过随机数判断生成会引起异常的指令或是不会引起异常的指令,这样生成的数据效果尚可。
架构与图模型
在架构上,由于官方包已经给出了比较多且明确的要求和限制,我并没有做什么突破,不同类之间的层次关系都是按照官方包写的。
而对于图模型的设计和维护则是本单元算法考察的重点。下面重点谈谈我在三次作业中的图模型设计。
图使用邻接表,即Acquaintance
构建。对于qci
和qbs
,hw9就涉及删边,故并不采用并查集等算法,在每次qci
时做BFS
,在加边/删边时BFS动态维护qbs
。对于qts
,在加边和删边时动态维护。对于qba
,对每个点开一个TreeSet
,在增/删/修改边权时,动态维护该点的bestAc
;对于qcs
,每次遍历所有点进行统计。
优化的重点在于Tag
中的方法,尤其是qtvs
方法,经过本地压力测试,发现如果不进行动态维护,而是使用JML
描述的直接查询的O(
n
2
n^2
n2)的复杂度方法,则会CTLE
,因此必须在每次att, dft,ar, mr
的时候进行动态维护。
规格与实现分离及性能问题
规格与实现分离
在这个单元中,其实很能体会到规格与实现的分离,因为假如不分离,实现按着JML
来,便会因为性能不佳O(
n
2
n^2
n2)甚至O(
n
3
n^3
n3)的时间复杂度可不是闹着玩的😅而大把扣分。而为了实现规格与代码的分离,我认为要做以下两步。
- 第一步是通读
JML
规格,把JML
的规格设计转换为自然语言,抽象为一些符合常识的描述。在面对一些复杂方法时,这一步尤为重要,因为JML
只描述规格,而我们写代码一般需要知道某个函数的意义(我认为一般人都没有在不知道意义的情况下直接把一堆通过遍历描述的方法转换为dijkstra
等较优算法的能力)。 - 第二步是根据抽象出来的自然语言描述选取合适的数据结构和算法,完成需要实现的功能。
实现中的性能问题
我的代码在本单元三次作业的强测和互测中均未出现性能问题。究其原因,在于我在每一次阅读JML
时就对其中蕴涵的时间复杂度“陷阱”,在经过压力测试后采用动态维护,最终避免了CTLE
。
通过上面规格与实现分离部分的讨论便可看出,规格和实现是可以分离的,而作为程序员,应该追求满足规格下的最优性能实现。但是否能达到很好的效果,则取决于程序员的知识积累与阅历。简单来说,如果知道更好的算法,就有可能写出性能更好的代码,否则仅凭规格就写不出来。
Junit测试
Junit测试与充分诠释了“测不准原理”的OO
评测机共同构成了Unit3的两大梦魇,甚至在中测环节,Junit测试带来的压迫感远比程序正确性要大得多。
看着即将见底的提交剩余次数与随时可能测出问题的代码,和随机刷新的Junit测试WA
测试点,大家难免骂骂咧咧😡,汗流浃背😅。
那么,有没有办法系统性的解决这一问题呢,我认为是有的。我们发现,Junit测试重点关注两个部分——覆盖率高的测试数据,以及确保“万无一失”的标程。
构造覆盖率高的数据
构造覆盖率高的数据技巧不高,在完成数据构造后,可以运用IDEA中的使用覆盖率运行test来进行分支覆盖率测试,最终不断完善,获得一组对目标方法分支覆盖率100%的测试数据。
撰写标程
标程考察的无非以下几个条件:
- 检查
ensure
; - 检查
pure/assignable
; - 检查
invariant/constraint
; - 等等
在具体了解了这些条件后,我们其实可以借鉴OKTest
的方法来撰写标程,下面以hw11
中的deleteColdEmoji()
的测试为例来进行说明。
public int judge(HashMap<Integer, Integer> beforeEmojis, HashMap<Integer, Integer> beforeMessages, HashMap<Integer, Integer> afterEmojis, HashMap<Integer, Integer> afterMessages, int limit, int result) {
for (int eid : beforeEmojis.keySet()) {
if (beforeEmojis.get(eid) >= limit && !afterEmojis.containsKey(eid)) {
//System.out.println(eid + " " + beforeEmojis.get(eid));
return 1;
}
}
for (int eid : afterEmojis.keySet()) {
if (!beforeEmojis.containsKey(eid)
|| !beforeEmojis.get(eid).equals(afterEmojis.get(eid))) {
return 2;
}
}
int cnt = 0;
for (Map.Entry<Integer, Integer> entry : beforeEmojis.entrySet()) {
if (entry.getValue() >= limit) {
++cnt;
}
}
if (cnt != afterEmojis.size()) {
return 3;
}
for (Map.Entry<Integer, Integer> entry : beforeMessages.entrySet()) {
if (entry.getValue() != null && afterEmojis.containsKey(entry.getValue())
&& (!afterMessages.containsKey(entry.getKey())
|| !entry.getValue().equals(afterMessages.get(entry.getKey())))) {
return 5;
}
}
for (Map.Entry<Integer, Integer> entry : beforeMessages.entrySet()) {
if (entry.getValue() == null && (!afterMessages.containsKey(entry.getKey())
|| afterMessages.get(entry.getKey()) != null)) {
return 6;
}
}
cnt = 0;
for (Map.Entry<Integer, Integer> entry : beforeMessages.entrySet()) {
if (entry.getValue() == null) {
++cnt;
} else if (afterEmojis.containsKey(entry.getValue())) {
++cnt;
}
}
if (cnt != afterMessages.size()) {
return 7;
}
return result == afterEmojis.size() ? 0 : 8;
}
可以看到,我们针对JML
中的\old
条件设置了before*
的一系列数据点,同时针对每个ensure
条件依次测试,对于违反条件的情况依次返回不同的错误值,这样也方便Debug
,最后,只要assertEquals(0, judge(beforeEmojis, beforeMessages, afterEmojis, afterMessages, limit, result));
即可完成正确性的判断。
借由OKTest
的思想,我们能够对于每一个ensure
条件都进行测试的撰写,保证了标程的**“万无一失”**。
心得体会
收获
还是照例先谈谈这一单元的收获吧,在Unit3的学习中,我主要在以下三个方面有所收获:
- 复习了图论的相关算法,包括
BFS,并查集,Dijkstra
等 - 初步学习了
JML
,对于规格化设计有了初步了解。 - 了解了各种测试方法,并且重新捡起了在电梯单元无暇进行的评测机撰写。
一点吐槽
先谈谈对于JML
的感受吧,在进行这一单元学习的同时,我读到了一个有趣的对于语言的吐槽,来自我在北外的学弟。我认为这十分契合我在Unit3的学习体会,决心一定要写进博客作业里。
JML像是在英汉对译的时候,先将中文翻译为法语,再把法语翻译成英文。我中文还好点,英文水平一般,法语则是完全不会。纵使说法语严谨到天花乱坠,对我来讲与鬼画符真没啥区别。谈及JML的优点,我确实无话可说,能与JML相遇是我的缘分,不过我希望这样的缘分越少越好。
当然,JML
能够流传下来,传播甚广,一定是有其独到之处的。就像是那个经典的哲学辩题——被误解是不是表达者的宿命,我在想,如果哲学家辩论时能够使用JML
华山论剑,那想必答案已然明了,作为一种**“建模”语言**,JML
最大的有点便是尽可能的减少了误解的存在。
但事实是否真的如此呢,JML
显然不便于撰写,Unit3横跨五一假期,假期中我最担心的便是指导书和JML
标程的突然修改,因为这很有可能使我的假期泡汤,而这,在这一单元经常发生!!!
第二则是评测机的“不确定性”,虽说课程组给出的时间限制是10s,但是在实际情况下,一旦本地运行超过1.8s,那么便很有可能发生CTLE
,这实在叫人胆战心惊,每一次点开强测结果都像是开盲盒,非常刺激😊。
最后还是感谢助教的辛勤付出,以上只是我的一点牢骚,我们也很能理解助教频繁修改指导书,JML
背后的不易。只是,连助教团队在实际编写过程中都频繁出错的JML
是否有继续推广的价值呢,我认为值得商榷!!!