[SYNAPSE 개발일지 #5] 테스트 코드 도입과 GitHub Actions를 활용한 CI 파이프라인 구축
[SYNAPSE 개발일지 #4] Refresh Token 적용을 위한 API 구조 개선과 Axios 마이그레이션
[SYNAPSE 개발일지 #4] Refresh Token 적용을 위한 API 구조 개선과 Axios 마이그레이션
[SYNAPSE 개발일지 #3] GraphRAG 파이프라인 구축 - 그래프 생성과 탐색, SSE 기반 실시간 통신까지 [SYNAPSE 개발일지 #3] GraphRAG 파이프라인 구축 - 그래프 생성과 탐색, SSE 기반 실시간 통신까지[SYNAPSE 개
blog.juyear.dev
이전 글 읽으러 가기!
👋 소개 및 회고
안녕하세요. 대학생 개발자 주이어입니다.
저번 글에서는 Refresh Token을 이용한 재발급 기능을 구현하고, 이를 적용하기 위해 API 구조 개선과 Axios 마이그레이션 작업을 했었습니다.
오늘은 점점 커져가는 프로젝트를 좀 더 수월하게 관리할 수 있도록 도와주는 테스트 코드(Test Code)와 이를 이용한 GitHub Actions 자동 테스트 파이프라인 까지 구축해보려 합니다.
- 테스트 코드를 도입한 이유
- Unit Test
- E2E Test
- GitHub Actions 적용
위 4가지를 중심으로 작성하며, 중간 중간 필요한 내용을 덧붙일 생각입니다.
🔐 테스트 코드를 도입한 이유
테스트 코드를 작성하는 것은 기술적으로 어려운 작업은 아니라고 생각합니다.
중요한 것은 왜, 어떻게, 무슨 기준으로 도입하는지 라고 생각합니다.
[도입 이유]
저의 경우 테스트 코드를 도입해야 하는 이유가 점점 명확해지고 있었습니다.
Graph RAG, SSE 실시간 통신, Orchestrator 등 복잡한 로직이 추가되고 있었고,
이러한 로직은 기존 도메인 모델이나 코드에 영향이 갈 수 있는 작업들이었습니다.
하지만 이러한 작업이 끝날 때 마다, 기존 로직이 제대로 작동하는지 일일이 테스트 하는 것은 규모가 커질수록 비효율적이고 쉽지 않은 작업이었습니다.
또한 외부 모듈에 의존하는 기능들이 많아질 수록 이러한 복잡한 관계를 모두 테스트하기 쉽지 않았습니다.
따라서 저는 늦기 전에 테스트 코드를 도입하고 작성하는 작업을 해야겠다고 생각했습니다.
[도입 방법]
저는 Nest를 사용하고 있었기 때문에 일반적으로 주로 사용하는 jest를 사용하여 테스트 코드를 도입하였습니다.
[기준]
테스트 코드 작성 기준이라고 하면 여러 기준이 있겠지만, 저는 단순 coverage를 채우기 보다는 핵심 로직을 확실히 테스트 하는 것이 더 중요하다고 생각했고, 기준도 이에 맞게 설계했습니다.
(개발 중간에 테스트 코드를 도입하는 상황에서 단순히 coverage를 높이는 것이 기준이 되는 것은 적절하지 않다고 생각했습니다.)
1. Service와 Service 처럼 핵심 비즈니스 로직을 가지고 있는 코드에 대해서 Unit Test를 작성하여 coverage 80% 이상을 유지한다.
2. Controller, Dto, Guard와 같이 상대적으로 덜 중요한 코드에 대해서는 E2E Test에서 어느정도 같이 검증할 수 있도록 설계한다.
3. Push 진행 시 Unit Test를 실행한다.
4. PR 생성 시 Unit Test와 E2E Test를 모두 실행하며, 실패 시 Merge를 할 수 없도록 한다.
위와 같이 4가지 기준을 세웠고, 해당 기준에는 GitHub Actions에 들어갈 자동 테스트와 허용 기준도 포함하였습니다.
처음부터 이러한 기준을 세웠던 것은 아니었습니다.
테스트 코드를 작성하기 전에 현재 상태를 확인하기 위해서 npm run test:cov를 진행했는데, 현재 코드, 파일 수, coverage를 보니 무작정 테스트 코드를 도입하자 형식으로 가는 것은 적절하지 않다고 생각해서 기준을 세웠습니다.
🔐 Unit Test
Unit Test를 작성하는 이유
Unit Test는 이름에서 어느정도 예상할 수 있듯이 Unit 단위 별로 즉, 기능 단위로 테스트하기 위한 테스트 코드입니다.
이러한 Unit Test는 테스트 코드 중 가장 작은 단위의 테스트이며, 테스트 코드 비중에서 가장 많은 양을 차지합니다.
Unit Test의 특징 중 하나는 "하나의 Test에서는 하나의 기능(또는 하나의 핵심 로직)만을 테스트한다." 입니다.
이 특징은 Unit Test 작성에 있어서 가장 중요한 규칙 중 하나라고 생각하고, 이를 이해해야 Mock 데이터를 왜 활용하는지 이해할 수 있다고 생각합니다.
Unit Test에서 Mock 데이터를 사용하는 이유
Unit Test는 방금 말했듯이 하나의 Test에서 하나의 기능을 테스트합니다.
예를 들어 유저 삭제 서비스를 테스트한다고 가정해보겠습니다.
유저 삭제 서비스는 유저를 조회 및 삭제하기 위해 DB에 접근해야 합니다.
만약 이 때 Mock 데이터를 사용하지 않으면 어떻게 될까요?

해당 Unit Test가 검증하고 싶은 것은 유저 삭제 서비스 로직입니다.
예를 들면 삭제 로직이 제대로 작동하는지, 유저를 조회하지 못 했을 때 예외 처리가 정상적으로 작동하는지 등 입니다.
하지만 실제 DB로 테스트를 진행하다가 테스트가 실패한다면, 유저 삭제 서비스 로직이 잘못된 것인지, Repository 설정이 잘못된 것인지, 아니면 단순히 DB 연결에 실패한 것이지 제대로 구분하기가 어렵습니다.
또한 검증하고 싶은 기능뿐만 아니라 다른 기능이나 외부 의존성까지 검증이 필요하게 됩니다.
즉, Unit Test의 기능 단위 테스트라는 특징이 깨지게 됩니다.
이러한 문제를 해결하기 위해 일반적으로 외부 모듈, 외부 infra, 외부 함수 등 해당 로직에서 외부로 나가는 코드는 대부분 Mock으로 대체합니다.
이렇게 하면, Unit Test는 DB나 외부 모듈에 관계없이 유저 삭제 서비스 로직이 제대로 작동하는지만 테스트 할 수 있게 됩니다.
물론 DB와 연결해서 실제로 작동이 되는지 테스트하고 싶을 수도 있습니다.
하지만 이런 경우 Unit Test가 아닌 더 넓은 개념의 테스트인 Integration Test나 E2E Test를 활용합니다.
각 테스트의 역할과 책임을 명확하게 구분해야 의미있는 테스트를 진행할 수 있다고 생각합니다.
Unit Test를 작성하기
Unit Test 작성 예시를 보여드리겠습니다.
당연하지만 테스트 상황은 매우 다양하고, 상황에 따라 DB에 의존하기도, 외부 infra에 의존하기도, 같은 Service의 다른 함수를 호출하기도 합니다.
jest의 경우 이런 다양한 상황에 맞는 다양한 함수를 제공하고 있기 때문에 상황에 맞게 사용할 수 있습니다.
describe('CategoryService', () => {
let service: CategoriesService;
let repository: jest.Mocked<Repository<Category>>;
const createUser = (): User => ({ id: 1 }) as User;
const createNote = (): Note => ({ id: 1 }) as Note;
const createCategory = (overrides: Partial<Category> = {}): Category =>
({
id: 1,
title: '개발',
color: '#000000',
...overrides,
}) as Category;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CategoriesService,
{
provide: getRepositoryToken(Category),
useValue: {
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<CategoriesService>(CategoriesService);
repository = module.get(getRepositoryToken(Category));
});
위 코드는 Unit Test 설정 부분입니다.
let repository: jest.Mocked<Repository<Category>>;
여기서 주목해서 봐야할 부분은 의존성 주입에서 실제 Repository를 주입하는 것이 아닌 jest에서 지원하는 Mock Repository를 주입한다는 점입니다. 위에서 설명했던 Mock을 어떻게 생성하는지 확인할 수 있습니다.
{
provide: getRepositoryToken(Category),
useValue: {
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
delete: jest.fn(),
},
이렇게 생성된 Mock Repository는 당연히 실제 Repository가 아니기 때문에 create, save 등을 기본적으로 지원하지 않습니다.
따라서 위 처럼 어떤 함수가 있는지 선언을 해줘야 하며, Mock 함수라는 것을 알려주기 위해 jest.fn()을 사용합니다.
describe('remove', () => {
it('카테고리를 ID 기준으로 삭제하고 성공 여부를 반환한다.', async () => {
const category = createCategory({ notes: [] });
repository.findOne.mockResolvedValue(category);
repository.delete.mockResolvedValue({ affected: 1 } as DeleteResult);
const result = await service.remove(category.id);
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: category.id },
relations: {
notes: true,
},
});
expect(repository.delete).toHaveBeenCalledWith(category.id);
expect(result).toEqual({ affected: 1 });
});
it('카테고리에 노트가 존재하는 경우 BadRequestException을 발생시킨다.', async () => {
const note = createNote();
const category = createCategory({ notes: [note] });
repository.findOne.mockResolvedValue(category);
await expect(service.remove(category.id)).rejects.toThrow(
BadRequestException,
);
expect(repository.delete).not.toHaveBeenCalled();
});
it('카테고리를 찾을 수 없는 경우 NotFoundException을 발생시킨다.', async () => {
const category = createCategory();
repository.findOne.mockResolvedValue(null);
await expect(service.remove(category.id)).rejects.toThrow(
NotFoundException,
);
expect(repository.delete).not.toHaveBeenCalled();
});
});
위 코드는 카테고리 삭제 서비스를 검증하는 Unit Test 코드입니다.
it 하나 하나가 테스트이고, 위 코드는 카테고리 삭제에 대해서 3가지 테스트를 진행하는 것을 알 수 있습니다.
카테고리 삭제는 단순히 하나의 기능이지만 카테고리가 존재하는지, 카테고리 안에 포함된 노트가 있는지 등에 따라서 실행 결과가 바뀌고 Unit Test에서는 이런 모든 분기에 대해서 테스트 하는 것이 목적이기 때문에 보통 하나의 기능에 대해서도 여러 테스트 코드가 작성됩니다.
let redisService: jest.Mocked<RedisService>;
지금은 Mock Repository에 대해서만 예시를 보여드렸지만, 실제로는 서비스도 Mock으로 만들어 주입할 수 있습니다.
Redis Service와 같이 외부 infra에 의존해야 하는 함수를 실제로 넣어 테스트를 하면 테스트 범위가 모호해지기 때문에 Mock Redis Service를 만들어 주입할 수 있습니다.
jest.spyOn(authService, 'login').mockReturnValue(tokens);
이외에도 테스트 해야하는 서비스의 특정 함수만 Mock 함수로 변경하는 기능도 제공합니다.
테스트를 검증하는 방식에도 단순히 값이 같은지, 같은 객체인지(깊은 비교), 함수가 몇 번 호출됐는지, 함수가 호출이 안됐는지, 함수에서 오류를 던졌는지 등 여러가지가 있습니다.
npm test
npm run test:cov
이렇게 완성된 Unit Test는 터미널에서 위와 같이 한줄만 입력하면 한 번에 테스트를 진행할 수 있습니다.
npm test의 경우 모든 Unit Test를 실행하고 통과 결과만 알려줍니다.
반면 npm run test:cov는 coverage 비율까지 같이 알려줍니다.

위와 같이 작성된 테스트 코드에 대해서 실행하고 결과를 알려주는 것을 알 수 있습니다.
🔐 E2E Test
E2E Test를 작성하는 이유
E2E Test는 위에서 말했었지만, Unit Test보다 더 넓은 개념의 테스트입니다.
E2E는 End To End의 줄임말로 이름 그대로 처음부터 끝까지 테스트합니다.
E2E Test의 경우 실서비스 환경과 유사한 환경에서 테스트를 진행하기 때문에 AppModule을 기반으로 애플리케이션의 여러 계층을 연결하여 테스트합니다.
Unit Test로 Controller, Service, 외부 모듈 등을 각각 테스트했다면, E2E Test로 Controller -> Service -> Repository 까지 비즈니스 흐름 전체를 테스트합니다.
그럼 처음부터 E2E Test를 작성하면 되는거 아닌가 라는 생각도 들지만, E2E Test는 모든 상황에 대해서 테스트를 작성하기에는 매우 복잡하고 비효율적입니다.
그래서 보통 Unit Test에서 대부분의 분기에 대해서 테스트를 진행하고, E2E Test에서는 핵심 비즈니스 흐름에 대해서만 테스트를 진행합니다.
E2E Test 작성하기
Category 생성 비즈니스 흐름을 검증하는 E2E Test 작성 예시를 보여드리겠습니다.
describe('Categories E2E', () => {
let app: INestApplication;
let agent!: ReturnType<typeof request.agent>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
await app.init();
agent = request.agent(app.getHttpServer());
});
afterAll(async () => {
await app.close();
});
우선 테스트 시작 전 설정 부분입니다.
E2E Test의 경우 실제 환경과 유사하게 진행하기 때문에 특정 모듈이 아니라 AppModule 자체를 넣어 진행하며, Mock 설정 비율이 적어지거나 없습니다.
또한 agent를 설정하고 있는 것을 알 수 있는데, 이는 밑에서 자세히 설명하도록 하겠습니다.
describe('POST /categories', () => {
it('로그인 후 카테고리를 생성한다.', async () => {
await agent
.post('/auth/login')
.send({
email: 'test@example.com',
password: '1111',
})
.expect(201);
const response = await agent
.post('/categories')
.send({
title: '개발',
color: '#000000',
})
.expect(201);
expect(response.body).toEqual(
expect.objectContaining({
title: '개발',
color: '#000000',
}),
);
});
});
이 부분이 E2E Test 부분입니다.
여기서 agent를 사용하는 것을 알 수 있습니다.
agent는 간단하게 말해서 이전 요청에 대한 결과를 유지한 채로 다음 요청을 보낼 수 있게 도와주는 기능입니다.
이 기능이 필요한 이유는 위 테스트 코드를 보면 알 수 있습니다.
POST/categories를 요청하기 위해서는 유저 인증이 선행되어야 하고, 현재 저는 JWT 기반 유저 인증 방식을 사용하고 있었기 때문에 쿠키값을 확인하여 유저 인증을 진행합니다.
쿠키를 받기 위해서는 login을 먼저 진행해야 하는데, 이 때 받은 쿠키를 유지하기 위해서는 agent를 사용해야 하는 것입니다.
E2E Test의 경우 하나의 흐름에 대해서만 검증하기도 하지만, 이와 같이 여러 요청 응답을 하나의 시나리오로 설정하고 테스트를 진행하기도 합니다.
POST /auth/login -> Auth Controller -> Auth Service -> Return Token -> POST /categories -> Categories Controller -> Categories Service -> Categories Repository -> Return
즉, 위와 같이 굉장히 긴 단위의 흐름을 검증하기도 합니다.
간단히 보면 "로그인 후 카테고리 생성 흐름 테스트"로 볼 수 있습니다.
만약 여기서 "유저 인증이 되는지"만 테스트 하고 싶다면, 이는 Guard의 세부적인 분기에 더 가깝기 때문에 E2E Test 보다는 Unit Test에 더 적합합니다.
npm run test:e2e
이렇게 완성된 E2E Test 코드는 터미널에서 위와 같이 입력하여 바로 테스트할 수 있습니다.
결과는 Unit Test 실행시켰을 때와 비슷하게 나옵니다.
🔐 GitHub Actions를 활용한 자동 테스트 파이프라인
이렇게 작성한 테스트 코드는 터미널에서 실행시켜 직접 확인하는 용도로도 사용할 수 있지만,
GitHub Actions에 등록하여 push나 pr같은 특정 상황에서 자동으로 해당 테스트 코드를 실행시켜 코드가 정상적으로 돌아가는지 확인하는 용도로도 사용할 수 있습니다.
ci에서 자주 사용하는 자동 테스트 방법입니다.
name: Backend Test
on:
push:
branches:
- "*"
pull_request:
branches:
- main
- dev
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run Unit Tests
run: npm test
push나 pr시 Unit Test를 실행시키는 GitHub Actions workflow 설정 코드입니다.
이 부분이 핵심 내용은 아니기 때문에 간단히 설명하자면 push(모든 브랜치)나 pr(main과 dev)이 발생하면 backend에서 npm test를 실행시켜 오류 없이 작동하는지 확인하겠다는 뜻입니다.

이를 적용하고 push 및 pr을 해보면, GitHub Actions에 위와 같이 제가 작성한 테스트 코드를 실행시키고 통과하는지 확인하는 것을 알 수 있습니다. 하지만 아직 실패한다고 해서 merge를 할 수 없는 것은 아닙니다.

이를 적용하기 위해서 branch ruleset을 만들었고, dev와 main에 pr 생성 시 test GitHub Actions을 실행시켜 통과하지 못할 경우 merge를 할 수 없도록 해주었습니다.
이를 통해 단순히 로컬에서만 검증하는 것을 넘어 push나 pr시 GitHub에서 검증하는 파이프라인까지 만들어주었습니다.
앞으로 복잡한 기능을 구현하거나 기존 도메인 모델을 변경하더라도 비효율적으로 하나씩 검증할 필요가 없어졌습니다.
추가로 pr 진행 시 E2E Test를 같이 진행한다고 했었는데, E2E Test의 경우 단순히 백엔드 서버 만으로는 실행이 불가능한 경우가 많습니다. 왜냐하면 db, redis 등 실제로 외부 의존까지 구성해야 하는 경우가 있기 때문입니다.
그래서 이 부분은 제가 추후에 AWS에 서버를 구축하고, CI/CD를 고도화하는 과정에서 같이 적어보도록 하겠습니다.
😊 마무리
✅ Unit Test 작성
✅ E2E Test 작성
✅ GitHub Actions를 활용한 자동 테스트 CI 구축 (현재는 Unit Test만 적용)
[진행 상황 정리]
이번 글에서는 설계와 기술적인 관점보다는 프로젝트가 점점 복잡해지는 과정에서 테스트 코드를 왜 도입했는지, 이를 어떻게 활용했는지에 초점을 맞춰 작성해보았습니다.
단순히 테스트 코드를 작성하는 것에서 끝나는 것이 아니라, GitHub Actions와 Branch Ruleset을 통해 코드 변경이 기존 기능에 문제를 일으키지 않는지 자동으로 확인하는 CI 환경까지 구축할 수 있었습니다.
다음 글의 주제는 아직 확정되지 않았지만, 지금 예상으로는 아마 "검색 시스템 설계 및 구현"이 될 것 같습니다.
그럼 지금까지
읽어주셔서 감사드리며, 다음에 더 좋은 글로 돌아오도록 하겠습니다.
by. 대학생 개발자 주이어
KYT CODING COMMUNITY Discord 서버에 가입하세요!
Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 26명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.
discord.com
KYT CODING COMMUNITY 가입하기!