Skip to content

Commit a643710

Browse files
jtrobecStu Hood
authored andcommitted
Working implementation of jacoco. (#4978)
### Problem Jacoco is now available as an option, but the existing implementation is a no-op stub. ### Solution Filled out the implementation of the jacoco code coverage engine. ### Result Specifying jacoco as the code coverage processor now creates jacoco reports for junit tests. See the attachment for an example of the jacoco output against pants java code: [coverage.zip](https://github.com/pantsbuild/pants/files/1385892/coverage.zip) One place where I could use advice: what's the best way to handle working with the snapshot? I'm using the snapshot because it was the only available version of jacoco that included the client tools. I could write a wrapper around the reporting classes, but it seemed best to avoid that if there was already a cli I could use, and there is...just not in any of the release versions. It's not clear when the next release will be (https://groups.google.com/forum/#!topic/jacoco/gd8xD30TDNo), so my feeling is that moving forward with the current snapshot build (as long as it seems to work) is the best bet. However, maybe pants has some space where I could push the current snapshot so we can guarantee a stable version until the next jacoco release hits maven central? Please let me know what seems best.
1 parent 9648311 commit a643710

File tree

5 files changed

+224
-44
lines changed

5 files changed

+224
-44
lines changed

build-support/ivy/ivysettings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Licensed under the Apache License, Version 2.0 (see LICENSE).
1313
value="${m2.repo.relpath}/[artifact](-[classifier])-[revision].[ext]"/>
1414
<property name="m2.repo.dir" value="${user.home}/.m2/repository" override="false"/>
1515
<property name="kythe.artifact" value="[organization]-[revision]/[artifact].[ext]"/>
16+
<!-- for retrieving jacoco snapshot, remove when a version containing cli is released -->
17+
<!-- see https://github.com/pantsbuild/pants/issues/5010 -->
18+
<property name="sonatype.nexus.snapshots.url" value="https://oss.sonatype.org/content/repositories/snapshots/" />
1619

1720
<!-- This dance to set ANDROID_HOME seems more complex than it need be, but an absolute path is
1821
needed whether or not the ANDROID_HOME env var is set and just using the following does
@@ -69,6 +72,10 @@ Licensed under the Apache License, Version 2.0 (see LICENSE).
6972
<url name="benjyw/binhost">
7073
<artifact pattern="https://github.com/benjyw/binhost/raw/master/[organisation]/${kythe.artifact}" />
7174
</url>
75+
76+
<!-- for retrieving jacoco snapshot, remove when a version containing cli is released -->
77+
<!-- see https://github.com/pantsbuild/pants/issues/5010 -->
78+
<ibiblio name="sonatype-nexus-snapshots" m2compatible="true" root="${sonatype.nexus.snapshots.url} "/>
7279
</chain>
7380
</resolvers>
7481
</ivysettings>

src/python/pants/backend/jvm/tasks/coverage/cobertura.py

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ def __init__(self, settings, targets, execute_java_for_targets):
9393
self._settings = settings
9494
options = settings.options
9595
self._context = settings.context
96-
self._coverage = options.coverage
9796
self._coverage_datafile = os.path.join(settings.coverage_dir, 'cobertura.ser')
97+
self._coverage_force = options.coverage_force
9898
touch(self._coverage_datafile)
9999
self._rootdirs = defaultdict(OrderedSet)
100100
self._include_classes = options.coverage_cobertura_include_classes
@@ -184,7 +184,7 @@ def instrument(self):
184184
args += ["--listOfFilesToInstrument", tmp_file.name]
185185

186186
main = 'net.sourceforge.cobertura.instrument.InstrumentMain'
187-
self._context.log.debug(
187+
self._settings.log.debug(
188188
"executing cobertura instrumentation with the following args: {}".format(args))
189189
result = self._execute_java(classpath=cobertura_cp,
190190
main=main,
@@ -208,40 +208,45 @@ def classpath_prepend(self):
208208
def extra_jvm_options(self):
209209
return ['-Dnet.sourceforge.cobertura.datafile=' + self._coverage_datafile]
210210

211-
def report(self, execution_failed_exception=None):
211+
def should_report(self, execution_failed_exception=None):
212212
if self._nothing_to_instrument:
213-
self._context.log.warn('Nothing found to instrument, skipping report...')
214-
return
213+
self._settings.log.warn('Nothing found to instrument, skipping report...')
214+
return False
215215
if execution_failed_exception:
216-
self._context.log.warn('Test failed: {0}'.format(execution_failed_exception))
216+
self._settings.log.warn('Test failed: {0}'.format(execution_failed_exception))
217217
if self._settings.coverage_force:
218-
self._context.log.warn('Generating report even though tests failed.')
218+
self._settings.log.warn('Generating report even though tests failed.')
219+
return True
219220
else:
220-
return
221-
cobertura_cp = self._settings.tool_classpath('cobertura-report')
222-
source_roots = {t.target_base for t in self._targets if Cobertura.is_coverage_target(t)}
223-
for report_format in ['xml', 'html']:
224-
report_dir = os.path.join(self._settings.coverage_dir, report_format)
225-
safe_mkdir(report_dir, clean=True)
226-
args = list(source_roots)
227-
args += [
228-
'--datafile',
229-
self._coverage_datafile,
230-
'--destination',
231-
report_dir,
232-
'--format',
233-
report_format,
234-
]
235-
main = 'net.sourceforge.cobertura.reporting.ReportMain'
236-
result = self._execute_java(classpath=cobertura_cp,
237-
main=main,
238-
jvm_options=self._settings.coverage_jvm_options,
239-
args=args,
240-
workunit_factory=self._context.new_workunit,
241-
workunit_name='cobertura-report-' + report_format)
242-
if result != 0:
243-
raise TaskError("java {0} ... exited non-zero ({1})"
244-
" 'failed to report'".format(main, result))
221+
return False
222+
return True
223+
224+
def report(self, execution_failed_exception=None):
225+
if self.should_report(execution_failed_exception):
226+
cobertura_cp = self._settings.tool_classpath('cobertura-report')
227+
source_roots = {t.target_base for t in self._targets if Cobertura.is_coverage_target(t)}
228+
for report_format in ['xml', 'html']:
229+
report_dir = os.path.join(self._settings.coverage_dir, report_format)
230+
safe_mkdir(report_dir, clean=True)
231+
args = list(source_roots)
232+
args += [
233+
'--datafile',
234+
self._coverage_datafile,
235+
'--destination',
236+
report_dir,
237+
'--format',
238+
report_format,
239+
]
240+
main = 'net.sourceforge.cobertura.reporting.ReportMain'
241+
result = self._execute_java(classpath=cobertura_cp,
242+
main=main,
243+
jvm_options=self._settings.coverage_jvm_options,
244+
args=args,
245+
workunit_factory=self._context.new_workunit,
246+
workunit_name='cobertura-report-' + report_format)
247+
if result != 0:
248+
raise TaskError("java {0} ... exited non-zero ({1})"
249+
" 'failed to report'".format(main, result))
245250

246251
def maybe_open_report(self):
247252
if self._settings.coverage_open:

src/python/pants/backend/jvm/tasks/coverage/jacoco.py

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,54 @@
66
unicode_literals, with_statement)
77

88
import functools
9+
import os
910

11+
from pants.backend.jvm.subsystems.jvm_tool_mixin import JvmToolMixin
1012
from pants.backend.jvm.tasks.coverage.engine import CoverageEngine
13+
from pants.base.exceptions import TaskError
14+
from pants.java.jar.jar_dependency import JarDependency
1115
from pants.subsystem.subsystem import Subsystem
16+
from pants.util import desktop
17+
from pants.util.dirutil import safe_delete, safe_mkdir
1218

1319

1420
class Jacoco(CoverageEngine):
1521
"""Class to run coverage tests with Jacoco."""
1622

17-
class Factory(Subsystem):
23+
class Factory(Subsystem, JvmToolMixin):
1824
options_scope = 'jacoco'
1925

2026
@classmethod
21-
def create(cls, settings, targets, execute_java_for_targets):
27+
def register_options(cls, register):
28+
super(Jacoco.Factory, cls).register_options(register)
29+
30+
# We need to inject the jacoco agent at test runtime
31+
cls.register_jvm_tool(register,
32+
'jacoco-agent',
33+
classpath=[
34+
JarDependency(
35+
org='org.jacoco',
36+
name='org.jacoco.agent',
37+
# TODO(jtrobec): get off of snapshat once jacoco release with cli is available
38+
# see https://github.com/pantsbuild/pants/issues/5010
39+
rev='0.7.10-SNAPSHOT',
40+
classifier='runtime',
41+
intransitive=True)
42+
])
43+
44+
# We'll use the jacoco-cli to generate reports
45+
cls.register_jvm_tool(register,
46+
'jacoco-cli',
47+
classpath=[
48+
JarDependency(
49+
org='org.jacoco',
50+
name='org.jacoco.cli',
51+
# TODO(jtrobec): get off of snapshat once jacoco release with cli is available
52+
# see https://github.com/pantsbuild/pants/issues/5010
53+
rev='0.7.10-SNAPSHOT')
54+
])
55+
56+
def create(self, settings, targets, execute_java_for_targets):
2257
"""
2358
:param settings: Generic code coverage settings.
2459
:type settings: :class:`CodeCoverageSettings`
@@ -30,9 +65,11 @@ def create(cls, settings, targets, execute_java_for_targets):
3065
`pants.java.util.execute_java`.
3166
"""
3267

33-
return Jacoco(settings, targets, execute_java_for_targets)
68+
agent_path = self.tool_jar_from_products(settings.context.products, 'jacoco-agent', scope='jacoco')
69+
cli_path = self.tool_classpath_from_products(settings.context.products, 'jacoco-cli', scope='jacoco')
70+
return Jacoco(settings, agent_path, cli_path, targets, execute_java_for_targets)
3471

35-
def __init__(self, settings, targets, execute_java_for_targets):
72+
def __init__(self, settings, agent_path, cli_path, targets, execute_java_for_targets):
3673
"""
3774
:param settings: Generic code coverage settings.
3875
:type settings: :class:`CodeCoverageSettings`
@@ -44,12 +81,20 @@ def __init__(self, settings, targets, execute_java_for_targets):
4481
`pants.java.util.execute_java`.
4582
"""
4683
self._settings = settings
84+
options = settings.options
85+
self._context = settings.context
4786
self._targets = targets
87+
self._coverage_targets = {t for t in targets if Jacoco.is_coverage_target(t)}
88+
self._agent_path = agent_path
89+
self._cli_path = cli_path
4890
self._execute_java = functools.partial(execute_java_for_targets, targets)
91+
self._coverage_force = options.coverage_force
92+
self._coverage_datafile = os.path.join(settings.coverage_dir, 'jacoco.exec')
93+
self._coverage_report_dir = os.path.join(settings.coverage_dir, 'reports')
4994

5095
def instrument(self):
51-
# jacoco does runtime instrumentation, so this is a noop
52-
pass
96+
# jacoco does runtime instrumentation, so this only does clean-up of existing run
97+
safe_delete(self._coverage_datafile)
5398

5499
@property
55100
def classpath_append(self):
@@ -61,13 +106,71 @@ def classpath_prepend(self):
61106

62107
@property
63108
def extra_jvm_options(self):
64-
# TODO(jtrobec): implement code coverage using jacoco
65-
return []
109+
agent_option = '-javaagent:{agent}=destfile={destfile}'.format(agent=self._agent_path,
110+
destfile=self._coverage_datafile)
111+
return [agent_option]
112+
113+
@staticmethod
114+
def is_coverage_target(tgt):
115+
return (tgt.is_java or tgt.is_scala) and not tgt.is_test and not tgt.is_synthetic
66116

67117
def report(self, execution_failed_exception=None):
68-
# TODO(jtrobec): implement code coverage using jacoco
69-
pass
118+
if execution_failed_exception:
119+
self._settings.log.warn('Test failed: {0}'.format(execution_failed_exception))
120+
if self._coverage_force:
121+
self._settings.log.warn('Generating report even though tests failed, because the coverage-force flag is set.')
122+
else:
123+
return
124+
125+
safe_mkdir(self._coverage_report_dir, clean=True)
126+
for report_format in ['xml', 'csv', 'html']:
127+
target_path = os.path.join(self._coverage_report_dir, report_format)
128+
args = ['report', self._coverage_datafile] + self._get_target_classpaths() + self._get_source_roots() + [
129+
'--{report_format}={target_path}'.format(report_format=report_format,
130+
target_path=target_path)
131+
]
132+
main = 'net.sourceforge.cobertura.reporting.ReportMain'
133+
result = self._execute_java(classpath=self._cli_path,
134+
main='org.jacoco.cli.internal.Main',
135+
jvm_options=self._settings.coverage_jvm_options,
136+
args=args,
137+
workunit_factory=self._context.new_workunit,
138+
workunit_name='jacoco-report-' + report_format)
139+
if result != 0:
140+
raise TaskError("java {0} ... exited non-zero ({1})"
141+
" 'failed to report'".format(main, result))
142+
143+
def _get_target_classpaths(self):
144+
runtime_classpath = self._context.products.get_data('runtime_classpath')
145+
146+
target_paths = []
147+
for target in self._coverage_targets:
148+
paths = runtime_classpath.get_for_target(target)
149+
for (name, path) in paths:
150+
target_paths.append(path)
151+
152+
return self._make_multiple_arg('--classfiles', target_paths)
153+
154+
def _get_source_roots(self):
155+
source_roots = {t.target_base for t in self._coverage_targets}
156+
return self._make_multiple_arg('--sourcefiles', source_roots)
157+
158+
def _make_multiple_arg(self, arg_name, arg_list):
159+
"""Jacoco cli allows the specification of multiple values for certain args by repeating the argument
160+
with a new value. E.g. --classfiles a.class --classfiles b.class, etc. This method creates a list of
161+
strings interleaved with the arg name to satisfy that format.
162+
"""
163+
unique_args = list(set(arg_list))
164+
165+
args = [(arg_name, f) for f in unique_args]
166+
flattened = list(sum(args, ()))
167+
168+
return flattened
70169

71170
def maybe_open_report(self):
72-
# TODO(jtrobec): implement code coverage using jacoco
73-
pass
171+
if self._settings.coverage_open:
172+
report_file_path = os.path.join(self._settings.coverage_dir, 'reports/html', 'index.html')
173+
try:
174+
desktop.ui_open(report_file_path)
175+
except desktop.OpenError as e:
176+
raise TaskError(e)

tests/python/pants_test/backend/jvm/tasks/coverage/test_cobertura.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ def debug(self, string):
4040
"""
4141
pass
4242

43+
def warn(self, string):
44+
"""
45+
:API: public
46+
"""
47+
pass
48+
4349

4450
class MockSystemCalls(object):
4551
"""
@@ -85,6 +91,7 @@ def setUp(self):
8591

8692
self.pants_workdir = 'workdir'
8793
self.conf = 'default'
94+
self.factory = Cobertura.Factory("test_scope", [])
8895

8996
self.jar_lib = self.make_target(spec='3rdparty/jvm/org/example:foo',
9097
target_type=JarLibrary,
@@ -195,3 +202,40 @@ def test_target_annotation_processor(self):
195202
self.assertEquals(len(syscalls.copy2_calls), 0,
196203
'Should be 0 call for the single annotation target.')
197204
self._assert_target_copytree(syscalls, '/anno/target/dir', '/coverage/classes/foo.foo-anno/0')
205+
206+
def _get_fake_execute_java(self):
207+
def _fake_execute_java(classpath, main, jvm_options, args, workunit_factory, workunit_name):
208+
# at some point we could add assertions here for expected paramerter values
209+
pass
210+
return _fake_execute_java
211+
212+
def test_coverage_forced(self):
213+
"""
214+
:API: public
215+
"""
216+
options = attrdict(coverage=True, coverage_force=True, coverage_jvm_options=[])
217+
218+
syscalls = MockSystemCalls()
219+
settings = self.get_settings(options, self.pants_workdir, fake_log(), syscalls)
220+
cobertura = self.factory.create(settings, [self.binary_target], self._get_fake_execute_java())
221+
222+
self.assertEquals(cobertura.should_report(), False, 'Without instrumentation step, there should be nothing to instrument or report')
223+
224+
# simulate an instrument step with results
225+
cobertura._nothing_to_instrument = False
226+
227+
self.assertEquals(cobertura.should_report(), True, 'Should do reporting when there is something to instrument')
228+
229+
exception = Exception("uh oh, test failed")
230+
231+
self.assertEquals(cobertura.should_report(exception), True, 'We\'ve forced coverage, so should report.')
232+
233+
no_force_options = attrdict(coverage=True, coverage_force=False, coverage_jvm_options=[])
234+
no_force_settings = self.get_settings(no_force_options, self.pants_workdir, fake_log(), syscalls)
235+
no_force_cobertura = self.factory.create(no_force_settings, [self.binary_target], self._get_fake_execute_java())
236+
237+
no_force_cobertura._nothing_to_instrument = False
238+
self.assertEquals(no_force_cobertura.should_report(exception), False, 'Don\'t report after a failure if coverage isn\'t forced.')
239+
240+
241+

tests/python/pants_test/backend/jvm/tasks/test_junit_run_integration.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ def test_junit_run_with_cobertura_coverage_succeeds(self):
5656
with codecs.open(cucumber_src_html, 'r', encoding='utf8') as src:
5757
self.assertIn('String pleasantry()', src.read())
5858

59+
def test_junit_run_with_jacoco_coverage_succeeds(self):
60+
with self.pants_results(['clean-all',
61+
'test.junit',
62+
'testprojects/tests/java/org/pantsbuild/testproject/unicode::',
63+
'--test-junit-coverage-processor=jacoco',
64+
'--test-junit-coverage']) as results:
65+
self.assert_success(results)
66+
# validate that the expected coverage file exists, and it reflects 100% line rate coverage
67+
coverage_xml = os.path.join(results.workdir, 'test/junit/coverage/reports/xml')
68+
self.assertTrue(os.path.isfile(coverage_xml))
69+
with codecs.open(coverage_xml, 'r', encoding='utf8') as xml:
70+
self.assertIn('<class name="org/pantsbuild/testproject/unicode/cucumber/CucumberAnnotatedExample"><method name="&lt;init&gt;" desc="()V" line="13"><counter type="INSTRUCTION" missed="0" covered="3"/>', xml.read())
71+
# validate that the html report was able to find sources for annotation
72+
cucumber_src_html = os.path.join(
73+
results.workdir,
74+
'test/junit/coverage/reports/html/'
75+
'org.pantsbuild.testproject.unicode.cucumber/CucumberAnnotatedExample.html')
76+
self.assertTrue(os.path.isfile(cucumber_src_html))
77+
with codecs.open(cucumber_src_html, 'r', encoding='utf8') as src:
78+
self.assertIn('class="el_method">pleasantry()</a>', src.read())
79+
5980
def test_junit_run_against_invalid_class_fails(self):
6081
pants_run = self.run_pants(['clean-all',
6182
'test.junit',

0 commit comments

Comments
 (0)