본문 바로가기
개발상식

[개발상식] tdd란 (tdd 예제, tdd하는법)

by devjh 2021. 11. 16.
반응형

1. tdd란

test driven development의 약자로 테스트코드를 작성하여 프로그램이 잘못되었음을 증명하고,
잘못된 부분을 수정하여 목표에 부합할 만큼 참으로 만드는 개발 방법론입니다.
최종적으로 올바르지 않음을 증명하는데 실패하게되면(내가 설계한 모든 잘못된점을 수정하면)
비로소 올바름이 된다는 게 해당 tdd의 사상입니다.
그러나 테스트코드는 작성하여 버그가 있음을 보여줄뿐, 버그가 없음을 보여줄 수는 없습니다.
다만 tdd를 통해 미리 생각해낸 생기는 버그는 모두 잡아낼 수 있습니다.

최근에는 테스트코드가 동작을 테스트하기위해 사용될 뿐 아니라, 
jenkins 등의 ci 도구를 사용할때 test코드의 성공여부를 확인해 all green 인 경우에만
pr을 통과시키거나 운영브랜치로 merge시키는 등의 추가 동작을 하는데도 많이 사용됩니다.

예제는 단순히 하나의 컴포넌트를 테스트하고있지만
의존관계가 복잡해진다면 mockup을 할수 있도록, 테스트코드를 짜기 쉽도록, 설계도 고려해야합니다.
(추상에 의존해야 mockup이 쉽게 가능합니다)

2. tdd 과정

크게 질문 응답 정제 세가지 과정으로 나눠집니다.

(1). 질문
작성하고자 하는 메소드나 기능이 무엇인지 선별하고 작성 완료 조건을 정해서 실패 하는 테스트 케이스를 작성하는 행위 입니다.
tdd에서 가장 중요한 부분이자 가장 어려운 부분입니다.

모든 케이스를 예상하여 내가 짠 코드가 올바르지 않음을 증명해야합니다.

(최종적으로 내가 짠 코드가 올바르지 않음을 증명하는데 실패하도록)

(2). 응답
테스트 케이스를 통과하는 코드를 작성하는 과정입니다.
리턴타입은 null, 0 등으로 설정해놓고 (스켈레톤 구현) 시작합니다.

todo list를 작성하며 진행해 나갑니다.
실패한 테스트에 대해 All Green이 나오도록 작성해야 합니다.

(3). 정제
todo목록을 지우면서 리팩토링이 필요한지 확인합니다.
소스의 가독성이 적절한지
중복된 코드가 없는지
이름이 잘못 부여된 메서드나 변수명은 없는지
구조의 개선이 필요하지는 않은지를 확인합니다.

3. 예제

계좌생성, 입금, 출금, 잔고조회를 하는 컴포넌트를 tdd로 작성합니다.
java와 junit5를 사용하였지만 개념만 알고있다면 어떤 언어를 사용하든지 상관없습니다.

요구사항
1. 계좌생성하는 기능이 필요하며 생성과 동시에 입금을 하는 경우가 빈번합니다.
2. 계좌에 입금, 출금을 할 수 있어야합니다.
3. 잔고조회가 가능해야합니다.

- 질문
계좌 생성이 잘 되나요, 생성과 동시에 입금하는경우도 잘 동작하나요?
계좌 잔고 조회가 잘 되나요?
계좌에 입출금하고 잔고 조회가 잘 되나요?

중요한 질문을 하나더 설계했어야하는데 요구사항에 없어서 생각하지 못했다고 가정합니다.(아래에서 수정)

- 응답
all green을 만들며 개발 시작
정제(리팩토링)
테스트 케이스에서 중복된 코드가 없는지
구조의 개선이 필요하지는 않는가
junit을 더 활용할 수 있는지

- 실습진행순서
(1). component(AccountService)를 생성하려고 하면서 테스트에 실패한다.(질문)
(2). 컴포넌트를 생성한다.(응답)
(3). 생성자에 파라미터를 추가하면서(계좌를 생성하며 입금할수 있도록) 테스트에 실패(질문)
(4). 생성자에 아규먼트를 받을 수 있도록 개발(응답)
(5). 계좌 잔고조회를 하면서 메서드가 없어서 실패(질문)
(6). 계좌 잔고조회 개발(스켈레톤 타입으로 개발) (응답)
(7). 계좌 잔고조회가 스켈레톤이라 실패(질문)
(8). 계좌 잔고조회가 정상적으로 돌아가도록 개발(응답)
(9). 입금 질문

(10). 입금 응답

(11). 출금 질문

(12). 출금 응답

(13). @BeforeEach 어노테이션을 활용하여 테스트케이스 리팩토링(정제)
(14). assertThrow를 사용하여 Exception 테스트(질문)

(15). assertThrow를 사용하여 Exception 테스트(응답)

 

편의를 위해 하나의 두 파일을 하나의 코드블럭에 넣었습니다.

 

(1). component(AccountService)를 생성하려고 하면서 테스트에 실패한다.(질문)

(2). 컴포넌트를 생성한다.(응답)

public class AccountService {

}

class AccountServiceTest {

    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }
}

(3). 생성자에 파라미터를 추가하면서(계좌를 생성하며 입금할수 있도록) 테스트에 실패(질문)

class AccountServiceTest {
    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        AccountService accountService = new AccountService(10000);
        if (accountService == null) {
            fail();
        }
    }
}


(4). 생성자에 아규먼트를 받을 수 있도록 개발(응답)

public class AccountService {

    public AccountService(int money) {

    }

    public AccountService() {

    }
}

(5). 계좌 잔고조회를 하면서 메서드가 없어서 실패(질문)

class AccountServiceTest {
    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        AccountService accountService = new AccountService(10000);
        if (accountService == null) {
            fail();
        }
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        AccountService accountService = new AccountService(10000);
        if (accountService.getBalance() != 10000) {
            fail("잔고 조회 이상발견");
        }
    }
}

(6). 계좌 잔고조회 개발(스켈레톤 타입으로 개발) (응답)

public class AccountService {

    public AccountService(int i) {

    }

    public AccountService() {

    }

    public int getBalance() {
        return 0;
    }
}

 

(7). 계좌 잔고조회가 스켈레톤이라 실패(질문)

class AccountServiceTest {
    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        AccountService accountService = new AccountService(10000);
        if (accountService == null) {
            fail();
        }
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        AccountService accountService = new AccountService(10000);
        if (accountService.getBalance() != 10000) {
            fail("잔고 조회 이상발견");
        }
    }
}

(8). 계좌 잔고조회가 정상적으로 돌아가도록 개발(응답)

public class AccountService {

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

    public AccountService() {
    
    }

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

(9). 입금 질문

class AccountServiceTest {

    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        AccountService accountService = new AccountService(10000);
        if (accountService == null) {
            fail();
        }
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        AccountService accountService = new AccountService(10000);
        if (accountService.getBalance() != 10000) {
            fail("잔고 조회 이상발견");
        }
    }

    @Test
    @DisplayName("입금")
    void deposit() {
        AccountService accountService = new AccountService(10000);
        int depositMoney = 1200;
        accountService.deposit(depositMoney);
        if (accountService.getBalance() != 11200) {
            fail("입금실패");
        }
    }
}

(10). 입금 응답(스켈레톤으로 구현하고 다시 질문 후 만드는게 정석이지만 편의상 생략)

public class AccountService {

    private int balance;

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

    public AccountService() {

    }

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

    public void deposit(int depositMoney) {
        this.balance += depositMoney;
    }
}

(11). 출금 질문

class AccountServiceTest {

    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        if (accountService == null) {
            fail();
        }
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        AccountService accountService = new AccountService(10000);
        if (accountService == null) {
            fail();
        }
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        AccountService accountService = new AccountService(10000);
        if (accountService.getBalance() != 10000) {
            fail("잔고 조회 이상발견");
        }
    }

    @Test
    @DisplayName("입금")
    void deposit() {
        AccountService accountService = new AccountService(10000);
        int depositMoney = 1200;
        accountService.deposit(depositMoney);
        if (accountService.getBalance() != 11200) {
            fail("입금실패");
        }
    }

    @Test
    @DisplayName("출금")
    void withdraw() {
        AccountService accountService = new AccountService(10000);
        int withdrawMoney = 1200;
        accountService.withdraw(1200);
        if (accountService.getBalance() != 8800) {
            fail("출금실패");
        }
    }
}

(12). 출금 응답(스켈레톤구현은 생략)

public class AccountService {

    private int balance;

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

    public AccountService() {

    }

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

    public void deposit(int depositMoney) {
        this.balance += depositMoney;
    }

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

(13). @BeforeEach 어노테이션을 활용하여 테스트케이스 리팩토링(정제)

class AccountServiceTest {

    AccountService accountService;
    int initMoney = 10000;
    
    @BeforeEach
    void setup() {
        accountService = new AccountService(initMoney);
    }
    
    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        assertThat(accountService).isNotNull();
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        if (accountService == null) {
            fail();
        }
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        if (accountService.getBalance() != 10000) {
            fail("잔고 조회 이상발견");
        }
    }

    @Test
    @DisplayName("입금")
    void deposit() {
        int depositMoney = 1200;
        accountService.deposit(depositMoney);
        if (accountService.getBalance() != 11200) {
            fail("입금실패");
        }
    }

    @Test
    @DisplayName("출금")
    void withdraw() {
        int withdrawMoney = 1200;
        accountService.withdraw(1200);
        if (accountService.getBalance() != 8800) {
            fail("출금실패");
        }
    }
}

계좌를 생성하는 로직이 모든 테스트케이스마다 중복되었습니다.

@BeforeEach를 사용하여 한번만 생성하도록 합니다.

 

(12). assertThat을 사용하도록 변경(정제)

class AccountServiceTest {

    AccountService accountService;
    int initMoney = 10000;

    @BeforeEach
    void setup() {
        accountService = new AccountService(initMoney);
    }

    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        assertThat(accountService).isNotNull();
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        assertThat(accountService).isNotNull();
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        assertThat(accountService.getBalance()).isEqualTo(initMoney);
    }

    @Test
    @DisplayName("입금")
    void deposit() {
        int depositMoney = 1200;
        accountService.deposit(depositMoney);
        assertThat(accountService.getBalance()).isEqualTo(initMoney+depositMoney);
    }

    @Test
    @DisplayName("출금")
    void withdraw() {
        int withdrawMoney = 1200;
        accountService.withdraw(1200);
        assertThat(accountService.getBalance()).isEqualTo(initMoney-withdrawMoney);
    }
}

assertThat을 사용하면 로그에서 기대했던 값과 결과값이 다름을 직접 확인할수 있습니다.

더 직관적이라 fail("xx때문에 실패했습니다.")처럼 실패이유를 적어줘야 할 필요도 없습니다.

이제 tdd가 완료되었습니다.

이대로 이 컴포넌트는 테스트 커버리지가 100%가 나온 코드이며 ci가 성공적으로 수행되었고

운영서버에 반영되었습니다.

(13). assertThrow를 사용하여 Exception 테스트(질문)

package com.seminar.tdd.mytdd;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class AccountServiceTest {

    AccountService accountService;
    int initMoney = 10000;

    @BeforeEach
    void setup() {
        accountService = new AccountService(initMoney);
    }

    @Test
    @DisplayName("계좌생성")
    void newAccount() {
        AccountService accountService = new AccountService();
        assertThat(accountService).isNotNull();
    }

    @Test
    @DisplayName("계좌를 생성하며 입금")
    void newAccountWithMoney() {
        assertThat(accountService).isNotNull();
    }
    @Test
    @DisplayName("잔고조회")
    void getBalance() {
        assertThat(accountService.getBalance()).isEqualTo(initMoney);
    }

    @Test
    @DisplayName("입금")
    void deposit() {
        int depositMoney = 1200;
        accountService.deposit(depositMoney);
        assertThat(accountService.getBalance()).isEqualTo(initMoney+depositMoney);
    }

    @Test
    @DisplayName("출금")
    void withdraw() {
        int withdrawMoney = 1200;
        accountService.withdraw(withdrawMoney);
        assertThat(accountService.getBalance()).isEqualTo(initMoney-withdrawMoney);
    }

    @Test
    @DisplayName("잔고보다 많은 돈을 뽑으려고 한경우 Exception")
    void noMoney() {
        int withdrawMoney = 10001;
        assertThrows((RuntimeException.class), ()-> {
            accountService.withdraw(withdrawMoney);
        });
    }
}

단순히 요구사항만 보고 질문 리스트를 대충 작성하고 시작하면 이런 문제를 야기할 수 있습니다.

'이 개발건은 tdd로 개발했고 테스트 커버리지는 100%고 버그가 없어' 라고 생각했다면

이 계좌를 사용하는 사용자는 돈이 하나도없는데 1억을 출금해 갈 것입니다.

 

tdd는 내가 설계한 모든 올바르지 못한 점을 수정해 나가며

올바르지 않음을 증명하는데 실패하게되면 비로소 올바르다는 가정입니다.

즉 내가 설계한 모든 올바르지 못한 점에 부족한 부분이 있었다면 장애가 발생할 수 있으며

 

질문을 잘 만드는게 무엇보다 중요하고 어렵습니다.

 

결론은 잔고보다 많은 돈을 뽑으려고 한 경우 exception이 발생해야 합니다.

(14). assertThrow를 사용하여 Exception 테스트(응답+정제)

public class AccountService {

    private int balance;

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

    public AccountService() {

    }

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

    public void deposit(int depositMoney) {
        this.balance += depositMoney;
    }

    public void withdraw(int withdrawMoney) {
        if (this.balance < withdrawMoney) {
            throw new RuntimeException();
        }
        this.balance -= withdrawMoney;
    }
}

 

tdd가 완료되었습니다.

 

앞으로 추가 기능이 들어오거나 수정건이 들어오면 test코드부터 작성하도록 합니다.

기능부터 작성하고 브라우저나 postman을 사용하여 api를 보내고, 코드의 동작을 확인한후

테스트코드를 작성하려고하면 테스트 코드를 작성하는게 죽을만큼 싫어질 수도 있습니다.

브라우저나 postman을 사용하여 디버깅하지 않고 테스트코드를 통해 디버깅 하는 습관을 만듭시다.

 

4. spring과 junit을 사용하는경우 추가로 알아놓으면 좋은 내용

(1). 대표 라이브러리

- junit5
spring boot에 내장되어있으며 정식이름은 jupiter입니다

- Mockto
mock 객체를 만들어주는 라이브러리이며
인터페이스의 구현체를 내가 원하는데로 조작할수 있도록 도와줍니다.

- AssertJ
결과가 참인지 거짓인지 판별하기 위해 사용합니다.


(2). test 종류 및 방법

- 통합테스트
@springboottest 어노테이션을 사용합니다.
spring환경과 똑같은 환경으로 테스트가 가능합니다.
상태기반 테스트라고도 합니다.

- 단위테스트
@webmvctest 및 직접 객체를 생성하여 테스트합니다.
의존하고 있는 모듈을 테스트 더블로 처리하여 원하는 기능만 테스트 할 수 있습니다.
spring에 최소한만 의존하므로 테스트가 훨씬 가볍습니다.
행위기반 테스트라고도 합니다


(3). junit의 헷갈리기 쉬운 어노테이션 및 mockup시 사용하는 라이브러리

@Mock, @MockBean차이
Mock은 스프링에서 관리하지 않는 테스트 스텁이며 직접 생성후 주입해줘야 합니다.
MockBean은 스프링 컨테이너에서 관리해주므로 직접 DI를 해줄 필요가 없습니다.
mockito, assertj
given when then 의 방식으로 테스트를 진행합니다.


(4). 통합테스트, 단위테스트(controller, service) 테스트 방법 정리

 

Junit5(jupiter) Controller 테스트코드 작성법 (WebMvcTest, MockMvc, MockBean을 사용한 테스트)

Mock객체를 통한 행위기반 컨트롤러 테스트코드 작성법에 대해서 알아보겠습니다. 1. Mock 객체란? 실제 객체를 만들어 사용하기에 시간, 비용 등의 Cost가 높은경우 사용 가짜객체를 만들어 가짜객

frozenpond.tistory.com

 

 

Junit5(Juptier) Service 테스트코드 작성법(Mock, MockBean 차이점 확인)

서비스코드 테스트작성법에 대해 정리하며 Mock과 MockBean의 차이를 알아보겠습니다. Mock vs MockBean 공통점 둘다 가짜객체이며 테스트스텁의 한 종류입니다. given, when. verify 등을 사용하여 행위를

frozenpond.tistory.com

 

 

Junit5(jupiter) 테스트코드 작성법(@SpringBootTest)

이번게시글에서는 @SpringBootTest 어노테이션을 활용한 통합테스트(상태기반 테스트) 방법을 알아보겠습니다. 어플리케이션의 모든 Bean을 로드하기에 실제 운영환경과 비슷한 환경이라는 장점이

frozenpond.tistory.com

 

반응형

댓글