Skip to content

Instantly share code, notes, and snippets.

@jontwo
Created July 1, 2016 11:01
Show Gist options
  • Save jontwo/27ecb6da05eb05ecd050ff4aebf2076b to your computer and use it in GitHub Desktop.
Save jontwo/27ecb6da05eb05ecd050ff4aebf2076b to your computer and use it in GitHub Desktop.
C# test coverage
# -*- coding: utf-8 -*-
"""
Name: get_test_coverage.py
Purpose: Analyze C# project to check each class has at least one unit test
Author: jpm
Created: 18/09/2015
Copyright: (c) jpm 2015
Licence: GPL v3. See http://www.gnu.org/licenses/
"""
import json
import os
import re
import sys
pattern = re.compile(
r"\s*(public|private|internal|protected)?\s*(static|virtual|abstract)?"
"\s*([a-zA-Z‌​\<\>_1-9]+)\s(?P<method>[a-zA-Z\<\>_1-9]+\([\w,=\s\<\>]*\))"
)
ui_methods = [
'AfterCheck', 'AfterCollapse', 'AfterExpand', 'AfterSelect', 'Click',
'CheckChanged', 'CheckedChanged', 'Closed', 'DropDown', 'FormClosed',
'FormClosing', 'ItemDrag', 'ItemSelectionChanged', 'KeyDown', 'KeyUp',
'Leave', 'Load', 'MouseDown', 'MouseEnter', 'MouseMove', 'MouseUp',
'Paint', 'SelectedIndexChanged', 'SelectionChangeCommitted', 'Selecting',
'Shown', 'SizeChanged', 'Tick'
]
def file_read(filename):
"""
Read from a file
:param filename: Full path of file
:return: File contents as string or empty string if there was an error
"""
try:
with open(filename, 'r') as f:
return f.read()
except (OSError, IOError):
return ''
def get_methods(filename):
"""
Read C# file and use regex to find method names
:param filename: Full path of file
:return: Array of method names in alphabetical order
"""
found = []
for line in file_read(filename).split('\n'):
result = pattern.match(line)
if result:
found.append(result.group('method'))
return sorted(found)
def get_coverage_for_class(methods, tests, missing_only=False):
"""
Compare method names and test names and print the names of methods that
do not have at least one test
:param methods: List of method names
:param tests: List of test names
:param missing_only: If true, only print missing names
"""
if not missing_only:
print 'Methods:'
print '\n'.join(methods)
print 'Tests:'
print '\n'.join(tests)
missing = []
for method in methods:
method_name = method.split('(')[0]
has_test = False
for test in tests:
if test.startswith(method_name):
has_test = True
continue
if not has_test and method_name not in missing and \
method_name.split('_')[-1] not in ui_methods:
missing.append(method_name)
if missing:
print 'Missing:'
print '\n'.join(missing)
else:
print 'OK'
def get_coverage_for_config(config):
"""
Use config file to examine a whole project for missing tests
:param config: Project configuration as JSON in the following format:
{ "class_path": "<project folder>", "test_path": "<test folder>",
"tests": { "<filename>": { "test": "<testfilename>", "missing":
[<method names>] }, ... }, "skip": [<filenames>] }
"missing" and "skip" refer to methods/files that do not need to be tested
"""
# get absolute paths for classes and tests
class_path = os.path.abspath(
os.path.join(os.path.dirname(config_file), config['class_path'])
)
test_path = os.path.abspath(
os.path.join(os.path.dirname(config_file), config['test_path'])
)
# find project files
for proj_file in os.listdir(class_path):
if os.path.splitext(proj_file.lower())[1] != '.cs':
continue
if proj_file.lower().endswith('.designer.cs'):
continue
if proj_file in config['tests'].keys():
methods = get_methods(os.path.join(class_path, proj_file))
tests = get_methods(
os.path.join(test_path, config['tests'][proj_file]['test'])
)
# remove methods we know are missing
methods = filter(
lambda x: x not in config['tests'][proj_file]['missing'],
map(lambda y: y.split('(')[0],methods)
)
print 'Getting coverage for {0} -> {1}...'.format(
proj_file, config['tests'][proj_file]['test']
)
get_coverage_for_class(methods, tests, True)
print ''
elif proj_file not in config['skip']:
print 'Class {0} not found in config'.format(proj_file)
def show_usage():
print 'Usage: get_test_coverage.py <class file> <test file>'
print 'Checks each method in the class file has at least one test'
print ' or get_test_coverage.py <config file>'
print 'Specify class and test files and any methods that can be skipped'
sys.exit(1)
if __name__ == '__main__':
if len(sys.argv) == 2 and (sys.argv[1] == '-?' or sys.argv[1] == '-h'):
show_usage()
config_file = ''
methods = []
tests = []
if len(sys.argv) == 1:
# no args - look for coverage.json
if not os.path.exists('coverage.json'):
show_usage()
config_file = 'coverage.json'
elif len(sys.argv) == 2:
# single arg is coverage.json
if not os.path.exists(sys.argv[1]):
print '{0} not found'.format(sys.argv[1])
show_usage()
config_file = sys.argv[1]
elif len(sys.argv) == 3:
# class/test
methods = get_methods(sys.argv[1])
tests = get_methods(sys.argv[2])
else:
show_usage()
if config_file:
try:
get_coverage_for_config(json.loads(file_read(config_file)))
except KeyError:
print 'Warning: missing key in config file'
sys.exit(2)
else:
get_coverage_for_class(methods, tests)
@jontwo
Copy link
Author

jontwo commented Jul 1, 2016

This is a very basic test coverage tool that just looks at the method names in your project and checks that each xxx method has a corresponding xxxTest method. Can be run on individual classes or, using a JSON config file, for the whole project at once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment