🏭

TypeScriptの型データから自動でfactoryを作る

2021/12/20に公開約7,900字

この記事はLAPRAS Advent Calendar 2021の19日目の記事です。


What is this?

TypeScriptの型定義を元にして、ランタイムで動作するモック を作ることができるts-auto-mockというライブラリを紹介します。

Why?

TypeScriptで(あるいは他のどんな言語でも)ソフトウェア開発をしていてこんなことないでしょうか?

🤷‍♂️ 「テストを書くぞ」
🤷‍♂️ 「ここのオブジェクトはproductionで使うものを使えないから、モックしないといけないな」
🤷‍♂️ 「あ、参照しているこっちのオブジェクトもモックしなきゃいけないな」
🤷‍♂️ 「あ、参照しているこっちのオブジェクトもモックしなきゃいけないな(N回繰り返す)」
🤷‍♂️ 「というか外部ライブラリのこの巨大なオブジェクト全部モックするのはつらくない??🥺」
🤷‍♂️ 「でも型定義は全部あるんだから、型を元にしてランタイムで動くように適当にモック作れないのかな?

これを実現します。

ts-auto-mock を簡単に紹介

たとえばこのような型定義のオブジェクトをモックしたいとします。

types.ts
interface Person {
    id: string;
    getName(): string;
    details: {
        phone: number
    }
}
app.ts
// ジェネリクスでモックしたい型を渡す
const person = createMock<Person>()

console.log('------person------')
console.log('id', person.id);
console.log('name', person.getName());
console.log('details', person.details.phone);

これを実行すると、このようにログが出ます。

------person------
id id6d7203
name id6ca860
details -8033.633002643623

また、

app.ts
createMock<Person>({
    details: {
      phone: 7423232323
    }
});

このように記述すると、
指定したプロパティに対するモックの挙動を明示的に指定することもできます。


さて
TypeScriptの型情報はトランスパイル後に失われることは自明なので、
ここで
「え?なんでジェネリクスで渡した情報がランタイムで利用できているの??👀」
という疑問があるかと思います。

その疑問に答えるために、
ts-auto-mockをどのようにセットアップするかをまず説明します。

セットアップ方法

実は、
ts-auto-mockは、通常の tsc でトランスパイルしても動作しません。

tsc自体は通りますが、実行時に下記のようなエラーが出ます。

Error:
  hey, it looks like ts-auto-mock is not configured correctly! You can find the instructions here https://typescript-tdd.github.io/ts-auto-mock/installation.
  If you need further assistance feel free to drop a message on slack. (link at the bottom of https://typescript-tdd.github.io/ts-auto-mock)

    at r.createMock (/Users/yuichkun/Documents/workspaces/ts-auto-mock-handson/node_modules/ts-auto-mock/index.js:1:502)
    at Object.<anonymous> (/Users/yuichkun/Documents/workspaces/ts-auto-mock-handson/dist/app.js:18:46)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:76:12)
    at internal/main/run_main_module.js:17:47

そのため、tsc を用いない方法でトランスパイルすることで、
トランスパイル時にコードのtransformを行う ことでランタイムで動作するモックの実現をしています。

詳しいセットアップ方法については公式のガイドに譲りますが、

本記事執筆時点でサポートされているトランスパイル実行方法としては、

  • jest + ts-jest + ttypescript
  • jest + ts-jest + ts-patch
  • Webpack
  • ttypescript
  • ts-patch
  • ts-node + Mocha

などがあります。

先に示した例のコードは、ttypescript を用いて実行した例でしたので、
そのセットアップ方法だけ簡単に解説します。

ttypescriptで ts-auto-mockを使用できるようにする

※ 標準的なTypeScriptのビルド環境が既にあるとします。

  1. ttypescriptts-auto-mock をインストールする
npm i ttypescript ts-auto-mock -D
  1. tsconfig.json に下記を追記する。
tsconfig.json
{
  "compilerOptions": {
    //...,
    "plugins": [
      { "transform": "ts-auto-mock/transformer" }
    ]
  },
  //...
}
  1. buildスクリプトを書き換える
package.json
- "build": "tsc"
+ "build": "ttsc"

これだけです 👏

Webのプロジェクトだとwebpackなどが使われることが多いかと思いますが、
そちらのセットアップ方法も同じぐらい簡単なので、導入コストとしてはかなり低いのではないかと思います。

TypeScript本家との互換性は?

ttypescriptREADME.md を読むと

Instead of tsc and tsserver, use ttsc and ttsserver wrappers. This wrappers try to use locally installed typescript first.
No version lock-ins - typescript used as peer dependency.

とあり、
プロジェクトで使われているTypeScriptを内部ではpeer dependencyとして使っているようなので、「tsc だと動いたものが動かなくなる」というようなことが起きるリスクは比較的低いのではないかと思います。

設定など

先に掲示した例では、実は random という ts-auto-mock の機能を有効にしていました。

tsconfig.json
{
  "compilerOptions": {
    //...,
    "plugins": [
      {
        "transform": "ts-auto-mock/transformer",
        "features": ["random"]
      }
    ]
  },
  //...
}

ts-auto-mock では TypeScriptのプリミティブ型に対するデフォルトのモックの挙動は以下のようになります。

number // 0
string // ""
boolean // false
boolean[] // []
void // undefined
null // null
undefined // undefined
never // undefined

random というフィーチャーを有効にすると、number enum string boolean に対して、動的にランダムな値を返すような挙動を取るようになります。
このランダム化の実装は、ソースコード中のこのあたりにあります。

random.ts
const MIN_NUMBER: number = -10000;
const MAX_NUMBER: number = 10000;

export class Random {
  public static number(): number {
    return Math.random() * (MAX_NUMBER - MIN_NUMBER) + MIN_NUMBER;
  }

  public static enumValue(...args: Array<string | number>): string | number {
    return args[Math.floor(Math.random() * args.length)] ?? 0;
  }

  public static string(prefix: string): string {
    return prefix + Math.random().toString(20).substr(2, 6);
  }

  public static boolean(): boolean {
    return !Math.round(Math.random());
  }
}

発展: traitを実現してみる

※ ここからは、筆者がなんとなく考えているアイデアレベルの雑記です。より良いアイデアがあったら是非シェアしてください!

ts-auto-mockはオブジェクトがネストしていても再帰的にモックを実装してくれるので便利なのですが、配列の型のモックのデフォルトの挙動は空配列になってしまうので、様々なパターンのモックがほしい時に、ちょっと不便だなと思いました。

理想としては、

  • 可能な限り、factoryを手書きで書く部分を減らして、自動で作りたい
  • でも、柔軟にモックの挙動を差し替えられるようにしたい

これらを満たしたいと考えた時、ts-auto-mockを使いつつ、Rubyの factory_bot でいうところのtraitのような機能があればよいのではないかと思いました。
jsでtraitを実装しやすそうなfactoryライブラリを探していたところfisheryというライブラリを見つけたので(ちなみにfactory_botと同じ会社でした)、
ts-auto-mock + fisheryという構成で、trait付き半自動factoryを実現します。

実装

なるべくリアルワールドにありそうな状態で検証してみたかったので、
下記のような型定義を用意してみました。

types.ts
export type Organization = {
    name: string
    users: User[]
    location: Location
}

export type User = {
    id: number
    name: string
    isAdmin: boolean
    location: Location
    organization?: Organization
    phoneNumber?: string
    sns: SNS[]
}

export type Location = {
    lat: number
    name: string
}

export type SNS = {
    name: string
    url: string
}

先に述べた通り、この状態で素直に OrganizationcreateMock すると、
namelocation はランダムな文字列になりますが、 users は常に空配列になってしまいます。

そこで、このようにtrait付きでfactoryを定義してみます。

factory/organization.ts

import { Factory } from "fishery";
import { createMock, createMockList } from "ts-auto-mock";
import { Organization, User } from "../types";

class OrganizationTraits extends Factory<Organization> {
  withUsers(n: number) {
    return this.params({
      users: createMockList<User>(n),
    })
  }
}

export const OrganizationFactory = OrganizationTraits.define(() => createMock<Organization>());

すると使う側では、

app.ts
// デフォルトの設定でfactoryがビルドされるため、usersが[]
const organization = OrganizationFactory.build();
// withUsersというtraitで明示的にusersの数を指定
const organizationWithUsers = OrganizationFactory.withUsers(3).build();

orgazation は下図のようなオブジェクトになりますが、

organizationWithUsers は下図のようなオブジェクトになります。

このようにfactoryを記述するメリットとしては、

  • ユースケースに応じていくらでもtraitを生やせる。
  • fisheryの FactoryクラスはchainableなAPIを提供しているので、UserFactory.admin().withFacebook().buildList(3) のようにチェーンして記述できる。
  • ts-auto-mockのデフォルトのモックの挙動で構わない部分は、記述しなくてもよい
    • 上書きしたいところだけtraitを書けばよい
    • なにも書かなかったプロパティは適当にモックしてもらえる

などがあるかと思います。

参考までに、このpocを実装したソースコード全体をgithubに置いておきます:

https://github.com/yuichkun/ts-auto-factory-poc

まとめ・雑記

ts-auto-mockの randomフィーチャーはまあまあ便利なのですが、
もうちょっとヒューマンフレンドリーなモックの値にしたいなという気持ちもだんだん湧いてきそうな気がしました。

e.g. string 型のモックを完全にランダムな文字列にするのではなく、faker などのライブラリを使った読みやすい実装に差し替える、など

本記事執筆時点では、このようにユーザー側でrandomの挙動を設定することができなさそうだったので、こんど時間のある時にts-auto-mockにproposalを出してPR作ってみようと思いました。

あと、まだ試してみていないのですが、web開発をしていてモックがほしくなる場面としては、テストも勿論そうですが、storybookもあるかなと思いました。

APIやpropsなどに型さえついていれば、既にそれは資産なので、比較的ローコストでモックを導入できるのではないかと思います。

Discussion

ログインするとコメントできます