Claude Media
Claude CodeでTkinterのGUIアプリを作る — ウィジェット構成をテキストで検証

Claude CodeでTkinterのGUIアプリを作る — ウィジェット構成をテキストで検証

画面を見られないClaude Codeに、Tkinterのウィジェット構成をテキストで書き出させ、invokeでボタンを押してunittestで確かめさせる手順を、CLAUDE.mdと許可設定の例つきで示します。

Claude CodeにTkinterのGUIアプリを作らせるときの最大の壁は、Claude Codeがウィンドウを目で見られないことです。そこで完了の判定を「画面の見た目」から「ウィジェット構成のテキスト出力と、invoke() でボタンを押した結果の値」に置き換えます。Tkinterは各ウィジェットが winfo_children() や cget() で自分の状態を答えられるので、見た目の代わりになる文字列をいくらでも取り出せます。

この記事では、摂氏を華氏に変える小さなアプリを例に、構成を書き出す関数、操作を再現するunittest、CLAUDE.mdと許可設定の順に組みます。

画面が見えないClaude Codeに何を完了条件として渡すか

人間がTkinterアプリを作るときは、python app.py で窓を開き、入力して、結果を目で見ます。Claude Codeに同じことを頼むと、窓を開いた時点で mainloop() が戻らなくなります。mainloop() は画面に反映し、プログラムが終了するまでユーザー入力に応答し続けるメソッドです。

完了条件を、戻ってくるコマンドに寄せます。

くらべる

完了条件を2通りで比べる

目視

python app.pyで窓を開く

プロセスが戻らず、成否が終了コードに出ません。確かめられるのは、窓を見た人だけです。

テキスト検証

unittestでウィジェットを検査

構成と操作の結果が文字列で返り、終了コードで合否が決まります。検証内容がテストファイルとして残ります。

検証できる対象は3つに分けられます。

検証対象

画面なしで確かめられる3つのこと

  • 構成

    どのクラスのウィジェットが、どの名前で、どの親の下にあるか。

  • 配置

    ジオメトリマネージャーに登録されているか。作っただけで配置し忘れた部品を見つけられます。

  • 操作の結果

    ボタンを押したあとに、変数や表示文字列がどうなったか。

「配置」を独立して見る理由は、Tkinterの仕様にあります。ウィジェットは作っただけではユーザーインターフェースに加わらず、grid のようなジオメトリマネージャーが位置を決めて初めて画面に載ります。ttk.Button(...) と書いて .grid() を呼び忘れても、エラーは出ません。窓を見れば分かりますが、テキストでは仕掛けが要ります。

同じ「見えないものを検証コマンドで補う」発想は、WPFアプリのViewModelテストやFlaskのtest_clientの記事にもあります。Tkinterの場合は、ウィジェット自身に問い合わせる点が違います。

検証対象にする最小のアプリ

題材は、ttk のウィジェットを grid で並べ、値の受け渡しに変数を使う、摂氏から華氏への変換アプリです。

# app.py
import tkinter as tk
from tkinter import ttk
 
 
class ConverterApp(ttk.Frame):
    def __init__(self, master):
        super().__init__(master, padding=10)
        # 変数への参照は属性として持ち続ける
        self.celsius = tk.StringVar()
        self.result = tk.StringVar()
 
        ttk.Label(self, text="摂氏", name="celsius_label").grid(column=0, row=0)
        ttk.Entry(self, textvariable=self.celsius, name="celsius_entry").grid(
            column=1, row=0
        )
        self.button = ttk.Button(
            self, text="変換", command=self.convert, name="convert_button"
        )
        self.button.grid(column=2, row=0)
        ttk.Label(self, textvariable=self.result, name="result_label").grid(
            column=0, row=1, columnspan=3
        )
 
    def convert(self):
        try:
            c = float(self.celsius.get())
        except ValueError:
            self.result.set("数値を入力してください")
            return
        self.result.set(f"{c * 9 / 5 + 32:.1f} °F")
 
 
if __name__ == "__main__":
    root = tk.Tk()
    root.title("温度変換")
    ConverterApp(root).grid()
    root.mainloop()

検証しやすさのために、3つの工夫を入れています。

  1. 窓の起動処理を if __name__ == "__main__": の中に閉じ込め、テストから ConverterApp だけを取り出せるようにする
  2. 各ウィジェットに name= を付け、構成の出力で「どの部品か」が一意に読めるようにする
  3. 計算を convert() のようなメソッドに置き、ボタンのコールバックから呼ぶ

2つ目の name= は、ウィジェットのパス名の一部になります。winfo_name() は親の中での名前を返すので、省略すると自動採番の名前が出力に並び、差分が読みにくくなります。

変数を属性に持つのは、ウィジェットが使っている間は変数への参照を保持する必要があるからです。ガベージコレクトされた Variable は裏のTcl変数を消し、ウィジェットとの接続が切れます。ローカル変数で作って捨てる書き方はClaude Codeに任せると混ざりやすいので、CLAUDE.mdにも入れておきます。

ウィジェット構成をテキストに書き出す

構成の書き出しには、Tkinterに備わった内省の手段をそのまま使います。設定できるオプションは configure() で、名前だけなら keys() で調べられます。winfo_children() は子ウィジェットを積み重ね順に返し、winfo_class() はクラス名を返します。

# tree.py
def describe(widget):
    """子ウィジェットを 1 行ずつの文字列にする。"""
    lines = []
    for w in widget.winfo_children():
        opts = w.keys()
        parts = [w.winfo_class(), w.winfo_name()]
        text = str(w.cget("text")) if "text" in opts else ""
        if text:
            parts.append(f"text={text}")
        var = str(w.cget("textvariable")) if "textvariable" in opts else ""
        if var:
            parts.append(f"var={w.getvar(var)}")
        lines.append(" ".join(parts))
    return lines

getvar(name) はTclのグローバル変数の値を返します。textvariable に結び付いた表示文字列は、text ではなく変数の側に入っているので、クラスごとに読み分けます。

describe() は、このアプリに対して次のような4行のリストを返す設計です。1行が「クラス名 ウィジェット名 表示内容」の形式です。

TLabel celsius_label text=摂氏
TEntry celsius_entry var=
TButton convert_button text=変換
TLabel result_label var=

ttk のクラスは TLabel のように先頭に T が付く名前で返ります。旧来の tk.Label と混在させると Label と TLabel が混ざるので、構成の出力はどちらのAPIを使っているかの確認にもなります。Tcl/Tk 8.5で導入されたテーマ付き部品が新しいAPIで、オンラインの情報の多くは古いAPIを使っています。Claude Codeが古い書き方を持ち込んだとき、T の有無で気づけます。

ボタン操作を再現して結果を確かめる

操作の再現には Button.invoke() を使います。invoke() はボタンに結び付いたコマンドを実行して戻り値を返します。ボタンが disabled なら何もしません。マウスのクリックを模す必要はなく、コールバックが呼ばれます。

# test_app.py
import tkinter as tk
import unittest
 
from app import ConverterApp
from tree import describe
 
 
class ConverterAppTest(unittest.TestCase):
    def setUp(self):
        self.root = tk.Tk()
        self.root.withdraw()  # 窓は表示しない
        self.app = ConverterApp(self.root)
        self.app.grid()
 
    def tearDown(self):
        self.root.destroy()
 
    def test_widget_tree(self):
        self.assertEqual(
            describe(self.app),
            [
                "TLabel celsius_label text=摂氏",
                "TEntry celsius_entry var=",
                "TButton convert_button text=変換",
                "TLabel result_label var=",
            ],
        )
 
    def test_every_widget_is_placed(self):
        for w in self.app.winfo_children():
            self.assertEqual(w.winfo_manager(), "grid", w.winfo_name())
 
    def test_convert_boiling_point(self):
        self.app.celsius.set("100")
        self.app.button.invoke()
        self.root.update()
        self.assertEqual(self.app.result.get(), "212.0 °F")
 
    def test_invalid_input_shows_message(self):
        self.app.celsius.set("abc")
        self.app.button.invoke()
        self.root.update()
        self.assertEqual(self.app.result.get(), "数値を入力してください")
 
 
if __name__ == "__main__":
    unittest.main()

実行は次の1行です。

python3 -m unittest -v

test_every_widget_is_placed が、先ほどの「作っただけで配置されていない」ミスを拾います。winfo_manager() はそのウィジェットを担当するジオメトリマネージャーの名前を返し、どれにも管理されていなければ空文字列を返します。

self.root.withdraw() で窓を隠しているので、winfo_ismapped() を使った「画面に出ているか」の検査はここでは成立しません。winfo_ismapped() はマップされているかどうかを返すメソッドです。画面への表示まで確かめる検査は、窓を出せる環境の別テストに分けます。

self.root.update() は、保留中のイベントとアイドルコールバックを処理して戻るメソッドです。イベントループを回さない限り、画面は更新されません。テストの中で mainloop() を呼ぶとテストが終わらなくなるので、update() で必要な分だけ処理を進めます。

update_idletasks() との違いも押さえておきます。こちらはアイドルコールバックだけを処理し、ユーザー操作に起因するイベントは処理しません。ジオメトリの再計算だけが目的ならこちらで足ります。

CLAUDE.mdに書く検証のルール

テストを用意しても、Claude Codeが毎回それを使うとは限りません。完了条件と書き方の約束をCLAUDE.mdに置きます。ドキュメントによれば、プロジェクトのCLAUDE.mdは ./CLAUDE.md か ./.claude/CLAUDE.md に置け、ビルドとテストのコマンドやコーディング規約など、プロジェクト全体に当てはまる指示を書く場所です。

# Tkinterアプリのルール
 
## 完了条件
- `python3 -m unittest -v` が成功するまで完了としない
- `python3 app.py` は実行しない。`mainloop()` から戻らない
- 画面は見えない。見た目の確認には `tree.py` の `describe()` の出力を使う
 
## 書き方
- 起動処理は `if __name__ == "__main__":` の中に置く
- ウィジェットには `name=` を付ける
- `ttk` を使う。旧 `tk.Label` などは使わない
- `StringVar` などの変数は `self` の属性に持つ。ローカル変数にしない
- ウィジェットを作ったら同じ行か直後で `grid()` する
- 時間のかかる処理をボタンのコールバックに直接書かない

最後の1行は、Tkinterがイベントハンドラーの速い復帰を前提にしているためです。長い計算はタイマーで細かく分けるか、別スレッドで走らせます。ブロックするコールバックは、窓が固まる原因になります。この種の不具合は単純なテストでは見えにくいので、規約側で防ぎます。

unittestを許可しておく

python3 -m unittest が毎回許可待ちになると、反復が止まります。権限ルールの書き方は、たとえば Bash(npm run *) が npm run build や npm run test --watch に一致し、npm install には一致しない、という形です。unittestも同じ形で書けます。

{
  "permissions": {
    "allow": [
      "Bash(python3 -m unittest *)"
    ]
  }
}

これを .claude/settings.json に置きます。ワイルドカードはサブコマンドの後ろに付けます。Bash(ls *) が ls -la に一致して lsof に一致しないように、単語の区切りまでが条件になるためです。python3 app.py は許可に含めません。

窓を開く検証が必要になったとき

ここまでは、窓を出さずにウィジェットへ問い合わせる方法でした。それでも手が届かない領域があります。

落とし穴

Tk() が失敗する環境がある

tk.Tk() はTkの初期化とルートウィンドウの作成を行うので、グラフィカルな表示先がない環境や、Tkが入っていないPythonでは失敗することがあります。tkinterはオプションのモジュールなので、CPythonに含まれていないビルドでは、配布元の案内に従って導入します。python3 -m tkinter を実行すると簡単なTkの窓が開き、導入状況とTcl/Tkのバージョンが分かります。

Claude Codeが動く環境で Tk() が失敗するなら、次の分け方が現実的です。

  • 計算や状態遷移は、tkinter を読み込まない関数やクラスに出し、普通の単体テストで検証する
  • ウィジェットの検査は、Tkが動く環境でだけ実行する。動かない環境ではテストを飛ばす
  • 実際の見た目は、人が一度だけ python3 app.py で確認する

最初の分け方は、ウィジェットの検査より堅く、convert() の中身を convert_celsius(text) -> str のような関数にすれば、Tkなしで検証できます。ウィジェットは「変数に値を入れて、関数を呼び、結果を表示に渡す」だけの薄い層にしておくと、構成の検査も軽く済みます。

Pythonのバージョンも関わります。tkinterが対応するTcl/Tkは8.5.12以上です。それより古いTcl/TkのサポートはPython 3.11で削除されました。公式配布のバイナリはTcl/Tk 9.0を同梱します。環境ごとにTkのバージョンが違うので、構成の出力が環境で変わったときは、まず TkVersion を疑います。

見た目まで含めたいとき

ウィジェットの構成が正しくても、窓が小さすぎる、文字が切れるといった問題は、テキスト検証では見つかりません。幅と高さの要求値を返す winfo_reqwidth() のようなメソッドはありますが、Claude Codeに「十分な大きさ」の基準を与えないと判定できません。判断基準を数値で渡せる場合だけ、検査に入れます。

それが難しいときは、割り切ります。ロジックと構成はClaude Codeが検証し、見た目は人が最後に一度見る、という分担です。デスクトップアプリをexeにまとめる段階の確認は、PyInstallerのexe化を実行して確かめる手順で扱っています。全体の使い方はClaude Codeの完全ガイドにあります。

つまずきやすい点

つまずき

Tkinterをこの方法で検証するときの落とし穴

  • mainloopが戻らない

    テストや確認コマンドに mainloop() が含まれると、プロセスが終わりません。起動処理を __main__ の中に閉じ込めます。

  • ttkと旧APIの混在

    tk.Label と ttk.Label が混ざると、外観の揃わない窓になります。構成の出力で TLabel と Label が並んだら混在のサインです。

  • 変数が消える

    StringVar を関数のローカル変数にすると、表示が空のまま動きます。エラーは出ません。

  • 配置し忘れ

    grid() を呼ばなかった部品は画面に出ません。winfo_manager() の検査で拾います。

どれも、Pythonの文法としては正しく動いてしまう種類のミスです。そのため構文チェックやlintでは見つからず、ウィジェット自身への問い合わせが効きます。

まとめ

TkinterのGUIアプリは、画面を見られないClaude Codeでも検証できます。構成は winfo_children() と cget() で文字列にし、操作は invoke() と update() で再現し、結果は変数の値で確かめます。この3点をunittestに入れ、python3 -m unittest -v を完了条件としてCLAUDE.mdに書けば、窓を開かずに反復が回ります。

残る空白は、見た目と、Tkが動かない環境です。前者は人が最後に一度見ること、後者はTkなしで検証できる関数を増やすことで埋めます。

この記事を共有:XはてブLinkedIn