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 활용
오늘의 챌린지

Spring Boot 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 5월 1일 업데이트

태그

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

공유

관련 기사