일단 이 글을 작성하기 전에 알아야 할 부분이 있다.
테스트 하기 좋은 코드는 뭘까?
개인 사이드 프로젝트를 진행하면서 가장 어렵고도 고민이 많이 되는 부분이 바로 테스트에 대한 부분이다.
테스트는 프로그램의 안정성을 확보해주는 동시에 메서드 하나하나의 동작을 예상가능하게 만들어야 된다고 생각했다.
예를 들어서, 계산기의 plus 라는 메서드가 존재한다고 생각해보자.
public int plus(int a, int b) {
return a + b;
}
이런식의 메서드가 나올 것이다. 이것은 테스트 하기 아주 좋은 코드라고 생각된다.
이유는, 항상 동일한 입력에 대해서 동일한 출력을 반환한다. 1과 2가들어가면 항상 3이라는 결과가 100번 1000번 1만번을 해도 동일하다. 이러한 경우 메서드가 예측 가능하기 때문에 테스트의 신뢰성이 매우 높고, 어떤 환경이든 동일한 결과를 낼 것이다.
이러한 테스트는 프로그래머가 설계한 프로그램이 어떻게 흘러가는지 어떤 결과를 가져오는지 명확해지고 더 안정적인 프로그램을 설계할 수 있도록 도와준다.
테스트하기 좋은 코드
- 예측이 가능해야된다.
- 외부 상태와 독립적으로 관리되면 좋다.
- 단일 책임을 가지도록 설계한다.
- 실패 케이스가 명확해야된다.
이러한 생각을 가지고 FCM을 테스트하기 좋은 코드로 만들어보자.
FCM을 테스트 하기 좋은 코드로 만들기
일단 테스트가 예측이 가능하려면 Input과 Output이 들어갔을때 동일한 결과가 계속 나오도록 해야한다. 계산기 예제에서 설명했듯이 a+b = c 처럼 많은 시도를 해도 결과가 동일해야한다.
결과가 동일하려면 어떻게 할까
결과가 동일하려면 같은 값이 들어갔을때 똑같이 결과가 나와야한다.
그렇다면 계산기처럼 a,b 를 우리가 직접 객체로 만들어준다면 되지 않을까라고 생각했다.
input과 output을 정의하기
public interface NotificationService {
NotificationResult send(NotificationRequestDto<String> request) throws FirebaseMessagingException;
List<NotificationResult> sendMultiCast(NotificationRequestDto<List<String>> request) throws FirebaseMessagingException;
}
이렇게 설계 했을 경우 NotificationResult와 NotificationRequestDto처럼 우리가 원하는 값을 명시적으로 표시했기 때문에, input과 output이 동일한 결과로 나올 것이다. 테스트에 용이하다.
테스트하기 좋게 추상화 했으니 구현체를 만든다.
추상화를 바탕으로 객체를 만든다.
실제 비즈니스 로직을 구현하지 않고 테스트를 위한 가짜 객체를 만든다.
public class FakeNotificationService implements NotificationService {
private final List<NotificationRecord> sentNotifications = new ArrayList<>();
private boolean shouldFail = false;
@Override
public NotificationResult send(NotificationRequestDto<String> request) {
if (shouldFail) {
throw new MessagePushException(ErrorCode.MESSAGE_NOT_PUSHED);
}
String messageId = generateMessageId();
sentNotifications.add(new NotificationRecord(
request.getTitle(),
request.getBody(),
request.getTargetToken(),
messageId
));
return NotificationResult.success(messageId);
}
@Override
public List<NotificationResult> sendMultiCast(NotificationRequestDto<List<String>> request) {
if (shouldFail) {
throw new MessagePushException(ErrorCode.MESSAGE_NOT_PUSHED);
}
return request.getTargetToken().stream()
.map(token -> send(new NotificationRequestDto<>(
request.getTitle(),
request.getBody(),
token
)))
.toList();
}
public List<NotificationRecord> getSentNotifications() {
return new ArrayList<>(sentNotifications);
}
public void setShouldFail(boolean shouldFail) {
this.shouldFail = shouldFail;
}
private String generateMessageId() {
return "fake-message-" + UUID.randomUUID();
}
// 알림 기록을 위한 레코드
public record NotificationRecord(
String title,
String body,
String token,
String messageId
) {
}
}
코드가 난잡할 수 있으니 간단히 설명하자면, 메세지를 보내는 것을 fcm서버가 아니라 자바의 메모리를 사용해서 구현시킨 것이다. 알림이 성공적으로 보내지면 success 메서드를 실행시켜 성공을 반환하고 중간에 실패시 exception이 발생하도록, exception이 발생하면 application의@RestControllerAdvice에서 처리하도록 구현했다.
만약 실패 테스트를 하고싶다면 setShouldFail 메서드를 통해 테스트가 가능할 것이다.
class FcmNotificationServiceTest {
private FakeNotificationService notificationService = new FakeNotificationService();
@Test
void 단일_알림_전송_성공() {
// given
NotificationRequestDto<String> request = new NotificationRequestDto<>(
"test-fcm-token",
"테스트 제목",
"테스트 내용"
);
// when
NotificationResult result = notificationService.send(request);
// then
List<FakeNotificationService.NotificationRecord> notifications = notificationService.getSentNotifications();
assertThat(notifications).hasSize(1);
assertThat(notifications.get(0).title()).isEqualTo("테스트 제목");
assertThat(notifications.get(0).token()).isEqualTo("test-fcm-token");
}
@Test
void 알림_전송_실패() {
// given
notificationService.setShouldFail(true);
NotificationRequestDto<String> request = new NotificationRequestDto<>(
"token", "제목", "내용"
);
// when
assertThatThrownBy(() -> notificationService.send(request)).isInstanceOf(MessagePushException.class);
// then
assertThat(notificationService.getSentNotifications()).isEmpty();
}
}
이런식으로 테스트를 성공 테스트와 실패 테스트가 가능할 것이다.
마지막으로 실제 구현체를 만들기
테스트는 만들어 졌고 정형화된 인터페이스를 바탕으로 구현체를 만들어야한다.
아래는 내가 직접 구현한 구현체이다.
@Service
public class FcmNotificationService implements NotificationService {
private final FirebaseMessaging firebaseMessaging;
public FcmNotificationService(FirebaseMessaging firebaseMessaging) {
this.firebaseMessaging = firebaseMessaging;
}
@Override
public NotificationResult send(final NotificationRequestDto<String> request) throws FirebaseMessagingException {
final Notification notification = createNotification(request);
final Message message = Message.builder()
.setNotification(notification)
.setToken(request.getTargetToken())
.build();
final String messageId = firebaseMessaging.send(message);
return NotificationResult.success(messageId);
}
@Override
public List<NotificationResult> sendMultiCast(final NotificationRequestDto<List<String>> request) throws FirebaseMessagingException {
final MulticastMessage message = MulticastMessage.builder()
.addAllTokens(request.getTargetToken())
.setNotification(createNotification(request))
.build();
final BatchResponse response = firebaseMessaging.sendMulticast(message);
if (response.getFailureCount() > 0) {
throw new MessagePushException(ErrorCode.MESSAGE_NOT_PUSHED);
}
return response.getResponses().stream()
.map(sendResponse -> NotificationResult.success(sendResponse.getMessageId()))
.toList();
}
private Notification createNotification(final NotificationRequestDto<?> request) {
return Notification.builder()
.setTitle(request.getTitle())
.setBody(request.getBody())
.build();
}
}
이런식으로 구현해서 가짜 구현체가 아니라 실제로 비즈니스 로직에 맞게 구현된 구현체를 사용하여서 쓸 수 있게 된다.
테스트를 구현할때는 반드시 A -> B라는 명확한 결과물이 존재해야하고 그걸 가능하게 해주는게 가짜객체이다.
가짜객체는 실제 구현체가 의존하는 추상클래스를 가지고 있기 때문에 내부 로직은 다르더라도 결국에는 input 과 output이 같다 그러므로 안정적인 테스트 구현이 가능해지는 것이다.
'BackEnd' 카테고리의 다른 글
백엔드 면접 질문 정리해보기 - 1 (1) | 2025.01.24 |
---|---|
[Spring] FCM + 스프링 부트로 푸시 알림 구현 고민하기 (0) | 2025.01.07 |
[Spring] Filter에서 Exception을 관리하는 법 (0) | 2024.12.19 |
[Spring] @RestControllerAdvice를 통한 스프링에서의 예외처리 (1) | 2024.12.16 |
[DB] 효율적인 설계를 위해 어떤 SQL을 사용해야 할까? RDBMS vs NoSQL과 DB에서의 수직 확장(scale-up)과 수평 확장(scale-out) (0) | 2024.06.20 |