회사에서 단위 테스트를 하다가, SE 라는 부서 (개발을 하기보단 SW 인프라, 설계, TC 지원 등의 업무를 하는 곳) 에서 테스트에 대한 지적(?) 을 많이 받았다. 사실 따지고 보면 관례적인 것 vs 이론적인 것 느낌이 좀 있긴 했는데, 관점의 차이가 확실히 존재하는 것 같았다. 이번엔 그게 어떤 부분이였는지 살펴보자.
Group 이라는 Entity, Member 라는 Entity 가 있고, GroupMember 라는 Entity 를 통해 N:N 관계를 맺고 있는 상황이다. 이 때, Group 의 필드 정보들을 수정하기 위한 요청을 보내주는 상황이다. Controller 는 생략하고, Business Logic 인 Service 부분을 살펴보자.
public Group updateGroup(GroupUpdateReqDto reqDto, Member curMember){
GroupMember groupMemberPs = groupMemberRepository.findByMemberAndGroupId(curMember.getId, reqDto.getGroupId());
if(!groupMemberPs.hasPermissionForUpdate()){
throw new RuntimeException("그룹에 대한 수정 권한이 없습니다 ");
}
// Group 에는 Category 가 있고, N:N 관계이다. (별로안중요)
List<Category> changingCategories = categoryService.generateCategoryList(reqDto.getCategoryNameValues());
// Group Domain Entity 단에서 직접 수정한다
groupMemberPs.getGroup().update(reqDto, changingCategories);
return groupMemberPs.getGroup();
}
* GroupUpdateReqDto - Update 를 하기 위한 정보들을 매핑해서 가지고 있는 객체
* groupMemberPs.hasPermissionForUpdate() - 현재 groupMember 의 Role 이 운영진인지 확인
* List<Category> - Group 에는 Category 가 연관관계를 가지고 있다.
1. 결과 기반 테스트 (Return Value Based)
이 포스트는 Unit Test 에 대한 게시물이다. 별 설명 필요 없이 바로 코드를 보자. 아닐 수도 있지만, 정말 많은 개발자들은 다음과 같은 Test Code 를 짤 것이라고 생각한다.
@Test
void updateGroup_shouldReturnUpdatedEntity_whenSuccessful(){
// given (요청을 수행해보기 위해 객체들을 직접 생성해서 준비한다)
Member testMember = new Member(1L, MEMBER_NAME, MEMBER_GENDER, MEMBER_EMAIL);
Group testGroup = new Group(1L, GROUP_NAME, GROUP_INFO, CATEGORY1, CATEGORY2, testMember);
GroupMember testGm = new GroupMember(1L, testGroup, testMember, Role.MANAGER);
Category category1 = new Category(1L, CATEGORY1);
Category category2 = new Category(2L, CATEGORY2);
// given (수정시키는 정보 입력)
GroupUpdateReqDto reqDto = new GroupUpdateReqDto(testGroup.getId(), UPDATE_NAME, UPDATE_INFO, ...);
// given - stub (Mocking 객체들의 행위 결정)
doReturn(testGm).when(groupMemberRepository).findByMemberAndGroupId(any(), any());
doReturn(List.of(category1, category2)).when(categoryService).generateCategoryList(any());
// when
Group updatedGroup = groupService.updateGroup(reqDto, testMember);
// then
assertThat(updatedGroup.getGroupId()).isEqualTo(1L);
assertThat(updatedGroup.getGroupName()).isEqaulTo(GROUP_NAME);
assertThat(updatedGroup.getGroupInfo()).isEqaulTo(GROUP_INFO);
}
참고로 위 방식은 내가 Test 하던 방식이다. Mock 라이브러리를 사용해서 간단히 객체를 생성할 수 있지만, getGroupName(), getGroupInfo() 나, 연관 객체들을 매번 mocking 해서 넣기가 어렵고, 실제 객체를 만드는 것과 큰 차이가 없다고 생각했다. 그리고 비즈니스 로직의 수행되는 모습들을 직접 보려면 실제 객체가 들어가서 동작하는 것이 중요하다고 생각했다.
뭐 BeforeEach 를 사용해서 더 짧다고 해도, 어쨌든 실제로 다음과 같은 단계를 밟아나가서 Test 하는 것은 매우 일반적인 순서이다.
1. GIVEN - Test 사전 정보 준비 / Test 함수 처리 준비 / Repository, Service 등 다른 Mock Class 들 Stubbing (Aggregation 주입)
2. WHEN - Test 대상 함수 수행
3. THEN - Assertions 로 원하는 결과 확인
위와 같이 Value 기반 테스트를 [결과 기반 테스트 (Return Value Based)] 라고 하며, 실제로 실무에서 많이 테스트 하는 방식일 것이다. 가장 일반적인 방법이기 때문이다. 또한, 내가 원하는 대로 비즈니스 로직이 동작하는지 검증하기 가장 쉬운 방법이기도 하다.
결과 기반을 Test 할 때 mock(Group.class) 와 같이 Mock Object 를 사용하면 실제 동작을 모두 진행시켜야 하기 때문에 해당 객체의 Get, Set 등을 모두 doReturn().when() 을 통해서 구현해야 한다. 따라서 위와 같이 실제 객체를 생성해서 사용하는 방법도 일반적으로 행해진다.
우선적으로 말하고 싶은건 위 테스트 방식이 틀린게 절대 아니라고 생각한다. 이 포스트에서는 테스트 관점의 차이를 말하고 싶은 것이다. 위에서 말한 SE 분들은 위 테스트를 [잘못되었다] 고 생각하는데, 현업과 이론에서 격차는 어쩔 수 없이 존재한다고 생각한다.
하지만 위 테스트 방식의 단점은 확실히 볼 수 있다.
1. Test 준비가 너무 오래걸린다 (GIVEN)
2. Test 대상 외 타 Class 를 배제하지 않았다
3. Assert 하는 대상이 결과 뿐이다
1. Test 준비가 너무 오래걸린다. 똑같은 함수에서 다른 상황을 Test 하려거든 똑같이 GIVEN 을 준비해야한다. @BeforeEach 를 사용한다고 해도 안하는게 아니고 중복된 작업을 묶었을 뿐이다. 위는 간단한 함수인데, 이렇게 되면 모든 Test 를 할 때마다 Test 를 준비하기 힘들어지고, GIVEN 이 점점 길어질 수 밖에 없다.
2. 가장 중요한 부분이다. Service의 update 함수가 대상인 Unit Test 인데, Test 대상 외 타 Class 를 배제하지 않았다.
- Member 필드가 변경이 되었다. 그러면 해당 테스트는 Fail 한다.
- Group.update() 을 수행하는 도중 update() 에 문제가 있었다. 그러면 위 테스트는 Fail 한다. 해당 함수가 대상이 아님에도, 위 테스트는 Fail 한다
3. Assert 하는 목적이 결과 뿐이다. 만약 내가 updateGroup 함수에 임의로 수정된 정보를 통한 Group 을 만들어서 반환했다고 해보자. 그리고 update() 는 주석처리했다. 억지의 상황을 만들어 보는 것이다. 이렇게 해도 위 Test 는 통과한다.
처음에는 억지라고 느껴질 수 있지만, 여기서 중요한건 return value 만을 assert 하면 "언제든 발견될 수 있었던 예외 상황이 피해갈 가능성이 조금이라도 존재한다"는 점이다. 저 TC가 예외를 잡지 못하는 상황이 존재한다는 것 자체가 강력한 반례이다.
2. 상호 작용 기반 테스트 (Interaction-Based)
그렇다면 이런 상황을 어떻게 개선할 수 있을까? 중요한 것은 Test 대상 함수를 명확히 하는 것이고, 이 함수의 목적을 정확히 하는것이다. 그리고 Test 대상 함수의 동작 외 모든 것은 절대 아무 관심 없음을 명확하게 하는것이다. 다음 Test 코드를살펴보도록하자.
@Test
void updateGroup_shouldProcessUpdate_whenCalled(){
// given
GroupUpdateReqDto mockReqDto = mock(GroupUpdateReqDto.class);
Group mockGroup = mock(Group.class);
GroupMember mockGroupMember = mock(GroupMember.class);
Member mockMember = mock(Member.class);
// given - stub
doReturn(true).when(mockGroupMember).hasPermissionForUpdate();
doReturn(mockGroupMember).when(groupMemberRepository).findByMemberAndGroupId(any(), any());
doReturn(new ArrayList<>()).when(categoryService).generateCategoryList(any());
doReturn(mockGroup).when(mockGroupMember).getGroup();
// when
groupService.updateGroup(mockReqDto, mockMember);
// then
verify(mockGroup, times(1)).update(any(), any());
}
위 Test 를 기준으로 아까 결과 기반 테스트에서의 문제점들을 다시 한번 생각해보자.
1. Test 준비가 너무 오래걸린다 -> 굳이 생성자 필드를 다 넣고, 연관관계 생각하고, 줄줄이 소세지로 생성하지 않고, 간단히 mockito 를 사용하여 생성하고 끝낼 수 있다 (상황에 따라 필요한 함수들에 대해서 stubbing 만 진행해주면 된다)
2. Member 필드가 변경되었다 -> 해당 테스트에는 아무런 상관이 없이 통과한다.
3. Group.update() 함수에 문제가 있다 -> 해당 테스트에는 아무런 상관이 없이 통과한다.
4. update() 를 주석처리하고 원하는 return 값을 만들어 반환하는 비즈니스 로직으로 바꾼다 -> 해당 테스트는 Fail 한다 (update 함수가 호출되지 않았기 때문이다) - 동작성 검토를 assert 한다
* 생각날법한 의문들
Q1: 아니 update 를 해주는 함수인데, 당연히 update 가 잘 되었는지를 파악해줘야 하는 것 아닌가??
- 실제 필드를 변경하는 update 는 Group Entity 내에 있다. 거기서 Test 하면 된다.
Q2: return value 를 당연히 주고 Test 를 해야 하는거 아닌가? 제대로 수정되었는지는 확인 안하는가??
- return 을 하지 말라는게 아니고, return 이 이 함수의 동작성에 있어서 그닥 중요하지 않다는 것이다. return value 는 이 함수를 call 한 곳에서 중요한 값이므로, 통합 테스트에서 진행하면 된다.
Q3. GroupUpdateReqDto 가 비어있어도 되는거냐?
- 된다. 해당 함수 내에서 reqDto 를 직접적으로 사용 하는부분 없다그저전달하거나, update 에 통째로 넘길 뿐이다. 그러므로 UpdateReqDto 가 잘 채워져 있는지에 대해서는 이 함수는 관심이 없다.
any() 의 의미를 잘 생각해볼 수 있어야 한다. 이 테스트를 읽을 때, any() 라는 부분은
any() - 이 함수에서 어떤 변수가 들어와도 된다 = 이 함수가 크게 관심이 없다 = 이 함수의 테스트의 관심사가 전혀 아니다
라고 생각해야 한다. 그래서 이 Test 의 목적, 이 함수의 목적을 파악할 때 도움이 되어야 하는 것이 any() 이지, Test 쉽게 쉽게 가자~! 하려고 하는게 any() 가 아니다.
이렇게 결과 기반 테스트가, return 해주는 Value 가 중요한게 아니라, Class 에서 해당 함수가 수행하는 로직들이 정말 제대로 실행 되는지 확인하는 것을 중요하다고 생각하는 것이 [상호 작용 기반 테스트 (Interaction-Based)] 이다. 함수마다 목적성에 있어서 차이가 확실히 존재하기 때문에, 항상 어떤 방식이 맞다는 말은 틀린 것같다.
'Testing > Unit' 카테고리의 다른 글
[단위테스트] Unit Test 를 통해 함수를 Refactoring 해보자 (0) | 2023.09.11 |
---|---|
[단위테스트] Spring Service 단을 테스트해보자 (0) | 2023.08.01 |