Skip to content

Commit ddca86c

Browse files
committed
Issue #106: enforce pitest
1 parent a0ae57d commit ddca86c

File tree

5 files changed

+1631
-0
lines changed

5 files changed

+1631
-0
lines changed

.ci/pitest-survival-check.groovy

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
import groovy.io.FileType
2+
import groovy.transform.EqualsAndHashCode
3+
import groovy.transform.Immutable
4+
import groovy.xml.XmlUtil
5+
import groovy.xml.XmlParser
6+
7+
int exitCode = checkPitestReport()
8+
System.exit(exitCode)
9+
10+
/**
11+
* Check the generated pitest report. Parse the surviving and suppressed mutations and compare
12+
* them.
13+
*
14+
* @return {@code 0} if pitest report is as expected, {@code 1} otherwise
15+
*/
16+
private static int checkPitestReport() {
17+
final XmlParser xmlParser = new XmlParser()
18+
File mutationReportFile = null
19+
final String suppressedMutationFileUri = ".${File.separator}config${File.separator}" +
20+
"pitest-suppressions.xml"
21+
22+
final File pitReports =
23+
new File(".${File.separator}target${File.separator}pit-reports")
24+
25+
if (!pitReports.exists()) {
26+
throw new IllegalStateException(
27+
"Pitest report directory does not exist, generate pitest report first")
28+
}
29+
30+
pitReports.eachFileRecurse(FileType.FILES) {
31+
if (it.name == 'mutations.xml') {
32+
mutationReportFile = it
33+
}
34+
}
35+
final Node mutationReportNode = xmlParser.parse(mutationReportFile)
36+
final Set<Mutation> survivingMutations = getSurvivingMutations(mutationReportNode)
37+
38+
final File suppressionFile = new File(suppressedMutationFileUri)
39+
Set<Mutation> suppressedMutations = new TreeSet<>()
40+
if (suppressionFile.exists()) {
41+
final Node suppressedMutationNode = xmlParser.parse(suppressedMutationFileUri)
42+
suppressedMutations = getSuppressedMutations(suppressedMutationNode)
43+
}
44+
45+
if (survivingMutations.isEmpty()) {
46+
if (suppressionFile.exists()) {
47+
suppressionFile.delete()
48+
}
49+
}
50+
else {
51+
final StringBuilder suppressionFileContent = new StringBuilder(1024)
52+
suppressionFileContent.append(
53+
'<?xml version="1.0" encoding="UTF-8"?>\n<suppressedMutations>\n')
54+
55+
survivingMutations.each {
56+
suppressionFileContent.append(it.toXmlString())
57+
}
58+
suppressionFileContent.append('</suppressedMutations>\n')
59+
60+
if (!suppressionFile.exists()) {
61+
suppressionFile.createNewFile()
62+
}
63+
suppressionFile.write(suppressionFileContent.toString())
64+
}
65+
66+
return printComparisonToConsole(survivingMutations, suppressedMutations)
67+
}
68+
69+
/**
70+
* Get the surviving mutations. All child nodes of the main {@code mutations} node
71+
* are parsed.
72+
*
73+
* @param mainNode the main {@code mutations} node
74+
* @return A set of surviving mutations
75+
*/
76+
private static Set<Mutation> getSurvivingMutations(Node mainNode) {
77+
78+
final List<Node> children = mainNode.children()
79+
final Set<Mutation> survivingMutations = new TreeSet<>()
80+
81+
children.each { node ->
82+
final Node mutationNode = node as Node
83+
84+
final String mutationStatus = mutationNode.attribute("status")
85+
86+
if (mutationStatus == "SURVIVED" || mutationStatus == "NO_COVERAGE") {
87+
survivingMutations.add(getMutation(mutationNode))
88+
}
89+
}
90+
return survivingMutations
91+
}
92+
93+
/**
94+
* Get the suppressed mutations. All child nodes of the main {@code suppressedMutations} node
95+
* are parsed.
96+
*
97+
* @param mainNode the main {@code suppressedMutations} node
98+
* @return A set of suppressed mutations
99+
*/
100+
private static Set<Mutation> getSuppressedMutations(Node mainNode) {
101+
final List<Node> children = mainNode.children()
102+
final Set<Mutation> suppressedMutations = new TreeSet<>()
103+
104+
children.each { node ->
105+
final Node mutationNode = node as Node
106+
suppressedMutations.add(getMutation(mutationNode))
107+
}
108+
return suppressedMutations
109+
}
110+
111+
/**
112+
* Construct the {@link Mutation} object from the {@code mutation} XML node.
113+
* The {@code mutations.xml} file is parsed to get the {@code mutationNode}.
114+
*
115+
* @param mutationNode the {@code mutation} XML node
116+
* @return {@link Mutation} object represented by the {@code mutation} XML node
117+
*/
118+
private static Mutation getMutation(Node mutationNode) {
119+
final List childNodes = mutationNode.children()
120+
121+
String sourceFile = null
122+
String mutatedClass = null
123+
String mutatedMethod = null
124+
String mutator = null
125+
String lineContent = null
126+
String description = null
127+
String mutationClassPackage = null
128+
int lineNumber = 0
129+
childNodes.each {
130+
final Node childNode = it as Node
131+
final String text = childNode.name()
132+
133+
final String childNodeText = XmlUtil.escapeXml(childNode.text())
134+
switch (text) {
135+
case "sourceFile":
136+
sourceFile = childNodeText
137+
break
138+
case "mutatedClass":
139+
mutatedClass = childNodeText
140+
mutationClassPackage = mutatedClass.split("[A-Z]")[0]
141+
break
142+
case "mutatedMethod":
143+
mutatedMethod = childNodeText
144+
break
145+
case "mutator":
146+
mutator = childNodeText
147+
break
148+
case "description":
149+
description = childNodeText
150+
break
151+
case "lineNumber":
152+
lineNumber = Integer.parseInt(childNodeText)
153+
break
154+
case "lineContent":
155+
lineContent = childNodeText
156+
break
157+
}
158+
}
159+
if (lineContent == null) {
160+
final String mutationFileName = mutationClassPackage + sourceFile
161+
final String startingPath =
162+
".${File.separator}src${File.separator}main${File.separator}java${File.separator}"
163+
final String javaExtension = ".java"
164+
final String mutationFilePath = startingPath + mutationFileName
165+
.substring(0, mutationFileName.length() - javaExtension.length())
166+
.replace(".", File.separator) + javaExtension
167+
168+
final File file = new File(mutationFilePath)
169+
lineContent = XmlUtil.escapeXml(file.readLines().get(lineNumber - 1).trim())
170+
}
171+
if (lineNumber == 0) {
172+
lineNumber = -1
173+
}
174+
175+
final String unstableAttributeValue = mutationNode.attribute("unstable")
176+
final boolean isUnstable = Boolean.parseBoolean(unstableAttributeValue)
177+
178+
return new Mutation(sourceFile, mutatedClass, mutatedMethod, mutator, description,
179+
lineContent, lineNumber, isUnstable)
180+
}
181+
182+
/**
183+
* Compare surviving and suppressed mutations. The comparison passes successfully (i.e. returns 0)
184+
* when:
185+
* <ol>
186+
* <li>Surviving and suppressed mutations are equal.</li>
187+
* <li>There are extra suppressed mutations but they are unstable
188+
* i.e. {@code unstable="true"}.</li>
189+
* </ol>
190+
* The comparison fails (i.e. returns 1) when:
191+
* <ol>
192+
* <li>Surviving mutations are not present in the suppressed list.</li>
193+
* <li>There are mutations in the suppression list that are not there is surviving list.</li>
194+
* </ol>
195+
*
196+
* @param survivingMutations A set of surviving mutations
197+
* @param suppressedMutations A set of suppressed mutations
198+
* @return {@code 0} if comparison passes successfully
199+
*/
200+
private static int printComparisonToConsole(Set<Mutation> survivingMutations,
201+
Set<Mutation> suppressedMutations) {
202+
final Set<Mutation> survivingUnsuppressedMutations =
203+
setDifference(survivingMutations, suppressedMutations)
204+
final Set<Mutation> extraSuppressions =
205+
setDifference(suppressedMutations, survivingMutations)
206+
207+
final int exitCode
208+
if (survivingMutations == suppressedMutations) {
209+
exitCode = 0
210+
println 'No new surviving mutation(s) found.'
211+
}
212+
else if (survivingUnsuppressedMutations.isEmpty()
213+
&& hasOnlyUnstableMutations(extraSuppressions)) {
214+
exitCode = 0
215+
println 'No new surviving mutation(s) found.'
216+
}
217+
else {
218+
if (!survivingUnsuppressedMutations.isEmpty()) {
219+
println "New surviving mutation(s) found:"
220+
survivingUnsuppressedMutations.each {
221+
println it
222+
}
223+
}
224+
if (!extraSuppressions.isEmpty()
225+
&& extraSuppressions.any { !it.isUnstable() }) {
226+
println "\nUnnecessary suppressed mutation(s) found and should be removed:"
227+
extraSuppressions.each {
228+
if (!it.isUnstable()) {
229+
println it
230+
}
231+
}
232+
}
233+
exitCode = 1
234+
}
235+
return exitCode
236+
}
237+
238+
/**
239+
* Whether a set has only unstable mutations.
240+
*
241+
* @param mutations A set of mutations
242+
* @return {@code true} if a set has only unstable mutations
243+
*/
244+
private static boolean hasOnlyUnstableMutations(Set<Mutation> mutations) {
245+
return mutations.every { it.isUnstable() }
246+
}
247+
248+
/**
249+
* Determine the difference between 2 sets. The result is {@code setOne - setTwo}.
250+
*
251+
* @param setOne The first set in the difference
252+
* @param setTwo The second set in the difference
253+
* @return {@code setOne - setTwo}
254+
*/
255+
private static Set<Mutation> setDifference(final Set<Mutation> setOne,
256+
final Set<Mutation> setTwo) {
257+
final Set<Mutation> result = new TreeSet<>(setOne)
258+
result.removeIf { mutation -> setTwo.contains(mutation) }
259+
return result
260+
}
261+
262+
/**
263+
* A class to represent the XML {@code mutation} node.
264+
*/
265+
@EqualsAndHashCode(excludes = ["lineNumber", "unstable"])
266+
@Immutable
267+
class Mutation implements Comparable<Mutation> {
268+
269+
/**
270+
* Mutation nodes present in suppressions file do not have a {@code lineNumber}.
271+
* The {@code lineNumber} is set to {@code -1} for such mutations.
272+
*/
273+
private static final int LINE_NUMBER_NOT_PRESENT_VALUE = -1
274+
275+
String sourceFile
276+
String mutatedClass
277+
String mutatedMethod
278+
String mutator
279+
String description
280+
String lineContent
281+
int lineNumber
282+
boolean unstable
283+
284+
@Override
285+
String toString() {
286+
String toString = """
287+
Source File: "${getSourceFile()}"
288+
Class: "${getMutatedClass()}"
289+
Method: "${getMutatedMethod()}"
290+
Line Contents: "${getLineContent()}"
291+
Mutator: "${getMutator()}"
292+
Description: "${getDescription()}"
293+
""".stripIndent()
294+
if (getLineNumber() != LINE_NUMBER_NOT_PRESENT_VALUE) {
295+
toString += 'Line Number: ' + getLineNumber()
296+
}
297+
return toString
298+
}
299+
300+
@Override
301+
int compareTo(Mutation other) {
302+
int i = getSourceFile() <=> other.getSourceFile()
303+
if (i != 0) {
304+
return i
305+
}
306+
307+
i = getMutatedClass() <=> other.getMutatedClass()
308+
if (i != 0) {
309+
return i
310+
}
311+
312+
i = getMutatedMethod() <=> other.getMutatedMethod()
313+
if (i != 0) {
314+
return i
315+
}
316+
317+
i = getLineContent() <=> other.getLineContent()
318+
if (i != 0) {
319+
return i
320+
}
321+
322+
i = getMutator() <=> other.getMutator()
323+
if (i != 0) {
324+
return i
325+
}
326+
327+
return getDescription() <=> other.getDescription()
328+
}
329+
330+
/**
331+
* XML format of the mutation.
332+
*
333+
* @return XML format of the mutation
334+
*/
335+
String toXmlString() {
336+
return """
337+
<mutation unstable="${isUnstable()}">
338+
<sourceFile>${getSourceFile()}</sourceFile>
339+
<mutatedClass>${getMutatedClass()}</mutatedClass>
340+
<mutatedMethod>${getMutatedMethod()}</mutatedMethod>
341+
<mutator>${getMutator()}</mutator>
342+
<description>${getDescription()}</description>
343+
<lineContent>${getLineContent()}</lineContent>
344+
</mutation>
345+
""".stripIndent(10)
346+
}
347+
348+
}

.ci/pitest.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# Attention, there is no "-x" to avoid problems on CircleCI
3+
set -e
4+
5+
echo "Generation of pitest report:"
6+
echo "./mvnw -e --no-transfer-progress -Ppitest clean test-compile org.pitest:pitest-maven:mutationCoverage"
7+
set +e
8+
./mvnw -e --no-transfer-progress -Ppitest clean test-compile org.pitest:pitest-maven:mutationCoverage
9+
EXIT_CODE=$?
10+
set -e
11+
echo "Execution of comparison of suppressed mutations survivals and current survivals:"
12+
echo "groovy .ci/pitest-survival-check.groovy"
13+
groovy .ci/pitest-survival-check.groovy
14+
exit $EXIT_CODE

0 commit comments

Comments
 (0)