0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TDDからCIまで一気に体験する最小ハンズオンを作ってみた

0
Posted at

はじめに

「テスト駆動開発 (TDD) って名前は聞くけど、実際どう進めるの?」
「テストを書いた後、それをどう CI (自動テスト) につなげるの?」

そんな人向け(私です)に、FizzBuzz の小さな Web アプリを題材にして、TDD から CI までに必要となる技術要素を一気に体験できるハンズオンを、AI を活用しながら作りました。

本記事では、次の流れで進めます。

テストを先に書く → 失敗を確認 → 最小のコードで通す → リファクタリング → Web アプリにする → コンテナで動かす → GitHub Actions で自動テスト

作るのは、数字を入力すると FizzBuzz の結果を返す、1 画面だけの Web アプリです。

  • 対象読者: Python を少し触ったことがある人 (ターミナルでコマンドを打てればOK)
  • 所要時間: 1 時間程度 (目安)

動作確認した環境

使うもの 何のため? バージョン
OS 作業するパソコン macOS
Python プログラミング言語 3.12
uv Python 本体と、使うパッケージの管理 0.10.9
pytest テストの自動化 9.2.0
ruff コードの書き方のチェック (lint) と、見た目の整形 (format) 0.17.3
FastAPI Web アプリを作る (Python のフレームワーク) 0.162.0
uvicorn Web サーバー (FastAPI で作ったアプリを動かす) 0.54.0
podman Web アプリをコンテナの中で動かす 5.6.0
GitHub Actions GitHub に push するたびに、自動でチェックとテストを実行 N/A

TDD とは

TDD は 「テストを先に書く」 開発の進め方です。次の 3 ステップをぐるぐる回します。

ステップ やること テストの状態
1. RED (レッド) これから作る機能のテストを先に書く。まだコードが無いので失敗する ❌ 失敗
2. GREEN (グリーン) テストが通る最小のコードを書く。きれいさは気にしない ✅ 成功
3. REFACTOR (リファクタリング) テストが通ったまま、コードを読みやすく整える ✅ 成功

ポイントは 2 つです。

  • 先に失敗を見る: 「テストが失敗する」ことを確認しておくと、そのテストが本当に役に立つ (コードがおかしいと気づける) と分かります。
  • 小さく進む: 1 回に 1 つだけ機能を足します。デグレしたときに、原因がすぐ分かります。

事前準備

次の 4 つを入れておきます (macOS + Homebrew の例)。

brew install uv podman git gh

podman は、最初に 1 度だけ「仮想マシン」を作って起動します。

podman machine init
podman machine start

git の設定は済んでいる前提で進めます。


Step 1. プロジェクトを作る

mkdir tdd-fizzbuzz-handson
cd tdd-fizzbuzz-handson
git init -b main
uv init --bare --python 3.12
uv python pin 3.12
  • git init -b main : Git の管理を始めます。
  • uv init --bare : pyproject.toml だけの最小のプロジェクトを作ります。
  • uv python pin 3.12 : このプロジェクトは Python 3.12 を使う、と .python-version に書き込みます。

次に、テストツール (pytest) とコードチェックツール (ruff) を入れます。--dev は「開発のときだけ使うもの」という意味です。

uv add --dev pytest ruff
Resolved 8 packages in 377ms
Installed 6 packages in 35ms
 + iniconfig==2.3.1
 + packaging==26.3
 + pluggy==1.7.0
 + pygments==2.21.0
 + pytest==9.2.0
 + ruff==0.17.3

pyproject.toml に開発用パッケージが書き込まれ、uv.lock (バージョンの記録) と .venv (このプロジェクト専用の Python 環境) もできました。

ls -A
.git
.python-version
.venv
pyproject.toml
uv.lock

pyproject.toml の末尾に、次の設定を追記します。

pyproject.toml
# --- pytest の設定 ---
[tool.pytest.ini_options]
# src フォルダを import の探索先に加える (テストから `from fizzbuzz import ...` できるようにする)
pythonpath = ["src"]
testpaths = ["tests"]

# --- ruff の設定 ---
[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
# E/F: 基本的な誤り, I: import の並び順, B: バグになりやすい書き方, UP: 古い書き方
select = ["E", "F", "I", "B", "UP"]

それぞれの設定が必要な理由です。

設定 必要な理由
pythonpath = ["src"] テストから、src/ の中のコードを読み込めるようにするため。書かないと、src/fizzbuzz.py があっても、テストの from fizzbuzz import fizzbuzz が ModuleNotFoundError になります
testpaths = ["tests"] テストを探す場所を tests/ に限るため。書かないと、関係ないフォルダの test_*.py まで実行されることがあります
[tool.ruff] と [tool.ruff.lint] ruff がチェックする基準を、明示しておくため。select では、このハンズオンで使う lint のルール群を指定しています。ruff の既定のルールや、使えるルールは、バージョンによって変わる可能性があるので、記事では設定を明示しています。line-length (1 行の長さ) と target-version (対象の Python の版) も、読む人に分かるように書いています

最後に、コードを置くフォルダを作ります。

mkdir src tests
  • src/ : 作るコード (アプリ本体)
  • tests/ : そのテスト

Step 2. TDD で FizzBuzz を作る

仕様

整数 n を受け取り、次の文字列を返す関数 fizzbuzz(n) を作ります。

条件 返す値
3 と 5 の両方の倍数 (15 の倍数) "FizzBuzz"
3 の倍数 "Fizz"
5 の倍数 "Buzz"
それ以外 n を文字列にしたもの (例: "1")

一度に全部は作りません。1 つずつ、テストから書きます。

サイクル 1: 1 → "1"

🔴 RED: テストを先に書く

tests/test_fizzbuzz.py
from fizzbuzz import fizzbuzz


def test_fizzbuzz_1():
    assert fizzbuzz(1) == "1"

テスト関数の名前について

  • pytest は、名前が test_ で始まる関数を、テストとして自動で見つけて実行します。名前が test_ で始まらないと、実行されません。
  • ここでは test_fizzbuzz_1 (= 「fizzbuzz に 1 を渡すテスト」) と名付けました。何を確かめるテストかが、名前から読めるようにするためです。テストが失敗したとき、この名前が画面に表示されるので、原因のヒントになります。

実行します。まだ src/fizzbuzz.py が無いので、失敗するはずです。

uv run pytest
tests/test_fizzbuzz.py:1: in <module>
    from fizzbuzz import fizzbuzz
E   ModuleNotFoundError: No module named 'fizzbuzz'
=============================== 1 error in 0.04s ===============================

fizzbuzz という名前のモジュールが無い と言われています。作る前なので当然です。これが RED です。

🟢 GREEN: 通る最小のコードを書く

src/fizzbuzz.py
def fizzbuzz(n: int) -> str:
    return str(n)
uv run pytest
tests/test_fizzbuzz.py .                                                 [100%]

============================== 1 passed in 0.00s ===============================

通りました。「n をそのまま文字列にする」だけの、最小のコードです。まだ Fizz や Buzz は出てきません。それでよいのが TDD です。

💡 AI コードアシスタントが活用できる箇所です (GREEN のコードを書く作業)

GREEN は「失敗しているテストを通すコードを書く」作業なので、AI コードアシスタント (Claude Code、GitHub Copilot など) に任せやすい箇所です。テストという「正解の判定基準」が先にあるので、AI が書いたコードが正しいかどうかを、uv run pytest ですぐ確かめられます。

任せるときは、次のように指示すると TDD の流れに合います。

tests/test_fizzbuzz.py のテストが失敗しています。
このテストを通す最小のコードを src/fizzbuzz.py に書いてください。
テストは変更しないでください。

注意点が 2 つあります。

  • 「最小」と指示する: 指示しないと、AI は最初から FizzBuzz を全部書いてしまうことがあります。それでは「小さく進む」という TDD の練習になりません。
  • 「テストは変更しない」と指示する: テストを通すために、テスト側を書き換えてしまう AI もあります。それでは、テストが「正解の判定基準」にならなくなります。このハンズオンでは、テストを「人間が決めた期待値」として固定し、AI には実装側を書かせます。 AI にテストまで自由に変更させると、実装に合わせて期待値まで変えてしまう可能性があるためです。

そして、AI が書いたコードは、自分で uv run pytest を実行して確かめてください。

サイクル 2: 3 → "Fizz"

🔴 RED: tests/test_fizzbuzz.py の末尾にテストを足します。

tests/test_fizzbuzz.py
def test_fizzbuzz_3():
    assert fizzbuzz(3) == "Fizz"
uv run pytest
    def test_fizzbuzz_3():
>       assert fizzbuzz(3) == "Fizz"
E       AssertionError: assert '3' == 'Fizz'
E
E         - Fizz
E         + 3

========================= 1 failed, 1 passed in 0.01s ==========================

失敗の読み方です。

  • > が付いた行が、失敗した行です。
  • - Fizz が期待した値、+ 3 が実際の値です。
  • 「3 を渡したのに "3" が返ってきた。"Fizz" がほしい」と読めます。
  • 最後の 1 failed, 1 passed から、サイクル 1 のテストはデグレしていない (まだ通っている) ことも分かります。

🟢 GREEN: コードを直します。(ここも AI コードアシスタントに任せられる箇所です。指示のしかたは、サイクル 1 の 💡 を参照)

src/fizzbuzz.py
def fizzbuzz(n: int) -> str:
    if n % 3 == 0:
        return "Fizz"
    return str(n)

n % 3 は「n を 3 で割った余り」です。余りが 0 なら 3 の倍数です。

uv run pytest
tests/test_fizzbuzz.py ..                                                [100%]

============================== 2 passed in 0.00s ===============================

サイクル 3: 5 → "Buzz"

🔴 RED: テストを足します。

tests/test_fizzbuzz.py
def test_fizzbuzz_5():
    assert fizzbuzz(5) == "Buzz"
E       AssertionError: assert '5' == 'Buzz'
========================= 1 failed, 2 passed in 0.02s ==========================

🟢 GREEN: コードを直します。(ここも AI コードアシスタントに任せられる箇所です。指示のしかたは、サイクル 1 の 💡 を参照)

src/fizzbuzz.py
def fizzbuzz(n: int) -> str:
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
tests/test_fizzbuzz.py ...                                               [100%]

============================== 3 passed in 0.01s ===============================

サイクル 4: 15 → "FizzBuzz" (ここが山場です)

🔴 RED: テストを足します。

tests/test_fizzbuzz.py
def test_fizzbuzz_15():
    assert fizzbuzz(15) == "FizzBuzz"
    def test_fizzbuzz_15():
>       assert fizzbuzz(15) == "FizzBuzz"
E       AssertionError: assert 'Fizz' == 'FizzBuzz'
========================= 1 failed, 3 passed in 0.02s ==========================

15 は 3 の倍数でもあるので、先に n % 3 == 0 に当たって "Fizz" で止まっています。

🟢 GREEN: 15 の倍数の判定を先頭に置きます。 (ここも AI コードアシスタントに任せられます。ただし、AI が「15 を先に判定する」理由まで説明できているか、自分の目で確かめましょう)

src/fizzbuzz.py
def fizzbuzz(n: int) -> str:
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
tests/test_fizzbuzz.py ....                                              [100%]

============================== 4 passed in 0.00s ===============================

これで仕様の 4 パターンが全部そろいました。


Step 3. 🔵 REFACTOR: 整える

テストが全部通っている今が、コードを整えるチャンスです。整えたあとも全部通れば、デグレしていない と確信できます。これがテストを先に書いた利点です。

テストをまとめる

リファクタリングは、動作を変えずに、コードの作りを整える作業です。そのため、ここでは次の順に、2 段階に分けて進めます。

  1. 既存の 4 ケースを、pytest.mark.parametrize で 1 つにまとめる (動作は変えない)
  2. まとめたあとで、同じ仕様を別の値でも確かめるために、2・6・10・30 のケースを追加する

1. 既存の 4 ケースをまとめる

サイクル 1〜4 で書いた 4 つのテストは、同じ形をしています。これを parametrize で 1 つにまとめます。

tests/test_fizzbuzz.py
"""fizzbuzz 関数のテスト。

テスト駆動開発 (TDD) では、先にこのようなテストを書き、
そのテストが通る最小のコードを後から書く。
"""

import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    ("n", "expected"),
    [
        (1, "1"),
        (3, "Fizz"),  # 3 の倍数
        (5, "Buzz"),  # 5 の倍数
        (15, "FizzBuzz"),  # 3 と 5 の両方の倍数
    ],
)
def test_fizzbuzz(n: int, expected: str) -> None:
    assert fizzbuzz(n) == expected

parametrize は、表の 1 行ごとに同じテストを実行する仕組みです。実行して、まとめる前と同じ 4 件が成功することを確かめます。

uv run pytest -v
tests/test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                        [ 25%]
tests/test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                     [ 50%]
tests/test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                     [ 75%]
tests/test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                [100%]

============================== 4 passed in 0.01s ===============================

2. テストケースを追加する

次に、同じ仕様を別の値でも確かめるため、2・6・10・30 のケースを足します。

tests/test_fizzbuzz.py
"""fizzbuzz 関数のテスト。

テスト駆動開発 (TDD) では、先にこのようなテストを書き、
そのテストが通る最小のコードを後から書く。
"""

import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    ("n", "expected"),
    [
        (1, "1"),  # 普通の数はそのまま
        (2, "2"),
        (3, "Fizz"),  # 3 の倍数
        (6, "Fizz"),
        (5, "Buzz"),  # 5 の倍数
        (10, "Buzz"),
        (15, "FizzBuzz"),  # 3 と 5 の両方の倍数
        (30, "FizzBuzz"),
    ],
)
def test_fizzbuzz(n: int, expected: str) -> None:
    assert fizzbuzz(n) == expected

コードに説明を足す

「なぜ 15 を先に判定するのか」を、あとで読んだ人が分かるようにコメントを足します。ロジックは変えません。

src/fizzbuzz.py
"""FizzBuzz を返す関数を提供するモジュール。"""


def fizzbuzz(n: int) -> str:
    """整数 n に対する FizzBuzz の結果を文字列で返す。

    - 3 と 5 の両方の倍数 (= 15 の倍数) なら "FizzBuzz"
    - 3 の倍数なら "Fizz"
    - 5 の倍数なら "Buzz"
    - それ以外は n をそのまま文字列にして返す
    """
    # 先に 15 の倍数を判定する。
    # 3 や 5 を先に判定すると、15 のときに "Fizz" や "Buzz" で止まってしまう。
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)

最後に、もう一度テストを実行します。-v を付けると、ケースごとの結果が見えます。

uv run pytest -v
tests/test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                        [ 12%]
tests/test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                        [ 25%]
tests/test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                     [ 37%]
tests/test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                     [ 50%]
tests/test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                     [ 62%]
tests/test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                    [ 75%]
tests/test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                [ 87%]
tests/test_fizzbuzz.py::test_fizzbuzz[30-FizzBuzz] PASSED                [100%]

============================== 8 passed in 0.01s ===============================

8 件すべて成功です。整えてもデグレしていません。

ここまでで、FizzBuzz の仕様を RED → GREEN の小さなサイクルで実装し、最後に REFACTOR まで体験しました。次は、この関数を使う Web アプリを、同じ TDD の流れで作ります。


Step 4. Web アプリにする

ここまでで、FizzBuzz の判定を行う関数ができました。この関数を、ブラウザから使える Web アプリにします。

作るものは、次のような画面です。

  • 数字を入力するフォームがある
  • 「判定する」を押すと、同じ画面に FizzBuzz の結果が表示される
URL: http://localhost:8000/?n=15

  FizzBuzz
  [ 15 ] [判定する]
  FizzBuzz        ← 結果

追加するパッケージ

Web アプリを作るために、FastAPI (Web アプリのフレームワーク) と uvicorn (Web サーバー) を入れます。

uv add fastapi uvicorn
Resolved 22 packages in 723ms
Installed 14 packages in 31ms
 + fastapi==0.162.0
 + uvicorn==0.54.0
 (ほか 12 個の依存パッケージ)

uv add に --dev を付けていないことに注意してください。--dev を付けると「開発のときだけ使うもの」になりますが、fastapi と uvicorn はアプリを動かすために必ず必要なので、付けません。これは Step 6 のコンテナで効いてきます。

次に、テストで Web アプリを呼び出すためのパッケージを入れます。

uv add --dev httpx2
Installed 3 packages in 17ms
 + httpcore2==2.16.0
 + httpx2==2.16.0
 + truststore==0.10.5

こちらはテストのときだけ使うので、--dev を付けます。

TestClient とは

FastAPI には、TestClient というテスト用のツールがあります。これを使うと、Web サーバーを起動しなくても、「/?n=15 にアクセスしたら何が返ってくるか」をテストの中で確かめられます。サーバーもコンテナも要らないので、テストが速く、後で出てくる GitHub Actions でもそのまま動かせます。

TestClient は内部で httpx2 というパッケージを使います。入れ忘れると、次のようなエラーになります。

RuntimeError: The starlette.testclient module requires the httpx2 package to be installed.
    $ pip install httpx2

(古い情報では httpx を入れるように書かれていることがありますが、2026 年 11 月時点では httpx2 を入れるのが推奨で、httpx のままだと警告が出ます。)

🔴 RED: テストを先に書く

Step 2 と同じように、テストを先に書きます。Web アプリに期待することを、3 つ書きます。

tests/test_app.py
"""Web アプリのテスト。

TestClient を使うと、サーバーを起動せずに、アプリへリクエストを送れる。
そのため、コンテナを使わずに GitHub Actions でもそのまま実行できる。
"""

from fastapi.testclient import TestClient

from app import app

client = TestClient(app)


def test_index_shows_form() -> None:
    response = client.get("/")
    assert response.status_code == 200
    assert '<input type="number" name="n"' in response.text


def test_index_shows_result() -> None:
    response = client.get("/", params={"n": 15})
    assert response.status_code == 200
    assert '<p id="result">FizzBuzz</p>' in response.text


def test_index_rejects_non_number() -> None:
    response = client.get("/", params={"n": "abc"})
    assert response.status_code == 422

3 つのテストは次の内容を確かめています。

テスト 確かめること
test_index_shows_form / にアクセスすると、数字の入力欄がある画面が返る
test_index_shows_result /?n=15 にアクセスすると、FizzBuzz が表示される
test_index_rejects_non_number /?n=abc のように数字でない入力は、エラー (422) になる

422 は「送られてきた内容を処理できない」を表す HTTP のステータスコードです。FastAPI は、n: int という型ヒントを見て、数字でない入力を自動でこのエラーにしてくれます。

まだ src/app.py が無いので、テストを実行すると失敗するはずです。

uv run pytest
tests/test_app.py:9: in <module>
    from app import app
E   ModuleNotFoundError: No module named 'app'
=========================== short test summary info ============================
ERROR tests/test_app.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.18s ===============================

これが RED です。

🟢 GREEN: 通るコードを書く

src/app.py
"""FizzBuzz を判定する Web アプリ (FastAPI)。

ブラウザで数字を入力すると、FizzBuzz の結果を表示する。
"""

from fastapi import FastAPI
from fastapi.responses import HTMLResponse

from fizzbuzz import fizzbuzz

app = FastAPI()

# 画面の HTML。{n} と {result} に、入力した数字と結果が入る。
PAGE = """<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>FizzBuzz</title>
</head>
<body>
  <h1>FizzBuzz</h1>
  <form action="/" method="get">
    <input type="number" name="n" value="{n}" required>
    <button type="submit">判定する</button>
  </form>
  <p id="result">{result}</p>
</body>
</html>
"""


@app.get("/", response_class=HTMLResponse)
def index(n: int | None = None) -> str:
    """トップページを返す。

    - n が無いとき: 入力フォームだけを表示する
    - n があるとき: fizzbuzz(n) の結果も表示する
    - n が数字でないとき: FastAPI が自動で 422 エラーを返す
      (型ヒントの int を見て、FastAPI がチェックしている)

    n は int に変換済みなので、そのまま HTML に埋め込んでも安全。
    (文字列をそのまま埋め込むと、悪意のあるスクリプトを混入される恐れがある)
    """
    if n is None:
        return PAGE.format(n="", result="")
    return PAGE.format(n=n, result=fizzbuzz(n))

ポイントを説明します。

  • from fizzbuzz import fizzbuzz : Step 2〜3 で作った関数を、そのまま使っています。判定のロジックは Web 側に書き直していません。
  • @app.get("/") : 「/ に GET でアクセスされたら、この関数 index を呼ぶ」という意味です。
  • n: int | None = None : 「n は整数。無くてもよい」という意味です。ここに数字でない値が来ると、FastAPI が自動で 422 エラーにします。
  • 画面は、HTML の文字列 PAGE の {n} と {result} を差し替えて返すだけの、簡単な作りです。

テストを実行します。

uv run pytest -v
tests/test_app.py::test_index_shows_form PASSED                          [  9%]
tests/test_app.py::test_index_shows_result PASSED                        [ 18%]
tests/test_app.py::test_index_rejects_non_number PASSED                  [ 27%]
tests/test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                        [ 36%]
tests/test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                        [ 45%]
tests/test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                     [ 54%]
tests/test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                     [ 63%]
tests/test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                     [ 72%]
tests/test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                    [ 81%]
tests/test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                [ 90%]
tests/test_fizzbuzz.py::test_fizzbuzz[30-FizzBuzz] PASSED                [100%]

============================== 11 passed in 0.19s ==============================

Web アプリのテスト 3 件と、Step 3 までの 8 件、合わせて 11 件がすべて成功です。サーバーは一度も起動していません。 起動して画面を見るのは、この次の「ローカルで起動して確認する」です。

💡 この Step も AI コードアシスタントに任せられます。 src/app.py を書く作業は、GREEN と同じく、AI に任せやすい作業です。「tests/test_app.py のテストを通す Web アプリを、FastAPI で src/app.py に書いて。テストは変更しないで」のように指示できます。AI が書いたコードは、必ず自分で uv run pytest を実行して確かめてください。

ローカルで起動して確認する

テストは通りましたが、実際に画面を見て動かして確かめてみましょう。ここでは、自分のパソコンの上で直接 Web アプリを起動します。(コンテナを使った起動は、Step 6 で行います。)

1. Web アプリを起動する

プロジェクトのフォルダで、次のコマンドを実行します。

uv run uvicorn app:app --app-dir src --reload

コマンドの意味です。

部分 意味
uv run このプロジェクトの Python 環境で、続くコマンドを実行する
uvicorn Web サーバーを起動するコマンド
app:app 「app.py の中の app」を動かす (ファイル名:変数名 の形)
--app-dir src app.py を src/ フォルダの中から探す
--reload コードを書き換えて保存すると、自動で再起動する (開発用)

次のような表示が出れば、起動しています。

INFO:     Will watch for changes in these directories: ['<プロジェクトのフォルダ>']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [18752] using StatReload
INFO:     Started server process [18757]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Application startup complete. が出ていれば成功です。<プロジェクトのフォルダ> の部分には、実際はあなたのパソコンのフォルダの場所が表示されます。

2. ブラウザで確認する

ブラウザで http://localhost:8000 を開きます。数字の入力欄と「判定する」ボタンが表示されます。

数字を入力して「判定する」を押すと、結果が表示されます。次の数字で試してみてください。

入力する数字 表示される結果
3 Fizz
5 Buzz
15 FizzBuzz
7 7

fizzbuzz_screen.png

アドレスバーに http://localhost:8000/?n=15 と直接入力しても、同じ結果になります。?n=15 の部分が、アプリに渡している数字です。

数字でない入力も試してみましょう。アドレスバーに http://localhost:8000/?n=abc と入力すると、エラーの画面になります。これは、Step 4 のテスト test_index_rejects_non_number で確かめた動きを、画面で見ていることになります。

3. ターミナルから確認する (別のターミナルで)

ブラウザを使わず、curl というコマンドで確認することもできます。サーバーを起動したターミナルとは別の、新しいターミナルを開いて実行してください。

curl -s "http://localhost:8000/?n=15" | grep 'id="result"'
  <p id="result">FizzBuzz</p>
for n in 3 5 7; do curl -s "http://localhost:8000/?n=$n" | grep -o '<p id="result">[^<]*</p>'; done
<p id="result">Fizz</p>
<p id="result">Buzz</p>
<p id="result">7</p>

4. サーバー側のログを見る

アクセスするたびに、サーバーを起動したターミナルに 1 行ずつログが出ます。

INFO:     127.0.0.1:PORT - "GET / HTTP/1.1" 200 OK
INFO:     127.0.0.1:PORT - "GET /?n=15 HTTP/1.1" 200 OK
INFO:     127.0.0.1:PORT - "GET /?n=3 HTTP/1.1" 200 OK
INFO:     127.0.0.1:PORT - "GET /?n=5 HTTP/1.1" 200 OK
INFO:     127.0.0.1:PORT - "GET /?n=7 HTTP/1.1" 200 OK
INFO:     127.0.0.1:PORT - "GET /?n=abc HTTP/1.1" 422 Unprocessable Entity

(PORT の部分には、実際は接続元のポート番号が入ります。ここでは省略しています。)

行の読み方です。

  • "GET /?n=15 HTTP/1.1" : /?n=15 にアクセスされた。
  • 200 OK : 正常に処理できた。
  • 422 Unprocessable Entity : 数字でない入力 (abc) のため、処理を断った。Step 4 のテストで期待したとおりのエラーです。

5. --reload の動きを確かめる (任意)

--reload を付けたので、コードを書き換えて保存すると、サーバーが自動で再起動されます。試してみましょう。

サーバーを起動したまま、src/app.py の中の 判定する を 判定! に書き換えて保存します。

src/app.py
    <button type="submit">判定!</button>

サーバーを起動したターミナルに、次のログが出ます。

WARNING:  StatReload detected changes in 'src/app.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [PID]
INFO:     Started server process [PID]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

(PID の部分には、実際はプロセスの番号が入ります。)

ブラウザを再読み込みすると、ボタンの文字が「判定!」に変わっています。サーバーを手で止めて起動し直す必要はありません。 確かめたら、判定! を 判定する に戻して保存してください。

⚠️ 戻し忘れに注意してください。 このあと Step 5 で ruff を実行したり、Step 7 でコミットしたりするので、書き換えたままだと、テストや記事の出力と食い違います。

6. サーバーを止める

サーバーを起動したターミナルで、Ctrl + C を押します。

ローカル起動とコンテナ起動の違い

Step 6 では、同じアプリをコンテナで起動します。2 つの違いを整理しておきます。

ローカル起動 (この節) コンテナ起動 (Step 6)
必要なもの 自分のパソコンに Python 環境 (uv sync で作った .venv) podman だけ
実行環境 プロジェクト内の .venv を使う コンテナの中に実行環境を作る
--reload 付ける (開発用) 付けない
待ち受け先 127.0.0.1 (自分のパソコンだけ) 0.0.0.0 (コンテナの外からも接続できる)
向いている場面 コードを書き換えながら確認する 同じ環境で動かす、片付けを簡単にする

Step 5. ruff でコードをチェック・整形する

テストは「処理が仕様どおりか」を確かめます。ruff は 「書き方が良いか」 を確かめます。2 つの役割があります。

コマンド 役割 例
ruff check lint: 間違いや良くない書き方を見つける 使っていない import
ruff format format: 見た目 (空白、引用符など) をそろえる ' を " にそろえる

まず、今のコードに実行します。プロジェクトのフォルダ (pyproject.toml がある場所) で実行してください。

uv run ruff check .
uv run ruff format --check .
All checks passed!
4 files already formatted

--check は「直さずに、直す必要があるかだけ調べる」オプションです。

末尾の . について

コマンドの最後の . は、「今いるフォルダ」を表す記号です。ruff check . は、「今いるフォルダの下にある Python ファイルを、すべて調べる」という意味になります。

調べる対象のファイルは、pyproject.toml などで指定しているわけではありません。ruff が、次のように自動で決めています。

  • . の下のフォルダをすべてたどり、Python ファイル (.py) を探す
  • .venv (Python の環境) や .git などのフォルダは、自動で除外する
  • .gitignore に書いたファイルやフォルダも、除外する

このプロジェクトでは、その結果として src/ と tests/ の 4 ファイル (app.py、fizzbuzz.py、test_app.py、test_fizzbuzz.py) が対象になり、4 files already formatted と表示されています。ファイルを足せば、そのファイルも自動で対象に加わります。

⚠️ サブフォルダで実行しないように注意してください。 たとえば src/ の中で実行すると、. は src/ を指すので、src/ の 2 ファイルしか調べません (2 files already formatted になります)。tests/ を調べていないのに All checks passed! と表示されるので、見落としに気づけません。

わざと怒られてみる

src/fizzbuzz.py を、次の 2 点だけ変更します。

  1. 2 行目の空行の次に import os を足す (使っていない import)
  2. return "FizzBuzz" を return 'FizzBuzz' に変える (引用符を ' にする)
src/fizzbuzz.py
"""FizzBuzz を返す関数を提供するモジュール。"""

import os


def fizzbuzz(n: int) -> str:
    ...
    if n % 15 == 0:
        return 'FizzBuzz'
    ...
uv run ruff check .
F401 [*] `os` imported but unused
 --> src/fizzbuzz.py:3:8
  |
1 | """FizzBuzz を返す関数を提供するモジュール。"""
2 |
3 | import os
  |        ^^
help: Remove unused import: `os`

Found 1 error.
[*] 1 fixable with `--fix`.

os を import したのに使っていない と指摘されました。[*] は自動で直せるという印です。

uv run ruff format --check .
unformatted: File would be reformatted
  --> src/fizzbuzz.py:17:16
   |
16 |     if n % 15 == 0:
   -         return 'FizzBuzz'
17 +         return "FizzBuzz"

1 file would be reformatted, 3 files already formatted

引用符を " にそろえるべき、と言われています。

自動で直す

uv run ruff check --fix .
uv run ruff format .
Found 1 error (1 fixed, 0 remaining).
1 file reformatted, 3 files left unchanged

もう一度チェックします。

uv run ruff check .
uv run ruff format --check .
All checks passed!
4 files already formatted

ファイルを開くと、import os が消え、'FizzBuzz' が "FizzBuzz" に戻って、元どおりになっています。


Step 6. podman のコンテナで Web アプリを動かす

Step 4 では、自分のパソコンの上で直接 Web アプリを起動して確認しました。ここでは、同じアプリをコンテナの中で動かします。

先に確認: Step 4 で起動したサーバーが、まだ動いていないか確認してください。動いたままだと、同じ 8000 番ポートを使うため、ブラウザがどちらのアプリに繋がっているか分からなくなります。止めるには、サーバーを起動したターミナルで Ctrl + C を押します。

コンテナは、アプリと実行環境 (Python やパッケージ) をひとまとめにした「持ち運べる箱」です。Web アプリをコンテナで動かすと、次のように便利です。

  • 実行環境をパソコンから分離できる: アプリを動かす Python やパッケージは、コンテナの中に用意します。パソコン側に FastAPI などを入れなくても動かせます。(Step 4 のローカル起動は、プロジェクトの中の .venv に環境を作って動かしました。)
  • 同じように動く: 「自分のパソコンでは動くのに、他の人の環境では動かない」を防げる。
  • 止めるのも簡単: コンテナを止めて消すだけで、元に戻る。

podman では、Docker の Dockerfile にあたるファイルを Containerfile と呼びます。

Containerfile
# FizzBuzz の Web アプリを動かすためのコンテナ定義 (podman で使う)。
# Docker 用の「Dockerfile」と書き方は同じで、podman では「Containerfile」と呼ぶ。

FROM docker.io/library/python:3.12-slim

# uv 本体を、公式イメージからコピーして使う
# (uv のバージョンを固定して、誰が作っても同じ uv になるようにしている)
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv

# コンテナ内の Python を使う (uv に Python を別途ダウンロードさせない)
ENV UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# アプリを動かすのに必要なパッケージだけをインストールする
# (--no-dev: pytest や ruff などの開発用ツールは入れない)
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --locked --no-dev

# アプリのコードだけをコピーする (テストはコンテナに入れない)
COPY src ./src

# uv sync で作られた仮想環境 (.venv) のコマンドを使えるようにする
ENV PATH="/app/.venv/bin:$PATH"

# このコンテナは 8000 番ポートで待ち受ける (説明のための宣言)
EXPOSE 8000

# コンテナを起動すると、Web サーバー (uvicorn) が src/app.py の app を動かす
# --host 0.0.0.0 : コンテナの外 (自分のパソコンのブラウザ) からの接続を受け付ける
CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8000"]

各部分の意味です。

  • FROM : 土台にするイメージ (Python 3.12 が入った軽量版)。
  • COPY --from=...uv:0.10.9 : uv の公式イメージから、uv のプログラムだけをもらってきます。:0.10.9 の部分で、uv のバージョンを固定しています。固定しないと、:latest のように最新版を取ることになり、ビルドする日によって uv が変わってしまいます。
  • uv sync --locked --no-dev : uv.lock に書かれたとおりのバージョンでインストールします。
    • --locked は、ロックと pyproject.toml がずれていたらエラーにする指定です。
    • --no-dev は、--dev を付けて入れた開発用ツール (pytest と ruff は Step 1、httpx2 は Step 4 で入れました) を入れないという指定です。Web アプリを動かすだけなら不要なので、コンテナが小さくなります。fastapi と uvicorn は --dev なしで入れたので、ここでちゃんと入ります。
  • COPY src : アプリのコードだけを入れます。tests/ は入れません。 テストは、手元や GitHub Actions で実行するもので、動かすコンテナには要らないためです。
  • ENV PATH="/app/.venv/bin:$PATH" : uv sync が作った仮想環境 (.venv) の中の uvicorn を、そのまま呼べるようにします。
  • EXPOSE 8000 : 「このコンテナは 8000 番ポートで待ち受ける」という宣言です。これだけでは、外から繋がるようにはなりません (後で出てくる -p が必要です)。
  • CMD : コンテナを起動したときに、コマンドを指定しなかった場合に実行される、既定のコマンドです。ここでは Web サーバー (uvicorn) を起動します。
    • これは CMD という命令の標準的な仕組みで、別の場所での設定は要りません。 podman run の末尾に何も書かないと、これが実行されます。
    • 逆に、podman run の末尾にコマンドを書くと、CMD は上書きされます。(例: podman run --rm tdd-fizzbuzz-handson python --version と実行すると、サーバーは起動せず、Python のバージョンを表示して終わります)
    • --host 0.0.0.0 が大事です。 uvicorn の既定は 127.0.0.1 (そのコンテナの中からの接続だけを受け付ける) です。これだと、自分のパソコンのブラウザから繋がりません。0.0.0.0 にすると、コンテナの外からの接続も受け付けます。

イメージを作る (build)

podman build -t tdd-fizzbuzz-handson .
STEP 1/10: FROM docker.io/library/python:3.12-slim
STEP 2/10: COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv
STEP 3/10: ENV UV_PYTHON_DOWNLOADS=never
STEP 4/10: WORKDIR /app
STEP 5/10: COPY pyproject.toml uv.lock .python-version ./
STEP 6/10: RUN uv sync --locked --no-dev
STEP 7/10: COPY src ./src
STEP 8/10: ENV PATH="/app/.venv/bin:$PATH"
STEP 9/10: EXPOSE 8000
STEP 10/10: CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8000"]
Successfully tagged localhost/tdd-fizzbuzz-handson:latest

コンテナを起動する (run)

podman run -d --rm --name fizzbuzz-web -p 8000:8000 tdd-fizzbuzz-handson

オプションの意味です。

オプション 意味
-d バックグラウンドで動かす (ターミナルを占有しない)
--rm 止めたら、コンテナを自動で消す
--name fizzbuzz-web コンテナに fizzbuzz-web という名前を付ける (後で止めるときに使う)
-p 8000:8000 自分のパソコンの 8000 番ポートを、コンテナの 8000 番ポートに繋ぐ。自分のパソコン側:コンテナ側 の順

起動すると、長いコンテナ ID が表示されます。動いているかを確かめます。

podman ps
CONTAINER ID  IMAGE                                  COMMAND               CREATED        STATUS        PORTS                   NAMES
4bcd08f9b9d7  localhost/tdd-fizzbuzz-handson:latest  uvicorn app:app -...  3 seconds ago  Up 3 seconds  0.0.0.0:8000->8000/tcp  fizzbuzz-web

他のコンテナが動いていると、その行も表示されます。NAMES が fizzbuzz-web の行を見てください。PORTS の 0.0.0.0:8000->8000/tcp が、-p 8000:8000 で繋がっている印です。

画面を確認する

ブラウザで http://localhost:8000 を開くと、数字の入力欄が表示されます。入力して「判定する」を押すと、結果が表示されます。

画面は、Step 4 の「ローカルで起動して確認する」で見たものとまったく同じです。同じアプリが、今度はコンテナの中で動いています。

ターミナルからも curl で確認できます。/?n=15 にアクセスします。

curl "http://localhost:8000/?n=15"
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>FizzBuzz</title>
</head>
<body>
  <h1>FizzBuzz</h1>
  <form action="/" method="get">
    <input type="number" name="n" value="15" required>
    <button type="submit">判定する</button>
  </form>
  <p id="result">FizzBuzz</p>
</body>
</html>

<p id="result">FizzBuzz</p> と、結果が返ってきました。いくつか試します。

for n in 3 5 7; do curl -s "http://localhost:8000/?n=$n" | grep -o '<p id="result">[^<]*</p>'; done
<p id="result">Fizz</p>
<p id="result">Buzz</p>
<p id="result">7</p>

数字でない入力は、Step 4 のテストで確かめたとおり、エラー (422) になります。

curl -s -w "\nHTTP %{http_code}\n" "http://localhost:8000/?n=abc"
{"detail":[{"type":"int_parsing","loc":["query","n"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
HTTP 422

Web サーバーの記録 (ログ) は、podman logs で見られます。アクセスするたびに 1 行増えます。

podman logs fizzbuzz-web
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     10.88.0.33:55384 - "GET /?n=15 HTTP/1.1" 200 OK
INFO:     10.88.0.33:55392 - "GET /?n=3 HTTP/1.1" 200 OK

最後に、コンテナを止めます。--rm を付けたので、止めると自動で消えます。

podman stop fizzbuzz-web
fizzbuzz-web

コンテナの中身を確かめる

--no-dev と、tests/ を入れなかった効果を確かめてみます。コンテナの中に、テストのツールは入っていません。

podman run --rm tdd-fizzbuzz-handson python -c "import pytest"
ModuleNotFoundError: No module named 'pytest'

コンテナの中のファイルも、アプリに必要なものだけです。

podman run --rm tdd-fizzbuzz-handson ls /app
pyproject.toml
src
uv.lock

ここで大事なこと: テストは、コンテナの外で実行します。 この Step のコンテナは「Web アプリを動かす」ためのものです。Step 4 までで、テストは uv run pytest で (サーバーもコンテナも使わずに) 実行できるようにしました。次の Step 7 の GitHub Actions でも、コンテナを起動せずにテストできます。


Step 7. GitHub Actions で自動実行する

ここまでの「ruff でチェック → pytest でテスト」を、GitHub に push するたびに自動で実行する設定を作ります。これが CI (継続的インテグレーション) です。

この CI では、コンテナも Web サーバーも起動しません。 Step 4 で、TestClient を使ってサーバーなしでテストできるようにしたので、Web アプリのテストも、普通の uv run pytest の中で実行されます。

ワークフローを書く

GitHub Actions の設定は、.github/workflows/ の中に YAML ファイルとして置きます。

mkdir -p .github/workflows
.github/workflows/ci.yml
# GitHub に push するたびに、自動でチェックとテストを実行する設定。
name: CI

on:
  push:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # リポジトリのコードを取得する
      - uses: actions/checkout@v7

      # uv をインストールする (uv のバージョンも固定する)
      - uses: astral-sh/setup-uv@v10.3.3
        with:
          version: "0.10.9"

      # uv.lock のとおりに依存関係をインストールする
      - run: uv sync --locked

      # ruff でコードの書き方をチェックする (lint)
      - run: uv run ruff check .

      # ruff でコードの整形が済んでいるかチェックする (format)
      - run: uv run ruff format --check .

      # pytest でテストを実行する
      # (Web アプリのテストは TestClient を使うので、サーバーもコンテナも起動しない)
      - run: uv run pytest

読み方です。

部分 意味
on: push: いつ動くか。ここでは「push されたとき」
runs-on: ubuntu-latest どんなマシンで動くか。GitHub が用意する Ubuntu
uses: 既製の部品を使う。checkout はコードの取得、setup-uv は uv の準備
with: 部品に渡す設定。ここでは version: "0.10.9" で、インストールする uv のバージョンを固定している
run: 普通のコマンドを実行する。手元で打ったものと同じ

手元で実行したコマンドと同じものを並べているだけです。どれか 1 つでも失敗すると、CI 全体が失敗 (❌) になります。

uv run pytest は、tests/ の中のテストを全部実行します。Step 3 までの関数のテストと、Step 4 の Web アプリのテストが、1 回のコマンドで両方走ります。

Git にコミットする

.gitignore を作って、Git に入れたくないものを指定します。

.gitignore
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/

何が入るかを確認してから、コミットします。

git add .
git status --short
A  .github/workflows/ci.yml
A  .gitignore
A  .python-version
A  Containerfile
A  pyproject.toml
A  src/app.py
A  src/fizzbuzz.py
A  tests/test_app.py
A  tests/test_fizzbuzz.py
A  uv.lock

.venv などが入っていないことを確認できました。

git commit -m "FizzBuzz を TDD で実装し、Web アプリと CI を追加"

GitHub に push する

GitHub のコマンドラインツール (gh) で、ログインとリポジトリ作成をします。

gh auth login

画面の質問に答えて、GitHub.com にログインします。続けてリポジトリを作り、push します。

gh repo create tdd-fizzbuzz-handson --private --source=. --remote=origin --push
  • --private : 自分だけが見られるリポジトリにします。
  • --source=. : 今のフォルダの内容を使います。
  • --push : そのまま push します。

組織 (Organization) の中に作る場合は、組織名/tdd-fizzbuzz-handson と書きます。

Actions の結果を見る

push した時点で、CI が自動で動き始めます。ターミナルから確認する場合は、次のコマンドを使います。

gh run watch

ブラウザなら、リポジトリのページの Actions タブで確認できます。

push の直後から CI が動き、約 13 秒で緑のチェック ✅ になりました。コミットメッセージの左にある緑の丸が、「ruff のチェックと pytest のテストがすべて成功した」という印です。

ci.png

わざとデグレさせて、CI が止めてくれるのを見る

CI の価値は「デグレしたときに気づける」ことです。デグレとは、変更したせいで、前は正しく動いていた機能が正しく動かなくなること (「デグレード」の略) です。実際にわざとデグレさせてみます。

src/fizzbuzz.py の中の、15 の判定のブロックを切り取って、3 の判定のブロックの後ろに移します。

src/fizzbuzz.py
    if n % 3 == 0:
        return "Fizz"
    if n % 15 == 0:        # ← 3 の判定より後ろに移した
        return "FizzBuzz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)

これは、サイクル 4 で直したバグそのものです。手元で実行すると、関数のテストだけでなく、Web アプリのテストも失敗します。

uv run pytest
tests/test_app.py .F.                                                    [ 27%]
tests/test_fizzbuzz.py ......FF                                          [100%]

FAILED tests/test_app.py::test_index_shows_result - assert '<p id="result">Fi...
FAILED tests/test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] - AssertionError: a...
FAILED tests/test_fizzbuzz.py::test_fizzbuzz[30-FizzBuzz] - AssertionError: a...
========================= 3 failed, 8 passed in 0.19s ==========================

3 件が失敗しました。内訳は、関数のテスト 2 件 (15 と 30) と、Web アプリのテスト 1 件です。Web アプリのコード (src/app.py) は一切変えていないのに、fizzbuzz 関数を変更したせいで、画面に表示される結果 (/?n=15) も FizzBuzz ではなく Fizz になってしまったためです。関数のテストと、Web 画面のテストの両方が、同じバグを見つけてくれました。

この状態のまま push します。

git commit -am "わざとデグレさせてみる"
git push

Actions の Actions タブを開くと、今度は赤い ❌ になっています。ターミナルでも確認できます。

gh run list --limit 2
completed	failure	わざとデグレさせてみる	CI	main	push	42153929282	14s	2026-11-06T03:01:13Z
completed	success	FizzBuzz を Web アプリ (FastAPI) にし、コンテナで起動できるようにする	CI	main	push	42153803811	13s	2026-11-06T03:00:07Z

degrade.png

どこで止まったかを見てみます。gh run view <実行ID> --log-failed で、失敗したステップのログだけを表示できます。

gh run view 42153929282 --log-failed
tests/test_app.py .F.                                                    [ 27%]
tests/test_fizzbuzz.py ......FF                                          [100%]

___________________________ test_index_shows_result ____________________________
E       assert '<p id="result">FizzBuzz</p>' in '<!doctype html> ... <p id="result">Fizz</p> ...'
tests/test_app.py:23: AssertionError
__________________________ test_fizzbuzz[15-FizzBuzz] __________________________
E       AssertionError: assert 'Fizz' == 'FizzBuzz'
__________________________ test_fizzbuzz[30-FizzBuzz] __________________________
E       AssertionError: assert 'Fizz' == 'FizzBuzz'

========================= 3 failed, 8 passed in 0.75s ==========================

(実際のログはもっと長いので、要点だけ抜粋しています。<!doctype html> ... の部分は、画面の HTML を省略した表記です。)

手元で見た失敗と同じ内容 (3 件の失敗) が、GitHub 上でも再現しています。ステップごとの結果は次のとおりです。

  ✓ Run uv sync --locked
  ✓ Run uv run ruff check .
  ✓ Run uv run ruff format --check .
  X Run uv run pytest

ruff の 2 つは通っていて、テストだけが失敗しています。起きたのは「処理の不整合」(処理の結果が仕様とずれたこと) であって、「書き方」の問題ではないので、ruff が通るのは正しい結果です。lint と format は書き方を、pytest は処理が仕様どおりかどうかを守る、という役割分担が見えます。

元に戻す

最後に、デグレさせたコミットを打ち消すコミットを作ります。git revert は、過去のコミットを消さずに「取り消し」を新しい履歴として積む安全な方法です。

git revert HEAD --no-edit
git push

CI が再び動き、緑に戻ります。

gh run list --limit 3
completed	success	Revert "わざとデグレさせてみる"	CI	main	push	42154043465	14s	2026-11-06T03:02:20Z
completed	failure	わざとデグレさせてみる	CI	main	push	42153929282	14s	2026-11-06T03:01:13Z
completed	success	FizzBuzz を Web アプリ (FastAPI) にし、コンテナで起動できるようにする	CI	main	push	42153803811	13s	2026-11-06T03:00:07Z

履歴を見ると、「成功 → 失敗 → 成功」と並んでいます。デグレさせた記録も残るので、あとから「いつ・何が原因でデグレしたか」をたどれます。

revert.png


まとめ

この記事では、次のことを一通り体験しました。少しでもお役に立てたなら幸いです。

やったこと 学べたこと
RED → GREEN → REFACTOR を 4 サイクル テストを先に書くと、小さく安全に進められる
FastAPI (TestClient) Web アプリも、サーバーを起動せずにテストできる
pytest (parametrize) 同じ形のテストは表にまとめられる
ruff (check / format) 書き方のチェックと整形は、ツールに任せられる
uv (uv.lock) みんなが同じバージョンで動かせる
podman (Containerfile) Web アプリを、パソコンの環境から分離して、同じように動かせる
GitHub Actions チェックとテストを自動化でき、デグレしたらすぐ分かる。コンテナも不要

次のステップ

  • Pull Request でも CI を動かす (on: に pull_request: を足す)
  • テストの網羅率を測る (pytest-cov)
  • コミットの前に自動でチェックする (pre-commit)
  • 入力欄に POST で送る、結果を JSON で返す API を足すなど、Web アプリを育てながら TDD を回してみる
  • コンテナを GitHub Actions でも使う (イメージのビルドが通るかを CI で確かめる)
  • RAG など、出力が決定的でない AI アプリケーションに TDD を適用すると何が変わるかを検討する (次回予定)

つまずきやすいところ

症状 原因と対処
ModuleNotFoundError: No module named 'fizzbuzz' がサイクル 1 のあとも消えない pyproject.toml の pythonpath = ["src"] が無い、または src/fizzbuzz.py の置き場所が違う
podman build で Cannot connect to Podman と出る podman machine start を実行していない (macOS / Windows)
RuntimeError: The starlette.testclient module requires the httpx2 package と出る uv add --dev httpx2 を実行していない (Step 4)
コンテナを起動したのに、ブラウザで localhost:8000 に繋がらない (curl が exit=52) --host 0.0.0.0 を付け忘れた (uvicorn が 127.0.0.1 で待ち受けている)。Containerfile の CMD を確かめる
podman run で proxy already running と出る 8000 番ポートが、すでに別のコンテナで使われている (同じ fizzbuzz-web を起動したままにしていないか)。podman ps で確かめ、podman stop 名前 で止める。(ローカルのサーバーが動いているだけでは、このエラーにはなりません)
podman build の uv sync --locked で No interpreter found for Python ... と出る .python-version に書いた Python が、コンテナの Python (3.12) と違う。uv python pin 3.12 で合わせる
Actions が uv sync --locked で失敗する uv.lock をコミットし忘れている、または pyproject.toml を変えたのに uv lock していない
gh repo create が失敗する gh auth login が終わっていない

おことわり 記事は執筆時点の情報を元に執筆しております。必ずしも最新情報であるとはかぎりません。記事の内容の正確性に関して責任を負うものではない事はご了承下さい。 掲載内容はあくまで私自身の見解であり、私の所属団体・企業における立場、戦略、意見を代表するものではありません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?