RareJob Tech Blog

レアジョブのエンジニアによる技術ブログです

PlantUML で仕様を明確に、常に最新に更新する

どうも。システムディレクションチームの伊東です。 幼少向け学校法人様向け などいろいろなサービスの開発側ハンドリングをしています。

さて、いろいろな要求仕様を具体的な設計に落とし込んでいったり、既存の仕様を整理するために、ユースケースフローチャートを作成したり ER 図を書いたりすることはよくあるかと思います。

こういう図って、性格が出ますよね。 ツールによってはどうしても線が曲がってしまったり、きれいに整列させることが難しかったり、またせっかくきれいに書いていても、途中で間にオブジェクトを追加するとレイアウトが崩れたりなかなか厄介です。オンライン・オフライン問わず、ライセンスの数が足りなくなったりもありますよね。 また、最終的に出力されるのが画像なので、変更差分を目で見ると大変ですし、履歴を残すにしても人によって変更履歴の粒度はまちまちです。

そこで、PlantUML

f:id:lunasys:20190716165553p:plain
sequence_sample
こんなのや
f:id:lunasys:20190716165819p:plain
usecase_sample
こんなのがテキストベースで簡単に管理できてしまいます。

IDEプラグインとしては、 VS Code 用 だったり IntelliJ 用 があるのでローカル環境では瞬殺で導入することができます。 Markdown にも対応しているのですんなり Git 管理下にも置くことができます。

とはいえ、非エンジニアがプラグインを入れないと見れないのは辛い。そのために、わざわざ画像で出力するのはめんどくさい効率が悪すぎる。 Confidential な内部仕様を 公式の PlantUML Server を利用して描画するのは問題があるため、専用のインスタンスAWS Fargate で立ち上げてみましょう。

AWS Fargate で立ち上げる

  1. クラスター作成
    適当な VPC 配下にクラスターを作成します。直接グローバル IP を付与しないのであれば、 ALB を立てる必要があるので、サブネットは最低 2つの AZ が必要です。
  2. タスク定義
    FARGATE タスクを作成します。タスクロールはデフォルトの ecsTaskExecutionRole のままでいいでしょう。タスクサイズは最低の 512MB メモリと 0.25vCPU でもとりあえず問題ないです。コンテナイメージは、 公式のものを そのまま指定します。 (plantuml/plantuml-server:tomcat)
  3. サービスの追加
    作成したクラスターからサービスを作成します。起動タイプ FARGATE タスク定義に、作成したタスク定義を指定します。タスクの数もまずは 1 でいいでしょう。ネットワーク構成、AutoScaling (必要あれば) を完了すれば、自動的にコンテナが起動します。

起動したコンテナの http://[INSTANCE HOST]:8080/uml もしくは ALB 経由の http://[ALB ARN]/uml でアクセスできれば準備完了です。

Confluence で利用する

Confluence では、 Cloud 向け には Draw.io が、 Server 向け には avono AG がそれぞれアプリを公開しています。

Gitlab で利用する

弊社では Git 環境で主に Gitlab を利用していますが、公式で PlantUML に対応しています。 https://docs.gitlab.com/ee/administration/integration/plantuml.html#gitlab から有効にし、 PlantUML URL にコンテナの URL を入力すれば、 WikiIssue でインライン Markdown で PlantUML が使えるようになります。 Issue で細かい仕様を詰めつつ、成果物を Confluence に上げていくような使い方もできますね。

GitHub で利用する

GitHub ではいまのところ、 まだ検討の前段階 のようですね。 クラスメソッドの中の人が Chrome 拡張 を公開してくれているようです。

さいごに

絵心がある人もない人も、UML チャートならみんなが共通の認識を持つことができます。 複雑化するプロダクトも、エンジニアと非エンジニアも、UML チャートで正しく最新に管理しましょう。

UML を書いたことがある人もない人も、一緒にいいプロダクトを作りたい方、ぜひお待ちしています!

Vueプロジェクトで使えるちょっと便利なTips集

はじめまして、開発本部 APP/UXチームの一員として、フロントエンドエンジニアとして活動させて頂いております、 田原(ドンキーorDKというあだ名で生きております)と申します。
先日の弊社ブログのコチラの記事にも紹介があります、 WebRTCを利用したレッスンルームというプロダクトにおける、Webアプリケーションの開発をメイン業務としております。

当Webアプリケーションはフロントエンドフレームワークとして、Vue.jsを採用し開発をおこなっておりますので、 今回はVue.jsでアプリケーションを実装していくにあたっていくつか(備忘録も兼ねた)Tipsのご紹介をさせて頂ければと思います。

お手柔らかにお願いします。

watch immediateについて

コンポーネントの初期化のタイミング(lifecycle hookのcreated)で何かしらのrouteのparamsを引数にしてdataを取得してきていると仮定した場合、記述は以下のようになるかと思います。 ※ここでは教材のデータを取得しているとします。

created() {
  this.fetchLessonMaterialData(this.$route.params.id)
}

Vueでは遷移先が同一コンポーネントである場合、ex:/lesson_material/1234から/lesson_material/5678に 遷移した場合、コンポーネントのcreatedが再び呼ばれることはなく(仮想DOM等の差分を検知できず、インスタンスを使いまわそうとするため) fetchすべきdataをうまく取得できないということが起きます。 このため以下の様にwatchの処理の中にdata fetchの処理も書く必要があります。

watch:{
  $route($route) {
    this.fetchLessonMaterialData($route.params.id)
  }
}

immediateのプロパティを使用して以下のように記述することで コンポーネントが作成されるとすぐにハンドラが呼び出されるようになります。 これにより、createdで行っていたdata fetchの処理と同様の処理が走るようになりますので createdで記述していたdata fetchの処理を削除することができ、少し見通しがよくなります。 ※ただ、この即時watcherのイベントハンドラは順序的にはcreatedの処理の直後に実行されることになるため、注意が必要です。

watch:{
  $route: {
    handler($route) {
      this.fetchLessonMaterialData($route.params.id)
    },
    Immediate: true
  }
}

基底componentの自動登録について

コチラについては公式のコチラに詳細の説明がある通り、 bundle時のエントリーポイントで基底のコンポーネントをグローバルにインポートする方法です。

const requireComponent = require.context(
  './components/',
  true,
  /\.vue$/
)
requireComponent.keys().forEach(relativeFilePath => {
  const componentConfig = requireComponent(relativeFilePath)
  const fileName = relativeFilePath.split('/').pop()
  if (fileName) {
    const componentName = upperFirst(camelCase(fileName.replace(/\.\w+$/, '')))
    Vue.component(
      componentName,
      componentConfig.default || componentConfig
    )
  }
})

directory sample

├── atoms
│   └── HogeButton.vue
├── molecules
│   └── HogeChatBox.vue
├── organisms
│   └── HogeHeader.vue
├── pages
│   ├── Lesson.vue
└── templates
    └── HogeLayout.vue

弊社、WebRTCのプロダクトにおいてcomponent毎に別のcomponentを参照する頻度が多い為、この方法を採用して実装しております。 componentのdirectory構造については、上記sampleに記載にあるようなAtomicDesignを踏襲している為、relativeFilePathの末尾のファイル名を component名として登録しております。 この方法については懸念としてはglobalに全てのcomponentをimportすることになるため、bundleファイルのサイズが増大してしまうことが上げられますが、 ほぼ単一ページのWebアプリケーションである為、この方法を採用しております。

this.$on('hook')を利用し、他のlifecycle hookの定義を避ける方法

lifecycle hookの処理の中のmountedのタイミングでカスタムイベントリスナーを追加し、メモリリーク発生を防ぐ為に beforDestoryのタイミングでイベントリスナーを破棄しなければならない時など、通常はlifecycle hookの定義を並べて書く必要がありますが、 $on('hook:)を使用するとlifecycle hookの定義を避けることができます。 これにより、Vueのlifecycle hook処理の見通しがよくなります。

並べて書く場合

mounted() {
  this.cunstomEvent = cunstomEvent()
},
beforeDestory() {
  this.cunstomEvent
}

$on('hook:)の利用

mounted() {
  const cuntomEvent = customElements()
  this.$on('hook:beforeDestory', () => {
    cuntomEvent
  })
}

同一リポジトリ内で2つ以上のAppを立ち上げる場合のpackage.json設定について (vue-cli3系)

弊社ではプロジェクトを構成するツールとしてvue-cli(3系)を採用しております。 初期の準備が全て整った後、(global install・create等によるプロジェクトの準備についての説明については公式をご確認ください。) 通常、serve コマンドで一つのアプリケーションが立ち上がるよう設定がなされておりますが、 PC・SPとアプリケーションを切り分けて立ち上げたい時など(弊社の場合は生徒側と講師側のアプリケーションを同一directoryで立ち上げたかった) 初期状態のままでは、期待する動作が行えない為、package.jsonに少し修正を加える必要があります。

初期のpackage.json

"scripts": {
    "serve": "vue-cli-service serve",
    ︙

上記のままではserveコマンドで立ち上がるアプリケーションを分けることが出来ないため、以下の様に変更を加えます。

device毎にserve(アプリケーションの立ち上げを分けた場合)

"scripts": {
    "serve-pc": "VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

上記のように環境変数であるVUE_CLI_SERVICE_CONFIG_PATHにルールとして設定したいvue.config.js設定を絶対Pathで指定します。 ※次の項目にも後述しますが、vue.config.jsはdevice毎や環境毎に自由に設定出来ますので任意のvue.config.jsを作成してください。 (尚、ここではpc/spと環境を分けた場合を想定して指定した場合としております。) VUE_CLI_SERVICE_CONFIG_PATHはvue-cli内部のコチラで定義されており、これに 読み込ませたいvue.config.jsのpathを代入している形になります。

また、上記の指定のままではwindows上でserveコマンドを叩いた際に失敗するのでnpm modulesのcross-envなどを利用します。

windows対応ver(cross-envをmodules importした上で)

"scripts": {
    "serve-pc": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

serveコマンドだけでなく、testbuildについても同様に対応が可能です。 懸念としてはpackage.json内の記載がどうしても増えてしまうので、別にシェルスクリプトなどを準備するとスッキリします。

vue.config.jsの設定周辺について

上記のpackage.jsonの説明にあります通り、vue-cliではbundle条件のレシピとして、vue.config.jsの指定が必要になります。 これはvue-cliでcreateしたアプリケーション毎に一つだけしかダメということはなく、(上記にあります通り)同一directory内で 別アプリケーションとしてserveコマンドによる立ち上げを行いたい時など、複数指定することが可能です。

その際、以下の様にどのコマンドでどのvue.config.jsを読み込ませるかの指定をする必要があります。 ※以下の場合はプロジェクトrootにconfig/vue.config-xx.jsを指定していると想定した場合になります。

"scripts": {
    "serve-pc": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

vue.config.jsの周辺設定については公式に記法についての詳細が書かれており、 とても参考になります。 参考までに雛形を以下に用意しております。(pathやfile名は適宜読み替えてください)

module.exports = {
  // local serveでの起動時のwebpack devServerの設定
  devServer: {
    port: 8081,
    https: false
  },
  // 吐き出されるindex.htmlが読み込むjsやcssのrootにもなる。
  publicPath: '/sample',
  // build時のdistファイルのアウトプット先
  outputDir: 'dist/',
  // sourcemap出力の可否
  productionSourceMap: false,
  // 対象のappファイル等の設定
  pages: {
    index: {
      // vue.config.jsをdeviceで分けている場合などentryをどの基底ファイルにするか
      entry: 'src/pc/app.ts',
      template: 'public/pc/index.html',
      // production build時に拡張子変更したい場合など
      filename: process.env.NODE_ENV === 'production' ? 'index.php' : 'index.html',
      chunks: ['chunk-vendors', 'chunk-common', 'index']
    }
  },
  css: {
    loaderOptions: {
      sass: {
        // 全てのscssに読み込ませておきたいscssを設定
        data: `
          @import '@/pc/styles/material/_base.scss';
          @import '@/pc/styles/material/_reset.scss';
        `
      }
    }
  },
  configureWebpack: {
    resolve: {
      alias: {
        "@pc": require('path').join(__dirname, '..', 'src/pc')
      }
    }
  },
  chainWebpack: config => {
    // dist のindex.htmlが重複して吐き出されないようしている。
    config.plugin('copy')
      .tap(args => {
        args[0][0].ignore.push('index.html')
        return args
      })
    // 画像ファイルがある場合、hash値を付けるinline化
    config.module
      .rule('images')
      .test(/\.(png|jpe?g|gif)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'img/[name].[hash:7].[ext]'
      })
    // svgファイルがある場合、hash値を付けるinline化
    config.module
      .rule('svg')
      .test(/\.(svg)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'img/[name].[hash:7].[ext]'
      })
    // mediaファイルがある場合、hash値を付けるinline化
    config.module
      .rule('media')
      .test(/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'media/[name].[hash:7].[ext]'
      })
    // fontファイルがある場合、hash値を付けるinline化
    config.module
      .rule('fonts')
      .test(/\.(woff2?|eot|ttf|otf)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'fonts/[name].[hash:7].[ext]'
      })
  },
  // webpackBundleAnalyzerの出力設定。
  pluginOptions: {
    webpackBundleAnalyzer: {
      analyzerMode: 'static',
      openAnalyzer: false
    }
  }
}

webpack likeに記述できるのであまり迷うことも無いと思います。

vuex-module-decoratorを利用した際のError handlingについて

Vueをベースとしていることと併せて、弊社でも実装にはTypescriptを採用しております。 Typescriptを採用するにあたり、コンポーネントだけではなく、vuexもTypescriptで型を入れたいなということで、vuex-module-decoratorsを利用しております。 サードパーティーではありますが、非常に便利で使い勝手の良いライブラリです。 詳しい使い方はライブラリの公式ページに預けるとして、こちらでは実装を進めていく中でちょっと困ることがあったので、以下、それについての説明をさせて頂きます。 記述方法は少し変わりますがvuexの拡張ライブラリなのでお馴染みの通り【state/getter/action/mutation】は全て利用できます。(当ライブラリ独自のMutationActionたるものも使えます。)

actionの記述については以下の様に記載するのですが

@Action
sampleAction() {
  //ここにAction内で行う処理を書いていく
}

以下の様にactionメソッド内で何かしらの処理を行った結果、errorをthrowしたい場合(例えば何かしらの非同期処理など)

@Action
async sampleAction() {
  const sampleData = await fetchSampleData()
    .catch((error) => {
      throw error
    })
}

これでerrorがthrowされて捕捉できると思っていたのですが、ライブラリのAction Modelに指定されている rawErrorにdefaultでfalseが入っている為、この部分三項演算子で必ず 以下の記述の方を通ることになり、

: new Error(
              'ERR_ACTION_ACCESS_UNDEFINED: Are you trying to access ' +
                'this.someMutation() or this.someGetter inside an @Action? \n' +
                'That works only in dynamic modules. \n' +
                'If not dynamic use this.context.commit("mutationName", payload) ' +
                'and this.context.getters["getterName"]' +
                '\n' +
                new Error(`Could not perform action ${key.toString()}`).stack +
                '\n' +
                e.stack
            )

ライブラリ内部で定義されている固定文言がErrorとしてthrowされてしまうため、期待したErrorを捕捉することができません。

解決方としては@Actionの引数に指定を渡してActionを設定します。

@Action({ rawError: true })
async sampleAction() {
  const sampleData = await fetchSampleData()
    .catch((error) =>{
      throw error
    })
}

これによりこの部分三項演算子

? e

と判定され、オリジナルのErrorを捕捉できるようになります。
公式ページに基本的な使い方は載せてくれているのですがこの指定についての記述はなく中身を確認する必要があったので、実装の際にちょっと困りました。

まとめ

備忘録も兼ねた紹介になりましたが、Vue(特にvue-cli)を利用し、プロダクトを作る際の参考にして頂ければ幸いです。 最後まで目を通して頂きましてありがとうございます!!

分析チームの開発スタイルについて

データサイエンティストの山本(@hayata_yamamoto)です。

レアジョブでは、EdTech Labという研究開発の部署で、主にスピーキングテストの自動化プロジェクトに関わっています。弊社全体としては、PHP, Go, TypeScriptなどがメイン言語ですが、私たちのチームはPythonで開発をしています。

今回は、Pythonを用いた分析チームの開発スタイルの話をします。

www.rarejob.co.jp

分析チームでの開発スタイル

分析チームでは、開発で以下のような工夫をしています。なお、コマンドラインから実行できるものについてはMakefileにコマンドを記載し、開発者がコマンドを覚えなくても良い仕様にしています。

  • 特徴量エンジニアリングとトレーニングの切り分け、最適化処理の簡略化
  • jupyterlab_templatesでのNotebookスタイルの統一
  • コア部分の単体テスト
  • mypyでの型チェック
  • flake8でのLintと、autoflake, black, isortを用いたコード整形

このスタイルに至った背景

私たちのプロジェクトは、同じデータセットに対してそれぞれ特性の違う機械学習モデルを用意する必要があるため、チームを編成し機械学習のプロジェクトを行っていく必要がありました。Python機械学習エコシステムをうまく活用しつつ、メンバーとの協業がしやすい環境を作るために、いわゆる設計思想に当たるものが必要になりました。

そこで私たちのチームでは、以下のように開発をしていくことにしています。

* 汎用的なコードを統一し共有して、チームメンバー全員がアクセスできるようにします。
* コードは明示的に型を宣言し、インターフェースを統一することで可用性を高めます。

というのも、機械学習や分析関連のコードは往々にして難しいロジックや高度な背景知識が必要になるため、自分以外の人との共有や関数の再利用までのハードルが高いという問題点を抱えていました。また、使えたとしても自分が書いていない関数を利用する際に、「意図しないエラー」や「関数はエラーなしで返答するのに、思った値と違う」といった問題は著しく開発のスピードを落とすので避ける必要がありました。

故に、なるべく開発者の手間を増やさず、かつ開発者同士の知見の共有とコードの再利用可能性を高めるために諸々の工夫をしています。

どのようにやっているか

特徴量エンジニアリングとトレーニングの切り分け、最適化処理の簡略化

弊チームの開発では、「特徴量エンジニアリング」と「トレーニング」でノートブックを分けています。(厳密には、「共通化部分の実装」もあるのですが、プロジェクトによって入れたり入れなかったりするので今回は省略します)

これは、「特徴量を共通化する」「精度の高いモデルを作る」のどちらも大切にしていきたいという思いを反映しています。それぞれのフェイズで求めるもの、求めないものを明示的に決定し、PRの際には何を見て、どのようなことをチェックするかを言語化しています。

f:id:hayata-yamamoto:20190627193249j:plain
Feature Engineering

f:id:hayata-yamamoto:20190627193326j:plain
Training

scikit-learnベースのモデルにOptunaを用いたパラメータ最適化も行なっています。

Optunaは、機械学習のハイパーパラメータ最適化を自動で行うライブラリです。開発元は、Preferred Networks社で、並列分散最適化や脈のないパラメータの枝切りなどをよしなに行なってくれる素晴らしいライブラリです。もしかすると、Kaggleなどで目にした人もいるかもしれません。裏側では、Tree-structured Parzen Estimator というベイズ最適化アルゴリズムを用いて最適化を行なっているようです。*1

よく使うモデルについてはあらかじめOptunaをラップしたクラスを用意して、特徴量のDataFrameと最適化したい指標(正答率など)を指定したら勝手にやってくれるようにしています。もちろん、自分たちがTensorFlowやPyTorchなどを用いて機械学習モデルを構成する場合は、別途最適化処理を書いたりします。

from copy import deepcopy
from typing import Callable, Dict, Iterator, List, Optional, Union

import numpy as np
import optuna
import pandas as pd
import sklearn
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from tqdm import tqdm


class ParameterOptimizer:
    def __init__(
        self,
        x: pd.DataFrame,
        y: pd.DataFrame,
        pipeline: Optional[Pipeline] = None,
        cv: int = 5,
        scoring: str = "accuracy",
        verbose: bool = False,
    ) -> None:
        """
        Args:
            x: dependent variables. this shape is (n_samples, n_features)
            y: predictor variables. this shape is (n_samples, )
            pipeline:
            cv:
            scoring:
        """
        self.x = x
        self.y = y
        self.cv = cv
        self.scoring = scoring

        if verbose:
            optuna.logging.enable_default_handler()
        else:
            optuna.logging.disable_default_handler()

        if pipeline is None:
            self.pipeline = pipeline
        else:
            assert isinstance(pipeline, Pipeline), "pipeline must be sklearn.pipeline.Pipeline instance"
            self.pipeline = pipeline

    def _add_model(self, model: sklearn.base.BaseEstimator) -> Pipeline:
        """
        add model to sklearn pipeline as a last step
        Args:
            model:
        Returns:
        """
        if self.pipeline is None:
            return Pipeline([("model", model)])

        pipeline = deepcopy(self.pipeline)
        pipeline.steps.append(("model", model))
        return pipeline

    def _optimize(
        self, jobs: Optional[Dict[str, Callable]] = None, n_trials: int = 10, n_jobs: int = 1
    ) -> Iterator[Dict[str, Union[str, float]]]:
        """
        Args:
            jobs:
            n_trials:
            n_jobs:
        Returns:
        """
        if jobs is None:
            jobs = {
                "LogisticRegression": self.optimize_logistic_regression,
                "SVC": self.optimize_svc,
                "DecisionTree": self.optimize_decision_tree,
                "RandomForest": self.optimize_random_forest,
            }

        for model, job in tqdm(jobs.items()):
            study = optuna.create_study()
            study.optimize(job, n_jobs=n_jobs, n_trials=n_trials)
            result = {"model": model, "best_score": 1 - study.best_value, "best_params": study.best_params}
            yield result

    def optimize(
        self, jobs: Optional[Dict[str, Callable]] = None, n_trials: int = 10, n_jobs: int = 1
    ) -> List[Dict[str, Union[str, float]]]:
        """
        Args:
            jobs:
            n_trials:
            n_jobs:
        Returns:
        """
        return list(self._optimize(jobs=jobs, n_jobs=n_jobs, n_trials=n_trials))

    def optimize_logistic_regression(self, trial: optuna.Trial) -> float:

        params = {
            "C": trial.suggest_uniform("C", 0.1, 10),
            "fit_intercept": trial.suggest_categorical("fit_intercept", [True, False]),
            "intercept_scaling": trial.suggest_uniform("intercept_scaling", 0.1, 2),
            "solver": trial.suggest_categorical("solver", ["newton-cg", "lbfgs", "liblinear", "saga"]),
            "max_iter": trial.suggest_int("max_iter", 100, 1000),
            "multi_class": trial.suggest_categorical("multi_class", ["auto"]),
        }

        clf = self._add_model(LogisticRegression(**params))
        score = cross_val_score(clf, self.x, self.y, cv=self.cv, scoring=self.scoring, error_score=np.nan)
        return 1 - score.mean()

    def optimize_svc(self, trial: optuna.Trial) -> float:

        params = {
            "C": trial.suggest_uniform("C", 0.1, 10),
            "kernel": trial.suggest_categorical("kernel", ["linear", "rbf", "poly", "sigmoid"]),
            "gamma": trial.suggest_uniform("gamma", 0.1, 2),
            "coef0": trial.suggest_int("coef0", 0, 10),
            "shrinking": trial.suggest_categorical("shrinking", [True, False]),
        }

        clf = self._add_model(SVC(**params))
        score = cross_val_score(clf, self.x, self.y, cv=self.cv, scoring=self.scoring, error_score=np.nan)
        return 1 - score.mean()

    def optimize_decision_tree(self, trial: optuna.Trial) -> float:

        params = {
            "criterion": trial.suggest_categorical("criterion", ["gini", "entropy"]),
            "splitter": trial.suggest_categorical("splitter", ["random", "best"]),
            "max_depth": trial.suggest_int("max_depth", 2, 50),
        }

        clf = self._add_model(DecisionTreeClassifier(**params))
        score = cross_val_score(clf, self.x, self.y, cv=self.cv, scoring=self.scoring, error_score=np.nan)
        return 1 - score.mean()

    def optimize_random_forest(self, trial: optuna.Trial) -> float:

        params = {
            "criterion": trial.suggest_categorical("criterion", ["gini", "entropy"]),
            "max_depth": trial.suggest_int("max_depth", 2, 100),
        }

        clf = self._add_model(RandomForestClassifier(**params))
        score = cross_val_score(clf, self.x, self.y, cv=self.cv, scoring=self.scoring, error_score=np.nan)
        return 1 - score.mean()

jupyterlab_templatesを利用したNotebookスタイルの統一

Jupyter Notebookは便利ですが、うまく利用しないと再現性やドキュメントのスタイルが属人的になってしまう問題があります。例えば、ある実験で得られた知見をどのようにまとめるかはかなり人に依存してしまいます。私たちのチームでは、Pull Requestをベースにした開発をするため、開発者それぞれが自分好みのノートブックを作成してしまうとレビュワーに負担がかかってしまいます。また、レビュワーがOKを出したら、社内向けに公開もしているため、統一したフォーマットが必要でした。

juptyer_templatesを用いると、あるノートブックをテンプレートとして、そのノートブックをベースにして新しいノートブックを作成できるため、便利です。

現在利用しているテンプレートは2種類運用しています。

  • アドホックな分析用
    • 実験の結果やまとめを書くドキュメント部分のみ共通化
  • モデルトレーニング用
    • 実験に使用した特徴量やパイプライン、モデル構成などを記述するドキュメント部分
    • optunaをベースにしたパラメータ最適化部分の共通化

インストールと利用開始はとても簡単です。コマンドラインから、

$ pip install jupyterlab_templates && \
jupyter labextension install jupyterlab_templates && \
jupyter serverextension enable --py jupyterlab_templates

として、jupyterlab_templatesをインストールし、jupyterlabに登録します。その後、~/.jupyter/jupyter_notebook_config.pyに以下を書き込めばOKです。

c.JupyterLabTemplates.template_dirs = ["ディレクトリのパス"]
c.JupyterLabTemplates.include_default = False # サンプルのテンプレートを使いたければTrue

コア部分の単体テスト

今は、pytestをメインに利用しています。unittestでも十分でしょう。pytestには、pytest-codestyleというライブラリがあり、初期は簡易的なlintとしても利用していました。

例えば、word_countを計算する場合は以下のような実装になります。

def test_word_count():
    df = pd.DataFrame(
        {
            "words": [
                [["hello", "world"]],
                [["Automate", "the", "Boring", "Stuff", "with", "Python"]],
                [["Buffalo", "buffalo", "Buffalo", "buffalo", "buffalo", "buffalo", "Buffalo", "buffalo"]],
            ]
        }
    )
    ret = word_count(df) # word countを計算する実装
    assert ret.equals(pd.Series([2, 6, 8]))

多くの人が必ず使うであろう関数については、テストを書くようにしています。まだ、明確にどのタイミングでテストを書くかについては言語化できていない部分があり、試行錯誤の段階です。

mypyでの型チェック

私たちのチームでは、関数やクラスの引数や返り値の型を明示的に宣言して、他の人が実装したコードを再利用しやすくする取り組みをしています。例えば、単語数を数える関数を実装するなら、

def word_count(word): # 避ける
    return len(word.split()) 

def word_count(word: str) -> int: # こちらで書く
    return len(word.split())

といった具合に、インプットとアウトプットの型を書くようにしています。これを行うことで、「実装の内容全体を理解するのは難しいが、利用はしたい」というケースに対応できます。実際、信号処理や自然言語処理、統計モデルや機械学習を普段扱うために、それぞれの実装者が全ての知識を得るのは大変です。難しいところは詳しい人に任せて、出来上がった実装は皆が利用できるようにしています。

しかし、この型付けを努力目標にとどめてしまうと、いづれ使われなくなって技術的な負債が増えることが想像されます。その対策として、型をチェックするツールとしてmypyを採用し、Pull Requestを出すたびに必ず型が適切に書かれているかをチェックしています。

CIが落ちているとコードレビューを行わないフローにすることで、全てのコードに型が書かれている環境を維持しています。mypyは、python用の静的型付けツールで、関数やクラスの入力の型とその返り値が適切に書かれているかをチェックしてくれます。dropboxを中心に開発が行われています。

その時に使用されているコマンドは以下です。それぞれのコマンドについては、こちらに詳しい記載があります。

$ mypy --allow-redefinition --ignore-missing-imports --disallow-untyped-defs --warn-redundant-casts --show-error-context --no-incremental --no-implicit-optional --html-report ./report project_dir/

flake8でのLintと、autoflake, black, isortを用いたコード整形

Pythonのコーディング規約であるPEP8に私たちのコードが準拠できているかを確認するために、flake8を用いています。 flake8とは、以下のようなコマンドを実行すると、プロジェクト全体のコードを走査して、PEP8に従っていないコードを、従っていないとされる規約とともに教えてくれるツールです。

$ flake8 project_dir/

39      E111 indentation is not a multiple of four
1       E128 continuation line under-indented for visual indent
1       E302 expected 2 blank lines, found 1
10      F821 undefined name 'cmp'
1       H306  imports not in alphabetical order (urlparse, urllib)

実際の利用ではいくつか設定をsetup.cfgに記述して以下の仕様で利用しています。一行あたりの最大文字数の制限と、PEP8の任意の項目については無視しています。

[flake8]
max-line-length=119
ignore=E121,E123,E126,E133,E226,E241,E242,E704,W503,W504,W505, E127,E266,E402,W605,W391,E701,E731

上記をCI上で行うことで、PEP8に従っていないコードが生まれないようにしています。

ただ、自分のコードがPEP8に準拠しているかどうかを常に気にしながらコードを書くのは正直苦痛です。なので開発者の負担を減らすための工夫もしています。

具体的には、いくつかのライブラリを組み合わせ、自分のコードが勝手にPEP8準拠にフォーマットされるようにしています。

$ autoflake --in-place --remove-all-unused-imports --remove-unused-variables --recursive project_dir/ &&\
 isort -rc project_dir/ &&\
 black --line-length 119 project_dir/

使用しているのは、autoflake, isort, blackの3つです。直感的には、「flake8に従わせる」「import部分を整理する」「コードスタイルを綺麗にする」といった感じです。もちろん、それぞれを単体で利用しても十分コードは綺麗になります。この組み合わせにしているのは、ライブラリごとにフォーマットの仕方に癖があるからです。

それぞれの開発者は、自由にコードを書いて

$ make format 

とするだけです。

おわりに

分析チームがもっと楽して、楽しく面白い分析できるようにしていきたいなと思ってやみません。

GoからWebAssemblyを動かしてみる

初登場コアテクノロジープラットフォーム部プラットフォームチーム所属の南です
すごい記事は先輩たちにお任せして、気になることをふわふわっと書こうと思います
最近は業務でGoを書いています(3ヶ月目)

今回はGo1.11から正式サポートされたWebAssemblyをさわりたいと思います

WebAssemblyとは

公式では

ネイティブに近いパフォーマンスで動作するコンパクトなバイナリー形式の低レベルなアセンブリ風言語です。C/C++ や Rust のような言語のコンパイル対象となって、それらの言語をウェブ上で実行することができます。WebAssembly は JavaScript と並行して動作するように設計されているため、両方を連携させることができます。

とのことです
要するに

  • Goのコードをブラウザ上で動かせる
  • コンパイルするので動作が速い
  • javascriptとの連携をさせられる(代替ではない)
  • 対応していないブラウザもある
  • wasmと呼ばれる

Hello, WebAssembly!

とりあえずみんな大好きHelloなんちゃらをやりたいと思います

$ mkdir wasm
$ cd wasm
$ touch main.go

main.goを作成し、以下のように記述します

package main

import (
    "fmt"
)

func main()  {
    fmt.Println("Hello, WebAssembly!")
}

Goファイルはこれだけです
WebAssemblyはhtmlとjsを用いて動作するので、それぞれ用意する必要があります
今回は公式さんが用意しているソースを使わせていただきます

$ curl -sO https://raw.githubusercontent.com/golang/go/master/misc/wasm/wasm_exec.html
$ curl -sO https://raw.githubusercontent.com/golang/go/master/misc/wasm/wasm_exec.js

go build

main.goをbuildします

$ GOOS=js GOARCH=wasm go build -o test.wasm

wasmを利用する場合は上記のようにGOOSとGOARCHを設定する必要があります
これでtest.wasmが作成され、htmlから読み込むことが可能になります

サーバ起動

今回は簡易的に動作を確認したいだけなのでgoexecを用いてGoのサーバを起動したいと思います

$ go get -u github.com/shurcooL/goexec
$ goexec 'http.ListenAndServe(":8888", http.FileServer(http.Dir(".")))'

動作確認

サーバまで起動したので実際にhttp://localhost:8888/wasm_exec.htmlをブラウザで開きます

f:id:nannannanan:20190704183843p:plain

これがRunボタンをクリックすると

f:id:nannannanan:20190704183948p:plain

コンソールにfmtの出力結果が表示されます
簡単ですね

まとめ

  • ブラウザ上でGoのプログラムを動かせるのはおもしろい
  • 公式さんが色々用意してくれてるので始めるのもかなり容易
  • 業務内で微妙に不便なこととかをささっとwasmで作って喜ばれる経験とかをしたい人生だった

という感じです
うまく使えると便利なツールを簡単に作れそうです
一緒にwasmとかGoを書いてくださる方いらっしゃればぜひご応募ください

サーバーサイドエンジニア(共通基盤) | アピール | 未来の教育を作る人のマガジン

iOSアプリでリファクタリングしたいクラスにユニットテストを導入する時の知見を共有します

開発本部APP・UXチームの玉置(@tamappe)と申します。主にiOSAndroidの運用を担当しています。 担当しているのに最近は専らiOSのみを開発するようになりました。

Swift 3 からSwift 4.2 にリプレイスとリファクタリングした話しを紹介します。

rarejob-tech-dept.hatenablog.com

前回の記事を投稿してからちょうど2ヶ月が経ちました。 この2ヶ月間が非常に内容が濃過ぎて2ヶ月間やってきた内容を振り返るのは非常に難しいです。

また本日で入社から三ヶ月間が経ち研修期間が終わります。 この三ヶ月間でチームメンバーだけでなく様々なメンバーに支えて頂きましたのでとても快適に開発を進めることができています。 来月からはこれまで以上に頑張ってレアジョブアプリを支えていくつもりです。

今回はフリースタイルに文章を書いても良いということでiOSでのユニットテストコードの書き方について紹介したいと思います。 モバイルアプリ開発においてもう避けて通れなくなっているからですね!

Xcodeユニットテストクラスを追加する

どのIDEでもそうですがほとんどの場合、アプリのプロジェクトファイルを作成すると標準のテストクラスが作成されます。 Xcodeの場合もオプションですがUnit TestをTarget に追加するかどうかは最初に決められます。

f:id:qed805:20190613155310p:plain
xcode_unit_test

また、最初にオプションで設定せずにプロジェクトファイルを作成しても後からユニットテスト用のTargetを追加できます。

f:id:qed805:20190613155451p:plain
add_unit_test_target

今回は便宜上UnitTestSampleというプロジェクトファイル名にします。

ユニットテストの書き方について

それでは実際にユニットテストを書いていきます。 Xcodeユニットテスト命名に厳しくないためどのようなクラス名にしてもいいはずですが、 だいたいの命名規約としてテストするクラス名Testsという命名になるかと思います。

今回はUnitTestSampleTestsというユニットテストクラスを使います。 クラスの作成時のUnitTestSampleTestsの中身は以下の通りです。

UnitTestSampleTests

import XCTest
@testable import UnitTestSample // この行を入れないとテストができません。

class UnitTestSampleTests: XCTestCase {

    override func setUp() {
        // このユニットテストクラスを使う場合の初期設定
    }

    override func tearDown() {
        // クラスのテストを終了した時に実行する内容
    }

    func testExample() {
        // サンプルの関数
    }

    func testPerformanceExample() {
        // パフォーマンスを測定する際に使うような関数
        self.measure {

        }
    }

}

今回は特に上記4つを使う場面はありませんので全て削除します。削除しても問題ありません。

UnitTestSampleTests

import XCTest
@testable import UnitTestSample

class UnitTestSampleTests: XCTestCase {

}

試しにユニットテストをしたいのでテストするためのクラスを本番スキームで作成します。

計算する用のクラスとしてCalculationクラスを作成します。

Calculation

import UIKit

class Calculation: NSObject {
    
    func addNumber(a: Int, b: Int) -> Int {
        return a + b
    }

    func minusNumber(a: Int, b: Int) -> Int {
        return a - b
    }
}

足し算用のメソッドaddNumberと引き算用のメソッドminusNumberを作りました。 それぞれのメソッドに引数a, b を設定します。

これらのメソッドが正常に動くかをテストしたいと思います。

UnitTestSampleTestsクラスを修正します。

import XCTest
@testable import UnitTestSample

class UnitTestSampleTests: XCTestCase {
    
    func test_addNumber() {
        let calculation = Calculation()
        
        let testable = calculation.addNumber(a: 1, b: 2)
        XCTAssertEqual(testable, 3)
    }
    
    func test_minusNumber() {
        let calculation = Calculation()
        
        let testable = calculation.minusNumber(a: 5, b: 1)
        XCTAssertEqual(testable, 4)
    }
}

テストの命名は私の慣習でtest_テストする関数名としていますがチーム開発の場合はある程度の規則で命名すれば良いと思います。

この後に「command + B」のショートカットを実行すると行数の部分にダイヤマークが出てきます。

f:id:qed805:20190613161430p:plain
sample_test

ユニットテストの実行は「command + U」で実行できます。

とりあえず、何も考えずに「command + B」してから「command + U」をすればユニットテストができると思います。

ビルドが成功するとTest Succeededと表示されるはずです。

成功した後はダイヤマークが緑色に変わると思います。

f:id:qed805:20190613161800p:plain
test_succeeded

これがユニットテストの基本形です。

iOSエンジニアでユニットテストに書き慣れていない方でしたら 上記コードで見慣れないのがXCTAssertEqualメソッドかと思われます。

これはXCTAssertEqual(A, B)という書き方をしまして、AとBの値が等しいかどうかを確認するメソッドです。 正しくない場合はテストが失敗して赤色のエラーが表示されます。

試しに

UnitTestSampleTests

import XCTest
@testable import UnitTestSample

class UnitTestSampleTests: XCTestCase {
    
    func test_addNumber() {
        let calculation = Calculation()
        
        let testable = calculation.addNumber(a: 1, b: 2)
        XCTAssertEqual(testable, 3)
    }
    
    func test_minusNumber() {
        let calculation = Calculation()
        
        let testable = calculation.minusNumber(a: 5, b: 1)
        XCTAssertEqual(testable, 3)
    }
}

と修正してみて「Command + U」を実行してみましょう。minusNumber に 5, 1 を入れました。 testableには4が入ってますがそれと期待値である3が等しいかどうかの確認です。

当然、4と3は違いますのでテストが失敗します。

f:id:qed805:20190613162323p:plain
test_failed

テストが失敗する場合は緑色から赤色になります。

エラーの内容はXCTAssertEqual failed: ("4") is not equal to ("3")というような感じです。

これで失敗した内容がわかりました。

UnitTest のメソッドの種類について

上記ではXCTAssertEqualのみを使用してきましたが、他にも代表的なテスト用のメソッドがあります。

  • XCTAssertNil(A) (A がnilであるかどうかの確認。Aがnilであれば成功)
  • XCTAssertNotNil(B) (Bがnil以外のオブジェクトかどうかの確認。Bがnilでなければ成功)
  • XCTAssertTrue(C) (C がtrueであるかどうかの確認。Cがtrueであれば成功)
  • XCTAssertFalse(D) (Dがfalseであるかどうかの確認。Dがfalseで成功)
  • XCTAssertNotEqual(E, F) (EとFが等しくないかどうかの確認。EとFが等しくない時に成功)
  • XCTFail() (意図していない挙動の場合にユニットテストを失敗させることができる)

だいたいのテストはこれだけで知りたい内容の確認ができるかなと思います。

import XCTest
@testable import UnitTestSample

class UnitTestSampleTests: XCTestCase {
    
    func test_addNumber() {
        let calculation = Calculation()
        
        let testable = calculation.addNumber(a: 1, b: 2)
        XCTAssertEqual(testable, 3)
    }
    
    func test_minusNumber() {
        let calculation = Calculation()
        
        let testable = calculation.minusNumber(a: 5, b: 1)
        XCTAssertEqual(testable, 4)
    }
    
    func test_nilObject() {
        var nilObject: Int? = nil
        
        XCTAssertNil(nilObject)
        
        nilObject = 1
        XCTAssertNotNil(nilObject)
        
        var isSample = false
        XCTAssertFalse(isSample)
        
        isSample = true
        XCTAssertTrue(isSample)

        let testable = "Hello World"
        XCTAssertEqual(testable, "Hello World")
    }
}

f:id:qed805:20190613163619p:plain
test_sample

他のオブジェクトに関するテストについて

今までは単純な型のユニットでしたが次はオブジェクトのユニットテストについて紹介します。

Calculationクラスに新しい構造体 Userを作成します。

import UIKit

struct User {
    let id: Int
    let name: String
    let number: Int
}

class Calculation: NSObject {
    
    var id: Int = 0
    
    func addNumber(a: Int, b: Int) -> Int {
        return a + b
    }

    func minusNumber(a: Int, b: Int) -> Int {
        return a - b
    }
    
    func createSampleUser(name: String, a: Int, b: Int) -> User {
        let number = addNumber(a: a, b: b)
        let user = User(id: id, name: name, number: number)
        id += 1
        return user
    }
}

こちらのクラスの挙動を確認します。

import XCTest
@testable import UnitTestSample

class UnitTestSampleTests: XCTestCase {

// 省略します。 //
    
    func test_sampleUser() {
        let calculation = Calculation()
        let firstUser = calculation.createSampleUser(name: "太郎", a: 1, b: 2)
        XCTAssertEqual(firstUser.id, 0)
        XCTAssertEqual(firstUser.name, "太郎")
        XCTAssertEqual(firstUser.number, 3)
        
        let secondUser = calculation.createSampleUser(name: "花子", a: 3, b: 6)
        XCTAssertEqual(secondUser.id, 1)
        XCTAssertEqual(secondUser.name, "花子")
        XCTAssertEqual(secondUser.number, 9)
    }
}

これを書いた後に「command + U」をタップしてユニットテストを実行してみましょう。

これでテストが通れば意図通りです。

f:id:qed805:20190613164751p:plain
user_test

テストが通ったことが分かりました。

このようにカスタムなstructclassに対する挙動もユニットテストができるようになります。

レアジョブアプリで導入する予定のクラスについて

レアジョブアプリは2016年にリリースしてからおよそ3年が経過しています。 3年も経過するとコードのメンテナンスが必要である場面が出てきたり、 プラットフォームのAPIのアップデートにより一部分のメソッドが非推奨になる可能性もありえます。

今後レアジョブアプリでユニットテストを施していこうと思うクラスについては

  • Utilityなどの便利クラス
  • extensionクラス
  • API通信で使うModelクラス

このようなクラスのテストを書いていこうと思います。 実際にテストコードを書いてみるとそのクラスのプロパティやメソッドの使い方と結果がわかって動作確認にも便利です。

ぜひ今回の記事を参考にしてユニットテストを書いてみてください。

golangでサービスロケーターパターン

コアテクノロジープラットフォーム部・プラットフォームチームの金丸です。
主にgolangを使用したAPI開発を担当しております。

さて現在、一部のプロジェクトでDB等の外部リソースにアクセスするテストを行う際、弊社ではdockertestを使用しています。

コンテナを立ち上げる事で外部リソースへのテストを行う事ができ、便利なのは良いのですが
現状全てのunitテストで使用されているため、上位packageのunit test内でもDBの設定が必要な状態になっています。

そのためmockで置き換える事ができるサービスロケーターパターンを使用した実装へ変更する事にします。

DB

mysql> desc users;
+-------+------------------+------+-----+---------+-------+
| Field | Type             | Null | Key | Default | Extra |
+-------+------------------+------+-----+---------+-------+
| id    | int(10) unsigned | NO   | PRI | NULL    |       |
| name  | varchar(255)     | NO   |     | NULL    |       |
+-------+------------------+------+-----+---------+-------+
2 rows in set (0.03 sec)

mysql> select * from users;
+----+------+
| id | name |
+----+------+
|  1 | test |
+----+------+
1 row in set (0.00 sec)

通常時の確認のためDBを準備します。
idとnameだけのシンプルなテーブルで、テストデータを1レコードだけ投入してあります。

プロジェクトを作成する

~go/src/github.com/hogehoge/locator-go
 ┣ repositories
 ┃ ┣ db.go
 ┃ ┗ db_test.go
 ┗services
   ┣ services.go
   ┗ services_test.go

プロジェクトを作成します。repositories/db.goがDBとアクセスするpackageで、services.service.goが前述のpackageを呼び出します。

DBにアクセスするpackageを作る

db.go

package repositories

import (
    "context"
    "database/sql"
)

// Repositoriesで実装しているインターフェイス
type Repositories interface {
    Search(ctx context.Context, id uint64) (string, error)
    NotUseFunc()
}

// Repositories内のメソッドを使うための構造体
type Repository struct {
    DB *sql.DB
}

// 新規Repositoryオブジェクト作成
func NewRepository(db *sql.DB) Repository {
    return Repository{
        DB: db,
    }
}

// DBから検索するメソッド
func (r Repository) Search(ctx context.Context, id uint64) (name string, err error) {
    err = r.DB.QueryRow("SELECT name FROM students WHERE id = ? LIMIT 1", id).Scan(&name)

    return
}

// mockで置き換えないメソッド
func (r Repository) NotUseFunc() {
    return
}

やってる事はシンプルなSQLを実行しているだけです。
この時実装しているメソッドはRepositoryをレシーバとして設定してあり、これらはRepositoriesインターフェイスを満たしています。

DBにアクセスするpackageを動かしてみる

db_test.go

package repositories

import (
    "context"
    "database/sql"
    "fmt"
    _ "github.com/go-sql-driver/mysql"
    "testing"
)

func TestRepositories_Search(t *testing.T) {
    var (
        cnn  *sql.DB
        err  error
        name string
    )

    cnn, err = sql.Open("mysql", "user:password@tcp(host:port)/db_name")
    if err != nil {
        panic(err)
    }

    name, err = NewRepository(cnn).Search(context.TODO(), 1)
    if err != nil {
        panic(err)
    }
    fmt.Printf("name = %s\n", name)
}

 

=== RUN   TestRepositories_Search
name = test
--- PASS: TestRepositories_Search (0.02s)

簡易テストですので標準出力でログを出しています。
DBの値が取れている事が確認できます。

DBにアクセスするpackageを呼ぶpackageを作る

services.go

package services

import (
    "context"
    "github.com/hogehoge/locator-go/repositories"
)

// Servicesで実装しているインターフェイス
type Services interface {
    SearchService(ctx context.Context, id uint64) (name string, err error)
}

// Services内のメソッドを使うための構造体
type Service struct {
    Repository repositories.Repositories
}

// 新規Serviceオブジェクト作成
func NewService(rep repositories.Repositories) Service {
    return Service{
        Repository: rep,
    }
}

// Repository.Search()を呼ぶメソッド
func (s Service) SearchService(ctx context.Context, id uint64) (string, error) {
    return s.Repository.Search(ctx, id)
}

DBアクセス時と同様にpackageを作成します。
この時レシーバにrepositories.Repositoriesインターフェイス型の変数を実装しておき、メソッド内部ではそれを使用してSearch()を呼ぶ様にしています。

DBにアクセスするpackageをmock化して呼ぶpackageのテストを作る

services_test.go

package services

import (
    "context"
    "fmt"
    "github.com/hogehoge/locator-go/repositories"
    "testing"
)

// 置き換えるmockオブジェクト
type mockRepository struct {
    repositories.Repositories // インターフェイス埋め込み
    rName string              // result用name変数
    rErr  error               // result用err変数
}

// overrideするメソッド
func (m mockRepository) Search(ctx context.Context, id uint64) (string, error) {
    return m.rName, m.rErr
}

func TestService_SearchService(t *testing.T) {
    mock := mockRepository{
        rName: "mockName",
    }
    // mockで置き換える
    service := NewService(mock)

    // mockが渡されているためoverrideしたメソッドが呼ばれる
    name, err := service.SearchService(context.TODO(), 1)
    if err != nil {
        panic(err)
    }
    if name != mock.rName {
        panic("name is not equal")
    }
    fmt.Printf("name = %s\n", name)
}
  • 置き換えるmockオブジェクトを作成します
    • この時インターフェイスを埋め込んでおく事で、テストに関係のないメソッドを実装しなくて良くなります
  • mock化するメソッドを作成する
    • mockオブジェクトをレシーバとするメソッドを作成します。インターフェイス通りに実装しましょう
    • 結果を入れる変数を返す様にすればテスト毎に望む結果が返せて便利です
  • テストを実装する
    • Service.Repositoryオブジェクトにmockを入れます
    • mockが入ったService.RepositoryをNewServiceに受け渡します
    • これ以降、テスト内ではmock内のメソッドが呼ばれるようになります
=== RUN   TestService_SearchService
name = mockName
--- PASS: TestService_SearchService (0.00s)

これでRepositoriesを呼んでいるパッケージはテスト時にmockに置き換えられる様になりました。
ビューなどを実装する場合は、さらにServicesを同じ要領で置き換えてください。
これで実際にDBに繋げてテストする部分はrepositoriesだけになります。

もっと良いやり方や上手い実装があればウチに入社してこっそりマージリクエストください。

VSM(ValueStreamMapping)をレアジョブでやってみた

はじめに

こんにちは。レアジョブのサービス開発チームでアシスタントチームリーダーをやっております三上と申します。入社して4ヶ月目になりまして、入社当時はナニコレ!効率悪い!改善したい!と思うことが多々あり、改善したいマンの毎日でしたが、良くも悪くも慣れてきて焦りを感じています。 この記事では改善活動の一環として実施したValueStreamMapping (以下 VSM)について書きます。

VSMとは?

今更な感じはしていますが、改めて。

dev.classmethod.jp

バリューストリームマッピングとは、製品やサービス、機能を顧客に届けるために必要なプロセスを可視化するためのツールです。

今回はレアジョブでアジャイルコーチとしてお世話になっている、クラスメソッドの藤村さんにファシリテート&アドバイスいただきながらワークショップをやってみました。 ちなみに上記参考記事の執筆者でもあります。

レアジョブでやってみた

f:id:rarejobmikami:20190527202028j:plain

レアジョブでは案件のステークホルダーが多い方なのですが、今回は開発チームとPO(プロダクトオーナー)に当たる企画チームと合同で行いました。

まずはユーザーから始めて、実際に価値を提供出来るまでのプロセスをホワイトボードに書き出していきます。 一人ずつ順番に板書を担当しながら、みんなでワイワイガヤガヤ書き始めること2時間...(途中休憩をはさむ)

やってみた結果

結果は写真でドン。

f:id:rarejobmikami:20190527203029j:plain:w300f:id:rarejobmikami:20190527202808j:plain:w300f:id:rarejobmikami:20190527202711j:plain:w300

分かったこと

大きく気になったのが2つ。

  • 企画承認
  • 受入テスト完了〜リリース

この2つのリードタイムが長い! 企画とリリースのどちらにも共通している課題は、「承認者」がDH(役員)であり、それは一人の人間であり且つ多忙であること。

さらに、リリースについてはリリース日を週一固定で実施している点も課題として上がりました。 ユーザー目線になってみれば出来上がった機能は早く体験したいはず。 当たり前のようで気付いてなかった課題の共通認識が持てました。

改善したこと

課題が見えたことで実際に改善につながったこととして、

  • 承認フローの見直し
  • 定期リリースの運用廃止

があります。 定期リリースの運用廃止については準備中ですが、 承認フローについては新しいルールですでに運用が始まっており、リリース承認のリードタイムは大きく改善されました!(体感的に)

最後に

皆さん毎日忙しい日々が過ぎていくと思います。私も4ヶ月あっという間に過ぎ去りました。 だからこそ今回のように「振り返りの場」が重要だと改めて感じました。

これからも改善したいマンの気持ちを忘れずに精進してまいります。

それでは、また。