现象
TestUtils
测试类中,同时存在 JUnit4
(通过 org.junit.Test
注解标识)和 JUnit5
(通过 org.junit.jupiter.params.ParameterizedTest
注解标识)的测试用例。
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
import org.junit.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.Collections;
import java.util.stream.Stream;
@Slf4j
public class TestUtils {
@Test
public void test() {
log.info("record");
Assert.assertTrue(Utils.test(1, 2));
Assert.assertFalse(Utils.test(10, 2));
}
@ParameterizedTest
@MethodSource("dataProvider")
void test(int a, int b, boolean expected) {
log.info(strlen(1000 * 1000));
Assert.assertEquals(expected, Utils.test(a, b));
}
static Stream<Arguments> dataProvider() {
return Stream.of(
Arguments.of(1, 2, true)
);
}
private String strlen(int len) {
return String.join("", Collections.nCopies(len, "a"));
}
}
执行 mvn clean test
命令后,查看 surefire-reports
文件夹下的 TEST-com.nocompany.mk.english.util.TestUtils.xml
报告异常,报告内容如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<testsuite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report-3.0.xsd" version="3.0" name="com.nocompany.mk.english.util.TestUtils" time="0.012" tests="1" errors="0" skipped="0" failures="0">
...
<testcase name="test(int, int, boolean)[1]" classname="com.nocompany.mk.english.util.TestUtils" time="0.23">
<system-out><![CDATA[
<system-out>
节点内容丢失。
源码分析
StatelessXmlReporter
类
用来生成 xml
的测试报告。
package org.apache.maven.plugin.surefire.report;
/*
* XML format reporter writing to <code>TEST-<i>reportName</i>[-<i>suffix</i>].xml</code> file like written and read
* by Ant's <a href="http://ant.apache.org/manual/Tasks/junit.html"><code><junit></code></a> and
* <a href="http://ant.apache.org/manual/Tasks/junitreport.html"><code><junitreport></code></a> tasks,
* then supported by many tools like CI servers.
*/
public class StatelessXmlReporter
implements StatelessReportEventListener<WrappedReportEntry, TestSetStats>
{
@Override
public void testSetCompleted( WrappedReportEntry testSetReportEntry, TestSetStats testSetStats )
{
Map<String, Map<String, List<WrappedReportEntry>>> classMethodStatistics =
arrangeMethodStatistics( testSetReportEntry, testSetStats );
OutputStream outputStream = getOutputStream( testSetReportEntry ); // 获取 xml 报告文件的输出流
try ( OutputStreamWriter fw = getWriter( outputStream ) )
{
XMLWriter ppw = new PrettyPrintXMLWriter( fw );
ppw.setEncoding( UTF_8.name() );
createTestSuiteElement( ppw, testSetReportEntry, testSetStats ); // TestSuite
showProperties( ppw, testSetReportEntry.getSystemProperties() );
for ( Entry<String, Map<String, List<WrappedReportEntry>>> statistics : classMethodStatistics.entrySet() )
{
for ( Entry<String, List<WrappedReportEntry>> thisMethodRuns : statistics.getValue().entrySet() )
{
// 1. 序列化 thisMethodRuns 表示的测试方法
serializeTestClass( outputStream, fw, ppw, thisMethodRuns.getValue() );
}
}
ppw.endElement(); // TestSuite
}
catch ( Exception e )
{
// It's not a test error.
// This method must be sail-safe and errors are in a dump log.
// The control flow must not be broken in TestSetRunListener#testSetCompleted.
InPluginProcessDumpSingleton.getSingleton()
.dumpException( e, e.getLocalizedMessage(), reportsDirectory );
}
}
private OutputStream getOutputStream( WrappedReportEntry testSetReportEntry )
{
File reportFile = getReportFile( testSetReportEntry ); // 创建 xml 报告文件
return new BufferedOutputStream( new FileOutputStream( reportFile ), 64 * 1024 );
}
private File getReportFile( WrappedReportEntry report )
{
String reportName = "TEST-" + ( phrasedFileName ? report.getReportSourceName() : report.getSourceName() );
String customizedReportName = isBlank( reportNameSuffix ) ? reportName : reportName + "-" + reportNameSuffix;
return new File( reportsDirectory, stripIllegalFilenameChars( customizedReportName + ".xml" ) );
}
private void serializeTestClass( OutputStream outputStream, OutputStreamWriter fw, XMLWriter ppw,
List<WrappedReportEntry> methodEntries )
{
serializeTestClassWithoutRerun( outputStream, fw, ppw, methodEntries );
}
private void serializeTestClassWithoutRerun( OutputStream outputStream, OutputStreamWriter fw, XMLWriter ppw,
List<WrappedReportEntry> methodEntries )
{
for ( WrappedReportEntry methodEntry : methodEntries )
{
startTestElement( ppw, methodEntry );
if ( methodEntry.getReportEntryType() != SUCCESS )
{
getTestProblems( fw, ppw, methodEntry, trimStackTrace, outputStream,
methodEntry.getReportEntryType().getXmlTag(), false );
}
// 2. 创建 <system-out> 和 <system-err> 元素
createOutErrElements( fw, ppw, methodEntry, outputStream );
ppw.endElement();
}
}
// Create system-out and system-err elements
private static void createOutErrElements( OutputStreamWriter outputStreamWriter, XMLWriter ppw,
WrappedReportEntry report, OutputStream fw )
{
EncodingOutputStream eos = new EncodingOutputStream( fw );
// 3. 在 xml 报告文件中添加 <system-out> 元素
addOutputStreamElement( outputStreamWriter, eos, ppw, report.getStdout(), "system-out" );
addOutputStreamElement( outputStreamWriter, eos, ppw, report.getStdErr(), "system-err" );
}
private static void addOutputStreamElement( OutputStreamWriter outputStreamWriter,
EncodingOutputStream eos, XMLWriter xmlWriter,
Utf8RecodingDeferredFileOutputStream utf8RecodingDeferredFileOutputStream,
String name )
{
if ( utf8RecodingDeferredFileOutputStream != null && utf8RecodingDeferredFileOutputStream.getByteCount() > 0 )
{
xmlWriter.startElement( name );
try
{
xmlWriter.writeText( "" ); // Cheat sax to emit element
outputStreamWriter.flush();
utf8RecodingDeferredFileOutputStream.close();
eos.getUnderlying().write( ByteConstantsHolder.CDATA_START_BYTES ); // emit cdata
// 4. 输出在 utf8RecodingDeferredFileOutputStream 缓存的数据到 xml 文件中
utf8RecodingDeferredFileOutputStream.writeTo( eos );
eos.getUnderlying().write( ByteConstantsHolder.CDATA_END_BYTES );
eos.flush();
}
catch ( IOException e )
{
throw new ReporterException( "When writing xml report stdout/stderr", e );
}
xmlWriter.endElement();
}
}
}
Utf8RecodingDeferredFileOutputStream
类
package org.apache.maven.plugin.surefire.report;
/**
* A deferred file output stream decorator that recodes the bytes written into the stream from the VM default encoding
* to UTF-8.
*/
final class Utf8RecodingDeferredFileOutputStream
{
private final DeferredFileOutputStream deferredFileOutputStream;
Utf8RecodingDeferredFileOutputStream( String channel ) {
// 5. 设置 ThresholdingOutputStream 类的 threshold 值为 1_000_000。单位是字节!
deferredFileOutputStream = new DeferredFileOutputStream( 1_000_000, channel, "deferred", null );
}
public synchronized void writeTo( OutputStream out ) throws IOException {
deferredFileOutputStream.writeTo( out );
}
public synchronized void free() {
if ( null != deferredFileOutputStream && null != deferredFileOutputStream.getFile() ) {
if ( !deferredFileOutputStream.getFile().delete() ) {
deferredFileOutputStream.getFile().deleteOnExit();
}
}
}
}
DeferredFileOutputStream
类
package org.apache.maven.surefire.shared.io.output;
public class DeferredFileOutputStream extends ThresholdingOutputStream {
// 6. 达到 Utf8RecodingDeferredFileOutputStream 中设置的 1_000_000 字节后,创建临时文件存储 <system-out> 数据
protected void thresholdReached() throws IOException {
if (this.prefix != null) {
this.outputFile = File.createTempFile(this.prefix, this.suffix, this.directory);
}
FileUtils.forceMkdirParent(this.outputFile);
FileOutputStream fos = new FileOutputStream(this.outputFile);
try {
this.memoryOutputStream.writeTo(fos);
} catch (IOException var3) {
fos.close();
throw var3;
}
this.currentOutputStream = fos;
this.memoryOutputStream = null;
}
public boolean isInMemory() {
return !this.isThresholdExceeded();
}
}
package org.apache.maven.surefire.shared.io.output;
public abstract class ThresholdingOutputStream extends OutputStream {
private final int threshold;
private long written;
private boolean thresholdExceeded;
public ThresholdingOutputStream(int threshold) {
this.threshold = threshold;
}
public void write(int b) throws IOException {
this.checkThreshold(1);
this.getStream().write(b);
++this.written;
}
public void write(byte[] b) throws IOException {
this.checkThreshold(b.length);
this.getStream().write(b);
this.written += (long)b.length;
}
public void write(byte[] b, int off, int len) throws IOException {
this.checkThreshold(len);
this.getStream().write(b, off, len);
this.written += (long)len;
}
public boolean isThresholdExceeded() {
return this.written > (long)this.threshold;
}
// 7. 每次调用 write 方法时,都先调用此方法,
// 判断写入数据字节数是否超过 Utf8RecodingDeferredFileOutputStream 中设置的 1_000_000 字节,
// 超过就调用 DeferredFileOutputStream 的 thresholdReached() 用临时文件保存数据。
protected void checkThreshold(int count) throws IOException {
if (!this.thresholdExceeded && this.written + (long)count > (long)this.threshold) {
this.thresholdExceeded = true;
this.thresholdReached();
}
}
protected abstract void thresholdReached() throws IOException;
}
TestSetRunListener
类
package org.apache.maven.plugin.surefire.report;
/**
* Reports data for a single test set.
* <br>
*/
public class TestSetRunListener
implements RunListener, ConsoleOutputReceiver, ConsoleLogger {
@Override
public void testSetCompleted( TestSetReportEntry report )
{
final WrappedReportEntry wrap = wrapTestSet( report );
final List<String> testResults =
briefOrPlainFormat ? detailsForThis.getTestResults() : Collections.<String>emptyList();
fileReporter.testSetCompleted( wrap, detailsForThis, testResults );
// 8. 调用 StatelessXmlReporter 类的 testSetCompleted() 方法,生成 xml 文件的测试报告
simpleXMLReporter.testSetCompleted( wrap, detailsForThis );
statisticsReporter.testSetCompleted();
consoleReporter.testSetCompleted( wrap, detailsForThis, testResults );
consoleOutputReceiver.testSetCompleted( wrap );
consoleReporter.reset();
// 9. 调用 Utf8RecodingDeferredFileOutputStream 的 free 方法,如果已经创建了文件,则会把文件删除。
wrap.getStdout().free();
wrap.getStdErr().free();
addTestMethodStats();
detailsForThis.reset();
clearCapture();
}
}
代码执行流程
第一次执行 JUnit5
测试时调用:
- ① 执行
TestSetRunListener
类的testSetCompleted()
方法。 - ② 执行
StatelessXmlReporter
类的testSetCompleted()
方法。
- 执行
serializeTestClass()
方法- 执行
serializeTestClassWithoutRerun()
方法- 执行
createOutErrElements()
方法- 执行
addOutputStreamElement()
方法,往 xml 文件中写入<system-out>
元素。
- ③ 执行
Utf8RecodingDeferredFileOutputStream
类的writeTo()
方法。执行 2.4 的步骤的实际写入操作。
- 如果写入总数据量小于等于
1_000_000
字节,则写入到memoryOutputStream
(ByteArrayOutputStream
实例)中。- 如果写入总数据流大于
1_000_000
字节,则写入到FileOutputStream
中。
1_000_000
字节在Utf8RecodingDeferredFileOutputStream
构造函数中传入)
- ④ 执行
Utf8RecodingDeferredFileOutputStream
类的free()
方法(在TestSetRunListener
类的testSetCompleted()
方法中被调用)
如果
deferredFileOutputStream
在第 ③ 步已经写入数据到文件中,则把文件删除。
第二次执行 JUnit4
测试时调用:
- ① 执行
TestSetRunListener
类的testSetCompleted()
方法。 - ② 执行
StatelessXmlReporter
类的testSetCompleted()
方法。
- 调用
arrangeMethodStatistics()
,通过testClassName
参数合并历史WrappedReportEntry
集合。(即JUnit5
执行同一个testClassName
的测试方法报告)- 执行
serializeTestClass()
方法- 执行
serializeTestClassWithoutRerun()
方法- 执行
createOutErrElements()
方法- 执行
addOutputStreamElement()
方法,往 xml 文件中写入<system-out>
元素。
- ③ 执行
Utf8RecodingDeferredFileOutputStream
类的writeTo()
方法。执行 2.4 的步骤的实际写入操作。
写入
JUnit5
的WrappedReportEntry
数据时,发现文件已经被删除,抛出IOException
异常,中断后续写入流程。
自己尝试修复 maven-surefire-plugin 的这个问题
修改下 Utf8RecodingDeferredFileOutputStream
类的 free()
方法即可解决问题。
修改前:
public synchronized void free()
{
if ( deferredFileOutputStream.getFile() != null )
{
try
{
close();
if ( !deferredFileOutputStream.getFile().delete() )
{
deferredFileOutputStream.getFile().deleteOnExit();
}
}
catch ( IOException ioe )
{
deferredFileOutputStream.getFile().deleteOnExit();
}
}
}
修改后:
public synchronized void free()
{
if ( deferredFileOutputStream.getFile() != null )
{
try
{
close();
deferredFileOutputStream.getFile().deleteOnExit();
}
catch ( IOException ioe )
{
deferredFileOutputStream.getFile().deleteOnExit();
}
}
}
因为同一个 testClassName
表示的测试类中,可能同时包含 JUnit4
和 JUnit5
的测试代码,所以要避免在 JVM 存活时立即删除文件!!!
因此去掉 deferredFileOutputStream.getFile().delete()
逻辑,仅保留 getFile().deleteOnExit()
。
修改后执行 mvn clean install -DskipTests
安装依赖到本地 Maven 仓库,修改测试项目的依赖版本,测试正常!!!
参考
拓展
新的 commit 中已经修复这个问题!!!
https://github.com/apache/maven-surefire/commit/32bd56b4ea908147592ef92c71c4e7936e070993
/**
* A deferred file output stream decorator that recodes the bytes written into the stream from the VM default encoding
* to UTF-8.
*
* @author Andreas Gudian
*/
final class Utf8RecodingDeferredFileOutputStream {
public synchronized long getByteCount()
{
try
{
long length = 0;
if ( storage != null )
{
sync();
length = storage.length();
}
return length;
}
catch ( IOException e )
{
return 0; // 抛出异常后返回 byteCount = 0,在 StatelessXmlReporter 的 addOutputStreamElement() 方法 utf8RecodingDeferredFileOutputStream.getByteCount() > 0 逻辑被过滤掉
}
}
}
public class StatelessXmlReporter
implements StatelessReportEventListener<WrappedReportEntry, TestSetStats> {
private static void addOutputStreamElement( OutputStreamWriter outputStreamWriter,
EncodingOutputStream eos, XMLWriter xmlWriter,
Utf8RecodingDeferredFileOutputStream utf8RecodingDeferredFileOutputStream,
String name )
{
if ( utf8RecodingDeferredFileOutputStream != null && utf8RecodingDeferredFileOutputStream.getByteCount() > 0 )
{
xmlWriter.startElement( name );
try
{
xmlWriter.writeText( "" ); // Cheat sax to emit element
outputStreamWriter.flush();
eos.getUnderlying().write( ByteConstantsHolder.CDATA_START_BYTES ); // emit cdata
utf8RecodingDeferredFileOutputStream.writeTo( eos );
eos.getUnderlying().write( ByteConstantsHolder.CDATA_END_BYTES );
eos.flush();
}
catch ( IOException e )
{
throw new ReporterException( "When writing xml report stdout/stderr", e );
}
xmlWriter.endElement();
}
}
}