Python | テスト・設計・品質:モジュール分割

Python Python
スポンサーリンク

モジュール分割って何?一言でいうと「ファイルごとに役割をはっきり分けること」

Python でいう「モジュール分割」は、
コードを複数の .py ファイルに分けて、それぞれに「はっきりした役割」を持たせることです。

1ファイルに全部書いても動きます。
でも、コード量が増えると、

どこに何が書いてあるか分からない
同じような処理があちこちにコピペされる
ちょっと直すだけで全体に影響しそうで怖い

という状態になります。

モジュール分割の目的は、
「コードを見つけやすく・理解しやすく・直しやすくすること」です。
そのために、ファイル単位で責務(役割)を分けていきます。


まずは「全部1ファイル」の状態を見てみる

before:よくある「全部 app.py に詰め込んだ」コード

例えば、簡単なタスク管理アプリを考えます。
最初はだいたい、こんな感じになりがちです。

# app.py

import json
from dataclasses import dataclass
from pathlib import Path

# モデル
@dataclass
class Task:
    id: int
    title: str
    done: bool = False

# 永続化
DATA_FILE = Path("tasks.json")

def load_tasks() -> list[Task]:
    if not DATA_FILE.exists():
        return []
    data = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    return [Task(**item) for item in data]

def save_tasks(tasks: list[Task]) -> None:
    data = [task.__dict__ for task in tasks]
    DATA_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

# アプリのロジック
def add_task(title: str) -> None:
    tasks = load_tasks()
    new_id = (max((t.id for t in tasks), default=0) + 1)
    tasks.append(Task(id=new_id, title=title))
    save_tasks(tasks)

def list_tasks() -> None:
    tasks = load_tasks()
    for task in tasks:
        mark = "✔" if task.done else " "
        print(f"[{mark}] {task.id}: {task.title}")

def complete_task(task_id: int) -> None:
    tasks = load_tasks()
    for task in tasks:
        if task.id == task_id:
            task.done = True
            break
    save_tasks(tasks)

# CLIインターフェース
def main() -> None:
    import sys

    if len(sys.argv) < 2:
        print("usage: app.py [add|list|done] ...")
        return

    cmd = sys.argv[1]
    if cmd == "add":
        title = " ".join(sys.argv[2:])
        add_task(title)
    elif cmd == "list":
        list_tasks()
    elif cmd == "done":
        complete_task(int(sys.argv[2]))
    else:
        print("unknown command")

if __name__ == "__main__":
    main()
Python

これでも動きます。
でも、役割が全部混ざっています。

タスクという「モデル」
ファイルへの保存・読み込み(永続化)
アプリのロジック(追加・一覧・完了)
CLI のインターフェース

これらが1ファイルに詰め込まれているので、
コードが増えると一気にカオスになります。


モジュール分割の基本方針:「役割ごとにファイルを分ける」

どんな軸で分けると分かりやすいか

初心者にとって一番分かりやすい分け方は、
「役割(責務)」ごとに分けることです。

モデル(データ構造・ドメイン)
永続化(DB・ファイルなど)
アプリケーションのロジック(ユースケース)
インターフェース(CLI・Web・GUIなど)

さっきのタスク管理アプリを、この軸で分けてみます。


実際にモジュール分割してみる

domain.py:タスクという「ドメイン」を表すモジュール

# domain.py

from dataclasses import dataclass

@dataclass
class Task:
    id: int
    title: str
    done: bool = False

    def complete(self) -> None:
        self.done = True
Python

ここには、「タスクとは何か」だけを書きます。
ファイルのことも CLI のことも知りません。

「タスクを完了にする」という振る舞いも、
Task.complete としてここに閉じ込めます。

repository.py:タスクの保存・読み込みを担当するモジュール

# repository.py

import json
from pathlib import Path
from typing import Protocol
from domain import Task

class TaskRepository(Protocol):
    def load(self) -> list[Task]:
        ...

    def save(self, tasks: list[Task]) -> None:
        ...


class JsonTaskRepository:
    def __init__(self, path: Path) -> None:
        self._path = path

    def load(self) -> list[Task]:
        if not self._path.exists():
            return []
        data = json.loads(self._path.read_text(encoding="utf-8"))
        return [Task(**item) for item in data]

    def save(self, tasks: list[Task]) -> None:
        data = [task.__dict__ for task in tasks]
        self._path.write_text(
            json.dumps(data, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
Python

ここは「タスクをどこにどう保存するか」だけを担当します。
CLI のことも、コマンド名も知りません。

TaskRepository という Protocol を定義しておくと、
あとで「SQLite版」「メモリ版」などに差し替えやすくなります。

usecase.py:アプリとして「何をするか」を表すモジュール

# usecase.py

from dataclasses import dataclass
from domain import Task
from repository import TaskRepository

@dataclass
class AddTaskInput:
    title: str

def add_task(repo: TaskRepository, data: AddTaskInput) -> None:
    tasks = repo.load()
    new_id = (max((t.id for t in tasks), default=0) + 1)
    tasks.append(Task(id=new_id, title=data.title))
    repo.save(tasks)


def list_tasks(repo: TaskRepository) -> list[Task]:
    return repo.load()


def complete_task(repo: TaskRepository, task_id: int) -> None:
    tasks = repo.load()
    for task in tasks:
        if task.id == task_id:
            task.complete()
            break
    repo.save(tasks)
Python

ここは「アプリとしての振る舞い」だけを担当します。

タスクを追加する
タスク一覧を取得する
タスクを完了にする

というユースケースを、関数として表現しています。

CLI のことも、ファイルパスも知りません。
TaskRepository という抽象にだけ依存しています。

cli.py:CLIインターフェースを担当するモジュール

# cli.py

import sys
from pathlib import Path
from repository import JsonTaskRepository
from usecase import (
    add_task,
    list_tasks,
    complete_task,
    AddTaskInput,
)

def main() -> None:
    repo = JsonTaskRepository(Path("tasks.json"))

    if len(sys.argv) < 2:
        print("usage: app.py [add|list|done] ...")
        return

    cmd = sys.argv[1]
    if cmd == "add":
        title = " ".join(sys.argv[2:])
        add_task(repo, AddTaskInput(title=title))
    elif cmd == "list":
        tasks = list_tasks(repo)
        for task in tasks:
            mark = "✔" if task.done else " "
            print(f"[{mark}] {task.id}: {task.title}")
    elif cmd == "done":
        complete_task(repo, int(sys.argv[2]))
    else:
        print("unknown command")

if __name__ == "__main__":
    main()
Python

ここは「コマンドライン引数をどう解釈するか」「どう表示するか」だけを担当します。

タスクのルールも、保存形式も知りません。
それらは usecaserepository に任せています。


モジュール分割で何が嬉しくなるのか

「どこを見ればいいか」が一瞬で分かる

仕様変更やバグ修正が入ったときに、
どのファイルを開けばいいかが明確になります。

タスクのルールを変えたい
domain.py を見る

保存形式を変えたい(JSON → SQLite)
repository.py を見る

新しいコマンドを追加したい
cli.pyusecase.py を見る

1ファイルに全部詰め込んでいると、
「とりあえず app.py をスクロールしまくる」ことになります。
モジュール分割すると、その時間がごっそり減ります。

テストが書きやすくなる

例えば、ユースケースだけをテストしたいとき、
CLI やファイル I/O を気にせずに済みます。

from usecase import add_task, list_tasks, AddTaskInput
from repository import TaskRepository
from domain import Task

class InMemoryRepo(TaskRepository):
    def __init__(self) -> None:
        self.tasks: list[Task] = []

    def load(self) -> list[Task]:
        return list(self.tasks)

    def save(self, tasks: list[Task]) -> None:
        self.tasks = list(tasks)


def test_add_task():
    repo = InMemoryRepo()
    add_task(repo, AddTaskInput(title="test"))
    tasks = list_tasks(repo)

    assert len(tasks) == 1
    assert tasks[0].title == "test"
Python

usecase モジュールは、
「タスクのルール」と「保存の仕組み」から切り離されているので、
テスト用のリポジトリを差し替えるだけでテストできます。

これは、品質に直結します。


モジュール分割で一番大事な感覚:「一つのモジュールに一つの責務」

初心者がまず意識してほしいのは、
「1ファイルに何でもかんでも詰め込まない」ということです。

完璧な分割ルールを覚える必要はありません。
最初は、次のような感覚だけで十分です。

データ構造やビジネスルールは「domain.py」などにまとめる
外部とのやりとり(DB・ファイル・API)は「repository.py」などにまとめる
アプリとしての操作(ユースケース)は「usecase.py」にまとめる
UI(CLI・Web)は「cli.py」「web.py」などにまとめる

「このファイルは何担当?」と聞かれたときに、
一言で答えられる状態を目指すと、モジュール分割はだいたいうまくいきます。

逆に、

「このファイルは、まあ…いろいろ…」

となっていたら、
そこは分割候補です。


モジュール分割でやりがちな失敗と、その避け方

「機能ごとに分けすぎて、逆に分かりにくくなる」

例えば、こんな分け方はやりすぎです。

add_task.py
list_tasks.py
complete_task.py

ファイルが細かすぎると、
「どこに何があるか」を覚えるのが大変になります。

一つのモジュールに「関連する機能」をまとめる方が、
結果的に見通しが良くなります。

タスク関連のユースケースは usecase.py にまとめる
タスク関連のドメインは domain.py にまとめる

くらいの粒度が、初心者にはちょうどいいです。

「名前がふわっとしていて、役割が伝わらない」

utils.py
common.py
helper.py

こういう名前のモジュールは、
だいたい「何でも入れていい箱」になってしまいます。

結果として、
utils.py が 1000 行を超える、みたいな地獄が生まれます。

できるだけ、「何のためのモジュールか」が分かる名前を付けてください。

string_utils.py
date_utils.py
task_repository.py

など、「対象」や「責務」が分かる名前にすると、
自然と中身も整理されていきます。


初心者がモジュール分割を練習するときのおすすめステップ

まずは「1ファイルで書いてから、あとで分ける」

最初から完璧に分割しようとすると、
手が止まります。

一番いい練習は、

まずは1ファイルで素直に書く
コードが増えてきて「ごちゃっとしてきたな」と感じたら、役割ごとに分ける

という流れです。

そのときに、

モデル(ドメイン)
永続化(DB・ファイル)
ユースケース
インターフェース(CLI・Web)

のどれに近いかを考えながら、
ファイルを分けていくと、自然といい形に近づきます。

分けたあとに「import の向き」を眺めてみる

モジュール分割がうまくいっているかどうかは、
import の向きを見ると分かります。

cli.pyusecase を import している
usecase.pydomainrepository を import している
repository.pydomain を import している

このように、「外側が内側を import する」形になっていると、
設計としてかなりきれいです。

逆に、

domain.pycli を import している

みたいな状態になっていたら、
それは依存の向きが逆です。
そのときは、「本当にそれが必要か?」を一度立ち止まって考えてみてください。


まとめ(モジュール分割は「役割ごとにファイルを分けて、未来の自分を助ける技術」)

モジュール分割を初心者目線でまとめると、こうなります。

モジュール分割は、コードを複数の .py ファイルに分けて、それぞれに「はっきりした役割(責務)」を持たせることで、コードを見つけやすく・理解しやすく・直しやすくするための技術。
モデル(ドメイン)、永続化、ユースケース、インターフェースといった軸で分けると、どこを直せばいいかが一瞬で分かるようになり、テストもしやすくなる。
完璧な分割を最初から目指す必要はなく、「まず1ファイルで書いてから、役割ごとに分ける」「utils などの曖昧な名前を避ける」「import の向きを眺めてみる」といった小さな工夫だけでも、設計と品質がかなり良くなる。

タイトルとURLをコピーしました