Software Design連載 2021年9月号 「テストが無い」からの脱却

こんにちは、鈴木です。

「テストが無い」状態を脱却しました。

「いつの時代かよ!」と突っ込まれるかもしれませんが、モノタロウは創業から 20 年ほど EC をやっています。昨日書いたコードも、15 年前に書いたコードも、元気にビジネスを支えています。

本記事ではモノタロウの EC を支える API の話をします。「テストが無い」状態がスタートラインでした。そこから、CI を導入して、ローカル開発環境の整備して、テストコードを書いて、リリースマネジメントを導入しました。

目新しいことは書きません。長寿の大規模システムであっても、愚直に数年取り組むことで、「前進できる!」「変えられる!」という実例を書きます。

※本記事の初出は、 Software Design2021年9月号「Pythonモダン化計画(第2回)」になります。第1回の記事は「Software Design連載 2021年8月号 Python製のレガシー&大規模システムをどうリファクタリングするか」です。

はじめに

モノタロウでは、取り扱い商品の検索、商品の到着日の算出、在庫状況の取得、バスケットの管理などなど、ECサイトのさまざまな機能を「モノタロウAPI」という名のAPIとして整備しています。

今回は、このモノタロウAPIの開発において「テストがない状況からテストを支える環境をどう改善してきたか」をご紹介します。ビジネスもシステムも成長する中での開発には、限られた時間の中で顧客に十分な品質の価値を提供するというトレードオフに迫られます。そのような「かなり頑張らないと技術がビジネスの足を引っ張る」緊張感のある現場で活躍する方に、テストの書き方でなく、テストを支える環境の重要性を読み取ってもらえればと思います。

モノタロウAPIの規模感

モノタロウAPIの開発は、初コミットがあった2012年ごろに遡ります。 それから現在まで、ECサイトの機能拡張や業務ロジックの改修など、さまざまなタイミングで徐々に規模を拡大してきました。

いまでは150程度のエンドポイントが、フロントエンドのJavaScriptやバックエンドのサーバはもちろん、基幹システムや海外サイト、商品データを利用する社内ユーザから利用されています。 ビジネスの拡大に伴ってリクエスト数もどんどん増加しており、直近2年で約1.6倍、月間54億回を優に超えるまでになりました。

モノタロウAPIはすべてPythonで実装しており、ソースコードは20万行を超えています。 それ以外に、APIのデータソースとなる商品情報や価格データを作成するバッチ群が存在します。 これらを10名程度のエンジニアで開発、運用しています。

モノタロウ API 周辺のシステム構成
モノタロウ API 周辺のシステム構成

CI(Continuous Integration)の導入

2012年から継続して開発しているモノタロウAPIですが、当初はCIがなく、その導入は2018年1月のことでした。 きっかけになったのは、検索APIの構造整理の際のうっかりミスが原因で、リリース後のエラーによるロールバックが複数回発生したことです。 最終的には人力で頑張ってリリースに成功したのですが、人が時間を費やして頑張る部分ではないのでCI導入に踏み切ることになりました。

とはいえ、この時点では単体テストが十分に整備されていなかったこともあり、CIで実行していたのは未定義変数の検出のみです(flake8を利用した静的解析)。 単体テストに対してCIを導入したのは、その後の2018年8月、それまでPython2.7で動いていたアプリをPython3に移行する際でした*1。 当初は最低限の互換性担保のためにPython2.7と3の両方でバイトコンパイル(python -m compileall)の成否をチェックするだけでしたが、最終的には単体テストもCIで動かすようになりました。

JenkinsとDockerを活用

ここまでざっくりとモノタロウAPIにおけるCIの進化をまとめましたが、ここで少しツールについて触れておきます。

モノタロウAPIではCIにJenkinsを利用し、Dockerコンテナの起動と依存パッケージのインストール、そしてテストまでを実行するという形をとっています。 Dockerベースなので、Pythonの複数バージョンでCIを実行しても各ビルド間で干渉することがないのが利点です。

また、Jenkinsにはジョブ定義をスクリプトによって記述できるという特長があります(Jenkinsfile)。 その際の記述方式としては、Declarative PipelineとScripted Pipelineの2種類があり、導入時は簡易に記述できるDeclarative Pipelineを使っていましたが、現在はより柔軟な記述ができるScripted Pipelineに移行しています。 これにより、将来のバージョンアップでの苦労を減らすために、現行のアプリケーションが使っているものより新しいバージョンのPythonでもCIを回すことが実現できています。 Jenkinsfileによるジョブ定義には、ビルド手順をgitで管理できるという利点もあります。

Jenkins と Docker による CI 環境
Jenkins と Docker による CI 環境

CI導入の効果

いまや開発工程にCIが存在するのは当たり前という感もありますが、モノタロウAPIでもCIを導入したことには大きな価値がありました。 開発業務の中で自動化できる部分をCIにさせて楽ができれば、より集中すべき場所に人が力を向けられるようになります。

さらにモノタロウAPIでは、次節で紹介する「AST比較テスト」もCIに組み込んでいます。 CIでこのテストに通っていることを確認できれば、プルリクエストをそのまま承認できるので、レビュアーの負担を減らすことにも一役買っています。

AST比較テスト

20年ほどの歴史を持つモノタロウのシステムには、テストが十分に整備できていた部分もあれば、ほとんどテストがなかった部分もあります。 十分なテストコードが存在しない部分については、コーディング規約に合わせたフォーマッタの適用や、デッドコードや不要なコメントの一括削除など、ちょっとしたコードの改善のはずなのに安心して実施できない場合がありました。

この問題の解消には、「テストが不要な基準」を技術的にしっかりと定め、事実に基づいた判断ができる必要があります。 モノタロウでは、議論の結果、「抽象構文木(AST)が変わらない変更はテスト不要」という基準に落ち着きました。

PythonにはASTを扱うためのastという標準ライブラリがあります。 このastをラップして使いやすいインタフェースを提供するastorというサードパーティライブラリを利用し、ソースファイルごとにASTのハッシュ値を出力する小さなツールを作成しました。

# ast_analyzer.py
import sys
import astor
import hashlib

for file in sys.argv[1:]:
    ast = astor.parse_file(file)
    dumped_ast = astor.dump_tree(ast)
    hashed_ast = hashlib.md5(dumped_ast).hexdigest()
    print("{} {}".format(file, hashed_ast))

このツール(ast_analyzer.py)を使うことで、次のように「ソースファイルのASTがコードの修正によって変化していないか」を検証できます。

$ python ast_analyzer.py a.py b.py
a.py 3f120028bd271b98ded2e65fca91fc44
b.py 47d0920d0fb39a688eb2acb06f1e4461

現在では、コミットメッセージに「[check ast]」とあったらAST比較テストを実施するようにCIに仕込んでおり、ブラウザでビルド結果を見ればASTに変化があったかどうかを確認できるようにしています。

ローカル開発環境の改善

ここでは、テストそのものから視点を少しずらし、「開発者による動作確認をしやすくする」という側面についてモノタロウAPIにおける取り組みを紹介します。

実を言うと、モノタロウAPIには長らく開発者が手元の開発用の端末(Windows)で動作確認する手段がありませんでした。 成果物をバージョン管理システムにプッシュし、共用のLinuxサーバの自分用のVirtual Hostにデプロイすることで動作確認をしていたのです。 Pythonなのでコンパイルの過程がないとはいえ、これでは修正ごとに数分は待ち時間が発生します。 試行錯誤しながらプログラムを書いていくうえでは大きなボトルネックになっていました。

手元でアプリケーションの挙動を試せず、共用サーバでの動作確認のみという状況は、「単体テストが書かれにくい」という構造的な問題にもつながっていました。 共用サーバ上では、どうしてもエンドツーエンドの動作テストが中心になってしまうからです。

現在では、Windows上にLinuxの仮想マシンを立ててアプリケーションが動く環境を構築することで、開発者が手元の端末で動作確認できるようにしています。 ちなみに、Dockerを利用したコンテナ化も検討しましたが、モノタロウAPIの本番環境はコンテナ化されていないので追加の設計が必要になります。 構築に時間をかけるよりも、使える環境をまず用意することを優先するという判断から、コンテナではなくVirtualBox上の仮想マシンを選択しました。

AnsibleとVagrantで仮想環境を提供

メンバーに今までのやり方を変えてもらう必要があるので、利用のハードルを下げることを重視し(手順が面倒だとそもそも使ってもらえない可能性があります)、AnsibleとVagrantを利用して誰でも簡単に同じ仮想環境を利用できるようにしました。 以下のようなVagrantfileを用意し、vagrant upというたった1つのコマンドを実行するだけでモノタロウAPIに必要なOS、ミドルウェア、アプリケーションの構成がすべて完了するようになっています。

Vagrant.configure("2") do |config|
  config.vm.box="ubuntu/bionic64"

  # configure vagrant-proxyconf plugin.
  config.proxy.http     = "http://proxy:3128"
  config.proxy.https    = "http://proxy:3128"
  config.proxy.no_proxy = "localhost,127.0.0.1"

  config.vm.define "monotaroapi", primary: true do |machine|
    machine.vm.hostname = "monotaroapi"
    machine.vm.synced_folder ".", "/vagrant"
    machine.vm.network "forwarded_port", guest: 80, host: 8080, auto_correct: true
    machine.vm.network "forwarded_port", guest: 22, host: 12222, id: "ssh", auto_correct: true

    machine.vm.provision :ansible_local do |ansible|
      ansible.playbook       = "playbook/playbook.yml"
    end
  end
end

さらに、開発者が目的に応じてプリセットの仮想環境以外にも自由に仮想環境を作成、破棄できるように、venvとvirtualenvwrapperを併用しています。 virtualenvwrapperを利用することで、以下のような簡単なコマンドで仮想環境の作成と切り替えが可能になります。

仮想環境の作成:

$ mkvirtualenv {環境名}

仮想環境の切り替え:

$ workon {環境名}

venvとvirtualenvwrapperの設定も、以下のようなプレイブックを用意することでAnsibleに任せられます。

# venv, virtualenvwrapperのインストール
- name: install python3-venv
 apt:
   name: python3-venv

- name: install packages for virtualenv
 pip:
   name: "virtualenvwrapper"
# virtualenvwrapperの初期設定
- name: set up virtualenvwrapper
 become: yes
 become_user: vagrant
 blockinfile:
   path: "/home/vagrant/.bash_profile"
   create: no
   marker: "# {mark} ANSIBLE MANAGED BLOCK virtualenv settings"
   block: |
     if [ -f /usr/local/bin/virtualenvwrapper.sh ]; then
       export WORKON_HOME=/opt/virtualenvs
       source /usr/local/bin/virtualenvwrapper.sh
     fi

# Python仮想環境の構築とパッケージインストール
- name: install python packages
 pip:
   requirements: "{{ item }}"
   virtualenv: /opt/virtualenvs/py3  ; workon py3 で利用できる
   virtualenv_command: pyvenv
   virtualenv_python: python3
 with_fileglob:
   - /sourcedir/requirements.txt
 become: yes
 become_user: vagrant

メンバーに使ってもらう

せっかく用意したローカルでの動作確認ができる仮想環境も、チームのメンバーに使ってもらわなければ意味がありません。 ちょうどチームメンバーが数人かかわるプロジェクトが立ち上がったところだったので、そこでの開発から利用を始めてもらうことにしました。

まずは、この仮想環境上で単体テストを実行する手順を整理し、それをチームメンバー内で共有するところから始めました。

実際に使ってもらった結果、それまでの冗長な開発サイクルに比べると、「少し修正してノータイムでテストを実行できる」環境の便利さを実感してもらうことができました。 折よく前述したCIによる単体テストの自動化が導入されつつあったこと、後述するリリースマネージャの導入でテストを書く機運が盛り上がっていたことも後押しになりました。 このプロジェクトは実に順調に進み、コードの品質も開発速度も向上するという成果が得られました。

なにより、テストを書くとローカルですぐ実行できる環境が整備されたことで、単体テストを書くことへのハードルがぐっと下がりました。 これにより開発へのモチベーションもあがり、良い循環が生まれました。

課題

便利になって開発者体験は確かに改善しましたが、実用面ではつらい部分も見えてきました。 まず、仮想マシンであることから、起動とプロビジョニングにかかる時間が気になり始めました。 また、アプリケーション起動のために必要なOSやミドルウェアの設定が膨れ上がり、徐々にメンテナンスしづらい状態になっていきました。 IDEとの連携の課題もあります。 Windows上のIDEからローカル開発環境に接続してリモートデバッグやファイル共有を簡単にする方法は整備していますが、仮想マシンへのファイルアップロードやデバッグサーバ設置などの煩雑な作業からは逃れられません。

現在では、ここまで紹介した仮想マシンによるローカル開発環境から、改めてコンテナベースの開発環境への移行を進めているところです。 より迅速に改善を続けるために、弊社サービスを構成するフロントエンドからバックエンドまでを一気通貫に開発、テストできるようにすることが目標です。

リリースマネージャの導入

最後に、テストへの取り組みに関連して組織面から大きなインパクトのあったリリースマネージャの導入についてご紹介します。

そもそもモノタロウAPIの開発では、リリースは開発メンバーが交代で行う作業という位置づけになっていました。 必然的に担当者の負担が重くなるだけでなく、リリースフロー(だれがいつまでに何をするか)やロールバックの判断がその時々の作業担当者任せなのでリリース品質が安定しないという課題がありました。

そこで、まずはリリースマネージャというロールを新設し、リリースフローもしっかりと定義することにしました。 これにより、リリースに起因する残業が減るといった直接的な効果のみならず、テストをめぐる開発者の態度にも変化が起きたのです。

具体的には、リリースマネージャの導入によって開発者の間で「テストコードを書く習慣」がつくようになりました。 リリースマネージャの作業のひとつとして、リリース前に「リリース対象をマージした統合ブランチでのテスト」を行うことが明示されたことにより、開発担当者にテストコードの準備が求められるようになったことが功を奏したと考えています(それ以前にはリリース単位でのテストはしておらず、開発案件ごとのテストのみでステージングリリースして動作確認するという流れがよくありました)。 また、リリース後の動作確認についてもやり方を変えました。以前は各案件担当者が行っていましたが、リリースマネージャーが統括して確認を行うことになりました。この場合もリリース前のテストと同様に、案件担当者はテストコードの準備を求められるようになりました。

テストコードを書く習慣がついたことで、テストコードの量がどんどん増えていき、それらが回帰テストとしての役割を果たすようになるという効能もありました。 回帰テストの存在は、開発環境のテストの品質をさらに向上させることに繋がっています。 もちろん、テストの品質が上がれば、リリース作業のコストダウンもさらに進みます。

テストコードを書くようになったからといって、それにより生産性が落ちることがないという発見もありました(最初は多少学習コストがかかりましたが許容範囲でした)。 むしろ生産性が上がったという意見もあったくらいです。

議論と進化

テストが習慣化したことで、「そもそも本番リリース後の動作確認はどこまでやる必要があるのか?」、「開発環境でしっかりテストできているならレビュー時は簡易的な確認でいいのでは?」、「テストコードがまだ少ない。テストコードがもっと増えればもっと安心できる」といった議論も開発者間で活発化しました。

それらの議論を経て、現在では以下のような方針が開発者の間で共有されています。

  • リリース後の動作確認は、開発環境でできないテストがある場合のみ必要とする(マシンスペック、本番環境の設定に依存する場合など)
  • 必要と判断される場合は、原則としてテストコードを用意し、本番リリース時に走るテストに含める
  • ログファイルの確認が必要ならコードやコマンドにする
  • メモリ使用量の確認など機械的に判断できない要素は判断基準を明確にする

このようにモノタロウAPIの開発においては、リリースマネージャー導入によってリリース作業の質が改善、安定化しただけでなく、リリース前のテストやリリース後の確認のあるべき姿についての議論につながり、さらにその結果としてテストそのものの質の向上につながるという好循環が生まれています。

リリースマネージャ導入前のリリースフロー
リリースマネージャ導入前のリリースフロー

リリースマネージャ導入後のリリースフロー
リリースマネージャ導入後のリリースフロー

おわりに

モノタロウの API では、CI を導入して、ローカル開発環境の整備して、テストコードを書いて、リリースマネジメントを導入しました。数年で劇的に変わりました。「テストが無い」状態を脱却しました。

システムの課題に立ち向かう方への励ましになれば幸いです。

*1:この移行に興味がある方はPyCon 2018での当社の増田による発表をぜひご覧ください。