- 投稿日:
prettier-vscodeをPrettier v3に対応させる試みは既にあるのだが、PRがRejectされている。レビューを見た感じtypoが原因っぽいのでtypoを直してみたのだがテストが落ちる。
まずテストが通らない原因だが、これはアサーション結果とテストコード、落ちた時の結果を三点比較したときに落ちた時の結果がプラグインを反映せずprettier -w
されたものであることに気が付いた。次にこの解決法だが、原因が複数あるため、その切り分けが必要だと考えている。これはテスト走行時に指定されたプラグインが正しく読み込まれていればテストを通すことができるはずだが、package.json
の指定通りインストールされているのかや、prettier側が正常に読み込めているのかなどが解らないと、真の原因が突き止められないためだ。
見た感じ割と面倒な仕組みなテストを実行してそうだったので一旦私は諦めることにした。これはprettier v3を個別にインストールすることで基本的に解決できるためだ。とはいえ、インストールできない条件だとその回避に苦労するのでネイティブに対応してほしい思いはあるが、検証するにも本家リポジトリにPRを向けないとテスト走行すらままならず、ローカルでの検証が極めて厳しいので、正直やる気が起きない。ぶっちゃけDockerで個別テストケースごとにビルドしてやるのが一番仕組みが単純で安全に思うが、敷くほど遅くなりそうだし、イメージのメンテも大変そうだし、アホほどイメージができるはずなので、容量も食いそうでなかなか微妙そうである。
そもそも論としてmainブランチを引っ張ってきてビルドすると、この時点で型エラーを吐くので、ここからどうにかしていく必要があり、割としんどい作業になりそうだ。
- 投稿日:
この記事では下図のシステム構成を前提に話を進めるものとする。この時、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からリクエストを投げづらい
- クエリ文が複雑になればなるほど投げづらくなる
- 下手したらこれだけでオーバーフェッチとかどうでもよくなるレベルだと思う
- 投稿日:
関数の仮引数にthis
が入っているタイプの奴。FastifyのsetErrorHandlerに渡す関数のテストをする時に詰まったので書いておく。滅多に遭遇しないケースだと思う。
確認環境
Env | Ver |
---|---|
jest | 29.7.0 |
typescript | 5.3.3 |
実装コード
export class HogeServer {
constructor() {
console.log();
}
}
interface HogeServerInstance {
setErrorHandler(
handler: (
this: HogeServer,
error: unknown,
callback: (code: number) => void
) => void
): HogeServer;
}
// 型定義にthisが入っていてテスト時に上手くいかない
// const handle: (this: HogeServer, error: unknown, callback: (code: number) => void) => void
type HandlerFunc = Parameters<HogeServerInstance['setErrorHandler']>[0];
export const handle: HandlerFunc = (err, cb) => {
if (err instanceof Error) {
cb(500);
} else {
cb(200);
}
};
export const fuga = (s: HogeServerInstance) => {
s.setErrorHandler(handle);
};
テストコード
import { HogeServer, handle } from '.';
describe('handle', () => {
it('errがErrorインスタンスの時', () => {
const thisMock = {} as unknown as HogeServer;
const err = new Error('error-hoge');
const cbFn = jest.fn();
// .call()を使うことでthis付きで呼び出せる
handle.call(thisMock, err, cbFn);
expect(cbFn).toHaveBeenCalledWith(500);
});
it('errがErrorインスタンスではない時', () => {
const thisMock = {} as unknown as HogeServer;
const err = 1234;
const cbFn = jest.fn();
handle.call(thisMock, err, cbFn);
expect(cbFn).toHaveBeenCalledWith(200);
});
});
- 投稿日:
今回は何故単体テスト書くのかというのを個人的な見地から書いていく。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 === '';
};
また全体的にきちんと型で縛っておくと、改修などで型が変わった時に実装コードやテストで型不整合が発生する機会が増え、型チェックでエラーが発生するため、未然に不具合を防ぎやすくなる筈だ。
- 投稿日:
親クラスをモックして子クラスの単体テストをしたいときに
確認環境
Env | Ver |
---|---|
@swc/cli | 0.1.65 |
@swc/core | 1.3.105 |
@swc/jest | 0.2.31 |
@types/jest | 29.5.11 |
jest | 29.7.0 |
typescript | 5.3.3 |
サンプルコード
コードのフルセットは以下
https://github.com/Lycolia/typescript-code-examples/tree/main/mocking-base-class-with-extension-class-swc-ts
ディレクトリ構成
src
├─BaseClass
│ └─index.ts
└─ChildrenClass
├─index.ts
└─index.spec.ts
src/BaseClass/index.ts
親クラス。この例は抽象クラスだが別に具象クラスでも何でもいいと思う
export abstract class BaseClass {
constructor(
private baseValue: string,
private ctx: { [key: string]: unknown }
) {}
public hoge<ResultT>(payload: { [key: string]: unknown }) {
// テスト走行時には全て無効な値を流し込むため
// それらの影響で落ちないことの確認のために、オブジェクトの子を意図的に書いている
console.log(this.baseValue, this.ctx.hoge, payload.piyo);
// サンプルコードなので戻り値はスタブ
return {} as ResultT;
}
}
src/ChildrenClass/index.ts
子クラス
import { BaseClass } from '../BaseClass';
export class ChildrenClass extends BaseClass {
constructor(baseValue: string, ctx: { [key: string]: unknown }) {
super(baseValue, ctx);
}
public piyo<ResultT>(payload: { [key: string]: unknown }) {
try {
// このsuper.hoge()をモックして、
// catchの中に流れるかどうかを確認するのが目的
return super.hoge<ResultT>(payload);
} catch (err) {
// 実際はロガーが動くような部分だが、サンプルコードのためconsole.logで代用する
console.log(err);
throw err;
}
}
}
src/ChildrenClass/index.spec.ts
子クラスのテスト
import { ChildrenClass } from '.';
import { BaseClass } from '../BaseClass';
describe('fetch', () => {
it('親のfetchが呼ばれ、親の戻り値が返ってくること', () => {
// 子が正しくreturnしてくるかどうかを確認するための値
const expected = { code: 200 };
// Class.prototypeとやるとモック出来る
const spiedSuperFetch = jest
.spyOn(BaseClass.prototype, 'hoge')
.mockReturnValue(expected);
// 親クラスの処理はモックするので適当な値を入れておく
// この内容が実行されていればテストが落ちるのでテストコードが間違っていることの検証に使える
const inst = new ChildrenClass('aaaa', {} as { [key: string]: unknown });
const actual = inst.piyo({ foo: 123 });
// 親クラスのメソッドが正しい引数で呼ばれたことの確認
expect(spiedSuperFetch).toHaveBeenCalledWith({ foo: 123 });
// 子クラスのメソッドの戻り値が正しいことの確認
expect(actual).toStrictEqual(expected);
});
it('親のfetchで例外が出たときに、ログ出力とリスローがされること', () => {
const expected = Error('ERR');
// 親クラスのメソッドが例外をスローするケースを作る
jest.spyOn(BaseClass.prototype, 'hoge').mockImplementation(() => {
throw expected;
});
// catch句の中でロガーが動いているかどうかの検査用
const spiedConsoleLog = jest.spyOn(console, 'log');
const inst = new ChildrenClass('aaaa', {} as { [key: string]: unknown });
// 例外がリスローされていることを確認
expect(() => {
inst.piyo({ foo: 123 });
}).toThrow(expected);
// ロガーが動いていることを確認
expect(spiedConsoleLog).toHaveBeenCalled();
});
});