Skip to content

Commit cb1b678

Browse files
Represent test timeouts in XML (#247)
This creates similar output as bazelbuild/bazel@7b091c1 Note: This is a duplicate of #242, but with the comments addressed and the merge conflicts resolved. --------- Co-authored-by: Daniel Wagner-Hall <[email protected]>
1 parent 2a60641 commit cb1b678

File tree

7 files changed

+180
-50
lines changed

7 files changed

+180
-50
lines changed

MODULE.bazel

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ IO_GRPC_GRPC_JAVA_ARTIFACTS = [
103103
"com.google.auto.value:auto-value:1.9",
104104
"com.google.code.findbugs:jsr305:3.0.2",
105105
"com.google.code.gson:gson:2.9.0",
106-
"com.google.errorprone:error_prone_annotations:2.9.0",
106+
"com.google.errorprone:error_prone_annotations:2.11.0",
107107
"com.google.guava:failureaccess:1.0.1",
108108
"com.google.guava:guava:31.0.1-android",
109109
"com.google.j2objc:j2objc-annotations:1.3",

java/src/com/github/bazel_contrib/contrib_rules_jvm/junit5/ActualRunner.java

+7
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ public boolean run(String testClassName) {
4444
}
4545

4646
try (BazelJUnitOutputListener bazelJUnitXml = new BazelJUnitOutputListener(xmlOut)) {
47+
Runtime.getRuntime()
48+
.addShutdownHook(
49+
new Thread(
50+
() -> {
51+
bazelJUnitXml.closeForInterrupt();
52+
}));
53+
4754
CommandLineSummary summary = new CommandLineSummary();
4855
FailFastExtension failFastExtension = new FailFastExtension();
4956

java/src/com/github/bazel_contrib/contrib_rules_jvm/junit5/BazelJUnitOutputListener.java

+69-24
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.util.Map;
1414
import java.util.NoSuchElementException;
1515
import java.util.concurrent.ConcurrentHashMap;
16+
import java.util.concurrent.atomic.AtomicBoolean;
1617
import java.util.logging.Level;
1718
import java.util.logging.Logger;
1819
import java.util.stream.Collectors;
@@ -31,9 +32,21 @@ public class BazelJUnitOutputListener implements TestExecutionListener, Closeabl
3132
public static final Logger LOG = Logger.getLogger(BazelJUnitOutputListener.class.getName());
3233
private final XMLStreamWriter xml;
3334

35+
private final Object resultsLock = new Object();
36+
// Commented out to avoid adding a dependency to building the test runner.
37+
// This is really just documentation until someone actually turns on a static analyser.
38+
// If they do, we can decide whether we want to pick up the dependency.
39+
// @GuardedBy("resultsLock")
3440
private final Map<UniqueId, TestData> results = new ConcurrentHashMap<>();
3541
private TestPlan testPlan;
3642

43+
// If we have already closed this listener, we shouldn't write any more XML.
44+
private final AtomicBoolean hasClosed = new AtomicBoolean();
45+
// Whether test-running was interrupted (e.g. because our tests timed out and we got SIGTERM'd)
46+
// and when writing results we want to flush any pending tests as interrupted,
47+
// rather than ignoring them because they're incomplete.
48+
private final AtomicBoolean wasInterrupted = new AtomicBoolean();
49+
3750
public BazelJUnitOutputListener(Path xmlOut) {
3851
try {
3952
Files.createDirectories(xmlOut.getParent());
@@ -73,12 +86,17 @@ public void testPlanExecutionFinished(TestPlan testPlan) {
7386
this.testPlan = null;
7487
}
7588

76-
private Map<TestData, List<TestData>> matchTestCasesToSuites(List<TestData> testCases) {
89+
// Requires the caller to have acquired resultsLock.
90+
// Commented out to avoid adding a dependency to building the test runner.
91+
// This is really just documentation until someone actually turns on a static analyser.
92+
// If they do, we can decide whether we want to pick up the dependency.
93+
// @GuardedBy("resultsLock")
94+
private Map<TestData, List<TestData>> matchTestCasesToSuites_locked(
95+
List<TestData> testCases, boolean includeIncompleteTests) {
7796
Map<TestData, List<TestData>> knownSuites = new HashMap<>();
7897

7998
// Find the containing test suites for the test cases.
8099
for (TestData testCase : testCases) {
81-
82100
TestData parent;
83101

84102
// The number of segments in the test case Unique ID depends upon the nature of the test:
@@ -120,13 +138,20 @@ private Map<TestData, List<TestData>> matchTestCasesToSuites(List<TestData> test
120138
throw new IllegalStateException(
121139
"Unexpected test organization for test Case: " + testCase.getId());
122140
}
123-
knownSuites.computeIfAbsent(parent, id -> new ArrayList<>()).add(testCase);
141+
if (includeIncompleteTests || testCase.getDuration() != null) {
142+
knownSuites.computeIfAbsent(parent, id -> new ArrayList<>()).add(testCase);
143+
}
124144
}
125145

126146
return knownSuites;
127147
}
128148

129-
private List<TestData> findTestCases() {
149+
// Requires the caller to have acquired resultsLock.
150+
// Commented out to avoid adding a dependency to building the test runner.
151+
// This is really just documentation until someone actually turns on a static analyser.
152+
// If they do, we can decide whether we want to pick up the dependency.
153+
// @GuardedBy("resultsLock")
154+
private List<TestData> findTestCases_locked() {
130155
return results.values().stream()
131156
// Ignore test plan roots. These are always the engine being used.
132157
.filter(result -> !testPlan.getRoots().contains(result.getId()))
@@ -169,28 +194,35 @@ private void outputIfTestRootIsComplete(TestIdentifier testIdentifier) {
169194
return;
170195
}
171196

172-
List<TestData> testCases = findTestCases();
173-
Map<TestData, List<TestData>> testSuites = matchTestCasesToSuites(testCases);
197+
output(false);
198+
}
199+
200+
private void output(boolean includeIncompleteTests) {
201+
synchronized (this.resultsLock) {
202+
List<TestData> testCases = findTestCases_locked();
203+
Map<TestData, List<TestData>> testSuites =
204+
matchTestCasesToSuites_locked(testCases, includeIncompleteTests);
174205

175-
// Write the results
176-
try {
177-
for (Map.Entry<TestData, List<TestData>> suiteAndTests : testSuites.entrySet()) {
178-
new TestSuiteXmlRenderer(testPlan)
179-
.toXml(xml, suiteAndTests.getKey(), suiteAndTests.getValue());
206+
// Write the results
207+
try {
208+
for (Map.Entry<TestData, List<TestData>> suiteAndTests : testSuites.entrySet()) {
209+
new TestSuiteXmlRenderer(testPlan)
210+
.toXml(xml, suiteAndTests.getKey(), suiteAndTests.getValue());
211+
}
212+
} catch (XMLStreamException e) {
213+
throw new RuntimeException(e);
180214
}
181-
} catch (XMLStreamException e) {
182-
throw new RuntimeException(e);
183-
}
184215

185-
// Delete the results we've used to conserve memory. This is safe to do
186-
// since we only do this when the test root is complete, so we know that
187-
// we won't be adding to the list of suites and test cases for that root
188-
// (because tests and containers are arranged in a hierarchy --- the
189-
// containers only complete when all the things they contain are
190-
// finished. We are leaving all the test data that we have _not_ written
191-
// to the XML file.
192-
Stream.concat(testCases.stream(), testSuites.keySet().stream())
193-
.forEach(data -> results.remove(data.getId().getUniqueIdObject()));
216+
// Delete the results we've used to conserve memory. This is safe to do
217+
// since we only do this when the test root is complete, so we know that
218+
// we won't be adding to the list of suites and test cases for that root
219+
// (because tests and containers are arranged in a hierarchy --- the
220+
// containers only complete when all the things they contain are
221+
// finished. We are leaving all the test data that we have _not_ written
222+
// to the XML file.
223+
Stream.concat(testCases.stream(), testSuites.keySet().stream())
224+
.forEach(data -> results.remove(data.getId().getUniqueIdObject()));
225+
}
194226
}
195227

196228
@Override
@@ -199,10 +231,23 @@ public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry e
199231
}
200232

201233
private TestData getResult(TestIdentifier id) {
202-
return results.computeIfAbsent(id.getUniqueIdObject(), ignored -> new TestData(id));
234+
synchronized (resultsLock) {
235+
return results.computeIfAbsent(id.getUniqueIdObject(), ignored -> new TestData(id));
236+
}
237+
}
238+
239+
public void closeForInterrupt() {
240+
wasInterrupted.set(true);
241+
close();
203242
}
204243

205244
public void close() {
245+
if (hasClosed.getAndSet(true)) {
246+
return;
247+
}
248+
if (wasInterrupted.get()) {
249+
output(true);
250+
}
206251
try {
207252
xml.writeEndDocument();
208253
xml.close();

java/src/com/github/bazel_contrib/contrib_rules_jvm/junit5/TestCaseXmlRenderer.java

+25-12
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import java.math.RoundingMode;
99
import java.text.DecimalFormat;
1010
import java.text.DecimalFormatSymbols;
11+
import java.time.Duration;
12+
import java.time.Instant;
1113
import java.util.Locale;
1214
import javax.xml.stream.XMLStreamException;
1315
import javax.xml.stream.XMLStreamWriter;
@@ -47,21 +49,32 @@ public void toXml(XMLStreamWriter xml, TestData test) throws XMLStreamException
4749
xml.writeStartElement("testcase");
4850
xml.writeAttribute("name", name);
4951
xml.writeAttribute("classname", LegacyReportingUtils.getClassName(testPlan, id));
50-
xml.writeAttribute("time", decimalFormat.format(test.getDuration().toMillis() / 1000f));
5152

52-
if (test.isDisabled() || test.isSkipped()) {
53-
xml.writeStartElement("skipped");
54-
if (test.getSkipReason() != null) {
55-
xml.writeCData(test.getSkipReason());
56-
} else {
53+
/* @Nullable */ Duration maybeDuration = test.getDuration();
54+
boolean wasInterrupted = maybeDuration == null;
55+
Duration duration =
56+
maybeDuration == null ? Duration.between(test.getStarted(), Instant.now()) : maybeDuration;
57+
xml.writeAttribute("time", decimalFormat.format(duration.toMillis() / 1000f));
58+
59+
if (wasInterrupted) {
60+
xml.writeStartElement("failure");
61+
xml.writeCData("Test timed out and was interrupted");
62+
xml.writeEndElement();
63+
} else {
64+
if (test.isDisabled() || test.isSkipped()) {
65+
xml.writeStartElement("skipped");
66+
if (test.getSkipReason() != null) {
67+
xml.writeCData(test.getSkipReason());
68+
} else {
69+
writeThrowableMessage(xml, test.getResult());
70+
}
71+
xml.writeEndElement();
72+
}
73+
if (test.isFailure() || test.isError()) {
74+
xml.writeStartElement(test.isFailure() ? "failure" : "error");
5775
writeThrowableMessage(xml, test.getResult());
76+
xml.writeEndElement();
5877
}
59-
xml.writeEndElement();
60-
}
61-
if (test.isFailure() || test.isError()) {
62-
xml.writeStartElement(test.isFailure() ? "failure" : "error");
63-
writeThrowableMessage(xml, test.getResult());
64-
xml.writeEndElement();
6578
}
6679

6780
writeTextElement(xml, "system-out", test.getStdOut());

java/src/com/github/bazel_contrib/contrib_rules_jvm/junit5/TestData.java

+16-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ class TestData {
1616
private final TestIdentifier id;
1717
private final List<ReportEntry> reportEntries = Collections.synchronizedList(new ArrayList<>());
1818
private Instant started = Instant.now();
19-
private Instant finished = Instant.now();
19+
// Commented out to avoid pulling in the dependency, but present for documentation purposes.
20+
// @Nullable
21+
private Instant finished = null;
2022
private String reason;
2123
private TestExecutionResult result;
2224
private boolean dynamic;
@@ -36,8 +38,6 @@ public TestData started() {
3638

3739
public TestData mark(TestExecutionResult result) {
3840
if (result.getStatus() == TestExecutionResult.Status.ABORTED) {
39-
skipReason("");
40-
4141
Optional<Throwable> maybeThrowable = result.getThrowable();
4242
if (maybeThrowable.isPresent() && maybeThrowable.get() instanceof TestAbortedException) {
4343
skipReason(maybeThrowable.get().getMessage());
@@ -62,7 +62,13 @@ public TestExecutionResult getResult() {
6262
return result;
6363
}
6464

65+
/** Returns how long the test took to run - will be absent if the test has not yet completed. */
66+
// Commented out to avoid pulling in the dependency, but present for documentation purposes.
67+
// @Nullable
6568
public Duration getDuration() {
69+
if (finished == null) {
70+
return null;
71+
}
6672
return Duration.between(started, finished);
6773
}
6874

@@ -84,6 +90,9 @@ public boolean isFailure() {
8490
|| isSkipped()) {
8591
return false;
8692
}
93+
if (result.getStatus() == TestExecutionResult.Status.ABORTED) {
94+
return true;
95+
}
8796

8897
return result.getThrowable().map(thr -> (!(thr instanceof AssertionError))).orElse(false);
8998
}
@@ -133,4 +142,8 @@ public TestData setDynamic(boolean isDynamic) {
133142
public boolean isDynamic() {
134143
return dynamic;
135144
}
145+
146+
public Instant getStarted() {
147+
return this.started;
148+
}
136149
}

java/src/com/github/bazel_contrib/contrib_rules_jvm/junit5/TestSuiteXmlRenderer.java

+17-10
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,24 @@ public void toXml(XMLStreamWriter xml, TestData suite, Collection<TestData> test
2727
int disabled = 0;
2828
int skipped = 0;
2929
for (TestData result : tests) {
30-
if (result.isError()) {
31-
errors++;
32-
}
33-
if (result.isFailure()) {
30+
// Tests which didn't complete are considered to be failures.
31+
// The caller is expected to filter out still-running tests, so if we got here,
32+
// it's because the test has been cancelled (e.g. because of a timeout).
33+
if (result.getDuration() == null) {
3434
failures++;
35-
}
36-
if (result.isDisabled()) {
37-
disabled++;
38-
}
39-
if (result.isSkipped()) {
40-
skipped++;
35+
} else {
36+
if (result.isError()) {
37+
errors++;
38+
}
39+
if (result.isFailure()) {
40+
failures++;
41+
}
42+
if (result.isDisabled()) {
43+
disabled++;
44+
}
45+
if (result.isSkipped()) {
46+
skipped++;
47+
}
4148
}
4249
}
4350
xml.writeAttribute("failures", String.valueOf(failures));

java/test/com/github/bazel_contrib/contrib_rules_jvm/junit5/BazelJUnitOuputListenerTest.java

+45
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,51 @@ public void disabledTestsAreMarkedAsSkipped() {
141141
assertNotNull(skipped);
142142
}
143143

144+
@Test
145+
public void interruptedTestsAreMarkedAsFailed() {
146+
TestData suite = new TestData(identifier);
147+
148+
TestPlan testPlan = Mockito.mock(TestPlan.class);
149+
150+
TestIdentifier completedChild =
151+
TestIdentifier.from(new StubbedTestDescriptor(createId("complete-child")));
152+
TestData completedChildResult = new TestData(completedChild).started();
153+
completedChildResult.mark(TestExecutionResult.successful());
154+
155+
TestIdentifier interruptedChild =
156+
TestIdentifier.from(new StubbedTestDescriptor(createId("interrupted-child")));
157+
TestData interruptedChildResult = new TestData(interruptedChild).started();
158+
159+
Document xml =
160+
generateTestXml(testPlan, suite, List.of(completedChildResult, interruptedChildResult));
161+
162+
// Because of the way we generated the XML, the root element is the `testsuite` one
163+
Element root = xml.getDocumentElement();
164+
assertEquals("testsuite", root.getTagName());
165+
assertEquals("2", root.getAttribute("tests"));
166+
assertEquals("0", root.getAttribute("disabled"));
167+
assertEquals("0", root.getAttribute("errors"));
168+
assertEquals("0", root.getAttribute("skipped"));
169+
assertEquals("1", root.getAttribute("failures"));
170+
171+
NodeList allTestCases = root.getElementsByTagName("testcase");
172+
assertEquals(2, allTestCases.getLength());
173+
Node testCaseZero = allTestCases.item(0);
174+
Node testCaseOne = allTestCases.item(1);
175+
176+
Node failureZero = getChild("failure", testCaseZero);
177+
Node failureOne = getChild("failure", testCaseOne);
178+
179+
if (!(failureZero == null ^ failureOne == null)) {
180+
fail(
181+
String.format("Expected exactly one failure but got %s and %s", failureZero, failureOne));
182+
}
183+
184+
Node failure = failureZero == null ? failureOne : failureZero;
185+
186+
assertEquals("Test timed out and was interrupted", failure.getTextContent());
187+
}
188+
144189
@Test
145190
void throwablesWithNullMessageAreSerialized() {
146191
var test = new TestData(identifier).mark(TestExecutionResult.failed(new Throwable()));

0 commit comments

Comments
 (0)