電通総研 テックブログ

電通総研が運営する技術ブログ

Dev Containerを使ってステップバイステップで作るPythonアプリケーション開発環境

みなさんこんにちは、電通国際情報サービス(ISID)Xイノベーション本部ソフトウェアデザインセンターの佐藤太一です。

この記事では、VS CodeのDev Containerを使ってOSに依存しないPythonの開発環境を構築する方法をステップバイステップで丁寧に説明します。

VS Codeの利用経験があり、またPythonによるアプリケーション開発に興味のある方を想定読者として記述しています。Pythonの初心者から中級者向けを意識して書いていますので、意図して冗長な説明をしています。

すでにPythonによるアプリケーション開発に十分に詳しい方は、まずはまとめだけ読んでみてください。私自身それほどPythonのエコシステムに詳しいわけではありませんので、知識の抜け漏れは恐らくあるでしょう。そういった事に気が付いたら、XなどのSNSでこの記事のURLを付けてコメントをしていただけると幸いです。

はじめに

ソフトウェア開発をチームで行うにあたって、もっとも困難な課題の一つは開発環境の再現性を担保することです。

開発チームのメンバーは、それぞれが固有の経験と知識を持っていますし、プロジェクトに参画する際の契約が異なることも多いでしょう。全てのメンバーが、単一のプロジェクトに使いうる全ての工数を使ってプロジェクトに貢献できるわけではありません。人によっては、複数のプロジェクトを掛け持ちしていることはあります。

ある開発メンバーは慣れているMacを使いたいと考える時、違うメンバーは会社から標準的に貸与されるWindowsで開発したいと考えるかもしれません。しかし、アプリケーションのデプロイ先はUbuntuDebianといったLinuxだったりします。

こういった状況では、特定のメンバーでのみ発生する不具合が存在したり、または特定のメンバーのローカル環境でしかアプリケーションが動作しないといった事が起こります。
原因が明らかになれば、単にPATH環境変数の違いのような簡単なことかもしれませんが、その過程は往々にして困難なものになります。そういった細かい環境差異を原因とする問題の調査はシニアなメンバーにしかできないことが多いのです。
しかし、その調査の時間は明らかに無駄ですし、ほぼ何の価値もありません。

付加価値の高いシニアなメンバーの工数を、そういった調査で浪費することはプロジェクトにとって望ましくないでしょう。

この記事で紹介する手法では、Dockerベースのコンテナ技術を使うことで、メンバー毎の環境差分をできるだけ小さくできます。ハードウェアスペックやネットワークに起因するもの以外は差分が全く存在しなくなると言っても良いでしょう。つまり、開発メンバーのなかで誰かのマシンで起きた問題は、メンバー全員のマシンで再現します。

気になってきましたか?それでは、Dev Containerを使った開発環境構築について説明を始めます。

事前の準備

この記事が前提とする環境について軽く説明します。

まず、 VS Code を事前にインストールしておいてください。

次に、Docker Desktopをインストールして動作する状態にしてください。基本的には単にインストーラを実行すれば動作する状態になります。

そして、VS CodeDev Containers拡張をインストールしておいてください。

最後に、作業用のプロジェクトディレクトリを作成してください。ここでは、devcontainer-python というディレクトリを作成してプロジェクトのルートディレクトリとしています。

最小限のDev Container

まずは、Dev Containerで公式に提供されているPythonの開発環境を導入してみましょう。

プロジェクトのルートディレクトリに、 .devcontainer というディレクトリを作って、その中に devcontainer.json というファイル名で以下の内容を保存します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"]
}
  • name の値は、分かりやすい名前なら何でもいいです。ここでは、devcontainer-python としています。
  • image の値は、mcr.microsoft.com/devcontainers/python:3.12-bookworm としています。これは、公式のイメージ名です。
  • containerEnv の値は、コンテナ内で参照される環境変数です。ここではタイムゾーンAsia/Tokyoになるよう設定しています。時刻に起因する問題の調査は難しいので、ここで明示的に設定しています。
  • runArgs の値として --init を渡すことで、Dockerが /dev/init というシグナルハンドリング用のプロセスを起動してくれます。これによってコンテナを安定的にシャットダウンできるようになります。

Dev Containerの起動

それでは、作ったDev Containerを起動してみましょう。

プロジェクトのルートディレクトリをVS Codeで開いた上で画面左端のアイコンをクリックしてREMOTE EXPLORERを表示します。

reopen the current folder in a container のリンクをクリックするとDev Containerの起動が始まります。

画面右下に、Dev Containerの起動が始まったことを通知するダイアログが数秒間だけ表示されるので、サッとクリックしましょう。

クリックすると起動ログが流れていく様子を観察できます。

起動ログの流れが落ち着いたら、ターミナルを起動してみましょう。ログの右上あたりにある + ボタンです。Ctrlキーと@キーを同時に押しても構いません。

以下のようにターミナルが表示されますね。

コンテナ内ではvscodeというユーザーで、 workspace/devcontainer-python というディレクトリをカレントディレクトリにしています。 ディレクトリパスのdevcontainer-python 部分はプロジェクトのルートディレクトリと同じになっているはずです。

devcontainer.jsonを編集する環境の構築

ここから、devcontainer.json を編集しながら開発環境を構築していくので、まずは快適にjsonファイルを編集できるようにしましょう。

devcontainer.jsonには、Dev Containerとして起動したVS Codeを構成するための設定項目がありますので、それらを編集します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode"
      ]
    }
  }
}

customizations というキーがDev Containerの構成を行うための設定項目です。この中にvscode という項目がありますね。

settingsの中では、VS Codeの設定項目を管理します。

  • editor.renderWhitespace の値として、 all を設定しているのは、ファイルの中に紛れ込んだ全角スペースを見つけやすくするためです。私たちがIMEを使っている以上、意図しない場所に全角スペースが入り込んでしまい、それによって理解が困難なエラーメッセージを読むことになるのは避けられません。全角スペースが見えていれば、そういったドハマりから抜け出しやすくなります。
  • [json][jsonc] の値として、いくつか設定しています。ちなみに、jsoncは、JSON with commentsの略称です。
    • editor.defaultFormatter の値として、esbenp.prettier-vscode を設定しています。これによってprettierを使ったフォーマットが行われます。
    • editor.formatOnSave の値として、trueを設定することでファイル保存時にフォーマットが行われるようにしています。
    • editor.codeActionsOnSave の値として、source.fixAll を有効化することで自動的に補正できるフォーマットエラーをprettierが積極的に補正してくれます。

extensionsの中では、Dev Containerとして起動されたVS CodeにインストールされるVS Code拡張を列挙します。ここでは、JSONを自動フォーマットするための esbenp.prettier-vscode を設定しています。

最小限のPythonVS Code拡張

次は、Python用のVS Code拡張をいくつか追加していきましょう。

おすすめの拡張は、

の三つです。他にも便利なものは多くありますが、特に議論の余地なく導入できるものはこれらです。

devcontainer.jsonのextensionsにこれらの拡張を追加すると、以下のようになります。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent"
      ]
    }
  }
}

devcontainer.jsonを修正したら、忘れずにDev Containerをリビルドしましょう。

リビルドするには、REMOTE EXPLORERを表示して動作しているコンテナを右クリックします。
ここで表示されるコンテキストメニューから Rebuild Container を選択します。

これで、VS CodePython用のエディタとして使うための準備は整ったと言えます。

しかしながら、アプリケーションの開発環境と呼ぶには、まだまだ不足がありますので順次整えていきましょう。

Poetryの導入

Pythonでアプリケーション開発を行うなら、標準ライブラリを使うだけでなく巨大なコミュニティ内で提供されるモジュールを利用して、その恩恵にあずかりましょう。

pipコマンドだけでOSSのモジュールをインストールするというストロングスタイルも良いものですが、私が推奨するのはPoetryを使ったモジュール管理です。ここでは、Poetry自体の是非については特に議論しません。

Dev Containerには、featuresという機能がありdevcontainer.jsonを少し書き加えるだけでPoetryを使えます。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent"
      ]
    }
  }
}

featuresの値として、 "ghcr.io/devcontainers-contrib/features/poetry:2": {} が記述されていますね。

こうすることで、Dev Container起動時にPoetryがコンテナ内のゲストOSにグローバルインストールされるのです。

devcontainer.jsonを書き換えたらDev Containerをリビルドしましょう。

Python仮想環境の構築

Dev Containerのリビルドから戻ってきたら、Poetryが使うプロジェクト構成ファイルを作りましょう。

プロジェクト構成ファイルの作成

プロジェクトのルートディレクトリに、pyproject.toml というファイル名で以下の内容を保存します。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

このファイルを作る方法は色々ありますので、詳細を知りたい方はPoetryのドキュメントを参考にしてください。

コンテナ作成時に動作するシェルスクリプトの追加

次は、コンテナをビルドした直後だけ動作するシェルスクリプトを追加します。

このシェルスクリプトを工夫するとコンテナ内で色んな作業をして、混乱状況になってもリビルドするだけで全てを元に戻せます。

まずは、簡単なシェルスクリプトから始めましょう。.devcontainerディレクトリにpostCreateCommand.shというファイル名で以下の内容を保存します。このファイルの改行コードはLFにてください。

#!/bin/sh
# postCreateCommand.sh

echo "START Install"

sudo chown -R vscode:vscode .

echo "FINISH Install"

内容としては、プロジェクトのルートディレクトリ以下にあるファイルやディレクトリのオーナー権限を全てvscodeユーザーおよびvscodeグループにするというものです。

以下のコマンドをターミナルで実行して実行権限を付与して下さい。

chmod +x .devcontainer/postCreateCommand.sh

Dev Containerでは、ホストOS上にあるプロジェクトのルートディレクトリを自動的にbindマウントしています。つまり、ゲストOSであるコンテナから見える既存のファイルやディレクトリの権限がrootになってしまいます。Dev Container内で使うユーザーをrootにしても良いのですが、個人的には例えコンテナ内であったとしても普段使いするユーザーとしてrootを使いたくはありません。このような習慣を持つことで例えば、何か悪意のあるモジュールを誤ってインストールしてしまった時の被害を少しだけ低減できます。

便利な事にDev Containerが公式に提供しているコンテナイメージでは、vscodeユーザーをsudoersとして登録しています。参考:common-debian.sh#L209-L213

このシェルスクリプトを動かすには、devcontainer.jsonにpostCreateCommandというキーでシェルスクリプトワークスペースからの相対パスを設定します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent"
      ]
    }
  }
}

devcontainer.jsonを書き換えたのでDev Containerをリビルドしましょう。

コンテナ作成時に、postCreateCommand.shが動作するのを確認できます。

これは作成時しか動作しないので、例えば単にVS Codeを再起動しても動作しません。つまり少々コストの高い処理を記述しても待つのは一度だけになります。

Pythonの仮想環境用ボリュームのマウント

次は、Poetryが作るPythonの仮想環境について考えてみましょう。
要はプロジェクトのルートディレクトリ以下に、作成される .venvディレクトリをどう扱うかということです。

取りうる選択肢として最初の候補は特に気にせず .gitignore ファイルへ.venv ディレクトリを追加する、というやり方です。プロジェクトのルートディレクトリは、Dev Containerによって既にbindマウントされているのだから、そのままにしておいてもそれほど大きな問題はありません。とはいえ、ホストOSでは実行不能な実行バイナリが直接ファイルシステム上に現れるのは、あまり気持ちよくはありません。

次の選択肢は、.venv ディレクトリを特別扱いしてvolumeマウントすることです。これによって、ホストOS上にPythonの仮想環境が直接現れなくなります。加えて、volumeマウントはbindマウントするよりもI/Oのパフォーマンスが少しだけ改善します。また、gitなどの構成管理ツールから明示的に除外しなくていいのも利点です。

volumeマウントをDev Containerに追加するには、devcontainer.jsonにmountsという項目の設定を追加します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent"
      ]
    }
  }
}

devcontainer.jsonでは、設定値の中に ${ で始まり } で終わる部分があると、その中を変数として特別扱いします。

mountsの値だけを取り出してきたのがこれです。

"source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"

ここでは、devcontainerIdcontainerWorkspaceFolder という変数が展開されてDockerの起動オプションに渡されます。それぞれにどんな値が入っているのかは、公式のマニュアルを確認してください。https://containers.dev/implementors/json_reference/#variables-in-devcontainerjson

プロジェクトローカルに仮想環境を作る

次は、いよいよPython仮想環境の構築です。と言ってもPoetryを使っているので極めて簡単です。

先ほど作った、postCreateCommand.sh にPoetry用のコマンドを2行を追加するだけです。

#!/bin/sh
# postCreateCommand.sh

echo "START Install"

sudo chown -R vscode:vscode .

poetry config virtualenvs.in-project true
poetry install

echo "FINISH Install"

グローバルにインストールされているPoetryの設定を変えるコマンドは poetry config です。ここでは、 virtualenvs.in-project を有効化しています。

その後、 poetry install コマンドを実行することで仮想環境がワークスペース直下の .venv ディレクトリに作成されます。

あわせて、ワークスペースのルートディレクトリにREADME.mdというファイルを作ります。中身は何でもいいのですが、一旦以下のようにすると良いでしょう。

# devcontainer-python

動作を確認するために、シェルスクリプトの変更が終わったらDev Containerをリビルドしましょう。

さぁ、Pythonを動かそう

ここからは、いよいよPythonのコードを動かします。

ワークスペースのルートディレクトリに mymoduleというディレクトリを作って、その中にmain.pyというファイル名で以下の内容を保存します。

#!/usr/bin/env python

if __name__ == "__main__":
    print("Hello, World")

特別さのないコードですね。このファイルを開いた状態でVS Codeの右下あたりに注目してください。

このような表示になっているなら、Pythonの仮想環境に配置されているインタープリターが使われています。

そうでない場合は赤く囲った内側の青い部分をクリックしてください。そうすると、インタープリターを選択するダイアログが表示されるので、.venv/bin/python というパスのインタープリターをクリックすることで選択してください。

プロジェクト直下の.venvディレクトリがVS Codeに正しく認識された状態になると、ターミナルを起動した際、自動的にactivateスクリプトを実行してくれます。

これでPoetryで管理しているモジュールや実行バイナリが実行されるようになりました。

この状態を維持するために、devcontainer.jsonpython.defaultInterpreterPathという設定に".venv/bin/python"を追加します。

設定追加後のdevcontainer.jsonは以下のようになります。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "python.defaultInterpreterPath": ".venv/bin/python",
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent"
      ]
    }
  }
}

devcontainer.jsonを書き換えたらDev Containerをリビルドしましょう。

フォーマッタとLinterの導入

コードを実行できるようになったので、次はフォーマッタとLinterを導入しましょう。ターミナルをVS Codeから起動して以下のコマンドを実行します。

poetry add --group dev ruff

これによって、Python用のコードフォーマッタかつ、LinterであるRuffがインストールされます。

これらをVS Codeと連携するための設定と拡張を追加しましょう。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "python.defaultInterpreterPath": ".venv/bin/python",
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true,
            "source.organizeImports": true
          }
        },
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent",
        "charliermarsh.ruff"
      ]
    }
  }
}

ここでは、 以下のような変更を加えています。

  • [python] に設定を記述することでファイル保存時にフォーマットやLint、それに伴う自動補正が行われるようにしました。
  • charliermarsh.ruffVS Code拡張として追加しました。

RuffはBlackとの互換性のためにコードを折り返す際の基準とする文字数が 88 と異様に小さいのでここだけは設定を変更します。
Ruffの設定は、pyproject.tomlに記載します。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

devcontainer.jsonを書き換えたのでDev Containerをリビルドですよね。

pytestの導入

コードを快適に書けるようになってきたので、次はテスティングフレームワークの導入です。

Pythonには多くのテスティングフレームワークがありますが、私が一番気に入っているのはpytestです。

Poetryを使ってpytestを導入しましょう。以下のコマンドを実行します。

poetry add --group dev pytest

モジュールを追加したらVS Codeの設定も変更します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "python.defaultInterpreterPath": ".venv/bin/python",
        "python.testing.pytestArgs": ["tests", "--capture=tee-sys", "-vv"],
        "python.testing.pytestEnabled": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true,
            "source.organizeImports": true
          }
        },
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent",
        "charliermarsh.ruff"
      ]
    }
  }
}

ここで追加したのは、以下の二つです。

  • python.testing.pytestEnabled を trueにすることでVS Codeがpytestを使ってテストコードを検索します。
  • python.testing.pytestArgs にはpytest起動時のオプションを三つ設定しています。
    • 最初の tests はこのディレクトリ内にあるテストコードを実行するという意味です。
    • 次の --capture=tee-sys は、テストコード内で標準出力された内容をpytestがキャプチャしてターミナルに出力してくれます。
    • 最後の -vv を付けることで、pytestがキャプチャした出力を途中で切らずに全て出力します。

テストコードの追加

テストコードを追加して動かしてみましょう。

ワークスペースのルートディレクトリに tests というディレクトリを作って、その中にtest_sample.pyというファイル名で以下の内容を保存します。

# content of test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5

与えられた数字に1を加算する関数と、それをテストする関数です。

テストをターミナルから動かす

まずは、テストをターミナルで動かしてみましょう。以下のコマンドを実行します。

pytest

案の定アサーションが正しくないのでテストは失敗します。

テストをGUIから動かす

次は、VS Codeからテストを動かしてみましょう。

左側のメニューからフラスコのようなアイコンをクリックしてTESTINGビューを表示した上で右向きの三角をクリックするとテストを実行できます。もしくは、単にテスト関数の近くにある右向き三角でも構いません。

予想通りテストは失敗します。

テストをデバッガから動かす

次はテストをデバッグ実行しましょう。コードを見てもどうしてテストが失敗するのか分からない時は、デバッガを使うと便利です。

まずは、ブレークポイントを設定します。エディタのガッター部分をクリックすると赤い〇が付いて、そこがブレークポイントになります。

TESTINGビューの虫アイコンがついたボタンを押して実行するとデバッガが動作します。

設定されたブレークポイントで止まると変数の中身やスタックトレースが確認できますね。

これで快適にテストが実行できるようになったので、ソフトウェア開発環境としては十分だと言えるかもしれません。

高品質なソフトウェアを作るために、もうひと踏ん張りしてみましょう。

テストカバレッジを計測する

テストカバレッジを取ることで、テストコードが意図したとおりにアプリケーションコードをテストできているか確認できるようにしましょう。

テストカバレッジは100%を目指してホワイトボックステストするのに使うと極めて不毛な時間を過ごすことになります。
しかし、大体75%~85%を目途に意図したとおりにアプリケーションコードが動作しているかを確認するのに使うと非常に便利です。自分は仕様を完全に把握しているのだと思っても抜け漏れは少なからずあるものです。

pytest-covの導入

pytestには、Coverage.pyを使ってテストカバレッジをとれるプラグインがあるので、それを導入しましょう。以下のコマンドを実行します。

poetry add --group dev pytest-cov

これでpytestを実行する際にカバレッジを取得するオプションが使えます。例えば、以下のコマンドでカバレッジを取得できます。

pytest --cov=. --cov-report html

このコマンドを実行後はプロジェクトのルートディレクトリにhtmlcov というディレクトリが作成されて、その中にhtmlで作成されたカバレッジレポートが格納されています。

HTMLでそれなりにきれいな表示がされていて、ソフトウェア開発が主たる業務でない人にとっては十分なレポートだと言えます。

テストカバレッジの微調整

テストカバレッジを取り始めると、細かい調整が必要になっていきます。

私自身、それほどPythonに詳しいわけではありませんが、実案件での利用を通じて発生した微調整をいくつかご紹介します。

実行時オプションの調整

pyproject.tomlに [tool.coverage.run] という項目を追加します。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

[tool.coverage.run]
branch = true
source = ["mymodule", "tests"]
omit = ["tests/fixtures/*"]
data_file = ".pytest_cache/.coverage"

設定項目は四つです。

  • branch を有効化することでブランチカバレッジを取得するようにしています。これによってカバレッジの計測コストが若干上がります。
  • sourceカバレッジの計測対象となるファイルが格納されているディレクトリを指定しています。
  • omit では、逆にカバレッジの計測対象としないファイルが格納されているディレクトリを指定しています。
  • data_file では、カバレッジ計測データのバイナリファイルを格納するディレクトリをプロジェクトのルートディレクトリから.pytest_cacheディレクトリ内に移動することで、普段はその存在を気にしないで済むようにしています。

カバレッジ除外の調整

ここでは、pyproject.tomlに [tool.coverage.report] という項目を追加して細粒度の構文レベルでカバレッジ取得対象からソースコードを除外しています。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

[tool.coverage.run]
branch = true
source = ["mymodule", "tests"]
omit = ["tests/fixtures/*"]
data_file = ".pytest_cache/.coverage"

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "def __str__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
    "if typing.TYPE_CHECKING:",
]

それぞれの詳細については、ここでは説明しません。

VS Codeカバレッジレポートを統合する

コードを書いているプログラマーとしては、カバレッジレポートは普段使っているエディタ上に表示されてほしいものです。レポートを見るためだけにウィンドウを切り替えるのはわずらわしいですからね。

というわけで、拡張の導入と設定です。コードカバレッジを表示するVS Code拡張はいくつかあるのですが、私が試した範囲内では、Coverage Guttersが最も期待通りに動作しました。

Coverage Guttersは、Coverage.pyが出力したXMLのレポートを入力情報として使いますので、pyproject.tomlにレポートの出力先を設定します。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

[tool.coverage.run]
branch = true
source = ["mymodule", "tests"]
omit = ["tests/fixtures/*"]
data_file = ".pytest_cache/.coverage"

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "def __str__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
    "if typing.TYPE_CHECKING:",
]

[tool.coverage.xml]
output = ".pytest_cache/coverage.xml"

最後の二行が追加した項目です。これによって、出力レポートのタイプとしてXMLを指定した際に、.pytest_cache/coverage.xmlへファイルが出力されます。

次は、devcontainer.jsonを修正します。

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "python.defaultInterpreterPath": ".venv/bin/python",
        "python.testing.pytestArgs": ["tests", "--capture=tee-sys", "-vv"],
        "python.testing.pytestEnabled": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true,
            "source.organizeImports": true
          }
        },
        "coverage-gutters.showLineCoverage": true,
        "coverage-gutters.showRulerCoverage": true,
        "coverage-gutters.coverageFileNames": [".pytest_cache/coverage.xml"],
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent",
        "charliermarsh.ruff",
        "ryanluker.vscode-coverage-gutters"
      ]
    }
  }
}

ここでは、三つの設定項目を追加すると共にインストールする拡張として ryanluker.vscode-coverage-gutters を追加しています。

  • coverage-gutters.showLineCoverage を有効化することで、行全体に色が付くようになります。
  • coverage-gutters.showRulerCoverage を有効化することで、行の左側にある目盛り部分に色が付くようになります。
  • coverage-gutters.coverageFileNames に設定しているパスと、pyproject.tomlが一致していることで、カバレッジレポートの結果をryanluker.vscode-coverage-gutters が正しく処理できます。

devcontainer.jsonを書き換えたのでDev Containerをリビルドしましょう。

VS Codeにおけるカバレッジレポートの確認方法

まずは、以下のコマンドを実行してカバレッジレポートを作成します。

pytest --cov=. --cov-report xml

相変わらずテストは失敗していますね。しかし、 .pytest_cache/coverage.xml というファイルは出力されているはずです。これを使ってカバレッジを確認していきましょう。

VS Codeの左下あたりに注目してください。 〇Watch という表示があるはずです。これをクリックするとカバレッジレポートの表示が有効化されます。

カバレッジレポートがエディタ上に表示されるとこのようになります。

これで、カバレッジレポートを確認しながらコードを書けるようになりました。

そろそろこの記事も終盤に差し掛かってきています。もう少しだけお付き合いください。

スクランナーの導入

最後は、プロジェクト構成ファイルであるpyproject.tomlに定型化された作業をタスクとして記述する方法を説明します。

長いコマンドでも構成ファイル内に書いてあればコマンドの実行ミスは無くなります。また、GitHub ActionsのようなCIワークフローを構成する際にも、すでにタスクが定義されていれば非常に少ない工数で実現できます。

様々なタスク定義ツールがありますが、私が気に入って使っているのは、Poe the Poetです。以下のコマンドを実行してインストールしましょう。

poetry add --group dev poethepoet

インストールが終わったら、pyproject.tomlにここまで構成してきたタスクをいくつか書いてみましょう。

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"
poethepoet = "^0.24.2"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

[tool.coverage.run]
branch = true
source = ["mymodule", "tests"]
omit = ["tests/fixtures/*"]
data_file = ".pytest_cache/.coverage"

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "def __str__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
    "if typing.TYPE_CHECKING:",
]

[tool.coverage.xml]
output = ".pytest_cache/coverage.xml"

[tool.poe.tasks]
lint = "ruff check mymodule"
test = "pytest"
cover = "pytest --cov=mymodule --cov-report xml"
fmt = "black mymodule --check"
build = ["fmt", "lint", "test"]

[tool.poe.tasks] 以下に書かれているものが実行可能なタスクです。ここでは、五つのタスクを定義しています。

  • lint タスクでは、 Ruffによるコードの検査を実行します。
  • testタスクでは、pytestを実行します。
  • coverタスクでは、コードカバレッジを取得しながらpytestを実行します。
  • fmtタスクでは、Ruffによるコードの整形を実行します。
  • buildタスクでは、 fmtタスクを実行した後にlintタスクを実行し最後にtestタスクを実行します。

これらのタスクは、poe コマンドから実行できます。例えば、lintタスクを実行する際には、以下のようなコマンドを実行します。

poe lint

非常に簡単ですね。testタスクのように内部的にはコマンドを引数なしで実行しているだけでもタスクとして定義しているのは、ツールの移行コストを下げるためです。

例えば、今回はlinterとしてRuffを使っていますが、使い込む過程で回避しようのない不具合が見つかりFlake8のような実績のあるツールに切り戻すことはありえます。そういった時にCIやCDのワークフローに対する影響をできるかぎり減らして移行できるようにするには、タスクランナーによるコマンドの抽象化が有効です。

まとめ

ここまで、Dev ContainerでPythonアプリケーションの開発環境を構築する方法について説明してきました。

プロジェクトメンバーができるかぎり同じ環境でアプリケーション開発を行うことは、浪費される工数を減らしプロジェクトとして価値のある作業に集中するために必要な事です。

この記事ではPython開発環境を扱いましたが、同様の考え方でTypeScriptや、Rust、RubyJavaといった他の言語の開発環境を構築できます。

また、GitHub Codespacesのようなクラウド開発環境の利用も難なくできます。
作っているアプリケーションの種類や、プロジェクトチーム内での役割次第ではブラウザ一つ動く環境さえあればソフトウェア開発できるというのは、非常に魅力的ですよね。

ここまで読んでいただいた皆さん、本当にお疲れさまでした。この記事を読んだ皆さんが、利便性の高い開発環境で本来のソフトウェア開発に集中できることを願って記事の結びとします。

この記事で紹介している開発環境の構成ファイル

最後に作った構成ファイルをまとめて紹介します。

.devcontainer/devcontainer.json

{
  "name": "devcontainer-python",
  "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
  "containerEnv": {
    "TZ": "Asia/Tokyo"
  },
  "runArgs": ["--init"],
  "features": {
    "ghcr.io/devcontainers-contrib/features/poetry:2": {}
  },
  "postCreateCommand": "./.devcontainer/postCreateCommand.sh",
  "mounts": [
    "source=venv-${devcontainerId},target=${containerWorkspaceFolder}/.venv,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "editor.renderWhitespace": "all",
        "python.defaultInterpreterPath": ".venv/bin/python",
        "python.testing.pytestArgs": ["tests", "--capture=tee-sys", "-vv"],
        "python.testing.pytestEnabled": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true,
            "source.organizeImports": true
          }
        },
        "coverage-gutters.showLineCoverage": true,
        "coverage-gutters.showRulerCoverage": true,
        "coverage-gutters.coverageFileNames": [".pytest_cache/coverage.xml"],
        "[json][jsonc]": {
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll": true
          }
        }
      },
      "extensions": [
        "esbenp.prettier-vscode",
        "ms-python.python",
        "njpwerner.autodocstring",
        "KevinRose.vsc-python-indent",
        "charliermarsh.ruff",
        "ryanluker.vscode-coverage-gutters"
      ]
    }
  }
}

.devcontainer/postCreateCommand.sh

改行コードはLFです。実行権限の付与を忘れずに。

#!/bin/sh
# postCreateCommand.sh

echo "START Install"

sudo chown -R vscode:vscode .

poetry config virtualenvs.in-project true
poetry install

echo "FINISH Install"

.gitignore

*.pyc
*_cache

pyproject.toml

[tool.poetry]
name = "mymodule"
version = "0.1.0"
description = ""
authors = ["sato taichi <sato.taichi@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
ruff = "^0.1.4"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"
poethepoet = "^0.24.2"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 200

[tool.coverage.run]
branch = true
source = ["mymodule", "tests"]
omit = ["tests/fixtures/*"]
data_file = ".pytest_cache/.coverage"

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "def __str__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
    "if typing.TYPE_CHECKING:",
]

[tool.coverage.xml]
output = ".pytest_cache/coverage.xml"

[tool.poe.tasks]
lint = "ruff check mymodule"
test = "pytest"
cover = "pytest --cov=mymodule --cov-report xml"
fmt = "ruff format ."
build = ["fmt", "lint", "test"]

執筆:@sato.taichi、レビュー:@mizuno.kazuhiro
Shodoで執筆されました