7.6 KiB
Check if the last Git commit has test coverage
- date
2018-07-26T12:49:52Z
- category
blog
- tags
python,development,testing
- url
2018/07/26/check-if-last-git-commit-has-test-coverage/
- save_as
2018/07/26/check-if-last-git-commit-has-test-coverage/index.html
- status
published
- author
Gergely Polonkai
I use Python at work and for private projects. I also aim to write tests for my code, especially recently. And as I usually don’t start from 100% code coverage (TDD is not my game), I at least want to know if the code I just wrote have full coverage.
The trick is to collect all the lines that changed, and all the lines that has no coverage. Then compare the two, and you have the uncovered lines that changed!
Getting the list of changed lines
Recently, I bumped into this article. It is a great awk script that lists the lines that changed in the latest commit. I have really no problem with awk, but I’m pretty sure it can be done in Python, as that is my main language nowadays.
def get_changed_lines():
"""Get the line numbers that changed in the last commit
"""
= subprocess.check_output('git show', shell=True).decode('utf-8')
git_output
= None
current_file = {}
lines = 0
left = 0
right
for line in git_output.split('\n'):
= re.match(r'^@@ -([0-9]+),[0-9]+ [+]([0-9]+),[0-9]+ @@', line)
match
if match:
= int(match.groups()[0])
left = int(match.groups()[1])
right
continue
if re.match(r'^\+\+\+', line):
= line[6:]
current_file
continue
if re.match(r'^-', line):
+= 1
left
continue
if re.match(r'^[+]', line):
# Save this line number as changed
lines.setdefault(current_file, [])
lines[current_file].append(right)+= 1
right
continue
+= 1
left += 1
right
return lines
OK, not as short as the awk script, but works just fine.
Getting the uncovered lines
Coverage.py can list the uncovered lines with coverage report --show-missing
. For Calendar.social, this looks something like this:
Name Stmts Miss Cover Missing
----------------------------------------------------------------------
calsocial/__init__.py 173 62 64% 44, 138-148, 200, 239-253, 261-280, 288-295, 308-309, 324-346, 354-363
calsocial/__main__.py 3 3 0% 4-9
calsocial/account.py 108 51 53% 85-97, 105-112, 125, 130-137, 148-160, 169-175, 184-200, 209-212, 221-234
calsocial/app_state.py 10 0 100%
calsocial/cache.py 73 11 85% 65-70, 98, 113, 124, 137, 156-159
calsocial/calendar_system/__init__.py 10 3 70% 32, 41, 48
calsocial/calendar_system/gregorian.py 77 0 100%
calsocial/config_development.py 11 11 0% 4-17
calsocial/config_testing.py 12 0 100%
calsocial/forms.py 198 83 58% 49, 59, 90, 136-146, 153, 161-169, 188-195, 198-206, 209-212, 228-232, 238-244, 252-253, 263-267, 273-277, 317-336, 339-342, 352-354, 362-374, 401-413
calsocial/models.py 372 92 75% 49-51, 103-106, 177, 180-188, 191-200, 203, 242-248, 257-268, 289, 307, 349, 352-359, 378, 392, 404-409, 416, 444, 447, 492-496, 503, 510, 516, 522, 525, 528, 535-537, 545-551, 572, 606-617, 620, 652, 655, 660, 700, 746-748, 762-767, 774-783, 899, 929, 932
calsocial/security.py 15 3 80% 36, 56-58
calsocial/utils.py 42 5 88% 45-48, 52-53
----------------------------------------------------------------------
TOTAL 1104 324 71%
All we have to do is converting these ranges into a list of numbers, and compare it with the result of the previous function:
def get_uncovered_lines(changed_lines):
"""Get the full list of lines that has not been covered by tests
"""
= []
column_widths = {}
uncovered_lines
for line in sys.stdin:
= line.strip()
line
if line.startswith('---'):
continue
if line.startswith('Name '):
= re.match(r'^(Name +)(Stmts +)(Miss +)(Cover +)Missing$', line)
match assert match
= [len(col) for col in match.groups()]
column_widths
continue
= [
name sum(column_widths[0:idx]):sum(column_widths[0:idx]) + width].strip()
line[for idx, width in enumerate(column_widths)][0]
= line[sum(column_widths):].strip()
missing
for value in missing.split(', '):
if not value:
continue
try:
= int(value)
number except ValueError:
= value.split('-')
first, last = range(int(first), int(last) + 1)
lines else:
= range(number, number + 1)
lines
for lineno in lines:
if name in changed_lines and lineno not in changed_lines[name]:
uncovered_lines.setdefault(name, [])
uncovered_lines[name].append(lineno)
return uncovered_lines
At the end we have a dictionary that has filenames as keys, and a list of changed but uncovered lines.
Converting back to ranges
To make the final result more readable, let’s convert them back to a nice from_line-to_line
range list first:
def line_numbers_to_ranges():
"""List the lines that has not been covered
"""
= get_changed_lines()
changed_lines = get_uncovered_lines(changed_lines)
uncovered_lines
= []
line_list
for filename, lines in uncovered_lines.items():
= sorted(lines)
lines = None
last_value
= []
ranges
for lineno in lines:
if last_value and last_value + 1 == lineno:
-1].append(lineno)
ranges[else:
ranges.append([lineno])
= lineno
last_value
= []
range_list
for range_ in ranges:
= range_.pop(0)
first
if range_:
f'{first}-{range_[-1]}')
range_list.append(else:
str(first))
range_list.append(
', '.join(range_list)))
line_list.append((filename,
return line_list
Printing the result
Now all that is left is to print the result on the screen in a format digestable by a human being:
def tabular_print(uncovered_lines):
"""Print the list of uncovered lines on the screen in a tabbed format
"""
= max(len(data[0]) for data in uncovered_lines)
max_filename_len
for filename, lines in uncovered_lines:
print(filename.ljust(max_filename_len + 2) + lines)
And we are done.
Conclusion
This task never seemed hard to accomplish, but somehow I never put enough energy into it to make it happen. Kudos to Adam Young doing some legwork for me!