Home 1장 테스트 주도 개발
Post
Cancel

1장 테스트 주도 개발

1.1 흔하디 흔한 소프트웨어 개발 방식

전통적인 소프트웨어 개발 방식에서는 문제가 발생하고, 문제를 해결하고, 검증을 위한 테스트를 진행시 대표적으로 ‘콘솔’ 화면에 값을 찍어보는 방식을 사용했다. 대개 이런 경우 작성된 코드의 문제 유무 판단을 개발자 자신의 두뇌에 상당 부분 의존하게 된다.

이러한 전통적인 개발 및 테스트에서는 다음의 문제들을 흔히 볼 수 있다.

  • 특정 모듈의 개발 기간이 길어질수록 개발자의 목표의식이 흐려진다.
  • 작업 분량이 늘어날수록 확인이 어려워진다.
  • 개발자의 집중력이 필요해진다.
  • 논리적인 오류를 찾기가 어렵다.
  • 코드의 사용방법과 변경 이력을 개발자의 기억력에 의존하게 되는 경우가 많다.
  • 테스트 케이스가 적혀 있는 엑셀 파일을 보며 매번 테스트를 실행하는 게 점점 귀찮아져서 점차 간소화하는 항목들이 늘어난다.
  • 코드 수정시에 기존 코드의 정상 동작에 대한 보장이 어렵다.
  • 테스트를 해보려면 소스코드에 변경을 가하는 등, 번거로운 선행 작업이 필요할 수 있다.
  • 그래서 소스코드 변경시 해야하는 회귀 테스트(기존 동작하던 부분의 소스코드 변경시 정상적으로 동작하는지 확인하기 위해 수행하는 테스트)는 희귀 테스트가 되기 쉽다.
  • 테스트는 개발자의 귀중한 노동력을 적지 않게 소모하게 된다.

1.2 테스트 주도 개발(TDD)

TDD는 프로그램을 작성하기 전에 테스트 먼저 하라! -켄트 벡 즉 ‘업무 코드를 작성하기 전에 테스트 코드를 먼저 만드는 것’으로 얘기할 수 있다. 메소드나 함수 같은 프로그램 모듈을 작성할 때 작성 종료조건을 먼저 정해놓고 코딩을 시작한다는 의미로 받아들이면 편하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Calculator {
  //컴파일시 에러만 나지 않도록 하고, 내부는 비워둔 상태
  public int sum(int a, int b) {
    return 0;
  }

  public static void main(String[] args) {
    //검증 코드를 먼저 작성하고 해당 조건이 모두 만족하면 sum 메소드가 정상적으로 작성된 것으로 판단한다.
    Calculator calc = new Calculator();
    System.out.println(calc.sum(10,20) === 30);
    System.out.println(calc.sum(1, 2) === 3);
    System.out.println(calc.sum(-10,20) === 10);
    System.out.println(calc.sum(0,0) === 0);
  }
}

TDD는 위와 같이 명시적인 코드로 개발 종료조건을 정해놓은 것이다. 테스트 케이스 작성으로 구현을 시작하는 것 그게 바로 TDD 이다.

1.3 테스트 주도 개발의 목표

TDD 방식을 통해 얻고자 하는 최종 목적은 잘 동작하는 깔끔한 코드 -론 제프리 이다. 즉 정상적으로 동작하는 코드만이 개발 목표가 아니라 작성된 코드도 명확한 의미를 전달할 수 있게 작성되어야 한다. 이는 소프트웨어의 품질을 비롯한 유지보수의 편의성, 가독성, 그리고 그에 따른 소프트웨어의 비용과 안정성 등 여러가지 측면의 의미를 내포한다.

1.4 테스트 주도 개발의 기원

애자일 개발 방식 중 하나인 XP의 실천 방식 중 하나다. XP는 애자일 소프트웨어 개발론의 하나로 고객에게 최고의 가치를 빨리 전달하는 것을 목표로 삼는다. 다양한 실천방법을 제시하고 있으나 일부 극단적인 실천 방법을 요구하기도 하여 모든 내용을 적용하는 기업은 많지 않다.

1.5 개발에 있어 테스트 주도 개발의 위치

개발에서 TDD는 개발자가 자신을 위해 처음으로 수행하는 테스트에 해당한다. TDD에서 개발자는 자신이 작성한 프로그램에 대해 메소드 또는 함수 단위 로 테스트(단위 테스트)를 수행하고, 이후 발생하는 테스트 단계에서의 결함발생 비용을 줄여준다.

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

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

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

1.7 실습 먼저 시작해보기

은행계좌 클래스 만들기

[요구사항]

  • 계좌잔고 조회
  • 입금/출금
  • 예상 복리 이자

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

TDD에서는 테스트의 최소 작성 단위를 최하위 모듈의 단위와 일치시킨다. Java 기준 최하위 모듈은 ‘메소드’이다. 질문 단계에서는 이 메소드 수준의 단위 테스트를 작성하게 된다. 실제로 해야하는 일은 작성하고자 하는 메소드나 기능이 무엇인지 선별하고 작성 완료 조건을 정해서 실패하는 테스트 케이스를 작성하는 것이다. 이때 리턴 타입은 기본 초기값 null, 0 등의 위주로 설정해놓으면 편하다.

뚜렷한 설계서가 없는 경우 구현해야 하는 기능과 유의 사항을 생각 나는대로 적는다 => 클래스의 이름은 Account, 기능은 잔고조회, 입금, 출금, 금액은 원단위로 한다.

테스트 케이스 작성시 두 가지 접근 방식이 있다.

  1. 구현 대상 클래스 외형에 해당하는 메소드들을 먼저 만들고 테스트 케이스를 일괄적으로 만드는 방식
  2. 테스트 케이스를 하나씩 추가해나가면서 구현 클래스를 점진적으로 만드는 방식 (책에서는 2번 방식으로 진행한다.)

테스트 케이스는 테스트하고자 하는 대상에 대해 간단한 시나리오를 만들고 그것을 코드로 표현한 모습이다. TDD에서는 하나의 테스트 케이스가 하나의 기능을 테스트하도록 만드는 것이 기본 원칙이고, 대부분 하나의 테스트 케이스는 하나의 메소드로 표현된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package test;
public class AccountTest {
    //1. 계좌를 생성한다 -> 계좌가 정상적으로 생성되었는지 확인한다.
    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();
        test.testAccount(); //테스트 케이스 실행
    }
}

main 메소드를 실행하여 올바르게 동작하는지 확인한다. 이 작업이 바로 시스템에 대해 개발자가 하는 질문이다. 하지만 Account 클래스가 없으므로 시스템에서는 에러를 발생한다. 따라서 이제 시스템의 메시지에 응답할 시간이다.

1) 응답

위에서 실행한 테스트 케이스는 에러와 함께 실패한다. 따라서 이번엔 테스트를 성공시켜본다.

1
2
3
package main;
public class Account {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package test;
import main.Account;
public class AccountTest {
    //1. 계좌를 생성한다 -> 계좌가 정상적으로 생성되었는지 확인한다.
    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("실패(X)");
            return;
        }
        System.out.println("성공(O)");
    }
}

1) 최초의 정제

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

리팩토링을 수행하게 되는 정제 단계에서는 다음과 같은 질문에 대해 고민해보는 시간을 갖는다.

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

(현재 예제에서는 리팩토링이 딱히 필요 없으므로 넘어간다.)

JUnit 단위테스트 프레임워크 적용

코드내에 main 메소드를 지운 뒤 testAccount() 메소드 위에 @Test 애노테이션을 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package test;
import main.Account;
import org.junit.Test;

public class AccountTest {

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

JUnit은 테스트 케이스 실행의 성공과 실패를 글자 대신에 색깔이 있는 막대로 표시해준다. 실패일 경우 녹색 대신 붉은색 막대로 표시한다. JUnit의 기본 사상 중 하나는 테스트의 성공 여부를 글자를 이해해서 머리로 판단하는 것이 아니라 O/X 개념의 막대로 단순하게 판단하도록 만든다는 것이다. 따라서 개발자의 오해와 잘못된 판단의 여지를 줄여준다.

**클래스 설계시 중요한 것은 속성이 아니라 동작이다. 동작을 먼저 정하고, 동작에 필요한 속성을 고현하는 식으로 접근하는 것이 불필요한 속성이 클래스 내에 섞여 들어가는 것을 줄여준다.

2) 질문 : 잔고조회

잔고 조회 테스트 시나리오 : 1만원으로 계좌 생성, 잔고 조회 결과 일치

1
2
3
4
5
6
7
8
//2. 1만원으로 계좌 생성 -> 잔고 조회 결과 일치
@Test
public void testGetBalance() throws Exception {
  Account account = new Account(10000);
  if(account.getBalance() != 10000) {
    fail(); //JUnit에서 제공하는 메소드, 호출시 해당 테스트 케이스는 무조건 실패한다.
  }
}
1
2
3
4
5
6
7
8
package main;
public class Account {
    public Account(int i) {
    }
    public int getBalance() {
        return 0;
    }
}

테스트 케이스 결과 중 오류와 실패의 차이점은 무엇일까? 실패는 AssertEquals 등의 테스트 조건식을 만족시키지 못했다는 것을 의미하고, 오류는 테스트 케이스 수행 중 예상치 못한 예외가 발생해서 테스트 수행을 멈췄다는 것을 뜻한다. 테스트 케이스가 가치를 지니기 위해서는, 어떠한 경우에도 테스트 케이스 그 자체는 정상적으로 끝까지 수행되어야 한다. 따라서 결과는 실제값이 예상값과 다르다는 신호인 실패가 나오도록 테스트 케이스를 작성해야 한다.

오류는 작성자가 의도하지 않은 예상치 못한 실패를 뜻하므로 이 경우엔 테스트 케이스 자체가 문제가 있음을 시사한다.

2) 응답

질문에 대한 응답으로 녹색 막대를 볼 수 있도록 계좌의 잔고를 알려주는 getBalance 메소드를 구현한다.

1
2
3
public int getBalance() {
  return 10000;
}

getBalance 메서드를 하드코딩하여 작성하였다. 이렇게 하드 코딩하면 테스트 케이스를 엉성하게 만들면 테스트 자체를 신뢰할 수 없게 된다. 하지만 하드코딩으로 시작하는 것도 괜찮은 출발점이다. 이렇게 하면 적어도 두 가지 이상의 테스트 케이스를 작성하도록 자연스럽게 유도해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//2. 1만원으로 계좌 생성 -> 잔고 조회 결과 일치
@Test
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();
    }
}

문제가 있는 부분에 대한 로직을 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
package main;
public class Account {
    private int balance;

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

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

2) 두 번째 정제

1
2
3
4
5
6
7
8
9
10
11
12
13
package main;
public class Account {
    private int balance;

    //파라미터 명을 의미있는 단어로 변경한다.
    public Account(int money) {
        this.balance = money;
    }

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

JUnit 테스트 프레임워크에서 제공하는 assertEquals() 메소드를 사용하면 if문을 사용할 필요없이 편리하게 값을 비교할 수 있다. assertEquals(예상값, 실제값),assertEquals("설명",예상값, 실제값)

1
2
3
4
5
6
7
8
9
10
11
@Test
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());
}

3) 질문 : 입금과 출금 테스트

테스트 시나리오 : 입금(10000원으로 계좌 생성, 1000원 입금, 잔고 11000원 확인) / 출금(10000원으로 계좌 생성, 1000원 출금, 잔고 9000원 확인)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//3-1. 입금(10000원으로 계좌 생성, 1000원 입금, 잔고 11000원 확인)
@Test
public void testDeposit() throws Exception {
    Account account = new Account(10000);
    account.deposit(1000);
    assertEquals(11000, account.getBalance());
}

//3-2. 출금(10000원으로 계좌 생성, 1000원 출금, 잔고 9000원 확인)
@Test
public void testWithdraw() throws Exception {
    Account account = new Account(10000);
    account.withdraw(1000);
    assertEquals(9000, account.getBalance());
}
1
2
public void deposit(int i) { }
public void withdraw(int i) { }

3) 응답

1
2
3
4
5
6
7
public void deposit(int i) {
    this.balance += i;
}

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

3) 정제

1
2
3
4
5
6
7
public void deposit(int money) {
    this.balance += money;
}

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

AccountTest 클래스 리팩토링 : 반복되는 Account 생성 부분을 따로 뺀다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package test;

import main.Account;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;

public class AccountTest {
    private Account account;
    //1. 계좌를 생성한다 -> 계좌가 정상적으로 생성되었는지 확인한다.
    @Test
    public void testAccount() throws Exception {
        setup();
    }

    //2. 1만원으로 계좌 생성 -> 잔고 조회 결과 일치
    @Test
    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());
    }

    private void setup() {
        account = new Account(10000);
    }
    //3-1. 입금(10000원으로 계좌 생성, 1000원 입금, 잔고 11000원 확인)
    @Test
    public void testDeposit() throws Exception {
        setup();
        account.deposit(1000);
        assertEquals(11000, account.getBalance());
    }

    //3-2. 출금(10000원으로 계좌 생성, 1000원 출금, 잔고 9000원 확인)
    @Test
    public void testWithdraw() throws Exception {
        setup();
        account.withdraw(1000);
        assertEquals(9000, account.getBalance());
    }
}

테스트에 사용할 자원이나 객체들을 준비해놓는 부분을 ‘픽스처’ 라고 부른다. JUnit에서는 BeforeAfter라는 개념(JUnit4부터 제공)으로 준비와 정리 작업에 해당하는 처리방법을 제공한다.

최종 : setup()메소드를 public으로 바꾸고 @Before 애노테이션을 붙인다, 각 테스트 케이스마다 setup();을 호출하던 메소드를 제거한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package test;

import main.Account;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;

public class AccountTest {
    private Account account;

    //테스트 케이스 실행되기 전에 가장 먼저 실행된다. 이때 접근자를 private -> public
    @Before
    public void setup() {
        account = new Account(10000);
    }

    //1. 계좌를 생성한다 -> 계좌가 정상적으로 생성되었는지 확인한다.
    @Test
    public void testAccount() throws Exception {
    }

    //2. 1만원으로 계좌 생성 -> 잔고 조회 결과 일치
    @Test
    public void testGetBalance() throws Exception {
        assertEquals(10000, account.getBalance());

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

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


    //3-1. 입금(10000원으로 계좌 생성, 1000원 입금, 잔고 11000원 확인)
    @Test
    public void testDeposit() throws Exception {
        account.deposit(1000);
        assertEquals(11000, account.getBalance());
    }

    //3-2. 출금(10000원으로 계좌 생성, 1000원 출금, 잔고 9000원 확인)
    @Test
    public void testWithdraw() throws Exception {
        account.withdraw(1000);
        assertEquals(9000, account.getBalance());
    }
}

**TDD는 간결함을 추구하는 경제성의 원리가 내포되어 있는 개발 방식이다. 따라서 현재 필요한 기능이 아니라면 절대 미리 만들지 말자, 현재 작성한 소스코드를 개선하는 데 좀 더 시간을 투자하자!

11.8 TDD의 장점

  1. 개발의 방향을 잃지 않게 유지해준다 : 현재 자신이 어떤 기능을 개발하고 있고, 또 어디까지 와 있는지를 항상 살펴볼 수 있다. 또 남은 단계와 목표를 잊지 않게 도와준다.
  2. 품질 높은 소프트웨어 모듈 보유 : 필요한 만큼 테스트를 거친 품질이 검증된 부품을 갖게 되는 것과 마찬가지다.
  3. 자동화된 단위 테스트 케이스를 갖게 된다 : 자동화된 단위 테스트 케이스들은 개발자가 필요한 시점에 언제든지 수행해볼 수 있다. 또 현재까지 작성된 시스템에 대한 이상 유무를 바로 확인할 수 있고, 회귀 테스트에 대한 부담도 줄어든다.
  4. 사용설명서 & 의사소통의 수단 : 테스트 코드들의 가치는 시간이 지나면서 두고두고 빛을 발한다. 이는 현재 자신과 주위의 개발자, 미래의 개발자에게 제공되는 상세화된 모듈 사용 설명서라는 부분도 포함된다.
  5. 설계 개선 : 테스트 케이스를 작성함으로써 개발에 포함된 다양한 설계 요소들에 대해 미리부터 고민하게 되고, 테스트가 가능하도록 설계 구조를 고민하다 보면 자연스럽게 디자인을 개선하게 된다.
  6. 보다 자주 성공한다 : TDD는 매 주기를 짧게 설정하도록 권장한다. 그러면 성공 케이스를 자주 보고, 성취감도 느낄 수 있다.

로버트 마틴은 반드시 따르기 원하는 TDD의 원칙을 다음과 같이 말한다.

  • 실패하는 테스트를 작성하기 전에는 절대로 제품 코드를 작성하지 않는다.
  • 실패하는 테스트 코드를 한 번에 하나 이상 작성하지 않는다.
  • 현재 실패하고 있는 테스트를 통과하기에 충분한 정도를 넘어서는 제품 코드를 작성하지 않는다.
This post is licensed under CC BY 4.0 by the author.

11장 웹 콘텐츠에서 사용하는 기술

2장 JUnit과 Hamcrest