해당 코드들은 옆에 있는 링크에서 전부 보실 수 있습니다. => 비밀일기 레포지토리
언제나 마음에 걸리던 것이 있었다.
혹시, 테스트코드는 짜보셨나요?
팀프로젝트 당시에도, 테스트코드의 중요성은 알았지만
커리큘럼에서 배운 것은 하루(3시간 남짓)뿐이라 제대로된 지식이 없었다.
찾아서 작업을 해보려고 했더니, 테스트라는 것 자체가 거대한 시스템에 가까워서 다가갈 수 없었고,
그렇게 시간이 계속 흘러가고 있었다.
그러던 와중 과제테스트를 받게 되었는데, 테스트 코드를 넣는게 좋지 않을까 라는 생각이 그냥 들었다.
물론 테스트를 안하는 회사도 있긴 하지만! CTO가 테스트같은거 필요없다고 이야기하는 회사도 있다고 들었지만
그래도 면접질문에서 테스트코드를 왜 넣었냐고 물어봤을 때
당당하게 기초지만 CRUD정도는 짜봤고, 중요성을 알기 때문이라고 말을 하고 싶었다.
그래서 무작정, 같은 부트캠프 수료생의 레포를 털어서(ㅋㅋ) 보기 시작했다.
어떻게 짜면 돌아갈까, 일단 넣어서 돌려보고 안맞으면 공식문서와 질문을 하면서 고쳐나갔다.
그러다보니 여러가지에서 막혔고, 더 나은 방법이 있었기에 그것을 공유하려고 한다.
물론 이게 잘한 코드는 아니지만 동작이 제대로 된다면 일단 OK 라는 느낌이다.
유닛테스트 단위테스트 TDD같은 것은 언급하지 않는다.
이게 정말 중요하면서 테스트코드를 짜는 것에 있어서 많은 문제를 만들어낸다.
why?
디비에서 값을 꺼낼 수가 없어요
그래서 그 값을 모조리 다 하드코딩으로 입력을 해줘야만한다(....)
관계가 깊게 들어간다면, 그것도 모조리 다 적어야한다(과거에 이래서 포기했다.)
비즈니스 로직은 실제로 사용하지만, 값은 모두 지정을 해줘야하기에 테스트코드를 처음 짤 때는
관계가 아무것도 없는 Entity로 짜보는 것을 추천한다.
import { Test, TestingModule } from '@nestjs/testing';
import { Room } from '../entities/room.entity';
import { Repository } from 'typeorm';
import { RoomService } from '../room.service';
import { ArgumentMetadata, CacheModule, ValidationPipe } from '@nestjs/common';
import { CreateRoomInput } from '../dto/create-room.intput';
import * as bcrypt from 'bcrypt';
import { UpdateRoomInput } from '../dto/update-room.input';
import { AdminRoomInput } from '../dto/admin-room.input';
// 솔직히 말하면 이거 자체를 이해하진 못했다, 파셜 레코드 키오프같은 여러가지 선택 옵션 + 제네릭을 통해서 모킹레포의 타입을 구성한다.
type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>;
// 비즈니스 로직에서 사용하던 ORM의 메소드를 넣으면 된다.
// 해당 작업에서는 4개면 충분해서 다른 것들은 없다.
// 쿼리빌더같은거 들어가면 그때부터 멘탈붕괴가 온다는 이야기를 얼핏 들은 것 같다.
const mockRepository = () => ({
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
softDelete: jest.fn(),
});
// 이름은 자유롭게, describe를 여는 것으로 테스트코드 작성이 시작된다.
describe('RoomService', () => {
// 여기부터 아래로 쭉 있는 객체들이 입력값, 리턴값으로 지정하기 위하여 미리 선언을 해놓은 것이다.
// 원래는 로직마다 한개씩 적었는데, 이런식으로 하는 것이 코드가 예뻐서(?) 바꿨다.
const findOne = {
id: 'farg-36512d-gaj241',
name: '첫번째 룸',
password: bcrypt.hashSync('10671', 10),
adminPassword: bcrypt.hashSync('196723', 10),
email: 'test@gmail.com',
userCount: 0,
createAt: '2022-07-27T08:32:50.701Z',
updateAt: '2022-07-27T08:31:54.179Z',
deleteAt: null,
hit: 0,
};
const save = {
id: 'farg-36512d-gaj242',
name: '첫번째 룸',
password: bcrypt.hashSync('10671', 10),
adminPassword: bcrypt.hashSync('196723', 10),
email: 'test1@gmail.com',
userCount: 0,
createAt: '2022-07-27T08:32:50.701Z',
updateAt: '2022-07-27T08:31:54.179Z',
deleteAt: null,
hit: 0,
};
const update = {
id: 'farg-36512d-gaj242',
name: '업데이트된 룸',
password: save.password,
adminPassword: save.adminPassword,
email: 'test1@gmail.com',
userCount: 0,
createAt: '2022-07-27T08:32:50.701Z',
updateAt: '2022-07-27T08:31:54.179Z',
deleteAt: null,
hit: 0,
};
const createRoomInput: CreateRoomInput = {
name: '짠짠',
password: '1234',
adminPassword: '12345',
email: 'test1@gmail.com',
};
const adminRoomInput: AdminRoomInput = {
id: 'farg-36512d-gaj241',
adminPassword: '196723',
};
const updateRoomInput: UpdateRoomInput = { name: '업데이트된 룸' };
// 여기부터 진짜 시작인데, 메소드를 추가해야하기 때문에 const가 아닌 let으로 선언한다.
let roomService: RoomService;
let roomRepository: MockRepository<Room>;
// beforeEach는 말 그대로 시작하기 전 밑작업이라고 생각하면 된다.
beforeEach(async () => {
// 서버를 키지 않아도 돌아가야하기에, 테스팅 모듈을 선언한다.
const modules: TestingModule = await Test.createTestingModule({
// 나같은 경우에는 비즈니스 로직에 레디스를 붙여놔서 추가해놨지만, 일반적으로는 없어도 된다.
imports: [CacheModule.register({})],
// 비즈니스 로직이 들어가있는 RoomService를 주입한다.
providers: [
RoomService,
{
// 진짜 레포지토리에 접근을 하면 안되기에, 모킹레포로 덮는다.
provide: 'RoomRepository',
useFactory: mockRepository,
},
],
}).compile();
// 테스트모듈에 선언되어있는 룸서비스와 룸레포지토리를 주입(?)한다.
roomService = modules.get<RoomService>(RoomService);
roomRepository = modules.get('RoomRepository') as MockRepository<Room>;
// 일반적으로 DB가 있을 경우에는 findOne을 할 경우 값이 나온다.
// 하지만 값이 나오지 않기 때문에 가짜 값을 넣어주는 것이다.
// 해당 코드에서는 위에 선언한 findOne의 객체값이 들어갔다.
roomRepository.findOne.mockResolvedValue(findOne);
// 이것 또한 마찬가지인데, 생성하는 로직을 테스트하기 위하여 사용했고
// 결과값으로 리턴받을 것들을 이렇게 넣어둔다, 이것도 가짜값.
roomRepository.save.mockReturnValue(save);
});
// 이것은 제대로 연결이 되었는지 확인하는 것으로 알고 있다.
it('Define RoomServiceTest', () => {
expect(roomService).toBeDefined();
});
조회는 언제나 쉽다. 변수도 딱히 존재하지 않고
describe('findOne', () => {
it('룸 조회 성공', async () => {
// 스파이 붙여서 findOne의 사용 회수를 체크
const roomRepositorySpyFindOne = jest.spyOn(roomRepository, 'findOne');
// findOne 메소드를 사용해서, 객체인 findOne과 같은지 검증한다.
expect(await roomService.findOne('test@gmail.com')).toEqual(findOne);
// findOne의 사용 회수가 1이면 성공, 아니라면 실패다.
expect(roomRepositorySpyFindOne).toHaveBeenCalledTimes(1);
});
it('룸 조회 실패', async () => {
// 상단에서 값을 지정해놔서, undefined를 통하여 무조건 false이도록 넣어놨다.
roomRepository.findOne.mockResolvedValue(undefined);
// 아래 비즈니스 로직을 보면 값이 undefined일 경우 에러가 발생되는 것을 볼 수 있다.
// throw new NotFoundException('룸이 존재하지 않습니다.');
// 에러메세지를 체크해서 같은지 검증하고, 다르면 테스트 실패로 들어간다.
await roomRepository.findOne('').catch((e: any) => {
expect(e.message).toEqual('룸이 존재하지 않습니다.');
});
});
});
async findOne(email: string): Promise<Room> {
const roomData = await this.roomRepository.findOne({
where: { email },
});
try {
if (roomData === undefined)
throw new NotFoundException('룸이 존재하지 않습니다.');
if (roomData.email !== email)
throw new BadRequestException('정보가 일치하지 않습니다.');
return roomData;
} catch (e) {
return e;
}
}
쉬울줄만 알았던 조회에서 생각지도 못한 일이 있었다.
원래까지는 조건문이 roomData === undefined만 있었는데
위에 선언해놓은 roomRepository.findOne.mockResolvedValue(findOne); 이 값때문에
무조건 roomData가 true가 되는 일이 벌어졌다(....)
솔직히 이런 일이 벌어지나? 라는 생각이 들었다.
email 같은 경우에는 유니크한 값이기에 문제가 생기지 않을 것 같다는 생각을 하면서도
어....? 설....마? 라는 마음이 있어서 if(roomData.email!==email)까지 추가를 해놨다.
여기서 mockResolvedValue의 역할을 알게된 것 같다.
그 전까지는 저게 뭔가~ 라는 생각이 많았는데 ㅋㅋ
describe('create', () => {
it('룸 생성 완료', async () => {
// 중복을 체크하고, 저장을 하기 때문에 Save와 findOne에 스파이를 붙여놨다.
const roomRepositorySpySave = jest.spyOn(roomRepository, 'save');
const roomRepositorySpyFindOne = jest.spyOn(roomRepository, 'findOne');
// 똑같이 값을 넣어서 생성한 것과 같은지 검증한다.
expect(await roomService.create(createRoomInput)).toEqual(save);
// 사용 회수 체크
expect(roomRepositorySpySave).toHaveBeenCalledTimes(1);
expect(roomRepositorySpyFindOne).toHaveBeenCalledTimes(1);
});
it('룸 생성 실패', async () => {
roomRepository.findOne.mockResolvedValue(true);
// 유효성 검사 들어가있는 로직
const target: ValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
});
const createRoomInput: CreateRoomInput = {
name: '짠짠',
password: '',
adminPassword: '',
email: 'testgmail.com',
};
const metadata: ArgumentMetadata = {
type: 'body',
metatype: CreateRoomInput,
data: '',
};
await target.transform(createRoomInput, metadata).catch((e) => {
expect(e.getResponse().error).toEqual('Bad Request');
});
});
});
async create(createRoomInput: CreateRoomInput): Promise<Room> {
const { name, password, adminPassword, email } = createRoomInput;
try {
// 비밀번호 두개가 동일한 것은 보안적으로 위험해서 에러 발생
if (password === adminPassword)
throw new BadRequestException(
'관리자 비밀번호와 룸 비밀번호가 동일합니다.',
);
const isEmail = await this.roomRepository.findOne({ email });
// 이메일로 룸 찾기 검증할거라서 유니크값으로 지정함
if (isEmail === undefined && isEmail.email !== email)
throw new BadRequestException('사용할 수 없는 이메일입니다.');
// bcrypt로 비밀번호 암호화
return await this.roomRepository.save({
name,
email,
password: bcrypt.hashSync(password, 10),
adminPassword: bcrypt.hashSync(adminPassword, 10),
});
} catch (e) {
if (e.status === 400) {
return e;
}
// 400 에러 아니면 서버 에러
throw new Error('Room Create Server Error');
}
}
여기는 할 말이 좀 많은 챕터다.
왜냐하면 바로 유효성 검사
가 존재하는 중요한 로직이였는데, 얘가 무시를 하고 들어오는 것이 포착됐다(....)
import { InputType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class CreateRoomInput {
@IsNotEmpty()
@IsString()
@Field(() => String)
name: string;
@IsNotEmpty()
@IsEmail()
@Field(() => String)
email: string;
@IsNotEmpty()
@IsString()
@Field(() => String)
password: string;
@IsNotEmpty()
@IsString()
@Field(() => String)
adminPassword: string;
}
위의 코드를 보면 IsNotEmpty,IsString,IsEmail 같은 유효성검사 데코레이터가 붙어있는 것을 볼 수 있는데
이게 입력값에서 검증을 하는 것이기에, 테스트에서는 그게 무시가 되더라(...)
그래서 고민을 하고 있었는데, 청하님께서 자료를 주셔가지고 작업을 할 수 있게 됐다.
const target: ValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
});
const createRoomInput: CreateRoomInput = {
name: '짠짠',
password: '',
adminPassword: '',
email: 'testgmail.com',
};
const metadata: ArgumentMetadata = {
type: 'body',
metatype: CreateRoomInput,
data: '',
};
await target.transform(createRoomInput, metadata).catch((e) => {
expect(e.getResponse().error).toEqual('Bad Request');
});
그것이 바로 여기 부분이였는데, nestjs에서 기본적으로 지원하는 ValidationPipe를 통하여
DTO에 입력되어있던 데코레이터의 메타데이터를 읽어와서 적용을 시켜줄 수 있었다.
하지만 문제는 한번 더 있었다.
바로 유효성 검사를 할 경우에 발생되는 에러가 배열의 형태로 들어오는 것이였다.
정규식도 써보고 이것저것 고민을 해보다가, 겹치는게 be밖에 없는 것 같아서 혹시? 하고
message가 아니라 errer로 바꿔서 콘솔을 찍어봤다.
메세지의 경우에는 배열로 담겨서 개수를 확인할 수 없지만
에러 메세지의 경우에는 Bad Request로 고정이 되어있는 것을 확인하고,
해당 부분을 message에서 error로 바꾸는 것으로 이 문제를 해결 할 수 있었다.
describe('update', () => {
it('룸 수정 성공', async () => {
// 나같은 경우에는 업데이트를 덮어쓰기 개념으로 하는 편이라 save를 그대로 쓸 경우 값이 그대로다.
// 그래서 let으로 선언을 했기 때문에, 값을 변경하여 같은지 판별했다.
roomRepository.save.mockReturnValue(update);
// 여기도 똑같이 findOne과 save에 스파이를 심어놨다.
const roomRepositorySpyFindOne = jest.spyOn(roomRepository, 'findOne');
const roomRepositorySpySave = jest.spyOn(roomRepository, 'save');
// 관리자 권한 + 업데이트하고 싶은 내용이 입력값이라 이렇게 적용했다.
expect(await roomService.update(adminRoomInput, updateRoomInput)).toEqual(
update,
);
// 똑같이 회수 카운트.
expect(roomRepositorySpyFindOne).toHaveBeenCalledTimes(1);
expect(roomRepositorySpySave).toHaveBeenCalledTimes(1);
});
it('룸 수정 실패', async () => {
// 실패같은 경우에는, 어드민인풋의 비밀번호가 달라야해서 그냥 하드코딩으로 집어넣었다.
// 로직이 실행되던 중 에러가 발생하기에, 에러 메세지가 같으면 성공하는 로직이다.
await roomService
.update(
{
id: 'farg-36512d-gaj241',
adminPassword: '1967237',
} as AdminRoomInput,
{ name: '업데이트된 룸' } as UpdateRoomInput,
)
.catch((e) => {
expect(e.message).toEqual('관리자 비밀번호가 틀립니다.');
});
});
});
async update(
adminRoomInput: AdminRoomInput,
updateRoomInput: UpdateRoomInput,
): Promise<Room | boolean> {
const { id, adminPassword } = adminRoomInput;
const { changePassword, changeAdminPassword, ...data } = updateRoomInput;
// 검증 로직 실행
const isRoom = await this.isRoomCheck(id, '', adminPassword);
try {
// 비밀번호, 관리자 비밀번호를 변경할 수 있기 때문에 존재여부 확인해서 있으면 data에 추가
if (changePassword)
data['password'] = bcrypt.hashSync(changePassword, 10);
if (changeAdminPassword)
data['adminPassword'] = bcrypt.hashSync(changeAdminPassword, 10);
//추가된 여부 확인하고 덮어쓰기
return await this.roomRepository.save({
...isRoom,
...data,
});
} catch (e) {
// 문제 있으면 400 에러
throw new Error('Room Update Server Error');
}
}
// 룸 여부 검증 및 접근 권한 여부 확인 API////////////////////////////////////////////////////////////////////////////////////////////////////
async isRoomCheck(
id: string,
password?: string,
adminPassword?: string,
): Promise<Room> {
// 룸 여부 검증
const roomData = await this.roomRepository.findOne({
where: { id },
});
try {
// 없으면 400 에러
if (roomData === undefined)
throw new NotFoundException('룸이 존재하지 않습니다.');
if (adminPassword !== '') {
const isPassword = bcrypt.compareSync(
adminPassword,
roomData.adminPassword,
);
if (isPassword) return roomData;
throw new UnauthorizedException('관리자 비밀번호가 틀립니다.');
}
if (password !== '') {
const isPassword = bcrypt.compareSync(password, roomData.password);
if (isPassword) return roomData;
throw new UnauthorizedException('비밀번호가 틀립니다.');
}
} catch (e) {
if (e.status === 404 || e.status === 401) {
throw e;
}
throw new Error('Room Check Server Error');
}
}
// 해당 검증단의 로직은 개선이 필요한데, 아직은 어떻게 해야할지 모르겠어서 잠깐 놔두고 있다.
원래 CRUD에 업데이트가 제일 어려운 것처럼, 업데이트를 하면서 많은 고민을 했다.
비즈니스로직의 업데이트 메소드가 update로 되어있었기에 착각을 한 것인데(...)
왜 계속 이름이 안바뀌는거지 라면서 고민하다가 콘솔을 찍어보니
roomRepository.save.mockReturnValue(update);
이게 없어가지고 값이 바뀌질 않더라(....)
jest.fn()
으로 만든 메소드는 결과값이 고정되어있다는 사실을 망각했던 것이다.
그 외에는 헷갈렸던 부분은 없었다.
describe('delete', () => {
it('룸 삭제 성공', async () => {
// 동-일
const roomRepositorySpyFindOne = jest.spyOn(roomRepository, 'findOne');
const roomRepositorySpySoftDelete = jest.spyOn(
roomRepository,
'softDelete',
);
//나는 소프트 딜리트로 구현을 해놓았고, 리턴값이 boolean값이라 toEqual에 true를 넣어놨다.
expect(await roomService.delete(adminRoomInput)).toEqual(true);
// 동-일
expect(roomRepositorySpyFindOne).toHaveBeenCalledTimes(1);
expect(roomRepositorySpySoftDelete).toHaveBeenCalledTimes(1);
});
it('룸 삭제 실패', async () => {
// 이부분도 사실상 업데이트 실패와 동일한 구조로 되어있다.
await roomService
.delete({
id: 'farg-36512d-gaj241',
adminPassword: '1967237',
} as AdminRoomInput)
.catch((e) => {
expect(e.message).toEqual('관리자 비밀번호가 틀립니다.');
});
});
});
async delete(adminRoomInput: AdminRoomInput): Promise<boolean> {
const { id, adminPassword } = adminRoomInput;
// 검증 로직
const isRoom = await this.isRoomCheck(id, '', adminPassword);
try {
// 있으면 삭제하고 true 리턴
if (isRoom) {
await this.roomRepository.softDelete(id);
return true;
} else {
// 없으면 false 리턴
return false;
}
} catch (e) {
// 그 외의 에러는 서버문제로 휙
throw new Error('Room Delete Server Error');
}
}
여기도 큰 어려움 없이 슥슥 지나갔던 것 같다.
내가 참고를 했던 동기의 프로젝트에서는 실패 로직에서 expect().rejects.toThrowError()를 사용했다.
근데 도무지 해도 나는, 저게 맞질 않더라(...)
계속 저 문제가 해결이 안되서, 그냥 실패하는 로직들은 결과가 실패로 나오는게 맞는거 아닌가? 라는 생각도 들었다(ㅋㅋ)
그런데 그럴리가 있나(....)
await expect(
await roomService.delete({
id: 'farg-36512d-gaj241',
adminPassword: '19672317',
} as AdminRoomInput),
).rejects.toThrowError('관리자 비밀번호가 틀립니다.');
});
그래서 별 짓을 다하다가 어짜피 프로미스니까 비즈니스로직 수행하는 코드에 .catch을 붙이면 되지 않을까? 라고
생각을 했더니 해결이 됐다.(이렇게 하는게 맞나 솔직히 모르겠다.)
아예 에러를 체크하는 메소드가 있음에도 불구하고 아래처럼 쓰는게 맞는지는 잘 모르겠다.
await roomService
.delete({
id: 'farg-36512d-gaj241',
adminPassword: '1967237',
} as AdminRoomInput)
.catch((e) => {
expect(e.message).toEqual('관리자 비밀번호가 틀립니다.');
});
이렇게 날을 잡고, 테스트코드를 짜봤는데, CRUD만 만드는데 6시간 가량 걸린 것 같다.
그 와중에 많은 분들이 콕콕 찌르면서 도움을 주셨는데, 그게 없었더라면 얼마나 걸렸을지(...)
하지만 이렇게 머리싸메면서 코드를 작성해보았더니 이제는 조금 자신이 붙었달까. 기본적인 것은 금방 만들 것 같다.
물론 관계가 엮여있는 것들이나, CRUD가 아닌 추가적으로 작업이 필요한 기능같은 것을 어서 작업을 해봐야겠지만 말이다.
그리고 더더욱 좋은 방법으로 짜려면 무엇이 있을지도 고민을 해봐야할 것 같고 할게 정말 태산인 것 같다:(
코딩테스트 준비도 해야하고, 과제테스트 준비도 해야하고, 기술면접 준비도 해야하고 몸이 한개로는 정말....모자르다
시간이라도 하루에 48시간이였으면 좋겠다는 생각이 더욱 심하게 드는 요즘이다.
끝
이전에 동욱님 블로그에서 본적이 있는데
이렇게 하는 방식도 있었습니다~