visible true

技術的なメモを書く

開発時の動作確認ツールとしてCypressのE2Eテストを導入した話

ユビーAI問診は、Ubieが提供する医療機関向けのプロダクトです。患者さんに対して問診を実施し、医師向けのカルテを作成します。現在は大きく分けて、タブレットスマートフォンの2つの利用方法があります。

f:id:sys1yagi:20220109133721p:plain
タブレット用、スマートフォン用の画面

これらはどちらもWebアプリケーションとして実装していて、フロントエンドはReact/TypeScriptで書いています。

問診のプロセスは画面遷移が多い

ユビーAI問診は紙の問診票で書くような定型的な質問だけでなく、来院した目的に合わせて様々な質問を行います

例えば「頭が痛い」といった症状を入力した場合、発症時期や部位、痛みの程度、持続時間、経過、頻度などを掘り下げて、更にそれらの回答内容から疑われる疾患に関連する質問を重ねていきます。あるいは「足をひねった」など外傷に関する場合は、スポーツをしていたかや事故かといった状況を聴取したりします。問診の長さは入力内容によって様々ですが、短くて10数回、長いと4、50回ほど画面遷移を行います

質問の種類は10数種程度なので、質問の表示の動作確認はStorybookなどを用いれば十分行えます。 しかし問診の回答結果に基づいて作成するカルテは、膨大なパターンの質問と回答の組み合わせがあるため、動作確認にはかなりの労力を必要とします。

f:id:sys1yagi:20220109150227j:plain
最初の数質問の分岐。今はもっと複雑に

CypressによるE2Eテストを開発時の動作確認用に導入する

Ubieでは基本的にひとつのフィーチャーをひとりのエンジニアが担当します*1。バックエンドとフロントエンドの両方を設計・実装したあと、全体の動作確認を行うわけですが、一回の問診は数分かかるのでトライアンドエラーが発生すると非常に時間がかかります。

そこでE2EテストフレームワークであるCypressを、開発時の動作確認用に導入することにしました。特定の問診フローを実行するテストを追加していくことで、開発時の動作確認を気軽に行えるようにしようという目論見です。

Cypressを選んだ深い理由は特になかったのですが、

  • 導入が簡単な点
  • 拡張が容易な点(テスト中にNode.jsで任意の処理を実行できる)
  • Cypress StudioというGUIアプリケーションで、テストの実行や管理が容易な点

などが気に入っています。

Cypressを導入する

Cypressの導入は非常にかんたんです。

yarn add -D cypress # あるいは npm install cypress --save-dev

次のコマンドでCypress Studioを起動できます。

yarn run cypress open # あるいは npx cypress open

初回の起動時にテストのための各種ファイルが生成されます。

cypress
├── fixtures
│   └── example.json
├── integration # テストをここに置く
│   ├── 1-getting-started
│   └── 2-advanced-examples
├── plugins
│   └── index.js
└── support
    ├── commands.js
    └── index.js

f:id:sys1yagi:20220109210900p:plain
Cypress Studio。integration配下のテストが一覧される

デフォルトはjsなので、TypeScriptにするための設定がちょこちょこ必要になります。 https://docs.cypress.io/guides/tooling/typescript-support

とりあえず動かす

Googleで'Cypress E2E'というキーワードで検索し、https://www.cypress.ioのページを開くテストをするとします。実装は次のとおりです。

describe('CypressをGoogleで検索する', () => {
  it('Cypress E2Eで検索するとヒットする', () => {
    // Googleを開く
    cy.visit('https://google.com');

    // input要素にキーワードを入力する
    cy.get('input')
        .first()
        .clear()
        .type('Cypress E2E{enter}');

    // Cypressのページタイトルを探して、クリック
    cy.get('h3')
      .contains('JavaScript End to End Testing Framework')
      .click();
  });
});

cyという特殊なオブジェクト以外は概ねJestのような書き口です

f:id:sys1yagi:20220109162555g:plain
上記のコードが動作する様子

開発環境でCypressを利用する

基本的には最初にvisitするページをlocalhostにすれば自分の環境で立ち上げたアプリケーションにアクセスするテストが書けます。

f:id:sys1yagi:20220109164059g:plain
開発環境でのログインのテストの様子

画面の操作をする他にアサーションも書けます。

// 画面上に'診察券がある'という文言のボタンが存在することを要求する
cy.get('button').contains('診察券がある').should('exist');

要素の状態のアサーション以外にも、Cookieの値やURL、API Callの内容の検証などについても行えます。

Introduction to Cypress | Cypress Documentation

プラグインを使ってテスト実行前にDB設定を整える

localhostに向けてテストを書くだけでは不十分です。医療機関にはいろいろな種類や設定があり、それぞれ動作が異なります。これらの設定を行うにはデータベースをセットアップしなければなりません

CypressのテストコードはChromeFirefoxなどの環境で動作するので、そこからデータベースを直接操作するといったことは基本的にはできません。バックエンド側に開発用のAPIを生やすことも考えられますが、そうすると複数の環境にテストのためのコードが散逸してしまうためできれば避けたいところです。

Cypressではプラグインを追加できます。プラグインはNode.jsで動作します。 デフォルトでtaskというプラグインがあり、ここでNode.jsで動作する任意の処理を追加できます。 https://docs.cypress.io/api/commands/task

plugins/index.tsでtaskイベントに対する処理を記述することで、

(module).exports = (on) => {
  on('task', {
    hello(message: string): string {
      // Node.jsで動作する
      const value = `hello ${message}!`;
      console.log(value);
      return value;
    }
  });
}

テストコードから呼び出せるようになります。

// ブラウザではなく、コマンドラインのほうに'hello world!'とログが出る
cy.task('hello', 'world'); 

ここでNode.js向けのデータベースクライアントを導入し、データをセットアップするtaskを追加することで、テスト実行時に必要な環境を整えられるようになります。

ユビーAI問診では各テーブル毎にCRUD操作をする関数を生やして、任意のデータを用意できるようにしています。TypeOrm を使い、typeorm-model-generatorで既存のテーブルからEntityを生成したので、比較的簡単に準備用のコード群ができました*2

このスタイルの場合、データベースを直接操作し、バックエンドAPIに対して実際にAPI Callをすることになるので、CIなどでのテスト実行は困難になります。少し迷いましたが、あくまで開発時に使うということで割り切ることにしました。

導入してよかった点

思ったよりメンテしやすい

動作確認を楽にするためとはいえ、メンテナンスが難しいと結局コストとしてどうなんだっけ?ということになりますが、Cypressのテストコードはかなりメンテナンスがしやすい印象です。Best Practices | Cypress Documentationを参考に再利用可能な関数を整頓していくと様々なバリエーションのテストを素早く増やしていけます。TypeScriptが使える点もありがたいです

experimentalですがCypress Studio上で操作を記録し、テストコードを生成する機能もあります。 Cypress Studio | Cypress Documentation この辺りは要素のセレクタなどを適切に準備する必要があるので気軽には使えないですが、テスタブルなコードを書く動機にもなって良いなと思います。

大胆な変更も安心できる

一連のシナリオを実行するテストを書けば、何度でも使えるので、そのシナリオ中に関連するコードを変更する際にリグレッションテストとして機能します。 問診のフローはかなり複雑に関連しあっているので、これまでは変更にかなり慎重に取り組まなければなりませんでしたが、テストが増えていくにつれて大胆な変更が可能になりました。

ドキュメントとしての価値もあった

実はCypressを導入したあとに気づいたのですが、各種テストをきちんと構造化するとドキュメントとしての価値もでてきました。現在カバーできている範囲は全体の数%にも満たないですが、それでも新たな実装に対してCypressのテストを追加することで、他の人に引き継いだり、時間が経過した後のキャッチアップなどが容易に行えます。 データのセットアップもコードで表現しているので、前提条件なども把握できるようになっており、CIでの実行を犠牲にした価値は十分あったかなと思います。

心残りな点

とはいえやはり、CIなどの三者による定期的な実行は諦めきれない要素です。どうしても人間が任意のタイミングで実行するだけでは漏れが生じるからです。この間も圧倒的に壊れていました。この辺りは別途対策を考えています。各リポジトリのdevelopブランチをデプロイする環境を持っておいて、変更が入るとデプロイ後にCypress Testをキックするなど、自前で用意することになりますが実現自体は可能なのではないかと思っています。

まとめ

E2Eテストというと結構重たいというか、大変そうなイメージがありましたが、開発時の動作確認ツールとして割り切ることで、かなり便利に使えることがわかりました。

ユビーAI問診は開発が始まってから時間が経っていて、ドメインの深さやコードの複雑さが新メンバーの負担になったり、あるいは古い人が離れられないといった問題がありました。CypressのE2Eテストがすべてを解決するわけではありませんが、今後もこうした取り組みを重ねて、壊れにくく、キャッチアップしやすく、手離れしやすい環境を作っていきたいと思っています。

そんなUbieでは新たなソフトウェアエンジニアを募集しています。最近はフロントエンド、バックエンドに特化したポジションが増えたりしていますので、昔見たな〜という方もぜひまた見てみてください。 recruit.ubie.life

*1:もちろん規模が大きい場合手分けする場合もあります

*2:prismaも検討しましたが、複数のDB接続を簡単にはできなそうだったので諦めました。https://github.com/prisma/prisma/issues/2443

rubyのslice!でめっちゃハマった

map!

各要素を順番にブロックに渡して評価し、その結果で要素を置き換えます。

https://docs.ruby-lang.org/ja/latest/class/Array.html#I_COLLECT--21

てことで、自分自身を書き換える。

array = [1,2,3,4,5]
array.map!{ |a| a * 2 }  # [2,4,6,8,10]になる

slice! も同じノリと思ったら、

array = [1,2,3,4,5]
array.slice!(0, 2)  # [1,2]になると思ったら...

指定した要素を自身から取り除き、取り除いた要素を返します。取り除く要素がなければ nil を返します。

https://docs.ruby-lang.org/ja/latest/class/Array.html#I_SLICE--21

てことで、

戻り値 = [1, 2] array = [3,4,5]

になった。めちゃくちゃ時間を溶かしました。 ドキュメントはちゃんと読まないといけませんね。

Junit5でJetpack ViewModelのviewModelScopeを使っている関数のテストをする

viewModelScopeを使っている関数を持つViewModel

次のRecommendBookViewModelクラスは、loadRecommendBooks関数という内部でviewModelScopeを用いて非同期処理を行う関数を持っている。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import hoge.fuga.recommendbook.RecommendBook
import hoge.fuga.recommendbook.RecommendBookRepository

data class ViewState(
  val loading: Boolean,
  val recommendBooks: List<RecommendBook>
)

class RecommendBookViewModel(
  private val recommendBookRepository: RecommendBookRepository
) : ViewModel() {

  private val _viewState = MutableStateFlow(ViewState(true, emptyList()))

  val viewState: StateFlow<ViewState> = _viewState

  fun loadRecommendBooks() {
    viewModelScope.launch {    
      val recommendBooks = recommendBookRepository.recommendBooks()
      _viewState.value = ViewState(false, recommendBooks)
    }
  }
}

loadRecommendBooks関数をいい感じにテストするためにはいくらかの準備が必要になる。

環境

次のような環境とする。

implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.21"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'

testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.6.1"

testImplementation 'org.robolectric:robolectric:4.4' // 多分今回のテストには関係ない
testImplementation 'androidx.test:runner:1.3.0'
testImplementation 'androidx.test.ext:junit:1.1.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2'
testImplementation "io.mockk:mockk:1.10.2"
testImplementation('androidx.test.ext:truth:1.3.0') {
  exclude group: 'com.google.auto.value', module: 'auto-value-annotations'
}

TestCoroutinesExtensionを用意する

Junit5ではorg.junit.rules.TestRule は無くなった。代わりにorg.junit.jupiter.api.extensionパッケージ内の各種インタフェースを実装したクラスを、ExtendWithで指定する形になった。そこでDispatchers.setMain()などを実行するTestCoroutinesExtensionを作る。

package hoge.fuga

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.*

// テストの前後の実行、テストのコンストラクタへのパラメータをサポートする
class TestCoroutinesExtension : AfterTestExecutionCallback, BeforeTestExecutionCallback, ParameterResolver {
  private val namespace = ExtensionContext.Namespace.create(javaClass)

  private val key: Any = TestCoroutineDispatcher::class.java

  private val dispatcher = TestCoroutineDispatcher()

  override fun afterTestExecution(context: ExtensionContext?) {
    Dispatchers.resetMain()
  }

  override fun beforeTestExecution(context: ExtensionContext?) {
    Dispatchers.setMain(dispatcher)
  }

  override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
    return parameterContext.parameter.type === TestCoroutineDispatcher::class.java
  }

  override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any {
    return extensionContext.getStore(namespace).getOrComputeIfAbsent(key, { dispatcher }, TestCoroutineDispatcher::class.java)
  }
}

ViewModelのテストを書く

TestCoroutinesExtensionを使ってRecommendBookViewModelのテストを書く。

import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.mockk
import hoge.fuga.TestCoroutinesExtension
import hoge.fuga.recommendbook.RecommendBook
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

// エクステンションを使う
@ExtendWith(TestCoroutinesExtension::class)
class RecommendBookViewModelTest(val dispatcher: TestCoroutineDispatcher) {
  // コンストラクタでTestCoroutineDispatcherを受け取る↑

  @Nested
  inner class loadRecommendBooks {
    @Test
    fun `success`() = dispatcher.runBlockingTest {
      val viewModel = RecommendBookViewModel(
        mockk {
          coEvery { recommendBooks() } returns listOf(
            RecommendBook("a"), 
            RecommendBook("b"), 
            RecommendBook("c")
          )
        }
      )
      viewModel.loadRecommendBooks()

      val viewState = viewModel.viewState.value
      assertThat(viewState.loading).isFalse()
      assertThat(viewState.recommendBooks.size).isEqualTo(3)
    }
  }
}

割とシンプルにできて良さそう。

患者さんと病院をつないでいくシステムの今とこれから

本エントリはUbie Advent Calendar 2020の22日目です。 21日目はtoCプロダクトAI受診相談ユビーのプロダクトオーナーである@shikicheeエンジニアの僕が強みを活かして施策推進したら、異次元の角度で数字が伸びちゃった話でした。

自分は現在toBプロダクトである、医療機関向けのAI問診ユビーのソフトウェアエンジニアをしています。普段はRails/Ruby、Spring Boot/Kotlin、React/TypeScriptを使ってバックエンドからフロントエンドまでプロダクトに関連することについて全般的に開発をしています。

またこのほかに最近は全社的な技術戦略に関するロールを持っており、サービス全体のカタチをどのようにしていくかについて考えたり手を動かしたりしています。

本エントリでは、Ubieの現在のシステムの状況と、起こり始めている現象などを紹介しつつ、今後どのようなことを行っていくかについてまとめます。

Ubieの今とサービス群

Ubieは今年で創業から4年目を迎えました。現在のサービス群の基となるコードは、共同創業者の一人である久保(@quvo_ubie)が大学院時代の研究で作り始めました*1。コードベースとしてはこの研究時代の2年間を足した6年物ということになります。

現在のUbieのソフトウェアエンジニアやサービス、プロダクトの数は次の通りです。

  • ソフトウェアエンジニアの数: 24人
  • サービスの数(デプロイの単位):15個
  • プロダクトの数:4個

独立して動作するサービス群の関連は次のようになっています。

f:id:sys1yagi:20201222101719p:plain
サービスの関連図

この図にプロダクトの境界を引くと...

f:id:sys1yagi:20201222101752p:plain
プロダクトの境界線

結構こんがらがっているように見えますね。実際に結構こんがらがっています。

現在に至る変遷

Ubieでは創業から現在に至るまで、不確実性を下げるための検証にフォーカスしてきました。それぞれの局面毎に、リソースや時間の制約を鑑みて最善手と考えられるものを重ねているものの*2、やはりどうしても複雑化していっています。

創業前 疾患推測エンジン

f:id:sys1yagi:20201222104134p:plain:w200
最初のサービス

  • 創業前(およそ2年)
  • ソフトウェアエンジニアの数:1人
  • サービスの数:1個
  • プロダクトの数:0個
  • 不確実性:症状群の入力による疾患推測が実現可能か

創業1年〜 toC 症状チェッカー Androidアプリ

f:id:sys1yagi:20201222104540p:plain:w300
AndroidアプリとAPI

  • 創業1年~
  • ソフトウェアエンジニアの数:2〜3人
  • サービスの数:2個 (Androidアプリも1個と数える)
  • プロダクトの数:2個
  • 不確実性:症状に基づいた疾患推測にニーズがあるのか

創業2年〜 toB AI問診サービス 病院向け

f:id:sys1yagi:20201222105152p:plain:w350
病院向けのフロントエンドを追加

  • 創業2年~
  • ソフトウェアエンジニアの数:3〜7人
  • サービスの数:3個
  • プロダクトの数:2個
  • 不確実性:症状チェッカーを病院につなげることがそもそもできるのか

疾患推測エンジンの実現、症状チェッカーの価値検証を経た後、出口としては 実際に患者さんが病院に受診する/しないことが重要となってきます。この時点で医療機関にとっては、症状チェッカーは患者さんの受診行動に影響を与えるかもしれませんが、業務上の課題には特に貢献しません。病院に受診した患者さんは改めて1から問診を行うからです。そこで医療機関の業務上の課題解決にフィットするプロダクトの検証が始まりました。

創業2.5年〜 toC 症状チェッカーサービス(テスト版)

f:id:sys1yagi:20201222124624p:plain:w500
チームが複数生まれてサービスも分割されていった

  • 創業2.5年~
  • ソフトウェアエンジニアの数:7〜12人
  • サービスの数:8個 (toC, toB, 疾患推測の単位で分割開始)
  • プロダクトの数:2個
  • 不確実性:症状に基づいた疾患推測ニーズのスケーラビリティ

医療機関向けのプロダクトの形がある程度見えてきた頃、症状チェッカーのリプレースが始まりました。Androidアプリは廃止し、Webサービスとして作り直しています。アプリの場合、症状チェックをするために症状の発現 -> 症状で検索などをする -> アプリの認知 -> インストール -> チェック という風にかなりの数の手続きを踏まなければなりません。症状があるユーザにとってはかなりの負担です。Webサービスにすることで症状の発現 -> 症状で検索などをする -> ランディング -> チェック とスムーズに症状チェックができるようになりました。同時にその形態でスケールは可能なのかという点の検証が必要となります。

創業3年〜 toB AI問診サービス クリニック向け

f:id:sys1yagi:20201222130549p:plain
病院向けにクリニック向けが乗っかる形

  • 創業3年~
  • ソフトウェアエンジニアの数:12~18人
  • サービスの数:12個
  • プロダクトの数:3個
  • 不確実性:クリニックでスケールするための特有の普遍的な課題が存在するか

病院とは入院施設としてベッド数が20床以上、医師3名以上の機関を指します。それ以外は診療所となります。街に沢山あるクリニックや医院は診療所です。国内の病院はおよそ8000件で、診療所は100000件です。病院診療所では来院患者数や医療従事者の規模が異なります。業務上の課題や関心事も大きく異なるのではないか、という仮説のもと、プロダクトを分けて検証をはじめました。

創業4年〜 toB AI問診サービス アジア向け、症状チェッカーと AI問診サービスの接続

f:id:sys1yagi:20201222131505p:plain
あっという間に15個

  • 創業4年~
  • ソフトウェアエンジニアの数:18~24人
  • サービスの数:15個
  • プロダクトの数:4個
  • 不確実性:
    • AI問診サービスが、海外の医療環境においても課題解決に貢献できるのか
    • 症状チェッカーをトリガーとした、受診をする/しないが起こるのか

国内の医療機関への導入が徐々に進んできたところで、医療制度が近しい国においてもAI問診サービスが役に立つのか検証をはじめました。また国内においては症状チェッカーで行った問診をAI問診サービスを導入している病院に直接送ることができるようになりました。2つのプロダクトが繋がり、サービスの関連図には現れてこない複雑さも生まれ始めています*3

現在起こっている現象

ソフトウェアエンジニアの人数、サービスの個数、プロダクトの数が着々と増えていく中、開発において起こっている現象が沢山あるわけですが、代表的なものとしては次の通りです。

  • 暗黙知の部分のキャッチアップや変更コストが増加
    • 特に文脈を知らない新メンバーは大変そうです。新メンバーはみんな荒野でも生き抜けるパワーを持っていますがとはいえ力を発揮するリードタイムはだんだん増加していっています。
  • プロダクト間で共用している箇所が存在し、壊れやすい
    • 共通ではなく共用となっています。一言でいうと1ロジックに2プロダクトの知識が詰まっていたりします。即日壊れます。
  • 新しいアイデアの検証をするときのインベストが大きい領域がある
    • たとえば、問診を聴取していくフローは症状チェッカーと医療機関向けプロダクトの両方の知識を有していて、さらにそれぞれが複数のパターンを持っているので、分岐が複雑になっています。特定の診療科向けに柔軟に質問を追加したり省略するといったことを気軽に試すのは難しい状態となっています。

技術的負債という視線

個別に現象に対処して行ってもいいですが、現在の人数やプロダクトの増加速度を鑑みると、もう少し中長期目線で考えたほうがよさそうだなぁということで、技術的負債の観点でそれぞれの現象を見直してみました。

ここでいう技術的負債とは、技術的負債の言葉の生みの親であるウォード・カニンガム氏の次の定義に準じます。

「もしも自分たちが書いているプログラム(WyCash)を、金融の世界に関する正しい捉え方だと自分たちが理解した姿と一致させることができなくなれば、自分たちは絶えずその不一致につまずき続けることになり、開発スピードは遅くなっていくでしょう。それはまるで借金の利子を払い続けるかのようです」

t-wada.hatenablog.jp

それぞれ 正しい捉え方だと自分たちが理解した姿と一致していないのではないか という観点で見直すと、

  • 暗黙知の部分のキャッチアップや変更コストが増加
    • システムが、正しい捉え方だと自分たちが理解した姿と一致していない形であることで、リバースエンジニアリング的なアプローチによる理解を困難にしているのではないか。本当に複雑なのか、一致していないのか見直す必要があるのではないか
  • プロダクト間で共用している箇所が存在し、壊れやすい
    • 共用している部分が、利用者にとって独立した共通のものなのか、似ている別のものなのかが曖昧。あるべき姿にできていないか、あるべき姿をまだ見いだせていない可能性があるのではないか。
  • 新しいアイデアの検証をするときのインベストが大きい領域がある
    • システムが、あるべき姿の解像度が低い段階の時のままになっていることで大きく結合してしまっているのではないか。そのために不確実性の高いものを部分的に足し合わせる時に必要な変更が大きくなるのではないか。

不確実性の高い部分を素早く検証するために、不一致を許容するのは重要である一方で、不確実性が下がった後もその部分を放置しておくと、やがて様々な症状が現れてくるのかなと思います。

f:id:sys1yagi:20201222162344p:plain
不確実性の混在を避ける

逆にこれらを解決していけば、現象の解消や防止ができるのではないかと考えました。

ROIで考える

正しい捉え方だと自分たちが理解した姿と一致させ続けることはとても重要だと考えつつも、とはいえリソースと時間の制約があるので、どこまでなにをするかをどう決めていくかは大事です。Ubieでは皆2言目にはROIだROIだと言っています。

f:id:sys1yagi:20201222163521p:plain
ポジショントーク用のポンチ絵

図はほとんど意味を持っていませんが、つまり、複雑度を上げるような追加変更は線形に増えていきますが、開発のインベストはいつの間にか指数関数的に増加していくので、インベストが爆発するまえになんとかしたい、ということです。同時にこれを予防できればある程度の負債は許容してもよさそうです。

なぜインベストの増加を予防したいのかというと、当然速度やコストに効いてくるからですが、それ以外にも意思決定の歪みを防ぐという観点があるかと思います。

f:id:sys1yagi:20201222164024p:plain
issueの質が高くて低インベストの領域にフォーカスしたい

技術的負債によってインベストが増加すると、質の高いissueであっても優先度が下がったりします。重要な検証の優先度が下がるのは時にクリティカルになります。

f:id:sys1yagi:20201222164027p:plain
issueの質が高いのに高インベストのために後回しになる

ということで、直近で解くべき課題は

  1. 現在すでにインベストが高くなっている箇所の解消
  2. 将来インベストが高くなると予見される箇所などの可視化
  3. 不確実性が高い場所と低い場所が共存できるカタチを見つける(機動的な検証が可能な状態)

かなと定義しました。これによって人数やサービスやプロダクトが増えても速度を落とさない、むしろ加速するような状態を作っていきます。

マイクロサービスアーキテクチャとDDD(ドメイン駆動設計)に入門する

守破離ということで、すでにややマイクロサービスぽく分割していることもあって、マイクロサービスアーキテクチャについて改めてしっかりと入門することにしました。

マイクロサービスアーキテクチャの序盤の章では、サービス分割の観点でDDDを参照しています。

DDDの考え方は、技術的負債のあるべき姿と一致させるという考え方と似ています。エリック・エヴァンスのドメイン駆動設計 では歯を食いしばって真のモデルとシステムを一致させるといった言葉が何度も出てきます。

マイクロサービスアーキテクチャの各種ノウハウを取り入れる前に、まずは現在のシステムとあるべき姿のギャップを、DDDにおけるコンテキストとその境界を描くことで可視化していきました。

あるべき形をコンテキストマップで可視化する

f:id:sys1yagi:20201222173856p:plain
境界づけられたコンテキストを考えていく

コアドメインはなんだろうというところから初めて、徐々に現在のサービス群が何を提供しているかなどを考えながら付け足していきました。

実際の作業はmiroで複数人で行っています。

f:id:sys1yagi:20201222174652j:plain
実際の図。めっちゃぼかしてます。

あんまり現実のシステムを意識せず、理想状態を考えるとスムーズに行くなぁという感想です。同時にあまりにギャップありすぎてちょっと引くみたいな時もあります。

コンテキスト境界と現実の乖離が分かってくる

コンテキストマップを書いていくと、実際のコンテキストとサービスが横断している場所が見えてきました。次の画像は症状チェッカーと医療機関向けプロダクトが連携する箇所ですが、複数のサービスが1つのコンテキストに対して同時に携わっていることがわかります。実際にこの部分は壊れやすく、慎重な変更が必要になってしまっています。

f:id:sys1yagi:20201222174932p:plain
複数のプロダクトが交差する場所

価値創出の単位と業務フロー

また、コンテキストマップを描いていく中で、1つの業務フローに対して、いくつかの独立したコンテキスト達が複数組み合わせられていることに気づきました。

f:id:sys1yagi:20201222180152p:plain
一つの業務フローに対して複数のコンテキストが組み合わさっている

それぞれのコンテキストは個別に価値を生み出せそうな単位でありますが、現状はプロダクトの特定の業務フローのなかに埋まっています。これらを価値創出の単位として切り出せそうです。

f:id:sys1yagi:20201222180600p:plain
それぞれのコンテキストが独立して価値を生み出す

価値創出の単位を自由に組み合わせることができるようになれば、考えてもみなかった、あるいは実現が難しいと思っていたプロダクトをどんどん作り出せるのではないか、と期待できます。

不確実性を内包するためのモジュラモノリスの考え方を取り入れる

コンテキストマップによって現在すでにインベストが高くなっている箇所の解消のためのアクションの洗い出しや、将来インベストが高くなると予見される箇所などの可視化ができました。不確実性が高い場所と低い場所が共存できるカタチを見つけるについてはまだまだこれからですが、価値創出の単位がそれぞれ確からしいかどうかを判断したり、新しい不確実性をどのように追加していくかについて、モジュラモノリスの考え方が役に立つのではないかと思っています。

www.infoq.com qiita.com

モジュラモノリスモノリスの中でモジュールによって境界を分離していくものです。すでにUbieは複数のサービスが分かれているので、本当のモジュラモノリスにしていくぞというのは現実的ではありません。しかし複数のコンテキストを持っていそうなサービスにおいて、モジュールによる分離をまずは行うというアプローチは非常にコスパが良いのかなと思っています*4

価値創出を掛け算可能にするシステムを作る

ここから先はまだこれからです。ということでおそらく大小様々な課題と直面していくと思いますが、一連の活動を価値創出を掛け算可能にするシステム の第1歩目と定義し、医療の領域の課題解決を加速していきたいと考えています*5。プロダクト開発もエンジニアリングも好きという方は是非一緒にやっていきませんか!?

note.com

待ってます。

*1:症状に基づいた疾患推測エンジンの研究が、現在のUbieのプロダクトの根幹となっています

*2:もちろん失敗してる場合も沢山あります!

*3:サービスの関連図に現れてないということはどこかのサービスが複数の関心事を取り扱っていることになりますが、まさにそうです!

*4:まだ何も試してないので本当に雰囲気で良さそうって思ってるだけです

*5:正直来年の今頃入社するメンバーがうらやましい!

testing-library/reactでmaterial-uiのTextFieldの値をテストする

material-uiのTextFieldをtesting-library/reactでテストしようとすると、HTMLElementを取り出すところで苦労したのでメモを残す。

TextFieldに付与したaria-labelはinputのラッパー要素に付く

次のTextFieldがどこかのコンポーネントにあるとする。

<TextField
  name="name"
  label="name"
  aria-label="name"
  variant="outlined"
  value="title"
/>

実際にDOMにレンダリングすると次の構造になる。ルートの要素にaria-labelがついていることがわかる。

<div
  aria-label="name"
  class="MuiFormControl-root MuiTextField-root"
>
  <label
  class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink MuiInputLabel-outlined MuiFormLabel-filled"
  data-shrink="true"
  >
  name
  </label>
  <div
  class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl"
  >
  <input
    aria-invalid="false"
    class="MuiInputBase-input MuiOutlinedInput-input"
    name="name"
    type="text"
    value="title"
  />
  <fieldset
    aria-hidden="true"
    class="PrivateNotchedOutline-root-1 MuiOutlinedInput-notchedOutline"
  >
    <legend
    class="PrivateNotchedOutline-legendLabelled-3 PrivateNotchedOutline-legendNotched-4"
    >
    <span>
      name
    </span>
    </legend>
  </fieldset>
  </div>
</div>

testing-libraryのgetByLabelText関数は、ラベル名でHTMLElementを取り出すので、input要素に直接アクセスできないことがわかる。解決策として2つの方法がある。

querySelector関数を使う

1つ目の方法はgetByLabelText関数でルート要素を取り出したあと、querySelector関数を使ってinput要素を探すというもの。

import { render, cleanup } from "@testing-library/react/pure";
import "@testing-library/jest-dom/extend-expect";
import { TextField } from "@material-ui/core";

describe("Material UIのTextFieldのテスト", () => {
  it("querySelectorを使ってvalueの値をチェックする", () => {
    const result = render(
      <TextField
        name="name"
        label="name"
        aria-label="name"
        variant="outlined"
        value="title"
      />
    );
    // input要素を取り出す。
    const input = result.getByLabelText("name").querySelector("input");
    expect(input?.value).toEqual("title");
  });
});

querySelector("input")の戻り値はHTMLInputElement | null なので適宜nullチェックを加える必要があるが、値を検証するだけならinput?.valueとするだけで十分。

getByDisplayValue関数を使う

2つめの方法は、getByDisplayValue関数を使ってvalue値からinput要素を取り出す。

describe("Material UIのTextFieldのテスト", () => {
  it("getByDisplayValueを使ってvalueの値をチェックする", () => {
    const result = render(
      <div>
        <div>title</div>
        <TextField
          name="name"
          label="name"
          aria-label="name"
          variant="outlined"
          value="title"
        />
      </div>
    );
    const input = result.getByDisplayValue("title");
    // elementが取り出せた時点で 'title' というvalueを持っていることがわかるので
    // このexpectは実際は不要
    expect(input.getAttribute("value")).toEqual("title");
  });
});

getByDisplayValue関数はinput要素系のための関数なので便利なのだが、値を使って取り出すので同じ値のinput要素があったりするとエラーになってしまう。

おわりに

個人的にはラベルを指定した上でquerySelector関数を使う方法が好きですが、しかしMaterial UIの内部構造を知った上で書く必要があるのでちょっとな〜という気はしますが仕方ない気もする。

bit全探索 in Kotlin

最近AtCoderなどに参加していて、すべての組み合わせを生成しつつ計算するといった機会になんどか遭遇し、毎回頑張って実装していたのだけど、bit全探索という方法があるらしいと知り、調べて、Kotlinでどう書くか考えた結果次のようになった。

import java.util.BitSet

fun bitFullSearch(n: Int): List<BitSet> = (0 until (1 shl n)).map { bit ->
    BitSet(n).apply {
        repeat(n) { i ->
            set(i, bit and (1 shl i) > 0)
        }
    }
}

たとえば bitFullSearch(4) などと呼び出すと、それぞれ次のbitが立ったBitSetのリストが手に入る。

{}
{0}
{1}
{0, 1}
{2}
{0, 2}
{1, 2}
{0, 1, 2}
{3}
{0, 3}
{1, 3}
{0, 1, 3}
{2, 3}
{0, 2, 3}
{1, 2, 3}
{0, 1, 2, 3}

bitSet.get(i) でそのインデックスのビットが有効かBooleanが手に入るほか、bitSet.stream()で有効なインデックスのストリームが手に入るので大体いい感じにできる。

Material-UIのHidden要素をテストする

個人のプロジェクトでMaterial-UIを使っているんですが、 コンポーネントHiddenを含んでいると、テストがうまく動きません。

hoge.tsx

import * as React from 'react';
import {Hidden} from "@material-ui/core";

export const Hoge = (): JSX.Element => {
  return (
    <div>
      <Hidden mdUp>
        <div>mdUp</div>
      </Hidden>
      <Hidden smDown>
        <div>smDown</div>
      </Hidden>
    </div>
  )
};

hoge.test.tsx

import React from "react";
import {render, cleanup} from '@testing-library/react'
import {Hoge} from "../hoge";

afterEach(async () => {
  await cleanup();
});

describe('hoge', () => {
  describe('ある幅以上の時のレンダリング', () => {
    it('mdUpが表示される', () => {
      const {getByText} = render(
          <Hoge/>
      );
      expect(getByText("mdUp")).toBeVisible()
    });
  });
});

見つけられない...ていうかそもそもHidden要素がどっちもレンダリングされていない

TestingLibraryElementError: Unable to find an element with the text: mdUp. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    <body>
      <div>
        <div />
      </div>
    </body>

      13 |           <Hoge/>
      14 |       );
    > 15 |       expect(getByText("mdUp")).toBeVisible()
         |              ^
      16 |     });
      17 |   });
      18 | });

環境

"@material-ui/core": "^4.10.2",
"react": "^16.13.1",
    
"@testing-library/dom": "^7.16.3",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.3.0",
"@testing-library/user-event": "^12.0.7"

原因など

Unable to test React with Material-UI Hidden element · Issue #2179 · enzymejs/enzyme · GitHub によると、jest-domとmaterial-uiの双方に課題があるようです。 -> https://github.com/enzymejs/enzyme/issues/2179#issuecomment-528973289

対応

対応方法は2つほどあるぽいです、ここではかんたんな方を書きます。 テスト側でrenderするときにcreateMuiThemeを用いて初期の画面幅を指定するやりかたです。

import React from "react";
import {render, cleanup} from '@testing-library/react'
import {createMuiTheme, MuiThemeProvider} from "@material-ui/core";
import {Hoge} from "../hoge";

afterEach(async () => {
  await cleanup();
});

describe('hoge', () => {
  describe('ある幅以上の時のレンダリング', () => {
    it('mdUpが表示される', () => {
      const theme = createMuiTheme({props: {MuiWithWidth: {initialWidth: 'sm'}}})
      const {getByText} = render(
        <MuiThemeProvider theme={theme}>
          <Hoge/>
        </MuiThemeProvider>
      );
      expect(getByText("mdUp")).toBeVisible()
    });
  });
});

これで画面幅を指定して実行できます。 initialWidthをmdにするとsmDownの方の要素が表示状態になります。

おわりに

ReactでTDDでやっていくぞと思って触り始めたけどやはりUI部分はなかなか大変だなと思いつつ、Androidよりは楽かもという気持ち