인프런 워밍업 스터디 클럽 2기 백엔드(클린코드&테스트코드) Day 15 미션
인프런 워밍업 클럽 2기, 백엔드(클린코드&테스트코드) 과정에 참여하고 있습니다.
이번 글은 Day 15 미션 제출을 위해 작성하였습니다.
[미션 내용]
Layered Architecture 에서 각 레이어별 어떤 특징이 있고, 어떻게 테스트를 하면 좋을지에 대해 나만의 언어로 표현해보자
Layered Architecture
Layered Architecture는 관심사와 책임의 분리를 위해 만들어진 계층 구조 아키텍쳐이다. 관심사를 기준으로 책임을 나누면, 각 계층의 응집도를 높이고 결합도를 낮춘, 유지보수에 용이한(확장이 쉬운) 코드를 작성할 수 있게 된다는 이점을 얻을 수 있다.
Spring 진영에서 일반적으로 얘기하는 Layered Architecture는 보통 아래와 같이 3-tier(3 계층)로 구성되어 있는 아키텍쳐이다.
Persistence Layer (영속성, 데이터베이스 레이어)
Data Access의 역할을 한다.
~~Repository
비즈니스 가공 로직이 포함되어서는 안된다.
Data에 대한 CRUD에만 집중한 레이어
4-tier 구조의
Database Layer
가 3-tier 구조에서는 이 레이어에 포함된다.
Business Layer (비즈니스 레이어)
비즈니스 로직을 구현하는 역할
Persistence Layer와의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킨다.
트랜잭션을 보장해야 한다.
작업 단위에 대한 원자성
Presentation Layer (사용자 요청&응답 레이어)
외부 세계의 요청을 가장 먼저 받는 계층
파라미터에 대한 최소한의 검증을 수행한다.
각 Layer 별 테스트 방법
공통적으로 테스트 코드를 작성할때는 BDD(Behavior-Driven Development) 스타일로 작성하는 것을 권장한다. 이는 테스트 코드의 가독성을 높여준다는 장점이 있다. BDD는 given/when/then
구조로 작성하는 것을 기본으로 한다.
테스트 시나리오는 가급적 작은 코드 단위를 독립적으로 검증하는 것을 목표로 하는것이 좋다. 검증 속도가 빠르고, 외부에 의존을 하지 않기 때문에 안정적으로 테스트를 수행할 수 있게 한다. 그리고 해피 케이스와 예외 케이스를 도출 해낼수 있어야 한다. 이를 위해선 테스트를 작성할 때 항상 "암묵적이거나, 아직 드러나지 않은 요구사항이 있는가?" 라는 질문을 끊임없이 스스로에게 던지는 습관을 기르는 것이 도움이 된다.
또, given
코드를 작성할 때는 가급적 경계값이 되는 데이터를 사용하여 긍정 케이스와 부정 케이스를 각각 검증하도록 시나리오를 작성하는 것이 좋다. 여기서 말하는 경계값이란, 성공과 실패 조건의 경계에 걸쳐 있는 값을 의미한다. 예를 들어, 주어진 값이 10 이상일 때 부터 성공하는 케이스에 대해 실패 테스트에는 9를, 성공 테스트에는 10을 사용하는 것을 말한다.
Persistence Layer
JPA Repository에 대한 단위 테스트를 수행하기 위해서 @DataJpaTest
애노테이션을 사용한다. @DataJpaTest
는 JPA와 관련된 설정만 로드하기 때문에 최소 비용으로 JPA 테스트를 수행할 수 있게 해주고, @Transactional
을 기본적으로 내장하고 있으므로 매 테스트 코드가 종료되면 자동으로 DB가 롤백되도록 해준다.
다만, 의존을 주입받기 위해서는 @AutoWired
를 사용해야 한다는 것을 주의해야 한다.
@ActiveProfiles("test")
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@DisplayName("User 이름으로 User 를 찾을 수 있다.")
@Test
public void findUserByName() {
// given
User givenUser = new User("alex", "alex@example.com");
userRepository.save(givenUser);
// when
User foundUser = userRepository.findByName(givenUser.getName());
// then
assertThat(foundUser.getName()).isEqualTo(givenUser.getName());
}
}
위 예시는 아주 단순한 JPA Repository의 쿼리 메서드를 테스트하는 예제이다. 예시를 위해 최대한 간단하게 작성한 것이므로, 위와 같은 Hibernate 라는 거대한, 검증된 라이브러리가 제공해주는 쿼리 메서드에 대한 테스트는 굳이 매번 작성할 필요는 없다고 생각한다. 그러니 스스로가 직접 작성한 쿼리 메서드에 대한 테스트를 꼼꼼하게 하는것에 집중하는 것을 권장한다.
참고로, @ActiveProfiles("test")
는 테스트 코드 실행 영역에서는 실제 DB가 아닌 테스트용 DB로 연결되도록 하기 위해 추가해준 애노테이션임을 알아두자.
Business Layer
Service 코드에 대한 테스트를 수행하기 위해서는 JPA Repository에 대한 의존성이 추가로 필요하게 된다. 이를 위해 @SpringBootTest
애노테이션을 사용해서 자동으로 의존성 주입을 받고 코드를 간단하게 작성할 수 있지만, @SpringBootTest
는 간편한 만큼 많은 비용이 들어간다는 점을 항상 염두에 두어야 한다.
다음은 @SpringBootTest
를 사용한 Business Layer 테스트 코드 예제이다.
@ActiveProfiles("test")
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@AfterEach
void tearDown() {
userRepository.deleteAllInBatch();
}
@DisplayName("유효 상태인 유저를 모두 조회할 수 있다.")
@Test
void findAllUserByValid() {
// given
User user1 = new User("testUser", "test@example.com", true); // 이름, 이메일, 유효상태
User user2 = new User("testUser", "test@example.com", false);
userRepository.saveAll(List.of(user1, user2));
// when
List<User> foundUsers = userService.findAllUserByValid();
// then
assertThat(foundUsers).hasSize(1)
.extracting("name", "email", "isValid")
.contains(
tuple("testUser", "test@example.com", true)
);
}
}
위 예제에서 @Transactional
을 사용하지 않고 @AfterEach
를 통해 매 테스트마다 Repository의 데이터 클렌징을 수행하도록 코드를 작성하였는데, 이는 휴먼 에러를 줄여주기 위한 코딩 습관과 연관이 있다.
만약, 실수로 실제 Service 코드에서 데이터를 저장, 수정, 삭제하는 메서드(또는 클래스 레벨)에 @Transactional
을 달아주지 않았다면 트랜잭션 관리의 부재로 인해 개발자가 의도하지 않은 에러가 발생할 수 있는 가능성이 생긴다. 만약 이 때 테스트 코드에 @Transactional
을 달아서 테스트를 수행했다면 테스트 시점에는 그 에러를 발견할 가능성이 희박해진다. 따라서, 개발자가 Service 레이어 코드에 @Transacional
애노테이션을 빼먹는 실수를 테스트 코드에서 잡아낼 수 있는 가능성을 열어주기 위해, 테스트 코드에서 @Transactional
대신 tearDown
클렌징 기법을 사용한 경우인 것이다.
그리고 @SpringBootTest
를 사용하지 않고 테스트를 작성하기 위해서는 Repository 코드를 @Mock
으로 작성하면 된다. @InjectMocks
애노테이션이 붙어있는 UserService
객체가 생성될 때, UserRepository
에는 @Mock
이 붙어있으므로 대안(가짜) 객체가 생성되어 주입되게 된다. 따라서 테스트 메서드 내부에서 해당 쿼리 메서드의 행동을 함께 정의해주는 방법으로 테스트를 작성할 수 있다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@DisplayName("유효 상태인 유저를 모두 조회할 수 있다.")
@Test
void findAllUserByValid() {
// given
User user1 = new User("testUser1", "test1@example.com", true);
User user2 = new User("testUser2", "test2@example.com", false);
List<User> mockUsers = List.of(user1, user2);
when(userRepository.findAll()).thenReturn(mockUsers);
// when
List<User> foundUsers = userService.findAllUserByValid();
// then
assertThat(foundUsers).hasSize(1)
.extracting("name", "email", "isValid")
.contains(
tuple("testUser1", "test1@example.com", true)
);
}
}
Presentation Layer
Controller 코드에 대한 테스트는 @WebMvcTest
애노테이션과 MockMvc
객체를 활용해서 를 수행할 수 있다. 이를 통해 API 엔드포인트의 동작, HTTP 상태 코드, 헤더, 요청과 응답에 대한 검증을 할 수 있다.
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
public void testGetUser() throws Exception {
User user = new User(1L, "testUser", "test@example.com");
when(userService.getUserById(1L)).thenReturn(user);
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("testUser"));
}
}
Presentation Layer는 Service 코드에 대한 의존성이 필요하게 된다. 마찬가지로, Mock 객체로 대체해서 테스트를 수행해서 Controller 로직만을 독립적으로 검증해야 한다. 이 때 Business Layer와의 차이점은, @Mock
이 아닌 @MockBean
을 사용해서 의존성을 주입받을 수 있다는 것인데, 이는 @WebMvcTest
는 Spring Boot 테스트 프레임워크의 전반적인 설정과 부트스트래핑 과정을 포함하고 있기 때문에 가능한 것이다.
댓글을 작성해보세요.