GraphGists

jqa

简介

jQAssistant 是一款 QA 工具,它允许在结构层面上定义和验证项目特定的规则。它建立在 Neo4j 图数据库之上,可以轻松地集成到构建流程中,以自动检测约束违规并生成有关用户定义的概念和指标的报告。

示例用例

  • 强制命名约定(例如 EJB、JPA 实体、测试类、包、Maven 模块等)

  • 验证项目模块之间的依赖关系

  • 分离 API 和实现包

  • 检测常见问题,例如循环依赖或没有断言的测试

使用 jQAssistant

规则用 Cypher 表达,Cypher 是 Neo4j 的易于学习的查询语言

不调用断言方法的测试
MATCH (t:Test:Method)
WHERE NOT (t)-[:INVOKES]->(:Assert:Method)
RETURN t AS TestWithoutAssertion

许可证:jQAssistant 在 GNU 通用公共许可证 v3 下贡献。

扫描流程

如果你还没有扫描你的项目,请下载 jQAssistant 此处

然后使用以下命令运行项目的扫描

bin/jqassistant.sh scan -f lib

它将扫描以下方面

  • Java 类和 jar 文件

  • Maven pom.xml

  • XML 配置文件

  • Spring 配置

  • Persistence.xml

  • …等等

启动 Neo4j 并运行 jqa

然后你可以使用以下命令启动 Neo4j 服务器

bin/jqassistant.sh server

并在该服务器中使用 :play https://guides.neo4j.com/jqa 运行本指南。

数据概览

如果你在扫描后的数据上运行 Neo4j 实例,你可以使用以下查询快速查看数据模型

call db.schema()

要查看表格概述,请使用以下内容

MATCH (n)
// a collection of multiple labels is turned into rows
UNWIND labels(n) as label
RETURN label, count(*)
ORDER BY count(*) asc

分析:项目中包含多少种类型?

此查询计算所有标记为 TypeFile 的节点,即直接从文件扫描的所有 Java 类型。

MATCH (t:Type:File)
RETURN count(t)

分析:哪个类扩展自另一个类?

此查询显示前 20 个类的 FQN 以及它们的超类型(类或接口)。

MATCH (c1:Class)-[:EXTENDS]->(c2:Type)
RETURN c1.fqn, c2.fqn
LIMIT 20

分析:哪些类包含最多的方法?

每个 Type 都 DECLARES 成员,即链接到 MethodField 节点。这里我们只计算方法并返回前 20 个“违规者”。

MATCH (class:Class)-[:DECLARES]->(method:Method)
RETURN class.fqn, count(method) AS Methods
ORDER BY Methods DESC
LIMIT 20

分析:哪个类具有最深的继承层次结构?

我们沿着 EXTENDS 关系进行传递性跟踪,直到层次结构的顶部,并返回前 20 个最长的继承链。

MATCH h = (class:Class)-[:EXTENDS*]->(super:Type)
WHERE NOT EXISTS((super)-[:EXTENDS]->())
RETURN class.fqn, length(h) AS Depth
ORDER BY Depth DESC
LIMIT 20

分析:哪些类受到某些异常的影响?

现在我们想知道哪些方法传递性地调用给定异常类型的构造函数。

MATCH (e:Type)-[:DECLARES]->(init:Constructor)
WHERE e.fqn = 'java.io.IOException'
WITH e, init
MATCH (type:Type)-[:DECLARES]->(method:Method)
MATCH path = (method)-[:INVOKES*]->(init)
RETURN type, path LIMIT 10

分析:有多少方法调用给定包中的某个内容?

了解有多少方法会受到你更改方法返回类型的影响会很有趣。或者,了解解耦某些架构工件需要多少努力。

MATCH (caller:Method:Java)-[:INVOKES]->(callee:Method:Java)<-[:DECLARES]-(t:Type)
WHERE t.fqn STARTS WITH {package}
RETURN t.fqn, callee.name, count(caller) AS callers
ORDER BY callers

可见性:查找不必要的公共可见性

第一步:在公共方法上贴上“Public”标签。

MATCH (m:Method)
WHERE  m.visibility='public'
 SET m:Public

可见性:步骤 2

第二步 - 报告在同一包内调用的前 20 个公共方法。

MATCH (package:Package)-[:CONTAINS]->(t1:Type)-[:DECLARES]->(m:Method),
     (package:Package)-[:CONTAINS]->(t2:Type)-[:DECLARES]->(p:Method:Public),
     (m)-[:INVOKES]->(p)
WHERE t1 <> t2
WITH p, t2, count(*) as freq
ORDER BY freq DESC LIMIT 20
RETURN p.name, t2.fqn, freq

不变性:将具有不可变状态的类标记为“Immutable”

MATCH (immutable:Class)-[:DECLARES]->(field:Field)<-[:WRITES]-(accessorMethod)
WHERE field.visibility = 'private'

WITH immutable, collect(accessorMethod) AS accessorMethods
WHERE ALL(accessorMethod IN accessorMethods WHERE accessorMethod:Constructor)

SET immutable:Immutable
RETURN immutable

进一步分析:标记要调查的类型

标记要调查的某个包中的类型。因此,我们可以直接匹配 :Investigate 标签,而不是始终检查以下条件:WHERE exists(t.byteCodeVersion) AND t.fqn STARTS WITH 'some.package.'

MATCH (t:Type:File)<-[:DEPENDS_ON]-(dependent:Type)
WHERE exists(t.byteCodeVersion) AND t.fqn STARTS WITH {package}
SET t:Investigate

进一步分析:将扇入添加到类型

让我们将属性 'fanIn' 添加到 Type,其中包含依赖它的其他类型的数量。

MATCH (t:Type:File:Investigate)<-[:DEPENDS_ON]-(dependent:Type)
WITH t, count(dependent) AS dependents
 SET t.fanIn = dependents
RETURN t.fqn AS type

添加扇出到类型

现在让我们将属性 'fanOut' 添加到 Type,其中包含它依赖它的其他类型的数量。

MATCH (t:Type:File:Investigate)-[:DEPENDS_ON]->(dependency:Type)
WITH t, count(dependency) AS dependencies
SET t.fanOut = dependencies
RETURN t.fqn AS Type, t.fanOut AS fanOut
ORDER BY fanOut DESC

添加默认扇出

我们还可以将属性 'fanOut' 添加到所有没有 fanOut 属性的 Type。

MATCH (t:Type:File)
WHERE NOT exists(t.fanOut)
SET t.fanOut = 0
RETURN t.fqn AS type

添加默认扇出

接下来,将属性 'fanIn' 添加到所有没有 fanIn 属性的 Type。

MATCH (t:Type:File:Investigate)
 WHERE NOT exists(t.fanIn)
SET t.fanIn = 0
RETURN t.fqn AS type

添加类型耦合

让我们将属性 typeCoupling 添加到 Type,作为 fanInfanOut 的总和。

MATCH (t:Type:File:Investigate)
SET t.typeCoupling = t.fanIn + t.fanOut
RETURN t.fqn AS type, t.typeCoupling AS typeCoupling,
      t.fanIn AS fanIn, t.fanOut AS fanOut
ORDER BY typeCoupling DESC, fanIn DESC

添加包内扇出

我们可以将属性 'inPackageFanOut' 添加到 Type,其中包含它依赖它的其他类型的数量。

MATCH (p1:Package)-[:CONTAINS]->(t:Type:File:Investigate)-[:DEPENDS_ON]->
      (dependency:Type)<-[:CONTAINS]-(p2:Package)
WHERE p1 = p2 AND NOT dependency.fqn CONTAINS '$'

WITH t, count(dependency) AS dependencies
SET t.inPackageFanOut = dependencies

RETURN t.fqn AS type, t.inPackageFanOut AS fanOut
ORDER BY fanOut DESC

添加包内扇入

在此查询中,我们将属性 inPackageFanIn 添加到 Type,其中包含它依赖它的其他类型的数量。

MATCH (p1:Package)-[:CONTAINS]->(t:Type:File:Investigate)<-[:DEPENDS_ON]-
     (dependency:Type)<-[:CONTAINS]-(p2:Package)
WHERE p1 = p2 AND NOT dependency.fqn CONTAINS '$'
WITH t, count(dependency) AS dependencies
SET t.inPackageFanIn = dependencies
RETURN t.fqn AS type, t.inPackageFanIn AS fanIn
ORDER BY fanIn DESC

添加包内类型耦合

现在我们添加一个属性 typeInPackageCouplingType,作为 fanInfanOut 的总和。

MATCH (t:Type:File:Investigate)
SET t.typeInPackageCoupling = t.inPackageFanIn + t.inPackageFanOut
RETURN t.fqn AS type, t.typeInPackageCoupling AS typeCoupling,
      t.inPackageFanIn AS fanIn, t.inPackageFanOut AS fanOut
ORDER BY typeCoupling DESC, fanIn DESC

单元测试:验证断言

单元测试应该每个测试方法只有一个(逻辑)断言。由于模拟框架的一些方法也计为断言,因此我们需要对它们进行标记。

Mockito 示例

这是一个 Mockito 示例,用于使用 Junit4Assert 将所有由“org.mockito.Mockito”声明的名称为“verify*”的断言方法标记为 Assert

MATCH (assertType:Type)-[:DECLARES]->(assertMethod)
WHERE assertType.fqn = 'org.mockito.Mockito'
AND assertMethod.signature CONTAINS 'verify'
SET assertMethod:Junit4:Assert
RETURN assertMethod

jUnit 示例

org.junit.Assert.fail 方法也计为断言

MATCH (assertType:Type)-[:DECLARES]->(assertMethod)
WHERE assertType.fqn = 'org.junit.Assert'
AND assertMethod.signature starts with 'void fail'
SET assertMethod:Junit4:Assert
RETURN assertMethod

测试覆盖率

测试覆盖率是一个广泛的领域。关于单元测试和测试覆盖率有很多讨论。

Kontext E 提供了一个 JaCoCo 插件,用于将 JaCoCo 测试覆盖率结果导入 jQAssistant 数据库。将所有信息保存在一个数据库中,你就可以以非常灵活的方式定义测试覆盖率规则(以及规则的例外)。

一个基于方法及其复杂度的示例是,更复杂的方法需要更多的测试覆盖率,因为出现错误的可能性更高(作为经验法则)。

定义测试覆盖率目标

因此,我们根据分支数量定义了两个方法复杂度范围

CREATE (medium:TestCoverageRange {complexity : 'medium', min : 4, max : 5, coverage : 80})
CREATE (high:TestCoverageRange {complexity : 'high', min : 6, max : 999999, coverage : 90})
RETURN medium, high

查找测试覆盖率过低的方法

现在我们可以找到测试覆盖率过低的方法

MATCH (tcr:TestCoverageRange)
WITH tcr.min AS mincomplexity, tcr.max AS maxcomplexity, tcr.coverage AS coveragethreshold

MATCH (cl:Jacoco:Class)--(m:Jacoco:Method)--(c:Jacoco:Counter {type: 'COMPLEXITY'})
WHERE c.missed + c.covered >= mincomplexity AND c.missed + c.covered <= maxcomplexity

WITH m AS method, cl.fqn AS fqn, m.signature AS signature,
    c.missed + c.covered AS complexity, coveragethreshold

MATCH (m)--(branches:Jacoco:Counter {type: 'BRANCH'})
WHERE m = method
WITH *, branches.covered * 100 / (branches.covered + branches.missed) AS coverage
WHERE coverage < coveragethreshold

RETURN complexity, coveragethreshold, coverage, fqn, signature
ORDER BY complexity, coverage

添加规则的例外

并从该规则中添加一些例外

  • equals() 和 hashCode() 方法由 IDE 生成,不需要进行测试

  • 出于某种原因,我们不想衡量 UI 包的测试覆盖率

  • StringTool.doSomethingwithStrings 方法也应该被排除在外

  • 我们知道还有 10 个违规项,我们现在想跳过 +(但我们发誓在下个春天会处理好这个技术债务)。

添加异常的查询

MATCH (tcr:TestCoverageRange)

WITH tcr.min AS mincomplexity, tcr.max AS maxcomplexity, tcr.coverage AS coveragethreshold
MATCH (cl:Jacoco:Class)--(m:Jacoco:Method)--(c:Jacoco:Counter {type: 'COMPLEXITY'})
WHERE c.missed + c.covered >= mincomplexity AND c.missed + c.covered <= maxcomplexity
AND NOT m.signature IN ['boolean equals(java.lang.Object)', 'int hashCode()']
AND NOT(cl.fqn STARTS WITH 'de.kontext_e.demo.ui')
AND NOT(cl.fqn = 'de.kontext_e.demo.tools.StringTool'
AND m.signature = 'java.lang.String doSomethingwithStrings(java.lang.String)')

WITH m AS method, cl.fqn AS fqn, m.signature AS signature, c.missed+c.covered AS complexity, coveragethreshold AS coveragethreshold
MATCH (m)--(branches:Jacoco:Counter {type: 'BRANCH'})
WHERE m=method AND branches.covered*100/(branches.covered+branches.missed) < coveragethreshold
RETURN complexity, coveragethreshold, branches.covered*100/(branches.covered+branches.missed) AS coverage, fqn, signature
ORDER BY complexity, coverage
SKIP 10

特殊情况:经常变更的类

也许对经常变更的类进行更高的测试覆盖率也是个好主意。使用Kontext E 的 Git 插件,可以测试这一点。

MATCH (c:Git:Commit)-[:CONTAINS_CHANGE]->(change:Git:Change)-[:MODIFIES]->(f:Git:File)
WHERE f.relativePath=~'.*.java'
AND NOT f.relativePath CONTAINS 'ui'
WITH count(c) AS cnt, replace(f.relativePath, '/','.') AS gitfqn
ORDER BY cnt DESC
LIMIT 10
MATCH (class:Java:Class)
WHERE gitfqn CONTAINS class.fqn
WITH cnt, class.fqn AS classfqn
MATCH (cl:Jacoco:Class)--(m:Jacoco:Method)--(c:Jacoco:Counter {type: 'COMPLEXITY'})
WHERE classfqn=cl.fqn
AND c.missed+c.covered > 3
AND NOT(m.signature ='boolean equals(java.lang.Object)')
AND NOT(m.signature ='int hashCode()')
WITH m AS method, cl.fqn AS fqn, m.signature AS signature, c.missed+c.covered AS complexity
MATCH (m)--(branches:Jacoco:Counter {type: 'BRANCH'})
WHERE m=method
AND branches.covered*100/(branches.covered+branches.missed) < 90
RETURN DISTINCT fqn, signature, complexity, branches.covered*100/(branches.covered+branches.missed) AS coverage
ORDER BY fqn
SKIP 3

对于 10 个最常变更的 Java 文件(除 UI 包中的文件外),分支的测试覆盖率对于具有超过 3 个分支的方法不应低于 90%,这个规则有三个未命名的例外。

封装:将具有内部 FQNs 的标签类型标记为内部

MATCH (t:Type) WHERE t.fqn CONTAINS {fqn_internal}
SET t:Internal

API/SPI 类型不得扩展/实现内部类型

MATCH (class:Class)-[:EXTENDS|IMPLEMENTS]->(supertype:Type:Internal)
WHERE NOT class:Internal
RETURN DISTINCT class as extendsInternal

API/SPI 方法不得公开内部类型

// return values
MATCH (class:Type)-[:DECLARES]->(method:Method)
WHERE NOT class:Internal
AND method.visibility IN ["public","protected"]
AND (exists ((method)-[:RETURNS]->(:Type:Internal)) OR
    exists ((method)-[:`HAS`]->(:Parameter)-[:OF_TYPE]->(:Internal)))
RETURN method

API/SPI 字段不得公开内部类型

MATCH (class:Class:Internal)-[:DECLARES]->(field)-[:OF_TYPE]->(fieldtype:Type:Internal)
WHERE field.visibility IN ["public","protected"]
RETURN class as internalClass, field, fieldtype as internalType