Search
🚦

사내 단위 테스트 작성 문화 도입기 (2) DB Sociable Test 환경 구축

Tags
Post
Test
Last edited time
2025/03/23 01:48
2 more properties

1. 들어가기에 앞서

현재 재직중인 회사의 백엔드 팀에 도입하고자 했던 문화 중 하나가 테스트 코드를 작성하는 것이었다. 입사 초기에 남는 시간을 활용하여 모킹을 활용한 Solitary Test 방식의 테스트 코드 작성법을 공유한 적이 있는데, 이와 관련해서 약 1년반전쯤에 블로그에 포스팅도 했었다.
이후, 이어서 테스트 대상과 의존하는 대상을 함께 테스트하는 Sociable Test에 관해서도 팀 내에 전파를 한적이 있는데 생각해보니 이와 관련된 내용으로 블로그 포스팅은 하지 않았다는걸 최근에 깨닫고 이제서야 포스팅을 하려고한다. (왜 나는 지금까지 포스팅을 한줄 알았을까? )
여기서 필자가 말하는 Sociable Test는 외부 의존성 객체들을 함께 테스트한다는 의미도 있지만, 이번 포스팅에서 조금 더 집중하고 싶은 외부 의존성은 데이터베이스이다. 특히 TypeORM과 같은 경우, 타입 시스템이 견고하지 않아 내가 실제로 작성한 Repository 코드가 정상적으로 동작하는지에 대한 불안감이 존재해왔다. 이럴 때 단위 테스트에서 실제 데이터베이스까지 연동해서 테스트할 수 있다면 테스트를 통해 다양한 불안 요소들을 해소할 수 있다. 비단 Repository 단의 Test가 아니더라도 Application Layer의 Service나 UseCase 객체에서도 데이터베이스를 연동해서 테스트를 할 수 있다.
지금부터 NestJS에서 어떤식으로 DB와 연동해서 단위 테스트를 작성할 수 있는지를 살펴보도록 하자.

2. Solitary Test와 Sociable Test의 차이

이전 포스팅에서도 간략하게 설명했지만, 다시 한번 두 테스트 방식의 차이점을 짚고 넘어가자.
Solitary Test는 의존성을 제거하여 테스트 대상만 격리해서 테스트하는 방식이다. 이 방법에서는 외부 의존성(데이터베이스, 외부 API 등)을 Test Double(Mock, Stub)로 대체하여 테스트한다. 이렇게 하면 테스트 속도가 빨라지고, 의존성 문제로 인한 테스트 실패 요소를 제거할 수 있다는 장점이 있다. 그러나 실제 환경과는 차이가 있을 수 있고, 의존성 간 상호작용에서 발생할 수 있는 문제를 발견하기 어렵다는 단점이 존재한다.
반면, Sociable Test는 테스트 대상과 의존하는 대상을 함께 테스트하는 방식이다. 실제 외부 의존성(데이터베이스 등)을 직접 사용하여 테스트하기 때문에, 실제 환경과 유사한 상태에서 테스트할 수 있어서 신뢰성이 높다는 장점이 있다. 하지만 테스트 속도가 느려지고, 외부 의존성 설정이 복잡할 수 있다는 점은 감안해야 한다.

3. Solitary Test의 한계와 Sociable Test의 필요성

3.1. 모킹 기반 Solitary Test의 한계

이전 포스팅에서 소개한 Mock/Stub을 활용한 Solitary Test 방식은 분명 많은 장점이 있지만, 몇 가지 중요한 한계점이 존재한다.
첫째, 모킹은 실제 구현체를 대체하기 때문에 구현체의 실제 동작과 다를 수 있다. 특히 복잡한 비즈니스 로직이나 여러 계층 간의 상호작용에서는 모킹이 실제 동작을 정확히 시뮬레이션하지 못하는 경우가 있다. 이로 인해 테스트에서는 문제가 없어 보이더라도 실제 운영 환경에서는 오류가 발생하는 경우가 종종 발생할 수 있다.
둘째, 모킹 설정 자체가 매우 복잡하고 번거로운 작업이다. 특히 NestJS와 같은 의존성 주입이 복잡한 프레임워크에서는 하나의 클래스를 테스트하기 위해 연쇄적으로 많은 의존성을 모킹해야 하는 경우가 많다. 이러한 과정이 테스트 작성의 장벽으로 작용하여, 결국 테스트를 작성하지 않거나 최소한의 테스트만 작성하게 되는 상황이 발생할 수 있다.
셋째, 모킹된 테스트는 실제 통합적인 동작을 검증하지 못한다. 특히 여러 컴포넌트가 상호작용하는 복잡한 시스템에서는 각 부분이 독립적으로는 잘 동작하더라도, 함께 작동할 때 예상치 못한 문제가 발생할 수 있다. 이러한 통합적인 문제는 모킹 기반 테스트에서는 발견하기 어렵다.
마지막으로, 모킹 코드가 실제 구현체와 함께 변경되지 않는 경우가 많아 테스트의 신뢰성이 저하되는 문제가 있다. 실제 코드가 변경되었을 때 모킹 코드도 함께 업데이트되어야 하지만, 이 과정이 누락되는 경우가 많아 테스트가 거짓된 결과를 내는 경우가 있다.

3.2. 외부 의존성 주입의 중요성과 DB 연동

Sociable Test는 이러한 Solitary Test의 한계를 극복하기 위한 대안으로, 테스트 대상과 실제 의존성을 함께 테스트하는 방식이다. Sociable Test에서는 외부 API, 데이터베이스, 메시징 시스템 등 다양한 외부 의존성을 실제로 사용할 수 있다.
그중에서도 필자가 데이터베이스 연동 테스트에 특별히 초점을 맞춘 이유는 백엔드 시스템에서 데이터베이스가 가장 중요하고 복잡한 외부 의존성이기 때문이다. 실제로 코드 작성이 끝나고 E2E 테스트를 진행해보면 데이터베이스 상호작용 관련 문제들이 많이 발생한다. 복잡한 쿼리, 트랜잭션 관리, 관계 설정 등에서 발생하는 문제는 모킹만으로는 찾아내기 어려운게 사실이다.
또한, 모던 백엔드 아키텍처에서 Repository 패턴은 핵심적인 역할을 한다. 이 계층이 제대로 동작하지 않으면 상위 계층인 Service나 Controller도 함께 영향을 받게 된다. 따라서 데이터 접근 계층을 실제 데이터베이스와 연동하여 테스트하는 것은 전체 시스템의 안정성을 높이는 데 큰 도움이 된다.
특히 우리 팀에서는 비즈니스 로직이 복잡한 것도 있지만 엔티티간의 연관 관계와 쿼리의 복잡성도 큰 문제 중 하난였다. 예를 들어, 여러 테이블을 조인하는 복잡한 쿼리나 다중 트랜잭션을 사용하는 로직은 모킹으로는 제대로 테스트하기 어려웠다. 이런 상황에서 실제 데이터베이스를 사용한 테스트는 실질적인 문제를 조기에 발견하는 데 큰 도움이 될 수 있다.
다른 외부 의존성(예: 외부 API, Kafka)의 경우에는 모킹을 활용하는게 적절할수 있지만, 데이터베이스의 경우 실제 DB와의 상호작용을 테스트하는 것이 더 효과적이고 직접적이라고 생각한다. 특히 트랜잭션 관리, 등 데이터베이스 특유의 동작을 테스트하려면 실제 DB를 사용하는 것이 필수적이다.

3.3. TypeORM  AnyORM

Sociable Test는 TypeORM을 사용할때 더 큰 힘을 발휘한다. TypeORM은 Node.js 생태계에서 가장 널리 사용되는 ORM 중 하나지만, 타입 시스템이 완벽하지 않아 개발자에게 불안감을 주는 경우가 많았다.
TypeORM의 가장 큰 문제점은 타입 체크가 런타임이 아닌 컴파일 타임에만 이루어진다는 점이다. 코드를 작성할 때는 타입스크립트의 정적 타입 검사를 통과하더라도, 실제 런타임에서는 데이터베이스에서 반환된 값이 예상과 다를 수 있다. 이는 특히 복잡한 쿼리나 관계가 있는 경우 더욱 두드러진다.
또한, Raw Query나 QueryBuilder를 사용할 때 타입 안정성이 크게 부족하다. 예를 들어, createQueryBuilder().select().where() 등의 메서드 체이닝으로 작성된 쿼리는 타입스크립트에서 반환 타입을 정확히 추론하지 못하는 경우가 많다. 특히 조인이나 서브쿼리가 포함된 복잡한 쿼리에서는 이 문제가 더욱 두드러진다.
복잡한 Repository 메서드를 작성한 후에 실제로 어떤 SQL이 생성되는지, 그리고 그 SQL이 의도대로 동작하는지 확인하기도 어렵다. TypeORM은 내부적으로 복잡한 SQL 생성 로직을 가지고 있어, 때로는 예상과 다른 쿼리가 생성되어 성능 문제나 기능적 오류를 일으키기도 한다.
이러한 TypeORM의 특성은 테스트에서 실제 데이터베이스를 사용해야 할 필요성을 더욱 높인다. 모킹만으로는 위에서 언급한 문제들을 발견하기 어렵기 때문에, 실제 데이터베이스와 연동한 테스트를 통해 TypeORM 관련 이슈를 조기에 발견하고 해결할 수 있다.
이러한 이유들로 인해, 필자는 데이터 접근 로직에 대해서는 실제 데이터베이스와 연동한 Sociable Test를 적극적으로 도입하는 것을 제안했다.

4. NestJS에서 DB 연동 테스트 구현하기

4.1. DB 연동 테스트를 위한 기본 설정

DB 연동 테스트를 구현하기 위해서는 테스트 환경에 맞는 데이터베이스 설정과 트랜잭션 관리가 필수적이다. 아래 코드는 이러한 설정을 위한 기반을 제공한다.
import { DataSource, EntityManager } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; const PROJECT_ROOT = `${process.cwd()}`; export const integrationTestDataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST || 'localhost', port: Number(process.env.DB_PORT) || 3306, username: process.env.DB_USERNAME || 'test', password: process.env.DB_PASSWORD || 'test', database: process.env.DB_DATABASE || 'test', entities: [`${PROJECT_ROOT}/src/**/*.entity{.ts,.js}`], namingStrategy: new SnakeNamingStrategy(), charset: 'utf8mb4', logging: ['error', 'warn'], synchronize: false, }); export async function initIntegrationTestDataSource() { await integrationTestDataSource.initialize(); return integrationTestDataSource; } export async function startIntegrationTestTransaction(entityManager: EntityManager) { await entityManager.query(`SET FOREIGN_KEY_CHECKS = 0`); await entityManager.query('START TRANSACTION'); } export async function rollbackIntegrationTestTransaction(entityManager: EntityManager) { await entityManager.query('ROLLBACK'); }
TypeScript
복사
이 코드에서는 여러 중요한 설정과 함수들이 포함되어 있다. 먼저, integrationTestDataSource는 테스트 전용 데이터베이스 연결 설정을 담고 있다. 여기서는 MySQL을 사용하고 있으며, 환경 변수를 통해 접속 정보를 구성할 수 있다. 기본값으로는 로컬 환경의 'test' 데이터베이스를 사용하도록 설정되어 있다.
특히 중요한 설정은 entities 옵션으로, TypeORM이 인식해야 할 엔티티 파일의 경로 패턴을 지정한다. 이 예시에서는 프로젝트 루트 디렉토리 아래의 모든 .entity.ts 또는 .entity.js 파일을 찾아서 엔티티로 등록한다. namingStrategysynchronize 등의 옵션은 이번 포스팅에서 크게 중요한 내용은 아니므로 생략한다.
initIntegrationTestDataSource 함수는 DataSource 객체를 초기화하고 반환한다. 이 함수는 테스트가 시작되기 전에 호출되어 데이터베이스 연결을 설정한다. TypeORM의 DataSource는 NestJS 애플리케이션에서 데이터베이스 연결을 관리하는 핵심 객체로, 이를 통해 EntityManager를 얻고 Repository를 생성할 수 있다.
startIntegrationTestTransactionrollbackIntegrationTestTransaction 함수는 테스트 간의 데이터 격리를 위한 트랜잭션 관리를 담당한다. 특히 startIntegrationTestTransaction에서는 두 가지 중요한 작업을 수행한다. 먼저 SET FOREIGN_KEY_CHECKS = 0을 통해 외래 키 제약조건을 일시적으로 비활성화한다. 이는 테스트 데이터를 설정할 때 참조 무결성 제약조건으로 인한 문제를 방지하기 위한 것이다. 그 다음 START TRANSACTION 쿼리를 실행하여 새로운 트랜잭션을 시작한다.
트랜잭션 내에서의 데이터 조회 원리는 데이터베이스의 트랜잭션 격리 수준(Transaction Isolation Level)과 관련이 있다. MySQL의 기본 격리 수준인 REPEATABLE READ에서는 트랜잭션 내에서 수행된 모든 데이터 변경은 해당 트랜잭션 내에서 즉시 조회 가능하다. 예를 들어, 테스트 코드에서 다음과 같은 순서로 작업을 수행한다고 가정해보자:
1.
startIntegrationTestTransaction을 호출하여 트랜잭션 시작
2.
entityManager.getRepository(Course).insert(course)로 데이터 삽입
3.
Repository 메서드를 통해 방금 삽입한 데이터 조회
이 경우, 2번 단계에서 삽입한 데이터는 아직 데이터베이스에 영구적으로 커밋되지 않았지만, 같은 트랜잭션 내에서는 조회가 가능하다. 이것이 가능한 이유는 트랜잭션이 생성한 변경사항이 해당 트랜잭션의 컨텍스트 내에서 유지되기 때문이다.
그러나 이 데이터는 다른 트랜잭션에서는 보이지 않는다. 테스트가 완료되고 rollbackIntegrationTestTransaction이 호출되면, 트랜잭션 내에서 수행된 모든 변경사항이 롤백되어 데이터베이스 상태가 원래대로 돌아간다. 이러한 메커니즘을 통해 각 테스트는 독립적인 환경에서 실행되며, 한 테스트의 데이터 변경이 다른 테스트에 영향을 주지 않는다.
이러한 트랜잭션 기반 테스트 방식은 테스트의 격리성(Isolation)을 보장하면서도, 실제 데이터베이스와의 상호작용을 테스트할 수 있게 해주는 매우 강력한 패턴이다. 특히 TypeORM과 같은 ORM을 사용할 때 발생할 수 있는 다양한 이슈를 발견하는 데 큰 도움이 된다.

4.2. 테스트 라이프사이클 관리

테스트 코드에서는 Jest의 라이프사이클 훅을 활용하여 데이터베이스 연결, 트랜잭션 관리, 그리고 테스트 종료 후 리소스 정리를 체계적으로 수행할 수 있다. 이러한 라이프사이클 관리는 테스트의 신뢰성과 일관성을 유지하는 데 매우 중요하다.
describe('테스트 대상명', () => { let dataSource: DataSource; let entityManager: EntityManager; let testTarget: TestTarget; beforeAll(async () => { // 테스트 실행 전 한 번만 실행: DB 연결 초기화 dataSource = await initIntegrationTestDataSource(); entityManager = dataSource.manager; testTarget = new TestTarget(dataSource); }, 20000); beforeEach(async () => { // 각 테스트 케이스 실행 전마다 실행: 트랜잭션 시작 await startIntegrationTestTransaction(entityManager); }); afterEach(async () => { // 각 테스트 케이스 실행 후마다 실행: 트랜잭션 롤백 await rollbackIntegrationTestTransaction(entityManager); }); afterAll(async () => { // 테스트 실행 후 한 번만 실행: DB 연결 종료 await dataSource.destroy(); }); // 실제 테스트 케이스들... });
TypeScript
복사
beforeAll 훅에서는 테스트 스위트가 시작되기 전에 한 번만 실행되어야 하는 설정을 수행한다. 여기서는 initIntegrationTestDataSource 함수를 호출하여 데이터베이스 연결을 초기화하고, 이 연결을 통해 EntityManager를 얻는다. 그리고 테스트 대상 객체를 생성할 때 이 데이터소스를 주입한다. 이 단계에서 주목할 점은 타임아웃 값이 20000ms(20초)로 설정되어 있다는 것이다. 이는 데이터베이스 연결이 느릴 경우를 대비해, 충분한 시간을 할당하는 것이다.
beforeEach 훅은 각 테스트 케이스가 실행되기 전에 호출된다. 여기서는 startIntegrationTestTransaction 함수를 호출하여 새로운 트랜잭션을 시작한다. 이렇게 함으로써, 각 테스트는 독립적인 트랜잭션 내에서 실행되어, 서로 영향을 주지 않게 된다.
afterEach 훅은 각 테스트 케이스가 종료된 후에 호출된다. 여기서는 rollbackIntegrationTestTransaction 함수를 호출하여 테스트 중에 수행된 모든 데이터베이스 변경사항을 롤백한다. 이는 테스트 간의 데이터 격리를 보장하고, 테스트 후에 데이터베이스 상태가 깨끗하게 유지되도록 한다.
마지막으로, afterAll 훅은 모든 테스트가 완료된 후에 호출된다. 여기서는 dataSource.destroy()를 호출하여 데이터베이스 연결을 정리한다. 이는 메모리 누수를 방지하고, 다른 테스트가 실행될 때 문제가 발생하지 않도록 한다.
이러한 라이프사이클 관리를 통해, 각 테스트는 독립적이고 예측 가능한 환경에서 실행되며, 테스트 간의 상호 영향을 최소화할 수 있다. 이는 특히 데이터베이스 연동 테스트에서 중요한데, 그렇지 않으면 한 테스트에서 변경된 데이터가 다른 테스트의 결과에 영향을 줄 수 있기 때문이다.

5. 실제 테스트 코드 예시

5.1. Repository 테스트 예시

실제 애플리케이션에서 Repository 계층은 데이터베이스와 직접 상호작용하는 중요한 부분이다. 아래 예시에서는 일반적인 온라인 교육 플랫폼의 코스(Course) 저장소를 테스트하는 방법을 보여준다.
먼저 테스트 대상이 되는 Repository 구현체는 다음과 같다
import { Injectable } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { CourseRepository } from '@domain/course/repository/course.repository'; import { Course } from '@domain/course/entity/course.entity'; import * as R from 'remeda'; @Injectable() export class CourseRepositoryImpl implements CourseRepository { constructor(private dataSource: DataSource) {} async createCourse(entityManager: EntityManager, course: Course): Promise<Course> { // 연관 엔티티 먼저 저장 await this.upsertCourseVersions(entityManager, [course.latestCourseVersion]); // 주요 엔티티에서 중첩된 객체 제거 후 저장 const omittedCourse = R.omit(course, [ 'latestCourseVersion', ]); await entityManager.getRepository(Course).insert(omittedCourse); return course; } // 기타 구현 메서드... }
TypeScript
복사
이제 이 Repository를 테스트하는 코드를 작성해보자
import { DataSource, EntityManager } from 'typeorm'; import { initIntegrationTestDataSource, rollbackIntegrationTestTransaction, startIntegrationTestTransaction, } from '../helpers/integration-test.helper'; import { CourseRepositoryImpl } from '@infrastructure/course/repository/course.repository.impl'; import { createCourseFactory } from '../factories/course.factory'; import { Course } from '@domain/course/entity/course.entity'; import { CourseVersion } from '@domain/course/entity/course-version.entity'; describe('CourseRepositoryImpl', () => { let repo: CourseRepositoryImpl; let dataSource: DataSource; let entityManager: EntityManager; beforeAll(async () => { dataSource = await initIntegrationTestDataSource(); entityManager = dataSource.manager; repo = new CourseRepositoryImpl(dataSource); }, 20000); beforeEach(async () => { await startIntegrationTestTransaction(entityManager); }); afterEach(async () => { await rollbackIntegrationTestTransaction(entityManager); }); afterAll(async () => { await dataSource.destroy(); }); describe('createCourse', () => { it('코스 객체가 생성되고 반환되어야 한다', async () => { // Given const course = createCourseFactory()[0]; // 테스트용 코스 데이터 생성 // When const res = await repo.createCourse(entityManager, course); // Then const createdCourse = await entityManager.getRepository(Course).findOne({ where: { id: course.id, }, }); expect(createdCourse).not.toBeNull(); const createdCourseVersion = await entityManager.getRepository(CourseVersion).findOne({ where: { id: course.latestCourseVersionId, }, }); expect(createdCourseVersion).not.toBeNull(); }); }); });
TypeScript
복사
이 테스트는 다음과 같은 흐름으로 진행된다:
1.
beforeAll에서 테스트 데이터베이스 연결을 초기화하고 Repository 객체를 생성
2.
각 테스트 케이스 전/후로 트랜잭션을 시작하고 롤백
3.
테스트 케이스 내에서는:
Given: 테스트 팩토리를 사용해 테스트 데이터 생성
When: Repository의 createCourse 메서드 호출
Then: 데이터베이스에서 저장된 엔티티를 직접 조회하여 검증
이 테스트는 실제 데이터베이스와 상호작용하여 Repository 메서드가 올바르게 동작하는지 검증한다. 특히, 코스 엔티티와 관련 엔티티(코스 버전)가 모두 올바르게 저장되는지 확인한다.

5.2. UseCase 테스트 예시

UseCase는 애플리케이션의 비즈니스 로직을 담당하는 중요한 부분이다. 아래는 코스를 아카이브(보관)하는 UseCase의 예시이다.
import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CourseRepository } from '@domain/course/repository/course.repository'; import { getCurrentDate } from '@common/utilities/time'; import { Course } from '@domain/course/entity/course.entity'; import { concurrent, map, pipe, toArray, toAsync } from '@fxts/core'; @Injectable() export class ArchiveCoursesByIdsUseCase { constructor( private dataSource: DataSource, @Inject('CourseRepository') private courseRepo: CourseRepository, ) {} async execute(courseIds: string[]): Promise<Course[]> { const entityManager = this.dataSource.manager; // 병렬로 코스 조회 const courses = await pipe( courseIds, toAsync, map(courseId => this.courseRepo.findCourseByIdWithProgressRelation(entityManager, courseId)), concurrent(5), toArray, ); // 코스 아카이브 처리 courses.map(course => course.archiveCourse(getCurrentDate())); // 변경사항 저장 await this.courseRepo.updateCourses(entityManager, courses); return courses; } }
TypeScript
복사
이 UseCase를 테스트하는 코드는 다음과 같다
import { DataSource, EntityManager } from 'typeorm'; import { initIntegrationTestDataSource, rollbackIntegrationTestTransaction, startIntegrationTestTransaction, } from '../helpers/integration-test.helper'; import { CourseRepositoryImpl } from '@infrastructure/course/repository/course.repository.impl'; import { createCourseFactory } from '../factories/course.factory'; import { Course } from '@domain/course/entity/course.entity'; import { CourseVersion } from '@domain/course/entity/course-version.entity'; import { ArchiveCoursesByIdsUseCase } from '@application/course/archive-courses-by-ids.use-case'; import { LearningProgress } from '@domain/learning/entities/learning-progress.entity'; import { CourseProgressMapping } from '@domain/course/entity/course-progress-mapping.entity'; import { CourseStatusEnum } from '@domain/course/course.enum'; import { ProgressStatus } from '@domain/learning/learning.enum'; describe('ArchiveCoursesByIdsUseCase', () => { let uc: ArchiveCoursesByIdsUseCase; let dataSource: DataSource; let entityManager: EntityManager; let courseRepo: CourseRepositoryImpl; beforeAll(async () => { dataSource = await initIntegrationTestDataSource(); entityManager = dataSource.manager; courseRepo = new CourseRepositoryImpl(dataSource); uc = new ArchiveCoursesByIdsUseCase(dataSource, courseRepo); }, 20000); beforeEach(async () => { await startIntegrationTestTransaction(entityManager); }); afterEach(async () => { await rollbackIntegrationTestTransaction(entityManager); }); afterAll(async () => { await dataSource.destroy(); }); describe('execute', () => { it('코스를 아카이브하고, 새 버전을 생성하며, 학습 진행 상태를 비활성화한다', async () => { // Given - 테스트 데이터 준비 const course = createCourseFactory()[0]; await entityManager.getRepository(Course).insert(course); await entityManager.getRepository(CourseProgressMapping).insert(course.learningProgressMapping); await entityManager.getRepository(LearningProgress).insert(course.learningProgressMapping.learningProgress); await entityManager.getRepository(CourseVersion).insert(course.latestCourseVersion); // When - 유스케이스 실행 await uc.execute([course.id]); // Then - 결과 검증 const archivedCourse = await entityManager.getRepository(Course).findOne({ where: { id: course.id }, relations: { courseVersions: true }, withDeleted: true, }); // 코스가 아카이브됨 expect(archivedCourse).toBeInstanceOf(Course); expect(archivedCourse.deletedAt).not.toBeNull(); // 아카이브 상태의 새 버전이 생성됨 const archivedVersion = archivedCourse.courseVersions.find(cv => cv.status === CourseStatusEnum.ARCHIVED ); expect(archivedVersion).toBeInstanceOf(CourseVersion); expect(archivedVersion.deletedAt).not.toBeNull(); // 연결된 학습 진행 상태가 비활성화됨 const learningProgress = await entityManager.getRepository(LearningProgress).findOne({ where: { id: course.learningProgressMapping.learningProgressId }, withDeleted: true, }); expect(learningProgress).toBeInstanceOf(LearningProgress); expect(learningProgress.progress).toEqual(ProgressStatus.INACTIVE); expect(learningProgress.isVisible).toEqual(0); }); }); });
TypeScript
복사
이 테스트는 다음과 같은 내용을 검증한다
1.
코스 아카이브 처리가 정상적으로 수행되는지(soft delete)
2.
아카이브 상태의 새 코스 버전이 생성되는지
3.
연결된 학습 진행 상태가 비활성화되는지
이러한 유형의 테스트는 여러 엔티티와 관계가 관여된 복잡한 비즈니스 로직을 테스트하는 데 매우 유용하다. 실제 데이터베이스를 사용하기 때문에, 모든 관계와 영향이 예상대로 동작하는지 확인할 수 있다.

6. DB 연동 테스트의 장점과 주의점

6.1. 장점

데이터베이스 연동 테스트는 다양한 장점을 제공한다. 무엇보다 실제 데이터베이스와 상호작용하므로 높은 신뢰성을 가진다. 테스트 환경에서 동작하는 코드가 실제 운영 환경에서도 유사하게 동작할 가능성이 높아지므로, 모킹을 사용한 테스트에서는 발견하기 어려운 데이터베이스 관련 문제를 조기에 발견할 수 있다.
TypeORM과 같은 ORM 라이브러리를 사용할 때는 타입 안정성을 실질적으로 검증할 수 있다는 큰 장점이 있다. TypeORM에서 작성한 쿼리가 실제로 예상대로 동작하는지, 타입 시스템의 한계로 인해 발생할 수 있는 문제가 있는지 직접 확인함으로써 신뢰성을 높일 수 있다. 특히 복잡한 조건의 쿼리나 다양한 관계 설정이 필요한 경우, 이러한 검증은 매우 중요하다.
여러 계층이 함께 동작할 때 발생할 수 있는 통합적인 문제를 조기에 발견할 수 있다는 것도 큰 장점이다. Repository, Service, UseCase 등 여러 계층이 조합되어 동작할 때, 각 계층을 독립적으로 테스트했을 때는 드러나지 않던 상호작용 문제를 발견하고 해결할 수 있다. 예를 들어, 한 Repository 메서드의 반환값이 다른 Repository 메서드의 입력으로 사용될 때 타입 불일치나 데이터 형식 문제가 발생할 수 있는데, 실제 데이터베이스를 사용한 테스트에서는 이러한 문제를 쉽게 발견할 수 있다.
엔티티 설정과 실제 데이터베이스 스키마 간의 불일치를 발견할 수 있다는 점도 중요한 장점이다. TypeORM 엔티티 정의가 실제 데이터베이스 스키마와 일치하지 않으면, 테스트 실행 시 오류가 발생하여 이를 즉시 확인할 수 있다. 이는 배포 전에 스키마 관련 문제를 미리 발견하고 수정할 수 있게 해주므로, 운영 환경에서의 장애를 예방하는 데 큰 도움이 된다.

6.2. 주의점

데이터베이스 연동 테스트는 많은 이점을 제공하지만, 몇 가지 주의해야 할 점도 있다. 가장 큰 단점은 테스트 속도가 느려질 수 있다는 점이다. 실제 데이터베이스와의 상호작용은 메모리 내 작업보다 훨씬 더 많은 시간이 소요되므로, 테스트 실행 시간이 크게 증가할 수 있다. (특히 CI/CD 파이프라인에서 Runner 사양에 따라 상당한 시간이 걸릴 수 있다). 따라서 모든 테스트를 데이터베이스 연동 방식으로 구현하기보다는, 중요한 부분에 집중적으로 적용하는 것이 효율적이다.
테스트 환경 구성이 복잡해질 수 있다는 점도 고려해야 한다. 테스트용 데이터베이스 설정, 스키마 관리, 환경 변수 설정 등 추가적인 작업이 필요하며, 이는 개발 환경 설정의 복잡성을 증가시킨다.
테스트 간의 격리를 보장하기 위해 트랜잭션 관리를 철저히 해야 한다는 점도 중요하다. 앞서 설명한 것처럼, 각 테스트는 독립적인 트랜잭션 내에서 실행되어야 하며, 테스트 종료 후에는 변경사항이 롤백되어야 한다. 그렇지 않으면, 한 테스트의 결과가 다른 테스트에 영향을 줄 수 있어 테스트의 신뢰성이 저하된다. 이러한 트랜잭션 관리는 추가적인 코드와 주의가 필요하며, 특히 여러 트랜잭션이 중첩된 복잡한 시나리오에서는 더욱 주의해야 한다.
마지막으로, CI/CD 파이프라인에 테스트 데이터베이스 설정을 통합해야 한다는 점도 고려해야 한다. 이는 파이프라인 구성의 복잡성을 증가시키며, 특히 클라우드 환경이나 컨테이너화된 환경에서는 데이터베이스 연결 등 추가적인 고려사항이 필요할 수 있다.

7. 효과적인 DB 연동 테스트를 위한 팁

효과적인 데이터베이스 연동 테스트를 위해서는 몇 가지 전략과 도구를 활용하는 것이 좋다.
첫째로, 테스트 데이터 생성을 위한 Factory 패턴을 적극 활용하는 것이 중요하다. 테스트에 필요한 데이터를 일관되고 편리하게 생성할 수 있는 Factory 함수를 구현하면, 테스트 코드의 가독성과 유지보수성이 크게 향상된다.
export function createCourseFactory(override = {}) { const id = generateUuid(); const courseVersion = new CourseVersion({ id: generateUuid(), title: '테스트 강좌', description: '테스트 설명', difficulty: 'BEGINNER', status: 'PUBLISHED', ...override.version, }); return [ new Course({ id, latestCourseVersionId: courseVersion.id, latestCourseVersion: courseVersion, instructorId: generateUuid(), categoryId: generateUuid(), isPublic: true, ...override, }), ]; }
TypeScript
복사
이러한 Factory 함수는 테스트 데이터 생성을 위한 코드 중복을 줄이고, 일관된 테스트 데이터를 제공한다. override 파라미터를 통해 필요에 따라 특정 속성을 변경할 수 있는 유연성도 제공하므로, 다양한 테스트 시나리오에 맞게 데이터를 조정할 수 있다.
둘째로, 테스트용 데이터베이스를 완전히 분리하여 관리하는 것이 좋다. 운영 데이터베이스나 개발 데이터베이스와는 별도의 테스트 전용 스키마나 데이터베이스를 사용함으로써, 테스트로 인한 데이터 손상 위험을 줄이고 더 안정적인 테스트 환경을 구축할 수 있다. 테스트 데이터베이스는 필요에 따라 자동으로 초기화되거나, 테스트 데이터로 미리 채워질 수 있으며, 이를 통해 일관된 테스트 실행을 보장할 수 있다.
셋째로, 각 테스트마다 트랜잭션을 시작하고 롤백하는 방식을 철저히 적용해야 한다. 이는 테스트 간의 데이터 격리를 보장하고, 테스트 실행 순서에 의존하지 않는 안정적인 테스트를 가능하게 한다. 앞서 설명한 startIntegrationTestTransactionrollbackIntegrationTestTransaction 함수를 통해 이러한 트랜잭션 관리를 일관되게 적용할 수 있다.
넷째로, 데이터베이스 연동 테스트를 병렬로 실행할 때는 특별한 주의가 필요하다. 여러 테스트가 동시에 같은 데이터베이스에 접근할 때 경합 조건(race condition)이 발생할 수 있으며, 이는 간헐적인 테스트 실패의 원인이 될 수 있다. 이를 방지하기 위해, 테스트 격리를 철저히 하거나, 필요에 따라 병렬 실행을 제한하는 설정을 적용할 수 있다. Jest의 경우 --runInBand 옵션을 사용하여 테스트를 순차적으로 실행할 수 있다.
다섯째로, 모든 테스트를 데이터베이스 연동 방식으로 구현할 필요는 없다. 테스트 실행 속도와 신뢰성 사이의 균형을 맞추기 위해, 중요한 데이터 접근 로직과 비즈니스 로직에 대해서만 데이터베이스 연동 테스트를 적용하고, 나머지는 모킹 기반 테스트로 구현하는 하이브리드 접근 방식을 사용할 수 있다. 이는 전체 테스트의 실행 시간을 줄이면서도, 중요한 부분에 대한 높은 신뢰성을 확보할 수 있게 해준다.
마지막으로, 데이터베이스 스키마 변경이 있을 때마다 관련 테스트를 꼼꼼히 검토하고 업데이트해야 한다. 스키마 변경은 테스트 코드에도 영향을 줄 수 있으므로, 마이그레이션 작업 시 관련 테스트도 함께 수정하는 습관을 들이는 것이 중요하다. 이를 통해 테스트 코드와 실제 코드 간의 불일치를 방지하고, 테스트의 신뢰성을 유지할 수 있다.

8. 결론

Solitary Test와 Sociable Test는 각각의 장단점을 가지고 있으며, 서로를 보완하는 역할을 한다. 이전 포스팅에서 다룬 모킹 기반의 Solitary Test는 테스트 속도가 빠르고 외부 의존성 없이 독립적으로 실행될 수 있다는 장점이 있지만, 실제 환경과의 차이로 인해 특정 문제를 발견하지 못할 수 있다.
반면, 이번 포스팅에서 다룬 데이터베이스 연동 Sociable Test는 실제 환경과 유사한 조건에서 테스트함으로써 높은 신뢰성을 제공하지만, 테스트 속도가 느리고 설정이 복잡하다는 단점이 있다. 특히 TypeORM과 같이 타입 시스템이 완벽하지 않은 라이브러리를 사용할 때는 실제 데이터베이스와 연동한 테스트가 타입 관련 문제나 ORM 관련 문제를 조기에 발견하는 데 큰 도움이 된다.
NestJS에서 데이터베이스 연동 테스트를 구현할 때는 테스트용 데이터베이스 설정, 트랜잭션 관리, 테스트 데이터 생성 등 여러 요소를 체계적으로 관리해야 한다. 이러한 노력을 통해 높은 품질의 소프트웨어를 유지하고, TypeORM 사용 시 발생할 수 있는 불안감을 크게 줄일 수 있다.
가장 이상적인 접근법은 Solitary Test와 Sociable Test를 적절히 조합하여 사용하는 것이다. 비즈니스 로직이 복잡하지만 데이터베이스 상호작용이 단순한 부분은 모킹 기반 테스트로, 데이터베이스 상호작용이 복잡하거나 TypeORM와 같이안정성이 우려되는 부분은 실제 데이터베이스 연동 테스트로 구현하는 방식이다. 이렇게 함으로써 테스트 실행 속도와 신뢰성 사이의 균형을 맞출 수 있다.
테스트는 코드의 품질을 높이고 개발자의 자신감을 증가시키는 중요한 도구이다. 특히 TypeORM과 같은 도구를 사용할 때 발생할 수 있는 불안감을 해소하기 위해, 실제 데이터베이스와 연동한 테스트는 매우 효과적인 해결책이 될 수 있다. 이러한 테스트 방식을 팀 내에 도입하고 정착시키는 것은 소프트웨어의 품질을 한 단계 높이는 중요한 과정이다.