一般的にTypeScriptでDIをしようとするとクラス+デコレータを避けて通れません
しかし個人的には関数型っぽくコーディングするのが好きなのでクラスもデコレータも使わず関数だけでDIする方法を考えました
たった15行なので解説の前にコードを紹介します
type Deps<T extends Record<string, any>> = {
[P in keyof T]: T[P] extends { _velona: boolean }
? (...args: Parameters<T[P]>) => ReturnType<T[P]>
: T[P]
}
export const depend = <T extends Record<string, any>, U extends any[], V>(
dependencies: T,
cb: (deps: Deps<T>, ...args: U) => V
) => {
const fn = (...args: U) => cb(dependencies, ...args)
fn._velona = true
fn.inject = (deps: Deps<T>) => (...args: U) => cb(deps, ...args)
return fn
}
当然フロントでもバックでも使えます
「Velona(ヴェローナ)」と名付けてnpmにも公開済みです
https://github.com/frouriojs/velona
スターを押してもらえると嬉しいです!
基本的な使い方
上記のコードをコピペしてファイルを作るかnpmでインストールします
$ npm install velona
例えば、足し算をする関数があるとします
export const add = (a: number, b: number) => a + b
このadd関数に依存する関数を定義します
import { add } from './add'
export const parentFn = (a: number, b: number, c: number) => add(a, b) * c
console.log(parentFn(2, 3, 4)) // 20
parentFnはこのままではテスト時に依存性(add関数)を別の関数に差し替えることができません
そこでVelona(冒頭の15行)を使います
import { depend } from 'velona'
import { add } from './add'
export const parentFn = depend(
{ add }, // 連想配列でデフォルトの依存性を注入する
({ add }, a: number, b: number, c: number) => add(a, b) * c // ここのaddは「注入された」ローカルスコープの関数
)
console.log(parentFn(2, 3, 4)) // 20
parentFnの呼び出し方は元と変わってません
dependを使って定義するときに第一引数に依存性を連想配列で渡し、第二引数の関数の最初の引数で受け取れます
これでDIでのテストが可能になりました
以下のコードはJestですが、どのフレームワークでも使い方は同じです
import { parentFn } from './parentFn'
test('parentFnにDI出来るかどうかチェック', () => {
const injectedParentFn = parentFn.inject({ add: (a, b) => a * b }) // 足し算を掛け算に変えてみる
expect(parentFn(2, 3, 4)).toBe((2 + 3) * 4) // 20でPass、parentFnの結果は足し算のまま
expect(injectedParentFn(2, 3, 4)).toBe(2 * 3 * 4) // 24でPass、parentFnの結果が掛け算に変わった
})
たったこれだけのことで、JavaScriptでよく使うコールバック関数にDIが出来るようになります
コールバック関数にDIする
DOMのクリックイベントやExpressのミドルウェアなどコールバック関数が必要になる場面はたくさんあります
export const doSomething = () => {
console.log('Hello workd!') // <- typo
}
setTimeout(doSomething, 1000)
例えば上の例だと、期待する文字列Hello world!
をdoSomethingが出力するかどうかをテストするにはconsole.logを以下のようにspyOnする必要があります
import { doSomething } from './doSomething'
test('Hello world!が出力される', () => {
const spy = jest.spyOn(console, 'log')
doSomething()
expect(console.log).toHaveBeenCalledWith('Hello world!') // Error!
spy.mockRestore()
})
DIが出来るようにVelonaを使ってみます
import { depend } from 'velona'
export const doSomething = depend(
{ print: (message: string) => console.log(message) },
({ print }) => print('Hello workd!') // <- typo
)
setTimeout(doSomething, 1000)
テストコードはちょっと長くなりますね
import { doSomething } from './doSomething'
test('Hello world!が出力される', () => {
let called
const injectedDoSomething = doSomething.inject({
print: message => {
called = message
}
})
injectedDoSomething()
expect(called).toBe('Hello world!') // Error!
})
結合テストでネストした関数にDIする
parentFnから3階層下のadd関数を掛け算関数に差し替えてみます
export const add = (a: number, b: number) => a + b
import { depend } from 'velona'
import { add } from './add'
export const grandchild = depend(
{ add },
({ add }, a: number, b: number) => add(a, b)
)
import { depend } from 'velona'
import { grandchild } from './grandchild'
export const child = depend(
{ grandchild },
({ grandchild }, a: number, b: number, c: number) => grandchild(a, b) * c
)
import { depend } from 'velona'
import { child } from './child'
export const parentFn = depend(
{
child,
print: (data: number) => alert(data) // ブラウザではalertが呼ばれるが、テスト環境のNode.jsにはないのでこれもDIが必要
},
({ child, print }, a: number, b: number, c: number) => print(child(a, b, c))
)
ここまででparentFnの定義が完了です
3階層下にadd関数がいる状態
ブラウザ上では以下の通り普通の関数として使えます
import { parentFn } from './parentFn'
parentFn(2, 3, 4) // alert(20)
テスト時に最下層のaddを差し替えるには愚直にinjectの入れ子を行います
import { parentFn } from './parentFn'
import { child } from './child'
import { grandchild } from './grandchild'
const injectedFn = parentFn.inject({
child: child.inject({
grandchild: grandchild.inject({
add: (a, b) => a * b // 最下層のaddを掛け算に変更
})
}),
print: data => data // alertはJest/Node.js環境にはないので差し替える
})
expect(injectedFn(2, 3, 4)).toBe(2 * 3 * 4) // pass
結構な手間に見えますが、エディタがサポートしてくれるので見た目ほど大きな負担にはならないです
もう一度15行のコードをおさらい
上記の機能全てがたったの15行で実現できます
type Deps<T extends Record<string, any>> = {
[P in keyof T]: T[P] extends { _velona: boolean }
? (...args: Parameters<T[P]>) => ReturnType<T[P]>
: T[P]
}
export const depend = <T extends Record<string, any>, U extends any[], V>(
dependencies: T,
cb: (deps: Deps<T>, ...args: U) => V
) => {
const fn = (...args: U) => cb(dependencies, ...args)
fn._velona = true
fn.inject = (deps: Deps<T>) => (...args: U) => cb(deps, ...args)
return fn
}
コピペしてもいいですが、Velonaをインストールすると共通概念として会話が成り立つのでおススメです
https://github.com/frouriojs/velona
面白いアイディアだなあと思ったらスターを押してもらえると嬉しいです!