본문 바로가기
개발 이야기/Springboot

Testcontainers로 테스트 코드 만들기

by 농개 2024. 1. 7.
반응형

좋은 테스트 코드는 제품을 더욱 신뢰성 있고 견고하게 만듭니다.

주목할 점은 '좋은' 테스트 코드일 것입니다.

잘못 작성 된 테스트 코드는 되려 해로울 수 있습니다.

 

'좋은' 테스트 코드는 일반적으로 아래와 같은 특징을 가집니다.

  • 리팩터링 시, 테스트 코드는 수정되지 않음
  • 버그 수정 시, 테스트 코드는 수정되지 않음
  • 테스트 코드 내에 조건문, 순환문은 없음
  • 메서드 보다는 행위를 테스트
  • 딱히 실행할수 없는 상황이 아니라면 실제 의존성을 끊지 않도록 함

여기서 "딱히 실행할수 없는 상황이 아니라면 실제 의존성을 끊지 않도록 함"에 주목 해봅니다.

 

대부분 Springboot를 통해 Web Application 만들 때에는

데이터를 보관할 DB를 사용하게 될 것입니다.

 

DB는 Web Application 입장에서 외부 의존성이 될 것입니다.

하여 테스트 코드를 작성할 때 Mocking 기법을 이용하기도 합니다.

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    public void testSaveUserSuccess() {
        // Mocking and Stubbing
        doNothing().when(userRepository).save(any(User.class));

        User user = new User();
        user.setUsername("testUser");
        user.setEmail("test@example.com");

        String result = userService.saveUser(user);

        verify(userRepository).save(user);

        assertEquals("OK", result);
    }
}

- mock을 활용한 테스트 코드 예시

 

위 코드는 DB와 의존적인 UserRepository를 Mocking 하고

의도적으로 save 메서드 동작을 규정합니다.

 

물론 위 테스트 코드는 테스트로서 가치 있습니다.

DB 의존성을 제외하고 UserService.saveUser()를 테스트 할 수 있습니다.

 

하지만 Testcontainer를 사용하면

DB 의존성을 유지한 채로 테스트를 수행 할 수 있습니다.

Testcontainer는 Docker가 설치되어 있어야 합니다.

해당과정은 생략합니다.

 

아래는 Testcontainer를 활용하는 방법입니다.

테스트 범위는 아래와 같습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/member")
public class MemberController {

    private final MemberService memberService;

    @GetMapping("")
    public ApiResult<?> getMembers() {
        List<MemberDto> result = memberService.getMembers();
        return ApiResult.ok(result);
    }
}

- controller

 

@Service
@RequiredArgsConstructor
public class MemberService {

    private final TblMemberRepository memberRepository;

    public List<MemberDto> getMembers() {
        List<MemberDto> result = memberRepository.findAll()
            .stream()
            .map(MemberDto::new)
            .collect(Collectors.toList());
        return result;
    }
}

- service

 

@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "tbl_member")
public class TblMember {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, length = 100)
    private String email;

    @Column()
    private String category;

    public TblMember(MemberDto dto) {
        this.name = dto.getName();
        this.email = dto.getEmail();
        this.category = dto.getCategory();
    }
}

- entity

 

@Repository
public interface TblMemberRepository extends JpaRepository<TblMember, Long> {
}

- repository

 

자 이제 Testcontainer를 활용하여 테스트 코드를 작성해 봅시다.

 

1. build.gradle에 의존성 추가

...(중략)
/** testcontainers **/
testImplementation "org.testcontainers:testcontainers:1.17.6"
testImplementation "org.testcontainers:junit-jupiter:1.17.6"
testImplementation "org.testcontainers:mysql:1.17.6"

 

여기서는 DB로 mysql을 사용한다고 가정합니다.

그렇기 때문에 mysql 관련 의존성을 추가로 기입했습니다.

 

2. Testcontainer를 포함한 추상 클래스

테스트를 도메인 별로 추상화하기 위해 추상클래스를 만들고

Testcontainer를 static 하게 구성하고자 합니다.

 

아래와 같이 추상클래스를 하나 만들어 줍니다.

@SpringBootTest
@ContextConfiguration(initializers = IntegrationTest.ContainerPropertyInitializer.class)
public abstract class IntegrationTest {

    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0.28")
        .withDatabaseName("testdb")
        .withUsername("root")
        .withPassword("root")
        .withInitScript("testcontainers/mysql/init.sql")        // 스크립트 실행 path는 기본적으로 src/test/resources를 참조
        .waitingFor(Wait.forHttp("/"))                          // 가용가능 한지 기다렸다가
        .withReuse(true);                                       // 재사용

    @DynamicPropertySource
    public static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
    }

    @BeforeAll
    static void init() {
        if (!mySQLContainer.isRunning()) {
            mySQLContainer.start();
        }
    }

    @AfterAll
    static void close() {
        mySQLContainer.close();
    }

    static class ContainerPropertyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext context) {
            System.out.println("testcontainer logs: " + mySQLContainer.getLogs());
        }
    }
}
  • mySQLContainer는 static으로 선언해줍니다.
  • "mysql:8.0.28"라는 Docker image를 사용합니다.
  • 그리고 DB 연결 정보는 Docker image의 default 값인 'root'로 기입해줬습니다.
  • withInitScript를 통해 DB Table생성을 위한 sql 스크립트를 실행 할 수 있습니다.
  • @DynamicPropertySource를 통해 Datasource 빈 생성을 위한 설정값을 Testcontainer의 것으로 교체해줍니다.
    • @Testcontainer, @Container 어노테이션을 통해 만들때, Datasource 빈 생성이 실패함.
    • 하여 해당 어노테이션을 제거하고 수동으로 설정값을 바꿔 Datasource에 주입해줬습니다.
  • @BeforeAll을 통해 클래스 테스트 전에 Testcontainer를 running 상태로 만듭니다.
  • @AfterAll을 통해 close

 

만약 Testcontainer에서 발생하는 로그를 확인하고 싶다면

ApplicationContextInitializer<ConfigurableApplicationContext>를 추가 구현한 Initializer를 추가하면 됩니다.

 

3. 실제 테스트를 위한 클래스

이제 앞서 만든 추상 클래스를 상속 받아

실제 테스트 클래스를 만들어 봅시다.

@ExtendWith(MockitoExtension.class)
@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
class MemberControllerTest extends IntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void _01_멤버조회() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/member"))
            .andExpect(MockMvcResultMatchers.status().isOk());
    }
}

 

여기서는 MockMvc를 사용한 API 행위를 테스트 합니다.

 

위 테스트 코드를 실행하면

/api/v1/member API의 실제 행위를 테스트하게 됩니다.

 

 

반응형