데이터베이스를 테스트하기 전에 충족해야 하는 몇 가지 조건이 있다. 이 조건들을 마련하지 않으면 테스트는 불안정하고, 관리하기 어렵고, 심지어 무의미해질 수 있다.

데이터베이스 테스트를 향한 첫 번째 단계는 데이터베이스 스키마를 일반 코드처럼 취급하는 것입니다. 일반 코드와 마찬가지로, 데이터베이스 스키마는 Git과 같은 소스 제어 시스템에 저장하는 것이 가장 좋습니다.
이렇게는 하지 말 것!
데이터베이스에는 보통 기본적으로 들어 있어야 하는 참조 데이터가 있다. 예를 들어 국가 코드, 화폐 단위, 권한 목록 같은 값들이다. 이런 데이터는 단순한 입력값이 아니라 데이터베이스 스키마의 일부로 취급해야 한다. 즉, 스키마와 함께 버전 관리에 포함시키고, 테스트 환경에서도 항상 동일하게 로드되도록 해야 한다.
TIP! 참조 데이터와 일반 데이터를 구별하는 간단한 방법이 있습니다. 애플리케이션이 데이터를 수정할 수 있으면 일반 데이터이고, 그렇지 않으면 참조 데이터입니다.
데이터베이스 테스트에서 흔히 발생하는 문제는 여러 개발자가 하나의 공유 DB를 사용한다는 점이다. 이 경우 테스트가 서로 영향을 주고, 실행 결과가 예측 불가능해진다. 따라서 각 개발자마다 독립적인 데이터베이스 인스턴스를 가져야 한다. 이 방식은 격리성을 보장하고 테스트 신뢰성을 크게 높인다.
데이터베이스 전달에는 두 가지 주요 접근법이 있다. 상태 기반(state-based)과 마이그레이션 기반(migration-based)이다. 마이그레이션 기반 접근은 처음 구현하고 유지하는 것이 더 어렵지만, 장기적으로 상태 기반 접근보다 훨씬 더 잘 작동한다.
발 과정 내내 유지되는 모델 데이터베이스(model database) 가 있고, 배포할 때 비교 도구(comparison tool)가 프로덕션 데이터베이스를 이 모델 데이터베이스와 비교해 최신 상태로 맞추는 스크립트를 생성한다.
다만 상태 기반 접근에서는 실제 물리적인 모델 데이터베이스를 진리의 원천(source of truth)으로 두지 않는다. 대신 해당 데이터베이스를 생성할 수 있는 SQL 스크립트를 보관하며, 이 스크립트는 소스 제어에 저장된다.
상태 기반 접근에서는 비교 도구가 모든 무거운 작업을 처리한다. 프로덕션 데이터베이스가 어떤 상태에 있든 상관없이, 이 도구가 불필요한 테이블 삭제, 새로운 테이블 생성, 컬럼 이름 변경 등을 수행해 모델 데이터베이스와 동기화한다.
반면 마이그레이션 기반 접근은 데이터베이스를 한 버전에서 다음 버전으로 옮기는 명시적인 마이그레이션(explicit migrations) 에 중점을 둔다.
이 방식에서는 프로덕션과 개발 데이터베이스를 자동으로 동기화하는 도구를 사용하지 않고, 개발자가 직접 업그레이드 스크립트를 작성한다. 다만 프로덕션 데이터베이스 스키마에 문서화되지 않은 변경을 감지하는 데는 비교 도구가 여전히 유용할 수 있다.
마이그레이션 기반 접근에서는 데이터베이스 상태가 아니라 마이그레이션 자체가 소스 제어에 저장되는 산출물이 된다. 마이그레이션은 보통 단순한 SQL 스크립트로 표현되지만(Flyway, Liquibase 같은 도구가 널리 쓰임), SQL로 변환되는 DSL 언어로 작성할 수도 있다. 예컨대 FluentMigrator 라이브러리를 사용하면 다음과 같은 C# 클래스로 마이그레이션을 정의할 수 있다.
테스트를 고려할 때는 마이그레이션 기반 접근법이 유리하다. 왜냐하면 모든 단계에서 데이터베이스의 진화 과정을 재현할 수 있고, 각 버전이 실제로 잘 동작하는지 검증할 수 있기 때문이다.

public class CreateUserTable: Migration {
public override func Up() {
Create.Table("Users");
}
public override func Down() {
Delete.Table("Users");
}
}
두 접근법의 차이
이 차이는 다른 트레이드오프를 만든다. 데이터베이스 상태를 명시적으로 다루면 병합 충돌(merge conflict)을 관리하기 쉽다. 반대로 마이그레이션을 명시적으로 다루면 데이터 모션(data motion) 을 다루기가 수월하다.

이 때문에 대부분의 프로젝트에서 상태 기반 접근은 실용적이지 않다. 단, 프로젝트가 아직 운영에 배포되지 않았을 때는 임시로 사용할 수 있다. 이 시점에는 테스트 데이터가 중요하지 않으므로, 데이터베이스를 변경할 때마다 쉽게 다시 만들 수 있다. 하지만 일단 첫 번째 버전을 릴리스하면, 올바른 데이터 모션을 처리하기 위해 반드시 마이그레이션 기반 접근으로 전환해야 한다 .
데이터베이스 트랜잭션 관리는 운영 코드와 테스트 코드 모두에서 중요한 주제다.
운영 코드에서는 데이터 불일치를 피할 수 있고, 테스트에서는 운영 환경과 유사한 조건에서 DB 통합을 검증할 수 있다.
데이터베이스 연결과 트랜잭션 분리
잠재적인 불일치를 피하려면 다음 두 가지 유형의 결정을 분리해야 한다.
이러한 분리는 저장소(repositories) 와 트랜잭션(transaction) 클래스로 분리하여 구현할 수 있다. 저장소는 항상 현재 트랜잭션 위에서 작동한다. 컨트롤러는 비즈니스 작업이 성공했을 때만 트랜잭션을 커밋하여 데이터베이스가 행복한 경로(happy path)에서만 변경되도록 보장한다.
final class Transaction {
private var committed = false
func commit() { committed = true }
deinit {
if committed { persist() } else { rollback() }
}
private func persist() {}
private func rollback() {}
}
final class UserRepository {
private let transaction: Transaction
init(transaction: Transaction) { self.transaction = transaction }
func getUserById(_ id: Int) -> User { ... }
func saveUser(_ user: User) {}
}
final class CompanyRepository {
private let transaction: Transaction
init(transaction: Transaction) { self.transaction = transaction }
func getCompany() -> Company { ... }
func saveCompany(_ company: Company) {}
}
final class UserController {
private let transaction: Transaction
private let userRepository: UserRepository
private let companyRepository: CompanyRepository
init(transaction: Transaction,
userRepository: UserRepository,
companyRepository: CompanyRepository) {
self.transaction = transaction
self.userRepository = userRepository
self.companyRepository = companyRepository
}
func changeEmail(userId: Int, newEmail: String) -> String {
var user = userRepository.getUserById(userId)
var company = companyRepository.getCompany()
user.changeEmail(newEmail, &company)
companyRepository.saveCompany(company)
userRepository.saveUser(user)
transaction.commit()
return "OK"
}
}
통합 테스트에서 데이터베이스 트랜잭션을 관리할 때는 다음 지침을 준수해야 한다.
import Testing
struct ChangeEmailIntegrationTests {
@Test
func testChangeEmailUpdatesCompanyAndUser() throws {
// Arrange
let arrangeContext = InMemoryCrmContext()
let arrangeRepo = UserRepositoryEF(context: arrangeContext)
var user = try arrangeRepo.getUserById(1)
#expect(user.email == "old@corp.example")
// Act
let actContext = InMemoryCrmContext()
let actUserRepo = UserRepositoryEF(context: actContext)
let actCompanyRepo = CompanyRepositoryEF(context: actContext)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: actContext,
userRepository: actUserRepo,
companyRepository: actCompanyRepo,
eventDispatcher: dispatcher
)
let result = try controller.changeEmail(userId: 1, newEmail: "new@gmail.com")
#expect(result == "OK")
// Assert
let assertContext = InMemoryCrmContext()
let assertUserRepo = UserRepositoryEF(context: assertContext)
let updatedUser = try assertUserRepo.getUserById(1)
#expect(updatedUser.email == "new@gmail.com")
}
}
공유 데이터베이스는 통합 테스트 사이에서 격리 문제를 일으킨다.
단위 테스트는 병렬 실행이 가능하다. 하지만 DB 통합 테스트는 동일한 리소스를 공유하므로 병렬 실행 시 충돌 위험이 있다.
따라서 DB 통합 테스트는 항상 순차 실행해야 한다.
테스트 실행이 끝난 후에는 데이터가 반드시 정리되어야 한다. 그렇지 않으면 다음 테스트 실행 시 예기치 않은 성공/실패가 발생한다.
struct UserRepositoryIntegrationTests {
@Test
func testUserCreationAndCleanup() throws {
DatabaseHelper.clearAll() // Arrange: DB 초기화
let repo = UserRepository()
let user = User(userId: 0, email: "user@test.com", type: .customer, domainEvents: [])
try repo.saveUser(user)
let saved = try repo.getUserById(user.userId)
#expect(saved.email == "user@test.com")
DatabaseHelper.clearAll() // Cleanup
}
}
인메모리 DB는 실제 DB의 동작을 흉내 내지 못한다.
특히 트랜잭션, 동시성, 제약 조건에서 차이가 크다.
따라서 통합 테스트에서는 반드시 실제 DB 엔진을 사용해야 한다. 운영과 동일한 종류의 DB를 쓰는 것이 최선이다.
Arrange 단계의 코드 재사용은 대체로 괜찮다.
예를 들어 TestDataFactory 같은 헬퍼를 만들어 더미 데이터를 반복 생성할 수 있다.
struct TestDataFactory {
static func makeUser(id: Int = 0, email: String = "user@test.com") -> User {
User(userId: id, email: email, type: .customer, domainEvents: [])
}
}
Act 단계는 핵심 동작을 검증하는 부분이다.
여기서 로직을 재사용하면 테스트의 의도가 흐려진다.
각 테스트는 Act를 명시적으로 표현하는 편이 낫다.
WRONG
struct UserControllerTests {
private func performChangeEmail(userId: Int, newEmail: String) throws -> String {
let context = InMemoryCrmContext()
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher
)
return try controller.changeEmail(userId: userId, newEmail: newEmail)
}
@Test
func testChangeEmailToNonCorporate() throws {
let result = try performChangeEmail(userId: 1, newEmail: "user@gmail.com")
#expect(result == "OK")
}
@Test
func testChangeEmailToCorporate() throws {
let result = try performChangeEmail(userId: 2, newEmail: "staff@corp.example")
#expect(result == "OK")
}
}
RIGHT
struct UserControllerTests {
@Test
func testChangeEmailToNonCorporate() throws {
let context = InMemoryCrmContext()
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher
)
let result = try controller.changeEmail(userId: 1, newEmail: "user@gmail.com")
#expect(result == "OK")
}
@Test
func testChangeEmailToCorporate() throws {
let context = InMemoryCrmContext()
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher
)
let result = try controller.changeEmail(userId: 2, newEmail: "staff@corp.example")
#expect(result == "OK")
}
}
Assert 단계도 재사용보다는 명확성이 중요하다.
실패했을 때 원인을 빠르게 파악하려면 Assert 코드를 눈으로 바로 읽을 수 있어야 한다.
WRONG
struct UserControllerTests {
private func assertEmailChanged(to expected: String, userId: Int) throws {
let context = InMemoryCrmContext()
let userRepo = UserRepositoryEF(context: context)
let user = try userRepo.getUserById(userId)
#expect(user.email == expected)
}
@Test
func testChangeEmailToNonCorporate() throws {
let context = InMemoryCrmContext()
let controller = makeController(context: context)
_ = try controller.changeEmail(userId: 1, newEmail: "user@gmail.com")
try assertEmailChanged(to: "user@gmail.com", userId: 1)
}
@Test
func testChangeEmailToCorporate() throws {
let context = InMemoryCrmContext()
let controller = makeController(context: context)
_ = try controller.changeEmail(userId: 2, newEmail: "staff@corp.example")
try assertEmailChanged(to: "staff@corp.example", userId: 2)
}
private func makeController(context: InMemoryCrmContext) -> UserControllerEF {
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
return UserControllerEF(context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher)
}
}
RIGHT
struct UserControllerTests {
@Test
func testChangeEmailToNonCorporate() throws {
let context = InMemoryCrmContext()
let controller = makeController(context: context)
_ = try controller.changeEmail(userId: 1, newEmail: "user@gmail.com")
let userRepo = UserRepositoryEF(context: context)
let user = try userRepo.getUserById(1)
#expect(user.email == "user@gmail.com")
}
@Test
func testChangeEmailToCorporate() throws {
let context = InMemoryCrmContext()
let controller = makeController(context: context)
_ = try controller.changeEmail(userId: 2, newEmail: "staff@corp.example")
let userRepo = UserRepositoryEF(context: context)
let user = try userRepo.getUserById(2)
#expect(user.email == "staff@corp.example")
}
private func makeController(context: InMemoryCrmContext) -> UserControllerEF {
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
return UserControllerEF(context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher)
}
}
테스트가 너무 많은 DB 트랜잭션을 만들면 성능이 떨어진다.
그러나 Arrange–Act–Assert를 분리하기 위해 최소 세 개의 트랜잭션은 필요하다.
WRONG
struct ChangeEmailIntegrationTests {
@Test
func testChangeEmail_ReusesSingleTransaction() throws {
let context = InMemoryCrmContext() // 단일 컨텍스트 재사용
let userRepo = UserRepositoryEF(context: context)
let companyRepo = CompanyRepositoryEF(context: context)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: context,
userRepository: userRepo,
companyRepository: companyRepo,
eventDispatcher: dispatcher
)
// Arrange
var user = try userRepo.getUserById(1)
// Act
_ = try controller.changeEmail(userId: 1, newEmail: "new@gmail.com")
// Assert (여전히 같은 컨텍스트)
let updated = try userRepo.getUserById(1)
#expect(updated.email == "new@gmail.com")
}
}
RIGHT
struct ChangeEmailIntegrationTests {
@Test
func testChangeEmail_UsesSeparateTransactions() throws {
// Arrange
let arrangeContext = InMemoryCrmContext()
let arrangeUserRepo = UserRepositoryEF(context: arrangeContext)
var user = try arrangeUserRepo.getUserById(1)
#expect(user.email == "old@corp.example")
// Act
let actContext = InMemoryCrmContext()
let actUserRepo = UserRepositoryEF(context: actContext)
let actCompanyRepo = CompanyRepositoryEF(context: actContext)
let dispatcher = EventDispatcher()
let controller = UserControllerEF(
context: actContext,
userRepository: actUserRepo,
companyRepository: actCompanyRepo,
eventDispatcher: dispatcher
)
_ = try controller.changeEmail(userId: 1, newEmail: "new@gmail.com")
// Assert
let assertContext = InMemoryCrmContext()
let assertUserRepo = UserRepositoryEF(context: assertContext)
let updatedUser = try assertUserRepo.getUserById(1)
#expect(updatedUser.email == "new@gmail.com")
}
}
일반적으로 쓰기 작업은 테스트 대상이다.
쓰기 작업은 비즈니스 로직을 포함할 가능성이 크고, 잘못 구현하면 데이터 불일치를 일으킨다.
반대로 읽기 작업은 단순 SQL 실행인 경우가 많다.
단순 조회라면 테스트 필요성이 낮다.
하지만 다음과 같은 경우라면 읽기도 반드시 테스트해야 한다:
즉, 단순 조회는 생략 가능하지만, 비즈니스 로직이 얽힌 조회는 테스트 필요가 있다.


레포지토리는 대체로 데이터베이스 액세스를 단순히 감싸는 역할을 한다.(e.g. insert, update, delete 같은 CRUD)
이 경우, 레포지토리 자체를 단위 테스트하는 건 중복된 검증에 불과하다.
왜냐하면 이 메서드들은 데이터베이스 프레임워크가 제공하는 기능을 그대로 위임하기 때문이다.
중요한 건 레포지토리 단위가 아니라 시스템 전체의 통합 동작이다.
따라서 더 가치 있는 접근은 다음과 같다: