- 投稿日:
Next.jsなどの画面をはじめとしたプログラミング設計において、状態管理は非常に重要な要素だ。特に、複雑なアプリケーションでは、状態がどこに存在するかがコードの可読性や保守性に大きな影響を与える。
基本的に状態は親にすべて望むのが望ましい。これは子が状態を持っている場合に、親がこれを利用することが困難だからだ。例えば以下のようなコンポーネント構造において、子コンポーネントAでAPIをコールし、その結果を持っているとする。仕様変更でこのデータを子コンポーネントBでも利用したいとなった場合、子コンポーネントAから状態を引きはがす必要があるが、実装によっては状態と実装が密結合になっており、特にスパゲッティ化しているコードでは容易に引きはがせないこともあるだろう。
親コンポーネント
├子コンポーネントA
└子コンポーネントB
しかし親であらかじめ状態を持っておけば、このような苦労は不要になる。子AでAPIをコールした結果を親で持っておけば、それをそのままBに引き渡せばいいだけだからだ。
これは信頼できる唯一の情報源にも通じる話で、情報源が分散していると誤って同じ状態を二つ持ってしまったり、実装が複雑化してしまうことがある。酷い場合では子コンポーネントAと親の両方でAPIをコールするような実装もあるだろう。APIコールのタイミングがずれることで期待した通りの結果が得られなかったり、通信が増えることでロードが増えUXを毀損したり、APサーバーの負荷が高まる懸念もある。
基本的に同じ責務を持つ実装はDRYであることが望ましいので、このような悲劇を生まないためにも状態は親で持っておくことが重要だと考える。
逆に責務の範囲に閉じることができるのであれば、子で持ってよいケースもある。例えば親画面から子画面を出すときに、子画面だけに状態をもっていたいケースがある。こういう場合には子に一時的な状態を持つのはありだろう。例えば以下のような保存ボタンを押したときにモーダルの内容を確定するようなものであれば、モーダル内の状態として持ってよい。これは親で使わないからだ。その代わり画面状態の初期化時は親から状態を渡してやり、保存ボタンを押したときはコールバックで通知するという構造だ。
type HogeModalProps = {
shown: boolean;
hoge: string;
onClose: () => void;
onApplyClose: (state: { hoge: string }) => void;
};
export const HogeModal = (props: HogeModalProps) => {
const [hoge, setHoge] = useState(props.hoge);
const shownStyle = props.shown ? { display: 'block' } : { display: 'hidden' };
const updateHoge = (ev: FormEvent<HTMLInputElement>) => {
setHoge(ev.currentTarget.value);
};
const closeWithApply = () => {
props.onApplyClose({ hoge });
};
return (
<dialog style={shownStyle}>
<input type="text" value={hoge} onInput={updateHoge} />
<button onClick={closeWithApply}>保存</button>
<button onClick={props.onClose}>キャンセル</button>
</dialog>
);
};
このようなケースでは、子の状態を親で管理すると煩雑になるため子に持たせておいた方が良い。
- 投稿日:
普段でWeb系のコードを書いているときに、こういうのがよいのではないか?と考えていることを雑に書いてゆく。いわゆるクラスを使わず関数を主体にして実装するなんちゃって関数型モデルみたいなやつの話。雑に書いているのでところどころ変なかもしれない。コードはNext.jsを題材にして書いている。
端的に言うとSOLIDを軸に、適切にDRY、KISS、YAGNIといった一般的な設計原則を織り込み、MVCのように役割分担のあるアーキテクチャを利用することや、実装方式を統一することを意識している。
実装方式の統一
実装方式、コーディングスタイルを統一することで、微妙な書き方の差異に起因する不具合や、レビュー時のコスト、新人が来たときの迷いを減らせる。恐らくこれこそが最も重要な設計原則だと思う。
例えば以下のコードは書き方は違うがやりたいことは似ている処理群だ。もしチームに新人が来た場合、新人はどちらを書けばよいだろうか?レビューの時に複数の実装パターンが混ざったときにレビュアーはどう対応すればいいだろうか?
// undefinedの判定方法の違い。よほど特殊なことをしているのでなければ、基本的に後者でいい
if (typeof hoge === 'undefined') { }
if (hoge === undefined) { }
// 関数の定義方法の違い。どちらかに統一するほうが望ましい
function func1(param: T) { }
const func1 = (param: T) => {}
// 仮引数の定義方法の違い。これは分割代入とそれ以外で致命的に振る舞いが変わることがあるので統一することが望ましい
const func2 = ({ hoge, piyo }: { hoge: string, piyo: number }) => {}
const func2 = (param: { hoge: string, piyo: number }) => {}
type Func2Param = {
hoge: string;
piyo: number;
}
const func2 = (param: Func2Param) => {}
何よりこれらの処理は同じような処理に見えて、微妙に振る舞いが変わるものがある。それを意識せずコードスタイルとして表現していた場合に不具合を引き起こすトリガーになりやすい。特に分割代入はオブジェクトの参照が切れるので予期せぬ動作になりやすいし、関数のスコープが長くなってきたときに由来が解りづらくなるケースがある。逆に分割代入をしないことによって変数名が冗長になることもある。
こういった問題を解決するために、コードの書き方をあらかじめチームで決めておくのがよい。特にチーム開発でコードの書き方に秩序がないプロジェクトは品質が低い傾向があると感じる。例えば車のタイヤを全て別のメーカーにしていても車は走行できるが、基本的にメーカーが違う場合、品質などの差異があるので、追々予期せぬ問題が発生する可能性がある。それと同じ問題がコードにもあると私は考えている。
他にも同じことがしたいのに複数の実装方法があるとgrepしづらかったり、直感的ではないなど、コードを読んだ人間の認知負荷の増大に繋がるため、基本的には統一されていたほうがよいだろう。
MVCのように役割分担のあるアーキテクチャ
私が普段考えているレイヤリングはMVCとClean Architectureを足して二で割ったようなものだ。DDDや厳密なClean Arctectureだと複雑で重くなりがちなので、このくらいが丁度いいのではないかと考えている。これでも十分複雑なのは承知している。
コンポーネント | 役割 |
---|---|
Adaptor | APIコールなど、外部と接続するためのアダプタ関数。呼び出し処理以外を一切含まない。 ここでデータの操作や例外処理は行わず、必要な場合は呼び出しの前後で行う |
Util | 全体的な共通処理や、画面単位の細かいロジック類 |
Controller | 画面で利用するイベントコールバック処理。useEffect() みたいなのもここ |
State | 画面で利用する状態 |
View | 画面のビュー、ほぼ純粋なJSX/TSXで、booleanによる表示分岐のうち、単純なもののみ扱う。ネストしたり、複合条件を使った分岐は扱わず、必要に応じてUI Components側に移譲する |
UI Components | 画面のビューで利用するUI部品、状態はすべてprops経由で受け取り、自身では持たない |
Usecase | Pageコンポーネントに埋め込む存在。画面のController, State, Viewの繋ぎ合わせと、Pageコンポーネントから来たgetServerSideProps() やgetStaticProps() の結果を適切なコンポーネントに注入する |
Page | ページコンポーネント。Usecaseコンポーネントの埋め込みとgetServerSideProps() やgetStaticProps() の呼び出しのみを行う。 |
getServerSideProps getStaticProps |
getServerSideProps() やgetStaticProps() で処理するコードを書く |
SOLIDを意識する
リスコフの置換原則と、インターフェース分離の原則については、今回のケースでは無視でいいと思っている。
原則 | 効能 |
---|---|
単一責任の原則 | 関数の実装を単一責務として切り出すことで、関数の肥大化を防げ、再利用性を高めることができるほか、単体テストが書きやすくなる |
開放閉鎖の原則 | 関数のインターフェースを抽象化し、不変のものとすることで仕様変更などの変化に対応しやすくなる |
依存性逆転の原則 | その関数に必要な依存性をインターフェース経由で注入することで、関数内の処理が関数本体に依存しなくなり、疎結合になるほか、注入する依存性をモックなどに変えることでテストが容易になる |
単一責任の原則
一つの関数には一つの責務だけを持たせようという原則だ。
一例としてReactでよくみられるreducerは以下のように書ける。これはreducerはaction.type
に応じた関数を呼び出すことを責務として、実際の処理は関数に移譲するといった内容だ。
const reducer = (state, action) => {
switch (action.type) {
case 'update_account':
updateAccountProc(state, action.payload);
break;
...
}
}
この実装であればreducer関数のテストは呼び出す関数をすべてモックしておき、action.type
に応じた関数が、想定通りの引数で呼ばれているかどうかを確認するだけでいい。各関数は関数単体でテストが書けるのでシンプルだ。
もしこれが関数呼び出しではなく、処理がベタで書いてあると単体の規模が大きくなり、コードの見通しが悪くなり、比例してテストコードも長くなり、個人的にはあまりよくないと思っている。コードの見通しは良いほうがよいし、責務ごとに関数を切ることでコードマージ時のコンフリクトの規模を抑えられることや、Feature flagを導入しやすくなるなど、利点は多いと思う。
欠点としては細切れになることによって、パッと見何をしているかわかりづらくなることがあると思うが、適切に抽象化し、関数名をちゃんとつけていれば基本的にそこは考慮しなくてよいと考えている。
開放閉鎖の原則
この原則は拡張に対しては開かれており、修正に対しては閉じられていないといけないということだ。
例えば以下のコンポーネントではUsernameという単語がAccountNameに代わったときにPropsを書き換える必要があるが、もしもuserName
というワードを使わずにonChange
やvalue
といった抽象的な用語にしておけば、変更を不要にできる。
type Props = {
onUserNameChanged: (username: string) => void;
userName: string;
}
export const UserNameInputField = (props: Props) => {
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
props.onUserNameChanged(event.target.value);
};
return <input type="text" onChange={onChange} value={props.userName} />;
}
多くのライブラリやAPIではメソッド名やプロパティ名は汎用的な名称になっていることが多く、アップデートで名前が破壊されることはそこまで多くない。こういった風に実装しておくことで内部実装への影響なく処理ができるという寸法だ。
依存性逆転の原則
これは下位コンポーネントに実装を持たせず、上位コンポーネントから中小を介して実装を注入する原則だ。
例えば以下のような実装がある場合、「この辺りに長い色んな処理があるものとする」の部分を仕様変更などで書き換えないといけなくなることがある。
type Props = {
onChange: (value: string) => void;
value: string;
}
export const UserNameInputField = (props: Props) => {
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
/**
* この辺りに長い色んな処理があるものとする
*/
props.onChange(event.target.value);
/**
* この辺りに長い色んな処理があるものとする
*/
};
return <input type="text" onChange={onChange} value={props.value} />;
}
しかし、props.onChange
の中に前後の処理を挟んでいれば、これを回避することができるし、onChangeの中に処理がうじゃうじゃあるのも責務として過剰だと思うので、親からonChangeに対し何かしらの処理をすることが自明な関数を注入することで、こういった問題を回避できる。
DRYについて
この原則については、基本的に過度に意識する必要はなくシステム全体の共通部品以外は二重実装を許容して構わないと考えている。
これは余りにもDRYにしすぎると、仕様変更などで影響箇所が広がりすぎて修正が困難になるからというのと、共通部品が多すぎると探すのが大変で、最悪自力で実装されるケースもあるためだ。
KISSについて
程度問題ではあるが、DDDのような冗長で難解な設計原則は避け、ある程度単純なものにしたほうがいいというのは思っている。
べた書きをすることでコードジャンプが減って見通しがいいという意見は行き過ぎだと考えている。
YAGNIについて
これは技術選定フェーズで主に使うもので、何かの技術を入れるとき、その技術である必要がなければ使わないのがよいと考えている。
例えばWeb画面からAPIを叩くときにGraphQLを採用するケースがよくあると思うが、RESTやWebAPIで済むならそちらのほうがよい可能性がある。
GraphQLはWeb標準ではなく、ライブラリによって実装もまちまちで、複雑な仕組みが絡み合っており、全容を理解するのはなかなか難しいうえに、HTTPの上にあるため、使いこなすためには基礎の理解が必要だ。スキーマ設計も難しく、単純にやるとWebAPIと特に変わらないものができてしまう。そう考えたときに、絶対にGraphQLでないといけないというケースがないのであれば、より簡単な技術を使ったほうがよいと思っている。
複雑なものを作っている時間で休憩でもしたほうが頭がすっきりするだろう。
処理の境界を挟んだ時に影響が波及しないようにする
これはAPI通信など、外部から貰ってきたデータがあったときに、内部向けにデータ変換を行うということだ。
例えばAPIからisHoge, isPiyo, isFuga
というデータが来るパターンがあり、この三つのフィールドはいずれか一つしかtrue
にならないケースがあるとして、画面ではこれに対応するラジオボタンを持っている場合、true
だった項目を文字列として持っておくと便利だ。こういった場合に、使う場所で毎回変換処理を挟むと将来的に修正漏れが起きるリスクが増えたり、個々の場所で微妙に違う実装になって実装方式に統一がなくなったり。そもそも出現箇所が増えて認知負荷が増えるなど厄介だ。
これを防ぐために、一番最初にレスポンスを受けた時点で変換処理を挟み、以後それを引き回すというのがよいと考えている。APIに戻すときはisHoge, isPiyo, isFuga
のフォーマットに戻す必要があるのが、その場合はAPIを叩く前に逆変換の処理を挟むと、データ変換処理が入り口と出口だけに存在することになり、データフローがシンプルになる。
他にもAPIと画面の境界でENUMの内容を変換する処理を入れておくとAPI側がしれっとリファクタなどで名称変更をしたときに、異常値を検出しやすくなるし、画面の開発者は基本的にAPIのことを考えなくてもよくなるので楽になると思っている(境界部分を設計する人間だけが考えればよくなる)
あとがき
雑に書こうとしたものの、結局まとまりきらず、まとめようとしても永遠に終わらなかったので一旦吐き出してみた。多分全部話そうとすると発散しすぎて収拾がつかなくなるので、個々について深ぼったことを書いて、最後にそれをまとめたほうがいいのかもしれない。
といっても何を書くかが問題になってくるので、日々思ったことを雑にアウトプットしつつ、あとで振り返ってまとめていくのがいいのかもしれない。
- 投稿日:
Next.jsのディレクトリ構成を、かれこれ3年くらい考えているのだが、ローカルのメモ帳に溜め込んでいても腐るだけなので一旦雑に吐き出してみる。
やりたいこと
大きくは基本の開発ルールを定めて、チームで混乱が生じないようにしたい。
- コードファイルの配置ルールを決め円滑な開発ができるようにする
- ディレクトリごとにスコープを作り、ある機能で使うコードは一か所にまとめる(コロケーション)
- ロジック・状態・ビューを分離し、コードの肥大化を抑制することで、見通しをよくする
- 大まかなルールとしてはロジックは
controller.ts
,util.ts
に置き、状態はstate.ts
に、ビューはview.tsx
に配置する
- 大まかなルールとしてはロジックは
- 共通部品と共通部品でないものを明確に分ける
- 命名規則を単純化して命名で悩んだり属人化することを防ぐ
大きなこと
基本的なデザインパターンはMVCをベースにしていて、controller.ts
にはイベントハンドラを、state.ts
には画面の状態を、view.tsx
にはTSXを持つという設計。controller, state, viewの定義は以下の通り。
- controller.ts
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state.ts
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view.tsx
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合は別コンポーネントに切り出す
props.isHoge ? <Hoge /> : < />
ただ、これだとstateはModelではなくVewModelになるので、MVVCみたいな構成になってしまうので、一般的なデザインパターンから外れてしまうのがやや懸念だ。但しModelがないことで、Modelを修正したら参照している全画面に影響が出てデグレしたみたいなのは回避できると思っている。MVCというよりClean Archtectureとかレイヤーアーキテクチャの方が近いかもしれない。
ディレクトリ・ファイル構成例
└─src
├─adaptors
│ ├─HogeRequest
│ │ ├─index.ts
│ ...
├─components
│ ├─Fields
│ │ ├─Checkbox
│ │ │ ├─controller.spec.ts
│ │ │ ├─controller.ts
│ │ │ ├─view.spec.ts
│ │ │ └─view.ts
│ │ ...
│ ├─Layouts
│ │ ├─HogeLayout
│ │ │ └─index.tsx
│ │ ...
│ └─Pages
│ ├─Dashboard
│ │ ├─ui-parts
│ │ │ ├─HogeSection
│ │ │ │ ├─controller.spec.ts
│ │ │ │ ├─controller.ts
│ │ │ │ ├─view.spec.ts
│ │ │ │ └─view.ts
│ │ │ ...
│ │ ├─controller.spec.ts
│ │ ├─controller.ts
│ │ ├─state.spec.ts
│ │ ├─state.ts
│ │ ├─view.spec.ts
│ │ ├─view.ts
│ │ ├─usecase.spec.ts
│ │ └─usecase.ts
│ ...
├─hooks
│ ├─ValueState
│ │ ├─index.spec.ts
│ │ └─index.ts
│ ...
├─pages
│ ├─Dashboard
│ │ ├─index.page.ts
│ │ ├─server.spec.ts
│ │ └─server.ts
│ ...
├─resources
│ ├─RoutingConfig.ts
│ ...
└─utils
├─HttpClient
│ ├─index.spec.ts
│ └─index.ts
...
各ディレクトリ・ファイルの役割
src/adaptors/**/*
API通信などのリクエストを投げる処理だけを置く場所。投げる処理以外は一切書かない。ここではパースや例外処理を行わなず、必要な処理がある場合は、呼び出し元に委任する。
これは呼び出し元によってハンドリングが変わるケースがあり、機能AとBでは最初同じハンドリングだったが、のちに機能Aだけハンドリングが変わりアダプタ側に手を入れるというケースを防ぐためだ。要するに開放閉鎖の原則を守るためである。
src/components/**/*
Pageコンポーネント以外の全てのUIコンポーネントと、それに付随する処理を配置する場所。MVC一式がここに入る。
Modelをここに入れることに関しては悩みがあるが、MVVMのViewModelとして考える場合はそこまで違和感がないように思う。
src/components/Fields/**/*
<input type="text" />
みたいなフォーム系の入力コンポーネントの部品置き場。
ここに配置されるコンポーネントは原則として状態を持たず、状態は親コンポーネントからprops経由で渡される。このため、viewとcontrollerのみがある。
src/components/Layouts/**/*
ページ全体のレイアウト置き場。大外のレイアウトを入れる想定、そんなに数は生まれないと思う。
例えば以下のコードで~Layout
となっているのがここに入る。基本的にはViewしかない想定だが、必要ならControllerなどを置いてもよい。Stateが存在することはない(props経由で差し込まれることはある)
<CommonLayout>
<FullWideViewLayout>
<ヘッダーコンポーネントとか />
</FullWideViewLayout>
<HalfWideViewLayout>
<ボディコンポーネントとか />
</HalfWideViewLayout>
<FullWideViewLayout>
<フッターコンポーネントとか />
</FullWideViewLayout>
</CommonLayout>
src/components/Pages/**/*
ページコンポーネントの実体置き場。この設計ではNext.jsのページコンポーネントはこのディレクトリにあるUsecaseを参照するための存在なので、ページ本体の実装はここに置く。
src/components/Pages/Hoge/ui-parts/**/*
このページコンポーネント内で使う細かいUI部品置き場。src/components/Fields/**/*
などにある共通UIコンポーネントや、巣のTSXの組み合わせ。stateは持たず、controllerとviewのみを持つ。
src/components/Pages/Hoge/*.{ts, tsx}
このページコンポーネントの本体。
- controller
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view
- controllerとstateを受け取るpropsを持つ
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合はui-parts側に実装することが望ましい
props.isHoge ? <Hoge /> : < />
usecase
getServerSideProps
の結果を受け取るpropsを持つ- controller, state, viewの橋渡しをする場所
この階層でcontrollerにstateを差し込みラップした関数を作り、viewに差し込む
```tsx
import { sendHoge } from './controller';const ps = usePageState();
const onClickHoge = (ev: EventT) => { // これは中でHTTPリクエストを行っており、ローディングの状態を切り替えている
sendHoge(ps.hoge, ev.target.value);
}return
;
```
viewやcontrollerレベルで切り替わる場合は、切り替え先をcontroller, state, view, usecaseの単位で子にして、親側でラップする
src/hooks/**/*
共通的な状態操作用のHooks置き場。
src/pages/**/*
Next.jsのページコンポーネント置き場。*.page.tsx
にはusecaseコンポーネントの呼び出しのみを記述する。SSRする場合はserver.ts
に関数を実装し、*.page.tsx
側から参照して使う。
src/resources/**/*
定数置き場。
src/utils/**/*
共通処理置き場。全体的に利用する共通処理のみ配置する。局所的に使うものは置かない。
一定のドメインの範囲で利用するものをどこに置くかは考え切れていない。
- 投稿日:
この記事では下図のシステム構成を前提に話を進めるものとする。この時、BFFの部分のアーキテクチャをREST APIにするか、GraphQLにするかというのが今回の話。
REST API
長所
- Web標準であるため特別な技術が必要ない
- ファイルアップロードのような特殊な通信プロセスも何も考えずに実装できる
- エラーもエラーコードを返せばいい
- 学習コストが低い
- 設計が単純
- エンドポイントに何かを要求すれば、それに対する応答が帰ってくるという点で非常にシンプル
- CRUDはGET/POST/PUT/DELETEで分かれており、これも単純
- 依存するライブラリがない
- 破壊的変更を受ける可能性がない
- renovateによるアップデート地獄とも無縁
- BFFとフロントエンドで型情報の共有が容易
- BFFをTypeScriptで実装していれば、そのまま型情報が流用できるし、他の言語で実装していてもOpenAPIを作り、そこから型情報を得ることが可能だ
- curlからリクエストを投げやすい
- 基本的にペイロードが単純であるため
短所
- 複数のデータを取得する際に複数回APIを呼ぶ必要があり、オーバヘッドが大きい
- 今回のケースでは呼び出し先のAPIはBFFによって集約されているため、適切にエンドポイントを設計すればこの問題は回避できる
- オーバーフェッチとアンダーフェッチ
- データを取得しすぎたり、取得しきれないといった問題が発生することがある
- 例えば
/api/v1/user
からユーザー名だけを取得したい場合に、ユーザーIDや年齢といった余計なデータが付随してしまうことがあったり、このエンドポイントからそのユーザーのログイン履歴を取得することができなければそれはアンダーフェッチになる - 今回のケースでは呼び出し先のAPIはBFFによって集約されているため、適切にエンドポイントを設計すればこの問題は回避できる
- エンドポイントが増えすぎると何が何かわからなくなる
- GraphQLでもQueryが増えすぎれば同様の問題だと思うのでREST API固有の問題だとは思わない
- レガシーな技術
- レガシーというのはそれだけで悪である
GraphQL
長所
- モダンであり、流行っている
- モダンであり、流行っているということはREST APIより良い選択肢であると言える可能性がある
- クエリを利用した柔軟な要求が可能
- REST APIと異なり特定のエンドポイントに縛られることがない
- またクエリを読むことで、フロントエンド側の実装のみで、どのような要求なのかを知ることができる
- フロントエンド側で取得したいデータを制御できる
- エコシステムが充実している
- キャッシュやエラーハンドリングは勿論のこと、Reactなど特定のフレームワークと統合するための仕組みが存在する
- デバッグ用のブラウザ拡張機能が存在する
- デバッグ用のブラウザ拡張機能が存在し、これがあるとデバッグがしやすい
短所
- Web標準ではない
- ファイルアップロードのような特殊な通信プロセスは追加のライブラリなどが必要になるケースがある
- 学習コストが高い
- 非常に充実したエコシステムが存在するということは反面学ぶことも多いということになる
- Web標準ではないが通信にはHTTPを使っているため、Web標準を知らなくていいわけではない
- GraphQL標準のようなものは存在し、それと別にライブラリの構成についても学ぶ必要がある
- REST APIのノリで利用するのであれば使う意味がない
- 非常に充実したエコシステムが存在するということは反面学ぶことも多いということになる
- 型システムとの相性が悪い
- クエリを利用し柔軟にデータを要求できるということはデータ構造が不定であるため、静的型付け言語との相性が悪い
- 多くの場合、辞書型を使って処理することになると思うが、このままでは不具合を引き起こしやすく、型の正規化を行う場合余計な処理プロセスが入ってしまう
- また型の正規化をするための関数を個別に作っているとやっていることがREST APIと大差なくなる
- 設計が難しい
- スキーマ設計が難しいと言われており、HowTo記事が多くある。AWSにさえある
- 何でもかんでも取得できる魔法のリゾルバみたいなのを作ってしまうとフロントエンド側で取得できてはならないデータまで取得できてしまうためよく考える必要がある
- 例えばクエリに応じた内容をDBから引っこ抜いてくる実装だったりすると、他のユーザーの情報を取得するなどができてしまうので結局BFF側でもある程度の作りこみが必要になる
- 他にもN+1問題が起きるとパフォーマンスが悪くなるどころか、最悪サーバーが落ちることすらあり得るだろう
- 実装が難しい
- 学習コストが高い部分と同じだが、全員がGraphQLとそのライブラリの理念を理解するのはあまり現実的ではなく、実装が難しくなる要因になると思う
- 開発ツールの対応状況があまりよくない
- VSCodeを開発に使おうとしたときにGraphQLの対応状況があまりよくなく、入力補完やLintなどをいい感じにしてくれる存在がいない
- ライブラリの保守作業が必要
- ライブラリはアップデートされ、時として破壊的変更も起きるものなので、それらの対応が必要
- renovate地獄の一翼を担う存在になる
- curlからリクエストを投げづらい
- クエリ文が複雑になればなるほど投げづらくなる
- 下手したらこれだけでオーバーフェッチとかどうでもよくなるレベルだと思う
- 投稿日:
今回は何故単体テスト書くのかというのを個人的な見地から書いていく。TypeScriptとJestで記述するが、他の言語でも応用は可能だと思う。
単体テストとは何か?
関数やクラスのメソッドを単体としてみた場合の振る舞いを確かめるものである。以下はその一例だ。
hoge.ts
export const joinString = (string1: string, string2: string) => {
return `${string1}${string2}`;
};
hoge.spec.ts
import { joinString } from '.';
describe('joinString', () => {
it('引数として渡した文字列が正しく結合されること', () => {
const actual = joinString('123', 'abcd');
expect(actual).toBe('123abcd');
});
it('第一引数が空文字の時に第二引数のみが出力されること(空文字が結合されること)', () => {
const actual = joinString('', 'edfg');
expect(actual).toBe('edfg');
});
it('第二引数が空文字の時に第一引数のみが出力されること(空文字が結合されること)', () => {
const actual = joinString('zxcvb', '');
expect(actual).toBe('zxcvb');
});
});
何のために単体テストを実装するか
- 実装されているコードが実装者の意図通りに動いていることを証明するため
- いわゆるテストコードが仕様書になるってやつです
- 実装コードの改修時に元々の意図と異なる状況(不具合など)が発生した場合に、それを検知するため
- 初回実装時であってもコードを直したときに予期せぬ不具合を防ぐため
- 動作確認した後にちょろっと直すことはよくあると思うが、そういう時に動作を担保するのに使える
- 他にも実装時にコードを思案しながら書いているときに、動作確認をする手間を省くのにも使える
何故単体テストなのか?
結合テストやe2eテストではなく、何故単体テストをするか、これは実務面では結構出てくる疑問だと思うので、以下で説明する。
まず単体テストは一般的に結合テストやe2eテストに比べ、実行速度が速く、コードを修正したときに素早く回し、デグレードしていないかの確認に使うときに有益である。これは単体テストは実際にサーバーを起動したり、ヘッドレスブラウザでUI操作を行うなどしないためだ。他にも同じ処理を何度も見るのでテストのオーバーヘッドが膨らみ、実行時間が落ちるといったこともある。単体テストであれば簡単なロジックの実行しかしないし、結合部分はすべてモックにするため高速に実行できる利点がある。
次に結合テストやe2eテストは共通処理を一個直すと、それを使っている処理のテストをすべて直す必要があり、保守性がよくない。単体テストは基本的に直した箇所のテストコードを直すだけなので保守性が高い。
最後に単体テストでは基本的に関数単体を見るためテストコードが肥大化しづらく、見通しが良いので何をテストしているのかが結合テストやe2eテストと比べて分かりやすい。またスコープが関数単体であるため、カバレッジが取りやすく、いわゆるC0, C1, C2と言われる、命令網羅、分岐網羅、条件網羅の三点を網羅しやすい。結合やe2eでは共通処理の全機能を使うとは限らないため、この網羅がしづらい。
単体テスト向きの設計は何か?
これについてはSOLID原則を使った設計が一番無難だと考える。画面であれば更にMVCモデルを使うのがよい。
SOLIDにする利点
- S:単一責任の原則で関数の責務を切り分けることで、関数の責務を小さくすることができる。責務が小さい関数はテストケースが少なく、テストコードを書きやすく保守しやすい
- O:閉鎖解放の原則により、関数を呼ぶ関数自体を不変にでき、テストの保守性を向上することができる
- L:特にテストには貢献しない
- I:特にテストには貢献しない
- D:依存逆転の法則により、関数内で呼び出す関数をモックやスパイといったテストオブジェクトに置換できるため、テスト容易性が向上する
MVCにする利点
基本的に画面の実装は密結合になりやすい。密結合になっているとテストが書きづらいのでModelとViewとControllerに分けてテストをしやすくするのが目的だ。きちんと分けた場合、Viewは文字列が出ているか、表示項目が出ているかみたいなテストだけでよく、Modelはビジネスロジックのテストをするだけでいいし、Controllerは互いの橋渡しができているかを見ればいい。このため、テストコードをシンプルにできるのが利点だ。
欠点は画面の項目数が多い場合、インターフェースがそれに応じて肥大化するため、画面に仕様変更が入ったときに保守性が悪くなることだ。ここは品質とのトレードオフで許容するしかないと思う。
単体テストで処理の結合部をどう見るか
一つの関数で完結している関数はそれでいいとして、他の関数を呼んでいる関数をどのように単体テストするかという部分。よくあるパターンとしては他の関数ごとテストしてしまうことだが、これでは結合観点になってしまう。
この部分については呼び出す関数をモックすることで単体観点で確認することが出来る。呼び出す先の関数が単体テストで網羅されていれば、その関数の振る舞いは正しいはずなので、呼び出す側としては呼び出す関数をモックして、関数が正常終了したケースと、異常終了したケースでカバレッジが取れるようにテストすればよい。これは例えば次のようなテストを書くことが出来る。
index.ts
import { isEmpty } from './lib/string-util';
export const validate = (hoge: string) => {
if (isEmpty(hoge)) {
throw new Error('空です。');
} else {
// 何もしない
}
};
index.spec.ts
import { validate } from '.';
import * as empty from './lib/string-util';
jest.mock('./lib/string-util');
describe('validate', () => {
it('hogeが空文字の場合、例外がスローされること', () => {
jest.spyOn(empty, 'isEmpty').mockReturnValueOnce(true);
expect(() => {
validate('aaaa');
}).toThrow(new Error('空です。'));
});
it('hogeが空文字でない場合、例外がスローされないこと', () => {
jest.spyOn(empty, 'isEmpty').mockReturnValueOnce(false);
expect(() => {
validate('aaaa');
}).not.toThrow(new Error('空です。'));
});
});
このテストではisEmpty()
の実際の振る舞いを一切見ていないため、validate()
の単体テストということが出来る。但しisEmpty()
のテストが漏れていると機能しないため、これをきちんと機能させる場合、呼び出し先の関数の単体テストも必須だ。
単体テストに出来ない事と、単体テストの意義
単体テストは単体を見るものなので、当たり前だが単体しか見ることが出来ない。結合観点やe2e観点はなく、単体テストが通っているからデグレやバグがないという通りはない。
基本的に単体テストは動作確認が人間がやり、カバレッジが網羅できており、テストしている内容も正しければ以後のテストを工数を削るためのものである。過去に行った動作確認でテストが通っていて、今も通っているのだから恐らく動作しているだろうということを見るためのもので、単体テストが通っているからデグレがないとか、そんなことの保証はない。
では単体テストを何のためにするかだが、複数の確認工程鵜を作ることでバグを作りこむことを防ぐ役割が一つだ。例えば単体テスト+結合テスト+e2eテスト+人による動作確認やテストと言ったように多層の防御があれば、不具合が起きる可能性を減らすことが出来る。もう一つはコードの仕様となることだ。実装を見て意図が読み取れないコードであってもテストコードがあればわかることもあるだろう。つまるところ実装品質を保つための防御機構の一つとみなすことが出来る。
単体テストを書くときにやったほうがいいこと
テストコードの動作確認をする
テストコードを書いても、そのテストコードが有効に動いているかどうかの観点が抜けていると有意なものとならないので、テストコードの動作確認は必要だ。偶にテストコードは動いているが、偶々テストがパスしてそれっぽく動いているだけみたいなこともある。TDDでやってもこれは起きえる。
期待値や結果値をいじって落ちるかどうかや、パラメーターなどの条件をいじって落ちるかは見たほうが良い。何をしてもパスする場合、そのテストコードは無意味である。
境界値テストを書く
文字列の入力パターンを見るテストや組み合わせテストではカバレッジを網羅していてもテストケースが漏れていることがある。例えば次のテストコードはカバレッジ100%だがtrueになる場合のテストが不足している。もしも関数の中身が必ずfalse
を返す実装になっている場合に、これを確かめることが出来ない。実装を知っているからこれでいいとみなすのではなく、基本的には実装を知らない体で書いた方が良い。とはいえ、限度はあるので現実的に起きうる条件でテストするのが無難だろう。この辺りはSIerで堅めの開発をしていると身に付きやすいのだが、個人の感性に左右される部分であり難しいところでもあると思う。
index.ts
export const isEmpty = (str: string) => {
return str === '';
};
index.spec.ts
import { isEmpty } from '.';
describe('isEmpty', () => {
it('空文字でない時にfalseが返る事', () => {
const actual = isEmpty('aa');
expect(actual).toBe(false);
});
});
変数を型で縛る
例えば以下の実装であれば引数は文字列型なので文字列以外が来ることは考えなくていい。
export const isEmpty = (str: string) => {
return str === '';
};
しかし以下の実装では何が来るかわからない。テストとしてはありとあらゆる値が来ることを想定して書かないとリファクタリングに耐えられないだろう。そんなテストは書きたくない。そもそも実装からどれほどテストを書けばいいのか予測し辛いのもある。
export const isEmpty = (str: any) => {
return str === '';
};
もしどうしても何が来るかわからない場合はunknown
にしておくとよいだろう。何故なら以下のコードはエラーになるからだ。
export const isEmpty = (str: unknown) => {
return str === '';
};
また全体的にきちんと型で縛っておくと、改修などで型が変わった時に実装コードやテストで型不整合が発生する機会が増え、型チェックでエラーが発生するため、未然に不具合を防ぎやすくなる筈だ。