0

This is a continuation of the SO question asked here but with a more complicated pattern than originally requested. My main intention is to try to mock an API call based on values passed to its caller. The API call has no idea of the values passed to its caller but needs to provide the correct behavior so that the caller can be tested fully. I am using time to determine which behavior I want when I want it.

Given a an object:

# some_object.py 
from some_import import someApiCall

class SomeObject():
    def someFunction(time, a, b, c):
        apiReturnA = someApiCall(a)
        returnB = b + 1
        apiReturnC = someApiCall(c)
        return [apiReturnA, returnB, apiReturnC]

that is created by another object with entry point code:

# some_main.py
import some_object

class myMainObject():
    def entry_point(self, time):
        someObj = some_object.SomeObject()
        if 'yesterday' == time:
            (a, b, c) = (1, 1, 1)
        elif 'today' == time:
            (a, b, c) = (2, 2, 2)
        elif 'later' == time:
            (a, b, c) = (3, 3, 3)
        elif 'tomorrow' == time:
            (a, b, c) = (4, 4, 4)
        else:
            return "ERROR"
        return someObj.someFunction(time, a, b, c)

how can I get someApiCall to change based on the time argument?

# some_import.py
def someApiCall(var):
    print("I'm not mocked! I'm slow, random and hard to test")
    return var + 2

Here is an example test case

# test_example.py
import some_main
def amend_someApiCall_yesterday(var):
    # Reimplement api.someApiCall
    return var * 2


def amend_someApiCall_today(var):
    # Reimplement api.someApiCall
    return var * 3


def amend_someApiCall_later(var):
    # Just wrap around api.someApiCall. Call the original function afterwards. Here we can also put
    # some conditionals e.g. only call the original someApiCall if the var is an even number.
    import some_import
    var *= 4
    return some_import.someApiCall(var)


def someObject_decorator_patch(someFunction, mocker, *args):
    def wrapper(time, a, b, c):
        # If x imports y.z and we want to patch the calls to z, then we have to patch x.z. Patching
        # y.z would still retain the original value of x.z thus still calling the original
        # functionality. Thus here, we would be patching src.someApiCall and not api.someApiCall.
        if time == "yesterday":
            mocker.patch("some_object.someApiCall", side_effect=amend_someApiCall_yesterday)
        elif time == "today":
            mocker.patch("some_object.someApiCall", side_effect=amend_someApiCall_today)
        elif time == "later":
            mocker.patch("some_object.someApiCall", side_effect=amend_someApiCall_later)
        elif time == "tomorrow":
            mocker.patch("src.someApiCall", return_value=0)
        else:
            # Use the original api.someApiCall
            pass
        return someFunction(time, a, b, c)
    return wrapper

def test_some_main(mocker):
    results = 0
    uut = some_main.myMainObject()
    times = ['yesterday', 'today', 'later', 'tomorrow']
    mocker.patch.object(some_main.some_object.SomeObject, 'someFunction', someObject_decorator_patch)
    for time in times:
        results = uut.entry_point(time)
    print(results)
    assert 0 != results

The test case doesn't get the result I want (it returns a function pointer).

LeanMan
  • 474
  • 1
  • 4
  • 18

1 Answers1

1

Here is an improvised solution of https://stackoverflow.com/a/67498948/11043825 without using decorators.

As already pointed out, we still need to intercept the calls to the function that accepts the time argument which indicates how someApiCall would behave, which is either entry_point or someFunction. Here we would intercept someFunction.

Instead of implementing python decorator on someFunction which then needs to call that explicitly created decorated function, here we would amend (well this still follows the decorator design pattern) the someFunction in-place and make it available to the rest of the source code calls without explicitly changing the call to the decorated function. This is like an in-place replacement of the original functionalities, where we would replace (or rather wrap around) the original functionality with an updated one which would perform an assessment of the time before calling the original functionality.

Also for your reference, I solved it for 2 types of functions, a class method src.SomeClass.someFunction and a global function src.someFunction2.

./_main.py

import src

class MyMainClass:
    def __init__(self):
        self.var = 0

    def entry_point(self, time):
        someObj = src.SomeClass()
        self.var += 1
        if self.var >= 10:
            self.var = 0
        ret =  f'\n[1]entry_point({time})-->{someObj.someFunction(time, self.var)}'
        self.var += 1
        ret +=  f'\n[2]entry_point({time})-->{src.someFunction2(time, self.var)}'
        return ret

./src.py


class SomeClass:
    def someFunction(self, time, var):
        return f'someFunction({time},{var})-->{someSloowApiCall(var)}'

def someFunction2(time, var):
    return f'someFunction2({time},{var})-->{someSloowApiCall2(var)}'

./api.py

def someSloowApiCall(var):
    return f'someSloowApiCall({var})-->{special_message(var)}'

def someSloowApiCall2(var):
    return f'someSloowApiCall2({var})-->{special_message(var)}'

def special_message(var):
    special_message = "I'm not mocked! I'm slow, random and hard to test"
    if var > 10:
        special_message = "I'm mocked! I'm not slow, random or hard to test"
    return special_message

./test_main.py

import _main, pytest, api


def amend_someApiCall_yesterday(var):
    # Reimplement api.someSloowApiCall
    return f'amend_someApiCall_yesterday({var})'


def amend_someApiCall_today(var):
    # Reimplement api.someSloowApiCall
    return f'amend_someApiCall_today({var})'


def amend_someApiCall_later(var):
    # Just wrap around api.someSloowApiCall. Call the original function afterwards. Here we can also put
    # some conditionals e.g. only call the original someSloowApiCall if the var is an even number.
    return f'amend_someApiCall_later({var})-->{api.someSloowApiCall(var+10)}'

def amend_someApiCall_later2(var):
    # Just wrap around api.someSloowApiCall2. Call the original function afterwards. Here we can also put
    # some conditionals e.g. only call the original someSloowApiCall2 if the var is an even number.
    return f'amend_someApiCall_later2({var})-->{api.someSloowApiCall2(var+10)}'


def get_amended_someFunction(mocker, original_func):
    def amend_someFunction(self, time, var):
        if time == "yesterday":
            mocker.patch("_main.src.someSloowApiCall", amend_someApiCall_yesterday)
            # or
            # src.someSloowApiCall = amend_someApiCall_yesterday
        elif time == "today":
            mocker.patch("_main.src.someSloowApiCall", amend_someApiCall_today)
            # or
            # src.someSloowApiCall = amend_someApiCall_today
        elif time == "later":
            mocker.patch("_main.src.someSloowApiCall", amend_someApiCall_later)
            # or
            # src.someSloowApiCall = amend_someApiCall_later
        elif time == "tomorrow":
            mocker.patch("_main.src.someSloowApiCall", lambda var: f'lambda({var})')
            # or
            # src.someSloowApiCall = lambda var: 0
        else:
            pass
            # or
            # src.someSloowApiCall = someSloowApiCall
        return original_func(self, time, var)
    return amend_someFunction


def get_amended_someFunction2(mocker, original_func):
    def amend_someFunction2(time, var):
        if time == "yesterday":
            mocker.patch("_main.src.someSloowApiCall2", amend_someApiCall_yesterday)
            # or
            # src.someSloowApiCall2 = amend_someApiCall_yesterday
        elif time == "today":
            mocker.patch("_main.src.someSloowApiCall2", amend_someApiCall_today)
            # or
            # src.someSloowApiCall2 = amend_someApiCall_today
        elif time == "later":
            mocker.patch("_main.src.someSloowApiCall2", amend_someApiCall_later2)
            # or
            # src.someSloowApiCall2 = amend_someApiCall_later
        elif time == "tomorrow":
            mocker.patch("_main.src.someSloowApiCall2", lambda var : f'lambda2({var})')
            # or
            # src.someSloowApiCall2 = lambda var: 0
        else:
            pass
            # or
            # src.someSloowApiCall2 = someSloowApiCall2
        return original_func(time, var)
    return amend_someFunction2


@pytest.mark.parametrize(
    'time',
    [
        'yesterday',
        'today',
        'later',
        'tomorrow',
        'whenever',
    ],
)
def test_entrypointFunction(time, mocker):
    mocker.patch.object(
        _main.src.SomeClass,
        "someFunction",
        side_effect=get_amended_someFunction(mocker, _main.src.SomeClass.someFunction),
        autospec=True,  # Needed for the self argument
    )
    # or
    # src.SomeClass.someFunction = get_amended_someFunction(mocker, src.SomeClass.someFunction)

    mocker.patch(
        "_main.src.someFunction2",
        side_effect=get_amended_someFunction2(mocker, _main.src.someFunction2),
    )
    # or
    # src.someFunction2 = get_amended_someFunction2(mocker, src.someFunction2)

    uut = _main.MyMainClass()
    print(f'\nuut.entry_point({time})-->{uut.entry_point(time)}')

Output:

    $ pytest -rP
    =================================== PASSES ====================================
_____________________ test_entrypointFunction[yesterday] ______________________
---------------------------- Captured stdout call -----------------------------

uut.entry_point(yesterday)-->
[1]entry_point(yesterday)-->someFunction(yesterday,1)-->amend_someApiCall_yesterday(1)
[2]entry_point(yesterday)-->someFunction2(yesterday,2)-->amend_someApiCall_yesterday(2)
_______________________ test_entrypointFunction[today] ________________________
---------------------------- Captured stdout call -----------------------------

uut.entry_point(today)-->
[1]entry_point(today)-->someFunction(today,1)-->amend_someApiCall_today(1)
[2]entry_point(today)-->someFunction2(today,2)-->amend_someApiCall_today(2)
_______________________ test_entrypointFunction[later] ________________________
---------------------------- Captured stdout call -----------------------------

uut.entry_point(later)-->
[1]entry_point(later)-->someFunction(later,1)-->amend_someApiCall_later(1)-->someSloowApiCall(11)-->I'm mocked! I'm not slow, random or hard to test
[2]entry_point(later)-->someFunction2(later,2)-->amend_someApiCall_later2(2)-->someSloowApiCall2(12)-->I'm mocked! I'm not slow, random or hard to test
______________________ test_entrypointFunction[tomorrow] ______________________
---------------------------- Captured stdout call -----------------------------

uut.entry_point(tomorrow)-->
[1]entry_point(tomorrow)-->someFunction(tomorrow,1)-->lambda(1)
[2]entry_point(tomorrow)-->someFunction2(tomorrow,2)-->lambda2(2)
______________________ test_entrypointFunction[whenever] ______________________
---------------------------- Captured stdout call -----------------------------

uut.entry_point(whenever)-->
[1]entry_point(whenever)-->someFunction(whenever,1)-->someSloowApiCall(1)-->I'm not mocked! I'm slow, random and hard to test
[2]entry_point(whenever)-->someFunction2(whenever,2)-->someSloowApiCall2(2)-->I'm not mocked! I'm slow, random and hard to test
============================== 5 passed in 0.07s ==============================
LeanMan
  • 474
  • 1
  • 4
  • 18
  • I have an edit for your solution but seems like the suggested edit queue is full. I will add my suggestions once the queue clears up. – LeanMan May 13 '21 at 05:59
  • I've updated your answer. It shows the train of calls that occurs through the mocking so I think that is better. Great job! Let me know if you have any comments on it. Once edit accepted, I will make it the answer for the question. – LeanMan May 14 '21 at 10:40
  • Accepted. Glad it helped you :) – Niel Godfrey Pablo Ponciano May 14 '21 at 11:01