📝

NestJS+Prisma+GitHub Actionsでサービスコンテナを使ってPrismaの結合テストをしてみた

2022/05/16に公開

Prismaのテストについて

公式ドキュメントではIntegration testingのページでDBに依存した結合テストをDBのコンテナを使って実施する方法が紹介されています。実際にNestJS+Prisma+GitHub Actionsで、Prismaの結合テストを試してみました。
https://www.prisma.io/docs/guides/testing/integration-testing

前提

PrismaでUserモデルとPostモデルが既に定義されている以下のレポジトリのstartブランチの状態から、Integration testingの環境を構築していきます。 NestJSとPrismaの環境構築の方法はこの記事では紹介しませんので、実際に自分で確かめたい方は以下のレポジトリを使って頂ければ幸いです。(完成したものはcompleteブランチにプッシュしています。)
https://github.com/shoma-mano/nestjs-prisma-starter

nest g resourceでモジュールを作成

モデルだけ定義されていてAPIは作成されていないので、APIを作成する為にコマンドでモジュールを作成します。

terminal
#REST APIを選択して、CRUD entory pointsの作成もyesを選択します。
nest g resource modules/user

今回はuser.controller.spec.tsのテストファイルは編集せず、user.service.spec.tsのテストのみ編集してテストを実行したいと思います。(controllerは基本的に受けた値をサービスに渡して実行するだけの場合が多いと思うので、テストの優先度はserviceより低いと思います。)

投稿した記事と一緒にユーザーを返すAPIを作成

コントローラーとサービスのファイルを編集します。

user.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto);
  }

  @Get()
  findAll() {
    return this.userService.findAll();
  }

  @Get(':email')
  findOneWithPost(@Param('email') email: string) {
    return this.userService.findOneWithPost(email);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.userService.update(+id, updateUserDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.userService.remove(+id);
  }
}
user.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'nestjs-prisma';

@Injectable()
export class UserService {
  //PrismaModuleをグローバルで登録している為、UserModuleを編集しなくてもDIが可能です。
  constructor(readonly prismaService: PrismaService) {}

  create(createUserDto: CreateUserDto) {
    return 'This action adds a new user';
  }

  findAll() {
    return `This action returns all user`;
  }

  findOneWithPost(email: string) {
    return this.prismaService.user.findUnique({
      where: {
        email: email,
      },
      include: {
        posts: {},
      },
    });
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    return `This action updates a #${id} user`;
  }

  remove(id: number) {
    return `This action removes a #${id} user`;
  }
}

テストコード作成

自動生成されたspecファイルを編集します。

user.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { PrismaService } from 'nestjs-prisma';

describe('UserService', () => {
  let userService: UserService;
  
  //テストを実行する前にテストで使用するデータを投入します。
  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UserService, PrismaService],
    }).compile();

    userService = module.get<UserService>(UserService);

    console.log('💫 seed executing ...');

    await userService.prismaService.user.create({
      data: {
        name: 'john',
        email: 'john@gmail.com',
        posts: {
          create: {
            title: 'first article',
            content: 'hello!world!',
            published: true,
          },
        },
      },
    });

    console.log('💫 seed finished.');
  });
   
  it('findOneWithPost', async () => {
    //任意の値でテストを通したいプロパティにexpect.anyを使っています。
    const expectedResult = {
      id: expect.any(Number),
      name: 'john',
      email: 'john@gmail.com',
      posts: [
        {
          authorId: expect.any(Number),
          id: expect.any(Number),
          title: 'first article',
          content: 'hello!world!',
          published: true,
        },
      ],
    };

    const actualResult = await userService.findOneWithPost(
      expectedResult.email,
    );

    expect(expectedResult).toEqual(actualResult);
  });

  //テスト実行後、投入したデータを削除します。
  afterAll(async () => {
    await userService.prismaService.user.deleteMany();
    await userService.prismaService.$disconnect();
  });
});

テストが通るように、ついでにuser.controller.spec.tsも編集します。ファイルを削除しても問題無いです。(実際にNestJSを起動する際には、AppModuleにPrismaModuleがグローバルで登録されている為、エラーは起こらないです。)

user.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { PrismaService } from 'nestjs-prisma';

describe('UserController', () => {
  let controller: UserController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UserController],
      //PrismaServiceを追加
      providers: [UserService, PrismaService],
    }).compile();

    controller = module.get<UserController>(UserController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

テスト実行

terminal
npm run test

上手くいけば、以下のように成功のログが確認できると思います。

GitHub Actionsの設定ファイルを作成

.github/workflow/ci.yamlを作成します。

terminal
mkdir -p .github/workflows
cat > .github/workflows/ci.yaml
ci.yaml
name: learn-github-actions
on: [push]
jobs:
  jest:
    runs-on: ubuntu-latest
    env: 
     DATABASE_URL: mysql://root:root@127.0.0.1:3306/test
    services:
          mysql:
            image: mysql:8
            ports:
              - 3306:3306
            env:
              MYSQL_DATABASE: test
              MYSQL_ROOT_USER: root
              MYSQL_ROOT_PASSWORD: root
            options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '14'
      - run: npm ci
      - run: npx prisma generate
      - run: npx prisma migrate deploy
      - run: npm run test

pushしてCIを実行

CIの設定が完了したので実際にGitHubにプッシュして、CIを実行します。
上手くいけば、下記のようにCIが成功するはずです🎉

まとめ

GitLabで同じ様な環境を構築したことはあるのですが、GitHub Actionsを使うのは初めてだったのでyamlを作成するのに若干苦戦しました。例えば、docker composeでブリッジネートワークを使うノリでサービス名を使ってmysqlコンテナを名前解決しようとすると、containerのkeyを指定してjobでコンテナを利用する必要があります。しかし、これを利用するとレポジトリのファイルにアクセスする為にバインドマウントを使う必要があり面倒だった為、今回は結局jobでコンテナを使用するのは辞めたりと、紆余曲折がありました。(ここら辺は使い勝手がGitLabと若干違ったと思います。)
最後に、今回参考にしたページを挙げておきます。

https://ldarren.medium.com/number-of-ways-to-setup-database-in-github-actions-2cd48df9faae

https://github.community/t/how-can-i-access-the-current-repo-context-and-files-from-a-docker-container-action/17711

Discussion