TDD와 리팩토링을 잘하는 방법은 오직 연습이다. 하지만 무조건 연습을 많이 한다고 잘할 수 있을까?
무엇인가를 연습할 때는 의식적인 연습이 필요하다.
내가 사용하는 API 사용법을 익히기 위한 학습 테스트에서 시작
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class StringTest {
@Test
public void split() {
String[] values = "1.".split(",");
assertThat(values).contains("1");
values = "1,2".split(",");
assertThat(values).containsExactly("1","2");
}
@Test
public void subString() {
String input = "(1,2)";
String result = input.subString(1, input.length() -1);
assertThat(result).isEqualTo("1,2");
}
}
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class CollectionTest {
@Test
public void arrayList() {
ArrayList<String> values = new ArrayList<>();
values.add("first");f
values.add("second");
assertThat(values.add("third")).isTrue();
assertThat(values.size()).isEqualTo(3);
assertThat(values.get(0)).isEqualTo("first");
assertThat(values.contains("first")).isTrue();
assertThat(values.remove(0)).isEqualTo("first");
assertThat(values.size()).isEqualTo(2);
}
}
내가 구현하는 메소드 중 Input과 Output이 명확한 클래스 메소드(보통 Util 성격의 메소드)에 대한 단위 테스트를 연습한다.
알고리즘을 학습한다면 알고리즘 구현에 대한 검증을 단위테스트로 한다.
어려운 문제를 해결하는 것이 목적이 아니라 TDD 연습이 목적. 난이도가 낮거나 자신에게 익숙한 문제로 시작하는 것을 추천함
웹, 모바일 UI나 DB에 의존관계를 가지지 않는 요구사항으로 연습하는게 좋다(토이 프로젝트)
입력(input) | 출력(output) |
---|---|
null || "" | 0 |
"1" | 1 |
"1,2" | 3 |
"1, 2:3" | 6 |
Test Passes :arrow_right: Test Fails :arrow_right: Refactor
많은 개발자가 놓치는 것이 TDD연습을 실패하는 테스트를 만들고, 패스하고를 반복한다는 것이다. 이 중에서 가장 중요한 것은 리팩토링에 있다고 한다.
public class StringCalculatorTest {
@Test
public void null_또는_빈값() {
assertThat(StringCalculator.splitAndSum(null)).isEqualTo(0);
assertThat(StringCalculator.splitAndSum("")).isEqualTo(0);
}
@Test
public void 값_하나() {
assertThat(StringCalculator.splitAndSum("1")).isEqualTo(3);
}
@Test
public void 쉼표_구분자() {
assertThat(StringCalculator.splitAndSum("1,2")).isEqualTo(3);
}
@Test
public void 쉼표_콜론_구분자() {
assertThat(StringCalculator.splitAndSum("1,2:3")).isEqualTo(6);
}
}
/*
* 리팩토링 전 코드
*/
public class StringCalculator {
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
result = 0;
} else {
String[] values = text.split(",|:");
for(String value : values) {
result += Integer.parseInt(value);
}
}
return result;
}
}
테스트 코드는 변경하지 말고, 테스트 대상 코드(프로덕션 코드)를 개선하는 연습을 의식적으로 집중하여 한다.
public class StringCalculator {
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
result = 0;
} else {
String[] values = text.split(",|:");
for(String value : values) {
result += Integer.parseInt(value);
}
}
return result;
}
}
for(String value : values) {
result += Integer.parseInt(value);
}
현재 위 구문은 들여쓰기가 2인 곳이 있다. 이를 고쳐보자
public class StringCalculator {
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
result = 0;
} else {
String[] values = text.split(",|:");
result = sum(values); // 들여쓰기가 1로 유지된다!
}
return result;
}
private static int sum(String[] values) {
int result = 0;
for(String value : values) {
result += Integer.parseInt(value);
}
return result;
}
}
private static int sum(String[] values) {
int result = 0;
for(String value : values) {
result += Integer.parseInt(value);
}
return result;
}
위 메소드를 보면 현재 문자열을 숫자로 바꾼뒤 이를 result에 담는 두 가지의 일을 하고 있다.
public class StringCalculator {
public static int splitAndSum(String text) {
{...}
}
private static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i =0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
private static int sum(int[] numbers) {
int result = 0;
for(String number : numbers) {
result += number;
}
return result;
}
}
이렇게 메소드를 분리한다면 재사용이 쉽다. for문을 두 번 돌기는 하지만 대부분 구현하는 반복문은 데이터 크기가 크지 않기 때문에 성능 차이에 큰 영향을 끼치지 않는다.
public class StringCalculator {
public static int splitAndSum(String text) {
if (text == null || text.isEmpty()) {
return 0; // 바로 return을 하여 로컬 변수를 만들 필요가 없다!
}
// String[] values = text.split(",|:"); // + 로컬 변수가 필요한가?
// return sum(values);
return sum(toInt(text.split(",|:")));
}
private static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i =0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
private static int sum(int[] numbers) {
int result = 0;
for(String number : numbers) {
result += number;
}
return result;
}
}
public class StringCalculator {
public static int splitAndSum(String text) {
if(isBlank(text)) {
return 0;
}
return sum(toInts(split(text)));
}
private static boolean isBlank(String text) {
return text == null || text.isEmpty();
}
private static String[] split(String text) {
return text.split(",|:");
}
private static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i =0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
private static int sum(int[] numbers) {
int result = 0;
for(String number : numbers) {
result += number;
}
return result;
}
}
1줄 단위의 메소드를 만들어 처음 코드를 파악하는 이에게도 알기 쉽게끔 구현이 된다.
쉼표(,) 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환.
숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 Thorw 한다.
입력(input) | 출력(output) |
---|---|
null || "" | 0 |
"1" | 1 |
"1,2" | 3 |
"1, 2:3" | 6 |
"-1, 2:3" | RuntimeException |
public class StringCalculatorTest {
@Test
public void null_또는_빈값() {
assertThat(StringCalculator.splitAndSum(null)).isEqualTo(0);
assertThat(StringCalculator.splitAndSum("")).isEqualTo(0);
}
@Test
public void 값_하나() {
assertThat(StringCalculator.splitAndSum("1")).isEqualTo(3);
}
@Test
public void 쉼표_구분자() {
assertThat(StringCalculator.splitAndSum("1,2")).isEqualTo(3);
}
@Test
public void 쉼표_콜론_구분자() {
assertThat(StringCalculator.splitAndSum("1,2:3")).isEqualTo(6);
}
// +++ 음수값 테스트 추가 +++
@Test(expected = RuntimeException.class) {
public void 음수값() {
StringCalculator.splitAndSum("-1,2:3");
}
}
}
public class StringCalculator {
public static int splitAndSum(String text) {
{...}
}
private static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i =0; i < values.length; i++) {
numbers[i] = toInt(values[i]);
}
return numbers;
}
private static int[] toInt(String value) {
int number = Integer.parseInt(value);
if (number < 0) {
throw new RuntimeException();
}
return number;
}
}
private static int[] toInt(String value) {
int number = Integer.parseInt(value);
if (number < 0) {
throw new RuntimeException();
}
return number;
}
위 메소드를 클래스로 분리한다.
public class Positive {
private int number;
public Positive(String value) {
this(Integer.parseInt(value));
}
public Positive(int number) {
if (number > 0) {
throw new RuntimeException();
}
this.number = number;
}
}
Positive(양수)라는 클래스로 분리하므로써 string과 int로 생성자를 구현하였다. 이로 인해 Positive 클래스는 양수를 보장받을 수 있게된다. 따라서 다시 StringCalculator를 다음과 같이 수정할 수 있다.
public class StringCalculator {
{...}
private static Positive[] toPositives(String[] values) {
Postive[] numbers = new Positive[values.length];
for(int i = 0; i < values.length; i++) {
numbers[i] = new Positive(values[i]);
}
return numbers;
}
private static int sum(Positive[] numbers) {
Positive result = new Positive(0);
for (Positive number : numbers) {
result = result.add(number); // Positive.add(int number) 매소드 추가
}
return result.getNumber();
}
}
/*
* Positive add Method
*/
public class Positive {
{...}
public Positive add(Positive other) {
return new Positive(this.number + other.number);
}
public int getNumber() {
return number;
}
}
클래스를 포장하는 클래스를 만든다.
import java.util.Set;
public class Lotto {
private static final int LOTTO_SIZE = 6;
private final Set<LottoNumber> lotto;
private Lotto(Set<LottoNumber> lotto) {
if(lotto.size() != LOTTO_SIZE) {
throw new IllegalException();
}
this.lotto = lotto;
}
}
public class WinningLotto {
private final Lotto lotto;
private final LottoNumber no;
public WinningLotto(Lotto lotto, LottoNumber no) {
if(lotto.contains(no)) {
throw new IllegalArgumentException();
}
this.lotto = lotto;
this.no = no;
}
public Rank match(Lotto userLotto) {
int matchCount = lotto.match(userLotto);
boolean matchBonus = userLotto.contains(no);
return Rank.valueOf(matchCount, matchBonus);
}
}