0

Say one has a function that, among other tasks, makes a few api calls. Is there a way, when testing this function, to mock all the api calls and specify return values from the calls depending on the input. For example, say the function you want to test is this:

def someFunction (time, a, b, c) {
    const apiReturnA = someApiCall(a)
    const returnB = b + 1
    const apiReturnC = someApiCall(c)
    return [apiReturnA, returnB, apiReturnC]
}

I'd like to test someFunction and specify that, every time someApiCall gets called, don't execute the function, simply return a value based on the input to this function. For example, if I am working with time, I want the api call to return specific values based on a specific time otherwise return a noop value. How can one do this?

LeanMan
  • 474
  • 1
  • 4
  • 18
  • Inside someFunction, write an override_function, which when called maps someApiCall to userDefinedSomeApiCall? – lllrnr101 May 12 '21 at 05:40
  • Just write your own test version of `someApiCall` that does what you want, and mock the real function with that one (e.g. `patch('someModule.someApiCall', myApiCall)`). – MrBean Bremen May 12 '21 at 05:49
  • The problem that path doesn't solve (or that I fail to see how) is that lets say that time is some value I care about a specific output from someApiCall, I would want to make sure the mock returns that... otherwise, I want it to return a default noop value. I don't know how to change the behavior of someApiCall based on the time argument. – LeanMan May 12 '21 at 05:54
  • It is your function, you can write any returnj value. someApiCall is not getting called. – lllrnr101 May 12 '21 at 06:02
  • @LeanMan I just want to clarify, the time is only an argument for someFunction() right? And it really isn't an argument for someApiCall()? – Niel Godfrey Pablo Ponciano May 12 '21 at 06:31
  • @Neil that is correct – LeanMan May 12 '21 at 12:20

2 Answers2

2

You mentioned that the behavior of the someApiCall depends on the time argument:

... lets say that time is some value I care about a specific output from someApiCall, I would want to make sure the mock returns that...

To do this, then we have to intercept calls to the outer someFunction and check the time argument so that we can update the someApiCall accordingly. One solution is by decorating someFunction to intercept the calls and modify someApiCall during runtime based on the time argument before calling the original someFunction.

Below is an implementation using decorator. I made 2 possible ways:

  • one by patching via someFunction_decorator_patch
  • and another by manually modifying the source code implementation and then performing a reload via someFunction_decorator_reload

./src.py

from api import someApiCall


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

./api.py

def someApiCall(var):
    return var + 2

./test_src.py

from importlib import reload
import sys

import api
from api import someApiCall
from src import someFunction

import pytest


def amend_someApiCall_yesterday(var):
    # Reimplement api.someApiCall
    return var * 2


def amend_someApiCall_now(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.
    var *= 4
    return someApiCall(var)


def someFunction_decorator_patch(someFunction, mocker):
    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("src.someApiCall", side_effect=amend_someApiCall_yesterday)
        elif time == "now":
            mocker.patch("src.someApiCall", side_effect=amend_someApiCall_now)
        elif time == "later":
            mocker.patch("src.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 someFunction_decorator_reload(someFunction):
    def wrapper(time, a, b, c):
        # If x imports y.z and we want to update the functionality of z, then we have to update
        # first the functionality of z then reload x. This way, x would have the updated
        # functionality of z.
        if time == "yesterday":
            api.someApiCall = amend_someApiCall_yesterday
        elif time == "now":
            api.someApiCall = amend_someApiCall_now
        elif time == "later":
            api.someApiCall = amend_someApiCall_later
        elif time == "tomorrow":
            api.someApiCall = lambda var: 0
        else:
            # Use the original api.someApiCall
            api.someApiCall = someApiCall
        reload(sys.modules['src'])
        return someFunction(time, a, b, c)
    return wrapper


@pytest.mark.parametrize(
    'time',
    [
        'yesterday',
        'now',
        'later',
        'tomorrow',
        'whenever',
    ],
)
def test_sample(time, mocker):
    a, b, c = 10, 10, 10

    someFunction_wrapped_patch = someFunction_decorator_patch(someFunction, mocker)
    result_1 = someFunction_wrapped_patch(time, a, b, c)
    print("Using patch:", time, result_1)

    someFunction_wrapped_reload = someFunction_decorator_reload(someFunction)
    result_2 = someFunction_wrapped_reload(time, a, b, c)
    print("Using reload:", time, result_2)

Output:

$ pytest -rP
____________________________________________________________________________________ test_sample[yesterday] _____________________________________________________________________________________
------------------------------------------------------------------------------------- Captured stdout call --------------------------------------------------------------------------------------
Using patch: yesterday [20, 11, 20]
Using reload: yesterday [20, 11, 20]
_______________________________________________________________________________________ test_sample[now] ________________________________________________________________________________________
------------------------------------------------------------------------------------- Captured stdout call --------------------------------------------------------------------------------------
Using patch: now [30, 11, 30]
Using reload: now [30, 11, 30]
______________________________________________________________________________________ test_sample[later] _______________________________________________________________________________________
------------------------------------------------------------------------------------- Captured stdout call --------------------------------------------------------------------------------------
Using patch: later [42, 11, 42]
Using reload: later [42, 11, 42]
_____________________________________________________________________________________ test_sample[tomorrow] _____________________________________________________________________________________
------------------------------------------------------------------------------------- Captured stdout call --------------------------------------------------------------------------------------
Using patch: tomorrow [0, 11, 0]
Using reload: tomorrow [0, 11, 0]
_____________________________________________________________________________________ test_sample[whenever] _____________________________________________________________________________________
------------------------------------------------------------------------------------- Captured stdout call --------------------------------------------------------------------------------------
Using patch: whenever [12, 11, 12]
Using reload: whenever [12, 11, 12]
======================================================================================= 5 passed in 0.03s =======================================================================================

Here, you can see that the responses from someApiCall changes based on the time argument.

  • yesterday means var * 2
  • now means var * 3
  • later means (var * 4) + 2
  • tomorrow means 0
  • anything else means the default implementation of var + 2
  • I think this is close to what I'm looking for but the test does need to call the decorator in order to test someFunction(). What if this is not possible? Such as someFunction() is function within an object and you only want to modify that object's calls to someApiCall() (based on time argument) when it calls someFunction()? – LeanMan May 12 '21 at 12:33
  • You mean someFunction() is a method of a class e.g. MyClass? And it will be called internally from another method of that same class e.g. MyClass.entrypointFunction()? Something like this? class MyClass: def someFunction(self, time, a, b, c): apiReturnA = someApiCall(a) returnB = b + 1 apiReturnC = someApiCall(c) return [apiReturnA, returnB, apiReturnC] def someOtherFunction(self): return self.someFunction("now", 10, 10, 10) – Niel Godfrey Pablo Ponciano May 12 '21 at 12:46
  • That is one scenario except its an instance and not a class. Lets call it `myObject`. Another scenario is that another object calls `myObject.someFunction(time, a, b, c)`. Let's call this other object `entryPointObj`, now we have the unit under test `results = entryPointObj.behavior()` -> `myObject.someFunction(time, a, b, c)` -> intercept time and modify `api.someApiCall` -> execute `myObject.someFunction(time, a, b, c)` -> return results. After `entryPointObj.behavior()` completes, test `results` for expected behavior. – LeanMan May 12 '21 at 12:56
  • I've created a new question using parts of your answer to help improve the scenario I am after. Thank you for your help! https://stackoverflow.com/questions/67505679/mocking-api-call-embedded-in-some-objects-and-changing-behavior-based-on-inputs – LeanMan May 12 '21 at 14:32
  • 1
    Okay. I was actually making it work :D I would reply on the new thread :) – Niel Godfrey Pablo Ponciano May 12 '21 at 15:17
0

Let's say that your test file is test.py, and your library file is lib.py. Then test.py should read as follows:

import lib

def fakeApiCall(*args, **kwargs):
  return "fake"

lib.someApiCall = fakeApiCall

lib.someFunction(args)

The someApiCall method is just a variable in the namespace of the module in question. So, change the value of that variable. You may need to dig into locals() and/or globals(), like so:

data = None
locals()['data'] = "data"
print(data)  # will print "data"
mmiron
  • 164
  • 5