ContractS開発者ブログ

契約マネジメントシステム「ContractS CLM」の開発者ブログです。株式会社HolmesはContractS株式会社に社名変更しました。

イベントストーミング体験ワークショップに参加した学びと感想

こんにちは。テックリードの友野です。最近、急に寒くなったもので、衣替えが追いついていません。

さて、11/10(金)にUMTP主催のModeling Forum 2023ワークショップ「ドメインモデリングの強力なツール: Event Stormingを体験しよう」に参加してきたので感想と、メモの整理を兼ねてポストします。
ドメインイベントの伝播による整合性担保やその仕組みはContractS CLMで部分的に採用していますが、その領域はごくわずかです。イベントストーミングという言葉そのものは知っていましたが、業務では採用しておらず、経験もありません。今回のワークショップを通じて、多くの学びがありました。

umtp-japan.org

(2023/11/15 追記:UMTP事務局よりmiroキャプチャ利用許可をいただいたので、画像を追加しました)

ワークショップ概要

ワークショップは、イベントストーミングを体験し、慣れることを目的としています。
1チーム4~6人で構成し、イベントストーミングのプロセスに沿って議論しながらmiro上でモデリングをしました。テーマは図書館業務。誰もが知っている/利用したことがある一方で、その業務についてすべてを理解している人はいないことが理由だそうです。図書館司書と利用者、2つのアクターそれぞれの業務を参加者全員(30人!)で分担して整理しました。
具体的には、書籍"Learning Domain Driven Design”(以下、LDDD)で紹介されているステップに沿って(一部省略して)進めていく形式です。ステップバイステップで進めていく中で生まれた疑問点や不明点を都度、講師(Chatwork加藤さん/GMOインターネット成瀬さん)に聞けるという贅沢な時間でした。

図書館業務のユースケースサンプル
図書館業務のユースケースサンプル

イベントストーミングとは

イベントストーミングはブレインストーミングドメインイベント版です。つまり、イベントをとにかく発散させて徐々に収束させていくドメインモデリングの手法です。このアプローチは、ドメイン上の重要な関心ごとであるイベント=出来事(コト)に着目しています。
イベントに着目する理由として、以下があります。

  • イベントが分かれば、そのイベントを生み出したふるまいが分かる
  • イベントはヒトやモノと必ず関係があり、コンテキストがあるので収束させやすい
    • 逆にモノからアプローチすると、複数コンテキストがある場合があり、発散させやすい

イベントストーミングはビッグピクチャー、プロセスモデリング、ソフトウェアデザインから構成されます*1。参加者の知見や認識を揃えながら、業務の流れを整理したいシーンにはフィットしそうです。

イベントストーミングで使う付箋と依存の方向
イベントストーミングで使う付箋と依存の方向

進め方

1. イベントを整理する(ビッグピクチャー)

ワークショップでは、まず以下の流れでイベントを整理しました。

  1. イベントを書き出す
    • イベントは出来事なので動詞の過去分詞形で表現し、この段階では他者との重複は気にしない。
      • 例:利用者を登録した蔵書を貸し出した
  2. イベントを精査する
    • 同じもの・似ているものをまとめる
      • 特に、用語のゆらぎに注意。ユビキタス言語となりうる。
    • イベントはドメインの状態変化を表すため、確認や閲覧のような状態を変化させないものは除外する
  3. イベントを左から右へ時系列に並べる
    • 同時に起きるイベントは縦に並べる
  4. 時系列を順に読み合わせて矛盾がないか確認する(ウォークスルー)
    • 例えば、登録していない情報を突然更新していないか など
    • 足りなければ、この段階でイベントを追加する

※ LDDDに記載されているフェーズについては、ワークショップではスキップしました。

イベントストーミングでは議論が白熱したり、横道に外れたりした場合は、ホットスポットとしてメモを残して議論を先へ進めます。例えば、「発注した本の支払い方法は銀行振り込みかクレジットカード払いか」という論点は図書館業務の整理から見れば、ホットスポットです。

2. イベントからプロセスの流れを見つける(プロセスモデリング

コマンドとアクター/ポリシーをつなげる

イベントが揃ったら次は、そのイベントを生み出すふるまいの整理です。

  1. イベントからコマンドを作る
    • コマンドはリクエストなので動詞の現在形(命令形)として表現する。日本語の場合、体言止めは分かりにくいので非推奨とのこと。
    • 蔵書を貸し出した(イベント)→蔵書を貸し出す(コマンド)
  2. コマンドをリクエストするアクターを配置する
  3. システムが自動的/連鎖的にリクエストする場合はポリシー
    • ポリシーは次のコマンドをリクエストするトリガーのようなもの
    • ポリシーの例:本を入荷した(イベント)->管理番号採番ルール(ポリシー)->管理番号を採番する(コマンド)

コマンドを実行するためのリードモデルを定義する

コマンドを実行するために何かしら情報が必要であり、この情報こそがリードモデルです。システム外のリードモデルも表現しておくのが肝です。

  1. リードモデルを追加する
    • 複数のリードモデルからコマンドを実行する場合もある
      • 例えば、蔵書を貸し出す(コマンド)ために、蔵書(リードモデル)と貸出状況(リードモデル)が必要
  2. どのイベントからリードモデルが作られるのかを紐づける
    • 蔵書を貸し出した(イベント)後に、貸出状況(リードモデル)が更新される
  3. システム外のリードモデルも表現しておく
    • 利用者登録時の身分証明書 など

外部システムを追加する

モデリング対象の業務とは直接関係のない外部システムがある場合は、議論の発散を避けるため明示するだけに留めます。

3. コマンドとイベントから集約を見つける(ソフトウェアデザイン)

最後に一連の流れを俯瞰してみて、コマンドを受け取り、処理結果としてイベントを生み出す集約を見つけます。
当たり前ですが、このステップは機械的には出来ず、コマンドとイベントからどのような概念があるのか議論が必要です。イベントストーミングを用いない、ヒアリング中心のドメインモデリングでも変わらず難しいステップです。集約は名詞で表現するのが一般的なので、その命名に相応しい責務なのか、分割/統合するならライフサイクルは適切かという観点が重要です。

感想

終日議論をし続けて、疲労感と充実感で満たされたワークショップでした。イベントストーミング初体験でしたが、良い点と注意点が見えてきました。

イベントストーミングの結果
モデリングの様子
チームで発見した集約
チームで発見した集約

良い点

議論を進めやすい

モデリングするフレームワークなので当たり前と言えば当たり前ですが、ステップバイステップで参加者の意識・興味を集中できるので、議論が進めやすく感じました。ワークショップの同じチームメンバーとは初対面、かつ図書館業務経験者(ドメインエキスパート)不在でしたが、適度な発散と収束を繰り返しながら、メンバー全員が納得いく集約の定義まで時間内に進めることができました。

集約検討時に前提の認識を揃えやすい

一つ目と似たような観点ではありますが、イベントに着目した業務の流れを参加者全員で作り上げていくため、いざ集約の議論を始めようとするとそこまでの知識が揃った状態で始められます。場合により、発散させたいケースもあるかもしれませんが、こと問題領域を明らかにしたいケースにおいて、参加者の認識が揃った状態で議論が開始できるのはとても強力です。

要求の曖昧さを排除できる

イベントが時系列に並び、ウォークスルーで整合性を担保できるので、ユースケースの確からしさを検証できます。ContractSでは予備設計としてロバストネス分析を一部領域に採用していますが、近しいものを感じました。繰り返し実施することで曖昧さを排除し、不確実性を減らしながら価値検証するアプローチにフィットしそうです。

注意点

効果を最大化するためにはアーキテクチャの制約がある

注意が必要なのは、イベントストーミングの効果を最大化するためにはCQRS(Command-Query Responsibility Segregation)+イベントソーシングの構成が前提となっている点です。
ふるまいの結果として生み出されたドメインイベントが記録され、そのイベントからリードモデルが作られる前提のため、CQRSのようにモデルレベルで分離した構成が必要ですし、何よりイベントを記録していくためにイベントソーシングを採用していないと、イベントをイベントのまま永続化できません。
ワークショップでは成瀬さんがステートソーシングによる実装方式をライブコーディングしてくれましたが、コードからイベントの知識は消えているので、いずれモデルとコードに乖離が発生してアジリティを失う懸念があります。

逆に言えば、CQRS+イベントソーシングの構成であれば、これほど強力なモデリングフレームワークはないとも思います。

終わりに

初体験のイベントストーミングでしたが、とても興味深いものでした。適用するにはアーキテクチャ観点で若干のハードルがあるものの、エンジニア職以外のメンバーと共通言語で会話する最初の一歩として効果がありそうです。幸い、JavaにはAxon Frameworkというイベントソーシングをサポートするフレームワークがあるため、小さく検証することもできそうです。試した結果はまた別記事でまとめようと思います。

*1:必ずしもこれらのプロセスをしなければならない、というわけではないようです。

Lombokを利用した開発環境でGradleからJavadocを出力する

エンジニアの友野です。久しぶりのポストですが小ネタです。

日頃、開発時はIDE上でJavadocを参照していたので気にしていませんでしたが、ふと、現状整理するために俯瞰した全体のJavadocドキュメントが欲しくなりました。Lombokコンパイル時にコード生成するため、Javadoc生成時にはLombokが生成するコードがドキュメント化されません。ちなみに、onMethodのように生成したコードにアノテーション付与などするオプションではそもそもJavadoc生成時にビルドエラーになりました。

Lombokにはアノテーションからコード生成するdelombokという機能があります。つまり、delombokしてからJavadocを生成すればLombokが生成したコードのJavadoc化ができるはず。ということで試してみました。

環境

本記事のサンプルコードは以下の環境で動作確認しています。

JVM: OpenJDK Runtime Environment Corretto-11.0.12.7.2 (build 11.0.12+7-LTS)
Gradle: 6.6.1
Lombok: 1.18.12

プラグインの存在

まず初めに、gradle-lombokというプラグインが存在します。このプラグインは以下の機能を提供しており、これを使えばやりたいことは簡単にできます。

it adds the Lombok dependency to the classpath
it simplifies the Eclipse IDE installation
it offers support for delomboking
(訳)
Lombokの依存関係をクラスパスに追加
Eclipse IDEへのインストールの簡易化
delombokのサポート

しかし、以下のいくつかの理由で、このプラグインを参考に自分で書いてみることにしました。

  • すでにLombokは依存関係に追加済み
  • 開発で使用しているIDEIntellij
  • delombok以外の機能は使わなさそう
  • プラグインのコードを見るとシンプルなJavaExecタスクである
  • このプラグインは開発停止中、不具合対応のみ行う状態である

delombokタスクの実装

JavaExecタスクということは、通常のjavaコマンドによる実行をスクリプトに置き換えればうまくいきます。javaコマンドでdelombokを実行するのは以下の通りです(公式サイトから引用)。srcがdelombok対象のディレクトリ、src-delombokedがdelombok結果を格納するディレクトリです。

$ java -jar lombok.jar delombok src -d src-delomboked

これをJavaExecタスクにすると以下のように書けます。lombok.jarはすでにコンパイルのクラスパスに含まれるので、mainを直接指定しています。argsでdelombokを文字列で渡すことで、上記コマンドラインと同じ結果が得られます。
今回はプロダクションコードのJavadocだけで良いので、ソースにはsrc/main/javaを指定しました。

task delombok(type: JavaExec, dependsOn: compileJava) {
    ext.outputDir = file("$buildDir/delombok")

    classpath = sourceSets.main.compileClasspath + sourceSets.main.output.generatedSourcesDirs
    main = 'lombok.launch.Main'
    args('delombok', 'src/main/java', '-d', outputDir)

    doFirst {
        outputDir.deleteDir()
    }
}

JPAメタクラスコンパイルの依存に含んでいるので、compileJavaタスクに依存させてannotationProcesserの出力先も含めていますが、Javadoc出力のためだけであれば、これがなくともdelombokタスクでエラーが出るだけなので動作には問題ありません(気持ちの問題だけ)。

Javadocタスクの設定

次にJavadocを出力する設定です。javaプラグインをインポートしていればタスク定義はすでにあるので、設定だけ追加します。

apply plugin: 'java'

/* JavadocでJava8以降のタグを出力するためのオプション */
def tagOptions = ["apiNote:a:API Note:", "implSpec:a:Implementation Requirements:", "implNote:a:Implementation Note:"]
/* Javadoc生成時のクラスパス */
def javadocClasspath = sourceSets.main.compileClasspath + sourceSets.main.output.generatedSourcesDirs + files("$buildDir/delombok")

javadoc {
    dependsOn([compileJava, delombok])
    classpath = javadocClasspath
    source = delombok.outputDir
    options.tags = tagOptions
    failOnError = false
}

実行は以下の通りです。

$ ./gradlew javadoc

これでdelombokしたソースからJavadocを出力できました!

今回はjavadocの設定で例示しましたが、特定のクラス群だけフィルタしたい場合などは別のJavadocタスクを定義して、sourceに渡す対象を変えてあげれば可能です。

それでは良いJavadocライフを!

transformプロパティを用いたアニメーション改善

こんにちは、id:j-horikawaです。

昨今のUIはリッチ化が進みインタラクティブなデザインを実現するためアニメーションが多く使われています。

アニメーションを多用すると、コンポーネントのアニメーションがカクつく、もたつくなど処理が重くなりがちです。

今回はアニメーションのパフォーマンスに関する1つの改善方法としてDOMのレンダリングに絞った対応策を探ってみましょう。

今回改善したいアニメーション

f:id:j-horikawa:20210907121241g:plain

要素が左から右へ400px移動するだけのシンプルな実装なので実際に処理が重くなることはないのですが、処理が重くなりアニメーションがカクついているという前提でお話を進めます。

HTML

<div class="animation-cpu">CPU</div>

CSS

.animation-cpu {
  animation-name: position-element;
  animation-duration: 2s;
  animation-iteration-count: infinite;
  animation-timing-function: ease;
  background-color: gray;
  color: white;
  width: 100px;
  height: 100px;
  position: relative;
  left: 0;
  top: 0;
}

@keyframes position-element {
  100% {
    left: 400px;
  }
}

問題箇所の調査

Chromeをお使いの場合、DevToolsのRenderingタブにあるPaint flashingを有効にしてみましょう。

Renderingタブがない場合は下ペインの左にある「︙」からRenderingを選択すると表示されます。

f:id:j-horikawa:20210907121441p:plain

Paint flashingを有効にするとページ上で再描画が発生したエリアが緑色でハイライトされ、DOMのリペイントが行われていることがわかります。

f:id:j-horikawa:20210907121540g:plain

このリペイントは要素の位置が変更されるたびにCPUが使用されペイント処理が走るため、使用箇所が多くなるとCPUの負荷が高くなっていきます。

リペイントによる処理の負荷を軽減させる

今回の左から右へ要素が移動する処理、つまりright, left, top, bottomによる位置の移動にはCPUが使用されるので、GPUを使用する transformプロパティに変更します。

HTML

<div class="animation-gpu">GPU</div>

CSS

.animation-gpu {
  animation-name: transform-css;
  animation-duration: 2s;
  animation-iteration-count: infinite;
  animation-timing-function: ease;
  background-color: gray;
  color: white;
  width: 100px;
  height: 100px;
}

@keyframes transform-css {
    0% { transform: translate(0px); }
    100% { transform: translate(400px); }
}

要素が左から右へ400px移動する処理を transform に置き換えました。

Paint flashingを有効にした状態で比較してみると transformを使用した要素がリペイントされていないことが分かります。

f:id:j-horikawa:20210907121632g:plain

対象ブラウザの使用率によるバランスの良い設計が必要

GPUを使った処理が早いとは言え今回ご紹介した改善方法はアニメーションの描画に関する速度改善です。

GPUを使ったハードウェア・アクセラレーションを行うとメモリの使用率が高くなり端末への負荷が高くなります、改善によって新しいボトルネックが生まれないか慎重に確認を行いながらパフォーマンス改善を行うと良いでしょう。

最後に

ContractSはエンジニア・デザイナーを募集しています。 共にプロダクト作りを通して、社会・組織を良くしていきましょう! 興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

SpringBoot+Slackアプリでメッセージ通知を実装してみる

こんにちは、id:c-terashimaです。
技術書典11で無料配布している「Holmes Tech Book」ですが、多くの方にダウンロードしていただいております!ありがとうございます!!
ドメイン駆動設計やmiroアプリの作成などバラエティに富んだ内容になっていますので、ぜひダウンロードしていただけると幸いです。

techbookfest.org

さて、今回はJava(SpringBoot)からSlackへメッセージ送信を試してみました。

Slackアプリを作る

Slackとアプリのやり取りを行うためのアプリを作成します。作成するSlackアプリからDMが届くことを想定しています。

  1. SlackAPIのアプリページに移動して、「Create New App」をクリックします f:id:c-terashima:20210719152014p:plain
  2. 「App Name」に作成するアプリ名、「Pick a workspace to develop your app in:」に使用するワークスペースを選択し、「Create App」ボタンをクリックします f:id:c-terashima:20210719152450p:plain
  3. 「OAuth&Permissions」にアプリに許可させたい権限を付与させます。今回は「chat:write」を付与します f:id:c-terashima:20210719162232p:plain
  4. 「Redirect URLs」に認証時に呼び出されるエンドポイントを指定します。後述で作成する「/slack/oauth」を指定します。SSL化されたエンドポイントを指定する必要があるのでご注意ください。(画像はダミー) f:id:c-terashima:20210720094057p:plain
  5. 「App Home」に移動して「Display Name」と「Default username」を入力します f:id:c-terashima:20210719163418p:plain
  6. 「OAuth&Permissions」の設定が完了したら、作ったアプリをワークスペースにインストールします f:id:c-terashima:20210719162650p:plain
  7. 「Basic Information」に移動して「Client ID」と「Client Secret」をメモします。この2つはホームズクラウドなどのアプリケーションからメッセージを書き込む際に必要となります

アプリとの認証

サンプルは以下の環境で動作確認をしております。

  • Java8
  • SpringBoot 2.3

Slack SDK

build.gradledependencies に以下を追加してSlack SDKをインストールします。

dependencies {
    implementation 'com.slack.api:slack-api-client:1.7.1'
}

アプリケーションとの認証

Slackユーザとアプリケーションユーザの紐付けを行います。まずは認証用のボタンを表示します。

<a href="https://slack.com/oauth/v2/authorize?client_id=「Client ID」&scope=chat:write&state=「任意のデータ」">
  <img alt="Add to Slack"
    height="40"
    width="139"
    src="https://platform.slack-edge.com/img/add_to_slack.png"
    srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" />
</a>

client_id には作成したSlackアプリの「Client ID」を指定します。
state はSlackからアプリケーションの認証エンドポイントが実行された際に設定される値になります。認証用エンドポイントの呼び元がこのアプリによって実行されたのかをチェックするのに利用します。

次にSlackから呼ばれるエンドポイントを作成します。

@RestController
@RequiredArgsConstructor
@Slf4j
public class SlackController {
    private static final String STATE = "state_id";
    private final String slackClientId = "client_id";
    private final String slackClientSecret = "client_secret";

    @GetMapping("/slack/oauth")
    @SneakyThrows
    public ResponseEntity<Void> oauth(@RequestParam("code") String code, @RequestParam("state") String state) {
        // stateの確認
        if (!Objects.equals(state, STATE)) {
            log.error("stateが一致しません。{}", state);
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        try (Slack slack = Slack.getInstance()) {
            // Slackアプリとの認証
            OAuthV2AccessResponse response = slack.methods()
                    .oauthV2Access(req -> req.code(code).clientId(slackClientId).clientSecret(slackClientSecret));

            if (!response.isOk()) {
                log.error("認証に失敗しました。{}", response.getError());
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
            }

            // SlackユーザID、Slackアプリのトークンを取得
            // メッセージを送信するのに必要になるため、DBかファイルに保存しておこう
            String slackUserId = response.getAuthedUser().getId();
            String slackAccessToken = response.getAccessToken();
        }

        // 認証後のリダイレクト先を指定する
        HttpHeaders headers = new HttpHeaders();
        UriComponentsBuilder builder =
                UriComponentsBuilder.fromUri(URI.create("http://localhost:8080/slack"));
        headers.setLocation(URI.create(builder.toUriString()));

        return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
    }
}

「Add to Slack」ボタンをクリックすると次の画面が表示されますので、「許可をする」ボタンをクリックすることで上で作ったエンドポイントが呼び出されます。
f:id:c-terashima:20210719184234p:plain

Slackへ通知

通知するメッセージはJSON形式で指定します。(Block Kit Builder)https://app.slack.com/block-kit-builder/T3HBJHYRHを利用するとかんたんに作ることができます。
以下のメッセージを送ってみたいと思います。
f:id:c-terashima:20210719185834p:plain

認証を行ったユーザにメッセージを送るソースは次になります。

private String slackAccessToken = "accessToken";
private String slackUserId = "userId";

@SneakyThrows
public void postApprovalRequestMessage() {
    try (Slack slack = Slack.getInstance()) {
        MethodsClient client = slack.methods(slackAccessToken);

        ConversationsOpenResponse openResponse =
                client.conversationsOpen(req -> req.users(Arrays.asList(slackUserId)));
        if (!openResponse.isOk()) {
            throw new Exception("DMを開くことができませんでした");
        }

        String message =
                "[\n" +
                "    {\n" +
                "        \"type\": \"section\",\n" +
                "        \"text\": {\n" +
                "            \"type\": \"mrkdwn\",\n" +
                "            \"text\": \"こんにちは!メッセージを送るよ!!\"\n" +
                "        }\n" +
                "    }\n" +
                "]\n";


        ChatPostMessageResponse messageResponse = client.chatPostMessage(req ->
                req.channel(openResponse.getChannel().getId())
                .blocksAsString(message)
        );
        if (!messageResponse.isOk()) {
            throw new Exception("DMを送ることができませんでした." + messageResponse.getError());
        }

        ConversationsCloseResponse closeResponse = client.conversationsClose(req ->
                req.channel(openResponse.getChannel().getId()));
        if (!closeResponse.isOk()) {
            throw new Exception("DMを閉じることができませんでした");
        }
    }
}

実行するとメッセージが!!
f:id:c-terashima:20210719191910p:plain

まとめ

Slackは情報も豊富でかんたんにDMを送ることが出来ました。
Block Kitのテンプレートを見ていただくとボタンやテキストボックスをメッセージで送ることができますので、ぜひ色々試していただければ楽しいと思います。
参考にさせていただいた資料は下記に載せておきます。

参考資料


Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com

#技術書典 11に参加します!

こんにちは、id:c-terashimaです。

今週末に開催される技術書典11にHolmes開発部の有志メンバーで参加させていただきます!

techbookfest.org

DDDやmiroアプリ作成、E2EテストにPM視点と盛りだくさんとなっております。
価格は無料を予定しておりますので、ぜひお手に取っていただきホームズクラウドの開発裏話を楽しんでいただけたらと思います。
乞うご期待!

techbookfest.org

Holmesのとあるスクラムチームのとある一週間

Holmesでエンジニアをしている三澤です。

Holmesではアジャイル開発を採用しており、開発部には複数のスクラムチームが存在しています。 私はその中のWakaruチームに所属しているのですが、日々、ホームズクラウドが分かりやすいものになるよう改善を続けています。

今回はWakaruチームの一週間の活動を通して、開発メンバーに焦点を当てて、どのようにスクラム開発を行っているのか、その雰囲気をお伝えしたいと思います。

前提として、Holmesの各スクラムチームはコンポーネントチームではなく、フィーチャーチームとして構成されています。つまり、開発の企画からリリースまでを一貫して行う機能を持ったチームになっています。具体的に言うと、Epic作成→PBI作成→デザイン→実装→テスト→リリース(※一部Opsチームに依頼)をチーム内の作業で完結させています。

  • チーム構成とスプリントについて
    • 役割
    • スプリントについて
    • 主なスクラムイベント
  • 一週間の流れ
    • 水曜日(午後)
      • 15:30〜18:00 スプリントプランニング
    • 木曜日
    • 金曜日
    • 月曜日
      • 各メンバーの個別作業
    • 火曜日
      • 11:00〜12:00 PBIのリファインメント
      • 各メンバーの個別作業
    • 水曜日(午前)
      • 9:30〜10:30 スプリントレビュー
      • 10:30〜12:00 スプリントレトロスペクティブ
  • まとめ
続きを読む

TestCafeによるPage Object Patternの実装

こんにちは。Holmesでエンジニアをしている山本です。

以前TestCafeについて調べた際、TestCafeでもPage Object Patternが利用できることが分かりました。

E2Eテストとしての導入を検討しているため、簡単にではありますが、TestCafeによるPage Object Patternの実装を試してみました。

実行環境

名前 バージョン
OS Windows 10 Pro 64bit バージョン2004
Node.js 14.16.1
Yarn 1.22.5
TestCafe 1.14.0
TypeScript 3.9.7

Page Object Patternの概要

テスト対象となるページへのインターフェイスとなるオブジェクトまたはクラスを作成し、テストケースのコードと分離する手法です。

UIをページオブジェクトで隠蔽し、操作をメソッドとしてテストケースに提供することで、UIに変更があってもページオブジェクトの変更のみで対応できるようになります。

Seleniumを用いたテスト自動化において、コードの重複を減らしてメンテナビリティを高めるためのデザインパターンとして広まりました。

JavaSeleniumラッパーであるSelenideで推奨されており、GroovyのSeleniumラッパーであるGebでもファーストクラスとしてサポートされています

近年人気のCypressのブログでは、Page Object Patternの問題点を指摘されていますが、少なくともテストを都度スクリプトとして記述するよりも、メンテナンス性は高くなると思います。

TestCafeにおけるPage Object Patternの実装

ガイドページに、Page Modelとして実装例が記載されています。

今回は、弊社プロダクトであるホームズクラウドの、2021年4月末時点でのログイン画面を対象として、ページオブジェクトおよびテストケースを実装していきます。

TestCafeではJavaScriptおよびTypeScriptでテストを実装できますが、今回は型の指定が可能なTypeScriptを利用します。

実装対象画面

ログイン画面の初期表示は、ログインID入力欄とソーシャルログイン用のリンクが表示されます。

f:id:h-yamamoto_holmescloud:20210506193738p:plain
ログイン画面の初期表示

ログインIDを入力して「次へ」ボタンをクリックすると、パスワード入力欄が表示されます。

f:id:h-yamamoto_holmescloud:20210506195835p:plain
ログイン画面のパスワード入力欄

パスワードを入力し、「ログイン」ボタンをクリックすると、ログインIDとパスワードが正しければ、ダッシュボード画面に遷移します。

また、ログインの導線以外に、パスワードリセット画面へのリンクが表示されます。

ページオブジェクトの実装

まず、 pages/login.ts として、以下のファイルを作成します。なお、今回はソーシャルログインに関する要素を除外しております。

import { Selector, t } from 'testcafe';

export default class LoginPage {
  private url = 'http://localhost:8080/login';

  private loginIdInput = Selector('#username');
  private nextButton = Selector('.footer button[type=button]');

  private passwordInput = Selector('#password');
  private loginButton = Selector('.footer button[type=submit]');

  private alert = Selector('.alert');
  private passwordResetLink = Selector('a[href="/reset"]');

  async open(): Promise<void> {
    await t.navigateTo(this.url);
  }

  async toPasswordReset(): Promise<void> {
    await t.click(this.passwordResetLink.filterVisible());
  }

  async typeLoginId(loginId: string): Promise<void> {
    await t.typeText(this.loginIdInput, loginId).click(this.nextButton);
  }

  async doLogin(loginId: string, password: string): Promise<void> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
  }

  async hasError(): Promise<boolean> {
    return await this.alert.exists;
  }
}

実装例ではコンストラクタ内でSelectorを設定していましたが、TestCafeのSelectorは遅延評価されるため、privateなフィールドとして初期化しています。

UIの各要素を隠蔽し、操作をメソッドとして提供しています。また、初期ページのためURLが必要ですが、それもページオブジェクトに持たせています。

TestCafeのテストにはTestControllerが不可欠なため、当初はSelenium系のページオブジェクトを作る場合と同様、コンストラクタ引数でTestControllerを引数として受けてやる必要があるかと思ったのですが、TestCafeの場合、TestControllerをimportするだけで、適切なコンテキストが解決されるため、非常にシンプルになりました。

テストケースの実装

作成したページオブジェクトを用いて、実際のテストケースを記述していきます。TestCafe実行時に指定するファイルはこちらになります。

今回は、 features/login.ts として以下のファイルを記述します。

import { t } from 'testcafe';
import LoginPage from '../pages/login';

fixture `ログイン画面`;

// 実際は、IDやパスワードは外部ファイルから読み込みます
const loginId = 'test@example.com';
const password = 'test-password';

test('ログインするとダッシュボードが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, password);

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/ws/dashboard');
});

test('ログインID入力前にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('ログインID入力後にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId(loginId);
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('存在しないログインIDを入力すると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId('notfound-loginId@example.com');

  await t.expect(await loginPage.hasError()).ok();
});

test('パスワードを間違えていると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, 'invalid-password');

  await t.expect(await loginPage.hasError()).ok();
});

実際のテストケースの流れを記述していきます。入力する値などもこちらで制御し、ページオブジェクトに渡すようにします。

今後の実装予定

今回はログイン画面のテストの例に対象を絞ったため、パスワードリセットやログイン後のページ遷移先ではURLを確認しただけですが、ページ遷移を伴うメソッドでは、遷移先のページオブジェクトを生成し、それを返すように実装していきます。

今回の例でいうと、 pages/reset.tsPasswordResetPagepages/ws/dashboard.tsDashboardPage を追加し、 LoginPage#toPasswordResetLoginPage#doLogin の戻り値としてそれぞれ返すイメージです。該当部分だけ抽出すると、以下のようになります。

export default class LoginPage {

  async toPasswordReset(): Promise<PasswordResetPage> {
    await t.click(this.passwordResetLink.filterVisible());
    return new PasswordResetPage();
  }

  async doLogin(loginId: string, password: string): Promise<DashboardPage> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
    return new DashboardPage();
  }

}

また、ログイン後の画面には、ヘッダやフレームといった共通コンポーネントが存在します。

それらもページオブジェクトとして実装し、継承やCompositeパターンを用いることで、各ページオブジェクトでのUIや操作を共通化できます。

class Header {
  ...
}

class DashboardPage extends Header {
  ...
}

感想

Page Object Pattern自体は歴史もあり、枯れたパターンであるため、特に違和感なく実装できました。

ただ、TestCafeの場合、どうしても処理の実行に asyncawait が必要になるため、以下の2点が気になりました。

  1. ページオブジェクトを生成したタイミングでのURL遷移が難しい
    • コンストラクタに async を付与できないため、今回はページオブジェクト側でURL遷移を行わせるメソッドを用意しました
    • static なファクトリーメソッドを作成し、それに async を付与することは可能ですが、やや煩雑になります
  2. Promise で値を返す必要があるため、メソッドチェーンが書きにくく、1行ずつ await して実行していく記述になりがち
    • TestController のように、 this | Promise<any> を返すようにすればできるかもしれませんが、テスト記述としては実装難度が高そうです

とはいえ、 async/await の頻出はJavaScriptベースのE2Eテストツールでは仕方のないことかと思います。また、E2Eテストはメンテナンスが難しいため、書きやすさよりも読みやすさを重視すべきですが、どちらのケースでも行単位で処理を実行していくことになるため、結果的にテストケースの可読性が上がることにもつながるかと思いました。

参考ページ

最後に

Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com