Merge lp:~stub/charm-helpers/test-harness into lp:charm-helpers

Proposed by Stuart Bishop
Status: Rejected
Rejected by: Stuart Bishop
Proposed branch: lp:~stub/charm-helpers/test-harness
Merge into: lp:charm-helpers
Prerequisite: lp:~stub/charm-helpers/bug-1214793-service-wrappers
Diff against target: 247 lines (+232/-0)
3 files modified
charmhelpers/testing/README (+36/-0)
charmhelpers/testing/__init__.py (+3/-0)
charmhelpers/testing/jujufixture.py (+193/-0)
To merge this branch: bzr merge lp:~stub/charm-helpers/test-harness
Reviewer Review Type Date Requested Status
Matthew Wedgwood (community) Needs Fixing
Charm Helper Maintainers Pending
Review via email: mp+181865@code.launchpad.net

Description of the change

This is a Python test fixture for driving Juju, simplifying the task of writing charm tests.

charm-helpers seems a suitable home for this. contrib for now, as the API will likely change when Juju features like containers land.

To post a comment you must log in.
Revision history for this message
Matthew Wedgwood (mew) wrote :

I think this is great, and and I agree that charmhelpers is the right place for this. Here's what I think it needs before merge:

* Combine the two modules into one. run() looks lonely.
* make lint, fix the issues
* Install python-fixtures for the user if they don't already have it installed. See charmhelpers.fetch.bzrurl.
* Move this to charmhelpers/testing/__init__.py (out of contrib)
* Add some docs, even if it's simply a description and example in the module docstring.

Thanks!

review: Needs Fixing
lp:~stub/charm-helpers/test-harness updated
44. By Stuart Bishop

Remove run.py

45. By Stuart Bishop

Move out of contrib

46. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

47. By Stuart Bishop

delint

48. By Stuart Bishop

Some basic docs for the testing infrastructure

49. By Stuart Bishop

update docs

Revision history for this message
Stuart Bishop (stub) wrote :

All done, apart from installing python-fixtures automatically as it doesn't make sense for this use case.

Revision history for this message
Stuart Bishop (stub) wrote :

I'm thinking that a better home would be in amulet. It is already duplicating some of its functionality.

lp:~stub/charm-helpers/test-harness updated
50. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

Unmerged revisions

52. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

51. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

50. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

49. By Stuart Bishop

update docs

48. By Stuart Bishop

Some basic docs for the testing infrastructure

47. By Stuart Bishop

delint

46. By Stuart Bishop

Merged bug-1214793-service-wrappers into test-harness.

45. By Stuart Bishop

Move out of contrib

44. By Stuart Bishop

Remove run.py

43. By Stuart Bishop

Extract JujuFixture from postgresql charm

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'charmhelpers/testing'
2=== added file 'charmhelpers/testing/README'
3--- charmhelpers/testing/README 1970-01-01 00:00:00 +0000
4+++ charmhelpers/testing/README 2013-10-09 09:59:44 +0000
5@@ -0,0 +1,36 @@
6+This package provides a Juju test fixture to help you write tests for
7+your charms as a Python test suite. It requires you to already have a
8+bootstrapped environment.
9+
10+Below is a simple example using the fixture. You can find a more extensive
11+example in the PostgreSQL charm.
12+
13+#!/usr/bin/python
14+import os
15+import fixtures
16+import testtools
17+import unittest
18+from charmhelpers.testing.jujufixture import JujuFixture
19+
20+
21+class CharmTestCase(testtools.TestCase, fixtures.TestWithFixtures):
22+
23+ def setUp(self):
24+ super(CharmTestCase, self).setUp()
25+
26+ self.juju = self.useFixture(JujuFixture(reuse_machines=True))
27+
28+ # If the charms fail, we don't want tests to hang indefinitely.
29+ timeout = int(os.environ.get('TEST_TIMEOUT', 900))
30+ self.useFixture(fixtures.Timeout(timeout, gentle=True))
31+
32+ def test_basic(self):
33+ self.juju.deploy('local:mycharm', 'test-mycharm', num_units=1)
34+ self.juju.add_unit('local:mycharm', 'test-mycharm', num_units=2)
35+ self.juju.deploy('local:othercharm')
36+ self.juju.do(['add-relation', 'test-mycharm:rel', 'othercharm:rel'])
37+ self.juju.wait_until_ready()
38+ [ ... test the deployment ... ]
39+
40+if __name__ == '__main__':
41+ unittest.main()
42
43=== added file 'charmhelpers/testing/__init__.py'
44--- charmhelpers/testing/__init__.py 1970-01-01 00:00:00 +0000
45+++ charmhelpers/testing/__init__.py 2013-10-09 09:59:44 +0000
46@@ -0,0 +1,3 @@
47+from jujufixture import JujuFixture, run
48+_ = JujuFixture, run
49+del _
50
51=== added file 'charmhelpers/testing/jujufixture.py'
52--- charmhelpers/testing/jujufixture.py 1970-01-01 00:00:00 +0000
53+++ charmhelpers/testing/jujufixture.py 2013-10-09 09:59:44 +0000
54@@ -0,0 +1,193 @@
55+import json
56+import subprocess
57+import time
58+
59+import fixtures
60+from testtools.content import text_content
61+
62+
63+__all__ = ['JujuFixture', 'run']
64+
65+
66+class JujuFixture(fixtures.Fixture):
67+ """Interact with juju.
68+
69+ Assumes juju environment is bootstrapped.
70+ """
71+
72+ def __init__(self, reuse_machines=False, do_teardown=True):
73+ super(JujuFixture, self).__init__()
74+
75+ self._deployed_charms = set()
76+
77+ self.reuse_machines = reuse_machines
78+
79+ # Optionally, don't teardown services and machines after running
80+ # a test. If a subsequent test is run, they will be torn down at
81+ # that point. This option is only useful when running a single
82+ # test, or when the test harness is set to abort after the first
83+ # failed test.
84+ self.do_teardown = do_teardown
85+
86+ self._deployed_services = set()
87+
88+ def do(self, cmd):
89+ cmd = ['juju'] + cmd
90+ run(self, cmd)
91+
92+ def get_result(self, cmd):
93+ cmd = ['juju'] + cmd + ['--format=json']
94+ out = run(self, cmd)
95+ if out:
96+ return json.loads(out)
97+ return None
98+
99+ def deploy(self, charm, name=None, num_units=1):
100+ # The first time we deploy a local: charm in the test run, it
101+ # needs to deploy with --update to ensure we are testing the
102+ # desired revision of the charm. Subsequent deploys we do not
103+ # use --update to avoid overhead and needless incrementing of the
104+ # revision number.
105+ if not charm.startswith('local:') or charm in self._deployed_charms:
106+ cmd = ['deploy']
107+ else:
108+ cmd = ['deploy', '-u']
109+ self._deployed_charms.add(charm)
110+
111+ cmd.append(charm)
112+
113+ if name is None:
114+ name = charm.split(':', 1)[-1]
115+
116+ cmd.append(name)
117+ self._deployed_services.add(name)
118+
119+ if self.reuse_machines and self._free_machines:
120+ cmd.extend(['--to', str(self._free_machines.pop())])
121+ self.do(cmd)
122+ if num_units > 1:
123+ self.add_unit(charm, name, num_units - 1)
124+ else:
125+ cmd.extend(['-n', str(num_units)])
126+ self.do(cmd)
127+
128+ def add_unit(self, charm, name=None, num_units=1):
129+ if name is None:
130+ name = charm.split(':', 1)[-1]
131+
132+ num_units_spawned = 0
133+ while self.reuse_machines and self._free_machines:
134+ cmd = ['add-unit', '--to', str(self._free_machines.pop()), name]
135+ self.do(cmd)
136+ num_units_spawned += 1
137+ if num_units_spawned == num_units:
138+ return
139+
140+ cmd = ['add-unit', '-n', str(num_units - num_units_spawned), name]
141+ self.do(cmd)
142+
143+ # The most recent environment status, updated by refresh_status()
144+ status = None
145+
146+ def refresh_status(self):
147+ self.status = self.get_result(['status'])
148+
149+ self._free_machines = set(
150+ int(k) for k in self.status['machines'].keys()
151+ if k != '0')
152+ for service in self.status.get('services', {}).values():
153+ for unit in service.get('units', []):
154+ if 'machine' in unit:
155+ self._free_machines.remove(int(unit['machine']))
156+
157+ return self.status
158+
159+ def wait_until_ready(self, extra=45):
160+ ready = False
161+ while not ready:
162+ self.refresh_status()
163+ ready = True
164+ for service in self.status['services']:
165+ if self.status['services'][service].get('life', '') == 'dying':
166+ ready = False
167+ units = self.status['services'][service].get('units', {})
168+ for unit in units.keys():
169+ agent_state = units[unit].get('agent-state', '')
170+ if agent_state == 'error':
171+ raise RuntimeError('{} error: {}'.format(
172+ unit, units[unit].get('agent-state-info', '')))
173+ if agent_state != 'started':
174+ ready = False
175+ time.sleep(1)
176+ # Unfortunately, there is no way to tell when a system is
177+ # actually ready for us to test. Juju only tells us that a
178+ # relation has started being setup, and that no errors have been
179+ # encountered yet. It utterly fails to inform us when the
180+ # cascade of hooks this triggers has finished and the
181+ # environment is in a stable and actually testable state.
182+ # So as a work around for Bug #1200267, we need to sleep long
183+ # enough that our system is probably stable. This means we have
184+ # extremely slow and flaky tests, but that is possibly better
185+ # than no tests.
186+ time.sleep(extra)
187+
188+ def setUp(self):
189+ super(JujuFixture, self).setUp()
190+ self.reset()
191+ if self.do_teardown:
192+ self.addCleanup(self.reset)
193+
194+ def reset(self):
195+ # Tear down any services left running that we know we spawned.
196+ while True:
197+ found_services = False
198+ self.refresh_status()
199+
200+ # Kill any services started by the deploy() method.
201+ for service_name, service in self.status.get(
202+ 'services', {}).items():
203+ if service_name in self._deployed_services:
204+ found_services = True
205+ if service.get('life', '') != 'dying':
206+ self.do(['destroy-service', service_name])
207+ # If any units have failed hooks, unstick them.
208+ for unit_name, unit in service.get('units', {}).items():
209+ if unit.get('agent-state', None) == 'error':
210+ self.do(['resolved', unit_name])
211+ if not found_services:
212+ break
213+ time.sleep(1)
214+
215+ self._deployed_services = set()
216+
217+ # We need to wait for dying services
218+ # to die before we can continue.
219+ if found_services:
220+ self.wait_until_ready(0)
221+
222+ # We shouldn't reuse machines, as we have no guarantee they are
223+ # still in a usable state, so tear them down too. Per
224+ # Bug #1190492 (INVALID), in the future this will be much nicer
225+ # when we can use containers for isolation and can happily reuse
226+ # machines.
227+ if not self.reuse_machines:
228+ self.do(['terminate-machine'] + list(self._free_machines))
229+
230+
231+def run(detail_collector, cmd, input=''):
232+ try:
233+ proc = subprocess.Popen(
234+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
235+ stderr=subprocess.PIPE)
236+ except subprocess.CalledProcessError:
237+ raise
238+
239+ (out, err) = proc.communicate(input)
240+ if out:
241+ detail_collector.addDetail('stdout', text_content(out))
242+ if err:
243+ detail_collector.addDetail('stderr', text_content(err))
244+ if proc.returncode != 0:
245+ raise subprocess.CalledProcessError(
246+ proc.returncode, cmd, err)
247+ return out

Subscribers

People subscribed via source and target branches