Testcontainers Spring Boot: ๊ณ ํต ์๋ ํตํฉ ํ ์คํธ
Spring Boot 3.4์์ Testcontainers๋ฅผ ๊ตฌ์ฑํ๋ ์์ ํ ๊ฐ์ด๋์ ๋๋ค. ์ ๋ขฐํ ์ ์๊ณ ์ฌํ ๊ฐ๋ฅํ ํตํฉ ํ ์คํธ๋ฅผ ์ํด Docker ์ปจํ ์ด๋์์ PostgreSQL, Redis, Kafka๋ฅผ ์คํํฉ๋๋ค.

ํตํฉ ํ ์คํธ๋ Spring Boot ์ ํ๋ฆฌ์ผ์ด์ ๊ฐ๋ฐ์์ ํฐ ๊ณผ์ ์ ๋๋ค. ์ค์ PostgreSQL ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ Kafka ๋ธ๋ก์ปค์ ๋ํด ํ ์คํธํ๋ ค๋ฉด ๋ฌด๊ฑฐ์ด ์ธํ๋ผ๋ฅผ ์ ์งํด์ผ ํฉ๋๋ค. Testcontainers๋ ํ ์คํธ ์ค์ Docker ์ปจํ ์ด๋๋ฅผ ์จ๋๋งจ๋๋ก ์คํํจ์ผ๋ก์จ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๋ฉฐ, ๊ฒฉ๋ฆฌ๋๊ณ ์ฌํ ๊ฐ๋ฅํ ํ๊ฒฝ์ ๋ณด์ฅํฉ๋๋ค.
Spring Boot 3.4๋ ์๋ ๊ตฌ์ฑ์ด ํฌํจ๋ ๋ค์ดํฐ๋ธ Testcontainers ์ง์์ ํฌํจํ๊ณ ์์ต๋๋ค. spring-boot-testcontainers ์์กด์ฑ์ ์ค์น๋ฅผ ํฌ๊ฒ ๋จ์ํํ๊ณ ํ
์คํธ ๊ฐ ์ปจํ
์ด๋ ์ฌ์ฌ์ฉ์ ๊ฐ๋ฅํ๊ฒ ํฉ๋๋ค.
Testcontainers์ Spring Boot ํตํฉ ์ดํดํ๊ธฐ
Testcontainers๋ ํ ์คํธ ์คํ ์ค์ Docker ์ปจํ ์ด๋๋ฅผ ์์ํ๊ธฐ ์ํ Java API๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ธ๋ถ ์์กด์ฑ์ ๋ชจํนํ๊ฑฐ๋ ๊ณต์ ํ ์คํธ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ ์งํ๋ ๋์ , ๊ฐ ํ ์คํธ ์คํ์ ์์ฒด ๊ฒฉ๋ฆฌ๋ ์ธ์คํด์ค๋ฅผ ๋ฐ์ต๋๋ค.
์ํคํ ์ฒ๋ ์ธ ๊ฐ์ง ์ฃผ์ ๊ตฌ์ฑ ์์์ ์์กดํฉ๋๋ค: Docker๋ฅผ ์ ์ดํ๋ Testcontainers ๋ผ์ด๋ธ๋ฌ๋ฆฌ, ๊ฐ ๊ธฐ์ (PostgreSQL, Redis, Kafka)์ ์ํ ์ ๋ฌธ ๋ชจ๋, ๊ทธ๋ฆฌ๊ณ ์ฐ๊ฒฐ ๋งค๊ฐ๋ณ์๋ฅผ ์๋์ผ๋ก ์ฃผ์ ํ๋ Spring Boot ํตํฉ์ ๋๋ค.
<!-- pom.xml -->
<dependencies>
<!-- Main Testcontainers dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 support -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<!-- Testcontainers BOM for version management -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Testcontainers BOM์ ํ๋ก์ ํธ์์ ์ฌ์ฉ๋๋ ๋ชจ๋ ๋ชจ๋ ๊ฐ์ ๋ฒ์ ์ผ๊ด์ฑ์ ๋ณด์ฅํฉ๋๋ค.
PostgreSQL์ ์ฌ์ฉํ ๊ธฐ๋ณธ ๊ตฌ์ฑ
๊ฐ์ฅ ์ผ๋ฐ์ ์ธ ์ฌ์ฉ ์ฌ๋ก๋ ์ค์ PostgreSQL ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ก ํ
์คํธํ๋ ๊ฒ์
๋๋ค. Spring Boot 3.4๋ ๋ ๊ฐ์ง ์ ๊ทผ ๋ฐฉ์์ ์ ๊ณตํฉ๋๋ค: ์๋ ๊ตฌ์ฑ์ ์ํ @ServiceConnection ์ด๋
ธํ
์ด์
๋๋ @DynamicPropertySource๋ฅผ ํตํ ์๋ ๊ตฌ์ฑ์
๋๋ค.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserRepositoryIntegrationTest {
// Starts a PostgreSQL container before tests
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
// Given: a user to persist
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// When: save and retrieve
User saved = userRepository.save(user);
Optional<User> found = userRepository.findById(saved.getId());
// Then: user is correctly persisted
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
@Test
void shouldFindUserByEmail() {
// Given: a user in database
User user = new User();
user.setEmail("search@example.com");
user.setName("Search User");
userRepository.save(user);
// When: search by email
Optional<User> found = userRepository.findByEmail("search@example.com");
// Then: user is found
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Search User");
}
}@ServiceConnection ์ด๋
ธํ
์ด์
์ ์ปจํ
์ด๋ ์ ํ์ ์๋์ผ๋ก ๊ฐ์งํ๊ณ ํด๋น Spring ์์ฑ(spring.datasource.url, spring.datasource.username ๋ฑ)์ ๊ตฌ์ฑํฉ๋๋ค. ์ด ์ ๊ทผ ๋ฐฉ์์ ๋ฐ๋ณต์ ์ธ ๊ตฌ์ฑ ์ฝ๋๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
์ ์ ํ๋์ @Container๋ฅผ ์ฌ์ฉํ๋ฉด ์ปจํ
์ด๋๋ ํด๋์ค์ ๋ชจ๋ ํ
์คํธ ์ ์ ํ ๋ฒ ์์๋๊ณ ๋ง์ง๋ง ํ
์คํธ ํ์ ์ค์ง๋ฉ๋๋ค. ํ
์คํธ๋น ํ๋์ ์ปจํ
์ด๋๋ฅผ ์ํ๋ ๊ฒฝ์ฐ ๋น์ ์ ์ธ์คํด์ค ํ๋๋ฅผ ์ฌ์ฉํด์ผ ํฉ๋๋ค.
@DynamicPropertySource๋ฅผ ์ฌ์ฉํ ์๋ ๊ตฌ์ฑ
์ผ๋ถ ์๋๋ฆฌ์ค์์๋ ์ฃผ์
๋ ์์ฑ์ ๋ํ ๋ ์ธ๋ฐํ ์ ์ด๊ฐ ํ์ํฉ๋๋ค. @DynamicPropertySource ์ด๋
ธํ
์ด์
์ ๊ตฌ์ฑ ๊ฐ์ ๋ช
์์ ์ผ๋ก ์ ์ํ ์ ์๊ฒ ํด์ค๋๋ค.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
// Specific container configuration
.withDatabaseName("orders_test")
.withUsername("test_user")
.withPassword("test_password")
// SQL initialization script
.withInitScript("db/init-orders.sql");
// Manual injection of dynamic properties
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// JDBC URL generated dynamically with mapped port
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Additional properties if needed
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@Test
void shouldPersistOrderWithItems() {
// Given: an order with items
Order order = new Order();
order.setOrderNumber("ORD-2026-001");
order.setStatus(OrderStatus.PENDING);
OrderItem item = new OrderItem();
item.setProductId(1L);
item.setQuantity(2);
item.setUnitPrice(BigDecimal.valueOf(29.99));
order.addItem(item);
// When: save the order
Order saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear();
// Then: order and its items are persisted
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(1);
assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2);
}
}์ด๊ธฐํ ์คํฌ๋ฆฝํธ withInitScript๋ ํ
์คํธ ์คํ ์ ์ ์คํค๋ง๋ฅผ ์ค๋นํ๊ฑฐ๋ ์ฐธ์กฐ ๋ฐ์ดํฐ๋ฅผ ์ฝ์
ํฉ๋๋ค.
์์ ํ Spring Boot ํตํฉ ํ ์คํธ
๋ชจ๋ ๊ตฌ์ฑ ์์๊ฐ ๋ก๋๋ ์ํ๋ก ์ ์ฒด ์ ํ๋ฆฌ์ผ์ด์
์ ํ
์คํธํ๋ ค๋ฉด @SpringBootTest๊ฐ @DataJpaTest๋ฅผ ๋์ฒดํฉ๋๋ค. ์ด ๊ตฌ์ฑ์ PostgreSQL ์ปจํ
์ด๋์ ํจ๊ป ์์ ํ Spring ์ปจํ
์คํธ๋ฅผ ์์ํฉ๋๋ค.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserService userService;
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateUserViaApi() {
// Given: a creation request
CreateUserRequest request = new CreateUserRequest(
"api@example.com",
"API User",
"securePassword123"
);
// When: call the REST API
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
"/api/users",
request,
UserResponse.class
);
// Then: user is created successfully
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().email()).isEqualTo("api@example.com");
}
@Test
void shouldRetrieveUserById() {
// Given: an existing user
UserResponse created = userService.createUser(
new CreateUserRequest("retrieve@example.com", "Retrieve User", "password")
);
// When: retrieve by ID
ResponseEntity<UserResponse> response = restTemplate.getForEntity(
"/api/users/" + created.id(),
UserResponse.class
);
// Then: user is returned
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().name()).isEqualTo("Retrieve User");
}
}์๋ ๊ตฌ์ฑ๋ TestRestTemplate์ ์์์ ํฌํธ์์ ์์๋ ์๋ฒ๋ฅผ ๊ฐ๋ฆฌํค๋ฉฐ, ๋ณ๋ ฌ ํ
์คํธ ๊ฐ์ ํฌํธ ์ถฉ๋์ ๋ฐฉ์งํฉ๋๋ค.
Spring Boot ๋ฉด์ ์ค๋น๊ฐ ๋์ จ๋์?
์ธํฐ๋ํฐ๋ธ ์๋ฎฌ๋ ์ดํฐ, flashcards, ๊ธฐ์ ํ ์คํธ๋ก ์ฐ์ตํ์ธ์.
ํ ์คํธ ๊ฐ ์ปจํ ์ด๋ ์ฌ์ฌ์ฉ
Docker ์ปจํ ์ด๋ ์์์๋ ๋ช ์ด๊ฐ ์์๋ฉ๋๋ค. ํ ์คํธ ์คํ์ ๊ฐ์ํํ๊ธฐ ์ํด Spring Boot 3.4๋ ์ค์ ์ง์ค์ ๊ตฌ์ฑ์ ํตํด ์ปจํ ์ด๋๋ฅผ ์ฌ์ฌ์ฉํ ์ ์๊ฒ ํด์ค๋๋ค.
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
// Reusable container bean across all tests
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a");
}
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer(DockerImageName.parse("redis:7-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a");
}
}ํ ์คํธ๋ ๋์ผํ ์ปจํ ์ด๋๋ฅผ ๊ณต์ ํ๊ธฐ ์ํด ์ด ๊ตฌ์ฑ์ ๊ฐ์ ธ์ต๋๋ค.
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class ProductServiceIntegrationTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductDetails() {
// Given: a product in database
Product product = new Product();
product.setName("Cached Product");
product.setPrice(BigDecimal.valueOf(99.99));
productRepository.save(product);
// When: two successive calls
ProductDto first = productService.getProductById(product.getId());
ProductDto second = productService.getProductById(product.getId());
// Then: second call uses cache
assertThat(first).isEqualTo(second);
}
}์ปจํ
์ด๋ ์ฌ์ฌ์ฉ์ ํ์ฑํํ๋ ค๋ฉด ~/.testcontainers.properties์ ๊ตฌ์ฑ์ ์ถ๊ฐํด์ผ ํฉ๋๋ค:
# ~/.testcontainers.properties
testcontainers.reuse.enable=true์ปจํ
์ด๋๋ฅผ ์ฌ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ๊ฐ ์คํ ๊ฐ์ ์ ์ง๋ฉ๋๋ค. ๊ฐ ํ
์คํธ ์ ์ ํ
์ด๋ธ์ ์ ๋ฆฌํ๋ ค๋ฉด @Sql ๋๋ @BeforeEach๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ ํ
์คํธ ํด๋์ค๋ณ๋ก ๋ค๋ฅธ ์คํค๋ง๋ฅผ ๊ตฌ์ฑํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
Redis์ ๋ถ์ฐ ์บ์๋ฅผ ์ฌ์ฉํ ํ ์คํธ
Testcontainers๋ ์บ์ ๊ธฐ๋ฅ ํ ์คํธ๋ฅผ ์ํด Redis๋ฅผ ์ง์ํฉ๋๋ค. Redis ๋ชจ๋์ ์ฆ์ ์ฌ์ฉ ๊ฐ๋ฅํ ์ฌ์ ๊ตฌ์ฑ๋ ์ปจํ ์ด๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
@SpringBootTest
@Testcontainers
class CacheServiceIntegrationTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine")
);
@Autowired
private CacheService cacheService;
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void shouldStoreAndRetrieveFromCache() {
// Given: a value to cache
String key = "user:123";
String value = "{\"id\":123,\"name\":\"Cached User\"}";
// When: store in cache
cacheService.put(key, value, Duration.ofMinutes(10));
// Then: value is retrievable
String cached = cacheService.get(key);
assertThat(cached).isEqualTo(value);
}
@Test
void shouldExpireAfterTtl() throws InterruptedException {
// Given: a value with short TTL
String key = "expiring:key";
cacheService.put(key, "temporary", Duration.ofSeconds(1));
// When: wait for expiration
Thread.sleep(1500);
// Then: key has expired
String cached = cacheService.get(key);
assertThat(cached).isNull();
}
@Test
void shouldIncrementCounter() {
// Given: a counter key
String counterKey = "page:views:homepage";
// When: multiple increments
Long first = redisTemplate.opsForValue().increment(counterKey);
Long second = redisTemplate.opsForValue().increment(counterKey);
Long third = redisTemplate.opsForValue().increment(counterKey);
// Then: counter increments correctly
assertThat(first).isEqualTo(1);
assertThat(second).isEqualTo(2);
assertThat(third).isEqualTo(3);
}
}Spring Boot๋ @ServiceConnection์ ํตํด RedisContainer๋ฅผ ์๋์ผ๋ก ๊ฐ์งํ๊ณ spring.data.redis.host์ spring.data.redis.port๋ฅผ ๊ตฌ์ฑํฉ๋๋ค.
Kafka์ ๋น๋๊ธฐ ๋ฉ์์ง์ ์ฌ์ฉํ ํ ์คํธ
์ด๋ฒคํธ ๊ธฐ๋ฐ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ค์ Kafka ๋ธ๋ก์ปค๋ก ํ ์คํธํด์ผ ํฉ๋๋ค. Testcontainers๋ ํ ์คํธ์ ์ ํฉํ ๋จ์ผ ๋ ธ๋ ํด๋ฌ์คํฐ๋ฅผ ์์ํ๋ Kafka ๋ชจ๋์ ์ ๊ณตํฉ๋๋ค.
@SpringBootTest
@Testcontainers
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Autowired
private OrderEventConsumer orderEventConsumer;
@Test
void shouldPublishAndConsumeOrderEvent() throws Exception {
// Given: an order event
OrderEvent event = new OrderEvent(
"ORD-2026-100",
OrderEventType.CREATED,
LocalDateTime.now()
);
// When: publish to Kafka
kafkaTemplate.send("order-events", event.orderId(), event).get();
// Then: event is consumed (with timeout)
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
assertThat(orderEventConsumer.getReceivedEvents())
.hasSize(1)
.first()
.extracting(OrderEvent::orderId)
.isEqualTo("ORD-2026-100");
});
}
}@Component
public class OrderEventConsumer {
private final List<OrderEvent> receivedEvents = new CopyOnWriteArrayList<>();
@KafkaListener(topics = "order-events", groupId = "test-group")
public void consume(OrderEvent event) {
receivedEvents.add(event);
}
public List<OrderEvent> getReceivedEvents() {
return List.copyOf(receivedEvents);
}
public void clear() {
receivedEvents.clear();
}
}Awaitility ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ํ์์์์ด ์๋ ๋น๋๊ธฐ ์ด์ค์
์ ์ฒ๋ฆฌํ์ฌ ํ
์คํธ์์ ์ทจ์ฝํ Thread.sleep ํธ์ถ์ ๋ฐฉ์งํฉ๋๋ค.
Docker Compose๋ฅผ ์ฌ์ฉํ ๋ค์ค ์ปจํ ์ด๋ ๊ตฌ์ฑ
์ฌ๋ฌ ์ํธ ์์กด์ ์ธ ์๋น์ค๊ฐ ํ์ํ ๋ณต์กํ ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ฒฝ์ฐ Testcontainers๋ Docker Compose ํ์ผ์ ์ง์ํฉ๋๋ค.
# src/test/resources/docker-compose-test.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5432"
redis:
image: redis:7-alpine
ports:
- "6379"
localstack:
image: localstack/localstack:3.0
environment:
SERVICES: s3,sqs
DEFAULT_REGION: eu-west-1
ports:
- "4566"@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {
@Container
static DockerComposeContainer<?> environment = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("postgres", 5432)
.withExposedService("redis", 6379)
.withExposedService("localstack", 4566)
.waitingFor("postgres", Wait.forListeningPort())
.waitingFor("redis", Wait.forListeningPort())
.waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// PostgreSQL configuration
String postgresHost = environment.getServiceHost("postgres", 5432);
Integer postgresPort = environment.getServicePort("postgres", 5432);
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb");
registry.add("spring.datasource.username", () -> "testuser");
registry.add("spring.datasource.password", () -> "testpass");
// Redis configuration
String redisHost = environment.getServiceHost("redis", 6379);
Integer redisPort = environment.getServicePort("redis", 6379);
registry.add("spring.data.redis.host", () -> redisHost);
registry.add("spring.data.redis.port", () -> redisPort);
// LocalStack S3 configuration
String localstackHost = environment.getServiceHost("localstack", 4566);
Integer localstackPort = environment.getServicePort("localstack", 4566);
registry.add("aws.s3.endpoint",
() -> "http://" + localstackHost + ":" + localstackPort);
}
@Autowired
private FileStorageService fileStorageService;
@Test
void shouldUploadFileToS3() {
// Given: a file to upload
byte[] content = "Test file content".getBytes();
String fileName = "test-file.txt";
// When: upload to S3 via LocalStack
String url = fileStorageService.upload(fileName, content);
// Then: file is accessible
assertThat(url).contains(fileName);
byte[] downloaded = fileStorageService.download(fileName);
assertThat(downloaded).isEqualTo(content);
}
}์ฐ์ต์ ์์ํ์ธ์!
๋ฉด์ ์๋ฎฌ๋ ์ดํฐ์ ๊ธฐ์ ํ ์คํธ๋ก ์ง์์ ํ ์คํธํ์ธ์.
๋ชจ๋ฒ ์ฌ๋ก ๋ฐ ์ฑ๋ฅ ์ต์ ํ
ํจ์จ์ ์ธ Testcontainers ์คํ์ ์ํด์๋ ๋น๋ ์๊ฐ์ ์ค์ด๊ธฐ ์ํ ๋ช ๊ฐ์ง ์ต์ ํ๊ฐ ํ์ํฉ๋๋ค.
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// Shared container across all inheriting classes
@Container
@ServiceConnection
protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
.withReuse(true);
@Autowired
protected JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
// Clean tables in order to respect FK constraints
jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
}
}class UserIntegrationTest extends AbstractIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
// PostgreSQL container already started via parent class
User user = new User();
user.setEmail("inherited@test.com");
user.setName("Inherited Test");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
}
}์ถ์ ํด๋์ค๋ ์ปจํ ์ด๋ ๊ตฌ์ฑ๊ณผ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ๋ฆฌ๋ฅผ ์ค์ ์ง์คํํ์ฌ ์ฝ๋ ์ค๋ณต์ ๋ฐฉ์งํฉ๋๋ค.
# src/test/resources/application-test.properties
# Test-specific configuration
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
# Disable Flyway/Liquibase if using ddl-auto
spring.flyway.enabled=false
# Reduced connection pool for tests
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2๋ ๊ฐ๋ณ๊ณ ๋น ๋ฅด๊ฒ ์์๋๋ Alpine ์ด๋ฏธ์ง(postgres:16-alpine, redis:7-alpine)๋ฅผ ์ ํธํ๋ ๊ฒ์ด ์ข์ต๋๋ค. ํ
์คํธ์ ๊ฒฝ์ฐ ์ ์ฒด ์ด๋ฏธ์ง์์ ๊ธฐ๋ฅ์ ์ฐจ์ด๋ ๋ฌด์ํ ์ ์๋ ์์ค์
๋๋ค.
๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์ ํ ์คํธ
Testcontainers๋ ์ค์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ํด Flyway ๋๋ Liquibase ๋ง์ด๊ทธ๋ ์ด์ ์ ํ ์คํธํ๋ ๋ฐ ํ์ํฉ๋๋ค.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class FlywayMigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
// Given: migrations applied at startup
// When: check state
MigrationInfoService info = flyway.info();
// Then: all migrations are applied
assertThat(info.pending()).isEmpty();
assertThat(info.applied()).isNotEmpty();
}
@Test
void shouldCreateExpectedTables() {
// Given: migrations applied
// When: query system tables
List<String> tables = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
String.class
);
// Then: expected tables exist
assertThat(tables).contains("users", "orders", "order_items", "products");
}
@Test
void shouldHaveCorrectColumnTypes() {
// Given: users table created
// When: verify schema
List<Map<String, Object>> columns = jdbcTemplate.queryForList(
"SELECT column_name, data_type, is_nullable " +
"FROM information_schema.columns " +
"WHERE table_name = 'users'"
);
// Then: columns have correct types
assertThat(columns)
.extracting(c -> c.get("column_name"))
.contains("id", "email", "name", "created_at");
}
}์ด ํ ์คํธ๋ ํ๋ก๋์ ๋ฐฐํฌ ์ ์ SQL ๋ง์ด๊ทธ๋ ์ด์ ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์๋ํ๋์ง ๋ณด์ฅํฉ๋๋ค.
๊ฒฐ๋ก
Testcontainers๋ Spring Boot ํตํฉ ํ
์คํธ๋ฅผ ์ ๋ขฐํ ์ ์๊ณ ์ฌํ ๊ฐ๋ฅํ๋ฉฐ ๋ก์ปฌ ํ๊ฒฝ๊ณผ ๋
๋ฆฝ์ ์ผ๋ก ๋ง๋ค์ด ๋ณํ์ํต๋๋ค. @ServiceConnection์ ์ฌ์ฉํ Spring Boot 3.4์ ๋ค์ดํฐ๋ธ ์ง์์ ๊ตฌ์ฑ์ ํฌ๊ฒ ๋จ์ํํ๊ณ , ์ปจํ
์ด๋ ์ฌ์ฌ์ฉ์ ์คํ ์๊ฐ์ ์ต์ ํํฉ๋๋ค.
Testcontainers Spring Boot ์ฒดํฌ๋ฆฌ์คํธ:
- โ
๋ค์ดํฐ๋ธ ์๋ ๊ตฌ์ฑ์ ์ํด
spring-boot-testcontainers์ฌ์ฉ - โ
๊ฐ๋ฅํ ๊ฒฝ์ฐ
@DynamicPropertySource๋ณด๋ค@ServiceConnection์ ํธ - โ
์ฐ์ ์คํ ์๋๋ฅผ ๋์ด๊ธฐ ์ํด
withReuse(true)ํ์ฑํ - โ
์ถ์ ํด๋์ค ๋๋
@TestConfiguration์ ๊ตฌ์ฑ ์ค์ ์ง์คํ - โ
@BeforeEach๋๋@Sql๋ก ํ ์คํธ ๊ฐ ๋ฐ์ดํฐ ์ ๋ฆฌ - โ ๋ ๋น ๋ฅธ ์์์ ์ํด Alpine ์ด๋ฏธ์ง ์ฌ์ฉ
- โ ์ค์ PostgreSQL์ ๋ํด Flyway/Liquibase ๋ง์ด๊ทธ๋ ์ด์ ํ ์คํธ
- โ ๋ค์ค ์๋น์ค ํ๊ฒฝ์ ์ํด Docker Compose ํ์ฉ
ํ๊ทธ
๊ณต์
๊ด๋ จ ๊ธฐ์ฌ

Spring Modulith: ๋ชจ๋๋ฌ ๋ชจ๋๋ฆฌ์ค ์ํคํ ์ฒ ํด์ค
Spring Modulith๋ก ์๋ฐ ๋ชจ๋๋ฌ ๋ชจ๋๋ฆฌ์ค๋ฅผ ๊ตฌ์ถํ๋ ๋ฐฉ๋ฒ์ ๋ฐฐ์๋๋ค. ์ํคํ ์ฒ, ๋ชจ๋, ๋น๋๊ธฐ ์ด๋ฒคํธ, Spring Boot 3 ์์ ๋ก ์ดํด๋ณด๋ ํ ์คํธ.

Spring Batch 5 ๋ฉด์ : ํํฐ์ ๋, ์ฒญํฌ, ์ฅ์ ํ์ฉ
Spring Batch 5 ๋ฉด์ ์ ์ ๋ณตํ์ธ์. ํํฐ์ ๋, ์ฒญํฌ ์ฒ๋ฆฌ, ์ฅ์ ํ์ฉ์ ๊ดํ 15๊ฐ์ง ํต์ฌ ์ง๋ฌธ๊ณผ Java 21 ์์ ๋ฅผ ์ ๊ณตํฉ๋๋ค.

Spring Boot ๋ฉด์ : ํธ๋์ญ์ ์ ํ ์ค๋ช
Spring Boot ํธ๋์ญ์ ์ ํ ๋ง์คํฐํ๊ธฐ: REQUIRED, REQUIRES_NEW, NESTED ๋ฑ. ์ฝ๋ ์์ ์ ์ผ๋ฐ์ ์ธ ํจ์ ์ ํฌํจํ 12๊ฐ์ง ๋ฉด์ ์ง๋ฌธ.