はじめに
こんにちは!むらぴょんと申します。
私は3,4年ほど前にプログラミングスクール通い、卒業後はヘルプデスク等で働きながらプログラミングの勉強をしている初心者エンジニアです。
様々な言語やフレームワークに触れたいと思い、簡単なWebアプリケーションをポートフォリオとして作成しています。
実際に実装の手順を記載していきますが自身が躓いた個所や、エラーの解消方法等は別途Zennの方に記載しています。
Zennの方にスクラップ集として遭遇したエラーをまとめています。
内容に間違い等があるかと思いますが、温かい目で見守っていただけますと幸いです。
また、本記事を書いていて記事の内容がとても多いことに気が付いたため、シリーズ化して投稿していきたいと思います。
ちなみにどうでもいいことですが、好きな言語はRubyとTypeScriptです。(Rubyは現在あまり触れていないです・・・)
なぜ記事を作成したのか
私自身が1人でもくもく作業することが多く、自分の知見をアウトプットする機会がほとんどないため、記事を投稿しようと決意しました。
あとは、自分がどのようにそのアプリを作ったのかを備忘録として残しておきたいという思いもありました。
今回実装するもの
タスクを作成し、そのタスクをアプリに登録しているユーザーに共有できるSNSアプリです。
タスク共有型SNSアプリ(TaskShareApp)
使用技術と構成
Flutter
元々はWebブラウザやPWAを使用したWebアプリケーションを構築していましたが、クロスプラットフォーム開発に憧れがあり、今回から挑戦していこう、今流行りの注目プラットフォームとしてFlutterがあり、こちらを選択しました。
Nest.js
バックエンド側にはNest.jsを採用しました。React.jsやNext.jsに使用されているTypeScriptを使用してコードを記述できる点で魅力に感じ、フロントエンドで何度も利用しているため選択しました。コードの可読性の向上や、意図しないバグを防ぐことができる点も良いところだと感じています。
PostgreSQL
データベースにはPostgreSQLを使用しています。普段はMySQL系を使用していますが、PostgreSQLも人気のため今回挑戦してみようと思いました。
認証
ユーザー認証にはnestjs/passport、 nestjs/jwt 、passport、 passport-local、passport-google-oauth20、 passport-azure-ad(Google認証やMicrosoft認証)等のJWTやOAuthを採用しています。
参考
Google Auth
Microsoft Auth
あまり記事が見つからず、少し参考にした程度です。
インフラ構成
インフラ構成は画像の通りであり、Flutter側はFirebase Hostingにデプロイし、Cloudflare Tunnelを間に通してバックエンドに接続し、NestJSとDBはCoolifyにセルフホスティングしました。
環境構築
詳しい手順は割愛させていただきますが、以下を参考に構築しました。
Linux用Windowsサブシステムの項目が出ない場合は使用しているWindowsのエディション(HomeかPro等)を確認してください。
また、BIOSの設定から仮想化の有効化が必要になる場合もあります。
nvmインストールしたら、nodeのltsバージョンをインストールします。
$ nvm install --lts
⚠️ 上記サイトではCREATE ROLE ユーザ名 LOGIN CREATEDB;
となっていますがtypeormの接続の関係上、
CREATE ROLE ユーザ名 LOGIN CREATEDB PASSWORD 'パスワード';
としてください。(パスワードは任意のもので構いません。)
1. NestJS CLIのインストール
Node環境を構築完了したら、次にNestJSの環境構築を行います。今回はNestJS CLIを使用します。
$ npm i -g @nestjs/cli
npmのバージョンが古いと以下のようなエラーが発生する可能性があります。
$ npm i -g @nestjs/cli
added 245 packages in 11s
46 packages are looking for funding
run `npm fund` for details
npm notice
npm notice New major version of npm available! 10.9.3 -> 11.5.2
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.5.2
npm notice To update run: npm install -g npm@11.5.2
npm notice
その場合は、画面の指示通りnpm install -g npm@(バージョン)
で対応するnpmをインストールしてから再度npm i -g @nestjs/cli
を実行してください。
2. アプリケーションのプロジェクトを作成
NestJSプロジェクトを作成するために、以下のコマンドを実行します。
<アプリケーション名>
には、作成したい任意のアプリケーションの名前を指定できます。(今回はbackendという名前にしています。)
$ nest new <アプリケーション名>
# 今回は以下のようにbackendという名前でプロジェクトを作成
$ nest new backend
✨ We will scaffold your app in a few seconds..
# 十字キーでカーソルを合わせてEnterを押すと次に進む
? Which package manager would you ❤️ to use? (Use arrow keys)
❯ npm
yarn
pnpm
# 今回はnpmを選択
✔ Which package manager would you ❤️ to use? npm
CREATE backend/.prettierrc (51 bytes)
CREATE backend/README.md (5028 bytes)
CREATE backend/eslint.config.mjs (836 bytes)
CREATE backend/nest-cli.json (171 bytes)
CREATE backend/package.json (1978 bytes)
CREATE backend/tsconfig.build.json (97 bytes)
CREATE backend/tsconfig.json (677 bytes)
CREATE backend/src/app.controller.ts (274 bytes)
CREATE backend/src/app.module.ts (249 bytes)
CREATE backend/src/app.service.ts (142 bytes)
CREATE backend/src/main.ts (228 bytes)
CREATE backend/src/app.controller.spec.ts (617 bytes)
CREATE backend/test/jest-e2e.json (183 bytes)
CREATE backend/test/app.e2e-spec.ts (674 bytes)
✔ Installation in progress... ☕
🚀 Successfully created project backend
👉 Get started with the following commands:
$ cd backend
$ npm run start
Thanks for installing Nest 🙏
Please consider donating to our open collective
to help us maintain this package.
🍷 Donate: https://opencollective.com/nest
実装前のDB準備
Nest.js側は環境構築の章でも記載している通り、NestJSをゼロから学ぶを参考に進めています。(サイトではDockerを使用していますが、開発段階ではDockerは使用しておりません。)
TypeORMのセットアップ
次のコマンドを入力し、TypeORMをインストールします。
$ npm install --save @nestjs/typeorm typeorm pg
続いてTypeORMの設定ファイルを作成します。アプリケーションのルートディレクトリ直下にconfig
フォルダを作成し、typeorm.config.ts
というファイルを作成します。内容は以下の通りです。
(database
、username
はPostgresの環境構築で作成したデータベース名、ユーザー名になります。password
は任意のもので構いません。)
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'postgres',
host: 'localhost',
port: 5432,
database: '<データベース名>',
username: '<ユーザー名>',
password: '<パスワード>',
entities: ['dist/**/entities/**/*.entity.js'],
migrations: ['dist/**/migrations/**/*.js'],
logging: true,
synchronize: false,
});
データベース接続に使用する環境変数の定義
データベースに接続するために必要な情報を、環境変数でアプリケーションに渡し、NestJSで環境変数を取得するライブラリとして、@nestjs/config
を使用します。次のコマンドでインストール可能です。
$ npm install --save @nestjs/config
インストールが終わったらこのライブラリの設定ファイルであるconfiguration.ts
をconfig
ディレクトリ内に作成し、以下の内容を記載します。
export default () => ({
database: {
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
username: process.env.DATABASE_USER || '<ユーザー名>',
password: process.env.password || '<パスワード>',
name: process.env.database || '<データベース名>',
},
});
configuration.ts
やtypeorm.config.ts
をGithub等にアップロードする場合はDB情報が見えてしまうので環境変数への記載を推奨します。
その場合は、まずdotenvをインストールします。
$ npm i dotenv
インストールしたらアプリケーションのルートディレクトリ直下に.env
ファイルを作成し、環境変数を設定します。
DATABASE_HOST="localhost"
DATABASE_PORT=5432
DATABASE_NAME="<データベース名>"
DATABASE_USER="<ユーザー名>"
DATABASE_PASSWORD="<パスワード>"
次にconfiguration.ts
とtypeorm.config.ts
を以下のように編集します。
import 'dotenv/config';
export default () => ({
database: {
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
name: process.env.DATABASE_NAME,
},
});
import 'dotenv/config';
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
database: process.env.DATABASE_NAME,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
entities: ['dist/**/entities/**/*.entity.js'],
migrations: ['dist/**/migrations/**/*.js'],
logging: true,
synchronize: false,
});
それではDBを作成していきましょう。
$ psql -d postgres
psql (16.9 (Ubuntu 16.9-0ubuntu0.24.04.1))
Type "help" for help.
postgres=> CREATE DATABASE データベース名;
実装(認証編)
下準備
まずはJWT認証に必要となるライブラリをインストールします。
$ npm install @nestjs/passport @nestjs/jwt bcrypt passport passport-local passport-jwt
$ npm i -D @types/passport @types/passport-jwt @types/passport-local
1. ユーザー認証のRest APIを作成
NestJS CLI(nest
コマンド)を使用してCRUD処理のファイルを作成します。
(今回はusers
としています。また、usersのdtoは使用しないのでdto
ディレクトリごと削除してください。)
$ nest g resource users
dtoディレクトリを削除したため、users.controller.ts
とusers.sevice.ts
の不要なものは削除します。
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
// import { CreateUserDto } from './dto/create-user.dto'; 削除
// import { UpdateUserDto } from './dto/update-user.dto'; 削除
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
/* 削除
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
*/
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
/* 削除
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
*/
}
import { Injectable } from '@nestjs/common';
// import { CreateUserDto } from './dto/create-user.dto'; 削除
// import { UpdateUserDto } from './dto/update-user.dto'; 削除
@Injectable()
export class UsersService {
/* 削除
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
}
*/
findAll() {
return `This action returns all users`;
}
findOne(id: number) {
return `This action returns a #${id} user`;
}
/* 削除
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
}
remove(id: number) {
return `This action removes a #${id} user`;
}
*/
}
1.1. データベースと接続してテーブルを作成
データベースと接続し、アプリケーションからTypeORMを使用してテーブルを作成するように記述します。
1.1.1. エンティティを定義
まずはsrc/users/users.entity/ts
を編集してDBのテーブル定義を行います。
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class User extends BaseEntity {
@PrimaryGeneratedColumn({
comment: 'ユーザーID',
})
readonly id: number;
@Column('varchar', { comment: 'ユーザー名' })
name: string;
@Column('varchar', { comment: 'メールアドレス' })
email: string;
@Column('uuid', {
name: 'image_id',
nullable: true,
comment: 'プロフィール画像'
})
imageId: string | null;
@Column('text', { default: '', comment: '自己紹介' })
introduction: string;
@Column('text', {
name: 'hashed_password',
nullable: true,
comment: 'パスワード',
})
hashedPassword: string | null;
@Column('text', {
name: 'image_url',
nullable: true,
comment: '外部プロフィール画像URL',
})
imageUrl: string | null;
@CreateDateColumn({ name: 'created_at', comment: '作成日時' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', comment: '更新日時' })
updatedAt: Date;
constructor(name: string) {
super();
this.name = name;
}
}
1.1.2. TypeORMモジュールにエンティティを登録
先ほど作成したエンティティをTypeORMモジュールに登録し、紐付けます。src/users/users.module.ts
を編集します。
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
// 以下2つ追加
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
@Module({
controllers: [UsersController],
providers: [UsersService],
// 追加
imports: [TypeOrmModule.forFeature([User])],
})
export class UsersModule {}
1.1.3. TypeORMモジュールにアプリケーションを登録
次にsrc/app.module.ts
を編集して、TypeORMモジュールをアプリケーションに登録します。
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// 追加
import { UsersModule } from './users/users.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from '../config/configuration';
import { TypeOrmModule } from '@nestjs/typeorm';
// ここまで
@Module({
// 追加 >>>>>>>>>>>>>>>>>>>>>>>>>>
imports: [
UsersModule,
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('database.host'),
port: configService.get('database.port'),
username: configService.get('database.username'),
password: configService.get('database.password'),
database: configService.get('database.name'),
entities: ['dist/**/entities/**/*.entity.js'],
synchronize: false,
}),
inject: [ConfigService],
}),
],
// ここまで >>>>>>>>>>>>>>>>>>>>>>>
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
1.1.4. TypeORMでマイグレーションする準備
users.module.ts
で定義したエンティティがTypeORMモジュールに紐づいたので、マイグレーションを行い、PostgreSQLデータベースにテーブルを作成していきます。まずはマイグレーションファイルを作成します。
マイグレーションファイル作成の前に、TypeORMのCLIツールをnpmスクリプトとして実行できるようにするため、アプリケーションのルートディレクトリ直下にあるpackage.jsonファイルに以下を追記します。
{
...
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
// ここから
"typeorm": "ts-node ./node_modules/typeorm/cli",
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./config/typeorm.config.ts",
"typeorm:generate-migration": "npm run typeorm -- -d ./config/typeorm.config.ts migration:generate ./migrations/$npm_config_name",
"typeorm:revert-migration": "npm run typeorm -- -d ./config/typeorm.config.ts migration:revert",
// ここまで
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
...
}
1.1.5. マイグレーションファイル作成
マイグレーションファイルを作成します。typeorm:generate-migration
を実行するとマイグレーションファイルを作成できます。
typeorm:generate-migration
を実行する前に、dist
ディレクトリを作成する必要があります。
dist
ディレクトリはnpm run start:dev
を実行すると作成されます。
$ npm run start:dev
$ npm run typeorm:generate-migration --name=CreateUsers
1.1.6. マイグレーションファイルの実行
上記1.1.5で作成したファイルをtypeorm:run-migrations
で実行してマイグレーションします。
$ npm run typeorm:run-migrations
query: SELECT version()
query: SELECT * FROM current_schema()
query: SELECT * FROM "information_schema"."tables" WHERE "table_schema" = 'public' AND "table_name" = 'migrations'
query: CREATE TABLE "migrations" ("id" SERIAL NOT NULL, "timestamp" bigint NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_8c82d7f526340ab734260ea46be" PRIMARY KEY ("id"))
query: SELECT * FROM "migrations" "migrations" ORDER BY "id" DESC
0 migrations are already loaded in the database.
1 migrations were found in the source code.
1 migrations are new migrations must be executed.
query: START TRANSACTION
query: CREATE TABLE "users" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "email" character varying NOT NULL, "image_id" uuid NOT NULL, "introduction" text, "hashed_password" character varying NOT NULL, "image_url" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")); COMMENT ON COLUMN "users"."id" IS 'ユーザーID'; COMMENT ON COLUMN "users"."name" IS 'ユーザー名'; COMMENT ON COLUMN "users"."email" IS 'メールアドレス'; COMMENT ON COLUMN "users"."image_id" IS 'プロフィール画像'; COMMENT ON COLUMN "users"."introduction" IS '自己紹介'; COMMENT ON COLUMN "users"."hashed_password" IS 'パスワード'; COMMENT ON COLUMN "users"."image_url" IS '外部プロフィール画像URL'; COMMENT ON COLUMN "users"."created_at" IS '作成日時'; COMMENT ON COLUMN "users"."updated_at" IS '更新日時'
query: INSERT INTO "migrations"("timestamp", "name") VALUES ($1, $2) -- PARAMETERS: [1753943067672,"CreateUsers1753943067672"]
Migration CreateUsers1753943067672 has been executed successfully.
query: COMMIT
これでテーブルが作成できました。PostgreSQLで実際に確認できます。
...
query: SELECT version()
query: SELECT * FROM current_schema()
query: SELECT * FROM "information_schema"."tables" WHERE "table_schema" = 'public' AND "table_name" = 'migrations'
query: CREATE TABLE "migrations" ("id" SERIAL NOT NULL, "timestamp" bigint NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_8c82d7f526340ab734260ea46be" PRIMARY KEY ("id"))
query: SELECT * FROM "migrations" "migrations" ORDER BY "id" DESC
No migrations are pending
上記のように出た場合は、npm run build
してから再度npm run typeorm:run-migrations
を実行してください。
$ psql -d postgres
psql (16.9 (Ubuntu 16.9-0ubuntu0.24.04.1))
Type "help" for help.
postgres=> \c <データベース名>
You are now connected to database "<データベース名>" as user "<ユーザー名>".
<データベース名>=> \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+----------+----------
public | migrations | table | <ユーザー名>
public | migrations_id_seq | sequence | <ユーザー名>
public | users | table | <ユーザー名>
public | users_id_seq | sequence | <ユーザー名>
(4 rows)
<データベース名>=> select * from users;
id | name | email | image_id | introduction | hashed_password | image_url | created_at | updated_at
----+------+-------+----------+--------------+-----------------+-----------+------------+------------
(0 rows)
2. サインアップ用のRest APIを作成
NestJS CLI(nest
コマンド)を使用してCRUD処理のファイルを作成します。
(今回はauth
としています。)
$ nest g resource auth
今回は認証成功したらユーザーが登録されるようにするため、作成されたファイルの内、entities/auth.entity.ts
とcreate-auth.dto.ts
、update-auth.dto.ts
は不要のため削除します。
2.1. DTOの作成とバリデーション
DTOはデータ構造を定義するクラスであり、そのデータ構造をバリデーションするパッケージに、class-validator
というものがあるため、そちらを利用します。
$ npm i --save class-validator class-transformer
このパッケージを利用するようにsrc/auth/dto/signup.dto.ts
を作成し、編集します。
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { Match } from '../../common/decorators/match.decorator';
export class SignupDto {
@IsString()
@IsNotEmpty({ message: '入力必須です' })
name: string;
@IsEmail({}, { message: 'メールアドレスが無効です' })
@IsNotEmpty({ message: '入力必須です' })
email: string;
@IsNotEmpty()
@IsString()
imageId: string | null;
@IsString()
@IsNotEmpty({ message: '入力必須です' })
@MinLength(6)
password: string;
@IsString()
@IsNotEmpty({ message: '入力必須です' })
@Match('password', { message: 'パスワードが一致していません' })
confirmPassword: string;
@IsOptional()
@IsString()
introduction: string;
}
また、パスワード一致しているかのチェック用のカスタムデコレータを作成していきます。src/common/decorators
ディレクトリにmatch.decorator.ts
ファイルを作成し、編集します。
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export function Match(property: string, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'Match',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property],
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return value === relatedValue;
},
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
return `${args.property} must match ${relatedPropertyName}`;
},
},
});
};
}
定義したDTOでバリデーションを行うパイプとしてValidationPipeを使用し、main.ts
で設定しています。(useGlobalPipesでグローバルに設定することも可能)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 追加>>>>>
app.useGlobalPipes(new ValidationPipe())
// <<<<<
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
2.2. コントローラにPostメソッドを追加
src/auth/auth.controller.ts
にユーザーの新規登録を行うHTTPリクエスト処理を記述します。
import {
Body,
Controller,
Post,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignupDto } from './dto/signup.dto';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('signup')
signUp(@Body() dto: SignupDto) {
return this.authService.signUp(dto);
}
}
AuthControllerクラスに@Post
デコレータを追加して、POSTメソッドを定義しています。@Body
で、リクエストボディを取得しています。
2.3. サービスにcreateメソッドを追加
続いてsrc/auth/auth.service.ts
を編集して、ユーザーを作成するプログラムを記述していきます。
import {
ForbiddenException,
Injectable,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { Repository } from 'typeorm';
import { SignupDto } from './dto/signup.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async signUp(dto: SignupDto) {
const hashed = await bcrypt.hash(dto.password, 12);
try {
const user = this.userRepo.create({
name: dto.name,
email: dto.email,
imageId: dto.imageId,
hashedPassword: hashed,
introduction: dto.introduction,
});
await this.userRepo.save(user);
return user;
} catch (error) {
if (error.code === '23505') {
throw new ForbiddenException(
'このメールアドレスは既に登録されています',
);
}
throw error;
}
}
}
AuthService
クラスにcreate()
メソッドでユーザーを作成し、await this.userRepo.save(user)
でデータベースに保存しています。
2.4. 作成したAPIの動作確認
動作確認の前に、変更を加える箇所があります。
まず、app.module.ts
にAuthModuleを入れます。
import { UsersModule } from './users/users.module';
// 追加
import { AuthModule } from './auth/auth.module'; // ←ここ!
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from '../config/configuration';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
// AuthModuleを追加
imports: [
UsersModule,
AuthModule, // ←ここ!
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('database.host'),
port: configService.get('database.port'),
username: configService.get('database.username'),
password: configService.get('database.password'),
database: configService.get('database.name'),
entities: ['dist/**/entities/**/*.entity.js'],
synchronize: false,
}),
inject: [ConfigService],
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
次に、auth.module.ts
を編集します。
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
// 👇 この2行を追加
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
@Module({
// 👇 importとTypeOrmModuleを追加
imports: [
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
作成したサインアップのAPIの動作を確認します。curl
メソッドで確認することもできますが、今回はPostmanを使用して動作を確認します。
問題なく登録できていることを確認できました。
2.5. サービスのテストを作成
動作することを確認したらサービスのユニットテストを実施します。
サインアップで確認するテスト項目は 正常系(ユーザー登録ができてかつ、登録ユーザーを返す) と、 異常系(メールアドレスが重複してそのエラーを返す) の2つです。
詳細に書くと以下の通りです。
-
正常系
-
bcrypt.hash
が呼ばれていることを確認する- 引数が
(dto.password, 12)
で渡されていることを期待する
- 引数が
-
userRepo.create
が呼ばれていることを確認する- 渡された引数が dto の値+ hashedPassword であることを期待する
-
userRepo.save
が呼ばれていることを確認する- 戻り値として
user
オブジェクトを返すよう mock しておく
- 戻り値として
- signUp メソッドの戻り値が
user
オブジェクトであることを確認する
-
-
異常系(ユニーク制約違反)
-
userRepo.save
がerror.code = '23505'
を投げるように mock する -
signUp
呼び出し時にForbiddenException
が throw されることを確認する - 例外メッセージが「このメールアドレスは既に登録されています」であることを確認する
-
-
その他例外
-
userRepo.save
が'23505'
以外のエラーを投げるように mock する -
signUp
呼び出し時にそのまま同じエラーが throw されることを確認する
-
-
追加観点
-
userRepo.create
,userRepo.save
,bcrypt.hash
が正しい回数呼ばれているか確認する -
bcrypt.hash
の戻り値が正しくhashedPassword
として保存されているか確認する -
save
後の戻り値が正しく返却されるか確認する
-
ではauth.service.spec.ts
にテストコードを記述していきましょう。
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
import { SignupDto } from './dto/signup.dto';
// --- 準備 (beforeEach の方針) ---
// - bcrypt をモック化する(hash を jest.fn() に差し替え)
// - AuthService をテスト対象として利用する
// - UserRepository をモックして DI する
// - TestingModule を作成し、AuthController と AuthService を登録
// - service (AuthService のインスタンス) を取り出す
jest.mock('bcrypt', () => ({
hash: jest.fn(), // bcrypt.hash をモック化
}));
describe('AuthService', () => {
let service: AuthService;
let userRepo: jest.Mocked<Repository<User>>;
beforeEach(async () => {
// 各依存サービスのモック定義(Repository の代替実装)
userRepo = {
create: jest.fn(), // User エンティティ作成のモック
save: jest.fn(), // User 保存処理のモック
} as any;
// テスト用のモジュールを作成する
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController], // AuthController をテスト対象に含める
providers: [
AuthService, // テスト対象のサービス
{ provide: getRepositoryToken(User), useValue: userRepo }, // UserRepository をモックで差し替え
],
}).compile();
// AuthService のインスタンスを取得
service = module.get<AuthService>(AuthService);
});
// AuthService が正しくインスタンス化されているかの確認
it('should be defined', () => {
expect(service).toBeDefined();
});
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
describe('signUp()', () => {
// - 共通で使う dto を用意(name, email, password, confirmPassword, imageId, introduction)
const dto: SignupDto = {
name: 'dummy',
email: 'test@dummy.com',
imageId: '5d78f017-ef80-fbbf-3aad-f3f5d6c10043',
password: 'dummy123',
confirmPassword: 'dummy123',
introduction: 'Hello',
};
const createdUser = {
id: 1,
name: dto.name,
email: dto.email,
imageId: dto.imageId,
introduction: dto.introduction,
hashedPassword: 'hashed',
imageUrl: null,
createdAt: now,
updatedAt: now,
};
// --- 正常系テスト ---
it('正常にユーザー登録ができ、登録したユーザーを返す', async () => {
// 1. bcrypt.hash が呼ばれていることを spy で確認する(jest.mockを使ってモックしているのでspyOnは利用しない)
const jestHash = (bcrypt.hash as jest.Mock).mockResolvedValue('hashed');
// 2. userRepo.create が呼ばれていることを spy で確認する(userRepo を as any でモックしているのでspyOnは利用しない)
(userRepo.create as jest.Mock).mockReturnValue(createdUser);
// 3. userRepo.save が呼ばれていることを spy で確認する(userRepo を as any でモックしているのでspyOnは利用しない)
// - 戻り値として user オブジェクトを返すよう mock しておく
(userRepo.save as jest.Mock).mockResolvedValue(createdUser);
const result = await service.signUp(dto);
// - 引数が (dto.password, 12) で渡されていることを期待する
expect(jestHash).toHaveBeenCalledWith(dto.password, 12);
// --- 追加観点 ---
// bcrypt.hash が正しい回数呼ばれているか確認する
expect(jestHash).toHaveBeenCalledTimes(1);
// bcrypt.hash の戻り値が正しく hashedPassword として保存されているか確認する
expect(result.hashedPassword).toBe('hashed');
// - 渡された引数が dto の値+ hashedPassword であることを期待する
expect(userRepo.create).toHaveBeenCalled();
expect(userRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: dto.name,
email: dto.email,
imageId: dto.imageId,
introduction: dto.introduction,
hashedPassword: 'hashed',
}),
);
// --- 追加観点 ---
// userRepo.create が正しい回数呼ばれているか確認する
expect(userRepo.create).toHaveBeenCalledTimes(1);
// 4. signUp メソッドの戻り値が user オブジェクトであることを確認する
expect(userRepo.save).toHaveBeenCalledWith(createdUser);
expect(result).toEqual(createdUser);
// --- 追加観点 ---
// - userRepo.save が正しい回数呼ばれているか確認する
// - save 後の戻り値が正しく返却されるか確認する
expect(userRepo.save).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
id: 1,
email: dto.email,
hashedPassword: 'hashed',
});
});
// --- 異常系テスト(ユニーク制約違反) ---
it('メールアドレスが重複しており、登録できないかつエラーメッセージが表示される', async () => {
(userRepo.create as jest.Mock).mockReturnValue(createdUser);
// 1. userRepo.save が error.code = '23505' を投げるように mock する
(userRepo.save as jest.Mock).mockRejectedValue({ code: '23505' });
// 2. signUp 呼び出し時に ForbiddenException が throw されることを確認する
// 3. 例外メッセージが「このメールアドレスは既に登録されています」であることを確認する
await expect(service.signUp(dto)).rejects.toThrow(
'このメールアドレスは既に登録されています',
);
// --- その他の例外 ---
// 1. userRepo.save が '23505' 以外のエラーを投げるように mock する
// 2. signUp 呼び出し時にそのまま同じエラーが throw されることを確認する
(userRepo.save as jest.Mock).mockRejectedValue(new Error('DB error'));
await expect(service.signUp(dto)).rejects.toThrow('DB error');
});
});
});
テストコードを記述したら実際にテストを実行します。
$ npm run test
以下のような結果が返ってきたらテスト成功です!!
2.6. コントローラーのテストを作成
サービスのテストが完成したので次はコントローラーのテストを作成していきましょう。
テストで確認する項目は大きく正常系(正しくServiceを呼んでいるか) と、 異常系(例外を投げる) の2つです。
-
正常系
-
signUp
が呼ばれたとき、authService.signUp
が正しい引数で呼ばれること -
authService.signUp
が返した値をそのまま返すこと
-
-
異常系(例外)
-
authService.signUp
が例外(ForbiddenException
など)を投げたとき- → Controller からも同じ例外が伝播することを確認する
-
-
追加観点
- 返却
shape
の柔軟性 -
@Post('signup')
デコレータが付与されているか(ユニットでやるため、Reflect
経由の軽いチェック) -
@Body()
で DTO がバインドされること(ValidationPipe
を組み合わせたときの挙動確認) -
authService.signUp
の呼ばれる回数が1回であること
- 返却
ではauth.controller.spec.ts
にテストコードを記述していきましょう。
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { ForbiddenException, RequestMethod } from '@nestjs/common';
import 'reflect-metadata';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
describe('AuthController', () => {
let controller: AuthController;
let service: jest.Mocked<AuthService>;
// --- 準備(beforeEach の方針)---
beforeEach(async () => {
// 各依存サービスのモック定義
// - AuthService をモックして DI する(signUp を jest.fn())
const serviceMock: Partial<jest.Mocked<AuthService>> = {
// コントローラ内で呼ばれるメソッドだけ形だけ用意
signUp: jest.fn(),
// ほか必要なら追加
};
// テスト用のモジュールを作成する
// - TestingModule に controller: [AuthController], providers: [{ provide: AuthService, useValue: mock }] を登録
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [{ provide: AuthService, useValue: serviceMock }],
}).compile();
// - controller と service を取り出す
controller = module.get<AuthController>(AuthController);
service = module.get(AuthService) as jest.Mocked<AuthService>;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
describe("@Post('signup')", () => {
// - 共通で使う dto を用意(name, email, password, confirmPassword, imageId, introduction)
const dto: SignupDto = {
name: 'dummy',
email: 'test@dummy.com',
password: 'dummy123',
confirmPassword: 'dummy123',
imageId: '5d78f017-ef80-fbbf-3aad-f3f5d6c10043',
introduction: 'Hello, Elden',
};
// - 共通で使う user を用意(id, email, ...)
const user = {
id: 1,
name: dto.name,
email: dto.email,
imageId: dto.imageId,
introduction: dto.introduction,
hashedPassword: 'hashed',
imageUrl: null,
createdAt: now,
updatedAt: now,
};
// --- 正常系テスト ---
it('Controllerが正しくServiceを呼び、正しい戻り値を返すか', async () => {
// 3) dto の改変をしないこと(必要なら)
// - signUp 呼び出し前後で dto が変更されていないことを確認(オブジェクトの一部を検査)
const before = structuredClone(dto);
// 1) service.signUp の戻り値(例: user or { id, email, ... })をそのまま返すこと
// - mockResolvedValueOnce した値と toEqual / toMatchObject で一致確認
(service.signUp as jest.Mock).mockResolvedValueOnce(user);
const result = await controller.signUp(dto);
expect(result).toEqual(expect.objectContaining(user));
// 2) controller.signUp(dto) を呼ぶと、service.signUp が一度だけ呼ばれること
// - 引数が dto(そのままの参照 or 値)であることを toHaveBeenCalledWith で確認
// - 呼び出し回数 toHaveBeenCalledTimes(1)
expect(service.signUp).toHaveBeenCalledWith(
expect.objectContaining({
name: 'dummy',
email: 'test@dummy.com',
password: 'dummy123',
confirmPassword: 'dummy123',
imageId: '5d78f017-ef80-fbbf-3aad-f3f5d6c10043',
introduction: 'Hello, Elden',
}),
);
expect(service.signUp).toHaveBeenCalledTimes(1);
// 3) dto の改変をしないこと(必要なら)
// - signUp 呼び出し前後で dto が変更されていないことを確認(オブジェクトの一部を検査)
expect(dto).toEqual(before);
});
// 異常系テスト
it('service.signUpの例外(ForbiddenExceptionなど)と同じ例外を返すか', async () => {
// 4) service.signUp が ForbiddenException を投げた場合
// - controller.signUp も同じ例外を reject すること(rejects.toThrow(ForbiddenException))
// - メッセージが一致するならメッセージも確認
(service.signUp as jest.Mock).mockRejectedValue(
new ForbiddenException('このメールアドレスは既に登録されています'),
);
await expect(controller.signUp(dto)).rejects.toThrow(
'このメールアドレスは既に登録されています',
);
// 6) 異常時に余計な呼び出しが発生していないこと(必要なら)
// - 失敗後に他のメソッドが呼ばれていないことを確認(今回は signUp だけなので回数確認で十分)
expect(service.signUp).toHaveBeenCalledTimes(1);
});
it('service.signUp が汎用的な Error を投げた場合、 controller.signUp も同じエラーをそのまま投げるか', async () => {
// 5) service.signUp が汎用的な Error を投げた場合
// - controller.signUp も同じエラーをそのまま投げること(変換や握りつぶしをしない)
(service.signUp as jest.Mock).mockRejectedValue(new Error('DB error'));
await expect(controller.signUp(dto)).rejects.toThrow('DB error');
// 6) 異常時に余計な呼び出しが発生していないこと(必要なら)
// - 失敗後に他のメソッドが呼ばれていないことを確認(今回は signUp だけなので回数確認で十分)
expect(service.signUp).toHaveBeenCalledTimes(1);
});
// 追加観点
// 7) 返却 shape の柔軟性(toMatchObject で主要項目だけ確認)
it('Service が追加フィールドを返しても Controller は素通しで返すか', async () => {
(service.signUp as jest.Mock).mockResolvedValue({
id: 1,
name: dto.name,
email: dto.email,
imageId: dto.imageId,
introduction: dto.introduction,
hashedPassword: 'hashed',
imageUrl: null,
createdAt: now,
updatedAt: now,
// 以下の余計なフィールドを含めてモックする
extra: 'extra',
});
const result = await controller.signUp(dto);
expect(result).toMatchObject(user);
expect(result).toHaveProperty('extra', 'extra');
});
// 8) デコレーターの存在確認(軽め)
it("@Post('signup') が付いているか", async () => {
// AuthController.prototype から signUp メソッドを参照
const target = AuthController.prototype;
const propertyKey = 'signUp';
// Reflect.getMetadata を使って Nest が定義しているメタデータを取得
const routePath = Reflect.getMetadata(PATH_METADATA, target[propertyKey]);
const requestMethod = Reflect.getMetadata(
METHOD_METADATA,
target[propertyKey],
);
// パスが'signup'であることを確認
expect(routePath).toBe('signup');
// HTTPメソッドが POST であることを確認
expect(requestMethod).toBe(RequestMethod.POST);
});
// 8) デコレーターの存在確認(軽め)
it("@Controller('api/auth')が付いているか", async () => {
// クラスレベルの PATH=METADATA を取得
const controllerPath = Reflect.getMetadata(PATH_METADATA, AuthController);
// 'api/auth'であることを確認
expect(controllerPath).toBe('api/auth');
});
});
});
コントローラもテストコードを記述したら実際にテストを実行します。
$ npm run test
サービス同様に以下のような結果が返ってきたらテスト成功です!!
まとめ
ここまで読んでいただきありがとうございました!以上で、サインアップの実装は完了となります。
冒頭でも記述しましたが、すべての実装を1つの記事にしようとするとものすごい長さの記事になってしまうと思ったため、機能ごとに分けて投稿したいと思います。
次はログイン編を作成したいと思います!
もしよければそちらもご覧ください!