Getting monkeypatching in pytest to work

Question:

I’m trying to develop a test using pytest for a class method that randomly selects a string from a list of strings.

It looks essentially like the givemeanumber method below:

import os.path
from random import choice

class Bob(object):
  def getssh():
    return os.path.join(os.path.expanduser("~admin"), '.ssh')

  def givemeanumber():
    nos = [1, 2, 3, 4]
    chosen = choice(nos)
    return chosen

the first method, getssh, in the class Bob is just the example from the pytest docs

My production code fetches a list of strings from a DB and then randomly selects one. So I’d like my test to fetch the strings and then instead of randomly selecting, it selects the first string. That way I can test against a known string.

From my reading I reckon I need to use monkeypatching to fake the randomisation.

Here’s what I’ve got so far

import os.path
from random import choice
from _pytest.monkeypatch import MonkeyPatch
from bob import Bob

class Testbob(object):
    monkeypatch = MonkeyPatch()

    def test_getssh(self):
        def mockreturn(path):
            return '/abc'
        Testbob.monkeypatch.setattr(os.path, 'expanduser', mockreturn)
        x = Bob.getssh()
        assert x == '/abc/.ssh'

    def test_givemeanumber(self):
        Testbob.monkeypatch.setattr('random.choice',  lambda x: x[0])
        z = Bob.givemeanumber()
        assert z == 1

The first test method is again the example from the pytest docs (adapted slightly as I’m using it in a test class). This works fine.

Following the example from the docs I would expect to use
Testbob.monkeypatch.setattr(random, 'choice', lambda x: x[0])
but this yields
NameError: name 'random' is not defined

if I change it to
Testbob.monkeypatch.setattr('random.choice', lambda x: x[0])

it gets further but no swapping out occurs:
AssertionError: assert 2 == 1

Is monkeypatching the right tool for the job?
If it is where am I going wrong?

Asked By: Pierre Brasseau

||

Answers:

The problem comes from how the variables names are handled in Python. The key difference from other languages is that there is NO assigments of the values to the variables by their name; there is only binding of the variables’ names to the objects.

This is a bigger topic out of scope of this question, but the consequence is as follows:

  1. When you import a function choice from the module random, you bind a name choice to the function that exists there at the moment of import, and place this name in the local namespace of the bob module.

  2. When you patch the random.choice, you re-bind the name choice of module random to the new mock object.

  3. However, the already imported name in the bob module still refers to the original function. Because nobody patched it. The function itself was NOT modified, just the name was replaced.

  4. So, the Bob class calls the original random.choice function, not the mocked one.

To solve this problem, you can follow one of two ways (but not both, as they are conflicting):


A: Always call random.choice() function by that exact full name (i.e. not choice). And, of course, import random before (not from random import ...) — same as you do for os.path.expanduser().

# bob.py
import os.path
import random

class Bob(object):
  @classmethod
  def getssh(cls):
    return os.path.join(os.path.expanduser("~admin"), '.ssh')

  @classmethod
  def givemeanumber(cls):
    nos = [1, 2, 3, 4]
    chosen = random.choice(nos)   # <== !!! NOTE HERE !!!!
    return chosen

B: Patch the actual function that you call, which is bob.choice() in that case (not random.choice()).

# test.py
import os.path
from _pytest.monkeypatch import MonkeyPatch
from bob import Bob

class Testbob(object):
    monkeypatch = MonkeyPatch()

    def test_givemeanumber(self):
        Testbob.monkeypatch.setattr('bob.choice',  lambda x: x[0])
        z = Bob.givemeanumber()
        assert z == 1

Regarding your original error with unknown name random: If you watn to patch(random, 'choice', ...), then you have to import random — i.e. bind the name random to the module which is being patched.

When you do just from random import choice, you bind the name choice, but not random to the local namespace of the variables.

Answered By: Sergey Vasilyev

I landed on this question because I wanted to get MonkeyPatch working.

To get it working, you don’t write from _pytest.monkeypatch import MonkeyPatch. The leading underscore is a hint this is an internal method.

If you look at the docs, MonkeyPatch is a fixture.

import pytest


def test_some_foobar_env_var(monkeypatch):
    monkeypatch.setenv("SOME_ENV_VAR", "foobar")
    assert something

More about fixtures here.

Answered By: rustyMagnet
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.