PySide Qt for Python 테스트 가이드 QSignalSpy 시그널 발생 횟수 인자 검증 QTest.qWait 활용법
🧪 QSignalSpy로 시그널을 잡고 QTest.qWait로 타이밍을 통제하는 실전 테스트 패턴
GUI 테스트는 동기 코드처럼 한 줄씩 깔끔히 떨어지지 않아서 작은 타이밍 차이로도 실패하거나, 시그널이 몇 번 발생했는지 꼭 집어내지 못해 애를 먹기 쉽습니다. 그래서 PySide(Qt for Python)에서는 QSignalSpy로 시그널 발생을 기록하고, 필요하면 QTest.qWait로 이벤트 루프를 잠깐 돌려 비동기 동작을 안정적으로 관찰합니다. 이 글은 QSignalSpy·시그널 발생 횟수/인자 검증·가짜 타이머(QTest.qWait)라는 핵심 흐름을 중심으로, 테스트 설계 포인트와 실수하기 쉬운 타입/스레드 이슈까지 한 번에 정리합니다. 복잡한 위젯 상호작용, 네트워크 콜백, 타이머 기반 갱신처럼 ‘언젠가 일어날’ 동작을 검증해야 한다면 여기서 소개하는 패턴이 가장 간단하고 재현성도 좋습니다.
먼저 QSignalSpy는 지정한 시그널이 발생할 때마다 호출 인자를 목록에 쌓아 주므로, 총 발생 횟수와 각 호출의 인자를 그대로 꺼내 비교할 수 있습니다. 또한 생성 직후 isValid() 확인 같은 안전장치를 통해 엉뚱한 시그널을 감시하는 실수를 빠르게 잡아낼 수 있습니다. 한편 QTest.qWait(ms)는 일정 시간 동안 이벤트 루프를 돌려서 타이머, 네트워크, 작업 큐 처리 등을 진행시키는 간단한 ‘가짜 타이머’처럼 쓰입니다. 두 도구를 조합하면 “N ms 안에 이 시그널이 정확히 k번 발생하고, 각 인자가 기대와 일치한다”는 식의 조건을 깔끔하게 표현할 수 있습니다. PySide 공식 문서와 Qt Test 문서에서 권장하는 핵심 사용법을 바탕으로, pytest-qt 같은 테스트 프레임워크와 함께 쓰는 팁도 아울러 담았습니다.
📋 목차
🧪 QSignalSpy 기본 개념과 설치 환경
QSignalSpy는 지정한 시그널이 발생할 때마다 호출 인자 스냅샷을 순서대로 저장해 주는 테스트 도우미입니다.
테스트 코드는 이 목록의 길이로 시그널 발생 횟수를 검증하고, 각 원소에 담긴 파라미터로 인자 값까지 꼼꼼히 확인할 수 있습니다.
GUI 테스트처럼 비동기 이벤트가 많은 환경에서 ‘언제’ 시그널이 올지 모를 때 신뢰도를 높여 주는 핵심 도구이며, PySide(Qt for Python)에서는 PySide6.QtTest 모듈로 제공합니다.
테스트 러너로는 표준 pytest를 주로 사용하고, 위젯 픽스처가 필요하면 pytest-qt를 함께 쓰면 편합니다.
설치는 가상환경을 만든 뒤 PySide6와 테스트 의존성을 받으면 됩니다.
운영체제와 상관없이 동일한 커맨드로 준비할 수 있고, CI 환경에서도 헤드리스 모드로 무리 없이 동작합니다.
아래 절차를 따라 처음 세팅을 마치면, 간단한 시그널 1회 발생 테스트를 바로 실행해 동작을 확인할 수 있습니다.
- 📦Python 3.9 이상 가상환경 생성 및 활성화
- ⬇️PySide6, pytest, pytest-qt 설치
- 🧪테스트 파일에서 QSignalSpy 임포트 및 시그널 감시자 생성
- ⏱️필요 시 QTest.qWait(ms)로 이벤트 루프를 잠시 돌려 비동기 진행
# 가상환경 예시 (macOS/Linux)
python -m venv .venv
source .venv/bin/activate
# Windows PowerShell
# python -m venv .venv
# .\.venv\Scripts\Activate.ps1
pip install -U pip
pip install pyside6 pytest pytest-qt
이제 최소 예제로 QSignalSpy가 어떻게 동작하는지 확인해 보겠습니다.
테스트 대상은 정수 카운트를 내보내는 시그널이며, 타이머로 한 번만 emit 합니다.
검증은 발생 횟수와 인자 값 모두를 체크합니다.
# tests/test_spy_basic.py
from PySide6.QtCore import QObject, Signal, QTimer, QCoreApplication
from PySide6.QtTest import QSignalSpy, QTest
class Counter(QObject):
ticked = Signal(int)
def fire_once(self):
# 50ms 뒤 1회 시그널
QTimer.singleShot(50, lambda: self.ticked.emit(1))
def test_signal_once(qtbot):
# qtbot은 pytest-qt 픽스처 (QApplication 제공)
c = Counter()
spy = QSignalSpy(c.ticked)
assert spy.isValid() # 올바른 시그널에 연결되었는지 확인
c.fire_once()
# 방법 A) spy.wait(timeout_ms) 사용 (True면 적어도 한 번 수신)
assert spy.wait(200) is True
# 방법 B) qWait로 이벤트 루프를 잠시 진행
# QTest.qWait(200)
# 총 1회 발생 확인
assert len(spy) == 1
# 첫 호출의 인자 검증 (리스트 내부에 튜플 형태로 저장됨)
args = spy[0]
assert args[0] == 1
💡 TIP: spy.wait(ms)는 지정한 시간 안에 시그널이 최소 1회 발생하면 True를 반환합니다.
여러 번 발생하는 동작을 검증할 때는 while len(spy) < N: QTest.qWait(step) 패턴으로 누적 감시를 구현하면 안정적입니다.
⚠️ 주의: 테스트가 간헐적으로 실패한다면 타임아웃이 너무 짧거나, UI 스레드가 블로킹되어 이벤트 루프가 돌지 않는 경우일 수 있습니다.
장시간 블로킹 I/O는 워커 스레드로 분리하고, GUI 스레드에서는 QTest.qWait 또는 spy.wait를 통해 이벤트 처리가 흐르도록 보장하세요.
✅ 시그널 발생 횟수 검증 패턴
PySide의 QSignalSpy는 단순히 시그널을 잡는 용도뿐 아니라, 시그널이 몇 번 발생했는지를 검증하기에 매우 유용합니다.
이는 GUI에서 버튼 클릭, 값 변경, 네트워크 응답 등 여러 이벤트가 비슷한 타이밍에 일어날 수 있기 때문입니다.
시그널 발생 횟수는 코드의 의도를 정확히 보장하는 핵심 단서이며, 예를 들어 ‘버튼 클릭 시 1회만 emit 되어야 함’을 검증하면 중복 실행을 방지할 수 있습니다.
다음은 간단한 예제입니다.
버튼 클릭 시 두 번 emit 되면 안 된다는 규칙을 테스트합니다.
이때 len(spy)의 결과로 시그널 호출 횟수를 검증합니다.
또한 이벤트 큐 처리를 위해 QTest.qWait()를 함께 사용하면 비동기 처리가 완료될 때까지 기다릴 수 있습니다.
from PySide6.QtWidgets import QPushButton
from PySide6.QtTest import QSignalSpy, QTest
def test_button_click_signal(qtbot):
btn = QPushButton("Run")
spy = QSignalSpy(btn.clicked)
qtbot.addWidget(btn)
QTest.mouseClick(btn, Qt.LeftButton)
QTest.qWait(100)
assert len(spy) == 1, "clicked 시그널은 1회만 발생해야 합니다."
QSignalSpy는 리스트처럼 작동하기 때문에, 시그널이 여러 번 발생하면 그 순서대로 기록됩니다.
예를 들어 3회 발생했다면 len(spy) == 3이 되고, 각각의 인자에 접근하려면 spy[0], spy[1], spy[2]를 사용하면 됩니다.
또한 Qt의 기본 이벤트 루프가 처리되기 전에 테스트가 종료되지 않도록 spy.wait(timeout)을 사용하는 것이 중요합니다.
💬 spy.wait(ms)는 지정한 시간 내에 시그널이 발생하면 True를 반환합니다. 이 방식은 qWait보다 명시적으로 이벤트 완료 시점을 확인할 수 있어, 불필요한 대기 시간을 줄여 줍니다.
다음은 여러 번 emit되는 시그널의 횟수를 검증하는 고급 패턴입니다.
예를 들어 데이터 처리 중 단계별로 시그널이 여러 번 발생하는 상황에서, 정확히 3회만 발생하는지를 체크합니다.
def test_signal_multiple_emits(qtbot):
from PySide6.QtCore import QObject, Signal, QTimer
class Worker(QObject):
progress = Signal(int)
def run(self):
for i in range(3):
QTimer.singleShot(100 * i, lambda v=i: self.progress.emit(v))
w = Worker()
spy = QSignalSpy(w.progress)
w.run()
# 500ms 동안 이벤트를 돌려 3회 모두 수신
QTest.qWait(500)
assert len(spy) == 3
# 각 호출 인자 확인
values = [args[0] for args in spy]
assert values == [0, 1, 2]
이 패턴은 타이머 기반 업데이트나 반복 신호를 검증할 때 특히 유용합니다.
테스트 시간은 다소 늘어나지만, 실서비스 코드에서 비동기 동작의 정확성을 보장하기 위해 꼭 필요한 단계입니다.
비동기 작업을 동기적으로 테스트하려면 결국 ‘얼마나 기다릴지’ 명시해야 하며, QSignalSpy는 그 대기 중에도 발생한 시그널을 모두 기록해 줍니다.
💎 핵심 포인트:
시그널 발생 횟수 검증은 단순히 “emit 됐는가”를 넘어서, 동작의 재현성과 품질을 담보합니다.
테스트 실패가 간헐적이라면 대부분 타이밍이나 루프 처리 문제이므로, QTest.qWait 또는 spy.wait를 통해 안정적인 이벤트 대기를 설계해야 합니다.
🧷 시그널 인자 값 검증과 타입 주의점
시그널 검증의 또 다른 핵심은 “인자 값이 올바른가”입니다.
PySide의 QSignalSpy는 시그널이 발생할 때 전달된 인자들을 튜플 형태로 리스트에 저장하므로, 인자 값 검증이 아주 직관적입니다.
예를 들어 spy[0][0]은 첫 번째 시그널 호출의 첫 번째 인자를 의미합니다.
이를 활용하면 UI 동작이나 로직 계산 결과가 예상대로 시그널에 반영되는지 손쉽게 테스트할 수 있습니다.
다만 PySide와 PyQt 간에는 인자 타입 변환 규칙이 약간 다릅니다.
PySide6는 내부적으로 C++의 QVariant 래퍼를 사용하지 않기 때문에, 복합 객체를 시그널 인자로 보낼 경우 Python 객체 참조가 그대로 유지됩니다.
이 덕분에 테스트 시 비교가 쉬운 대신, deepcopy가 필요한 경우가 있습니다.
특히 list, dict 타입 인자를 emit할 때는 shallow copy로 인해 같은 객체 참조를 공유할 수 있으니 주의하세요.
from PySide6.QtCore import QObject, Signal
class DataEmitter(QObject):
updated = Signal(dict)
def test_signal_argument_value(qtbot):
e = DataEmitter()
spy = QSignalSpy(e.updated)
payload = {"status": "ok", "count": 3}
e.updated.emit(payload)
assert len(spy) == 1
received = spy[0][0]
# 인자 값 비교
assert received["status"] == "ok"
assert received["count"] == 3
이처럼 인자 검증을 통해 단순한 발생 여부를 넘어 시그널의 데이터 정확성까지 테스트할 수 있습니다.
특히 View와 ViewModel이 시그널로 연결된 구조라면, 잘못된 값 전달로 UI가 틀어지는 경우를 조기에 발견할 수 있습니다.
🧩 복합 객체와 사용자 정의 타입
PySide6에서는 커스텀 클래스를 시그널 인자로 전달할 수도 있습니다.
이 경우 해당 클래스가 Python 객체라면 그대로 전달되지만, C++ 바인딩 객체를 포함한 경우에는 QVariant 변환이 일어나기도 합니다.
테스트 시에는 비교 연산자(__eq__)를 오버라이드하여 동일성 검증을 명확히 해주는 것이 좋습니다.
class User:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return isinstance(other, User) and self.name == other.name
class Notifier(QObject):
sent = Signal(object)
def test_custom_object_signal(qtbot):
n = Notifier()
spy = QSignalSpy(n.sent)
user = User("Alice")
n.sent.emit(user)
assert spy[0][0] == user
💡 TIP: PySide의 QSignalSpy는 인자를 모두 튜플로 보관합니다.
따라서 하나의 시그널에 여러 인자가 있다면, spy[i][j] 형태로 접근하면 됩니다.
예: spy[0][1] → 첫 번째 시그널의 두 번째 인자.
⚠️ 주의: 리스트나 딕셔너리 같은 mutable 객체를 emit할 경우, 이후 원본을 수정하면 spy 내부 값도 바뀔 수 있습니다.
테스트 전에는 copy.deepcopy()를 활용해 독립 객체로 만들어 두는 것이 안전합니다.
⏱️ QTest.qWait와 가짜 타이머 활용
Qt의 테스트 환경에서 QTest.qWait()는 단순한 “sleep” 함수가 아닙니다.
이 함수는 주어진 밀리초 동안 이벤트 루프를 돌려 타이머, 네트워크, 애니메이션 등 비동기 작업이 자연스럽게 처리되도록 합니다.
즉, 코드 실행을 잠시 멈추는 대신, 실제 GUI 동작과 동일한 환경을 유지한 채 기다리는 것입니다.
예를 들어 QTimer.singleShot()으로 예약된 시그널이 있거나, QThread를 통해 결과가 나중에 emit될 경우 time.sleep()을 사용하면 메인 스레드가 블로킹되어 시그널이 처리되지 않습니다.
하지만 QTest.qWait()를 사용하면 이벤트 루프를 돌려 이런 비동기 신호가 정상적으로 emit됩니다.
from PySide6.QtCore import QObject, Signal, QTimer
from PySide6.QtTest import QTest, QSignalSpy
class Notifier(QObject):
done = Signal(str)
def test_qwait_usage(qtbot):
n = Notifier()
spy = QSignalSpy(n.done)
# 200ms 뒤에 시그널 emit
QTimer.singleShot(200, lambda: n.done.emit("complete"))
# 단순 sleep은 비권장 (이벤트 루프 정지)
# time.sleep(0.2)
# 이벤트 루프를 돌리면서 대기
QTest.qWait(300)
assert len(spy) == 1
assert spy[0][0] == "complete"
이처럼 QTest.qWait()는 타이밍 제어뿐 아니라, 테스트 중 이벤트 루프가 멈추지 않게 하는 ‘가짜 타이머’ 역할을 수행합니다.
특히 짧은 간격의 타이머나 애니메이션이 많은 위젯을 테스트할 때 필수적입니다.
또한 특정 시그널이 발생할 때까지 반복적으로 기다리고 싶다면 spy.wait(ms)와 함께 사용하여 신호 완료 시점을 명확히 할 수 있습니다.
💬 QTest.qWait()는 실제 GUI의 반응 속도와 동일하게 이벤트 루프를 돌리므로, “사용자 인터랙션”을 테스트할 때 time.sleep보다 훨씬 신뢰성이 높습니다.
만약 복수의 이벤트를 순차적으로 기다려야 한다면, 아래처럼 while 루프와 함께 사용할 수도 있습니다.
이 패턴은 시그널이 일정 횟수 도달할 때까지 대기하는 “폴링(polling)” 형태의 가짜 타이머로 자주 활용됩니다.
def wait_until_signal_count(spy, count, timeout=2000, step=50):
elapsed = 0
while len(spy) < count and elapsed < timeout:
QTest.qWait(step)
elapsed += step
return len(spy) >= count
def test_multiple_signals(qtbot):
from PySide6.QtCore import QObject, Signal, QTimer
class Worker(QObject):
tick = Signal(int)
def start(self):
for i in range(3):
QTimer.singleShot(100 * i, lambda v=i: self.tick.emit(v))
w = Worker()
spy = QSignalSpy(w.tick)
w.start()
assert wait_until_signal_count(spy, 3)
values = [args[0] for args in spy]
assert values == [0, 1, 2]
이처럼 qWait를 반복적으로 호출하면 짧은 간격으로 이벤트 루프를 돌리면서 조건을 검사할 수 있습니다.
특히 GUI 자동 테스트에서 화면 렌더링이나 사용자 입력 시뮬레이션 후 상태를 검증할 때 자주 사용됩니다.
💎 핵심 포인트:
QTest.qWait은 단순한 대기 함수가 아니라, 테스트 중 이벤트 루프를 유지하는 ‘비동기 타이밍 컨트롤러’입니다.
비동기 신호를 기다릴 때는 time.sleep이 아닌 qWait 또는 spy.wait을 사용해야 신호가 정상적으로 처리됩니다.
🔄 비동기 작업·스레드와 함께 쓰는 요령
PySide로 작성한 애플리케이션에서 비동기 작업이나 QThread를 테스트할 때는 이벤트 루프의 상태를 신중히 관리해야 합니다.
메인 스레드에서 시그널을 emit하는 경우엔 문제가 없지만, 백그라운드 스레드에서 발생한 시그널은 테스트 코드가 이벤트 루프를 제대로 돌리지 않으면 수신되지 않습니다.
이럴 때 QSignalSpy와 QTest.qWait()를 함께 사용하면, GUI와 스레드 간 통신을 안정적으로 검증할 수 있습니다.
대표적인 예로 QThread에서 비동기 연산이 끝났을 때 finished 시그널이 emit되는지를 확인하는 테스트를 살펴보겠습니다.
이 과정에서는 spy.wait(timeout)을 사용해 시그널 발생을 직접 기다리거나, 루프를 유지하기 위해 QTest.qWait()를 병행할 수 있습니다.
from PySide6.QtCore import QObject, QThread, Signal, QTest, QSignalSpy
class Worker(QObject):
done = Signal()
def run(self):
# 실제 연산 대신 100ms 대기 후 완료 시그널
QTest.qWait(100)
self.done.emit()
def test_thread_signal(qtbot):
thread = QThread()
worker = Worker()
worker.moveToThread(thread)
spy = QSignalSpy(worker.done)
thread.started.connect(worker.run)
worker.done.connect(thread.quit)
thread.start()
# 비동기 완료까지 대기
assert spy.wait(500)
thread.wait()
assert len(spy) == 1
위 코드에서 spy.wait(500)은 스레드 내부에서 시그널이 emit될 때까지 최대 500ms 동안 이벤트 루프를 유지하며 대기합니다.
이 덕분에 별도의 join이나 sleep 없이 자연스럽게 비동기 완료를 검증할 수 있습니다.
만약 QTest.qWait()만 사용할 경우 스레드 종료 시점을 직접 확인해야 하지만, QSignalSpy의 wait 메서드는 이벤트 루프와 함께 타임아웃 제어까지 맡아 주므로 더 안전합니다.
🧠 스레드 환경에서의 안정적 테스트 패턴
스레드 기반 테스트를 설계할 때는 다음의 세 가지 원칙을 지키면 대부분의 불안정한 테스트를 예방할 수 있습니다.
- 🧩moveToThread() 호출은 스레드 시작 전 반드시 수행할 것
- 🧵thread.quit()과 thread.wait()를 통해 자원 누수를 방지할 것
- ⏱️비동기 완료 검증에는 spy.wait()을 우선 사용하고, 필요시 qWait()로 보조 대기
PySide의 이벤트 루프는 기본적으로 단일 스레드에서 돌기 때문에, 잘못된 스레드 이동이나 블로킹 호출은 테스트 실패의 주요 원인입니다.
QSignalSpy를 중심으로 타이밍을 제어하면, GUI 이벤트와 스레드 완료 신호가 정확히 맞물려 안정적인 검증이 가능합니다.
💎 핵심 포인트:
스레드 기반 테스트에서는 반드시 이벤트 루프를 유지해야 합니다.
QSignalSpy.wait()와 QTest.qWait()는 함께 사용될 때 비동기 작업의 완료 시점을 가장 확실하게 검증할 수 있는 도구입니다.
❓ 자주 묻는 질문 (FAQ)
QSignalSpy와 spy.wait()의 차이는 무엇인가요?
QSignalSpy 자체는 단순히 시그널 발생을 기록만 하므로, 대기 로직이 필요할 때는 반드시 wait()을 함께 써야 합니다.
time.sleep()을 써도 되지 않나요?
따라서 QTimer나 시그널이 작동하지 않아 테스트가 실패할 수 있습니다.
반드시 QTest.qWait()을 사용해야 합니다.
spy[0][0]의 의미가 무엇인가요?
spy[0]은 첫 번째 시그널 호출의 인자 튜플이며, spy[0][0]은 그 중 첫 번째 인자 값을 나타냅니다.
인자가 여러 개인 경우 spy[0][1], spy[0][2]처럼 접근합니다.
spy.wait()과 QTest.qWait()을 같이 써도 되나요?
두 함수를 함께 사용하면 더 안정적인 비동기 검증이 가능합니다.
QSignalSpy가 시그널을 못 잡는 경우는 왜 생기나요?
항상 spy를 시그널에 연결한 후 동작을 시작해야 합니다.
또한 잘못된 객체나 시그널 슬롯 이름을 지정했을 때 isValid()가 False가 됩니다.
스레드 내 시그널도 QSignalSpy로 테스트할 수 있나요?
다만 스레드에서 emit된 시그널은 메인 이벤트 루프가 돌아가야 수신되므로, spy.wait()이나 QTest.qWait()으로 루프를 유지해야 합니다.
pytest-qt와 PySide의 QTest는 어떤 차이가 있나요?
QTest는 Qt 공식 테스트 유틸리티로 이벤트 시뮬레이션과 대기 기능을 제공합니다.
두 도구는 함께 사용할 때 가장 효율적입니다.
비동기 타이머가 계속 도는 테스트를 멈추려면 어떻게 하나요?
테스트 중에는 항상 singleShot=True로 한 번만 실행되도록 설정하거나, 타이머 인스턴스를 명시적으로 stop() 해줘야 합니다.
QSignalSpy의 timeout을 조정할 수 있나요?
일반적으로 UI 응답 테스트는 300~1000ms, 네트워크 시뮬레이션은 2000ms 정도가 적절합니다.
🧩 QSignalSpy와 QTest.qWait로 완성하는 안정적 PySide 테스트
PySide(Qt for Python) 환경에서 신뢰할 수 있는 테스트를 작성하려면 QSignalSpy와 QTest.qWait를 이해하고 함께 활용하는 것이 필수입니다.
QSignalSpy는 시그널 발생 횟수와 인자 검증을 정확히 수행해 주고, qWait은 이벤트 루프를 유지시켜 비동기 동작이 정상적으로 처리되도록 도와줍니다.
이 두 도구를 조합하면 GUI 이벤트, 스레드, 타이머 기반 업데이트 등 실제 환경과 거의 동일한 조건에서 동작을 테스트할 수 있습니다.
테스트가 불안정하거나 간헐적으로 실패한다면 대부분의 원인은 이벤트 루프 정지, 시그널 연결 타이밍 문제, 혹은 sleep() 사용 때문입니다.
이 글에서 소개한 패턴대로 spy.wait()과 qWait()를 함께 사용하면 이런 문제를 거의 제거할 수 있습니다.
비동기 GUI 테스트를 보다 견고하게 만들고 싶다면, QSignalSpy의 결과 분석을 통해 “무엇이 언제 일어났는가”를 명확히 기록하는 습관을 들이는 것이 좋습니다.
PySide의 이벤트 기반 구조를 이해하고 이 테스트 도구들을 익숙하게 사용하면, 복잡한 인터페이스나 사용자 액션을 자동화된 테스트로 안정적으로 커버할 수 있습니다.
이는 장기적으로 유지보수 비용을 줄이고, UI 변경에도 강한 코드를 만드는 핵심 기반이 됩니다.
🏷️ 관련 태그 : PySide, QtTest, QSignalSpy, PySide6, Python테스트, GUI테스트, 비동기테스트, QThread, 이벤트루프, QTest.qWait