Storybookを導入してみて見えた、業務委託中心のチームで安定して成果を出す手法
はじめに
この記事は
※この記事はSEVENRICH GROUPの開発チームDELTAのアドベントカレンダー9日目の記事です。
よろしければ是非ご購読ください!
「DELTA」の自己紹介↓
自己紹介
丹哲郎(たんてつろう)といいます。
株式会社 DELTA の代表兼、SEVENRICH GROUP のグループ CTO を務めています。
SEVENRICH GROUP とは?
やっていること
CLINIC TEN SHIBUYA をはじめとするスマートクリニック向けのソリューションを構築しています。
特に 2022 年の大半は、開業に合わせてアドホックに整備されていた医師・看護師・事務向けの管理画面のフルリニューアルを行っていました。
2022 年 4 月にエンジニアを一人採用できたものの、リニューアルをはじめとして大きい開発がスピード感をもって展開される中で、
自分を除けばほぼ全員が業務委託というチームで、多いときは 12 人ほどで一気に開発を進める必要がありました。
課題
業務委託中心で、ディレクションをできるメンバーがごく少数なのに対しエンジニアメンバーは多いため、
認識のブレやオンボーディングコストの増大等、特にコミュニケーションや品質担保の面でコストが大きく、
また小数のメンバーがボトルネックになってしまうことが予想されました。
実は以前にも同様の大規模開発を業務委託メンバー中心に進めることがありましたが、同様の課題により
スケジュールが遅延することもしばしばありました。
Storybook について
Storybook とは、コンポーネントカタログを作ることができる UI のフレームワークです。
例えば react であれば、Button というコンポーネントに対して、その単体のカタログ・ショーケースのような画面を簡単に作ることができます。
// Button.tsx
type Props = {
children: ReactNode;
color?: "primary" | "secondary";
};
export const Button = ({ children,color }: Props) => <button className={color}>{children}</button>;
// Button.stories.tsx
export const Default = () => <Button>lorem</Button>;
export const Primary = () => <Button color="primary">ipsum</Button>;
通常であればコンポーネントのテストは結合レベルで、画面を実装してみて実際にエンドツーエンドで叩かないとできなかったところ、
Storybook を利用すれば単体レベルでの描画されたコンポーネントの手動でのテストを行うことができます。
この「単体レベルだがエンドツーエンド」というポジショニングが絶妙で、
- 全体のエンドツーエンドは環境構築のコストやその画面・状態にたどり着くまでのデータ仕込みなどのキャッチアップコストがかかる
- すべてのコンポーネントに対して、単体自動テストだけで完結できるような開発をしていない
という現場においては絶大な生産性を発揮してくれます。
本稿では、この Storybook を用いた開発プロセスの運用と、
そこからみえてくる、業務委託メンバー中心の環境においての開発生産性を上げる知見について書いていこうと思います。
なお、単体の自動テストを実装してそれですべて賄うという場合にはあたらない話も多々あります。
Storybook を用いた開発プロセスとその学び
ルール:全てのコンポーネントに Storybook を書く
CLINIC TEN の管理画面のリニューアルプロジェクトでは、参画したメンバ―全員が「全てのコンポーネント」、すなわち UI パーツだけではなく、ドメインごとのコンポーネントについてもあらゆる階層のすべてのコンポーネントについて Storybook を書くようにしました。
めちゃくちゃいっぱいある
Storybook First な開発体験
各ドメインの開発者は、まず Storybook から書き始める、Storybook First な実装をすることで、以下のようなメリットがあったと感じています。
基本的には TDD のライト版のようなイメージです。
「書いた通りに」動作することが保証された HTML の実装から始めることができるため、心理的安全性が高い
開発者にとって、まず「動くもの」「見栄えがそれっぽいもの」から作り始めることができるというのは安心感があると感じます。
デザインカンプという最強のドキュメントが利用でき、ディレクションコストが節約できる
業務委託中心のチームだと、リードエンジニアが1から10までディレクションする必要があったため、ドキュメント作成や同期のために工数がかなりかかっていました。
それが、「まずこのデザインカンプをベースに、Storybook を作るところまでお願いします!」ということで、横にスケールしやすいうえにディレクション工数がかからない工程を増やすことができるようになりました。
個人的には、フロントエンドは外部に委託しやすく、その理由としてはディレクションに必要な仕様書をデザイナーに作ってもらうことが可能だからと結論づけていますが、その恩恵をフルに受けられる状況を作ることに大きく寄与していると感じています。
依存性を分離するようになる
Storybook を必ず書くようにすることによって、副作用の大きいコンポーネントや、ドメインロジックが入り込んでしまっているコンポーネントはそもそも書きづらくなりました。
コンポーネントが必要とする状態やコンテクストが大きければ大きいほど、Storybook の宣言が肥大化してしまって「面倒くさい」からです。
これによって、コンポーネントがクリーンに保たれるという副作用もありました。
レビューが簡単になる
PR が出るたびに、Storybook をビルドして Vercel にデプロイするようにしています。
それにより、「この PR では、どういったコンポーネントが出るのか」が一目瞭然になりました。
スモークテストや VRT の下敷きに使うことができる
何かの変更により、意図しない影響が出てしまっていないか、のテストを行いたい際には Storybook を対象に VRT[1] やスモークテスト[2]を行うことができるため、Story が整備されていることで周辺の自動テストも比較的導入しやすくなりました。
分業がしやすい
弊社では、「UI とその振舞を実装するエンジニア」と、「デザイナーの作ったカンプに対してピクセルパーフェクトにスタイリングを合わせるエンジニア」を分業する体制をしいています。
それにより、機能の実装のスピード感とデザイン性を同時に成立させようとしているわけですが、この際も「UI エンジニア」から「マークアップエンジニア」に対しての引き渡しに Storybook が活用できました。
Storybook はスタンドアロンでコンポーネントを動作させることができるため、環境構築もデータの整備も不要で、マークアップエンジニアがいきなり稼働することが可能になりました。
Storybook で API 疎通したい場合のプラクティス
スタンドアロンで動くと言ったものの、実際にはアプリケーションは外部のリソース、特に API への依存性を持っています。
Storybook は本来はコンポーネントのカタログフレームワークなので、依存性フリーの状態を作っておき、スナップショットたる引数を一方的に渡すようにすべきというのが本来ではあるのですが、
本来邪道ではあるのは理解しつつ、「実際に API に繋いでみたい!」となるケースも多々あります。
そのような、完全スタンドアロンで動かすケース と API とも時折繋ぐケース を両立させるため、以下のような工夫を行っています。
前提
当プロジェクトではフレームワークとして React を使っていますが、状態管理ライブラリとして MobX を利用しています。
Store クラスをコンポーネントごとに定義する
ドメインコンポーネントにおいては、通常の UI Parts コンポーネントのように I/O (Props とイベント)で制御するのではなく、
思い切って全ての引数を Store クラスのインスタンスにしています。
ディレクトリ構成としては以下のようにしました。
∟ components
...
∟ ReservationDetail
∟ stores
∟ ReservationDetailStore
∟ index.ts => Storeクラス(APIへの依存性なし)
∟ client.gql.ts => APIクライアント(GQL実装)
∟ client.mock.ts => APIクライアント(mock実装)
Store クラスの設計でモック API クライアントを実現
Store クラスが内部的には API をコールし、その結果を元に自らの状態を変化させるわけですが、Store クラスには API の知識を持たせたくないため API クライアントのインターフェースだけを定義し、それを依存性として引き受けるようにします。
export class ReservationDetailStore {
private client: IReservationDetailAPIClient;
public async fetch() {
await this.client.fetch(this);
}
}
export interface IReservationDetailAPIClient {
public fetch(store: ReservationDetailStore): Promise<void>;
}
イメージとしては java の DI のような感じですね。
これによってモック API クライアントと、実際の API クライアントを切り替えられるようにしています。
Service Worker で API との疎通を実現
当プロジェクトでは AppSync をバックエンドに使っています。
フロントエンドで利用しているライブラリでは暗黙的にHeaderにアクセストークンを設定してリクエストしています。
このHeaderの値はライブラリの外部からは設定できないため、ログイン機構を持たないStorybookの内部からはうまくAPIと疎通ができませんでした。
そこで、シンプルなService Workerプロキシを実装することで、その問題を解決しました。
Service Worker の実装
Storybook は機能として「static files」を指定することができます。
これを利用し、service-worker.js を実装します。
service-worker.js は以下のようになります。
// public/service-worker.js
const handleInstall = () => {
console.log("[SW] service worker installed");
self.skipWaiting();
};
const handleActivate = () => {
console.log("[SW] service worker activated");
return self.clients.claim();
};
const handleFetch = (event) => {
event.respondWith(
(async () => {
const req = event.request;
if (req.url.includes("graphql") && new URL(req.url).port === "20002") {
const headers = new Headers();
req.headers.forEach((val, key) => {
headers.append(key, val);
});
// ランタイムから事前に受け取って保存しておいた「token」「endpoint」をリクエストに詰める。
headers.append("Authorization", self.token || "xxxxxx");
const request = new Request(self.endpoint || req.url, {
method: req.method,
headers: headers,
mode: req.mode,
credentials: req.credentials,
cache: req.cache,
redirect: req.redirect,
referrer: req.referrer,
body: JSON.stringify(await req.json()),
});
return fetch(request);
}
return fetch(event.request);
})()
);
};
const handleMessage = (event) => {
console.log(`[SW] received message:`, event.data);
// ランタイムから、「token」「endpoint」を受け取って保存する。
self.token = event.data.token;
self.endpoint = event.data.endpoint;
};
self.addEventListener("install", handleInstall);
self.addEventListener("activate", handleActivate);
self.addEventListener("fetch", handleFetch);
self.addEventListener("message", handleMessage);
Storybook に Service Worker を配置する
以下のように設定すると、Storybook のサーブ・ビルド時に service-worker.js がサーブされるようになります。
// .storybook/main.js
module.exports = {
// ...
staticDirs: ["./public"],
};
Story から Service Worker を利用する
以下のようなユーティリティコンポーネントを作り、
アクセストークンと接続先エンドポイントを Storybook から設定できるようにしてみました。
Service Worker のインストールと、postMessage
を利用したアクセストークン・エンドポイントの送信を行ってくれるラッパーコンポーネントになります。
Amplify のappsync-simulator
を使っている場合、アクセストークンはお好きな秘密鍵で適当に署名したものでよいため、アクセストークンの生成を Storybook 内で行ってしまっても大丈夫です。
export const ServiceWorker = ({ children }) => {
const [token, setToken] = useState("");
const [endpoint, setEndpoint] = useState("");
useEffect(() => {
window.navigator.serviceWorker
.register("./service-worker.js")
.then(async (registration) => {
registrationRef.current = registration;
registration.active?.postMessage({ token: store.token });
setConfigured(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
registrationRef.current?.active?.postMessage({
token,
endpoint,
});
}, [token, endpoint]);
return (
<>
{children}
{/** <>
アクセストークンと、エンドポイントを設定するUI
</> **/}
</>
);
};
これにより、モック API クライアントと Service Worker を経由して実際の API を叩くクライアントとを Storybook から使い分けて呼ぶことが可能になりました。
まとめ
-
Storybook は、単体テストや TDD が齎すメリットを大きなコストなくほどよく享受できる
-
他にも、デザインカンプの有効活用やエンジニア間のタスク受け渡しの効率向上など、組織の生産性向上にも寄与してくれた
-
外部依存性については、最初からモックできる構造を作ることと、Service Worker をうまく使うことで実現可能
みんなも Storybook、使っていきましょう!
We're hiring!
ここまで読んでくださってありがとうございます!
Team DELTA では、多事業展開している VC の中のソフトウェア開発チームというユニークなポジションを活かし、
多様な環境での開発に日々チャレンジしています。
株主や自社事業という立場からは中長期的な視座での関わりが求められる一方、
「多」事業展開しており VC としても多様な出資先を持つため幅広な技術力が必要になる環境だと感じています。
いわば多様な技術にチャレンジし取り入れられる受託開発と、
深くコミットメントを持ち製品に向き合うことのできる自社サービス開発の「良いとこどり」のような働き方ができる職場です。
エンジニアにとっては非常に面白い環境だと思うので、ぜひぜひここまで読んでくださった方とは何らかの形で繋がりたいです!
ご興味ありましたら delta.tech@sevenrich.jp まで!
Discussion