💡 예능적인 발상일 뿐, 실제로 시도하기엔 별로 좋은 방법이 아니라고 생각합니다. 아이디어만이라도 주워가실 분들은 읽어보세요!
📕 목차
1. Introduction
2. Test Builder Pattern for Domain Rule
3. Conclusion
1. Introdution
📌 What's the problem?
모든 코드는 깃헙에서 확인 가능합니다.
구현하고 있던 Usecase는 "채팅방에 메시지가 전송되었을 때, offline 혹은 백그라운드, 다른 뷰를 보고 있던 사용자에게 채팅 메시지를 푸시 알림으로 보여준다"였다.
offline인 사용자의 경우 웹 소켓이 연결된 서버가 없기 때문에, 반드시 relay 서버에서 채팅 큐를 관찰해야만 하는 상황.
푸시 알림을 전송할 비즈니스 규칙은 본질적 복잡성(essential complexity)에 의해 애플리케이션 모듈 서비스에서 구현하는 것은 적절치 않다고 판단했다.
따라서 "똑똑한 도구, 멍청한 사용자" 규칙을 준수하기 위해서, 비즈니스 규칙을 도메인 모듈 내의 서비스에 캡슐화 하였는데 설계는 다음과 같이 구상했었다.
- Socket 애플리케이션에서 Message Queue에 채팅 메시지를 삽입하면,
- Socket Relay 애플리케이션에서 언제나 queue에서 값을 꺼내본 후,
- "적절한 사용자들"에게 푸시 알림을 전송한다는 플로우를 갖는다.
문제는 이 적절한 사용자들을 판단하는 기준에 있었다.
이를 결정하는 비즈니스 규칙을 ChatNotificationCoordinatorService(이름 구린 건 저도 알고 있습니당..)에서 처리하고 있는데, 의존하고 있는 서비스만 벌써 4개나 된다.
당연히 복잡한 비즈니스 규칙에 의해, 단위 테스트를 하는 것도 쉽지 않았는데 첫 번째 결과물이 다음과 같았다.
고작 전송자, 수신자 각각 1개 데이터를 mock하기 위한 라인 수가 한 화면에 들어오지도 않는 문제가 발생했다.
이전까지는 위 방식으로 처리했었지만, 이번엔 그 정도가 너무 과했다.
따라서 이를 개선해볼 수 있는 방법이 있을 지에 대해 고민해보기 시작했다.
그 전에 테스트 케이스 작성을 용이하게 하는 두 가지 방법을 알아보자.
📌 Fixture
Test Fixture는 테스트를 수행하기 전에 필요한 상태나 환경을 설정하는 것이다.
pre-condition에 해당하는 조건들의 일관성을 보장하면서, 반복적인 테스트 코드를 축약하는 방법인데 예를 들어 다음과 같이 사용할 수 있다.
public enum UserFixture {
GENERAL_USER(1L, "user", "사용자", "user@test.com", UserRole.USER),
ADMIN_USER(2L, "admin", "관리자", "admin@test.com", UserRole.ADMIN);
private final Long id;
private final String username;
private final String name;
private final String email;
private final UserRole role;
UserFixture(Long id, String username, String name, String email, UserRole role) {
this.id = id;
this.username = username;
this.name = name;
this.email = email;
this.role = role;
}
public User toEntity() {
return User.builder()
.id(id)
.username(username)
.name(name)
.email(email)
.role(role)
.build();
}
}
void testWithFixture() {
// given
User user = UserFixture.GENERAL_USER.toEntity();
// when & then
assertThat(user.getRole()).isEqualTo(UserRole.USER);
}
User Entity 처럼 자주 생성되어야 하는 데이터 셋의 경우, 매우 손쉽고 간편하게 생성할 수 있는 편의성을 제공해준다.
TestFixture 사용을 도와주는 라이브러리도 있다고 하니, 관심있으면 읽어봐도 좋을 듯.
📌 Test Builder Pattern
테스트 빌더 패턴은 Fixture로만 표현하기엔 객체 생성 로직이 복잡한 경우에 사용하면 좋다.
public class UserTestBuilder {
private Long id = 1L;
private String username = "defaultUser";
private String name = "Default User";
private NotifySetting notifySetting = NotifySetting.of(true, true, true);
private List<DeviceToken> deviceTokens = new ArrayList<>();
public static UserTestBuilder aUser() {
return new UserTestBuilder();
}
public UserTestBuilder withId(Long id) {
this.id = id;
return this;
}
public UserTestBuilder withName(String name) {
this.name = name;
return this;
}
public UserTestBuilder withNotificationsDisabled() {
this.notifySetting = NotifySetting.of(false, false, false);
return this;
}
public UserTestBuilder withDeviceToken(String token, String deviceId) {
this.deviceTokens.add(DeviceToken.of(token, deviceId, "Device", null));
return this;
}
public User build() {
User user = User.builder()
.id(id)
.username(username)
.name(name)
.notifySetting(notifySetting)
.build();
deviceTokens.forEach(token -> ReflectionTestUtils.setField(token, "user", user));
return user;
}
}
@Test
void testWithBuilder() {
// given
User user = UserTestBuilder.aUser()
.withId(5L)
.withName("Test User")
.withNotificationsDisabled()
.withDeviceToken("token1", "device1")
.withDeviceToken("token2", "device2")
.build();
// when & then
assertThat(user.getNotifySetting().isChatNotify()).isFalse();
assertThat(user.getDeviceTokens()).hasSize(2);
}
객체 생성의 복잡성을 외부로 부터 감추고, 개발자가 테스트 자체에 보다 집중할 수 있도록 돕는다.
실제로 사용해본 건 이번이 처음이지만, 여러 객체 상태를 설정해야 하는 케이스에 보다 유용하다고 생각한다.
📌 Quick
⚠️ 자바 테스트 도구가 아닙니다! 뒤의 아이디어 파트에 이어지는 내용이라 추가했을 뿐입니다.
iOS의 테스트 도구를 공부할 때, Quick, Nimble 이란 라이브러리의 존재를 처음 알게 되었었다.
순전히 iOS에선 어떻게 테스트를 하는 지 공부해서 알아본 내용이었지만, BDD Test를 지원하기 위한 Quick이란 라이브러리가 상당히 재미있는 방법을 제공하고 있었다.
단순히 given - when - then 한 블록으로 끝내는 것이 아니라, 위 패턴을 반복하여 최종적인 결과까지 테스트를 검증한다.
아무래도 클라이언트 애플리케이션 특성 상, 하나의 액션으로 시나리오가 종료되는 경우가 거의 없기 때문이라 생각한다.
이걸 갑자기 뜬금없이 왜 다루었냐구요??
이게 이번 예능 코드의 핵심 아이디어로 작용했기 때문입니다. 🤗
2. Test Builder Pattern for Domain Rule
📌 Idea
Fixture나 TestBuilderPattern을 도입한다고 해서 상황이 나아지는 건 없었다.
왜냐하면, 위 방법들은 데이터 셋의 일관성을 위한 방법이지, 서비스 mocking을 위한 방법은 아니기 때문이다.
현재 테스트 코드의 복잡성을 유발하는 가장 큰 요인은 서비스 객체 mock 설정이므로, 다른 방법을 고민해봐야 했다.
그러다 한 가지 기발한 아이디어가 떠올랐는데, "Test Builder Pattern으로 도메인 규칙을 표현하는 건 어떨까?"였다.
예를 들어, iOS 라이브러리인 Quick을 보면 given 블럭에 여러 사전 조건들을 설정해두고 있다.
이 라이브러리의 특징은
- 계층적인 테스트 구조
- 각 계층별로 독립된 사전 조건 설정
- 명확한 테스트 시나리오 구분
이러한 특징에서 착안하여, '우리도 테스트 조건을 계층적으로 설정할 수 있지 않을까?'란 호기심이 생겼다.
givenSender(1L)
.withNotifyEnabled() // 1단계: 알림 설정
.withStatus(...) // 2단계: 상태 설정
.withDeviceTokens(...) // 3단계: 디바이스 설정
그리고 각 계층이 독립적으로 설정되면서도, 전체적으로는 하나의 시나리오를 구성하도록 만드는 것이다.
ChatNotificationTestFlow.init(...)
.givenSender(...) // Sender 시나리오
.withNotifyEnabled()
.and()
.givenRecipient(...) // Recipient 시나리오
.withNotifyEnabled()
.withStatus(...)
.and()
.whenMocking(); // 최종 설정
이러한 방식은 복잡한 도메인 규칙을 테스트하기에 상당히 적합하다고 생각했는데, 이유는 다음과 같았다.
- 여러 계층의 필터링 조건으로부터 독립적으로 사전 조건을 설정 (개발자가 검증 순서를 고려하지 않고, 데이터 셋에만 집중하도록 만든다)
- 각 조건이 다음 단계 모킹 여부를 스스로 결정한다.
결과적으로 Quick의 계층적 테스트 구성이라는 전략을 이용해, 계층적 도메인 규칙 테스트 구현의 영감으로 작용하였다.
생각해보라.
위와 같이, pre-condition 설정만 완료하면 모든 테스트 사전 조건을 설정해주는 컴포넌트라니.
이 제정신 나간 아이디어를 직접 구현해보지 않을 이유가 존재하지 않았다. (이때까지만 해도)
📌 Analysis
이를 구현하기 전에 도메인 규칙을 철저하게 파악할 필요가 있었다.
왜냐하면, mockito 라이브러리가 제공해주는 BDDMockito는 불필요한 mocking 혹은 잘못된 mocking에 대해 상당히 까다롭기 때문이다.
따라서, 사전 조건들의 상태에 따라 어디까지 mocking을 할 지 판단하려면, 테스트 대상의 비즈니스 규칙을 잘 고려해야 한다.
플로우 이해를 돕기 위한 다이어그램을 그려봤는데, 이렇게 생겨먹었다...
/**
* 채팅방에 참여 중인 사용자들 중에서 푸시 알림을 받아야 하는 사용자들을 판별합니다.
* <pre>
* [판별 기준]
* - 전송자는 푸시 알림을 받지 않습니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅방 리스트 뷰를 보고 있지 않는 사용자들만 필터링합니다.
* - 사용자 세션 중 하나라도 해당 채팅방 뷰를 보고 있는 경우, 해당 사용자의 전체 세션을 제외합니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅 알림을 받지 않는 사용자들은 제외합니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅방의 알림을 받지 않는 사용자들은 제외합니다.
* </pre>
*
* @param senderId Long 전송자 아이디. Must not be null.
* @param chatRoomId Long 채팅방 아이디. Must not be null.
* @return {@link ChatPushNotificationContext} 전송자와 푸시 알림을 받아야 하는 사용자들의 정보를 담은 컨텍스트
* @throws IllegalArgumentException 전송자 정보를 찾을 수 없을 때 발생합니다.
*/
@Transactional(readOnly = true)
public ChatPushNotificationContext determineRecipients(Long senderId, Long chatRoomId) {
User sender = userService.readUser(senderId).orElseThrow(() -> new IllegalArgumentException("전송자 정보를 찾을 수 없습니다."));
Map<Long, Set<UserSession>> participants = getUserSessionGroupByUserId(senderId, chatRoomId);
Set<UserSession> targets = filterNotificationEnabledUserSessions(participants, chatRoomId);
List<String> deviceTokens = getDeviceTokens(targets);
return ChatPushNotificationContext.of(sender.getName(), sender.getProfileImageUrl(), deviceTokens);
}
ChatNotificationCoordinatorService의 유일하게 개방된 메서드는 위와 같은 스펙을 가지고 있다.
하지만 판별 기준은 최종 결과에 대한 내용일 뿐이고, 세부적인 필터링은 private 메서드에 의해 수행되고 있으므로,
각각의 메서드들의 순서 규칙에 따라 분석해봐야 한다.
1️⃣ 사용자 세션 기반 1차 필터링
/**
* <pre>
* [STEP]
* 1. 채팅방에 참여 중인 사용자 세션들을 가져옴 (사용자 별로 여러 세션이 존재할 수 있음)
* 2. 사용자 세션 중에서 전송자는 제외하고, 채팅방에 참여 중 혹은 채팅방 리스트 뷰를 보고 있지 않은 사용자들만 필터링
* 3. 사용자 세션을 사용자 아이디 별로 그룹핑
* 4. 사용자 세션 중 하나라도 해당 채팅방에 참여 중인 경우, 해당 사용자의 전체 세션 제외
* </pre>
*
* @return 사용자 아이디 별로 사용자 세션들을 그룹핑한 맵
*/
private Map<Long, Set<UserSession>> getUserSessionGroupByUserId(Long senderId, Long chatRoomId) {
Set<Long> userIds = chatMemberService.readUserIdsByChatRoomId(chatRoomId);
List<Map<String, UserSession>> userSessions = userIds.stream()
.filter(userId -> !userId.equals(senderId))
.map(userSessionService::readAll)
.toList();
Map<Long, Set<UserSession>> sessions = userSessions.stream()
.flatMap(userSessionMap -> userSessionMap.entrySet().stream())
.filter(entry -> isTargetStatus(entry, chatRoomId))
.collect(Collectors.groupingBy(entry -> entry.getValue().getUserId(), Collectors.mapping(Map.Entry::getValue, Collectors.toSet())));
sessions.entrySet().removeIf(entry -> entry.getValue().stream().anyMatch(userSession -> isExistsViewingChatRoom(Map.entry(entry.getKey(), userSession), chatRoomId)));
return sessions;
}
/**
* 사용자 세션의 상태가 푸시 알림을 받아야 하는 상태인지 판별합니다.
*
* @return '채팅방 리스트 뷰'를 보고 있지 않은 경우 false를 반환합니다.
*/
private boolean isTargetStatus(Map.Entry<String, UserSession> entry, Long chatRoomId) {
return !(UserStatus.ACTIVE_CHAT_ROOM_LIST.equals(entry.getValue().getStatus()));
}
/**
* chatRoomId에 해당하는 채팅방을 보고 있는 사용자 세션이 존재하는지 판별합니다.
*/
private boolean isExistsViewingChatRoom(Map.Entry<Long, UserSession> entry, Long chatRoomId) {
return UserStatus.ACTIVE_CHAT_ROOM.equals(entry.getValue().getStatus()) && chatRoomId.equals(entry.getValue().getCurrentChatRoomId());
}
- 채팅방에 가입한 사용자 아이디 리스트 조회
- 전송자, 수신자, 권한 여부와 상관없이 모든 id 조회
- mock: senderId를 포함한 모든 멤버 정보 포함
- 전송자 제외
- 전송자 세션은 애초에 조회하지 않음
- mock: userSessionService.readAll()에서 전송자 ID는 언제나 제외
- 세션 상태 기반 필터링
- 채팅방 리스트 뷰를 보고 있는 세션 제외
- mock: 각 세션의 상태에 따라 후속 모킹 여부 결정
- 채팅방 뷰 상태 확인
- 하나의 세션이라도 해당 채팅방을 보고 있다면, 해당 사용자의 모든 세션 제외
- mock: 채팅방을 보고 있는 사용자는 이후 모킹 불필요
2️⃣ 알림 설정 기반 2차 필터링
/**
* <pre>
* [STEP]
* 1. 사용자 아이디로 채팅 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
* 2. 사용자 아이디로 채팅방의 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
* 3. 사용자 아이디로 디바이스 토큰을 가져옴
* </pre>
*
* @return 푸시 알림을 받아야 하는 사용자 세션들
*/
private Set<UserSession> filterNotificationEnabledUserSessions(Map<Long, Set<UserSession>> participants, Long chatRoomId) {
return participants.entrySet().stream()
.filter(entry -> isChatNotifyEnabled(entry.getKey())) // N개 쿼리 발생
.filter(entry -> isChatRoomNotifyEnabled(entry.getKey(), chatRoomId)) // N개 쿼리 발생
.flatMap(entry -> entry.getValue().stream())
.collect(Collectors.toUnmodifiableSet());
}
private boolean isChatNotifyEnabled(Long userId) {
Optional<User> user = userService.readUser(userId);
return user.isPresent() && user.get().getNotifySetting().isChatNotify();
}
private boolean isChatRoomNotifyEnabled(Long userId, Long chatRoomId) {
Optional<ChatMember> chatMember = chatMemberService.readChatMember(userId, chatRoomId);
return chatMember.isPresent() && chatMember.get().isNotifyEnabled();
}
- 채팅 알림 설정 확인
- 전체 채팅 알림이 비활성화된 사용자 제외
- mock: 채팅 알림 비활성화 사용자는 userService.readUser() 까지만 모킹
- 채팅방별 알림 설정 확인
- 해당 채팅방의 알림이 비활성화된 사용자는 제외
- mock: chatMemberService.readChatMember() 까지만 모킹
3️⃣ 디바이스 토큰 수집
/**
* 사용자 세션들 중에서 기기별 활성화된 디바이스 토큰들을 가져옵니다.
*
* @return 활성화된 디바이스 토큰들
*/
private List<String> getDeviceTokens(Iterable<UserSession> targets) {
List<String> deviceTokens = new ArrayList<>();
for (UserSession target : targets) {
deviceTokenService.readAllByUserId(target.getUserId()).stream()
.filter(DeviceToken::isActivated)
.filter(deviceToken -> deviceToken.getDeviceId().equals(target.getDeviceId()))
.findFirst()
.map(DeviceToken::getToken)
.ifPresent(deviceTokens::add);
}
return deviceTokens;
}
- 활성화된 디바이스 토큰만을 수집
- 세션의 디바이스 ID와 일치하는 토큰만 수집
- mock: 이전 필터를 모두 통과한 세션에 대해서만 디바이스 토큰 모킹
✒️ 최종 모킹 전략 수립
1. 발신자 정보 반환은 언제나 모킹: userService.readUser(senderId)
2. 모든 채팅방 멤버 아이디 리스트 조회도 언제나 모킹: chatMemberService.readUserIdsByChatRoomId()
3. 사용자 세션 반환은 sender 제회하고 반환: userSessionService.readAll()
4. 전체 채팅 알림 설정이 비활성화된 사용자는 userService.readUser() 까지만 모킹
5. 해당 채팅방 알림 설정이 비활성화된 사용자는 chatMemberService.readMember() 까지만 모킹
6. deviceToken이 존재하는 사용자들에게 deviceTokenService.readAllByUserId() 모킹
📌 Design
처음 설계는 단순하기 그지 없었다.
우선 설정해야 할 데이터 셋은 발신자 정보와 수신자 정보 두 가지였는데, 두 데이터 모두 필요로 하는 Entity는 다음과 같았다.
- User: 사용자 정보
- ChatMember: 채팅방 가입 정보
- UserSession: 웹 소켓 서버에 연결된 사용자 세션 정보
- DeviceToken: 사용자의 디바이스 토큰 정보
문제는 위 구조처럼 마냥 단순하질 않았는데, 상당히 복잡한 시나리오들이 더 많았다.
- Device 정보를 등록하면, 이를 기반으로 UserSession 데이터를 생성한다. (쉽게 말해, 사용자 별로 여러 기기로 접속할 수 있음) 따라서, User를 기준으로 현재 상태(offline인지, 채팅방 뷰를 보고 있는지)를 설정하는 게 아니라, 세션 별로 설정할 수 있어야 한다.
- 연관 관계에 설정을 위해 User Entity는 언제나 먼저 생성되어야 한다. (withNotifyEnabled() 혹은 withNotifyDisabled()를 언제나 먼저 설정하도록 강제해야 한다. 그러나 이건 다른 방법으로 우회할 수 있었을 것 같다. 설계 미스)
위 구현 사항을 충족하려면, 인터페이스를 다음과 같이 정의할 수 있다.
/**
* 발신자의 상태를 설정하기 위한 빌더 클래스입니다.
*/
private class SenderBuilder {
public interface NotificationSettingStep {
/**
* 발신자의 채팅 알림을 활성화 상태로 설정합니다.
*
* @return {@link ConfigurationStep}
*/
ConfigurationStep withNotifyEnabled();
/**
* 발신자의 채팅 알림을 비활성화 상태로 설정합니다.
*
* @return {@link ConfigurationStep}
*/
ConfigurationStep withNotifyDisabled();
}
public interface ConfigurationStep {
/**
* 발신자의 채팅방 알림을 활성화 상태로 설정합니다.
*
* @return {@link ConfigurationStep}
*/
ConfigurationStep withChatRoomNotifyEnabled();
/**
* 발신자의 채팅방 알림을 비활성화 상태로 설정합니다.
*
* @return {@link ConfigurationStep}
*/
ConfigurationStep withChatRoomNotifyDisabled();
/**
* 발신자의 세션 상태를 설정합니다.
* 채팅방 뷰 상태를 설정하고 싶다면, {@link #withStatus(UserStatus, Long)} 메서드를 사용하세요.
*
* @param status 사용자 세션 상태
* @return {@link ConfigurationStep}
*/
ConfigurationStep withStatus(UserStatus status);
/**
* 발신자의 채팅방 뷰 세션 상태를 설정합니다.
*
* @param status 사용자 세션 상태
* @param chatRoomId 채팅방 ID
* @return {@link ConfigurationStep}
*/
ConfigurationStep withStatus(UserStatus status, Long chatRoomId);
/**
* 사용자가 속한 채팅방을 설정합니다.
*
* @param chatRoom 채팅방 정보
* @return ConfigurationStep
*/
ConfigurationStep inChatRoom(ChatRoom chatRoom);
/**
* 설정한 정보를 저장하고, 다음 설정을 위한 빌더 인스턴스를 반환합니다.
*
* @return {@link ChatNotificationTestFlow}
*/
ChatNotificationTestFlow and();
}
private class SenderBuilderImpl implements NotificationSettingStep, ConfigurationStep {
// ...
}
}
발신자는 언제나 처음 Session부터 필터링 되기 때문에, device token을 필요로 하지 않으므로 설정할 메서드를 제공해줄 이유도 없다.
반면, 수신자 상태를 결정하기 위한 메서드는 이를 필수로 정해주어야 한다.
/**
* 수신자의 상태를 설정하기 위한 빌더 클래스입니다.
*/
private class RecipientBuilder {
public interface NotificationSettingStep {
// 이전과 동일
}
public interface ConfigurationStep {
// 이전 내용 동일
/**
* 수신자의 디바이스 토큰을 설정합니다.
* 이 메서드는 기존 디바이스 토큰을 모두 제거하고 새로 설정합니다.
* <p>
* 반드시 #withNotifyEnabled() 혹은 #withNotifyDisabled() 메서드를 통해 알림 설정을 먼저 해야 합니다.
*
* @param deviceTokens 설정할 디바이스 토큰 정보 목록
* @return {@link RecipientBuilder}
*/
ConfigurationStep withDeviceTokens(List<DeviceTokenInfo> deviceTokens);
/**
* 수신자의 디바이스별 세션 상태를 설정합니다.
* <p>
* 예시:
* <pre>
* .withSessionStatuses(Map.of(
* "deviceId1", new SessionStatus(UserStatus.ACTIVE_CHAT_ROOM, chatRoom.getId()),
* "deviceId2", new SessionStatus(UserStatus.ACTIVE_APP, null)
* ))
* </pre>
*
* @param deviceStatuses 디바이스ID를 키로, 세션 상태 정보를 값으로 하는 Map
* @return {@link ConfigurationStep}
*/
ConfigurationStep withSessionStatuses(Map<String, SessionStatus> deviceStatuses);
}
private class RecipientBuilderImpl implements NotificationSettingStep, ConfigurationStep {
// ..
}
}
그리고 가장 기본이 되는 환경을 구축하기 위한 ChatNotificationTestFlow에서, 위 builder class들을 호출할 수 있도록 하는 메서드를 제공하면 된다.
@Slf4j
@TestComponent
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
class ChatNotificationTestFlow {
// ... 속성 정의
public static ChatNotificationTestFlow init(...) {
// Mock Service 주입
}
/**
* 테스트할 발신자를 설정합니다.
* 발신자의 상태는 반환된 SenderBuilder를 통해 추가로 구성할 수 있습니다.
*
* @param senderId 발신자 ID
* @return {@link SenderBuilder} 인스턴스
*/
public SenderBuilder.NotificationSettingStep givenSender(Long senderId) {
this.senderId = senderId;
return new SenderBuilder().new SenderBuilderImpl(this, senderId);
}
/**
* 테스트할 수신자를 설정합니다.
* 수신자의 상태는 반환된 RecipientBuilder를 통해 추가로 구성할 수 있습니다.
*
* @param recipientId 수신자 ID
* @return {@link RecipientBuilder} 인스턴스
*/
public RecipientBuilder.NotificationSettingStep givenRecipient(Long recipientId) {
this.recipientIds.add(recipientId);
return new RecipientBuilder().new RecipientBuilderImpl(this, recipientId);
}
/**
* 설정된 시나리오에 따라 모든 필요한 모킹을 수행합니다.
* 이 메서드는 시나리오 구성의 마지막 단계로 호출되어야 합니다.
*
* @return {@link ChatNotificationTestFlow}
*/
public ChatNotificationTestFlow whenMocking() {
// ...
}
}
Sender와 Recipient의 모킹 과정은 자세하게 설명하지 않을 생각이다.
지극히 내 도메인 규칙에 따른 설정들이라, 적어봐야 별로 의미가 없을 듯.
다만 중요한 것은 각 하위 Builder에 의해 설정된 값들을 상위 빌더 클래스인 ChatNotificationTestFlow의 속성으로 주입해주기만 하면 된다. (최종 mocking을 위해)
📌 Mocking
위에서 수립한 mocking 전략을 기반으로 ChatNotificationTestFlow의 whenMocking() 메서드를 구현해보자.
// 기본 모킹 설정 (언제나 수행)
// 발신자 정보 반환
given(userService.readUser(senderId)).willReturn(Optional.ofNullable(sender));
// 모든 수신자 ID 반환
given(chatMemberService.readUserIdsByChatRoomId(chatRoomId)).willReturn(recipientIds);
// 사용자별 세션 정보 반환
sessions.forEach((userId, userSessions) -> {
if (userId.equals(senderId)) {
return;
}
log.debug("User ID: {}, Sessions: {}", userId, userSessions);
given(userSessionService.readAll(userId)).willReturn(userSessions.stream()
.collect(Collectors.toMap(UserSession::getDeviceId, session -> session)));
});
여긴 딱히 고려할 내용도 없다.
- 가장 처음 senderId로 userService.readUser()를 호출할 때는 언제나 sender 정보를 반환하면 된다.
- 모든 수신자 ID를 반환하는 로직에서도 채팅방 참가자들 아이디를 반환하면 된다. (여기서 주의할 점은 senderId가 언제나 필터링되기 때문에 포함을 안 시켜도 에러가 발생하질 않는다. 그렇다고 빼먹으면 정확한 테스트 수행이 어려우니 잊어먹지 말자)
- 사용자별 세션 정보를 반환할 때는 (2)의 결과에서 senderId를 필터링하고 있기 때문에, 이를 제외하고 모든 사용자의 userId에 대해서 mocking 해주어야 한다.
recipients.forEach((userId, recipient) -> {
// 발신자는 제외
if (userId.equals(senderId)) {
return;
}
if (!isRequireMoking(userId)) {
return;
}
...
}
// 사용자 세션 중 하나라도 해당 채팅방을 보고 있거나, 모든 세션이 모킹 대상이 아닐 경우
private boolean isRequireMoking(Long userId) {
boolean flag = false;
for (UserSession session : sessions.get(userId)) {
if (UserStatus.ACTIVE_CHAT_ROOM.equals(session.getStatus()) && chatRoomId.equals(session.getCurrentChatRoomId())) {
flag = false;
break;
}
if (!UserStatus.ACTIVE_CHAT_ROOM_LIST.equals(session.getStatus())) {
flag = true;
}
}
return flag;
}
그리고 채팅 참여자들에 대해 mocking을 해야하는데, 여기가 참 사람을 미치게 만들었었다.
현재 모킹하는 부분은 filterNotificationEnabledUserSessions() 메서드 내부인데, 바로 직전 메서드에서 다음의 경우를 추가로 걸러내고 있기 때문이다.
- 세션이 채팅방 리스트 뷰를 보고 있는 경우 제외
- 임의의 사용자의 세션 중 하나라도 해당 채팅 뷰를 보고 있는 경우, 전체 세션을 제외한다.
- 예를 들어, iPhone으로 이미 채팅방 뷰를 보고 있다면, iPad로 push notification이 오지 않아야 한다.
이 부분을 TestBuilder에서 걸러내지 않는 바람에, 이후에 채팅방 알림 설정 여부 필터링 조건 mocking을 실행하게 되어, 불필요한 mocking 에러 로그가 발생하고 있었다.
이러한 케이스를 추가로 걸러내야 했기에, isRequireMocking() 메서드가 userId를 필터링하도록 만들었다.
- userSession 중 하나라도 해당 채팅 뷰를 보고 있는 경우가 있다면, 응답을 false로 결정하고 반복 중지
- 하나라도 Chat Room List 뷰를 보고 있지 않은 세션이 있다면, 응답을 true로 전환.
이 과정만 끝나면, 다음은 다시 순서대로 예외 처리를 해주면 끝난다.
- 전체 채팅 알림 설정이 비활성화된 사용자의 모든 세션은 userService.readUser()까지만 모킹
- 해당 채팅방 알림 설정이 비활성화된 사용자의 모든 세션은 chatMemberService.readMember()까지만 모킹
- 최종적으로 deviceToken이 존재하는 사용자들에 대해 전부 deviceTokenService.readAllByUserId()를 모킹
📌 최종 코드
솔직히 도중에 관두고 싶었는데, 마지막의 마지막에 성공하는 걸 보고 감격의 눈물을 흘렸다.
이것도 충분히 길다고 볼 수 있지만, 두 번째 테스트를 처음의 방식으로 진행했다면 상황은 더 심각해졌을 것이다.
3. Conclusion
📌 Pros and Cons
이번 아이디어를 실제로 구현해보고, 마지막에 실행이 되는 것까지 확인하면서 상당히 흡족했다.
그러나 흡족함과는 달리, "그래서 이 방법이 좋은가?"에 대해서는 상당히 회의적인 입장이다.
- 장점
- 테스트 코드의 가독성과 의도가 명확해짐
- 반복되고 복잡한 테스트 사전 조건 설정 코드를 모두 감춤
- 도메인 규칙 변경 시, 테스트 코드 수정 포인트가 명확함
- 단점
- 테스트 코드가 비즈니스 로직에 과하게 결합됨
- 테스트를 위한 Builder 자체의 유지보수 비용이 매우 큼 (테스트를 위한 코드를 유지/보수 해야 하는 모순적인 상황)
- 새로운 팀원의 진입 장벽이 매우 높아짐. (비즈니스 규칙을 상세히 꿰고 있지 않으면, 문제가 생겨도 고칠 수 없음)
- 테스트를 위한 개발이 되어버리는 함정
이 방법의 가장 큰 문제는 추후 비즈니스 규칙이 수정되어 Builder를 수정해야 할 일이 발생했을 때,
솔직히 나조차도 이걸 다시 이해하고 수정하는 데 얼마나 걸릴지 가늠이 안 간다는 점이 상당한 지분을 차지한다.
📌 Practicality
- 비용 대비 효과
- Builder 구현과 유지 보수 비용이 매우 커짐
- 테스트 코드 작성의 편의성 향상은 비교적 제한적 (가독성은 상당히 향상되었으나, 다소 더러워도 주석으로 표현했어도 될 일)
- 장기적으로 봤을 때, 오히려 부담이 될 수 있음.
- 팀 관점
- 팀원은 비즈니스 로직과 테스트 빌더 두 가지를 모두 이해해야 하는 학습 비용 상승
- 코드 리뷰 시, 테스트 빌더의 올바른 사용 여부까지도 리뷰어가 확인해주어야 함.
- 이는 결과적으로 팀의 생산성을 저하시킬 수도 있음.
- 유지보수성
- 비즈니스 규칙 변경 시, 테스트 코드와 빌더를 모두 수정해야 함.
- 심지어 메서드 호출 순서에 완전히 종속되어 있기에, 단순히 filter 로직을 조금만 옮겨도 전체 테스트가 실패하게 될 수도 있다.
- 빌더가 복잡해질 수록 수정 난이도 또한 증가.
- 실수로 잘못된 테스트를 작성할 가능성이 증가.
- 이를 위해, TestBuilder를 Test해야 하는 이상한 상황이 발생할 수도 있음.
- 비즈니스 규칙 변경 시, 테스트 코드와 빌더를 모두 수정해야 함.
📌 Improvement Directions
1️⃣ 더 단순하게 접근할 수는 없었을까?
- 최소한의 Fixture와 TestBuilder를 사용하는 방법
- 어찌보면, 전체가 아니라 service mocking에 대해서만 보다 간단하게 설정하는 전략을 구현했다면 어땠을까?
- 반복되는 코드들은 헬퍼 메서드를 사용하는 것이 나았을 수도 있을 것 같다.
예를 들어, 다음과 같이 helper 메서드를 사용했다면 어느정도 가독성도 확보하면서, 보다 단순한 구조를 만들 수도 있었을 것 같다.
물론, 모든 테스트마다 헬퍼 메서드를 추가로 만들 게 아니라면, 범용성에 대해 고려해볼 필요가 있다.
/**
* 더 단순한 접근 방식 예시
*/
class ChatNotificationTest {
@Test
void testNotificationEligibility() {
// given
ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L);
User sender = createUserWithNotification(1L, true);
User recipient = createUserWithNotification(2L, true);
// 헬퍼 메서드를 통한 세션 설정
setupUserSession(recipient.getId(), UserStatus.ACTIVE_APP);
setupChatMember(recipient, chatRoom, true); // 채팅방 알림 활성화
setupDeviceToken(recipient, "token1", "device1");
setupBasicMocking(sender, recipient, chatRoom);
// when
ChatPushNotificationContext context = service.determineRecipients(
sender.getId(), chatRoom.getId());
// then
assertThat(context.deviceTokens()).contains("token1");
}
// 헬퍼 메서드들
private User createUserWithNotification(Long id, boolean notifyEnabled) {
return UserFixture.GENERAL_USER.toUserWithCustomSetting(
id,
"user" + id,
"User " + id,
NotifySetting.of(notifyEnabled, true, true)
);
}
private void setupUserSession(Long userId, UserStatus status) {
UserSession session = UserSession.of(userId, "device1", "Device");
session.updateStatus(status, null);
given(userSessionService.readAll(userId))
.willReturn(Map.of("device1", session));
}
private void setupChatMember(User user, ChatRoom chatRoom, boolean notifyEnabled) {
ChatMember member = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER);
if (!notifyEnabled) {
member.notifyDisabled();
}
given(chatMemberService.readChatMember(user.getId(), chatRoom.getId()))
.willReturn(Optional.of(member));
}
private void setupDeviceToken(User user, String token, String deviceId) {
DeviceToken deviceToken = DeviceToken.of(token, deviceId, "Device", user);
given(deviceTokenService.readAllByUserId(user.getId()))
.willReturn(List.of(deviceToken));
}
private void setupBasicMocking(User sender, User recipient, ChatRoom chatRoom) {
given(userService.readUser(sender.getId())).willReturn(Optional.of(sender));
given(userService.readUser(recipient.getId())).willReturn(Optional.of(recipient));
given(chatMemberService.readUserIdsByChatRoomId(chatRoom.getId()))
.willReturn(new HashSet<>(List.of(sender.getId(), recipient.getId())));
}
}
2️⃣ 보다 적절한 추상화 수준을 지키는 방법은 없을까?
- 모든 상황을 커버하려다가 오히려 범용성이라곤 눈 씻고 찾아볼 수 없는 결과물을 낳았는데, 그 대신 자주 사용되는 패턴만 분석해서 추상화하는 것이 보다 적절했을 수 있다.
- 특수한 케이스는 각 테스트 케이스에서 직접 구현하되, 마찬가지로 헬퍼 메서드로 구분하는 게 나을 수도.
- 가장 중요한 것은 뭐가 됐건 팀원 모두가 이해하기 쉬운 수준을 유지하기 위해 고민해볼 필요가 있다.
📌 Concluding Suggestions
복잡한 도메인 로직의 테스트 가독성을 높이려는 시도가 잘못되었다고 생각하지는 않는다.
어차피 리뷰해줄 사람도 없고, 혼자서 이런저런 시도를 해볼 수 있다는 게 사이드 프로젝트의 장점이기 때문에 재밌는 경험에 후회하지 않는다.
다만, 이걸 협업에 반영할 것이냐 묻는다면 절대 그렇지 않을 것이다.
팀원들의 러닝 커브 및 위 방법의 유지 보수성을 따졌을 때, 가독성은 다소 떨어트릴지언정, 더 단순한 접근 방식이 오히려 효과적이었을 거라 보기 때문이다.
특히 위 방법은 자칫 "테스트를 위한 테스트 코드"를 작성하게 되는 함정이 발생할 수 있는 요인까지 존재한다.
어쩌면 실용적인 접근과 우아한 접근에 대한 관점 차이라고 볼 수도 있겠지만, 이번 방법은 물 위의 백조랑 비슷한 꼴이다.
겉으로는 우아하지만 물 밑으로는 열심히 발을 휘젓고 있는..ㅋㅋ
어떻게 하면 더 나은 방법으로 위 테스트를 개선할 수 있을까?
앞으로도 이 주제에 대해 다양한 시행착오를 통해서 답을 얻어나가야 할 것 같다.