2014-12-15 13:04:08 +00:00
|
|
|
#
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
|
|
# not use this file except in compliance with the License. You may obtain
|
|
|
|
# a copy of the License at
|
|
|
|
#
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
#
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
|
|
# License for the specific language governing permissions and limitations
|
|
|
|
# under the License.
|
|
|
|
"""Generate HTTP tests from YAML files
|
|
|
|
|
|
|
|
Each HTTP request is its own TestCase and can be requested to be run in
|
2015-04-08 00:00:03 +01:00
|
|
|
isolation from other tests. If it is a member of a sequence of requests,
|
2014-12-15 13:04:08 +00:00
|
|
|
prior requests will be run.
|
|
|
|
|
|
|
|
A sequence is represented by an ordered list in a single YAML file.
|
|
|
|
|
|
|
|
Each sequence becomes a TestSuite.
|
|
|
|
|
|
|
|
An entire directory of YAML files is a TestSuite of TestSuites.
|
|
|
|
"""
|
2014-10-29 16:05:26 +00:00
|
|
|
|
2015-04-20 18:39:54 +01:00
|
|
|
import copy
|
2014-10-29 16:05:26 +00:00
|
|
|
import glob
|
2014-12-27 18:50:05 +00:00
|
|
|
import inspect
|
2016-03-10 14:33:37 +00:00
|
|
|
import io
|
2014-10-29 16:05:26 +00:00
|
|
|
import os
|
2016-04-05 15:20:52 +01:00
|
|
|
import unittest
|
2014-12-15 13:04:08 +00:00
|
|
|
from unittest import suite
|
2014-12-15 21:55:14 +00:00
|
|
|
import uuid
|
2014-10-29 16:05:26 +00:00
|
|
|
|
2015-04-20 18:39:54 +01:00
|
|
|
import six
|
2014-10-30 15:11:47 +00:00
|
|
|
import yaml
|
2014-10-29 16:05:26 +00:00
|
|
|
|
2015-01-26 19:49:42 +00:00
|
|
|
from gabbi import case
|
2015-03-27 16:33:53 +00:00
|
|
|
from gabbi import handlers
|
2015-08-09 18:13:05 +01:00
|
|
|
from gabbi import httpclient
|
2015-01-26 19:49:42 +00:00
|
|
|
from gabbi import suite as gabbi_suite
|
2014-12-27 23:39:39 +00:00
|
|
|
|
2015-10-11 11:30:46 +02:00
|
|
|
|
2015-03-27 16:33:53 +00:00
|
|
|
RESPONSE_HANDLERS = [
|
2015-12-09 18:11:53 +00:00
|
|
|
handlers.ForbiddenHeadersResponseHandler,
|
2015-07-23 12:13:24 +01:00
|
|
|
handlers.HeadersResponseHandler,
|
2015-03-27 16:33:53 +00:00
|
|
|
handlers.StringResponseHandler,
|
|
|
|
handlers.JSONResponseHandler,
|
|
|
|
]
|
|
|
|
|
2014-10-30 15:11:47 +00:00
|
|
|
|
2015-07-22 17:49:53 +01:00
|
|
|
class GabbiFormatError(ValueError):
|
|
|
|
"""An exception to encapsulate poorly formed test data."""
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2015-10-08 19:00:21 +01:00
|
|
|
class TestMaker(object):
|
|
|
|
"""A class for encapsulating test invariants.
|
|
|
|
|
|
|
|
All of the tests in a single gabbi file have invariants which are
|
|
|
|
provided when creating each HTTPTestCase. It is not useful
|
|
|
|
to pass these around when making each test case. So they are
|
|
|
|
wrapped in this class which then has make_one_test called multiple
|
|
|
|
times to generate all the tests in the suite.
|
|
|
|
"""
|
|
|
|
|
2015-10-15 15:09:22 +02:00
|
|
|
def __init__(self, test_base_name, test_defaults, test_directory,
|
2015-10-10 18:00:46 +01:00
|
|
|
fixture_classes, loader, host, port, intercept, prefix):
|
2015-10-08 19:00:21 +01:00
|
|
|
self.test_base_name = test_base_name
|
2015-10-15 15:09:22 +02:00
|
|
|
self.test_defaults = test_defaults
|
|
|
|
self.default_keys = set(test_defaults.keys())
|
2015-10-08 19:00:21 +01:00
|
|
|
self.test_directory = test_directory
|
|
|
|
self.fixture_classes = fixture_classes
|
2015-10-10 18:00:46 +01:00
|
|
|
self.host = host
|
|
|
|
self.port = port
|
2015-10-08 19:00:21 +01:00
|
|
|
self.loader = loader
|
2015-10-10 18:00:46 +01:00
|
|
|
self.intercept = intercept
|
|
|
|
self.prefix = prefix
|
2015-10-08 19:00:21 +01:00
|
|
|
|
2015-10-15 15:09:22 +02:00
|
|
|
def make_one_test(self, test_dict, prior_test):
|
2015-10-08 19:00:21 +01:00
|
|
|
"""Create one single HTTPTestCase.
|
|
|
|
|
|
|
|
The returned HTTPTestCase is added to the TestSuite currently
|
|
|
|
being built (one per YAML file).
|
|
|
|
"""
|
2015-10-15 15:09:22 +02:00
|
|
|
test = copy.deepcopy(self.test_defaults)
|
2015-10-08 19:00:21 +01:00
|
|
|
try:
|
2015-10-15 15:09:22 +02:00
|
|
|
test_update(test, test_dict)
|
2015-10-08 19:00:21 +01:00
|
|
|
except KeyError as exc:
|
|
|
|
raise GabbiFormatError('invalid key in test: %s' % exc)
|
|
|
|
except AttributeError as exc:
|
2015-10-15 15:09:22 +02:00
|
|
|
if not isinstance(test_dict, dict):
|
2015-10-08 19:00:21 +01:00
|
|
|
raise GabbiFormatError(
|
2015-10-15 15:09:22 +02:00
|
|
|
'test chunk is not a dict at "%s"' % test_dict)
|
2015-10-08 19:00:21 +01:00
|
|
|
else:
|
2015-10-10 18:00:46 +01:00
|
|
|
# NOTE(cdent): Not clear this can happen but just in case.
|
2015-10-08 19:00:21 +01:00
|
|
|
raise GabbiFormatError(
|
2015-10-15 15:09:22 +02:00
|
|
|
'malformed test chunk "%s": %s' % (test_dict, exc))
|
2015-10-08 19:00:21 +01:00
|
|
|
|
2015-10-19 11:07:10 +01:00
|
|
|
test_name = self._set_test_name(test)
|
|
|
|
self._set_test_method_and_url(test, test_name)
|
|
|
|
self._validate_keys(test, test_name)
|
2015-10-10 18:00:46 +01:00
|
|
|
|
|
|
|
http_class = httpclient.get_http(verbose=test['verbose'],
|
|
|
|
caption=test['name'])
|
|
|
|
|
|
|
|
# Use metaclasses to build a class of the necessary type
|
|
|
|
# and name with relevant arguments.
|
2015-10-19 11:07:10 +01:00
|
|
|
klass = TestBuilder(test_name, (case.HTTPTestCase,),
|
2015-10-10 18:00:46 +01:00
|
|
|
{'test_data': test,
|
|
|
|
'test_directory': self.test_directory,
|
|
|
|
'fixtures': self.fixture_classes,
|
|
|
|
'http': http_class,
|
|
|
|
'host': self.host,
|
|
|
|
'intercept': self.intercept,
|
|
|
|
'port': self.port,
|
|
|
|
'prefix': self.prefix,
|
|
|
|
'prior': prior_test})
|
|
|
|
|
|
|
|
tests = self.loader.loadTestsFromTestCase(klass)
|
|
|
|
# Return the first (and only) test in the klass.
|
|
|
|
return tests._tests[0]
|
|
|
|
|
|
|
|
def _set_test_name(self, test):
|
|
|
|
"""Set the name of the test
|
|
|
|
|
|
|
|
The original name is lowercased and spaces are replaces with '_'.
|
|
|
|
The result is appended to the test_base_name, which is based on the
|
|
|
|
name of the input data file.
|
|
|
|
"""
|
2015-10-08 19:00:21 +01:00
|
|
|
if not test['name']:
|
|
|
|
raise GabbiFormatError('Test name missing in a test in %s.'
|
|
|
|
% self.test_base_name)
|
2015-10-19 11:07:10 +01:00
|
|
|
return '%s_%s' % (self.test_base_name,
|
|
|
|
test['name'].lower().replace(' ', '_'))
|
2015-10-10 18:00:46 +01:00
|
|
|
|
|
|
|
@staticmethod
|
2015-10-19 11:07:10 +01:00
|
|
|
def _set_test_method_and_url(test, test_name):
|
2015-10-10 18:00:46 +01:00
|
|
|
"""Extract the base URL and method for this test.
|
2015-10-08 19:00:21 +01:00
|
|
|
|
2015-10-10 18:00:46 +01:00
|
|
|
If there is an upper case key in the test, that is used as the
|
|
|
|
method and the value is used as the URL. If there is more than
|
|
|
|
one uppercase that is a GabbiFormatError.
|
|
|
|
|
|
|
|
If there is no upper case key then 'url' must be present.
|
|
|
|
"""
|
2015-10-08 19:00:21 +01:00
|
|
|
method_key = None
|
|
|
|
for key, val in six.iteritems(test):
|
|
|
|
if _is_method_shortcut(key):
|
|
|
|
if method_key:
|
|
|
|
raise GabbiFormatError(
|
|
|
|
'duplicate method/URL directive in "%s"' %
|
2015-10-19 11:07:10 +01:00
|
|
|
test_name)
|
2015-10-08 19:00:21 +01:00
|
|
|
|
|
|
|
test['method'] = key
|
|
|
|
test['url'] = val
|
|
|
|
method_key = key
|
|
|
|
if method_key:
|
|
|
|
del test[method_key]
|
|
|
|
|
|
|
|
if not test['url']:
|
|
|
|
raise GabbiFormatError('Test url missing in test %s.'
|
2015-10-19 11:07:10 +01:00
|
|
|
% test_name)
|
2015-10-10 18:00:46 +01:00
|
|
|
|
2015-10-19 11:07:10 +01:00
|
|
|
def _validate_keys(self, test, test_name):
|
2015-10-10 18:00:46 +01:00
|
|
|
"""Check for invalid keys.
|
2015-10-08 19:00:21 +01:00
|
|
|
|
2015-10-10 18:00:46 +01:00
|
|
|
If there are any, raise a GabbiFormatError.
|
|
|
|
"""
|
2015-10-15 15:09:22 +02:00
|
|
|
test_keys = set(test.keys())
|
|
|
|
if test_keys != self.default_keys:
|
2015-10-08 19:00:21 +01:00
|
|
|
raise GabbiFormatError(
|
|
|
|
'Invalid test keys used in test %s: %s'
|
2015-10-19 11:07:10 +01:00
|
|
|
% (test_name,
|
2015-10-15 15:09:22 +02:00
|
|
|
', '.join(list(test_keys - self.default_keys))))
|
2015-10-08 19:00:21 +01:00
|
|
|
|
|
|
|
|
2014-12-15 13:33:00 +00:00
|
|
|
class TestBuilder(type):
|
2015-02-10 22:51:45 +00:00
|
|
|
"""Metaclass to munge a dynamically created test."""
|
2014-12-15 13:33:00 +00:00
|
|
|
|
|
|
|
required_attributes = {'has_run': False}
|
|
|
|
|
|
|
|
def __new__(mcs, name, bases, attributes):
|
|
|
|
attributes.update(mcs.required_attributes)
|
|
|
|
return type.__new__(mcs, name, bases, attributes)
|
|
|
|
|
|
|
|
|
2014-12-27 18:50:05 +00:00
|
|
|
def build_tests(path, loader, host=None, port=8001, intercept=None,
|
2015-03-28 18:07:12 +00:00
|
|
|
test_loader_name=None, fixture_module=None,
|
2015-10-08 17:54:00 +01:00
|
|
|
response_handlers=None, prefix=''):
|
2014-12-15 13:04:08 +00:00
|
|
|
"""Read YAML files from a directory to create tests.
|
|
|
|
|
|
|
|
Each YAML file represents an ordered sequence of HTTP requests.
|
2015-06-17 12:04:25 +01:00
|
|
|
|
|
|
|
:param path: The directory where yaml files are located.
|
|
|
|
:param loader: The TestLoader.
|
2015-06-17 12:16:24 +01:00
|
|
|
:param host: The host to test against. Do not use with ``intercept``.
|
2015-06-17 12:04:25 +01:00
|
|
|
:param port: The port to test against. Used with ``host``.
|
|
|
|
:param intercept: WSGI app factory for wsgi-intercept.
|
|
|
|
:param test_loader_name: Base name for test classes. Rarely used.
|
|
|
|
:param fixture_module: Python module containing fixture classes.
|
|
|
|
:param response_handers: ResponseHandler classes.
|
|
|
|
:type response_handlers: List of ResponseHandler classes.
|
|
|
|
:param prefix: A URL prefix for all URLs that are not fully qualified.
|
2015-06-17 12:16:24 +01:00
|
|
|
:rtype: TestSuite containing multiple TestSuites (one for each YAML file).
|
2014-12-15 13:04:08 +00:00
|
|
|
"""
|
2015-03-10 16:04:54 +00:00
|
|
|
|
2015-10-11 12:03:07 +01:00
|
|
|
# Exit immediately if we have no host to access, either via a real host
|
|
|
|
# or an intercept.
|
2015-05-26 23:04:37 +01:00
|
|
|
if not (bool(host) ^ bool(intercept)):
|
|
|
|
raise AssertionError('must specify exactly one of host or intercept')
|
2015-03-10 16:04:54 +00:00
|
|
|
|
2015-01-14 14:31:10 +00:00
|
|
|
if test_loader_name is None:
|
|
|
|
test_loader_name = inspect.stack()[1]
|
|
|
|
test_loader_name = os.path.splitext(os.path.basename(
|
|
|
|
test_loader_name[1]))[0]
|
2014-12-15 21:55:14 +00:00
|
|
|
|
2015-10-18 13:59:51 +02:00
|
|
|
# Initialize response handlers.
|
|
|
|
response_handlers = response_handlers or []
|
2015-03-28 18:07:12 +00:00
|
|
|
for handler in RESPONSE_HANDLERS + response_handlers:
|
2015-03-27 16:33:53 +00:00
|
|
|
handler(case.HTTPTestCase)
|
|
|
|
|
2015-10-18 13:59:51 +02:00
|
|
|
top_suite = suite.TestSuite()
|
|
|
|
for test_file in glob.iglob('%s/*.yaml' % path):
|
2015-03-10 16:04:54 +00:00
|
|
|
if intercept:
|
|
|
|
host = str(uuid.uuid4())
|
2015-10-15 15:09:22 +02:00
|
|
|
suite_dict = load_yaml(test_file)
|
2015-10-14 10:54:00 +01:00
|
|
|
test_base_name = '%s_%s' % (
|
|
|
|
test_loader_name, os.path.splitext(os.path.basename(test_file))[0])
|
2015-10-15 15:09:22 +02:00
|
|
|
file_suite = test_suite_from_dict(loader, test_base_name, suite_dict,
|
2015-03-10 16:04:54 +00:00
|
|
|
path, host, port, fixture_module,
|
2015-06-17 12:04:25 +01:00
|
|
|
intercept, prefix)
|
2015-03-10 16:04:54 +00:00
|
|
|
top_suite.addTest(file_suite)
|
2014-12-12 14:44:33 +00:00
|
|
|
return top_suite
|
2014-12-15 13:33:00 +00:00
|
|
|
|
|
|
|
|
2016-04-05 15:20:52 +01:00
|
|
|
def py_test_generator(test_dir, host=None, port=8001, intercept=None,
|
|
|
|
prefix=None, test_loader_name=None,
|
|
|
|
fixture_module=None, response_handlers=None):
|
|
|
|
"""Generate tests cases for py.test
|
|
|
|
|
|
|
|
This uses build_tests to create TestCases and then yields them in
|
|
|
|
a way that pytest can handle.
|
|
|
|
"""
|
|
|
|
loader = unittest.TestLoader()
|
|
|
|
tests = build_tests(test_dir, loader, host=host, port=port,
|
|
|
|
intercept=intercept,
|
|
|
|
test_loader_name=test_loader_name,
|
|
|
|
fixture_module=fixture_module,
|
|
|
|
response_handlers=response_handlers,
|
|
|
|
prefix=prefix)
|
|
|
|
|
|
|
|
for test in tests:
|
|
|
|
if hasattr(test, '_tests'):
|
|
|
|
for subtest in test._tests:
|
|
|
|
yield '%s' % subtest.__class__.__name__, subtest
|
|
|
|
else:
|
|
|
|
yield '%s' % test.__class__.__name__, test
|
|
|
|
|
|
|
|
|
2014-12-15 13:33:00 +00:00
|
|
|
def load_yaml(yaml_file):
|
2014-12-15 21:55:14 +00:00
|
|
|
"""Read and parse any YAML file. Let exceptions flow where they may."""
|
2016-03-10 14:33:37 +00:00
|
|
|
with io.open(yaml_file, encoding='utf-8') as source:
|
2014-12-15 13:33:00 +00:00
|
|
|
return yaml.safe_load(source.read())
|
2015-01-14 14:31:10 +00:00
|
|
|
|
|
|
|
|
2015-04-20 18:39:54 +01:00
|
|
|
def test_update(orig_dict, new_dict):
|
|
|
|
"""Modify test in place to update with new data."""
|
|
|
|
for key, val in six.iteritems(new_dict):
|
|
|
|
if key == 'data':
|
|
|
|
orig_dict[key] = val
|
|
|
|
elif isinstance(val, dict):
|
|
|
|
orig_dict[key].update(val)
|
|
|
|
elif isinstance(val, list):
|
|
|
|
orig_dict[key] = orig_dict.get(key, []) + val
|
|
|
|
else:
|
|
|
|
orig_dict[key] = val
|
|
|
|
|
|
|
|
|
2015-10-15 15:09:22 +02:00
|
|
|
def test_suite_from_dict(loader, test_base_name, suite_dict, test_directory,
|
2015-10-08 17:54:00 +01:00
|
|
|
host, port, fixture_module, intercept, prefix=''):
|
2015-10-08 19:00:21 +01:00
|
|
|
"""Generate a TestSuite from YAML-sourced data."""
|
2015-07-22 17:49:53 +01:00
|
|
|
try:
|
2015-10-15 15:09:22 +02:00
|
|
|
test_data = suite_dict['tests']
|
2015-07-22 17:49:53 +01:00
|
|
|
except KeyError:
|
2015-10-11 14:08:25 +02:00
|
|
|
raise GabbiFormatError('malformed test file, "tests" key required')
|
2015-07-22 17:49:53 +01:00
|
|
|
except TypeError:
|
2015-10-15 15:09:22 +02:00
|
|
|
# `suite_dict` appears not to be a dictionary; we cannot infer
|
2015-10-11 11:02:16 +02:00
|
|
|
# any details or suggestions on how to fix it, thus discarding
|
|
|
|
# the original exception in favor of a generic error
|
2015-07-22 17:49:53 +01:00
|
|
|
raise GabbiFormatError('malformed test file, invalid format')
|
|
|
|
|
2015-10-11 20:34:37 +02:00
|
|
|
# Merge global with per-suite defaults
|
2015-10-15 15:09:22 +02:00
|
|
|
default_test_dict = copy.deepcopy(case.HTTPTestCase.base_test)
|
|
|
|
local_defaults = _validate_defaults(suite_dict.get('defaults', {}))
|
|
|
|
test_update(default_test_dict, local_defaults)
|
2015-01-14 14:31:10 +00:00
|
|
|
|
2015-10-08 19:00:21 +01:00
|
|
|
# Establish any fixture classes used in this file.
|
2015-10-15 15:09:22 +02:00
|
|
|
fixtures = suite_dict.get('fixtures', None)
|
2015-01-14 14:31:10 +00:00
|
|
|
fixture_classes = []
|
|
|
|
if fixtures and fixture_module:
|
|
|
|
for fixture_class in fixtures:
|
|
|
|
fixture_classes.append(getattr(fixture_module, fixture_class))
|
|
|
|
|
2015-10-15 15:09:22 +02:00
|
|
|
test_maker = TestMaker(test_base_name, default_test_dict, test_directory,
|
2015-10-10 18:00:46 +01:00
|
|
|
fixture_classes, loader, host, port, intercept,
|
|
|
|
prefix)
|
2015-10-11 20:34:37 +02:00
|
|
|
file_suite = gabbi_suite.GabbiSuite()
|
2015-01-14 14:31:10 +00:00
|
|
|
prior_test = None
|
2015-10-15 15:09:22 +02:00
|
|
|
for test_dict in test_data:
|
|
|
|
this_test = test_maker.make_one_test(test_dict, prior_test)
|
2015-01-14 14:31:10 +00:00
|
|
|
file_suite.addTest(this_test)
|
|
|
|
prior_test = this_test
|
|
|
|
|
|
|
|
return file_suite
|
2015-09-11 17:00:22 +01:00
|
|
|
|
|
|
|
|
2015-10-15 14:18:56 +02:00
|
|
|
def test_suite_from_yaml(loader, test_base_name, test_yaml, test_directory,
|
|
|
|
host, port, fixture_module, intercept, prefix=''):
|
|
|
|
"""Legacy wrapper retained for backwards compatibility."""
|
|
|
|
import warnings
|
|
|
|
|
|
|
|
with warnings.catch_warnings(): # ensures warnings filter is restored
|
|
|
|
warnings.simplefilter('default', DeprecationWarning)
|
|
|
|
warnings.warn('test_suite_from_yaml has been renamed to '
|
|
|
|
'test_suite_from_dict', DeprecationWarning, stacklevel=2)
|
|
|
|
return test_suite_from_dict(loader, test_base_name, test_yaml,
|
|
|
|
test_directory, host, port, fixture_module,
|
|
|
|
intercept, prefix)
|
|
|
|
|
|
|
|
|
2015-09-12 11:57:59 +02:00
|
|
|
def _validate_defaults(defaults):
|
2015-10-11 20:34:37 +02:00
|
|
|
"""Ensure default test settings are acceptable.
|
2015-09-12 11:57:44 +02:00
|
|
|
|
|
|
|
Raises GabbiFormatError for invalid settings.
|
|
|
|
"""
|
2015-09-12 12:00:34 +02:00
|
|
|
if any(_is_method_shortcut(key) for key in defaults):
|
2015-10-11 14:08:25 +02:00
|
|
|
raise GabbiFormatError('"METHOD: url" pairs not allowed in defaults')
|
2015-09-12 11:57:59 +02:00
|
|
|
return defaults
|
2015-09-11 18:22:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
def _is_method_shortcut(key):
|
2015-10-14 10:54:00 +01:00
|
|
|
"""Is this test key indicating a request method.
|
|
|
|
|
|
|
|
It is a request method if it is all upper case.
|
|
|
|
"""
|
2015-09-11 18:22:27 +02:00
|
|
|
return key.isupper()
|