독서/Java

자바 웹 프로그래밍 Next Step 2장 TDD 맛보기

윤도ri 2022. 8. 18. 21:58

저번 시간까지 Junit을 이용하여 리팩토링하는 것에 대해 배웠다. 책에서는 더 성장하고 싶을 시 추가적으로 TDD의 개념에 대해 학습하고 실습해볼 것을 추천하여 이렇게 글을 정리하게 되었다.

이 자료는 테스트 주도 개발 : 고품질 쾌속 개발을 위한 TDD 실천법과 도구” 책의 1장에 대한 내용이다.

1. 테스트 주도 개발(TDD)

TDD란?

  • Test-Driven Development 의 약자이며 테스트 주도 개발이라는 뜻이다.
  • 업무 코드를 작성하기 전에 테스트 코드를 만드는 것을 의미한다.

어렵게 들릴 수 있지만, 메소드나 함수 같은 프로그램 모듈을 작성할 때 작성 종료 조건을 먼저 정해놓고 코딩을 시작한다는 의미로 받아들이면 편하다.

이를테면, 두 숫자의 합을 구해서 반환하는 sum이라는 메소드를 작성한다고 가정해보자.

위 표는 만들고자 하는 메소드를 일종의 설계문서처럼 간단히 적어본 모습이다. 사실 우리가 프로그램을 작성할 때 머릿속으로 생각하는 내용과 별반 다르지 않다. 다만 ‘문서로 만들어 머리로 생각하고 눈으로 확인할 것인가?’ 아니면, ‘예상 결과를 코드로 표현해놓고 해당 코드가 자동으로 판단하게 할 것인가?’의 차이가 있다.

위 설계문서에 따라 sum 메소드를 작성할 때,코드를 통해 정상적으로 구현됐는지를 판단하는 방법을 선택한다면 아래와 같은 코드로 작업할 수도 있다.

package tdd;

public class Calculator {
	public int sum(int a , int b) {
		return 0 ;
	}
	public static void main(String[] args) {
		Calculator cal = new Calculator();
		System.out.println(cal.sum(10,20)==30);
		System.out.println(cal.sum(1,2)==3);
		System.out.println(cal.sum(-10,20)==10);
		System.out.println(cal.sum(0,0)==0);
	}
}


위 예제에서는 main 메소드를 테스트 메소드처럼 사용했다. sum 메소드는 컴파일 에러만 나지 않도록 해놓고 내부는 비어 있는 상태다. sum 메소드를 먼저 구현한 다음 테스트 할 수 있었지만 그렇게 하지 않고 검증코드를 먼저 만들었다. 그 검증 코드에 해당하는 테스트 케이스가 모두 만족하면 정상적으로 작성됐다고 판단하기로 한 것이다. 다시 말해, 명시적인 코드로 개발의 종료조건을 정해놓은 것이다. 이런식의 개발 접근 방식이 바로 TDD이다.

2. 테스트 주도 개발의 목표

" Clean code that works "


우리가 TDD를 통해 얻고자 하는 최종 목적은 ‘잘 동작하는 깔끔한 코드’이다.

일반적인 소프트웨어 개발의 목표는 제대로 동작함이지만 TDD방식은 추가적으로 깔끔해야 한다. 이 차이점은 소프트웨어의 품질,유지보수의 편의성, 가독성, 그에 따른 소프트웨어 비용 등 여러 가지 측면을 내포한다.

3. 테스트 주도 개발의 기원

: 애자일 개발 방식 중 하나인 XP의 실천 방식 중 하나

1) 애자일 개발이란?

  • 애자일(agile)이란 ‘민첩한, 기만한’의 뜻을 갖는다.

애자일 개발은 말 그대로, 좀 더 민첩하고 유연하게 개발에 임하는 것을 말하며, 개발 그 자체에 집중할 수 있도록 개발환경을 조성한다.

2) TDD 기원

TDD는 XP에서 등장하는 여러가지 실천 방법 중 하나로 테스트 우선 개발과 동일한 의미를 갖는다.

XP는 2000년대 초반에 급부상한 애자일 소프트웨어 개발론의 하나로 단순성, 상호소통,피드백 등 원칙에 기반해 ‘고객에게 최고의 가치를 최대한 빠르게 전달하는 것’을 목표로 삼는다.

다만 XP는 일부분 극단적인 실천 방법을 요구했기도 하여 XP의 유용한 기법들을 다른 애자일 방법론과 혼용해서 적용하는 상태가 많다.

4. 테스트 주도 개발의 진행 방식

Ask → Response → Refine

1) 질문(Ask)

테스트 작성을 통해 시스템에 질문한다. (수행 결과는 실패)

2) 응답(Response)

테스트를 통과하는 코드를 작성해서 질문에 대답한다. (테스트 성공)

3) 정제(Refine)

아이디어를 통합하고, 불필요한 것을 제거하고, 모호한 것은 명확히 해서 대답을 정제한다.
(리팩토링)

4) 반복(Repeat)

다음 질문을 통해 대화를 계속 진행한다.

동일한 내용을 다이어그램으로 도식화 해보면 위와 같다. 흔히 단위 테스트 JUnit을 사용한 테스트 코드 작성이 이루어진다고 생각하면 된다.

5. 실습 시작해보기

| 목표

  1. TDD의 기본적인 진행 방식을 익힌다.
  2. 시간을 절약시켜줄 이클립스 IDE의 관련 기능을 익힌다.
  3. TDD를 진행할 때 발생할 수 있는 몇 가지 기본적인 고려사항을 살펴본다.

| 실습 예제: 은행 계좌(Account)클래스 만들기

  • 기능

- 클래스 이름은 Account이다
- 계좌 잔고 조회

1. 질문: 계좌 생성 테스트

  • 구현해야 할 기능을 파악하고, 목록을 작성한다.
  • 계좌 생성 구현을 위한 최초의 테스트 케이스를 만들고 실패하는 모습을 확인한다.

TDD에서는 최하위 모듈인 ‘메소드’에서부터 단위 테스트를 작성하게 된다. 작성하고자 하는 메소드나 기능이 무엇인지 선별하고, 작성 완료 조건을 정해서 실패하는 테스트 케이스 코드를 작성해야 한다.

1) 구현해야 할 기능 파악 및 목록 작성

  • 클래스 이름은 Account 이다
  • 기능 : 잔고 조회, 입금, 출금 (금액은 원 단위로)

2) 최초의 테스트 케이스 만들고 실패해보기

  • 테스트 클래스를 만들고 테스트 케이스를 만들어라
  • 테스트 케이스는 테스트하고자 하는 대상에 대해 간단한 시나리오를 만들고 코드로 표현한 모습이다.

( 시나리오: 계좌를 생성한다 → 계좌가 정상적으로 생성됐는지 확인한다.)

  1. AccountTest() 클래스를 만들고 메소드로 testAccount()를 만든다.
  2. 시나리오의 첫번째가 계좌를 생성하는 것이므로 계좌 만들기.
  3. Account 클래스 안 만들어서 무슨 타입인지 못 알아듣겠다고 에러가 뜬다.
package test;

public class AccountTest {
	
  @Test
	public void testAccount() {
		Account account = new Account();
	}
}

3) 실패한 부분 원인 찾아서 해결하기 (Response)

  1. 계좌 생성 메소드 구현
package test;

import java.Account;

public class AccountTest {

	public void testAccount() throws Exception {
		Account account = new Account();
		if (account == null) {
			throw new Exception("계좌생성 실패");
		}
	}

	public static void main(String[] args) {
		AccountTest test = new AccountTest();
		try {
			test.testAccount();
		} catch (Exception e) {
			System.out.println("실패");
			return;
		}
		System.out.println("성공");
	}
}


개발이 시작되기도 전에 실패가 발생하는 상황부터 보고 시작하라고 하니 당황스러운것 같다. 보통 실패를 최대한 하지 않기 위해 노력하는 경우가 더 많다. 하지만 TDD는 실패를 통해 배움을 늘려가는 기법이다. OK 조건을 사전에 정해두고 빠르게 실패를 경험하며 빠르게 극복하고자 한다. 성공한 항목과 실패한 항목이 명확하고, 작업해야 하는 부분이 확실해진다. 성공에 필요한 조건을 만들고, 실패하는 조건 항목을 성공시킨다. 그래서 빨리 실패하면 실패할수록 좀 더 성공에 가까워지는 개발 방식이다.

4) Refine

  • 리팩토링을 적용할 부분이 있는지 찾아본다.
  • ToDo 목록에서 완료된 부분을 지운다.

리팩토링을 하는 정제 단계에서는 일반적으로 몇가지 질문에 대해 고민해보는 시간을 갖는다.

  1. 소스의 가독성은 적절한가?
  2. 중복된 코드는 없는가?
  3. 이름이 잘못 부여된 메소드나 변수명은 없는가?
  4. 구조의 개선이 필요한 부분이 없는가?

위와 같은 질문을 해보면서, 안전그물 (=앞서 작성한 테스트 케이스)에 의존해 소스를 가다듬는 단계가 정제 단계이다. 지금은 워낙 소심하게 진행해서 정제할 것은 없다.

  1. 계좌를 만드는 기능 테스트 성공을 하였으므로 ToDo 리스트에서 지워준다.

- 클래스 이름은 Account이다.

사실 if문에서 account가 null값인 경우는 발생하지 않는다. 단지 시나리오의 흐름상 ‘검증’이라는 부분을 표현하기 위해 사용했을 뿐이다. 만일 계좌 생성하는 부분에 문제가 생긴다면 throw new Exception()을 안해도 자동적으로 예외가 던져지고 JUnit이 이 상황을 처리한다. 따라서 JUnit을 쓰면서 main()메소드를 없앨 수 있고 If문도 지운다.

package test;

import org.junit.Test;

import main.Account;

public class AccountTest {
	
	@Test
	public void testAccount() throws Exception {
		Account account = new Account();
		
	}
	
}

2.질문: 잔고 조회 테스트

  • 잔고 조회 기능 작성을 위한 테스트 케이스를 작성한다.
  • 수행 결과가 오류로 표시된 항목은 실패 항목으로 만든다.

1) 구현해야 할 기능 파악 및 목록 작성

테스트 시나리오를 조금 발전시켜보고자 한다.

  1. 10000원으로 계좌 생성
  2. 잔고 조회 결과 일치

반드시 초기 금액을 넣어야 계좌 생성할 수 있게 끔 만들었다.

2) 최초의 테스트 케이스 만들고 실패해보기

package test;

import static org.junit.Assert.fail;

import org.junit.Test;

import main.Account;

public class AccountTest {
	
	@Test
	public void testAccount() throws Exception {
		Account account = new Account();
		
	}
	@Test
	public void testGetBalance() throws Exception {
		Account account = new Account(10000); //여기서 오류뜸 
		if(account.getBalance() != 10000 ) { //메소드 생성 안되서 오류
				fail();
			}
		}
	
}

3) 실패한 부분 원인 찾아서 해결하기 (Response)

package test;

import static org.junit.Assert.fail;

import org.junit.Test;

import main.Account;

public class AccountTest {
	
	@Test
	public void testAccount() throws Exception {
		Account account = new Account(10000);
		
	}
	@Test
	public void testGetBalance() throws Exception {
		Account account = new Account(10000);
		if(account.getBalance() != 10000 ) {
				fail();
			}
		}
	
}
package main;

public class Account {
	private int balance;

	public Account(int i) {
		this.balance = i;
	}

	public int getBalance() {
		
		return this.balance;
	}
	
}

4) 두번째 정제하기 (Refine)

  • 구현된 잔고 로직에 대한 리팩토링 작업을 한다.

변경한 점

  • Account 클래스 생성자의 파라미터 변수명 i는 의미가 명확하지 않아 money로 바꿔주었다.
  • JUnit을 쓰면 예상값과 실제값을 비교할때 assertEquals 를 이용해서 비교할 수 있었다. 이 메소드를 쓰면 훨씬 코드가 간단해지고 편리하므로 if문 대신 바꾸어 주었다.
package main;

public class Account {
	private int balance;

	public Account(int money) {
		this.balance = money;
	}

	public int getBalance() {
		
		return this.balance;
	}
	
}
package test;

import static org.junit.Assert.fail;
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.Test;

import main.Account;

public class AccountTest {

	@Test
	public void testAccount() throws Exception {
		Account account = new Account(10000);

	}

	@Test
	public void testGetBalance() throws Exception {
		Account account = new Account(10000);
		assertEquals(10000, account.getBalance());

	}

}

6. TDD의 장점

1. 개발의 방향을 잃지 않게 유지해준다.

현재 자신이 어떤 기능을 개발하고 또 어디까지 와 있는지를 항상 살펴볼 수 있다. 그리고 남은 단계와 목표를 잊지 않게 도와준다. TDD를 진행할 때 만들어지는 테스트 케이스들은, 자신이 어디까지 왔고, 앞으로 나아가야 하는 곳이 어디인지를 알려주는 나침판이 된다.

2. 품질 높은 소프트웨어 모듈을 보유한다.

TDD를 통해 만들어진 애플리케이션은 필요한 만큼 테스트를 거친 품질이 검증된 부품을 갖게 되는 것과 마찬가지다. 실제로 TDD를 사용하지 않은 개발팀에 비해 TDD를 적용한 팀의 결함률이 최대 1/10 정도까지 감소됐다고 한다.

3. 자동화된 단위 테스트 케이스를 갖게 된다.

TDD의 부산물로 나오는 자동화된 단위 테스트 케이스들은 개발자가 필요한 시점에 언제든지 수행해볼 수 있다. 그리고 그 즉시 현재까지 작성된 시스템에 대한 이상 유무를 바로 확인할 수 있다. 또한 기능을 추가한다든가, 수정하게 됐을 때 수행해야 하는 회귀 테스트에 대한 부담도 줄어든다.

4. 사용설명서 & 의사소통의 수단

TDD로 작성된 각 모듈에는 테스트 케이스라고 하는 테스트 코드가 개발 종료와 함께 남게 된다. 이 테스트 코드들의 가치는 고객뿐만 아니라 현재의 자신, 주위의 개발자 그리고 미래의 개발자에게 제공되는 상세화되는 모듈 사용 설명서라는 부분도 포함되어 있다. TDD를 통해 작성된 테스트 케이스는 사용 설명서이자, 그와 동시아 다른 개발자와 소통하는 커뮤니케이션 통로가 된다.
TDD는 리팩토링과 단짝으로 진행되며, 리팩토링을 하면 최대 미덕은 사람이 이해할 수 있는 코드로 만들 수 있다.

5. 설계 개선

테스트 케이스 작성 시에는 클래스나 인터페이스, 이름 짓기, 인자 등에 이르는 개발에 포함된 다양한 설계 요소들에 대해 미리부터 고민하게 된다. 흔히 테스트하기 어렵다고 생각되는 코드들은 객체 설계 원리 중 기본에 해당하는 원칙들이 잘못 적용됐거나 충분히 고려되지 않았을 가능성이 높다. TDD를 진행하면서, 테스트가 가능하도록 설계 구조를 고민하다 보면 자연스럽게 디자인을 개선하게 된다.

미리 고민해보고 작성하는 것과 작성하면서 그때그때 추가하는 것은 모듈의 응집도와 의존성 정도에 있어 적지 않은 차이를 만들어 낸다.

6. 보다 자주 성공한다.

기본적으로 TDD는 매 주기를 짧게 설정하도록 권장한다. 그렇게 하면 목표를 이뤘다는 성취감을 느낄 수 있다. 또한 이런 성공 습관이 개발의 기초를 바꿀 수 있게 도와준다.

<정리>

TDD는 개발자가 목표를 세워 개발을 진행해나가고, 설계에 대해 지속적으로 고민할 수 있게 도와준다. 또한 그 와중에 정형화된 형태의 테스트 케이스들을 작성함으로써 테스트 과정을 자동화하고, 테스트의 수행 결과와 개발의 목표 달성 여부를 즉각적으로 알 수 있게 된다.

개발 완료 시점 이후에도 TDD의 부산물인 자동화된 단위 테스트 케이스를 이용한 지속적인 테스트가 가능해진다. 결과적으로 TDD는 개발에 좀 더 집중할 수 있게 도와주고, 프로그램의 안정성에 크게 기여한다.

글을 마치면서...

엉클 밥의 원칙을 들어보았는가?

엉클 밥이라는 별명으로 불리며 객체 지향 개발의 선구자인 로버트 마틴이 만든 원칙이다.
엉클 밥은 TDD의 원칙을 다음과 같이 말하고 있다.

  1. 실패하는 코드를 작성하기 전에는 절대로 제품 코드를 작성하지 않는다.
  2. 실패하는 테스트 코드를 한 번에 하나 이상 작성하지 않는다.
  3. 현재 실패하고 있는 테스트를 통과하기에 충분한 정도를 넘어서는 제품 코드를 작성하지 않는다.

로버트 마틴은 이 세가지 법칙이 TDD 개발의 주기를 30초-1분 사이로 유지시켜 준다고 말한다.

TDD의 주기가 짧아진다는 건 그만큼 리듬을 타고 빠르게 진행할 수 있도록 만들어준다는 의미이기도 한다. 위 원칙은 TDD를 시작하는 사람들에게 TDD가 습관이며 개발의 한 부분으로 자리잡을 수 있도록 도와주는 방법이기도 하다. 적극적으로 따라보도록 하자!




전체 소스 코드 ↓↓↓
https://github.com/yoondori2/java_book-web_pro_nextstep

출처: 테스트 주도 개발 : 고품질 쾌속 개발을 위한 TDD 실천법과 도구 책 1장
https://velog.io/@ganghee/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-TDD

'독서 > Java' 카테고리의 다른 글

자바 웹 프로그래밍 Next Step 2장  (0) 2022.08.17