【2020年11月版】TypeGraphQLのDIコンテナにInversifyJSを使いJestでテストする。


TypeGraphQLデフォのDIコンテナ "TypeDI" には一抹の不安。

以下のページにあるようにサンプルのDIコンテナは"TypeDI"がおすすめの模様。
https://typegraphql.com/docs/dependency-injection.html

ただTypeDIは最近の開発が停止しているっぽいので若干の不安があります。以下はOpenBaseでのダッシュボードです。

そこでもうちょっと活発そうな"InversifyJS"を使ってみます。(いや、変わらなくない?)

結果を先に申し上げておきますと、なんの問題もないです。
それでは以下で手順をご紹介いたします。

導入

今回も例によってTypeORM+TypeGraphQLの構成で参ります。ここまでの環境構築などはこちらのページ(【2019年10月版】TypeGraphQL なら ORM は TypeORM でしょう。)などをご覧ください。

パッケージの追加

npm init後、いろいろパッケージを追加致しまして、package.jsonの依存関係は以下のようになります。

package.json
...
  "dependencies": {
    "class-validator": "^0.12.2",
    "graphql": "^15.4.0",
    "inversify": "^5.0.1",
    "reflect-metadata": "^0.1.13",
    "type-graphql": "^1.1.1",
    "typeorm": "^0.2.29"
  },
  "devDependencies": {
    "@types/jest": "^26.0.15",
    "@types/node": "^14.14.6",
    "jest": "^26.6.3",
    "ts-jest": "^26.4.3",
    "typescript": "^4.0.5"
  }
}
...

Typescriptの設定

ここでは InversifyJSのインストール手順 に従って、tsconfig.jsonを以下のように修正いたしました。

tsconfig.json
{
  "compilerOptions": {
      "target": "es5",
      "lib": ["es6"],
      "types": ["reflect-metadata", "jest"],
      "module": "commonjs",
      "moduleResolution": "node",
      "experimentalDecorators": true,
      "emitDecoratorMetadata": true,
      "baseUrl": "./",
      "paths": {
        "@src*": [
          "src*"
        ]}
  }
}

型の参照にjestも追加しました。
またsrcフォルダ以下を@srcで示せるようにエイリアスも作っておきます。
相対パスだらけだと視認性悪いので@srcを使うようにしてたりします。 

Jest+TypeScript の設定

Jestでは npx ts-jest config:init で基本的な設定は以上でOKですが、テスト用のファイル名と@srcのエイリアスを有効にするために以下の設定を追加致します。

jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',  roots: [
    "<rootDir>/src"
  ],
  testMatch: [
    "**/*.(spec|test).[jt]s"
  ],
  moduleNameMapper: {
    '^@src/(.*)$': '<rootDir>/src/$1'
  }
};

実装

それでは早速、モデルとリゾルバの定義を行いましょう。

モデル定義

以下のようにリレーションを設定したモデルを定義します。例によってDB上のモデルとGraphQLのモデルの同時宣言です。

src/models/User.ts
export class User extends BaseEntity {

  @Field()
  @PrimaryGeneratedColumn()
  id!: string;

  @Field()
  @Column({ comment: "ユーザー名" })
  name!: string;

  @Field()
  @Column({ comment: "Emailアドレス" })
  emailAddress!: string;

  @Field()
  @Column({ comment: "状態" })
  status!: string;

  @Field()
  @CreateDateColumn({ type: "timestamp",comment: "作成日"})
  created?: Date;

  @Field()
  @UpdateDateColumn({ type: "timestamp",comment: "変更日"})
  updated?: Date;

  @Field(_ => [Todo], { nullable: true })
  @OneToMany(_ => Todo, (todo) => todo.assignee, {lazy: true})
  tasks? : Promise<Todo[]> | null; 
}

続いて関連モデルの定義をしてみます。

src/models/todo.ts
@ObjectType()
@Entity()
export class Todo extends BaseEntity {

    @Field(_ => ID)
    @PrimaryGeneratedColumn()
    id!: number;

    @Field()
    @Column({ comment: "タイトル" })
    title!: string;

    @Field()
    @Column({ comment: "本文" })
    body!: string;

    @Field()
    @Column({ comment: "状態" })
    status!: string;

    @Field(_ => User)
    @ManyToOne(_ => User, (user) => user.tasks, { lazy: true})
    assignee!:Promise<User>;

    @Field()
    @CreateDateColumn({ type: "timestamp",comment: "作成日"})
    created?: Date;

    @Field()
    @UpdateDateColumn({ type: "timestamp",comment: "変更日"})
    updated?: Date;
}

ユーザー→TODOリストを持つように定義してました。はい、簡単、便利!!

ついでに modelフォルダにindex.ts作ってまとめておくとなんとなくモジュールがまとめられて便利です。

src/model/index.ts
export * from './User.ts';
export * from './Todo.ts';

サービスとリゾルバ

続いてサービス・リゾルバの実装を行っていきます。

サービスの実装とテスト

TypeORMではActiveRecordスタイルでもRepositoryスタイルでもコーディングできますが、トランザクションももちろん使えますので、アノテーション経由でEntityManagerを使ってトランザクションしてみましょう。

また、UserServiceをResolverで使うために、Inversifyでインジェクトさせるように、@injectable()アノテーションをクラスの設定します。

src/services/UserService.ts
@injectable()
export class UserService {
    @Transaction()
    add(user: Partial<User>, @TransactionManager() manager?: EntityManager) {
      if (manager != null) {
        const entity = manager.create(User, user);
        return manager.save(entity);
      }
      return null;
    }

    find(user: Partial<User>) {
      return User.find({where: user});
    }
}

続いてTODOサービスも実装してみましょう。

src/services/TodoService.ts
@injectable()
export class TodoService {
    constructor(@inject(UserService) private userService: UserService) {}
    @Transaction()
    async add(todo: Partial<Todo>, user: Partial<User>, @TransactionManager() manager?: EntityManager) {
      if (manager != null) {
        const entity = manager.create(Todo, todo);
        entity.assignee = Promise.resolve((await this.userService.find(user))[0]);
        return manager.save(entity);
      }
      throw new Error("EntityManager Not Passed !");
    }
    @Transaction()
    async query(rawquery: string, @TransactionManager() manager?: EntityManager) {
      if (manager != null) {
        return manager.query(rawquery);
      }
      throw new Error("EntityManager Not Passed !");
    }
  }

ざっくりですがこんな感じです。

サービスを Jest で単体テスト

続いてjestでのテストケースを作成してみます。今回はDevcontainerにTypeScript+PostgreSQLを使用したのでPostgresqlでの接続となっております。

src/service/Services.spec.ts
import { Todo, User } from "@src/models";
import { Connection, createConnection } from "typeorm";
import { UserService, TodoService} from ".";

describe('User & Todo Servivec Test', () => {
    let db_connection: Connection;
    const userService  = new UserService();
    const todoService  = new TodoService(userService);
    beforeAll(async () => {
        db_connection = await createConnection({
            type: "postgres",
            host: "localhost",
            port: 5432,
            username: "postgres",
            password: "postgres",
            database: "postgres",
            entities: [
                Todo,
                User
            ],
            synchronize: true,
            logging: true
        });
    });

    afterAll(async () => {
        await Todo.clear();
        // await User.clear();
        // PostgreSQLではawait User.caller();じゃうまく消せないようだ。
        await User.query('TRUNCATE "user" RESTART IDENTITY CASCADE;');
        await db_connection.close();
    })

    test("create User", async () => {
        const user = await userService.add({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });

        expect(user.id).toBeGreaterThanOrEqual(1);
        expect(user.created.getTime()).toBeLessThanOrEqual(Date.now());
        expect(user.updated.getTime()).toBeLessThanOrEqual(Date.now());
        expect(user).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
    });

    test("Find User By EMail", async () => {
        const user = await userService.findByEMail("メールアドレス@テスト");

        expect(user.id).toBeGreaterThanOrEqual(1);
        expect(user.created.getTime()).toBeLessThanOrEqual(Date.now());
        expect(user.updated.getTime()).toBeLessThanOrEqual(Date.now());
        expect(user).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
    });

    test("Create Todo", async () => {
        let user = await userService.findByEMail("メールアドレス@テスト");

        const todo = await todoService.add({
                title: "タスク1",
                body: "内容1",
                status: "未着手"
            }, user);

        expect(todo.id).toBeGreaterThanOrEqual(1);
        expect(todo.created.getTime()).toBeLessThanOrEqual(Date.now());
        expect(todo.updated.getTime()).toBeLessThanOrEqual(Date.now());
        expect(todo).toMatchObject({
            title: "タスク1",
            body: "内容1",
            status: "未着手",
            assignee: Promise.resolve(user)
        });

        const result = await Todo.findByIds([todo.id]);
        expect(result.length).toBe(1);
        expect(result[0].id).toBe(todo.id);
        const assignee = await result[0].assignee;
        expect(assignee).toBeDefined();
        expect(assignee).toMatchObject({
            "emailAddress": "メールアドレス@テスト",
            "id": 1,
            "name": "テストユーザー1",
            "status": "新規作成",
        });
    });

    test("Todo Find By User", async () => {

        const user = await userService.findByEMail("メールアドレス@テスト");
        const tasks = await user.tasks;
        expect(tasks).toBeDefined();
        expect(tasks.length).toBe(1);
        expect(tasks[0]).toMatchObject({
            title: "タスク1",
            body: "内容1",
            status: "未着手",
        });
    });

    test("Delete todo", async () => {
        const user = await userService.findByEMail("メールアドレス@テスト");
        const tasks = await user.tasks;
        expect(tasks).toBeDefined();
        expect(tasks.length).toBe(1);

        const todo = await tasks[0].remove();

        expect(todo.hasId()).toBeFalsy();
        expect(todo).toMatchObject({
            title: "タスク1",
            body: "内容1",
            status: "未着手",
        });
    });

    test("Delete user", async () => {
        const user = await userService.findByEMail("メールアドレス@テスト");

        const result = await user.remove();

        expect(result.hasId()).toBeFalsy();
        expect(result).toMatchObject({
            "emailAddress": "メールアドレス@テスト",
            "name": "テストユーザー1",
            "status": "新規作成",
        });
    });
    // モデルにマッピングしないただのクエリを実行
    test("Raw Query", async ()=>{
        const query = `select * from "user";`;

        const result = await todoService.query(query);

        console.log("result:%o", result);

        expect(result).toBeDefined();
    })
})

TypeORMとjestは普通に動きました。todoService.queryと呼べば@TransactionManager()の引数は自動で補完してくれます。素晴らしいですね〜。
また jest の toMatchObjectは便利ですね!

ここではまだ Inversifyは出てきません。。。

リゾルバの実装

まずは Userのリゾルバを実装してみます。

src/resolvers/UserResolver.ts
@InputType({ description: 'ユーザー' })
export class UserInput {
    @Field({ description: 'ユーザー名', nullable: true })
    name?: string;
    @Field({ description: 'メールアドレス', nullable: true })
    emailAddress?: string;
    @Field({ description: '状態', nullable: true })
    status?: string;
}

@injectable()
@Resolver(_ => User)
export class UserResolver {
    constructor(@inject(UserService) private userService: UserService) {}

    @Mutation(_ => User)
    async createUser(@Arg("user") user: UserInput) {
      return await this.userService.add(user);
    }

    @Query(_ => [User], { nullable: true })
    async users(@Arg("user") user: UserInput) {
      return await User.find({where: Object.assign( {}, user )});
    }

    @FieldResolver(_=> [Todo])
    async tasks(@Root() root: User) {
      return root.tasks;
    }
}

createとfindで手抜きをして同じInput型を使い回してます。。。
ここでクラスアノテーションの@injectable()とコンストラクタの@inject(UserService)に注目です。
ここで InversifyのアノテーションにてUserServiceをコンストラクタで自動で注入させてます。

同様に、Todoリストのリゾルバも作成してみましょう。

src/resolvers/TodoResolver.ts
@InputType({ description: 'TODOタスク' })
export class TodoParam {
    @Field({ description: 'タイトル', nullable: true })
    title?: string;
    @Field({ description: '本文', nullable: true })
    body?: string;
    @Field({ description: '状態', nullable: true })
    status?: string;
    @Field({ description: '担当者', nullable: true })
    user?: UserInput;
}

@injectable()
@Resolver(_ => Todo)
export class TodoResolver {
    constructor(@inject(TodoService) private todoService: TodoService) { }

    @Mutation(_ => Todo)
    async createTodo(@Arg("todo") todo: TodoParam) {
        return await this.todoService.add(todo, todo.user);
    }

    @Query(_ => [Todo], {nullable:true})
    async todos(@Arg("todo") todo: TodoParam) {
        return await Todo.find(todo);
    }

    @FieldResolver(_ => User)
    async assignee(@Root() root: Todo) {
        return await root.assignee;
    }
}

FieldResolverでtodoassigneeawait してますが、TypeORMでassigneelazy:true + Promise<User>にしているのでGraphQLでassigneeが指定されて初めてUserをSelectするクエリが発行されます。
このあたりもTypeGraphQLとTypeORMの合わせ技で非常にうまくできていますね。

リゾルバの Jest でのテスト

さて、今回のキモ、Jestでの単体ですが・・・いかにテストケースの実装を示します。

src/resolvers/Resolver.spec.ts
describe("User & Todo Resolver Test", () => {
    const DIContainer = new Container({
        autoBindInjectable: true,
        defaultScope: BindingScopeEnum.Singleton
      });

    let db_connection: Connection;

    let schema: GraphQLSchema;

    beforeAll(async () => {
        db_connection = await createConnection({
            type: "postgres",
            host: "localhost",
            port: 5432,
            username: "postgres",
            password: "postgres",
            database: "postgres",
            entities: [
                Todo,
                User
            ],
            synchronize: true,
            logging: true
        });

        schema = buildSchemaSync({
            validate: false,
            resolvers: [
              UserResolver,
              TodoResolver
            ],
            container: DIContainer
          });

    });

    afterAll(async () => {
        await Todo.clear();
        // await User.clear();
        // PostgreSQLではawait User.caller();じゃうまく消せないようだ。
        await User.query('TRUNCATE "user" RESTART IDENTITY CASCADE;');
        await db_connection.close();
    });

    test("create User", async () => {
        const query = `mutation { createUser(user: {
                name: "テストユーザー1",
                emailAddress: "メールアドレス@テスト",
                status: "新規作成"
            }){
                id
                name
                emailAddress
                status
                created
                updated
            }
        }
        `;
        const ret = await graphql(schema, query);

        expect(ret.data).toBeDefined();
        expect(ret.data.createUser).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
        expect(ret.data.createUser.id).not.toBeNull();

    });

    test("Find User By EMail", async () => {
        const query = `query { users(user: {
                emailAddress: "メールアドレス@テスト",
            }){
                id
                name
                emailAddress
                status
                created
                updated
            }
        }
        `;
        const ret = await graphql(schema, query);

        expect(ret.data).toBeDefined();
        expect(ret.data.users.length).toBe(1);
        expect(ret.data.users[0]).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
    });

    test("Create Todo", async () => {
        const query = `mutation { createTodo(todo: {
                title: "タスク1",
                body: "内容1",
                status: "未着手"
                user: {
                    emailAddress: "メールアドレス@テスト",
                }
            }){
                id
                title
                body
                status
                assignee {
                    id
                    name
                    emailAddress
                    status
                    created
                    updated
                }
                created
                updated
            }
        }
        `;
        const ret = await graphql(schema, query);
        expect(ret.data).toBeDefined();
        expect(ret.data.createTodo).toMatchObject({
            title: "タスク1",
            body: "内容1",
            status: "未着手"
        });
        expect(ret.data.createTodo.assignee).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
    });

    test("Todo Find By User", async () => {

        const query = `query { users(user: {
                emailAddress: "メールアドレス@テスト",
            }){
                id
                name
                emailAddress
                status
                created
                updated
                tasks {
                    id
                    title
                    body
                    status
                    created
                    updated
                }
            }
        }
        `;
        const ret = await graphql(schema, query);

        expect(ret.data).toBeDefined();
        expect(ret.data.users.length).toBe(1);
        expect(ret.data.users[0]).toMatchObject({
            name: "テストユーザー1",
            emailAddress: "メールアドレス@テスト",
            status: "新規作成"
        });
        expect(ret.data.users[0].tasks[0]).toMatchObject({
            title: "タスク1",
            body: "内容1",
            status: "未着手",
        });
    });
})

まず、InversifyのDIコンテナは以下のようにインスタンスを作成します。

    const DIContainer = new Container({
        autoBindInjectable: true,
        defaultScope: BindingScopeEnum.Singleton
      });

で、このDIContainerをGraphQLのbuildSchemaで指定するだけ、です。

        schema = buildSchemaSync({
            validate: false,
            resolvers: [
              UserResolver,
              TodoResolver
            ],
            container: DIContainer
          });

意外と、あっさり動いてしまいました。

まとめ

このように、TypeORMとTypeGraphQLを合わせて使うことで実装の負荷を下げつつ、GraphQLによってAPIに極限の柔軟性を与えているかと思います。
Jestで GraphQLのテストがしっかり行えるのでクライアントの実装もより安心して行えるのではないでしょうか。

以上です。