Skip to content
This repository has been archived by the owner on Jan 30, 2023. It is now read-only.

JUnit report sensor #143

Merged
merged 32 commits into from Dec 28, 2018
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
40f6c3b
WIP Unit tests sensor.
mwz Dec 19, 2018
00f8a1e
Tweak debug messages.
mwz Dec 20, 2018
657983a
Save test metrics.
mwz Dec 20, 2018
f019abe
Renamed the sensor; improved logging.
mwz Dec 20, 2018
a21ffd5
Update log tests.
mwz Dec 20, 2018
6a57a7a
Address some PR comments.
mwz Dec 21, 2018
ce6ecf8
Minor refactoring of file resolution.
mwz Dec 21, 2018
06964ad
Improved logging.
mwz Dec 21, 2018
34dfb6b
Improve logging.
mwz Dec 21, 2018
bf3686c
Update logic for filtering junit report files.
mwz Dec 21, 2018
ca4a468
Refactor utils.
mwz Dec 22, 2018
7c5e8cd
Refactor utils.
mwz Dec 22, 2018
51aa893
Add config syntax tests.
mwz Dec 22, 2018
6644a75
Add some sensor tests.
mwz Dec 22, 2018
c8d9884
Add file system syntax tests.
mwz Dec 22, 2018
4903118
Add sensor context syntax tests.
mwz Dec 23, 2018
79634e0
Add a syntax package object.
mwz Dec 23, 2018
76ba759
Refactor syntax into implicit classes.
mwz Dec 23, 2018
dd0446f
Refactor option conversions.
mwz Dec 23, 2018
08cf5b6
Add a new file system test.
mwz Dec 23, 2018
1ec5cc5
More sensor tests.
mwz Dec 23, 2018
50e6a5d
Report parser tests.
mwz Dec 23, 2018
8d4f1c8
Remove a trailing comma.
mwz Dec 24, 2018
3a5f129
Change logging level.
mwz Dec 24, 2018
bd2e976
Add sonar.tests property to the example set projects.
mwz Dec 24, 2018
e58775f
Make tweaks to filtering of junit files.
mwz Dec 24, 2018
5c4d653
Make parsing junit reports safer.
mwz Dec 24, 2018
4607cef
Update example mvn projects.
mwz Dec 24, 2018
036d17e
Update example grade projects.
mwz Dec 24, 2018
2df6b1c
Use getStringArray to get a list of paths from the config.
mwz Dec 24, 2018
b1411d7
Drop the ’sonar.junit.disable’ property (the sensor isn’t executed wi…
mwz Dec 25, 2018
e6c9f0f
Rename defaultPath to DefaultPaths.
mwz Dec 28, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/main/scala/com/mwz/sonar/scala/ScalaPlugin.scala
Expand Up @@ -20,7 +20,6 @@ package com.mwz.sonar.scala

import java.nio.file.{Path, Paths}

import cats.kernel.Eq
import com.mwz.sonar.scala.util.JavaOptionals._
import com.mwz.sonar.scala.util.Log
import org.sonar.api.Plugin
Expand Down Expand Up @@ -52,7 +51,7 @@ object Scala {
private val SourcesPropertyKey = "sonar.sources"
private val DefaultSourcesFolder = "src/main/scala"

private val logger = Log(classOf[Scala], "sonar-scala")
private val logger = Log(classOf[Scala])

def getScalaVersion(settings: Configuration): ScalaVersion = {
def parseVersion(s: String): Option[ScalaVersion] = s match {
Expand Down Expand Up @@ -122,7 +121,10 @@ final class ScalaPlugin extends Plugin {
// Scoverage.
classOf[scoverage.ScoverageMetrics],
classOf[scoverage.ScoverageReportParser],
classOf[scoverage.ScoverageSensor]
classOf[scoverage.ScoverageSensor],
// JUnit.
classOf[junit.JUnitReportParser],
classOf[junit.JUnitSensor]
)
}
}
29 changes: 29 additions & 0 deletions src/main/scala/com/mwz/sonar/scala/junit/JUnitReport.scala
@@ -0,0 +1,29 @@
/*
* Sonar Scala Plugin
* Copyright (C) 2018 All contributors
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package com.mwz.sonar.scala
package junit

private[junit] final case class JUnitReport(
name: String,
tests: Int,
errors: Int,
failures: Int,
skipped: Int,
time: Float
)
127 changes: 127 additions & 0 deletions src/main/scala/com/mwz/sonar/scala/junit/JUnitReportParser.scala
@@ -0,0 +1,127 @@
/*
* Sonar Scala Plugin
* Copyright (C) 2018 All contributors
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package com.mwz.sonar.scala
package junit

import java.io.File
import java.nio.file.Path

import com.mwz.sonar.scala.util.Log
import org.sonar.api.batch.ScannerSide
import org.sonar.api.batch.fs.{FilePredicate, FileSystem, InputFile}

import scala.collection.JavaConverters._
import scala.xml.{Elem, XML}

trait JUnitReportParserAPI {

/**
* Parse JUnit report files from the given directory
* and return a map from input files to the parsed reports.
*/
def parse(tests: List[Path], directories: List[File]): Map[InputFile, JUnitReport]
}

@ScannerSide
final class JUnitReportParser(fileSystem: FileSystem) extends JUnitReportParserAPI {
private[this] val log = Log(classOf[JUnitReportParser], "junit")

def parse(tests: List[Path], directories: List[File]): Map[InputFile, JUnitReport] = {
// Get report files - xml files starting with "TEST-".
val reports: List[File] = reportFiles(directories)

// Parse report files.
val unitTestReports: List[JUnitReport] = parseReportFiles(reports)
if (unitTestReports.nonEmpty)
log.debug(s"JUnit test reports:\n${unitTestReports.mkString(", ")}")

// Convert package names into files.
resolveFiles(tests, unitTestReports)
}

/**
* Get report files - xml files starting with "TEST-".
*/
private[junit] def reportFiles(directories: List[File]): List[File] = {
val reportFiles: List[File] = directories.filter(_.isDirectory).flatMap { dir =>
dir.listFiles(
(_, name) =>
!name.startsWith("TEST-") &&
!name.startsWith("TESTS-") &&
name.endsWith(".xml")
// TODO: Is this also the case for the Maven surefire plugin?
)
}

if (directories.isEmpty)
log.error(s"The paths ${directories.mkString(", ")} are not valid directories.")
else if (reportFiles.isEmpty)
log.error(s"No report files found in ${directories.mkString(", ")}.")

reportFiles
}

/**
* Parse report files.
*/
private[junit] def parseReportFiles(reports: List[File]): List[JUnitReport] = {
reports.map { file =>
val xml: Elem = XML.loadFile(file)
JUnitReport(
name = xml \@ "name",
tests = (xml \@ "tests").toInt,
errors = (xml \@ "errors").toInt,
failures = (xml \@ "failures").toInt,
skipped = (xml \@ "skipped").toInt,
time = (xml \@ "time").toFloat
)
}
}

/**
* Convert package names into files.
*/
private[junit] def resolveFiles(
tests: List[Path],
reports: List[JUnitReport]
): Map[InputFile, JUnitReport] = {
reports
.groupBy(_.name)
.flatMap {
case (name, reports) =>
val path: String = name.replace(".", "/")
val files: List[Path] = tests.map(_.resolve(s"$path.scala"))
val predicates: List[FilePredicate] =
files.map(f => fileSystem.predicates.hasPath(f.toString))

val inputFiles: Iterable[InputFile] =
fileSystem
.inputFiles(
fileSystem.predicates.or(predicates.asJava)
)
.asScala

if (files.isEmpty)
log.error(s"The following files were not found: ${files.mkString(", ")}")

// Collect all of the input files.
inputFiles.flatMap(file => reports.headOption.map((file, _)))
}
}
}
146 changes: 146 additions & 0 deletions src/main/scala/com/mwz/sonar/scala/junit/JUnitSensor.scala
@@ -0,0 +1,146 @@
/*
* Sonar Scala Plugin
* Copyright (C) 2018 All contributors
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package com.mwz.sonar.scala
package junit

import java.io.File
import java.nio.file.Path

import cats.instances.list._
import com.mwz.sonar.scala.util.Log
import com.mwz.sonar.scala.util.syntax.ConfigSyntax._
import com.mwz.sonar.scala.util.syntax.FileSystemSyntax._
import com.mwz.sonar.scala.util.syntax.SensorContextSyntax._
import org.sonar.api.batch.fs.{FileSystem, InputFile}
import org.sonar.api.batch.sensor.{Sensor, SensorContext, SensorDescriptor}
import org.sonar.api.config.Configuration
import org.sonar.api.measures.CoreMetrics

import scala.collection.JavaConverters._

/**
* Scala JUnit sensor.
* Parses JUnit XML reports and saves test metrics.
*/
final class JUnitSensor(
config: Configuration,
fs: FileSystem, // TODO: Is the injected fileSystem different from context.fileSystem?
untTestsReportParser: JUnitReportParserAPI
) extends Sensor {
import JUnitSensor._ // scalastyle:ignore org.scalastyle.scalariform.ImportGroupingChecker

private[this] val log = Log(classOf[JUnitSensor], "junit")

override def describe(descriptor: SensorDescriptor): Unit = {
descriptor
.name(SensorName)
.onlyOnLanguage(Scala.LanguageKey)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this sensor only works for test files.
Thus, you may add
.onlyOnFileType(InputFile.Type.TEST)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes definitely

.onlyOnFileType(InputFile.Type.TEST)
.onlyWhenConfiguration(shouldEnableSensor)
}

override def execute(context: SensorContext): Unit = {
log.info("Initializing the Scala JUnit sensor.")

// Get the test paths.
val tests: List[Path] = testPaths(config)
log.debug(s"The source prefixes are: ${tests.mkString("[", ",", "]")}.")

// Get the junit report paths.
val reports: List[Path] = reportPaths(config)
log.debug(s"The JUnit report paths are: ${reports.mkString("[", ",", "]")}.")

// Get test input files
val inputFiles: Iterable[InputFile] =
context.fileSystem
.inputFiles(
context.fileSystem.predicates.and(
context.fileSystem.predicates.hasLanguage(Scala.LanguageKey),
context.fileSystem.predicates.hasType(InputFile.Type.TEST)
)
)
.asScala

if (inputFiles.nonEmpty)
log.debug(s"Input test files: \n${inputFiles.mkString(", ")}")
else
log.warn(s"No test files found for module ${context.module.key}.")

// Resolve test directories.
val testDirectories: List[File] = fs.resolve(tests)
if (testDirectories.isEmpty)
log.error(s"The following test directories were not found: ${reports.mkString(", ")}.")

// Resolve JUnit report directories.
val reportDirectories: List[File] = fs.resolve(reports)
if (reportDirectories.isEmpty)
log.error(s"The following JUnit test report path(s) were not found : ${reports.mkString(", ")}.")

// Parse the reports.
val parsedReports: Map[InputFile, JUnitReport] = untTestsReportParser.parse(tests, reportDirectories)

// Save test metrics for each file.
save(context, parsedReports)
}

/**
* Save test metrics.
*/
private[junit] def save(
context: SensorContext,
reports: Map[InputFile, JUnitReport]
): Unit = {
if (reports.nonEmpty)
log.debug(s"Parsed reports:\n${reports.mkString(", ")}")
else
log.info("No test metrics were saved by this sensor.")

reports.foreach {
case (file, report) =>
log.debug(s"Saving junit test metrics for $file.")
context.saveMeasure[Integer](file, CoreMetrics.SKIPPED_TESTS, report.skipped)
context.saveMeasure[Integer](file, CoreMetrics.TESTS, report.tests - report.skipped)
context.saveMeasure[Integer](file, CoreMetrics.TEST_ERRORS, report.errors)
context.saveMeasure[Integer](file, CoreMetrics.TEST_FAILURES, report.failures)
context.saveMeasure[java.lang.Long](
file,
CoreMetrics.TEST_EXECUTION_TIME,
(report.time * 1000).longValue
)
}
}
}

object JUnitSensor {
val SensorName = "Scala JUnit Sensor"
val TestsPropertyKey = "sonar.tests"
val DefaultTests = "src/test/scala"
val ReportsPropertyKey = "sonar.junit.reportPaths"
val DefaultReportPaths = "target/test-reports"
val DisablePropertyKey = "sonar.junit.disable"

private[junit] def testPaths(conf: Configuration): List[Path] =
conf.getPaths(TestsPropertyKey, DefaultTests)

private[junit] def reportPaths(conf: Configuration): List[Path] =
conf.getPaths(ReportsPropertyKey, DefaultReportPaths)

private[junit] def shouldEnableSensor(conf: Configuration): Boolean =
!conf.getValue[Boolean](DisablePropertyKey)
}
16 changes: 9 additions & 7 deletions src/main/scala/com/mwz/sonar/scala/util/Log.scala
Expand Up @@ -19,7 +19,7 @@
package com.mwz.sonar.scala
package util

import org.sonar.api.utils.log.Loggers
import org.sonar.api.utils.log.{Logger, Loggers}

trait Log {
def debug(s: String): Unit
Expand All @@ -29,13 +29,15 @@ trait Log {
}

object Log {
def apply[T](clazz: Class[T], module: String): Log = {
val log = Loggers.get(clazz)
def apply[T](clazz: Class[T], module: String): Log = Log(clazz, Some(module))
def apply[T](clazz: Class[T], module: Option[String] = None): Log = {
val log: Logger = Loggers.get(clazz)
val prefix: String = "sonar-scala" + module.fold("")("-" + _)
new Log {
override def debug(s: String): Unit = log.debug(s"[$module] $s")
override def info(s: String): Unit = log.info(s"[$module] $s")
override def warn(s: String): Unit = log.warn(s"[$module] $s")
override def error(s: String): Unit = log.error(s"[$module] $s")
override def debug(s: String): Unit = log.debug(s"[$prefix] $s")
override def info(s: String): Unit = log.info(s"[$prefix] $s")
override def warn(s: String): Unit = log.warn(s"[$prefix] $s")
override def error(s: String): Unit = log.error(s"[$prefix] $s")
}
}
}