katharsisという京大生が経営しているバーで、六本木にあるハッカーズバーみたいなことをやろう的な企画があってそこでサイゼガチャならぬカクテルガチャを作りました。店側で用意した端末で触ってもらうだけのアプリなのですが、無駄にロジックを分離したり無駄にテストを書いたり無駄にCI/CDを構築したりしたのでそのあたりをまとめてみます。
カクテルガチャとは
カクテルガチャとは、ベースとなるリキュールと割材と隠し味をランダムに組み合わせてオリジナルのカクテルを作成するというアプリです。それを実際にバーテンダーがカクテルを作ってお客さんに提供するというものです。
お客さんの中には未成年の人もいるので、その人でも楽しめるようにノンアルバージョンを作ってほしいという要望もありました。
構成としては、フロントだけのアプリケーションでTypeScriptのreact/reduxを使って構築しています。(正直hooksでも十分だった。。)ホスティングはnowを使っていてbuildされたjsを配信しているだけです。
├── __tests__
│ ├── domain
│ └── redux
│ └── loading
├── components
│ ├── app
│ │ └── Application
│ ├── block
│ ├── pages
│ │ ├── Index
│ │ ├── Loading
│ │ └── Result
│ └── parts
│ └── Button
├── domain
│ ├── entity
│ └── service
└── redux
├── cocktail
├── loading
└── utils
ディレクトリ構成はこんな感じです。
ロジックの分離
今回はロジックも全てフロントにあるサービスであるためロジックの分離は不可欠である(?)と考えました。また、度重なる仕様変更にも耐えられるように(?)責任範囲を限定しておくのは重要なのは言うまでもありません。実際に途中からノンアルの仕様が追加されたし、イベント当日に3つ目のボタンを追加してやばいカクテルができるようにもしましたw
依存関係がなくてサービスを一個追加するだけで済んだのでけっこう綺麗に書けていたかなと思いました。とはいえ、サービスの部分はコピーして追加が多かったのでその点はけっこう反省です。。
そこで、domainというディレクトリを分離し、その中にentityとserviceを作るようにしました。
├── domain
│ ├── entity
│ └── service
entity
ここでは、主にデータの型とカクテルの材料のenumだけを定義しています。
export interface Cocktail {
name: string;
base: string;
base2: string;
accent?: string;
}
// リキュール
export enum Liqueur {
Malibu,
Peach,
Mango,
Cassis,
Aloe,
Lychee
}
こんな感じでずらっと定義しているだけです。
材料をenumで定義したのはどうだったんだろうといのが正直な感想で、enumの数字がアルコール版とノンアル版で衝突したり書き方が無駄に冗長になってしまったりと正解だったのかどうか微妙なところです。
もちろんswitch caseが綺麗にかけたりとかのメリットはあったもののただのObjectでもよかったのかなぁとは思っています。
service
ここでは、カクテルのガチャのロジックをカプセル化しています。actionの引数にガチャサービスのインスタンスを生成して渡しています。中ではconstructorでガチャを実行していて、状態の取得以外は外部から参照できないようにしています。
このような実装は見たことがないのでこれでいいのかなぁと思っています。ただの関数の集合にしてもよかったのですが、明示的にどれが公開されているのかがわかりやすかったりinterfaceがimplできたりとメリットがあったからです。
内部的には状態を持っていないも同然(持ってはいるが更新さることはなく副作用はない)なので、副作用を持たないreduxの思想には反していないかなと思います。
export default class CocktailGachaService extends GachaService
implements GachaImpl {
constructor() {
super();
}
getState() {
// 翻訳
const baseJa = this.translate(Material.Liqueur) || "";
const base2Ja = this.translate(Material.Base) || "";
const secretJa = this.translate(Material.Accent);
// redux側でのinterfaceに合わせる
const state: Cocktail = {
base: baseJa,
base2: base2Ja,
accent: secretJa,
name: baseJa + base2Ja
};
return state;
}
execGacha() {
// enumの長さを取得
const liqueurLength = Object.keys(Liqueur).length / 2;
const baseLength = Object.keys(Base).length / 2;
const accentLength = Object.keys(Accent).length / 2;
// indexをセット
this.base = this.random(liqueurLength);
this.base2 = this.random(baseLength);
// optionalのやつは確率1/2
const secretRandom = this.random(accentLength * 2);
this.accent = secretRandom < accentLength ? secretRandom : undefined;
}
translate(type: Material): string | undefined {
switch (type) {
case Material.Liqueur:
return Translator[Liqueur[this.base]];
case Material.Base:
return Translator[Base[this.base2]];
case Material.Accent:
return this.accent !== undefined
? Translator[Accent[this.accent]]
: undefined;
}
}
}
また、なんちゃってテンプレートメソッドパターンで実装してみました。アルコール版とノンアル版がありコアな実装と必要なメソッドは共通なので抽象クラスで実装しています。
export default abstract class GachaService {
protected base: Liqueur | NonAlcoholBase;
protected base2: Base | NonAlcoholBase2;
protected accent?: Accent | NonAlcoholAccent;
constructor() {
this.execGacha();
}
abstract getState(): Cocktail;
protected abstract execGacha(): void;
protected abstract translate(
type: Material | NonAlcoholMaterial
): string | undefined;
protected random(len: number): number {
return Math.floor(Math.random() * len);
}
}
export interface GachaImpl {
getState(): Cocktail;
}
ただ、絶妙に内部の実装が違っていたのであまり綺麗に分けることができませんでした。(translateとか)
テスト
うまくロジックを分離することはできたので、せっかくだからサービスとあとは今まで書いたことのなかったreduxのテストも書いてみました。viewはめんどかったので書いていません。ライブラリはjestを用いました。
domain
ガチャのロジックがうまく動いているかをテストしました。内部的にMath.rodom()を使っているのでランダム関数をモック化しました。複数回実行されるので、指定した順に指定した値を返すという実装にする必要がありました。クロージャーを使い関数内部に状態を持たせることで実現しました。
// random()をmock化
const mockRandom = (num: number | number[]) => {
const mockRandom: Math = Object.create(Math);
if (Array.isArray(num)) {
// クロージャーで配列で指定した順番に値を返すように
let count = 0;
mockRandom.random = () => {
return num[count++] || num[0];
};
} else {
mockRandom.random = () => num;
}
Math = mockRandom;
};
このモックがうまくいってるかのテストも書いています。
describe("random()のmock化テスト", () => {
it("mockRandom()がMath.random()を上書きできているか", () => {
const value = 0.5;
mockRandom(value);
expect(Math.random()).toBe(value);
});
it("配列を渡したときに指定した順番で値が返ってくるか", () => {
const value = [0.1, 0.2, 0.3];
mockRandom(value);
value.forEach(i => expect(Math.random()).toBe(i));
});
});
あとは、もろもろのロジックがしっかり動いているかを確認しています。(隠し味は50%かとか同じ素材を選んでいないかとか)
describe("カクテルガチャのテスト", () => {
it("randomが0.5以上のときに隠し味がundefinedになってるか", () => {
mockRandom(0.5);
const gacha = new CocktailGachaService();
const state = gacha.getState();
expect(state.accent).toBe(undefined);
});
// ...
it("randomが0.1のときに想定通りの結果になってるか", () => {
mockRandom(0.1);
const gacha = new CocktailGachaService();
const state = gacha.getState();
const expected = {
base: "マリブ",
base2: "パイナップル",
name: "マリブパイナップル",
accent: "ジン"
};
expect(state).toEqual(expected);
});
});
ノンアルと普通のでテストファイルを分けた方がよかったような気もしますが、モック用のランダム関数を共通化して、、とかが面倒でかつ冗長になるような気がしたので今回は一つのファイルにしました。
redux
reduxのテストでは、loadingの状態管理に関してだけテストしました。ガチャの方がサービスで品質を担保できていてredux側では結果を状態にセットしているだけだったのでテストはしませんでした。
loadingはloadingになってから3秒後にloadedになるという非同期処理と、reducerでアクションに応じた状態をセットするというロジックを含んでいたのでその部分をテストしました。
非同期処理に関してはstoreをモック化して、非同期なアクションが実行された際に指定の順番で指定のアクションが実効されているか、ということをテストしました。
import configureStore from "redux-mock-store";
import { Middleware, AnyAction } from "redux";
import thunk, { ThunkDispatch } from "redux-thunk";
import { Loading } from "../../../domain/entity/loading";
import { setLoading } from "../../../redux/loading/effects";
import { Action } from "../../../redux/utils/action";
import { LOADING, LOADED } from "../../../redux/loading/actions";
const middlewares: Middleware[] = [thunk];
const mockStore = configureStore<
Loading,
ThunkDispatch<Loading, void, AnyAction>
>(middlewares);
const init: Loading = {
loading: false,
isDoneGacha: false
};
it("非同期の二つのアクションがdispatchされているか", async () => {
const loadingExpected: Action<{}> = { type: LOADING };
const loadedExpected: Action<{}> = { type: LOADED };
const store = mockStore(init);
await store.dispatch(setLoading());
const actions = store.getActions();
expect(actions[0]).toEqual(loadingExpected);
expect(actions[1]).toEqual(loadedExpected);
}
redux-mock-store
というstoreをモック化するライブラリを用いています。
reducerに関しては、シンプルに引数を渡して関数を実行し期待通りの返り値になっているかをテストしました。調べているとリグレッションテストがいいとかも出てきたのですが、完全に自動化したかったのでこのような手法をとりました。
import { Loading } from "../../../domain/entity/loading";
import { loadingReducer } from "../../../redux/loading/reducer";
import { loading, loaded, reset } from "../../../redux/loading/actions";
const init: Loading = {
loading: false,
isDoneGacha: false
};
describe("loadingReducerが正しく動いているか", () => {
it("LODING", () => {
const expected: Loading = {
loading: true,
isDoneGacha: false
};
expect(loadingReducer(init, loading())).toEqual(expected);
});
it("LOADED", () => {
const expected: Loading = {
loading: false,
isDoneGacha: true
};
expect(loadingReducer(init, loaded())).toEqual(expected);
});
it("RESET", () => {
const expected: Loading = {
loading: false,
isDoneGacha: false
};
expect(loadingReducer(init, reset())).toEqual(expected);
});
});
CI/CD
めちゃくちゃ普通にCircleCIを使ってテストの自動化とデプロイの自動化を実装しました。特にブランチ戦略は立てていませんが、masterにプッシュするとデプロイまで実行されるフローになっています。
ただ、予期せぬデプロイや無駄なデプロイが走ってしまうことが多々あったので、approvalをつけるかタグでリリースするぐらいは実装してもよかったかもしれません。(ていうかやろかな)
たった1コマンドとはいえ、叩く面倒もなくなったしプロダクションビルドのし忘れもなくなるのでかなりいい開発環境になりました。
GitHubはこちら(当日機能追加をしてコードが汚くなってる、、)