PR-Agent /add_docs 자동 실행 테스트 PR 기여 후기

.·2025년 6월 27일
0
post-thumbnail

서론

이전 포스트인 PR_Agent /add_docs 자동 실행 문서화 기여 후기에서는 /add_docs 명령어의 자동 실행 기능을 문서화하는 과정을 공유했었다.

이번 포스트에서는 후속 작업으로, 해당 기능의 동작을 검증하는 유닛 테스트를 직접 작성하고 오픈소스 PR로 기여했던 경험을 정리하고자 한다!

기능은 이미 코드에 구현돼 있었지만, 이를 테스트하는 공식 커버리지가 없었기 때문에 실제로 동작이 잘 되는지 확인하기 어렵다는 점이 문제였다. 이러한 문제를 해결하기 위한 PR 이벤트(opened, draft, closed 등)에 따른 조건 분기 동작을 정밀하게 검증하는 테스트를 설계하고 작성하게 되었다.


본론


해당 PR : test: auto-trigger /add_docs on PR opened events #1891


(1) 테스트 대상: /add_docs 자동 실행 기능

/add_docs는 PR이 열릴 때 자동으로 실행될 수 있지만, 실제로 해당 코드 경로를 테스트하는 유닛 테스트는 존재하지 않았다.

즉, 다음과 같이 3가지 문제점이 존재하였다.

1) PR의 actionopened일 때만 실행되어야 하지만, 이를 검증하는 테스트가 존재하지 않았다.
즉, 실수로 다른 액션(edited, reopened 등)에서도 /add_docs가 실행되게 변경되더라도, 이를 즉시 감지할 방법이 없다는 문제가 있었다.

2) PR이 draft 상태이거나 이미 closed된 경우에는 /add_docs가 실행되지 않아야 한다!
하지만 이런 예외 조건이 제대로 차단되고 있는지를 검증하는 테스트 역시 없었다.

3) 지금은 기능이 정상적으로 동작하더라도, 향후 리팩토링이나 수정 과정에서 의도치 않게 동작이 깨질 위험이 존재한다.

이러한 이유로, 현재 기능이 당장 잘 작동하고 있다고 해도 나중 리팩토링할 때나 혹시 모를 버그를 막기위해 테스트 케이스가 꼭 필요하다고 생각하였다.


(2) 테스트 설계 : 조건 검증

이번 유닛 테스트에서는 /add_docs 명령어가 PR 이벤트 상황에 따라 정확히 필요한 경우에만 실행되는지를 검증하고자 하였다!

테스트는 다음 네 가지 주요 케이스를 기준으로 설계하였다.

PR ActionDraft 상태PR 상태기대 결과
openedfalseopen⭕️ 실행됨
editedfalseopen❌ 실행 안 됨
openedtrueopen❌ 실행 안 됨
openedfalseclosed❌ 실행 안 됨

간단히 말해서, "PR이 최초로 열리고(opened), draft(초안)도 아니며, 실제로 열려 있는(open) 상태일 때만 실행되어야 한다"는 조건을 정밀하게 테스트한 것이다!

각 테스트 케이스마다 실제로 PRAddDocs.run() 함수가 호출되었는지를 확인해야 하므로,
monkeypatch를 사용해 해당 함수를 스파이 함수로 교체하고, 실행 여부를 assert로 체크하였다!

이렇게 설계해두면 이후 코드가 변경되더라도, 조건이 깨지면 바로 알 수 있게 해준다 😎


(3) 코드 분석

실제로 코드를 하나씩 살펴보자!

1. parametrize 정의

@pytest.mark.parametrize(
    "action,draft,state,should_run",
    [
        ("opened", False, "open", True),
        ("edited", False, "open", False),
        ("opened", True, "open", False),
        ("opened", False, "closed", False),
    ],
)

→ 총 4가지 케이스를 parametrize로 정의하였다.
각 조합은 PR 액션 종류, draft 여부, PR 상태를 나타내며
이 조건일 때 run()이 실행되어야 하는지 아닌지를 should_run으로 명시한 것이다!


2. 테스트 함수 정의

async def test_add_docs_trigger(monkeypatch, action, draft, state, should_run):

async def로 비동기 테스트를 구성했고, monkeypatch를 통해 테스트 중 필요한 함수들을 가짜로 교체할 수 있도록 만들었다.

간단히 monkeypatch에 대해 설명해보자면,
monkeypatch란 pytest에서 제공하는 강력한 기능 중 하나로,
테스트 실행 중 특정 객체나 함수, 메서드를 임시로 교체(mock) 해주는 도구이다.

예를 들자면, 실제 GitHubProvider나 외부 API를 호출하는 함수를
테스트용 가짜(Fake) 객체로 대체하고 싶을 때 아주 유용하다.
덕분에 외부 의존성 없이 순수 로직만 테스트할 수 있다!


3. 테스트 환경 설정

    settings = get_settings()
    settings.github_app.pr_commands = ["/add_docs"]
    settings.github_app.handle_pr_actions = ["opened"]

→ settings에서 /add_docs가 자동 실행 명령어로 등록되어 있고, "opened" 이벤트에 반응하도록 설정한다.
이걸 기반으로 테스트에서 자동 실행 조건을 시뮬레이션하도록 하였다.


4. Fake GitHub Provider 정의

    class FakeGitProvider:
        def __init__(self, pr_url, *args, **kwargs):
            self.pr = type("pr", (), {"title": "Test PR"})()

→ 실제 GitProvider 객체를 대체할 가짜(Fake) 객체를 만들어주었다.
PRAddDocs는 내부적으로 self.pr.title을 참조하기 때문에, 필요한 속성만 최소한으로 정의하였다.
(실제로는 get_pr_branch, get_files 같은 메서드도 간단히 구현돼 있어야 한다!)


5. GitHub provider, 인증자 패치

    monkeypatch.setattr(
        "pr_agent.git_providers.utils.get_git_provider_with_context",
        lambda pr_url: FakeGitProvider(pr_url),
    )

→ 원래는 PR URL을 바탕으로 실제 GitHub provider를 생성하지만,
테스트에서는 그걸 Fake로 대체해 실제 GitHub 요청 없이도 동작 확인이 가능하도록 하였다!

    monkeypatch.setattr(
        get_identity_provider().__class__,
        "verify_eligibility",
        lambda *args, **kwargs: Eligibility.ELIGIBLE,
    )

그리고 이건 테스트의 불필요한 외부 조건을 제거하고, 우리가 검증하고자 하는 핵심 조건들인 action, draft, state에만 집중할 수 있도록 도와준다.

원래 실제 실행 흐름에서는 /add_docs가 호출되기 전에,
가장 먼저 작성자가 '신뢰된 사용자'인지 판단하는 절차가 존재한다.

identity_provider.verify_eligibility(sender, sender_id)

이런 식으로 호출되며, 반환값이 Eligibility.ELIGIBLE이 아닐 경우
PRAddDocs.run()은 아예 호출되지 않는다.

하지만 이번 테스트에서는 이 인증 로직 자체가 목적이 아니기 때문에,
이 조건은 항상 통과되도록 고정하여 테스트에만 집중하도록 하였다!


6. run() 호출 감지용 스파이 함수 설정

    ran = {"flag": False}

    async def fake_run(self):
        ran["flag"] = True

    monkeypatch.setattr(PRAddDocs, "run", fake_run)

→ 실제 run()을 실행하지 않고, 호출 여부만 감지하기 위해 flag 기반의 spy를 구현하였다. 이번 테스트의 목적은 run()의 내부 로직 등에 관심이 없고 실행 여부만 판단하면 되므로 가장 간단하고 명확한 방법인 flag 방식을 사용하였다!


7. PR 이벤트 본문 구성

    body = {
        "action": action,
        "pull_request": {
            "url": "https://example.com/fake/pr",
            "state": state,
            "draft": draft,
        },
    }

→ 실제 GitHub Webhook에서 전송되는 pull_request 이벤트의 JSON 구조를 흉내내었다.
테스트에서는 이 중에서도 동작 여부에 영향을 주는 필드들(action, state, draft)만 선택해서 최소한의 데이터만 구성하였다.

이렇게 구성된 body는 테스트 시 handler 함수에 전달되어, 실제 PR 이벤트가 발생했을 때와 유사한 상황을 재현하는 역할을 한다.


8. 이벤트 핸들러 호출 (진입 구간)

    agent = PRAgent()
    await handle_new_pr_opened(
        body=body,
        event="pull_request",
        sender="tester",
        sender_id="123",
        action=action,
        log_context=log_context,
        agent=agent,
    )

→ 실제로 GitHub에서 pull_request 이벤트가 발생했을 때 실행되는 핸들러 함수다.
테스트에서는 이 핸들러를 직접 호출함으로써, 우리가 구성한 body, action, state 값들이 실제 동작 조건에 어떤 영향을 주는지를 검증할 수 있다.


9. 최종 검증

    assert ran["flag"] is should_run

assert를 통해 우리가 구성한 조건에 따라 PRAddDocs.run()이 실행됐는지를 확인한다.
조건에 맞으면 True, 아니면 False가 되어야 하며, 이 assert가 실패하면 테스트는 곧장 실패하게 된다!


최종 코드

import pytest
from pr_agent.servers.github_app import handle_new_pr_opened
from pr_agent.tools.pr_add_docs import PRAddDocs
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings
from pr_agent.identity_providers.identity_provider import Eligibility
from pr_agent.identity_providers import get_identity_provider


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "action,draft,state,should_run",
    [
        ("opened", False, "open", True),
        ("edited", False, "open", False),
        ("opened", True, "open", False),
        ("opened", False, "closed", False),
    ],
)
async def test_add_docs_trigger(monkeypatch, action, draft, state, should_run):
    # Mock settings to enable the "/add_docs" auto-command on PR opened
    settings = get_settings()
    settings.github_app.pr_commands = ["/add_docs"]
    settings.github_app.handle_pr_actions = ["opened"]

    # Define a FakeGitProvider for both apply_repo_settings and PRAddDocs
    class FakeGitProvider:
        def __init__(self, pr_url, *args, **kwargs):
            self.pr = type("pr", (), {"title": "Test PR"})()
            self.get_pr_branch = lambda: "test-branch"
            self.get_pr_description = lambda: "desc"
            self.get_languages = lambda: ["Python"]
            self.get_files = lambda: []
            self.get_commit_messages = lambda: "msg"
            self.publish_comment = lambda *args, **kwargs: None
            self.remove_initial_comment = lambda: None
            self.publish_code_suggestions = lambda suggestions: True
            self.diff_files = []
            self.get_repo_settings = lambda: {}

    # Patch Git provider lookups
    monkeypatch.setattr(
        "pr_agent.git_providers.utils.get_git_provider_with_context",
        lambda pr_url: FakeGitProvider(pr_url),
    )
    monkeypatch.setattr(
        "pr_agent.tools.pr_add_docs.get_git_provider",
        lambda: FakeGitProvider,
    )

    # Ensure identity provider always eligible
    monkeypatch.setattr(
        get_identity_provider().__class__,
        "verify_eligibility",
        lambda *args, **kwargs: Eligibility.ELIGIBLE,
    )

    # Spy on PRAddDocs.run()
    ran = {"flag": False}

    async def fake_run(self):
        ran["flag"] = True

    monkeypatch.setattr(PRAddDocs, "run", fake_run)

    # Build minimal PR payload
    body = {
        "action": action,
        "pull_request": {
            "url": "https://example.com/fake/pr",
            "state": state,
            "draft": draft,
        },
    }
    log_context = {}

    # Invoke the PR-open handler
    agent = PRAgent()
    await handle_new_pr_opened(
        body=body,
        event="pull_request",
        sender="tester",
        sender_id="123",
        action=action,
        log_context=log_context,
        agent=agent,
    )

    assert ran["flag"] is should_run, (
        f"Expected run() to be {'called' if should_run else 'skipped'}"
        f" for action={action!r}, draft={draft}, state={state!r}"
    )

(4) 배운 점 & 회고

먼저, 확실히 문서화 → 테스트 기여로 확장해보면서 단순 문서만 보는 것이 아닌 실제 코드베이스 부분까지 이해하고 검증할 수 있는 능력을 키울 수 있었다.
특히 테스트 코드를 작성해보면서, 세밀하게 조건을 따져봐야하고, 예외를 적절하게 처리하는 등 정말 많은 부분에 관심을 가져야 한다는 점을 깨달았다.

이번 PR-Agent에서의 /add_docs 문서화와 테스트 코드 기여는 스스로 성장은 물론 다른 사람에게 도움이 조금이라도 되었다는 사실에 뿌듯했다. 또한 pr_agent와 같은 규모가 있는 프로젝트에 기여함으로써 내 문서화와 테스트 코드를 보고 참고하는 사람들이 많을 생각에 자랑스럽다!
특히 규모 있는 오픈소스 프로젝트에 코드 기반 기여를 남기고,
그 문서를 보고 누군가가 참고할 수 있다는 사실에 스스로도 꽤 뿌듯하고 자랑스러웠다 😊

앞으로도 더 많은 기여를 통해 많은 사람들에게 도움을 주고 싶다 !!!!

0개의 댓글