Skip to content

Commit

Permalink
Mirror the API that has been accepted in Python
Browse files Browse the repository at this point in the history
  • Loading branch information
mariocj89 committed Oct 9, 2017
1 parent 213d5af commit 91195b0
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 96 deletions.
28 changes: 16 additions & 12 deletions README.rst
Expand Up @@ -6,21 +6,24 @@ Sealed Mock
Whitelist the attributes/methods of your mocks instead of just letting
it create new mock objects.

SealedMock allows specify when you are done defining the mock, ensuring
`sealedmock` allows to specify when you are done defining the mock, ensuring
that any unexpected call to the mock is cached.

Sample:

.. code:: python
import sealedmock
m = sealedmock.SealedMock()
from unittest.mock import Mock
from sealedmock import seal
m = Mock()
m.method1.return_value.attr1.method2.return_value = 1
m.sealed = True
seal(m) # No new attributes can be declared
m.method1().attr1.method2()
# 1
m.method1().attr2
# Exception: SealedMockAttributeAccess: mock.method1().attr2
# Exception: AttributeError mock.method1().attr2
Big news! This is getting into Python3.7! See [this PR](https://github.com/python/cpython/pull/1923/files).

Install
=======
Expand Down Expand Up @@ -48,22 +51,23 @@ You can write a test like:

.. code:: python
from sealedmock import patch
from unittest.mock import patch
from sealedmock import seal
@patch("tests.sample_code.urllib2")
def test_using_decorator(mock):
sample = sample_code.SampleCodeClass()
mock.urlopen.return_value = 2
mock.sealed = True # No new attributes can be declared
seal(mock) # No new attributes can be declared
# calling urlopen succeeds as mock.urlopen has been defined
assert sample.calling_urlopen()
sample.calling_urlopen()
# This will fail as mock.splithost has not been defined
sample.calling_splithost()
If you use an common Mock the second part will pass as it will create a
mock for you and return it. With SealedMock you can choose when to stop
mock for you and return it. With sealedmock you can choose when to stop
that behaviour.

This is recursive so you can write:
Expand All @@ -74,7 +78,7 @@ This is recursive so you can write:
def test_recursive(mock):
sample = sample_code.SampleCodeClass()
mock.secret.call1.call2.call3.return_value = 1
mock.sealed = True # No new attributes can be declared
seal(mock) # No new attributes can be declared
# If secret is not used as specified above it will fail
# ex: if do_stuff also calls secret.call1.call9
Expand All @@ -90,10 +94,10 @@ It also prevents typos on tests if used like this:
sample.do_stuff()
mock.sealed = True
seal(mock)
mock.asert_called_with(1)
# Note the typo in asert (should be assert)
# Sealed mock will rise, normal mock won't
# A sealed mock will rise, normal mock won't
.. |Build Status| image:: https://travis-ci.org/mariocj89/sealedmock.svg?branch=master
:target: https://travis-ci.org/mariocj89/sealedmock
Expand Down
84 changes: 32 additions & 52 deletions sealedmock/__init__.py
Expand Up @@ -46,64 +46,44 @@ def _extract_mock_name(in_mock):
return ''.join(_name_list)


def _get_child_mock(mock, **kw):
"""Shared code betwee the SealedMock and the ones automagically defined in "seal" """
if mock._mock_sealed:
attribute = "." + kw["name"] if "name" in kw else "()"
mock_name = _extract_mock_name(mock) + attribute
raise AttributeError(mock_name)
else:
return mock.__class__(**kw)

class SealedMockAttributeAccess(AttributeError):
"""Attempted to access an attribute of a sealed mock"""

def _frankeinstainize(mock):
"""Given a mock dirty patches it to behave like a sealed mock
class SealedMock(Mock):
"""A Mock that can be sealed at any point of time
Once the mock is sealed it prevents any implicit mock creation
I know... give me a better way to do this.
"""
mock._mock_sealed = True
mock._get_child_mock = functools.partial(_get_child_mock, mock)

To seal the mock call seled_mock.sealed = True

:Example:
def seal(mock):
"""Disable the automatic generation of "submocks"
>>> import sealedmock
>>> m = sealedmock.SealedMock()
>>> m.method1.return_value.attr1.method2.return_value = 1
>>> m.sealed = True
>>> m.method1().attr1.method2()
>>> # 1
>>> m.method1().attr2
>>> # Exception: SealedMockAttributeAccess: mock.method1().attr2
Given an input Mock, seals it to ensure no further mocks will be generated
when accessing an attribute that was not already defined.
Submocks are defined as all mocks which were created DIRECTLY from the
parent. If a mock is assigned to an attribute of an existing mock,
it is not considered a submock.
"""
def __init__(self, *args, **kwargs):
super(SealedMock, self).__init__(*args, **kwargs)
self.__dict__["_mock_sealed"] = False

def _get_child_mock(self, **kw):
if self.sealed:
attribute = "." + kw["name"] if "name" in kw else "()"
mock_name = _extract_mock_name(self) + attribute
raise SealedMockAttributeAccess(mock_name)
else:
return SealedMock(**kw)

@property
def sealed(self):
"""Attribute that marks whether the mock can be extended dynamically
Once sealed is set to True no attribute that was not defined before can
be accessed.
:raises: SealedMockAttributeAccess
"""
return self._mock_sealed

@sealed.setter
def sealed(self, value):
self._mock_sealed = value
for attr in dir(self):
try:
m = getattr(self, attr)
except AttributeError:
pass
else:
if isinstance(m, SealedMock):
m.sealed = value


patch = functools.partial(patch, new_callable=SealedMock)
_frankeinstainize(mock)
for attr in dir(mock):
try:
m = getattr(mock, attr)
except AttributeError:
continue
if not isinstance(m, NonCallableMock):
continue
if m._mock_new_parent is mock:
seal(m)


6 changes: 3 additions & 3 deletions tests/test_integration.py
Expand Up @@ -2,14 +2,14 @@
import pytest

from tests import sample_code
from sealedmock import patch
from sealedmock import seal, patch


def test_using_context_manager():
with patch("tests.sample_code.os") as mock:
sample = sample_code.SampleCodeClass()
mock.rm.return_value = 2
mock.sealed = True
seal(mock)

assert sample.calling_rm() == 2
with pytest.raises(AttributeError):
Expand All @@ -20,7 +20,7 @@ def test_using_context_manager():
def test_using_decorator(mock):
sample = sample_code.SampleCodeClass()
mock.rm.return_value = 2
mock.sealed = True
seal(mock)

assert sample.calling_rm() == 2
with pytest.raises(AttributeError):
Expand Down
62 changes: 33 additions & 29 deletions tests/test_sealed_mock.py
@@ -1,69 +1,73 @@
from sealedmock import SealedMock
from sealedmock import Mock, seal
try:
from unittest.mock import Mock
except ImportError:
from mock import Mock
import pytest


def test_new_attributes_can_be_accessed_before_seal():
m = SealedMock()
m = Mock()
m.test
m.test()
m.test().test2


def test_attributes_return_more_mocks_by_default():
m = SealedMock()
m = Mock()

assert isinstance(m.test, SealedMock)
assert isinstance(m.test(), SealedMock)
assert isinstance(m.test().test2(), SealedMock)
assert isinstance(m.test, Mock)
assert isinstance(m.test(), Mock)
assert isinstance(m.test().test2(), Mock)


def test_new_attributes_cannot_be_accessed_on_seal():
m = SealedMock()
m = Mock()

m.sealed = True
seal(m)
with pytest.raises(AttributeError):
m.test
with pytest.raises(AttributeError):
m.test()


def test_existing_attributes_allowed_after_seal():
m = SealedMock()
m = Mock()

m.test.return_value = 3

m.sealed = True
seal(m)
assert m.test() == 3


def test_initialized_attributes_allowed_after_seal():
m = SealedMock(test_value=1)
m = Mock(test_value=1)

m.sealed = True
seal(m)
assert m.test_value == 1


def test_call_on_sealed_mock_fails():
m = SealedMock()
m = Mock()

m.sealed = True
seal(m)
with pytest.raises(AttributeError):
m()


def test_call_on_defined_sealed_mock_succeeds():
m = SealedMock(return_value=5)
m = Mock(return_value=5)

m.sealed = True
seal(m)
assert m() == 5


def test_seals_recurse_on_added_attributes():
m = SealedMock()
m = Mock()

m.test1.test2().test3 = 4

m.sealed = True
seal(m)
assert m.test1.test2().test3 == 4
with pytest.raises(AttributeError):
m.test1.test2.test4
Expand All @@ -83,12 +87,12 @@ def method_sample2(self):

def test_integration_with_spec_att_definition():
"""You are not restricted when defining attributes on a mock with spec"""
m = SealedMock(SampleObject)
m = Mock(SampleObject)

m.attr_sample1 = 1
m.attr_sample3 = 3

m.sealed = True
seal(m)
assert m.attr_sample1 == 1
assert m.attr_sample3 == 3
with pytest.raises(AttributeError):
Expand All @@ -97,50 +101,50 @@ def test_integration_with_spec_att_definition():

def test_integration_with_spec_method_definition():
"""You need to defin the methods, even if they are in the spec"""
m = SealedMock(SampleObject)
m = Mock(SampleObject)

m.method_sample1.return_value = 1

m.sealed = True
seal(m)
assert m.method_sample1() == 1
with pytest.raises(AttributeError):
m.method_sample2()


def test_integration_with_spec_method_definition_respects_spec():
"""You cannot define methods out of the spec"""
m = SealedMock(SampleObject)
m = Mock(SampleObject)

with pytest.raises(AttributeError):
m.method_sample3.return_value = 3


def test_sealed_exception_has_attribute_name():
m = SealedMock()
m = Mock()

m.sealed = True
seal(m)
try:
m.SECRETE_name
except AttributeError as ex:
assert "SECRETE_name" in str(ex)


def test_attribute_chain_is_maintained():
m = SealedMock(name="mock_name")
m = Mock(name="mock_name")
m.test1.test2.test3.test4

m.sealed = True
seal(m)
try:
m.test1.test2.test3.test4.boom
except AttributeError as ex:
assert "mock_name.test1.test2.test3.test4.boom" in str(ex)


def test_call_chain_is_maintained():
m = SealedMock()
m = Mock()
m.test1().test2.test3().test4

m.sealed = True
seal(m)
try:
m.test1().test2.test3().test4()
except AttributeError as ex:
Expand Down

0 comments on commit 91195b0

Please sign in to comment.