Framework/NestJS
[nestjs] jest를 사용한 테스트 코드 작성(단위, e2e)
bright jazz music
2024. 12. 26. 18:13
1. 단위 테스트
1.1. members.controller.specs.ts
// src/members/members.controller.spec.ts
// 회원 관리 컨트롤러 테스트
import { Test, TestingModule } from '@nestjs/testing';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
import { AuthProvider } from '@common/enums';
describe('MembersController', () => {
let controller: MembersController;
let service: MembersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MembersController],
providers: [
{
provide: MembersService,
useValue: {
create: jest.fn(),
findOne: jest.fn(),
// ... 다른 메서드들
},
},
],
}).compile();
controller = module.get<MembersController>(MembersController);
service = module.get<MembersService>(MembersService);
});
describe('create', () => {
it('should create a member', async () => {
const dto = {
email: 'test@example.com',
password: 'password123',
termsAgreed: true,
privacyAgreed: true,
marketingAgreed: false,
provider: AuthProvider.LOCAL,
};
await controller.create(dto);
expect(service.create).toHaveBeenCalledWith(dto);
});
});
});
1.2. members.service.specs.ts
// src/members/members.service.spec.ts
// 회원 관리 서비스 테스트
import { Test, TestingModule } from '@nestjs/testing';
import { MembersService } from './members.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Member } from './entities/member.entity';
import { Repository } from 'typeorm';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import { ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { AuthProvider, MemberStatus } from '@common/enums';
import { EmailService } from '@common/services/email.service';
describe('MembersService', () => {
let service: MembersService;
let repository: Repository<Member>;
let emailService: EmailService;
const mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
};
const mockEmailService = {
send: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MembersService,
{
provide: getRepositoryToken(Member),
useValue: mockRepository,
},
{
provide: EmailService,
useValue: mockEmailService,
},
],
}).compile();
service = module.get<MembersService>(MembersService);
repository = module.get<Repository<Member>>(getRepositoryToken(Member));
emailService = module.get<EmailService>(EmailService);
});
describe('create', () => {
const createMemberDto: CreateMemberDto = {
email: 'test@example.com',
password: 'password123',
provider: AuthProvider.LOCAL,
termsAgreed: true,
privacyAgreed: true,
marketingAgreed: false
};
it('필수 약관 미동의 시 예외가 발생해야 함', async () => {
const invalidDto = {
...createMemberDto,
termsAgreed: false
};
await expect(service.create(invalidDto)).rejects.toThrow(BadRequestException);
});
it('회원가입 성공 시 이메일이 발송되어야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
const emailServiceSpy = jest.spyOn(emailService, 'send');
await service.create(createMemberDto);
expect(emailServiceSpy).toHaveBeenCalled();
});
it('비밀번호가 해시되어 저장되어야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
const savedMember = jest.fn();
mockRepository.save.mockImplementation((member) => {
savedMember(member);
return member;
});
await service.create(createMemberDto);
// save 호출 시 전달된 엔티티의 password를 검증
expect(savedMember).toHaveBeenCalledWith(
expect.objectContaining({
password: expect.stringMatching(/^\$2[aby]\$\d{1,2}\$[./A-Za-z0-9]{53}$/)
})
);
});
it('약관 동의 시간이 기록되어야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
mockRepository.save.mockImplementation(member => member);
const result = await service.create(createMemberDto);
expect(result.termsAgreedAt).toBeInstanceOf(Date);
expect(result.privacyAgreedAt).toBeInstanceOf(Date);
expect(result.marketingAgreedAt).toBeUndefined(); // marketingAgreed가 false이므로
});
});
describe('findOneByUuid', () => {
const uuid = 'test-uuid';
const mockMember = {
uuid,
email: 'test@example.com',
status: MemberStatus.ACTIVE,
};
it('UUID로 회원을 찾아야 함', async () => {
mockRepository.findOne.mockResolvedValue(mockMember);
const result = await service.findOneByUuid(uuid);
expect(result).toBeDefined();
expect(result.uuid).toBe(uuid);
});
it('회원이 없으면 NotFoundException을 발생시켜야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOneByUuid(uuid)).rejects.toThrow(
NotFoundException,
);
});
});
describe('update', () => {
const uuid = 'test-uuid';
const updateMemberDto: UpdateMemberDto = {
// nickname: '새로운닉네임',
};
const mockMember = {
uuid,
email: 'test@example.com',
nickname: '기존닉네임',
};
it('회원 정보를 업데이트해야 함', async () => {
mockRepository.findOne.mockResolvedValue(mockMember);
mockRepository.save.mockResolvedValue({
...mockMember,
...updateMemberDto,
});
const result = await service.update(uuid, updateMemberDto);
// expect(result.nickname).toBe(updateMemberDto.nickname);
expect(mockRepository.save).toHaveBeenCalled();
});
it('존재하지 않는 회원이면 NotFoundException을 발생시켜야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.update(uuid, updateMemberDto)).rejects.toThrow(
NotFoundException,
);
});
it('마케팅 동의 상태 변경 시 동의 시간이 기록되어야 함', async () => {
const uuid = 'test-uuid';
const member = {
uuid,
marketingAgreed: false,
marketingAgreedAt: null,
};
const updateDto = {
marketingAgreed: true,
};
mockRepository.findOne.mockResolvedValue(member);
mockRepository.save.mockImplementation(m => ({
...m,
marketingAgreedAt: expect.any(Date),
}));
const result = await service.update(uuid, updateDto);
expect(result.marketingAgreed).toBe(true);
expect(result.marketingAgreedAt).toBeDefined();
});
it('비밀번호 변경 시 해시되어 저장되어야 함', async () => {
const uuid = 'test-uuid';
const member = {
uuid,
password: 'old-hashed-password',
};
const updateDto = {
password: 'NewPassword123!',
};
mockRepository.findOne.mockResolvedValue(member);
const savedMember = jest.fn();
mockRepository.save.mockImplementation((member) => {
savedMember(member);
return member;
});
await service.update(uuid, updateDto);
// save 호출 시 전달된 엔티티의 password를 검증
expect(savedMember).toHaveBeenCalledWith(
expect.objectContaining({
password: expect.stringMatching(/^\$2[aby]\$\d{1,2}\$[./A-Za-z0-9]{53}$/),
passwordChangedAt: expect.any(Date)
})
);
});
});
describe('softDelete', () => {
const uuid = 'test-uuid';
it('회원을 소프트 삭제해야 함', async () => {
mockRepository.softDelete.mockResolvedValue({ affected: 1 });
await service.softDelete(uuid);
expect(mockRepository.softDelete).toHaveBeenCalledWith({ uuid });
});
it('존재하지 않는 회원이면 NotFoundException을 발생시켜야 함', async () => {
mockRepository.softDelete.mockResolvedValue({ affected: 0 });
await expect(service.softDelete(uuid)).rejects.toThrow(NotFoundException);
});
});
describe('email verification', () => {
it('이메일 인증 토큰이 유효하면 회원 상태가 ACTIVE로 변경되어야 함', async () => {
const token = 'valid-token';
const member = {
verificationToken: token,
status: MemberStatus.PENDING,
verificationTokenExpiresAt: new Date(Date.now() + 3600000),
};
mockRepository.findOne.mockResolvedValue(member);
const result = await service.verifyEmail(token);
expect(result.status).toBe(MemberStatus.ACTIVE);
expect(result.emailVerified).toBe(true);
});
it('만료된 토큰으로 인증 시 새로운 토큰이 발급되어야 함', async () => {
const token = 'expired-token';
const member = {
verificationToken: token,
status: MemberStatus.PENDING,
verificationTokenExpiresAt: new Date(Date.now() - 3600000),
};
mockRepository.findOne.mockResolvedValue(member);
const emailServiceSpy = jest.spyOn(emailService, 'send');
await expect(service.verifyEmail(token)).rejects.toThrow(BadRequestException);
expect(emailServiceSpy).toHaveBeenCalled();
});
it('잘못된 토큰으로 인증 시 NotFoundException이 발생해야 함', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.verifyEmail('invalid-token')).rejects.toThrow(NotFoundException);
});
it('이미 인증된 회원은 BadRequestException이 발생해야 함', async () => {
const token = 'valid-token';
const member = {
verificationToken: token,
status: MemberStatus.ACTIVE,
emailVerified: true,
verificationTokenExpiresAt: new Date(Date.now() + 3600000),
};
mockRepository.findOne.mockResolvedValue(member);
await expect(service.verifyEmail(token)).rejects.toThrow(BadRequestException);
});
});
describe('password security', () => {
it('비밀번호 변경 시 토큰 버전이 증가해야 함', async () => {
const uuid = 'test-uuid';
const member = {
uuid,
tokenVersion: 1,
};
mockRepository.findOne.mockResolvedValue(member);
await service.update(uuid, { password: 'newPassword123!' });
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
tokenVersion: 2,
}),
);
});
});
describe('findOneByEmailWithPassword', () => {
it('이메일로 회원을 찾을 때 필요한 필드만 조회해야 함', async () => {
const email = 'test@example.com';
mockRepository.findOne.mockResolvedValue({
id: 1,
uuid: 'test-uuid',
email,
status: MemberStatus.ACTIVE,
loginAttempts: 0,
});
const result = await service.findOneByEmailWithPassword(email);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('uuid');
expect(result).toHaveProperty('email');
expect(result).toHaveProperty('status');
expect(result).toHaveProperty('loginAttempts');
expect(result).not.toHaveProperty('password'); // 비밀번호는 기본적으로 조회되지 않아야 함
});
});
});
2. e2e 테스트 디렉토리 설정 및 전체 테스트 코드 작성
2.1. 테스트 디렉토리 설정 파일 작성
루트 디렉토리(프로젝트 디렉토리, src의 상위 디렉토리)에 존재하는 test디렉토리에 jest-e2e.json 파일을 만들고 아래의 내용을 적어준다. json파일은 내외로 주석을 달면 에러가 발생하기 때문에 여기서는 달지 않았다.
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
2.2 애플리케이션 전체 테스트 코드 작성
// test/app.e2e-spec.ts
// 애플리케이션 전체 테스트
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200);
});
});
3. members의 e2e 테스트 작성
test/e2e/members 디렉토리 만들고 아래 파일 작성
// test/e2e/members/members.e2e-spec.ts
// 회원 관리 테스트
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../../src/app.module';
describe('MembersController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/members (POST)', () => {
return request(app.getHttpServer())
.post('/members')
.send({
email: 'test@example.com',
password: 'password123',
termsAgreed: true,
privacyAgreed: true,
marketingAgreed: false,
})
.expect(201)
.expect((res) => {
expect(res.body.email).toBe('test@example.com');
expect(res.body.uuid).toBeDefined();
});
});
});