排查 maven-surefire-plugin XmlRepo

2020-09-24  本文已影响0人  蓝笔头

现象

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 测试时调用:

  1. 执行 serializeTestClass() 方法
  2. 执行 serializeTestClassWithoutRerun() 方法
  3. 执行 createOutErrElements() 方法
  4. 执行 addOutputStreamElement() 方法,往 xml 文件中写入 <system-out> 元素。
  1. 如果写入总数据量小于等于 1_000_000 字节,则写入到 memoryOutputStreamByteArrayOutputStream 实例)中。
  2. 如果写入总数据流大于 1_000_000 字节,则写入到 FileOutputStream 中。
    1_000_000 字节在 Utf8RecodingDeferredFileOutputStream 构造函数中传入)

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

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

  1. 调用 arrangeMethodStatistics(),通过 testClassName 参数合并历史 WrappedReportEntry 集合。(即 JUnit5 执行同一个 testClassName 的测试方法报告)
  2. 执行 serializeTestClass() 方法
  3. 执行 serializeTestClassWithoutRerun() 方法
  4. 执行 createOutErrElements() 方法
  5. 执行 addOutputStreamElement() 方法,往 xml 文件中写入 <system-out> 元素。

写入 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();
        }
    }
}
上一篇 下一篇

猜你喜欢

热点阅读