Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Kubernetesを本番導入しました

こんにちは、SREチームの栗山(@sheepland)です。 さて、スタディプラスでは2020年9月に念願の Kubernetes本番導入 を果たしました🎉。 本番導入といってもまだ10マイクロサービスあるうちの1つをKubernetes上で稼働させているだけです。しかしそこに至るまで様々な苦労がありました。 今回はKubernetes本番導入をするにあたりやってきたことや使用したツールを紹介したいと思います。

スタディプラスのインフラの現状の課題

Kubernetes導入の話の前にスタディプラスのインフラの現状の課題を簡単にお話したいと思います。

デプロイ速度が遅い

スタディプラスの一番大きなメインのサービスはAWS Elastic Beanstalkで稼働しています。 Elastic Beanstalkを使ってgracefulにデプロイするためには、EC2インスタンスを追加→デプロイ→古いインスタンスを停止といった流れになります。EC2インスタンスを1から起動しセットアップ&デプロイするのでどうしてもデプロイ時間がかかってしまいます(現状10分以上かかっている)。

Elastic Beanstalkのマネージドサービスがゆえの不自由さ

Elastic Beanstalkはあまりサーバーを意識せずに使えるというメリットがありますが、専用の知識が必要でデプロイも遅く設定の更新やロールバックも遅いといった課題があります(他にも細かい不満はたくさんあったりします)。 またローカルで動かすことができないので試行錯誤は実際のElastic Beanstalkを使って行う必要があります。マネージドサービスのつらいところですね。

AMIの更新、OS/ライブラリ/ミドルウェア/言語のバージョンアップが手間がかかる

スタディプラスのメインのサービス以外はEC2上で動いています。 それらのEC2のAMIの更新やOS/ライブラリ/ミドルウェア/言語のバージョンアップをする場合、新しいEC2を立ててリクエストをそっちに流し古いEC2を削除する...といった流れになり手間がかかるという問題があります。

CI/CDパイプラインが複雑

CI/CDパイプラインにはJenkinsおよびHubotを使っていますが、なかなか複雑なフロー&処理内容になっています。 またJenkins自体の管理コストの高さも問題です。

カナリーリリースが手間がかかる

言語、フレームワークのバージョンアップや、大きな機能のリリース時にはカナリーリリースをしたくなるものです。しかし現状、カナリーリリースをするためには、新しいElastic BeanstalkもしくはEC2をたてて、新しい方へ少しずつリクエストを流す必要があります。そして終わったらインスタンスを破棄し…となかなか手間がかかります。

Kubernetesを選んだ理由

上記であげた課題を解決するために、Kubernetesを導入することを決めました。 「アイデアというのは複数の問題を一気に解決するものである」という言葉がありますが、まさにKubernetesは様々な課題をまとめて解決できる可能性を秘めている素晴らしいソリューションです。

他にも、コンテナオーケストレーションツールとしてKubernetesを選んだ理由としては以下があります。

  • 必要な機能が揃っており、やりたことがスマートにできる
  • 自己回復力(セルフヒーリング)の高さ
  • ユーザ数の多さ、人気の高さ
  • 強力なエコシステム
  • 進化の速度が速い
  • 高い拡張性
  • 特定のベンダーに依存しない

導入戦略

いきなり一番大きなメインのサービスをKubernetes移行するのはリスクがあったため、手始めにEC2上で稼働している小さめのサービスをKubernetes上で稼働するようにしました。失敗しても影響が小さいサービスを移行し、そこから得た知見を元に一番大きなサービスを移行するという戦略です。 結果的にはKubernetesの知見を溜めることができ、Kubernetes本番導入までの時間を短くできたので戦略として正解でした。

構成

KubernetesクラスタとしてEKSを使っています。 LBに関してはAWS ALB Ingress Controllerを使って作成をしています。 またシングルテナント(サービスごとにKubernetesクラスタを作成)にすると、Kubernetesクラスタバージョンアップのコストが高くなってしまうため、マルチテナントを選択しました。

監視ツールの移行

監視システムはMackerelを使っています。しかしDatadogのほうが高機能でKubernetesにもフィットするため、Datadogに移行することを決めました。 現在はKubernetes上で稼働しているサービスとAWSサービス(RDS、ALB等々)をDatadogで監視しており、今後全ての監視をDatadogに移行する予定です。

IaCツールの移行

IaCツールはAnsibleをメインで使っています。しかしAnsibleのAWSモジュールはあまりメンテナンスされていないという問題や、現在の構成との差分が取れない、使っている人が少ないため情報が少ないという問題があります。 そこでKubernetes移行のタイミングでAWSリソースをTerraformを使って構成管理していくことに決めました。 まだ全てをTerraform化はできていませんが、Kubernetesクラスタを作成するのはTerraformを使ってできるようになっています。

使っているツール

Kustomize

マニフェストファイルの管理にはKustomizeを使っています。 Kustomizeの良い点は以下です。

  • 環境差分をシンプルに定義できる
  • ConfigMapやSecretを更新した際にPodも合わせて再生成できる(configMapGenerator、secretGenerator機能)
  • リソースに対してnamespaceやlabelを簡単に一括付与できる

Skaffold

KubernetesへのデプロイツールとしてSkaffoldを使っています。 Skaffoldの良い点は以下です。

  • imageのbuild、imageのpush、マニフェストファイルのapplyをまとめて実行できる
  • ローカルのKubernetesでも、ローカル以外のKubernetesでも同じコマンド(skaffold run)でデプロイできる
    • またローカルの場合はimageのpushがskipされる
  • ローカルではskaffold devコマンドによって、コードの変更を自動検知して imageのbuild、マニフェストファイルのapplyを自動実行してくれる。これによりDockerfileの修正やマニフェストファイルの修正の確認が素早く行える
  • profile機能によって環境差分が定義できる
  • image tagの生成ルールが柔軟に指定できる

その他

kubectx、kubensはcontext、namespaceの切り替えに重宝しています。(今後はkubectlプラグインのほうを使っていきたい) またkube-ps1も、どのcontext、namespaceを使っているかがひと目で分かり重宝しています。

デプロイ

デプロイサービスとしてCircle CIを使っています。 最近のトレンドであるGitOpsも考えましたが、ファーストリリースはできるだけミニマムの構成にしたいというのと、チームの学習コストの増加を避けるために、アプリケーションのCIでも使っているCircle CIを使うことを選択しました。(今後はGitOpsでデプロイすることも検討しています。)

デプロイの流れは以下のようになっています。

  1. masterブランチにPull Requestをmergeする
  2. Circle CIのworkflowが起動する。以降はworkflow内の処理
  3. imageのbuild & ECRへpushがされる
  4. staging環境へデプロイがされる
  5. Slackへ「staging環境へデプロイが終わりました。動作確認をして問題なければ承認ボタンを押して下さい」と通知がされる(Circle CIのジョブへのリンク付き)
  6. Circle CI上の承認ボタンを押したら、production環境へデプロイがされる
  7. デプロイ完了通知をSlackへ通知がされる

細かい工夫としてはproduction環境デプロイ前にkubectl diffを実行して、現在の環境とこれから適用するマニフェストの差分をCircle CI上で実行&出力し、意図した変更が行われるかを確認できるようにしています。(Slackに差分確認ボタン付き通知もしている)

ログ収集

ログ収集&ログ分析は Fluentd + S3 + Athena で行っています。 詳しくは以下の記事をご覧ください。
Kubernetes上でのFluentdを使ったログ収集について

負荷試験

サービスを新しい基盤で動かす際には負荷試験が不可欠です。 今回はLocustを使って負荷試験を行い、本番のリクエスト数の3倍でも問題ないかと、どのくらいまでのRPSを処理できるかをテストしました。 負荷試験によってどこがボトルネックになるかを把握でき、万が一処理限界に達した場合に監視で気付けることを確認できて、安心してリリースすることができました。

クラスタバージョンアップ試験

今後定期的にKubernetesクラスタをバージョンアップしていく必要があります。 その際スムーズにバージョンアップができることを確認するためにバージョンアップ試験をしました。
あえて古いバージョンでクラスタを作成し、作成したバージョンアップ手順書に沿ってバージョンアップ作業を行い、滞りなくバージョンアップができることを確認しました。 この事前のクラスタバージョンアップによって、Terraformのディレクトリ構成の改善(複数バージョンのKubernetesクラスタを定義できるように修正)ができたりしてとても有意義でした。

Kubernetesを組織に浸透させるために

真にKubernetesを組織に浸透させるためには、SREメンバーだけではなくアプリケーションエンジニアもKubernetesに習熟することが肝要です。 しかしKubernetesはそれなりに学習コストがあり、実際に手を動かしてみないと理解が難しいという側面があります。
そこで現在はKubernetes輪講会を開催しており、Kubernetes完全ガイドを教材にして持ち回りで講義を開いています。輪講という形であれば強制的に人に教えられるレベルに引き上げられるため、参加者の負担が大きいですがとても効果的だと感じています。まだ始めたばかりですがKubernetes輪講会の完走を目指して参加メンバー一丸となって頑張っています。

今後について、やりたいこと

ひとまずKubernetes導入は達成できましたが、やりたいことはまだまだあります。
まずはメインシステムのKubernetes移行です。それが終われば、GitOps、 スポットインスタンスの活用、サービスメッシュ、Progressive Deliveryなどを検討していきたいと思っています。

さいごに

今回は一部のサービスをKubernetes移行しましたが今回の作業によって下地ができたため他のサービスのKubernetes移行は楽になっていると思います。今後はKubernetes移行を加速させ、開発速度を上げていき、サービスの価値向上に結びつけていきたいと思っています!

iOS 13におけるSiri Shortcuts 最小実装+α スライド書き起こしと補足

こんにちは、モバイルクライアントグループの明渡です。

先日iOSDC Japan 2020にてLTへ登壇させていただき、「iOS 13におけるSiri Shortcuts 最小実装+α」というテーマで発表いたしました。

fortee.jp

フィードバッグも思いの外たくさん頂戴いたしまして、登壇してみてよかったなと嬉しい限りですし、励みになります。ありがとうございました!

こちらは、上記LTをテーマとした以下の内容を含む記事となっております。

  • スライドの内容書き起こし
  • 補足
    • 一部のフィードバックへの回答
    • 観測した一部のニコ生コメントへの補足
    • Ask the Speakerで回答した内容
    • スライド作成過程で削った発表内容

なお、タイトルでiOS 13におけるとうたっておりますが、iOS 13から14へのアップデート内容はこちらの記事の内容でカバーしてる範囲について特に影響ございません。

StudyplusのSiri Shortcuts

2020年6月23日、StudyplusでSiri Shortcuts機能をリリースしました🎉

Siri Shortcutsに対応した画面

2画面対応を行い、それぞれAppleがSiri Shortcutsの実装を勧めるアプリを利用する上で何回も繰り返し行われる操作に該当すると判断して実装しました。

  • ユーザーのQRコード画面
  • 書籍のバーコード読み取りカメラ画面

f:id:m_yamada1992:20201012144642j:plain

Siri Shortcuts 利用状況

※2020年6月23日〜9月13日時点

  • ショートカット経由でアプリを開いた
    • 回数:4,697
    • 人数:4,438
  • Add to Siriボタンからアプリを開くショートカットを登録した
    • 回数:7,942
    • 人数:4,889

補足: 集計方法

導入済みのFirebase Analyticsにて上記の集計対象の操作をイベントとして定義し、ユーザーが該当の操作をした際に送信してます。 送信タイミングはこの発表のスライドにて記載したコードの範囲に含まれております。

Siri Shortcuts 最小実装

実装方法の前提

Siriショートカット自体に実装方法は大きく二通りありますが、紹介するのはNSUserActivityを用いる方法のみです。

  • Intents & Intents UIを用いる
    • アプリにて繰り返し行う動作を様々な値を受け取りつつ再現可能
  • NSUserActivityを用いる
    • 特定の画面を開いた状態のアプリを起動するなど、単純な動作を再現可能

iOS 14のアップデートでIntents UI側は追加要素があったものの、NSUserActivityのみ用いる実装には影響ないです。

実装手順

1. Info.plistにアクションのIDを定義

<plist version="1.0">
<dict>
<!--  中略  -->
    <key>NSUserActivityTypes</key>
    <array>
        <string>$(BUNDLE_ID).materialBarcodeRead</string>
        <string>$(BUNDLE_ID).viewQRCode</string>
    </array>
</dict>
</plist>

2. 該当のアクションを行うNSUserActivityオブジェクトを生成

Info.plistへ定義したアクションIDを含む、NSUserActivityオブジェクトを生成します。

コードはStudyplusバーコード読み取りカメラ画面用のオブジェクト生成処理より引用しました。

import Intents

struct MaterialBarcodeReadAction {
    
    static var actionName: String {
        return "\(bundleId).materialBarcodeRead"
    }
    
    static var userActivity: NSUserActivity {
        let userActivity = NSUserActivity(activityType: actionName)
        userActivity.persistentIdentifier = actionName
        userActivity.title = "教材のバーコードを読み取る"
        return userActivity
    }
}

3. 該当のアクションで開くUIViewControllerへオブジェクトを設定

生成したNSUserActivityオブジェクトを、アクションの結果開きたい画面のuserActivityプロパティへ入れます。

final class MaterialBarcodeReaderViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let activity = MaterialBarcodeReadAction.userActivity
        userActivity = activity
    }
}

4. AppDelegateにハンドル時の処理を実装

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {

    switch userActivity.activityType {
    case MaterialBarcodeReadAction.actionName:
        // バーコード読み取りカメラ画面表示
        return true
    case ViewQRCodeAction.actionName:
        // QRコード画面表示
        return true
    default:
        return false
    }
}

最小実装を済ませるとできること

iOS 13以降はプリインストールされている"ショートカット"アプリにて、利用可能なアクションとして選択肢が表示されます。

※選択肢に表示されるには、Siri Shortcuts実装した後の対象画面を1回以上開いている必要はあります。

ショートカットの登録手順

"ショートカット"アプリ上で以下の操作を行うと選択肢に表示されています。

f:id:m_yamada1992:20201012174411j:plain

f:id:m_yamada1992:20201012174333j:plain

ギャラリータブからも追加する導線もあります。

f:id:m_yamada1992:20201012174338j:plain

Spotlightの“Siriからの提案”にショートカットを載せる

最小実装のみだとユーザーが見つけにくいので、導線を増やしましょう。

実装手順

1. NSUserActivityオブジェクトにて提案に載せる値を設定

NSUserActivityオブジェクトを生成時に、Spotligit向けの値の設定します。

#if canImport(CoreSpotlight)
import CoreSpotlight
import MobileCoreServices
#endif
/* 省略 */
    static var userActivity: NSUserActivity {
        let userActivity = NSUserActivity(activityType: actionName)
        userActivity.persistentIdentifier = actionName
        userActivity.title = "教材のバーコードを読み取る"

        userActivity.isEligibleForPrediction = true
        #if canImport(CoreSpotlight)
        let attributes = CSSearchableItemAttributeSet(itemContentType: kUTTypeContent as String)
        attributes.title = "教材のバーコードを読み取る"         
        attributes.contentDescription = "登録したい教材のバーコードを読み取れます"
        userActivity.contentAttributeSet = attributes
        #endif
        
        return userActivity
    }

isEligibleForPredictiontrueへ設定すると、Spotlightで"Siriからの提案"として表示を有効にします。 このプロパティをtrueにしないとSpotlightに永遠に表示されないので注意です。

2. 該当ViewControllerを開いた際に通知する

画面からOSへ、このアクションが実装されている操作をした旨を通知をします。

final class MaterialBarcodeReaderViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let activity = MaterialBarcodeReadAction.userActivity
        userActivity = activity

        activity.becomeCurrent()
    }
}

activity.becomeCurrent()が、実装済みのNSUserActivitiyオブジェクトで再現できる操作をしたことを通知する処理です。

通知の実装を済ませるとできること

該当の画面をよく使う場合、"Siriからの提案"へ表示されるようになります。

"Siriからの提案"で該当アクションを選択すると、アクションが実行され画面に遷移します。 この導線から、ショートカットの追加はできません。

"Siriからの提案"のデバッグについて

端末の設定 > デベロッパ > Display Recent ShortcutsをON にしましょう。

"該当の画面をよく使う場合"という定義が、具体的にどの程度の回数や条件を満たせば良いのかはアプリ開発者に公開されていません。動作確認時は必ずデバッグ機能を利用しましょう。

Add to Siriボタンを配置する

Add to Siriボタンを配置すると、実際にその画面をよく利用するユーザーが見つけやすくなります。

なお、Add to Siriボタンを配置するか否かの判断は、後述するAdd to Siriボタンを配置するとよい画面、配置しないほうがよい画面も踏まえることをお勧めします。

実装手順

1. ボタンを配置する

final class MaterialBarcodeReaderViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let activity = MaterialBarcodeReadAction.userActivity
        userActivity = activity

        activity.becomeCurrent()

        let addToSiriButton = INUIAddVoiceShortcutButton(style: .automaticOutline)
        addToSiriButton.shortcut = INShortcut(userActivity: activity)
        addToSiriButton.delegate = self
        
        view.addSubview(addToSiriButton)
    }
}

Add To Siriボタンのオブジェクトが上記におけるaddToSiriButtonです。 ショートカットへ登録させたいアクションのNSUserActivityオブジェクトを基に生成したINShortcutを、shortcutプロパティへ入れます。

また、INUIAddVoiceShortcutButtonDelegate に準拠して、次に実装を行うボタン押下時のイベントを受け取れるようにします。

2. ボタンを押下後の処理を実装

extension MaterialBarcodeReaderViewController: INUIAddVoiceShortcutButtonDelegate {
    
    func present(_ addVoiceShortcutViewController: INUIAddVoiceShortcutViewController, 
                 for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {

        addVoiceShortcutViewController.delegate = self
        present(addVoiceShortcutViewController, animated: true, completion: nil)
    }
    
    func present(_ editVoiceShortcutViewController: INUIEditVoiceShortcutViewController,
                 for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {

        editVoiceShortcutViewController.delegate = self
        present(editVoiceShortcutViewController, animated: true, completion: nil)
    }
}

INUIAddVoiceShortcutViewController、およびINUIEditVoiceShortcutViewController は親にUIViewControllerを持つSiriに追加画面専用クラスです。 上記コードのpresentは通常のUIViewControllerでモーダル表示を行う際の処理と本質的に変わりません。

補足: 親にUIViewControllerを持つためできることの例

親にUIViewControllerを持つSiriに追加画面専用クラス

なので、iOS 13以降の標準であるmodalPresentationStyleプロパティへ.pageSheetが設定済みのモーダル表示をした際に、下スワイプ操作で画面を閉じた場合に通常の画面と同様に検知する手段があります。

extension MaterialBarcodeReaderViewController: INUIAddVoiceShortcutButtonDelegate {
    
    func present(_ addVoiceShortcutViewController: INUIAddVoiceShortcutViewController, 
                 for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {

        addVoiceShortcutViewController.delegate = self
        addVoiceShortcutViewController.presentationController?.delegate = self 

        present(addVoiceShortcutViewController, animated: true, completion: nil)
    }
    
    func present(_ editVoiceShortcutViewController: INUIEditVoiceShortcutViewController,
                 for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {

        editVoiceShortcutViewController.delegate = self
        editVoiceShortcutViewController.presentationController?.delegate = self 

        present(editVoiceShortcutViewController, animated: true, completion: nil)
    }
}

表示元のクラスをUIAdaptivePresentationControllerDelegateへ準拠して、presentationController?.delegateプロパティに入れます。

extension MaterialBarcodeReaderViewController: UIAdaptivePresentationControllerDelegate {
    
    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
        // 画面が閉じた際に行う処理
    }
}

通常のUIViewControllerと同様に、下スワイプにより画面を閉じる操作を検知して処理を実行できるようになります。

Studyplusでは、カメラ映像のキャプチャを伴うバーコード読み取り画面でSiriに追加画面を開く際にキャプチャを一時停止しております。 そしてSiriに追加画面でショートカットの追加・編集・削除・キャンセル時に加え、下スワイプ操作で画面を閉じた際にもキャプチャを再開する実装をしました。

3. Siri Shortcutsの追加・編集完了時の処理を実装

Siriに追加画面にてショートカット追加完了時
extension MaterialBarcodeReaderViewController: INUIAddVoiceShortcutViewControllerDelegate {

    func addVoiceShortcutViewController(
             _ controller: INUIAddVoiceShortcutViewController, 
             didFinishWith voiceShortcut: INVoiceShortcut?, error: Error?) {

        controller.dismiss(animated: true, completion: nil)
    }
    
    func addVoiceShortcutViewControllerDidCancel(
             _ controller: INUIAddVoiceShortcutViewController) {

        controller.dismiss(animated: true, completion: nil)
    }
}
Siriに追加画面にてショートカット編集・削除完了時
extension MaterialBarcodeReaderViewController: INUIEditVoiceShortcutViewControllerDelegate {
    
    func editVoiceShortcutViewController(
             _ controller: INUIEditVoiceShortcutViewController,
             didUpdate voiceShortcut: INVoiceShortcut?, error: Error?) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func editVoiceShortcutViewController(
             _ controller: INUIEditVoiceShortcutViewController, 
             didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
        controller.dismiss(animated: true, completion: nil)
    }
}

Add to Siriボタンを配置するとできること

"ショートカット"アプリを経由せず、自分のアプリからSiri Shortcutsを追加・編集・削除することが可能になります。

実装してみたうえでの私的考察

Add to Siriボタンを配置するとよい画面、配置しないほうがよい画面

Add to Siriボタンは、ショートカットを追加後も編集と削除の導線を担保し続けることが前提です。

それを踏まえると、それぞれ以下の通りです。

配置すると良い画面

  • 配置することで本来の役割を全うするのに支障が出ない
    • もともと余白が十分にあり、永続的に表示し続けても問題ない
    • クッションページとして別画面を挟む形で表示しても差し支えない

配置しないほうがよい画面

  • 配置すると本来の役割を全うするための領域が狭くなる画面
    • 様々な情報を見るための画面など

画面の役割を邪魔せず配置できる場合はメリットが勝るでしょう。 そうでない場合はデメリットが大きいので、ボタンの実装はやめておきましょう。

提案フレーズの選びかた

提案フレーズとは

NSUserActivityオブジェクトのsuggestedInvocationPhraseプロパティです。

Siriに追加画面から登録する際、呼びかけるフレーズのデフォルト値として表示されます。

f:id:m_yamada1992:20201012192427j:plain

そのまま呼びかけて不都合のないフレーズを設定しておくと、ユーザーは自分で「何て呼びかけよう?」と考える手間を省けて便利です。

補足: 提案フレーズの多言語対応

実際に動作検証はしておらず恐縮ですが、おそらく通常アプリ内で多言語対応した文言を呼び出す際に利用するNSLocalizedString(key, comment)では実現できません。 iOS 13時点での話ですが、Spotlightに表示する文言などはちょっと更新してビルドしたりアプリ再インストールしても即座に反映されなくて、検証に少し根気がいるんですよね...

AppleのSiri ShortcutsサンプルコードであるSoupChefにて、提案フレーズとIntents UIから参照する文言はNSString.deferredLocalizedIntentsString(with:table:arguments:) を利用しているので、そちらを試すとよさそうです。

よりユーザーが利用しやすいフレーズを選ぶには

3つに気をつけて選定してみましょう。

  • 他アプリのショートカットと重複しない
  • 検証する端末
  • 検証する環境
他アプリのショートカットと重複しない
  • 同じ端末内に共存している可能性が高いアプリのショートカットを確認
    • 類似のショートカットがある場合、共存しても呼び分けできるようアプリ名を含めるのがオススメ
    • アプリ名は優先して聞き取りしてくれる様子
    • アプリ名でも略称などは要検証

なお、StudyplusではTwitterにQRコードに関するショートカットが存在することを考慮して、QRコード画面を開く提案フレーズを「スタディプラスのQRコード」としました。

補足: 提案フレーズにアプリ名を含めることの是非

いただいたフィードバックにて、「ガイドラインに以下の記述があるので、アプリ名は含めない方がよいと思っていたのですがいかがでしょう?」とご質問をいただきました。

Exclude your app name. The system already identifies the app associated with a shortcut.

引用元:Shortcuts and Suggestions - Siri - Human Interface Guidelines - Apple Developer

私が実装調査時に主に確認したのはWWDCのSiri Shortcuts関連動画の方なのですが、あくまで「アプリを識別するのには不要だから無闇に含める必要はない」という文脈で語られていた認識です。 それを踏まえての私的解釈ですが、アプリ名を含めることでユーザーの利便性向上を見込めるなら入れても良いんじゃないかと思ってます。

現実的として、同じ端末内に共存する類似ショートカットを複数利用することがありうるならば、最初から呼び分けられる提案フレーズで登録できたほうが親切でしょう。

ただ、1度登録した後でもユーザーが自由に編集できる要素なので、必死に他アプリの調査をしてなんとしても重複を回避しなければいけないというほどのものではないです。提案フレーズ検討時に、自分の端末にインストール済みアプリたちのショートカットをざっと確認してみる程度で十分だと思います。

検証する端末
  • サポートしているOSバージョンのうち、一番低いメジャーバージョンの端末で確認
    • OSバージョンが進む毎に聞き取り性能が大きく向上している
  • サポートしているOSバージョンの範囲で極力スペックの低い端末で確認
    • ハード面でも聞き取り性能が異なる

StudyplusでQRコード画面の提案フレーズ検討時に、アプリの略称を提案フレーズに含めた際の検証をしました。 具体的には、Studyplus内にてユーザーの間で「スタプラ」と略して呼ばれがちなため、提案フレーズに「スタプラ」を含めようとしました。

しかしながら、最低サポートバージョンであるiOS 12、かつiOS 12まででサポートを終了した古いiPad端末で検証したところ、10回中8回聞き取り失敗したので諦めました。 こちらの検証をした筆者は比較的に機会に拾われやすい声質と自負しているので、採用してしまうと使われなくなる恐れがあると察知しました...

検証する環境
  • できるだけユーザーが実際に利用しうる環境と近い状態で試す
    • スポーツジムで利用するアプリなら実際に持っていき聞き取りを試すなど
  • 机の上に平置きした状態で試す
    • 角度や端末への接地面の都合からか、手で持つより意図しない雑音が拾われがち

アプリ略称の検証をした際に、机の上に平置きで10回中8回失敗、手に持った状態では10回中4回失敗でした。

高い確率で発生しうるユースケースとして、端末を机の上などに平置きした状態での検証まではしておくことをお勧めします。

参考

NSUserActivityオブジェクトのプロパティ⇔画面の表示箇所対応表

f:id:m_yamada1992:20201012201722j:plain

実装・発表にあたり参考にしたWebページのURL

さいごに

このLTに登壇したきっかけが「ひとまず特定画面を開く単純なSiri Shortcuts機能をリリースしよう!」と調査・実装・検証・ドキュメントまとめて社内展開まで行なったことです。それらの対応にかかったのが合計で3営業日。

Siri ShortcutsはiOS 12から13へアップデート時点で大幅に挙動が変わっている箇所がそこそこあります。 にも関わらず、Apple公式の一部ドキュメントすらiOS 12段階の情報で更新が止まっている箇所がありました。 その辺りの検証に大いに戸惑い、盛大につまづいた時間も込みで3営業日です。

それらの経験を踏まえて、「これくらい最初から情報がまとまっていれば1日は短縮できた」と思える情報を詰め込んで発表してみました。

ユーザーが頻繁に使う画面に心当たりがあれば是非、実装してみてください。実装の際にこちらの記事が少しでも助けになれば幸いです。

iOSDC Japan 2020に参加しました

こんにちは、モバイルクライアントグループ iOSエンジニアの大石です。

9/19~21に開催された iOSDC Japan 2020 へ参加した件をブログにします。 今回、弊社としてはシルバースポンサーとしてスポンサードしており、LTへの登壇は1名、他のメンバーは勉強会・カンファレンス参加補助で参加しました。

はじめに

弊社スタディプラス 株式会社はシルバースポンサーとして、参加者へ送付されたノベルティボックスに缶バッチを提供させていただきました。もし、機会がありましたらどこかに付けていただければと思います。(切実)

f:id:k_oishi:20201009173041j:plain:w320

感想

参加したメンバーの感想です。 今回は弊社のiOSエンジニアとAndroidエンジニアの全員が参加しました。 弊社は昨年までiOSチームとAndroidチームに別れていたのですが、今年からモバイルクライアントグループという一つのチームになりました。 両方のプラットフォームの開発に少しずつ無理のない範囲で携わり、個人としては新しい開発スキルの習得、組織としては将来的にはエンジニアリソースの効率化を目指しています。

明渡

今年が2度目の参加で、iOS 13における Siri Shortcuts 最小実装+αというテーマのLTでiOSDCへ初めて登壇してきました。 LTの解説・補足記事は別途こちらのブログへ投稿する予定です。遅筆で恐れ入ります...

LT待機部屋では「生放送だし、コメント拾いながら発表できるとかっこいいよね!」等雑談が交わされておりましたが、もちろんそんな余裕はなく。ドラが鳴る前に発表し切れたのでそれだけで御の字かなと思ってます。

視聴したうち、弊社でも是非なるべく早めに取り組めたらいいなと思ったセッションの感想を綴ります。

Xcode Preview でUIKitベースのアプリ開発を効率化する

https://fortee.jp/iosdc-japan-2020/proposal/a88be712-b87a-4d87-bc6d-2579c2ce9b35

Xcode PreviewsをSwift UIではなくUIKitでも利用するための方法と、利用した際のメリットを具体的に提示していただけたセッションです。

UIKitベースでもXcode Previewsを導入する方法があると知ってはいたものの、iOS 12のサポートをし続けた状態での導入手順に煩雑な印象があり、iOS 12を切ってから導入を進めたいと思ってました。 こちらのセッションを視聴して、今はできるだけ早急に導入したい気持ちです。

古くから存在する機能は特に、ソースコードが仕様として取り扱われている側面が残念ながらそこそこ存在しております。プレビュー用のコードを記載しておきさえすれば、Xcode上でビルドをせずに画面の仕様を確認することができるのは非常に魅力的です。

最近チーム内でiOSとAndroidの開発タスクの行き来が活発になっており、iOS開発歴の浅いメンバーにも心強いツールとなります。 良いことづくめであるイメージが具体的にできたので、極力近いうちに対応をねじ込む機会をうかがいます。

iPadOSDC: Multiple Windows

https://fortee.jp/iosdc-japan-2020/proposal/b60ddbb9-7b37-4f24-b530-c87581d35e43

iOS 12のサポートを終了すると発生する、UIApplicationDelegateSceneDelegateへ差し替えるに対応の際に大変有用な知見の詰まったセッションでした。

こちらのセッションの事前知識がない状態で臨むと、対応時により考慮漏れが発生したかもしれないです。 とてもありがたい内容で、参考にさせてもらいながら作業すると思います。

また、アプリのプロセスがキルされる前に画面を復帰するための仕組みに非常に既視感を覚え、やはりそういう実装に落ち着くのねと一人ほくそ笑んだりしておりました。Androidは時代を先取りしすぎている...?

若宮

今回初めての参加になりました。若宮(id:D_R_1009)です。 久々にニコニコ動画にログインし、楽しく視聴しました。遅延もなく、非常に見易かったです。

普段はAndroidとiOSとFlutterを混ぜて開発しているので、アーキテクチャあたりに引かれつつ、尖っていて面白かった2セッションを挙げたいと思います。

Synchronized iPhones, Again!

https://fortee.jp/iosdc-japan-2020/proposal/3a9b496e-8745-4d1d-8952-8ac45a42ca8a

登壇についてのブログ。スライドも掲載されています。

https://www.toyship.org/2020/09/22/154728

個人的な印象になりますが、iPhone/iPadは近距離の端末で接続をした際にとても安定しています。 そんな端末たちがスムーズに連携していく様は感動的でした。

最初のARには騙されてしまいましたが、後半の有機的なネットワークを接続する箇所は、iOSだけに閉じることなくネットワークを考える上で非常によい題材になる気がしました。 実機で行うにはチョット金額的に厳しいものがありますが、三桁台の端末を繋いだ時にどうすれば良いのかなど、アルゴリズムとiOSの仕組みの両方に興味が湧くとても刺激的なセッションだったと思います。

Apple Siliconへの長い旅

https://fortee.jp/iosdc-japan-2020/proposal/0188c283-2804-42cc-acb2-0287ec38ca57

iOSと The other OS のそれぞれを思い浮かべながら聞いていると、この10年の進歩がうかがえる40分と3秒の濃密なセッションでした。 なぜiPhoneが早いのか、なぜiPadが綺麗な描画だったのか、なぜApple SiliconによりMacが置き換えられていくのかと言った興味にひたすらに答えてもらったような気がしています。

モバイルアプリを作っていると、そのアプリが動いている端末のディスプレイサイズを意識することはありますが、SoCを意識することはなかなかありません。 おそらくビルド時にストップしてしまった時や、なんらかのオプションを追加しなければならなくなった時程度ではないでしょうか。 その中で、アプリの動作を支えてくれる。そして速度を保ってくれるSoCを考えることができ、同時にOSのバージョンアップ理由もなんとなく理解できる、意義深いセッションだったと感じています。

隅山

主にAndroid開発を行っていますが、最近はiOS開発も始めたので勉強のため初参加しました。

今まで勉強会でiOSの5分枠LTは見たことありましたが、40分枠の発表をがっつり見るのが初めてだったのでiOSならではのつらさを感じることができました。(つらさへの共感コメントも多かった印象) 自分的に面白いと思った発表の感想を書いていきます。

エラーアーキテクチャ設計について考える

https://fortee.jp/iosdc-japan-2020/proposal/68905652-4f5d-444b-965f-ba572b750467

自分はよくユニットテストを書いていて、テスト観点でエラーケースを考えるときに漏れがないか心配することがありました。 そこでこの発表を通して、エラーアーキテクチャ設計の考え方が非常にいいなと感じました。

エラーハンドリングを頭の中で整理すると漏れなどが生じて、同じ処理でも異なってしまう可能性があります。特に複数人開発だと統一感がなくなりやすいかなと思います。 解決方法として、エラー型をラッパークラスに定義し、コンパイラにエラーハンドリングの検知をさせることで上記の問題を解決する考え方が勉強になりました。 開発の本質ではないエラーハンドリングをコンパイラに任せることで、開発の本質に集中できると思います。

テストコードが増えるとバグは減るのだろうか? - 「0% → 60.3%」で見えた世界の話

https://fortee.jp/iosdc-japan-2020/proposal/f12fb46e-1604-4339-8f76-4289fb835e6e

この発表ではテストコードに関する共感が非常に多かったです。 自分としてもテストコードを運用していく内にわかったことが、今現在の不具合を発見するためにテストコードを書くわけではなく、今後の機能追加で不具合を発生させないために書くということがわかりました。

以前テストコードは仕様書のようなものという風に教わったのですが、この発表を通してそれを再確認することができました。 テストコードを書くことでどのような入出力を想定していて、どのような入出力がイレギュラーケースなのかを把握することができます。 自分が触れたことない部分のコードをリファクタリングすることは不具合との戦いになると思うので、弊社でもテストコード(仕様書)をまとめることで心理的ハードルを下げることができればと思います。

中島

普段は主にAndroid開発をしている中島です。iOSDCは初めての参加でしたが、オンライン上でも活気が伝わってきました。

iOSの方の開発はほとんどしたことがないので新鮮な気持ちでセッションを聴講していましたが、特に興味深いなと思ったものについて感想を述べたいと思います。

iOSリジェクト戦記 ~リジェクトされないための課金ページ~

https://fortee.jp/iosdc-japan-2020/proposal/09b08386-9b39-4225-873e-229bfdc0bcaa

iOSのリジェクトについて、今までiOSエンジニアの方々が苦労していたのをよく見ていましたが、この発表では特に激しい戦いである課金関連申請の辛さを改めて見せつけられました。

自動継続についての説明というガイドラインに載っていない項目の記載、目立たせるべきまたは目立たせてはいけない文字列、審査員のさじ加減などといったスライドそれぞれに辛さが感じ取れました。 巻物風のスライドの作り方も印象に残りやすく、発表の工夫として素晴らしかったと思います。 また、「次回の申請に問題を持ち越す」仕組みをAppleから提案されているということを初めて知りました。

この発表を通して様々な理由によるリジェクト事例と対策を知ることができましたが、まとめの「課金ページのデザインに正解はない」に真理を見た気持ちになりました。

Firebase Dynamic Links で既存のユーザーだけでなく、潜在的ユーザーにも体験を提供したい!

https://fortee.jp/iosdc-japan-2020/proposal/009e39f5-b07b-481b-9e43-d5bd6dc7217e

Firebase Dynamic Links は自分も割と昔から実装経験があり、Androidでの挙動や実装については少し勉強していましたが、iOS側の実装や挙動については細かいところまで把握できていませんでした。

UniversalLinksとCustom URL SchemeそれぞれをOSのバージョンで使い分けられているとは聞いたことがあったのですが、この発表でそれぞれの受け取り方などを把握することができました。 AndroidではminimumVersionCodeの判定は自動で行われるが、iOS側ではハンドリングするコードが必要だった点など、差異について知ることができたのがよかったと思います。

大石

素晴らしいセッションばかりでしたが印象に残ったセッションの感想と、初めて担当したスポンサーの申し込みに関して書きたいと思います。

「iOSエンジニアだし、Androidアプリも作れるでしょ?」

https://fortee.jp/iosdc-japan-2020/proposal/5fd45cf8-2911-419a-9bba-b535d26fe98a

モバイルに関してはiOSのみの経験だった私にとってぴったりのセッションでした。 私もモバイルアプリ開発はiOS専門でしたが、最近はAndroidも触りつつあります。 iOSとAndroidのUI、アーキテクチャ、IDEの違いなど、iOSアプリエンジニアがつまずくであろうポイントがまとめられているセッションでした。 今後もスライドを見返していくことになると思います。

Flutter移行の苦労と、乗り越えた先に得られたもの

https://fortee.jp/iosdc-japan-2020/proposal/ffc2099c-a65c-414b-90f0-677b90260201

リリースから10年という歴史の長いアプリのFlutterへの移行という興味深いセッションでした。 具体的にはAdd-to-appという既存のネイティブアプリにFlutterプロジェクトを部分的に組み込むという移行手段でしたが、Flutterならではの挙動の違いなどを知ることが出来ました。 スポンサーセッションではありましたが、通常のセッションと同じくらい興味深いセッションでした。 弊社のアプリもそれなりに歴史がありますので将来的なアプリのリニューアルなどの検討をする必要があるかもしれないと、ふと思いました。

初めてのスポンサー申し込み

弊社はこれまでもiOSDC Japanへのスポンサードをしていましたが、社内の体制が変更になり各種イベントへのスポンサードの対応をイベントに関係するグループで行うことになりました。 そのため、今回のiOSDC Japanへのスポンサーの申し込みをモバイルクライアントグループのリーダーである私が担当しました。

  • スポンサー費用の申請
    例年通り、社内のブランディングおよび採用向けの露出としての予算が取られていたので予算の申請はスムーズに進めることができました。

  • ノベルティ
    ほぼリモート勤務状態のため、Slackで弊社の管理部へノベルティに関して相談したところ、既存のノベルティの在庫状況から缶バッチが良さそうでは?という提案を受け、缶バッチを提供させて頂きました。

  • スポンサー枠のカタログ入稿
    今回のiOSDCでは参加者へ配布するカタログにスポンサー記事の掲載枠があるとのことでした。 iOS開発者とUIデザイン関連の参加者を想定して、弊社の2つのプロダクトの紹介をテーマとして弊社のデザイナーに制作を依頼しました。 入稿の締め切りまで1ヶ月ほどありましたので、余裕を持って制作することができました。 次回も機会があれば今回とは違うものを作ってみたいと思っています!

f:id:k_oishi:20201009173605p:plain:w600

  • 次回はやるぞ!と思ったこと
    他のスポンサー企業さんがTwitterを活用したスポンサードの事前告知や提供したノベルティの紹介、社員の登壇内容の告知をされていました。今回、弊社ではそこまで手が回っていなかったため、次回は計画的に準備したいと思っています。

最後に

今回はオンラインでの開催でしたが、多種多様なセッションを聞く事ができ、弊社のエンジニアもLTで登壇することができました。来年もまたスポンサーとしてのイベントへの協力と、各エンジニアが登壇できるような新しいこと・面白いことを業務でやっていけるような会社にしていければと思っています!

おわり

ReactのSPAでUIへのテストを真面目に取り組んでいく話

こんにちは。ForSchool事業部の@okuparaです。最近入社しました。今後ともよろしくお願いします。

Studyplus for SchoolはReactを使用したSPAとして構築されています。フロントエンドのテスト関しては以前よりReduxやロジックに対してのテストがいくつか存在していたものの、コンポーネント(UI)のテストはあまり存在していませんでした。最近自分の方でこの辺の足りていなかったUIテストへの対応を行ったので、その時のお話を書きたいと思います。

Unit tests, Integration testsについて

元々フロントエンドのリポジトリにはEnzymeが入っていましたが、今回react-testing-libraryにしました。Integration testsを書くのに相性が良いと思ったからです。

テストの話をする際に、Unit tests、Integration testsはコンテキストや人によって微妙に意味が違ったりすることもあるので、一旦このエントリでそれぞれが何を意味するのか整理しておきます。 ここではreact-testing-libraryのauthorでもあるKent C. Dodds氏のブログから参考にしています。 https://kentcdodds.com/blog/unit-vs-integration-vs-e2e-tests

  • Unit tests

UIのそれぞれ独立したパーツへのテストです。ボタンやフォームなどです。

  • Integration tests

先のリンクよるとIntegration testsは

  • Unit testsが連携して動くのを確認する
  • 出来る限り最低限のmockにとどめる

といった事が書かれています。出来る限り最低限のmockにとどめる、とはmockをするのはAPIやアニメーションだけにして、例えばonSubmitonClickなどにダミーのpropsを渡さないと言う意味だと解釈しています。これにより実際のアプリケーションの挙動に近い状況がテストできることになります。

自分がもう一つ、Integration testsの意味として大事にしておきたいと思っているのが "実際に特定の機能のシナリオに沿ってテストする" ということです。例えばユーザープロフィール機能でユーザーがユーザー名を変更するテストを書くとします。 その際、ForSchoolの画面上では以下の事が起こります。

  • ユーザープロフィール機能のコンポーネントをロードする
  • API(mock)からユーザー情報を取得する
  • ユーザー名が画面に表示される
  • ユーザーが、ユーザー名を編集するための編集ボタンをクリックする
  • ユーザー名編集のためのモーダルダイアログが立ち上がる
  • モーダルの中にテキストボックスが表示され、現在設定されているユーザー名がテキストボックスに入っている
  • ユーザーが、ユーザー名を編集し、更新ボタンを押す
  • ユーザー名を更新するためのAPIが呼ばれる(mock)
  • APIが成功であれば、更新用のモーダルダイアログが閉じられる
  • 画面上のユーザー名表示が新しい名前で更新されている

Integration testsではこのシナリオに沿ってテストすることができます。ロードするコンポーネントは、可能であればapp全体でも良いですし(Next.jsを採用している場合は難しいと思いますが・・・)、containers/presentationパターンを使っていればcontainerに相当するコンポーネントが主な対象にしても良いと思いますし、colocationを意識した設計ならそれぞれAPIとの通信が発生するコンポーネントになってくるでしょう。 いずれにせよ、Integration testsでは実際のアプリケーションの動作に出来る限り近い状況で、API通信を含めた一連のシナリオでテストできる単位のコンポーネントを使います。

Integration testsにフォーカスする

Kent C. Dodds氏の別のエントリにて同氏はUnit testsやE2E testsより多くのリソースをIntegration testsに割くと良い、と書いています。

react-testing-libraryによるIntegration testsは各シナリオのテストを、jsdomにレンダリングされた内容を通して行うので、基本的には内部の実装がReduxかどうかということと依存関係がありません。これにより、内部のステートマネジメントの変更や各種ライブラリの移行、大きめのリファクタリングなどもIntegration testsが揃っていれば、自信をもって行うことができます。

それでは具体的なテスト回りについて書いていきたいと思います。先ほど例としてあげた、"ユーザーがプロフィール画面上でユーザー名を変更する"シナリオでテストを書いていきたいと思います。ForSchoolではこのような画面です。

f:id:okupala:20200929180912g:plain

APIのmock

今回セットアップしたIntegration testsはjestを用いたフロントエンドで閉じたテストとなりますので、バックエンドと通信する部分はmockの機能が必要となります。今回我々はmsw.jsを採用しました。 理由はKent C. Dodds氏が推薦していること、ユースケースとしてブラウザが含まれる(ブラウザで動作する場合はServiceWorker上で動作する)のでレスポンスのmockデータをそのままStorybookでも流用でき、APIとの連携が含まれるコンポーネントのstoryを作成する必要があった場合にも流用できると思ったからです。

msw.jsの実装はexpressライクなAPIとなっているので、比較的直感的に扱えると思います。

import { rest } from "msw"
import { setupServer } from "msw/node"
import { UserInfo } from "../mock/api/userinfo"

export const server = setupServer(
  rest.get("/api/me", (_, res, ctx) => {
    return res(ctx.json(UserInfoMock));
  }),
  rest.patch("/api/user_profile", (_, res, ctx) => {
    // 成功の場合は空のレスポンスを返している
    return res(ctx.json({}))
  }),
)

/api/meのmockデータとして返却しているUserInfoMockはJSのオブジェクトです。基本的にはダミーデータの入ったテスト環境のAPIのレスポンスのjsonからコピーしてきて作ったりしています。 ここで定義したserverは後述するテスト本体にてserver.listen()を呼び出すことでmockが使用可能になります。

Storybookで使うケースなど、ブラウザでmockしたい場合はsetupServerの代わりにsetupWorkerを使います。中のハンドラの設定は同じです。

テストを書く

では実際のテストを見てみましょう。 ForSchoolでは現状React Routerを使っているので、Routingをまとめているコンポーネントをロードできるようラッパーのヘルパーメソッドを作りました。

これによりURLを指定することで、対応したコンポーネントがロードされます。React RouterのURLのパラメータの値を解決して各コンポーネントのpropsに渡す部分もjestのテスト上でそのまま動きます。

export const appRenderer = (url: string) => {
    if (typeof(window) === "undefined") {
      throw new Error("This function should be called on browser or jsdom")
    }
    window.history.pushState({}, "Integration Test", url)
    return render (
      <Router>
        <Provider store={store}>
          <Routes />
        </Provider>
      </Router>
    )
}

実際のIntegration testのコードは次のようになります。

import { appRenderer } from "../../../tests/helpers/TestHelper"
import { screen, waitFor } from "@testing-library/react"
import { UserInfoMock } from "../../../mock/api/userinfo"
import userEvent from "@testing-library/user-event"
import "@testing-library/jest-dom/extend-expect"
import { server } from "../../../tests/msw" // 先ほどmockを作ったモジュール
import { rest } from "msw"

const spyFetch = jest.spyOn(window, "fetch")

describe("SettingProfile", () => {
  beforeAll(() => {
    server.listen()
  })
  afterAll(() => {
    server.close()
  })
  test("アカウント名が変更できる", async () => {
    appRenderer("/settings/profile")
    await screen.findByText("アカウント設定")

    // 現在のユーザ名が画面に表示されているか
    const fullNameTexts = screen.queryAllByText("スタプラ タロウ")
    // ユーザー名はメイン画面とグローバルヘッダーの二箇所に表示されているか
    expect(fullNameTexts.length).toBe(2)

    // 変更ボタンを探す
    const buttons = screen.queryAllByText("変更")
    // ボタンが0ではないことをアサーション(変更ボタンは3つある)
    expect(buttons.length).not.toBe(0)

    const theFirstButton = buttons[0]
    userEvent.click(theFirstButton)

    // クリックした後にユーザー名変更のためのダイアログが出るまで待つ
    await screen.findByRole("dialog")

    // 姓、名、それぞれのテキストフィールドのエレメントを取得する
    const lastNameInput = screen.getByLabelText("姓")
    const firstNameInput = screen.getByLabelText("名")

    const newLastName = "新しい姓"
    const newFirstName = "新しい名"

    // 一旦テキストフィールの名前を消して、新しい名前を入力する
    // user-eventモジュールのtypeで定義済みの特別なキーワードを使うことで簡単に実装できる
    userEvent.type(lastNameInput, `{selectall}{backspace}${newLastName}`)
    userEvent.type(firstNameInput, `{selectall}{backspace}${newFirstName}`)

    const updateButton = screen.getByText("更新")
    userEvent.click(updateButton)

    // 更新後/api/meをもう一度叩いて更新を確かめているので、
    // msw.jsのresponse#onceを使って更新後のデータがmockできるようにする
    const newFullName = `${newLastName} ${newFirstName}`;
    const newResponse: typeof UserInfoMock = {
      ...UserInfoMock,
      fullName: newFullName,
      firstName: newFirstName,
      lastName: newLastName,
    }

    server.use(
      rest.get(`/api/me`, (_, res, ctx) => {
        return res.once(ctx.json(newResponse))
      })
    )

    // 画面を通して更新APIへリクエストした内容が期待通りになっているか
    await waitFor(() => {
      const calledProfileApi =spyFetch.mock.calls.find(item =>
        item[0] === `/api/user_profile` &&
        item[1]?.method === "PATCH"
      )
      // 更新の時送ったリクエストの内容をテスト
      if (!calledProfileApi || !calledProfileApi[1]) {
        throw new Error("The request for settings/profile couldn't be found")
      }
      const reqBody = JSON.parse(calledProfileApi[1].body as string)
      expect(reqBody).toStrictEqual({
        operator: {
          first_name: newFirstName,
          last_name: newLastName
        }
      })
    })

    // 更新後はダイアログが消える
    await waitFor(() => {
      const modal = screen.queryByRole("dialog")
      expect(modal).not.toBeInTheDocument()
    })

    const newElements = screen.queryAllByText(newFullName)
    // グローバルヘッダー + 情報画面 の名前が更新されている
    expect(newElements.length).toBe(2)
  })
})

const buttons = screen.queryAllByText("変更")のようにreact-testing-libraryの機能を使って画面上の文字列からUIのElementを特定しています。 もちろんjsdom上ではDOM APIを直接使って(siblingsやchildrenを使うのも含む)要素を特定することも可能ですが、避けた方が良いと思います。 DOM構造やReactコンポーネントのツリー構造に依存したテストをかくと、簡単なHTMLのリファクタリングをしただけなのにIntegration testsが落ちるといったことが発生し、逆にストレスとなり得ます。 こういった問題を避けるためにも、実際に画面上に表示されている情報からUIの要素を特定していくのが良いと思います。

上記ではわかりやすくgetByTextなど要素を取得するメソッドのパラメータにテキストをベタ書きしていますが、こちらも実装側の文言が変わるとテストも落ちてしまうので、実装でi18n等に対応しmessagesのファイルから共有されたテキストを使うのが理想的だと思います。

内部の実装ではユーザーが更新ボタンをクリックした後、成功した後に一度プロフィール情報を取り直しているので、msw.jsのresponse.onceを使って一度だけ上書きした内容でレスポンスするようにしています。

その後のwaitFor内でfetchをjest.spyOnしている内容から更新のAPIが呼ばれたか、バックエンドへ想定した内容でリクエストボディを送っているかのアサーションが通るまで待っています。ユーザーがフォームを操作した内容が期待通りAPIへ送信されているかテストしておくことにより、自信を持ってバックエンドとの結合が行えますし、何か問題があった場合でも原因が切り分けやすくなります。

Integration testsではAPI(mock)を介してシナリオをシミュレートするので、上記のように非同期のメソッドを実行してawaitで待つようなケースが多くなってくると思います。その際に特定の要素が出現するのを待つfind*のメソッドと、特定のアサーションが成功するまで待ち続けるwaitForを使います。この辺りは慣れが必要になってくるかな、と個人的には思います。 自分もクリックした後すぐにAPIに送っているリクエストが正しいかアサーションしていたところ、どうしてもパスしなくてハマったことがあったのですが、理由はクリックした後にリクエスト送信は非同期で行われるため、waitForで少し待つ必要があった、ということでした。 アサーションを書く際には非同期が発生するシナリオかどうか毎回意識しておく必要があります。

運用について

テストの難しいところは導入すれば終わり、というわけではないところです。特にプロジェクトの初期からUIへのテストを書いていないと、"書いて当たり前"という雰囲気になるまでにはいろいろと工夫する必要があると思います。

始めはカバレッジなどの数値を指標として設定し、目標などに組み込んでも良いと思いますし、スクラムなどでチーム開発を行っていれば計画ミーティングなどで、ポイントの見積もりをする際にその機能にIntegration testsが存在しているか、なければIntegration testsを書くまでを作業見積もりに入れるかどうかを都度話したり、フロントエンド関連のスプリントバックログのゴールの項目の一つとして明示しておく等も効果があると思います。

自分達もまだまだ導入してあまり経っていないので、運用の中で振り返りつつしっかり継続できるように工夫していきたいと思っています。

Studyplus for School ではこのようなUIテストや新機能開発・機能改善を一緒にやってくれるフロントエンドエンジニアを募集しています!

RecyclerViewで Drawable に tint を設定する際は気をつけよう

こんにちは、モバイルクライアントグループの中島です。 最近健康診断で久しぶりに出社しましたが、体脂肪率が痛かったのでランニングを始めました。 頑張っていきたい。

さて、9月頭にも本ブログで紹介いたしましたが、Studyplusでは8月31日にiOS/Android両OSでダークモード/ダークテーマがリリースされました

tech.studyplus.co.jp

この開発に伴いアプリ内の画像リソースに多くのメスが入ったのですが、その際に起きたhotfixについて今回は話していきたいと思います。

プロジェクトとしてはダークテーマ対応でしたが、それに関わらず発生しうる事象だと思いますので、皆様のお役に立てれば幸いです。

何が起きたのか

Studyplusでは、登録した教材をカテゴリ分けできる機能があります。 そして、そのカテゴリのマークとして様々な色付きのアイコンを使っています。 ダークテーマ対応の際に、それらの色もダークテーマ用に調整したアイコンが必要になったのですが、各色で個別にpngの素材を持っていたためリソースファイル数が多くなってしまう問題がありました。 もともと単純なアイコン系素材はsvgで登録してtintで色分けしていきたいという気持ちもあり、この機会にデザイナーの方にも相談して黒単色のsvg素材を作ってもらいました。

8月31日のことです。 ダークテーマの対応がされたアプリを利用しているユーザーから、「カテゴリアイコンの色がチカチカ変わる」とのお問い合わせが届きました。

手元の端末(Pixel3 Android 10)では再現しなかったのですが、社内端末での再現状況から条件を絞り込んだところ、端末のOSバージョン依存ではないかと推測しました。

API 27 のエミュレータを起動してみた結果が以下の動画です。

f:id:nacatl:20200917140637g:plain

スクロールして次のカテゴリアイコンの描画が行われるたびに、画面内の全ての色が変わっている様子がわかります。 バージョンごとに検証をしてみましたが、 API 24 ~ 28 で同様の現象を確認しました。

調査とその結果

ConstantState

この現象を見たとき、ふと思い出したことがありました。 つい4日前に見たばかりである、DroidKaigi 2020 Liteで公開された、HiroYUKI Seto さんの発表で紹介されていた Drawable の ConstantState です。

MDCの内部実装から学ぶ 表現力の高いViewの作り方

www.youtube.com

この発表の 8:00 辺りからの情報を一部、以下に抜粋します。

  • ConstantState は Drawable の アルファ値 ColorStateList Tint の情報を保持している
  • ConstantState.newDrawable() で作成されたDrawable間で共有される
    • Resources.getDrawable() の内部で使われている
    • Drawable.mutate() を呼ぶことで状態が独立する

複数の Drawable 間で tint が共有される…今回の現象と関連がありそうです。

コードを見ていく

次に現象が起きたコードを見ていきます。

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)
    imageView.setImageDrawable(drawable)

コードを書いたときは「Drawable を都度生成しているので問題ないだろう」と思っていたのですが、ConstantState の話を知った後ではかなり危ないように見えてきます。

API 25のAOSPで内部を確認していきましょう。

ContextCompat.getDrawable の中を見ていきますと、最終的に Drawable の生成は ResourceImpl 内で行われていました。

以下 ResourceImpl.javaより一部抜粋します。

@Nullable
Drawable loadDrawable(Resources wrapper, TypedValue value, int id, Resources.Theme theme,
        boolean useCache) throws NotFoundException {

    // ~~~~~

    // ↓同じ theme のコンテキストで同じリソースから作られた Drawable が既にあるか確認
    // 問題になった画面で、2つ目以降のアイコンはキャッシュ処理で return されている

    // First, check whether we have a cached version of this drawable
    // that was inflated against the specified theme. Skip the cache if
    // we're currently preloading or we're not using the cache.
    if (!mPreloading && useCache) {
        final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
        if (cachedDrawable != null) {
            return cachedDrawable;
        }
    }

    // ここより下は新規作成時のコードなので割愛

    // ~~~~~

}

DrawableCacheの取得を見ます。

class DrawableCache extends ThemedResourceCache<Drawable.ConstantState> {

    public Drawable getInstance(long key, Resources resources, Resources.Theme theme) {
        final Drawable.ConstantState entry = get(key, theme);
        if (entry != null) {
            return entry.newDrawable(resources, theme);
        }

        return null;
    }

    // ~~~~

}

entry.newDrawable(resources, theme);

確認できました。

これで「同じ ConstantState から生成された Drawable なので tint が共有されている」ことが原因だと確定できました。

普段なら問題なくキャッシュ取得で運用できるのだと思います。事実、私も今まで特に気にした覚えはありませんでした。 今回は「同じ画面上に同じ画像リソースで違う色のものを多く並べる」画面が発生したことで、 ConstantState を意識する必要が生まれた形になります。

修正

原因がわかったところで修正方法を考えます。

mutate() を追加する

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)

    drawable?.mutate()  // ←追加

    imageView.setImageDrawable(drawable)

HiroYUKI Seto さんが発表内で紹介されていた通り、 mutate() を呼ぶことで ConstantState が分離されるのでこれで問題が解消されます。

ただ後々、修正の意図を把握していないエンジニアの方がこのコードを見た時に、意図を調べ直す必要が出るかもしれません。 その点に関してはコメントを付ければいいですが、そもそもこのコードは「同じ画像リソースで色だけ分岐する」という目的に対し Drawable の生成まで記述しているのが冗長に思えてきます。 この処理の流れは、元々 Drawable ごと分岐させていた時の名残ですが、Drawable リソースが固定になったのならxml側で指定してしまいたいですね。

ImageView.setImageTintList() で色指定する

そもそも Drawable にではなく ImageView から tint を指定した場合はどうなるのか、 ImageView.imageTintList の中身を確認しました。

public class ImageView extends View {

    // ~~~~

    public void setImageTintList(@Nullable ColorStateList tint) {
        mDrawableTintList = tint;
        mHasDrawableTint = true;

        applyImageTint();
    }

    private void applyImageTint() {
       if (mDrawable != null && (mHasDrawableTint || mHasDrawableBlendMode)) {
           mDrawable = mDrawable.mutate();

           if (mHasDrawableTint) {
               mDrawable.setTintList(mDrawableTintList);
           }

           if (mHasDrawableBlendMode) {
               mDrawable.setTintBlendMode(mDrawableBlendMode);
           }

           // The drawable (or one of its children) may not have been
           // stateful before applying the tint, so let's try again.
           if (mDrawable.isStateful()) {
               mDrawable.setState(getDrawableState());
           }
       }
    }

    // ~~~~

}

mDrawable = mDrawable.mutate();

ImageView が内部で mutate() を呼んでくれていることが確認できましたので、この方針で修正していきます。 (API 24~29でこの処理に変更がないことも確認しています)

<!-- ImageView内 -->

    app:srcCompat="@drawable/ic_bookshelf_category_24dp"
// RecyclerView.onBindViewHolder

    imageView.imageTintList = ColorStateList.valueOf(/* ColorInt */)

これならシンプルでコメントも要らないですね。 現在この修正を行なったコードでリリースされていますが、新たな不具合などもなくほっとしています。

なお、Studyplusでは現在 minSdkVersion 23 で開発を行なっているため、ImageViewCompat は必要ありませんでした。 ImageViewCompat.setImageTintList では (Build.VERSION.SDK_INT >= 21) で内部分岐を行なっているため、環境によってはこちらを利用しましょう。

なぜ Android 10 では再現しなかったのか

この不具合が世に出てしまったのは自分がOS別検証を怠ったのが原因ではありますが、ではなぜ Android 10 では再現しなかったのか、Android Code Searchで調査してみました。 結果として明確な答えは出なかったのですが、 Drawable への理解が深まったかなとは思います。

mutateを呼ぶようになったわけではない

まずはResourcesImpl.loadDrawable())から流れをまた追ってみましたが、 mutate() が追加されているような箇所は見当たりませんでした。 VectorDrawable クラスには mMutated というBooleanメンバ変数も存在していますが、そちらもfalseのままでした。

ColorFilter の変更

VectorDrawable の draw 関数において、 PorterDuffColorFilter から、 API29で追加されたBlendModeColorFilter への参照変更が見受けられました。

API 25

private PorterDuffColorFilter mTintFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mTintFilter : mColorFilter);

API 29

private BlendModeColorFilter mBlendModeColorFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mBlendModeColorFilter : mColorFilter);

その後の描画は native 関数になってしまい追えませんでしたが、この描画周りに手が加わったためかなと個人的には推測しています。

終わりに

RecyclerView で アイコンを色分けする際にハマってしまった事例を紹介しました。

今回については、発覚の直前に関連した情報を得られていて運が良かったと思います。 hotfixで急ぎ修正する必要があったこともあり、DroidKaigi 2020 Liteの知見が大いに助けとなりました。 発表者の HiroYUKI Seto さん、DroidKaigi 2020 Lite を企画運営してくださった皆様に多大なる感謝をお送りしたいと思います、ありがとうございます。

本ブログをまとめるにあたり、修正時には急いでいて調べきれなかったことも調査しましたが、様々なクラスについてOSバージョンごとに比較確認する必要がありなかなか大変でした。 最初は mutate() が追加されているんじゃないかと思ったのですが探しても見つからず、改めて最初から関連コードを細かく追い直したりもしました。 修正後はコードも綺麗になり、調査も知見が深まっていい経験になったと思います。 教訓としては、OS別の検証について改めて注意していきたいと思いました。

最後までご覧いただき、ありがとうございました!

Studyplusアプリでダークモード・ダークテーマに対応しました

こんにちは、モバイルクライアントグループの明渡です。

8月31日、StudyplusのiOS版でダークモードに、Android版でダークテーマに対応したバージョンをリリースしました🎉

Studyplus iOS版のダークモードキャプチャStudyplus Android版のダークモードキャプチャ
左がiOSのダークモード、右がAndroidのダークテーマ

今回は、私も一部を担当したiOSアプリの実装の話を僅かに混えつつ、Studyplusのダークモード・ダークテーマ対応(以降はダークモード対応と表記)の進め方をご紹介します。

前提

方針

  • 9割以上、ダークモード・ダークテーマへ最適化された状態を目指す
    • できるだけ対応が漏れないよう善処はする
    • 100%対応完了したと判断できるところまで時間をかけるより、少々の漏れがある状態でもリリースする方がユーザーにとって嬉しいはずと判断

期間

期限

  • 7〜9月中にリリース目標
    • 9月以降はiOS・Android共にOSのメジャーバージョンアップデート対応が多かれ少なかれ控えており、できれば8月中にリリースまでこぎつけたい

開始

  • 2020年7月中旬〜
    • Studyplusとしてダークモード対応より高い優先度で走っていたプロジェクトの数々がひと段落した頃合い

開発体制

対応期間中、ダークモード対応と比較すると粒度が小さいが優先度の高いタスクが発生すればそちらを優先しています。 以下のメンバーはMAXで動いていた際のものです。

なお、全メンバーリモート勤務です。

メンバー

  • デザイナー
    • 1名
      • 仕様の策定、対応する・しないの意思決定を行うプロジェクトリーダー(以降はPLと表記)を兼務
  • iOSエンジニア
    • 2名
      • うち1名、明渡がタスク進行スケジュール管理を行うプロジェクトマネージャー(以降はPMと表記)を兼務
  • Androidエンジニア
    • 2名

対応の流れ

開発工数の見積もり

普段プロジェクトを進行する際行う開発にかかる工数見積もりは、諦めました。理由は以下の通りです。

  • 影響範囲がアプリ全体に渡り、長年の歴史的経緯の都合でレイアウトの組み方・色の指定方法が入り混じっている状態を解消し切れていない
    • 修正が必要な箇所の洗い出しは事前にできても、実際に着手してみないとかかる工数の読めないタスクが相当数発生する予測
  • 上記の状態で正確な工数を見積もる場合、具体的に必要な作業の調査を細かくする必要があり時間を要する
    • そこまで掘り下げて調査するくらいなら最早手を動かした方が良いだろうという判断

スケジュールは大まかな単位で対応目標期日のみ設定し、洗い出したタスクの進捗を定期的に確認したり、メンバーの休暇予定を鑑みたりして実態に合わせて少し調整しました。

「手出してみないと具体的にどのくらい時間かかるか全然分からないけど、とりあえずこのくらいの目標で進めますね!」 という見様によっては雑と捉えられる場合がある進め方を、何の軋轢もなく許容してくれる環境で本当に良いなと思います。

手順

iOS・Android共に以下の流れで進めました。

  1. 画面に跨がり使い回している共通UIパーツにて、対応が必要なものを洗い出し
  2. 共通UIパーツ対応
  3. 個別に対応が必要な画面を洗い出し
  4. 画面個別対応
  5. 担当外のメンバーに協力を仰ぎ、考慮漏れや改善点の洗い出し
  6. 考慮漏れや改善点を対応

画面に跨がり使い回している共通UIパーツにて、対応が必要なものを洗い出し

共通UIパーツを最初に対応すると、必然的に個別対応の必要な箇所が分かりやすくなるので早い段階で対応することにしました。

共通UIパーツ対応

iOSの場合だと、枠線・塗りつぶしボタンやそのハイライト、キーボードのinputAccessoryViewなどがありました。

一部、全く同じ見た目かつソースコードも使い回されているUIが見つかり、切り出して共通化する対応も併せて行いました...

f:id:m_yamada1992:20200831144653p:plainf:id:m_yamada1992:20200831144709p:plain
例として、左が塗りつぶしボタン、右のキーボード上部に「閉じる」ボタンを含む領域がinputAccessoryView

個別に対応が必要な画面を洗い出し

対応が必要な背景色やラベルなどの情報をチェックリストにして添えつつ、画面単位でタスクを起票しました。

アプリの全画面を網羅しているドキュメントなどは特に存在しないため、iOSについては見渡す限り片っ端から画面を開いて確認する形で進めました。

当然ながら、そんな作業で起票したタスクを元に実装を進めるともちらほら漏れがあり追加で対応しながら進めることになったので、このやり方をお勧めはできません...

対応の種別

以下の2種類に分けることができ、前者は実装タスクとして対応を粛々と進め、後者を検討タスクとして起票してデザイナーに依頼しておきます。

  • エンジニアの判断で対応を進められる
    • OS標準色に準拠させると違和感が解消できる
      • iOSでいうところの、iOS 13以上向けに追加されているSystem Colors・Dynamic System Colors*1
      • AndroidではMaterialDesignComponentライブラリを利用しているため、MaterialDesignのカラーパレットや、The color system*2に則りパレットツールによる色の作成
  • デザイナーの判断を仰いでから対応を進める
    • もともとこだわりの色指定をしているが、ダークモードでは違和感が出てしまう画面やUIパーツ

画面個別対応

タスクを起こしさえすれば手分けして作業しやすくなるので、淡々と消化します。

タスク管理ツール上で、現在誰が何のタスクを対応中かさえ見えるようにしておけば重複して作業してしまうこともありませんでした。

また、洗い出し時点でデザイナーさんに検討を依頼したタスクの方針が固まり次第、随時実装タスクを起票してそちらも併せて粛々と対応します。

実装が完了した後

実装まで完了したタスクはすべて、社内向けに開発環境アプリを配信した後にレビューを依頼するステータスでデザイナーをアサインしました。

これにより、デザイナーが全く把握していない変更が入ってしまうことを防ぎました。

担当外のメンバーに協力を仰ぎ、考慮漏れや改善点の洗い出し

Studyplus事業部では、プロジェクトの終盤に"デバッグ大会"という形でプロジェクトを担当してないメンバーも募ってアプリを触ってもらう文化ができています。

今回はリリース予定日の1週間前に設定しました。

通常は、プロジェクトで開発した新しい機能などを触ってもらうために予めテスト項目を準備します。

ですが、今回は開発した箇所ベースで項目を用意すると考慮漏れを発見するには逆効果になると判断。 2時間以内で思い思いに触ってもらい、気になったことを起票してもらう形を取りました。

考慮漏れや改善点を対応

上記のデバッグ大会で気になった点を起票してもらう際に、優先度となる度合いも併せて記入してもらいました。

  • 優先度: 高
    • 読み取り・利用が困難
  • 優先度: 中
    • 読みにくい・利用しにくい
  • 優先度: 低
    • 支障はないがより改善したい

優先度高〜中の項目はダークモード対応初期リリース時点で含める前提、優先度低は次回以降のリリースでも構わないという形でタスクを起票して対応しました。

対応してみての感想

「9割以上対応された状態を目指そう!」といいつつ、いざ終わってみると両OSとも見渡す限りダークモードに最適化されており、感慨深いものがありますね。

個人的には、PM引き受けたのが初めてだったのでリリースまでこぎつけられてホッと一安心です。

他のプロジェクトでPMを引き受けていた方々がどう立ち回っていたか思い出しながら見様見真似で進めましたが、今まで自分が参画したプロジェクトの中で一番雑な管理だった自覚はあります。

やったことないからこの機会にやってみるかと軽いノリで引き受けたのですが、なんとかなるものですね! いや、メンバー個々の戦闘力が高いからなんとかできたんですけども。自分もメンバーの時にPL、PMの人がスムーズにプロジェクト進行していけるようサポートも頑張ります...

さいごに

以上、Studyplusアプリでのダークモード対応の進め方でした。

iOS・AndroidのOSで正式にサポートされてから対応まで比較的遅いほうだったとは思うのですが、「これから対応を進めたいけどどこから手をつけよう?」という方がいらっしゃったら参考になれば幸いです。

Studyplus for Schoolの1人目のQAエンジニアを募集中

Studyplus for Schoolの開発チームのリーダーをしている@atomiyamaです.

Studyplus for Schoolでは現在1人目のQAエンジニアを募集しています. 募集ページでは説明できていない現状や課題,QAエンジニアの方と実現していきたいことなどを詳しく書いていきたいと思います. この記事を読んで少しでも興味を持ってくれた方がいれば気軽に応募していただけると嬉しいです.

Studyplus for Schoolの現状と課題

Studyplus for Schoolの開発チームでは現在テストなど品質管理を専門とするエンジニアはいませんでした.
その中で品質を保証するため,新規機能開発プロジェクトなどではリリース前に「デバッグ大会」と呼ばれるテストを行う会を開いたり,新しいテストツールを導入したり色々と品質を担保するための取り組みを行ってきました. また開発チームのエンジニアが開発業務の傍らテスト項目書作成を作成するなど品質を担保するための取り組みも行っています.

しかしコロナ禍の影響でユーザーが大幅に増加したり,ローンチから時間が経ち機能が増えてきたこともありテストを行う上で考慮しなければならないことが増えてきた状況で,デグレを起こしてしまったりリリース後に不具合を発生させてしまったりと多くの問題とぶつかりながらもこれまでなんとかやって来ました.

今後,よりサービスを成長させていくためにリリーススピードを上げつつも品質も保証していくためには開発チームが片手間でやっていくことは厳しく,品質に責任を持って動いてくれる専門のユニットが必要だと考えています.
そこで,機能追加や品質の保証といったサービスの成長を支えるQAユニットを作ることに決め,1人目となるメンバー募集をすることになりました.

求める役割

Studyplus for Schoolのプロダクト開発の中では「開発チームとプロダクトオーナーとの橋渡し」のような役割を担っていただきたいと思っています.

機能開発の計画に対した遅れや見えない仕様がでてきた時,当初の受け入れ可能な状態と現実との差分をプロダクトオーナーと相談し新しい計画を提案したり, 変更があれば開発チームに達成するべき条件を伝えるような働き方をお願いしたいです.
大規模な機能のリリースになればテストの計画・実施をメンバーを巻き込んで推進したり,テストの自動化などを行い,日頃の軽微な修正などで問題が発生するまえに未然に防げる仕組みを作っていくような動きもしていただきたいと考えています.

またこれまで弊チームにはQAを専門としてきたメンバーがおらず私自身もQAエンジニアとして働いた経験が無いので, 一緒にQAとはどういったものなのか,どういったことをするのかといったQAの役割や文化の浸透,評価など組織へ浸透させていく活動も行っていきたいと思っています.

入ってからお願いしたいこと

現在立て続けに新規機能開発プロジェクトが立ち上がる状態にあるので,まず最初はテストの計画・実施を行い機能開発プロセスの中に浸透させていくことをお願いすると思います.
スクラムで開発を行っているためスプリントプランニングをはじめスクラムイベントへ参加をしていただき仕様の決定段階や,プロダクトの受け入れ判断に対してQA視点から意見を貰いたいと考えています.

その後はテストの拡充や自動化といった取り組みをしながら,長期的にはQAエンジニアやSETなどの採用支援などもお願いすることになると思います.

ここに関しては選考フローの中でお話しながら決めて行けたらと思っています.

Studyplus for SchoolでQAをする楽しさ

現チームメンバーの間では現在のプロダクトにはテストを始めとした品質を保証する仕組みが不足しているという共通認識があります.「品質を保証するためには何をすればいいのか」「品質が保証されている状態とはどういったものなのか」といった共通認識まではできていない状態にありますが, 専門の知識を持った方を中心にそういった取り組みをしていきたいとは全員が思っておりチーム内でも手探りではありますが取り組みを重ねている段階です.

なので「QAを通してプロダクトを成長させたい」「QAの価値をより広めて行きたい」などと思っている方にとってはチャレンジングな環境を提供できるのではないかと思います.

弊社はValueの1つに「Fail Forward」というものがあり新しいことに挑戦することを歓迎する文化があります. 開発チームのなかでも毎週勉強会を行ったり,新しいツールを積極的に試してみたりと色々なことに挑戦しやすい環境ではないかと思っています.


もしこの記事を読んで「興味を持ったから話だけでも聞いてみたい」「QAの事について教えて上げてもいい」「QAエンジニアとして働いてみたい」と思った方がいれば気軽に連絡いただけるとすごく嬉しいです. ぜひご応募お待ちしております!!

speakerdeck.com