[Spring] 내 테스트에만 stub 적용하기 😈
🐹 서론 🐹
요즘 🐹줍줍🐹의 백엔드 팀에서는 큰 기능을 도입하기 앞서 테스트 코드 리팩터링에 한참 열을 올리고 있습니다. 오늘은 이 테스트 리팩터링을 하다 겪은 일에 대해 포스팅하려 합니다.
이번 리팩터링을 진행하면서 모킹을 제거하는 부분이 있었는데요. 줍줍은 Slack이라는 외부 서비스에 크나큰 의존을 갖고 있습니다. Slack 라이브러리에 대한 의존성을 갖고 있었고 해당 라이브러리를 호출하는 부분은 모킹 처리를 해두었습니다. 인수 테스트를 뜯어 고치면서 생각한 것은 모킹 작업이 계속 반복되고 지루한 작업으로 차지하고 있었습니다. 그래서 이 모킹 작업을 계속 반복할 바에는 스텁용 객체를 하나 생성해서 사용하는 것이 훨씬 편하겠다고 판단하여 모킹을 제거하기 시작했습니다.
🐹 리팩터링 시작 🐹
1. 외부 라이브러리를 쓰는 로직 추상화
가장 먼저 한 일은 외부 라이브러리에 대한 로직들을 추상화하는 일이었습니다. 예제로 채널을 생성하는 ChannelCreateService의 기존 로직을 가져와보았습니다. 자세한 로직을 이해할 필요는 없지만 딱 봐도 로직의 대부분이 Slack 라이브러리에 의존하고 있습니다.
public Channel createChannel(final String channelSlackId) {
try {
// slack api를 호출하는 부분
Conversation conversation = slackClient
.conversationsInfo(request -> request.channel(channelSlackId))
.getChannel();
// slack의 response를 우리 도메인인 channel 형태로 변환
Channel channel = toChannel(conversation);
// channel을 저장하고 return
return channels.save(channel);
} catch (IOException | SlackApiException e) { // slack api 호출로 인해 발생할 수 있는 exception
throw new SlackApiCallException("conversationsInfo");
}
}
위 로직을 아래처럼 두 개로 분리했습니다.
- ChannelCreateService: Channel을 저장하는 로직을 가진 클래스
- SlackClient: Slack 라이브러리 호출하는 클래스
ChannelCreateService
public Channel createChannel(final String channelSlackId) {
Channel channel = slackClient.callChannel(channelSlackId);
return channels.save(channel);
}
SlackClient
@Component
public class SlackClient {
public Channel callChannel(final String channelSlackId) {
try {
Conversation conversation = methodsClient.conversationsInfo(
request -> request.channel(channelSlackId))
.getChannel();
return new Channel(conversation.getId(), conversation.getName());
} catch (IOException | SlackApiException e) {
throw new SlackApiCallException("conversationsInfo");
}
}
}
2. 추상화한 로직 인터페이스로 분리
좀 더 편리한 스텁 객체를 만들기 위해 SlackClient를 인터페이스로 만들었습니다. 기존의 SlackClient에 존재하던 로직은 ExternalClient이라는 인터페이스를 구현한 친구로 옮겨줬습니다.
ExternalClient
public interface ExternalClient {
Channel callChannel(String channelSlackId);
}
SlackClient
@Component
public class SlackClient implements ExternalClient {
public Channel callChannel(final String channelSlackId) {
try {
Conversation conversation = methodsClient.conversationsInfo(
request -> request.channel(channelSlackId))
.getChannel();
return new Channel(conversation.getId(), conversation.getName());
} catch (IOException | SlackApiException e) {
throw new SlackApiCallException("conversationsInfo");
}
}
}
3. 스텁용 빈 생성
이제 테스트에서 사용할 빈을 생성합니다.
public class FakeClient implements ExternalClient {
private List<Channel> channels = List.of( ... ); // 채널 데이터들 초기화
@Override
public Channel callChannel(final String channelSlackId) {
return channels.stream()
.filter(it -> it.sameSlackId(channelSlackId))
.findAny()
.orElseThrow(() -> new SlackApiCallException("test-callChannel"));
}
}
4. 원하는 테스트에 적용
이제 3에서 만들었던 스텁용 빈에 @Component를 붙여서 마법같이 되면 정말 좋겠지만! SlackClient 타입의 bean이 2개나 있다, 어떤걸 @Autowired할지 모르겠다며 스프링 부트가 어쩔줄 몰라합니다. 이때 @Primary나 @Qualifier를 이용하면 쉽게 해결할 수 있습니다.
@Primary
@Component
public class FakeClient implements ExternalClient { ... }
이 단계에서 문제가 하나 발생했습니다. 저희 팀에서는 테스트 리팩터링을 계층별로 나눠서 진행하고 있었습니다. 저는 인수 테스트, 다른 팀원은 컨트롤러 테스트, 또 다른 팀원은 서비스 테스트를 맡고 있기 때문에 다른 계층의 테스트에는 영향이 가면 안됩니다. 이 말은 즉 제가 담당하고 있는 인수 테스트에서만 SlackClientTest를 사용해서 모킹을 제거하고 다른 팀원들이 작업하는 컨트롤러 테스트와 서비스 테스트에는 모킹 상태를 그대로 두어야한다는 의미입니다. 그래서 이용하게 된 것이 @TestConfiguration과 @Import 입니다.
먼저 @TestConfiguration을 이용해 3에서 만들었던 클래스를 빈으로 등록해주는 config 파일을 작성합니다.
@TestConfiguration
public class TestConfig {
@Primary
@Bean
public ExternalClient externalClient() {
return new FakeClient();
}
}
이제 제가 작업중인 AccepatenceTest에 @Import를 적용해 위 config 파일을 적용해주기만 하면 됩니다.
@Import(value = TestConfig.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest { ... }
🐹 추가 학습 🐹
4번의 TestConfig에 @Configuration을 달면 모든 테스트에 해당 config 파일이 적용됩니다. 근데 왜 @TestConfiguration을 이용하는건 @Import한 테스트에만 동작할까요? Spring 문서에 의하면 @TestConfiguration은 Spring이 기본적으로 스캔하는 Configuration 외에 추가 설정이 필요할 때 사용하는 것 같습니다.
If you want to customize the primary configuration, you can use a nested @TestConfiguration
class. Unlike a nested @Configuration class, which would be used instead of your application’s primary configuration, a nested @TestConfiguration class is used in addition to your application’s primary configuration.
@TestConfiguration을 적용하기 위해서는 3가지 방법이 있습니다.
- @Import
- @ContextConfiguration의 classes 옵션
- @SpringbootTest의 classes 옵션
🐹 글을 마치며.. 🐹
테스트 리팩터링이 완전히 끝나면 모킹을 완전히 제거하고 전부 다 stub bean을 사용할 예정이긴 하지만 당장은 일부에만 적용하기 위해 위와 같은 과정을 거쳐왔습니다. 오늘 종일 삽질했지만 어떻게든 돼서 뿌듯하네요😄👍 도움을 주신 같은 우테코 크루인 써머와 이프에게 이 영광을 돌립니다✨
참고