排查 maven-surefire-plugin XmlReporter 异常问题

现象

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>&lt;junit&gt;</code></a> and
 * <a href="http://ant.apache.org/manual/Tasks/junitreport.html"><code>&lt;junitreport&gt;</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() 方法。
  1. 执行 serializeTestClass() 方法
  2. 执行 serializeTestClassWithoutRerun() 方法
  3. 执行 createOutErrElements() 方法
  4. 执行 addOutputStreamElement() 方法,往 xml 文件中写入 <system-out> 元素。
  • ③ 执行 Utf8RecodingDeferredFileOutputStream 类的 writeTo() 方法。执行 2.4 的步骤的实际写入操作。
  1. 如果写入总数据量小于等于 1_000_000 字节,则写入到 memoryOutputStreamByteArrayOutputStream 实例)中。
  2. 如果写入总数据流大于 1_000_000 字节,则写入到 FileOutputStream 中。
    1_000_000 字节在 Utf8RecodingDeferredFileOutputStream 构造函数中传入)
  • ④ 执行 Utf8RecodingDeferredFileOutputStream 类的 free() 方法(在 TestSetRunListener 类的 testSetCompleted() 方法中被调用)

如果 deferredFileOutputStream 在第 ③ 步已经写入数据到文件中,则把文件删除。

第二次执行 JUnit4 测试时调用:

  • ① 执行 TestSetRunListener 类的 testSetCompleted() 方法。
  • ② 执行 StatelessXmlReporter 类的 testSetCompleted() 方法。
  1. 调用 arrangeMethodStatistics(),通过 testClassName 参数合并历史 WrappedReportEntry 集合。(即 JUnit5 执行同一个 testClassName 的测试方法报告)
  2. 执行 serializeTestClass() 方法
  3. 执行 serializeTestClassWithoutRerun() 方法
  4. 执行 createOutErrElements() 方法
  5. 执行 addOutputStreamElement() 方法,往 xml 文件中写入 <system-out> 元素。
  • ③ 执行 Utf8RecodingDeferredFileOutputStream 类的 writeTo() 方法。执行 2.4 的步骤的实际写入操作。

写入 JUnit5WrappedReportEntry 数据时,发现文件已经被删除,抛出 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 表示的测试类中,可能同时包含 JUnit4JUnit5 的测试代码,所以要避免在 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();
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。