Testcontainers Spring Boot: ๊ณ ํ†ต ์—†๋Š” ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ

Spring Boot 3.4์—์„œ Testcontainers๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ์™„์ „ํ•œ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๊ณ  ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด Docker ์ปจํ…Œ์ด๋„ˆ์—์„œ PostgreSQL, Redis, Kafka๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.

Testcontainers, PostgreSQL, Redis, Kafka๋ฅผ ์‚ฌ์šฉํ•œ Spring Boot ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ

ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋Š” Spring Boot ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ฐœ๋ฐœ์—์„œ ํฐ ๊ณผ์ œ์ž…๋‹ˆ๋‹ค. ์‹ค์ œ PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋‚˜ Kafka ๋ธŒ๋กœ์ปค์— ๋Œ€ํ•ด ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด ๋ฌด๊ฑฐ์šด ์ธํ”„๋ผ๋ฅผ ์œ ์ง€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Testcontainers๋Š” ํ…Œ์ŠคํŠธ ์ค‘์— Docker ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์˜จ๋””๋งจ๋“œ๋กœ ์‹คํ–‰ํ•จ์œผ๋กœ์จ ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๋ฉฐ, ๊ฒฉ๋ฆฌ๋˜๊ณ  ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ํ™˜๊ฒฝ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

Spring Boot 3.4์™€ Testcontainers

Spring Boot 3.4๋Š” ์ž๋™ ๊ตฌ์„ฑ์ด ํฌํ•จ๋œ ๋„ค์ดํ‹ฐ๋ธŒ Testcontainers ์ง€์›์„ ํฌํ•จํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. spring-boot-testcontainers ์˜์กด์„ฑ์€ ์„ค์น˜๋ฅผ ํฌ๊ฒŒ ๋‹จ์ˆœํ™”ํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ„ ์ปจํ…Œ์ด๋„ˆ ์žฌ์‚ฌ์šฉ์„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

Testcontainers์™€ Spring Boot ํ†ตํ•ฉ ์ดํ•ดํ•˜๊ธฐ

Testcontainers๋Š” ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ค‘์— Docker ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•œ Java API๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ชจํ‚นํ•˜๊ฑฐ๋‚˜ ๊ณต์œ  ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์œ ์ง€ํ•˜๋Š” ๋Œ€์‹ , ๊ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰์€ ์ž์ฒด ๊ฒฉ๋ฆฌ๋œ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

์•„ํ‚คํ…์ฒ˜๋Š” ์„ธ ๊ฐ€์ง€ ์ฃผ์š” ๊ตฌ์„ฑ ์š”์†Œ์— ์˜์กดํ•ฉ๋‹ˆ๋‹ค: Docker๋ฅผ ์ œ์–ดํ•˜๋Š” Testcontainers ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ, ๊ฐ ๊ธฐ์ˆ (PostgreSQL, Redis, Kafka)์„ ์œ„ํ•œ ์ „๋ฌธ ๋ชจ๋“ˆ, ๊ทธ๋ฆฌ๊ณ  ์—ฐ๊ฒฐ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์ž๋™์œผ๋กœ ์ฃผ์ž…ํ•˜๋Š” Spring Boot ํ†ตํ•ฉ์ž…๋‹ˆ๋‹ค.

xml
<!-- 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๋ฅผ ํ†ตํ•œ ์ˆ˜๋™ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค.

UserRepositoryIntegrationTest.javajava
@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 ์–ด๋…ธํ…Œ์ด์…˜์€ ๊ตฌ์„ฑ ๊ฐ’์„ ๋ช…์‹œ์ ์œผ๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

OrderRepositoryIntegrationTest.javajava
@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 ์ปจํ…์ŠคํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

UserServiceIntegrationTest.javajava
@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๋Š” ์ค‘์•™ ์ง‘์ค‘์‹ ๊ตฌ์„ฑ์„ ํ†ตํ•ด ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

TestcontainersConfiguration.javajava
@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");
    }
}

ํ…Œ์ŠคํŠธ๋Š” ๋™์ผํ•œ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ๊ณต์œ ํ•˜๊ธฐ ์œ„ํ•ด ์ด ๊ตฌ์„ฑ์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.

ProductServiceIntegrationTest.javajava
@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์— ๊ตฌ์„ฑ์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:

properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
๋ฐ์ดํ„ฐ ์ •๋ฆฌ

์ปจํ…Œ์ด๋„ˆ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ์‹คํ–‰ ๊ฐ„์— ์œ ์ง€๋ฉ๋‹ˆ๋‹ค. ๊ฐ ํ…Œ์ŠคํŠธ ์ „์— ํ…Œ์ด๋ธ”์„ ์ •๋ฆฌํ•˜๋ ค๋ฉด @Sql ๋˜๋Š” @BeforeEach๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋ณ„๋กœ ๋‹ค๋ฅธ ์Šคํ‚ค๋งˆ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

Redis์™€ ๋ถ„์‚ฐ ์บ์‹œ๋ฅผ ์‚ฌ์šฉํ•œ ํ…Œ์ŠคํŠธ

Testcontainers๋Š” ์บ์‹œ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด Redis๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. Redis ๋ชจ๋“ˆ์€ ์ฆ‰์‹œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์‚ฌ์ „ ๊ตฌ์„ฑ๋œ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

CacheServiceIntegrationTest.javajava
@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 ๋ชจ๋“ˆ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

OrderEventIntegrationTest.javajava
@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");
            });
    }
}
OrderEventConsumer.javajava
@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 ํŒŒ์ผ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

yaml
# 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"
FullStackIntegrationTest.javajava
@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 ์‹คํ–‰์„ ์œ„ํ•ด์„œ๋Š” ๋นŒ๋“œ ์‹œ๊ฐ„์„ ์ค„์ด๊ธฐ ์œ„ํ•œ ๋ช‡ ๊ฐ€์ง€ ์ตœ์ ํ™”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

AbstractIntegrationTest.javajava
@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");
    }
}
UserIntegrationTest.javajava
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();
    }
}

์ถ”์ƒ ํด๋ž˜์Šค๋Š” ์ปจํ…Œ์ด๋„ˆ ๊ตฌ์„ฑ๊ณผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ •๋ฆฌ๋ฅผ ์ค‘์•™ ์ง‘์ค‘ํ™”ํ•˜์—ฌ ์ฝ”๋“œ ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.

properties
# 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
๊ฐ€๋ฒผ์šด Docker ์ด๋ฏธ์ง€

๋” ๊ฐ€๋ณ๊ณ  ๋น ๋ฅด๊ฒŒ ์‹œ์ž‘๋˜๋Š” Alpine ์ด๋ฏธ์ง€(postgres:16-alpine, redis:7-alpine)๋ฅผ ์„ ํ˜ธํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ์˜ ๊ฒฝ์šฐ ์ „์ฒด ์ด๋ฏธ์ง€์™€์˜ ๊ธฐ๋Šฅ์  ์ฐจ์ด๋Š” ๋ฌด์‹œํ•  ์ˆ˜ ์žˆ๋Š” ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ

Testcontainers๋Š” ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋Œ€ํ•ด Flyway ๋˜๋Š” Liquibase ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐ ํƒ์›”ํ•ฉ๋‹ˆ๋‹ค.

FlywayMigrationTest.javajava
@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 ํ™œ์šฉ

ํƒœ๊ทธ

#testcontainers
#spring boot
#integration testing
#docker
#postgresql

๊ณต์œ 

๊ด€๋ จ ๊ธฐ์‚ฌ