【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の依存関係は以下のようになります。
...
"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を以下のように修正いたしました。
{
"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
のエイリアスを有効にするために以下の設定を追加致します。
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node', roots: [
"<rootDir>/src"
],
testMatch: [
"**/*.(spec|test).[jt]s"
],
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1'
}
};
実装
それでは早速、モデルとリゾルバの定義を行いましょう。
モデル定義
以下のようにリレーションを設定したモデルを定義します。例によってDB上のモデルとGraphQLのモデルの同時宣言です。
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;
}
続いて関連モデルの定義をしてみます。
@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
作ってまとめておくとなんとなくモジュールがまとめられて便利です。
export * from './User.ts';
export * from './Todo.ts';
サービスとリゾルバ
続いてサービス・リゾルバの実装を行っていきます。
サービスの実装とテスト
TypeORMではActiveRecordスタイルでもRepositoryスタイルでもコーディングできますが、トランザクションももちろん使えますので、アノテーション経由でEntityManagerを使ってトランザクションしてみましょう。
また、UserService
をResolverで使うために、Inversifyでインジェクトさせるように、@injectable()
アノテーションをクラスの設定します。
@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サービスも実装してみましょう。
@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での接続となっております。
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のリゾルバを実装してみます。
@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リストのリゾルバも作成してみましょう。
@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でtodo
のassignee
を await
してますが、TypeORMでassignee
を lazy:true
+ Promise<User>
にしているのでGraphQLでassignee
が指定されて初めてUserをSelectするクエリが発行されます。
このあたりもTypeGraphQLとTypeORMの合わせ技で非常にうまくできていますね。
リゾルバの Jest でのテスト
さて、今回のキモ、Jestでの単体ですが・・・いかにテストケースの実装を示します。
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のテストがしっかり行えるのでクライアントの実装もより安心して行えるのではないでしょうか。
以上です。
Author And Source
この問題について(【2020年11月版】TypeGraphQLのDIコンテナにInversifyJSを使いJestでテストする。), 我々は、より多くの情報をここで見つけました https://qiita.com/koinori/items/e9c8bf748264e665cb4c著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Content is automatically searched and collected through network algorithms . If there is a violation . Please contact us . We will adjust (correct author information ,or delete content ) as soon as possible .