Merge lp:~jml/libdep-service/test-double into lp:libdep-service

Proposed by Jonathan Lange
Status: Superseded
Proposed branch: lp:~jml/libdep-service/test-double
Merge into: lp:libdep-service
Diff against target: 540 lines (+449/-2)
9 files modified
buildout.cfg (+3/-0)
djlibdep/test_double.py (+99/-0)
djlibdep/tests/__init__.py (+3/-1)
djlibdep/tests/_djangofixture.py (+86/-0)
djlibdep/tests/test_interface.py (+77/-0)
djlibdep/tests/test_test_double.py (+132/-0)
setup.py (+6/-0)
test-double/libdep_double.go (+40/-0)
versions.cfg (+3/-1)
To merge this branch: bzr merge lp:~jml/libdep-service/test-double
Reviewer Review Type Date Requested Status
Canonical Consumer Applications Hackers Pending
Review via email: mp+126660@code.launchpad.net

Description of the change

WIP

To post a comment you must log in.
lp:~jml/libdep-service/test-double updated
61. By Jonathan Lange

REFACTOR: Move out root resource creation.

62. By Jonathan Lange

RED: We can give it a data file, and it serves JSON accordingly

63. By Jonathan Lange

Comment about where to go once this test passes

64. By Jonathan Lange

Handle unexpected errors a little better.

65. By Jonathan Lange

Remind self that it is a bad idea

66. By Jonathan Lange

Add the argument, pass it through.

67. By Jonathan Lange

Failing test is now valid.

68. By Jonathan Lange

Start a resource that can generate the results we want.

69. By Jonathan Lange

Initial correct implementation and correct test

70. By Jonathan Lange

Different implementation.

71. By Jonathan Lange

GREEN: Handle recursive response.

72. By Jonathan Lange

Really crumby query param support.

73. By Jonathan Lange

REFACTOR: get path better for tests.

74. By Jonathan Lange

GREEN: Restore the disabled test. It now works.

75. By Jonathan Lange

RED: Basic test for API. Fails because we don't have testscenarios set up.

76. By Jonathan Lange

GREEN: Make the test run against the test double.

77. By Jonathan Lange

Don't leak processes.

78. By Jonathan Lange

Add a fixture. Use that.

79. By Jonathan Lange

Do the mixin thing until we also do testscenarios.

80. By Jonathan Lange

RED: Failing tests against the real thing.

81. By Jonathan Lange

Fix process leak.

82. By Jonathan Lange

Make the test pass:
 - set the base_url
 - poll a given path, rather than a fixed pkgme-service path
 - just check for 200, not for the content

83. By Jonathan Lange

Move the port to the constructor.

84. By Jonathan Lange

Split poll_until_running into a separate function.

85. By Jonathan Lange

Parametrize the hard-coded numbers.

86. By Jonathan Lange

Split the logic of polling out from the thing we're polling for.

87. By Jonathan Lange

process can be local.

88. By Jonathan Lange

Use testresources and testscenarios.

89. By Jonathan Lange

RED: Failing test from James

90. By Jonathan Lange

Use local branch of pkgme-devportal for now.

91. By Jonathan Lange

404 stock response if not found.

92. By Jonathan Lange

Make the test double more informative on 404.

93. By Jonathan Lange

Annotate problems

94. By Jonathan Lange

Make the test server a little more self-documenting.

95. By Jonathan Lange

Simple cleanup

96. By Jonathan Lange

Simplify by figuring out the data in main, rather than way down the stack.

97. By Jonathan Lange

make_root_resource is less meaningful now.

98. By Jonathan Lange

Remove more unnecessary junk

99. By Jonathan Lange

Print the base URL when starting the server. Makes manual testing easier.

100. By Jonathan Lange

This ought to work, but doesn't. Need to change data spec format.

101. By Jonathan Lange

Change the data format to be more verbose, but perhaps easier to specify
for query args.

102. By Jonathan Lange

Clean up XXXs a bit.

103. By Jonathan Lange

Go from 2.5s to 1s by using testresources properly.

104. By Jonathan Lange

Done

105. By Jonathan Lange

RED: Failing test for found data.

106. By Jonathan Lange

Wrap django fixture in another fixture.

107. By Jonathan Lange

Tests that return actual data.

108. By Jonathan Lange

Re-enable tests.

109. By Jonathan Lange

Bye-bye go

110. By Jonathan Lange

Used release pkgme-devportal.

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'buildout.cfg'
2--- buildout.cfg 2012-09-07 11:54:59 +0000
3+++ buildout.cfg 2012-09-28 14:14:21 +0000
4@@ -25,6 +25,7 @@
5 prefer-final = true
6
7 develop = .
8+ /home/jml/src/pkgme-devportal
9
10 [scripts]
11 recipe = z3c.recipe.scripts
12@@ -33,6 +34,8 @@
13 pep8
14 pkgme-devportal[testing]
15 pyflakes
16+ testresources
17+ testscenarios
18 testtools
19 include-site-packages = false
20 interpreter = py
21
22=== added file 'djlibdep/test_double.py'
23--- djlibdep/test_double.py 1970-01-01 00:00:00 +0000
24+++ djlibdep/test_double.py 2012-09-28 14:14:21 +0000
25@@ -0,0 +1,99 @@
26+import argparse
27+import json
28+from pprint import pformat
29+import sys
30+
31+from twisted.internet import reactor as mod_reactor
32+from twisted.internet.defer import maybeDeferred
33+from twisted.internet.endpoints import TCP4ServerEndpoint
34+from twisted.web.resource import (
35+ NoResource,
36+ Resource,
37+ )
38+from twisted.web.server import Site
39+from twisted.web.static import Data
40+
41+
42+# XXX: Global mutable state :(
43+_EXIT_CODE = 0
44+
45+
46+class StockResponse(Resource):
47+
48+ def __init__(self, stock_data):
49+ Resource.__init__(self)
50+ self._stock_data = stock_data
51+
52+ def getChild(self, name, request):
53+ if not name:
54+ return self
55+ full_name = '/'.join([name] + request.postpath)
56+ request.postpath = []
57+ value = self._stock_data.get(full_name, [])
58+ for args, data in value:
59+ if args == request.args:
60+ return Data(data.encode('utf8'), 'text/html')
61+ return NoResource(
62+ '%r (%r) not in %r' % (full_name, request.args, self._stock_data))
63+
64+ def render_GET(self, request):
65+ if request.method == 'HEAD':
66+ return ''
67+ return (
68+ '<html><head><title>Stock responses</title></head>'
69+ '<body><h1>Stock responses</h1><pre>%s</pre></body></html>'
70+ % pformat(self._stock_data))
71+
72+
73+def make_options():
74+ parser = argparse.ArgumentParser("Test server for libdep-service")
75+ parser.add_argument('data', type=argparse.FileType('r'), nargs='?')
76+ return parser
77+
78+
79+def run_web_server(reactor, root, port):
80+ endpoint = TCP4ServerEndpoint(reactor, port)
81+ site = Site(root)
82+ return endpoint.listen(site)
83+
84+
85+def unexpected_error(failure):
86+ global _EXIT_CODE
87+ failure.printTraceback(sys.stderr)
88+ mod_reactor.stop()
89+ _EXIT_CODE = 2
90+
91+
92+def get_base_url(listening_port, hostname=None):
93+ address = listening_port.getHost()
94+ if not hostname:
95+ hostname = address.host
96+ return 'http://%s:%s' % (hostname, address.port)
97+
98+
99+def run(reactor, data, hostname='localhost', port=0):
100+ root = StockResponse(data)
101+ d = maybeDeferred(run_web_server, reactor, root, port)
102+
103+ def print_port(listening_port):
104+ print get_base_url(listening_port, hostname)
105+ # Must flush here, so that things watching our stdout know that we've
106+ # started.
107+ sys.stdout.flush()
108+ return listening_port
109+
110+ d.addCallback(print_port)
111+ d.addErrback(unexpected_error)
112+ return d
113+
114+
115+def main():
116+ parser = make_options()
117+ args = parser.parse_args()
118+ if args.data:
119+ data = json.load(args.data)
120+ else:
121+ data = {}
122+ mod_reactor.callWhenRunning(run, mod_reactor, data)
123+ mod_reactor.run()
124+ sys.exit(_EXIT_CODE)
125
126=== modified file 'djlibdep/tests/__init__.py'
127--- djlibdep/tests/__init__.py 2012-09-10 14:34:38 +0000
128+++ djlibdep/tests/__init__.py 2012-09-28 14:14:21 +0000
129@@ -20,9 +20,11 @@
130
131 TEST_MODULES = [
132 'api',
133+ 'interface',
134 'pep8',
135 'preflight',
136- 'views'
137+ 'test_double',
138+ 'views',
139 ]
140
141 SUITE_FACTORY = OptimisingTestSuite
142
143=== added file 'djlibdep/tests/_djangofixture.py'
144--- djlibdep/tests/_djangofixture.py 1970-01-01 00:00:00 +0000
145+++ djlibdep/tests/_djangofixture.py 2012-09-28 14:14:21 +0000
146@@ -0,0 +1,86 @@
147+import errno
148+import subprocess
149+import sys
150+import time
151+from urllib2 import (
152+ URLError,
153+ urlopen,
154+ )
155+
156+from fixtures import Fixture
157+from testtools.content import (
158+ Content,
159+ UTF8_TEXT,
160+ )
161+from twisted.internet.error import TimeoutError
162+
163+
164+def poll(poll_interval, max_tries, predicate, *args, **kwargs):
165+ for i in range(max_tries):
166+ if predicate(*args, **kwargs):
167+ return
168+ time.sleep(poll_interval)
169+ raise TimeoutError("Timed out waiting for %r" % (predicate,))
170+
171+
172+def _is_server_up(url):
173+ try:
174+ response = urlopen(url)
175+ except URLError, e:
176+ error_no = getattr(e.reason, 'errno', None)
177+ if error_no in (errno.ECONNREFUSED, errno.ECONNRESET):
178+ return False
179+ raise
180+ except IOError, e:
181+ if e.errno in (errno.ECONNREFUSED, errno.ECONNRESET):
182+ return False
183+ raise
184+ return response.code == 200
185+
186+
187+def poll_until_running(url, poll_interval=0.05, max_tries=100):
188+ try:
189+ poll(poll_interval, max_tries, _is_server_up, url)
190+ except TimeoutError:
191+ raise TimeoutError("Timed out waiting for %s to come up" % (url,))
192+
193+
194+def get_manage_location():
195+ return 'django_project/manage.py'
196+
197+
198+# XXX: Copied from lp:pkgme-service. Extract to separate library.
199+class DjangoFixture(Fixture):
200+ """A simple Django service, with database.
201+
202+ Essentially does 'runserver'.
203+ """
204+
205+ def __init__(self, all_clear_path, port=8001):
206+ super(DjangoFixture, self).__init__()
207+ self._all_clear_path = all_clear_path
208+ # XXX: parallelism: Hard-code the port to run on for now. Don't know
209+ # how to figure out what port it's actually listening on.
210+ self._port = port
211+
212+ def setUp(self):
213+ super(DjangoFixture, self).setUp()
214+ process = subprocess.Popen(
215+ [sys.executable, get_manage_location(),
216+ 'runserver', '--noreload', str(self._port)],
217+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
218+ self.addCleanup(process.terminate)
219+ self.addCleanup(
220+ self.addDetail,
221+ 'runserver-log',
222+ Content(
223+ UTF8_TEXT,
224+ process.stdout.readlines))
225+ self.base_url = 'http://localhost:%s' % (self._port,)
226+ all_clear_url = '%s/%s' % (self.base_url, self._all_clear_path)
227+ try:
228+ poll_until_running(all_clear_url)
229+ except:
230+ # fixtures don't get cleaned up automatically if setUp fails.
231+ self.cleanUp()
232+ raise
233
234=== added file 'djlibdep/tests/test_interface.py'
235--- djlibdep/tests/test_interface.py 1970-01-01 00:00:00 +0000
236+++ djlibdep/tests/test_interface.py 2012-09-28 14:14:21 +0000
237@@ -0,0 +1,77 @@
238+"""Test the HTTP interface of libdep-service."""
239+
240+import json
241+from urllib2 import urlopen
242+
243+from devportalbinary.database import PackageDatabase
244+from devportalbinary.testing import (
245+ DatabaseConfig,
246+ PostgresDatabaseFixture,
247+ )
248+from fixtures import Fixture
249+from testresources import (
250+ FixtureResource,
251+ ResourcedTestCase,
252+ )
253+from testscenarios import generate_scenarios
254+from testtools import TestCase
255+
256+from .helpers import populate_sample_data
257+from .test_test_double import (
258+ test_double_fixture,
259+ )
260+from ._djangofixture import DjangoFixture
261+
262+
263+class RealServerFixture(Fixture):
264+
265+ def __init__(self, sample_data):
266+ super(RealServerFixture, self).__init__()
267+ self._sample_data = sample_data
268+
269+ def setUp(self):
270+ super(RealServerFixture, self).setUp()
271+ db_fixture = self.useFixture(PostgresDatabaseFixture())
272+ # This has to come first so the spawned Django server can inherit our
273+ # modified environment, and thus our modified configuration.
274+ self.useFixture(DatabaseConfig(db_fixture))
275+ django = self.useFixture(DjangoFixture('v1/service_check'))
276+ db = PackageDatabase(db_fixture.conn)
277+ self.addCleanup(db.close)
278+ populate_sample_data(db, self._sample_data)
279+ self.base_url = django.base_url
280+
281+real_server_fixture = FixtureResource(
282+ RealServerFixture(
283+ [('libc', {'i386': {'libc.so.6': 'libc-bin'}}),
284+ ]))
285+
286+
287+class InterfaceTests(TestCase, ResourcedTestCase):
288+
289+ scenarios = [
290+ ('real', {'resources': [('server', real_server_fixture)]}),
291+ ('double', {'resources': [('server', test_double_fixture)]}),
292+ ]
293+
294+ def test_service_check(self):
295+ url = '%s/v1/service_check' % (self.server.base_url,)
296+ data = urlopen(url).read()
297+ self.assertEqual('Hello world!', data)
298+
299+ def test_not_found(self):
300+ url = '%s/v1/get_binaries_for_libraries' % (self.server.base_url,)
301+ url += '?libs=doesnotexist'
302+ data = urlopen(url).read()
303+ self.assertEqual('{}', data)
304+
305+ def test_found(self):
306+ url = '%s/v1/get_binaries_for_libraries' % (self.server.base_url,)
307+ url += '?libs=libc.so.6'
308+ data = urlopen(url).read()
309+ self.assertEqual(json.dumps({'libc.so.6': ['libc-bin']}), data)
310+
311+
312+def load_tests(loader, tests, ignored):
313+ from unittest import TestSuite
314+ return TestSuite(generate_scenarios(tests))
315
316=== added file 'djlibdep/tests/test_test_double.py'
317--- djlibdep/tests/test_test_double.py 1970-01-01 00:00:00 +0000
318+++ djlibdep/tests/test_test_double.py 2012-09-28 14:14:21 +0000
319@@ -0,0 +1,132 @@
320+import json
321+import os
322+from pprint import pformat
323+import subprocess
324+import sys
325+from urllib2 import urlopen
326+
327+from fixtures import Fixture
328+from testtools import TestCase
329+from testresources import (
330+ FixtureResource,
331+ ResourcedTestCase,
332+ )
333+from treeshape import (
334+ CONTENT,
335+ FileTree,
336+ )
337+from twisted.web.resource import getChildForRequest
338+from twisted.web.test.test_web import DummyRequest
339+
340+from ..test_double import (
341+ StockResponse,
342+ )
343+
344+
345+def get_test_double_path():
346+ """Get the path to the test double executable."""
347+ # We are /blah/blah/djpkgme/tests/test_test_double.py. We want to get to
348+ # /blah/blah/bin/libdep-service-testd.
349+ up = os.path.dirname
350+ return os.path.join(up(up(up(__file__))), 'bin', 'libdep-service-testd')
351+
352+
353+class LibdepServiceDouble(Fixture):
354+
355+ def __init__(self, data):
356+ super(LibdepServiceDouble, self).__init__()
357+ self._data = data
358+
359+ def setUp(self):
360+ super(LibdepServiceDouble, self).setUp()
361+ tree = self.useFixture(
362+ FileTree({'data.json': {CONTENT: json.dumps(self._data)}}))
363+ data_path = tree.join('data.json')
364+ p = subprocess.Popen(
365+ [sys.executable, get_test_double_path(), data_path],
366+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
367+ self.addCleanup(p.terminate)
368+ # XXX: For better debug-ability.
369+ if p.poll() is not None:
370+ raise Exception(p.stderr.read())
371+ # XXX: Assume that the "I'm ready" message is only a single line.
372+ # XXX: Maybe make it JSON?
373+ self.base_url = p.stdout.readline().strip()
374+
375+
376+TEST_DATA = {
377+ 'v1/service_check': [({}, 'Hello world!')],
378+ 'v1/get_binaries_for_libraries': [
379+ ({'libs': ['doesnotexist']}, '{}'),
380+ ({'libs': ['libc.so.6']}, '{"libc.so.6": ["libc-bin"]}'),
381+ ],
382+ }
383+test_double_fixture = FixtureResource(LibdepServiceDouble(TEST_DATA))
384+
385+
386+class TestInterface(TestCase, ResourcedTestCase):
387+ """Tests for the public interface of the test double.
388+
389+ We want any & all clients to be able to use the test double for writing
390+ their tests, regardless of the language that they're written in. As such,
391+ the interface for the test double is "run a process". These tests very
392+ that we can run the process, feed it data, and shut it down.
393+ """
394+
395+ resources = [('double', test_double_fixture)]
396+
397+ def test_executable(self):
398+ # We can find and run the executable sanely. Using '--help' as an
399+ # option as that's unlikely to launch a web server and more likely to
400+ # terminate quickly!
401+ p = subprocess.Popen(
402+ [sys.executable, get_test_double_path(), '--help'],
403+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
404+ out, err = p.communicate()
405+ self.assertEqual(0, p.returncode)
406+
407+ def test_launches_web_server(self):
408+ fd = urlopen(self.double.base_url)
409+ self.assertEqual(200, fd.code)
410+
411+ def test_serves_simple_data_from_file(self):
412+ service_check_url = '%s/v1/service_check' % (self.double.base_url,)
413+ fd = urlopen(service_check_url)
414+ self.assertEqual('Hello world!', fd.read())
415+
416+
417+def _get_path(resource, path, args=None):
418+ request = DummyRequest(path)
419+ if args:
420+ request.args = args
421+ child = getChildForRequest(resource, request)
422+ return child.render(request)
423+
424+
425+class TestStockResponses(TestCase):
426+
427+ def test_simple_resource(self):
428+ resource = StockResponse({'foo': [({}, 'bar')]})
429+ result = _get_path(resource, ['foo'])
430+ self.assertEqual('bar', result)
431+
432+ def test_recursive_resource(self):
433+ resource = StockResponse({'foo/bar': [({}, 'baz')]})
434+ result = _get_path(resource, ['foo', 'bar'])
435+ self.assertEqual('baz', result)
436+
437+ def test_query_params(self):
438+ resource = StockResponse({'foo': [({'baz': 'bar'}, 'qux')]})
439+ result = _get_path(resource, ['foo'], {'baz': 'bar'})
440+ self.assertEqual('qux', result)
441+
442+ def test_not_found(self):
443+ resource = StockResponse({})
444+ result = _get_path(resource, ['foo'])
445+ self.assertIn('404', result)
446+
447+ def test_base(self):
448+ data = {'foo': [({}, 'bar')]}
449+ resource = StockResponse(data)
450+ result = _get_path(resource, [])
451+ self.assertIn(pformat(data), result)
452
453=== modified file 'setup.py'
454--- setup.py 2012-08-24 19:34:45 +0000
455+++ setup.py 2012-09-28 14:14:21 +0000
456@@ -37,8 +37,14 @@
457 # The 0.4 package of django-openid-auth doesn't depend on
458 # python-openid, so we have to list it here.
459 'python-openid>=2.2.5',
460+ 'Twisted',
461 'txstatsd',
462 ],
463+ entry_points = {
464+ 'console_scripts': [
465+ 'libdep-service-testd=djlibdep.test_double:main',
466+ ],
467+ },
468 zip_safe=False,
469 packages=find_packages('.'),
470 )
471
472=== added directory 'test-double'
473=== added file 'test-double/libdep_double.go'
474--- test-double/libdep_double.go 1970-01-01 00:00:00 +0000
475+++ test-double/libdep_double.go 2012-09-28 14:14:21 +0000
476@@ -0,0 +1,40 @@
477+package main
478+
479+import (
480+ "encoding/json"
481+ "fmt"
482+ "log"
483+ "net"
484+ "net/http"
485+ "os"
486+)
487+
488+type StartupDetails struct {
489+ Error string
490+ Addr string
491+}
492+
493+func main(){
494+ http.HandleFunc("/", sayHello)
495+ details := StartupDetails{}
496+ listner, err := net.Listen("tcp", "127.0.0.1:0")
497+ if err != nil {
498+ details.Error = err.Error()
499+ } else {
500+ details.Addr = listner.Addr().String()
501+ }
502+ enc := json.NewEncoder(os.Stdout)
503+ err = enc.Encode(details)
504+ if err != nil {
505+ log.Fatal(err)
506+ }
507+ err = http.Serve(listner, nil)
508+ if err != nil {
509+ log.Fatal("Serve:", err)
510+ }
511+}
512+
513+func sayHello(w http.ResponseWriter, req *http.Request) {
514+ fmt.Printf("New Request\n")
515+ fmt.Fprintf(w, "<h1>Go Say's Hello</h1><h2>(Via http)</h2>")
516+}
517
518=== modified file 'versions.cfg'
519--- versions.cfg 2012-09-13 22:20:17 +0000
520+++ versions.cfg 2012-09-28 14:14:21 +0000
521@@ -38,7 +38,7 @@
522 pep8 = 1.3.3
523 PIL = 1.1.7
524 pkgme = 0.4.1
525-pkgme-devportal = 0.4.3
526+pkgme-devportal = 0.4.5
527 postgresfixture = 0.1.2
528 psycopg2 = 2.4.5
529 pyflakes = 0.5.0
530@@ -50,8 +50,10 @@
531 south = 0.7.3
532 storm = 0.19
533 testresources = 0.2.5
534+testscenarios = 0.3
535 testtools = 0.9.16
536 treeshape = 0.2.1
537+Twisted = 12.1.0
538 # A pre-release of txstatsd as there hasn't been a real release yet.
539 # Can be replaced with the proper release when it happens.
540 # This dist was created from lp:~james-w/txstatsd/pre-release

Subscribers

People subscribed via source and target branches