0

So this involves a maybe unusual chain of things:

A.py

from B import call

def make_call():
    print("I'm making a call!")
    call(phone_number="867-5309")

B.py

def call(phone_number):
    pass

test_A.py

import pytest

from A import make_call

@pytest.fixture
def patch_A_call(monkeypatch):
    number = "NOT ENTERED"
    number_list = []
    def call(phone_number):
        nonlocal number
        number = phone_number
        number_list.append(phone_number)

    monkeypatch.setattr("A.call", call)
    return (number, number_list)


def test_make_call(patch_A_call):
    make_call()

    print(f"number: {patch_A_call[0]}")
    print(f"number_list: {patch_A_call[1]}")

What's printed is:

number: NOT ENTERED
number_list: [867-5309]

I expected "867-5309" to be the value for both results.

I know that lists are passed by reference in Python—but I assumed that the nonlocal declaration would pass the variable along down the chain.

Why doesn't it work this way?

AmagicalFishy
  • 1,249
  • 1
  • 12
  • 36
  • @larsks nope! that's just pytest syntax. i dunno how familiar you are with [pytest fixtures](https://docs.pytest.org/en/6.2.x/fixture.html), but accessing the requested fixture just accesses its return-value (in this case: a list) – AmagicalFishy Jun 29 '23 at 02:23
  • Do you understand about Python's scoping rules, and in particular [lexical scoping](https://en.wikipedia.org/wiki/Scope_(computer_science)#Lexical_scope)? `patch_A_call` fixture just returns a tuple of (str, list). The nonlocal keyword did apply, but it applied _within_ the scope of `patch_A_call`. It can't magically modify the tuple, which has already been returned _before_ you even invoke `make_call()`. If instead of returning `number` you return `lambda: number` (and then use this "getter" in your print function) then you'll see that the call did modify the nonlocal variable. – wim Jun 29 '23 at 04:19

1 Answers1

2

If you want to see how call is called, I think a simpler option is to replace it with a Mock object:

from unittest import mock

from A import make_call


@mock.patch('A.call')
def test_make_call(fake_call):
    make_call()
    assert fake_call.call_args.kwargs['phone_number'] == '867-5309'

Here, we're replacing A.call with a unittest.mock.Mock object. This gets called by make_call, and the call arguments are recorded by the Mock object for later inspection.

This requires substantially less code.

Note that I'm using an assert statement here, but you could instead print out or otherwise record the value of phone_number if that's your goal.


The primary problem with your solution is that your patch_A_call fixture is called once, before your test_make_call method executes.

So while the nonlocal keyword is working as intended...you never see the result, because that return (number, number_list) statement ran before you made the call to make_call.

You see the result in the list because a list is a "container" -- you add the number to it when calling make_call, and you can see the result because the returned list is the same object available from inside your patched call method.


For my solution, we don't have to use mock.patch(); we can rewrite your fixture like this:

import pytest
from unittest import mock

from A import make_call


@pytest.fixture
def patch_A_call(monkeypatch):
    call_recorder = mock.Mock()
    monkeypatch.setattr("A.call", call_recorder)
    return call_recorder


def test_make_call(patch_A_call):
    make_call()
    assert patch_A_call.call_args.kwargs["phone_number"] == "867-5309"

This accomplishes pretty much the same thing.

larsks
  • 277,717
  • 41
  • 399
  • 399
  • to make sure i'm understanding you correctly: if i wanted `number` to be accessible in the way i expect it to be, i'd have to use `nonlocal` somewhere in... `B` or something? (not that I intend to do this, i'm just making sure i understand the flow of things) – AmagicalFishy Jun 29 '23 at 03:04
  • 1
    I don't think there's any good way to do what you want. With you current solution, using just `number_list` instead of `number` is probably the best idea (since, as you noted in your question, that gives you access to the value you want), but I think using a mock object and inspecting the call arguments is a cleaner solution. – larsks Jun 29 '23 at 03:05
  • i think that'd be cleaner in general too. my case specifically though is: the to-be-mocked- function needs to be mocked, and is used a bunch—but i only explicitly need the return value in *one* case. i'd rather use an `autouse` fixture than a decorator for every test – AmagicalFishy Jun 29 '23 at 03:14
  • Note that I updated the question to show how to accomplish the same thing via your fixture instead of the `mock.patch` decorator :) – larsks Jun 29 '23 at 03:15