coveragepy icon indicating copy to clipboard operation
coveragepy copied to clipboard

Branch coverage of try/except

Open bensimner opened this issue 6 years ago • 4 comments

Is your feature request related to a problem? Please describe. Consider the following code:

def f(x):
  try:
    y = 1/x
  except ZeroDivisionError:
    y = 0
  return y


def test_exception_branch_coverage():
  f(0)


test_exception_branch_coverage()

Here f(0) clearly misses a path: the case where 1/x did not raise an exception isn't covered. However Coverage.py (version 4.5.4) reports 100% statement and branch coverage.

Describe the solution you'd like A try/except is like a branch. Consider the following:

def g(x):
  try:
    y = 1/x
  except ZeroDivisionError:
    y = 0
  else:
    pass  # this line wasn't executed, but coverage doesn't report it as missed
  return y

g(0)

The else represents a branch target that was never taken. Coverage could know about the branch intrinsic in the try/except/else itself: whether an exception was raised or not.

Describe alternatives you've considered The else block does not always represent a missed branch:

def h(x):
  try:
    return 1/x
  except ZeroDivisionError:
    return 0
  else:
    pass  # never executed, but this does not imply a missed branch ...

h(3)
h(0)

Instead of just looking at whether the whole try threw an exception, one could consider each line individually:

def i(x):
  try:
    a = 1/(x+1)
    b = 1/(x+2)
    return a + b
  except ZeroDivisionError:
    return 0

i(0)
i(-1)

At first, it appears that all branches were covered by the above. i(0) executes the entire try block and throws no exception. i(-1) throws an exception and executes the except block. However, both a = ... and b = ... could raise exceptions and only one of them was tested.

Keeping track of each line like this is tracking too much: in real code, not every line can raise interesting exceptions and those that don't shouldn't be considered missed branches:

def j(x):
  try: 
    y = x[0]
    return y + 1  # this line never raised an IndexError, but that's ok
  except IndexError:
    return None

j([])
j([1])

The return y + 1 line cannot raise an IndexError and therefore isn't a missed branch.

Additional context It's not clear whether coverage tools should consider these "branches", or whether they should even report this kind of thing. One would expect that most of the time, statement coverage will catch a try/except that misses one of these cases, and it's only in the situation where there's a one-line try where later code is not conditional on the exception happening/not happening.

Maybe if added behaviours like this should be hidden behind flags for those that want more pedantic coverage metrics.

With thanks to @edk0 who originally pointed this out and for comments.

bensimner avatar Dec 04 '19 14:12 bensimner

I've been using the following hack to experiment with this feature without having to implement it:

import sys

try_stack = []

with open(sys.argv[1], 'r') as f:
    lines = [*f, '']

sys.stdout = open(sys.argv[1], 'w')

last_block_stmt = None
jump = False

for i, line in enumerate(lines):
    line = line.rstrip('\n')
    bare_line = line.lstrip(' ')
    indent_level = len(line) - len(bare_line)
    append = []
    if ':' in bare_line:
        block_stmt = bare_line.partition(':')[0].split(' ')[0]
    else:
        block_stmt = None

    if block_stmt == 'try':
        jump = False
    elif block_stmt == 'except':
        if not try_stack or try_stack[-1] < indent_level:
            try_stack.append(indent_level)

    if bare_line and block_stmt != 'except':
        # we might need to close things
        while try_stack and indent_level <= try_stack[-1]:
            try_level = try_stack.pop(-1)
            if jump:
                continue
            if block_stmt == 'else' and indent_level == try_level:
                append.append(f"{' ' * indent_level}    assert True")
                break
            print(f"{' ' * try_level}else:")
            print(f"{' ' * try_level}    assert True")
        if block_stmt is None and last_block_stmt == 'try':
            jump = bare_line.split(' ')[0] in ('return', 'continue', 'break')

    if i < len(lines) - 1:
        print(line)
    for a in append:
        print(a)
    last_block_stmt = block_stmt or last_block_stmt

While obviously far from perfect, so far it's at least told me that the feature as requested doesn't have false positives on my code.

edk0 avatar Dec 04 '19 17:12 edk0

Thanks for the thought-provoking issue, and @edk0 for the impressive hack to try it out :)

I think just as branch coverage can shine light on the "missing implicit else" in if-statements, we can try doing the same for try-blocks. I have to get 5.0 out the door before I can experiment with it, though.

nedbat avatar Dec 05 '19 10:12 nedbat

I have a case where the lines are reported as "missing" but are indeed covered, with branching on. Line 11 to 14 is from the if statement to print('3'). As you can see, all 1, 2, and 3 were printed.

tests/module.py

class Creds:

  def __init__(self, store):
    self.store = store

  def func(self):
    try:
      1/0
    except:
      print('1')
      if self.store:
        print('2')
        self.store.unlock()
      print('3')
      raise

tests/test_coverage.py

from . import module

import mock
import unittest

class TestCoverage(unittest.TestCase):

  def test_coverage(self):
    store = mock.Mock()
    creds = module.Creds(store)
    with self.assertRaises(Exception):
      creds.func()

py.test tests/test_coverage.py --cov=tests.module -s --cov-branch

tests/test_coverage.py 1
2
3
.

----------- coverage: platform linux, python 3.8.5-final-0 -----------
Name              Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------
tests/module.py      13      0      2      1    93%   11->14

py.test tests/test_coverage.py --cov=tests.module -s --cov-branch

tests/test_coverage.py 1
2
3
.

----------- coverage: platform linux, python 3.8.5-final-0 -----------
Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
tests/module.py      13      0   100%

postmasters avatar Sep 22 '20 21:09 postmasters

py.test tests/test_coverage.py --cov=tests.module -s --cov-branch

tests/test_coverage.py 1
2
3
.

----------- coverage: platform linux, python 3.8.5-final-0 -----------
Name              Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------
tests/module.py      13      0      2      1    93%   11->14

py.test tests/test_coverage.py --cov=tests.module -s --cov-branch

tests/test_coverage.py 1
2
3
.

----------- coverage: platform linux, python 3.8.5-final-0 -----------
Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
tests/module.py      13      0   100%

@postmasters Is that an intermittent problem, or do you know what caused the two different outputs?

HalfWhitt avatar Jun 26 '25 19:06 HalfWhitt