# Testcontainers Spring Boot: การทดสอบ Integration ที่ไร้ความยุ่งยาก > คู่มือฉบับสมบูรณ์สำหรับการตั้งค่า Testcontainers ร่วมกับ Spring Boot 3.4 PostgreSQL, Redis และ Kafka ในคอนเทนเนอร์ Docker เพื่อการทดสอบ Integration ที่เชื่อถือได้และทำซ้ำได้ - Published: 2026-03-19 - Updated: 2026-05-01 - Author: SharpSkill - Tags: testcontainers, spring boot, integration testing, docker, postgresql - Reading time: 14 min --- การทดสอบ Integration เป็นความท้าทายสำคัญในการพัฒนาแอปพลิเคชัน Spring Boot การทดสอบกับฐานข้อมูล PostgreSQL จริงหรือ Kafka broker ต้องใช้โครงสร้างพื้นฐานที่หนักหน่วงในการบำรุงรักษา Testcontainers แก้ปัญหานี้ด้วยการรันคอนเทนเนอร์ Docker ตามต้องการระหว่างการทดสอบ รับประกันสภาพแวดล้อมที่แยกอิสระและทำซ้ำได้ > **Spring Boot 3.4 และ Testcontainers** > > Spring Boot 3.4 มีการรองรับ Testcontainers แบบเนทีฟพร้อมการตั้งค่าอัตโนมัติ Dependency `spring-boot-testcontainers` ทำให้การติดตั้งง่ายขึ้นอย่างมากและรองรับการนำคอนเทนเนอร์กลับมาใช้ใหม่ระหว่างการทดสอบ ## ทำความเข้าใจการผสานระหว่าง Testcontainers และ Spring Boot Testcontainers จัดเตรียม Java API สำหรับการรันคอนเทนเนอร์ Docker ระหว่างการทดสอบ แทนที่จะ mock การพึ่งพาภายนอกหรือบำรุงรักษาฐานข้อมูลทดสอบที่ใช้ร่วมกัน การทดสอบแต่ละครั้งจะได้รับอินสแตนซ์ที่แยกอิสระของตัวเอง สถาปัตยกรรมประกอบด้วยสามส่วนประกอบหลัก ได้แก่ ไลบรารี Testcontainers ที่ควบคุม Docker โมดูลเฉพาะสำหรับแต่ละเทคโนโลยี (PostgreSQL, Redis, Kafka) และการผสานกับ Spring Boot ที่ฉีดพารามิเตอร์การเชื่อมต่อโดยอัตโนมัติ ```xml org.springframework.boot spring-boot-testcontainers test org.testcontainers postgresql test org.testcontainers junit-jupiter test org.testcontainers testcontainers-bom 1.20.4 pom import ``` BOM ของ Testcontainers ช่วยให้แน่ใจว่าเวอร์ชันสอดคล้องกันในทุกโมดูลที่ใช้ในโปรเจกต์ ## การตั้งค่าพื้นฐานด้วย PostgreSQL กรณีการใช้งานที่พบบ่อยที่สุดคือการทดสอบกับฐานข้อมูล PostgreSQL จริง Spring Boot 3.4 มีสองแนวทาง ได้แก่ annotation `@ServiceConnection` สำหรับการตั้งค่าอัตโนมัติ หรือการตั้งค่าด้วยตนเองผ่าน `@DynamicPropertySource` ```java // UserRepositoryIntegrationTest.java @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 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 found = userRepository.findByEmail("search@example.com"); // Then: user is found assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("Search User"); } } ``` Annotation `@ServiceConnection` ตรวจจับประเภทของคอนเทนเนอร์โดยอัตโนมัติและตั้งค่าคุณสมบัติของ Spring ที่สอดคล้องกัน (`spring.datasource.url`, `spring.datasource.username` ฯลฯ) แนวทางนี้ช่วยลดโค้ดการตั้งค่าซ้ำซ้อน > **วงจรชีวิตของคอนเทนเนอร์** > > เมื่อใช้ `@Container` บน field แบบ static คอนเทนเนอร์จะเริ่มต้นเพียงครั้งเดียวก่อนการทดสอบทั้งหมดในคลาสและหยุดหลังการทดสอบสุดท้าย หากต้องการคอนเทนเนอร์หนึ่งตัวต่อการทดสอบหนึ่งครั้ง ควรใช้ field แบบ instance ที่ไม่ใช่ static ## การตั้งค่าด้วยตนเองด้วย @DynamicPropertySource บางสถานการณ์ต้องการการควบคุมที่ละเอียดกว่าในการฉีดคุณสมบัติ Annotation `@DynamicPropertySource` ช่วยให้สามารถกำหนดค่าการตั้งค่าได้อย่างชัดเจน ```java // OrderRepositoryIntegrationTest.java @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` เตรียม schema หรือแทรกข้อมูลอ้างอิงก่อนการรันการทดสอบ ## การทดสอบ Integration แบบเต็มของ Spring Boot เพื่อทดสอบแอปพลิเคชันทั้งหมดพร้อมส่วนประกอบทั้งหมดที่โหลดแล้ว `@SpringBootTest` แทนที่ `@DataJpaTest` การตั้งค่านี้เริ่มต้นบริบท Spring แบบเต็มร่วมกับคอนเทนเนอร์ PostgreSQL ```java // UserServiceIntegrationTest.java @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 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 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` ที่ตั้งค่าโดยอัตโนมัติชี้ไปยังเซิร์ฟเวอร์ที่เริ่มต้นบนพอร์ตแบบสุ่ม หลีกเลี่ยงความขัดแย้งของพอร์ตระหว่างการทดสอบแบบขนาน ## การนำคอนเทนเนอร์กลับมาใช้ใหม่ระหว่างการทดสอบ การเริ่มต้นคอนเทนเนอร์ Docker ใช้เวลาหลายวินาที เพื่อเร่งความเร็วการรันการทดสอบ Spring Boot 3.4 ให้สามารถนำคอนเทนเนอร์กลับมาใช้ใหม่ผ่านการตั้งค่าแบบรวมศูนย์ ```java // TestcontainersConfiguration.java @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"); } } ``` การทดสอบ import การตั้งค่านี้เพื่อแบ่งปันคอนเทนเนอร์เดียวกัน ```java // ProductServiceIntegrationTest.java @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` เพื่อล้างตารางก่อนการทดสอบแต่ละครั้ง หรือกำหนด schema ที่แตกต่างสำหรับแต่ละคลาสทดสอบ ## การทดสอบกับ Redis และ Cache แบบกระจาย Testcontainers รองรับ Redis สำหรับการทดสอบฟีเจอร์แคช โมดูล Redis จัดเตรียมคอนเทนเนอร์ที่ตั้งค่าไว้ล่วงหน้าและพร้อมใช้งาน ```java // CacheServiceIntegrationTest.java @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 ตรวจจับ `RedisContainer` โดยอัตโนมัติผ่าน `@ServiceConnection` และตั้งค่า `spring.data.redis.host` และ `spring.data.redis.port` ## การทดสอบกับ Kafka และการส่งข้อความแบบอะซิงโครนัส แอปพลิเคชันที่ขับเคลื่อนด้วยอีเวนต์ต้องการการทดสอบกับ Kafka broker จริง Testcontainers จัดเตรียมโมดูล Kafka ที่เริ่มต้นคลัสเตอร์โหนดเดียวที่เหมาะสำหรับการทดสอบ ```java // OrderEventIntegrationTest.java @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 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"); }); } } ``` ```java // OrderEventConsumer.java @Component public class OrderEventConsumer { private final List receivedEvents = new CopyOnWriteArrayList<>(); @KafkaListener(topics = "order-events", groupId = "test-group") public void consume(OrderEvent event) { receivedEvents.add(event); } public List getReceivedEvents() { return List.copyOf(receivedEvents); } public void clear() { receivedEvents.clear(); } } ``` ไลบรารี Awaitility จัดการการตรวจสอบแบบอะซิงโครนัสด้วย timeout หลีกเลี่ยงการเรียก `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" ``` ```java // FullStackIntegrationTest.java @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 อย่างมีประสิทธิภาพต้องการการเพิ่มประสิทธิภาพบางอย่างเพื่อลดเวลา build ```java // AbstractIntegrationTest.java @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"); } } ``` ```java // UserIntegrationTest.java 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 ``` > **Image Docker ขนาดเล็ก** > > ควรเลือกใช้ image แบบ Alpine (`postgres:16-alpine`, `redis:7-alpine`) ซึ่งมีขนาดเล็กกว่าและเริ่มต้นเร็วกว่า สำหรับการทดสอบ ความแตกต่างด้านฟังก์ชันจาก image แบบเต็มถือว่าน้อยมาก ## การทดสอบ Migration ฐานข้อมูล Testcontainers โดดเด่นในการทดสอบ migration ของ Flyway หรือ Liquibase กับฐานข้อมูลจริง ```java // FlywayMigrationTest.java @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 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> 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"); } } ``` การทดสอบเหล่านี้รับประกันว่า migration SQL ทำงานอย่างถูกต้องก่อนการ deploy ไปยัง production ## บทสรุป Testcontainers เปลี่ยนแปลงการทดสอบ Integration ของ Spring Boot โดยทำให้เชื่อถือได้ ทำซ้ำได้ และเป็นอิสระจากสภาพแวดล้อมท้องถิ่น การรองรับแบบเนทีฟของ Spring Boot 3.4 ด้วย `@ServiceConnection` ช่วยลดความซับซ้อนในการตั้งค่าได้อย่างมาก ในขณะที่การนำคอนเทนเนอร์กลับมาใช้ใหม่ช่วยเพิ่มประสิทธิภาพเวลาในการรัน **รายการตรวจสอบ Testcontainers Spring Boot:** - ✅ ใช้ `spring-boot-testcontainers` สำหรับการตั้งค่าอัตโนมัติแบบเนทีฟ - ✅ เลือกใช้ `@ServiceConnection` แทน `@DynamicPropertySource` เมื่อทำได้ - ✅ เปิดใช้งาน `withReuse(true)` เพื่อเร่งความเร็วการรันต่อเนื่อง - ✅ รวมศูนย์การตั้งค่าในคลาสนามธรรมหรือ `@TestConfiguration` - ✅ ล้างข้อมูลระหว่างการทดสอบด้วย `@BeforeEach` หรือ `@Sql` - ✅ ใช้ image แบบ Alpine เพื่อเริ่มต้นเร็วขึ้น - ✅ ทดสอบ migration ของ Flyway/Liquibase กับ PostgreSQL จริง - ✅ ใช้ Docker Compose สำหรับสภาพแวดล้อมหลายบริการ --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/spring-boot/testcontainers-spring-boot-integration-testing