Claude CodeでPySideのアプリを作る — 画面なしテストで検証まで
Claude CodeにPySide6のデスクトップアプリを書かせ、pytest-qtとQT_QPA_PLATFORM=offscreenで画面を開かずにボタン操作まで検証させる手順を、CLAUDE.mdとhooksの設定例つきで示します。
PySideのデスクトップアプリをClaude Codeに書かせるときの要点は、「窓を開いて自分で押して確かめることはできない」前提で完了条件を決めることです。目で確かめる代わりに、画面なしで通るpytestを渡します。pytest-qtでボタンのクリックまで再現し、QT_QPA_PLATFORM=offscreen を付ければ、Claude Codeは自分でテストを回して結果を読み、直せます。
この記事では、税込計算の小さなアプリを題材に、環境づくりからCLAUDE.mdの規約、テスト、編集のたびにテストを走らせるhooksまでを順に組みます。PySide6 6.12.0、pytest 9.1.1、pytest-qt 4.5.0、Python 3.12.2のmacOSで実際に動かした出力を載せています。
窓を開けないClaude Codeに渡す完了条件
GUIアプリの開発では、書いた人が窓を開いて押してみるのが普通の検証です。Claude Codeは窓を開いて自分で押して確かめることができません。コードが構文として正しくても、ボタンを押したときにラベルが更新されるかは、実行しないと分かりません。
そこで完了条件を次の3つに分けます。
Claude Codeに渡す検証の3層
1. 計算ロジック
窓を作らずに呼べる純関数のテストです。税込計算のような処理を関数に切り出しておきます。いちばん速く、いちばん壊れにくい層です。
2. ウィジェットの操作
pytest-qtで入力欄に文字を入れ、ボタンを押し、ラベルの文字列を比べます。画面は要りません。
3. 見た目
配置や色は自動検証が難しい層です。最後に人が一度見る分担にします。
1と2はClaude Codeが自分で回せます。同じ考え方で別のフレームワークを扱った記事に、TkinterのGUIアプリをウィジェット構成のテキストで検証する手順と、WPFアプリをViewModelのテストで検証する手順があります。Qtでは、ウィジェットを本物のまま動かせる点が違いです。
環境を作る
Qt for Pythonの入口ページには、安定版は pip install pyside6 で入ると書かれています。仮想環境を作り、テスト用の2つと一緒に入れます。
python3 -m venv .venv
.venv/bin/pip install PySide6 pytest pytest-qtPySide6 6.12.0の要求するPythonは3.10以上3.16未満で、pytest-qt 4.5.0は3.9以上です。Pythonがこの範囲に収まっているかを先に見ておくと、インストールの失敗を避けられます。
ライセンスにも触れておきます。Qt for PythonはLGPLv3、GPLv3、Qtの商用ライセンスで提供されています。配布するアプリを作る場合は、どのライセンスで使うかを先に決めてからClaude Codeに依存関係を足させる流れが安全です。
CLAUDE.mdに書くルール
Claude Codeが毎回正しいコマンドでテストを回せるように、リポジトリ直下のCLAUDE.mdに検証の手順を書きます。プロジェクトのCLAUDE.mdは ./CLAUDE.md か ./.claude/CLAUDE.md に置けます。
# 検証のルール
- テストは `QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q` で実行する
- GUIの変更後は必ず上のコマンドを実行し、全件通るまで完了としない
- 計算は純関数に切り出し、窓なしのテストを先に書く。MainWindow は入力を読んで結果を表示するだけにする
- テストでは QApplication を自分で作らず、pytest-qt の qtbot を使う
- 操作の再現は、setText など Widget のメソッドを優先する。qtbot.mouseClick は
「ボタンが押された経路」を確かめたいときだけ使う
- ウィジェットには setObjectName で名前を付け、テストは名前か属性から探す最後の2行は、Claude Codeが書くテストの安定性に効きます。名前の付いた属性を持たせておけば、findChild で探す回り道が要らず、レイアウトを組み替えても壊れにくくなります。
最小のアプリを作る
題材は、価格を入れて「計算」を押すと税込価格を出す窓です。Claude Codeへの最初の依頼は、たとえば次のように、完了条件を含めて書きます。
PySide6で、価格を入力して「計算」ボタンで税込(10%)を表示する窓を作って。
入力が整数でなければ「整数を入力してください」と表示する。
pytest-qt のテストを書き、CLAUDE.md の検証コマンドが全件通るまで直して。出来上がるコードの一例が次の形です。ウィジェットを属性として保持し、税込の計算は窓に依存しない calc_tax 関数に切り出します。
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QLineEdit, QPushButton, QLabel,
)
def calc_tax(price: int) -> int:
return int(price * 1.1)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("税込計算")
self.price_edit = QLineEdit()
self.calc_button = QPushButton("計算")
self.result_label = QLabel("")
layout = QVBoxLayout()
for w in (self.price_edit, self.calc_button, self.result_label):
layout.addWidget(w)
central = QWidget()
central.setLayout(layout)
self.setCentralWidget(central)
self.calc_button.clicked.connect(self.calculate)
def calculate(self) -> None:
try:
price = int(self.price_edit.text())
except ValueError:
self.result_label.setText("整数を入力してください")
return
self.result_label.setText(f"{calc_tax(price)}円")起動用の main.py は、QApplication(sys.argv) を作り、窓を show() して app.exec() でイベントループに入る標準の形です。Qt for Pythonの最初のチュートリアルも、QApplication の生成と app.exec() で主ループに入る流れで説明しています。テストはこの起動部分を通らないので、main.py は薄く保っておきます。
pytest-qtでボタン操作まで再現する
pytest-qtは、PySide6、PyQt6、PyQt5のいずれにも使えるpytestプラグインです。qtbot というフィクスチャを受け取るだけで、QApplication の生成を含めて面倒を見てくれます。チュートリアルにも、qtbot を使うテストでは自分で QApplication インスタンスを作る必要がないと書かれています。
from PySide6.QtCore import Qt
from app.main_window import MainWindow, calc_tax
def test_calc_tax():
assert calc_tax(1000) == 1100
def test_calculate_tax(qtbot):
window = MainWindow()
qtbot.addWidget(window)
window.price_edit.setText("1000")
qtbot.mouseClick(window.calc_button, Qt.MouseButton.LeftButton)
assert window.result_label.text() == "1100円"
def test_invalid_input(qtbot):
window = MainWindow()
qtbot.addWidget(window)
window.price_edit.setText("abc")
qtbot.mouseClick(window.calc_button, Qt.MouseButton.LeftButton)
assert window.result_label.text() == "整数を入力してください"qtbot.addWidget は、テストで作ったウィジェットを qtbot に登録する呼び出しです。チュートリアルの例も、窓を作った直後にこれを呼んでいます。CLAUDE.mdに「窓を作ったら必ず addWidget」と1行足しておくと、Claude Codeが書くテストの型が揃います。
クリックの再現は、qtbot.mouseClick が実際のイベントを発行します。チュートリアルは、通常の操作には setText のようなウィジェット自身のメソッドを使うよう勧めています。理由は、qtbot の mouseClick や keyClicks がイベントを発行し、そのイベントは次のイベントループで処理されるためです。上のテストはクリックの経路を確かめたいので mouseClick を使い、入力にはメソッドの setText を使っています。
QT_QPA_PLATFORM=offscreenで画面なしに回す
Qtは、描画先をプラットフォームプラグインで切り替えます。QGuiApplication のドキュメントには、環境変数 QT_QPA_PLATFORM か、コマンドラインの -platform で使うプラグインを指定できると書かれています。プラグインの一覧には offscreen も載っています。
ただし、その一覧で offscreen に付いている説明文はありません。隣の minimal には、GUIのないサーバーのような環境でGUIアプリを動かすのに使えるという説明があります。offscreen の挙動は、手元で動かして確かめるのが確実です。次が、窓を使う2テスト(calc_tax の窓なしテストは除く)に、ラベルの画像保存を試すテストを加えた3件をoffscreenで実行した結果です。
QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q -sqt.qpa.fonts: Populating font family aliases took 156 ms. Replace uses of missing font family "Sans Serif" with one that exists to avoid this cost.
..This plugin does not support propagateSizeHints()
size 200 100 True
.
3 passed in 0.92s3件とも通りました。フォントの別名に関する警告と、propagateSizeHints() 非対応のメッセージが出ていますが、テストの結果には影響していません。同じテストを環境変数なしで流しても3件通りました。画面のあるMacでは、offscreenを付けても付けなくても結果が変わらなかったことになります。画面を持たないLinuxサーバーやCIで動くかどうかは、そこで同じコマンドを一度流して確かめてください。
3件目のテストは、ウィンドウを grab() して画像に保存するものでした。
def test_grab(qtbot, tmp_path):
window = MainWindow()
qtbot.addWidget(window)
window.price_edit.setText("1000")
window.calculate()
window.show()
out = tmp_path / "window.png"
assert window.grab().save(str(out))offscreenでは、show() した窓のサイズが200×100で、PNGの保存も成功しました。画面に出さなくても、描画結果を画像ファイルとして取り出せます。見た目を最後に人が見る分担にするとき、この画像を材料にできます。保存したPNGはClaude Codeに読ませる経路もありますが、余白や色の良し悪しを決める基準は人が持ちます。
編集のたびにテストを走らせるhooks
CLAUDE.mdに書いても、Claude Codeがコマンドを実行し忘れる場面はあります。確実にしたいときは、PostToolUse のhookで、ファイルを編集するたびにテストを走らせます。hooksの設定例は Edit|Write をmatcherにした PostToolUse で、ファイル編集のあとに検査を呼ぶ形が基本です。
.claude/settings.json に次のように書きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-gui-tests.sh"
}
]
}
]
}
}呼び出すシェルスクリプトは、失敗したときに終了コード2で抜けます。
#!/bin/sh
QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q -x 1>&2 || exit 2PostToolUse で終了コード2を返すと、標準エラー出力がClaude Codeに表示されます。ツールはすでに実行済みなので、編集自体は取り消されません。失敗の内容を読んだClaude Codeが、次の編集で直す流れになります。出力を標準エラーに回している(1>&2)のは、このためです。
テストが増えて遅くなったら、hookは実行を速い層に絞り、全件はCLAUDE.mdの完了条件として手動実行に回します。テストの完了までClaude Codeを止めない仕組みを作りたいときは、agent hookでテストが通るまで止めない設定が参考になります。
PyQtで作る場合の違い
題材をPyQtに替えても、検証の組み立ては同じです。pytest-qtはPySide6、PyQt6、PyQt5に対応しており、どれが入っているかを見て自動で選びます。複数入っている環境では、qt_api の設定で使うバインディングを指定できます。
差が出るのはimport文です。from PySide6.QtWidgets import ... が from PyQt6.QtWidgets import ... に替わります。Claude Codeにライブラリの使い分けを任せると、1つのファイルにPySide6とPyQt6が混ざることがあります。CLAUDE.mdに「このプロジェクトはPySide6だけを使う」と明記し、importを統一させてください。
つまずきやすい点
- 自分で
QApplicationを作ってしまう:qtbotを使うテストでは作る必要がありません。不要な生成をClaude Codeが足さないよう、CLAUDE.mdで「qtbotを使う」と指定しておきます - クリックで入力を再現して不安定になる: コンボボックスの選択のように、
mouseClickを2回必要とする操作は崩れやすくなります。setCurrentIndexのようなメソッドに置き換えると安定します main.pyだけを実行して終わりにする:app.exec()はイベントループに入って戻らないため、Claude Codeが実行すると応答待ちになります。起動確認が必要なら、show()までで抜けるテストを使い、main.pyは直接実行させません- 警告をエラーと誤解する: 先の出力のフォント警告は、テストの合否と関係しません。警告のたびに直させると、本筋と無関係な修正が増えます
- 見た目の判定をテストに入れようとする: 余白や色の「良し悪し」は、Claude Codeに基準を渡せない限り検証できません。画像を保存するところまでをテストの役割にして、良し悪しの判断は人が持ちます
まとめ
PySideのアプリをClaude Codeに任せるときは、窓を開かないテストを完了条件にします。pytest-qtの qtbot でボタン操作を再現し、QT_QPA_PLATFORM=offscreen を付けたpytestをCLAUDE.mdのコマンドとして固定すれば、Claude Codeは書く、回す、直すを自分で繰り返せます。
hookで編集のたびにテストを走らせれば、実行し忘れも防げます。残るのは見た目の判断です。ここは人が最後に一度見る担当として、grab() で保存した画像を見る手順にしておくと、確認の手間が小さく済みます。画像はClaude Codeに読ませることもできますが、良し悪しの判断は人に残ります。