Mocking functions

From function object to function mock

Checking arguments against original signature

For a given argument list, we want to know whether the original function can be called with that set of parameters. This can be done in two steps: 1. Extract the signature 2. Try to bind the argument list against the signature. This throws an exception if the arguments can not be matched against the signature

Binding free methods

def f(a: int, b: str, c:str|None =None) -> None:
    pass

Successful bind:

inspect.signature(f).bind(1, "1", "2")
<BoundArguments (a=1, b='1', c='2')>

Unsuccessful bind:

try:
    inspect.signature(f).bind(1)
except Exception as e:
    print(e)
missing a required argument: 'b'

Binding class methods

When binding class methods, the special case of the self parameter has to be considered.

class A:
    def f(self, a:int):
        return a+1
signature = inspect.signature(A.f)

try:
    signature.bind(1)
except Exception as e:
    print(e)
missing a required argument: 'a'

We will mark a function as ‘class’-function if it has a parameter called ‘self’.


source

is_class_method

 is_class_method (func:pymoq.core.AnyCallable)

Returns true if the given function has a parameter called ‘self’

assert is_class_method(A.f)
assert not is_class_method(f)

source

remove_self_parameter

 remove_self_parameter (args:tuple[typing.Any])

Removes the first parameter of the argument list.


source

add_self_parameter

 add_self_parameter (args:tuple[typing.Any])

Adds None to the front of the given tuple of arguments

assert add_self_parameter((1,2))==(None,1,2)
assert remove_self_parameter((None, 1,2))==(1,2)
assert remove_self_parameter(add_self_parameter((1,2))) == (1,2)

Function Mock


source

FunctionMock

 FunctionMock (func:pymoq.core.AnyCallable)

Mocks a function object based on its signature

mock = FunctionMock(f)
assert mock._argument_names == ['a', 'b', 'c']

Successful binds:

assert mock.arguments_valid(1, "1", "2")
assert mock.arguments_valid(1, "1")
assert mock.arguments_valid(1, "1", c="2")
assert mock.arguments_valid(a=1, b="1", c="2")

Note that the types are not checked with this:

assert mock.arguments_valid(1, 1)

Unsuccessful binds might be:

def show_exception(func: AnyCallable):
    try:
        func()
    except Exception as e:
        print(e)
        return
    assert False, "Expected Exception to be thrown"
show_exception(lambda: mock.arguments_valid(1))# too few arguments
missing a required argument: 'b'
show_exception(lambda:mock.arguments_valid(1,2,3,4)) # too many arguments
too many positional arguments
show_exception(lambda: mock.arguments_valid(1,2,d=3)) # unknown argument name
got an unexpected keyword argument 'd'

Special case class method. Note that when calling arguments_valid directly, we need to pass in a dummy self argument.

mock = FunctionMock(A.f)

assert mock.arguments_valid(None, 1)
show_exception(lambda: mock.arguments_valid(None))
show_exception(lambda: mock.arguments_valid(None, 1,2))
missing a required argument: 'a'
too many positional arguments

Setup

We want to be able to create call-setups on the function mock. A setup consists of a signature validation and a return value generator. When the mock is called with a list of arguments, we check this list against the signature validator. If the call matches, we call the return value generator to generate the return value.


source

Setup

 Setup (signature_validator:pymoq.signature_validators.SignatureValidator)

This class bundles a signature validator with a call-result-action


source

Setup.returns

 Setup.returns (return_value_generator:Any)

Set the ReturnValueGenerator to be called when this setup is successfully called


source

FunctionMock.setup

 FunctionMock.setup (*args, **kwargs)

A setup can now be defined by passing a function that takes in the call parameters and outputs the appropriate value:

mock = FunctionMock(f)

mock.setup(ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=0)).returns(lambda first, **kwargs: first+1)
assert mock._setups[0].get_return_value(4) == 5
assert mock._setups[0].get_return_value(4)==5
assert mock._setups[0].get_return_value(4, a=1)==5
assert mock._setups[0].is_valid(1)
assert not mock._setups[0].is_valid("1")

or by passing a constant, which is then returned regardless of the call values:

mock = FunctionMock(f)

mock.setup(ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=0)).returns(5)
assert mock._setups[0].get_return_value() == 5

self argument on class methods

class A:
    def f(self, a: int) -> None:
        pass
mock = FunctionMock(A.f)

mock.setup(lambda a: isinstance(a, int)).returns(lambda self, a: 5)

assert mock._setups[0].is_valid(None, 1)
mock._setups[0]._signature_validator.argument_validators
[ArgumentFunctionValidator(argument_name:self, position=0): callable(),
 ArgumentFunctionValidator(argument_name:a, position=1): callable()]

Call validation

When called, a function mock should perform the following steps: 1. check if the argument list binds against the original functions signature 2. check if the signature validator matches for the stored Setups in order they were added 3. The first setup with a matching signature validator should be used for generating the return value

Edge cases: - If no Setup matches, return None

Dealing with default-values

The number of arguments passed to a function call might differ from call to call if default values are used:

def f(a:int, b:str="str"):
    ...
    
mock = FunctionMock(f)
mock.setup(ArgumentFunctionValidator(lambda a: True, name='a', position=0), ArgumentFunctionValidator(lambda b: True, name='b', position=1)).returns(lambda *args,**kwargs: print(args, kwargs))
mock._setups[0].get_return_value(1), mock._setups[0].get_return_value(1, b="b")
(1,) {}
(1,) {'b': 'b'}
(None, None)

Next, we’ll match an argument list against the default values.


source

FunctionMock.fill_up_arg_list

 FunctionMock.fill_up_arg_list (args:list[typing.Any],
                                kwargs:dict[str,typing.Any],
                                verbose:bool=False)

If a default-value is already present via name, the default value should not be used:

def f(a: int, b: str, c:str|None ='default str') -> None:
    pass

mock = FunctionMock(f)

assert mock.fill_up_arg_list([1, 1.1], {'c': 'custom str'}) == {'c': 'custom str'}

If a default-value is already overriden by a positional argument, it should not be used:

assert mock.fill_up_arg_list([1, 1.1, 'custom str'], {}) == {}

If neither is the case, the default-value should be used:

assert mock.fill_up_arg_list([1, 1.1], {}) == {'c': 'default str'}

Call method

Calls on a mock should be recorded. This makes it possible to unit-test that specific argument combinations were called a specific amount of time.

def f(a: int, b: str, c:str|None =None) -> None:
    pass

mock = FunctionMock(f)

Without any setup a call either fails or returns None:

test_fail(lambda: mock())
assert mock(1,"1") is None

Now we’ll add two setups. The first one is the more generic one that only checks if a is an int and b is a string. The second one checks if a==2 and b is a string.

# this will be prettier, I promise!
mock = FunctionMock(f)

# Generic type checker
mock.setup(
    ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=0),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=1),
    ArgumentFunctionValidator(lambda c: c is None, name='c', position=2)).returns(lambda a,b,c: 5)

# Value checker
mock.setup(
    ArgumentFunctionValidator(lambda a: a==2, name='a', position=0),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=1),
    ArgumentFunctionValidator(lambda c: c is None, name='c', position=2)).returns(lambda a,b,c: 6)

assert mock(2, 'anyString')==6
assert mock(1, 'anyString')==5
assert mock(1, 1) is None
assert mock._calls == [
    ((2, 'anyString'), {'c': None}),
    ((1, 'anyString'), {'c': None}),
    ((1, 1), {'c': None})
]

Lastly, the generic return value generator could be used to return a value based on the input value:

mock = FunctionMock(f)

# Generic type checker
mock.setup(
    ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=0),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=1),
    ArgumentFunctionValidator(lambda c: c is None, name='c', position=2)).returns(lambda a,b,c: a+1)

assert mock(1,'anyString')==2
assert mock(2,'anyString')==3

On class methods

class A:
    def f(self, a: int, b: str, c:str|None =None) -> None:
        pass
mock = FunctionMock(A.f)
test_fail(lambda: mock())
assert mock(1, "1", "c") is None
# this will be prettier, I promise!
mock = FunctionMock(A.f)

# Generic type checker
mock.setup(
    ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=1),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=2),
    ArgumentFunctionValidator(lambda c: c is None, name='c', position=3)).returns(lambda self,a,b,c: 5)

# Value checker
mock.setup(
    ArgumentFunctionValidator(lambda a: a==2, name='a', position=1),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=2),
    ArgumentFunctionValidator(lambda c: c is None, name='c', position=3)).returns(lambda self,a,b,c: 6)

assert mock(2, 'anyString')==6
assert mock(1, 'anyString')==5
assert mock(1, 1) is None

Named arguments in setup

class A:
    def f(self, a: int, b: str, c:str|None =None) -> None:
        pass
# this will be prettier, I promise!
mock = FunctionMock(A.f)

# Generic type checker
mock.setup(
    ArgumentFunctionValidator(lambda a: isinstance(a, int), name='a', position=1),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=2),
    c=ArgumentFunctionValidator(lambda c: c is None, name='c', position=3)).returns(lambda self,a,b,c: 5)

# Value checker
mock.setup(
    ArgumentFunctionValidator(lambda a: a==2, name='a', position=1),
    ArgumentFunctionValidator(lambda b: isinstance(b, str), name='b', position=2),
    c=ArgumentFunctionValidator(lambda c: c is None, name='c', position=3)).returns(lambda self,a,b,c: 6)

assert mock(2, 'anyString')==6
assert mock(1, 'anyString')==5
assert mock(1, 1) is None

Build library