PySide6 실전 강좌 #7 테스트 — pytest-qt와 층별 테스트 전략
“GUI 앱은 테스트가 어렵다"는 말은 절반만 맞습니다. 어려운 것은 화면을 통한 테스트이고, 잘 나뉜 앱은 화면을 거의 거치지 않고도 대부분을 검증할 수 있습니다. 1편부터 지켜 온 3층 구조가 이번 편에서 효과를 발휘합니다. 층마다 테스트 방법이 다르고, 아래층일수록 쉽고 많이, 위층일수록 어렵고 적게가 원칙입니다.
1층: core 테스트 — 그냥 pytest입니다 #
core에는 PySide6 임포트가 없으므로, 파이썬 테스트 시리즈에서 배운 pytest가 그대로 적용됩니다. 4편의 통계 함수가 좋은 예입니다.
# tests/test_stats.py
from datetime import date
from daily.core.stats import completion_rate, current_streak
def test_오늘_체크했으면_오늘부터_연속():
checked = {"2026-09-28", "2026-09-29", "2026-09-30"}
assert current_streak(checked, today=date(2026, 9, 30)) == 3
def test_오늘_아직_안_했으면_어제까지로_계산():
checked = {"2026-09-28", "2026-09-29"}
assert current_streak(checked, today=date(2026, 9, 30)) == 2
def test_중간에_구멍이_있으면_거기서_끊긴다():
checked = {"2026-09-27", "2026-09-29", "2026-09-30"}
assert current_streak(checked, today=date(2026, 9, 30)) == 2
def test_달성률은_기간_전체_대비():
checked = {"2026-09-29", "2026-09-30"}
rate = completion_rate(checked, date(2026, 9, 27), date(2026, 9, 30))
assert rate == 0.5current_streak가 today를 인자로 받도록 설계한 것이 여기서 회수됩니다. 함수 안에서 date.today()를 부르는 설계였다면 날짜를 고정하는 별도 장치가 필요했을 겁니다. 시간을 인자로 받는 순수 함수가 테스트 비용을 최소로 만듭니다. “오늘 미체크면 연속이 끊긴 건가?“라는 4편의 정책 질문이 두 번째 테스트로 문서화된 것도 눈여겨볼 부분입니다.
2층: 리포지토리 테스트 — 임시 DB 하나면 됩니다 #
데이터 층은 진짜 SQLite로 테스트합니다. SQLite는 파일 하나짜리 DB라서, pytest의 tmp_path 픽스처로 테스트마다 깨끗한 DB를 만드는 비용이 사실상 0입니다.
# tests/test_repository.py
from datetime import date
import pytest
from daily.data.repository import HabitRepository
@pytest.fixture
def repo(tmp_path):
return HabitRepository(tmp_path / "test.db")
def test_습관_추가와_조회(repo):
repo.add_habit("운동")
habits = repo.active_habits()
assert [h.name for h in habits] == ["운동"]
def test_같은_날_중복_체크는_한_번으로(repo):
habit_id = repo.add_habit("독서")
repo.set_checked(habit_id, date(2026, 9, 30), True)
repo.set_checked(habit_id, date(2026, 9, 30), True) # 중복
assert repo.checked_days(habit_id) == {"2026-09-30"}
def test_보관된_습관은_목록에서_빠진다(repo):
habit_id = repo.add_habit("명상")
repo.archive_habit(habit_id)
assert repo.active_habits() == []2편에서 리포지토리 생성자가 경로를 받게 만든 것(의존성 주입)이 이 픽스처 세 줄의 근거였습니다. 스키마 제약(복합 기본 키의 중복 차단)이 실제로 동작하는지를 mock 없이 진짜 DB로 확인하는 것이 이 층 테스트의 가치입니다.
3층: Qt가 필요한 테스트 — pytest-qt와 qtbot #
모델과 위젯은 Qt 객체라서 QApplication이 있어야 살 수 있습니다. 이 성가신 준비를 대신해 주는 것이 pytest-qt 플러그인입니다. 테스트 함수에 qtbot 픽스처를 받기만 하면, QApplication 생성과 정리, 이벤트 처리를 플러그인이 관리합니다.
# tests/test_today_model.py
from datetime import date
from PySide6.QtCore import Qt
from daily.data.repository import HabitRepository
from daily.ui.today_model import TodayModel
def make_model(tmp_path) -> TodayModel:
repo = HabitRepository(tmp_path / "t.db")
repo.add_habit("운동")
return TodayModel(repo)
def test_모델은_습관_수만큼_행을_가진다(qtbot, tmp_path):
model = make_model(tmp_path)
assert model.rowCount() == 1
index = model.index(0, 0)
assert model.data(index, Qt.ItemDataRole.DisplayRole) == "운동"
def test_체크_토글이_DB까지_반영된다(qtbot, tmp_path):
model = make_model(tmp_path)
index = model.index(0, 0)
with qtbot.waitSignal(model.dataChanged): # 시그널 발생을 검증
model.setData(index, Qt.CheckState.Checked.value,
Qt.ItemDataRole.CheckStateRole)
assert model.data(index, Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked
assert date.today().isoformat() in model._repo.checked_days(1)qtbot.waitSignal은 블록 안의 동작이 해당 시그널을 실제로 방출하는지를 검증합니다. 3편에서 “setData는 dataChanged를 쏜다"고 정한 계약이 테스트로 고정되는 순간입니다. 모델 테스트는 뷰 없이 모델의 계약(rowCount, data, setData, 시그널)만 검증한다는 점이 요령입니다. 뷰가 없어도 모델/뷰 구조의 대부분은 이 수준에서 잡힙니다.
위젯 상호작용까지 확인하고 싶다면 qtbot으로 실제 클릭을 보낼 수 있습니다.
def test_추가_버튼이_다이얼로그를_연다(qtbot, tmp_path, monkeypatch):
from daily.ui.today_page import TodayPage
from PySide6.QtWidgets import QInputDialog
model = make_model(tmp_path)
page = TodayPage(model)
qtbot.addWidget(page) # 정리를 qtbot에 위임
monkeypatch.setattr(QInputDialog, "getText",
staticmethod(lambda *a, **k: ("스트레칭", True)))
qtbot.mouseClick(page.add_button, Qt.MouseButton.LeftButton)
assert model.rowCount() == 2모달 다이얼로그는 테스트를 멈춰 세우므로 monkeypatch로 대체하는 것이 정석입니다. 이런 위젯 테스트는 작성·유지 비용이 위 두 층보다 크므로, 핵심 흐름 몇 개에만 씁니다. “테스트 수는 core > 리포지토리 > 모델 > 위젯” 순의 피라미드가 건강한 배분입니다.
CI에서 화면 없이 돌리기 #
Qt 테스트는 디스플레이를 요구하므로, 화면 없는 CI에서는 대책이 필요합니다. 리눅스 러너의 표준은 둘입니다. 가상 디스플레이(xvfb)를 씌우거나, 더 간단하게는 환경 변수 QT_QPA_PLATFORM=offscreen으로 Qt를 화면 없는 모드로 돌리는 것입니다. 8편의 GitHub Actions 워크플로에 이 한 줄이 들어갑니다.
정리 #
- 테스트 전략은 층 구조를 따라갑니다. Qt 없는 core는 일반 pytest로 가장 많이, 리포지토리는 tmp_path의 진짜 SQLite로, Qt 계층은 pytest-qt로 적게.
- 시간을 인자로 받는 순수 함수 설계가 테스트 비용을 최소화하고, 정책 질문이 테스트 케이스로 문서화됩니다.
- qtbot은 QApplication 관리를 대신하고, waitSignal로 시그널 계약을 검증합니다. 모델 테스트는 뷰 없이 계약만 확인합니다.
- 모달 다이얼로그는 monkeypatch로 대체하고, 위젯 상호작용 테스트는 핵심 흐름에만 아껴 씁니다.
- CI에서는 QT_QPA_PLATFORM=offscreen으로 화면 없이 돌립니다. 다음 편, 마지막 회에서 이 워크플로를 포함한 배포 마감을 다룹니다.