尝试资源的8个分支 - 是否可能进行Jacoco覆盖?

63

我有一些使用try with resources的代码,在jacoco中只覆盖了一半。所有源代码行都是绿色的,但我得到一个小黄色符号告诉我只有4个分支被覆盖了。

enter image description here

我很难弄清楚所有的分支在哪里,以及如何编写代码来覆盖它们。可能有三个地方会抛出PipelineException异常,它们是createStageList()processItem()和隐含的close()

  1. 不抛出任何异常
  2. createStageList()抛出异常
  3. processItem()抛出异常
  4. close()抛出异常
  5. processItem()close()同时抛出异常

我想不出还有其他情况,但我仍然只有4个覆盖了。

有人能解释一下为什么只有8个分支中的4个分支被覆盖了吗?是否有办法覆盖所有8个分支?我不擅长解密/阅读/解释字节码,但也许你擅长… :) 我已经看过https://github.com/jacoco/jacoco/issues/82,但它和它所引用的问题并没有什么帮助(除了指出这是由于编译器生成的块)。

嗯,在我写完这篇文章之后,我有一个想法,可以弄清楚哪些情况可能没有被我提到而不被测试… 如果我得到正确答案,我会发布一个回答。无论如何,我相信这个问题及其答案将对某些人有所帮助。

编辑:不,我没找到。抛出未被catch块处理的RuntimeExceptions并没有覆盖更多的分支


请问您能否发布一下类文件? - Antimony
不,我不能发布我的客户代码。 - Gus
我使用Eclemma(Eclipse中的Emma)所能达到的最佳覆盖率是“8个分支中有3个未被覆盖”,但是在Jenkins中使用Cobertura仍然只显示4/8。希望这些覆盖工具很快能正确处理try-with-resources。 - Torsten Römer
1
请注意,JaCoCo无法完全覆盖许多结构,例如这些结构,旨在帮助您减少代码中可能的路径数量(从而减少错误)。在这些结构上达到100%的覆盖率通常是不可能的,而且它对测试质量的提高并没有太大的帮助(但确实需要很多努力)。 - Thirler
我的方法是简单地重写代码,不使用try-with-resources子句。考虑到它只是语法糖,并且导致了这个测试的头痛,它并没有真正增加太多价值。 - Patrick Michaelsen
6个回答

58

虽然我不能告诉你Jacoco的确切问题是什么,但我可以向你展示Try With Resources如何编译。基本上,有很多编译器生成的开关来处理在各个点抛出的异常。

如果我们采用以下代码并进行编译

public static void main(String[] args){
    String a = "before";

    try (CharArrayWriter br = new CharArrayWriter()) {
        br.writeTo(null);
    } catch (IOException e){
        System.out.println(e.getMessage());
    }

    String a2 = "after";
}

然后进行拆卸,我们得到:
.method static public main : ([Ljava/lang/String;)V
    .limit stack 2
    .limit locals 7
    .catch java/lang/Throwable from L26 to L30 using L33
    .catch java/lang/Throwable from L13 to L18 using L51
    .catch [0] from L13 to L18 using L59
    .catch java/lang/Throwable from L69 to L73 using L76
    .catch [0] from L51 to L61 using L59
    .catch java/io/IOException from L3 to L94 using L97
    ldc 'before'
    astore_1
L3:
    new java/io/CharArrayWriter
    dup
    invokespecial java/io/CharArrayWriter <init> ()V
    astore_2
    aconst_null
    astore_3
L13:
    aload_2
    aconst_null
    invokevirtual java/io/CharArrayWriter writeTo (Ljava/io/Writer;)V
L18:
    aload_2
    ifnull L94
    aload_3
    ifnull L44
L26:
    aload_2
    invokevirtual java/io/CharArrayWriter close ()V
L30:
    goto L94
L33:
.stack full
    locals Object [Ljava/lang/String; Object java/lang/String Object java/io/CharArrayWriter Object java/lang/Throwable
    stack Object java/lang/Throwable
.end stack
    astore 4
    aload_3
    aload 4
    invokevirtual java/lang/Throwable addSuppressed (Ljava/lang/Throwable;)V
    goto L94
L44:
.stack same
    aload_2
    invokevirtual java/io/CharArrayWriter close ()V
    goto L94
L51:
.stack same_locals_1_stack_item
    stack Object java/lang/Throwable
.end stack
    astore 4
    aload 4
    astore_3
    aload 4
    athrow
L59:
.stack same_locals_1_stack_item
    stack Object java/lang/Throwable
.end stack
    astore 5
L61:
    aload_2
    ifnull L91
    aload_3
    ifnull L87
L69:
    aload_2
    invokevirtual java/io/CharArrayWriter close ()V
L73:
    goto L91
L76:
.stack full
    locals Object [Ljava/lang/String; Object java/lang/String Object java/io/CharArrayWriter Object java/lang/Throwable Top Object java/lang/Throwable
    stack Object java/lang/Throwable
.end stack
    astore 6
    aload_3
    aload 6
    invokevirtual java/lang/Throwable addSuppressed (Ljava/lang/Throwable;)V
    goto L91
L87:
.stack same
    aload_2
    invokevirtual java/io/CharArrayWriter close ()V
L91:
.stack same
    aload 5
    athrow
L94:
.stack full
    locals Object [Ljava/lang/String; Object java/lang/String
    stack 
.end stack
    goto L108
L97:
.stack same_locals_1_stack_item
    stack Object java/io/IOException
.end stack
    astore_2
    getstatic java/lang/System out Ljava/io/PrintStream;
    aload_2
    invokevirtual java/io/IOException getMessage ()Ljava/lang/String;
    invokevirtual java/io/PrintStream println (Ljava/lang/String;)V
L108:
.stack same
    ldc 'after'
    astore_2
    return
.end method

对于那些不懂字节码的人来说,这大致相当于以下伪 Java 代码。我不得不使用goto,因为字节码实际上并不对应Java控制流。
正如您所看到的,有很多情况要处理被抑制异常的各种可能性。无法涵盖所有这些情况是不合理的。事实上,在第一个try块上的 "goto L59" 分支是不可能到达的,因为第一个catch Throwable将捕获所有异常。
try{
    CharArrayWriter br = new CharArrayWriter();
    Throwable x = null;

    try{
        br.writeTo(null);
    } catch (Throwable t) {goto L51;}
    catch (Throwable t) {goto L59;}

    if (br != null) {
        if (x != null) {
            try{
                br.close();
            } catch (Throwable t) {
                x.addSuppressed(t);
            }
        } else {br.close();}
    }
    break;

    try{
        L51:
        x = t;
        throw t;

        L59:
        Throwable t2 = t;
    } catch (Throwable t) {goto L59;}

    if (br != null) {
        if (x != null) {
            try{
                br.close();
            } catch (Throwable t){
                x.addSuppressed(t);
            }
        } else {br.close();}
    }
    throw t2;
} catch (IOException e) {
    System.out.println(e)
}

是的,我想知道一些生成的代码是否实际上无法访问,谢谢。如果Oracle能够改进这一点,或者覆盖工具能够解决这个问题,那就太好了。 - Gus
非常好的解释,非常有趣!现在我可以停止猜测我错过了什么。谢谢! - Torsten Römer
6
这里不需要查看字节码(虽然这是一个有趣的练习)。JLS定义了try-with-resources在Java源代码中等同于什么:14.20.3.1 基本try-with-resources,这使得更容易看出分支是什么。 - Joshua Taylor
5
@JoshuaTaylor,《Java 语言规范》只定义了语义等价。您仍需检查字节码以确定编译器是否实际使用此策略。此外,您需要添加的知识是,现在(从 Java 7 开始),finally 块会为普通情况和异常情况复制,这使得在按照指定模式字面意义地使用时测试变得多余。正如try with resources introduce unreachable bytecode中所讨论的那样,这是 javac 特有的问题,例如 Eclipse 的编译器不会产生无法访问的字节码。 - Holger

9

我可以涵盖所有8个分支,所以我的答案是肯定的。看一下下面的代码,这只是一个快速尝试,但它有效(或者看我的github:https://github.com/bachoreczm/basicjava和'trywithresources'包,你可以找到如何使用try-with-resources工作的方法,查看“ExplanationOfTryWithResources”类):

enter image description here

import java.io.ByteArrayInputStream;
import java.io.IOException;

import org.junit.Test;

public class TestAutoClosable {

  private boolean isIsNull = false;
  private boolean logicThrowsEx = false;
  private boolean closeThrowsEx = false;
  private boolean getIsThrowsEx = false;

  private void autoClose() throws Throwable {
    try (AutoCloseable is = getIs()) {
        doSomething();
    } catch (Throwable t) {
        System.err.println(t);
    }
  }

  @Test
  public void test() throws Throwable {
    try {
      getIsThrowsEx = true;
      autoClose();
    } catch (Throwable ex) {
      getIsThrowsEx = false;
    }
  }

  @Test
  public void everythingOk() throws Throwable {
    autoClose();
  }

  @Test
  public void logicThrowsException() {
    try {
      logicThrowsEx = true;
      everythingOk();
    } catch (Throwable ex) {
      logicThrowsEx = false;
    }
  }

  @Test
  public void isIsNull() throws Throwable {
    isIsNull = true;
    everythingOk();
    isIsNull = false;
  }

  @Test
  public void closeThrow() {
    try {
      closeThrowsEx = true;
      logicThrowsEx = true;
      everythingOk();
      closeThrowsEx = false;
    } catch (Throwable ex) {
    }
  }

  @Test
  public void test2() throws Throwable {
    try {
      isIsNull = true;
      logicThrowsEx = true;
      everythingOk();
    } catch (Throwable ex) {
      isIsNull = false;
      logicThrowsEx = false;
    }
  }

  private void doSomething() throws IOException {
    if (logicThrowsEx) {
      throw new IOException();
    }
  }

  private AutoCloseable getIs() throws IOException {
    if (getIsThrowsEx) {
      throw new IOException();
    }
    if (closeThrowsEx) {
      return new ByteArrayInputStream("".getBytes()) {

        @Override
        public void close() throws IOException {
          throw new IOException();
        }
      };
    }
    if (!isIsNull) {
      return new ByteArrayInputStream("".getBytes());
    }
    return null;
  }
}

你的 autoClose 方法没有 catch 块。这不是同一种情况(通常不会在测试类本身上测量覆盖率?)另外,如果你想要宣称成功,Jacoco 输出截图显示其被覆盖也是很好的证明。 - Gus
我附上了一张截图,是的,在测试类的覆盖范围内(在try-with-resources-end行中,您将看到8/8)。 - user5963797
我还附上了一个链接,您可以在其中找到确切的描述,了解try-with-resources的工作原理。 - user5963797
1
那为什么不加一个异常捕获,消除所有的疑虑呢? - Gus
我添加了它(为了消除所有的疑虑,但我不理解这个主题与catch块的存在之间的关联)。 - user5963797
显示剩余5条评论

6
四年过去了,但仍然……
  1. 使用非空的AutoCloseable的正常情况
  2. 使用空的AutoCloseable的正常情况
  3. 写入时抛出异常
  4. 关闭时抛出异常
  5. 写入和关闭时都抛出异常
  6. 在资源规范中(例如构造函数调用)抛出异常
  7. try块中抛出异常,但AutoCloseable为空
以上列出了所有7个条件 - 8个分支的原因是由于重复条件。
所有分支均可到达,try-with-resources相对简单的编译器语法糖(至少与switch-on-string相比如此)- 如果无法到达,则根据定义是编译器错误。
实际上只需要6个单元测试(在下面的示例代码中,throwsOnClose@Ingore,分支覆盖率为8/8)。
请注意,Throwable.addSuppressed(Throwable) 不能压制自己,因此生成的字节码包含一个额外的保护(IF_ACMPEQ - 引用相等),以防止这种情况发生。幸运的是,这个分支被 throw-on-write、throw-on-close 和 throw-on-write-and-close 情况覆盖,因为字节码变量插槽被外部的三个异常处理程序区域重复使用。
这不是Jacoco的问题 - 实际上,链接的 issue #82 中的示例代码是不正确的,因为没有重复的空检查,并且没有包围关闭操作的嵌套 catch 块。
JUnit 测试演示了 8 个分支中的 8 个已被覆盖。
import static org.hamcrest.Matchers.arrayContaining;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.sameInstance;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;

import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;

import org.junit.Ignore;
import org.junit.Test;

public class FullBranchCoverageOnTryWithResourcesTest {

    private static class DummyOutputStream extends OutputStream {

        private final IOException thrownOnWrite;
        private final IOException thrownOnClose;


        public DummyOutputStream(IOException thrownOnWrite, IOException thrownOnClose)
        {
            this.thrownOnWrite = thrownOnWrite;
            this.thrownOnClose = thrownOnClose;
        }


        @Override
        public void write(int b) throws IOException
        {
            if(thrownOnWrite != null) {
                throw thrownOnWrite;
            }
        }


        @Override
        public void close() throws IOException
        {
            if(thrownOnClose != null) {
                throw thrownOnClose;
            }
        }
    }

    private static class Subject {

        private OutputStream closeable;
        private IOException exception;


        public Subject(OutputStream closeable)
        {
            this.closeable = closeable;
        }


        public Subject(IOException exception)
        {
            this.exception = exception;
        }


        public void scrutinize(String text)
        {
            try(OutputStream closeable = create()) {
                process(closeable);
            } catch(IOException e) {
                throw new UncheckedIOException(e);
            }
        }


        protected void process(OutputStream closeable) throws IOException
        {
            if(closeable != null) {
                closeable.write(1);
            }
        }


        protected OutputStream create() throws IOException
        {
            if(exception != null) {
                throw exception;
            }
            return closeable;
        }
    }

    private final IOException onWrite = new IOException("Two writes don't make a left");
    private final IOException onClose = new IOException("Sorry Dave, we're open 24/7");


    /**
     * Covers one branch
     */
    @Test
    public void happyPath()
    {
        Subject subject = new Subject(new DummyOutputStream(null, null));

        subject.scrutinize("text");
    }


    /**
     * Covers one branch
     */
    @Test
    public void happyPathWithNullCloseable()
    {
        Subject subject = new Subject((OutputStream) null);

        subject.scrutinize("text");
    }


    /**
     * Covers one branch
     */
    @Test
    public void throwsOnCreateResource()
    {
        IOException chuck = new IOException("oom?");
        Subject subject = new Subject(chuck);
        try {
            subject.scrutinize("text");
            fail();
        } catch(UncheckedIOException e) {
            assertThat(e.getCause(), is(sameInstance(chuck)));
        }
    }


    /**
     * Covers three branches
     */
    @Test
    public void throwsOnWrite()
    {
        Subject subject = new Subject(new DummyOutputStream(onWrite, null));
        try {
            subject.scrutinize("text");
            fail();
        } catch(UncheckedIOException e) {
            assertThat(e.getCause(), is(sameInstance(onWrite)));
        }
    }


    /**
     * Covers one branch - Not needed for coverage if you have the other tests
     */
    @Ignore
    @Test
    public void throwsOnClose()
    {
        Subject subject = new Subject(new DummyOutputStream(null, onClose));
        try {
            subject.scrutinize("text");
            fail();
        } catch(UncheckedIOException e) {
            assertThat(e.getCause(), is(sameInstance(onClose)));
        }
    }


    /**
     * Covers two branches
     */
    @SuppressWarnings("unchecked")
    @Test
    public void throwsOnWriteAndClose()
    {
        Subject subject = new Subject(new DummyOutputStream(onWrite, onClose));
        try {
            subject.scrutinize("text");
            fail();
        } catch(UncheckedIOException e) {
            assertThat(e.getCause(), is(sameInstance(onWrite)));
            assertThat(e.getCause().getSuppressed(), is(arrayContaining(sameInstance(onClose))));
        }
    }


    /**
     * Covers three branches
     */
    @Test
    public void throwsInTryBlockButCloseableIsNull() throws Exception
    {
        IOException chucked = new IOException("ta-da");
        Subject subject = new Subject((OutputStream) null) {
            @Override
            protected void process(OutputStream closeable) throws IOException
            {
                throw chucked;
            }
        };

        try {
            subject.scrutinize("text");
            fail();
        } catch(UncheckedIOException e) {
            assertThat(e.getCause(), is(sameInstance(chucked)));
        }

    }
}

Eclipse Coverage

注意事项

虽然不在OP的示例代码中,但我认为有一种情况无法测试。

如果将资源引用作为参数传递,则在Java 7/8中必须有一个本地变量来分配:

    void someMethod(AutoCloseable arg)
    {
        try(AutoCloseable pfft = arg) {
            //...
        }
    }

在这种情况下,生成的代码仍然将保护资源引用。语法糖是在Java 9中更新,其中不再需要局部变量:try(arg){ /*...*/ }

补充 - 建议使用库完全避免分支

诚然,其中一些分支可以被认为是不现实的 - 即尝试块使用AutoCloseable而没有进行空值检查或者资源引用(with)不能为null的情况。
通常,你的应用程序并不关心它失败的位置 - 是打开文件、写入文件还是关闭文件 - 失败的粒度是无关紧要的(除非应用程序专门处理文件,例如文件浏览器或文字处理器)。
此外,在OP的代码中,为了测试空闭合路径 - 你必须将try块重构为受保护的方法,子类化并提供NOOP实现 - 所有这些只是为了覆盖永远不会在野外执行的分支。
我写了一个小型的Java 8库io.earcam.unexceptional(在Maven Central中),用于处理大部分检查异常样板。与这个问题相关的是:它提供了一些零分支、单行代码,用于AutoCloseable,将受检异常转换为未受检异常。 例如:自由端口查找器
int port = Closing.closeAfterApplying(ServerSocket::new, 0, ServerSocket::getLocalPort);

2
问题在于您查看了Eclipse生成的代码,以解决javac生成的代码引发的问题。说“如果无法到达,则根据定义是编译器错误”有点过分,因为规范并没有保证字节码不包含无法到达的代码。在正常情况下,您甚至都不会注意到这一点。而且这不是javac生成无法到达的代码的唯一地方,例如我曾经在实际应用中看到过过时的access$…方法。值得庆幸的是,这两个问题在JDK 11中已经消失了。另请参见JDK-8194978 - Holger

6
没有实际问题,只是想提供更多的研究内容。简而言之,似乎你可以实现try-finally的100%覆盖率,但无法做到try-with-resource的100%覆盖率。
可以理解的是,传统的try-finally和Java7中的try-with-resources之间存在差异。以下是两个等效示例,展示了使用不同方法展现的相同结果。
传统示例(try-finally方法):
final Statement stmt = conn.createStatement();
try {
    foo();
    if (stmt != null) {
        stmt.execute("SELECT 1");
    }
} finally {
    if (stmt != null)
        stmt.close();
}

Java7示例(使用try-with-resource方法):

try (final Statement stmt = conn.createStatement()) {
    foo();
    if (stmt != null) {
        stmt.execute("SELECT 1");
    }
}

分析:老派样例:
使用Jacoco 0.7.4.201502262128和JDK 1.8.0_45,我能够通过以下4个测试,在旧式样例中获得100%的行,指令和分支覆盖率:
  • 基本润滑路径(语句不为空,并且execute()正常运行)
  • execute()抛出异常
  • foo()抛出异常并且语句返回为空
  • 语句返回为空
Jacoco在“try”内(对null检查)指示2个分支,以及finally内的4个分支(对null检查)。所有内容都被完全覆盖。
分析:java-7样例:
如果针对Java7样例运行相同的4个测试,则jacoco表示已覆盖6/8个分支(在try内部)以及try内的2/2个null-check。我尝试了许多其他测试以增加覆盖范围,但是我找不到比6/8更好的方法。正如其他人所指出的,针对java-7示例的反编译代码(我也看过)表明java编译器正在为try-with-resource生成无法访问的片段。Jacoco报告(准确地)存在这样的片段。
更新:使用Java7编码风格,您可能可以获得100%的覆盖率,如果使用Java7 JRE(请参见下面的Matyas响应)。但是,如果使用Java8 JRE和Java7编码风格,则我认为您将达到6/8覆盖范围。相同的代码,只是不同的JRE。似乎字节码在两个JRE之间的创建方式有所不同,其中Java8会创建无法访问的路径。

这两个代码块产生的字节码完全不同 - try-with-resources 有3个异常处理区域,一个是在 conn.createStatement() 之前开始的,另一个是在主体周围,还有一个就在调用 if(stmt != null){ stmt.close(); } 之前。此外,还有一个调用 Throwable.addSuppressed() 和一个 if 来防止抑制相同的异常。 - earcam

2
Jacoco最近解决了这个问题,发布了0.8.0版本(2018/01/02)。
在生成报告时,会过滤掉各种编译器生成的文件,否则需要使用不必要的、有时是不可能的技巧来避免部分或者缺失的覆盖率:
- 尝试使用资源语句的一部分字节码(GitHub #500)。

http://www.jacoco.org/jacoco/trunk/doc/changes.html


1
我遇到了类似这样的问题:

try {
...
} finally {
 if (a && b) {
  ...
 }
}

它抱怨有8个分支中的2个未被覆盖。最终做了这件事:

try {
...
} finally {
 ab(a,b);
}

void ab(a, b) {
 if (a && b) {
...
 }
}

没有其他更改,我现在达到了100%...


有趣,虽然已经过了很长时间。事情可能已经发生了变化,你正在使用什么工具和版本? - Gus
这不是一个像问题中所发布的try-with-resources,而是包含条件的try-finally - Parker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接