TestContainers를 사용한 통합 테스트
이 스킬을 사용해야 할 때
다음과 같은 상황에서 이 스킬을 사용하세요:
- 실제 인프라(데이터베이스, 캐시, 메시지 큐)가 필요한 통합 테스트를 작성할 때
- 데이터 액세스 계층을 실제 데이터베이스에 대해 테스트할 때
- 메시지 큐 통합을 검증할 때
- Redis 캐싱 동작을 테스트할 때
- 인프라 컴포넌트에 대한 목(mock)을 피하고 싶을 때
- 테스트가 프로덕션과 유사한 환경에서 작동하는지 확인할 때
- 데이터베이스 마이그레이션 및 스키마 변경을 테스트할 때
참고 파일
- database-patterns.md: SQL Server, PostgreSQL 및 마이그레이션 테스트 예제
- infrastructure-patterns.md: Redis, RabbitMQ, 다중 컨테이너 네트워크, 컨테이너 재사용, Respawn
핵심 원칙
- 실제 인프라가 목보다 낫다 - 목 대신 실제 데이터베이스/서비스를 컨테이너에서 사용
- 테스트 격리 - 각 테스트는 신규 컨테이너나 신선한 데이터를 사용
- 자동 정리 - TestContainers가 컨테이너 생명주기와 정리를 관리
- 빠른 시작 - 같은 클래스 내 테스트 간에 컨테이너를 적절히 재사용
- CI/CD 호환 - Docker를 지원하는 CI 환경에서 바로 작동
- 포트 무작위화 - 컨테이너는 충돌을 피하기 위해 무작위 포트 사용
왜 TestContainers가 목보다 나은가?
목을 사용한 인프라 테스트의 문제점
// 나쁜 예: 데이터베이스 목(mock) 사용
public class OrderRepositoryTests
{
private readonly Mock<IDbConnection> _mockDb = new();
[Fact]
public async Task GetOrder_ReturnsOrder()
{
// 실제 SQL 동작, 제약 조건, 성능을 테스트하지 않음
_mockDb.Setup(db => db.QueryAsync<Order>(It.IsAny<string>()))
.ReturnsAsync(new[] { new Order { Id = 1 } });
var repo = new OrderRepository(_mockDb.Object);
var order = await repo.GetOrderAsync(1);
Assert.NotNull(order);
}
}
문제점: 실제 SQL 쿼리를 테스트하지 않으며, 제약 조건/인덱스를 놓치고, 잘못된 확신을 주며, SQL 문법 오류를 잡지 못합니다.
더 나은 방법: 실제 데이터베이스와 함께 TestContainers 사용
// 좋은 예: 실제 데이터베이스에 대한 테스트
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
private IDbConnection _connection;
public OrderRepositoryTests()
{
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SA_PASSWORD", "Your_password123")
.WithPortBinding(1433, true)
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
var port = _dbContainer.GetMappedPublicPort(1433);
var connectionString = $"Server=localhost,{port};Database=TestDb;User Id=sa;Password=Your_password123;TrustServerCertificate=true";
_connection = new SqlConnection(connectionString);
await _connection.OpenAsync();
await RunMigrationsAsync(_connection);
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
await _dbContainer.DisposeAsync();
}
[Fact]
public async Task GetOrder_WithRealDatabase_ReturnsOrder()
{
await _connection.ExecuteAsync(
"INSERT INTO Orders (Id, CustomerId, Total) VALUES (1, 'CUST1', 100.00)");
var repo = new OrderRepository(_connection);
var order = await repo.GetOrderAsync(1);
Assert.NotNull(order);
Assert.Equal("CUST1", order.CustomerId);
Assert.Equal(100.00m, order.Total);
}
}
전체 SQL Server, PostgreSQL 및 마이그레이션 테스트 예제는 database-patterns.md를 참조하세요.
Redis, RabbitMQ, 다중 컨테이너 네트워크, 컨테이너 재사용 및 Respawn 데이터베이스 리셋 패턴은 infrastructure-patterns.md를 참조하세요.
필수 NuGet 패키지
<ItemGroup>
<PackageReference Include="Testcontainers" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<!-- 데이터베이스별 패키지 -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="*" />
<PackageReference Include="Npgsql" Version="*" /> <!-- PostgreSQL용 -->
<PackageReference Include="MySqlConnector" Version="*" /> <!-- MySQL용 -->
<!-- 기타 인프라 -->
<PackageReference Include="StackExchange.Redis" Version="*" /> <!-- Redis용 -->
<PackageReference Include="RabbitMQ.Client" Version="*" /> <!-- RabbitMQ용 -->
</ItemGroup>
모범 사례
- 항상 IAsyncLifetime 사용 - 올바른 비동기 설정 및 정리
- 포트 사용 가능 대기 -
WaitStrategy를 사용해 컨테이너가 준비될 때까지 대기 - 무작위 포트 사용 - TestContainers가 포트를 자동으로 할당하도록 함
- 테스트 간 데이터 정리 - 신규 컨테이너를 사용하거나 테이블을 잘라냄(truncate)
- 가능한 경우 컨테이너 재사용 - 각 테스트마다 새로 만드는 것보다 빠름
- 실제 쿼리 테스트 - 목만 테스트하지 말고 실제 SQL 동작을 검증
- 제약 조건 확인 - 외래 키, 고유 제약 조건, 인덱스 테스트
- 트랜잭션 테스트 - 롤백 및 커밋 동작 검증
- 현실적인 데이터 사용 - 프로덕션과 유사한 데이터 양으로 테스트
- 정리 처리 -
DisposeAsync에서 항상 컨테이너를 정리
일반적인 문제 및 해결 방법
컨테이너 시작 시간 초과
_container = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:latest")
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(5432)
.WithTimeout(TimeSpan.FromMinutes(2)))
.Build();
포트가 이미 사용 중
항상 무작위 포트 매핑을 사용하세요:
.WithPortBinding(5432, true) // true = 무작위 공개 포트 할당
컨테이너가 정리되지 않음
올바른 정리를 보장하세요:
public async Task DisposeAsync()
{
await _connection?.DisposeAsync();
await _container?.DisposeAsync();
}
테스트가 CI에서는 실패하지만 로컬에서는 성공
CI에 Docker 지원이 있는지 확인하세요:
# GitHub Actions
runs-on: ubuntu-latest # Docker가 사전 설치됨
CI/CD 통합
GitHub Actions
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: Run Integration Tests
run: |
dotnet test tests/YourApp.IntegrationTests \
--filter Category=Integration \
--logger trx
- name: Cleanup Containers
if: always()
run: docker container prune -f
성능 팁
- 컨테이너 재사용 - 컬렉션 내 테스트 간에 픽스처를 공유
- Respawn 사용 - 컨테이너를 다시 만들지 않고 데이터 리셋
- 병렬 실행 - TestContainers가 포트 충돌을 자동으로 처리
- 경량 이미지 사용 - Alpine 버전이 더 작고 빠름
- 이미지 캐시 - Docker는 가져온 이미지를 로컬에 캐시