1. 단위 테스트(Unit Test)란?
단위 테스트는 애플리케이션 코드 중 아주 작은 “단위(유닛)”—보통 함수나 메서드 하나—를 독립적으로 검증하는 테스트이다.
예를 들어, PostsService.getPostById()라는 메서드를 테스트한다고 가정하면,
- 외부에 실존 데이터베이스(DB)가 없어도
- “가짜(모킹) 레포지토리”를 사용해서
- findOne(…) 메서드가 어떤 값을 반환하도록 미리 정해두고
- getPostById()를 호출했을 때 기대한 값(or 예외)이 나오는지를 확인한다.
이렇게 하면 실제 DB 없이도 코드 흐름이 올바른지를 빠르게 검증할 수 있다.
2. Jest 설명
Jest(제스트)와 NestJS 테스트 환경
Jest는 자바스크립트/타입스크립트 프로젝트에서 가장 많이 쓰이는 테스팅 프레임워크 중 하나이다.
- describe, it(또는 test), expect와 같은 함수를 제공하며, 비동기 코드 테스트, 모킹(mocking) 기능을 모두 지원한다.
- jest.fn()을 사용하면 “가짜 함수” 혹은 “스파이(spy) 함수”를 만들어서, 실제 의존성을 대체한 뒤 호출 여부나 호출 파라미터를 검사할 수 있다.
NestJS TestingModule
- NestJS는 자체적으로 “의존성 주입(DI: Dependency Injection)” 컨테이너를 가지고 있는데, 테스트할 때도 실제 애플리케이션처럼 의존성을 주입받아야 코드를 검증할 수 있다.
- @nestjs/testing 패키지에서 제공하는 Test.createTestingModule({...}).compile() 을 쓰면, 애플리케이션의 모듈(프로바이더, 컨트롤러 등)을 테스트 전용 가상 환경에 ‘컴파일’(생성)해 준다.
- 이렇게 하면 실제 서비스에서 @InjectRepository(PostsModel)로 주입받았던 리포지토리도 테스트용 가짜(mock) 인스턴스로 등록해서 쓸 수 있고, 나머지 서비스 의존성(CommonService, RedisService, UsersService 등)도 원하는 대로 모킹할 수 있다.
2.1. describe()와 it()/test() 함수
2.1.1. describe()
describe('PostsService', () => {
// …여기에 여러 개의 it() 또는 또 다른 describe() 블록이 들어감
});
- describe()는 “테스트 시나리오(또는 테스트 스위트)”를 그룹화하는 용도이다.
- 첫 번째 인자로는 설명 문자열(예: 'PostsService')을, 두 번째 인자로는 그 설명에 해당하는 테스트 케이스들을 묶는 함수(콜백)를 받는다.
- 같은 describe 블록 안에서 묶인 it()들은 보통 “비슷한 기능” 혹은 “같은 대상(PostsService 등)”을 테스트할 때 사용한다.
2.1.2. it() (alias: test())
it('should be defined', () => {
expect(service).toBeDefined();
});
- it()은 “하나의 테스트 케이스(Test Case)”를 정의하는 함수이다.
- 첫 번째 인자로는 해당 테스트가 무엇을 검증하는지 설명하는 문자열(예: 'should be defined'),
- 두 번째 인자로는 실제 테스트 코드(콜백 함수)를 받는다.
- 콜백 내부에서는 expect(…) 구문을 통해 “기대값(Assertion)”을 검사합니다.
간단 예시:
describe('숫자 덧셈 함수', () => {
it('1 + 2는 3이어야 한다', () => {
const result = 1 + 2;
expect(result).toBe(3);
});
});
2.2. jest.fn()과 모킹(Mock)
2.2.1. 모킹(Mock) 이란?
모킹(Mock)은 “실제 의존성을 대체해서 테스트용으로 동작을 가짜로 정의”하는 기법이다.
예를 들어, 실제 DB를 매번 띄우거나 네트워크 요청을 보내지 않고, “이 가짜 레포지토리가 findOne()을 호출받으면 이 값을 반환해줄게”라고 미리 정의해 두는 방식이다.
이렇게 하면 테스트가 외부 환경(네트워크, DB 등)에 의존하지 않고, 순수하게 코드 논리만 검증할 수 있다.
2.2.2. jest.fn()으로 함수 모킹하기
const mockFn = jest.fn();
mockFn(); // 호출되면 특별한 로직 없이 그냥 찍혔다는 기록만 남김
expect(mockFn).toHaveBeenCalled(); // 호출됐는지 검증
expect(mockFn).toHaveBeenCalledWith(1, 'a'); // 어떤 파라미터로 호출됐는지 검증
- jest.fn()을 호출하면 “가짜 함수”를 하나 만든다.
const fakeFindOne = jest.fn();
fakeFindOne.mockResolvedValue({ id: 1, title: '테스트' });
// → fakeFindOne()을 호출하면 Promise.resolve({ id: 1, title: '테스트' })가 반환됨
- 모킹된 함수에 .mockResolvedValue(value)나 .mockReturnValue(value)를 붙여 주면, 호출됐을 때 특정 값을 반환하도록 설정할 수 있다.
PostsService 테스트 예시:
const postsRepo = module.get(getRepositoryToken(PostsModel));
// postsRepo.findOne을 가짜 함수로 모킹했으므로, 다음처럼 동작하도록 정의 가능
(postsRepo.findOne as jest.Mock).mockResolvedValue({
id: 1, title: '제목', content: '내용', author: { id: 1 }
});
// 이제 service.getPostById(1)을 호출하면, 내부적으로 postsRepo.findOne()이 미리 정의된 값을 반환함
2.3. beforeEach()와 afterEach()
2.3.1. beforeEach()
beforeEach(async () => {
// 테스트 하나가 실행되기 전에 매번 수행할 설정 코드
});
- beforeEach()는 “각각의 it() 테스트가 실행되기 전에 반드시 한 번씩 실행되는 함수”를 정의할 때 사용한다.
- 예를 들어, 테스트용 모듈을 매번 새로 생성하거나, 모킹 상태를 초기화하는 코드를 넣는다.
2.3.2. afterEach()
afterEach(() => {
// 테스트 하나가 끝난 뒤 매번 수행할 정리(clean-up) 코드
jest.clearAllMocks(); // 모든 mock 함수의 호출 기록과 리턴값 정의를 초기화
});
- afterEach()는 “각각의 it() 테스트가 끝난 뒤에 반드시 한 번씩 실행되는 함수”를 정의할 때 사용한다.
- 주로 모킹된 함수들의 호출 기록을 리셋하거나, 테스트용 리소스를 정리(clean-up)할 때 쓴다.
2.4. TestingModule과 getRepositoryToken()
2.4.1. TestingModule 만들기
const module: TestingModule = await Test.createTestingModule({
providers: [
PostsService,
// …여기에 모킹할 레포지토리, 서비스, 설정 등도 같이 등록
],
}).compile();
- Test.createTestingModule({ providers: […] })
- 실제 NestJS 애플리케이션 모듈처럼 “테스트용 가상 DI 컨테이너”를 만든다.
- providers 배열에 테스트 대상 서비스(PostsService)와, 그 서비스가 의존하는 모듈(레포지토리, 다른 서비스, 설정 등)을 함께 등록해 준다.
- compile()을 호출하면 TestingModule이 생성되어, 실제 코드처럼 의존성 주입(DI)이 가능한 상태가 된다.
- 이후에 module.get<PostsService>(PostsService)를 통해 PostsService 인스턴스를 꺼내올 수 있다.
- 이때, PostsService 내부에서 @InjectRepository(PostsModel)로 주입받은 레포지토리는 우리가 useValue: { … }로 등록해 둔 “가짜 객체”가 된다.
2.4.2. getRepositoryToken()
{
provide: getRepositoryToken(PostsModel),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
// …
},
}
- NestJS에서 TypeORM의 저장소(레포지토리)를 @InjectRepository(PostsModel) 같은 형태로 주입할 때, 내부적으로는 “getRepositoryToken(PostsModel)라는 토큰”을 사용한다.
- 따라서 테스트 환경에서는 getRepositoryToken(PostsModel)을 provide로 쓰고, useValue에 “모킹된 레포지토리 객체”를 주입해 주면,
- PostsService가 의존성을 주입 받을 때 실제 PostsModel 레포지토리 대신 우리가 지정한 “가짜(mock) 레포지토리”가 주입된다.
2.5. 주요 Jest Assertion 함수
2.5.1. expect()
- expect(대상값).toBe(기대값)
- 대상값(예: 함수 호출 결과)이 ‘기대값’과 정확하게 같은지(===) 확인한다.
- expect(대상함수).toHaveBeenCalled()
- 모킹된 함수가 호출됐는지 확인한다.
- expect(대상함수).toHaveBeenCalledWith(파라미터들)
- 모킹된 함수가 특정 파라미터들로 호출됐는지 확인한다.
- await expect(프로미스를 반환하는 함수()).rejects.toThrow(예외클래스)
- 비동기 함수(프로미스)를 실행했을 때, 특정 예외가 던져지는지를 검증한다.
3. 실제 코드에 적용해보기
3.1. jest.config.ts 작성
import type { Config } from 'jest';
const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.(t|j)s',
'!**/*.spec.ts',
'!**/*.interface.ts',
'!**/node_modules/**',
],
coverageDirectory: '../coverage',
testEnvironment: 'node',
preset: 'ts-jest',
moduleNameMapper: {
'^src/(.*)$': '<rootDir>/$1',
},
modulePaths: ['<rootDir>'],
globals: {
'ts-jest': {
tsconfig: './tsconfig.json',
},
},
};
export default config;
jest 를 사용하기 위핸 위와 같이 config 파일을 루트에 작성해주어야 한다.
3.2. package.json 수정
scripts: {
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"test:cov": "jest --config jest.config.ts --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --config jest.config.ts",
}
package.json에 위와 같이 수정 후
npm run test posts.service.spec.ts //일반 단위 테스트 실행
npm run test:cov posts.service.spec.ts //커버리지 단위 테스트 실행
npm run test // 모든 단위 테스트 실행
npm run test:watch //jest --watch 모드로 실행해서 코드가 변경될 때마다 자동으로 관련 테스트만 재실행
위와 같은 명령어로 실행할 수 있다.
3.3. PostsService 로직
더보기
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, LessThan, MoreThan, QueryRunner, Repository } from 'typeorm';
import { PostsModel } from './entity/posts.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PaginatePostDto } from './dto/paginate-post.dto';
import { CommonService } from 'src/common/common.service';
import { ImageModel } from 'src/common/entity/image.entity';
import { DEFAULT_POST_FIND_OPTIONS } from './const/default-post-find-options.const';
import { ConfigType } from '@nestjs/config';
import appConfig from 'src/configs/app.config';
import { RedisService } from 'src/redis/redis.service';
import { UsersService } from 'src/users/users.service';
import { REDIS_KEYS_MAPPER } from 'src/redis/redis.keys-mapper';
/**
* 게시물 데이터 응답 타입
*/
type PostsResult =
| { data: PostsModel[]; total: number }
| { data: PostsModel[]; cursor: { after: number | null }; count: number; next: string | null };
/**
* 게시물 데이터 관리 서비스
*
* 게시물의 생성, 조회, 수정, 삭제 기능과 관련 비즈니스 로직을 처리합니다.
* 페이지네이션, 캐싱, 사용자 인증 등의 기능을 포함합니다.
*/
@Injectable()
export class PostsService {
constructor(
@InjectRepository(PostsModel)
private readonly postsRepository: Repository<PostsModel>,
@InjectRepository(ImageModel)
private readonly imageRepository: Repository<ImageModel>,
private readonly commonServices: CommonService,
@Inject(appConfig.KEY)
private readonly config: ConfigType<typeof appConfig>,
private readonly redisService: RedisService,
private readonly usersService: UsersService,
) {}
/**
* 모든 게시물을 조회합니다.
*
* @returns {Promise<PostsModel[]>} 모든 게시물 목록
*/
async getAllPosts() {
return this.postsRepository.find();
}
/**
* 테스트용 게시물을 다량 생성합니다.
*
* @param {number} userId - 게시물을 생성할 사용자 ID
* @returns {Promise<void>}
*/
async generatePosts(userId: number) {
for(let i = 0; i < 100; i++) {
await this.createPost(userId, {
title: `임의로 생성된 포스트 제목 ${i}`,
content: `임의로 생성된 포스트 내용 ${i}`,
images: [],
});
}
}
/**
* DTO 기반으로 게시물을 페이지네이션하여 조회합니다.
* 팔로우 중인 사용자 게시물만 조회하는 기능을 지원합니다.
*
* @param {PaginatePostDto} dto - 페이지네이션 옵션
* @param {FindOptionsWhere<PostsModel>} additionalWhere - 추가 필터 조건
* @param {number} userId - 요청 사용자 ID (팔로우 필터링용)
* @returns {Promise<PostsResult>} 페이지네이션된 게시물 결과
*/
async paginatePosts(
dto: PaginatePostDto,
additionalWhere?: FindOptionsWhere<PostsModel>,
userId?: number
): Promise<PostsResult> {
if (!dto.isOnlyFollowingPosts || userId == null) {
return this.commonServices.paginate<PostsModel, PostsResult>(
dto,
this.postsRepository,
{ ...DEFAULT_POST_FIND_OPTIONS },
'posts',
additionalWhere,
);
}
const cacheKey = REDIS_KEYS_MAPPER.followingPosts(userId);
const cached = await this.redisService.get(cacheKey);
if (cached) {
return cached as PostsResult;
}
const result = await this.commonServices.paginate<PostsModel, PostsResult>(
dto,
this.postsRepository,
{ ...DEFAULT_POST_FIND_OPTIONS },
'posts',
additionalWhere,
);
await this.redisService.set(cacheKey, result, 60000);
return result;
}
/**
* 페이지 기반 게시물 페이지네이션을 수행합니다.
*
* @param {PaginatePostDto} dto - 페이지네이션 옵션
* @returns {Promise<{data: PostsModel[], total: number}>} 페이징된 게시물과 총 개수
*/
async pagePaginatePosts(dto: PaginatePostDto) {
/**
* data: Data[],
* total: number,
*
* [1] [2] [3] [4]
*/
const [posts, count] = await this.postsRepository.findAndCount({
skip: dto.take * ((dto.page ?? 1) - 1),
take: dto.take,
order: {
createdAt: dto.order__createdAt,
}
});
return {
data: posts,
total: count,
}
}
/**
* 커서 기반 게시물 페이지네이션을 수행합니다.
*
* @param {PaginatePostDto} dto - 페이지네이션 옵션
* @returns {Promise<{data: PostsModel[], cursor: {after: number | null}, count: number, next: string | null}>} 커서 페이징 결과
*/
async cursorPaginatePosts(dto: PaginatePostDto) {
const where: FindOptionsWhere<PostsModel> = {}
if(dto.where__id__less_than) {
where.id = LessThan(dto.where__id__less_than);
} else if(dto.where__id__more_than) {
where.id = MoreThan(dto.where__id__more_than);
}
const posts = await this.postsRepository.find({
where,
order:{
createdAt: dto.order__createdAt,
},
take: dto.take,
});
/**
* 해당되는 포스트가 0개 이상이면
* 마지막 포스트를 가져오고
* 아니면 null을 반환한다.
*/
const lastItem = posts.length > 0 && posts.length === dto.take ? posts[posts.length - 1] : null;
const protocol = this.config.http.protocol
const host = this.config.http.host;
const nextURL = lastItem && new URL(`${protocol}://${host}/posts`);
if(nextURL) {
for(const key of Object.keys(dto)) {
if(dto[key]) {
if(key !== 'where__id__more_than' && key !== 'where__id__less_than') {
nextURL.searchParams.append(key, dto[key]);
}
}
}
let key: string | null = null;
if(dto.order__createdAt === 'ASC') {
key = 'where__id__more_than';
} else {
key = 'where__id__less_than';
}
nextURL.searchParams.append(key, lastItem.id.toString());
}
/**
* Response
*
* data: Data[],
* cursor: {
* after: 마지막 Data의 ID
* },
* count: 응답한 데이터의 개수,
* next: 다음 요청에 사용할 URL
*/
return {
data: posts,
cursor: {
after: lastItem?.id ?? null,
},
count: posts.length,
next: nextURL?.toString() ?? null,
}
}
/**
* 특정 ID의 게시물을 조회합니다.
*
* @param {number} id - 조회할 게시물 ID
* @param {QueryRunner} qr - 선택적 QueryRunner (트랜잭션 처리용)
* @returns {Promise<PostsModel>} 조회된 게시물
* @throws {NotFoundException} 게시물이 존재하지 않을 경우
*/
async getPostById(id: number, qr?: QueryRunner) {
const repository = this.getRepository(qr);
const post = await repository.findOne({
where: {
id,
},
relations: ['author'],
});
if (!post) {
throw new NotFoundException();
}
return post;
}
/**
* QueryRunner 유무에 따라 적절한 Repository를 반환합니다.
*
* @param {QueryRunner} qr - 선택적 QueryRunner
* @returns {Repository<PostsModel>} 게시물 Repository
*/
getRepository(qr?: QueryRunner) {
return qr ? qr.manager.getRepository<PostsModel>(PostsModel) : this.postsRepository;
}
/**
* 특정 게시물의 카운트 필드를 증가시킵니다.
*
* @param {number} postId - 대상 게시물 ID
* @param {keyof Pick<PostsModel, 'commentCount'>} fieldName - 증가시킬 필드명
* @param {number} incrementCount - 증가시킬 값
* @param {QueryRunner} qr - 선택적 QueryRunner (트랜잭션 처리용)
* @returns {Promise<void>}
*/
async incrementFollowerCount(
postId: number,
fieldName: keyof Pick<PostsModel, 'commentCount'>,
incrementCount: number,
qr?: QueryRunner,
) {
const repository = this.getRepository(qr);
await repository.increment(
{
id: postId,
},
fieldName,
incrementCount,
);
}
/**
* 특정 게시물의 카운트 필드를 감소시킵니다.
*
* @param {number} postId - 대상 게시물 ID
* @param {keyof Pick<PostsModel, 'commentCount'>} fieldName - 감소시킬 필드명
* @param {number} decrementCount - 감소시킬 값
* @param {QueryRunner} qr - 선택적 QueryRunner (트랜잭션 처리용)
* @returns {Promise<void>}
*/
async decrementFollowerCount(
postId: number,
fieldName: keyof Pick<PostsModel, 'commentCount'>,
decrementCount: number,
qr?: QueryRunner,
) {
const repository = this.getRepository(qr);
await repository.decrement(
{
id: postId,
},
fieldName,
decrementCount,
);
}
/**
* 새 게시물을 생성합니다.
* 게시물이 생성되면 작성자의 팔로워들의 캐시를 갱신합니다.
*
* @param {number} authorId - 작성자 ID
* @param {CreatePostDto} postDTO - 게시물 생성 DTO
* @param {QueryRunner} qr - 선택적 QueryRunner (트랜잭션 처리용)
* @returns {Promise<PostsModel>} 생성된 게시물
*/
async createPost(authorId: number, postDTO: CreatePostDto, qr?: QueryRunner) {
// 1) create -> 저장할 객체를 생성한다.
// 2) save -> 객체를 저장한다 (create 메서드로 생성한 객체로)
const repository = this.getRepository(qr);
const post = repository.create({
author: {
id: authorId,
},
...postDTO,
images: [],
likeCount: 0,
commentCount: 0,
});
const newPost = await repository.save(post);
const followers = await this.usersService.getFollowers(authorId, false);
for (const follower of followers) {
const cacheKey = REDIS_KEYS_MAPPER.followingPosts(follower.id);
await this.redisService.delByPattern(cacheKey);
}
return newPost;
}
/**
* 기존 게시물을 업데이트합니다.
*
* @param {number} id - 업데이트할 게시물 ID
* @param {UpdatePostDto} postDto - 게시물 업데이트 DTO
* @returns {Promise<PostsModel>} 업데이트된 게시물
* @throws {NotFoundException} 게시물이 존재하지 않을 경우
*/
async updatePost(
id: number,
postDto: UpdatePostDto,
) {
const { title, content } = postDto;
// save의 기능
// 1) 만약에 데이터가 존재하지 않는다면 (id 기준으로) 새로 생성한다.
// 2) 만약에 데이터가 존재한다면 (같은 id의 값이 존재한다면) 존재하던 값을 업데이트한다/
const post = await this.postsRepository.findOne({
where: {
id,
},
});
if (!post) {
throw new NotFoundException();
}
if (title) {
post.title = title;
}
if (content) {
post.content = content;
}
const newPost = await this.postsRepository.save(post);
return newPost;
}
/**
* 게시물을 삭제합니다.
*
* @param {number} id - 삭제할 게시물 ID
* @returns {Promise<number>} 삭제된 게시물 ID
* @throws {NotFoundException} 게시물이 존재하지 않을 경우
*/
async deletePost(id: number) {
const post = await this.postsRepository.findOne({
where: {
id,
},
});
if (!post) {
throw new NotFoundException();
}
await this.postsRepository.delete(id);
return id;
}
/**
* 게시물 ID로 존재 여부를 확인합니다.
*
* @param {number} id - 확인할 게시물 ID
* @returns {Promise<boolean>} 존재 여부
*/
async checkPostExistsById(id: number) {
return await this.postsRepository.exists({
where: {
id,
},
});
}
/**
* 특정 게시물이 요청 사용자의 것인지 확인합니다.
*
* @param {number} userId - 사용자 ID
* @param {number} postId - 게시물 ID
* @returns {Promise<boolean>} 소유 여부
*/
async isPostMine(userId: number, postId: number) {
return await this.postsRepository.exists({
where: {
id: postId,
author: {
id: userId,
}
},
relations: {
author: true,
}
});
}
}
3.4. posts.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common'; // NotFoundException 클래스를 임포트 (예외 검증에 사용)
import { PostsService } from './posts.service';
import { PostsModel } from './entity/posts.entity';
import { ImageModel } from '../common/entity/image.entity';
import { CommonService } from '../common/common.service';
import { RedisService } from '../redis/redis.service';
import { UsersService } from '../users/users.service';
import appConfig from 'src/configs/app.config';
describe('PostsService', () => {
let service: PostsService; // 테스트 대상인 PostsService 인스턴스를 저장할 변수
let module: TestingModule; // TestingModule 인스턴스를 저장할 변수 (beforeEach 외부에서 선언)
beforeEach(async () => {
// Test.createTestingModule로 NestJS의 TestingModule을 생성
module = await Test.createTestingModule({
providers: [
// 실제 테스트 대상이 되는 PostsService 클래스를 등록
PostsService,
// PostsModel 엔티티에 대응하는 TypeORM 레포지토리를 모킹(Mocking)
{
provide: getRepositoryToken(PostsModel), // getRepositoryToken을 이용해 DI 토큰 생성
useValue: {
// PostsService 내부에서 호출되는 주요 메서드들을 jest.fn()으로 가짜 구현체(mock)로 정의
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
// createQueryBuilder를 사용해야 할 경우에 대비해, 기본 체이닝 메서드를 모두 가짜로 정의
createQueryBuilder: jest.fn(() => ({
leftJoinAndSelect: jest.fn().mockReturnThis(), // 체이닝을 위해 this 반환
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]), // 페이징 결과 (빈 배열, 0개)
getMany: jest.fn().mockResolvedValue([]), // getMany 결과 (빈 배열)
getOne: jest.fn(), // 단일 객체 조회용 메서드
})),
},
},
// ImageModel 엔티티에 대응하는 TypeORM 레포지토리를 모킹
{
provide: getRepositoryToken(ImageModel),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
// CommonService를 모킹: paginate, uploadFile, generateFileName, validateCursor 등 메서드를 jest.fn()으로 대체
{
provide: CommonService,
useValue: {
paginate: jest.fn(),
uploadFile: jest.fn(),
generateFileName: jest.fn(),
validateCursor: jest.fn(),
},
},
// appConfig.KEY에 해당하는 설정 값들을 모킹
{
provide: appConfig.KEY,
useValue: {
host: 'localhost',
port: 3000,
env: 'test',
// PostsService 내부에서 cursor 기반 페이지네이션 시 URL 생성할 때 사용하는 http 필드
http: {
protocol: 'http',
host: 'localhost',
},
},
},
// RedisService를 모킹: get, set, del, exists, expire, hget, hset, delByPattern 메서드를 정의
{
provide: RedisService,
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
exists: jest.fn(),
expire: jest.fn(),
hget: jest.fn(),
hset: jest.fn(),
delByPattern: jest.fn(), // createPost에서 사용되므로 추가
},
},
// UsersService를 모킹: findUserById, getUsersById, createUser, updateUser, deleteUser, getFollowers 메서드를 정의
{
provide: UsersService,
useValue: {
findUserById: jest.fn(),
getUsersById: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
deleteUser: jest.fn(),
getFollowers: jest.fn(), // createPost에서 사용되므로 추가
},
},
],
}).compile(); // 모듈 컴파일
// 컴파일된 TestingModule에서 PostsService 인스턴스를 가져옴
service = module.get<PostsService>(PostsService);
});
afterEach(() => {
// 각 테스트 후에 모든 mock 함수를 초기화하여, 다음 테스트에 영향이 없도록 함
jest.clearAllMocks();
});
it('should be defined', () => {
// PostsService가 정상적으로 정의되어 있는지 확인 (DI, 모듈 설정이 올바른지 검증)
expect(service).toBeDefined();
});
describe('getPostById', () => {
it('존재하는 postId인 경우, 레포지토리 findOne 결과를 반환해야 한다', async () => {
// 1) findOne이 항상 특정 객체를 반환하도록 mock 설정
const fakePost = { id: 1, title: '제목', content: '내용', author: { id: 1 } };
// 모킹된 PostsModel 레포지토리를 가져옴
const postsRepo = module.get(getRepositoryToken(PostsModel));
(postsRepo.findOne as jest.Mock).mockResolvedValue(fakePost);
// 2) 실제로 getPostById 메서드를 호출
const result = await service.getPostById(1);
// 3) findOne이 올바른 파라미터로 호출됐는지 확인
expect(postsRepo.findOne).toHaveBeenCalledWith({
where: { id: 1 },
relations: ['author'],
});
// 4) 반환값이 mock으로 설정한 fakePost와 동일한지 확인
expect(result).toBe(fakePost);
});
it('존재하지 않는 postId인 경우, NotFoundException을 던져야 한다', async () => {
// findOne이 null을 반환하도록 mock 설정 (게시물이 없는 상황 시뮬레이션)
const postsRepo = module.get(getRepositoryToken(PostsModel));
(postsRepo.findOne as jest.Mock).mockResolvedValue(null);
// getPostById를 호출했을 때 NotFoundException 예외를 던지는지 검사
await expect(service.getPostById(999)).rejects.toThrow(NotFoundException);
});
});
});
참고로 모듈을 NestJS CLI를 통해 생성한다면 spec.ts 파일이 자동 생성되지만, 추후 작성된 Service, Controller 코드들을 보고 테스트 코드를 작성하여야 한다.
3.5. 테스트 결과
위와 같이 커버리지 단위테스트를 실행해보았는데, 코드에 문제가 없이 잘 테스트가 진행된 것을 볼 수 있다.
'NestJS' 카테고리의 다른 글
시놀로지 나스 NestJS 개발서버 구축하기 - Docker Container (0) | 2025.06.19 |
---|---|
[NestJS] winston 로그 구현하기 (1) | 2025.06.05 |
[NestJS] 자동 문서화 가이드 (Compodoc) (1) | 2025.05.30 |
[NestJS] 각종 보안 적용하기 (0) | 2025.05.27 |
[NestJS] GraphQL 적용하기 (0) | 2025.05.26 |