Repository: yanyongyu/githubkit (language: python)

Issue:
Mock REST-API call
Hi,

thank you for the wonderful library. I use it quite a lot. Right now I would like to write a unit test for my own logic. Inside my logic I call the GitHub REST-API with githubkit. I did not manage to mock a call like

```python
github.rest.repos.get(owner="owner", repo="repo")
```

I tried it like this: https://stackoverflow.com/questions/39457108/how-to-mock-nested-multiple-layers-of-return-objects-in-python

Do you have a tip how to mock githubkit?


Discussion / hints:
It seems you need to mock the githubkit's request and return the fake response data to your code. I think you can monkey patch the `request` function of the `github` instance.
I have just made a example mock function by pytest. I will add it to the readme docs.

Fix patch:
diff --git a/README.md b/README.md
index cc5bcf743..5d1763643 100644
--- a/README.md
+++ b/README.md
@@ -872,6 +872,62 @@ refresh_token_expire_time = auth.refresh_token_expire_time
 user_github = github.with_auth(auth)
 ```
 
+## Unit Testing
+
+If you are using githubkit in your business logic, you may want to mock the github API in your unit tests. You can custom the response by mocking the `request`/`arequest` method of the `GitHub` class. Here is an example of how to mock githubkit's API calls:
+
+```python
+import json
+from pathlib import Path
+from typing import Any, Type, Union
+
+import httpx
+import pytest
+
+from githubkit import GitHub
+from githubkit.utils import UNSET
+from githubkit.response import Response
+from githubkit.typing import URLTypes, UnsetType
+from githubkit.versions.latest.models import FullRepository
+
+
+FAKE_RESPONSE = json.loads(Path("fake_response.json").read_text())
+
+
+# Example function you want to test, which calls the GitHub API
+def target_sync_func() -> FullRepository:
+    github = GitHub("xxxxx")
+    resp = github.rest.repos.get("owner", "repo")
+    return resp.parsed_data
+
+
+def mock_request(
+    g: GitHub,
+    method: str,
+    url: URLTypes,
+    *,
+    response_model: Union[Type[Any], UnsetType] = UNSET,
+    **kwargs: Any,  # other request parameters including headers, data, etc.
+) -> Response[Any]:
+    # When the request is made, return a fake response
+    if method == "GET" and url == "/repos/owner/repo":
+        return Response[T](
+            httpx.Response(status_code=200, json=FAKE_RESPONSE),
+            Any if response_model is UNSET else response_model,
+        )
+    raise RuntimeError(f"Unexpected request: {method} {url}")
+
+
+# Test the target function
+def test_sync_mock():
+    with pytest.MonkeyPatch.context() as m:
+        # Patch the request method with the mock
+        m.setattr(GitHub, "request", mock_request)
+
+        repo = target_sync_func()
+        assert isinstance(repo, FullRepository)
+```
+
 ## Development
 
 Open in Codespaces (Dev Container):


Test patch:
diff --git a/tests/test_unit_test/fake_response.json b/tests/test_unit_test/fake_response.json
new file mode 100644
index 000000000..43fdac844
--- /dev/null
+++ b/tests/test_unit_test/fake_response.json
@@ -0,0 +1,220 @@
+{
+  "id": 512138996,
+  "node_id": "R_kgDOHoae9A",
+  "name": "githubkit",
+  "full_name": "yanyongyu/githubkit",
+  "private": false,
+  "owner": {
+    "login": "yanyongyu",
+    "id": 42488585,
+    "node_id": "MDQ6VXNlcjQyNDg4NTg1",
+    "avatar_url": "https://avatars.githubusercontent.com/u/42488585?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/yanyongyu",
+    "html_url": "https://github.com/yanyongyu",
+    "followers_url": "https://api.github.com/users/yanyongyu/followers",
+    "following_url": "https://api.github.com/users/yanyongyu/following{/other_user}",
+    "gists_url": "https://api.github.com/users/yanyongyu/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/yanyongyu/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/yanyongyu/subscriptions",
+    "organizations_url": "https://api.github.com/users/yanyongyu/orgs",
+    "repos_url": "https://api.github.com/users/yanyongyu/repos",
+    "events_url": "https://api.github.com/users/yanyongyu/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/yanyongyu/received_events",
+    "type": "User",
+    "site_admin": false
+  },
+  "html_url": "https://github.com/yanyongyu/githubkit",
+  "description": "The modern, all-batteries-included GitHub SDK for Python, including rest api, graphql, webhooks, like octokit!",
+  "fork": false,
+  "url": "https://api.github.com/repos/yanyongyu/githubkit",
+  "forks_url": "https://api.github.com/repos/yanyongyu/githubkit/forks",
+  "keys_url": "https://api.github.com/repos/yanyongyu/githubkit/keys{/key_id}",
+  "collaborators_url": "https://api.github.com/repos/yanyongyu/githubkit/collaborators{/collaborator}",
+  "teams_url": "https://api.github.com/repos/yanyongyu/githubkit/teams",
+  "hooks_url": "https://api.github.com/repos/yanyongyu/githubkit/hooks",
+  "issue_events_url": "https://api.github.com/repos/yanyongyu/githubkit/issues/events{/number}",
+  "events_url": "https://api.github.com/repos/yanyongyu/githubkit/events",
+  "assignees_url": "https://api.github.com/repos/yanyongyu/githubkit/assignees{/user}",
+  "branches_url": "https://api.github.com/repos/yanyongyu/githubkit/branches{/branch}",
+  "tags_url": "https://api.github.com/repos/yanyongyu/githubkit/tags",
+  "blobs_url": "https://api.github.com/repos/yanyongyu/githubkit/git/blobs{/sha}",
+  "git_tags_url": "https://api.github.com/repos/yanyongyu/githubkit/git/tags{/sha}",
+  "git_refs_url": "https://api.github.com/repos/yanyongyu/githubkit/git/refs{/sha}",
+  "trees_url": "https://api.github.com/repos/yanyongyu/githubkit/git/trees{/sha}",
+  "statuses_url": "https://api.github.com/repos/yanyongyu/githubkit/statuses/{sha}",
+  "languages_url": "https://api.github.com/repos/yanyongyu/githubkit/languages",
+  "stargazers_url": "https://api.github.com/repos/yanyongyu/githubkit/stargazers",
+  "contributors_url": "https://api.github.com/repos/yanyongyu/githubkit/contributors",
+  "subscribers_url": "https://api.github.com/repos/yanyongyu/githubkit/subscribers",
+  "subscription_url": "https://api.github.com/repos/yanyongyu/githubkit/subscription",
+  "commits_url": "https://api.github.com/repos/yanyongyu/githubkit/commits{/sha}",
+  "git_commits_url": "https://api.github.com/repos/yanyongyu/githubkit/git/commits{/sha}",
+  "comments_url": "https://api.github.com/repos/yanyongyu/githubkit/comments{/number}",
+  "issue_comment_url": "https://api.github.com/repos/yanyongyu/githubkit/issues/comments{/number}",
+  "contents_url": "https://api.github.com/repos/yanyongyu/githubkit/contents/{+path}",
+  "compare_url": "https://api.github.com/repos/yanyongyu/githubkit/compare/{base}...{head}",
+  "merges_url": "https://api.github.com/repos/yanyongyu/githubkit/merges",
+  "archive_url": "https://api.github.com/repos/yanyongyu/githubkit/{archive_format}{/ref}",
+  "downloads_url": "https://api.github.com/repos/yanyongyu/githubkit/downloads",
+  "issues_url": "https://api.github.com/repos/yanyongyu/githubkit/issues{/number}",
+  "pulls_url": "https://api.github.com/repos/yanyongyu/githubkit/pulls{/number}",
+  "milestones_url": "https://api.github.com/repos/yanyongyu/githubkit/milestones{/number}",
+  "notifications_url": "https://api.github.com/repos/yanyongyu/githubkit/notifications{?since,all,participating}",
+  "labels_url": "https://api.github.com/repos/yanyongyu/githubkit/labels{/name}",
+  "releases_url": "https://api.github.com/repos/yanyongyu/githubkit/releases{/id}",
+  "deployments_url": "https://api.github.com/repos/yanyongyu/githubkit/deployments",
+  "created_at": "2022-07-09T08:55:35Z",
+  "updated_at": "2024-09-30T04:46:44Z",
+  "pushed_at": "2024-09-28T07:19:32Z",
+  "git_url": "git://github.com/yanyongyu/githubkit.git",
+  "ssh_url": "git@github.com:yanyongyu/githubkit.git",
+  "clone_url": "https://github.com/yanyongyu/githubkit.git",
+  "svn_url": "https://github.com/yanyongyu/githubkit",
+  "homepage": "",
+  "size": 17631,
+  "stargazers_count": 175,
+  "watchers_count": 175,
+  "language": "Python",
+  "has_issues": true,
+  "has_projects": false,
+  "has_downloads": true,
+  "has_wiki": false,
+  "has_pages": false,
+  "has_discussions": false,
+  "forks_count": 24,
+  "mirror_url": null,
+  "archived": false,
+  "disabled": false,
+  "open_issues_count": 6,
+  "license": {
+    "key": "mit",
+    "name": "MIT License",
+    "spdx_id": "MIT",
+    "url": "https://api.github.com/licenses/mit",
+    "node_id": "MDc6TGljZW5zZTEz"
+  },
+  "allow_forking": true,
+  "is_template": false,
+  "web_commit_signoff_required": false,
+  "topics": [
+    "async",
+    "github",
+    "github-api",
+    "github-sdk",
+    "httpx",
+    "octokit",
+    "pydantic",
+    "python",
+    "sync"
+  ],
+  "visibility": "public",
+  "forks": 24,
+  "open_issues": 6,
+  "watchers": 175,
+  "default_branch": "master",
+  "temp_clone_token": null,
+  "template_repository": {
+    "id": 505681231,
+    "node_id": "R_kgDOHiQVTw",
+    "name": "python-poetry-template",
+    "full_name": "yanyongyu/python-poetry-template",
+    "private": false,
+    "owner": {
+      "login": "yanyongyu",
+      "id": 42488585,
+      "node_id": "MDQ6VXNlcjQyNDg4NTg1",
+      "avatar_url": "https://avatars.githubusercontent.com/u/42488585?v=4",
+      "gravatar_id": "",
+      "url": "https://api.github.com/users/yanyongyu",
+      "html_url": "https://github.com/yanyongyu",
+      "followers_url": "https://api.github.com/users/yanyongyu/followers",
+      "following_url": "https://api.github.com/users/yanyongyu/following{/other_user}",
+      "gists_url": "https://api.github.com/users/yanyongyu/gists{/gist_id}",
+      "starred_url": "https://api.github.com/users/yanyongyu/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/yanyongyu/subscriptions",
+      "organizations_url": "https://api.github.com/users/yanyongyu/orgs",
+      "repos_url": "https://api.github.com/users/yanyongyu/repos",
+      "events_url": "https://api.github.com/users/yanyongyu/events{/privacy}",
+      "received_events_url": "https://api.github.com/users/yanyongyu/received_events",
+      "type": "User",
+      "site_admin": false
+    },
+    "html_url": "https://github.com/yanyongyu/python-poetry-template",
+    "description": "My python project template with poetry support",
+    "fork": false,
+    "url": "https://api.github.com/repos/yanyongyu/python-poetry-template",
+    "forks_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/forks",
+    "keys_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/keys{/key_id}",
+    "collaborators_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/collaborators{/collaborator}",
+    "teams_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/teams",
+    "hooks_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/hooks",
+    "issue_events_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/issues/events{/number}",
+    "events_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/events",
+    "assignees_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/assignees{/user}",
+    "branches_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/branches{/branch}",
+    "tags_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/tags",
+    "blobs_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/git/blobs{/sha}",
+    "git_tags_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/git/tags{/sha}",
+    "git_refs_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/git/refs{/sha}",
+    "trees_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/git/trees{/sha}",
+    "statuses_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/statuses/{sha}",
+    "languages_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/languages",
+    "stargazers_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/stargazers",
+    "contributors_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/contributors",
+    "subscribers_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/subscribers",
+    "subscription_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/subscription",
+    "commits_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/commits{/sha}",
+    "git_commits_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/git/commits{/sha}",
+    "comments_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/comments{/number}",
+    "issue_comment_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/issues/comments{/number}",
+    "contents_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/contents/{+path}",
+    "compare_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/compare/{base}...{head}",
+    "merges_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/merges",
+    "archive_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/{archive_format}{/ref}",
+    "downloads_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/downloads",
+    "issues_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/issues{/number}",
+    "pulls_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/pulls{/number}",
+    "milestones_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/milestones{/number}",
+    "notifications_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/notifications{?since,all,participating}",
+    "labels_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/labels{/name}",
+    "releases_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/releases{/id}",
+    "deployments_url": "https://api.github.com/repos/yanyongyu/python-poetry-template/deployments",
+    "created_at": "2022-06-21T03:51:04Z",
+    "updated_at": "2024-09-03T08:01:45Z",
+    "pushed_at": "2024-09-03T08:01:44Z",
+    "git_url": "git://github.com/yanyongyu/python-poetry-template.git",
+    "ssh_url": "git@github.com:yanyongyu/python-poetry-template.git",
+    "clone_url": "https://github.com/yanyongyu/python-poetry-template.git",
+    "svn_url": "https://github.com/yanyongyu/python-poetry-template",
+    "homepage": "",
+    "size": 54,
+    "stargazers_count": 9,
+    "watchers_count": 9,
+    "language": null,
+    "has_issues": false,
+    "has_projects": false,
+    "has_downloads": true,
+    "has_wiki": false,
+    "has_pages": false,
+    "has_discussions": false,
+    "forks_count": 3,
+    "mirror_url": null,
+    "archived": false,
+    "disabled": false,
+    "open_issues_count": 0,
+    "license": null,
+    "allow_forking": true,
+    "is_template": true,
+    "web_commit_signoff_required": false,
+    "topics": [],
+    "visibility": "public",
+    "forks": 3,
+    "open_issues": 0,
+    "watchers": 9,
+    "default_branch": "master",
+    "temp_clone_token": null
+  },
+  "network_count": 24,
+  "subscribers_count": 2
+}
diff --git a/tests/test_unit_test/test_unit_test.py b/tests/test_unit_test/test_unit_test.py
new file mode 100644
index 000000000..75ef45b5a
--- /dev/null
+++ b/tests/test_unit_test/test_unit_test.py
@@ -0,0 +1,78 @@
+import json
+from pathlib import Path
+from typing import Any, Type, Union, TypeVar
+
+import httpx
+import pytest
+
+from githubkit import GitHub
+from githubkit.utils import UNSET
+from githubkit.response import Response
+from githubkit.typing import URLTypes, UnsetType
+from githubkit.versions.latest.models import FullRepository
+
+T = TypeVar("T")
+
+
+FAKE_RESPONSE = json.loads((Path(__file__).parent / "fake_response.json").read_text())
+
+
+def target_sync_func():
+    github = GitHub("xxxxx")
+    resp = github.rest.repos.get("owner", "repo")
+    return resp.parsed_data
+
+
+def mock_request(
+    g: GitHub,
+    method: str,
+    url: URLTypes,
+    *,
+    response_model: Union[Type[T], UnsetType] = UNSET,
+    **kwargs: Any,
+) -> Response[Any]:
+    if method == "GET" and url == "/repos/owner/repo":
+        return Response[T](
+            httpx.Response(status_code=200, json=FAKE_RESPONSE),
+            Any if response_model is UNSET else response_model,
+        )
+    raise RuntimeError(f"Unexpected request: {method} {url}")
+
+
+def test_sync_mock():
+    with pytest.MonkeyPatch.context() as m:
+        m.setattr(GitHub, "request", mock_request)
+
+        repo = target_sync_func()
+        assert isinstance(repo, FullRepository)
+
+
+async def target_async_func():
+    github = GitHub("xxxxx")
+    resp = await github.rest.repos.async_get("owner", "repo")
+    return resp.parsed_data
+
+
+async def mock_arequest(
+    g: GitHub,
+    method: str,
+    url: URLTypes,
+    *,
+    response_model: Union[Type[T], UnsetType] = UNSET,
+    **kwargs: Any,
+) -> Response[Any]:
+    if method == "GET" and url == "/repos/owner/repo":
+        return Response[T](
+            httpx.Response(status_code=200, json=FAKE_RESPONSE),
+            Any if response_model is UNSET else response_model,  # type: ignore
+        )
+    raise RuntimeError(f"Unexpected request: {method} {url}")
+
+
+@pytest.mark.anyio
+async def test_async_mock():
+    with pytest.MonkeyPatch.context() as m:
+        m.setattr(GitHub, "arequest", mock_arequest)
+
+        repo = await target_async_func()
+        assert isinstance(repo, FullRepository)