TypeScript: FactoryGirl 💃
Factory Girl in JavaScript
最近、TypeScript に移行したプロジェクトで Factory Girl を使っている。おそらく、元になったのは Ruby の Factory Girl であろうが、いくつか不満があるのでメモ
モデルに save() と destroy() を要求する
Adapter をうまいことやれば回避できるのかもしれないが、生成対象となるクラスに save()
と destroy()
を要求するのが困る(メソッド名が Rails の ActiveRecord っぽいのも個人的にはちょっと…)
このプロジェクトは Node.js によるバックエンドサービスではなく、API から返却されたレスポンスをオブジェクト化しているだけなので、毎回、以下のように回避している。
import _MyResponse from 'api/entity/MyResponse';
class MyResponse extends _MyResponse {
save() {}
destroy() {}
}
- これは独自の ObjectAdapter を書けば解決しそう
TypeScript と相性が悪い
そもそも動的型言語が出自なので仕方ないと思うが、今一番困ってるのは DefinitelyTyped にあげられている定義がまだまだ不完全なこと。
なお、これまで以下のように書いていた import が
import { factory } from 'factory-girl';
以下のように書き換えなければならず、
import factory from 'factory-girl';
DefinitelyTyped のテストでは、以下のように書かれている
import * as factory from 'factory-girl';
これは以下の問題が絡んでいると思われる(プロジェクトで使っているのは Babel)
何故か changejs が依存に入っている
多分、歴史的敬意なのだろうけど、何故か chancejs への依存があって、以下のように使える。
// use the chance(http://chancejs.com/) library to generate real-life like data
factory.chance('sentence'),
しかし、これは型安全にするのも難しいし、chancejs を直接使った方が良さそう。
chance.sentence();
他の候補は?
- factory girl vs factory js vs fixture factory vs rosie | npm trends
-
rosie というライブラリがダウンロード数が多い
- builder パターンで書けるのが特徴?
- Type definition はある
- でも、わざわざ移行するほどでもなさそう...。
FactoryGirl の factory.chance(...) を Chancejs に置き換える
FactoryGirl は便利メソッドとして factory.chance(...)
が用意されているが、型安全ではないので、Chance を直接使うようにしたい。
まずは、chancejs と型定義を依存に追加する。
$ yarn add -D chance @types/chance
chancejs は以下のようにインスタンス化して使う必要がある。
import Chance from 'chance';
const chance = new Chance();
chance.natural({ min: 0, max: 100 });
以下のように使う方法もあるみたいだが、どちらにせよ ES6 module では使いづらい。
var chance = require('chance').Chance();
おそらく、このために FactoryGirl でも便利メソッドを用意して、グローバルな Chance インスタンスを使いまわせるようにしているのだと思う。これに倣って、プロジェクトに以下のモジュールを追加する。
import Chance from 'chance';
// Re-use a global instance of _Chance_ in entire tests.
const chance = new Chance();
export { chance };
利用するファイル側では、以下のように import して、
import { factory } from 'factory-girl';
import { chance } from '../testUtil/GlobalChance';
既存の factory.chance(...)
を正規表現で雑に置換する。引用符などのフォーマット Prettier で統一されているのであまり複雑な正規表現を書く必要がない。
\bfactory\.chance\('([^']+)'(?:,(.+?))?\)
chance.$1($2)
FactoryGirl を型安全にする
TypeDefinition にある型定義を使って、FactoryGirl の利用箇所を型づけしていく。ただし、2020年12月現在公開されているバージョンでは型定義は不完全なので、自分で補って行く必要がある(最終的には TypeDefinition に PR を投げる)。
- 参照している TypeDefinition
- 利用している FactoryGirl のバージョンは 5.0.4
Declaration Merging を用いて、@types/factory-girl
を活かす手段もあるが、どうせ後で PR を送るつもりなので、.d.ts をローカルにコピーして編集する(ただしタブ幅などのフォーマットが違うので、そのままは使えない...)。
baseUrl と paths でローカルの定義を読み込ませる
まずは TypeDefinitions から @types/factory-girl
をダウンロードして、ローカルに配置する
$ tree @types
@types
└── factory-girl
├── LICENSE
├── README.md
├── index.d.ts
└── package.json
tsconfig.json
を編集
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"factory-girl": ["../@types/factory-girl/index.d.ts"]
}
}
}
これで import ... from 'factory-girl'
の時にローカルの定義が読み込まれるようになる。なお、typeRoots は古い設定で、この用途には使えない。
変更履歴
factory
の Named export
import { factory } from 'factory-girl';
以下を追加した。
interface Static {
factory: Static;
...
seq
sequence
の引数
factory.seq(id, callback)
, factory.sequence(id, callback)
の引数はどちらも省略可能
コードを読むと
-
seq(id, callback)
なら callback を呼び出して結果を返す -
seq(callback)
なら id は自動生成 -
seq(id)
なら callback なし
つまり、以下のようにオーバーロードする
seq(): Generator<number>;
seq(name: string): Generator<number>;
seq<T>(name: string, fn: (sequence: number) => T): Generator<T>;
seq<T>(fn: (sequence: number) => T): Generator<T>;
DefinitelyTyped の .d.ts
に書かれている
export = React;
export as namespace React;
の意味。
Explain "export =" and "export as namespace" syntax in TypeScript - Stack Overflow
DefinitelyTyped のテストを書くときに、import all と named import をテストしたくて、
import * as factory from "factory-girl";
import { factory as namedImportedFactory } from "factory-girl";
上記のように書くと、dslint に怒られる。
ERROR: 2:1 no-duplicate-imports Multiple imports from 'factory-girl' can be combined into one.
しかし、import all と named import を一緒に書くことはできない(テスト以外では意味がない)。
他がどうやっているのか見てみたところ、tslint.json で警告を無効化していた。
{
"extends": "dtslint/dt.json",
"rules": {
...
"no-duplicate-imports": false
}
}
型の期待値テストはこんな風に書く
// Testing sequence with no params
// $ExpectType Generator<number>
factory.seq();
// $ExpectType Generator<number>
factory.sequence();
// Testing sequence with id
// $ExpectType Generator<number>
factory.seq('User.score');
// $ExpectType Generator<number>
factory.sequence('User.score');
// Testing sequence with callback
// $ExpectType Generator<string>
factory.seq(value => value.toString());
// $ExpectType Generator<string>
factory.sequence(value => value.toString());
- 理由は不明だが、
factory.seq(value =>
ExpectType` が CI でだけ落ちる{value}`);` とすると `
DefinitelyTyped に PR 投げた
Factory#build(name, attrs?)
Factory.build(...)
の型定義は以下のようになっている。
build<T>(name: string, attrs?: Attributes<Partial<T>>): Promise<T>;
これだと、attrs
に指定できるのは T
のプロパティを含むオブジェクトになるのだが、実際にはこれは正しくない。というのも、Factory.build()
では、
- まず、
Factory.attrs()
でモデルのコンストラクタに渡すオブジェクトを生成 - このオブジェクトを
adapter.build()
に渡して、モデルを生成 [code]
するので、attrs の型は T
ではなく、全く別のでも良い
なので、ここは、
build<T, U = T>(name: string, attrs?: Attributes<Partial<U>>): Promise<T>;
こうすればいいはず? 多くの場合は T でもいいはずなので、そこはそのまま
Factory#define() で buildOptions を受け取る関数を指定できない
factory.define('account', Account, buildOptions => {
...
});
初期化関数は buildOptions
を受け取れるはずだが、今の定義ではこれを受け取れなくなっている。
type Generator<T> = () => T;
type Definition<T> = T | Generator<T>;
type Attributes<T> = Definition<{
[P in keyof T]: Definition<T[P]>;
}>;
/**
* Define a new factory with a set of options
*/
define<T>(name: string, model: any, attrs: Attributes<T>, options?: Options<T>): void;
Attributes
の型定義
type Attributes<T> = Definition<{
[P in keyof T]: Definition<T[P]>;
}>;
Mapped Type と keyof
については
これはつまり、型 T
と同じプロパティを持つ Object 型を生成している。
{
[P in keyof T]: Definition<T[P]>;
}
となると、問題は Generator<T>
の定義で、ここに buildOptions
を追加すれば良さそうだが、
type Generator<T> = () => T;
これは他のいろいろなところで使われているので、ここに追加したくはない。いっそのこと、define に渡す attrs は新しい型を定義した方がいい気がする。
Options を渡せる場所が違う
Options 型があるが、
interface Options<T> {
afterBuild?: Hook<T>;
afterCreate?: Hook<T>;
}
これを渡せるのは、define()
だけのはずだが、なぜか createMany に渡せることになっている。
createMany<T>(name: string, num: number, attrs?: Attributes<Partial<T>>, buildOptions?: Options<T>): Promise<T[]>;
createMany<T>(name: string, attrs?: ReadonlyArray<Attributes<Partial<T>>>, buildOptions?: Options<T>): Promise<T[]>;
逆に buildOptions が渡せなくなっているので、これを修正する。
Factory#define(...) に正しい型を与える
Factory#define(...)
の型定義は 2021.01 時点で以下のようになっている。[1]
define<T>(name: string, model: any, attrs: Attributes<T>, options?: Options<T>): void;
この定義だといくつかうまくいかないことがあるので修正したい。具体的には、
-
model
がany
- 初期化のための引数として
Attributes<T>
を受け取るようになっているが、これは(options) => {...}
を受け取れない。 - 初期化で返される値と生成される値が同じ
T
になる前提だが、これは正しくない。
仕様を確認する
正しい型を与えるためには仕様が必要だ。主に、ドキュメントや API リファレンス、そして実装を参考にする。
返り値
実装を読むと、define は Factory インスタンスを返すようになっている。
const factory = this.factories[name] = new Factory(Model, initializer, options);
return factory;
ただ、これはドキュメントにも明記されていないし、index.js でも公開されていないので、ここは void
のママが良さそうだ。
#1: name
name
は文字列で良さそう。
#2: model
model
の型は T
のクラスで良さそうだが...。
model
は最終的には Adapter に渡されて処理されるので、ここの実装次第となる。例えば SequelizeAdapter だと、
build(Model, props) {
return Model.build(props);
}
このように Model.build
が呼び出されていたり、ObjectAdapter では、
build(Model, props) {
const model = new Model;
this.set(props, model, Model);
return model;
}
set(props, model, Model) {
return Object.assign(model, props);
}
Model.new
したあとに Object.assign
したりとなんでもありだ。ここは any
にせざるを得ない...。
#3: initializer
initializer
はオブジェクトの初期化オブジェクトを決定するために使われる。以下のいずれか:
- 初期化オブジェクト
- 初期化オブジェクトを返す関数
- 初期化オブジェクトの Promise を返す関数
また、関数の場合は、第一引数で buildOptions
を受け取れる。ただ、最終的な初期化オブジェクトは結局、Adapter 次第なので、ここも決められない...。
ただ、このまま進めていくと define() や build() で受け取るオブジェクトがすべて any になってしまう。Adapter に型を与えてやるのが良さそう?
しかし、FactoryGirl のインスタンスには複数の Adapter を紐づけられるので、これも難しいようだ。
setAdapter(adapter, factoryNames = null) {...}
全部を変えるのではなく、現状から差分を少なくする方向で、
-
Attributes<T>
を define 系と build 系両方で使うのではなく分ける-
define
- initializer は上記 -
build
- extraAttrs はオブジェクト
-
- また、attrs には生成するオブジェクト以外のプロパティを指定できるように頑張る
最終的に以下のような感じになった。PR するときコメントするためにメモ
- type Attributes<T> = Definition<{
+ type Attributes<T> = {
[P in keyof T]: Definition<T[P]>;
- }>;
+ };
+
+ type BuildOptions = Record<string, any>;
+
+ type Initializer<T, U = BuildOptions> = Attributes<T> | ((buildOptions?: U) => Attributes<T>);
ここで Definition<T>
には以下の定義が与えられている。
type Generator<T> = () => T;
type Definition<T> = T | Generator<T>;
要するに T
または T
を生成する関数なのだが、Attributes
を使う #attrs
, #build
などでは関数を指定できないので、これを外した。
また、新しく Initializer
を導入した。これは #define
で使われる。こちらは関数が使え、また、その関数には buildOptions
引数を渡せるので、このようにしている。buildOptions
にはあらゆる Plain object を渡せる。
- attrs<T>(name: string, attrs?: Attributes<Partial<T>>): Promise<T>;
+ attrs<T, U = T>(name: string, attrs?: Attributes<Partial<U>>, buildOptions?: BuildOptions): Promise<T>;
buildOptions
が不足しているところにオプショナル引数として追加している。また、新しい型変数 U
を導入しているが、これは Adapter によっては attrs
から得られるオブジェクトと T
は異なる可能性があるためである。
- attrsMany<T>(name: string, num: number, attrs?: ReadonlyArray<Attributes<Partial<T>>>): Promise<T[]>;
+ attrsMany<T>(
+ name: string,
+ num: number,
+ attrs?: ReadonlyArray<Attributes<Partial<T>>>,
+ buildOptions?: BuildOptions | ReadonlyArray<BuildOptions>,
+ ): Promise<T[]>;
#...Many
系では、buildOptions
の配列も受け取れるので、このようにしている。
- createMany<T>(name: string, num: number, attrs?: Attributes<Partial<T>>, buildOptions?: Options<T>): Promise<T[]>;
- createMany<T>(name: string, attrs?: ReadonlyArray<Attributes<Partial<T>>>, buildOptions?: Options<T>): Promise<T[]>;
+ createMany<T, U = T>(name: string, num: number, attrs?: Attributes<Partial<U>>, buildOptions?: BuildOptions | ReadonlyArray<BuildOptions>): Promise<T[]>;
+ createMany<T, U = T>(name: string, attrs?: ReadonlyArray<Attributes<Partial<U>>>, buildOptions?: BuildOptions | ReadonlyArray<BuildOptions>): Promise<T[]>;
なぜか元の定義では、#createMany
の options
に #define
と同じオプションが渡せるようになっていたが、実装を見ても間違いなので、ここは BuildOptions
に変更した。
- define<T>(name: string, model: any, attrs: Attributes<T>, options?: Options<T>): void;
+ define<T, U = T>(name: string, model: any, initializer: Initializer<U>, options?: Options<T>): void;
最後に、#define
を Initializer
を使うようにした。
上のだと dtslint で怒られてしまった。
言われてみれば、それもそうで、これでは any
で指定しているのと変わらない。
うーん、これは、attrs が T とは違う型になるのを一旦諦めた方が良さそう。
うーん、これは、attrs が T とは違う型になるのを一旦諦めた方が良さそう。
利用する側のコードを修正することにした。
こんな感じで定義した。
define<T>(name: string, model: new(...args) => T, initializer: Initializer<Partial<T>>, options?: Options<T>): void;
model
を T
を new できるクラスとしているので、型パラメーターを与えなくても型推論してくれる。
factory.define('my_object', MyObject, (buildOption: BuildOptions) => ({
name: "..."
}));
ここで initializer
は (buildOptions: BuildOptions) => Partial<T>
として推論されている。
しかし、まだ問題があって、model
にはクラス以外を指定することも想定されている。こことか。
かといって、ここで model: any
に戻してしまうと initializer
がなんでもありになってしまう。
-
model
がT
を new できるクラスのときは前者の定義を使い、 - それ以外では
any
にしたい。
define<T>(name: string, model: typeof model extends (new(...args) => T) ? never : any, initializer: Initializer<Partial<T>>, options?: Options<T>): void;
Confitional Type を使ってみたが、T
は initilaizer で決まってしまうので無意味...というか、この場合、model が any になってる?
欲しいのは Nagate Type かもしれない。
- Negated types by weswigham · Pull Request #29317 · microsoft/TypeScript
- Typescript negative type check - Stack Overflow
Exlude()
を使って、動いているようには見える...
define<T>(name: string, model: new(...args) => T, initializer: Initializer<Partial<T>>, options?: Options<T>): void;
define(name: string, model: ObjectConstructor, initializer: Initializer<Record<string, unknown>>, options?: Options<any>): void;
define<T, M>(name: string, model: Exclude<M, new(...args) => any>, initializer: Initializer<Partial<T>>, options?: Options<T>): void;
Object
で生成したいケースもあるので、2番目を定義している。
しかし、上の定義ではこれまで動いていたコードで大量の型エラーが出てしまう。特に、今取り組んでいるプロジェクトでは、生成結果のオブジェクトと初期化オブジェクトの型が違う(プロパティ名が CamelCase と snake_case...)ので、すべてを書き換える必要があり、かなりの苦痛である。
また、Object
を特別扱いしないといけないのもいけてない。
現状の定義では、
define<T>(name: string, model: any, attrs: Attributes<T>, options?: Options<T>): void;
initializer は実質的に any
である。また、define
の引数を分析すると、理想的には、
define<T, U, M>(name: string, model: M, attrs: Attributes<U>, options?: Options<T>): void;
このようになるはず(name
だけは string で確定)。ここで実装としては Adapter があり、T = Adapter(U, M)
である。
...まあ、今のままでいい気がしてきた。T
を指定すれば initializer の型検査も効くし。initializer の型だけ Initializer<T>
に変更