SpringBoot 3.1 TestContainer

2024. 5. 13. 13:40JAVA/Spring

 

개요

이 글에서는 SpringBoot 3.1 이상 버전의 프레임워크에서 테스트 컨테이너를 구현하는 방법에 대해서 소개합니다. 3.1 버전 이전까지 SpringBoot 프레임워크에서 테스트 컨테이너를 실행하기 위해서는 @DynamicPropertySource 애노테이션을 이용하여 동적 프로퍼티들을 설정하고 명시적으로 시작하도록 호출 했어야 했습니다. 하지만 3.1 버전 이상부터는 spring-boot-testcontainers 의존성을 지원하기 시작하면서 특정한 애노테이션만 적용해주면 동적 프토퍼티 설정들을 자동으로 설정해줍니다. 만약 SpringBoot 3.1 이전 버전을 사용해야 한다면 다음 링크를 참고해주시면 감사하겠습니다.

 

https://yonghwankim-dev.tistory.com/607

 

SpringBoot 2.7 TestContainer

개요Spring 프레임워크로 웹 애플리케이션 개발시 테스트 코드를 구현하는 일이 많습니다. 하지만 웹 애플리케이션이 단독으로 사용되는 일은 많지 않고 MySQL과 같은 데이터베이스와 같이 서비스

yonghwankim-dev.tistory.com

 

이 글에서는 테스트 컨테이너를 실행하기 위해서 @ServiceConnection 애노테이션을 사용하는 것을 소개합니다. 또한 테스트들을 실행하는 것만이 아닌 로컬 개발시 테스트 컨테이너를 지원하는 방법을 알아봅니다. 마지막으로 spring-boot-devtools 의존성 라이브러리와 @RestartScope 애노테이션을 이용하여 소스 코드 변경 후 스프링 서버를 재시작하는 경우 테스트 컨테이너를 다시 시작하지 않고 데이터를 유지 하는 방법에 대해서 알아봅니다.

 

1. 동적 프로퍼티들을 위한 @ServiceConnection 사용하기

SpringBoot 3.1부터는 동적 프로퍼티들을 정의하는 코드를 제거하기 위해서 @ServiceConnection 애노테이션을 사용할 수 있습니다. 예를 들어 3.1 이전까지는 다음과 같이 @DynamicPropertySource 애노테이션을 사용하여 테스트 컨테이너에 대한 프로퍼티들을 설정하여야 했습니다.

@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry) {
    // redis property config
    registry.add("spring.redis.host", REDIS_CONTAINER::getHost);
    registry.add("spring.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT).toString());

    // mysql property config
    registry.add("spring.datasource.driver-class-name", () -> "org.testcontainers.jdbc.ContainerDatabaseDriver");
    registry.add("spring.datasource.url", () -> "jdbc:tc:mysql:8.0.33://localhost/testdb");
    registry.add("spring.datasource.username", () -> "admin");
    registry.add("spring.datasource.password", () -> "password1234!");
}

위 코드는 Redis와 MySQL 컨테이너를 실행하기 위해서 설정하여야 하는 동적 프로퍼티 코드입니다. 하지만 테스트 컨테이너를 실행할때 위와 같은 코드는 보일러 플레이트 코드(자주 반복되는 코드)입니다. 

 

위와 같은 @DynamicPropertySource 애노테이션이 적용된 코드를 제거하기 위해서 @ServiceConnection 애노테이션을 사용할 수 있습니다. 해당 애노테이션을 사용하기 위해서는 다음과 같은 의존성이 필요합니다.

 

testImplementation 'org.springframework.boot:spring-boot-testcontainers'

 

또한 MySQL 테스트 컨테이너를 실행하기 위해서 다음과 같은 의존성을 추가합니다.

testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:mongodb'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.testcontainers:junit-jupiter'

 

의존성을 추가한 다음에 테스트 컨테이너를 실행하기 위한 클래스를 구현합니다.

@ActiveProfiles("test")
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class ServiceConnectionIntegrationTest {

	@Container
	@ServiceConnection
	private static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer("mysql:8.0.33")
		.withUsername("admin")
		.withPassword("password1234!")
		.withDatabaseName("testdb");
}

@Container 애노테이션은 "org.testcontainers.junit.jupiter" 라이브러리에 있는 애노테이션으로써 해당 애노테이션을 적용하면 테스트 시작 및 종료시에 컨테이너를 시작하고 종료시키는 기능을 수행합니다.

@ServiceConnection 애노테이션은 spring-boot-testcontainers 라이브러리에 있는 애노테이션으로써 해당 애노테이션을 적용하고 테스트 실행시 테스트 컨테이너에 필요한 프로퍼티들을 자동으로 설정해주는 기능을 수행합니다.

 

위 테스트 클래스를 상속받고 테스트를 실행하기 위해서 예제 코드를 준비합니다. 우선 예제에 사용된 의존성들은 다음과 같습니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // mysql
    implementation 'com.mysql:mysql-connector-j'

    // dev tools
    implementation 'org.springframework.boot:spring-boot-devtools'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // test containers
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:testcontainers'
    testImplementation 'org.testcontainers:mysql'
    testImplementation 'org.testcontainers:junit-jupiter'
    // rest assured
    testImplementation 'io.rest-assured:rest-assured'
}

 

테스트 실행시 JPA의 엔티티 클래스를 다시 생성하기 위해서 application-test.yml 파일의 내용은 다음과 같습니다.

spring:
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
    defer-datasource-initialization: true
    show-sql: true

 

엔티티 및 컨트롤러, 저장소의 코드는 다음과 같습니다.

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Member {
	@Id
	@GeneratedValue
	private Long id;

	private String name;
}

 

@RestController
@RequiredArgsConstructor
public class MemberRestController {
	private final MemberRepository memberRepository;

	@GetMapping("/members")
	public List<Member> members() {
		return memberRepository.findAll();
	}
}

 

public interface MemberRepository extends JpaRepository<Member, Long> {
}

 

ServiceConnectionIntegrationTest를 상속받은 테스트 클래스는 다음과 같이 구현합니다.

public class MemberIntegrationTest extends ServiceConnectionIntegrationTest{

	@Autowired
	private MemberRepository repository;

	@Test
	void findAllTest(){
	    // given
		repository.saveAll(
			List.of(
				new Member(null, "Frodo"),
				new Member(null, "Samwise")
			)
		);
	    // when & then
		when().get("/members")
			.then().statusCode(200)
			.and().body("name", hasItems("Frodo", "Samwise"));
	}
}

 

테스트 실행 결과는 다음과 같습니다. 테스트 실행 결과를 보면 테스트를 통과하였고 콘솔창에서 컨테이너가 생성된 것을 볼 수 있습니다. 그리고 이러한 테스트 컨테이너는 테스트가 종료되면 테스트 컨테이너는 같이 종료됩니다.

 

위와 같은 예제를 통하여 우리는 동적 프로퍼티를 별도로 정의하지 않고도 @Container와 @ServiceConnection 애노테이션만을 추가하여 테스트 컨테이너를 실행시킬수 있었습니다. 또한 테스트 컨테이너 자체를 실행시킴으로써 테스트 실행전에 별도의 MySQL 데이터베이스를 설치하거나 도커를 이용하여 사전에 MySQL 컨테이너를 실행시킨 상태에서 시작하지 않아도 됩니다.

 

2. 로컬 개발을 위한 테스트 컨테이너 지원

이전 장에서는 테스트 실행시 MySQL 컨테이너를 실행하여 테스트를 할 수 있었습니다. 하지만 테스트를 실행하는 경우가 아닌 로컬 개발 환경에서 스프링 서버를 실행하는 경우에는 MySQL 컨테이너는 실행되지 않습니다. 이러한 문제를 해결하기 위해서 로컬 개발 환경에 테스트 컨테이너를 통합시킬 수 있습니다.

 

로컬 개발을 위한 테스트 컨테이너를 지원하기 위해서는 @TestConfiguration 애노테이션이 추가된 설정 클래스를 구현해야 합니다. 다음 코드는 MySQL 테스트 컨테이너를 스프링 빈으로써 정의하는 코드입니다.

public class LocalDevApplication {

	public static void main(String[] args) {
		SpringApplication.from(Application::main)
			.with(LocalDevTestcontainersConfig.class)
			.run(args);
	}

	@TestConfiguration(proxyBeanMethods = false)
	static class LocalDevTestcontainersConfig{

		@Bean
		@ServiceConnection
		public MySQLContainer mySQLContainer() {
			return new MySQLContainer("mysql:8.0.33")
				.withUsername("admin")
				.withPassword("password1234!")
				.withDatabaseName("testdb");
		}
	}
}

 

@TestConfiguration

  • 해당 클래스에 정의된 스프링 빈들은 테스트를 수행할때만 사용됩니다.
  • proxyBeanMethods : 스프링 빈 메서드를 호출할때마다 싱글톤 객체를 반환할지 매번 다른 객체를 생성하여 반환할지 선택하는 옵션입니다. 기본값은 true이며, true인 경우 싱글톤 객체를 반환합니다. 반대로 false인 경우에는 매번 다른 객체를 생서하여 반환합니다.
  • 해당 빈 메서드를 프록시로 감싸지 않고 직접 호출하기 위해서 false로 설정합니다.

모든 테스트 컨테이너 의존성들은 테스트 스코프와 함께 가져오기 때문에 LocalDevApplication은 테스트 패키지에서 애플리케이션을 시작해야 합니다. 

 

LocalDevApplication을 시작하기 전에 MemberRestController에 다음과 같이 회원을 저장하는 API를 추가합니다.

@RestController
@RequiredArgsConstructor
public class MemberRestController {
	private final MemberRepository memberRepository;

    // ...

	@PostMapping("/members")
	public String crateMember(@RequestBody MemberCreateRequest request){
		memberRepository.save(new Member(null, request.getName()));
		return "OK";
	}
}

 

@Getter
public class MemberCreateRequest {
	private String name;
}

 

application.yml 파일은 다음과 같이 설정합니다.

spring:
  profiles:
    default: local

 

application-local.yml

spring:
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
    defer-datasource-initialization: true
    show-sql: true

 

위와 같이 구현한 다음에 LocalDevApplication을 실행시킵니다. 그런 다음에 다음과 같이 HTTP 요청합니다.

POST http://localhost:8080/members
Content-Type: application/json

{
  "name": "bob"
}

 

응답 결과는 다음과 같습니다.

 

데이터베이스에 회원이 저장되었는지 확인합니다.

실행 결과를 보면 정상적으로 테스트 컨테이너 안에 데이터베이스에 회원이 저장된 것을 확인할 수 있습니다.

 

위와 같은 예제를 통하여 우리는 로컬 개발할때도 테스트 컨테이너를 통합시킬수 있었습니다.

 

3. Devtools 및 @RestartScope와의 통합

로컬 개발을 하는 동안 종종 애플리케이션을 재시작하는 경우 모든 컨테이너가 매번 재시작된다는 단점이 있습니다. 이는 결과적으로 startup이 잠재적으로 느려질 수 있고, 테스트 데이터도 없어집니다. 

 

위와 같은 문제를 해결하기 위해서 spring-boot-devtools를 이용한 Testcontainers Integration을 활용하여 애플리케이션이 다시 부팅되어도 컨테이너를 유지할 수 있도록 합니다.

 

다음과 같이 spring-boot-devtools 의존성을 추가합니다.

implementation 'org.springframework.boot:spring-boot-devtools'

 

LocalDevTestcontainersConfig 클래스에서 MySQL 스프링 빈 메서드의 애노테이션 부분에 @RestartScope 애노테이션을 추가합니다.

@TestConfiguration(proxyBeanMethods = false)
static class LocalDevTestcontainersConfig{

    @Bean
    @ServiceConnection
    @RestartScope
    public MySQLContainer mySQLContainer() {
        return new MySQLContainer("mysql:8.0.33")
            .withUsername("admin")
            .withPassword("password1234!")
            .withDatabaseName("testdb");
    }
}

 

@RestartScope

해당 애노테이션을 사용하면 각 요청이나 특정 이벤트마다 빈을 초기화할 수 있습니다. 해당 애노테이션을 추가함으로써 데이터베이스 컨테이너 스프링 빈은 스프링 서버가 재시작되더라도 데이터를 유지할 수 있도록 합니다. 이는 @RestartScope가 스프링 빈의 상태를 초기화하는 것이 아니라 빈의 인스턴스 생성을 관리한다는 점을 의미합니다. 즉, 스프링 서버 재시작시 MySQLContainer 스프링 빈은 새로 생성되지만, 해당 데이터베이스 컨테이너는 종료되지 않고 상태가 유지됩니다.

 

스프링 서버를 실행한 상태에서 이전 예제와 같이 회원 "bob"을 추가합니다.

POST http://localhost:8080/members
Content-Type: application/json

{
  "name": "bob"
}

 

 

그 다음에 회원을 저장하는 API 경로를 "/api/members"로 변경합니다.

@PostMapping("/api/members")
public String crateMember(@RequestBody MemberCreateRequest request){
    memberRepository.save(new Member(null, request.getName()));
    return "OK";
}

 

그런 다음에 인텔리제이 기준 build -> Recompile 버튼(Recompile 'MemberRestController.java'을 클릭합니다.

 

스프링 서버가 재시작하였꼬 데이터베이스 컨테이너가 데이터를 유지하는지 확인합니다.

 

주의
application-local.yml 파일에서 spring.jpa.hibernate.ddl-auto=create인 경우 서버를 재시작할때마다 테이블의 데이터가 제거됩니다. 이러한 경우 update로 변경하여 수행할 수 있습니다.

 

References

https://www.baeldung.com/spring-boot-built-in-testcontainers
https://mangkyu.tistory.com/234
https://www.baeldung.com/spring-boot-redis-testcontainers