Skip to content

Commit

Permalink
Merge pull request #342 from onefinestay/testing_services_customise_d…
Browse files Browse the repository at this point in the history
…ependencies

customise_dependencies - More control over "replaced" dependencies when testing.
  • Loading branch information
mattbennett committed Sep 14, 2016
2 parents c9eaf51 + 6a57655 commit 96c91c6
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 35 deletions.
8 changes: 8 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ Here you can see the full list of changes between nameko versions. Versions
are in form of *headline.major.minor* numbers. Backwards-compatible changes
increment the minor version number only.

Version 2.4.1
-------------

Released PENDING

* Enhanced :func:`~nameko.testing.services.replace_dependencies` to allow
specific replacement values to be provided with named arguments.

Version 2.4.0
-------------

Expand Down
102 changes: 70 additions & 32 deletions nameko/testing/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,23 +218,56 @@ def cm_to_inches(self, cms):


class MockDependencyProvider(DependencyProvider):
def __init__(self, attr_name):
def __init__(self, attr_name, dependency=None):
self.attr_name = attr_name
self.dependency = MagicMock()
self.dependency = MagicMock() if dependency is None else dependency

def get_dependency(self, worker_ctx):
return self.dependency


def replace_dependencies(container, *dependencies):
def _replace_dependencies(container, **dependency_map):
if container.started:
raise RuntimeError('You must replace dependencies before the '
'container is started.')

dependency_names = {dep.attr_name for dep in container.dependencies}

missing = set(dependency_map) - dependency_names
if missing:
raise ExtensionNotFound("Dependency(s) '{}' not found on {}.".format(
missing, container))

existing_providers = {dep.attr_name: dep for dep in container.dependencies
if dep.attr_name in dependency_map}

for name, replacement in dependency_map.items():
existing_provider = existing_providers[name]
replacement_provider = MockDependencyProvider(
name, dependency=replacement)
container.dependencies.remove(existing_provider)
container.dependencies.add(replacement_provider)


def replace_dependencies(container, *dependencies, **dependency_map):
""" Replace the dependency providers on ``container`` with
:class:`MockDependencyProvider` objects if they are named in
``dependencies``.
instances of :class:`MockDependencyProvider`.
Dependencies named in *dependencies will be replaced with a
:class:`MockDependencyProvider`, which injects a MagicMock instead of the
dependency.
Return the :attr:`MockDependencyProvider.dependency` of the replacements,
so that calls to the replaced dependencies can be inspected. Return a
single object if only one dependency was replaced, and a generator
yielding the replacements in the same order as ``names`` otherwise.
Alternatively, you may use keyword arguments to name a dependency and
provide the replacement value that the `MockDependencyProvider` should
inject.
Return the :attr:`MockDependencyProvider.dependency` for every dependency
specified in the (*dependencies) args so that calls to the replaced
dependencies can be inspected. Return a single object if only one
dependency was replaced, and a generator yielding the replacements in the
same order as ``dependencies`` otherwise.
Note that any replaced dependencies specified via kwargs `**dependency_map`
will not be returned.
Replacements are made on the container instance and have no effect on the
service class. New container instances are therefore unaffected by
Expand All @@ -261,43 +294,48 @@ def cm_to_inches(self, cms):
return self.maths_rpc.divide(cms, 2.54)
container = ServiceContainer(ConversionService, config)
maths_rpc = replace_dependencies(container, "maths_rpc")
mock_maths_rpc = replace_dependencies(container, "maths_rpc")
mock_maths_rpc.divide.return_value = 39.37
container.start()
with ServiceRpcProxy('conversionservice', config) as proxy:
with ServiceRpcProxy('conversions', config) as proxy:
proxy.cm_to_inches(100)
# assert that the dependency was called as expected
maths_rpc.divide.assert_called_once_with(100, 2.54)
mock_maths_rpc.divide.assert_called_once_with(100, 2.54)
"""
if container.started:
raise RuntimeError('You must replace dependencies before the '
'container is started.')
dependency_names = {dep.attr_name for dep in container.dependencies}
Providing a specific replacement by keyword:
missing = set(dependencies) - dependency_names
if missing:
raise ExtensionNotFound("Dependency(s) '{}' not found on {}.".format(
missing, container))
::
class StubMaths(object):
def divide(self, val1, val2):
return val1 / val2
replace_dependencies(container, maths_rpc=StubMaths())
container.start()
with ServiceRpcProxy('conversions', config) as proxy:
assert proxy.cm_to_inches(127) == 50.0
"""
if set(dependencies).intersection(dependency_map):
raise RuntimeError(
"Cannot replace the same dependency via both args and kwargs.")

replacements = OrderedDict()
arg_replacements = OrderedDict((dep, MagicMock()) for dep in dependencies)

named_dependencies = {dep.attr_name: dep for dep in container.dependencies
if dep.attr_name in dependencies}
for name in dependencies:
dependency = named_dependencies[name]
replacement = MockDependencyProvider(name)
replacements[dependency] = replacement
container.dependencies.remove(dependency)
container.dependencies.add(replacement)
dependency_map.update(arg_replacements)
_replace_dependencies(container, **dependency_map)

# if only one name was provided, return any replacement directly
# otherwise return a generator
res = (replacement.dependency for replacement in replacements.values())
if len(dependencies) == 1:
res = (replacement for replacement in arg_replacements.values())
if len(arg_replacements) == 1:
return next(res)
return res

Expand Down
108 changes: 105 additions & 3 deletions test/testing/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ class OtherService(object):
worker_factory(Service, nonexist=object())


def test_replace_dependencies(container_factory, rabbit_config):
def test_replace_dependencies_kwargs(container_factory, rabbit_config):

class Service(object):
name = "service"
Expand All @@ -209,9 +209,50 @@ class Service(object):
def method(self, arg):
self.foo_proxy.remote_method(arg)

class FakeDependency(object):
def __init__(self):
self.processed = []

def remote_method(self, arg):
self.processed.append(arg)

container = container_factory(Service, rabbit_config)

# customise a single dependency
fake_foo_proxy = FakeDependency()
replace_dependencies(container, foo_proxy=fake_foo_proxy)
assert 2 == len([dependency for dependency in container.extensions
if isinstance(dependency, RpcProxy)])

# customise multiple dependencies
res = replace_dependencies(container, bar_proxy=Mock(), baz_proxy=Mock())
assert list(res) == []

# verify that container.extensions doesn't include an RpcProxy anymore
assert all([not isinstance(dependency, RpcProxy)
for dependency in container.extensions])

container.start()

# verify that the fake dependency collected calls
msg = "msg"
with ServiceRpcProxy("service", rabbit_config) as service_proxy:
service_proxy.method(msg)

assert fake_foo_proxy.processed == [msg]


def test_replace_dependencies_args(container_factory, rabbit_config):

class Service(object):
name = "service"
foo_proxy = RpcProxy("foo_service")
bar_proxy = RpcProxy("bar_service")
baz_proxy = RpcProxy("baz_service")

@rpc
def foo(self):
return "bar"
def method(self, arg):
self.foo_proxy.remote_method(arg)

container = container_factory(Service, rabbit_config)

Expand All @@ -236,6 +277,67 @@ def foo(self):
foo_proxy.remote_method.assert_called_once_with(msg)


def test_replace_dependencies_args_and_kwargs(container_factory,
rabbit_config):
class Service(object):
name = "service"
foo_proxy = RpcProxy("foo_service")
bar_proxy = RpcProxy("bar_service")
baz_proxy = RpcProxy("baz_service")

@rpc
def method(self, arg):
self.foo_proxy.remote_method(arg)
self.bar_proxy.bar()
self.baz_proxy.baz()

class FakeDependency(object):
def __init__(self):
self.processed = []

def remote_method(self, arg):
self.processed.append(arg)

container = container_factory(Service, rabbit_config)

fake_foo_proxy = FakeDependency()
mock_bar_proxy, mock_baz_proxy = replace_dependencies(
container, 'bar_proxy', 'baz_proxy', foo_proxy=fake_foo_proxy
)

# verify that container.extensions doesn't include an RpcProxy anymore
assert all([not isinstance(dependency, RpcProxy)
for dependency in container.extensions])

container.start()

# verify that the fake dependency collected calls
msg = "msg"
with ServiceRpcProxy("service", rabbit_config) as service_proxy:
service_proxy.method(msg)

assert fake_foo_proxy.processed == [msg]
assert mock_bar_proxy.bar.call_count == 1
assert mock_baz_proxy.baz.call_count == 1


def test_replace_dependencies_in_both_args_and_kwargs_error(container_factory,
rabbit_config):
class Service(object):
name = "service"
foo_proxy = RpcProxy("foo_service")
bar_proxy = RpcProxy("bar_service")
baz_proxy = RpcProxy("baz_service")

container = container_factory(Service, rabbit_config)

with pytest.raises(RuntimeError) as exc:
replace_dependencies(
container, 'bar_proxy', 'foo_proxy', foo_proxy='foo'
)
assert "Cannot replace the same dependency" in str(exc)


def test_replace_non_dependency(container_factory, rabbit_config):

class Service(object):
Expand Down

0 comments on commit 96c91c6

Please sign in to comment.