TDD 테스트 주도 개발 1

@t189216·2024년 1월 28일
0

💻 CS

목록 보기
12/36
post-thumbnail

참고 - 테스트 주도 개발 TDD 실천법과 도구

참고 영상

테스트 주도 개발 (TDD)


프로그램을 작성하기 전에 테스트를 먼저 진행하는 방식을 말한다. 코드를 검증하는 테스트 코드를 만든 다음, 실제 작성해야 하는 프로그램에 코드를 작성한다.

❗ 테스트 주도 개발의 기원


TDDXP 에서 등장하는 여러 가지 실천 방법 중 하나로, 테스트 주도 개발과 동일한 의미를 갖는다. XP 는 2000년대 초반에 급부상한 애자일 소프트웨어 개발론 의 하나로 단순성, 상호소통, 피드백, 용기 등의 원칙에 기반해 '고객에게 최고의 가치를 최대한 빨리 전달하는 것'을 목표로 삼으며, 애자일 개발론 에는 TDD, 짝 프로그래밍, 일일빌드, 지속적인 통합, 단위 테스트 등 다양한 실천 방법을 제시하고 있다.

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


  1. 질문(Ask): 테스트 작성을 통해 시스템에게 개발자가 질문한다. (테스트 수행 결과는 실패 (시스템이 실패했다고 대답) )
  2. 응답(Respond): 테스트를 통과하는 코드를 작성해서 질문에 대답한다. (테스트 성공)
  3. 정제(Refine): 아이디어를 통합하고 불필요한 것을 제거하고, 모호한 것은 명확히 해서 대답을 정제한다. (리팩토링)
  4. 반복(Repeat): 다음 질문을 통해 대화를 계속 진행한다.

TDD를 이용한 개발은 크게 질문 ➡️ 응답 ➡️ 정제 라는 세 단계가 반복적으로 이루어진다.

흔히 단위 테스트 프레임워크를 사용한 테스트 코드 작성이 이뤄진다고 생각하면 된다.

💡 TDD에서는 테스트 자동화를 통해서 개발이 시작된 시점부터 완료될 때까지 가능한 한 빠른 시점 내에 그리고 자주 실패를 경험하도록 유도하고 있다. TDD는 실패를 통해 배움을 늘려가는 기법이다. 그래서 빨리 실패하면 실패할수록 좀 더 성공에 가까워진다.

테스트 주도 개발 실습


은행 계좌 클래스를 TDD 방식으로 제작해볼 것이다.

🔻사용한 Github 레포지터리

1. 질문(Ask) - 테스트 작성

요구사항에는 다음과 같은 것이 있을 것이다.

  • 잔고 조회
  • 입출금 관리
  • 예상 복리 이자 (추가 개발)

질문 단계에서는 메모장이나 마인드맵 등을 활용하면 도움이 된다. 테스트 케이스를 작성하는 방식은 두 가지 정도가 있다.

  • 구현 대상 클래스의 외형에 해당하는 메소드들을 먼저 만들고 테스트 케이스를 일괄적으로 만드는 방식
  • 테스트 케이스를 하나씩 추가해나가면서 구현 클래스를 점진적으로 만드는 방식 (대부분의 TDD 방식✅)

💡 TDD에서는 하나의 테스트 케이스가 하나의 기능을 테스트하도록 만드는 것이 기본 원칙이다.

먼저 계좌 생성에 대한 테스트 케이스 시나리오는 다음과 같다.

계좌를 생성한다 ➡️ 계좌가 정상적으로 생성됐는지 확인한다.

class AccountTest {

	@Test
    @DisplayName("계좌 생성")
	void testAccount() {
    	Account account = new Account();
   	}
}

당연히 Account 클래스를 만든 적이 없으니 오류가 발생한다. 일단은 넘어간다. 테스트 시나리오를 코드로 기술하는 작업을 마치고, 그 다음 해결한다.

계좌가 정상적으로 생성됐는지 확인해야 한다.

class AccountTest {

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

여전히 실패한다. 지금 만들어진 코드는 Account 클래스의 생성자 메서드 Account()에 대한 테스트 코드에 해당한다. 즉, 만들고자 하는 메서드의 예상 동작을 시나리오에 기반하여 코드를 먼저 만들어놓은 모습이다.

2. 응답(Respond)

실패한 테스트를 성공해보자. Account 클래스를 만들고 예외를 try ~ catch 구문으로 감싸보기로 했다.

class AccountTest {

    @Test
    @DisplayName("계좌 생성")
    public void testAccount() throws Exception {
        Account account = new Account();

        if (account == null) {
            try {
                throw new Exception("계좌생성 실패");
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

테스트가 성공했다!

3. 정제(Refine)

리팩토링: 정상적으로 동작하는 코드를 수정해서 좀 더 이해하기 쉽고, 변경하기 용이한 구조로 소스코드를 개선하는 작업을 말한다.

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

이렇게 질문 ➡️ 응답 ➡️ 정제 라는 단계를 반복하면 된다.

다시 1. 질문(Ask) - 테스트 작성

테스트 시나리오를 다시 작성해보자.

  • 잔고 조회
    • 10,000원으로 계좌 생성
    • 잔고 조회 결과 일치
  • 입출금 관리
  • 예상 복리 이자 (추가 개발)

잔고 조회를 위한 테스트 코드를 작성하자.

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

@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
    Account account = new Account(10000);

    if (account.getBalance() != 10000) {
        fail();
    }
}

fail()은 Junit에서 제공하는 메서드인데, 호출되면 해당 테스트 케이스는 무조건 실패한다. Account 클래스도 수정한다.

public class Account {
    public Account(int i) {
    
    }

    public int getBalance() {
        return 0;
    }
}

수정하니 testAccount 클래스에서 에러가 발생한다. Account(int i)라는 생성자가 생겼기 때문이다. 이 경우 Java에서는 기본 생성자를 명시적으로 만들어주지 않으면 Account()를 호출할 수 없다.

여기서 업무적으로 생각할 필요도 있다. 생성자를 만들면 '초기 예치금액 없이 계좌를 만들 수 있다.'라는 의미이다. 일단 초기 입금액이 없다면 계좌를 생성할 수 없게끔 한다.

@Test
@DisplayName("계좌 생성")
public void testAccount() throws Exception {
    Account account = new Account(10000);

    if (account == null) {
        try {
            throw new Exception("계좌생성 실패");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

시스템에 대한 질문이 완성됐다.

2. 응답(Respond)

지금 우리가 작성한 코드에서 계좌에 10000원이 없으면 테스트는 실패한다. 그럼 10000을 돌려주도록 하드코딩으로 작성하면 어떻게 될까?

public class Account {
    public Account(int i) {
    }

    public int getBalance() {
        return 10000;
    }
}

테스트를 통과했다! 하지만 이렇게 해도 되는걸까..?

  1. 테스트 케이스를 엉성하게 만들면 테스트 자체를 신뢰할 수 없게 된다.
  2. 테스트 케이스를 통한 제품 코드 구현을 하드코딩으로 시작하는 것도 괜찮은 출발점이다.

신뢰할 수 없는 테스트 케이스이지만 우선 넘어가자. 정제 단계에서 리팩토링을 통해 수정해보면 된다. 그럼 신뢰할 수 있는 테스트는 어떻게 만들 수 있을까?

3. 정제(Refine)

테스트 케이스를 보강해보자.

@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
    Account account = new Account(10000);

    if (account.getBalance() != 10000) {
        fail();
    }
    
    account = new Account(1000);
        if (account.getBalance() != 1000) {
            fail();
    }
    
    account = new Account(0);
        if (account.getBalance() != 0) {
            fail();
    }
}

테스트가 실패했다! 로직에 문제가 있음을 알 수 있다.

❗️ TDD에서는 실패를 두려워하면 안된다. 빨리 실패하면 실패할수록 좀 더 성공에 가까워진다는걸 기억하자.

Account 클래스를 수정하자.

public class Account {
    private int balance;
    public Account(int i) {
    }

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

해당 부분에서 오류가 발생한다. getBalance()가 얼마를 돌려주는지 확인해보자.

@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
    Account account = new Account(10000);

    if (account.getBalance() != 10000) {
        fail("getBalance() => " + account.getBalance() );
    }

0이 출력된다. Account 클래스에서 계좌를 생성할 때 입력받는 금액을 내부 필드에 저장하는 로직을 추가한다.

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

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

테스트를 통과했다!

하지만 조금 더 리팩토링 해보자.

assertEquals(예상값, 실제값)
assertEquals("설명값", 예상값, 실제값)

Junit 에서 제공하는 메서드이다. assertEquals()를 활용하면 코드를 이렇게 수정할 수 있다.

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

@Test
@DisplayName("잔고 조회")
public void testGetBalance() throws Exception {
    Account account = new Account(10000);
    assertEquals(10000, account.getBalance());

    account = new Account(1000);
    assertEquals(1000, account.getBalance());

    account = new Account(0);
    assertEquals(0, account.getBalance());
}

또 다시 1. 질문(Ask) - 테스트 작성

테스트 시나리오를 좀 더 구체화해보자.

  • 잔고 조회 ✅
    • 10,000원으로 계좌 생성
    • 잔고 조회 결과 일치
  • 입금
    • 10,000원으로 계좌 생성
    • 1,000원 입금
    • 잔고 11,000원 확인
  • 출금
    • 10,000원으로 계좌 생성
    • 1,000원 출금
    • 잔고 9,000원 확인
  • 예상 복리 이자 (추가 개발)

테스트 코드를 작성해보자.

@Test
@DisplayName("입금")
public void testDeposit() throws Exception {
        Account account = new Account(10000);
        account.deposit(1000);
        assertEquals(11000, account.getBalance());
}

@Test
@DisplayName("출금")
    public void testWithdraw() throws Exception {
        Account account = new Account(10000);
        account.withdraw(1000);
        assertEquals(9000, account.getBalance());
}
// Account.java
public void deposit(int i) {
}

public void withdraw(int i) {
}   

두 개의 테스트 케이스 모두 실패했다!

❗️ 일반적으로 TDD에서는 실패하는 테스트를 여러 개 만들어놓고 진행하는걸 권장하지 않는다.

2. 응답(Respond)

// Account.java
public void deposit(int i) {
    this.balance += i;
}

public void withdraw(int i) {
    this.balance -= i;
} 

3. 정제(Refine)

account를 필드로 옮기고, setup() 을 추출해 중복을 없애자.

@SpringBootTest
class AccountTest {
    private Account account;

    @Test
    @DisplayName("계좌 생성")
    public void testAccount() throws Exception {
        setup();

        if (account == null) {
            try {
                throw new Exception("계좌생성 실패");
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    @Test
    @DisplayName("잔고 조회")
    public void testGetBalance() throws Exception {
        setup();
        assertEquals(10000, account.getBalance());

        account = new Account(1000);
        assertEquals(1000, account.getBalance());

        account = new Account(0);
        assertEquals(0, account.getBalance());
    }

    public void setup() {
        account = new Account(10000);
    }

    @Test
    @DisplayName("입금")
    public void testDeposit() throws Exception {
        setup();
        account.deposit(1000);
        assertEquals(11000, account.getBalance());
    }

    @Test
    @DisplayName("출금")
    public void testWithdraw() throws Exception {
        setup();
        account.withdraw(1000);
        assertEquals(9000, account.getBalance());
    }
}
profile
Today I Learned

0개의 댓글