TDD로 개발하기 2탄

카일·2020년 2월 16일
2
post-thumbnail

모든 코드는 여기를 클릭 하시면 확인 하실 수 있습니다.

안녕하세요. 저번 포스팅에 이어 TDD로 개발하기 2탄입니다. 이번 포스팅에서는 TDD란 무엇인지에 대해 간략하게 알아보고 개발을 시작하려 합니다.

TDD란?

테스트 주도 개발(Test-driven development TDD)은 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나이다. 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다. 마지막으로 작성한 코드를 표준에 맞도록 리팩토링한다. 이 기법을 개발했거나 '재발견' 한 것으로 인정되는 Kent Beck은 2003년에 TDD가 단순한 설계를 장려하고 자신감을 불어넣어준다고 말하였다. - 위키피디아

위키피디아에서 정의한 TDD란 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나라고 되어 있는데요. 주관적으로 받아들인 의미로 간략하게 적어보자면 기존에는 Production Code 코드를 작성하고 이를 검증하는 테스트 코드를 작성하였다면 TDD 는 반대로 테스트를 먼저 작성하고 이를 통과시키게 하는 프로덕션 코드를 작성하는 것을 의미합니다. 보고자 하는 결과를 정의하고 그 결과를 통과시키는 최소한의 코드를 작성 하여 테스트를 통과시킨 후 리팩토링을 통해 프로그램 표준에 맞는 코드로 변경하는 사이클 자체를 TDD라고 정의하는 것 같습니다. TDD가 숙달되고 의미를 더 잘 이해하게 될 때 더 구체적으로 수정하겠습니다.

전체적인 구조

앞서 포스팅 한 TODO-List에 따라 구현 할 예정입니다. 기능 위주로 정리해서 올리고 마지막에 특별한 기술이나 특정 개념을 사용한 이유에 대해 작성하겠습니다.

기능 : 사용자 이름 입력

사용자가 플레이 할 유저를 입력하는 경우

  • 사용자가 입력(String)이 Comma(,)를 기준으로 분리하는 기능을 개발합니다. 먼저 테스트 코드를 작성합니다.
    package kail.study.java.racing;
    
    import static org.assertj.core.api.Assertions.*;
    
    import java.util.List;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    
    public class UserInputTest {
    	@Test
    	@DisplayName("올바르게 ,를 구분으로 입력하는 경우")
    	void splitTest() {
    		List<String> names = StringUtils.parseByComma("pobi,hodol,kyle");
    		assertThat(names).containsExactly("pobi","hodol","kyle");
    		assertThat(names).hasSize(3);
    	}
    }
  • 위의 코드는 컴파일 조차 되지 않습니다. StringUtils 라는 클래스와 parseByComma 라는 메소드가 존재하지 않기 때문인데요. 최소한의 코드로 위에 작성한 테스트를 통과시켜 보겠습니다.
    package kail.study.java.racing;
    
    import java.util.Arrays;
    import java.util.List;
    
    public class StringUtils {
    	public static List<String> parseByComma(String userInput) {
    		return Arrays.asList(userInput.split(","));
    	}
    }
  • StringUtil에 위의 코드를 작성해 준다면 테스트는 통과합니다. 그럼 이제 남은 일은 코드를 리팩토링 하는 일이겠죠? 위의 코드는 매우 단순해서 크게 리팩토링 할 부분이 없네요. 다음 단계로 넘어 가겠습니다. (StringUtils에서 pobi,hodol,kyle을 담고 있는 리스트를 반환하는 야매(?)를 써서 테스트를 통과시키고 리팩토링을 해도 되지만 그 정도는 too much라 생각해서... 그냥 넘어가겠습니다.
  • 위의 예제에서는 파라미터를 통해 값을 검증하고 있기 때문에 사용자에게 입력을 요구하는 클래슨는 존재하지 않아 이 부분만 추가로 작성하고 다음 기능으로 넘어가겠습니다.
    package kail.study.java.racing;
    
    import java.util.Scanner;
    
    public class InputView {
    	private static final Scanner sc = new Scanner(System.in);
    
    	public static String getInput() {
    		return sc.nextLine();
    	}
    }

Comma를 기준으로 분리된 각 이름 검증

분리된 이름의 길이가 1~5 인 경우에만 통과하며

  • 이번에는 사용자가 입력한 이름 각각의 Length가 1~5인 경우인지 테스트 합니다. (Comma로 분리 된 각각의 이름)
    package kail.study.java.racing;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.ValueSource;
    
    public class NameTest {
    	@ParameterizedTest
    	@ValueSource(strings = {"kyle","hodle","pobi"})
    	@DisplayName("이름 규칙에 문제가 없어서 정상적으로 생성되는 경우")
    	void 정상적인_경우(String name) {
    		assertThat(new Name(name));
    	}
    }
  • 위의 테스트코드는 Name이라는 클래스를 생성 해주지 않아 에러가 발생합니다. 이를 해결하는 가장 간단한 방법으로 해결해 보겠습니다.
    package kail.study.java.racing;
    
    public class Name {
    	private final String name;
    
    	public Name(String name) {
    		this.name = name;
    	}
    }
  • Name 클래스를 생성함으로써 테스트는 통과하였습니다. 예외적인 상황들을 검증하는 메서드를 아래에서 추가해 보도록 하겠습니다. 사실 이 부분도 단순히 Validator와 같은 곳에서 name.length()를 검증하는 정도로 개발을 하고 리팩토링 과정에서 Name이라는 클래스로 객체화 시키는 것이 순서는 맞다고 생각하나, 쉬운 부분이라 생략하였습니다.

길이의 범위를 초과하여야 하며

  • 위의 테스트 케이스는 정상적으로 입력한 경우에 대해 테스트를 작성하였고 여기서부터는 잘못된 입력의 첫 번째 예제 길이의 범위를 초과한 경우에 대해 기능을 개발 해 보겠습니다.
    @ParameterizedTest
    	@ValueSource(strings = {"overLength", "helloWorld", "ImTheKing"})
    	void 길이_초과(String name) {
    		assertThatThrownBy(() -> {
    			new Name(name);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("길이 초과");
    	}
  • 위의 코드는 새로운 이름을 생성할 때 5글자 이상의 값이 생성자로 주입될 때 IllegalArgumentException을 반환하도록 하는 테스트 코드입니다. 이 테스트 코드 또한 실패하는데요, 현재는 Name을 생성할 때 주입 받는 파라미터에 대한 검증이 존재하지 않기 때문입니다. 그렇다면 이 테스트를 통과하도록 Name 클래스에 검증 메소드를 추가 해 보겠습니다.
    public class Name {
    	private final String name;
    
    	public Name(String name) {
    		validateOverLength(name);
    		this.name = name;
    	}
    
    	private void validateOverLength(String name) {
    		if (name.length() > 5 || name.length() < 1)
    			throw new IllegalArgumentException("길이가 초과되었습니다.");
    	}
    }
  • 이름을 생성할 때 validateOverLength() 라는 메소드를 통해 5 또는 1 미만의 값은 검증되는 로직을 추가하였습니다. 이를 통해 테스트를 통과시켰습니다. 추가적으로 아래의 테스트에서도 검증로직이 추가 될 것이기 때문에 조금 더 추상적인 메소드를 추가하는 방식으로 리팩토링을 해 보겠습니다.
    public class Name {
    	public static final int MAX_LENGTH = 5;
    	
    	private final String name;
    
    	public Name(String name) {
    		validate(name);
    		this.name = name;
    	}
    
    	private void validate(String name) {
    		checkLength(name);
    	}
    
    	private void checkLength(String name) {
    		if (name.length() > MAX_LENGTH)
    			throw new IllegalArgumentException("길이가 초과되었습니다.");
    	}
    }
  • 추가적으로 이름을 생성 할 때 검증되야 하는 로직이 있기 때문에 validate라는 메소드로 한 번 추상화를 하고 그 아래에 Length를 검증하는 로직을 수행하였습니다. 또한 1보다 작은 경우는 길이가 0인 경우이기에 아래부분에서 체크하기 위해 제거하였고 값의 의도를 명확히 드러내기 위해 5라는 수를 상수로 분리하였습니다.

공백 또는 널이 입력되는 경우

  • 위의 예제들에서 조금 상세히 설명했으니 이부분은 공백과 널을 묶어서 테스트 하도록 하겠습니다.
    @ParameterizedTest
    	@ValueSource(strings = {"", "   ", "            "})
    	void 공백_예외(String name) {
    		assertThatThrownBy(() -> {
    			new Name(name);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("공백은 입력할 수 없습니다.");
    	}
    	
    	@Test
    	void 널이라서_예외() {
    		assertThatThrownBy(() -> {
    			new Name(null);
    		}).isInstanceOf(NullPointerException.class)
    			.hasMessageContaining("널값은 입력할 수 없습니다.");
    	}
  • 공백이 입력되는 경우와 null이 입력되는 경우 각각 예외가 발생하기를 원했으나 공백의 경우는 예외자체를 던지고 있지 않기에 테스트가 통과하지 않고 Null은 자체적으로 NullPoint를 던지기 때문에 예외는 발생하나 예외에 대한 설명이 위에서 적은 것과 일치하지 않아 테스트가 통과하지 않습니다. 이를 통과시키기 위한 Production Code를 작성 해 보겠습니다.
    public Name(String name) {
    		validate(name);
    		this.name = name;
    	}
    
    	private void validate(String name) {
    		checkNull(name);
    		checkBlank(name);
    		checkLength(name);
    	}
    
    	private void checkNull(String name) {
    		if(name == null)
    			throw new NullPointerException("널값은 입력할 수 없습니다.");
    	}
    
    	private void checkBlank(String name) {
    		if(name.trim().isEmpty())
    			throw new IllegalArgumentException("공백은 입력할 수 없습니다.");
    	}
  • Null과 빈 문자열을 체크하는 메서드를 추가로 구현하였습니다. isBlank()를 사용하면 trim().isEmpty()를 하지 않아도 되지만 현재 개발 환경이 자바 8로 하고 있기 때문에 저렇게 구현하였습니다. 추가적으로 Validate 순서는 Null과 Blank를 먼저 체크하는 것이 의미 있기에 순서를 조정하였습니다.

같은 이름을 입력하는 경우 (중복된 이름은 받지 않는 것으로 하겠습니다)

  • 각각의 사용자를 구분 할 유일한 식별자는 이름이기 때문에 중복된 이름은 에러를 반환하는 것으로 정의하였습니다.
    @Test
    	void 중복된_이름 () {
    		Name pobi = new Name("pobi");
    		assertThatThrownBy(()->{
    			new Name("pobi");
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("중복된 이름이 존재합니다.");
    	}
  • 실패하는 중복된 이름 예외처리를 Name 클래스에 작성하였습니다. 이를 해결하기 위해서 Name 클래스에서 어떤 조치를 취할 수 있을까요? 각각의 인스턴스가 어떤 것인지 알 수 없고 Name의 인스턴스간의 비교가 불가능 하기 때문에 이미 생성된 Name 인스턴스들을 관리해 줄 대상이 필요해 보입니다. Names 라는 클래스를 추가로 생성해 이러한 Validation 기능을 생성자에 두어 제공 해 보겠습니다.
    public class Names {
    	private final List<Name> names;
    
    	public Names(List<Name> names) {
    		validate(names);
    		this.names = names;
    	}
    
    	private void validate(List<Name> names) {
    		checkDuplication(names);
    	}
    
    	private void checkDuplication(List<Name> names) {
    		Set<Name> set = names.stream().collect(Collectors.toSet());
    		if(set.size() != names.size())
    			throw new IllegalArgumentException("중복된 이름이 존재합니다.");
    	}
    }
  • 테스트를 NamesTest로 변경하고 Names를 생성 할 때 중복된 이름이 있으면 생성되지 않도록 테스트를 통과시켰습니다. 이 과정에서 Name간의 객체를 비교하여 중복을 제거하기 때문에 (Set에서 내부적으로 Name끼리 비교하여 넣을 때) Name에서 equals를 오버라이딩하여 이름이 같으면 같은 객체로 인식하도록 변경하였습니다. (Name이 인스턴스의 식별자이기 때문에)

추가적으로 원시값(String name)을 포장한 이유

  • 객체지향 생활체조에서는 다양한 규칙이 있지만 그 중 원시값과 문자열은 포장한다 라는 규칙이 있습니다. 이름을 String name = "kyle" 의 형태로 작성할 수도 있지만 이런 경우 의미 없는 굉장히 추상화된 값이 됩니다. 추상화가 심해진다는 것은 프로그래머가 타 프로그래머에게 본인의 코드를 설명하기 위한 설명이 길어질 뿐 아니라 프로그램이 커질 수록 이는 거의 불가능에 가까워 집니다. 하지만 이를 클래스의 형태로 한 번 포장하게 되면 아주 사소한 값이라도 컴파일러 뿐 아니라 프로그래머에게 왜 그리고 어떤 값을 의미하는 지를 명확하게 전달 할 수 있게 됩니다.
  • 예를 들어 String 타입으로 이름과 주소를 입력 받는 경우 각각의 스트링이 이름과 주소를 표현한다는 것을 컴파일러가 알 수 없기에 입력을 잘 못하여 오류를 발생 시킬 수 도 있으며 타 프로그래머가 보았을 때도 명확하게 메서드명이나 변수명이 되어있지 않다면 오해의 소지가 많아 집니다. 하지만 두 원시 값을 모두 클래스로 포장하게 되면 클래스 생성자 단계에서 검증 혹은 Setter를 통한 검증 등의 방식을 통해 한 번 더 구체화되어 검증된 값을 제공하는 것이며 뿐만 아니라 컴파일러도 잘못된 사용을 파악할 수 있게 만드는 장점을 갖는 것 입니다.
  • 뿐만 아니라 IDE의 도구 지원을 받을 수도 있습니다. String이나 원시값에 어떠한 작업을 하여 원하는 형태로 가공 할 수 있겠지만 이는 일회성 작업이 될 확률이 높으며 이를 클래스 타입으로 포장하는 경우 IDE의 도구를 통해 쉽게 찾고, 수정하는 것이 가능 해 집니다. 물론 어떠한 클래스 내에 메소드의 형태로 (보통 Util 클래스의 메소드로) 하는 것도 가능 하지만 검증하는 기능과 같은 부분은 도메인 자체와 어울리기에 클래스로 분리하는 것이 좋은 경우가 많은 것 같습니다.

다음 포스팅 에서는 현재 작성한 코드를 조금 더 리팩토링하고 라운드와 랜덤 값 처리 부분을 추가하겠습니다.

0개의 댓글