Skip to content

Commit

Permalink
feat: added JUnit-compatible xml generation
Browse files Browse the repository at this point in the history
  • Loading branch information
atty303 committed Mar 22, 2024
1 parent 9a734ab commit 80327ab
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 1 deletion.
3 changes: 2 additions & 1 deletion build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ object Deps {
ivy"org.scoverage::scalac-scoverage-serializer:${scoverage2Version}"
val scalaparse = ivy"com.lihaoyi::scalaparse:${fastparse.version}"
val scalatags = ivy"com.lihaoyi::scalatags:0.12.0"
def scalaXml = ivy"org.scala-lang.modules::scala-xml:2.2.0"
// keep in sync with doc/antora/antory.yml
val semanticDBscala = ivy"org.scalameta:::semanticdb-scalac:4.9.2"
val semanticDbJava = ivy"com.sourcegraph:semanticdb-java:0.9.9"
Expand Down Expand Up @@ -704,7 +705,7 @@ def formatDep(dep: Dep) = {

object scalalib extends MillStableScalaModule {
def moduleDeps = Seq(main, scalalib.api, testrunner)
def ivyDeps = Agg(Deps.scalafmtDynamic)
def ivyDeps = Agg(Deps.scalafmtDynamic, Deps.scalaXml)
def testIvyDeps = super.testIvyDeps() ++ Agg(Deps.scalaCheck)
def testTransitiveDeps = super.testTransitiveDeps() ++ Seq(worker.testDep())

Expand Down
75 changes: 75 additions & 0 deletions scalalib/src/mill/scalalib/TestModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import mill.api.{Ctx, PathRef, Result}
import mill.util.Jvm
import mill.scalalib.bsp.{BspBuildTarget, BspModule}
import mill.testrunner.{Framework, TestArgs, TestResult, TestRunner}
import sbt.testing.Status

trait TestModule
extends TestModule.JavaModuleBase
Expand Down Expand Up @@ -93,6 +94,12 @@ trait TestModule
*/
def testUseArgsFile: T[Boolean] = T { runUseArgsFile() || scala.util.Properties.isWin }

/**
* Sets the file name for the generated JUnit-compatible test report.
* If None is set, no file will be generated.
*/
def testReportXml: T[Option[String]] = T(Some("test-report.xml"))

/**
* The actual task shared by `test`-tasks that runs test in a forked JVM.
*/
Expand All @@ -101,6 +108,8 @@ trait TestModule
globSelectors: Task[Seq[String]]
): Task[(String, Seq[TestResult])] =
T.task {
testReportXml().foreach(file => os.remove(T.ctx().dest / file))

val outputPath = T.dest / "out.json"
val useArgsFile = testUseArgsFile()

Expand Down Expand Up @@ -160,6 +169,7 @@ trait TestModule
val jsonOutput = ujson.read(outputPath.toIO)
val (doneMsg, results) =
upickle.default.read[(String, Seq[TestResult])](jsonOutput)
testReportXml().foreach(file => TestModule.genTestXmlReport(results, T.ctx().dest / file))
TestModule.handleResults(doneMsg, results, Some(T.ctx()))
} catch {
case e: Throwable =>
Expand Down Expand Up @@ -321,4 +331,69 @@ object TestModule {
trait ScalaModuleBase extends mill.Module {
def scalacOptions: T[Seq[String]] = Seq.empty[String]
}

case class TestResultExtra(suiteName: String, testName: String, result: TestResult)

def genTestXmlReport(results0: Seq[TestResult], out: os.Path): Unit = {
val results = results0.map { r =>
val (suiteName, testName) = splitFullyQualifiedName(r.selector)
TestResultExtra(suiteName, testName, r)
}

val suites = results.groupMap(_.suiteName)(identity).map { case (suiteName, tests) =>
val cases = tests.map { test =>
val failure =
(test.result.exceptionName, test.result.exceptionMsg, test.result.exceptionTrace) match {
case (Some(name), Some(msg), Some(trace)) =>
Some(
<failure message={msg} type={name}>
{
trace
.map(t =>
s"${t.getClassName}.${t.getMethodName}(${t.getFileName}:${t.getLineNumber})"
)
.mkString(s"${name}: ${msg}\n at ", "\n at ", "")
}
</failure>
)
case _ => None
}
<testcase id={test.result.fullyQualifiedName}
classname={test.suiteName}
name={test.testName}
time={(test.result.duration / 1000.0).toString}>
{failure.orNull}
</testcase>
}

<testsuite id={suiteName}
name={suiteName}
tests={tests.length.toString}
failures={tests.count(_.result.status == Status.Failure.toString).toString}
errors={tests.count(_.result.status == Status.Error.toString).toString}
skipped={tests.count(_.result.status == Status.Skipped.toString).toString}
time={(tests.map(_.result.duration).sum / 1000.0).toString}>
{cases}
</testsuite>
}

val xml =
<testsuites tests={results.size.toString}
failures={results.count(_.result.status == Status.Failure.toString).toString}
errors={results.count(_.result.status == Status.Error.toString).toString}
skipped={results.count(_.result.status == Status.Skipped.toString).toString}
time={(results.map(_.result.duration).sum / 1000.0).toString}>
{suites}
</testsuites>
if (results.nonEmpty) scala.xml.XML.save(out.toString(), xml, xmlDecl = true)
}

private val RE_FQN = """^(([a-zA-Z_$][a-zA-Z\d_$]*\.)*[a-zA-Z_$][a-zA-Z\d_$]*)\.(.*)$""".r

private def splitFullyQualifiedName(fullyQualifiedName: String): (String, String) = {
RE_FQN.findFirstMatchIn(fullyQualifiedName) match {
case Some(m) => (m.group(1), m.group(3))
case None => ("", fullyQualifiedName)
}
}
}

0 comments on commit 80327ab

Please sign in to comment.