~vila/uci-engine/integration-speed-up

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
#!/usr/bin/env python
# Ubuntu CI Engine
# Copyright 2014 Canonical Ltd.

# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3, as
# published by the Free Software Foundation.

# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranties of
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
# PURPOSE.  See the GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""The CI Engine test runner."""

import logging
import os
import subunit
import sys


HERE = os.path.abspath(os.path.dirname(__file__))

# deploy.py isn't in our pythonpath when run as a script
sys.path.append(os.path.join(HERE, 'juju-deployer'))
import deploy
from ucitests import (
    filters,
    loaders,
    matchers,
    runners,
    results,
)


class SysPath(object):
    """Add paths in front of sys.path temporarily."""

    def __init__(self, paths):
        self.paths = paths
        self.orig = sys.path[:]

    def __enter__(self):
        sys.path = self.paths + sys.path

    def __exit__(self, *args):
        sys.path = self.orig


def load_component_tests(loader, component_base_dir):
    """Load tests for a given component.

    A component is organized inside a given base directory containing files
    *and* a python directory with the code and the tests. We care about that
    python directory only.
    """
    # We set the current directory to the component base one and restore it
    # after loading. This could produce duplicate tests if different components
    # define the same python modules but this would be a bug in itself so we
    # just ignore the potential issue.
    suite = loader.suiteClass()
    with SysPath([component_base_dir]):
        sub_loader = loader.SubLoader(root=component_base_dir)
        suite.addTests(sub_loader.loadTestsFromTree('.'))
    return suite


def load_regular_component_tests(include_regexps, exclude_regexps=None):
    """Load tests matching inclusive and exclusive regexps.

    :param include_regexps: A list of regexps describing the tests to include.

    :param exclude_regexps: A list of regexps describing the tests to exclude.

    :return: The test suite for all collected tests.
    """
    components = ['ci-utils',
                  'cli',
                  'branch-source-builder',
                  'gatekeeper',
                  'image-builder',
                  'lander',
                  'test_runner',
                  'publisher',
                  ]
    loader = loaders.Loader()
    # setuptools tends to leave eggs all over the place
    loader.dir_matcher = matchers.NameMatcher(includes=[r'.*'],
                                              excludes=[r'\.egg$',
                                                        r'\.egg-info$',
                                                        ])
    suite = loader.suiteClass()
    for c in components:
        suite.addTests(load_component_tests(loader, c))
    suite = filters.include_regexps(include_regexps, suite)
    suite = filters.exclude_regexps(exclude_regexps, suite)
    return suite


def get_runner():
    """Return a test runner class tailored to django needs.


    The django design forces us to delay the imports so we can prepare the
    environment before django attempt to import modules and set some globals
    there.

    Since the imports needs to be delayed, that DjangoTestRunner class has to
    be defined inside a function so it can inherit from DjangoTestSuiteRunner.
    """
    from django.test import (
        simple,
        utils,
    )

    class DjangoTestRunner(simple.DjangoTestSuiteRunner):

        def __init__(self, component_base_dir, requires_south, out, result,
                     options,
                     **kwargs):
            super(DjangoTestRunner, self).__init__(**kwargs)
            self.component_base_dir = component_base_dir
            self.requires_south = requires_south
            self.out = out
            self.result = result
            self.options = options
            # Used to cache the test suite
            self._cached_test_suite = None

        def build_suite(self, *args, **kwargs):
            # We cache the test suite to ensure it's loaded only once. This is
            # used to workaround DjangoTestSuiteRunner closed design that
            # doesn't allow to hook that more precisely. Concretely,
            # DjangoTestSuiteRunner.run_tests() setup the databases even if
            # there are no tests to run, we take care of that in our own
            # run_tests() by not upcalling when the test suite is empty.
            if self._cached_test_suite is not None:
                return self._cached_test_suite
            loader = loaders.Loader()
            suite = load_component_tests(loader, self.component_base_dir)
            # Filtered as required by the user
            suite = filters.include_regexps(
                self.options.include_regexps, suite)
            suite = filters.exclude_regexps(
                self.options.exclude_regexps, suite)
            self._cached_test_suite = suite
            return suite

        def run_tests(self, test_labels, extra_tests=None, **kwargs):
            # We load the tests early to avoid the costly database setup when
            # it's not needed.
            suite = self.build_suite(test_labels, extra_tests, **kwargs)
            if self.options.list_only:
                ret = runners.list_tests(suite, self.out)
                return ret

            if suite.countTestCases() == 0:
                # No tests to run, we let the caller handle the case where no
                # tests are run at all which is an error. By loading the test
                # suite early we avoid calling
                # DjangoTestSuiteRunner.run_tests() which will setup the
                # databases even if the test suite is empty.
                return 0

            # South needs to patch the way the db is created/synced.
            if self.requires_south:
                from south.management import commands
                commands.patch_for_test_db_setup()

            # Otherwise let the base class handle the setup and the run
            return super(DjangoTestRunner, self).run_tests(
                test_labels, extra_tests=None, **kwargs)

        def run_suite(self, suite, **kwargs):
            suite.run(self.result)
            return self.result

    return DjangoTestRunner


class IsolatedImportAndLogging(object):
    """Protect sys.modules, sys.path and logging.root

    This allows running django tests which relies on a lot of global context by
    taking a snapshot of 'sys.path', 'sys.modules' and 'logging.root' when
    entering the context and restoring them afterwards. As such, it creates a
    context where django can freely setup globals in modules all over the place
    without interfering with the outside.

    Several django apps can therefore be tested during the same test run
    without stepping on each other toes.
    """

    def __init__(self):
        # Keep copies of the right objects to be able to restore them
        self.paths = list(sys.path)
        self.modules = sys.modules.copy()
        self.root = logging.root

    def __enter__(self):
        logging.root.handlers = []
        logging.root.setLevel(logging.WARNING)

    def __exit__(self, *args):
        # Restore the copied list
        sys.path = self.paths
        # Remove all added modules
        added = [m for m in sys.modules.keys() if m not in self.modules]
        # Don't mess up with bzrlib modules, they use a lazy import mechanism
        # that doesn't cope with restoring modules this way.
        added = [m for m in added if not m.startswith('bzrlib')]
        if added:
            for m in added:
                del sys.modules[m]
        # Restore deleted or modified modules
        sys.modules.update(self.modules)
        logging.root = self.root


def run_django_tests(component_base_dir, component_name, requires_south,
                     out, result, options):
    """Run tests for a django app.

    :param component_base_dir: Where the component files are.

    :param component_name: The name of the component acting as a base for its
        python name space.

    :param requires_south: Whether the component uses the 'south' module and
        needs to be setup accordingly.

    :param out: The output stream to use.

    :param result: The test result object to use for the run.

    :param options: A dict with the options from the command line.

    :return: 0 on success, 1 on failure.
    """
    with IsolatedImportAndLogging():
        # We don't have to use setdefault here because we don't have (and don't
        # want !) to support overriding. We want to test with the default
        # values defined in the project settings.py
        settings_path = '{}.settings'.format(component_name)
        os.environ["DJANGO_SETTINGS_MODULE"] = settings_path
        kls = get_runner()
        test_runner = kls(component_base_dir, requires_south,
                          out, result, options)
        failures = test_runner.run_tests([])
        return failures


def load_orphan_tests(include_regexps, exclude_regexps=None):
    """Load tests matching inclusive and exclusive regexps.

    :param include_regexps: A list of regexps describing the tests to include.

    :param exclude_regexps: A list of regexps describing the tests to exclude.

    :return: The test suite for all collected tests.
    """
    components = ['juju-deployer']
    loader = loaders.Loader()
    suite = loader.suiteClass()
    for c in components:
        suite.addTests(load_component_tests(loader, c))
    suite = filters.include_regexps(include_regexps, suite)
    suite = filters.exclude_regexps(exclude_regexps, suite)
    return suite


def load_integration_tests(include_regexps, exclude_regexps=None):
    """Load tests matching inclusive and exclusive regexps.

    :param include_regexps: A list of regexps describing the tests to include.

    :param exclude_regexps: A list of regexps describing the tests to exclude.

    :return: The test suite for all collected tests.
    """
    components = ['tests']
    loader = loaders.Loader()
    suite = loader.suiteClass()
    for c in components:
        suite.addTests(load_component_tests(loader, c))
    suite = filters.include_regexps(include_regexps, suite)
    suite = filters.exclude_regexps(exclude_regexps, suite)
    return suite


def load_charm_tests(include_regexps, exclude_regexps=None):
    """Load charm unittest from <charm>/unit_test."""
    charms = ['charms/precise/restish']
    loader = loaders.Loader()
    suite = loader.suiteClass()
    for c in charms:
        with SysPath([c, os.path.join(c, 'hooks')]):
            sub_loader = loader.SubLoader(root=c)
            # Load only python tests from 'unit_tests' (that's specific
            # for uci-engine charms).
            suite.addTests(sub_loader.loadTestsFromTree('unit_tests'))
    suite = filters.include_regexps(include_regexps, suite)
    suite = filters.exclude_regexps(exclude_regexps, suite)
    return suite


def run_regular_tests(suite, out, result, options):
    """Run the given test suite under some isolation.

    :param suite: The test suite to run.

    :param out: The output stream to use.

    :param result: The test result object to use for the run.

    :param options: A dict with the options from the command line.

    :return: 0 on success, 1 on failure.
    """
    with IsolatedImportAndLogging():
        if options.list_only:
            # List the tests without running them
            ret = runners.list_tests(suite, out)
        else:
            suite.run(result)
            # TODO: This logic is worth putting into the test result class
            # itself or at least a helper should be provided by uci-tests
            # itself. -- vila 2014-02-08
            ret = int(not (result.wasSuccessful()
                           and result.testsRun > 0))
        return ret


def main(args, stdout, stderr):
    # Interpret user wishes
    parser = runners.RunTestsArgParser()
    options = parser.parse_args(args)
    if options.list_only:
        # We won't run tests, we won't need a test result
        result = None
    else:
        # Run the tests with the required output
        if options.format == 'text':
            result = results.TextResult(stdout, verbosity=2)
        else:
            result = subunit.TestProtocolClient(stdout)
        # We'll run tests, setup the result accordingly
        result.startTestRun()
    # Load the tests, keeping only the ones required by the user
    suite = load_regular_component_tests(options.include_regexps,
                                         options.exclude_regexps)
    if options.concurrency > 1:
        suite = testtools.ConcurrentTestSuite(
            suite, runners.fork_for_tests(options.concurrency))
    ret = 0
    # Regular components
    reg_rc = run_regular_tests(suite, stdout, result, options)
    ret = reg_rc or ret
    # orphan tests that don't have a proper home yet
    suite = load_orphan_tests(options.include_regexps,
                              options.exclude_regexps)
    orphan_rc = run_regular_tests(suite, stdout, result, options)
    ret = orphan_rc or ret
    # Django-based components
    with IsolatedImportAndLogging():
        ppa_rc =  run_django_tests('ppa-assigner', 'ppa_assigner', True,
                                   stdout, result, options)
        ret = ppa_rc or ret
    with IsolatedImportAndLogging():
        ts_rc = run_django_tests('ticket_system', 'ticket_system', True,
                                 stdout, result, options)
        ret = ts_rc or ret
    # charms and integration tests have a common setup
    with IsolatedImportAndLogging(), SysPath(['ci-utils']):
        # FIXME: We need some more setup here that tests themselves should do
        # but they aren't ripe for that yet -- vila 2014-04-11
        branches = os.path.join(HERE, 'branches')
        deploy.build_sourcedeps(branches)
        deploy.setup(for_tests=True, list_only=options.list_only)
        # Charm tests
        charm_suite = load_charm_tests(options.include_regexps,
                                       options.exclude_regexps)

        charm_rc = run_regular_tests(charm_suite, stdout, result, options)
        ret = charm_rc or ret
        # Integration tests
        suite = load_integration_tests(options.include_regexps,
                                       options.exclude_regexps)
        itg_rc = run_regular_tests(suite, stdout, result, options)
        ret = itg_rc or ret

    if not options.list_only:
        # We did run tests, stop the run
        result.stopTestRun()
    # Fails if at least one fail
    return int(ret)


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:], sys.stdout, sys.stderr))