5. pytest Chinese document monkey patch

Keywords: Python Attribute Database ssh github

Catalog

Sometimes, the test case needs to call some functions that depend on the global configuration, or these functions themselves call some code that is not easy to test (for example, network access). fixture monkeypatch can help you set / delete a property, dictionary item or environment variable safely, or even change the sys.path path path when importing the module.

monkeypatch provides the following methods:

monkeypatch.setattr(obj, name, value, raising=True)
monkeypatch.delattr(obj, name, raising=True)
monkeypatch.setitem(mapping, name, value)
monkeypatch.delitem(obj, name, raising=True)
monkeypatch.setenv(name, value, prepend=False)
monkeypatch.delenv(name, raising=True)
monkeypatch.syspath_prepend(path)
monkeypatch.chdir(path)

All changes will be undone after the test case or fixture is executed. The raising parameter indicates whether to report KeyError and AttributeError exceptions when the target of the set / delete operation does not exist.

1. Modify function function or class property

Use monkeypatch.setattr() to modify the function or attribute to the desired behavior, and use monkeypatch.delattr() to delete the function or attribute used in the test case;

Refer to the following three examples:

  • In this example, monkeypatch.setattr() is used to modify the Path.home method. During the test run, it always returns a fixed Path("/abc"), which removes its dependency on different platforms. After the test run, the modification to Path.home will be revoked.

    # src/chapter-5/test_module.py
    
    from pathlib import Path
    
    
    def getssh():
        return Path.home() / ".ssh"
    
    
    def test_getssh(monkeypatch):
        def mockreturn():
            return Path("/abc")
    
        # Replace Path.home
        # Need to be executed before the actual call
        monkeypatch.setattr(Path, "home", mockreturn)
    
        # Will use mockreturn instead of Path.home
        x = getssh()
        assert x == Path("/abc/.ssh")
  • In this example, monkeypatch.setattr() is used to simulate the return object of the function.

    Suppose we have a simple function to visit a url to return the content of the web page:

    # src/chapter-5/app.py
    
    from urllib import request
    
    
    def get(url):
        r = request.urlopen(url)
        return r.read().decode('utf-8')

    We are going to simulate r now. It needs a. read() method to return the data type of bytes. We can define a class in the test module to replace R:

    # src/chapter-5/test_app.py
    
    from urllib import request
    
    from app import get
    
    
    # Custom class to simulate the return value of urlopen
    class MockResponse:
    
        # Always return data of a fixed byte type
        @staticmethod
        def read():
            return b'luizyao.com'
    
    
    def test_get(monkeypatch):
        def mock_urlopen(*args, **kwargs):
            return MockResponse()
    
        # Replace request.urlopen with request.mock
        monkeypatch.setattr(request, 'urlopen', mock_urlopen)
    
        data = get('https://luizyao.com')
        assert data == 'luizyao.com'

    You can continue to build more complex MockResponse for the actual scenario; for example, you can include an ok attribute that always returns True, or return different values for read() based on the input string;

    We can also share across use cases through fixture:

    # src/chapter-5/test_app.py
    
    import pytest
    
    
    # monkeypatch is a function level scope, so the mock [response] can only be a function level.
    # Otherwise, scopemispatch will be reported. 
    @pytest.fixture
    def mock_response(monkeypatch):
        def mock_urlopen(*args, **kwargs):
            return MockResponse()
    
        # Replace request.urlopen with request.mock
        monkeypatch.setattr(request, 'urlopen', mock_urlopen)
    
    
    # Replace the original monkeypatch with "mock" response
    def test_get_fixture1(mock_response):
        data = get('https://luizyao.com')
        assert data == 'luizyao.com'
    
    
    # Replace the original monkeypatch with "mock" response
    def test_get_fixture2(mock_response):
        data = get('https://bing.com')
        assert data == 'luizyao.com'

    Be careful:

    • The fixture used in the test case is replaced by monkeypatch instead of the original mocku response.
    • Because monkeypatch is a function level scope, the mock response can only be a function level. Otherwise, scopemispatch: you tried to access the 'function' scoped fixture 'monkeypatch' with a 'module' scoped request object error will be reported.
    • If you want to apply mocku response to all test cases, you can consider moving it to conftest.py and marking autouse=True;
  • In this example, use monkeypatch.delattr() to delete the urllib.request.urlopen() method.

    # src/chapter-5/test_app.py
    
    @pytest.fixture
    def no_request(monkeypatch):
        monkeypatch.delattr('urllib.request.urlopen')
    
    
    def test_delattr(no_request):
        data = get('https://bing.com')
        assert data == 'luizyao.com'

    Implementation:

    λ pipenv run pytest --tb=native --assert=plain --capture=no src/chapter-5/test_app.
    py::test_delattr
    =============================== test session starts ================================ 
    platform win32 -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0
    rootdir: D:\Personal Files\Projects\pytest-chinese-doc
    collected 1 item
    
    src\chapter-5\test_app.py F
    
    ===================================== FAILURES ===================================== 
    ___________________________________ test_delattr ___________________________________ 
    Traceback (most recent call last):
      File "D:\Personal Files\Projects\pytest-chinese-doc\src\chapter-5\test_app.py", line 78, in test_delattr
        data = get('https://bing.com')
      File "D:\Personal Files\Projects\pytest-chinese-doc\src\chapter-5\app.py", line 26, in get
        r = request.urlopen(url)
    AttributeError: module 'urllib.request' has no attribute 'urlopen'
    ================================ 1 failed in 0.04s =================================

    Be careful:

    • Avoid deleting the methods in the built-in library. If you have to do so, you'd better add -- tb=native --assert=plain --capture=no;

    • Modifying the library used by pytest may pollute pytest itself. It is recommended to use MonkeyPatch.context(), which returns a MonkeyPatch object. In combination with with, these modifications are limited to the code of the package.

      def test_stdlib(monkeypatch):
      with monkeypatch.context() as m:
          m.setattr(functools, "partial", 3)
          assert functools.partial == 3

2. Modify environment variables

Using the setenv() and delenv() methods of monkeypatch, you can set / delete environment variables safely in the test;

# src/chapter-5/test_env.py

import os

import pytest


def get_os_user():
    username = os.getenv('USER')

    if username is None:
        raise IOError('"USER" environment variable is not set.')

    return username


def test_user(monkeypatch):
    monkeypatch.setenv('USER', 'luizyao')
    assert get_os_user() == 'luizyao'


def test_raise_exception(monkeypatch):
    monkeypatch.delenv('USER', raising=False)
    pytest.raises(IOError, get_os_user)

The raising of monkeypatch.delenv() should be set to False, otherwise KeyError may be reported;

You can also use fixture to share across use cases:

import pytest


@pytest.fixture
def mock_env_user(monkeypatch):
    monkeypatch.setenv("USER", "TestingUser")


@pytest.fixture
def mock_env_missing(monkeypatch):
    monkeypatch.delenv("USER", raising=False)


# notice the tests reference the fixtures for mocks
def test_upper_to_lower(mock_env_user):
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(mock_env_missing):
    with pytest.raises(OSError):
        _ = get_os_user_lower()

3. Modify dictionary

The monkeypatch.setitem() method can be used to safely modify specific values in the dictionary during the test.

DEFAULT_CONFIG = {"user": "user1", "database": "db1"}


def create_connection_string(config=None):
    config = config or DEFAULT_CONFIG
    return f"User Id={config['user']}; Location={config['database']};"

We can modify the users of the database or use other databases:

import app


def test_connection(monkeypatch):
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")

    expected = "User Id=test_user; Location=test_db;"

    result = app.create_connection_string()
    assert result == expected

You can use monkeypatch.delete to delete the specified item:

import pytest

import app


def test_missing_user(monkeypatch):
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

    with pytest.raises(KeyError):
        _ = app.create_connection_string()

GitHub warehouse address: https://github.com/luizyao/pytest-chinese-doc

Posted by sennetta on Fri, 18 Oct 2019 01:48:46 -0700