わみてっく

Blog of a software engineer who likes Kotlin, coffee, beer, baseball, and music!

End-to-endテストで日付のテストをする時に思い出してほしいこと

TL;DR

  • テストコードであっても腐るものは腐る

  • システムの表層にあたる画面の要素にとらわれず、業務のドメインを掘り下げて理解を深めるのが大事。

大体の話は

Twitterに書いておいたので先に読んでもらえると分かりやすいと思います。

対象読者

  • CypressとかSeleniumみたいなエンドツーエンドで画面の自動テストが実行できるフレームワークを使って自動テストを書いてる人

    • と思ったけど画面に限った話ではない気もするのでそれ以外の人も読んでもらえると何か学びがあるかもしれない

画面の自動テストを書く時に起こること

例えば画面内に、特定の日付が表れることをテストしたくなったとします。

その時、あなたはどんな表現でテストを書きますか?

どんなフレームワークでも、実装レベルでは「特定のCSSセレクタに該当するHTML要素の値が"2022年9月1日"になっていることをアサーションする」みたいな実装になると思います。

じゃあこれで終わりでいいのか、って言ったらそんなことはなくて、テストの保守性を高めるにはもう少しやれることがあるんじゃないかなーと思って筆を取りました。

以下サンプルコードはCypressとjavascriptを使って書きますが、言語やFWは本論じゃないので雰囲気で読み流して下さい。

愚直に実装する

「id="date"がついたdivタグの値が"2022/9/1"である」というテストを書きたいなーと思うと、Cypressだと以下のようなコードになります。

describe('テスト', () => {
  it('日付が2022年9月1日である', () => {
    cy.visit('/')

    // 中略

    cy.get('#date')
      .should('contain', '2022/9/1')
  })
})

今回話の対象にしたいのは、以上の例で言う「テスト」及び「日付が2022年9月1日である」の部分です。

これって何の日付なんだっけ?

「日付が2022年9月1日である」というアサーションの文言は、テストのアサーション表現としては必要最低限でしかありません。

まず「何の日付なのか?」という情報が欠けています。例えば受注データが記録された日なのか、荷物の発送予定日なのか、それともただの固定された文字列なのか*1、アプリケーションによってコンテキストは異なるはずです。

この「何の日付なんだっけ?」という問いへの答えをテストにも残しておくことで、後からテストを読んだ開発者がアプリケーションの仕様を理解しやすくなるのではないでしょうか。

以下の例は「受注日付が2022年9月1日であること」をわかりやすくしたコードのサンプルです。(あえてセレクタはそのままにしておきます)

describe('受注管理画面のテスト', () => {
  it('受注日付が2022年9月1日である', () => {
    cy.visit('/')

    // 中略

    cy.get('#date').should('contain', '2022/9/1')
  })
})

この日付っていつ変わるんだっけ?

次に、この日付がいつ変更される可能性があるのかという点も気にする必要があります。固定値であれば気にしなくても良いのですが、例えば月をまたいだり、年をまたいだりしたタイミングでこのセレクタで取得される日付が変わるとしたら?

もしこの自動テストがデプロイメントパイプライン上で定期的に実行されていたら、「コードを何も変更していないのにテストが失敗した!」という事態に陥ってしまうのではないでしょうか。*2

いつ日付が変わるのかに着目することで、より安定した、かつ仕様に忠実な表現に直すことができるのではないでしょうか。

以下の例は「id='date'に表示される日付が、アプリケーションを開いた月の初めの日であること」に読み替えたコードのサンプルです。

describe('受注管理画面のテスト', () => {
  it('表示される受注日付はその月の初めの日である', () => {
    cy.visit('/')

    // 中略
    const today = new Date()
    const firstDateOfMonth = [today.getFullYear(), today.getMonth()+1, 1].join('/')
    cy.get('#date').should('contain', firstDateOfMonth)
  })
})

何故こんな話がしたかったか

一見シンプルに見えるここまでの議論ですが、何故わざわざ取り上げようと思ったか。それはこのプロセスの中に、「システムの表層である画面」から「システムで取り扱うドメイン」への追究が発生していることに気がついたからです。

CypressやSeleniumのような自動テストフレームワークでテストを記述する時、開発者が一番注目するのは画面です。ともすれば、画面で起こっている表面的なことにばかり目が向いて、上で挙げたような愚直な実装に陥ってしまうこともあると思います。愚直な実装はその場では問題が無かったとしても、ドメイン知識を持つ開発者がチームを離れた時に仕様に対する知識が失われたり、突発的に失敗するテストに変貌するリスクを抱えています。

自動テストを書く時に、目線を画面からより深いドメインの部分に向ける、そしてそこで得た知識をコード上に反映させることで、テストコードの保守性をより高められるのではないでしょうか。

......というところで、先人(自分含む)が数年前に残したテストが突如失敗する様を目にした結果、勢いで書き始めた記事を締めようと思います。

*1:そんなアプリケーションがあるのかはわかりません

*2:私は最近なりました

Kotlin 1.2のアプリケーションを1.7までバージョンアップする

対象読者

  • 「このアプリケーション、Kotlinのバージョン古くなってきてるし、最新まで上げようぜ!」って仕事をやることになった、ちょっと前の私。
  • およびそのような人(いるのか)。

前提条件

  • 2017年頃ファーストリリース、現在も稼働中のバックエンドアプリケーション
    • ただしアクティブなメンテナとなるチームはおらず、今回たまたま私が改修するタイミングがあった
  • Java
    • 1.8系
  • Kotlin
    • 1.2.10
      • 2017年12月頃にリリースされた模様 github.com
  • フレームワーク
    • Spring Boot(1.5.8.RELEASE)*1
  • プロジェクト管理
  • その他
    • JUnit5
    • mockk
      • 1.7.15
    • mockito-kotlin
      • 1.6.0

結果

  • Kotlin
    • 1.2.10 -> 1.7.10
  • mockk
    • 1.7.15 -> 1.12.0
  • mockito-kotlin
    • 1.6.0 -> 2.2.0 *2
  • その他一部アプリケーションコードの修正(後述)

バージョンアップのモチベーション

Kotlin 1.2系が、最新のIntelliJ IDEAではサポートされていない為です。

執筆時点で最新バージョンの2022.2.1のIntelliJ IDEAでKotlin 1.2を利用しているアプリケーションを起動しようとしても、そもそも起動することができません。 (正確にはビルドすらできません。Kotlin: Language version 1.2 is no longer supported; please, use version 1.3 or greater.というメッセージの通り、最低でも1.3以上にする必要があります)

このことは開発生産性の観点で大きなマイナスなので、バージョンアップすることにしました。

方針

  • 時間をあまりかけたくないので、変更は必要最小限に
    • 極力バージョンアップ前に利用していたライブラリをそのまま利用する(ライブラリの追加、削除よりも、既存ライブラリの新しいバージョンを利用する)
    • 極力アプリケーションコードの修正を加えない

Kotlinのバージョンを上げる

最新のバージョンを確認する

  • GithubのReleasesページを確認 github.com
    • なおIntelliJ IDEAでは、より新しいバージョンのライブラリを利用できる場合、pom.xmlファイル上でガイドしてくれる機能がある

とりあえず上げてみる

  • アプリケーションコード上で修正が必要な箇所はビルド時点では特になし*3
  • Kotlinで書かれたコードをJava8上で動かす場合には、kotlin-stdlib-jre8 ではなく、kotlin-stdlib-jdk8を利用する必要がある点に注意が必要

単体テストを実行できるようにする

  • この時点では単体テストが実行できなくなっていた

    • mockkが依存するCoroutine周りのクラスパスが変わっていた
      • Kotlin 1.3以降でCoroutine周りの機能がデフォルトに変わったため
    • ということでmockkを最新までバージョンアップ github.com
  • 今度は一部のテストケースで「Caused by: java.lang.NoSuchFieldError: ASM_API」というエラーメッセージが出てテストに失敗するようになる

    • これはmockito-kotlinが依存しているbytebuddyのバージョンが古いことによって起こっているらしい
    • mockito-kotlinのバージョンも最新にすることで解決

アプリケーションが問題なく稼働することを確認する

  • JAX-RSのParamConverterを利用する処理の周りがうまく動かないことを確認
    • これはKotlinをバージョンアップしたことで、Null Safetyがより厳しく判定されるようになったため

例えば以下のようなParamConverterを実装したクラスがあるとします。(LocalDateTimeはJava標準のクラスとする)

class LocalDateConverter: ParamConverter<LocalDateTime> {
    override fun fromString(value: String): LocalDateTime {
        try {
            return LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"))
        } catch (e: Exception) {
            throw IllegalRequestException("Invalid date string. $value")
        }
    }

    override fun toString(value: LocalDateTime) =
            value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"))
}

ParamConverterはJAX-RSにおいて、String型のメッセージパラメータと特定の型Tで相互に変換する責務を担います。

参考:https://spring.pleiades.io/specifications/platform/8/apidocs/javax/ws/rs/ext/paramconverter

上記クラスで実装している fromString 関数は、文字列から特定の型Tへの変換処理を担っています。

Kotlin1.2においては、この fromString 関数の引数としてnullが渡ってきても問題なく動いていました。しかし、Kotlin 1.7までバージョンアップされる過程で、Null Safetyが厳しく検査されるようになり、nullが渡ってきた場合に想定通りに動かなくなってしまったようです。

その為、ワークアラウンドとして以下のように、nullの値が渡ってきても問題ないようにアプリケーションコードを修正しました。

    override fun fromString(value: String?): LocalDateTime? {
        try {
            return value
                ?.takeIf { value.isNotEmpty() }
                ?.let { LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")) }
        } catch (e: Exception) {
            throw IllegalRequestException("Invalid date string. $value")
        }
    }

ということで

こまめなバージョンアップって大事ですね。

*1:こいつもついでにバージョンアップしたかったが今回は時間の都合で割愛

*2:mockito-kotlinは2系にバージョンアップされる過程でGroup IDが微妙に変わっているので注意が必要です。com.nhaarman -> com.nhaarman.mockitokotlin2

*3:Deprecatedな関数はいくつかあります。String.toUpperCase等。https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/to-upper-case.html

マイクロサービスとは何であり何でないか?を知る、実践的な教科書:「モノリスからマイクロサービスへ ―モノリスを進化させる実践移行ガイド」を読んだ

はじめに

オライリー・ジャパンから出版されている、「モノリスからマイクロサービスへ ―モノリスを進化させる実践移行ガイド」を読みました。

最近で読んだ技術書の中でもなるほど、と思う部分が多く、概要に触れながら感想を書き残していこうと思います。

概要

この本の著者Sam Newmanは以前にBuilding Microservices(邦題:「マイクロサービスアーキテクチャ」)を著し、本書のことを冒頭でも「既存のシステムをマイクロサービスアーキテクチャに分解することをどう考え、実行するかを深く掘り下げた内容になっている」と紹介しています。

その紹介の通り、この書籍は確かに既存のシステムからマイクロサービスへの段階的な移行について、アプリケーションやデータベースの分割に当たって具体的な方法を示しています。

一方で、その移行前後の存在である「既存のシステム(しばしばモノリスとして形容される)」と「マイクロサービス」が何であり、何でないか、を詳細に紹介してくれています。

その為、マイクロサービスへの移行を志すエンジニアは勿論ですが、マイクロサービスを採用している現場で働くエンジニアにとっては何故今のようなシステムアーキテクチャが採用されているかの理解を助ける一冊となりますし、今後自分がシステムを設計していく際の引き出しを増やしてくれるような存在にもなっていると思います。

以下、オライリー・ジャパンの公式サイトから本書の内容の紹介を引用します。

本書は、モノリスからマイクロサービスアーキテクチャへと移行するための実践的なガイドです。マイクロサービスが自分たちのシステムに適しているかを判断するところから、ビジネスを維持しながらモノリシックなシステムを少しずつマイクロサービスに切り替えていく方法、さらには、マイクロサービスアーキテクチャが成長するにつれて起こる課題への対処の仕方まで、豊富な例やシナリオを用いて解説します。また、モノリスやデータベースを分解していくのに役立つ様々なパターンやテクニックも扱います。 システムのアーキテクチャ移行について具体的な方法を解説する本書は、エンジニア必携の一冊です。

O'Reilly Japan - モノリスからマイクロサービスへ

各章のまとめ

以下では、印象に残った箇所を引用しつつ、各章の内容の一部を簡単に紹介します。

1章 必要十分なマイクロサービス

この章では、以降で触れていくマイクロサービス、及びそれの対になるモノリスについて紹介しています。

まずマイクロサービスですが、本書内では以下のような言葉で説明されています。

マイクロサービスとは、ビジネスドメインに基づいてモデル化された、独立してデプロイ可能なサービスだ。サービス同士はネットワークを介して相互に通信してシステムを形成する。(p.1)

技術的な観点から見ると、マイクロサービスとは、カプセル化されたビジネス機能を1つ以上のネットワークエンドポイントを介して公開するものだ。それらはネットワークを介して相互に通信しあい、分散システムを形成する。(p.1)

ここにマイクロサービスを構成する要素である、以下のような3つの要素が見られます。

  • ビジネスドメインに基づいてモデル化・カプセル化されたサービスである
  • 独立してデプロイ可能なサービスである
  • ネットワークを介して相互に通信することでシステムを形成するサービスである

システムを実際に設計する上では、特に最初の2点が重要になります。1点目は、「どのような境界で、どのような方法でサービスを分割するか」「どのような情報を公開・隠蔽するか」という指針に、2点目は「独立してデプロイ可能にするために、どのような方法でサービスを分割するか」「分割されたサービスを独立してデプロイできるパイプラインをどのように設計するか」という指針に繋がります。

一方で筆者は、本書のテーマはモノリスからマイクロサービスへの移行である、と掲げています。その為この章の中で、モノリスがどのようなものであるかについても説明しています。本書の中では単一プロセスとしてデプロイされているシステムを一般的にモノリスとして紹介していますが、この章では、単一プロセスを複数モジュールで構成するモジュラーモノリス、複数サービスで高壊死されているもののシステム全体の独立デプロイ性を欠いている分散モノリスサードパーティ製のブラックボックスシステムを他の類型として紹介しています。

筆者はモノリスそれ自体にも利点があることを認めた上で、具体的に移行を行うにはどのようにしていくかを後の章で紹介しています。

2章 移行を計画する

この章では、実際に移行を計画する上で留意する点を説明しています。

章の冒頭で筆者は以下のように切り出しています。

マイクロサービスは目的ではない。マイクロサービスを手にすることが「勝利」ではないのだ。マイクロサービスアーキテクチャを採用することは、合理的な意思決定に基づいた意識的な判断であるべきだ。マイクロサービスアーキテクチャへの移行は、既存のシステムアーキテクチャのままでは達成できないことを実現するために考えるべきだ。(p.37)

この文章が本書の本質だと思います。執筆当初、マイクロサービス、が一種のバズワードと化していたように思います(今でもそうかもしれません)が、マイクロサービスはあくまでシステムを設計する上での合理的な手段の一つであり、目的とはならないということを留意するべきだと思います。

筆者はさらに、「3つの重要な質問(p.37)」として、マイクロサービスアーキテクチャ化を検討すべきか組織が見極める際、筆者が実際に行っている質問を紹介しています。

  • 達成したいことはなにか?

  • マイクロサービスの他に代替案はなかったか?

  • どうすれば移行がうまくいってるか分かるだろうか?

以降で筆者は、マイクロサービスを採用する際によく挙げられる理由(利益)を例として取り上げつつ、その利益がマイクロサービス以外でも享受できることを説明しています。例えば「チームの自律性向上」に対しては、コードベースの所有権の変更による自律性の付与、「リリースまでのリードタイム短縮」に対しては、デリバリーに関わるステップの可視化、ボトルネックの特定、といったように。

自分は特に2つ目の「リードタイム短縮」についての記述が参考になりました。一般的にアプリケーションのサイズ(コードの記述量、関連するドメインの規模、機能数)が小さくなるほど、リリースまでの時間は短縮される傾向にあると思います。一方でそれだけに着目して「マイクロサービスアーキテクチャを採用して、リードタイムを短縮するぞ!」と意気込んでも、実際にはサービス間のオーケストレーションであったり、分割に伴う組織設計であったり、それに付随するコストを見落としてしまい、結果的にリードタイムの短縮に繋がらない、という結果にもなり得るかもしれないなと思いました。

そのような場面でこそ、一見華やかな解決策に飛びつくのではなく、愚直に自分達のプロセスを可視化し、ボトルネックを特定、対処した方が、問題の解決に繋がるのではないかと感じました。

3章 モノリスを分割する

4章 データベースを分割する

この2章では、実際のモノリスとなっているアプリケーションや、共有してしまっているデータベースを分割するプラクティスを紹介しています。

詳細な説明は実際に本書を読んでいただくとして、自分が参考になった部分をいくつか抜粋します。

ストラングラーパターン(3.3)

既存のアプリケーションから新しいマイクロサービスへと機能を移行する際のパターンの一つです。このパターンでは、以下のようなステップで移行を進めます。

  1. 既存のシステム内で移行対象と、どのような呼び出しが行われるかを特定する
  2. 既存のシステムへの呼び出しを傍受する
  3. 移行対象の機能を新しいマイクロサービスへ移行する
  4. 移行が完了したタイミングで、2.で傍受していた呼び出しを新しいマイクロサービスへ切り替える

このパターンの長所として、呼び出しを傍受していることから、機能のリリース/ロールバックの切り替えを容易に行えることが挙げられます。既存のシステムを活かしつつ開発を進めた上で、最後に呼び出しを切り替えることで簡単にリリースができますし、万が一問題があったときも元の呼び出しにロールバックすれば良いだけです。

具体的な実装例としては、Nginxのようなリバースプロキシを前段に用意する、等が挙げられています。初めは新しいサービスへの呼び出しは 501 Not Implementedのレスポンスを返すようにして、最後に呼び出しを有効にするよう設定を行うことで簡単にストラングラーパターンを実現できます。

機能を移行しながら振る舞いを変える(3.4)

これは読んで字のごとく、機能を移行するタイミングと同時に、既存のシステムから振る舞いを変えてしまう、というものです。これはパターンと言うよりも、移行の開発中に既存システムに手を加える必要が出てきた場合にどうするか?という問に対する答えのようなものだと解釈しています。

筆者はこの場合に、以下のように述べています。

この問題を解決するのは簡単ではない。移行中の機能を途中で変更することを許容する場合には、ロールバックが困難になることを受け入れなければいけない。(p.99)

例えば既存システムに対してバグが見つかった場合、既存システムと新しいサービスの両方に不具合修正を施さなければ、ロールバックした際に振る舞いが変わってしまうリスクがあります。このようなリスクを避けるため、既存システムへは緊急のものを除いて手を加えない、逆に割り切って必ずどちらのバージョンにも不具合修正を加える、という意思決定をする必要が出てきます。

筆者はこの他にも変更データキャプチャパターン(3.9), サーガパターン(4.16)等の様々なパターンを紹介していますが、最後に3章の冒頭に載っている以下の文章を紹介します。

それぞれのパターンの長所と短所を必ず理解すること。これらは一概に「正しい」方法とは言えない。(p.80)

あくまで本書で紹介されている「パターン」は長所と短所を伴った解決策の一つであり、絶対的に正しい解決策ではない、ということを著者は最初に触れています。方法論が多く記述されているからこそ、それをそのまま模倣するのではなく、どのような問題意識に対して、どのように短所を割り切った上で、長所をもって問題を解決しようとしたかを検討するべき、という初心に戻らせてくれます。

5章 成長の痛み

筆者は本書の最後の章で、マイクロサービスへの移行に際して直面しうる問題について更に掘り下げて述べています。いつ、どのような問題が発生しやすいか、いつこうした問題に対処すべきなのか、どのように対処すべきか、という点を紹介してくれているこの章からも印象に残った箇所を抜粋します。

全体最適と局所最適の問題は)ランチの時に偶然耳にした一言がきっかけで気付くことが多い。コミュニティオブプラクティス(実践コミュニティ)のような、ある種のチーム横断的な技術グループがあれば、これらの問題をはるかに早く発見できる。(p.231)

マイクロサービスへの移行は全体最適と局所最適のバランスが必要な局面を生み出します。マイクロサービスとして分割されたサービスにチームが責任を持つ場合、意思決定はそのサービスに対する局所化されたものになりやすく、全体最適的な目線が失われるおそれがあります。そのような兆候に気付くという点で、カジュアルな他チームとの横断的なやり取りが役に立つのではないか、と著者は述べています。

昨今のコロナ禍によって、リモートワークへの移行が加速していきました。この傾向は、こうしたカジュアルな他チームとのやり取りの機械の喪失に繋がると日々体感しています。ゆえに、意図的にチームを横断するようなコミュニケーションをどのように設計するか、という点は今後のチーム開発において今まで以上に重要になってくるのではないかと感じています。

自分たちの意思決定が不可逆的なものと可逆的なもののどちらに傾いているかを、チームの人々が認識できるようにすることが秘訣だ。意思決定が不可逆的な方向に傾いているほど、チームの境界外の人を意思決定に参加させることが重要になる。

上記のようなチームを横断する話と類似して、意思決定の影響範囲について著者が指針を出しています。なんとなくこの決定は重要そうだから他のチームも巻き込んでおこう、という曖昧な基準ではなく、「この決定は不可逆性が高いので、隣のチームも巻き込んだ上で意思決定しよう」と能動的に選択していくことが重要だと感じました。

さいごに

自分は普段マイクロサービスアーキテクチャを採用している組織でソフトウェアエンジニアとして開発を行っています。そして自分がプライベートで開発を行う際も、規模の小さなアプリケーションをいくつか設計することが多いです。

そんな中本書を読んで、「マイクロサービスアーキテクチャを採用する」とは単に「Webとバックエンドを分割する」という部分にとどまらない意思決定を伴うものであること、そしてレガシーなアプリケーションからマイクロサービスに分割していく手法は、規模の大小を変えてアプリケーションの設計の様々な場面において役に立つことを学びました。

マイクロサービスという単語を知っている、普段マイクロサービスアーキテクチャを採用しているエンジニアにこそ、この本を読んでもらいたいなと感じました。

Jenkinsでジョブが終了しても生き残るプロセスを作る

はじめに

Jenkinsのジョブから、nohupコマンド等でジョブが終了しても生き残るようなプロセスを作りたくなる場面があるかと思います。(例えば特定のプログラムを監視する目的でサーバー上に常駐させたい場合など。)

業務の中で上記のようなケースで若干ハマってしまったため、備忘として残しておきます。

前提として、Jenkinsの中のProcess Tree Killerについて説明します。

Process Tree Killerについて

Jenkinsにはビルドの中で生成されたプロセスを列挙、削除するような機構としてProcess Tree Killerが存在します。

Jenkinsのジョブの中で生成されたプロセスは、ジョブの終了と同時に殺されてしまいます。

デフォルトでは有効に設定されているのですが、もし無効にしたい場合には以下のように起動時オプションとして hudson.util.ProcessTree.disable にtrueを渡してやることで実現可能です。

$ java -Dhudson.util.ProcessTree.disable=true -jar jenkins.war

詳細な説明は以下に譲ります。

ProcessTreeKiller - Jenkins - Jenkins Wiki

個々のビルドの中でProcess Tree Killerを無効にしたい

Process Tree Killerは不要なプロセスを削除する上で役に立つので、デフォルトで無効にすることは憚られるかと思います。

以下のように環境変数 BUILD_IDdontKillMe を渡してやることで、その環境変数が有効になっている限り一時的にProcess Tree Killerを無効にすることが出来ます。

$ BUILD_ID=dontKillMe nohup ping localhost &

因みにJenkins Jobの中で実行する場合には上記のようなやり方で実現できますが、Pipelineでの実行時は BUILD_ID の代わりに JENKINS_NODE_COOKIE を指定する必要があります。

$ JENKINS_NODE_COOKIE=dontKillMe nohup ping localhost &

アプリケーションドライバレイヤーを利用した自動受け入れテストを考える

この記事は

『継続的デリバリー』を引き続き読み進める中で、表題のアプリケーションドライバレイヤー(及びウィンドウドライバパターン)を利用して受け入れテストを実装することについての記述に行き当たりました。

www.amazon.co.jp

以前の記事はこちら。

i-whammy.hatenablog.com

私は業務の中でGaugeとSelenideを利用したUIに対する自動テストを書く事で受け入れテストを実装しているのですが、保守性の高いテストを考える上でヒントになりそうだと思い、読んだ内容を自分なりに解釈しつつ記事にすることにしました。

この記事では、Kotlinのサンプルコードを用いて実装イメージとともに、『継続的デリバリー』で紹介されているアプリケーションドライバレイヤーという考え方について、掘り下げていきたいと思います。

受け入れテストにおけるレイヤー

『継続的デリバリー』の中で、筆者は受け入れテストを以下の3つのレイヤーに分けて説明しています。

  • 実行可能な受け入れ基準
  • テスト実装
  • アプリケーションドライバ

それぞれのレイヤの簡単な説明は以下の通りです。

  • 実行可能な受け入れ基準
    • 名の通り、受け入れテストにおける受け入れの基準を定める
    • 実行可能であることがポイントであり、Gaugeはこの点で親和性が高い
  • テスト実装
    • 実際に処理の呼び出しを行いテストの一連の流れをコントロールする
  • アプリケーションドライバ
    • 受け入れテストの対象となるシステムと直接やり取りをする

私はこの中でもアプリケーションドライバのレイヤーが自動テストの保守性を高める上で大きな役割を果たすのではないかと思いました。

以下でこのレイヤーについて掘り下げてみます。

アプリケーションドライバレイヤーについて

上記の通り、アプリケーションドライバレイヤーは受け入れテストの対象となるシステムと直接やり取りをするレイヤーであり、このレイヤーはどのようにテスト対象とやり取りをするかの詳細を知っていることになります。例えばUIに対するテストであればSelenium、Selenide等を利用してUIに対する操作を行います。

一方で他のレイヤーから見た際に、アプリケーションドライバレイヤーはドメイン言語で表現されたインターフェースを提供するという特徴を持ちます。

すなわち、テストの実装の側から見た場合には、テストの実装から、テストに必要な情報のやり取りについての詳細について知る事はなく、抽象度が高いAPIを通して詳細とやりとりをすることが出来るようになります。

因みにGUIに対するこのようなアプローチはウィンドウドライバパターンとして紹介されており、以下のファウラーの記事でも詳しく知ることができます。

martinfowler.com

なおアプリケーションドライバレイヤー自体はGUIに限らず、API等他のシステムをテスト対象とする場合でも利用できます。

アプリケーションドライバレイヤーのメリット

上記のような特徴を持つアプリケーションドライバレイヤーですが、以下のようなメリットがあると感じました。

保守性が高まる

『継続的デリバリー』内でも紹介されていますが、一番大きなメリットはテストが変更に対して強くなることだと思います。

特にUIに対するテストは、画面の細かな変更に対応するためにテストの修正を余儀なくされる場面が多くあるかと思います。

アプリケーションドライバレイヤーにUIとのやり取りの詳細を押し込めることで、変更に対して強いテストが書きやすくなります。

テストが失敗した際の対応が容易になる

『継続的デリバリー』の同じ章(8章 自動受け入れテスト)の他の節でも紹介されているのですが、適切なレイヤリングによってテストの問題の調査が容易になると考えます。 例えばSeleniumを用いた自動テストでは、テストが失敗する場合に「画面描画を待っていてタイムアウトしてしまった」「セレクタの指定が誤っていた」等の原因から、純粋なアサーションエラーまで幅広い原因が考えられます。

アプリケーションドライバレイヤーが適切に分離されている場合、どのような原因でテストが失敗したのか分かりやすくなり、また部分的にタイムアウトまでの時間を延長する際にも対応する箇所を局所化させることが出来るようになります。

他にもマルチデバイスへの対応がしやすくなる(デスクトップアプリ、Webアプリ、モバイル等)というメリットがあるかなと思いました。

実践

実際にアプリケーションドライバのレイヤーを利用して受け入れテストのリファクタリングを行っていく様子を見ていきたいと思います。

サンプルコードはKotlinとGauge、Selenideを利用して記載していますが、適宜コメントで補足を入れています。

テスト対象

簡単な銀行口座のアプリケーションを考えます。

ユーザーがアプリを立ち上げてログインした際に、現在の口座残高を知ることが出来たり、他の様々なメニューを開くことが出来るようなポータル画面が表示されるようなものをイメージしてください。

以下のようなコンポーネントがあり、そこにユーザーの現在の残高が表示されることをテストすることとします。

  <div>
    <p class="current-balance">{{currentBalance}}</p></div>

まずは愚直に

まずはシンプルに、現在の残高を確かめる実装を書いてみます。

// GaugeはMarkdown形式で書かれたSpecファイルとStepアノテーションが付けられた実装を紐付けることが出来ます
@Step("現在の残高が<balance>円である")
fun assertCurrentBalance(balance: Int) {
  // query selectorを利用して現在の残高を取得しアサーションする
  assertEquals(`$`(".current-balance"), balance)
}

簡潔な実装ですが、受け入れの基準を満たしているかどうかを検証するテストになっています。

一方で画面に対する変更があった場合、その影響を受けやすいコードです。

また「現在の残高(currentBalance)」ということは分かりますが、どのような場面、画面で利用されている残高なのかイメージがつきづらいです。

アプリケーションドライバを導入する

ではアプリケーションドライバレイヤーを導入してリファクタリングをしてみます。

@Step("現在の残高が<balance>円である")
fun assertCurrentBalance(balance: Int) {
  // UserPortalPageDriverというドライバクラスを用意する
  val currentBalance = userPortalPageDriver.getCurrentBalance()
  assertEquals(currentBalance, balance)
}

今回は UserPortalPageDriver というドライバクラスを設けて、ログイン時に初めに表示されるポータルページとの直接のやり取りを担う役割を持たせてみました。 これによって、ドライバクラス内にUIとのやり取りの詳細を隠蔽出来ました。

またクラス名からどのような場面で利用される残高なのかという点がはっきりし、ドメインとしての主張が強まったと思います。

『継続的デリバリー』内ではページ単位よりもさらに細かく、画面を構成するコンポーネント単位でドライバーを用意した例が掲載されていましたが、この辺りはアプリケーションの規模に応じて変えると良さそうだなと思いました。

まとめ

記事にしてみると、プロダクションコードを書く際には意識してきた抽象化、レイヤリングという要素をきちんとテストコードを書く際にも意識する事でより良いテストコードが書ける、という事を改めて実感できました。

『継続的デリバリー』はこの受け入れテストに関する8章でちょうど折り返しにあたるため、引き続き少しずつ読み進めていきたいと思います。

「継続的デリバリー」を読んでいる

この記事は

最近「継続的デリバリー」を読んでいるので、そのまとめのようなものです。

www.amazon.co.jp

如何せん長編ということもあり *1、まだ読了していないのですが、この本の中心となる考えである「デプロイメントパイプライン」が紹介されている5章まで読み終わったので、一つの区切りとして記事を公開します。

まず、筆者である私と継続的デリバリーについて、この本に出会う前の状況を簡単に書き下します。

私と継続的デリバリー

  • 業務で一定の関わりはある
  • 一方で現状に漠然とした少し行き詰まりを感じていた
    • ここからもう一歩踏み込んで勉強したい、という時にどこから手を付けたものか分からない
    • 現状のCI/CDについてもっと課題点のイメージを固めたい
    • それに対する解決策のヒントを得たい

そこで、この「継続的デリバリー」に対しては、以下のようなことを期待して読み始めました。

  1. 継続的デリバリーに対する広く浅い、入門的な知識を得る
    • 実践はしてきたものの、ヌケモレが不安
    • ここで入門的な知識を固めておくことで、後から個別のテーマを追いやすくしたい
  2. 継続的デリバリーが誕生した経緯、歴史を知る
    • 個人的な興味に近い
    • どんなプラクティスでも歴史を知っておくことに意味があると思う

以下、5章までの簡単なまとめと、それに対して私が印象に残った箇所をまとめて記載します。

各章別の概要と感想

導入

あらすじ

  • この本ではソフトウェア開発において、開発からリリースまでの自動化戦略を通して、継続的にユーザーに価値を提供するにはどうすれば良いのかの見取り図を提供する
    • 中心となる考えが「deployment pipeline(5章)」

印象に残った箇所

  • 「コードを1行だけ変更したとして、その変更をデプロイするのにあなたの組織ではどのくらいかかるだろうか?その変更からデプロイまでの作業は反復可能で信頼できるやり方に基づいて行われているだろうか?」
    • 「時間」(サイクルタイムにも近い)だけじゃなく「反復可能性」という大事な考え方を提示してるとおもった
    • 反復可能=自動化ができるはず、自動化することによってプロセス自体の検証が容易になる?

感想

  • 理論よりも実践に近い本だと知る。「継続的デリバリ」そのものに対して大きな見取り図を提供してくれそう。自分の期待値に合っていると感じた。

1章

めっちゃまとめると

  • リリースって大変だし手作業だとミスが起こりやすいよね
  • なるべく自動化しよう、全ての関係あるものをバージョン管理しよう、開発者は運用、テスト担当と協調して仕事を進めよう
  • つまり、「ソフトウェアをリリースするための、反復可能で信頼できるプロセスを作り上げよ」

印象に残った箇所

  • 裁量措置について
    • 手作業が介する手順は作業者の判断の余地を生じさせる。それが結果ヒューマンエラーにつながる。
    • デプロイメントプロセスが自動化されていないと、反復可能にならないし、信頼できない
    • 「反復可能だから自動化できる」というよりも「自動化されているからちゃんと反復可能である」
  • リリースプロセスの品質を確かめるには自動化するしかない
    • そうしないと作業者の作業品質を評価することになる(それはプロセスの評価ではない)
  • プルシステム
    • QA,TE,運用担当が「このバージョンをデプロイしてくれ」と依頼するのでなく、彼らが好きなバージョンを好きな環境にデプロイできる状態にすること
  • どこにデプロイする場合でも、デプロイメントのアプローチを同一にするのが最善の策
    • 環境固有の作業を極力生じさせないことでデプロイプロセスの品質を担保する

感想

  • 自動化は偉大。自動化することでプロセスの品質を担保することが出来る
    • 逆に言えば手動で作業しなければいけない領域がある時点で、そこにプロセスの品質を阻害する要因が入り込む可能性がある
  • 極力エラーを減らした上で高速にフィードバックサイクルを回すにはどうすれば良いかを語っている
  • 継続的インテグレーションはそれまで開発プロセスの最後に一度だけ行なっていたインテグレーションを常に行う(チェックインと同時)ことで、インテグレーションに伴うリスクを軽減して品質を高めるアプローチ

2章 構成管理

まとめると

  • 構成管理とはプロジェクトの成果物に関わる関係性と成果物が保存、検索、一位に特定され修正されるプロセス
  • これだけだと抽象的だが、環境設定(OS、アプリ、ネットワークなど)などが対象というと具体的
  • これをチームメンバが再現可能で、インクリメンタルに改善可能な形にしておく必要がある

印象に残った箇所

  • トランクベースはCIと相性がいい
    • 逆にブランチベース開発がCIと相性がよくない
    • ブランチを統合するまでの間マスターでのCIを回すことができず頻度が落ちリスクが高まる
  • 社内maven repositoryのように外部ライブラリのコピーを取っておく話
    • これがコンプライアンスの観点から重要ということは初めて知ったかも
    • 確かに言われてみると外部からのファイルダウンロードになるので一定リスクがある
  • 設定はアプリケーションの挙動を外部から変更するもの、すなわち一定のリスクがあり必ずテストすべき
    • 設定をテストする、設定によってアプリを壊すという発見
  • アプリケーションのソースコードと設定は変更速度が異なるので分けて管理する
    • 変更される頻度、タイミングという観点で、SRP(単一責任原則)とも似ている考えかなと思った
  • 適切にデプロイされた状態にある環境はベースラインと呼ばれ、自動化された環境プロビジョニングシステムではあらゆるベースラインの構築、再構築をできる必要がある
    • 再現可能性

3章 継続的インテグレーション

ざっくりとしたまとめ

  • 継続的インテグレーション(CI)は定期的(しかも高頻度)にシステムを統合し、ビルド、テストすることで、システムが常に動作可能な状態を作ることを目指す
    • 元々統合にはリスク(ビルドが失敗しやすい、意図しないシステムを作り込んでしまう)があり、これを低減させるためにより頻繁に統合を行うようにした
  • CI実現のためにはコードのバージョン管理、環境の構成管理、そしてチームのコミットメント(ビルド失敗時にチェックインしない等)が必要不可欠

印象に残った箇所

  • コードベースを定期的に統合することがよいのならそれを常にやるほうが良い
    • 常に=誰かがバージョン管理システムに何かしらの変更をチェックインする時
    • つまり思ったよりも高頻度だしエンジニアの数が増えるほどその頻度は増していく
  • CIサーバーを使うことで得られる最も重要な恩恵の一つが可視性
    • CIサーバーにはコマンドの定期実行だけでなくグラフィカルな出力を行うwebサーバー、外部出力の機能がある
  • 飛行機のパイロットは着陸するたびに何か問題が起きることを想定するべきだと教えられる
    • 常にrevertできるようにせよ、というところの例
  • チェックインしてビルドが壊れたら10分取って直す、それで無理ならリバーとする
    • タイムボックスを切って修正する、というアイデア
  • テストが遅い場合にビルドを失敗させる
    • まずは計測しないと話にならない

4章 テスト戦略を実装する

ざっくりとしたまとめ

  • 自動テストはプロジェクトの状態によって導入戦略が異なる
    • 始まったばかりなら是非やる
    • 途中のプロジェクトなら大事なユースケースリグレッションテストから
    • レガシーなプロジェクトは新しいストーリーからテストを回せるようにする
    • 「プロジェクトのリスクを識別して優先順位をつけて、それを緩和するためにどんなアクションをとれば良いか決定する」プロセス
  • 「高品質を実現するために、大人数での調査に頼るのをやめよ。まずはプロセスを改善し、本番の品質を作り込め」(デミング)

印象に残った箇所

  • 同じテストを2回繰り返したら自動化するのが良い
    • ただしテストのメンテナンスコストが高いなら安易に作るのも考えもの
  • デプロイメントテスト
    • アプリケーションが正しくインストールされ、正しく設定され、必要なサービスと接続できてレスポンスが帰ってくることをテストする
    • 従来のE2E内で暗黙的に担保されているが、セットアップなどで分けて最初にテストするのもありかな?と思う
  • ビジネス視点でのプロジェクト評価「テスト」としてのイテレーションレビュー
    • 早めに成果物を見せる
    • どこまでフィードバックを取り込むかは別としても、早めに動かせるに越したことはない
  • モックをコラボレーターとのやりとりではなくいくつかのコードの動作に関する詳細をアサートするためだけに使うのはNG
    • 実装を変えるとテストが壊れてしまう、壊れやすいテストを生み出すもととなる
  • INVEST原則
    • independent, negotiatable, valuable, estimatable, small, testableなストーリーを作る

感想

  • モックの使い方はもっと洗練させられる

5章 デプロイメントパイプラインの解剖学

ざっくりまとめ

  • デプロイメントパイプラインはソースコードから顧客に成果物をデリバリするまでのプロセスを自動化したもの
    • 自動化されているからこそ反復可能であり、プロセスの品質を担保できる
  • デプロイメントパイプラインはチームがフィードバックを高速に受けられるようにするためにも存在する
  • デプロイメントパイプラインはコミットステージ、受け入れテストステージ、その後のステージからなる
    • コミットステージでは静的解析、ユニットテストなど(長くても10分)
    • 受け入れテストステージでは自動化された受け入れテストをまわす
    • その後のステージでは手動テストや非機能的なテスト(自動の場合もある)を回す
    • 結果リリースの準備が万全になる
  • デプロイメントパイプラインでリリースを自動化する
    • 後続するステージでリリースを自動化するところまで含むことができる
      • その場合はバックアウト戦略なども考えておく
  • デプロイメントパイプラインを作るには
    • まずバリューストリームを明確にする
    • そのあとはインクリメンタルに作っていく

印象に残った箇所

  • コミットステージではユニットテストをすべて実行するが、その後のステージで失敗しやすいテストを実行することも検討できる
    • より早くフィードバックを得てより早く失敗するため
  • ユニットテストでテストしているのはコードが正しく動くかであり、問題をきちんと解決できるかではない
    • それは受け入れテストの責務である
  • ユビキタス言語を用いてテストを設計する
    • デプロイを民主化する、従来の組織の役割を越境する
    • 開発者だけにテストを閉じさせない、その上でユーザーの要件を満たすものにする

感想

  • デプロイメントパイプラインが継続的デリバリの根本的な概念であるとわかる
    • あとで読み直すとしたらこの章になりそう
  • 非機能要件に対するテストがまだあまりイメージがついていないかもしれない
    • 実際にそこまでシビアなプロジェクトにぶつかっていないから説はある

まだ読み切れていないものの継続的デリバリ、デプロイメントパイプラインといった概念について見取り図を知ることが出来る、という観点で良い本だと思いました。

私は現状この分野についてあまり明るくないのですが、ここで見取り図のようなものが頭に入ることで詳細なテーマについてどのように深ぼっていけばいいかイメージが格段に湧きやすくなったと思っています。

*1:日本語版Kindleで読んでいますが約550ページあります。

「アート・オブ・アジャイル デベロップメント ―組織を成功に導くエクストリームプログラミング」を読んだ

この記事は

何であり

「アート・オブ・アジャイル デベロップメント ―組織を成功に導くエクストリームプログラミング」を読んだ私の感想を書き残すためのものです。

http://amazon.co.jp/dp/4873113954

何でないか

あくまで個人の見解、感想であり、批評、レビューの類ではないです。

個人が印象に残った箇所を備忘録的に書き残しておくものであり、本の内容を網羅するものでもありません。

また、アジャイル開発、エクストリーム・プログラミングそのものについての説明は部分的なものに留まることをご留意ください。

私は

以下、印象に残った箇所とその理由、簡単な感想をいくつか書き残しておきます。

印象に残った箇所

アジャイル開発は、個人的な成功、技術的な成功、そして組織的な成功を同時に達成することを重視している(1.4)

なぜ?

  • 個人的な成功が含まれると思っていなかった
    • 組織的成功、そして技術的成功には寄与すると思っていた
    • でもよく考えてみると、「アジャイル開発を実践する -> 楽しく開発する時間が増える -> 個人的なやりがい、成功につながる」と言うシンプルな図式かもしれない
      • 思い返すとそれができてたなと思うことがある
        • 前職ではアジャイルなどではなく常に全力疾走
        • 今の職場では持続可能なペースで計画を立てて開発を行っている
        • 結果的に自分の興味があるリファクタ、設計を重視した開発をできる余力が増えた

もう少し考えてみる

  • 従来のソフトウェア開発では3つのうちどれかしか重視/達成できていなかった背景がある?
    • 自分がソフトウェア開発に関わり始めたタイミング以後(2016)でも全てを重視/達成しているプロジェクトばかりとは言いがたかった
    • 全てを重視/達成するプロジェクトとそうでないものは何が違うのか?
      • 一つの要因が持続可能性

オンサイト顧客の役割(3.2.2)

  • オンサイト顧客には、チームが構築するソフトウェアを定義する責任がある。(中略)プロジェクトのビジョンをみんなに熱く語らなければならない。(中略)プログラマと調整して計画ゲームを進めることによって、達成可能な計画を作成する。(3.2.2)

なぜ?

  • 言葉からは「システムのエンドユーザー」であったり「そのシステム導入の意思決定を行う決裁権者」のようなイメージをうける
    • でもそれは部分的にしか合っていない(間違っているわけじゃない!)
    • XPにおいては、顧客はプロジェクトのビジョンを語り、計画ゲームを進めて達成可能な計画を作る

もう少し考えてみる

  • リモートワークする中でオンサイト顧客とどのような接点を持つべきか?
      1. デイリーイベントに参加してもらう
      1. 不定期で話せるようなインフラを整える
      2. オンライン会議ツール(Zoom, Google Meet, Microsoft Teams)
    • オンサイトと近い状態を作れるのがベストだがそうはいかない部分もある。オンサイト顧客と予めこのようにプロジェクトを進めたい、というコミュニケーションを取る必要がある
    • でも別にそれはオンサイトでやってた時と変わらないはず

「最終責任時点(3.3.4)」

  • 決定をギリギリの時点まで遅らせることにより、決定の精度を上げ、作業負荷を軽減し、変更の影響を減らすことができる -「決定を下し損ねると、重要な代替案がなくなる時点」で決断する

なぜ?

  • 遅らせることができるタイミングまでコミットメントを持った決定を遅らせる。結果的にそれまでに情報を集めたり、仮説を検証したり、より精度の高い結論にたどり着けるはず

「制約理論(3.3.8)」

  • どんなシステムにもシステム全体のスループットを決定づける1つの制約がある
  • プログラマをチームの制約だと仮定する
    • どんなにテスターや顧客が素早く働いても、プログラマがプログラミングするより早くプロジェクトを完成させることはできない
    • それ故にプログラマの見積もりが計画作りに使われる。
      • 故に持続可能なペースで計画を実現できるのか。
    • 逆にそれ以外の人はプログラマの障害を減らす役割を担う

*1:現職での経歴がそのまま当てはまる。現職に来る前も2年ほどエンジニアとして勤務していたが、その会社ではアジャイル開発と呼べるだけの開発体制が整っていなかった。

*2:どうでもいい話ですが私の周りには「アジャみつ」派と「アジャみけ」派がいました。