概述
本单元主要训练我们基于规格进行程序设计的能力,重点在于根据程序需求设计相应的方法规格和类规格,编写代码并基于规格进行测试。尽管课程主要强调理解和使用JML规格来书写代码,但在三次作业中我们发现,正确实现JML要求的功能只是基础。在此基础上,选择合适的数据结构和算法以提高程序性能也是关键。
一、测试过程分析
作业要求基本通过JML规格提供,因此正确理解JML规格并据此编写测试数据对保证程序正确性至关重要。以下是对一些测试方法的理解及应用。
1. 黑箱测试与白箱测试
黑箱测试和白箱测试是软件测试的两种常用方法。
黑箱测试在不考虑程序内部结构和实现细节的情况下,通过输入数据和预期输出测试软件系统的功能、性能和安全等方面。测试人员只关注软件接口的输入输出行为,不需了解程序的具体实现过程。白箱测试则是在了解程序内部结构和实现细节的基础上,通过各种测试手段验证软件系统的正确性、稳定性、安全性和性能等方面。测试人员需了解代码实现、算法、数据结构等技术细节,利用测试脚本、调试器等工具进行测试。
通俗地说,黑箱测试就像用户测试程序,提供需求和测试数据;白箱测试则要求我们从程序员视角出发,覆盖所有可能的输入数据,并重点测试潜在问题,利用各种工具提高测试强度和覆盖率。
2. 单元测试、功能测试、集成测试、压力测试、回归测试
单元测试:针对代码中最小的可测试单元(如函数、方法)进行测试,验证这些单元的行为是否符合预期。例如,在第二次作业中对modifyRelation
方法进行验证,考虑执行后可能的情况并编写相应测试数据。
public void modifyRelation(int id1, int id2, int value) throws PersonIdNotFoundException,
EqualPersonIdException, RelationNotFoundException {
if (contains(id1) && contains(id2) && id1 != id2
&& getPerson(id1).isLinked(getPerson(id2))) {
MyPerson person1 = (MyPerson) getPerson(id1);
MyPerson person2 = (MyPerson) getPerson(id2);
person1.modifyAcq(person2, value);
person2.modifyAcq(person1, value);
} else {
if (!contains(id1)) {
throw new MyPersonIdNotFoundException(id1);
} else if (!contains(id2)) {
throw new MyPersonIdNotFoundException(id2);
} else if (id1 == id2) {
throw new MyEqualPersonIdException(id1);
} else {
throw new MyRelationNotFoundException(id1, id2);
}
}
}
功能测试:模拟用户使用软件来验证整个应用程序是否按照需求工作。类似黑箱测试,从用户视角提供输入和预期输出,与程序输出比较,判断应用程序的正确性。
集成测试:将多个单元或组件组合测试,检查它们之间的交互是否如预期。确保组件之间接口的一致性和兼容性,测试多个模块协同工作。
压力测试:模拟大量用户访问应用程序,测试其性能和稳定性。帮助识别应用程序在高负载下的响应时间和资源使用情况,确定其承受负载能力。作业的强测环节就是一种压力测试,通过大量数据输入测试程序的正确性和性能。
回归测试:对软件进行更改后重新运行先前的测试,确保已有功能未被破坏。帮助开发人员快速发现并修复新更改引入的错误,确保软件的质量和稳定性。修改程序后需用之前的数据测试新程序的正确性,Gitlab中的CI/CD管道可以很好地开展回归测试。
3. 测试工具的使用
本单元建议使用Junit方法测试,但由于笔者的IDEA无法安装插件,故采用dalao的数据生成器和测试脚本进行随机测试。思路是利用数据生成器生成大量测试数据,通过对拍脚本对多个jar包的输出结果进行对拍,找出不同输出并寻找bug位置。
数据生成器和测试脚本如下:
//数据生成器
int main() {
int num = 100;
while (num-- > 1) {
printf("%d\\\\n", num);
num_person = num_message = num_group = 0;
char filename[100];
memset(filename, 0, 100);
memset(group_len, 0, 500 * sizeof(int));
memset(groupPerson, 0, 500 * 1111 * sizeof(int));
memset(hash, 0, 5000 * 5000 * sizeof(int));
char nums[100];
strcat(filename, "hw10datas\\\\\\\\fileIn");
itoa(num, nums, 10);
strcat(filename, nums);
strcat(filename, ".txt");
FILE *out = fopen(filename, "w");
for (int i = 0; i < 10; i++) {
AddPerson(out);
}
for (int i = 0; i < 100; i++) {
generator(out);
}
}
return 0;
}
//测试脚本
$y = 0
for ($x = 0; $x -lt 100; $x=$x+1)
{
cat ".\\\\hw10datas\\\\fileIn$x.txt" | java -jar hw_10.jar > ".\\\\hw10_out\\\\stdout$x.txt"
cat ".\\\\hw10datas\\\\fileIn$x.txt" | java -jar xxx.jar > ".\\\\hw10_out_xxx\\\\stdout$x.txt"
if($(diff (cat ".\\\\hw10_out\\\\stdout$x.txt") (cat ".\\\\hw10_out_xxx\\\\stdout$x.txt"))) {
echo "------------------------------------------------------------"
echo "$x xxx and zjy are different"
cat ".\\\\hw10_out\\\\stdout$x.txt" > ".\\\\error1\\\\stderr_xxx_$x.txt"
cat ".\\\\hw10_out_xxx\\\\stdout$x.txt" > ".\\\\error1\\\\stderr_xxx_$x.txt"
cat ".\\\\hw10datas\\\\fileIn$x.txt" > ".\\\\error1\\\\stderrInput$x.txt"
echo "------------------------------------------------------------"
$y=$y+1
}else {
echo "$x xxx and xxx are same"
}
if(!$y){
echo "NO_ERROR_THIS_TIME"
echo "Congratulation ! xxx xxx xxx are same in the test of $x"
}
4. 数据构造策略
作业数据构造分为正确性和性能两方面。对于正确性测试,社交关系网络的人数不会影响功能正确性,因此构造少数人的不同情况样例即可。例如,测试第三次作业中的deleteColdEmoji
方法:
ap 1 1 1
ap 2 2 2
ar 1 2 1
sei 1
aem 4 1 0 1 2
sm 4
sei 2
aem 5 1 0 1 2
aem 6 1 0 1 2
aem 7 2 0 1 2
dce 1
sm 5
aem 8 1 0 1 2
sm 7
该样例测试了dce
方法是否能正确删除heat低于limit的emoji,同时保留heat≥limit的表情消息。
对于性能测试,需要使用包含大量成员的社交网络,利用数据生成器生成随机数据,考虑网络中人数、边的密集程度、指令数量和比例等。不同条件下图的结构也需考虑,例如第三次作业中测试queryLeastMoments
时,需要考虑稠密图和稀疏图,否则难以发现Dijkstra算法在稀疏图下表现不佳的问题。
二、架构设计梳理
本单元作业中的图模型基于第一次作业的Block结构,即借鉴并查集的思想,将有关系的人放在一个Block中,维护Block结构,使一个Block中的人都是连通的,从而将qbs等方法的复杂度降到O(1)。具体操作如下:
public void manageBlock(int id1, int id2) {
HashMap<Integer, Person> block1 = new HashMap<>();
for (HashMap<Integer, Person> block : blocks) {
if (block.containsKey(id1)) {
block1 = block;
break;
}
}
if (!block1.containsKey(id2)) {
HashMap<Integer, Person> block2 = new HashMap<>();
for (HashMap<Integer, Person> block : blocks) {
if (block.containsKey(id2)) {
block2 = block;
blocks.remove(block);
break;
}
}
block1.putAll(block2);
}
}
public void deleteBlock(int id1
, int id2) {
HashMap<Integer, Person> dblock = null;
for (HashMap<Integer, Person> block : blocks) {
if (block.containsKey(id1) && block.containsKey(id2)) {
dblock = block;
break;
}
}
if (dblock != null) {
HashMap<Integer, Person> newblock = new HashMap<>();
getOneBlock(id1, dblock, newblock);
if (!newblock.containsKey(id2)) {
dblock.keySet().removeAll(newblock.keySet());
blocks.add(newblock);
}
}
}
对于其他时间复杂度较高的方法,可以采用“动态维护”的思想,即在改变社交网络的同时维护可能被查询到的数据,查询时直接返回相应数据,时间复杂度降到O(1)。例如,对qts指令,可以在每次加边时判断两点是否与Block中的第三点都有关系来增加qtsum
,删边时则需要判断Block中与这两点都有关系的点,减少qtsum
。对于qcs指令,因加边或删边影响人数最多是四人,判断这四人bestacquaintance情况即可计算qcsum
变化。具体实现如下:
public void manageCouple(int id1, int id2, int oldcp1, int oldcp2) {
MyPerson person1 = (MyPerson) getPerson(id1);
MyPerson person2 = (MyPerson) getPerson(id2);
int newcp1 = person1.getBestAcquaintance();
int newcp2 = person2.getBestAcquaintance();
MyPerson new1 = (MyPerson) getPerson(newcp1);
MyPerson new2 = (MyPerson) getPerson(newcp2);
MyPerson old1 = (MyPerson) getPerson(oldcp1);
MyPerson old2 = (MyPerson) getPerson(oldcp2);
if (oldcp1 != newcp1 || oldcp2 != newcp2) {
if (newcp1 == id2 && newcp2 == id1) {
cpsum++;
}
if (oldcp1 == id2 && oldcp2 == id1) {
cpsum--;
}
if (newcp1 != oldcp1) {
if (newcp1 == id2) {
if (old1 != null && old1.getBestAcquaintance() == id1) {
cpsum--;
}
} else {
if (new1 != null && new1.getBestAcquaintance() == id1) {
cpsum++;
}
}
}
if (newcp2 != oldcp2) {
if (newcp2 == id1) {
if (old2 != null && old2.getBestAcquaintance() == id2) {
cpsum--;
}
} else {
if (new2 != null && new2.getBestAcquaintance() == id2) {
cpsum++;
}
}
}
}
}
三、性能问题及修复
根据JML规格编写代码看似容易,但如果仅依照规格实现,例如使用数组存储Person对象,多次遍历qcs、qts、qlm等,程序运行速度会非常慢,导致强测失败。因此,掌握规格与实现分离的思想至关重要。
规格与实现分离的理念在于它们不同的要求。规格需严谨和易读,采用简单的数据结构和算法,以准确说明所需功能。而实现需保证程序正确性和运行效率。理解JML规格中方法的具体作用、前置条件和限制条件,从程序员角度选择合适的数据结构和算法来实现功能,就是规格与实现分离的具体体现。
在几次作业初期,我未很好地掌握这一思想。例如,第一次作业初步实现未使用动态维护和并查集,而是直接采用规格中的遍历方式,导致程序性能差。在同学建议下,重新修改框架,采用动态维护和并查集,程序效率提高数倍,顺利通过强测。
第三次作业较为复杂,要求查询社交关系网络中的最小环路。起初采用遍历源点每个邻接点,删除边后在新图中使用Dijkstra寻找最短路径,但效率低。后来改用只需运行一次Dijkstra的方法,通过遍历每条边判断最短路径是否重合,找出最小环路,将时间复杂度控制在O(n)内。
即便如此,Dijkstra算法在稀疏图下的高复杂度仍影响程序效率,导致强测CPU_TIME_LIMIT_EXCEED。最终对Dijkstra算法进行堆优化,将更新的最短路径推入堆中,下一次取堆顶元素递归,时间复杂度降低到O((n+m)logn),程序效率提高。
五、学习体会
回顾本单元学习内容,主要目标在于培养根据规格编写代码并进行测试的能力。不同于前两个单元侧重算法的高效性和创新性,本单元更注重算法的严谨性和鲁棒性。
从JML规格书写角度看,我们需确保严谨,准确描述方法的前置条件、副作用和后置条件,保证条件的正确性、完整性和无异议性。必要时构造中间数据,采用共性提取和组合机制等方法优化规格,提高可读性。
从根据JML规格实现代码的角度看,需了解规格管理的数据内容及其操作对象的本质功能,选择适当的数据结构和算法实现功能。
代码实现完成后,需回归规格,依据规格构造测试数据,对代码正确性和性能进行检验。
综上所述,通过本单元学习,我基本熟悉了基于JML规格的项目开发流程,学会了阅读和书写较为严谨的JML规格,同时掌握了依据规格实现代码并进行正确性验证的能力。更为重要的是,领会了规格与实现分离的核心思想,这些技能和思想将在未来的学习和工作中发挥重要作用。