직장에서 Nest.js에 테스트 코드를 도입했다가 실패했던 후기를 작성한 포스트 입니다. 잘 모르는 상태에서 무작정 테스트 코드를 도입하며 겪었던 문제점들과 깨달은 것들을 썼습니다.
Nest.js로 백엔드 서비스를 개발하고 있었을때 새로운 기능을 완성할때마다 해당 기능을 직접 하나하나 검증을 했었다.
그 당시에는 swagger 문서에서 각각의 엔드포인트를 직접 테스트하며 문제가 없으면 merge를 하는 식으로 일을 처리하다가 매번 직접 테스트를 하는 과정이 너무 귀찮고 시간이 많이 걸려서 이걸 자동화 할순 없을까? 라는 생각으로 테스트 코드를 떠올렸고 마침 직장 동료들도 나와 비슷한 생각이었다.
문제는 회사에 아무도 테스트 코드를 사용해본 경험자가 없는 상태였고 모두 테스트 코드를 들어는 봤지만 자세히는 모르는 상황이었다. 그 상태에서 어차피 언젠가는 해야할 사항이고 새로 배운다고 생각하고 일단은 도입해보자 라는 생각으로 테스트 코드에 대한 사전 조사와 학습이 없는 상태로 무작정 Nest.js의 공식문서에 있는 내용을 따라서 진행중이 프로젝트에 적용 시켜 버렸다.
사실 테스트 코드도 유닛 테스트,통합 테스트,E2E 테스트로 여러 종류가 있는데 우리는 그걸 무시하고(언제 어떻게 쓰는지 알아보지 않고) E2E 테스트를 도입했고 아래의 규칙을 정했다.
우리는 한 사람당 하나의 route(/user, /post 등) 를 분배했고 하위의 엔드포인트 개발도 담당했다. 예를들어 /aaa
라는 새로운 라우트를 개발해야할때 한사람이 /aaa와 그 하위의 모든 엔드포인트를 작업했기때문에 그 route에 대한 e2e 테스트 코드를 작성하고 모든 엔드포인트에 모든 경우의 수
까지 테스트 코드를 작성하기로 했다. 이렇게 테스트 코드를 적용하고 나서 대략 3개월 정도 일을 하면서 아래와 같은 문제들이 발생했다.
테스트 코드를 적용한 초반부터 어디까지 테스트를 적용해야 하느냐 라는 의문이 나왔다. 모든 엔드포인트를 테스트하기로 약속은 했지만 막상 개발을 하다보면 단순히 get이나 delete인곳에도 굳이 테스트를 적용해야할까 라는 생각이 계속 들었고 엔드포인트 뿐만이 아닌, 404와 403같은 어찌보면 당연한 응답에도 일일이 테스트 코드를 작성해야할까 라는 생각도 들었다.
당시 개발팀에서는 격리된 테스트 환경은 커녕 개발자 모두가 하나의 db를 공유하면서 개발을 하고있는 상황이었다. 정확히는 테스트 코드를 실행할때 격리된 테스트 환경이 필요하다는 사실조차 몰랐고 post,update,delete 같은 실제 db에 데이터가 반영이되는 엔드포인트에 테스트 코드를 작성하다가 뒤늦게 알게되었다. 이때는 임시 방편으로 테스트를 실행하기전에 필요한 데이터는 직접 생성하고 테스트가 끝나면 테스트중 생성하거나 변경한 데이터를 삭제하도록 처리했었다.
작업한 모든 엔드포인트에 모든 경우의수를 전부 테스트로 작성해야하다 보니 서비스 코드 작성보다 테스트 코드 작성에 시간이 더걸리는 배보다 배꼽이 더 커지는 경우가 자주 발생했다.
가장 큰 문제는 테스트 코드는 작성했지만 정작 테스트를 실행시키고 확인은 안했다는 것이다. 위에서 말한대로 테스트 환경이 구축된것이 아니다 보니 테스트를 하는것도 각자의 로컬에서만 했고 그저 본인의 로컬에서 돌아가는지 확인만하고 pr을 올렸다. 마치 서로가 "어차피 다른 사람이 작성한 테스트는 내 환경에서 실행이 안될것 같으니 신경쓰지 말자" 라고 암묵적으로 합의 한듯한 분위기였다.
결국 도입하기로한 테스트 코드는 어느 순간부터 형식상 작성은 해놓은 수준으로 방치되었다. 이마저도 좀더 시간이 지나자 일정 문제때문에 생략되는 경우도 생겼다. 처음 도입하기로한 의미도 잊혀지고 사실상 도입에 실패한것이다.
처음 테스트 코드의 실패를 경험하고 지금까지 대략 1년이라는 시간이 지났다. 그 사이 테스트 코드는 내 기억속에서 완전히 잊혀져 있다가 최근에서야 다시 한번 제대로 공부하고 써보자 라는 생각이 떠올라서 먼저 이전에 내가 경험한 실패 사례를 분석해 보기로 했다.
e2e 테스트로 모든 엔드포인트에 모든 경우의 수를 테스트하려고 하다 작업량이 늘어났고 그로 인해 완성하는데 시간도 늘어났다.
나중에 알게된 사실이지만 실제 테스트 코드는 유닛 테스트의 비중이 가장 높고 e2e 테스트 같은 규모가 큰건 비중을 최소화 해야한다고 한다. 그리고 e2e 테스트에서도 중요도가 높거나 버그가 발생할 가능성이 높은 로직을 우선
적으로 테스트 하며 모든 경우의 수를 테스트 하는건 비효율
적이라고 한다.
테스트를 위한 격리된 환경이 구축되어있지 않다 보니 각자의 로컬db에서 테스트를 실행시켰지만(심지어 로컬이 아닌 공유된 개발용 db에서 실행하는 경우도있었다) 테스트를 하는 과정에서 데이터가 손실되거나 오염되는 문제가 발생했다. 이 당시에는 테스트용 실행환경이 필요하다는 사실조차 몰랐고 내 로컬에서는 실행이 되어도 다른 환경에서 실행되었을때 db를 훼손하는 일이 발생하면 안돼기 때문에 테스트 코드 자체에 실행전 필요한 데이터 생성과 테스트 완료후 데이터를 삭제하는 코드를 직접 작성하였다 이 과정에서 많은 시간이 소모되었고 나뿐만이 아니라 다른 팀원들도 피로감을 호소하고있었다.
사실 처음부터 테스트 코드를 도입할 목적을 제대로 세우지 않고 무작정 도입한게 가장 큰 이유인것 같다. 솔직히 말하자면 그때 개발팀은 테스트 코드는 자동화 도구의 하나로만 생각을 하고 개발자로써 커리어를 위해 한번쯤은 해보고싶었다 라는 마인드로 도입한게 결정적인 이유였다. 이렇게 사전준비 없이 급하게 도입을 하다보니 무작정 e2e 테스트를 작성하려고 했으며 이는 작업량의 증가로 이어지고 위에서 말한 테스트 환경도 없는상태에서 굴러만 가는 테스트 코드
를 짜내고 피로감이 쌓이면서 팀원들 모두가 테스트 코드에 대해선 쉬쉬하는 분위기로 이어졌다.
좀더 추가적으로 말하자면 소통의 문제도 있었다. 다들 테스트 코드를 잘 모르는 상황에다 각자 일정에 쫒기고있는 상태였고 문제가 있다는건 알고있지만 선뜻 나서서 해결하려고 하는 사람이 없었다. 또한 테스트 코드 작성 규칙(의존성 주입,테스트 코드의 컨벤션 등)을 두고 팀원간의 의견충돌도 있었다. 상황이 이렇다 보니 누군가 지적을 안하면 그냥 조용히 넘어가길 바라고 있는 느낌이었다(물론 나도 그랬지만) 차라리 일단은 테스트 코드를 보류하고 나중에 좀더 학습을하고 계획이나 체계를 어느정도 세워놓은 상태에서 다시 도입을 했었다면 더 좋았겠다는 아쉬움이 크다.
현재 개인적으로 진행중인 프로젝트에 연습 겸 테스트 코드를 적용하기로 했다. 하지만 이번에는 테스트 코드를 제대로 학습하고 내 프로젝트에 어떤 테스트 코드가 적합한지 분석한 다음 적용했다. 내 개인 프로젝트에 사용된 기술 스택은 다음과 같다.
프로젝트의 코드를 분석하고 유닛 테스트를 작성할지 통합 테스트를 작성할지 철저하게 분석하였다. 비즈니스 로직이 제데로 동작하는지 확인이 필요한 코드에는 유닛 테스트를, 외부 api 연동이나 db에 저장하는 것이 핵심인 코드는 통합 테스트를 적용하기로 했다.
내 프로젝트의 컨트롤러의 경우 단순 서비스를 리턴하고 별다른 처리 로직이 없기때문에 컨트롤러의 테스트는 제외하기로 했고 유닛 테스트와 통합 테스트로도 충분히 검증이 가능하기때문에 e2e 테스트도 제외하고 서비스에 유닛 테스트와 통합 테스트만 작성하기로 결정했다. 핵심은 테스트 자체(커버리지)에 중점을 두는것이 아니라 최소한의 비용으로 코드를 검증 하는것에 집중했다.
데이터가 매우 중요한 프로젝트라 통합 테스트를 진행할때 기존에 db에 있던 데이터가 훼손되면 안되기때문에 완전히 격리된 테스트용 db를 구축하기로 했다.
docker compose를 사용하여 테스트용 db를 생성하고 테스트 완료후에는 바로 삭제하도록 하였다. 간단한 mysql 컨테이너를 띄우는 yml 이다.
version: '3.8'
services:
test-db:
image: mysql:8.0
environment:
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
MYSQL_DATABASE: testdb
MYSQL_ROOT_PASSWORD: rootpass
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "testuser", "-ptestpass"]
interval: 5s
timeout: 5s
retries: 5
그리고 컨테이너가 실행되면 내부의 db에 개발중인 스키마를 생성한다 나의 경우 prisma orm을 사용하고 있어서 prisma 명령어를 실행해야 한다.
dotenv -e .env.test -- npx prisma db push
prisma db push 명령어는 작성된 스키마 파일을 db에 그대로 생성하는 명령어다. 테스트용 db라 사용후 삭제할거기 때문에 그냥 그대로 push했다.
그리고 package.json
파일에 명령어를 추가했다.
{
"scripts":{
"test:db:up": "docker-compose up -d",
"test:db:down": "docker-compose down",
"test:db:init": "dotenv -e .env.test -- npx prisma db push"
}
}
아래의 코드는 통합 테스트를 작성한 서비스의 의사코드이다.
async getData(id:string){
const data = await otherApi({id, /*기타 데이터*/})
//db에 저장해야할 데이터 1
const saveData1 = data.some.thing
await this.prisma.someTable.create(saveData1)
//db에 저장해야할 데이터 2
const saveData2 = generateSaveDataFormFromApiData(data)
await this.prisma.someTable2.create(saveData2)
return '완료 응답'
}
이 코드의 핵심은 외부 api로 부터 데이터를 받아오고 그걸 가공해서 db에 저장하는 부분이다.
import { Test, TestingModule } from '@nestjs/testing';
import { PortfolioService } from './portfolio.service';
import { PrismaService } from 'prisma/prisma.service';
import * as yahooFinance from '@src/common/utils/yahooFinance';
import { YahooFinanceResponse } from '@src/common/types/YahooFinance';
describe('PortfolioService', () => {
let service: PortfolioService;
let prisma: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PortfolioService, PrismaService],
}).compile();
service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService);
});
it('findTicker DB에 TSLA 티커와 거래소가 저장되어야함', async () => {
//db에 저장되어야하는 데이터 양식
const tickerResult = {
id: 'TSLA',
name: 'Tesla, Inc.',
};
const exchangeResult = {
code: 'NMS',
name: 'NasdaqGS',
};
//테스트 실행중 실제 외부api를 호출하지 않고 모킹된 데이터 사용
const mockYahooData = {
chart: {
result: [
{
meta: {
fullExchangeName: 'NasdaqGS',
exchangeName: 'NMS',
longName: 'Tesla, Inc.',
instrumentType: 'EQUITY',
},
timestamp: [1697059200],
indicators: {
quote: [
{
open: [250],
high: [255],
low: [248],
close: [253],
volume: [1000000],
},
],
},
},
],
},
} as unknown as YahooFinanceResponse;
jest.spyOn(yahooFinance, 'getTickerData').mockResolvedValue(mockYahooData);
await service.findTicker('TSLA');
const [ticker, exchange] = await Promise.all([
prisma.tickers.findFirst({ where: { id: 'TSLA' } }),
prisma.exchanges.findFirst({ where: { code: 'NMS' } }),
]);
expect(ticker?.id).toBe(tickerResult.id);
expect(ticker?.name).toBe(tickerResult.name);
expect(exchange?.code).toBe(exchangeResult.code);
expect(exchange?.name).toBe(exchangeResult.name);
});
});
외부 api의 상태에 따라 테스트가 실패하면 안돼기때문에 모킹하여 고정된 데이터를 리턴하도록 처리했고 서비스가 실행되고 나면 실제 db에 쿼리를 날려 데이터를 가져와 비교하고 제대로 저장이 되었는지 검증한다.
실패를 겪고 제대로 한번 배우고 적용해 보자라는 생각으로 포스팅을 작성했지만 솔직히 말하자면 아직은 연습단계다. 예시로 작성해본 테스트 코드도 부족한 부분이 있지만 프로젝트의 상황에 맞게 어떤 테스트를 작성해야하는지 알게되었다.
포스팅을 작성하며 테스트 환경을 구축하고 직접 실행시켜 확인하는게 다소 까다로웠다. 처음 해본거라 그럴수도 있지만 은근히 설정해줘야할 부분이 많았다. 하지만 이번에 직접 경험을 해봤으니 다음번엔 빠르게 테스트 환경을 구축할수있을것 같다.
만약 다음에 테스트 코드를 도입해야할 때가 온다면 그때는 팀원들과 명확하게 목적과 가이드 라인을 세운다음 도입할것같다. 나뿐만이 아니라 함께 일하는 동료도 테스트 코드를 작성할줄 알아야 하기때문에 어떤 부분에 어떤 테스트를, 테스트 환경은 어떻게, 테스트 실행 방법 등의 어느정도의 가이드 라인이 제시되면 지난번처럼 헤메다가 실패하는 경우는 줄어들것이다.