ContractS開発者ブログ

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

Selenium IDEでイチからUIテストを作成してみる

本記事は2020年5月20日現在の情報をもとに作成しています。

はじめに

こんにちは。
株式会社Holmesでエンジニアをしている id:yoshiJ です。

Holmesはプロダクトの機能追加を確実かつ迅速にリリースを行うためにUIテストの自動化を推進しています。

UIテストとは…
UIテストは、プロダクトのユーザーインターフェイスが正しく機能しているかを確認するテストです。
画面上で適切なアクション実行され、データが表示され、インターフェイス状態が期待どおりに変化するかどうかのチェックを主眼においています。

手動で画面を操作して動作確認を行うUIテストは非常に時間がかかるため、これを自動化することでリリースに掛けるリードタイムを大幅に短縮することができ、大きなメリットとなります。
その反面、自動化したテストは画面の更新があるとそれまで使っていたものが動かなくなったりすることが多々あるのでメンテナンス性の良さが求められます。

そのUIテスト自動化の一環として弊社ではSelenium IDEというツールを使い始めたので、本日はそのツールの導入から実際に使用するところまでの紹介ができればと思います。

Selenium IDEとは?

Selenium IDEとは、SeleniumHQより提供されているオープンソースWebブラウザ操作ツールです。
現在ではChromeFireFox向けに拡張機能として展開されています。
(下記リンクのクリックで各ブラウザで提供されているストアページに遷移します。)
Chrome版Selenium IDE
FireFox版Selenium IDE

Selenium IDEの最大の特徴は、プログラムを直接書かずともブラウザ上の操作ベースで動作の記録、実行が行える部分にあります。
日々のルーティンとなっている入力操作の自動化からHolmesが採用しているようなUIテストまで使い方によって様々な応用がきくスグレモノです。
非エンジニアでもブラウザ操作のみで完結できる手軽さがあるため、導入ハードルは非常に低いと言えるのではないでしょうか。

今回はChromeの操作ベースで解説を行います。

Selenium IDEChromeに追加する

  1. Selenium IDEのストアページにアクセスし、画面上のChromeに追加ボタンを押下

    f:id:yoshiJ:20200520234647p:plain
    ストア画面

  2. 追加を行うかの確認メッセージが出てくるのでそのまま追加を続行 f:id:yoshiJ:20200520235236p:plain

  3. 追加が完了した通知とSelenium IDEを立ち上げるためのアイコンがウィンドウ右上に出現します。

    f:id:yoshiJ:20200521000022p:plain
    ウィンドウが暗色になるテーマを使用していると非常に見づらいアイコンです…

導入はこれで終わりです。拡張機能として提供されているので導入は非常に簡単ですね。

テストの作成(記録)

今回はGoogleでHolmesのホームページを検索してアクセスする作業をテストとして登録する手順で解説します。

  1. まず、導入時に追加されたアイコンをクリックしてSelenium IDEを起動します。立ち上がると別ウィンドウで開かれ、Selenium IDEのスタートメニューが表示されます。 f:id:yoshiJ:20200521001717p:plain
  2. メニューの中からRecord a new test in a new projectを選択し、プロジェクトの命名を行います。好きな名前を付けたらOKボタンを押下します。(プロジェクト名はいつでも変更可能) f:id:yoshiJ:20200521002722p:plain
  3. 登録する作業の開始ページのURLを求められるので入力後Start Recordingを押下。
    f:id:yoshiJ:20200521003213p:plain
    今回は検索するところからスタートするのでGoogleのURLを記入
  4. 別ウィンドウで新たにChromeが立ち上がり、記入したURLのサイトが自動で開かれます。画面右下に動作の録画をしている旨のメッセージが表示されていることを確認し、テストとして登録したい動作を実行していきます。 f:id:yoshiJ:20200521075038p:plain
    f:id:yoshiJ:20200521081042p:plain
    「Holmes」と検索しトップに来ている検索結果をクリック
    f:id:yoshiJ:20200521081142p:plain
    Holmesのトップページが開かれました
  5. 行いたい作業が一通り完了したらSelenium IDEに戻り、画面右上の録画ボタンをクリックして録画を停止します。(もう一度ボタンをクリックすることで続きから録画を再開することも可能です。) f:id:yoshiJ:20200521084820p:plain
  6. 先のステップで録画を行っていたウィンドウの画面右下に動作の録画をしている旨のメッセージが表示されていないことを確認します。
  7. Selenium IDEの画面に戻り、テストに対してタイトルを付けます。
    f:id:yoshiJ:20200521082013p:plain
    画面右の「Untitled*」と表記のある箇所にマウスオーバーするとメニューボタンが表示されるのでクリック
    f:id:yoshiJ:20200521082113p:plain
    「Reneme」をクリック
    f:id:yoshiJ:20200521082512p:plain
    名前を変更するポップアップが開かれるので任意のテスト名を命名して「Rename」をクリック
    f:id:yoshiJ:20200521082641p:plain
    名前が書き換わりました
  8. タイトルが保存できたらプロジェクトの保存を行います。画面右上のフロッピーディスクのアイコンから行います。
    f:id:yoshiJ:20200521083352p:plain
    アイコンにマウスオーバーするとSave projactと表示されるのでそのままクリック
    f:id:yoshiJ:20200521083912p:plain
    任意のファイル名を付けてPCに保存
  9. これで一通りの作成作業が完了です。次に起動した際に作成したプロジェクトを開くときはSelenium IDEの起動時画面の「Open an exsting project」をクリックするか、編集画面の右上の既存プロジェクトを開くアイコンをクリックすることで立ち上げることが出来ます。 f:id:yoshiJ:20200521084630p:plain f:id:yoshiJ:20200521084648p:plain

補足

新たにテストを追加する場合

プロジェクト内に新たに別のテストを追加したい場合は画面左側のメニューから行います。
メニューの項目名がTestsになっていることを確認してその隣にあるプラスボタンを押下することで新規テストの追加ができます。
テスト名を付けて新規テストが開けたら画面右上の録画ボタンをクリックすることで新たにテスト登録を行う事ができます。 f:id:yoshiJ:20200521100435p:plain

テストの実行

作成が完了したら実際にテストを実行して結果を確認してみます。

  1. 実行したいテストを開き、画面左上あたりに配置されているボタン群の中から表示しているテストの実行を行う三角形のボタンをクリックします。
    • テスト処理の実行速度は実行環境や確認の運用に合わせて設定が可能です。テスト実行ボタンの右側にあるボタンから調整できます。(デフォルトだと最速に設定されています。) f:id:yoshiJ:20200521102857p:plain
  2. すると別ウィンドウが立ち上がり、記録されたテストが自動で実行されます。
  3. テストの処理が完了するとSelenium IDEの画面に実行が完了した処理と実行ログが表示されます。 ログの結果が全てグリーンの表示になっていればすべての処理が正常に完了しています。ログに赤い表示が入っている場合は何処かの処理で失敗しているので、エラーメッセージを確認して失敗している箇所のテストの見直しが必要になります。
    ログは画面右下の削除ボタンからいつでも消すことができるのでテスト毎に消去すると実行結果が見やすくなります。
    f:id:yoshiJ:20200521111301p:plain
    テストの完了後画面

テストの修正

もしテストに失敗したり、記録時に余計な動作を追加してしまった際や入力値の変更を間違えてしまった場合でも後から修正を加えることが可能です。
例として上記で作ったテストに対して下記の動作を追加するパターンを作って見ていきます。

1. Holmesのページからブラウザの戻るボタンをクリックして検索結果のページに戻る
2. 「Holmes開発者ブログ」と検索
3. 検索結果の一番上に出る当ブログのトップページにアクセスする
  1. 例に記載した操作をブラウザ操作で登録してみました。 f:id:yoshiJ:20200521122136p:plain
  2. このテストを実行すると…
    失敗してしまいます。
    よく見るとブラウザの戻るボタンが記録されていないようです。どうやらSelenuim IDEでは録画する際にブラウザバックのボタンをクリックしても動作として記録されないようですね。 f:id:yoshiJ:20200521122207p:plain
  3. ではどうするかというと、Selenium IDEでは動作としてJavaScriptの記述ができるのでスクリプトを操作として追加していきます。
    まず、コマンドを入れたい位置の下の動作をクリックして選択したあと、Control+クリック(Windowsでは右クリックに相当)でメニューを出し「Insert new command」をクリックすると、新たな操作を登録するための空の行が生成されます。 f:id:yoshiJ:20200521122809p:plain f:id:yoshiJ:20200521123228p:plain
  4. 空の行が生成されたら行をクリックして画面下側のコマンド編集欄でJavaScriptで戻る挙動を登録します。
    Commandの欄にはrun scriptを設定し、Targetの欄にはwindow.history.back(-1);を登録します。
    これで設定完了です。 f:id:yoshiJ:20200521123027p:plain
  5. 再度テストを実行すると…正常に完了しました! f:id:yoshiJ:20200521123608p:plain
  6. 仕上げに余計な動作を削除します。
    12~14行目の操作は不要なので消していきます。
    削除する挙動をクリックし、Control+クリック(Windowsでは右クリックに相当)でメニューを出し「Delete」をクリックすると、指定した行が削除されます。 f:id:yoshiJ:20200521123852p:plain
  7. 不要な動作を消しきったらテストの完成です! f:id:yoshiJ:20200521123931p:plain

このような手順を繰り返して正常に動作するテストを組み上げていきます。

まとめ

以上が導入から作成までの一通りの操作の解説でした。 Selenium IDEの知識がない方でも操作できるように執筆したつもりですが、不明な点やわかりにくい点がありましたらご指摘いただけますと幸いです。

今回は紹介しきれませんでしたが、Selenium IDEではテスト処理の中で関数を使用して分岐や繰り返し処理を登録してテストに幅をもたせることができます。
加えてエクスポート機能が備わっており、JavaPythonRubyなどのコード用に整形して出力できるので既存のテストなどに組み込むことが出来ます。

ブレークポイントでのデバッグや操作する要素を細かく指定することもできるため、実際のUIテストでも十分実用できるレベルのツールだと思います。
使用するハードルも低いため、UIテストツールを検討している方は是非使ってみて下さい。

個人保有ないしは自分の所属する組織のサイト以外のページ巡回、ページ操作を含む処理を短時間に繰り返すと対象のサイトに負荷を与えてしまい、トラブルに発展する場合がありますのでご利用の際には十分に気をつけて行うようお願いします。
本エントリーはそのような操作を助長する意図のものではございません。

おわりに

いかがでしたでしょうか? Selenium IDEの導入検討をしている方の一助になりましたら幸いです。

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

lab.holmescloud.com

lab.holmescloud.com

Facebookライクなローディング、スケルトンスクリーンをVue.js、Reactで簡単に実装する

こんにちは!株式会社Holmesでエンジニアをしている平田です。

みなさんスケルトンスクリーンをご存知ですか?
FacebookYouTubeなどで使われているローディングのことをスケルトンスクリーンと言ったりします。
今では様々なWEBサイトでも使われているため、よく目にするのではないでしょうか

f:id:k-hirata:20200604162130g:plain
YouTubeローディング

ケルトンスクリーンを採用することで、以前よく使われていたスピナー等に比べて、
ローディング後に表示される情報を推測できるようになるため、待たされている時間を短く感じさせることができます。
そんなスケルトンスクリーンをVue.jsで簡単に実装できるvue-content-loaderの使い方を紹介します。
これはReact用のreact-content-loaderのvue版みたいなもののため、Reactを使われている方にも参考になると思います。

vue-content-loader使い方

インストール

# yarnを利用している方
yarn add vue-content-loader
# npmを利用している方
npm i vue-content-loader

使い方

使い方はいたって簡単で、下記のようにコンポーネントをif文で切り替えて表示したいコンテンツと差し替えるだけです。

<template>
  <content-loader v-if="isLoading"></content-loader>
</template>

<script>
import { ContentLoader } from 'vue-content-loader'

export default {
  components: {
    ContentLoader
  },
  data: {
    isLoading: true
  }
}
</script>

また、FacebookLoaderやInstagramLoaderなど5種類のローディングがパッケージに含まれているため、簡単に使ってみることもできます。

カスタムローディングの作り方

vue-content-loaderはSVGを使って作成されています。
そのため、先ほどの<content-loader>の子要素に表示したいローディングのSVGを指定するだけで、カスタムローディングが作成できます。

<content-loader width="130" height="40">
    <circle cx="20" cy="20" r="10" />
    <rect x="40" y="15" rx="4" ry="4" width="80" height="10"/>
</content-loader>

これをtemplateとして登録すればいつでも使えるようになります。

f:id:k-hirata:20200604162224g:plain
見本1:アイコン+ユーザー名

注意点としては、fill="none"を使って枠線のみの図形を表示しようとしても、塗りつぶされた図形が表示されます。
これは内部的にはユーザーが指定したSVGをclipPath要素で指定することで表示しているためです。

SVGなので、ellipse(楕円)やpolygon(多角形)、text(文字)も使えます。lineやpolylineなどの線分は、表示されないか、内側が塗りつぶされるためご注意ください。

f:id:k-hirata:20200604162311g:plain
<ellipse cx="60" cy="20" rx="50" ry="10" />
f:id:k-hirata:20200604162352g:plain
<polyline points="40,0 40,80 80,80" />
⬆︎L字を指定していますが三角に塗りつぶされています
f:id:k-hirata:20200604162417g:plain
<polygon points="150,75 179,161 269,161 197,215 223,301 150,250 77,301 103,215 31,161 121,161" />
f:id:k-hirata:20200604162439g:plain
<text x="20" y="20" font-size="30px">Hello World!</text>

また、こちらのオンラインツールを使うと、コーディングせずに図形を配置するだけでローダーを自作することもできます。活用してみてください。

プロパティ

プロパティを指定することでもローディングをカスタムできます。

Prop Type Default Description
width number 400 viewBoxの幅および、ビューポート(表示領域)の幅
height number 130 viewBoxの高さおよび、ビューポート(表示領域)の高さ
speed number 2 アニメーション効果の速度
preserveAspectRatio string 'xMidYMid meet' svgのpreserveAspectRatio属性
primaryColor string '#f9f9f9' 表示ローディングの基本色です
secondaryColor string '#ecebeb' 線形グラデーション(linearGradient)の中間色です。アニメーションでこちらの色が駆け抜けて見えます。
uniqueKey string randomId() SSRをしている場合、こちらを指定しないと、randomIdが2度生成されてしまい、ローディングが一瞬で消えてしまいます。
animate boolean true アニメーションさせるかを指定できます
baseUrl string empty string <head /><base url />を指定している場合、同様に指定する必要があります。
primaryOpacity number 1 primaryColorのopacityを指定できます。
secondaryOpacity number 1 secondaryColorのopacityを指定できます。

<content-loader width="130" height="40" primaryColor="red" secondaryColor="blue" :speed="5">
    <circle cx="20" cy="20" r="10" />
    <rect x="40" y="15" rx="4" ry="4" width="80" height="10"/>
</content-loader>

f:id:k-hirata:20200604162510g:plain
見本2

おわりに

いかがでしたでしょうか。
図形の組み合わせで簡単に実装できると思いますので、ぜひコンテンツにあったオリジナルなローディングを作って活用してください。

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

lab.holmescloud.com

lab.holmescloud.com

spring-boot-starter-scim2を使ってSCIM APIを作る

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

tl;dr

SCIMとはプロビジョニングやデプロビジョニング用のアカウント・グループ情報をRESTful APIで操作するプロトコルです
基幹システムで管理しているアカウント情報を別システムに流し込むのに利用され、ホームズクラウドでも6月にリリース予定です

spring-boot-starter-scim2の利用方法をまとめていこうと思います

参考

spring-boot-starter-scim2とは

SpringBoot上で SCIM2 SDK を利用するためのOSSになります
導入することで以下のメリットがあると考え導入しました

  • Request/ResponseのEntity
  • ServiceProvider、ResourceTypesなどのエンドポイントの自動生成
  • フィルター文字列の解析

github.com

環境

開発環境は以下の通りです

  • Java 8
  • Gradle 5.4.1
  • Kotlin 1.3.71
  • SpringBoot 2.2.6 RELEASE

build.gradle

以下のようにdependencyを追加します

dependencies {
    implementation 'com.bettercloud:spring-boot-starter-scim2:1.0.0'
    implementation 'com.bettercloud:scim2-sdk-common:1.0.0'
    implementation "com.unboundid.product.scim2:scim2-sdk-common:2.3.3"
    implementation "com.unboundid.product.scim2:scim2-sdk-server:2.3.3"
}

com.bettercloudでは足りない機能をcom.unboundidで補う必要があるため、追加します

エンドポイント

spring-boot-starter-scim2 は以下のエンドポイントを自動で出力してくれます
ServiceProviderConfigapplication.yml に出力する情報を記載するだけでOKで、残りの2つは@ScimResourceをControllerクラスに付与するだけです

@ScimResource(description = "Access User Resources", name = "User", schema = UserResource::class)
  • /ServiceProviderConfig
  • /ResourceTypes
  • /Schemas
scim2:
  service-provider-config:
    documentationUri: http://www.simplecloud.info
    patch:
      supported: true
    bulk:
      supported: true
      maxOperations: 1000
      maxPayloadSize: 10000
    filter:
      supported: true
      maxResults: 100
    change-password:
      supported: false
    sort:
      supported: true
    etag:
      supported: false
    authenticationSchemes:
      - name: SCIM
        description: SCIM
        specUri: http://localhost:8080
        documentationUri: http://localhost:8080
        type: oauthbearertoken
        primary: true

各項目についてはGithubに説明がありますので、そちらをご覧いただければと思います

github.com

/ServiceProviderConfigのResponse JSON

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
  ],
  "patch": {
    "supported": true
  },
  "bulk": {
    "supported": false,
    "maxOperations": 1000,
    "maxPayloadSize": 10000
  },
  "filter": {
    "supported": true,
    "maxResults": 100
  },
  "changePassword": {
    "supported": false
  },
  "sort": {
    "supported": false
  },
  "etag": {
    "supported": false
  },
  "meta": {
    "resourceType": "ServiceProviderConfig",
    "location": "http://localhost:8080/ServiceProviderConfig"
  }
}

/ResourceTypesのResponse JSON

{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "id": null,
  "externalId": null,
  "meta": null,
  "totalResults": 2,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "User",
      "name": "User",
      "description": "Access User Resources",
      "endpoint": "/Users",
      "meta": {
        "resourceType": "ResourceType",
        "location": "http://localhost:8080/ResourceTypes/User"
      }
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "Group",
      "name": "Group",
      "description": "Access Group Resources",
      "endpoint": "/Groups",
      "meta": {
        "resourceType": "ResourceType",
        "location": "http://localhost:8080/ResourceTypes/Group"
      }
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 2
}

/SchemasのResponse JSON

出力量が多いので折りたたんでおります

JSONを見る

{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "id": null,
  "externalId": null,
  "meta": null,
  "totalResults": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:Schema"
      ],
      "id": "urn:ietf:params:scim:schemas:core:2.0:User",
      "name": "User",
      "description": "User Account",
      "attributes": [
        {
          "name": "active",
          "type": "boolean",
          "multiValued": false,
          "description": "A Boolean value indicating the User's administrative status.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "addresses",
          "type": "complex",
          "subAttributes": [
            {
              "name": "country",
              "type": "string",
              "multiValued": false,
              "description": "The country name component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "formatted",
              "type": "string",
              "multiValued": false,
              "description": "The full mailing address, formatted for display or use with a mailing label. This attribute MAY contain newlines.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "locality",
              "type": "string",
              "multiValued": false,
              "description": "The city or locality component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "postalCode",
              "type": "string",
              "multiValued": false,
              "description": "The zipcode or postal code component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred address. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "region",
              "type": "string",
              "multiValued": false,
              "description": "The state or region component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "streetAddress",
              "type": "string",
              "multiValued": false,
              "description": "The full street address component, which may include house number, street name, PO BOX, and multi-line extended street address information. This attribute MAY contain newlines.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.",
              "required": false,
              "canonicalValues": [
                "other",
                "work",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Physical mailing addresses for this User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "displayName",
          "type": "string",
          "multiValued": false,
          "description": "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described if known.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "emails",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary e-mail address. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.",
              "required": false,
              "canonicalValues": [
                "other",
                "work",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "E-mail addresses for the user. The value\nSHOULD be canonicalized by the Service Provider, e.g.\nbjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type\nvalues of work, home, and other.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "E-mail addresses for the user. The value SHOULD be canonicalized by the Service Provider, e.g., bjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type values of work, home, and other.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "entitlements",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The value of an entitlement.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of entitlements for the User that represent a thing the User has.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "groups",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "$ref",
              "type": "reference",
              "multiValued": false,
              "description": "The URI of the corresponding Group resource to which the user belongs",
              "required": false,
              "caseExact": true,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none",
              "referenceTypes": [
                "Group",
                "User"
              ]
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'direct' or 'indirect'.",
              "required": false,
              "canonicalValues": [
                "indirect",
                "direct"
              ],
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The identifier of the User's group.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of groups that the user belongs to, either thorough direct membership, nested groups, or dynamically calculated.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "ims",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'aim', 'gtalk', 'mobile' etc.",
              "required": false,
              "canonicalValues": [
                "qq",
                "skype",
                "gtalk",
                "aim",
                "icq",
                "yahoo",
                "msn",
                "xmpp"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "Instant messaging address for the User.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Instant messaging addresses for the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "locale",
          "type": "string",
          "multiValued": false,
          "description": "Used to indicate the User's default location for purposes of localizing items such as currency, date time format, numerical representations, etc.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "name",
          "type": "complex",
          "subAttributes": [
            {
              "name": "familyName",
              "type": "string",
              "multiValued": false,
              "description": "The family name of the User, or Last Name in most Western languages (for example, Jensen given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "formatted",
              "type": "string",
              "multiValued": false,
              "description": "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display (for example, Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "givenName",
              "type": "string",
              "multiValued": false,
              "description": "The given name of the User, or First Name in most Western languages (for example, Barbara given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "honorificPrefix",
              "type": "string",
              "multiValued": false,
              "description": "The honorific prefix(es) of the User, or Title in most Western languages (for example, Ms. given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "honorificSuffix",
              "type": "string",
              "multiValued": false,
              "description": "The honorific suffix(es) of the User, or Suffix in most Western languages (for example, III. given the full name Ms. Barbara J Jensen, III.)",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "middleName",
              "type": "string",
              "multiValued": false,
              "description": "The middle name(s) of the User (for example, Robert given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": false,
          "description": "The components of the user's real name.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "nickName",
          "type": "string",
          "multiValued": false,
          "description": "The casual way to address the user in real life, e.g.'Bob' or 'Bobby' instead of 'Robert'. This attribute SHOULD NOT be used to represent a User's username (e.g., bjensen or mpepperidge)",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "password",
          "type": "string",
          "multiValued": false,
          "description": "The User's clear text password. This attribute is intended to be used as a means to specify an initial password when creating a new User or to reset an existing User's password.",
          "required": false,
          "caseExact": false,
          "mutability": "writeOnly",
          "returned": "never",
          "uniqueness": "none"
        },
        {
          "name": "phoneNumbers",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred phone number or primary phone number. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home' or 'mobile' etc.",
              "required": false,
              "canonicalValues": [
                "other",
                "pager",
                "work",
                "mobile",
                "fax",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "Phone number of the User",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Phone numbers for the User.  The value SHOULD be canonicalized by the Service Provider according to format in RFC3966 e.g., 'tel:+1-201-555-0123'.  Canonical Type values of work, home, mobile, fax, pager and other.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "photos",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'photo' or 'thumbnail'.",
              "required": false,
              "canonicalValues": [
                "thumbnail",
                "photo"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "reference",
              "multiValued": false,
              "description": "URI of a photo of the User.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none",
              "referenceTypes": [
                "external"
              ]
            }
          ],
          "multiValued": true,
          "description": "URIs of photos of the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "preferredLanguage",
          "type": "string",
          "multiValued": false,
          "description": "Indicates the User's preferred written or spoken language.  Generally used for selecting a localized User interface. e.g., 'en_US' specifies the language English and country US.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "profileUrl",
          "type": "reference",
          "multiValued": false,
          "description": "A fully qualified URL to a page representing the User's online profile",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none",
          "referenceTypes": [
            "external"
          ]
        },
        {
          "name": "roles",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The value of a role.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of roles for the User that collectively represent who the User is; e.g., 'Student', 'Faculty'.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "timezone",
          "type": "string",
          "multiValued": false,
          "description": "The User's time zone in the 'Olson' timezone database format; e.g.,'America/Los_Angeles'",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "title",
          "type": "string",
          "multiValued": false,
          "description": "The user's title, such as \"Vice President\".",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "userName",
          "type": "string",
          "multiValued": false,
          "description": "Unique identifier for the User typically used by the user to directly authenticate to the service provider.",
          "required": true,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "server"
        },
        {
          "name": "userType",
          "type": "string",
          "multiValued": false,
          "description": "Used to identify the organization to user relationship. Typical values used might be 'Contractor', 'Employee', 'Intern', 'Temp', 'External', and 'Unknown' but any value may be used.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "x509Certificates",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "binary",
              "multiValued": false,
              "description": "The value of a X509 certificate.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of certificates issued to the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        }
      ],
      "meta": {
        "resourceType": "Schema",
        "location": "http://localhost:8080/Schemas/urn:ietf:params:scim:schemas:core:2.0:User"
      }
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 1
}

UsersとGroups

ユーザとグループのCRUDは実装する必要があります

Get

単体複数のリソースを取得する2つのエンドポイントを作る必要があります
複数のリソースを取得するエンドポイントはFilter指定が可能で、以下のようなクエリパラータでアクセスされます

/Users?filter=userName eq "user name"

eqequalの略で他に指定される条件は以下のようになっています

Operator Description
eq equal
co contains
sw starts with
pr present value
gt greater than
ge greater than or equal
lt less than
le less than or equal
and logical And
or logical Or

これらの条件を独自に実装し絞り込むのはなかなか大変ではありますが、条件解析もフレームワークが用意してくれています
パラメータで受け取った絞り込み文字列をFilterクラスを通して絞り込みを行います

@GetMapping
fun search(request: HttpServletRequest, @ModelAttribute searchRequest: SearchRequest
           , @RequestParam(value = ApiConstants.QUERY_PARAMETER_FILTER, required = false) filterString: String?
): ResponseEntity<ListResponse<GenericScimResource>> {
    // 対象全リソース取得
    val resources = getResources()

    // 絞り込み
    val filter: Filter? =
        if(filterString != null) Filter.fromString(filterString) else null
    val result = if(filter != null) {
        resources.filter { it ->
            FilterEvaluator.evaluate(filter, it.objectNode)
        }
    } else resources

    val listResponse = ListResponse(result.size, result, 1, result.size)

    return ResponseEntity.ok(listResponse)
}

POST

こちらはユーザやグループを登録するのですが、UserResourceに予めValidation設定が記載されているので、SchemaCheckerを利用して入力チェックを行うことができます

@PostMapping
fun create(request: HttpServletRequest, @RequestBody data: UserResource): ResponseEntity<GenericScimResource> {
    parameterValidation(data)

    val response = createResource(data)
    return ResponseEntity.created(createLocation(response.id)).body(response)
}

private fun parameterValidation(data: UserResource) {
    val coreSchema = getSchema()
    val schemaExtensions = getSchemaExtensions()

    val builder =
            ResourceTypeDefinition.Builder("test", "/test")
                    .setCoreSchema(coreSchema)
                    .addOptionalSchemaExtension(schemaExtensions)

    val resourceTypeDefinition: ResourceTypeDefinition = builder.build()
    val checker = SchemaChecker(resourceTypeDefinition)
    val resource = checker.removeReadOnlyAttributes(JsonUtils.valueToNode(data))
    val results = checker.checkCreate(resource)
    if (results.syntaxIssues.isNotEmpty()) {
        throw BadRequestException.invalidSyntax(results.syntaxIssues.joinToString())
    }
}

PATCH

リソースを一部更新するのに利用されます
以下はグループの名前変更メンバー追加を行っており、複数の更新をサポートする必要があります

{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
        {
            "op": "replace",
            "path": "displayName",
            "value": "1879db59-3bdf-4490-ad68-ab880a269474updatedDisplayName"
        },
        {
            "op": "add",
            "path": "members",
            "value": [{
                "$ref": null,
                "value": "f648f8d5ea4e4cd38e9c"
            }
    ]
}

opには以下の3つが利用可能でEnumで定義されています

  • add
  • replace
  • remove
@PatchMapping("/{id}")
fun update(request: HttpServletRequest, @PathVariable("id") id: String
          , @RequestBody data: PatchRequest): ResponseEntity<Void> {

    updateResource(data, id)
    return ResponseEntity.noContent().build()
}

総評

exmapleも少なくGitHubユニットテストを解析しながらの作業でしたが、手間のかかるRequest/Responseクラスの作成や標準エンドポイントの自動出力など大変助かることが多かったかと思います
BulkUsersGroupsも標準規約があるのでフレームワークで用意してくれていてもいいのかなと感じました
時間があればPull Requestで改善を試みたいと思います

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

lab.holmescloud.com

lab.holmescloud.com

@SpringBootTestを使ったSpockテストが遅いので、Gradleでテストをカテゴリ分けして起動を高速化する

Holmesでエンジニアをしている山本です。

以前の記事でも言及があったように、現在はユニットテストの起動および実行に非常に時間がかかっています。

最もコード量が多いプロジェクトで、ユニットテストの起動に約2分、実行には数十分かかります。

理由としては、Spring Bootを使用したJavaプロジェクトにて、ControllerやServiceに対して @SpringBootTest を付与したテストを行っているためです。

中でもControllerに対しては WebEnvironment.RANDOM_PORT を指定した結合テストとして記述されているため、 gradle test --tests <className> などでControllerのテストを除外しないと、Webサーバーや組み込みDBの起動が行われ、テスト起動および実行に時間がかかってしまいます。

完了まで数十分では、変更の都度すべてのテストをローカルで実行というわけにはいかず、変更したクラスに対応するテストのみ修正する、という運用になりがちです。結果、CIから実行されたテストの失敗が、度々起こりました。

単体レベルのテストは、簡単に実行して30秒以内には結果が出てほしいので、結合レベルのテストを除外してテストしたいと考え、SpockとGradleで実現する方法を調査しました。

テストのカテゴリ分け

テストサイズの考え方を参考に、Smallテストを導入したいと思います。

Spockのテストのカテゴリ分けについては、JUnit@Category がそのまま使用できます。

任意のクラスをカテゴリ分けに使用できますが、マーカーとしてわかりやすいよう、テストサイズに対応したクラスを作成します。

package com.holmescloud

final class SmallTests {

    private SmallTests() {
    }

}

POJOEnum、依存クラスをモック化したServiceなど、Springに依存しないテストクラスに対して、 @Category(SmallTests.class) を付与しておきます。

smallTestタスクの作成

続いて、Gradleのタスクとして実行できるよう、build.gradleに以下のタスク定義を追加します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }
}

これで gradle smallTest を実行すると、@Category(SmallTests.class) を付与したテストクラスのみが実行されます。

ログ出力の追加

このままでは実行経過がわかりにくいため、 afterSuite にて lifecycle レベルのログ出力を追加します。

除外したテストも afterSuite に渡されますが、それらは第2引数 TestResultTestResult#getTestCount が0となるため除外しています。

また、クラス単位、Test Executor単位、タスク単位の結果が渡されますが、Test Executor単位の結果は不要と思い、除外しています。

Test Executorの場合、第1引数の TestDescriptor のうち、 TestDescriptor#getClassName がnull、かつ TestDescriptor#getParent が存在するため、それで判定します。

それ以外は成功件数とテスト件数、経過時間と表示名を出力するよう、タスク定義に追記します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }

    afterSuite { desc, ret ->
        if (!ret.testCount || (!desc.className && desc.parent)) {
            return
        }

        def retSec = (ret.endTime - ret.startTime) / 1000
        def retCount = "${ret.successfulTestCount}/${ret.testCount}"
        def retMsg = "[${ret.resultType}] ${retCount}, ${retSec}s"
        logger.lifecycle "${retMsg}: ${desc.displayName}"
    }
}

一度コンパイルした状態で、 gradle cleanCompileTestGroovy smallTest し、テストクラスのコンパイルおよびカテゴリ分けしたテストの実行時間を確認しました。以下のように、クラスごとの結果とタスクの総計が出力されます。

> Task :smallTest
[SUCCESS] 14/14, 0.981s: <className>
[SUCCESS] 13/13, 0.055s: <className>
[SUCCESS] 8/8, 0.041s: <className>
[SUCCESS] 35/35, 5.539s: Gradle Test Run :smallTest

BUILD SUCCESSFUL in 29s

smallTestタスクにおける経過時間の総計と各クラスごとの合計の差は、カテゴリ分けされていないテストの読み飛ばし時間です。

対象となるテストクラス数が少ないこともありますが、Webサーバーの起動が行われないため、タスクの実行時間を5.5秒まで短縮できました。テストクラスのコンパイルを含めても約30秒と、許容できる時間に収まったかと思います。

今後の課題

ひとまずテストをカテゴリ分けし、選択的に実行可能となりましたが、全てのテストを実行したときの遅さは解消されていません。

個人的には以下のように順を追って、少しずつ解消していければと考えています。

  1. Serviceについては、 @SpringBootTest は使わず、依存クラスをモックとしたシンプルな単体テストを記述し、Smallテスト扱いとする
  2. Controllerについては、 @SpringBootTest は使わず、 @WebMvcTest を利用し、依存するServiceなどは @MockBean などでモック化したテストを記述、Mediumテスト扱いとする
  3. Repositoryについては、組み込みDBを用いたテストを記述し、Mediumテスト扱いとする

また、Spring Bootアプリケーションのテストに関する私の知識はv1.5ごろのものです。v2になって以降、より良いテスト方法があるかもしれないので、適宜調査していきたいと思います。

最後に

Holmesではバックエンドだけでなく、フロントエンドでもテスト記述を推進しております。

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

lab.holmescloud.com

lab.holmescloud.com

参考資料

testing.googleblog.com

www.slideshare.net

qiita.com

qiita.com

Vuex + TypeScriptで非同期通信のサンプルを作りました

Holmesでエンジニアをしているid:w-miuchiです。

Holmesのアプリケーションは、フロントエンドを主にThymeleaf + JQueryで構築しています。
今後はSPA(Single Page Application)に切り替えます。それにともないNuxtJS + TypeScriptを採用することになりました。
現在、NuxtJSおよびVue.jsを鋭意勉強中です。

まずはNuxtJSの公式ドキュメントを読むところから始めました。
読む→作って試す、と繰り返していたのですが、いまいちうまく試せなかったのがVuexでした。 公式通りであれば問題なかったのですが、実現したいのは以下の条件でした。

  • Actions, Mutations, Stateを定義
  • TypeScript(Interface)
  • 非同期通信(GET/POST)

この条件で公開されているコードを調べました。
何かの条件が欠けていることが多かったのですが、見つけたのがGoogle-Books-APIです。

こちらのコードを元にしてサンプルを作ってみました。
Vuexの大まかな流れを理解できるかと思います。

Vuexとは

詳しくは下記のVuexの公式ドキュメントを見て頂ければと思います。
Vuexはコンポーネントが共通で持てるStoreであり、コントローラーになります。
ただし「共通の状態を共有する複数のコンポーネントを持ったときに、すぐに破綻します」 とあるように使い方は難しいです。
今回はこの点は置いておいて全体像を掴むことにしました。

Vuexの構成

f:id:w-miuchi:20200422090202p:plain
"単方向データフロー"のコンセプト
Stateを扱うのはMutations、そのMutationsを操作するのはActionsとなります。
作成したサンプルにおいては以下の役割としました。

Actions

非同期処理(GETやPOST)を記述する。
またComponentから呼ぶ場合、受け渡すパラメータはインターフェースを用意する。

Mutations

非同期処理(Actions)の処理結果をStateに保存する。
Componentからは呼ばない。

State

Componentから呼ばれる。
Stateにもインターフェースを用意する。

サンプル

作ったサンプルは、以下の画面2つです。
今回はアカウント一覧画面を紹介します。

  1. アカウント一覧画面

    • 非同期でアカウント一覧情報を取得すること
    • 取得時はローディングすること
    • 検索機能があること
  2. アカウント編集画面

    • URIにidを含むこと
    • idを元に非同期でアカウント情報を取得すること
    • 名前を編集すること
    • 非同期でアカウント情報を更新すること

Store

ファイル構成

store
├── account
│   ├── edit.ts
│   ├── search.ts
│   └── types.ts
├── dummy.ts
├── store.ts
└── types.ts

インターフェースを設定

store/account/types.ts

// Account
export interface Account {
  id: string
  name: string
}

// Account検索結果
export interface SearchAccountState {
  keywords: string
  accounts: Account[]
  total: number
  page: number
  isLoading: boolean
  isThere: boolean
  isError: boolean
}

// Account検索用パラメータ 
export interface SearchAccountsPayloadObj {
  keyword: string
  page: number
}

インタフェースの定義はわかりやすくできました。
HolmesのようにサーバーサイドにJavaの使っている場合、インターフェースは理解しやすいと思います。またサーバーサイドと定義を合わせられるのもメリットです。 またローディングやページングのような共通化できるものはインターフェースにした方がいいでしょう。

RootStateにModuleとして設定

store/types.ts

import { SearchAccountState, EditAccountState } from './account/types';

export interface RootState {
  // アカウント一覧
  SearchAccountModule: SearchAccountState,
  // アカウント編集
  EditAccountModule: EditAccountState
}

検索結果と考えるとRootStateに設定することは考えどころです。
同じComponentを複数設置すると、検索結果まで同期してしまいます。

Vuex.Storeを設定

store/store.ts

import Vue from 'vue';
import Vuex from 'vuex';
import { RootState } from './types';

Vue.use(Vuex);

export default new Vuex.Store<RootState>({});

Mutaions, Actionsの処理

store/account/search.ts

import { Module, VuexModule, Mutation, Action, getModule, } from 'vuex-module-decorators'
import { Account, SetSearchAccountsObj, SearchAccountsPayloadObj } from './types';
import store from '../store';
import dummy from '../dummy';

@Module({dynamic: true, store, name: 'SearchAccountModule', namespaced: true})
class SearchAccountModule extends VuexModule {
  // Store
  public keyword: string = '';
  public accounts: Account[] = [];
  public total: number = 0;
  public page: number = 0;
  public isLoading: boolean = false;
  public isThere: boolean = true;
  public isError: boolean = false;

  // 検索結果の保存
  @Mutation
  public setSearchAccounts(payload: SetSearchAccountsObj): void {
    this.accounts = payload.accounts;
    this.page += 1;
    this.total = payload.total;
  }

  // 検索キーワードの保存
  @Mutation
  public setSearchKeywords(payload: string): void {
    this.keyword = payload
  }

  // 検索結果の初期化
  @Mutation
  public resetSearchAccounts(): void {
    this.keyword = '';
    this.accounts = [];
    this.total = 0;
    this.page = 0;
  }

  // 検索結果取得中のローディング
  @Mutation
  public setLoading(payload: boolean): void {
    this.isLoading = payload
  }

  // 検索結果の有無
  @Mutation
  public setThere(payload: boolean): void {
    this.isThere = payload
  }

  // 検索結果取得のエラー有無
  @Mutation
  public setError(payload: boolean): void {
    this.isError = payload
  }

  // 検索結果取得
  @Action({})
  public async getAccounts(payload: SearchAccountsPayloadObj): Promise<void> {
    const { data, error }: any = await
      new Promise(res => 
        // 非同期処理
        setTimeout( () => {
          const items: Account[] = dummy.accounts.filter((item: Account) => {
            return item.name.match(new RegExp(payload.keyword, 'ig'));
          });
          res({
              data: {
                total: items.length,
                items: items
              },
              error: null
            }
          )
        }, 1000));
    this.setLoading(false);

    // 検索結果のStore保存
    if (data) {
      const info: SetSearchAccountsObj = {
        total: 0,
        accounts: [],
      };
      if (data.total !== 0) {
        info.total = data.total;
        info.accounts = data.items;
        this.setSearchAccounts(info);
        this.setThere(true)
      } else {
        this.setSearchAccounts(info);
        this.setThere(false)
      }
      this.setError(false)
    } else {
      this.resetSearchAccounts();
      this.setError(true)
    }
  }
}

export default getModule(SearchAccountModule)

こちらの@Mutationや@Actionというアノテーションの書き方はvuex-module-decoratorsを利用しています。
@MutationはStateの設定処理になります。
@Actionはダミーの情報をsetTimeoutで取得していますが、axiosを利用した通信に変わることを想定しています。
ダミーの情報は以下になります。

store/dummy.ts

export default {
  accounts: [
    {id: '1', name: 'Giorno Giovanna'},
    {id: '2', name: 'Bruno Bucciarati'},
    {id: '3', name: 'Guido Mista'},
    {id: '4', name: 'Leone Abbacchio'},
    {id: '5', name: 'Narancia Ghirga'},
    {id: '6', name: 'Pannacotta Fugo'},
  ]
};

Component

<template>
  <div class="AccountBlock">
    <div class="AccountBlock--header">
      <div class="AccountBlock--title">Account List</div>
      <a class="AccountBlock--refresh" @click="fetchAccounts">Refresh</a>
      <form @submit="search"><input class="AccountBlock--inputKeyword" type="text" placeholder="Keyword" v-model="keyword"></form>
    </div>
    <div class="AccountBlock--item" v-for="(account, index) in accountItems">
      <div class="AccountBlock--itemName">{{ account.name }}</div>
      <div class="AccountBlock--itemEdit"><nuxt-link v-bind:to="{name:'account-edit-id',params:{id:account.id}}">Edit</nuxt-link></div>
      <div class="AccountBlock--itemRemove"><a href="/remove">Remove</a></div>
    </div>
    <div class="AccountBlock--Loading" v-show="isLoading">Loading...</div>
    <div class="AccountBlock--noData" v-show="!isThere">No data.</div>
  </div>
</template>

<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator';
  import { Account, SearchAccountsPayloadObj } from '../../store/account/types';
  import SearchAccountModule from '../../store/account/search';

  @Component
  export default class SearchAccounts extends Vue {
    protected keyword: string = '';
    // アカウント一覧の取得
    protected get accountItems(): Account[] {
      return SearchAccountModule.accounts;
    }
    // ローディングの取得
    protected get isLoading(): boolean {
      return SearchAccountModule.isLoading;
    }
    // アカウント一覧有無の取得
    protected get isThere(): boolean {
      return SearchAccountModule.isThere;
    }
    // アカウント取得エラーの有無
    protected get isError(): boolean {
      return SearchAccountModule.isError;
    }
    // アカウント取得
    protected async fetchAccounts(): Promise<void> {
      await SearchAccountModule.resetSearchAccounts();
      SearchAccountModule.setLoading(true);
      SearchAccountModule.setThere(true);
      const data: SearchAccountsPayloadObj = {
        keyword: this.keyword,
        page: 0,
      };
      await SearchAccountModule.getAccounts(data);
      SearchAccountModule.setSearchKeywords(data.keyword)
    }
    created() {
      this.fetchAccounts();
    }
    search(e: any) {
      e.preventDefault();
      this.fetchAccounts();
    }
  }
</script>

レンダリングはStateを利用し、
非同期処理のアカウント情報取得はActionsを使用できるようになりました。

所感

Vuexの処理の流れや全体像を掴むことができました。 ただし、Storeにどの情報を持たせるべきかを考えて設計する必要があることがわかりました。

またサンプル作成のために他にもコードを調べましたが、
Vuexは作りたいものによって構造や書き方が様々でした。
非同期通信の有無やJavaScript OR TypeScriptでも大きく異なります。

ここからは、アプリケーションの構造に合わせた設計で考えて行きたいと思います。

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

lab.holmescloud.com

lab.holmescloud.com

新米エンジニアがTDD(テスト駆動開発)を、約3ヶ月間行なって感じたこと

2019卒で入社した、Holmesの倉島です。(サーバーサイドエンジニア)

2020年の1月から、自社でTDD(テスト駆動開発)を推進していく方向になりました。 そこで、私が開拓を任されましたので、ブログ記述時現在(2020年4月)まで約3ヶ月間TDDを行なった感想を述べたいと思います。

TDD開始時点

エンジニアとしての経験が浅いので、まずTDDとは何者かを知るところから始めました。
参考にさせていただいた資料:

channel9.msdn.com

dev.classmethod.jp


理解したこととしては...
  ・テストコードを記述してから実装をすること
  ・RED→GREEN→リファクタリング の3つのサイクルに沿って開発を行うこと
RED:落ちるテストを記述
GREEN:とにかくテストが落ちない実装をする
リファクタリング:テストが落ちない状態を維持して、意味のある実装にする



以上を意識して開発を行なってみました。
結果的に、プロダクトのカバレッジが15%上昇しました!
以下は行ってみての感想です。


よかったこと

1.テストコードを記述しない、という事が減った

テストコードを後に回してしまうと、記述し忘れや、時間の問題でテストコードが記述されないままになってしまう事がありました。TDDであればテストコードを記述してから実装を行うので、記述しないということは無くなっていきました。

2.仕様の確認をしながら実装することができる。

テストコードを記述するにあたって考えなくてはならない事が、「処理を通して如何なる結果を期待するか」であるので、何をしたいかが明確になる。私は「何から作ればいいんだ?」と思う事が多々あるので非常に有効でした。

3.画面(フロント)が完成する前に、サーバー側の動きをチェックできる

画面上から動かす事ができなくとも、処理の動きは確認する事ができるので、フロント側とサーバー側に作業のズレがあったとしても一定の処理の保証ができることが大きなメリットだと感じました。

4.既存処理の改修(リファクタリング)、仕様変更が楽。

テストコードが記述されていると、その処理は何をする処理なのかが理解しやすいし、既存のテストが落ちないようにリファクタリングをすれば良いので、悩む時間が(体感)減ったと感じました。

よくなかったこと

1.セットアップコードが多い場合やデータ関連が複雑な場合は時間がかかる

処理を動かすためのデータを用意する時間がとても多くなって、時間対効果が見込めなくなることがありました。

2.処理の結果が不鮮明な場合のテストコードが雑になりがち

処理の結果が不鮮明であると、テストで期待する結果が書きづらかった。例として、受け取ったデータを別の処理に送るだけの処理が挙げられます。結局、そういった処理は期待する結果を「エラーが出ない事」のみにしてしまうケースがありました。

3.テストの起動がすこぶる遅いので、時間がかかる(環境依存)

Holmesの開発環境に限った話ではあると思いますが、 Spring Boot を使用して起動を行なっていることに加え、DIコンテナで管理しているクラスの量が多いのでテストの起動がとても遅いです。一回の起動に2分ほどかかることもありました。

まとめ

TDDを行う上で最も気を使ったことは、どこまで端折りができるかだと思います。というのも、TDDのサイクルは細かくしようと思えばどこまでも細かい単位で行えてしまいます。この部分のこのテストは不要、と適切に判断し不要なテストを削っていくことがTDDにおいて大切なことだと感じました。 TDDを行なって、享受できるメリットは多いと思います。個人的には仕様の確認をしながら実装ができることが大きなメリットだと思いました。 また、上述の「2.処理の結果が不鮮明な場合のテストコードが雑になりがち」のようにどんな場面でも効果を発揮するものではないことがわかりました。

終わりに

私の感想が、TDD理解、導入への一助になれば幸いです。

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

lab.holmescloud.com

lab.holmescloud.com

認定スクラムマスター研修レポート

こんにちは! Holmesの吾郷です。

少し前になりますが、 2019年1月から本格的に運用スタートしたスクラム開発を経て、 2020年1月から新チームのスクラムマスターを務めることになりました。

それにあたり、会社から認定スクラムマスター研修を受ける機会をいただき、 無事に認定スクラムマスター資格を取得できました。

スクラムについてはスクラムガイドを参考にしてください。 https://www.scrumguides.org/docs/scrumguide/v2017/2017-Scrum-Guide-Japanese.pdf

研修概要

いくつか研修を開催している団体がありますが、

今回は株式会社Odd-e Japanさんが開催する研修に参加しました。 www.odd-e.jp

講師は日本人で唯一の認定スクラムトレーナーの江端さんです。

研修で認めてもらえると試験の受験資格を獲得できます。

無事、試験に合格すると下記資格を取得できます。 www.scrumalliance.org

1日目

午前

 スクラムとはなんぞや、スクラムマスターに必要な要素・役割を学習しました。大まかに以下が印象に残っています。

  • スクラムの目的とスクラムを通して得たい目的を区別すること
  • スクラムマスターは、スクラム適切かどうかを判断する(マスターとついてるだけに)
    • スクラム以外の方法論も知っている必要がある
  • スクラムはプロジェクトの現状を把握するフレームワークである
  • スクラムの世界では、人が言った言わないではなく、事実に基づく特定できる情報のみを扱う
  • スクラムはチーム中心型組織である
  • スクラムにおいては権利責任が同居している
  • スクラムマスターは安易に提案・遂行してはならない。
  • スクラムマスターの五大スキル
    • Teaching
    • Facilitating
    • Mentoring
    • Coaching
    • Situationaling
気づき

 スクラムは、改善の為のフレームワークと思っていましたが、改善ができるかどうかは別物でした。
 また、スクラムマスターとなるには、まだまだ知識が足らないことに気づきました。前提として、別の開発手法を知っていることが大切で、さらに学問的知識(集団心理学、組織論)をしっかり学ぶべきだと感じました。
 冒頭でも書いてますが、開発チームの一員だった頃は、思ったことをすぐに提案してました。しかし、論理的となると一度自分の中で落とし込み、チームに効果的な提案をする必要があります。POとチームの両者の連携を促進する立場として、両者から信頼されていることはとても重要です。そのためにはいい加減な言動をしてはならないということです。

午後

午前で学んだことを使ったグループワークを行いました。 トレーナーから出されたお題に研修参加者から提案を行うというものです。

条件は二つ

  • 発言は論理的であること
  • NGワード(とりあえず・まずは・一旦)
気づき

これは本当に難しいお題でした。 発言時に論理的でなければ、トレーナーから指摘を受けます。頭の中で論理的な提案を考え続けます。
初日だったこともあり、基本的に指摘が多く出ていたように思います。 午後はほとんど発言できなかったと記憶しています。

お恥ずかしながらその日の振り返りも掲載します。

自分の意見をなかなか共有できなかった。

因果を捉え、説明できるようにする

固定観念に囚われ、柔軟な発想ができなかった。

2日目

午前

主にスクラムの構成要素について学習しました。大まかに以下が印象に残っています。

  • Feedbackについて
    • 人とモノは分けて考える
    • 弱点/補強的であること
  • PBI(Product Baclog Item)は一つ一つがプロダクトであること。(顧客に提供できる単位)
気づき

 自分も以前経験したことがあるのですが、スプリント毎に提供可能なプロダクトを作っていかないと、まとめてリリースする直前にリリーススプリントなるものを経験しないとならず、たくさんの不具合が出て大変なことになります。提供可能状態を維持し続けることの大切さは身に沁みて感じました。。。

午後

前日からのワークを引き続き継続して行いました。
結果としては提案の内容の決め型を決めることに終始していましたが、冒頭で発言できそこから議論が活性化したように感じ非常に嬉しかったです。

気づき

前日の夜から、練って練って練ってだした一言ですが、論理的であることの大切さを感じました。
また、参加者の方が、ボードや紙を用いて説明し始めたりと言葉のみではなく道具を使ったディスカッションも活発になりました。

2日目の最後には、トレーナーから、どういう戦略指標をもっているのか?という問いにハットさせられたことも覚えています。また、行動しないと評価されないという厳しいお言葉も参加者には投げかけられました。

3日目(最終日)

午前

見積もりとDONEの定義について学習しました。大まかに以下が印象に残っています。

  • 相対見積もりは精度が高く、スクラムに適している(現状がわかるため)
  • ベロシティは生産性指標ではない
  • DONEとはProductを提供できる状態である
  • スプリント毎の提供状態を作ることでVolatilityを下げるようにする
気づき

DONEの定義に関しては、例えばアプリケーションによっては推奨環境などをもともと設定している物があると思いますが、推奨環境という制限をかけていることはすべてのユーザに提供できる状態ではないことから、DONEの定義を満たしていないと判断になります。なるほど、、、となりました。つまるとこ、そういった不可避なUNDONEというのは存在するはずで世界中の企業がUNDONEが少しでもなくなるように動いているとのことです。 また、DONEの定義の学習の過程で、DONEの定義をできていると言える状態にもっとも近い企業はNetflixであるということを学びました。今度遊びに行ってみたい。。。!

午後

前日に引き続き、お題に対してどう決めていくかのディスカッションです。 ある程度、方向が決まっていき最後には決め方が決まりかけるところまではいけました。 結局、トレーナーに提案するところまではいけませんでした。

  • 決める上での判断のための優先順位が必要であること
気づき

集団での合意を行う上では、個々の判断基準が異なると難しくなります。 今回自分たちは、議論をすすめる上での判断基準を設けずに走ってしまったことが低迷した原因の一つでした。

最後に

今回、エバッキーこと江端さんの研修を受けました。噂通り、へとへとになる研修でした。笑 ただ、非常に気づき、学びも多く、さらにトレーニングによって実践的に経験・理解することができたと思います。そんな成長する機会をいただけた会社に感謝すると共に、継続的にスクラムマスターとして成長していきたいと感じました。 次はCSPO,CSDも受けてみたい。。。。!!!!

Holmesはエンジニア・デザイナーを募集しています。
興味を持っていただけた方は、こちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com