Java/Test

JUnit을 사용하여 예제로 TDD 맛보기

keep it simple 2023. 8. 2. 16:29

TDD(Test Driven Development)란?

  • 테스트 중심적 개발이다. -> 테스트를 중심으로 개발하는 개발 방법론이다. 당연히 TDD는 설계 테스트 등 복잡하지만 이글에서는 간단하게 TDD관련된 메인 아이디어만 맛보기로 예시와 작성한 글이다.
  • TDD 작성 단계
    • RED: 테스트를 먼저 작성한다. 구현이 없으므로 테스트는 실패 해야한다. 
    • GREEN: 테스트를 성공시키기 위해 최소 요구조건에 맞게 코드 품질 상관없이 구현한다.
    • REFACTOR: 테스트 코드를 성공하면서 개선하고 리팩토링한다.

Advantages:

  • 협력 & 문서 -> 많은 개발자들이 협력해도 테스트 코드가 작성이 잘 되어있으면 파악이 쉬워 개발 생산성이 향상된다.
  • 좋은 설계 -> 테스트 코드를 짜면서 코드를 분리하고 리팩토링 함으로써, 결합도를 낮게함으로써 좋은 설계를 생각하게 된다.
  • 유지보수의 용이 -> 테스트 코드를 한번만 작성하면 코드가 리팩토링되도 쉽게 테스트 후 유지보수가 가능하다.
  • 좋은 퀄리티의 코드 -> 높은 코드 품질을 보장한다. 테스트가 먼저 작성되서 안정성과 코드 품질이 좋아진다.

Disadvantages:

  • 테스트를 가능하도록 설계 -> 추상화, 의존성 주입, 설계 등 테스트를 하기위해 고려해야하는 부분이 많다.
  • 개발시간 증가 -> 고민할 부분이 많아 개발시간이 증가되며 오래걸린다. 

Example

  • 유저가 회원가입하는 서비스와 테스트 코드를 작성해보자
    • 요구조건:
      • 나이가 15살 이상
      • 중복 닉네임 불가

Domain Layer

@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int age;
    private String name;
    private String nickname;

    private LocalDateTime joinedAt;

    @Builder
    private User(int age, String name, String nickname) {
        this.age = age;
        this.name = name;
        this.nickname = nickname;
    }

    public static User create(int age, String name, String nickname) {
        return User.builder()
                .age(age)
                .name(name)
                .nickname(nickname)
                .build();
    }

    public Boolean isAgeUnder15(int age){
        return age < 15;
    }

}
public interface UserRepository extends JpaRepository<User,Long> {
    Optional<User> findByNickname(String nickname);
}

Application Layer

@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserRepository userRepository;

    @Transactional
    public UserResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
        return UserResponse.of(savedUser);
    }
}

User Request

@Getter
@NoArgsConstructor
public class UserJoinRequest {
    private int age;
    private String name;
    private String nickname;

}

User Response

@Getter
public class UserResponse {
    private final Long id;
    private final int age;
    private final String name;
    private final String nickname;
    private final LocalDateTime joinedAt;

    @Builder
    private UserResponse(Long id,int age,String name,String nickname,LocalDateTime joinedAt){
        this.id = id;
        this.age = age;
        this.name = name;
        this.nickname = nickname;
        this.joinedAt = joinedAt;
    }

    public static UserResponse of(User user){
        return UserResponse.builder()
                .id(user.getId())
                .age(user.getAge())
                .name(user.getName())
                .nickname(user.getNickname())
                .joinedAt(user.getJoinedAt())
                .build();
    }
}

TDD STEPS

 

Red Phase:

  • Service
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    
    @Transactional
    public UserResponse joinUser(UserJoinRequest request) {
        return null;
    }
}
  • Test
@Test
@DisplayName("유저가 회원가입을 한다.")
void joinUserTest(){
    // given
    UserJoinRequest request = UserJoinRequest.builder()
            .age(15)
            .name("john")
            .nickname("test")
            .build();

    // when
    UserResponse response = userService.joinUser(request);

    // then
    assertThat(response).isNotNull();
}
  • Result

 

Green Phase:

  • Service
@Transactional
public UserResponse joinUser(UserJoinRequest request) {
    User user = User.create(request.getAge(), request.getName(), request.getNickname());

    userRepository.findByNickname(request.getNickname())
            .ifPresent(i -> {
                throw new IllegalStateException("이미 존재하는 닉네임입니다.");
            });

    if (user.isAgeUnder15(request.getAge())) {
        throw new IllegalArgumentException("나이가 15살 이상이어야합니다.");
    }
    
    User savedUser = userRepository.save(user);
    return UserResponse.of(savedUser);
}
  • Test
@Test
@DisplayName("유저가 회원가입을 한다.")
void joinUserTest(){
    // given
    UserJoinRequest request = UserJoinRequest.builder()
            .age(15)
            .name("john")
            .nickname("test")
            .build();

    // when
    UserResponse response = userService.joinUser(request);

    // then
    assertThat(response).isNotNull();
    assertThat(response).extracting("age","nickname","name")
            .contains(15,"john","test");
}
  • Result

Refactoring Phase:

  • Refactored Service: 한 함수에 있던 로직들을 역할때 따라 메소드를 분리하여 리팩토링 했다. 
    • 데이터 검증 메소드, request -> domain 메소드 , 나이 15살 이상인 지 확인하는 메소드
@Transactional
public UserResponse joinUser(UserJoinRequest request) {
    validateUserRequest(request);

    User user = createUserFromRequest(request);
    User savedUser = userRepository.save(user);

    return UserResponse.of(savedUser);
}

private void validateUserRequest(UserJoinRequest request) {
    userRepository.findByNickname(request.getNickname())
            .ifPresent(i -> {
                throw new IllegalStateException("이미 존재하는 닉네임입니다.");
            });

    if (userIsUnder15(request.getAge())) {
        throw new IllegalArgumentException("나이가 15살 이상이어야합니다.");
    }
}

private User createUserFromRequest(UserJoinRequest request) {
    return User.create(request.getAge(), request.getName(), request.getNickname());
}

private boolean userIsUnder15(int age) {
    return age < 15;
}
  • Result


결론

  • 확실히 위 스텝처럼 테스트 코드를 짜면 계속 생각하게 되고 중간에 부족한 점이라던지 수정해야할 부분을 파악하게 된다. 더군다나 마지막 리팩토링 스텝을 통해 더욱 더 가독성있고 깔끔하게 코드를 짤 수 있다. (보통은 예외처리와 도메인 단위테스트도 같이 진행해야하지만 코드가 길어지면 글읽는 피로감 때문에 유저 회원가입 테스트만 작성했다.)
  • TDD는 Test Code를 더욱 더 짜임새있고 생각하며 짤 수 있게 도와주는 개발론이라 생각한다.그리고 TDD는 필수가아니지만 Test Code는 필수라 생각한다. 초반 개발만 봤을때는 개발시간이 증가된다 생각하지만 전체적으로 봤을 땐테스트코드 없이 개발 후 운영상태에서 발견되는 버그 수정을 생각하면 오히려 개발시간이 감소되고 더 안전하고 확장성있게 개발할 수 있다고 생각한다. 또한, 개발은 혼자아는게 아니라 여럿이서 협력하고 더 좋은 프로덕트를 만들기 위해서 테스트 코드를 작성하면서 많은 생각과 서로간의 아이디어와 고민하고 이야기해야 더 나은 협력하고 좋은 품질의 코드를 짜는 개발자가 된다 생각한다. 이제까지는 선택적으로 테스트코드를 짯지만 이제 개발할 때는 필수로 작성해야겠다 생각한다.