Holmes開発者ブログ

契約マネジメントシステム「ホームズクラウド」の開発者ブログです

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

SpringBootAdminでかんたん見える化

こんにちは、サーバサイドエンジニアをしているid:c-terashimaです。
ホームズクラウドはSpringBootをメインフレームワークとして採用して複数のAPIサービスが稼働しています。
これからも増えていくが予想されるAPIサービスを管理運用していく上で、以下の課題がありました。

  • 障害発生時のログ調査権限がSREチームメンバーのみで運用負荷が高い
  • JVMが適切に稼働しているかリアルタイムに確認したい
  • どのバージョンのアプリケーションがデプロイ・稼働しているのか確認したい
  • サーバが増減しても低コストでかんたんに運用したい

これらを解決するのにSpringBootAdminが適していそうなので導入を検討していきます。

SpringBootAdminとは

SpringBootAdminはSpringBootで作られたアプリケーションの管理者向けのGUIツールです。
以下の情報を可視化することができます。

  • 各種メトリクスの表示
    • JVMとメモリ
    • データソース
    • キャッシュ
  • ビルド番号の表示
  • ログファイルのリアルタイム表示とダウンロード
  • ログ出力レベルの変更
  • JVMパラメータや環境設定プロパティの表示
  • SpringBoot構成プロパティの表示(application.yml)
  • スレッドダンプの表示
  • http-tracesの表示
  • endpointsの表示
  • スケジュールされたタスクの表示(SpringBatchやTaskschedulerなど)
  • SpringSessionの表示や削除
  • Flywayなどのマイグレーション状況の表示
  • heapdumpのダウンロード
  • イベントの通知(メール、Slackなど)

SpringBootAdminサーバの作成

導入は非常にシンプルで公式に書いてあるとおりに設定すればOKです。
新規にSpringBootアプリケーションを作成し、bild.gradleにSpringBootAdmin Serverの依存を追加し@EnableAdminServerアノテーションを付与するだけです。
application.ymlを作成して任意のポートを指定しておきます。server.port=9000

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("de.codecentric:spring-boot-admin-starter-server")
}
@SpringBootApplication
@EnableAdminServer
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

SpringBootAdminクライアントの設定

監視したアプリケーションにSpringBoot Actuatorを依存に追加し、application.ymlでエンドポイントを制御してあげるだけで設定はOKです。
非常にかんたんですね!

// 抜粋
dependencies {
    implementation "org.springframework.boot:spring-boot-starter-actuator"
    implementation "de.codecentric:spring-boot-admin-starter-client:2.3.1"
}

management以下は監視する情報を制御する設定になります。
Spring Boot Admin Reference Guideで監視したい設定を指定してください。
以下はすべてを公開する設定になっています。

spring:
  application.name: アプリケーションの名前     // SpringBootAdminサーバに表示させるアプリケーション名
  boot.admin.client:
    url: http://localhost:9000

management:
  server:
    port: 9001
  endpoints:
    web:
      exposure:
        include: "*"  
  endpoint:
    health:
      show-details: ALWAYS

スクリーンショット

http://localhost:9000にブラウザで接続すると管理コンソール画面が表示され、クライアントの情報が可視化されたと思います!

f:id:c-terashima:20210511185957p:plain f:id:c-terashima:20210511190016p:plain f:id:c-terashima:20210511190037p:plain f:id:c-terashima:20210511190050p:plain

まとめ

非常にかんたんな設定だけで構築することができ、たくさんの情報を可視化することができました。
その反面、セキュリティをしっかりしておかないと重大な事故につながる危険性もあり、監視する項目はしっかりと見極めないといけないことがわかりました。
皆様の導入の参考になれば幸いです。


Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

テストコードに関する疑問とその個人的アンサー

holmesの倉島です。テストコード記述に関連する個人的な疑問点を、意見・考えをまとめて自分なりの回答を出してみました。

Q1: TDDで実装中に、別のテスト記述可能なメソッド(publicメソッド等)を実装したくなった。これもテスト対象?

TDDのred→greenの実装中に「あれ?次に書く予定のメソッドもTDDで実装した方が良いのでは?」と、ふと思い作業の手が止まってしまったので考えてみました。
結論を述べると、TDDでは実装しないで、書く予定のメソッドで他に試したいパターンがあるなら記述後に改めてテストコードを書く でまとまりました。理由としては、本筋のコードで通るメソッドであるため、ある程度の担保はされているからですね。

Q2: コンストラクタのテストコードは必要?

コンストラクタ内に分岐があり想定パターンが複数存在する場合はテストコードを記述するのはアリだと思います。
ただ想定パターンが複数存在しない場合、コンストラクタはいずれかのテストコードの範囲内の筈かつ、コンストラクタは言語(Lombok等を使用しているのであればライブラリ)の機能で信頼性が高いのでテストコードは不要だと思います。

Q3: テストの結果として想定している値は複数でも良い?

一つのテストコード内で(結果として判断できる値は)一つであることが望ましい。理由としては、複数の値を結果として想定していると手前で落ちた場合その後の値が評価されないからですね(アサーションルーレットアンチパターン)。ただ例外として、まとめて審査しないと意味がないもの(インテグレーションテスト等)は結果として複数想定する場合があります。

Q4: TDDによる実装後のTODOリストの扱い

捨てます。TODOリストは飽くまでTDDを行うための道標であって成果物ではないことと、自分が良い・解りやすいと思ったテストコードを記述できているのであれば(テストコードにTODOリストの内容が落とし込めているため)TODOリストは不要だからですね。

Q5: テスト結果の検証を行う時 assertEqualsのように、 ===== を使用しないでメソッドを使用して比較する理由は?

言語によっては=====を使用した比較結果が異なる場合があるので、なるべくテスティングフレームワークのassertionに任せた方が良い、という理由ですね。
また余談として...、検証処理自体をテスティングフレームワークに移譲することによって、例えばテストが失敗した場合に、expectedとactualの差分をわかりやすく表示してくれたりなどのメリットがあります。

(最後に)おまけ

TDDを知って約1年半が経過しました。未来の自分に向けて、何故自分がTDDを続けているかの理由を少しだけ分析・記述しておきたいと思います。
恐らく一番の理由としては、単純に楽しいと思っているからですかね。TDDはred→green→リファクタリング のサイクルを回して実装を行う開発手法です、段々にステップを踏んで着実な一歩/前進を感じることができるので「楽しい」と思っている ですね。

Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

ドメインサービスを書く時の判断基準と大事にしていること

こんにちは。Holmesでサーバーサイドエンジニアをしている友野です。例年ひどい花粉症ですが、医者に処方してもらった薬で今年は快適に過ごせています。薬すごい。

ドメイン駆動設計(以降、DDD)を実践する上で悩みどころは色々ありますが、中でもドメインサービスをいつ、どのように使うかは特に難しい問題です。

今回は、開発時にドメインサービスを使うかどうかを判断するポイントを整理しておこうと思います。 現時点での整理であり、これがいかなる場面においても正解であるとは思いませんが、少なからず社内理解は進んでいます。

TL;DR

弊社ではドメインサービスの使い所として、そのふるまいが以下のいずれかに該当するかを判断基準としています。

  1. 複数集約間で整合性を担保する必要があるか
  2. 単一集約でも複数インスタンスを扱うか
  3. 実装したいドメインルールが外部サービスと連携する表現をした方が自然か

そしてなにより大事なのは、ビジネスルールとして名前がつけられるかどうかです。

背景−ドメインサービスを使わない選択

こちらの記事の通り、ホームズクラウドはSpring MVCに則ったパッケージ構成で構築されており、データクラスとサービスクラスの組み合わせでビジネスルールを実現していました。

この考えのままドメインサービスを適用しようとすると、手続き的なロジックがドメイン層に移るだけでドメイン貧血症に陥りやすい、というのはよく知られています。DDD原典である「エリック・エヴァンスのドメイン駆動設計(牧野 祐子 牧野 祐子 今関 剛 今関 剛 今関 剛 和智 右桂 和智 右桂 Eric Evans)|翔泳社の本(以降、Evans本)」でも、ドメインサービスに頼りすぎるのは良くないとする記述もあります。

サービスは節度を持って使用すべきで、エンティティと値オブジェクトからすべてのふるまいを奪ってはならない。

ユースケース層でドメインオブジェクトを組み合わせれば、ある程度のビジネスルールが表現できることもあり、積極的にドメインサービスは使用していませんでした*1。なにより、何かしらの基準を設けないとドメイン貧血症になる懸念がありました。

一方で、アーキテクチャを含めた書き換えをしていく中で、いよいよ表現が難しくなってくるシーンが出てきました。試験的にドメインサービスで書いてみようと一部メンバーと試行錯誤を始めて、手続き的なロジックが徐々に生まれてきたのもこの時です。本記事のモチベーションは、過去を振り返り、同じ徹を踏まないようにすることです*2

ドメインサービスの定義

まずは定義を確認します。Evans本では以下のように定義*3されています。

サービス

…概念的にどのオブジェクトにも属さないような操作が含まれることがある。強引に決着をつけるのではなく、問題領域に引かれる自然な輪郭にしたがって、モデルの中に明確にサービスを含めればよい。
<中略>
サービスとは、モデルにおいて独立したインタフェースとして提供される操作で、エンティティと値オブジェクトのようには状態をカプセル化しない。

また、「実践ドメイン駆動設計(ヴォーン・ヴァーノン 髙木 正弘)|翔泳社の本(以降、Vernon本)」では以下のように解説されています。

第7章 サービス

ドメインにおけるサービスとは、そのドメインに特化したタスクをこなす、ステートレスな操作のことだ。実行すべき何かの操作があって、それを集約や値オブジェクトのメソッドにするのは場違いだと感じたときは、ドメインモデルの中でサービスを作るべきだと考えられる。

対象のソフトウェアが実現すべきビジネスルールのうち、ドメインオブジェクトに持たせるには不自然なふるまいはドメインサービスに持たせる、と理解できます。

具体例に挙げられているものから考えてみる

不自然なふるまいとは一体どんなものがあるのでしょうか。プロダクションコードで利用するためにもっと解像度を高めていく必要があります。いくつかの書籍の例をピックアップしてみます。

これらを俯瞰してみると、以下のようなケースに使われることがありそうです。

  1. 単一/複数集約の複数インスタンス間にまたがって実現するケース(資金振替、メールアドレス重複チェック、拠点間の輸送)
  2. その処理過程・順序そのものがビジネスルールであるケース(認証の仕組み隠蔽)
  3. インタフェースを通じてインフラ層のふるまいと組み合わせるケース(メールアドレス重複チェック

これらのケースを踏まえて、どうやってドメインサービスを実装していくかを考えてみます。

考えるポイント

プロダクトに適用した際に考えたポイントは以下の通りです。

  1. 複数集約間で整合性を担保する必要があるか
  2. 単一集約でも複数インスタンスを扱うか
  3. 実装したいドメインルールが外部サービスと連携する表現をした方が自然か

以下、例を挙げながら、1つずつ見ていきます。サンプルコードは読者の方にイメージしていただくためのものであり、弊社プロダクトにおける実際のビジネスルールを正確に表したものではありません。また、弊社プロダクトの開発環境に合わせ、Javaで記述しています。

複数集約間で整合性を担保する必要があるか

最もドメインサービスの価値を発揮すると考えています。どちらかの集約にビジネスルールを書いたとしても、もう片方の集約のことを知らないとそのルールが実現できないのはやはり不自然です。かといって、そこで集約を1つにまとめるのはもっと不自然ですし、とても大きな集約になる懸念もあります。

複数集約間の整合性担保については、松岡さんのブログ記事にも記載がある通り、非常に分かりやすく、まさにドメインサービスの提供価値だと思います。

little-hands.hatenablog.com

弊社プロダクトのコアドメインである契約ドメインでいえば、例えば「業務委託契約の更新を行うために、今年度分の契約書に押印する」はこのケースに当てはまります。集約の分割は別テーマになるため詳細は省きますが、契約と契約書を同一集約としてモデリングすることも可能とは思います。ただ、異なる概念・異なるライフサイクルのため、別集約とする方が弊社プロダクトにとって、よりドメインに沿った考え方です。契約ライフサイクルの考え方は次の記事を参照ください。

www.holmescloud.com

契約は当事者間の合意によって成り立つもので、基本的にはそれを文書化したものが契約書です。しかし、必ずしも契約書がなくとも、口頭や覚書で契約を更新することも可能ですし、複数契約書によって1つの契約を結ぶこともあります。いずれにせよ、契約は何かしらの合意を示すエビデンスによって更新されるものです。今回はその中でも分かりやすい契約書の例で進めます。

f:id:a-tomono:20210328231352j:plain
契約書に押印して契約を更新する

契約書の押印により契約を更新するドメインサービスの、必要最小限の要素のみのサンプルは以下の通りです。

@RequiredArgsConstructor
public class ContractDomainService {
    // 契約書のリポジトリインタフェース
    // フレームワークによってDIされる
    private final DocumentRepository documentRepository;
    // 契約のリポジトリインタフェース
    private final ContractRepository contractRepository;

    /**
     * 契約書による契約更新
     */
    public void renewContract(
            DocumentId documentId,
            DocumentPartyId documentPartyId,
            RenewDateRange renewDateRange) {

        // 集約の復元
        Document document = documentRepository.findById(documentId);
        Contract contract = 
                contractRepository.findById(document.getContractId());

        // 契約書の当事者(DocumentParty)が押印する
        document.signBy(documentPartyId);

        // 契約期間を引数の期間で更新する
        contract.renew(renewDateRange);

        // 状態を変えた集約の永続化
        documentRepository.save(document);
        contractRepository.save(contract);
    }
}

処理詳細は置いておくとして、2つの集約の状態が変わることで実現できるビジネスルールであることが表現できていると思います。状態変更は、それぞれの集約のふるまいに移譲するので、ドメインサービス自身はステートレスです。

ドメインサービス内で、リポジトリ等のインタフェースを利用することについては賛否あると思います。Evans本、Vernon本ではそれぞれ以下のような記述があります。

サービスと隔離されたドメイン層(Evans本)

ドメイン層とアプリケーション層のサービスは、これらインフラストラクチャ層のサービスと協力して動作する。例えば、ある銀行のアプリケーションでは、口座残高が一定限度額を下回ると、顧客に電子メールを送信するかもしれない。この電子メールシステムはインタフェースによってカプセル化され、場合によっては、代わりの通知手段もそこに含まれるかもしれない。このインタフェースが、インフラストラクチャ層のサービスである。

7.3 ドメインにおけるサービスのモデリング(Vernon本)

ドメイン内のサービスは、必要に応じてリポジトリを使える。しかし、集約のインスタンスからリポジトリにアクセスすることは、お勧めできない。

これらを踏まえて、ホームズクラウドではドメインサービス内でのインタフェース利用を良しとしています。また別のサンプルとして、集約のインスタンスを引数に受け取り、直接状態を変えることも考えられます。こちらの方がシンプルに書けますね。

public class ContractDomainService2 {
    /**
     * 契約書による契約更新
     */
    public void renewContract(
            Document document,
            Contract contract,
            DocumentPartyId documentPartyId,
            RenewDateRange renewDateRange) {

        // 契約書の当事者(DocumentParty)が押印する
        document.signBy(documentPartyId);

        // 契約期間を引数の期間で更新する
        contract.renew(renewDateRange);

        // 集約の永続化はユースケース層で行う
    }
}

単一集約でも複数インスタンスを扱うか

ユーザーの存在チェック、重複チェックドメインサービスの例としてよく聞きます。同一集約でも任意のインスタンスが他のインスタンスの状態を知らないのでイメージはしやすいですが、この例だけだとプロダクトに適用するイメージはあまり持てませんでした。

こちらも契約ドメインの例を考えると「任意の契約書のレビュー依頼がすでにされているかどうか」がこのパターンに当たりそうです。ドメインモデルとサンプルコードは以下の通りです。

f:id:a-tomono:20210326001332j:plain
レビュー依頼は契約書につき同時に1つのみ依頼できる

@RequiredArgsConstructor
public class RenewRequestDomainService {
    // レビュー依頼のリポジトリインタフェース
    private final ReviewRequestRepository reviewRequestRepository;

    /**
     * レビュー依頼済みの契約書か判定
     */
    public boolean isAlreadyReviewRequested(DocumentId documentId) {

        // リポジトリインタフェース経由で集約を取得する
        Optional<ReviewRequest> reviewRequest =
                reviewRequestRepository.findByDocumentId(documentId);

        // すでにレビュー依頼がされている場合はtrue
        return reviewRequest.isPresent();
    }
}

リポジトリから集約を復元して真偽を判定しているだけなので、ユースケース層のふるまいとしても表現できそうです。ドメインサービスにするかどうかは、ビジネスルールとして名前がつけられるかどうかだと考えています。以下、Evans本の引用です。

モデルの言語を用いてインタフェースを定義し、操作名が必ずユビキタス言語の一部になるようにすること。

この場合は、契約書に対してレビューは常に1つしかない、というのが表現したいビジネスルールです。ユースケース層でリポジトリから復元してチェックするだけだと、アプリケーション上は同じふるまいですが、このルールは表現できなくなります。

実装したいドメインルールが外部サービスと連携する表現をした方が自然か

前述のEvans本からの引用部分、

ドメイン層とアプリケーション層のサービスは、これらインフラストラクチャ層のサービスと協力して動作する。

にて述べられているインフラストラクチャ層のサービスは、リポジトリだけではありません。

先日、ホームズクラウドは、契約書の締結にクラウドサイン連携を選択可能になりました(プレスリリース)。当事者に契約書の締結を依頼する締結依頼とクラウドサイン側のAPIをコールするリクエストを作る必要があります。この時、ドメインサービスからインタフェースを使います。

@RequiredArgsConstructor
public class ExecutionRequestDomainService {
    // クラウドサインAPIをコールするインタフェース
    private final CloudSignConnector cloudSignConnector;
    // 締結依頼のリポジトリインタフェース
    private final ExecutionRequestRepository executionRequestRepository;

    /**
     * クラウドサインを利用した締結依頼を発行する
     */
    public void issueExecutionRequestWithCloudSign(
                                        DocumentId documentId,
                                        CloudSignRequest cloudSignRequest) {

        ExecutionRequest executionRequest = 
                ExecutionRequest.initialize(documentId);

        // 必要な情報を渡して締結を依頼する
        cloudSignConnector.issue(cloudSignRequest);

        // 締結依頼を進行中にする
        executionRequest.inProgress();

        // 進行中にした締結依頼を永続化
        executionRequestRepository.save(executionRequest);
    }
}

ちなみに cloudSignConnector.issue() インタフェースの実装では、RestTemplate等でクラウドサインAPIをコールする、レスポンスモデルを変換するといった実装を書きます。

リポジトリインタフェースの例と同様で、こちらもビジネスルールとして名前がつけられるかどうかを重要視しています。

逆に、ユースケースの最後で集約の情報をログに出力する場合など、アプリケーションの関心事はビジネスルールとして名前がつけにくいので、ユースケースクラスからインタフェース経由で実装しています。

さいごに

ドメインサービスは使わない方が良い、というのはある意味で正しいと思います。MVCパターンからリアーキテクティングを試みると、多かれ少なかれドメイン貧血症に陥る可能性は高いですし、部分的には実際に陥りつつあります。

しかし、ドメインサービスを使わない選択をすることで、ビジネスルールが見つけられないとしたら、それは本末転倒な気がします。 ドメインルールとして名前をつけられるかどうかを注意深く考えながら、3つの判断基準に照らしてドメインサービスを書いていくと、最初の迷いは軽減できるはずです。

We're hiring

少しずつですが、HolmesでもDDDの考え方が浸透してきました。さらにスピードを上げて良いプロダクトを作るために、エンジニアを積極採用中です。DDDに関して知見や興味があり、一緒にHolmesの目指す世界観を作りたい方、是非力を貸してください!

Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

*1:ちなみに、Evans本ではドメインオブジェクト/ドメインサービスのクライアントはアプリケーションサービスですが、弊社ではユースケース層として同じ責務を定義しています。

*2:社内メンバーに説明するためでもあります。

*3:文中ではドメインサービスという言葉を使いますが、書籍ではサービスとして定義されています。

社内2人目のコードレビュアーになるためにやってきたこと

こんにちは!株式会社Holmesでエンジニアをしている平田です。最近日本酒にはまり、ネット販売のない酒屋のHPをスクレイピングして入荷したらLINEに通知することで、入手困難と言われる日本酒を飲むことができてエンジニア冥利に尽きる日々を過ごしています。

私は2020年初ごろからHolmesでコードレビュアーを担当しています。現在(2021/03)では開発部28名の中でレビュアー4名体制となりましたが、当時はテックリード1人しかレビュアーがいない状態で、各チームにとってはコードレビューがボトルネックになることがありました。また、レビュアーにとっても負担が大きく、本人のやりたいことに注力できていませんでした。 実際には他にもレビュアーとなれる人はいましたが、エンジニアリングマネージャーであったりと、役割やスピード感、仕様把握の観点から開発者でレビュアーを増やしたいという状況でした。

その中で自分がレビュアーとなるためにしたことを残そうと思います。現状のリソースの中でレビュアーを増やそうとしている組織やレビュアーになろうとしている人の参考になればと思います。

当時のチーム状況

当時の開発の状況は以下の通りでした。本格的な2チーム体制で、各チームがそれぞれミッションを持って開発に取り組んでいます。そのためボトルネックになりそうだというのが容易に想像できるのではないでしょうか。参考:現在(2021/03)のチーム状況はこちら*1

  • スクラム2チーム体制
    • チーム1
      • PO:1
      • SM:1
      • バックエンド:2(うち1名が当時唯一のレビュアー
      • フロントエンド:2
    • チーム2
      • PO:1
      • SM:1
      • バックエンド:1(
      • フロントエンド:2
      • デザイナー:1
      • SRE:1

やったこと

ここから私がレビュアーを任せてもらうために、やってきたことを紹介します。

現在のレビュアーから学ぶ

過去の指摘を振り返り

まずは現在のレビュアーの指摘がどのようなものか過去に振り返って確認して行きました。中にはドキュメントに落としづらいような経験からくる観点もあったりするので、話を聞いたりドキュメントを見るだけではなく、実際に行われた実績をみたり、一緒にレビューに立ち会ったりするのをお勧めします。

率先したレビュー指摘修正

並行して、レビュー指摘の修正を率先してやりはじめました。過去レビューの確認だけだと、本を読むのと一緒で手を動かしていないので、実際に指摘された内容の修正をしていくことで、同じことを繰り返さないようにして行きました。最終的にはレビュー依頼を出す前に私の方で確認をしてから依頼を出すという形式にして、指摘数を減らしていくことで、任せてもらえるようになりました。

プロダクトを学ぶ

私は特にここを重視しました。なぜなら私は当時エンジニア4年目のペーペーであり、経験豊富でテックリードを担っているレビュアーと肩を並べようというのですから経験で勝負するものではありません。

仕様に詳しくなる

プロダクトの仕様に詳しくないと、他の機能への影響や他の機能が及ぼす影響に気づくことができません。また、コードレベルでプロダクトを知ることも重要です。物によっては、nullが意味を持っている場合もあるでしょう。そういった(負債のような)違いにも気付き、リファクタリング(適切なdocを書くことも含め)していくことも私たちの組織には求められていました。

役割だけではない、視野の拡大

また、私の場合、様々な側面からプロダクトを見ていました。チーム体制で私はバックエンドと書いていますが、この頃からフロントエンドも兼任しています。また、デザイナーがチームにいたこともあり、UIUXの観点やデザイナーが作成したガイドラインの観点をコードレビューに活かしていました。具体的には、リクエストとレスポンスで空を期待するのかnullを期待するのかということであったり、ボタンの活性制御やエラー後の挙動などが正しく実装されているかというところまで見ていました。

設計を学ぶ

コードレビューの勉強というとリーダブルコードなど、コード自体の改善プラクティスを想像される方も多いのではないでしょうか。それらももちろん大切ですが、私は設計を学ぶことが大切だと思っています。設計論には様々なプラクティスが詰まっていますし、歴史的に何か課題を解決してきた経緯があると思っています。

バックエンド

Holmesでは現在ドメイン駆動設計(以下DDD)を取り入れて開発をしていますが、DDDをしていなくても、DDDが解決したい観点をコードレビューに活かす事はできるということです。

フロントエンド

フロントエンドでも設計パターンなど、特にHolmesでは現在Vue.jsへの移行を行っていますが、例えばVuexやReactの場合Reduxなどを使っていなかったとしてもVuexやRedux、Fluxを学び、また、それが生まれた背景や、そこに起因してMVC、MVVMなど以前からあるパターンを学ぶ事はできます。例えプロダクトが同じ設計パターンを用いてなかったとしても、それぞれの良い点・悪い点を知ることで、汎化してコードレビューに活かしていくことができます。

まとめ

以上が私がコードレビュアーとなるためにやってきたことです。 私がコードレビュアーになったことで、開発完了の定義を満たすまでチーム内で完結できるようになりました。2チームそれぞれがミッションを持ってプロダクトのアップデートに取り組んでいる中で、各チームが高速に開発サイクルが回せる体制ができました。また、レビュアーの負担が分散できたことで、テックリード(当時唯一のレビュアー)にさらなるアップデートの技術検証など、未来にかける時間に注力してもらえるようになってきました。

私自身レビュアーになったこともそうですが、バックエンド・フロントエンド双方に対して強みが持てるようになったので、チームの中でフレキシブルに必要なタスクを取れるようになり、大きく組織に貢献できるようになったと思います。また、Holmesでは新卒採用も行っているので、新入社員の書いたコードのレビューも担当するようになりました。教えるような機会が増え、さらなる自分自身の成長と組織貢献ができるようになりました。

現在は4名体制のレビュアーですが、レビュアーがレビューを受ける機会が少なかったり、レビュアーによって独自の観点を持っている部分もあるので、それらを改善していくのが今後の課題となります。

余談ですが、先日初めてOSSにPull Requestを出してレビューで指摘を受けたのですが、普段仕事で書くのとは違うコードだったりするので、新鮮なレビューが受けられて新しい学びが得られました。

最後に

Holmesでは、スタートアップのスピード感をレビュープロセスなどの品質担保で支えたいエンジニアを募集しています。 共にプロダクト作りを通して、社会・組織を良くしていきましょう! 興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

*1:2021/03:スクラム3チーム体制
・チーム1(PO:1、PM:1、SM:1、バックエンド:2、フロントエンド:3、SRE:1、デザイナー:1)
・チーム2(PO:1、PM:1、SM:1、バックエンド:2、フロントエンド:2、SRE:1、デザイナー:1)
・チーム3(PO:1、PM:1、SM:1、バックエンド:1、フロントエンド:1、SRE:1、デザイナー:1)

GitLabのIssueがリリースされると関連するSalesforceの要望リストをGASでSlackに通知させてみた

こんにちは。Holmesでプロダクトオーナーをしているid:w-miuchiです。

長々としたタイトルですが、3つのアプリケーションを連動させた例として紹介させてください。

背景

開発部以外の他部門から、リリースされる機能で解決する問題の把握が難しいという意見を頂きました。過去に投稿した要望が改善されたのかどうかがわからないとのことでした。

弊社では、プロダクトバックログ(以降PBI)をGitLabのIssueで管理しています。
また、内外問わずプロダクトに対する要望はSalesforceで管理しています。

リリース時に対象となるPBIを通知しているのですが、PBIを読み取るのが難しいという問題がありました。

改善案

PBIを作成する元となった要望を通知すれば把握が進むのではないかと考えました。
その際、PBIを一つ一つ開いて確認するのではなく、ある程度の一覧性は担保したいと思います。

Salesforceの要望にはIssueのURLが登録されています。
リリース時にはPBIとなるGitLabのIssueをCloseします。このタイミングで関連付いた要望を取得し通知することにしました。

構成

構成は下記通りです。

f:id:w-miuchi:20210313120400j:plain

当初Salesforceの要望リストはAPIで取得しようかと考えていましたが、Googleスプレッドシートに同期する方が早いことがわかりました。
こちらは後ほど記載します。

設定

1. GitLab -> Goole Apps Script

今回Goole Apps Script(以降GAS)に関しては、

github.com

を利用しました。 こちらに関しては本記事では省略させて頂きます。
自身のIDEを利用でき、コマンドラインでDeployまでできるのは非常に嬉しいです。

上記で発行したURLをGitLabのWebhookに設定するだけです。
今回はTriggerをIssues eventsのみ設定しました。

f:id:w-miuchi:20210313120801p:plain

2. SalesforceGoogle スプレッドシート

こちらはGoogleスプレッドシートのアドオンで可能です。 以下に沿って設定しました。

support.google.com

3. GAS -> Google スプレッドシート

Salesforceの要望を取得している関数が以下になります。
同期しているスプレッドシートのIDを指定して取得しています。
取得したスプレッドシートjsonに変換しています。

const getCustomerRequestList = (): object[] => {
  const sheet = SpreadsheetApp.openById('xxxxxxxxxxx');
  const rows = sheet.getDataRange().getValues() as object[];
  const keys = rows.splice(0, 1)[0] as any[];
  return rows.map(function(row) {
    const obj = {};
    row.map(function(item, index) {
      obj[String(keys[index])] = String(item);
    });
    return obj;
  });
}

4. GAS -> Slack

Slack APIを利用します。

tanuhack.com

こちら参考させていただきました。

ここでハマってしまったのがbotとしているためSlackチャンネルに登録したアプリケーションのインストールが必要なことでした。

1と3を含む全体の処理は以下のとおりです。
(中途半端なTypeScriptであることはご容赦くださいmm)

const doPost = (e: any) => {
  const postData = e?.postData ? JSON.parse(e.postData.getDataAsString()) : {} as object

  // issueの変更後ステータスを取得
  const currentLabels = postData.changes?.labels?.current || [] as object[]
  /// PB以外除外
  const pbLabel = getLabel(currentLabels, ['PB'])
  if (!pbLabel) return
  /// 開発開始、Sandboxリリース、本番リリースを対象
  const isClosed = postData.object_attributes?.state === 'closed'
  const statusLabel = getLabel(currentLabels, ['Doing', 'Sandbox'])
  if (!statusLabel && !isClosed) return

  // issueのIdを取得
  const issueId = postData.object_attributes?.iid as string
  /// 関連issueを取得
  const linkedIssueList = getLinkedIssueList(issueId)
  /// 対象issueIdを設定
  const issueIdList = [issueId].concat(linkedIssueList.map(linkedIssue => {
    return linkedIssue.iid
  }));

  // 要望一覧を取得
  const customerRequestList = getCustomerRequestList();
  // Issueに該当する要望を特定
  const issueCustomerRequestList = customerRequestList.filter(customerRequest => {
    return issueIdList.some(issueId => {
      return customerRequest.GitURL__c.match(new RegExp('/' + issueId + '$'))
    })
  })

  // Slackに要望のステータス変更を出力
  /// 出力テキストを作成
  const statusLabelText = isClosed ? 'Closed' : statusLabel?.title
  const noticeContent = createNoticeContent(postData.object_attributes?.title, statusLabelText, issueId, issueCustomerRequestList)
  /// Slack書き込み
  postSlack(noticeContent)
}

おまけ

コマンドやブラウザから実行した場合はログを出力してくれるのですが、 非同期で実行させた場合、ログを出力されません。 (方法があったかもしれませんが...) そのため以下ような処理でGoogle Drive上にログを出力しました。

const writeLogs = (content: string): void => {
  const folder = DriveApp.getFolderById('xxxxxxx')
  const contentType = 'text/plain' as string
  const charset = 'utf-8' as string
  const fileName = Utilities.formatDate(new Date(), 'JST', 'yyyyMMddHHmmss') as string

  const blob = Utilities.newBlob('', contentType, fileName + '.txt')
      .setDataFromString(content, charset)

  folder.createFile(blob)
}

f:id:w-miuchi:20210308085713p:plain

課題

実は大きな問題点があります。
Salesforceの要望にはIssueのURLが登録されている
と記載しましたが、そもそもこの運用が出来てないということです。。。
今回は技術検証を先に行いましたが、まだ本運用に至っておりません。。。

最後に

いかがだったでしょうか。
課題は残っておりますが、技術的に可能なことがわかりました。
複数のサービスをWebhook、Addon、APIを利用し連動させるのは楽しいですね。
部分的にでも参考になればなによりです。

頑張らずこだわるCSSアニメーション設定

こんにちは、株式会社Holmesでエンジニアをしている堀川です。

皆さんは普段フロントのインタラクションを作り込むときアニメーションにどのくらいこだわってますか?

アニメーション速度を操作するtransitionというプロパティは親の顔より見ていると思いますが、普段業務を行う中で細かいアニメーションの動作にコストをかけられない悩みがあるのではないかと思います。

そこで今回、なるべくコストをかけずにアニメーションにこだわれる animation-timing-function の使い方をご紹介させていただきます。

animation-timing-function

animation-timing-function プロパティは要素にキーフレームアニメーションを適用する場合のタイミングや進行を指定することが出来ます。

代表的な値

ease

アニメーションの中央に向けて変化量が増加し、最後に向けて減少します。

linear

等しい速度でアニメーションします。

ease-in

アニメーションの変化の速度はゆっくり始まり、終了まで加速します。

ease-out

アニメーションは速く始まり、速度を落としながら続きます。

ease-in-out

プロパティのアニメーションはゆっくり変化し、速度を上げ、また速度を落とします。

使用例

HTML

<body>
  <div class="animation ease">ease</div>
  <div class="animation linear">linear</div>
  <div class="animation ease-in">ease-in"</div>
  <div class="animation ease-out">ease-out</div>
  <div class="animation ease-in-out"> ease-in-out</div>
</body>

CSS

.animation {
  animation-name: anime;
  animation-duration: 3s;
  animation-iteration-count: infinite;
  background-color: gray;
  color: white;
}

.ease {
  animation-timing-function: ease;
}

.linear {
  animation-timing-function: linear;
}

.ease-in {
  animation-timing-function: ease-in;
}

.ease-out {
  animation-timing-function: ease-out;
}

.ease-in-out {
  animation-timing-function: ease-in-out ;
}

@keyframes anime {
  0% {
    width: 100px;
    height: 50px;
  }
  100% {
    width: 600px;
    height: 50px;
  }
}

実行結果

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

アニメーションを楽に設定したい

f:id:j-horikawa:20210217095631p:plainまずイメージに近い animation-timing-function の値を予め設定してChrome DevToolsで要素を確認してください。

animation-timing-function の値にアイコンが付いています、そちらをクリックすると cubic bezier editor が開きます。

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

左の3つはベジェ曲線の基本型を選択するもので、下はそこから複数のパターンを選択できるのでお気に入りのイージングが見つかるでしょう。

イージング関数のチートシートもあるのでこちらも参考にしてみてください https://easings.net/ja

アニメーションをもっと細く設定したい

そんな貪欲なあなたにはイージングを細かく設定できるcubic-bezierをおすすめします。

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

cubic bezier editorでは紫色の丸を動かすことによってアニメーションの微調整が行えます。

調整を行うとイージング名が表示されていたところがcubic-bezier(?,?,?,?)形式に変わるはずです、その値をそのまま適用したい animation-timing-function に設定してみましょう。

.custom {
  animation-timing-function: cubic-bezier(0.9, 0.54, 0, 0.83)
}

いかがでしたでしょうか? 適用したいイージングをチートシートで見つけて、細かい調整をChrome DevTools上で行うことによってご機嫌なアニメーションライフがおくれることでしょう。

最後に

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

lab.holmescloud.com

lab.holmescloud.com