# Test

# pytest

  1. Schema

    import pytest
    
    def test_():
        """
        This test function can be discovered
        """
        pass
    
    class MyClass:
        """
        No __init__
        """
        def test_sth(self):
            """
            This test function (starts with test and self as parameter) can be discovered
            """
            pass
    
  2. Command line

    > pytest ocr_test.py -k "test_single_document_endpoint"
    

    keyword expression -k

    > pytest ocr_test.py -k "123 and not 456"
    

    Runs 123.678 not 123.456

    > pytest ocr_test.py -k "123 or 456"
    

    Runs 123 and 456

    > touch repo/conftest.py
    
    repo
    ├── conftest.py
    ├── app.py
    ├── settings.py
    ├── models.py
    └── tests
        └── test_app.py
    

    pytest automatically adds PYTHONPATH as the path where conftest.py stays

    > pytest path/to/test_file.py::TestClass::test_method
    

    Runs one test method of a class

  3. Parameterization decorator

    # content of test_expectation.py
    import pytest
    
    @pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
    def test_eval(test_input, expected):
        assert eval(test_input) == expected
    

    pytest_generate_tests

    # content of test_strings.py
    
    def test_valid_string(stringinput):
        assert stringinput.isalpha()
    
    def pytest_generate_tests(metafunc):
        if "stringinput" in metafunc.fixturenames:
            metafunc.parametrize("stringinput", some_list)
    
  4. Fixture scope

    Fixtures are created when first requested by a test, and are destroyed based on their scope:

    • function: the default scope, the fixture is destroyed at the end of the test.
    • class: the fixture is destroyed during teardown of the last test in the class.
    • module: the fixture is destroyed during teardown of the last test in the module.
    • package: the fixture is destroyed during teardown of the last test in the package.
    • session: the fixture is destroyed at the end of the test session.

    caveats: only fixture value is destroyed. internal code memory status is stable.

    Fixture Availability

    1. PyPI projects that match “pytest-*” are considered plugins and are listed automatically. Packages classified as inactive are excluded. Full list
    2. Fixture availability is determined from the perspective of the test. A fixture is only available for tests to request if they are in the scope that fixture is defined in. If a fixture is defined inside a class, it can only be requested by tests inside that class. But if a fixture is defined inside the global scope of the module, than every test in that module, even if it’s defined inside a class, can request it.
      Similarly, a test can also only be affected by an autouse fixture if that test is in the same scope that autouse fixture is defined in (see Autouse fixtures are executed first within their scope).
      A fixture can also request any other fixture, no matter where it’s defined, so long as the test requesting them can see all fixtures involved.
    class Apple:
        def __init__(self, count):
            self.value = count
    
    
    class SS:
        instance = None
    
        def __init__(self):
            pass
    
        @classmethod
        def create(cls, count):
            if cls.instance is None:
                cls.instance = Apple(count)
            else:
                print(cls.instance)
                cls.instance = Apple(10)
    
            return cls.instance
    
    
    @pytest.fixture()  # default teardown after each test function
    def sston():
        guagua = SS.create(20)
        return guagua
    
    
    class TestSS:
        def test_one(self, sston):
            assert sston.value == 20
    
        def test_two(self, sston):
            assert sston.value == 10  # SS.instance is still there
    
  5. conftest.py

    In a wide meaning conftest.py is a local per-directory plugin. Here you define directory-specific hooks and fixtures.

    caveats

    • when testing, conftest.pys defined are imported and collected
    • fixtures defined in the conftest.py in the same directory win
  6. Practices

    6.1 mock requests

    # pytest register requests_mock as requests_mock.Mocker(real_http=False, session=None)
    # i.e., all requests are captured
    @pytest.fixture
    def mock_a_request(requests_mock):
        requests_mock.post(...)
        requests_mock.get(...)
        requests_mock.register_uir('POST', 'mock://127.0.0.1', additional_matcher=callable, json=callable)
    
    def test_sth(mock_a_request):
        ...
    

    6.2 MagicMock with monkeypatch

    @pytest_fixture
    def mock_attribute(monkeypatch):
        mock_sth = MagicMock()
        mock_sth.return_value = 'sth'
        monkeypatch.setattr(module, 'attribute', mock_sth)
    
    def test_sth(mock_attribute):
        ...
    
    ############
    # Alterative
    ############
    def test_sth():
        with patch.object(module, 'attribute') as mock_sth:  # a MagicMock
            mock_sth.return_value = 'sth'
            ...
    

    6.3 mock local or remote model predictions:

    • mock function call
    • mock tfs requests
  7. Folder structure

    __init__.py is needed to maintain a folder structure

  8. request

    request.getfixturevalue('name')
    
  9. logging

    • -s: Stops hiding std_out/std_err for successful tests

    • --log-cli-level=INFO: Shows logs above INFO

    • -p no:logging: Turns off logging plugin

  10. pytest-aiohttp

    by pip install pytest-aiohttp or pytest_plugins = 'aiohttp.pytest_plugin' in root conftest.py

    async coroutines are runnable automatically

    async def some_fn():
        r = await a_fn()
        assert r
    
  11. pytest-asyncio

    async coroutines are runnable with @pytest.mark.asyncio

    @pytest.mark.asyncio
    async def some_fn():
        r = await a_fn()
        assert r
    

    take care of contextvars usage

    import aiocontextvars
    class CM:
        def __init__(self):
            pass
        async def __aenter__(self):
            c = aiocontextvars._get_context()
            self.c = c
            return 10
        async def __aexit__(self, exc_type, exc_val, exc_tb):
            c2 = aiocontextvars._get_context()
            assert self.c == c2  # True
            assert self.c is c2  # False as pytest_asyncio handles aenter, aexit in two successive contexts, and the later is a copy of the former
    
    @pytest.fixture
    async def some_fn_with_context_manager():
        async with CM():
            yield
    
    async def test_debug(some_fn_with_context_manager):
        pass  # tear_down fails
    
    # from https://github.com/pytest-dev/pytest-asyncio/issues/127
    from contextvars import ContextVar
    import pytest
    blah = ContextVar("blah")
    
    @pytest.fixture
    async def my_context_var():
        blah.set("hello")
        assert blah.get() == "hello"
        yield blah
    
    @pytest.mark.asyncio
    async def test_blah(my_context_var):
        assert my_context_var.get() == "hello" # this fails, The cause appears to be that the async fixtures are ran in own Tasks.
    
  12. celery

    def test_some_task(celery_worker):
        some_task.delay()  # if no celery worker, some_task hangs
    
  13. parallel

    https://www.jetbrains.com/help/pycharm/performing-tests.html#test-mutliprocessing

  14. configuration

    Many pytest settings can be set in a configuration file, which by convention resides on the root of your repository or in your tests folder.

    example pytest.ini

    [pytest]
    log_cli=true
    log_level=DEBUG
    
  15. Issue with ContextVars As discussed in github, ContextVars set before yield in pytest's fixture is quaranntined from real test code

# unittest

unittest.mock wraps the outdated mock module since python 3.3

# patch

patch the correct item

If the function is used directly, instead of patching the original place where function is defined, patch the place where function is used.
If an attribute of an object is used, patching the place where the object is defined is okay (import is always the 1st step. patching before usage is important.).

"""
myapp.py
"""
from where.method.is_defined import standalone_method
from where.anotherMethod.is_defined import ObjectContainingThisMethod

class MyApp:
    def __init__(self):
        pass
    def abc(self):
       standalone_method() # method is called directly

    def efg(self):
        obj = ObjectContainingThisMethod()
        obj.method() # method is a class attribute

"""
patch the correct item
"""
import where.method.is_used.MyApp

def test_abc():
    from where.method import is_used
    with patch.object(is_used, 'standalone_method'):
        myapp = MyApp
        myapp.abc() # method is changed to mocked

def test_efg():
    from where.anotherMethod.is_defined import ObjectContainingThisMethod
    with patch.object(ObjectContainingThisMethod, 'method'):
        myapp = MyApp
        myapp.efg() # method is changed to mocked

patch, patch.dict, or patch.object can act as a function decorator, class decorator or a context manager. When used as a class decorator, methods starting with patch.TEST_PREFIX are mocked.

patch(target, new=DEFAULT, **kwargs)

  • if new is omitted, AsyncMock is generated for async target and MagicMock is generated for others
  • target must be string with the format package.module.ClassName
  • can only mock one module at that path, if the path is correct

patch.object(target, attribute, new=DEFAULT, **kwargs)

  • if new is omitted, AsyncMock is generated for async target and MagicMock is generated for others
  • can only mock one module at that path, if the path is correct
  • target must be imported in unit test for patch.object to work
  • target is python variable and attribute is string

patch.dict(in_dict, values=(), clear=False, **kwargs)

  • modifies the dictionary everywhere
  • in_dict can be a string('package.module.dict') or python dict variable
  • values can be a dictionary or a iterable of (key, value) pairs
  • if clear is true, in_dict is replaced. Otherwise, in_dict is updated
  • since python 3.8, if used as context manager the mocked dict is returned
  • in_dict and the mocked dict(since 3.8) are restored back after test
# affects dictionary in moduleA, moduleB, ...
# (target, value, clear)
# clear: whether to empty the dictionary before overriding
# target: imported or string
with mock.patch.dict('moduleA.dictionary', {'key':'value'}, clear=True):
    pass
with mock.patch.dict(moduleA.dictionary, {'key':'value'}, clear=True):
    pass

# only affects moduleA
# (target, attribute, value)
# read_only attribute cannot be patched
with mock.patch.object(moduleA, 'dictionary', {'key':'value'}):
    pass

# only affects moduleA
# (target, value)
# target can be string or imported
with mock.patch('moduleA.dictionary', {'key':'value'}):
    pass

# nested mock
with mock.patch('moduleA.dictionary', {'key':'value'}) as mock_a, mock.patch('moduleB.dictionary', {'key':'value'}) as mock_b:
    pass
# patch.dict can also be used to mock environment variables
with mock.patch.dict('os.environ', {'hello': 'world'}, clear=True):
    pass

# make patch a fixture
@pytest.fixture(autouse=True)
def mock_settings_env_vars():
    with mock.patch.dict(os.environ, {"FROBNICATION_COLOUR": "ROUGE"}):
        yield

side_effect

side_effect: assign an exception, iterable, or function.

  • iterable: return in order
  • function: pass in the arguments from original function

handle class mock

instance method with self needed a argument place, whereas class method with cls does not

# Coverage

Only importable files (ones at the root of the tree, or in directories with a init.py file) will be considered.

# command

pytest --cov-report term --cov=apple --cov=orange apple_tests


"""
---------- coverage: python ----------
Name                                        Stmts   Miss  Cover
---------------------------------------------------------------
apple/aaaaaaaaaa.py                       41      9    78%
apple/bbbbbbbbbbbbbbb.py                  48     13    73%
---------------------------------------------------------------
TOTAL                                     89     22    75%
"""

or

[run]
source = apple,orange
python --cov-config=.coveragerc --cov-report term --cov apple_tests
Last Updated: 2/1/2024, 4:22:58 PM