Skip to content

Commit 1013ee4

Browse files
authored
fix: BazelJUnitOutputListener logging xml output on suite-level failures (#328)
fixes: #327 1. Updated code to propagate the test suite failure to each test case. 2. The test suite failure will be propagated to each child node, even if the test is not executed. 3. Additionally, this change fixes the bug introduced in #300, which incorrectly logged the test_suite as a testcase and logged incorrect values for the "tests" attribute in the <testsuite> node.
1 parent 6a73203 commit 1013ee4

File tree

3 files changed

+154
-19
lines changed

3 files changed

+154
-19
lines changed

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

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.io.BufferedWriter;
66
import java.io.Closeable;
77
import java.io.IOException;
8+
import java.io.Writer;
89
import java.nio.file.Files;
910
import java.nio.file.Path;
1011
import java.util.ArrayList;
@@ -50,8 +51,12 @@ public class BazelJUnitOutputListener implements TestExecutionListener, Closeabl
5051
public BazelJUnitOutputListener(Path xmlOut) {
5152
try {
5253
Files.createDirectories(xmlOut.getParent());
53-
BufferedWriter writer = Files.newBufferedWriter(xmlOut, UTF_8);
54-
xml = XMLOutputFactory.newFactory().createXMLStreamWriter(writer);
54+
// Use LazyFileWriter to defer file creation until the first write operation.
55+
// This prevents premature file creation in cases where the JVM terminates abruptly
56+
// before any content is written. If no output is generated, Bazel has custom logic
57+
// to create the XML file from test.log, but this logic only activates if the
58+
// output file does not already exist.
59+
xml = XMLOutputFactory.newFactory().createXMLStreamWriter(new LazyFileWriter(xmlOut));
5560
xml.writeStartDocument("UTF-8", "1.0");
5661
} catch (IOException | XMLStreamException e) {
5762
throw new IllegalStateException("Unable to create output file", e);
@@ -140,8 +145,9 @@ private Map<TestData, List<TestData>> matchTestCasesToSuites_locked(
140145
throw new IllegalStateException(
141146
"Unexpected test organization for test Case: " + testCase.getId());
142147
}
143-
if (includeIncompleteTests || testCase.getDuration() != null) {
144-
knownSuites.computeIfAbsent(parent, id -> new ArrayList<>()).add(testCase);
148+
knownSuites.computeIfAbsent(parent, id -> new ArrayList<>());
149+
if (testCase.getId().isTest() && (includeIncompleteTests || testCase.getDuration() != null)) {
150+
knownSuites.get(parent).add(testCase);
145151
}
146152
}
147153

@@ -153,18 +159,19 @@ private Map<TestData, List<TestData>> matchTestCasesToSuites_locked(
153159
// This is really just documentation until someone actually turns on a static analyser.
154160
// If they do, we can decide whether we want to pick up the dependency.
155161
// @GuardedBy("resultsLock")
156-
private List<TestData> findTestCases_locked() {
162+
private List<TestData> findTestCases_locked(String engineId) {
157163
return results.values().stream()
158164
// Ignore test plan roots. These are always the engine being used.
159165
.filter(result -> !testPlan.getRoots().contains(result.getId()))
160166
.filter(
161-
result -> {
162-
// Find the test results we will convert to `testcase` entries. These
163-
// are identified by the fact that they have no child test cases in the
164-
// test plan, or they are marked as tests.
165-
TestIdentifier id = result.getId();
166-
return id.getSource() != null || id.isTest() || testPlan.getChildren(id).isEmpty();
167-
})
167+
result ->
168+
engineId == null
169+
|| result
170+
.getId()
171+
.getUniqueIdObject()
172+
.getEngineId()
173+
.map(engineId::equals)
174+
.orElse(false))
168175
.collect(Collectors.toList());
169176
}
170177

@@ -196,20 +203,38 @@ private void outputIfTestRootIsComplete(TestIdentifier testIdentifier) {
196203
return;
197204
}
198205

199-
output(false);
206+
output(false, testIdentifier.getUniqueIdObject().getEngineId().orElse(null));
200207
}
201208

202-
private void output(boolean includeIncompleteTests) {
209+
private void output(boolean includeIncompleteTests, /*@Nullable*/ String engineId) {
203210
synchronized (this.resultsLock) {
204-
List<TestData> testCases = findTestCases_locked();
211+
List<TestData> testCases = findTestCases_locked(engineId);
205212
Map<TestData, List<TestData>> testSuites =
206213
matchTestCasesToSuites_locked(testCases, includeIncompleteTests);
207214

208215
// Write the results
209216
try {
210217
for (Map.Entry<TestData, List<TestData>> suiteAndTests : testSuites.entrySet()) {
211-
new TestSuiteXmlRenderer(testPlan)
212-
.toXml(xml, suiteAndTests.getKey(), suiteAndTests.getValue());
218+
TestData suite = suiteAndTests.getKey();
219+
List<TestData> tests = suiteAndTests.getValue();
220+
if (suite.getResult() != null
221+
&& suite.getResult().getStatus() != TestExecutionResult.Status.SUCCESSFUL) {
222+
// If a test suite fails or is skipped, all its tests must be included in the XML output
223+
// with the same result as the suite, since the XML format does not support marking a
224+
// suite as failed or skipped. This aligns with Bazel's XmlWriter for JUnitRunner.
225+
getTestsFromSuite(suite.getId())
226+
.forEach(
227+
testIdentifier -> {
228+
TestData test = results.get(testIdentifier.getUniqueIdObject());
229+
if (test == null) {
230+
// add test to results.
231+
test = getResult(testIdentifier);
232+
tests.add(test);
233+
}
234+
test.mark(suite.getResult()).skipReason(suite.getSkipReason());
235+
});
236+
}
237+
new TestSuiteXmlRenderer(testPlan).toXml(xml, suite, tests);
213238
}
214239
} catch (XMLStreamException e) {
215240
throw new RuntimeException(e);
@@ -227,6 +252,18 @@ private void output(boolean includeIncompleteTests) {
227252
}
228253
}
229254

255+
private List<TestIdentifier> getTestsFromSuite(TestIdentifier suiteIdentifier) {
256+
return testPlan.getChildren(suiteIdentifier).stream()
257+
.flatMap(
258+
testIdentifier -> {
259+
if (testIdentifier.isContainer()) {
260+
return getTestsFromSuite(testIdentifier).stream();
261+
}
262+
return Stream.of(testIdentifier);
263+
})
264+
.collect(Collectors.toList());
265+
}
266+
230267
@Override
231268
public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry entry) {
232269
getResult(testIdentifier).addReportEntry(entry);
@@ -248,7 +285,7 @@ public void close() {
248285
return;
249286
}
250287
if (wasInterrupted.get()) {
251-
output(true);
288+
output(true, null);
252289
}
253290
try {
254291
xml.writeEndDocument();
@@ -257,4 +294,41 @@ public void close() {
257294
LOG.log(Level.SEVERE, "Unable to close xml output", e);
258295
}
259296
}
297+
298+
static class LazyFileWriter extends Writer {
299+
private final Path path;
300+
private BufferedWriter delegate;
301+
private boolean isCreated = false;
302+
303+
public LazyFileWriter(Path path) {
304+
this.path = path;
305+
}
306+
307+
private void createWriterIfNeeded() throws IOException {
308+
if (!isCreated) {
309+
delegate = Files.newBufferedWriter(path, UTF_8);
310+
isCreated = true;
311+
}
312+
}
313+
314+
@Override
315+
public void write(char[] cbuf, int off, int len) throws IOException {
316+
createWriterIfNeeded();
317+
delegate.write(cbuf, off, len);
318+
}
319+
320+
@Override
321+
public void flush() throws IOException {
322+
if (isCreated) {
323+
delegate.flush();
324+
}
325+
}
326+
327+
@Override
328+
public void close() throws IOException {
329+
if (isCreated) {
330+
delegate.close();
331+
}
332+
}
333+
}
260334
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@
1111
public class StubbedTestDescriptor implements TestDescriptor {
1212

1313
private final UniqueId uniqueId;
14+
private final Type type;
1415

1516
public StubbedTestDescriptor(UniqueId uniqueId) {
17+
this(uniqueId, Type.TEST);
18+
}
19+
20+
public StubbedTestDescriptor(UniqueId uniqueId, Type type) {
1621
this.uniqueId = uniqueId;
22+
this.type = type;
1723
}
1824

1925
@Override
@@ -68,7 +74,7 @@ public void removeFromHierarchy() {
6874

6975
@Override
7076
public Type getType() {
71-
return Type.TEST;
77+
return type;
7278
}
7379

7480
@Override

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
99
import static org.junit.platform.launcher.LauncherConstants.STDERR_REPORT_ENTRY_KEY;
1010
import static org.junit.platform.launcher.LauncherConstants.STDOUT_REPORT_ENTRY_KEY;
11+
import static org.mockito.Mockito.when;
1112

1213
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
1314
import java.io.File;
@@ -17,8 +18,10 @@
1718
import java.io.StringWriter;
1819
import java.io.Writer;
1920
import java.nio.file.Files;
21+
import java.nio.file.Path;
2022
import java.util.Collection;
2123
import java.util.List;
24+
import java.util.Set;
2225
import java.util.concurrent.atomic.AtomicBoolean;
2326
import javax.xml.parsers.DocumentBuilder;
2427
import javax.xml.parsers.DocumentBuilderFactory;
@@ -28,6 +31,7 @@
2831
import javax.xml.stream.XMLStreamWriter;
2932
import org.junit.jupiter.api.AfterAll;
3033
import org.junit.jupiter.api.Test;
34+
import org.junit.jupiter.api.io.TempDir;
3135
import org.junit.platform.engine.TestDescriptor;
3236
import org.junit.platform.engine.TestExecutionResult;
3337
import org.junit.platform.engine.UniqueId;
@@ -258,6 +262,57 @@ public void interruptedTestsAreMarkedAsFailed() {
258262
assertEquals("Test timed out and was interrupted", failure.getTextContent());
259263
}
260264

265+
@Test
266+
void testSuiteError(@TempDir Path tempDir)
267+
throws ParserConfigurationException, IOException, SAXException {
268+
TestIdentifier root =
269+
TestIdentifier.from(new StubbedTestDescriptor(UniqueId.parse("[engine:mocked]")));
270+
TestIdentifier suiteIdentifier =
271+
TestIdentifier.from(
272+
new StubbedTestDescriptor(
273+
UniqueId.parse("[engine:mocked]/[class:ExampleTest]"),
274+
TestDescriptor.Type.CONTAINER));
275+
TestData suite =
276+
new TestData(suiteIdentifier)
277+
.started()
278+
.mark(TestExecutionResult.failed(new RuntimeException("test suite error")));
279+
TestPlan testPlan = Mockito.mock(TestPlan.class);
280+
281+
TestIdentifier child1 = TestIdentifier.from(new StubbedTestDescriptor(createId("child1")));
282+
TestIdentifier child2 = TestIdentifier.from(new StubbedTestDescriptor(createId("child2")));
283+
284+
when(testPlan.getChildren(suite.getId())).thenReturn(Set.of(child1, child2));
285+
when(testPlan.getRoots()).thenReturn(Set.of(root));
286+
287+
Path xmlPath = tempDir.resolve("test.xml");
288+
try (BazelJUnitOutputListener listener = new BazelJUnitOutputListener(xmlPath)) {
289+
listener.testPlanExecutionStarted(testPlan);
290+
listener.executionStarted(root);
291+
listener.executionStarted(suiteIdentifier);
292+
listener.executionFinished(
293+
suiteIdentifier, TestExecutionResult.failed(new RuntimeException("test suite error")));
294+
listener.executionFinished(root, TestExecutionResult.successful());
295+
}
296+
297+
Document document =
298+
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlPath.toFile());
299+
document.getDocumentElement().normalize();
300+
301+
NodeList testsuiteList = document.getDocumentElement().getElementsByTagName("testsuite");
302+
assertEquals(1, testsuiteList.getLength());
303+
Element testsuite = (Element) testsuiteList.item(0);
304+
assertEquals("2", testsuite.getAttribute("tests"));
305+
assertEquals("2", testsuite.getAttribute("failures"));
306+
307+
NodeList testcaseList = testsuite.getElementsByTagName("testcase");
308+
assertEquals(2, testcaseList.getLength());
309+
310+
for (int i = 0; i < testcaseList.getLength(); i++) {
311+
Element testcase = (Element) testcaseList.item(i);
312+
assertNotNull(getChild("failure", testcase));
313+
}
314+
}
315+
261316
@Test
262317
void throwablesWithNullMessageAreSerialized() {
263318
var test = new TestData(identifier).mark(TestExecutionResult.failed(new Throwable()));

0 commit comments

Comments
 (0)