🍋

NestJS + graphQL + Prisma + Firebase でToDOリストを作ろう!〜テスト編〜

2024/07/08に公開

はじめに

今回はユニットテストとE2Eテストを実装していきます。
前回の記事はコチラ
https://zenn.dev/ouka031/articles/92d30ed1414b35

この記事で制作したコードはコチラ
https://github.com/Shige031/nestjs-graphql-prisma-starter

ユニットテスト

セットアップ

jest@nestjs/testingはあらかじめインストールされているのですぐにテストを書くことが可能ですが、一つだけ追加でパッケージをインストールしておきます。

ターミナル
npm install jest-mock-extended --save-dev

https://jestjs.io/ja/docs/getting-started
https://www.npmjs.com/package/jest-mock-extended

テスト実装

今のところ単体テストを書かなくてはならないような複雑なロジックを持つサービスがないので、
例として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を使用するようにします。
https://dev.classmethod.jp/articles/prisma_using_test_only_db/
https://www.prisma.io/docs/orm/more/development-environment/environment-variables/using-multiple-env-files
まず、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();
    });
  });
});

以下を参考にしました。
https://docs.nestjs.com/fundamentals/testing#end-to-end-testing
https://github.com/jmcdo29/testing-nestjs/tree/master/apps/graphql-sample

動作確認

テストを実行してみましょう。

ターミナル
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

https://qiita.com/shun198/items/14cdba2d8e58ab96cf95
https://docs.github.com/ja/actions

動作確認

ymlファイルを配置し終わったらpull requestを作成しCIが動作するかチェックしましょう。

OKですね!

Discussion