Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Unit-testing with PyGithub

Intro

Lately, I've been building a Python platform that relies heavily on interactions with Github. Instead of re-inventing the wheel, I decided to go with PyGithub as a wrapper around Github v3 API.

The problem

This library implements the full API, which allows me to be very productive when I need to add a workflow in my platform that involves Github. However, it quickly became a real PITA to write unit tests. Even if the package comes with its own testing Framework, it is not documented yet and I didn't manage to crack it up in a decent amount of time.

I decided to hack the testing a little bit, using another very cool package, httpretty. Httpretty allows you to monkey patch the socket module during testing, so you can respond anything you want to any kind of network requests. Here's what I came up with, do not hesitate to give any feedback.


NOTE This is a workaround solution, at best, knowing that PyGithub does have a built-in testing framework. If, like me and a few others, you don't want to spend too much time trying to figure out how it works, my solution is quick, easy and efficient enough... But is probably not as good as the built-in solution ;-)

{
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false,
"name": "monalisa octocat",
"company": "GitHub",
"blog": "https://github.com/blog",
"location": "San Francisco",
"email": "octocat@github.com",
"hireable": false,
"bio": "There once was...",
"public_repos": 2,
"public_gists": 1,
"followers": 20,
"following": 0,
"created_at": "2008-01-14T04:33:35Z",
"updated_at": "2008-01-14T04:33:35Z"
}
from unittest import TestCase
import httpretty
class JsonContent:
"""Descriptor that sets a new class attribute based on the JSON file of the same name"""
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return owner
with open(f'your/path/{self.name}.json') as file:
setattr(instance, self.name, file.read())
return getattr(instance, self.name)
class MockGithubResponse:
user = JsonContent('user')
# add any Github object you need here
class PyGithubTestCase(TestCase):
def setUp(self):
httpretty.enable()
httpretty.reset()
base_url = 'https://api.github.com'
headers = {
'content-type': 'application/json',
'X-OAuth-Scopes': 'admin:org, admin:repo_hook, repo, user',
'X-Accepted-OAuth-Scopes': 'repo'
}
fake = MockGithubResponse()
response_mapping = {
'/user(/(\w+))?': fake.user,
# Add API url RegEx, with its corresponding response here...
}
for url, response in response_mapping.items():
# Note: Here, I only bind `GET` methods, but you can bind any method you want
httpretty.register_uri(
httpretty.GET,
re.compile(base_url + url),
response,
adding_headers=headers # You need this!
)
def tearDown(self):
httpretty.disable()
import json
from github import Github
from .base import PyGithubTestCase
class TestUser(PyGithubTestCase):
def test_username(self):
g = Github('fake-token')
user = g.get_user()
with open('path/to/user.json', 'rb') as f:
expected_body = json.load(f)
self.assertEqual(user.login, expected_body['login']) # True
@Iron-Stark

This comment has been minimized.

Copy link

@Iron-Stark Iron-Stark commented Aug 13, 2020

How do you use this solution with something like g.get_organization(org).get_repos()? The response in this case is a Pagenation object which makes something like for repo in g.get_organization(org).get_repos(): throw errors because the object returned should be a pagenation object and its not so straightforward to create a mock of that!

@edthrn

This comment has been minimized.

Copy link
Owner Author

@edthrn edthrn commented Aug 13, 2020

As a quick answer, I would try to store dummy data in the JSON file as being an array of multiple objects. That way, the response body can be iterated over as if it was several pages of results...

@Iron-Stark

This comment has been minimized.

Copy link

@Iron-Stark Iron-Stark commented Aug 13, 2020

Thank you for your response. I did something similar but on calling the g.get_organization(org).get_repos(), its not calling the right response registered in httpretty:

class PageContent:
    """Descriptor that creates a list of JSON files"""

    def __init__(self, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return owner

        with open(f'tests/resources/{self.name}.json') as file:
            setattr(instance, self.name, json.load(file))

        return getattr(instance, self.name)

This is the pagenation class I have, where the repo.json file is a list of dictionaries containing some fake repo responses. The name of my org is dummy_org and this is my addition to response_mapping '/orgs/dummy_org/repos(/(\w+))?': fake.repo, but calling g.get_organization(org).get_repos() in the code is not getting fake.repo as a response, rather, its getting an empty PaginationList object

@edthrn

This comment has been minimized.

Copy link
Owner Author

@edthrn edthrn commented Aug 13, 2020

Make sure you also added the descriptor object to your base TestCase class:

I'm not sure of what you need exactly, but it may look similar to this:

class MockGithubResponse:

    user = JsonContent('user')
    # The name must match the name of your JSON file
    orgs = JsonContent('orgs')
    ...
@ChaiBapchya

This comment has been minimized.

Copy link

@ChaiBapchya ChaiBapchya commented Aug 17, 2020

how do you test this? @edouardtheron

  • user.json files left as is.

  • Updated the path to json file in base.py

  • Updated test.py with 2 things

  1. from base instead of relative import as python was complaining
  2. call the test_username function

test.py

import json

from github import Github

from base import PyGithubTestCase


class TestUser(PyGithubTestCase):
    def test_username(self):
        g = Github('fake-token')
        user = g.get_user()

        with open('user.json', 'rb') as f:
            expected_body = json.load(f)
        self.assertEqual(user.login, expected_body['login'])  # True

TestUser().test_username()

I tried invoking test.py

python test.py

This errors out with BadCredentialsException

Traceback (most recent call last):
  File "test.py", line 18, in <module>
    TestUser().test_username()
  File "test.py", line 16, in test_username
    self.assertEqual(user.login, expected_body['loin'])  # True
  File "/usr/local/lib/python3.8/site-packages/github/AuthenticatedUser.py", line 232, in login
    self._completeIfNotSet(self._login)
  File "/usr/local/lib/python3.8/site-packages/github/GithubObject.py", line 299, in _completeIfNotSet
    self._completeIfNeeded()
  File "/usr/local/lib/python3.8/site-packages/github/GithubObject.py", line 303, in _completeIfNeeded
    self.__complete()
  File "/usr/local/lib/python3.8/site-packages/github/GithubObject.py", line 310, in __complete
    headers, data = self._requester.requestJsonAndCheck("GET", self._url.value)
  File "/usr/local/lib/python3.8/site-packages/github/Requester.py", line 317, in requestJsonAndCheck
    return self.__check(
  File "/usr/local/lib/python3.8/site-packages/github/Requester.py", line 342, in __check
    raise self.__createException(status, responseHeaders, output)
github.GithubException.BadCredentialsException: 401 {"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}
@edthrn

This comment has been minimized.

Copy link
Owner Author

@edthrn edthrn commented Aug 18, 2020

This error is probably saying that the monkey-patching of your socket connection did not work for some reason. I suggest you double-check your configuration with httpretty

Also, note that this is not how tests should be invoked in Python. You may want to have a look at unittest and/or pytest

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment