契約による設計とテスト駆動開発で大きいタスクを楽にする

こんにちは。id:shallow1729です。先日一ヶ月ぐらいの工数のかかる大きめのタスクを担当しました。そのタスクは機能に関連するテーブルやエラーになる条件が多く、初めはどこから手をつけたらいいか分からない状態でした。今回紹介する契約による設計とテスト駆動開発は自分がうまくとっかかりを見つけて手を進める上ですごく役に立った開発手法です。うまく実装を進められない時とかに思い出してもらえたらと思います。

大きいタスクの難しさ

これはエンジニアとしての経験則ですが、規模の大きい機能の開発はたとえそこにアルゴリズム的な困難さを伴わなくても難しいものになるように思います。
もちろん全体のアーキテクチャを考えるには設計手法の理解が必要ですし、関連するツールが増えるとそれらの理解が必要だったりするので技術的な課題に直面しやすいというのも事実だと思いますが、実際に開発をしている中でそういう技術的な課題感が特に無いのに手が動かなくなる場面がいくつかありました。初めは原因がよく分からなかったのですが自己分析してみて以下のような問題があるように感じました。

  1. 考慮する事の多さに対する疲弊
  2. 動作確認がなかなかできなくて不安になる

1つめの問題はタスクが大きくなるとそれだけ関連するものが増えるために起きる問題に思われます。ユーザーのデータの状態や入力フォームによって動作が変わる機能の場合、それらの状態や入力の数だけ条件分岐が発生することになります。僕が先日関わった仕事は6個のテーブルに更新が走る処理で、入力フォームやそれらのテーブルの状態によってはユーザーに適切なエラーメッセージを伝える必要がありました。このような実装では例えアルゴリズム的にはシンプルでも考える事が多いので神経を使って実装する必要があります。

2つめの問題はタスクが大きくなると動作確認ができるまでの期間が長いために起きる問題に思われます。普段僕は手元環境を立ち上げて実際に動作確認をしながら実装を進めているのですが、このようなやり方はある程度すでに存在するシステムの拡張をする時にできる方法で、大きな機能開発の場合はコードを書き始めてから実際に手元環境で動作確認できるようになるまでに数日かかってしまう事もあるので使えないケースがあります。こうなると数日間動作確認をせずにコードを書く事になるので感覚的には数日間仕事が進んでないような気持ちがして気分が沈みますし、実際に動かしてみて初めてうまくいってない事が分かって大きくやり直す可能性も見えて不安になります。

ここで書いた課題はどちらかというと心理的な課題に思うのですが、今回僕はこれらの精神的な負担を下げるために契約による設計を用いたモジュール設計手法とテスト駆動開発を実践してみました。今回の実践の流れとしては最初に契約による設計を使って全体について擬似コードを書いて、そのあとモジュールごとに擬似コードをベースに自動テストを実装しながらプロダクトのコードを書くという流れで行いました。こうなった経緯としては初めは擬似コードがあれば実装が進むかなと思ったら思ったより手が動かなかったのでテスト駆動も取り入れたという感じです。実際は擬似コードを書かずに事前条件や事後条件の洗い出しだけしてさっさとテストを書いていく方がいいかもしれないとも思いました。なので、まだまだ工夫中ですが、参考になれば幸いです。

契約による設計について

契約による設計(Design By Contract)というのはBertrand Meyerが考案した開発手法で、コードの品質、特にモジュールの信頼性を高める事で再利用性や利便性を高める事ができ、かつオブジェクト指向で用いる抽象化と非常に相性の良い開発手法です。契約による設計は名前の通りモジュールの呼び出し元と呼び出し先の間で契約を結ぶようなイメージで説明できます。まず、モジュールが呼び出された時、呼び出されたモジュールはその呼び出しに対して適切に処理を行えるかの確認を行います。このバリデーションは事前条件(precondition)と呼ばれます。契約による設計では事前条件を満たさない入力は受け付けない一方で、事前条件を通過した場合に呼び出し先は予定されている処理を実行し、必ず期待されている条件を満たす実行結果を返すように実装します。この期待されている条件というのは事後条件(postcondition)と呼ばれます。たとえ呼び出し先のモジュールで例外が発生しなくても事後条件を満たさない出力である場合はその出力を返さずに何らかの処理をする必要があります。また、もう一つ不変条件(class invaliant)というのがあって、これは例えば木構造のクラスでノードAの子ノードの親ノードはノードAでないといけない、というような常に成り立つ必要のある条件に関する制約です。これは例えば複数のデータ操作を伴う処理が中途半端な段階で失敗した時にデータ不整合を防ぐチェックと考えるといいかなと思います。

また、今回の本筋ではありませんが契約による設計は特に抽象化を伴う場合にはLiskovの置換原則を成立させるためのいくつかの条件を表現する事ができます。Liskovの置換原則というのは筋の良い抽象化を行うための数学的な表現で、よくSOLID原則の一つとして紹介されると思います。 例えば抽象化を行った場合のモジュールの事前条件と事後条件の規則を説明すると、抽象化を行う場合、子モジュールは親モジュール以上の値域の入力を受け取れるように設計する必要がありますし、親モジュール以下の値域で値を返さなければ置換不可能な設計になってしまいます。この契約による設計を用いた指針は元々の数学的な表現よりも実装に近くて扱いやすいと思いますし、単に置換可能な設計と言われてるよりも実装のイメージが付きやすくてよいなというのが個人的な感想です。

契約による設計を用いた擬似コード

例えばサインアップのフォームの入力を受け取って確認メールを飛ばすメソッドだとこんな感じかなと思います。

method_name
input: // 入力
    user_account: user_account // 変数名と変数の型
    email_address: string
    password: string
    data1: bool
    db: db

output: // 戻り値
    error_code

pre_condition: // 事前条件
    // メールアドレスの形式が使用可能なものか
    email_address_is_valid_format(email)  == true
        error_handle: return 'ERROR_CODE1'

    // パスワードの形式が使用可能なものか
    password_is_valid_format(password) == true
        error_handle: return 'ERROR_CODE2'

    // メールアドレスがすでに使われているか?
    user_account.exists(email_address => email_address) == false
        error_handle: return 'ERROR_CODE3'

post_condition: // 事後条件
    // 一時的な登録データの作成は行われているか
    temporary_data.exists == true
        error_handle: exit 

    // 確認メールは送信されたか
    email.sent == true
        error_handle:
          count = 0
          while (count<5){
            retry
            count++
          }
          exit
        
code: // メインのロジック
    // 一時データを登録して確認メールを送信する
    token = create_temporary_data(user_account, email_address, password, data1, db)
    email = create_confirm_email(token)
    send_email(email_address, email)

擬似コードなので文法についてはご了承ください。ポイントはpre_conditionとpost_conditionでこのメソッドの事前条件と事後条件がどのようなものかと、それらのバリデーションに引っかかった場合にどうハンドリングするかを書いています。エラーを返したりプログラムを終了するだけではなく例えばメール送信や画像アップロードなど外部APIを叩く場合はリトライを試すという処理もここに書きます。

契約による設計の枠組みで考えて設計をしてみると分かるのですが、想定内の入力以外を初めに排除する事で入力の多様性について悩む問題が減るのでかなり見通しがよくなります。また、エラーハンドリングや失敗時の処理というあまり楽しくないけど設計能力が問われる仕組みについてもメインのロジックと分離して考える事ができるので筋道が立ちやすくなり、考慮漏れを事前に防げそうだな感じました。この擬似コードはメインのロジックがシンプルなので気になりませんが、複雑な実装の場合はメインのロジックを書きながらエラーハンドリングも書いていくと思考のノイズになりがちな気がするので、そういう例外をメインのロジックから分離して正常系の複雑な部分に集中できて良かったなと思いました。このように契約による設計を用いる事でメインのロジックとそれ以外の処理を分離して考える事ができるので「考慮する事の多さに対する疲弊」を抑える事ができました。ただ、多くの言語で事前条件や事後条件のバリデーションはサポートされていないので、実装時に工夫する必要があります。事前条件についてはunless文を最初にまとめて書けば良いかなと思いますが、事後条件についてはあまりうまく分離ができず、処理を進める中で適宜バリデーションと例外処理を書くしかないなと思いました。とはいえ事後条件については自明なケースも多いと思うので実際はバリデーションしないとかもあるかなと思います。

テスト駆動開発心理的効用

テスト駆動開発といっても正直僕がやったのはとりあえずテストを先に書いてから実装するというだけの事なので、あまりテクニックの紹介はできません。ですが、それでも先ほど説明した実装時の心理的障壁を解消するには十分な効果があると感じたので紹介します。
まずテストが通る事で仕事が進んでいる事がわかるという安心感があります。しかも契約による設計で例外ケースやエッジケースがはっきりしているのでテストケースはあまり悩む必要がなく、実装もとりあえずバリデーションの実装だけ先に書こうとなるのであっというまに5個ぐらいのテストケースを通るところまで実装できて気分が良いです。また、デグレーションのリスクにかなり強くなるので書いてて気になったら安心してリファクタを行う事ができるのも良かったです。

テスト駆動開発をやる時に心がけるといいなと思ったのは人に見せるテストとはある程度分けて考えるという事です。多くのチームではプルリク作成時にプロダクトのコードと合わせてユニットテストも書く規約にしていると思うのですが、テスト駆動開発を始める時はそういう最終的に納品するコードとは分けて考えた方が良いと思います。例えばあるモジュールに対するテストはだいたいどのテストケースでも同じような初期化が必要だと思います。こういう時に初期化部分を共通化しておこうとか綺麗な設計にしようとかそういう事を初めから意識するとなかなか手が動かなくなる気がします。初めはコピペしながらテストケースをどんどん用意するぐらいのゆるい気持ちで進めて、適宜綺麗にしていくぐらいでいいかなと思います。

終わりに

今回は大きな開発に伴う心理的な負担を和らげる方法という文脈で契約による設計やテスト駆動開発といった開発手法を紹介しました。一方でこれらの手法が筋の良い設計につながるという話題は特にしなかったので興味のある方はいろいろ参考文献を見てみたりしてください。正直開発手法は人間的な側面を無視できないのでこれがベストと言い切れるような指標もなくて議論が難しいなと思うのですが、こういう開発手法を個人レベルで始めるのは自由だと思うので自分なりにいろいろ学んで試してみると開発が楽しくなっていいんじゃないかなと思います。

参考文献

契約による設計については以下の本が参考になるかもです。

https://www.amazon.co.jp/dp/4798111112

自動テストやテスト駆動開発については自分も勉強中ですがこの辺を読み進めています。

https://www.amazon.co.jp/dp/4274217884/

https://www.amazon.co.jp/dp/B07SJYG949/