Skip to content

Commit 5fcc14e

Browse files
committed
Merge pull request #6804
b5cbd39 Add basic coverage reporting for RPC tests (James O'Beirne)
2 parents cbf9609 + b5cbd39 commit 5fcc14e

File tree

8 files changed

+334
-55
lines changed

8 files changed

+334
-55
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,6 @@ script:
6969
- make $MAKEJOBS $GOAL || ( echo "Build failure. Verbose build follows." && make $GOAL V=1 ; false )
7070
- export LD_LIBRARY_PATH=$TRAVIS_BUILD_DIR/depends/$HOST/lib
7171
- if [ "$RUN_TESTS" = "true" ]; then make check; fi
72-
- if [ "$RUN_TESTS" = "true" ]; then qa/pull-tester/rpc-tests.py; fi
72+
- if [ "$RUN_TESTS" = "true" ]; then qa/pull-tester/rpc-tests.py --coverage; fi
7373
after_script:
7474
- if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then (echo "Upload goes here. Something like: scp -r $BASE_OUTDIR server" || echo "upload failed"); fi

qa/pull-tester/rpc-tests.py

Lines changed: 152 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,32 @@
33
# Distributed under the MIT software license, see the accompanying
44
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
55

6-
#
7-
# Run Regression Test Suite
8-
#
6+
"""
7+
Run Regression Test Suite
8+
9+
This module calls down into individual test cases via subprocess. It will
10+
forward all unrecognized arguments onto the individual test scripts, other
11+
than:
12+
13+
- `-extended`: run the "extended" test suite in addition to the basic one.
14+
- `-win`: signal that this is running in a Windows environment, and we
15+
should run the tests.
16+
- `--coverage`: this generates a basic coverage report for the RPC
17+
interface.
18+
19+
For a description of arguments recognized by test scripts, see
20+
`qa/pull-tester/test_framework/test_framework.py:BitcoinTestFramework.main`.
21+
22+
"""
923

1024
import os
25+
import shutil
1126
import sys
1227
import subprocess
28+
import tempfile
1329
import re
30+
1431
from tests_config import *
15-
from sets import Set
1632

1733
#If imported values are not defined then set to zero (or disabled)
1834
if not vars().has_key('ENABLE_WALLET'):
@@ -24,15 +40,20 @@
2440
if not vars().has_key('ENABLE_ZMQ'):
2541
ENABLE_ZMQ=0
2642

43+
ENABLE_COVERAGE=0
44+
2745
#Create a set to store arguments and create the passOn string
28-
opts = Set()
46+
opts = set()
2947
passOn = ""
3048
p = re.compile("^--")
31-
for i in range(1,len(sys.argv)):
32-
if (p.match(sys.argv[i]) or sys.argv[i] == "-h"):
33-
passOn += " " + sys.argv[i]
49+
50+
for arg in sys.argv[1:]:
51+
if arg == '--coverage':
52+
ENABLE_COVERAGE = 1
53+
elif (p.match(arg) or arg == "-h"):
54+
passOn += " " + arg
3455
else:
35-
opts.add(sys.argv[i])
56+
opts.add(arg)
3657

3758
#Set env vars
3859
buildDir = BUILDDIR
@@ -98,24 +119,125 @@
98119
if ENABLE_ZMQ == 1:
99120
testScripts.append('zmq_test.py')
100121

101-
if(ENABLE_WALLET == 1 and ENABLE_UTILS == 1 and ENABLE_BITCOIND == 1):
102-
rpcTestDir = buildDir + '/qa/rpc-tests/'
103-
#Run Tests
104-
for i in range(len(testScripts)):
105-
if (len(opts) == 0 or (len(opts) == 1 and "-win" in opts ) or '-extended' in opts
106-
or testScripts[i] in opts or re.sub(".py$", "", testScripts[i]) in opts ):
107-
print "Running testscript " + testScripts[i] + "..."
108-
subprocess.check_call(rpcTestDir + testScripts[i] + " --srcdir " + buildDir + '/src ' + passOn,shell=True)
109-
#exit if help is called so we print just one set of instructions
110-
p = re.compile(" -h| --help")
111-
if p.match(passOn):
112-
sys.exit(0)
113-
114-
#Run Extended Tests
115-
for i in range(len(testScriptsExt)):
116-
if ('-extended' in opts or testScriptsExt[i] in opts
117-
or re.sub(".py$", "", testScriptsExt[i]) in opts):
118-
print "Running 2nd level testscript " + testScriptsExt[i] + "..."
119-
subprocess.check_call(rpcTestDir + testScriptsExt[i] + " --srcdir " + buildDir + '/src ' + passOn,shell=True)
120-
else:
121-
print "No rpc tests to run. Wallet, utils, and bitcoind must all be enabled"
122+
123+
def runtests():
124+
coverage = None
125+
126+
if ENABLE_COVERAGE:
127+
coverage = RPCCoverage()
128+
print("Initializing coverage directory at %s" % coverage.dir)
129+
130+
if(ENABLE_WALLET == 1 and ENABLE_UTILS == 1 and ENABLE_BITCOIND == 1):
131+
rpcTestDir = buildDir + '/qa/rpc-tests/'
132+
run_extended = '-extended' in opts
133+
cov_flag = coverage.flag if coverage else ''
134+
flags = " --srcdir %s/src %s %s" % (buildDir, cov_flag, passOn)
135+
136+
#Run Tests
137+
for i in range(len(testScripts)):
138+
if (len(opts) == 0
139+
or (len(opts) == 1 and "-win" in opts )
140+
or run_extended
141+
or testScripts[i] in opts
142+
or re.sub(".py$", "", testScripts[i]) in opts ):
143+
print("Running testscript " + testScripts[i] + "...")
144+
145+
subprocess.check_call(
146+
rpcTestDir + testScripts[i] + flags, shell=True)
147+
148+
# exit if help is called so we print just one set of
149+
# instructions
150+
p = re.compile(" -h| --help")
151+
if p.match(passOn):
152+
sys.exit(0)
153+
154+
# Run Extended Tests
155+
for i in range(len(testScriptsExt)):
156+
if (run_extended or testScriptsExt[i] in opts
157+
or re.sub(".py$", "", testScriptsExt[i]) in opts):
158+
print(
159+
"Running 2nd level testscript "
160+
+ testScriptsExt[i] + "...")
161+
162+
subprocess.check_call(
163+
rpcTestDir + testScriptsExt[i] + flags, shell=True)
164+
165+
if coverage:
166+
coverage.report_rpc_coverage()
167+
168+
print("Cleaning up coverage data")
169+
coverage.cleanup()
170+
171+
else:
172+
print "No rpc tests to run. Wallet, utils, and bitcoind must all be enabled"
173+
174+
175+
class RPCCoverage(object):
176+
"""
177+
Coverage reporting utilities for pull-tester.
178+
179+
Coverage calculation works by having each test script subprocess write
180+
coverage files into a particular directory. These files contain the RPC
181+
commands invoked during testing, as well as a complete listing of RPC
182+
commands per `bitcoin-cli help` (`rpc_interface.txt`).
183+
184+
After all tests complete, the commands run are combined and diff'd against
185+
the complete list to calculate uncovered RPC commands.
186+
187+
See also: qa/rpc-tests/test_framework/coverage.py
188+
189+
"""
190+
def __init__(self):
191+
self.dir = tempfile.mkdtemp(prefix="coverage")
192+
self.flag = '--coveragedir %s' % self.dir
193+
194+
def report_rpc_coverage(self):
195+
"""
196+
Print out RPC commands that were unexercised by tests.
197+
198+
"""
199+
uncovered = self._get_uncovered_rpc_commands()
200+
201+
if uncovered:
202+
print("Uncovered RPC commands:")
203+
print("".join((" - %s\n" % i) for i in sorted(uncovered)))
204+
else:
205+
print("All RPC commands covered.")
206+
207+
def cleanup(self):
208+
return shutil.rmtree(self.dir)
209+
210+
def _get_uncovered_rpc_commands(self):
211+
"""
212+
Return a set of currently untested RPC commands.
213+
214+
"""
215+
# This is shared from `qa/rpc-tests/test-framework/coverage.py`
216+
REFERENCE_FILENAME = 'rpc_interface.txt'
217+
COVERAGE_FILE_PREFIX = 'coverage.'
218+
219+
coverage_ref_filename = os.path.join(self.dir, REFERENCE_FILENAME)
220+
coverage_filenames = set()
221+
all_cmds = set()
222+
covered_cmds = set()
223+
224+
if not os.path.isfile(coverage_ref_filename):
225+
raise RuntimeError("No coverage reference found")
226+
227+
with open(coverage_ref_filename, 'r') as f:
228+
all_cmds.update([i.strip() for i in f.readlines()])
229+
230+
for root, dirs, files in os.walk(self.dir):
231+
for filename in files:
232+
if filename.startswith(COVERAGE_FILE_PREFIX):
233+
coverage_filenames.add(os.path.join(root, filename))
234+
235+
for filename in coverage_filenames:
236+
with open(filename, 'r') as f:
237+
covered_cmds.update([i.strip() for i in f.readlines()])
238+
239+
return all_cmds - covered_cmds
240+
241+
242+
if __name__ == '__main__':
243+
runtests()

qa/rpc-tests/getblocktemplate_longpoll.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def __init__(self, node):
3838
self.longpollid = templat['longpollid']
3939
# create a new connection to the node, we can't use the same
4040
# connection from two threads
41-
self.node = AuthServiceProxy(node.url, timeout=600)
41+
self.node = get_rpc_proxy(node.url, 1, timeout=600)
4242

4343
def run(self):
4444
self.node.getblocktemplate({'longpollid':self.longpollid})

qa/rpc-tests/rpcbind_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def run_allowip_test(tmpdir, allow_ips, rpchost, rpcport):
4747
try:
4848
# connect to node through non-loopback interface
4949
url = "http://rt:rt@%s:%d" % (rpchost, rpcport,)
50-
node = AuthServiceProxy(url)
50+
node = get_rpc_proxy(url, 1)
5151
node.getinfo()
5252
finally:
5353
node = None # make sure connection will be garbage collected and closed

qa/rpc-tests/test_framework/authproxy.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class AuthServiceProxy(object):
6969

7070
def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None):
7171
self.__service_url = service_url
72-
self.__service_name = service_name
72+
self._service_name = service_name
7373
self.__url = urlparse.urlparse(service_url)
7474
if self.__url.port is None:
7575
port = 80
@@ -102,8 +102,8 @@ def __getattr__(self, name):
102102
if name.startswith('__') and name.endswith('__'):
103103
# Python internal stuff
104104
raise AttributeError
105-
if self.__service_name is not None:
106-
name = "%s.%s" % (self.__service_name, name)
105+
if self._service_name is not None:
106+
name = "%s.%s" % (self._service_name, name)
107107
return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
108108

109109
def _request(self, method, path, postdata):
@@ -129,10 +129,10 @@ def _request(self, method, path, postdata):
129129
def __call__(self, *args):
130130
AuthServiceProxy.__id_count += 1
131131

132-
log.debug("-%s-> %s %s"%(AuthServiceProxy.__id_count, self.__service_name,
132+
log.debug("-%s-> %s %s"%(AuthServiceProxy.__id_count, self._service_name,
133133
json.dumps(args, default=EncodeDecimal)))
134134
postdata = json.dumps({'version': '1.1',
135-
'method': self.__service_name,
135+
'method': self._service_name,
136136
'params': args,
137137
'id': AuthServiceProxy.__id_count}, default=EncodeDecimal)
138138
response = self._request('POST', self.__url.path, postdata)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
This module contains utilities for doing coverage analysis on the RPC
3+
interface.
4+
5+
It provides a way to track which RPC commands are exercised during
6+
testing.
7+
8+
"""
9+
import os
10+
11+
12+
REFERENCE_FILENAME = 'rpc_interface.txt'
13+
14+
15+
class AuthServiceProxyWrapper(object):
16+
"""
17+
An object that wraps AuthServiceProxy to record specific RPC calls.
18+
19+
"""
20+
def __init__(self, auth_service_proxy_instance, coverage_logfile=None):
21+
"""
22+
Kwargs:
23+
auth_service_proxy_instance (AuthServiceProxy): the instance
24+
being wrapped.
25+
coverage_logfile (str): if specified, write each service_name
26+
out to a file when called.
27+
28+
"""
29+
self.auth_service_proxy_instance = auth_service_proxy_instance
30+
self.coverage_logfile = coverage_logfile
31+
32+
def __getattr__(self, *args, **kwargs):
33+
return_val = self.auth_service_proxy_instance.__getattr__(
34+
*args, **kwargs)
35+
36+
return AuthServiceProxyWrapper(return_val, self.coverage_logfile)
37+
38+
def __call__(self, *args, **kwargs):
39+
"""
40+
Delegates to AuthServiceProxy, then writes the particular RPC method
41+
called to a file.
42+
43+
"""
44+
return_val = self.auth_service_proxy_instance.__call__(*args, **kwargs)
45+
rpc_method = self.auth_service_proxy_instance._service_name
46+
47+
if self.coverage_logfile:
48+
with open(self.coverage_logfile, 'a+') as f:
49+
f.write("%s\n" % rpc_method)
50+
51+
return return_val
52+
53+
@property
54+
def url(self):
55+
return self.auth_service_proxy_instance.url
56+
57+
58+
def get_filename(dirname, n_node):
59+
"""
60+
Get a filename unique to the test process ID and node.
61+
62+
This file will contain a list of RPC commands covered.
63+
"""
64+
pid = str(os.getpid())
65+
return os.path.join(
66+
dirname, "coverage.pid%s.node%s.txt" % (pid, str(n_node)))
67+
68+
69+
def write_all_rpc_commands(dirname, node):
70+
"""
71+
Write out a list of all RPC functions available in `bitcoin-cli` for
72+
coverage comparison. This will only happen once per coverage
73+
directory.
74+
75+
Args:
76+
dirname (str): temporary test dir
77+
node (AuthServiceProxy): client
78+
79+
Returns:
80+
bool. if the RPC interface file was written.
81+
82+
"""
83+
filename = os.path.join(dirname, REFERENCE_FILENAME)
84+
85+
if os.path.isfile(filename):
86+
return False
87+
88+
help_output = node.help().split('\n')
89+
commands = set()
90+
91+
for line in help_output:
92+
line = line.strip()
93+
94+
# Ignore blanks and headers
95+
if line and not line.startswith('='):
96+
commands.add("%s\n" % line.split()[0])
97+
98+
with open(filename, 'w') as f:
99+
f.writelines(list(commands))
100+
101+
return True

0 commit comments

Comments
 (0)