编写重要的测试:首先处理最复杂的代码
已发表: 2022-03-11围绕代码质量主题有很多讨论、文章和博客。 人们说 - 使用测试驱动技术! 测试是开始任何重构的“必备”! 这一切都很酷,但现在是 2016 年,还有大量的产品和代码库仍在生产中,这些产品和代码库是在十年、十五甚至二十年前创建的。 众所周知,他们中的许多人都有测试覆盖率低的遗留代码。
虽然我希望始终处于技术世界的领先地位,甚至处于领先地位——参与新的酷项目和技术——但不幸的是,这并不总是可能的,而且我经常不得不处理旧系统。 我喜欢说,当你从零开始发展时,你就像一个创造者,掌握着新事物。 但是当你处理遗留代码时,你更像是一名外科医生——你知道系统一般是如何工作的,但你永远不知道病人是否能在你的“手术”中幸存下来。 而且由于它是遗留代码,因此没有多少最新的测试可供您依赖。 这意味着最常见的第一步是用测试覆盖它。 更准确地说,不仅仅是提供覆盖,而是制定测试覆盖策略。
基本上,我需要确定的是我们首先需要测试系统的哪些部分(类/包),我们需要单元测试的地方,集成测试更有帮助的地方等等。无可否认,有很多方法可以处理这种类型的分析,我用过的可能不是最好的,但它是一种自动的方法。 一旦实现了我的方法,实际进行分析本身只需要最少的时间,更重要的是,它为遗留代码分析带来了一些乐趣。
这里的主要思想是分析两个度量——耦合(即传入耦合或CA)和复杂性(即圈复杂性)。
第一个测量有多少类使用了我们的类,所以它基本上告诉我们一个特定的类与系统的核心有多接近; 使用我们的类的类越多,用测试覆盖它就越重要。
另一方面,如果一个类非常简单(例如只包含常量),那么即使它被系统的许多其他部分使用,创建测试也没有那么重要。 这是第二个指标可以提供帮助的地方。 如果一个类包含大量逻辑,则循环复杂度会很高。
同样的逻辑也可以反过来应用; 即,即使一个类没有被许多类使用并且只代表一个特定的用例,如果它的内部逻辑很复杂,用测试覆盖它仍然是有意义的。
但是有一个警告:假设我们有两个类 - 一个具有 CA 100 和复杂性 2,另一个具有 CA 60 和复杂性 20。即使第一个的指标总和更高,我们绝对应该涵盖第二个先。 这是因为第一个类被许多其他类使用,但不是很复杂。 另一方面,第二类也被许多其他类使用,但相对比第一类复杂。
总结一下:我们需要识别具有高 CA 和圈复杂度的类。 在数学术语中,需要一个可用作评级的适应度函数 - f(CA,Complexity) - 其值随着 CA 和复杂度的增加而增加。
寻找工具来计算整个代码库的 CA 和复杂度,并提供一种以 CSV 格式提取此信息的简单方法,这被证明是一项挑战。 在搜索过程中,我发现了两个免费的工具,因此不提它们是不公平的:
- 耦合指标:www.spinellis.gr/sw/ckjm/
- 复杂性:cyvis.sourceforge.net/
一点数学
这里的主要问题是我们有两个标准——CA 和圈复杂度——所以我们需要将它们结合起来并转换成一个标量值。 如果我们有一个稍微不同的任务——例如,找到一个与我们的标准组合最差的类——我们将遇到一个经典的多目标优化问题:
我们需要在所谓的帕累托前沿(上图中的红色)上找到一个点。 帕累托集的有趣之处在于,集合中的每个点都是优化任务的解决方案。 每当我们沿着红线前进时,我们都需要在我们的标准之间做出妥协——如果一个变得更好,另一个变得更糟。 这称为标量化,最终结果取决于我们如何做。
我们可以在这里使用很多技术。 每个都有自己的优点和缺点。 然而,最流行的是线性标量和基于参考点的。 线性是最简单的。 我们的适应度函数看起来像 CA 和复杂度的线性组合:
f(CA, 复杂度) = A×CA + B×复杂度
其中 A 和 B 是一些系数。
代表我们优化问题的解决方案的点将位于这条线上(下图中的蓝色)。 更准确地说,它将位于蓝线和红色 Pareto 前沿的交汇处。 我们最初的问题并不完全是一个优化问题。 相反,我们需要创建一个排名函数。 让我们考虑排名函数的两个值,基本上是 Rank 列中的两个值:
R1 = A∗CA + B∗Complexity 和 R2 = A∗CA + B∗Complexity
上面写的两个公式都是直线方程,而且这些直线是平行的。 考虑到更多的排名值,我们将得到更多的线,因此帕累托线与(虚线)蓝线相交的点也更多。 这些点将是对应于特定排名值的类别。

不幸的是,这种方法存在问题。 对于任何一条线(等级值),我们都会有 CA 非常小且复杂度非常大(反之亦然)的点位于其上。 这会立即将度量值之间存在很大差异的点放在列表顶部,这正是我们想要避免的。
进行标量化的另一种方法是基于参考点。 参考点是两个标准都具有最大值的点:
(最大(CA),最大(复杂性))
适应度函数将是参考点和数据点之间的距离:
f(CA,复杂度) = √((CA−CA ) 2 + (复杂度−复杂度) 2 )
我们可以把这个适应度函数想象成一个以参考点为中心的圆。 在这种情况下,半径是 Rank 的值。 优化问题的解决方案将是圆接触帕累托前沿的点。 原始问题的解决方案将是对应于不同圆半径的点集,如下图所示(不同等级的圆部分显示为蓝色虚线曲线):
这种方法可以更好地处理极值,但仍然存在两个问题:首先——我希望在参考点附近有更多的点,以更好地克服线性组合所面临的问题。 其次——CA 和圈复杂度本质上是不同的,并且具有不同的值集,因此我们需要对它们进行规范化(例如,使两个指标的所有值都从 1 到 100)。
这是我们可以用来解决第一个问题的一个小技巧——我们可以查看它们的倒置值,而不是查看 CA 和 Cyclomatic Complexity。 在这种情况下,参考点将是 (0,0)。 为了解决第二个问题,我们可以使用最小值标准化指标。 这是它的外观:
倒置和归一化复杂度——NormComplexity:
(1 + min(复杂度)) / (1 + 复杂度)∗100
倒置和归一化 CA – NormCA:
(1 + min(CA)) / (1+CA)∗100
注意:我加了 1 以确保没有除以 0。
下图显示了一个反转值的图:
最终排名
我们现在来到最后一步——计算排名。 如前所述,我使用的是参考点方法,所以我们唯一需要做的就是计算向量的长度,对其进行归一化,并使其随着为类创建单元测试的重要性而提升。 这是最终的公式:
Rank(NormComplexity , NormCA) = 100 - √(NormComplexity 2 + NormCA 2 ) / √2
更多统计数据
我还想补充一个想法,但让我们先看一些统计数据。 这是耦合指标的直方图:
这张图有趣的是低 CA (0-2) 的类的数量。 CA 0 的类要么根本不使用,要么是顶级服务。 这些代表 API 端点,所以我们有很多它们很好。 但是具有 CA 1 的类是端点直接使用的类,我们拥有的这些类比端点多。 从架构/设计的角度来看,这意味着什么?
一般来说,这意味着我们有一种面向脚本的方法——我们分别编写每个业务案例(我们不能真正重用代码,因为业务案例过于多样化)。 如果是这样,那肯定是代码异味,我们需要进行重构。 否则,说明我们系统的内聚度低,这种情况我们也需要重构,但这次是架构重构。
我们可以从上面的直方图中获得的其他有用信息是,我们可以从符合单元测试覆盖条件的类列表中完全过滤掉低耦合类({0,1} 中的 CA)。 但是,相同的类是集成/功能测试的良好候选者。
你可以在这个 GitHub 存储库中找到我使用过的所有脚本和资源:ashalitkin/code-base-stats。
它总是有效吗?
不必要。 首先,这都是关于静态分析,而不是运行时。 如果一个类与许多其他类相关联,则可能表明它已被大量使用,但并非总是如此。 例如,我们不知道最终用户是否真的大量使用该功能。 其次,如果系统的设计和质量足够好,那么它的不同部分/层很可能通过接口解耦,因此对 CA 的静态分析不会给我们一个真实的画面。 我想这是 CA 在 Sonar 等工具中不那么受欢迎的主要原因之一。 幸运的是,这对我们来说完全没问题,如果你还记得的话,我们有兴趣将它专门应用于旧的丑陋代码库。
一般来说,我会说运行时分析会产生更好的结果,但不幸的是,它的成本更高、更耗时且更复杂,因此我们的方法是一种潜在有用且成本更低的替代方案。