Mocking property attributes#
Python property attributes provide an interface for creating read-only properties and properties with getters, setters, and deleters. You can use Decoy to stub properties and verify calls to property setters and deleters.
Default behavior#
Unlike mock method calls - which have a default return value of None
- Decoy's default return value for attribute access is another mock. So you don't need to configure anything explicitly if you need a @property
getter to return another mock; Decoy will do this for you.
class SubDep:
...
class Dep:
@property
def sub(self) -> SubDep:
...
def test(decoy: Decoy) -> None:
dep = decoy.mock(cls=Dep) # <- mock of class Dep
sub = dep.sub # <- mock of class SubDep
...
Stubbing property access#
Stubbing a getter#
If you would like to stub a return value for a property that is different than the default behavior, simply use the property itself as your rehearsal.
dependency = decoy.mock(name="dependency")
decoy.when(
dependency.some_property # <- "rehearsal" of a property getter
).then_return(42)
assert dep.some_property == 42
You can also configure any other behavior, like raising an error.
dependency = decoy.mock(name="dependency")
decoy.when(
dependency.some_property
).then_raise(RuntimeError("oh no"))
with pytest.raises(RuntimeError, match="oh no"):
dependency.some_property
Stubbing a setter or deleter#
While you cannot stub a return value for a getter or setter, you can stub a raise
or a side effect by combining decoy.Decoy.when with decoy.Decoy.prop.
The prop
method allows you to create rehearsals of setters and deleters. Use decoy.Prop.set to create a setter rehearsal, and decoy.Prop.delete to create a deleter rehearsal.
dependency = decoy.mock(name="dependency")
decoy.when(
decoy.prop(dependency.some_property).set(42)
).then_raise(RuntimeError("oh no"))
decoy.when(
decoy.prop(dependency.some_property).delete()
).then_raise(RuntimeError("what a disaster"))
with pytest.raises(RuntimeError, match="oh no"):
dependency.some_property = 42
with pytest.raises(RuntimeError, match="what a disaster"):
del dependency.some_property
Tip
You cannot use decoy.Stub.then_return with property setters and deleters, because set and delete expressions do not return a value in Python.
Verifying property access#
You can verify calls to property setters and deleters by combining decoy.Decoy.verify with decoy.Decoy.prop, the same way you would configure a stub.
Use this feature sparingly! If you're designing a dependency that triggers a side-effect, consider using a regular method rather than a property setter/deleter. It'll probably make your code easier to read and reason with.
Mocking and verifying property setters and deleters is most useful for testing code that needs to interact with older or legacy dependencies that would be prohibitively expensive to redesign.
Tip
You cannot verify
getters. The verify
method is for verifying side-effects, and it is the opinion of the author that property getters should not trigger side-effects. Getter-triggered side effects are confusing and do not communicate the design intent of a system.
Verifying a setter#
Use decoy.Prop.set to create a setter rehearsal to use in decoy.Decoy.verify.
dependency = decoy.mock(name="dependency")
dependency.some_property = 42
decoy.verify(
decoy.prop(dependency.some_property).set(42) # <- "rehearsal" of a property setter
)
Verifying a deleter#
Use decoy.Prop.delete to create a deleter rehearsal to use in decoy.Decoy.verify.
dependency = decoy.mock(name="dependency")
del dependency.some_property
decoy.verify(
decoy.prop(dependency.some_property).delete() # <- "rehearsal" of a property deleter
)
Example#
In this example, we're developing a Core
unit, with a Config
dependency. We want to test that we get, set, and delete the port
property of the Config
dependency when various methods of Core
are used.
from decoy import Decoy
class InvalidPortValue(ValueError):
"""Exception raised when a given port value is invalid."""
class Config:
"""Config dependency."""
@property
def port(self) -> int:
...
@port.setter
def port(self) -> None:
...
@port.deleter
def port(self) -> None:
...
class Core:
"""Core test subject."""
def __init__(self, config: Config) -> None:
self._config = config
def get_port(self) -> int:
return self._config.port
def set_port(self, port: int) -> None:
try:
self._config.port = port
except ValueError as e:
raise InvalidPortValue(str(e)) from e
def reset_port(self) -> None:
del self._config.port
def test_gets_port(decoy: Decoy) -> None:
"""Core should get the port number from its Config dependency."""
config = decoy.mock(cls=Config)
subject = Core(config=config)
decoy.when(
config.port # <- "rehearsal" of a property getter
).then_return(42)
result = subject.get_port()
assert result == 42
def test_rejects_invalid_port(decoy: Decoy) -> None:
"""Core should re-raise if the port number is set to an invalid value."""
config = decoy.mock(cls=Config)
subject = Core(config=config)
decoy.when(
decoy.prop(config.port).set(9001) # <- "rehearsal" of a property setter
).then_raise(ValueError("there's no way that can be right"))
with pytest.raises(InvalidPortValue, "there's no way"):
subject.set_port(9001)
def test_sets_port(decoy: Decoy) -> None:
"""Core should set the port number in its Config dependency."""
config = decoy.mock(cls=Config)
subject = Core(config=config)
subject.set_port(101)
decoy.verify(
decoy.prop(config.port).set(101) # <- "rehearsal" of a property setter
)
def test_resets_port(decoy: Decoy) -> None:
"""Core should delete the port number in its Config dependency."""
config = decoy.mock(cls=Config)
subject = Core(config=config)
subject.reset_port()
decoy.verify(
decoy.prop(config.port).delete() # <- "rehearsal" of a property deleter
)