Implementation of a basic test gap analysis algorithm. JaCoCo coverage reports are used to check if changed or added methods (of a Git repository) are covered by automated tests. Covered in this scope means that any code branch of the methods is executed. The reported methods are giving some pointers where completely untested code is and test cases should be added.
- recent JDK 8 (also tested with Java 11)
- recent Maven (tested with 3.8.6)
mvn clean install
of Pocketsaw 1.5.1- [optionally] maven-skip-execution-profile-extension with a .mvn directory like this in the root folder for a smooth Netbeans experience
- a project with a Git repository and a local working tree
- JaCoCo with
xml
reports for test coverage
The plugin can be used in two ways that are described in the next sections.
The code changes in a local Git working tree are inspected and not covered methods are reported. This is useful for local development iterations before pushing the changes to a remote repository and starting a CI build.
mvn com.scheible.testgapanalysis:test-gap-analysis-maven-plugin:1.2.1:clean-jacoco-reports^
jacoco:prepare-agent^
surefire:test^
jacoco:report^
com.scheible.testgapanalysis:test-gap-analysis-maven-plugin:1.2.1:perform
By also adding jacoco:prepare-agent-integration
, failsafe:integration-test
and jacoco:report-integration
integration tests are also considered (method coverages of all JaCoCo reports are merged).
The code changes done between a reference commit and the head commit of the repository are inspected and not covered methods are reported.
This mode can for example be used to record the test gap between the current release and the previous one in a CI build.
The reference commit hash can be specified either via <configuration>
-element <referenceCommitHash>
in the Maven POM or as parameter -Dtest-gap-analysis.reference-commit-hash
.
Since version 1.2.0 refs like HEAD^
are also supported.
In addition to the reference commit referring to a certain previous tag or branch is also supported since 1.2.0.
With <previousBranchRegEx>
(-Dtest-gap-analysis.previous-branch-regex
as parameter) resp. <previousTagRegEx>
(-Dtest-gap-analysis.previous-tag-regex
as parameter) in the <configuration>
-element the previous branch/tag matching the regex is used as reference.
Previous means here before the head commit in terms of commit timestamp.
In the first step the changed and new Java files are identified. In case of changed files the contents of the previous revision and the current one are available.
All files of the change set are then parsed for methods in the second step. Methods are (static) methods, (static) initializers, constructors and lambda methods. Methods can be nested. The code of the inner methods shouldn't be considered as code of the outer methods. Therefore the code of the inner methods is masked. The same masking applies to comments. Comment changes shouldn't be treated as as code change as well.
For example the method
public String doIt(String param1, final boolean isDebugMode) {
// trim the string or just be happy
return Optional.ofNullable(param1).map(a -> /* make it short */ a.trim()).orElse(":-)");
}
contains a lambda method and comments.
Changes in the lambda method shouldn't be treated as changes in the doIt(...)
method but as changes of the lambda method itself.
After masking the code of the method doIt(...)
looks like this:
public String doIt(String param1, final boolean isDebugMode) {
###################################
return Optional.ofNullable(param1).map(#################################).orElse(":-)");
}
Also the lambda method has all outher code and comments masked:
####################################### a -> ################### a.trim() ################
The hash characters are only used for visualization purposes in debug mode. In the normal mode collapsed whitespaces are used instead. Collapsed means that more than one whitespace is collapsed to a single on.
Only parsed methods of the current Java files that are either changed or new are of interest in this step. All unchanged methods are filtered out.
The coverage information is resolved on a per top level type base.
In a first preparation step special cases like (static) initializers are handled. They need special treatment because all static initializers are concatenated to a single special method by the Java compiler. Instance initializers even do not appear in the byte code as a dedicated method. Instead they are injected at the beginning of each constructor.
In the main step the first code line of the parsed methods is used to find the corresponding JaCoCo method coverage.
Lambda methods need a special treatment because there could be multiple of them in a single line.
This is solved by sorting the lambdas from left to right.
For the parsed code the code column is used, whereas for the JaCoCo coverage the synthetic name generated by the Java compiler is used for ordering (e.g. first lambda$test$1
and next lambda$test$2
).
Also constructors are matched via their parameters if there is a initializer present or an initialized member variable. Reason is again that the initializers and member variable initializations are injected into the constructors and the line of all constructors is the code line of the first initializer or member variable initialization.
A report named test-gap-report.json
is placed in the target
folder of the Maven project.
The report contains the information printed by the plugin in a machine readable format.
This is an example of an shortened JSON report:
{
"workDir": "test-gap-analysis/test-gap-analysis/src/main/java",
"previousState": "30640f01d7c116ca0d75ce9285357e5a8053009e",
"currentState": "3ff04d92457cd655292534b79b65f97f4d624d6c",
"jaCoCoReportFiles": [
"target/site/jacoco/jacoco.xml"
],
"jaCoCoCoverageCount": 361,
"newOrChangedFiles": [
{
"repositoryPath": "test-gap-analysis/src/main/java/com/scheible/testgapanalysis/jacoco/InstrumentedMethod.java",
"state": "NEW"
},
{
"repositoryPath": "test-gap-analysis/src/main/java/com/scheible/testgapanalysis/jacoco/resolver/CoverageResult.java",
"state": "NEW"
},
...
],
"coveredMethodsCount": 176,
"uncoveredMethodsCount": 38,
"testGap": 0.17757009345794394,
"emptyMethodsCount": 13,
"unresolvableMethodsCount": 0,
"ambiguouslyResolvedCount": 0,
"coveredMethods": [
{
"topLevelTypeFqn": "com.scheible.testgapanalysis.git.PreviousStateFinder",
"description": " lambda method",
"sourceLine": 59,
"sourceColumn": 13,
"coveredMethodName": "lambda$findPrevious$2",
"coveredMethodLine": 59
},
...
],
"uncoveredMethods": [
{
"topLevelTypeFqn": "com.scheible.testgapanalysis.analysis.AnalysisResult",
"description": "#equals(...)",
"sourceLine": 59,
"sourceColumn": 2,
"coveredMethodName": "equals",
"coveredMethodLine": 61
},
...
],
"emptyMethods": [
{
"topLevelTypeFqn": "com.scheible.testgapanalysis.analysis.testgap.TestGapReportBuilder",
"description": " constructor with 0 parameters",
"sourceLine": 13,
"sourceColumn": 2
},
...
],
"unresolvableMethods": [],
"ambiguouslyResolvedCoverage": {}
}
Sample output of the plugin. At the bottom all changed or added methods (which are not a getter or setter) that are not covered are listed.
Performing test gap analysis in 'test-gap-analysis\test-gap-analysis\src\main\java'.
Found coverage info about 361 methods in [target/site/jacoco/jacoco.xml].
Comparing the repository head (3ff04d9) with reference commit 30640f0.
Found 34 new or changed Java files:
- NewOrChangedFile[repositoryPath='test-gap-analysis/src/main/java/com/scheible/testgapanalysis/analysis/Analysis.java', state=CHANGED]
- NewOrChangedFile[repositoryPath='test-gap-analysis/src/main/java/com/scheible/testgapanalysis/analysis/AnalysisResult.java', state=CHANGED]
...
Method blacklist (excluded from coverage check): all getter and setter
Test gap: 17%
Covered methods:
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.Analysis', description=' lambda method', sourceLine=25, sourceColumn=77, coveredMethodName=Optional[lambda$static$0], coveredMethodLine=Optional[25]]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.Analysis', description=' constructor with 1 parameters', sourceLine=30, sourceColumn=2, coveredMethodName=Optional[<init>], coveredMethodLine=Optional[30]]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.Analysis', description='#perform(...)', sourceLine=34, sourceColumn=2, coveredMethodName=Optional[perform], coveredMethodLine=Optional[36]]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.Analysis', description=' lambda method', sourceLine=37, sourceColumn=13, coveredMethodName=Optional[lambda$perform$1], coveredMethodLine=Optional[37]]
...
Uncovered methods:
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.AnalysisResult', description='#equals(...)', sourceLine=59, sourceColumn=2, coveredMethodName=Optional[equals], coveredMethodLine=Optional[61]]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.AnalysisResult', description='#hashCode(...)', sourceLine=75, sourceColumn=2, coveredMethodName=Optional[hashCode], coveredMethodLine=Optional[77]]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.AnalysisResult', description='#toString(...)', sourceLine=81, sourceColumn=2, coveredMethodName=Optional[toString], coveredMethodLine=Optional[83]]
...
Empty methods (no coverage information available):
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.testgap.TestGapReportBuilder', description=' constructor with 0 parameters', sourceLine=13, sourceColumn=2, coveredMethodName=Optional.empty, coveredMethodLine=Optional.empty]
- TestGapMethod[topLevelTypeFqn='com.scheible.testgapanalysis.analysis.testgap.TestGapReportBuilder', description=' constructor with 0 parameters', sourceLine=104, sourceColumn=3, coveredMethodName=Optional.empty, coveredMethodLine=Optional.empty]
...
- avoid
static
methods for easier testing- exception: (package) private helper methods in classes that are static to enforce functional purity
- exception: completely stateless
*Utils
classes with static methods only- utility classes must be
abstract
and have a private default constructor
- utility classes must be
- exception: real constants with names in upper case delimited by underscores
logger
has to beprotected final
but notstatic
:protected final Logger logger = LoggerFactory.getLogger(getClass());
(see https://www.slf4j.org/faq.html#declared_static)- restrict file, method and lambda lengths to reasonable values
- code dependency
- no code cycles on package level
- no dependencies between a package and any of its (sub-)sub-packages (only the other way around)
- restrict maximal number of parameters to a reasonable value
- in case of model class constructors with too many parameters use a http://rdafbn.blogspot.com/2012/07/step-builder-pattern_28.html
- extra abstract class with
<ModelClassName>Builder
name and private default constructor - static
builder()
method in model class as only way to instantiate the class - (package private) constructor in model class with
BuilderImpl
as only parameter - inner step interfaces in
<ModelClassName>Builder
withStep
suffix - inner static class
BuilderImpl
implementing all the steps
- extra abstract class with
- in case of model class constructors with too many parameters use a http://rdafbn.blogspot.com/2012/07/step-builder-pattern_28.html
- usage of immutable data structures only by making all members
final
- for members of type collection defensive copies and
Collections.unmodifiableXzy(...)
in constructor- the elements in the collections must also be immutable
- local variables and parameters must not use
final
because it adds too much noise (see https://github.com/spring-io/spring-javaformat#final)- only ever mutate parameters in
private
methods (for example recursive methods), for all other methods return proper result types
- only ever mutate parameters in
- for members of type collection defensive copies and
- usage of
Optional<?>
(was actually designed for method return types only, but is the only JDK built-in way to indicate a nullable value (all@Nullable
annotations are from third-party libraries))- never pass or return
null
, useOptional<?>
instead (this eliminates the need fornull
checks everywhere) - but prefer method overloading or usage of a builder over
Optional<?>
method parameters - even use
Optional<?>
for class fields (avoids unnecessaryOptional.ofNullable(...)
calls in getter of immutable objects)
- never pass or return
- Java source file organization (derived from https://github.com/spring-projects/spring-framework/wiki/Code-Style#java-source-file-organization)
enum
types if simple ones (contains only constants)static
fields and initializers- normal fields
- constructors (ordering of parameters should be consistent with the order of the corresponding fields)
static
factory methods- all other methods
- overridden methods have to be grouped together
- getters and setters (ordering of fields and getters/setters should be consistent with order of the corresponding fields)
equals(...)
,hashCode()
andtoString()
- inner types (
static
/inner classes,interface
and non-simpleenum
types)- don't overuse inner types, as soon as the type is also useful in some other context it should be a top-level type
- don't use checked (and unchecked) exceptions for program flow, always prefer returning proper result types (exceptions are reserved for real, unexpected errors)
- wrapping an
IOException
withUncheckedIOException
(or more general any exception in aIllegalStateException
) is okay but takes away the possibility from the caller to react to errors (for example by skipping a single file that cause anIOException
while reading it)
- wrapping an
- no static imports in production code (only allowed in tests)
- no wildcard imports in general
equals(...)
,hashCode()
andtoString()
must be implemented for all model/domain classes but not service or utility classestoString()
should only be used for debugging purposes (printed in logs (DEBUG
level) or in IDE) and never use to extract the state of an objecttoString()
should contain all interesting information (too long information can be shorten or summarized)- on high-level
equals(...)
must first test for identity (==
) and then for type compatibility withinstanceof
equals(...)
must compare different type of fields differently:float
anddouble
withFloat.compare(...)
resp.Double.compare(...)
- all other primitive types and enums with
==
- object references with
Objects.equals(...)
hashCode()
must useObjects.hash(...)
- always use
this.
for class fields but never for instance methods (same approach as https://github.com/spring-projects/spring-framework/wiki/Code-Style#field-and-method-references) static
fields and methods of own class must never be prefixed with the own class name- usage of Java Stream API
- don't overuse lambdas with streams, prefer method references or simple one line lambdas
- usage of
forEach
withstream()
is suspicious, should only be used in rare cases with a method reference (good old for-each loops might be easier to read and debug)