Closed19

TypeScript: FactoryGirl 💃

Takanori IshikawaTakanori Ishikawa

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() {}
}

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();

他の候補は?

Takanori IshikawaTakanori Ishikawa

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 インスタンスを使いまわせるようにしているのだと思う。これに倣って、プロジェクトに以下のモジュールを追加する。

test/testUtil/GlobalChance.ts
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)
Takanori IshikawaTakanori Ishikawa

FactoryGirl を型安全にする

TypeDefinition にある型定義を使って、FactoryGirl の利用箇所を型づけしていく。ただし、2020年12月現在公開されているバージョンでは型定義は不完全なので、自分で補って行く必要がある(最終的には TypeDefinition に PR を投げる)。

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 は古い設定で、この用途には使えない

Takanori IshikawaTakanori Ishikawa

変更履歴

factory の Named export

foo.ts
import { factory } from 'factory-girl';

以下を追加した。

index.d.ts
interface Static {
  factory: Static;
  ...

seq sequence の引数

factory.seq(id, callback), factory.sequence(id, callback) の引数はどちらも省略可能

コードを読むと

  1. seq(id, callback) なら callback を呼び出して結果を返す
  2. seq(callback) なら id は自動生成
  3. seq(id) なら callback なし

つまり、以下のようにオーバーロードする

index.d.ts
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>;
Takanori IshikawaTakanori Ishikawa

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
    }
}
Takanori IshikawaTakanori Ishikawa

型の期待値テストはこんな風に書く

// 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 => {value}`);` とすると `ExpectType` が CI でだけ落ちる
Takanori IshikawaTakanori Ishikawa

Factory#build(name, attrs?)

Factory.build(...) の型定義は以下のようになっている

build<T>(name: string, attrs?: Attributes<Partial<T>>): Promise<T>;

これだと、attrs に指定できるのは T のプロパティを含むオブジェクトになるのだが、実際にはこれは正しくない。というのも、Factory.build() では、

  1. まず、Factory.attrs() でモデルのコンストラクタに渡すオブジェクトを生成
  2. このオブジェクトを adapter.build() に渡して、モデルを生成 [code]

するので、attrs の型は T ではなく、全く別のでも良い

なので、ここは、

build<T, U = T>(name: string, attrs?: Attributes<Partial<U>>): Promise<T>;

こうすればいいはず? 多くの場合は T でもいいはずなので、そこはそのまま

Takanori IshikawaTakanori Ishikawa

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 は新しい型を定義した方がいい気がする。

Takanori IshikawaTakanori Ishikawa

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 が渡せなくなっているので、これを修正する。

Takanori IshikawaTakanori Ishikawa

Factory#define(...) に正しい型を与える

Factory#define(...) の型定義は 2021.01 時点で以下のようになっている。[1]

define<T>(name: string, model: any, attrs: Attributes<T>, options?: Options<T>): void;

この定義だといくつかうまくいかないことがあるので修正したい。具体的には、

  • modelany
  • 初期化のための引数として 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 には生成するオブジェクト以外のプロパティを指定できるように頑張る

脚注
  1. https://github.com/DefinitelyTyped/DefinitelyTyped/blob/d6194f0cc57d1bf1c86d00cf9a1f4c6207f2bf12/types/factory-girl/index.d.ts#L79 ↩︎

Takanori IshikawaTakanori Ishikawa

最終的に以下のような感じになった。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[]>;

なぜか元の定義では、#createManyoptions#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;

最後に、#defineInitializer を使うようにした。

Takanori IshikawaTakanori Ishikawa

うーん、これは、attrs が T とは違う型になるのを一旦諦めた方が良さそう。

利用する側のコードを修正することにした。

Takanori IshikawaTakanori Ishikawa

こんな感じで定義した。

define<T>(name: string, model: new(...args) => T, initializer: Initializer<Partial<T>>, options?: Options<T>): void;

modelT を new できるクラスとしているので、型パラメーターを与えなくても型推論してくれる。

factory.define('my_object', MyObject, (buildOption: BuildOptions) => ({
  name: "..."
}));

ここで initializer(buildOptions: BuildOptions) => Partial<T> として推論されている。

しかし、まだ問題があって、model にはクラス以外を指定することも想定されている。こことか。

かといって、ここで model: any に戻してしまうと initializer がなんでもありになってしまう。

  • modelT を 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 かもしれない。

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番目を定義している。

Takanori IshikawaTakanori Ishikawa

しかし、上の定義ではこれまで動いていたコードで大量の型エラーが出てしまう。特に、今取り組んでいるプロジェクトでは、生成結果のオブジェクトと初期化オブジェクトの型が違う(プロパティ名が 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> に変更

このスクラップは2021/01/12にクローズされました