OO第三单元——JML
测试过程
对黑箱、白箱测试的理解
- 黑箱测试:黑盒测试又称为功能测试,主要检测软件的每一个功能是否能够正常使用。在测试过程中,将程序看成不能打开的黑盒子,不考虑程序内部结构和特性的基础上通过程序接口进行测试,检查程序功能是否按照设计需求以及说明书的规定能够正常打开使用。换句话说,就是根据规则给定输入,检查输出是否合法。
- 优点:测试者不需要了解代码,实现独立于实现细节的验证。
- 缺点:可能无法覆盖所有的代码路径,潜在的内部缺陷可能未被发现。
- 白箱测试,也称为结构测试或透明盒测试,是一种测试方法,其中测试人员需要了解被测系统的内部结构和实现细节。测试的重点在于验证系统的内部操作是否按预期进行,通过检查代码路径、条件分支、循环等内部逻辑来确认系统的正确性。换句话说,就是人脑生成输入,根据代码逻辑进行模拟,检查输出是否合法
- 优点:可以发现代码中的逻辑错误和潜在缺陷,覆盖率较高。
- 缺点:对测试人员的技术要求较高,测试成本较大。
以下是对单元测试、功能测试、集成测试、压力测试和回归测试的详细解释:
单元测试 (Unit Testing)
单元测试是对软件中最小的可测试部分(通常是一个函数、方法或类)进行验证,以确保其按预期工作。也就是 JUnit
单元测试
- 优点:早期发现和修复错误,降低调试成本。
功能测试 (Functional Testing)
功能测试是一种黑箱测试方法,用于验证软件系统的功能是否符合需求规格说明。也就是对大系统实现的众多功能中的某一个进行测试,比如“向社交网络里增加一个用户”这个功能,涉及到了 Network.AddPerson, Network.containsPerson, DisjointSet.addPerson
等方法
集成测试 (Integration Testing)
集成测试是在多个单元或模块集成在一起后进行的测试,目的是验证它们之间的交互是否正确。集成测试可以采用白箱或黑箱测试方法。也就是中测强测之类的测试。
压力测试 (Stress Testing)
压力测试是一种非功能性测试,旨在评估系统在超负荷条件下的性能和稳定性。通过超出正常操作条件(如高负载、高并发)来测试系统的极限。比如在同一时间多个向电梯发送海量请求。
回归测试 (Regression Testing)
回归测试用于验证在软件修改后,未修改的部分仍然正确。每当软件进行代码变更或修复缺陷后,需要进行回归测试以确保新代码没有引入新的错误。每次在迭代之后都要检查之前的功能是否受影响。
总结
- 单元测试关注单个功能单元的正确性。
- 功能测试确保系统功能符合用户需求。
- 集成测试验证模块之间的接口和交互。
- 压力测试评估系统在极端条件下的性能和稳定性。
- 回归测试在软件修改后确保未修改部分仍然正确。
数据构造的策略
我主要采取白箱测试,构造数据时更多关注特殊的情况,比如 int
的极限值、完全图、整个图只有一条边、图中所有点都是孤立点、一个删去特殊边就会变为两个不相交子图的图等,这样构造数据基本上可以保证功能的正确性,但是无法保证高压下的性能。
架构设计
组成部分
MyPerson
:社交网络的成员MyNetwork
:社交网络,管理成员以及消息DisjointSet
:并查集,用来管理社交关系
Message
:各类消息MyTag
:标签,管理具有相同标签的成员
社交的图模型
构建策略
MyNetwork
管理所有成员,同时在MyNetwork
中的DisjointSet
中管理社交关系- 每次新增成员,则在
MyNetwork
中新增MyPerson
对象,同时在DisjointSet
中新增节点 - 每次新增关系,则在
DisjointSet
中新增一条边
维护策略
- 修改关系
- 如果是修改
value
,那么不更新边,只更新值 - 如果是删除边,那么要在删除边之后重新构建并查集
- 如果是修改
关于 Tag
和 Message
- 在
MyNetwork
中维护MyTag
集合和Message
集合 Tag
中内置valueSum
,在每次addRelation
和modifyRelation
时更新对应Tag
;在addPersonToTag
和delPersonFromTag
时更新对应Tag
性能问题与 Bug 修复
在前两次作业中出现了 TLE 的问题,主要原因还是性能优化不到位
并查集优化
-
通过并查集算法,能够很快判断
isCircle(id1, id2)
的问题,同时动态维护blockSum
和tripleSum
-
关于并查集:
-
并查集(Union-Find)是一种用于处理不相交集合的数据结构,支持合并两个集合和查找元素所属集合两种操作。并查集算法广泛应用于网络连接、图的连通性问题等领域。
-
并查集的基本操作:
- 查找(Find):确定元素属于哪个集合。在作业中可以使用路径优化:
public int find(int id) { int rep = id; while (rep != parentMap.get(rep)) { rep = parentMap.get(rep); } int now = id; while (now != rep) { int father = parentMap.get(now); parentMap.put(now, rep); now = father; } return rep; }
- 合并(Union):将两个集合合并为一个集合。在作业中可以使用按秩合并:
public void union(int x, int y, boolean flag) { int rootX = find(x); int rootY = find(y); if (rootX != rootY) { if (!flag) { blockSum--; // 维护 blockSum } int rankX = rankMap.get(rootX); int rankY = rankMap.get(rootY); if (rankX < rankY) { parentMap.put(rootX, rootY); } else if (rankX > rankY) { parentMap.put(rootY, rootX); } else { parentMap.put(rootY, rootX); rankMap.put(rootX, rankX + 1); // 更新秩 } } }
-
需要注意的是,如果采用了路径优化,那么每次删除关系(去掉某条边)后需要重建并查集
-
本单元中并查集的构建:
addPerson
时在并查集里新建节点,其父节点是自身,其秩为 0addRelation
相当于union
-
queryValueSum
的优化
注意:不考虑全局下相同 tagId
但是不同 Tag
的问题
-
采用动态计算的方式
-
对于
Tag
:-
在每次
addPersonToTag
时,将新加入的人与其它人的value
加到valueSum
里 -
在每次
delPersonFromTag
时,将要删去的人与其它人的value
从valueSum
里减去 -
在每次
addRelation
且id1
与id2
都在该Tag
中时,将value
加入valueSum
中 -
在每次
modifyRelation
且id1
与id2
都在该Tag
中时,根据value
进行对应的处理 -
@Override public void addPerson(Person person) { persons.put(person.getId(), person); for (Person personIn : persons.values()) { if (personIn.isLinked(person)) { valueSum += personIn.queryValue(person) * 2; } } ageSum += person.getAge(); } @Override public void delPerson(Person person) { if (hasPerson(person)) { ageSum -= person.getAge(); for (Person personIn : persons.values()) { if (personIn.isLinked(person)) { valueSum -= personIn.queryValue(person) * 2; } } persons.remove(person.getId()); } } public void addRelation(int value) { valueSum = valueSum + value * 2; } public void deleteRelation(int value) { valueSum = valueSum - value * 2; }
-
规格与实现分离
-
提高可维护性:
- 通过将规格与实现分开,代码的维护变得更加容易。可以在不改变规格的情况下,修改或优化实现。
- 示例:可以在保持接口不变的情况下,优化排序算法以提高性能,或者使用查找效率更高的数据结构。
-
增强可重用性:
- 规格提供了统一的接口,不同的实现可以基于相同的规格进行替换或复用。
- 示例:不同的排序算法可以复用相同的排序接口。
-
促进可扩展性:
- 通过定义清晰的规格,可以在未来轻松添加新的实现,而不影响现有系统。
- 示例:在已有排序算法的基础上,可以添加新的排序方法,而不需要更改调用代码。
-
便于测试:
- 通过分离规格与实现,可以针对规格编写测试用例,验证不同实现是否符合相同的功能要求。
- 示例:
Junit
单元测试
Junit
测试
利用规格信息设计Junit测试
- 明确测试目标:
- 规格定义了系统或组件的行为和功能,通过解析规格,可以明确需要测试的功能点和行为。
- 规格信息提供了详细的行为描述,包括前置条件、后置条件和类不变式。利用这些信息,可以明确测试的目标和预期结果。
- 示例:如果规格规定一个排序方法应该能够处理空列表、单元素列表和普通列表,那么这些情况都应该在测试中覆盖。
- 编写测试用例:
- 基于规格编写测试用例,确保测试用例覆盖所有预期的行为和功能。
- 示例:对排序算法的规格,可以编写测试用例检查排序方法对各种列表(空列表、已经排序的列表、逆序列表等)的处理。
- 使用边界值分析和等价类划分:
- 利用规格中的信息,进行边界值分析和等价类划分,以设计全面的测试用例。
- 示例:如果规格规定输入数值的范围是0到100,那么测试用例应包括0、100以及范围内的其他值。
- 测试异常和错误处理:
- 根据规格描述的异常情况和错误处理逻辑,编写测试用例验证这些情况。
- 示例:如果规格规定对非法输入应抛出特定异常,测试用例应验证该异常的正确抛出。
利用JUnit
测试检验代码实现与规格的一致性
-
验证前置条件
在
JUnit
测试中,可以首先验证前置条件是否满足,确保在适当的情况下调用方法。 -
验证后置条件
在方法调用后,使用
JUnit
断言验证后置条件是否满足。 -
验证类不变式
通过在方法调用前后验证类不变式,确保类在操作过程中始终保持一致性。
学习体会
使用 JML
优点
- 增强代码可读性和可维护性:
- 明确的行为规范:通过在代码中添加JML注释,可以清晰地定义每个方法的预期行为,方便开发者理解代码逻辑和意图。
- 文档化:JML注释可以作为详细的文档,帮助新加入的开发者快速上手项目。
- 提高代码质量和可靠性:
- 形式化验证:JML允许使用工具(如ESC/Java2)进行形式化验证,自动检查代码是否符合规范,提前发现潜在的错误和缺陷。
- 契约式编程:通过定义前置条件、后置条件和不变式,可以确保方法在预期的条件下运行,并在不满足条件时及时抛出异常,有效防止错误传播。
- 便于测试:
- 自动化测试生成:JML注释可以用于自动生成测试用例,覆盖规范中定义的各种情况,减少手工编写测试用例的工作量。
- 规范驱动的测试:通过JML定义的规范,可以确保测试用例严格按照预期的行为进行验证,提高测试的全面性和准确性。
一些想法
使用 JML 编程也有一些小问题,比如要理解并掌握 JML
语言需要时间;根据 JML
编写代码时,必须正确理解 JML
描述,当方法较为复杂时 JML
描述较为复杂,理解难度较大