如何为GitPython的克隆/拉取函数编写单元测试?

10
我有一个使用GitPython对远程Git仓库执行克隆和拉取功能的Python项目。
例如,一个简单的示例:
import git
from git import Git
from git import Repo


def clone_and_checkout(full_dir, git_url, repo_ver):

    repo = Repo.clone_from(
        url=git_url,
        to_path=full_dir
    )

    # Trigger re-create if repository is bare
    if repo.bare:
        raise git.exc.InvalidGitRepositoryError

    # Set origin and pull
    origin = repo.remotes.origin
    origin.pull()

    # Check out desired version of repository
    g = Git(full_dir)
    g.checkout(repo_ver)

我希望能为这个函数编写一个单元测试,但显然,目前这需要与外部系统进行交互。
我想知道是否有人有模拟外部交互的经验,类似于使用Mock模拟HTTP调用。我希望能以一种可以在测试时被模拟且不需要调用实际Git远程的方式执行这些任务。
我应该如何为此编写测试?
编辑:为了更清楚地表述我的问题,我应该提一下我对Mock不熟悉,并且很难理解如何模拟这些类的实例而非类本身。我的问题应该更好地表达为“如何使用Mock设置特定于实例的属性,例如bare?”
我已经学到了很多关于Mock的知识,并且已经找到了如何做到这一点的方法,所以我将回答自己的问题。

你提到了模拟和Mock - 你尝试过吗? - jonrsharpe
这实际上是我用于模拟GitPython方法的方式,但我认为我最困扰的是模拟Repo和Git对象。 - Mierdin
你需要比“挣扎”更具体 - 提供一个 [mcve] 的尝试和对其问题的精确描述。 - jonrsharpe
@jonrsharpe - 感谢你的提示。我承认我有点匆忙写这个问题。我会添加更多细节来改进它。 - Mierdin
2个回答

7
这似乎是对Mock的不完全理解或使用Patch方法的常见结果。
首先要做的是阅读位于Mock文档中的“where to patch”部分。掌握了这些信息,您应该能够使用patch函数来模拟上述函数中使用的GitPython对象。这些装饰器将出现在您的单元测试函数之上。
@mock.patch('gitter.Repo')
@mock.patch('gitter.Git')

为了为这些模拟对象的实例提供返回值,您可以使用PropertyMock。以下是一个完整的单元测试示例,利用了这个方法:
import gitter  # file containing our clone function
import mock
import unittest


class test_gitter(unittest.TestCase):

    @mock.patch('gitter.Repo')
    @mock.patch('gitter.Git')
    def runTest(self, mock_git, mock_repo):

        # Set the "bare" attribute of the Repo instance to be False
        p = mock.PropertyMock(return_value=False)
        type(mock_repo.clone_from.return_value).bare = p

        gitter.clone_and_checkout(
            '/tmp/docker',
            'git@github.com:docker/docker.git',
            'master'
        )
        mock_git.checkout.called_once_with('master')

嘿@mierdin,这是一个旧的(2015年!)但有用的讨论。我只是好奇你是否仍在维护这个项目,如果是的话,能否提供链接?我也需要修补一些git克隆操作,但我正在使用github3.py和pytest。如果我还需要从中获取对象,我正在尝试了解如何使用pytest和unittest。非常感谢。 - Leah Wasser
很遗憾,这是为雇主进行的内部项目。然而,“补丁放置位置”文档中描述的相同原则应该同样适用于任何库,而不仅仅是我上面使用的那些。 - Mierdin

0

知识是帮助的源泉——理解git协议

实际上,你不需要模拟任何东西。你可以测试所有的功能,例如本地git操作,如:添加、提交、切换、变基或挑选,以及远程操作,如获取、推送或拉取,而无需设置git服务器。

本地和远程仓库使用以下协议交换数据:

  • 本地协议(不使用网络,在Unix系统中表示为file://
  • http(s)://协议
  • ssh://协议
  • git://协议

关于这个问题,可以参考git协议解释

创建git操作的测试环境

使用本地协议-当您在本地文件系统中指定其他存储库的路径时。因此,为了在干净且隔离的环境中执行测试,您只需要安排两个存储库。惯例是将“远程”设置为裸存储库。 然后,另一个存储库应该通过路径设置为第一个存储库的上游,就完成了! 从现在开始,您拥有了完全功能的测试设置。感谢Linus Torvalds。
Pytest实现了一个虚拟git存储库的夹具
这使用了一个内置的tmp_path夹具,它负责在临时文件夹中创建(和清理)存储库,对我来说是在:
/tmp/pytest-of-mikaelblomkvistsson/pytest-current/test_preparing_repo0
import datetime
from pathlib import Path

import pytest
from git import Actor, Remote, Repo


@pytest.fixture
def fake_repo(tmp_path) -> "Helper":
    return Helper(tmp_path)


class Helper:
    """The main purpose of defining it as a class is to gather all the variables
    under one namespace, so that we don't need to define 6 separate pytest fixtures.

    You don't need git server to test pull/push operations. Since git offers
    "local protocol" - plain bare repository in your filesystem is fully
    compatible with http(s), ssh and git protocol (Neglecting authentication functionality).
    """

    def __init__(self, tmp_path_fixture: Path):
        self.local_repo_path: Path = tmp_path_fixture / "local-repo"
        remote_repo_path: Path = tmp_path_fixture / "remote-repo"
        remote_repo_path.mkdir()

        self.remote_repo: Repo = Repo.init(str(remote_repo_path), bare=True)

        self.repo: Repo = Repo.init(str(self.local_repo_path))
        self.remote_obj: Remote = self.repo.create_remote("origin", str(remote_repo_path))

        # do initial commit on origin
        commit_date = self.tz_datetime(2023, 10, 1, 11, 12, 13)
        self.repo.index.commit("Initial commit", author_date=commit_date, commit_date=commit_date)
        self.remote_obj.push("master")

    def local_graph(self) -> str:
        return self.repo.git.log("--graph --decorate --pretty=oneline --abbrev-commit".split())

    @classmethod
    def tz_datetime(cls, *args, **kwargs):
        tz_info = datetime.datetime.utcnow().astimezone().tzinfo
        return datetime.datetime(*args, **kwargs, tzinfo=tz_info)

    def do_commit(self, *files_to_add, msg: str = "Sample commit message.", author: str = "author") -> None:
        author = Actor(author, f"{author}@example.com")
        # constant date helps to make git hashes reproducible, since the date affects commit sha value
        date = self.tz_datetime(2023, 10, 4, 15, 45, 13)

        self.repo.index.add([str(file_) for file_ in files_to_add])
        self.repo.index.commit(msg, author=author, committer=author, author_date=date, commit_date=date)


def test_preparing_repo(fake_repo):
    file_1 = fake_repo.local_repo_path / "file_1.txt"

    file_1.write_text("Initial file contents")
    fake_repo.do_commit(file_1, msg="First commit.")

    fake_repo.repo.git.checkout("-b", "new_branch")
    file_1.write_text("Changed file contents")
    fake_repo.do_commit(file_1, msg="Second commit.")

    fake_repo.repo.git.checkout("-b", "other_branch")
    file_1.write_text("Another change")
    fake_repo.do_commit(file_1, msg="Change on other_branch.")

    assert (
        fake_repo.repo.git.branch("-a")
        == """\
  master
  new_branch
* other_branch
  remotes/origin/master"""
    )

    assert (
        fake_repo.local_graph()
        == """\
* 1743bd6 (HEAD -> other_branch) Change on other_branch.
* 2696781 (new_branch) Second commit.
* 5ea439d (master) First commit.
* 04fc02f (origin/master) Initial commit"""
    )



网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接