1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flask + PostgreSQLでつくる書籍管理アプリ:Jinja2分離・CSS設計・pytest自動テスト追従の実践記録

1
Posted at

1. はじめに(概要)

Python / Flask と PostgreSQL 16 を使用し、CRUD 機能を備えた書籍管理 Web アプリ(book-log-app)を開発しました。

初期状態の app.py 内に HTML を直接記述していた構成から、「保守性の向上」 と 「テスト駆動による品質保証」 を目指してリファクタリングを実施したプロセスの記録です。


2. システム構成と技術スタック

  • バックエンド: Python 3.12 / Flask / psycopg2-binary / Poetry
  • データベース: PostgreSQL 16 (RealDictCursor による辞書形式データ取得)
  • フロントエンド: Jinja2 テンプレート / 外部 CSS (static/css/style.css)
  • テスト / CI/CD: pytest / GitHub Actions (.github/workflows/test.yml)

3. リファクタリングのポイントとトラブルシューティング

① HTML/CSS の分離(Jinja2 & 静的ファイル化)

課題

app.py 内の Flask ルート関数内に直接 HTML 文字列を記述していたため、デザインの変更やロジックの可読性が著しく低下していました。

解決策

  1. プロジェクト直下に templates/ フォルダを作成し、index.html および edit.html にレンダリングを分離。
  2. static/css/style.css を作成し、Jinja2 の url_for 関数を用いて外部 CSS を読み込み。
<!-- templates/index.html 内での外部CSS読み込み -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">

つまずいたポイントと学び

  • TemplateNotFound エラー: フォルダ名を template(単数形)にしてしまっていたため発生。Flask の標準規約通り templates(複数形) へ修正して解消。
  • HTML コメントの構文エラー: HTML コメントで < !-- のように < と ! の間にスペースが入ってしまい、ブラウザがコメントと認識せず画面上にそのまま文字列が露出。正しい記法 <!-- ... --> に修正。
  • ブラウザキャッシュの挙動: CSS を更新した際、Flask / ブラウザ側で強烈に静的ファイルがキャッシュされるため、検証時は強力再読み込み(Cmd + Shift + R)を徹底する必要性を実感。

② UI 表示の工夫(数値データを星マーク ★ に変換)

設計方針

データベース側は集計や平均評価の計算を考慮して INTEGER(1〜5)で保持し、画面表示時のみ Jinja2 の条件分岐で ★ マークへ動的変換します。

<!-- templates/index.html 評価の星マーク表示 -->
<td>
    {% if book.rating == 5 %}<span class="star-filled">★★★★★</span>
    {% elif book.rating == 4 %}<span class="star-filled">★★★★</span><span class="star-empty">☆</span>
    {% elif book.rating == 3 %}<span class="star-filled">★★★</span><span class="star-empty">☆☆</span>
    {% elif book.rating == 2 %}<span class="star-filled">★★</span><span class="star-empty">☆☆☆</span>
    {% elif book.rating == 1 %}<span class="star-filled">★</span><span class="star-empty">☆☆☆☆</span>
    {% else %}<span class="star-none">-</span>
    {% endif %}
</td>

/* static/css/style.css */
.star-filled {
    color: #f39c12; /* オレンジ色 */
}
.star-empty {
    color: #ccc; /* グレー */
}
.star-none {
    color: #999;
}


③ あいまい検索・ステータス絞り込みの実装

タイトルや著者名に対する大文字・小文字を区別しない曖昧検索を実現するため、PostgreSQL の ILIKE 演算子を採用しました。WHERE 1=1 をベースに検索パラメータに応じて動的にクエリを構築しています。

# app.py
@app.route("/")
def index():
    keyword = request.args.get("keyword", "").strip()
    status = request.args.get("status", "").strip()

    conn = get_db_connection()
    cur = conn.cursor()

    sql = "SELECT * FROM books WHERE 1=1"
    params = []

    # キーワード検索(タイトルまたは著者名にマッチ)
    if keyword:
        sql += " AND (title ILIKE %s OR author ILIKE %s)"
        params.extend([f"%{keyword}%", f"%{keyword}%"])

    # ステータス絞り込み
    if status:
        sql += " AND status = %s"
        params.append(status)

    sql += " ORDER BY id DESC;"

    cur.execute(sql, params)
    books = cur.fetchall()
    cur.close()
    conn.close()

    return render_template("index.html", books=books, keyword=keyword, status=status)


4. 自動テスト(pytest)の追従と修復

ルーティングの変更や POST 先の変更に伴い、既存のテストコード(test_app.py)でアサーションエラーや 405 Method Not Allowed が発生しました。テストコードを最新のアプリケーション構造に同期させました。

発生したテストエラーと対処

  1. test_index_page 失敗
  • 原因: レスポンスに含まれる期待文字列が旧バージョンのタイトルのままだった。
  • 修正: アサーション対象を新 UI の表示文字 書籍管理アプリ に更新。
  1. **test_add_book で 405 METHOD NOT ALLOWED**
  • 原因: app.py 側で新規作成エンドポイントを / から /add(POST)に変更したため、client.post("/") が拒否された。
  • 修正: リクエスト先を client.post("/add", ...) に変更。

検索機能のテストケース追加

# test_app.py
def test_search_books(client):
    # テスト用データを3件作成
    client.post("/add", data={"title": "Python入門", "author": "山田太郎", "status": "読書中"}, follow_redirects=True)
    client.post("/add", data={"title": "Flask Web開発", "author": "佐藤花子", "status": "積読"}, follow_redirects=True)
    client.post("/add", data={"title": "SQL実践ガイド", "author": "山田太郎", "status": "読了"}, follow_redirects=True)

    # 1. キーワード「Python」で検索 -> 「Python入門」のみヒットすること
    response = client.get("/?keyword=Python")
    content = response.get_data(as_text=True)
    assert "Python入門" in content
    assert "Flask Web開発" not in content

    # 2. ステータス「積読」で絞り込み -> 「Flask Web開発」のみヒットすること
    response = client.get("/?status=積読")
    content = response.get_data(as_text=True)
    assert "Flask Web開発" in content
    assert "Python入門" not in content

最終的に 5 passed(全件合格)を達成し、CI/CD パイプライン(GitHub Actions)上でも問題なくグリーンパスすることを確認しました。


5. まとめ・今後の展望

今回のリファクタリングを通じて、以下の重要性を再確認しました。

  • 関心事の分離 (SoC): データ保持(PostgreSQL)、ビジネスロジック(Flask)、画面表示(Jinja2 / CSS)の役割を明確に分けることで、コードの可読性と保守性が大幅に向上する。
  • リファクタリングとテスト: アプリの構造を変えた際は即座に pytest を実行し、変更に対する影響範囲を素早く検知・修正する開発サイクルが極めて有効である。

今後は、存在しない ID アクセス時の 404 エラーハンドリングの強化や、サーバーサイド・バリデーションのさらなる堅牢化に取り組む予定です。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?