🍋
NestJS + graphQL + Prisma + Firebase でToDOリストを作ろう!〜テスト編〜
はじめに
今回はユニットテストとE2Eテストを実装していきます。
前回の記事はコチラ
この記事で制作したコードはコチラ
ユニットテスト
セットアップ
jest
と@nestjs/testing
はあらかじめインストールされているのですぐにテストを書くことが可能ですが、一つだけ追加でパッケージをインストールしておきます。
ターミナル
npm install jest-mock-extended --save-dev
テスト実装
今のところ単体テストを書かなくてはならないような複雑なロジックを持つサービスがないので、
例としてcreateUserユースケースに対してテストを書こうと思います。
src/user/usecase/test/createUser.usecase.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { mock } from 'jest-mock-extended';
import { User } from '@prisma/client';
import { CreateUserUseCase } from '../createUser.usecase';
import { FirebaseService } from 'src/util/firebase/firebase.service';
import { CreateUserService } from 'src/user/service/createUser.service';
import { UserRecord } from 'firebase-admin/lib/auth/user-record';
import { UserRepository } from 'src/user/repository/user.repository';
import { PrismaService } from 'src/prisma.service';
describe('createUserUseCase', () => {
let service: CreateUserUseCase;
let createUserService: CreateUserService;
let firebaseService: FirebaseService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CreateUserUseCase,
CreateUserService,
UserRepository,
PrismaService,
FirebaseService,
],
}).compile();
service = module.get<CreateUserUseCase>(CreateUserUseCase);
createUserService = module.get<CreateUserService>(CreateUserService);
firebaseService = module.get<FirebaseService>(FirebaseService);
});
afterEach(() => jest.resetAllMocks());
it('ユーザーが作成される', async () => {
const input = {
name: '不知火 フレン',
uid: 'xxx',
};
const firebaseSpy = jest
.spyOn(firebaseService, 'findByUid')
.mockResolvedValue(
mock<UserRecord>({
uid: input.uid,
}),
);
const createSpy = jest.spyOn(createUserService, 'handle').mockResolvedValue(
mock<User>({
name: input.name,
firebaseUId: input.uid,
}),
);
await service.handle(input);
expect(firebaseSpy).toHaveBeenCalledTimes(1);
expect(firebaseSpy).toHaveBeenCalledWith({
uid: input.uid,
});
expect(createSpy).toHaveBeenCalledTimes(1);
expect(createSpy).toHaveBeenCalledWith({
name: input.name,
firebaseUId: input.uid,
});
});
});
動作確認
書き終わったら実行してみましょう。
ターミナル
npm run test
> nestjs-graphql-prisma-starter@0.0.1 test
> jest
PASS src/user/usecase/test/createUser.usecase.spec.ts
createUserUseCase
✓ ユーザーが作成される (3 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.859 s, estimated 2 s
Ran all test suites.
E2Eテスト
続いてE2Eテストを実装していきます。
configファイル準備
まず、testディレクトリ下にconfigファイルを配置します。
jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1",
"^test/(.*)$": "<rootDir>/../test/$1"
}
}
テスト用DB準備
このままだとローカルで使用しているDBをテストでも使用してしまうので、テストの時はテスト用のDBを使用するようにします。dotenv-cli
をインストールします。
ターミナル
npm install -D dotenv-cli
次に、.env.test
を追加します。
.env.test
DATABASE_URL="mysql://root:password@localhost:5306/starter-db-test"
最後に、env.test
を読み込むテスト用のスクリプトを追加します。
package.json
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --runInBand",
"migrate:test": "dotenv -e .env.test -- npx prisma migrate dev",
マイグレートを実行してみましょう。
ターミナル
npm run migrate:test
> nestjs-graphql-prisma-starter@0.0.1 migrate:test
> dotenv -e .env.test -- npx prisma migrate dev
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "starter-db-test" at "localhost:5306"
MySQL database starter-db-test created at localhost:5306
Applying migration `20240702131738_add_user_and_todo_table`
The following migration(s) have been applied:
migrations/
└─ 20240702131738_add_user_and_todo_table/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (v5.16.1) to ./node_modules/@prisma/client in 48ms
これでテスト用のdbを作成することができました!
E2Eテスト実装
それではテストを実装していきましょう。
test/e2e-main-1/user.e2e-spec.ts
import { ExecutionContext, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { PrismaService } from 'src/prisma.service';
import { AppModule } from 'src/app.module';
import { FirebaseAuthGuard } from 'src/auth/firebase-auth.guard';
import { FirebaseService } from 'src/util/firebase/firebase.service';
describe('UserResolver', () => {
let app: INestApplication;
let prisma: PrismaService;
let fireBaseUserService: FirebaseService;
beforeAll(async () => {
// テスト用モジュール作成
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideGuard(FirebaseAuthGuard)
.useValue({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canActivate: (context: ExecutionContext) => {
// httpリクエストからコンテキストを作成
// const ctx = GqlExecutionContext.create(context).getContext();
return true;
},
})
.compile();
// テスト用アプリケーション起動
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
fireBaseUserService = app.get<FirebaseService>(FirebaseService);
});
afterEach(async () => {
// テストデータ削除
await prisma.user.deleteMany();
});
afterAll(async () => {
// テスト用アプリケーション終了
await app.close();
});
describe('user', () => {
it('ユーザーを取得する', async () => {
await prisma.user.create({
data: {
id: 'testUserId0',
firebaseUId: 'xxx',
name: '水瀬 リエル',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `query Query($userId: String!) {
user(id: $userId) {
id
name
}
}`,
variables: {
userId: 'testUserId0',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.user).toMatchObject({
id: 'testUserId0',
name: '水瀬 リエル',
});
});
});
describe('createUser', () => {
it('ユーザーを作成する', async () => {
fireBaseUserService.findByUid = jest.fn().mockResolvedValue({
uid: 'xxx',
email: 'example@test.com',
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($name: String!, $uid: String!) {
createUser(name: $name, uid: $uid) {
id
name
}
}`,
variables: {
name: '水瀬 リエル',
uid: 'xxx',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.createUser.name).toBe('水瀬 リエル');
});
});
describe('updateUser', () => {
it('ユーザーを更新する', async () => {
await prisma.user.create({
data: {
id: 'testUserId0',
firebaseUId: 'xxx',
name: '水瀬 リエル',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($updateUserId: String!, $name: String!) {
updateUser(id: $updateUserId, name: $name) {
id
name
}
}`,
variables: {
updateUserId: 'testUserId0',
name: '不知火 フレン',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateUser.name).toBe('不知火 フレン');
});
});
describe('deleteUser', () => {
it('ユーザーを削除する', async () => {
fireBaseUserService.delete = jest.fn().mockResolvedValue({
uid: 'xxx',
email: 'example@test.com',
});
await prisma.user.create({
data: {
id: 'testUserId0',
firebaseUId: 'xxx',
name: '水瀬 リエル',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($deleteUserId: String!) {
deleteUser(id: $deleteUserId) {
id
name
}
}`,
variables: {
deleteUserId: 'testUserId0',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.deleteUser.name).toBe('水瀬 リエル');
// DBから消えているか
const targetUser = await prisma.user.findUnique({
where: {
id: 'testUserId0',
},
});
expect(targetUser).toBeNull();
});
});
});
test/e2e-main-1/todo.e2e-spec.ts
import { ExecutionContext, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { PrismaService } from 'src/prisma.service';
import { AppModule } from 'src/app.module';
import { FirebaseAuthGuard } from 'src/auth/firebase-auth.guard';
import { FirebaseService } from 'src/util/firebase/firebase.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import { TodoStatus } from '@prisma/client';
describe('TodoResolver', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
// テスト用モジュール作成
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideGuard(FirebaseAuthGuard)
.useValue({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canActivate: (context: ExecutionContext) => {
// httpリクエストからコンテキストを作成
const ctx = GqlExecutionContext.create(context).getContext();
ctx.req.user = {
id: 'testUserId0',
firebaseUid: 'xxx',
name: '水瀬 リエル',
createdAt: new Date(),
};
return true;
},
})
.compile();
// テスト用アプリケーション起動
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
await prisma.user.create({
data: {
id: 'testUserId0',
firebaseUId: 'xxx',
name: '水瀬 リエル',
},
});
});
afterEach(async () => {
// テストデータ削除
await prisma.user.deleteMany();
});
afterAll(async () => {
// テスト用アプリケーション終了
await app.close();
});
describe('todos', () => {
it('todoを取得する', async () => {
await prisma.todo.create({
data: {
userId: 'testUserId0',
title: 'テストタイトル0',
description: 'テストディスクリプション0',
},
});
await prisma.todo.create({
data: {
userId: 'testUserId0',
title: 'テストタイトル1',
description: 'テストディスクリプション1',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `query Query {
todos {
title
description
userId
status
}
}`,
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.todos.length).toBe(2);
});
});
describe('createTodo', () => {
it('todoを作成する', async () => {
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($description: String!, $title: String!) {
createTodo(description: $description, title: $title) {
title
description
userId
status
}
}`,
variables: {
title: 'テストタイトル',
description: 'テストディスクリプション',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.createTodo).toMatchObject({
title: 'テストタイトル',
description: 'テストディスクリプション',
userId: 'testUserId0',
status: TodoStatus.NOT_STARTED,
});
});
});
describe('updateTodoContent', () => {
it('todoを更新する', async () => {
await prisma.todo.create({
data: {
id: 'testTodoId0',
userId: 'testUserId0',
title: 'テストタイトル0',
description: 'テストディスクリプション0',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($description: String!, $updateTodoContentId: String!, $title: String!) {
updateTodoContent(description: $description, id: $updateTodoContentId, title: $title) {
title
description
userId
status
}
}`,
variables: {
updateTodoContentId: 'testTodoId0',
title: 'テストタイトル1',
description: 'テストディスクリプション1',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateTodoContent).toMatchObject({
title: 'テストタイトル1',
description: 'テストディスクリプション1',
userId: 'testUserId0',
status: TodoStatus.NOT_STARTED,
});
});
});
describe('updateTodoStatus', () => {
it('todoを更新する - ステータス', async () => {
await prisma.todo.create({
data: {
id: 'testTodoId0',
userId: 'testUserId0',
title: 'テストタイトル0',
description: 'テストディスクリプション0',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($input: UpdateTodoStatusInput!) {
updateTodoStatus(input: $input) {
title
description
userId
status
}
}`,
variables: {
input: {
id: 'testTodoId0',
status: TodoStatus.IN_PROGRESS,
},
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateTodoStatus).toMatchObject({
title: 'テストタイトル0',
description: 'テストディスクリプション0',
userId: 'testUserId0',
status: TodoStatus.IN_PROGRESS,
});
});
});
describe('deleteTodo', () => {
it('todoを削除する', async () => {
await prisma.todo.create({
data: {
id: 'testTodoId0',
userId: 'testUserId0',
title: 'テストタイトル0',
description: 'テストディスクリプション0',
},
});
const res = await request(app.getHttpServer())
.post('/graphql')
.send({
query: `mutation Mutation($deleteTodoId: String!) {
deleteTodo(id: $deleteTodoId) {
title
description
userId
status
}
}`,
variables: {
deleteTodoId: 'testTodoId0',
},
});
expect(res.body.errors).toBeUndefined();
expect(res.body.data.deleteTodo).toMatchObject({
title: 'テストタイトル0',
description: 'テストディスクリプション0',
userId: 'testUserId0',
status: TodoStatus.NOT_STARTED,
});
// DBから消えているか
const targetTodo = await prisma.user.findUnique({
where: {
id: 'testTodoId0',
},
});
expect(targetTodo).toBeNull();
});
});
});
以下を参考にしました。
動作確認
テストを実行してみましょう。
ターミナル
npm run test:e2e
> nestjs-graphql-prisma-starter@0.0.1 test:e2e
> dotenv -e .env.test -- jest --config ./test/jest-e2e.json --runInBand
PASS test/e2e-main-1/todo.e2e-spec.ts
PASS test/e2e-main-1/user.e2e-spec.ts
Test Suites: 2 passed, 2 total
Tests: 9 passed, 9 total
Snapshots: 0 total
Time: 2.836 s, estimated 5 s
Ran all test suites.
通りましたね!
CI整備
それでは今書いたユニットテストとE2Eテストがgithub Actionsで自動実行されるようにしましょう。
ymlファイル配置
ルート下に.github
というディレクトリを作成し、その中にworkflows
ディレクトリを作成します。
.github/workflows/test-unit.yml
name: test-unit
on:
pull_request:
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: npm ci
- run: npm run test
.github/workflows/test-e2e.yml
name: test-e2e
on:
pull_request:
jobs:
test-e2e:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
ports:
- 5306:3306
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_USER: starter-api
MYSQL_PASSWORD: password
MYSQL_DATABASE: starter-db-test
TZ: "Asia/Tokyo"
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- uses: actions/cache@v3
id: node_modules_cache_id
env:
cache-name: cache-node-modules
with:
path: "**/node_modules"
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- run: echo '${{ toJSON(steps.node_modules_cache_id.outputs) }}'
# キャッシュがあれば、npm install をスキップする
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
run: npm install
- run: npm run migrate
env:
DATABASE_URL: mysql://root:password@localhost:5306/starter-db-test
- run: npm run test:e2e
env:
DATABASE_URL: mysql://root:password@localhost:5306/starter-db-test
ついでにbuildもチェックするアクションを追加しておきます。
.github/workflows/build.yml
name: build
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- uses: actions/cache@v3
id: node_modules_cache_id
env:
cache-name: cache-node-modules
with:
path: "**/node_modules"
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- run: echo '${{ toJSON(steps.node_modules_cache_id.outputs) }}'
# キャッシュがあれば、npm install をスキップする
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
run: npm install
- run: npm run build
動作確認
ymlファイルを配置し終わったらpull requestを作成しCIが動作するかチェックしましょう。
OKですね!
Discussion