こんにちはnaoです。
最近Nest.jsとGraphQLの構成で開発をすることが多くなってきたので
Jestを用いたテストコードを書いていきたいと思います。
あまり慣れていないので間違ったところあるかと思いますがそこはすみません。。
serviceのテスト
まずはserviceのテストから。
今回はPostテーブルがあり取得、追加、更新、削除ができるといった構成となっております。
post.service.ts
import { Injectable } from '@nestjs/common';
import { CreatePostInput } from './dto/createPost.input';
import { PrismaService } from '../prisma/prisma.service';
import { Post } from '@prisma/client';
import { UpdatePostInput } from './dto/updatePost.input';
@Injectable()
export class PostService {
constructor(private readonly prismaService: PrismaService) {}
async getPosts(): Promise<Post[]> {
return await this.prismaService.post.findMany();
}
async createPost(createPostInput: CreatePostInput): Promise<Post> {
const { title, content, userId } = createPostInput;
return await this.prismaService.post.create({
data: {
title,
content,
userId,
},
});
}
async updatePost(updatePostInput: UpdatePostInput): Promise<Post> {
const { id, title, content } = updatePostInput;
return await this.prismaService.post.update({
data: {
title,
content,
},
where: { id },
});
}
async deletePost(id: number): Promise<Post> {
return await this.prismaService.post.delete({
where: { id },
});
}
}
以下はdtoです。
createPost.input.ts
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsNotEmpty } from 'class-validator';
@InputType()
export class CreatePostInput {
@Field()
@IsNotEmpty()
title: string;
@Field()
@IsNotEmpty()
content: string;
@Field(() => Int)
userId: number;
}
updatePost.input.ts
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional } from 'class-validator';
@InputType()
export class UpdatePostInput {
@Field(() => Int)
id: number;
@Field({ nullable: true })
@IsNotEmpty()
@IsOptional()
title: string;
@Field({ nullable: true })
@IsNotEmpty()
@IsOptional()
content: string;
}
ではこのserviceのテストをします。
post.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PostService } from './post.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostInput } from './dto/createPost.input';
import { UpdatePostInput } from './dto/updatePost.input';
import { BadRequestException } from '@nestjs/common';
describe('PostService', () => {
let postService: PostService;
let prismaService: any;
beforeEach(async () => {
prismaService = {
post: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PostService,
{ provide: PrismaService, useValue: prismaService },
],
}).compile();
postService = module.get<PostService>(PostService);
});
it('should get posts', async () => {
const result = [];
prismaService.post.findMany.mockResolvedValue(result);
expect(await postService.getPosts()).toEqual(result);
});
it('should get posts with data', async () => {
const result = [
{
id: 1,
title: 'Sample Post 1',
content: 'This is a sample content for post 1.',
userId: 1,
},
{
id: 2,
title: 'Sample Post 2',
content: 'This is a sample content for post 2.',
userId: 2,
},
];
prismaService.post.findMany.mockResolvedValue(result);
expect(await postService.getPosts()).toEqual(result);
});
it('should create a post', async () => {
const input: CreatePostInput = {
title: 'Test',
content: 'Test content',
userId: 1,
};
prismaService.post.create.mockResolvedValue(input);
expect(await postService.createPost(input)).toEqual(input);
});
it('should throw error when creating a post with empty title', async () => {
const input: CreatePostInput = {
title: '', // empty title
content: 'Test content',
userId: 1,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('title should not be empty'),
); // Mock as rejected
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('title should not be empty'),
);
});
it('should throw error when creating a post with empty content', async () => {
const input: CreatePostInput = {
title: 'title',
content: '',
userId: 1,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('content should not be empty'),
);
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('content should not be empty'),
);
});
it('should throw error when creating a post with empty userId', async () => {
const input: CreatePostInput = {
title: 'title',
content: 'content',
userId: null,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('userId should not be empty'),
);
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('userId should not be empty'),
);
});
it('should update a post', async () => {
const input: UpdatePostInput = {
id: 1,
title: 'Updated',
content: 'Updated content',
};
prismaService.post.update.mockResolvedValue(input);
expect(await postService.updatePost(input)).toEqual(input);
});
it('should delete a post', async () => {
const id = 1;
const postToDelete = { id, title: 'Test', content: 'Test content' };
prismaService.post.delete.mockResolvedValue(postToDelete);
expect(await postService.deletePost(id)).toEqual(postToDelete);
});
});
以下、コードの詳細な解説です。
describe('PostService', () => { ... }):
describeはJestの関数で、一連の関連するテストをグループ化します。この場合、PostServiceというサービスのテストをグループ化しています。
let postService: PostService;:
postService変数を定義して、後でこのテストスコープ内で使います。これはテスト対象のサービスインスタンスを保持します。
let prismaService: any;:
prismaServiceというモック変数を定義しています。
beforeEach(async () => { ... }):
beforeEachはJestの関数で、それに渡されるコールバック関数は、各テストケースの実行前に毎回呼び出されます。
prismaService = { ... }:
prismaServiceをモックデータで上書きしています。各関数はjest.fn()を使用してモック化され、後でテスト中にこれらの関数がどのように呼び出されるかを検証できます。
const module: TestingModule = await Test.createTestingModule({ ... }).compile();:
NestJSのテスティングモジュールを使用して、テストのためのモジュールをセットアップおよびコンパイルしています。
providers: [ ... ]:
テストモジュール内で使用するサービスのリストを提供しています。
PostServiceはテスト対象のサービスです。
{ provide: PrismaService, useValue: prismaService }, はPrismaServiceの代わりにprismaServiceモックを使用することを指示しています。
postService = module.get<PostService>(PostService);:
テストモジュールからPostServiceのインスタンスを取得し、それをpostService変数に代入しています。
要するに、このコードはPostServiceのテストをセットアップするものであり、PrismaServiceの実際の実装をモック化して、実際のデータベースへのアクセスなしにPostServiceの振る舞いを検証することを可能にしています。
html
it('should get posts', async () => {
const result = [];
prismaService.post.findMany.mockResolvedValue(result);
expect(await postService.getPosts()).toEqual(result);
});
it('should get posts with data', async () => {
const result = [
{
id: 1,
title: 'Sample Post 1',
content: 'This is a sample content for post 1.',
userId: 1,
},
{
id: 2,
title: 'Sample Post 2',
content: 'This is a sample content for post 2.',
userId: 2,
},
];
prismaService.post.findMany.mockResolvedValue(result);
expect(await postService.getPosts()).toEqual(result);
});
PostServiceのgetPostsメソッドのテストを行うものです。PostServiceが内部で使用するprismaService.post.findManyメソッドが正常に動作する場合
期待される結果を返すかどうかをテストしています。
prismaService.post.findMany.mockResolvedValue(result);:
prismaService.post.findManyメソッドの戻り値をモック化しています。このメソッドが呼び出されると、先ほど定義した空の配列resultが返されるように設定されています。
expect(await postService.getPosts()).toEqual(result);:
postService.getPosts()メソッドを呼び出し、その結果が先ほど定義したresult(この場合、空の配列)と一致するかどうかを検証しています。
もう一つのテストは
resultという変数にサンプルの投稿データを2つ含む配列を定義しています。これはモックから返される期待される結果です。
各オブジェクト内のプロパティの解説:
id: 投稿の一意の識別子。
title: 投稿のタイトル。
content: 投稿の内容。
userId: この投稿を作成したユーザーのID。
prismaService.post.findMany.mockResolvedValue(result);:
prismaService.post.findManyメソッドの戻り値をモック化しています。このメソッドが呼び出されると、先ほど定義したresult配列が返されるように設定されています。
html
it('should create a post', async () => {
const input: CreatePostInput = {
title: 'Test',
content: 'Test content',
userId: 1,
};
prismaService.post.create.mockResolvedValue(input);
expect(await postService.createPost(input)).toEqual(input);
});
it('should throw error when creating a post with empty title', async () => {
const input: CreatePostInput = {
title: '', // empty title
content: 'Test content',
userId: 1,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('title should not be empty'),
); // Mock as rejected
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('title should not be empty'),
);
});
it('should throw error when creating a post with empty content', async () => {
const input: CreatePostInput = {
title: 'title',
content: '',
userId: 1,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('content should not be empty'),
);
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('content should not be empty'),
);
});
it('should throw error when creating a post with empty userId', async () => {
const input: CreatePostInput = {
title: 'title',
content: 'content',
userId: null,
};
prismaService.post.create.mockRejectedValue(
new BadRequestException('userId should not be empty'),
);
await expect(postService.createPost(input)).rejects.toThrow(
new BadRequestException('userId should not be empty'),
);
});
最初のテストはpostテーブルに正しいデータであれば挿入できるかについてです。
このテストは、正しい入力データでcreatePostメソッドを呼び出すと、投稿が正常に作成されることを確認しています。
Prismaサービスのcreateメソッドはモック化され、期待される入力値が返されるようになっています。
次からの3つはtitle、content、userIdが空の時にExceptionで期待しているエラーメッセージが出るかの確認をしています。
html
it('should update a post', async () => {
const input: UpdatePostInput = {
id: 1,
title: 'Updated',
content: 'Updated content',
};
prismaService.post.update.mockResolvedValue(input);
expect(await postService.updatePost(input)).toEqual(input);
});
it('should delete a post', async () => {
const id = 1;
const postToDelete = { id, title: 'Test', content: 'Test content' };
prismaService.post.delete.mockResolvedValue(postToDelete);
expect(await postService.deletePost(id)).toEqual(postToDelete);
});
まずは投稿の更新をテストしています。
このテストケースは、updatePost メソッドが正しく動作し、投稿の更新を正しく行うかを確認しています。
UpdatePostInput 型の input オブジェクトを定義して、更新する投稿のデータを指定します。
prismaService.post.update.mockResolvedValue(input) は、Prisma の update メソッドをモック化して、指定された input をそのまま返すように設定しています。
expect(await postService.updatePost(input)).toEqual(input) は、updatePost メソッドが上記の input を返すことを確認するアサーションです。
次は投稿の削除についてのテストです。
id は削除する投稿のIDを示します。
postToDelete は削除される予定の投稿のデータを指定しています。
prismaService.post.delete.mockResolvedValue(postToDelete) は、Prisma の delete メソッドをモック化して、postToDelete を返すように設定しています。
expect(await postService.deletePost(id)).toEqual(postToDelete) は、deletePost メソッドが指定されたIDの投稿を正しく削除し、その投稿データを返すことを確認するアサーションです。
全体的に、これらのテストは PostService のメソッドが期待通りに動作するかどうかを確認するものです。
resolverのテスト
次はresolverのテストとなります。
まず先にpost.resolver.tsのコードを載せます。
html
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PostService } from './post.service';
import { Post as PostModel } from './models/post.model';
import { CreatePostInput } from './dto/createPost.input';
import { Post } from '@prisma/client';
import { UpdatePostInput } from './dto/updatePost.input';
@Resolver()
export class PostResolver {
constructor(private readonly postService: PostService) {}
@Query(() => [PostModel], { nullable: 'items' })
async getPosts(): Promise<Post[]> {
return await this.postService.getPosts();
}
@Mutation(() => PostModel)
async createPost(
@Args('createPostInput') createPostInput: CreatePostInput,
): Promise<Post> {
return await this.postService.createPost(createPostInput);
}
@Mutation(() => PostModel)
async updatePost(
@Args('updatePostInput') updatePostInput: UpdatePostInput,
): Promise<Post> {
return await this.postService.updatePost(updatePostInput);
}
@Mutation(() => PostModel)
async deletePost(@Args('id', { type: () => Int }) id: number): Promise<Post> {
return await this.postService.deletePost(id);
}
}
次にテストコードです。
post.resolver.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PostResolver } from './post.resolver';
import { PostService } from './post.service';
import { CreatePostInput } from './dto/createPost.input';
import { UpdatePostInput } from './dto/updatePost.input';
import { BadRequestException } from '@nestjs/common';
describe('PostResolver', () => {
let postResolver: PostResolver;
let postService: any;
beforeEach(async () => {
postService = {
getPosts: jest.fn(),
createPost: jest.fn(),
updatePost: jest.fn(),
deletePost: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PostResolver,
{ provide: PostService, useValue: postService },
],
}).compile();
postResolver = module.get<PostResolver>(PostResolver);
});
it('should get posts', async () => {
const result = [];
postService.getPosts.mockResolvedValue(result);
expect(await postResolver.getPosts()).toEqual(result);
});
it('should get posts with data', async () => {
const result = [
{
id: 1,
title: 'Sample Post 1',
content: 'This is a sample content for post 1.',
},
{
id: 2,
title: 'Sample Post 2',
content: 'This is a sample content for post 2.',
},
];
postService.getPosts.mockResolvedValue(result);
expect(await postResolver.getPosts()).toEqual(result);
});
it('should create a post', async () => {
const input: CreatePostInput = {
title: 'Test',
content: 'Test content',
userId: 1,
};
const result = { ...input, id: 1 };
postService.createPost.mockResolvedValue(result);
expect(await postResolver.createPost(input)).toEqual(result);
});
it('should throw an error when creating a post with an empty title', async () => {
const input: CreatePostInput = {
title: '',
content: 'Test content',
userId: 1,
};
postService.createPost.mockRejectedValue(
new BadRequestException('title should not be empty'),
);
await expect(postResolver.createPost(input)).rejects.toThrow(
new BadRequestException('title should not be empty'),
);
});
it('should throw an error when creating a post with an empty content', async () => {
const input: CreatePostInput = {
title: 'Test',
content: '',
userId: 1,
};
postService.createPost.mockRejectedValue(
new BadRequestException('content should not be empty'),
);
await expect(postResolver.createPost(input)).rejects.toThrow(
new BadRequestException('content should not be empty'),
);
});
it('should update a post', async () => {
const input: UpdatePostInput = {
id: 1,
title: 'Updated',
content: 'Updated content',
};
const result = { ...input };
postService.updatePost.mockResolvedValue(result);
expect(await postResolver.updatePost(input)).toEqual(result);
});
it('should delete a post', async () => {
const id = 1;
const result = {
id: 1,
title: 'Deleted',
content: 'Deleted content',
userId: 1,
};
postService.deletePost.mockResolvedValue(result);
expect(await postResolver.deletePost(id)).toEqual(result);
});
});
テストするケースはserviceとほぼ同じなので説明は割愛します。
なぜほぼ同じなのにテストをする必要があるのかは以下なのではないかと思います。
Serviceのテスト:
Serviceはビジネスロジックを実装する場所で、通常はデータベースや他の外部サービスとのやり取りが含まれます。
Serviceのテストでは、そのビジネスロジックが正しく動作するか、また適切な例外処理が行われるかを確認します。
データベースや外部サービスの実際の呼び出しを避けるため、モックやスタブを使用してこれらの部分をシミュレートします。
Resolverのテスト:
ResolverはGraphQLのクエリやミューテーションを処理する場所で、クライアントからのリクエストに応じて適切なServiceメソッドを呼び出します。
Resolverのテストでは、特定のGraphQLクエリやミューテーションが期待通りのServiceメソッドを呼び出すか、また適切なデータやエラーをクライアントに返すかを確認します。
これもServiceの実際の呼び出しを避けるため、モックを使用してServiceの動作をシミュレートします。
共通点:
両方のテストでNest.jsのTestingModuleを使用してモジュールのテストインスタンスを作成します。
依存関係のモック化は共通のアプローチを使用します。これにより、外部の依存関係やサイドエフェクトを持つ部分を隔離し、ユニットテストの範囲を狭めることができます。
違い:
Serviceのテストは主にビジネスロジックの正確性に焦点を当てています。
Resolverのテストは、GraphQL APIのエンドポイントが期待通りに動作するかに焦点を当てています。
最終的に、テストの目的と焦点に応じてテストケースやアサーションを調整しますが、基本的なテストのセットアップやモック化のアプローチは非常に似ています。
e2eテスト
最後にe2eテストです。
実際にデータを挿入するテストなのでサーバを起動しておきましょう。
post.e2e.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import * as request from 'supertest';
import { PrismaService } from '../prisma/prisma.service';
import { PostResolver } from './post.resolver';
import { PostService } from './post.service';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
describe('PostResolver (e2e)', () => {
let app: INestApplication;
let prismaService: PrismaService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'src/schema.gql',
}),
],
providers: [PostResolver, PostService, PrismaService],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prismaService = moduleFixture.get<PrismaService>(PrismaService);
});
it('should get posts', () => {
const getPostsQuery = `
query {
getPosts {
title
content
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: getPostsQuery })
.expect(200)
.expect((res) => {
expect(res.body.data.getPosts).toBeInstanceOf(Array);
});
});
let createdPostId: number;
it('should create a post', () => {
const createPostMutation = `
mutation {
createPost(createPostInput: { title: "Test", content: "Test content", userId: 1 }) {
id
title
content
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: createPostMutation })
.expect(200)
.expect((res) => {
createdPostId = res.body.data.createPost.id;
console.log('Created Post ID:', createdPostId);
expect(res.body.data.createPost.title).toEqual('Test');
expect(res.body.data.createPost.content).toEqual('Test content');
});
});
it('should update a post', () => {
const updatePostMutation = `
mutation {
updatePost(updatePostInput: { id: ${createdPostId}, title: "Updated", content: "Updated content" }) {
id
title
content
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: updatePostMutation })
.expect(200)
.expect((res) => {
console.log(res.body.data.updatePost);
expect(res.body.data.updatePost.title).toEqual('Updated');
expect(res.body.data.updatePost.content).toEqual('Updated content');
});
});
it('should delete a post', () => {
const deletePostMutation = `
mutation {
deletePost(id: ${createdPostId},) {
title
content
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: deletePostMutation })
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
});
});
afterEach(async () => {
await prismaService.post.deleteMany();
});
afterAll(async () => {
await app.close();
});
});
beforeAllはJestのフックで、テストスイート内のすべてのテストが実行される前に一度だけ実行されます。
html
it('should get posts', () => {
const getPostsQuery = `
query {
getPosts {
title
content
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: getPostsQuery })
.expect(200)
.expect((res) => {
expect(res.body.data.getPosts).toBeInstanceOf(Array);
});
});
これはGraphQLのクエリを定義している部分です。具体的には、getPostsクエリを使用して投稿のタイトルと内容を取得するクエリです。
supertestライブラリを使用して、定義されたNestJSアプリケーションのHTTPサーバーにリクエストを送る部分を開始しています。supertestはHTTPリクエストを簡単にテストするためのライブラリです。
HTTP POSTリクエストを/graphqlエンドポイントに送ることを指示しています。これは、GraphQLのリクエストを送信するための標準的なエンドポイントです。
HTTPステータスコード200(成功)を期待することを指定しています。これは、リクエストが成功的に処理されたことを確認するためのものです。
この部分は、レスポンスの本文(res.body)に対する具体的な期待を定義しています。具体的には、レスポンスのdata.getPostsが配列のインスタンスであることを期待しています。
全体として、このテストはgetPosts GraphQLクエリを使用して投稿を取得し、その結果として返されるデータが配列であることを検証しています。
まとめ
簡単ですがservice、resolver、e2eのテストを行いました。
まだまだ時間がかかるのでたくさん書いて早くテストを実装できるようになりたいです。