Search code examples
pythonunit-testingpytestpython-unittestpython-unittest.mock

How to mock the post() and get() calls of httpx in python unitest?


The following test works when I patch the entire function get_post() and get_call(). How can I patch the httpx.post() and httpx.get()?

In src/app.py

import httpx

class Client:
    def __init__(self, url):
        self.url = url

    def post_call(self, payload):
        response = httpx.post(
                url=self.url,
                json=payload,
            )
            response.raise_for_status()
            return response.json()

    def get_call(self, id):
        response = httpx.get(
                url=self.url,
                params={"id": id},
            )

        response.raise_for_status()
        return response.json()

In test/test.py

from unittest.mock import patch

import httpx
import pytest
from src import app


@pytest.mark.anyio
@patch("app.get_call",return_value=httpx.Response(200, json ={"status":"passed"}))
def test_get(mocker):
    cl = Client("test-url")
    result = cl.get_call("test")
    assert result.json() == {"status":"passed"}

@pytest.mark.anyio
@patch("app.get_post", return_value=httpx.Response(200,json={"status":"passed"}),)
def test_post(mocker):
    cl = Client("test-url")
    result = cl.post_call({"data":"test"})
    assert result.json() == {"status":"passed"}

When I tried to patch the httpx call then I get thrown with the error: ModuleNotFoundError: No module named 'app.Client'; 'app' is not a package


Solution

  • Fix import paths

    Assuming that your directory layout looks like this:

    .
    ├── src
    │   ├── app.py
    └── test
        └── test_app.py
    

    Then you don't want from src import app, because src isn't a package. It's just a directory. To help pytest locate app.py, create an empty src/conftest.py so that you have:

    .
    ├── src
    │   ├── app.py
    │   └── conftest.py
    └── test
        └── test_app.py
    

    Then in test_app.py, instead of:

    from src import app
    

    Write:

    import app
    

    Fix syntax errors

    There is a missing " in your test code; as written, attempting to run a test will fail with:

    E     File "/home/lars/tmp/python/test/test_app.py", line 19
    E       result = cl.post_call({"data:"test"})
    E                                         ^
    E   SyntaxError: unterminated string literal (detected at line 19)
    

    We need to correct line 19 to read:

    result = cl.post_call({"data": "test"})
    

    Additionally, in your test functions you have:

    cl = Client("test")
    

    This will fail because you have import app, not from app import Client. We need to either fix the import statement or fix the test code:

    cl = app.Client("test")
    

    Fix @patch calls

    Your existing patch invocations are broken as written, since your app module has neither get_call or get_post methods -- these are methods on the Client class. Fortunately, we need to replace this by patching httpx.get and httpx.post:

    from unittest.mock import patch
    
    import httpx
    import pytest
    import app
    
    
    @patch("app.httpx.get")
    def test_get(fake_httpx_get):
        fake_httpx_get.return_value = httpx.Response(
            200,
            json={"status": "passed"},
            request=httpx.Request("GET", "test"),
        )
        cl = app.Client("test-url")
        result = cl.get_call("test")
        assert result == {"status": "passed"}
    
    
    @patch("app.httpx.post")
    def test_post(fake_httpx_post):
        fake_httpx_post.return_value = httpx.Response(
            200,
            json={"status": "passed"},
            request=httpx.Request("POST", "test"),
        )
        cl = app.Client("test-url")
        result = cl.post_call({"data": "test"})
        assert result == {"status": "passed"}
    

    I've moved setting the return value on the mock inside the functions because we also need to set the request parameter; without that your calls to res.raise_for_status() will fail:

    RuntimeError: Cannot call `raise_for_status` as the request instance has not been set on this response.
    

    With the above directory structure and test code, when we run pytest from the top-level directory, it results in:

    ========================================================= test session starts =========================================================
    platform linux -- Python 3.11.1, pytest-7.2.1, pluggy-1.0.0 -- /home/lars/.local/share/virtualenvs/python-LD_ZK5QN/bin/python
    cachedir: .pytest_cache
    rootdir: /home/lars/tmp/python
    plugins: anyio-3.6.2
    collected 2 items
    
    test/test_app.py::test_get PASSED                                                                                               [ 50%]
    test/test_app.py::test_post PASSED                                                                                              [100%]
    
    ========================================================== 2 passed in 0.08s ==========================================================
    

    We can also test how your code responds to HTTP errors:

    @patch("app.httpx.post")
    def test_post_failure(fake_httpx_post):
        fake_httpx_post.return_value = httpx.Response(
            400,
            json={"status": "failed"},
            request=httpx.Request("POST", "test"),
        )
        cl = app.Client("test-url")
        with pytest.raises(httpx.HTTPStatusError):
            cl.post_call({"data": "test"})