지금껏 생각해보면 TDD가 무엇인지 용어만 들어본 정도라고 생각이 들었습니다.
또 회사에서 일을 하다보면 우리 회사는 TDD(Test - Driven - Development)가 아니라 TDD(Test - 뒷전 - Development)가 아니냐는 농담도 심심찮게 했었죠
그래서 알고 싶었습니다.
TDD가 비용이 들어가는 일이기 때문에 이익집단인 회사에서 당장 업무를 진행하기 바빠서 할 수 없다면, 개인 프로젝트에서라도 내가 Test Code를 직접 짜보겠다 하고 마음을 먹었습니다.
항상 가장 큰 문제는 마음을 먹고 어떻게 해야하는지 모르기에 일어납니다.
물론 포기해야 할까? 하는 생각도 하고 TDD 관련 강의를 듣고 작업을 해야할까 하는 고민을 많이 하던 찰나에 김영한님 강의속에 나온 기본적인 Test Code 작성법을 보고 비슷하게 만들어 보기로 결정을 하게 되었습니다.
영어로 표현하면 Given - When - Then 이라고 하지만 사실 이렇게 외우는 것보다 김영한님께서 농담같이 말씀하셨던 머리 - 가슴 - 배가 가장 와닿아서 저도 이렇게 표현하고 있습니다.
Given - When - Then은 이렇게 간략히 설명할 수 있을 것 같습니다.
회원 CRUD에 사용할 JpaRepository를 상속받은 UserRepository 코드는 아래와 같습니다.
public interface UserRepository extends JpaRepository<User, Long> {
/*
* 회원 가입 & 회원 수정
* @param User
* @return userNo
* */
public User save(User user);
/*
* 회원 한명 조회
* @param userNo
* @return User
* */
public Optional<User> findById(Long id);
/*
* 회원 이름으로 조회
* @param userName
* @return User
* */
public User findByName(String name);
/*
* 회원 한명 삭제
* @param userNo
* */
public void delete(User user);
}
save 메서드가 회원 가입(Crate)과 수정(Update)를 같이 수행하게 되는데 이 부분은 나중에 JpaRepository에 대한 포스팅을 작성하면서 자세히 다뤄보겠습니다.
이제 테스트 코드를 케이스별로 작성해 보겠습니다.
테스트를 할때 여러가지 방법이 있지만, 저는 강의에서 배운대로 Spring 환경을 그대로 사용하는 Spring Integration Test(스프링 통합 테스트)로 진행하겠습니다.
아직 개발 단계이기 때문에 따로 yml 파일을 분리해서 Inmemory-Database(H2와 같은 종류)를 사용하는 것은 나중에 포스팅 하도록 하겠습니다.
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserRepositoryTest {
@Autowired UserRepository userRepository;
}
일단 @SpringBootTest 애너테이션과 @RunWith(SpringRunner.class) 애너테이션을 사용하기 때문에 이 애너테이션들을 테스트 클래스 위에 붙여줍니다.
@Test
@DisplayName("정상 회원 가입 케이스 테스트")
@Transactional
public void joinUser() {
// given
User insertUser = userRepository.save(User.builder()
.id("Kafka")
.pwd("javascript")
.email("aaa@aaa.com")
.name("RexSeo")
.userRole(UserRole.USER)
.build());
// when
User saveUser = userRepository.findById(insertUser.getNo()).get();
// then
assertThat(insertUser).isEqualTo(saveUser);
}
일단 테스트를 위한 코드임을 명시하기 위해서 @Test 애너테이션을 붙여주고, 제가 구분하기 쉽도록 테스트 수행 후 콘솔창에 보여질 이름을 @DisplayName으로 작성해줍니다.
JPA를 이용해서 데이터베이스에 접근 하여 테스트를 진행하기 때문에 @Transactional 애너테이션을 통해 트랜잭션을 사용할 수 있도록 했습니다.
JPA에서는 한 트랜잭션 안에서는 엔티티의 동일성을 보장합니다.
그말은 즉, 보통 JDBC나 MyBatis를 사용하면 같은 PK로 조회를 하더라도 객체의 주소값을 비교하기 때문에 두 객체가 다른 객체로 판단되어 equals를 오버라이딩 해야만 올바른 결과를 얻을 수 있었습니다.
하지만 JPA에서는 같은 PK값으로 조회를 한다면 몇번을 조회해도 같은 객체라는 결과를 얻을 수 있어 "동일성"을 보장 받을 수 있다고 합니다.
따라서, userRepository에서 save 메서드를 실행하면 save 메서드는 User를 반환합니다
User 엔티티를 반환받는 이유는, User Entity의 PK인 no 컬럼은 자동채번(AUTO_INCREMENT)로 해놓았기 때문에 User 엔티티가 영속화(Persist)될때 그 값을 알 수 있게 됩니다.
따라서, 테스트를 할때 User를 Persist하고 User를 반환받으면 PK값을 이용할 수 있기 때문에 이렇게 코드를 작성하였습니다.
이 테스트는 현재 insert한 User를 조회 했을때 제대로 조회가 된다면 성공했다고 볼 수 있으므로,
이런 시나리오로 테스트를 작성하였습니다.
@Test
@DisplayName("회원 이름으로 조회 테스트")
@Transactional
public void findUserByName() {
// given
User insertUser = userRepository.save(User.builder()
.id("Kafka")
.pwd("javascript")
.email("aaa@aaa.com")
.name("RexSeo")
.userRole(UserRole.USER)
.build());
// when
User findUser = userRepository.findByName("RexSeo");
// then
assertThat(insertUser).isEqualTo(findUser);
}
회원 조회 테스트도 맥락은 회원 가입 테스트와 비슷하다고 볼 수 있습니다.
이번 조회 테스트는 회원의 이름으로 조회하는 findByName 메서드를 사용하였습니다.
"RexSeo"라는 이름을 가진 회원을 회원 가입시키고, "RexSeo"라는 이름을 가진 회원이 있는지 조회한 후에 회원 가입시 사용한 User 엔티티와 조회한 User 엔티티가 동일한지 테스트하는 코드입니다.
이 테스트 역시 가입시 사용한 엔티티와 조회한 엔티티가 동일하면 테스트가 성공했다고 볼 수 있습니다.
회원 가입과 비슷한 흐름으로 테스트가 작성 된 것을 볼 수 있습니다.
@Test
@DisplayName("없는 회원 조회 예외 테스트")
@Transactional
public void notExistUserFindTest() {
// given
Long userNo = 99L;
// when
Optional<User> findUser = userRepository.findById(userNo);
// then
assertThatThrownBy(() -> findUser.get())
.isInstanceOf(NoSuchElementException.class);
}
지금까지 위에서 보여드린 테스트 코드는 성공 케이스를 검증했다면, 이번 테스트 코드는 없는 회원을 조회하거나 잘못된 요청을 했을때 예외를 잘 터뜨리는지 테스트하는 코드입니다.
findById 메서드는 User를 Optional로 한번 포장에서 반환받도록 작성 되어있습니다.
Optional에 한번 포장해서 객체를 다루게 되면, 직접 참조시에도 일단 NPE(NullPointerException)를 어느정도 방지할 수 있다는 점에서 큰 매력이 있습니다.
이 테스트 코드는 현재 존재하지 않는 '99'번을 PK 값으로 가진 User 한명을 조회합니다.
물론 Optional은 안에 가지고 있는 User 객체나 null이라도 NPE를 터뜨리지는 않습니다. 그렇다면 예외를 체크하는 방법은 Optional 안에서 User를 꺼내려고 할때 NoSuchElementException이 발생합니다.
만약 99번을 PK로 가진 User가 존재한다면 테스트는 NoSuchElementException이 발생하지 않아 실패할 것이고, User가 null 이라면 Optional에서 객체를 꺼낼때 예외가 발생할 것이므로 테스트가 성공할 것입니다.
@Test
@DisplayName("회원 삭제 테스트")
@Transactional
public void deleteUserTest() {
// given
User insertUser = userRepository.save(User.builder()
.id("Kafka")
.pwd("javascript")
.email("aaa@aaa.com")
.name("RexSeo")
.userRole(UserRole.USER)
.build());
// when
userRepository.delete(insertUser);
Optional<User> findUser = userRepository.findById(insertUser.getNo());
// then
assertThatThrownBy(() -> findUser.get())
.isInstanceOf(NoSuchElementException.class);
}
JpaRepository가 지원하는 Delete 메서드의 경우, void 타입이기 때문에 MyBatis를 사용할때와 같이 영향을 받은 row 수를 Integer(정수 값)값으로 반환받을 수 없습니다.
따라서 영향받은 행의 수가 1인지 0인지 체크해서 테스트가 성공했는지 실패했는지 알아보기는 쉽지 않습니다.
따라서, User 한명을 회원 가입시키고, 그 User를 삭제하고, 해당 User의 PK의 값으로 User 한명을 조회하여 Optional<User>로 반환받고 Optional에서 User를 꺼내려고 할때 예외가 발생하는지 테스트를 하는 코드로 작성하였습니다.
이미 삭제된 회원을 조회하면 당연히 null 저장되어 있기 때문에 Optional의 get 메서드를 실행하는 순간에 NoSuchElementException이 발생해야 테스트가 성공했다고 할 수 있습니다.
TDD TDD 어떻게 해야하지 이건 무엇일까 나는 누구? 여긴 어디? 이러지 말고 결국 제가 하나라도 직접 짜보고 느껴보는 것이 가장 이해가 빠르다고 알게 된 계기가 된 거 같습니다.
아직 TDD의 T도 제대로 알지 못하는 것 같지만 앞으로 더 많은 실습과 이론을 겸비하면 좀 더 나은 테스트 코드를 작성할 수 있을거라고 생각합니다.