Skip to content

Commit

Permalink
NET-767 Add test importer for MSTest format
Browse files Browse the repository at this point in the history
Co-authored-by: Costin Zaharia <[email protected]>
  • Loading branch information
2 people authored and sonartech committed Nov 28, 2024
1 parent ae1b97f commit b785ab0
Show file tree
Hide file tree
Showing 12 changed files with 748 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* SonarSource :: .NET :: Core
* Copyright (C) 2014-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* 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 Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
package org.sonar.plugins.dotnet.tests;

import java.io.File;
import java.util.Map;
import java.util.function.BiConsumer;

@FunctionalInterface
interface UnitTestResultParser extends BiConsumer<File, Map<String, UnitTestResults>> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@

public class UnitTestResults {

private int tests;
private int skipped;
private int failures;
private int errors;
private Long executionTime;
protected int tests;
protected int skipped;
protected int failures;
protected int errors;
protected Long executionTime;

public void add(int tests, int skipped, int failures, int errors, @Nullable Long executionTime) {
this.tests += tests;
Expand All @@ -41,6 +41,19 @@ public void add(int tests, int skipped, int failures, int errors, @Nullable Long
}
}

public void add(UnitTestResults unitTestResults) {
this.tests += unitTestResults.tests();
this.skipped += unitTestResults.skipped();
this.failures += unitTestResults.failures();
this.errors += unitTestResults.errors();
if (unitTestResults.executionTime() != null) {
if (this.executionTime == null) {
this.executionTime = 0L;
}
this.executionTime += unitTestResults.executionTime();
}
}

public int tests() {
return tests;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* SonarSource :: .NET :: Core
* Copyright (C) 2014-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* 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 Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
package org.sonar.plugins.dotnet.tests;

import java.io.File;
import java.io.IOException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class VisualStudioTestResultParser implements UnitTestResultParser {

private static final Logger LOG = LoggerFactory.getLogger(VisualStudioTestResultParser.class);
private final Map<String, String> methodFileMap;

VisualStudioTestResultParser(Map<String, String> methodFileMap) {
this.methodFileMap = methodFileMap;
}

@Override
public void accept(File file, Map<String, UnitTestResults> unitTestResults) {
LOG.info("Parsing the Visual Studio Test Results file '{}'.", file.getAbsolutePath());
new Parser(file, unitTestResults, this.methodFileMap).parse();
}

private static class Parser {
private final File file;
private final Map<String, UnitTestResults> testIdTestResultMap;
private final Map<String, UnitTestResults> unitTestResults;
// Date Format: // https://github.com/microsoft/vstest/blob/7d34b30433259fb914aaaf276fde663a47b6ef2f/src/Microsoft.TestPlatform.Extensions.TrxLogger/XML/XmlPersistence.cs#L557-L572
private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
private final Pattern millisecondsPattern = Pattern.compile("(\\.(\\d{0,3}))\\d*+");
private final Map<String, String> methodFileMap;

Parser(File file, Map<String, UnitTestResults> unitTestResults, Map<String, String> methodFileMap) {
this.file = file;
this.unitTestResults = unitTestResults;
this.testIdTestResultMap = new HashMap<>();
this.methodFileMap = methodFileMap;
}

public void parse() {
try (XmlParserHelper xmlParserHelper = new XmlParserHelper(file)) {
checkRootTag(xmlParserHelper);
dispatchTags(xmlParserHelper);
} catch (IOException e) {
throw new IllegalStateException("Unable to close report", e);
}
}

private void dispatchTags(XmlParserHelper xmlParserHelper) {
String tagName;
while ((tagName = xmlParserHelper.nextStartTag()) != null) {
if ("UnitTestResult".equals(tagName)) {
handleUnitTestResultTag(xmlParserHelper);
} else if ("UnitTest".equals(tagName)) {
handleUnitTestTag(xmlParserHelper);
}
}
}

private void handleUnitTestResultTag(XmlParserHelper xmlParserHelper) {
String testId = xmlParserHelper.getRequiredAttribute("testId");
String outcome = xmlParserHelper.getRequiredAttribute("outcome");
Date start = getRequiredDateAttribute(xmlParserHelper, "startTime");
Date finish = getRequiredDateAttribute(xmlParserHelper, "endTime");
long duration = finish.getTime() - start.getTime();

var testResult = new VisualStudioTestResults(outcome, duration);
testIdTestResultMap.put(testId, testResult);
LOG.debug("Parsed Visual Studio Unit Test - testId: {} outcome: {}, startTime: {}, endTime: {}",
testId, outcome, start, finish);
}

private void handleUnitTestTag(XmlParserHelper xmlParserHelper) {
String testId = xmlParserHelper.getRequiredAttribute("id");

String tagName;
while ((tagName = xmlParserHelper.nextStartTag()) != null) {
if ("TestMethod".equals(tagName)) {
break;
}
}
if (tagName == null){
throw new ParseErrorException("No TestMethod attribute found on UnitTest tag");
}

String methodName = xmlParserHelper.getRequiredAttribute("name");
String className = xmlParserHelper.getRequiredAttribute("className");
String codeBase = xmlParserHelper.getRequiredAttribute("codeBase");

String dllName = codeBase.substring(codeBase.lastIndexOf(File.separator) + 1, codeBase.lastIndexOf('.'));

String fullyQualifiedName = dllName + "." + className + "." + methodName;

associateTestIdTestResultWithFile(testId, fullyQualifiedName);
}

public void associateTestIdTestResultWithFile(String testId, String methodFullName) {
if(!methodFileMap.containsKey(methodFullName)) {
throw new IllegalStateException(String.format("Test method %s with testId %s cannot be mapped to the test source file", methodFullName, testId));
}

String fileName = methodFileMap.get(methodFullName);

if (unitTestResults.containsKey(fileName)) {
var fileTestResult = unitTestResults.get(fileName);
var testIdTestResult = testIdTestResultMap.get(testId);
fileTestResult.add(testIdTestResult);
} else {
unitTestResults.put(fileName, testIdTestResultMap.get(testId));
}

LOG.debug("Associated Visual Studio Unit Test to File - file: {}, TestId: {} MethodFullName: {}",
fileName, testId, methodFullName);
}

private Date getRequiredDateAttribute(XmlParserHelper xmlParserHelper, String name) {
String value = xmlParserHelper.getRequiredAttribute(name);
try {
value = keepOnlyMilliseconds(value);
return dateFormat.parse(value);
} catch (ParseException e) {
throw xmlParserHelper.parseError("Expected a valid date and time instead of \"" + value + "\" for the attribute \"" + name + "\". " + e.getMessage());
}
}

private String keepOnlyMilliseconds(String value) {
StringBuilder sb = new StringBuilder();

Matcher matcher = millisecondsPattern.matcher(value);
StringBuilder trailingZeros = new StringBuilder();
while (matcher.find()) {
String milliseconds = matcher.group(2);
trailingZeros.setLength(0);
trailingZeros.append("0".repeat(Math.max(0, 3 - milliseconds.length())));
matcher.appendReplacement(sb, "$1" + trailingZeros);
}
matcher.appendTail(sb);

return sb.toString();
}

private static void checkRootTag(XmlParserHelper xmlParserHelper) {
xmlParserHelper.checkRootTag("TestRun");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* SonarSource :: .NET :: Core
* Copyright (C) 2014-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* 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 Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
package org.sonar.plugins.dotnet.tests;

public class VisualStudioTestResults extends UnitTestResults {

VisualStudioTestResults(String outcome, Long executionTime) {
this.tests = 1;
this.executionTime = executionTime;
switch(outcome) {
case "Passed",
"Warning":
// success
break;
case "Failed":
// failure
this.failures = 1;
break;
case "Error":
// error
this.errors = 1;
break;
case "PassedButRunAborted",
"NotExecuted",
"Inconclusive",
"Completed",
"Timeout",
"Aborted",
"Blocked",
"NotRunnable":
//skipped
this.skipped = 1;
break;
default:
throw new IllegalArgumentException("Outcome of unit test must match VSTest Format");
}
}
}
Loading

0 comments on commit b785ab0

Please sign in to comment.