JUnit로 검색한 결과 :: 시소커뮤니티[SSISO Community]
 
SSISO 카페 SSISO Source SSISO 구직 SSISO 쇼핑몰 SSISO 맛집
추천검색어 : JUnit   Log4j   ajax   spring   struts   struts-config.xml   Synchronized   책정보   Ajax 마스터하기   우측부분

회원가입 I 비밀번호 찾기


SSISO Community검색
SSISO Community메뉴
[카페목록보기]
[블로그등록하기]  
[블로그리스트]  
SSISO Community카페
블로그 카테고리
정치 경제
문화 칼럼
비디오게임 스포츠
핫이슈 TV
포토 온라인게임
PC게임 에뮬게임
라이프 사람들
유머 만화애니
방송 1
1 1
1 1
1 1
1 1
1

JUnit로 검색한 결과
등록일:2008-06-10 17:54:58
작성자:
제목:TDD(Test Driven Development) Tutorial


소개

TDD(Test Driven Development) Tutorial

  • author - 박응용
  • date - 2003/04/25

오랜 경험이 있는 프로그래머라도 TDD에 대한 얘기를 들어보지 못한 사람들이 많다. 언젠가 한번은 들어본적이 있겠지만 그냥 무심코 지나쳐 버렸을 것이다. 정보화 시대에 홍수처럼 쏟아지는 정보들의 틈바구니 속에서 그냥 그런 정보로 흘려지나가 버렸을 것이다.

본인이 강조하고 싶은 것은 TDD는 유행이 아니라는 것이다.

자바나 C 또는 파이썬 같은 언어들마저도 한시대의 유행처럼 되어 버릴 수 있겠지만 TDD는 그렇지 않다. 왜냐하면 TDD는 프로그래밍 언어들 사이에서 살아 숨쉬는 것이기 때문이다.

즉, 내공이라 할 만하다. 진정한 실력말이다. 진정으로 프로페셔널한 프로그래머가 되고자 한다면 TDD에 관심을 가져야만 한다.

아니 적어도 이 책에서 말하는 내용만이라도 한번쯤 귀 기울여보길 바란다. 당신에게 어떤 변화가 있을지 모를 일 아닌가?

TDD란 무엇인가?

TDD는 몰라도 XP(Extream Programming)에 대해서 들어본 사람은 많을 것이다.

XP는 프로젝트의 혁명적인 새로운 시각을 보여준다. 하나의 프로젝트를 진행해 나가는 모든 과정이 매우 신선하고 충격적이다. XP에 관한 보다 자세한 사항은 다음의 사이트를 참고하도록 하자.

본인은 프로그래머이기에 XP관련 서적을 읽으며 TDD에 관심을 갖게 되었다. TDD는 간단하게 말해서 코딩을 해나가는 절대적인 방법이라 할 만하다.

모든 테스트를 100%통과하는 코드, 스트레스가 없는 코드, 언제든지 수정이 용이한 코드, 상호 의사소통이 가능한 코드.

어떠한가?

테스트란 무엇인가?

Test란 무엇인가?

대부분의 많은 프로젝트는 프로젝트가 종료되는 시점에 테스트를 행한다. 그것도 마감일이 얼마남지 않은 빠듯한 시점에서 말이다. 그런식의 테스트는 완전하지도 않고, 불안하기 짝이 없다.

그리고 테스트가 끝난후에 시스템을 오픈한 후, 문제점이 발생하면 기존에 했던 테스트를 기억할 수도 없고, 믿을 수도 없기에 또다시 한번 거쳤던 테스트를 다시 해야만 한다.

TDD프로그래머는 이러한 테스트 방식을 싫어한다.

아주 작은 단위의 실제코드가 만들어지는 시점에 테스트는 되어야 하고, 통과되어야만 한다. (TDD는 실제코드 이전에 테스트 코드가 존재해야 한다.) 이런 작은 단위의 코드들이 점차로 증가 한다고 가정해 보자. 각각의 작은 단위의 코드들은 충분한 테스트를 거친 것이므로 적어도 믿을만한 것들이다. 문제가 발생한다면 새로이 추가된 부분 New Code에서 문제가 발생할 확률이 높은 것이다.

디버깅하기가 쉽지 않겠는가? 단지 새로운 부분에 대한 테스트를 좀 더 보강하면 될것이다.

테스트는 프로그래머에게 확신을 준다.

때때로 프로그래머는 자신이 작성한 코드가 어떻게 동작하는지 명확히 알 지 못할때가 있다. 자신이 알지 못하는 무언가에 의해서 프로그램이 정상적으로 동작하는 경우를 말한다.

이런 불명확함을 남겨 놓고 계속 코딩을 진행해 나간다면 불행을 키우는 일이 될 것이다. 시스템이 계속해서 발전할수록 숨어 있는 불명확함은 그 정체를 더욱더 꽁꽁 숨겨놓을 테니까 말이다.

TDD 프로그래머는 불명확함을 명확함으로 밝혀내는 힘을 가져야 한다. 그것의 원동력은 역시 테스트이다. 불명확함을 테스트 코드로 명확함으로 바꾸는 것이다.

TDD와 테스트

See also : http://c2.com/cgi/wiki?TestDrivenDevelopment

What is Tdd ?

그렇다면 TDD의 실체에 대해서 생각해 보도록 하자.

TDD는 자바 랭귀지를 배우거나 Xmlrpc등의 개념을 배우는 것과는 매우 다르다. 지식 기반이 아니라 경험에서 우러나오는 것이기 때문이다.

xmlrpc의 개념에 대해서 알게 되면 그것을 바로 써먹을 수 있지만 TDD는 사뭇 다르다. TDD가 무엇을 하는것인지 알게 되어도 능숙해지려면 꽤나 시간이 걸리기 때문이다.

무협지를 보라. 내공이 하루아침에 쌓이는 경우가 있는가?

TestDrivenDevelopment

대부분의 프로그래머가 가지고 있는 코딩 습관에 대해서 먼저 언급해 보자.

일단 구현해야 할 대상이 생긴다면 프로그래머는 자신의 노하우를 이용하여 최상의 프로그램을 만들어 낸다. 그리고 자신이 만든 코드가 제대로 동작하는지 테스트를 한다. 이것이 우리가 늘 사용해 왔던 일반적인 방법이다.

하지만 TDD는 이러한 순서를 완전히 뒤짚는다. 테스트를 먼저하고 코딩을 한다. 이것이 도대체 말이 되는 얘기인가? 실제코드가 없는데 어떻게 테스트를 하겠는가? 당연한 의문이다. 하지만 우습게도 TDD를 하는 모든 사람들은 테스트를 먼저하고 코딩을 한다. 테스트를 한다는 의미는 테스트 코드를 작성한다는 말이다.

테스트 코드를 먼저작성하고 그 테스트 코드를 통과하는 실제 코드를 단계적으로 만들어가면서 코딩을 하는 것이 바로 TDD이다.

테스트를 먼저하는 것과 나중에 하는것에는 엄연한 차이가 있다. 테스트를 나중에 하는 것은 코드 종속적인 성향이 강하고, 테스트를 먼저 하는 것은 자신의 의도대로 코드가 만들어 지길 기대한다는 능동적인 느낌이다.

능동적인 코딩과 피동적인 코딩.

이것은 매우 큰 차이이다.

TestDrivenDevelopment는 초기에 Test First Programming으로 불리웠다. 테스트를 먼저 한다는 의미가 강한 말이다. 하지만 이보다 Test Driven이라는 표현이 훨씬 적합한 말이다. Test 주도적으로 실제코드가 만들어지기 때문이다.

간단한예

간단한 예를 들어보자.

만약에 구구단 프로그램을 만든다면 TDD 프로그래머는 다음과 같이 시작할 것이다. (아래의 예는 파이썬으로 작성되었으나 파이썬 언어를 몰라도 무슨내용인지 알 수 있을것이다.)

two = GuGu(2)
self.assertEquals(2*3, two.get(3))
위의 코드가 바로 테스트 코드이다. 의도는 다음과 같다.

GuGu라는 클래스는 구구단 클래스이다. 생성자에 몇단인지를 알려주고 get이라는 메써드를 통해 해당 단의 값을 리턴하게 된다. 위의 예처럼 2단 객체를 만들고 get(3)로 two.get(3)의 값이 2*3이 되는지를 확인하는 것이다.

물론 이러한 테스트 코드를 작성하는 순간 GuGu라는 클래스는 존재하지 않는다. 그냥 테스트 코드에 적힌 상상속의 클래스이다. 이 테스트 코드를 통과하기 위해서는 먼저 GuGu라는 클래스를 만들어야 할 것이고 그에 해당되는 메써드들을 만들어야 할 것이다.

위와 같은 테스트 코드를 통과하기 위해서는 아래와 같은 코드가 자연스럽게 생기게 된다.

class GuGu:
    def __init__(self, num):
        self.num = num

    def get(self, which):
        return self.num * which

물론 위처럼 테스트를 작성하고 실제코드를 작성하는것이 TDD의 전부는 아니다. 실은 아주 작은 부분이다. 일단 테스트를 만들고 테스트를 통과하기 위한 실제코드를 빠르게 작성하는 것이 주 목표이지만, 이렇게 작성된 실제 코드를 리팩토링하고 알맞는 메타포를 찾아내는 것들이 더욱 중요하다 하겠다.

보통 리팩토링 과정과 어떤 테스트를 수행해야 할지 결정해야 하는 시간이 가장 오래걸린다고 할 수 있겠다.

테스트코드 없이는 단 한줄도 코딩을 하지 않는다

TDD 초보자가 제일 처음에 겪는 어려움은 도대체 어떻게 테스트 코드를 만들것인가? 하는 점이다. 자신이 만들 실제 코드의 동작을 적절히 테스트할 수 있는 코드가 없이는 단 한줄도 실제코드를 작성하지 말라는 말이 있다.

하지만 이것은 테스트 코드의 중요성을 강조하기 위한 말임을 상기하면 될것이다. 자신이 실제 코드를 적절하고 효율적으로 테스팅 하고 있다고 믿는다면 아마도 당신의 생각이 맞을 것이다.

(개인적으로 절대적인 테스트 코딩의 룰은 없다고 생각한다. 실제 코드가 내가 원하는 대로 테스트 되고 있다면 그만 아니겠는가?)

TDD전문가들은 말한다. 어떤 코드도 테스트를 선행으로 작성할 수 있다. 그렇다, 맞는 말이다. 하지만 우리 TDD초보자들은 이 말에 너무 신경쓰지 말도록 하자. 언젠가는 우리도 그러한 경지에 오를 수 있다. 하지만 지금은 아니다.

모든것을 TDD로 해나가기에 어려운 것이 몇가지 있는데, 대표적인 것으로 네트워크, 웹, 데이터베이스 관련 프로그램이다. 한마디로 얘기하면 독립적인 프로그램이 아니라 다른 어떠한 것에 의존적으로 동작하는 프로그램을 작성할 때 TDD는 꽤 어렵다. (이런경우 Mock Object를 사용하기도 하고 실제 통신을 하여 테스트하기도 한다. 상황에 맞게끔 어떻게 테스트 코드를 만들것인가는 각자의 능력이다.)

하지만 보다 중요한 것은 실제 코드를 필요한곳에 정확하고 적절하게 테스팅하는 방법임을 명심하도록 하자.

항상 100% 테스트를 통과해야 한다

작성한 테스트 코드가 실패함에도 불구하고 다른 테스트 코드를 작성한다던가 테스트 코드 작성중 실제 코드의 어떠한 부분이 잘못되었다는게 상기되어 실제 코드를 재작성하는 오류를 범하지 않기 바란다.

이런식으로 코드를 만들다보면 스파게티 소스가 되기 쉽상이다.

이미 하나의 테스트가 잘못되었다면 그것 먼저 통과하도록 힘써야 할 것이다. 때론 테스트의 범위가 너무 커서 실제 코드를 어렵게 작성해야 하는 경우도 생긴다. 이때쯤에는 뭔가 잘못되었음을 느낄 수 있어야 한다. 테스트를 작성하고 그 테스트를 통과하기 위한 방법이 쉽게 떠오르지 않는다면 테스트에 문제가 있는 것이다.

테스트가 너무 큰 범위의 코드를 상대한다면 테스트를 잘게잘게 나누어 보라. 분명 쉬운 길이 있게 마련이다. 당신은 물론 똑똑하지만, divide and conquer가 당신을 더욱 영리하게 할 것이다.

TDD의 흐름

TDD에 절대적인 방법이 있는것은 아니지만, 일반적인 흐름이라는 것이 있다. 그 흐름은 다음과 같다.

1. 무엇을 테스트할 것인가 생각한다. 
2. 테스트를 작성한다.
3. 테스트가 실패하게 만든다. 
3. 테스트를 통과하는 코드를 작성한다.
4. 코드를 리펙토링한다. (리펙토링 과정에서 물론 테스트를 계속 통과해야만 한다.) 
5. 테스트코드 또한 리펙토링한다. 
6. 구현해야 할 것이 있을 때까지 위의 작업을 반복한다. 

보통 TDD를 하다보면 자연스럽게 위처럼 행동하게 된다. 여기서 중요한 것은 테스트를 작성하고 테스트를 통과하는 코드를 만드는 과정은 빨라야 한다는 것이다. 이 주기가 빠르면 빠를수록 더욱더 많은 재미를 느낄 수 있을 것이다.

코드하나 고쳐보고 테스트 통과하고 조금 더 고쳐보고 통과하고 조금씩 성숙되어가는 코드를 보며 흐뭇함을 느끼기 바란다.

무엇이 좋은가?

그렇다면 이렇듯 테스트 코드를 먼저 작성하는 것에는 어떤 이득이 있을지 생각해 보자.

1. 테스트 코드에는 실제코드를 어떻게 써먹는지에 대한 설명이 자세하게 들어 있다.(인터페이스가 정의된다.) 
2. 따로이 테스트를 할 필요가 없다. 
3. 코드 수정시 기존의 테스트 코드를 통과하는지 체크되기 때문에 통합적인 테스트가 유지된다. 
4. 불안한 마음이 사라진다. 적어도 내가 생각할 수 있는 테스트는 모두 통과하였으므로.
5. 테스트 코드를 만드는 일은 쉬운일이 아니다. 내공이 쌓이면 쌓일수록 테스트 코드를 작성하는것이 어려워진다. 
   (깊이 있는 테스트코드가 만들어지기 때문이다.) 따라서 프로그래밍 실력이 향상된다.

무엇이 나쁜가?

TDD를 진행하는데 다소 거부감이 들 만한 사항이라면

1. 테스트코드가 추가되기 때문에 관리해야 하는 코드의 양이 많아진다.
2. 불안한 테스트코드는 오히려 역효과를 끼칠수도 있다.
3. 테스트코드를 만들기 위해서 불필요한 코드가 실제코드에 삽입될 수 있다.

불안한 테스트는 엉뚱한 결과를 간혹 초래한다. 테스트를 통과하기 때문에 안전하다고 여겼으나 테스트 자체의 오류인 경우 리팩토링시 코드가 엉망이 될 수도 있다.

또한 테스트를 위한 불필요한 코드가 많이 추가된다면 우선 자신이 설계한 프로그램의 구조를 다시 한번 살펴보는것이 좋다. 디자인을 개선할 수록 불필요한 코드는 사라질 것이다.

테스트 코드는 훌륭한 문서이다

테스트 코드를 기반으로 작성된 프로그램을 다른 사람이 관리해야 할 일이 생긴다면 어떻게 될까?

해당 프로그램을 넘겨받는 사람은 일단 프로그램이 어떤 의도로 만들어졌고, 어떻게 구현되었는지 알아야 할 것이다.

최선의 방법은 프로그램을 만든 사람이 며칠이고 함께 붙어 앉아서 완벽하게 이해시키는 것이 좋을 것이다. 하지만 대부분의 경우 그당시 모든것을 이해하더라도 혼자 남게 되면 막막해지고 처음부터 다시 뜯어 보지 않던가?

그렇다면 테스트 코드는 어떠한가? 테스트 코드에는 실제 코드가 발전한 역사(프로그램을 만든이의 의도를 파악할 수 있다.)가 고스란히 남아있고, 어떤 테스트를 행했는지 실제 코드가 어떻게 동작하는지 너무나 자세하게 나와 있다. 또한 무엇보다도 당장 테스트 코드를 실행해 볼 수 있다.

이보다 더 훌륭한 문서가 있을까?

 

자바와 TDD

본인은 파이썬이란 언어를 매우 좋아한다. 파이썬 역시 자바와 마찬가지로 객체적인 설계를 할 수 있고 매우 쉽고 직관적이기 때문이다. 하지만 이 책에서는 파이썬 보다는 자바로 이야기를 진행하려 한다. 자바는 많은 사람들이 알고 있는 언어이기 때문이다. 파이썬을 모르는 사람에게 파이썬으로 TDD를 구경하라는 것은 좀 과도한 욕심인 듯 하다. (하지만 언젠가는 이 책을 보는 사람들이 파이썬에 대해서 관심을 가지기를 희망한다.)

자바는 매우 훌륭한 언어이다. 얼마나 많은 사람들이 자바를 사용하고 있는가? 굳이 자바의 좋은 점을 기술할 필요는 없을 것이다.

자바에서 TDD를 해 나가는데 꼭 필요한 것이 있다. JUnit은 자바에서 유닛테스트를 도와주는 프레임워크이고, 리펙토링은 코드를 성숙시키는 좋은 방법이다. 보다 자세한 사항은 아래를 참고하자.

파이썬과 TDD에 대한 챕터 추가

JUnitTutorial> 

JUnit 소개

JUnit Tutorial

JUnit은 자바에서 유닛 테스트를 하기 위해서 만들어진 frame work이다. 단 하나의 jar파일로 되어 있으며 사용법 또한 간단하다.

JUnit은 디자인패턴책의 저자인 ErichGamma와 TDD의 창시자라고 할 수 있는 KentBeck이 함께 만든것으로도 유명하다.

유닛테스트

JUnit의 사용법을 말하기 전에 도대체 테스팅이란 무엇인지 그 의미에 대해서 짚고 넘어가자.

테스트는 말 그대로 우리가 만든 프로그램이 원하는 대로 동작하는지 알아보는 것에 불과하다. 그렇다면 우리가 원하는 대로 동작하는지 알 수 있는 방법은 무엇이 있을까?

그것은 단 한가지이다.

기대값과 결과값을 비교한다.

우리가 기대하던 결과를 프로그램이 출력하는지를 살펴 보는것이 테스팅의 기본이고 끝이다.

유닛 테스트는 이러한 기대값과 결과값을 비교한다. TDD는 이러한 유닛 테스트를 기본으로 한다. 다만 테스트의 범위가 매우 작은것이 그 특징이라 할 수 있다.

비행기를 만들고 비행기가 날아가는 것을 보는것도 테스팅이지만 비행기의 부속하나하나 역시 테스트 하지 않던가? TDD는 비행기를 테스트 하는것이 아니라 비행기의 부속 하나하나를 꼼꼼하게 테스트한다. 그리고 100% 그 테스트를 통과해야 한다.

JUnitUsage>JUnit 사용법

http://www.JUnit.org 에서 JUnit.jar파일을 구하고 자바 클래스 패쓰에 다운 받은 jar파일을 설정한다.

그리고 에디터로 다음의 코드를 작성해 보자.

package tddbook;

import JUnit.framework.TestCase;

public class JUnitTutorialTest extends TestCase {
    public JUnitTutorialTest(String arg0) {
        super(arg0);
    }

    public void testNumber() {
        int expected = 10;
        assertEquals(expected, 2 * 5);
    }

    public static void main(String args[]) {
        JUnit.textui.TestRunner.run(JUnitTutorialTest.class);
    }
}

이것이 바로 JUnit을 이용한 테스트 코드이다. TestCase를 extends해서 testXXX메써드들을 테스트하고 있다.

위와 같은 모습의 코드가 전형적인 JUnit을 이용한 코드의 틀이라고 할 수 있겠다. 위의 testNumber가 실제적인 테스트를 행하는 메써드이며, 이렇게 메써드명이 test로 시작하는 메써드들은 원하는 만큼 많이 만들어서 쓸 수가 있다.

그렇다면 testNumber메써드를 보자. assertEquals라는 TestCase를 통해서 extend받은 메써드를 이용하여 2*5의 결과값이 기대한 값 (expected)와 일치하는지를 비교한다.

위의 코드를 실행하면 다음과 같은 결과를 보게 된다.

.
Time: 0.01

OK (1 test)

자세히 보면 제일 윗줄에 점(.)이 하나 보이는데 이것은 test로 시작하는 메써드들의 갯수 즉, 테스트의 갯수를 의미한단. 다음의 Time은 테스트하는데 소요된 시간을 말하며 OK는 1개의 테스트가 성공했음을 알린다.

이렇듯 text로 그 결과를 보여주는 까닭은 우리가 main메써드에서 JUnit.textui.TestRunner을 사용했기 때문이며 이 외에도 awt나 swing을 이용한 visual한 결과를 볼 수도 있다.

see also : JUnitGui - awt, swing을 이용한 유닛 테스팅

이번에는 테스트가 실패할 경우 어떻게 보여지는지 살펴보도록 하자. 다음과 같이 위의 코드를 수정해 보자.

package tddbook;

import JUnit.framework.TestCase;

public class JUnitTutorialTest extends TestCase {
    public JUnitTutorialTest(String arg0) {
        super(arg0);
    }

    public void testNumber() {
        int expected = 10;
        assertEquals(expected, 2 * 5);
    }

    public void testFailMessage() {
        int expected = 10;
        assertEquals(expected, 3*5);
    }

    public static void main(String args[]) {
        JUnit.textui.TestRunner.run(JUnitTutorialTest.class);
    }
}

testFailMessage라는 메써드를 추가했다. 코드를 보면 expected는 10이지만 3*5의 값은 10일리 없다.

위의 테스트 코드를 실행하면 다음과 같은 결과를 보게 된다.

..F
Time: 0.01
There was 1 failure:
1) testFailMessage(tddbook.JUnitTutorialTest)JUnit.framework.AssertionFailedError: expected:<10> but was:<15>
        at tddbook.JUnitTutorialTest.testFailMessage(JUnitTutorialTest.java:17)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
        at tddbook.JUnitTutorialTest.main(JUnitTutorialTest.java:21)

FAILURES!!!
Tests run: 2,  Failures: 1,  Errors: 0

점 두개는 역시 테스트의 갯수를 말하며 그 옆의 F는 테스트가 실패(Fail)되었음을 말한다. There was 1 failure: 밑에는 실패한 이유와 trace가 보인다.

우리의 짐작처럼 기대값은 10인데 결과값이 15라서 AssertionFailedError가 발생했음을 알려준다.

마지막 줄은 총 2개의 테스트 중 1개의 Fail이 있고 Error는 0개임을 말한다. 테스트 코드에서 Fail과 Error는 다르다. Fail은 우리가 테스트한 기대값과 결과값이 다를때 발생하지만 Error는 코드상의 오류나 NullPointerException같은 예측못한 Exception이 발생할 때 생긴다.

JUnitSetupTeardown>Setup TearDown

테스트는 각각의 테스트 메써드마다 독립성을 보장해야 한다. 만약 어떤 객체를 공유해서 테스트해야 할 때 객체의 데이터가 변경된다면 각각의 테스트의 독립성을 보장할 수 없을 것이다.

이에 JUnit은 setUp과 tearDown이라는 메써드를 제공한다.

  • setUp - JUnit 테스트 코드의 setUp 메써드는 특별한 의미이다. 주로 코드내에서 사용할 리소스를 초기화 시킬때 setUp을 이용한다. 즉, 각각의 테스트 코드가 항상 new Person()이라는 statement를 실행한다면 이것은 setUp에 선언해서 테스트 매써드가 실행될 때마다 수행하게 할 수 있는 것이다. 다시 말해 setUp은 각각의 testXXX메써드들이 수행되기 바로 직전에 매번 실행되는 것이다.

  • tearDown - setUp과 반대의 경우라고 생각하면 된다. testXXX매써드가 종료될 때마다 수행되는 매써드이다. 사용한 리소스를 클리어할때 주로 사용된다.

Examples.

package tddbook;

import JUnit.framework.TestCase;
import java.util.*;

public class TestSetupTearDown extends TestCase {

    public TestSetupTearDown(String arg0) {
        super(arg0);
    }

    public static void main(String[] args) {
        JUnit.textui.TestRunner.run(TestSetupTearDown.class);
    }

    Vector employee;

    protected void setUp() throws Exception {
        employee = new Vector();
    }

    protected void tearDown() throws Exception {
        employee.clear();
    }

    public void testAdd() {
        employee.add("Pey");
        assertEquals(1, employee.size());
    }

    public void testCleared() {
        assertEquals(0, employee.size());
    }

}

예제를 보면 setUp과 teawDown의 역할을 알 수 있다. employee객체를 초기화하고 클리어 한다는 것을 명시적으로 확인할 수 있다.

JUnitMethods>JUnit Methods

JUnit에서 가장 많이 사용되는 메써드는 assertEquals이지만 이 외에도 여러 유용한 메써드들이 있는데 그것에 대해서 알아보기로 하자.



assertTrue(X)

X가 참인지를 테스트한다.

assertFalse(X)

X가 거짓인지를 테스트한다.

assertNull(X)

X가 NULL인지를 테스트한다.

assertNotNull(X)

X가 NULL이 아닌지를 테스트한다.

fail(MSG)

무조건 실패시킨다 (MSG를 출력한다. ) 주로 Exception테스트를 할때 사용된다. 코드가 수행중 Exception을 일으켜야만 할 때 Exception이 제대로 일어났는지를 테스트할 수 있다.

try {
    userMethods.run(parameter.bad());
    fail("should not reach here!");
    
}catch(UserException e) {
    assertEquals(-1, e.getErrorCode());
}

위의 예는 userMethods.run이 실패해야만 하는 상황을 만들어 놓고 원하는 예외처리가 되는지를 테스트하는 것이다. 만약 run 실행시 exception이 발생하지 않으면 fail로 테스트가 실패하게 된다.

TDD의 목표

가능한한 가장 간단한 코드를 작성한다.

Simplicity

Clean Code that works

동작하는 깨끗한 코드 - Simplicity를 설명하기에 너무나 완벽한 표현이다. Ron Jeffry가 처음에 사용했다고 한다.

See also:

간단하다는 것의 의미에 대해서 먼저 생각해보자.

코드가 간단하다는 것은

  • 일단 이해하기 쉬워야 할 것이고,
  • 그 의도를 충분히 표현해 주고 있어야 한다.
  • 또한 코드의 중복이 없어야 한다.
  • 이런 조건을 만족시키면서 가장 적은 수의 클래스와 메써드를 가지고 있어야 한다.
  • 코딩할 때 가장 어려운 것은 어떤 기능을 추가할 것인가가 아니라 어떤 기능이 필요없는것인지를 알아내고 제거하는 것이다.

 

리팩토링

때때로 프로그래머는 코딩을 하며 기쁘기도 하고 슬프기도 하다. 슬플때는 내 마음대로 코딩이 되지 않을 때이며 기쁠때는 내가 원하는 바대로 코딩이 되어 질 때이다. 이렇듯 코드를 만들어가며 프로그래머는 웃기도 울기도 하는 것이다.

하지만 이제 난 항상 웃고 싶어졌다.

리펙토링이란 기존의 코드가 지니고 있는 역할은 그대로 두고 코드를 수정, 재구성 하는것이다.

리팩토링은 코드를 관찰하는 것이다. 올바른 방향으로 가기 위한 끊임없는 프로그래머의 도전정신이라고도 할 수 있다. 내 몸에 꼭 맞는 코드를 만들기 위해서 노력하는 것이 바로 리팩토링이라고 할 수 있겠다.

안좋은 코드의 징후

Bad Smell은 말 그대로 나쁜 냄새를 뜻한다. 리팩토링을 아는 프로그래머들은 간혹 코드를 보며 Bad smell이 난다고 얘기한다. 그것은 그 부분이 마음에 들지 않는다는 이야기이며 리팩토링을 하고 싶다는 이야기이다. 여기서는 리팩토링을 할 만한 나쁜냄새(징후)에 대해서 몇가지 얘기해 볼까 한다.

  • duplicated code

가장 심각한 냄새로 뽑히는 것중 하나로 코드의 중복을 든다. 코드의 중복이라고 하면 보통 copy and paste를 생각하겠지만, duplication은 copy and paste만이 아닌 더 포괄적인 의미를 갖는다.

중복이라 함은 똑같은 내용이 반복적으로 사용되었슴을 말한다. 그 부분을 수정해야 할 일이 생겼을 때 똑같이 적용된 모든 부분을 찾아 다니며 수정해 주어야 하고 이에는 헛된 시간과 노력, 에러가 남는다.

  • Long method

아주 긴 메써드 메써드가 길다는 의미는 라인이 길다는 의미와는 조금 다르다. 한 메써드에서 하나의 일을 처리하는 것이 아니라 여러개의 일을 한꺼번에 처리하는 경우 라인이 길 수 있다. 즉 한 메써드명이 나타내는 것만큼의 일을 해야 한다는 말이다.

보통 Long method의 경우가 duplication code를 만드는 주범이 되곤 한다. method내에 동일한 일을 하는 많은 구문들이 다른 매써드에도 존재한다면 그 것은 duplication코드이다.

이럴 경우 메써드를 잘게 나누는 방법(extract method)을 이용하여 리팩토링을 해야 한다.

  • Long Parameter List

긴 파라미터 리스트.

C와 같은 순차적인 프로그래밍에 익숙한 사람들은 OOP적인 프로그래밍에 익숙치 않고 주로 메써드에 정보를 전달할 때 필요한 정보들 모두를 파라미터로 전달하려고 한다. 하지만 그 메써드를 사용하기 위해서 그 많은 파라미터를 항상 사용해야 한다면 코드를 만드는 사람도 사용하는 사람도 매우 힘들 것이다.

  • Switch and long if else statement

Switch와 많은 if else 문들 역시 Bad Smell이라 할 만하다.

OOP에는 많은 개념들이 존재하는데 이 중에서도 Polymophism이라는 것이 있다. 비슷한 형태이나 행동하는 양식이 다를 경우에 이런 개념들이 사용되는데 이것은 switch case문 같은 Type에 의존적인 구문들을 대치한다.

  • Comments

주석은 Bad smell?

우리는 보통 프로그래밍을 처음 배울때 주석의 중요함을 강요받는다. 자신이 만든 코드에 주석을 꼭 달아라. 귀에 못이 박히도록 말이다.

하지만 자신이 만든 코드를 이해하기 위해서 주석이 꼭 필요한 정보라면 그것은 Bad smell이라고 할 만하다. 주석이 없으면 그 코드를 이해하기 어렵다는 것은 코드가 난해하게 작성되어져 있다는 말과 동일하다 할 것이다.

변수명과 메써드명 클래스명만 가지고도 이 코드가 무슨일을 하는 코드인지 알 수 없다면 Bad smell인 것이다.

리팩토링 연습

리팩토링은 코딩을 하며 자연스럽게 알게 되는 것이긴 하나, 시작도 해 보지 않은 사람에게는 생소한 것일 수 있다. 여기서는 몇가지 기본적인 리팩토링을 예로들어 그 필요성을 증명하고자 한다. 이에 감흥을 얻게 된다면 본격적인 리팩토링을 공부해 보기를 당부한다.

다음의 코드를 구경하자.

public class Charge {

    public static final int BUS = 0;
    public static final int TAXI = 1;
    public static final int SUBWAY = 2;

    public double calculate(int type, int age, int killometer) {
        double result = 0;
        switch(type) {
            case (BUS) :
                result = calculateBus(age, killometer);
                break;
            case (TAXI):
                result = calculateTaxi(age, killometer);
                break;
            case (SUBWAY):
                result = calculateSubway(age, killometer);
                break;
            default :
                throw new RuntimeException("Such type does not exist");
        }
        return result;
    }

    public double calculateBus(int age, int kilometer) {
        double baseBusCharge = 600;
        if(age < 15 ) {
            baseBusCharge = baseBusCharge * 0.5;
        }else if(age > 60) {
            baseBusCharge = baseBusCharge * 0.7;
        }
        return baseBusCharge;
    }

    public double calculateTaxi(int age, int kilometer) {
        double baseTaxiCharge = 3000;
        return baseTaxiCharge * kilometer;
    }

    public double calculateSubway(int age, int kilometer) {
        double baseSubwayCharge = 1000;
        if (age < 15) {
            baseSubwayCharge = baseSubwayCharge * 0.5;
        }else if(age > 60) {
            baseSubwayCharge = baseSubwayCharge * 0.7;
        }
        if(kilometer > 50) {
            return baseSubwayCharge * 1.5;
        }else {
            return baseSubwayCharge;
        }
    }
}

나이와 거리별로 버스, 택시, 지하철의 요금을 계산해 주는 코드이다. 이 코드는 그리 심각하지는 않지만 Bad smell이 난다. 느껴지는가?

이 코드에는 type에 의한 switch-case와 비슷한 형태의 calculate메써드들이 있다. 음 어디서 부터 시작해야 할까?

리팩토링을 진행하기 전에 난 테스트 코드를 먼저 작성하기로 한다. 난 위의 코드를 완벽하게 이해하기 위해서 또한 리팩토링을 진행하기 위해서 다음과 같은 100% 통과하는 테스트 코드를 작성했다.

import JUnit.framework.TestCase;

public class ChargeTest extends TestCase {

    public ChargeTest(String arg0) {
        super(arg0);
    }

    public void testBusCharge() {
        Charge charge = new Charge();
        assertEquals(0.001, 600.0, charge.calculate(Charge.BUS, 27, 1));
        assertEquals(0.001, 300.0, charge.calculate(Charge.BUS, 14, 1));
        assertEquals(0.001, 420.0, charge.calculate(Charge.BUS, 70, 1));
    }

    public void testTaxiCharge() {
        Charge charge = new Charge();
        assertEquals(0.001, 6000.0, charge.calculate(Charge.TAXI, 27, 2));
        assertEquals(0.001, 3000.0, charge.calculate(Charge.TAXI, 14, 1));
        assertEquals(0.001, 9000.0, charge.calculate(Charge.TAXI, 70, 3));
    }

    public void testSubwayCharge() {
        Charge charge = new Charge();
        assertEquals(0.001, 1500.0, charge.calculate(Charge.SUBWAY, 27, 60));
        assertEquals(0.001, 500.0, charge.calculate(Charge.SUBWAY, 14, 10));
        assertEquals(0.001, 700.0, charge.calculate(Charge.SUBWAY, 70, 30));
    }

}

테스트 코드는 그 의도를 잘 나타내 주고 있다고 생각한다. 가장 처음의 예를 보면 27살이고 1km거리를 버스로 이용할때의 요금은 600원인지 조사한다. 0.001이라는 수치는 double형의 결과값을 비교할때 오차범위이다.

가장 먼저 고치고 싶은 부분은 switch-case문이다.

먼저 커다란 이 클래스를 조금 잘게 나누어 보도록 하자. 우선 BusCharge라는 클래스를 만들어 보자.

BusCharge

public class BusCharge {
    public double calculateBus(int age, int kilometer) {
        double baseBusCharge = 600;
        if (age < 15) {
            baseBusCharge = baseBusCharge * 0.5;
        } else if (age > 60) {
            baseBusCharge = baseBusCharge * 0.7;
        }
        return baseBusCharge;
    }
}

Charge클래스에 있던 calculateBus메써드를 그대로 BusCharge클래스로 Copy하였다. 그 이유는 caculateBus라는 메써드는 BusCharge클래스에 있는 것이 더 의미있는 것이기 때문이다. 그런후에 Charge클래스의 caculate메써드를 다음과 같이 수정한다.

    public double calculate(int type, int age, int killometer) {
        double result = 0;
        switch(type) {
            case (BUS) :
                BusCharge charge = new BusCharge();
                result = charge.calculateBus(age, killometer);
                break;
            case (TAXI):
                result = calculateTaxi(age, killometer);
                break;
            case (SUBWAY):
                result = calculateSubway(age, killometer);
                break;
            default :
                throw new RuntimeException("Such type not exist");
        }
        return result;
    }

Charge클래스의 caculate메써드가 caculateBus메써드를 사용하지 않고 BusCharge의 caculateBus라는 메써드를 이용하도록 바꾸어 주었다. 그리고 테스트 코드를 실행시켜 본다. 만일 테스트가 통과되지 않는다면 무엇이 잘못되었는지 금방 알 수 있을 것이다. 테스트가 통과 된다면 이제 Charge클래스의 caculateBus메써드는 더이상 사용되지 않으므로 제거한다.

이와 같은 방법으로 TaxiCharge와 SubwayCharge클래스를 만들고 각각의 caculateTaxi, caculateSubway메써드를 Copy하고 Charge클래스의 caculate메써드를 모두 해당 클래스의 메써드를 사용하는 것으로 바꾼다. 지금까지의 결과는 다음과 같다.

Charge

public class Charge {

    public static final int BUS = 0;
    public static final int TAXI = 1;
    public static final int SUBWAY = 2;

    public double calculate(int type, int age, int killometer) {
        double result = 0;
        switch (type) {
            case (BUS) :
                BusCharge busCharge = new BusCharge();
                result = busCharge.calculateBus(age, killometer);
                break;
            case (TAXI) :
                TaxiCharge taxiCharge = new TaxiCharge();
                result = taxiCharge.calculateTaxi(age, killometer);
                break;
            case (SUBWAY) :
                SubwayCharge subwayCharge = new SubwayCharge();
                result = subwayCharge.calculateSubway(age, killometer);
                break;
            default :
                throw new RuntimeException("Such type not exist");
        }
        return result;
    }
}

BusCharge

public class BusCharge {
    public double calculateBus(int age, int kilometer) {
        double baseBusCharge = 600;
        if (age < 15) {
            baseBusCharge = baseBusCharge * 0.5;
        } else if (age > 60) {
            baseBusCharge = baseBusCharge * 0.7;
        }
        return baseBusCharge;
    }
}

TaxiCharge

public class TaxiCharge {
    public double calculateTaxi(int age, int kilometer) {
        double baseTaxiCharge = 3000;
        return baseTaxiCharge * kilometer;
    }
}

SubwayCharge

public class SubwayCharge {
    public double calculateSubway(int age, int kilometer) {
        double baseSubwayCharge = 1000;
        if (age < 15) {
            baseSubwayCharge = baseSubwayCharge * 0.5;
        } else if (age > 60) {
            baseSubwayCharge = baseSubwayCharge * 0.7;
        }
        if (kilometer > 50) {
            return baseSubwayCharge * 1.5;
        } else {
            return baseSubwayCharge;
        }
    }
}

Charge클래스를 각각 BusCharge, TaxiCharge, SubwayCharge로 나누고 메써드를 move했을 뿐이다. 그 이유는 Charge가 모든 교통수단을 관리해야 하던 책임을 각각의 교통수단으로 책임을 전가하고 싶었기 때문이다.

이제 XXXCharge클래스 각각의 caculateBus, caculateTaxi, caculateSubway메써드는 모두 동일한 의미이므로 모두 caculate라는 이름으로 rename한다.

이제 BusCharge, TaxiCharge, SubwayCharge클래스 모두가 calculate라는 메써드를 가지고 있으므로 우리는 Chargable이라는 인터페이스를 생성하자. 구현해야 할 메써드는 당연히 calculate이다.

Chargable

public interface Chargable {
    double calculate(int age, int kilometer);
}

그리고 각각의 클래스(BusCharge, TaxiCharge, SubwayCharge)는 Chargable인터페이스를 implements하도록 하자. 이상없이 컴파일되고 테스트가 통과된다면 다음을 계속 진행하도록 하자.

이제 어느정도의 팩토링이 진행되었으므로 본격적으로 Charge클래스를 공격해 보도록 하자. 우선 Charge클래스에 create라는 Factory메써드를 생성하자. 모두 Chargable이라는 인터페이스를 구현했으므로 리턴 타입은 Chargable로 할 수 있다.

public class Charge {

    public static final int BUS = 0;
    public static final int TAXI = 1;
    public static final int SUBWAY = 2;

    public double calculate(int type, int age, int kilometer) {
        return create(type).calculate(age, kilometer);
    }

    public static Chargable create(int type) {
        switch (type) {
            case (BUS) :
                return new BusCharge();
            case (TAXI) :
                return new TaxiCharge();
            case (SUBWAY) :
                return new SubwayCharge();
            default :
                throw new RuntimeException("Such type not exist");
        }
    }
}

이제 static 필드인 BUS, TAXI, SUBWAY를 해당 클래스로 move한다. 이제 Charge클래스는 다음과 같이 변했다.

public class Charge {
    public static double calculate(int type, int age, int kilometer) {
        return create(type).calculate(age, kilometer);
    }

    public static Chargable create(int type) {
        switch (type) {
            case (BusCharge.BUS) :
                return new BusCharge();
            case (TaxiCharge.TAXI) :
                return new TaxiCharge();
            case (SubwayCharge.SUBWAY) :
                return new SubwayCharge();
            default :
                throw new RuntimeException("Such type not exist");
        }
    }
}

테스트 코드역시 Charge.BUS로 사용했던 것을 BusCharge.BUS로 모두 수정해 준다. (TaxiCharge.TAXI, SubwayCharge.SUBWAY등으로..) 그리고 이제 모두 static매써드이므로 테스트 코드에서 Charge클래스의 인스턴스를 만들 필요가 없어졌다.

테스트 코드는 다음과 같이 변한다.

import JUnit.framework.TestCase;

public class ChargeTest extends TestCase {

    public ChargeTest(String arg0) {
        super(arg0);
    }

    public void testBusCharge() {
        assertEquals(0.001, 600.0, Charge.calculate(BusCharge.BUS, 27, 1));
        assertEquals(0.001, 300.0, Charge.calculate(BusCharge.BUS, 14, 1));
        assertEquals(0.001, 420.0, Charge.calculate(BusCharge.BUS, 70, 1));
    }

    public void testTaxiCharge() {
        assertEquals(0.001, 6000.0, Charge.calculate(TaxiCharge.TAXI, 27, 2));
        assertEquals(0.001, 3000.0, Charge.calculate(TaxiCharge.TAXI, 14, 1));
        assertEquals(0.001, 9000.0, Charge.calculate(TaxiCharge.TAXI, 70, 3));
    }

    public void testSubwayCharge() {
        assertEquals(0.001, 1500.0, Charge.calculate(SubwayCharge.SUBWAY, 27, 60));
        assertEquals(0.001, 500.0, Charge.calculate(SubwayCharge.SUBWAY, 14, 10));
        assertEquals(0.001, 700.0, Charge.calculate(SubwayCharge.SUBWAY, 70, 30));
    }

}

또한 switch-case문을 유심히 관찰해 보니 BusCharge나 TaxiCharge, SubwayCharge라는 이름 역시 duplicate되었음을 알 수 있다. 이것을 이용해서 좀 더 유연하게 코드를 수정할 수 있는 방법으로 Class.forName을 이용해 본다. (Class.forName은 코딩시 프로그래머가 실수할 확률이 높기는 하다.)

다음과 같이 Charge클래스에 두개의 메써드를 추가하자. 두개의 메써드는 int형 타입에 의한 개별 클래스 분기를 스트링형태의 클래스 분기로 바꾸어 주기 위한 것이다. 각각의 XXXCharge클래스의 이름이 XXX + Charge의 형태로만 구현된다면 아래의 리팩토링은 매우 효율적이게 될 것이다.

public static double calculate(String type, int age, int kilometer) {
    return create(type).calculate(age, kilometer);
}

public static Chargable create(String type) {
    try {
        return (Chargable) Class.forName(type+"Charge").newInstance();
    }catch(Exception e) {
        System.err.println(e.toString());
    }
    return null;
}

그리고 테스트 코드에서 create(BusCharge.BUS)대신에 create("Bus")를 이용하는 방식으로 바꾸어 보자.

assertEquals(0.001, 600.0, Charge.calculate("Bus", 27, 1));
assertEquals(0.001, 300.0, Charge.calculate("Bus", 14, 1));
assertEquals(0.001, 420.0, Charge.calculate("Bus", 70, 1));

테스트가 통과되면 테스트 코드의 Taxi, Subway마저 모두 String형태의 값으로 바꾸고 Charge클래스에서 사용하던 int type형태의 메써드를 모두 삭제하도록 하자.

이제 필드들 BusCharge.BUS, TaxiCharge.TAXI, SubwayCharge.SUBWAY필드값들도 필요가 없어졌으므로 삭제하도록 하자.

이쯤되면 굵직한 리팩토링은 끝난 셈이다.

이제 BusCharge, TaxiCharge, SubwayCharge클래스를 관찰해 보자.

나이에 따른 할인율은 동일한데 BusCharge와 SubwayCharge에 중복이 발견된다. 이에 우리는 나이에 따른 할인율을 담당하는 클래스를 생각해 보자. 나이에 따른 할인율을 결정하는 Age클래스를 다음과 같이 만들어 보자.

public class Age {
    public static double getDiscountRate(int age) {
        if (age < 15) {
            return 0.5;
        } else if (age > 60) {
            return 0.7;
        } else {
            return 1.0;
        }
    }
}

그러면 Age클래스를 이용하여 BusCharge의 메써드를 다음과 같이 바꿀 수 있다.

public class BusCharge implements Chargable{
    public double calculate(int age, int kilometer) {
        double baseBusCharge = 600;
        baseBusCharge = baseBusCharge * Age.getDiscountRate(age);
        return baseBusCharge;
    }
}

위의 메써드 역시 baseBusCharge라는 필드가 중복적으로 사용되었으므로 다음과 같이 수정한다.

public class BusCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * Age.getDiscountRate(age);
    }

    public double getBaseCharge() {
        return 600;
    }
}

위와 같은 방법으로 TaxiCharge와 SubwayCharge를 변경하니 다음과 같이 되었다.

TaxiCharge

public class TaxiCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * kilometer;
    }

    public double getBaseCharge() {
        return 3000.0;
    }
}

SubwayCharge

public class SubwayCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        if(kilometer > 50) {
            return getBaseCharge() * Age.getDiscountRate(age) * 1.5;
        }else {
            return getBaseCharge() * Age.getDiscountRate(age);
        }
    }

    public double getBaseCharge() {
        return 1000.0;
    }
}

getBaseCharge라는 메써드가 모두 공통적으로 사용되었으므로 Chargable인터페이스에 getBaseCharge라는 메써드를 추가하는 것이 합당할 듯 하다.

Chargable

public interface Chargable {
    double calculate(int age, int kilometer);
    double getBaseCharge();
}

이상과 같이 하여 Bad smell이 느껴지는 부분에 대해서 리팩토링을 진행하였다.

리팩토링의 결과 Charge라는 하나의 클래스가 가지고 있는 책임을 여러개로 분리하고 그에 합당한 클래스를 새로 만들게 되었다. 이제 AirplaneCharge, HorseCharge등 여러개가 추가되더라도 우리는 Chargable인터페이스만 구현하는 새로운 클래스를 만들기만 하면 된다.

리팩토링 전의 코드

Charge

public class Charge {

    public static final int BUS = 0;
    public static final int TAXI = 1;
    public static final int SUBWAY = 2;

    public double calculate(int type, int age, int killometer) {
        double result = 0;
        switch(type) {
            case (BUS) :
                result = calculateBus(age, killometer);
                break;
            case (TAXI):
                result = calculateTaxi(age, killometer);
                break;
            case (SUBWAY):
                result = calculateSubway(age, killometer);
                break;
            default :
                throw new RuntimeException("Such type does not exist");
        }
        return result;
    }

    public double calculateBus(int age, int kilometer) {
        double baseBusCharge = 600;
        if(age < 15 ) {
            baseBusCharge = baseBusCharge * 0.5;
        }else if(age > 60) {
            baseBusCharge = baseBusCharge * 0.7;
        }
        return baseBusCharge;
    }

    public double calculateTaxi(int age, int kilometer) {
        double baseTaxiCharge = 3000;
        return baseTaxiCharge * kilometer;
    }

    public double calculateSubway(int age, int kilometer) {
        double baseSubwayCharge = 1000;
        if (age < 15) {
            baseSubwayCharge = baseSubwayCharge * 0.5;
        }else if(age > 60) {
            baseSubwayCharge = baseSubwayCharge * 0.7;
        }
        if(kilometer > 50) {
            return baseSubwayCharge * 1.5;
        }else {
            return baseSubwayCharge;
        }
    }
}

리팩토링 후의 코드

ChargeTest

import JUnit.framework.TestCase;

public class ChargeTest extends TestCase {

    public ChargeTest(String arg0) {
        super(arg0);
    }

    public void testBusCharge() {
        assertEquals(0.001, 600.0, Charge.calculate("Bus", 27, 1));
        assertEquals(0.001, 300.0, Charge.calculate("Bus", 14, 1));
        assertEquals(0.001, 420.0, Charge.calculate("Bus", 70, 1));
    }

    public void testTaxiCharge() {
        assertEquals(0.001, 6000.0, Charge.calculate("Taxi", 27, 2));
        assertEquals(0.001, 3000.0, Charge.calculate("Taxi", 14, 1));
        assertEquals(0.001, 9000.0, Charge.calculate("Taxi", 70, 3));
    }

    public void testSubwayCharge() {
        assertEquals(0.001, 1500.0, Charge.calculate("Subway", 27, 60));
        assertEquals(0.001, 500.0, Charge.calculate("Subway", 14, 10));
        assertEquals(0.001, 700.0, Charge.calculate("Subway", 70, 30));
    }

}

Charge

public class Charge {
    public static double calculate(String type, int age, int kilometer) {
        return create(type).calculate(age, kilometer);
    }

    private static Chargable create(String type) {
        try {
            return (Chargable) Class.forName(type + "Charge").newInstance();
        } catch (Exception e) {
            System.err.println(e.toString());
        }
        return null;
    }
}

Chargable

public interface Chargable {
    double calculate(int age, int kilometer);
    double getBaseCharge();
}

BusCharge

public class BusCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * Age.getDiscountRate(age);
    }

    public double getBaseCharge() {
        return 600;
    }
}

TaxiCharge

public class TaxiCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * kilometer;
    }

    public double getBaseCharge() {
        return 3000.0;
    }
}

SubwayCharge

public class SubwayCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        if(kilometer > 50) {
            return getBaseCharge() * Age.getDiscountRate(age) * 1.5;
        }else {
            return getBaseCharge() * Age.getDiscountRate(age);
        }
    }

    public double getBaseCharge() {
        return 1000.0;
    }
}

Age

public class Age {
    public static double getDiscountRate(int age) {
        if (age < 15) {
            return 0.5;
        } else if (age > 60) {
            return 0.7;
        } else {
            return 1.0;
        }
    }
}

Refactoring Again

* 나이별 할인율은 각 교통수단마다 다를 수 있으니 Age클래스를 따로 만드는것은 바람직스럽지 않다. 
* 나이별 할인율과 거리별 부가금에 대한 책임을 각 교통수단마다 가지고 있어야 한다. 

이 두가지 문제점에 초점을 맞추어 리팩토링이 된 향상된 코드를 아래에 소개한다.

우선 BusCharge에서 Age클래스를 사용한 부분을 제거하고 새로운 매써드를 만든다. 그리고 버스는 거리에 따라 부가금이 없지만 그러한 내용을 BusCharge클래스에 표시한다.

BusCharge

public class BusCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * getDiscountRate(age) * getDistanceRate(kilometer);
    }

    public double getBaseCharge() {
        return 600;
    }

    public double getDiscountRate(int age) {
        if (age < 15) {
            return 0.5;
        } else if (age > 60) {
            return 0.7;
        } else {
            return 1.0;
        }
    }

    public double getDistanceRate(int kilometer) {
        return 1;
    }
}

TaxiCharge, SubwayCharge역시 동일하게 적용하면 다음과 같이 된다.

TaxiCharge

public class TaxiCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge() * getDiscountRate(age) * getDistanceRate(kilometer);
    }

    public double getBaseCharge() {
        return 3000.0;
    }

    public double getDiscountRate(int age) {
        return 1;
    }

    public double getDistanceRate(int kilometer) {
        return kilometer;
    }
}

SubwayCharge

public class SubwayCharge implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge()
            * getDiscountRate(age)
            * getDistanceRate(kilometer);

    }

    public double getBaseCharge() {
        return 1000.0;
    }

    public double getDiscountRate(int age) {
        if (age < 15) {
            return 0.5;
        } else if (age > 60) {
            return 0.7;
        } else {
            return 1.0;
        }
    }

    public double getDistanceRate(int kilometer) {
        if (kilometer > 50) {
            return 1.5;
        } else {
            return 1;
        }
    }
}

그리고 각각의 클래스가 모두 공통적인 메써드인 calculate가 있으므로 상단으로 끌어올리기 위해서 다음과 같은 ChargeCalculator클래스를 생성하고 각각(BusCharge, TaxiCharge, SubwayCharge)의 calculate메써드를 삭제한다.

그리고 각각의 교통수단을 나타내는 클래스는 ChargeCalculator클래스를 extends한다.

ChargeCalculator

public abstract class ChargeCalculator implements Chargable {
    public double calculate(int age, int kilometer) {
        return getBaseCharge()
            * getDiscountRate(age)
            * getDistanceRate(kilometer);
    }
}

그리고 Chargable 인터페이스는 다음과 같이 바뀐다. Chargable인터페이스는 각 교통수단 클래스가 꼭 지녀야 할 책임을 말한다. 따라서 아래와 같이 calculate메써드는 사라지고 나이별 할인율과 거리별 부가율에 대한 메써드가 추가 되었다.

Chargable

public interface Chargable {
    double getBaseCharge();
    double getDiscountRate(int age);
    double getDistanceRate(int kilometer);
}

Charge클래스 역시 다음과 같이 바뀐다.

Charge

public class Charge {
    public static double calculate(String type, int age, int kilometer) {
        return create(type).calculate(age, kilometer);
    }

    private static ChargeCalculator create(String type) {
        try {
            return (ChargeCalculator) Class.forName(type + "Charge").newInstance();
        } catch (Exception e) {
            System.err.println(e.toString());
        }
        return null;
    }
}

어쩌면 아직도 리팩토링할 거리가 많이 남아 있는지도 모르겠다. 그것은 독자의 몫으로 남겨 놓는다.

리팩토링 참고

본인이 강추하는 책중의 하나로 Martin Fowler가 쓴 Refactoring이라는 책을 든다. 경험에서 우러나오는 감동적인 글이다. Bad smell에 대한 정의와 그 해결방법을 체계적으로 들고 있다.

혹자는 이 책은 프로그래머의 내공을 약 3배정도 증가시켜준다고 한다.

TDD Examples

볼링점수 계산하기-자바

 

볼링점수 계산법

볼링 점수를 계산하는 방법은 의외로 복잡하다. 예를 들면 다음과 같다.

첫번째 프레임에서 스트라이크를 치고 두번째 프레임에서 1, 3을 치면 지금까지의 점수는 10 + 4 + 4(스트라이크 보너스)가 된다.

만약 첫번째 프레임에서 스페어를 치고 두번째 프레임에서 1, 3을 치면 지금까지의 점수는 10 + 4 +1(스페어 보너스)가 된다.

즉, 간단히 말하면 스트라이크를 쳤을 경우에는 다음 두번의 throw의 점수가 보너스가 되고 스페어의 경우에는 한번의 throw의 점수가 보너스가 되는 것이다.

마지막 프레임일 경우에는 조금 틀린데, 10프레임째에 스크라이크를 치면 한번의 기회가 더 주어지고 보너스 프레임에 또 다시 스트라이크를 치면 또한번의 기회가 주어진다. 10프레임째 스페어를 친경우에는 한번의 기회가 더 주어진다.

다음의 사이트가 볼링점수 계산에 대해 비교적 자세한 설명을 해주고 있다.

http://nicegus.hihome.com/hobby.htm

 

 

 

우리는 볼링점수를 계산하는 것을 차근차근 TDD로 풀어보도록 하자. 만만한 문제는 아닐 듯하다.

TODO 리스트 작성하기

가장 먼저 할 일은 테스트를 해야 할 것을 찾는 일이다. 어디서부터 시작해야 할까? 함께 고민해 보도록 하자.

...

  • 현재 프레임이 몇번째 프레임인지 알 수 있어야 한다.
  • 현재까지의 점수를 알 수 있어야 한다.
  • 스트라이크일 경우 두번의 보너스 점수가 더해져야 한다.
  • 스페어일 경우 한번의 보너스 점수가 더해져야 한다.
  • 마지막 프레임일 경우는 조금 다르게 적용해야 한다.

이것이 내가 찾아낸 몇개의 TODO리스트이다. 생각나는 대로 적은 것이며, 이것은 변경될 수도 있고, 추가할 수도 있으며 삭제될 수도 있는 것이다.

머리속에서만 빙글빙글 돌고 있는 막연한 생각을 이제 테스트 코드로 반영해 보도록 하자. 이것이 바로 TDD의 묘미일 것이다. 생각난 것을 그대로 한번 표현해 보자.

우선 현재 프레임이 몇번째 프레임인지 알아내야 한다는 것에 초점을 맞추어 보자. 가장 처음의 테스트 코드는 다음과 같았다.

    public void testCurrentFrame() {
        BowlingGame game = new BowlingGame();
        Frame frame = game.currentFrame();
        assertEquals(1, frame.no());
    }

위의 코드를 적는 순서는 다음과 같았다.

1. assertEquals(1, frame.no());

frame이 없기에 frame을 생성해야 했다.

2. Frame frame = game.currentFrame();

game이 없기에 game을 생성해야 했다.

3. BowlingGame game = new BowlingGame();

즉, assertEquals문을 먼저 작성하자는 말이다. assertEquals를 먼저 적는것은 무엇을 테스트 할지를 먼저 결정한다는 말과도 같다. 그리고 assert문을 통과하기 위해서 자연스럽게 상단에 적어주어야 할 선행 statement들이 자연스럽게 생김을 알 수 있다.

테스트 코드의 의도는 보는것과 같이 공을 던지지 않았을때의 현재 프레임 번호가 1이어야 하며 frame객체는 game객체의 현재 프레임이어야 한다는 것이다. 자, 이제 이 테스트 코드를 통과하도록 해보자.

일단, 컴파일이 되도록 하기 위해서 다음과 같은 클래스들이 생겨야만 했다.

public class Frame {
    public int no() {
        return 0;
    }
}

public class BowlingGame {
    public Frame currentFrame() {
        return null;
    }
}

그리고 테스트를 통과하기 위해서 다음과 같이 바뀌어야 했다.

public class BowlingGame {
    public Frame currentFrame() {
        return new Frame();
    }
}

public class Frame {
    public int no() {
        return 1;
    }
}

currentFrame에서 new Frame을 하고 Frame클래스에서 no()메써드가 무조건 1을 리턴하게 만든것은 테스트를 통과하기 위한 것이지 실제 코드가 그리 되어야 함을 뜻하지는 않는다.

위의 실제코드는 분명 다른 테스트 코드를 통과하기 위해서 반드시 정상적으로 바뀌어야 할 것이다. 만약 위의 실제 코드가 끝까지 위처럼 그대로 남아 있는다면 그것은 테스트 코드가 잘못되었거나, 아니면 제대로 된 로직이라고 할 수 있을 것이다. 자, 그 판단은 우리 프로그래머의 몫인 것이다.

이제 테스트 코드에 다음을 삽입하여 보자.

    public void testCurrentFrame() {
        BowlingGame game = new BowlingGame();
        Frame frame = game.currentFrame();
        assertEquals(1, frame.no());
        
        game.throwDown(3);
        game.throwDown(6);
        assertEquals(2, game.currentFrame.no());        
    }

game객체의 throwDown이라는 메써드는 몇개의 핀을 넘어뜨리는가를 나타낸다. 테스트코드는 3개, 6개의 핀을 넘어뜨린 후, 현재의 프레임은 2프레임이 되어야함을 의미한다.

테스트를 가장 빨리 통과하기 위한 실제코드는 다음과 같이 변경되었다.

public class BowlingGame {
    int currentFrameNo;
    
    public BowlingGame() {
        this.currentFrameNo = 1;
    }
    
    public Frame currentFrame() {
        return new Frame(currentFrameNo);
    }

    int pinCount = 0;
    public void throwDown(int pinNo) {
        pinCount++;
        if (pinCount == 2) {
            currentFrameNo++;
            pinCount = 0;
        }
    }
}

public class Frame {
    int frameNo;
    public Frame(int frameNo) {
        this.frameNo = frameNo;
    }
    public int no() {
        return frameNo;
    }
}

역시 예상대로 테스트 코드에 의해서 정상적인 실제 코드로 진행되어 지고 있음을 볼 수 있다. 이것이 바로 테스트 주도적 개발의 핵심이다. 테스트에 의해서 당연한 클래스들이 만들어 지고 있는 것이다. BowlingGame클래스는 두번 throwDown메써드가 실행되면 프레임 번호를 증가 시키게 해야 했고, 프레임 클래스는 생성자에서 프레임 번호를 입력으로 받아야 했다.

TDD를 하다가 갑자기 떠오른 생각이 있어 TODO리스트에 다음과 같이 추가 하였다. 그 이유는 현재의 currentFrame메써드는 매번 new를 해서 객체를 만들어 리턴하기 때문이다. 지금 당장 해결하기보다는 TODO에 적고 나중에 해결하는 것이 좋을것 같았다.

  • game.currentFrame()을 여러번 호출하더라도 같은 프레임 객체가 나오는지를 조사한다.

테스트 코드를 아래와 같이 setUp메써드를 이용하도록 리펙토링한 후 스트라이크일 경우를 조사해 보도록 하자.

import JUnit.framework.TestCase;

public class BowlingGameTest extends TestCase {

    public BowlingGameTest(String arg0) {
        super(arg0);
    }

    public static void main(String[] args) {
        JUnit.textui.TestRunner.run(BowlingGameTest.class);
    }
    
    BowlingGame game;
    public void setUp() {
        this.game = new BowlingGame();
    }
    
    public void testCurrentFrame() {
        assertEquals(1, game.currentFrame().no());        
        game.throwDown(3); game.throwDown(6);
        assertEquals(2, game.currentFrame().no());
    }
    
    public void testCurrentFraemAtStrike() {
        game.throwDown(10);
        assertEquals(2, game.currentFrame().no());
    }
}

당연히 스트라이크 후에는 프레임이 바뀌어야 하지만 이 테스트는 실패하게 된다.

다음을 수정함으로써 테스트는 간단하게 통과할 것이다.

BowlingGame

    int pinCount = 0;
    public void throwDown(int pinNo) {
        pinCount++;
        if (pinCount == 2 || pinNo==10) {
            currentFrameNo++;
            pinCount = 0;
        }
    }

pinNo는 넘어진 핀의 갯수를 의미하므로 10개의 핀이 넘어지면 프레임이 하나 증가한다는 의미를 실제 코드에 삽입한 것이다. 하지만 10프레임(열번째 프레임)일 경우 애매하므로 다음과 같은 테스트 코드를 만든다

    public void test10Frame() {
        for(int i=0; i<10; i++) {
            game.throwDown(10);
        }
        assertEquals(10, game.currentFrame().no());
    }

10프레임까지 모두 스트라이크를 친 후에 현재 프레임 번호를 조사해 보면 10이 나와야 하지만 11이 나와서 테스트는 실패한다. 볼링은 10프레임이 마지막이다. :)

통과하도록 가장 간단하게 만든 실제코드는 아래와 같았다.

BowlingGame

    public void throwDown(int pinNo) {
        pinCount++;
        if (currentFrameNo != 10 && (pinCount == 2 || pinNo==10)) {
            currentFrameNo++;
            pinCount = 0;
        }
    }

현재 프레임이 10프레임이 아닌경우에만 프레임을 증가하도록 한 것이다.

그런후 BowlingGame클래스를 관찰해 보니 몇가지 리팩토링할것이 생겼다.

public class BowlingGame {
    int currentFrameNo;
    
    public BowlingGame() {
        this.currentFrameNo = 1;
    }
    
    public Frame currentFrame() {
        return new Frame(currentFrameNo);
    }

    int throwCount = 0;
    public void throwDown(int pinCount) {
        throwCount++;
        if (isNextFrame(pinCount)) {
            currentFrameNo++;
            throwCount = 0;
        }
    }
    
    public boolean isNextFrame(int pinCount) {
        return (currentFrameNo != 10 && (throwCount == 2 || pinCount==10));
    }
}

pinCount변수명이 마음에 들지 않으므로 의미가 보다 명확한 throwCount로 바꾸어 주었고, pinNo변수를 pinCount로 바꾸어 주었다.

이번에는 아까전에 TODO리스트에 추가한 같은 프레임일 경우 같은 프레임 객체가 리턴되는지를 테스트해 본다.

    public void testSameFrame() {
        Frame frame1 = game.currentFrame(); 
        game.throwDown(1);
        Frame frame2 = game.currentFrame();
        assertEquals(frame1, frame2);
    }

예상대로 테스트는 실패하게 된다. 통과 하기 위해서 실제 코드는 다음과 같이 변경되었다.

public class BowlingGame {
    int currentFrameNo;
    Frame currentFrame;
    
    public BowlingGame() {
        this.currentFrameNo = 1;
        this.currentFrame = newFrame();
    }
    
    public Frame currentFrame() {
        return currentFrame;
    }

    int throwCount = 0;
    public void throwDown(int pinCount) {
        throwCount++;
        if (isNextFrame(pinCount)) {
            currentFrameNo++;
            currentFrame = newFrame();
            throwCount = 0;
        }
    }
    
    private Frame newFrame() {        
        return new Frame(currentFrameNo);
    }
    
    public boolean isNextFrame(int pinCount) {
        return (currentFrameNo != 10 && (throwCount == 2 || pinCount==10));
    }
}

생성자에서 현재 프레임 번호를 1로 한 후에 프레임이 올라갈 때 마다 현재 프레임을 변화시키도록(1프레임씩 증가하도록) 하였다.

이제는 실제 점수를 계산하는것을 테스트 해 본다.

    public void testScore1() {
        assertEquals(0, game.frame(1).score());
        game.throwDown(3);
        assertEquals(3, game.frame(1).score());
        game.throwDown(6);
        assertEquals(3+6, game.frame(1).score());
    }

위와 같은 테스트 코드를 생각하기가 쉽지 않았다. 위의 테스트 코드는 본인이 여러번의 시행착오 끝에 발견한 비교적 좋은 메타포였다. 각 프레임별 점수를 구할 수도 있고 game전체에서 점수를 구할 수도 있었으나, 프레임을 나누어서 점수를 내는 것이 매우 유리하다. 그 이유는 스트라이크나, 스페어시의 보너스 점수 때문이다.

TDD로 이러한 사항까지를 모두 쉽게 해낼 수는 없다. 이것은 본인이 그냥 깨우친 것이다. :(

아무튼 위의 테스트 코드의 의미는 이러하다. 볼링공을 던지지 않았을때는 첫프레임의 점수는 0이고, 한번 던져서 3개를 넘어뜨리면, 첫번째 프레임의 점수가 3, 그 다음에 던져서 6개의 핀을 넘어뜨리면 첫번째 프레임의 점수가 9점이 된다는 것을 의미한다.

통과하기 위한 실제 코드는 다음처럼 바뀐다. 아래와 같은 코드가 한번에 나온것은 아니다. 단지 호흡을 조금 빨리하기 위해서 중간의 자잘한 과정을 생략하였음을 알아주기 바란다. 아무튼 테스트 코드에 의해서 아래와 같은 코드가 만들어 지게 된다.

BowlingGame

import java.util.*;
public class BowlingGame {
    ...
    Vector frames;

    public BowlingGame() {
        frames = new Vector();
        ...
    }

    ...
    public void throwDown(int pinCount) {
        ...
        currentFrame.addScore(pinCount);
        ...
    }

    private Frame newFrame() {
        Frame frame = new Frame(currentFrameNo);
        frames.add(frame);
        return frame;
    }

    ...

    public Frame frame(int frameNo) {
        return (Frame) frames.get(frameNo-1);
    }
}

Vector변수에 각각의 프레임 객체를 담고 throwDown시에 해당 프레임의 addScore매써드를 호출하게 하였다. 그리고 0프레임은 없으므로 항상 첫 프레임이 1프레임임을 frame매써드에 표현하였다.

Frame

public class Frame {
    ...
    int score;
    ...    
    
    public int score() {
        return score;
    }
    
    ...

    public void addScore(int pinCount) {
        score += pinCount;
    }
}

프레임 객체는 위와 같이 만들어진다.

이제 보너스 점수를 가지고 있는 스트라이크일 경우에 스코어를 테스트 해보자.

    public void testScoreAtStrike() {
        game.throwDown(10);
        game.throwDown(3);
        game.throwDown(6);
        assertEquals(10+3+6, game.frame(1).score());
    }

테스트 코드의 의도대로 첫번째 프레임에 스트라이크를 치고, 연이어 3개, 6개의 핀을 2번째 프레임에서 치면 첫번째 프레임의 점수는 스트라이크점수(10)에 보너스 점수(3,6)을 더한 19가 나와야 한다. 하지만 이 테스트 코드는 실패할 것이다.

통과하기 위한 실제코드는 다음과 같이 되었다. (호흡을 많이 빨리했으므로, 실제 이것을 따라하고 있는 독자라면 아래는 좀더 단계적으로 나누어서 하는 것이 좋다.)

BowlingGame

import java.util.*;
public class BowlingGame {
    int currentFrameNo;
    Frame currentFrame;
    Vector frames;

    public BowlingGame() {
        frames = new Vector();
        this.currentFrameNo = 1;
        this.currentFrame = newFrame();
    }

    public Frame currentFrame() {
        return currentFrame;
    }
    
    private void checkFrameBonus(int score) {
        for(int i=0; i < frames.size()-1; i++) {
            ((Frame)frames.get(i)).checkBonus(score);            
        }
    }
   
    int throwCount = 0;
    public void throwDown(int pinCount) {
        throwCount++;
        currentFrame.addScore(pinCount);      
        checkFrameBonus(pinCount);        
        if (isNextFrame(pinCount)) {
            currentFrameNo++;
            currentFrame = newFrame();
            throwCount = 0;
        }
    }

    private Frame newFrame() {
        Frame frame = new Frame(currentFrameNo);
        frames.add(frame);
        return frame;
    }

    public boolean isNextFrame(int pinCount) {
        return (currentFrameNo != 10 && (throwCount == 2 || pinCount == 10));
    }

    public Frame frame(int frameNo) {
        return (Frame) frames.get(frameNo-1);
    }
}

checkFrameBonus라는 메써드를 새로이 작성하였고, frames를 순서대로 돌면서 해당 frame객체의 보너스를 체크하게 한다. 즉 스트라이크일 경우와 스페어일 경우에 보너스를 갖게 되는데, 그러한 책임을 game객체가 갖고 있는 것이 아니라 frame객체가 그 책임을 갖도록 한 것이다. 이것이 이번 TDD에서 가장 핵심이 될 만한 것이라 할 수 있다.

Frame

public class Frame {
    int frameNo;
    int score;
    int bonusCount = 0;
    public Frame(int frameNo) {
        this.frameNo = frameNo;
    }
    public int no() {
        return frameNo;
    }

    public int score() {
        return score;
    }

    public void addScore(int pinCount) {
        score += pinCount;
        if (pinCount==10) {
            bonusCount = 2;
        }
    }

    public boolean hasBonus() {
        return bonusCount > 0;
    }

    public void setBonusCount(int count) {
        this.bonusCount = count;
    }

    public String toString() {
        return "frame number : " + frameNo;
    }
    
    
    public void checkBonus(int bonusScore) {
        if (hasBonus()) {
            score += bonusScore;
            bonusCount--;
        }
    }
}

addScore시에 스트라이크가 오면 보너스 카운트를 2로 설정해 주고, checkBonus메써드가 호출되면 해당 점수를 프레임 점수에 더하고 보너스 카운트를 하나 감소시키면 된다.

스트라이크일 경우에 확실한 믿음을 주는지 테스트해 보면,

    public void testSocreAtStrikeII() {
        game.throwDown(10);
        game.throwDown(10);
        game.throwDown(10);
        assertEquals(10+10+10, game.frame(1).score());
        assertEquals(10+10, game.frame(2).score());
        assertEquals(10, game.frame(3).score());
    }

첫번째, 두번째, 세번째 프레임 모두 스트라이크일 경우, 현재까지의 1프레임 점수는 30이 되어야 하고, 2프레임 점수는 20점이 되어야 한다. 물론 1번의 보너스 카운트는 남아 있을 것이다. 마지막 세번째 프레임의 점수는 10이 될 것이고, 세번째 프레임 역시 보너스 카운트가 2만큼 남아 있을 것이다.

이제는 스페어일 경우를 테스트 해보자

    public void testScoreAtSpare() {
        game.throwDown(9);
        game.throwDown(1);
        game.throwDown(5);
        assertEquals(9+1+5, game.frame(1).score());
    }

테스트 코드의 의도대로 첫번째 프레임에 9,1을 쳐서 스페어가 된후에 두번째 프레임에 5점을 치면 첫번째 프레임의 점수는 15점이 되어야 한다. 스페어일 경우는 아직 생각하지 않았으므로 이 테스트는 예상대로 실패하게 된다.

통과하기 위해서는

Frame

    public void addScore(int pinCount) {
        score += pinCount;
        if (pinCount==10) {
            bonusCount = 2;
        }else if(pinCount!=10 && score==10) {
            bonusCount = 1; // spare
        }
    }

위와 같이 스페어일 경우를 추가 시켜주었다.

이제는 현재 프레임까지의 총 score를 알기 위한 테스트 코드를 작성해 보기로 하자.

    public void testGameScore() {
        game.throwDown(10);
        game.throwDown(10);
        game.throwDown(10);
        assertEquals(30+20+10, game.score());
    }

1,2,3프레임 모두 스트라이크라면 현재까지의 게임 스코어는 60점이 되어야 할 것이다.

통과하려면

BowlingGame

    public int score() {
        int result = 0;
        for (int i=0; i < frames.size(); i++) {
            result += ((Frame)frames.get(i)).score();
        }
        return result;        
    }

프레임을 돌면서 점수를 더해 주면 된다. 간단하다.

이제 All strike인 경우를 테스트 해 보자.

    public void testAllStrike() {
        for (int i = 0; i < 12; i++) {
            game.throwDown(10);
        }
        assertEquals(300, game.score());
    }

볼링에서 모두 스트라이크를 치면 300점이 나온다는 것은 다들 알고 있는 사실일 것이다.

테스트는 통과되었다.

Dutch man을 테스트 해 보았다. (Dutch man : 스트라이크와 스페어를 번갈아 가면서 친것)

    public void testDutchMan() {
        for (int i = 0; i < 10; i++) {
            if (i % 2 == 0) {
                game.throwDown(10);
            } else {
                game.throwDown(6);
                game.throwDown(4);
            }
        }
        game.throwDown(10);
        assertEquals(200, game.score());
    }

이것도 통과된다.

이제 마지막으로 테스트할것은 게임의 엔딩일것이다. 더 이상 throwDown을 할 수 없을 때, Exception을 일으키도록 하자. 이미 게임이 끝났는데도 계속 던지면 엉망진창이 되지 않겠는가?

public void testGameOverI() throws Exception {
        for (int i = 0; i < 9; i++) {
            game.throwDown(5);
            game.throwDown(4);
        }
        game.throwDown(10);
        game.throwDown(10);
        game.throwDown(1);
        try {
            game.throwDown(1);
            fail("should not reach here!");
        } catch (ThrowDownException e) {
        }
        assertEquals(9*9+10+10+1, game.score());
    }

    public void testGameOverII() throws Exception {
        for (int i = 0; i < 9; i++) {
            game.throwDown(5);
            game.throwDown(4);
        }
        game.throwDown(1);
        game.throwDown(2);        
        try {
            game.throwDown(1);
            fail("should not reach here!");
        } catch (ThrowDownException e) {
        }
        assertEquals(9 * 9 + 1 + 2, game.score());
    }

    public void testGameOverIII() throws Exception {
        for (int i = 0; i < 9; i++) {
            game.throwDown(5);
            game.throwDown(4);
        }
        game.throwDown(5);
        game.throwDown(5); // spare
        game.throwDown(10);
        try {
            game.throwDown(1);
            fail("should not reach here!");
        } catch (ThrowDownException e) {
        }
        assertEquals(9 * 9 + 5 + 5 + 10, game.score());
    }   

총 세가지의 경우를 테스트한것인데, 이것은 물론 하나씩 차례대로 테스트를 통과하면서 진행된 것이다. 다만 이곳에서는 읽는이의 지루함을 달래기 위해 한번에 보여주었다.

첫번째는 10프레임째에 스트라이크 2개를 치고 다음에 1개의 핀을 친 후에 게임이 종료되는지를 조사하는것이고, 두번째는 10프레임째에 1개의 핀, 2개의 핀을 차례로 쳤을때 게임이 종료되는지를 조사하는 것이고, 마지막으로 세번째는 10프레임째에 5개씩 두번을 쳐서 스페어를 한다음에 스트라이크를 쳐도 게임이 종료되는지를 조사한 것이다.

이러한 테스트 코드를 통과하기위한 실제 코드는 다음과 같이 되었다.

BowlingGame

    int throwCount = 0;
    public void throwDown(int pinCount) throws ThrowDownException {
        throwCount++;
        if (isGameOver()) {
            throw new ThrowDownException();
        }
        currentFrame.addScore(pinCount);
        checkFrameBonus(pinCount);
        if (isNextFrame(pinCount)) {
            currentFrameNo++;
            currentFrame = newFrame();
            throwCount = 0;
        }
    }
    private boolean isGameOver() {
        return throwCount > 3
            || (currentFrame.no() == 10
                && !currentFrame.hasBonus()
                && throwCount > 2);
    }

isGameOver라는 메써드를 생성하여 게임이 종료됨을 구현하였다. 3번을 초과하여 던질 수 있는 프레임은 존재하지 않으므로, 게임 종료조건이 될 것이고, 10프레임이고 보너스가 없는데, 2번을 초과하여 던지면 역시 게임이 종료되게 된다.

프레임은 자동으로 증가되므로 위의 게임 종료조건은 모두 10프레임째일 경우에 해당됨을 알 수 있을 것이다.

 

TDD Examples

 

볼링점수 계산하기-파이썬

 

볼링점수 계산법

볼링 점수를 계산하는 방법은 의외로 복잡하다. 예를 들면 다음과 같다.

첫번째 프레임에서 스트라이크를 치고 두번째 프레임에서 1, 3을 치면 지금까지의 점수는 10 + 4 + 4(스트라이크 보너스)가 된다.

만약 첫번째 프레임에서 스페어를 치고 두번째 프레임에서 1, 3을 치면 지금까지의 점수는 10 + 4 +1(스페어 보너스)가 된다.

즉, 간단히 말하면 스트라이크를 쳤을 경우에는 다음 두번의 throw의 점수가 보너스가 되고 스페어의 경우에는 한번의 throw의 점수가 보너스가 되는 것이다.

마지막 프레임일 경우에는 조금 틀린데, 10프레임째에 스크라이크를 치면 한번의 기회가 더 주어지고 보너스 프레임에 또 다시 스트라이크를 치면 또한번의 기회가 주어진다. 10프레임째 스페어를 친경우에는 한번의 기회가 더 주어진다.

다음의 사이트가 볼링점수 계산에 대해 비교적 자세한 설명을 해주고 있다.

http://nicegus.hihome.com/hobby.htm

 

 

 

가장 먼저 할 일은 테스트를 해야 할 것을 찾는 일이다. 어디서부터 시작해야 할까? 함께 고민해 보도록 하자.

...

  • 현재 프레임이 몇번째 프레임인지 알 수 있어야 한다.
  • 현재까지의 점수를 알 수 있어야 한다.
  • 스트라이크일 경우 두번의 보너스 점수가 더해져야 한다.
  • 스페어일 경우 한번의 보너스 점수가 더해져야 한다.
  • 마지막 프레임일 경우는 조금 다르게 적용해야 한다.

어떤 테스트 코드를 만들가를 알기위해 TODO리스트를 먼저 만들어보면,

'''
TODO
===========================================================
* make bowling game start
* somebody throwdown bowling pin
* game contains each frame
* calculate each frame score
* calculate strike score
* calculate spare score

DONE
============================================================

'''

1. 볼링게임을 시작하기
2. 누군가 공을 볼링핀에 던진다.
3. 볼링게임은 각 프레임을 포함해야 한다.
4. 각각의 프레임 점수를 구한다.
5. 스트라이크일 경우의 점수를 구한다.
6. 스페어일 경우의 점수를 구한다.

이미 자바로 볼링게임 문제를 풀어보았기 때문에 좀 더 세밀한 TODO가 나온듯 하다.

자, 이제 어떻게 시작할 것인가? 늘 우리는 프로그램을 만들때 어떤 객체를 만들것인가를 생각한다. 하지만 어떤 객체를 만들것인가 보다 어떤 테스트코드를 만들어야 할까에 집착해 보도록 하자.

볼링게임을 어떻게 시작할 것인가? 다음과 같은 테스트 코드를 만들 수 있었다.

import unittest


class BowlingTest(unittest.TestCase):
    def testGameStart(self):
        pey = Gamer()
        game = pey.startGame()
        frame = game.getCurrentFrame()
        pey.throwDown(frame, 1)


if __name__ == '__main__':
    unittest.main()

볼링게임의 스토리를 테스트코드에 작성해 놓은 것이다. pey라는 볼링게이머가 있고, 게임을 스타트한다. 그리고 게임은 현재의 프레임을 리턴하고, 게이머는 프레임을 향해 볼링공을 던져서 몇개의 핀을 쓰러뜨린다.

상상력을 발휘하여 테스트 코드에 적어놓은 것일 뿐이다.

자, 이제 우리가 할일은 위의 테스트코드를 빠르게 통과하는 일만 남았다.

class Gamer:
    def startGame(self):
        return BowlingGame()

    def throwDown(self, frame, pin):
        pass


class BowlingGame:
    def getCurrentFrame(self):
        return Frame


class Frame:
    pass

테스트만을 통과하기 위한 코드는 자연스럽게 위처럼 생성되었다. 이 외에 다른 것들은 지금당장은 필요없는 것이다.

TODO는 다음과 같이 변했다.

'''
TODO
===========================================================
* game contains each frame
* calculate each frame score
* calculate strike score
* calculate spare score

DONE
============================================================
* make bowling game start
* somebody throwdown bowling pin
'''

"game은 각 frame을 포함해야 한다" 라는 TODO에 해당되는 테스트 코드를 만들어 보면

class BowlingTest(unittest.TestCase):
    def testGameStart(self):
        pey = Gamer()
        game = pey.startGame()
        frame = game.getCurrentFrame()
        pey.throwDown(frame, 1)

    def testGameFrame(self):
        pey = Gamer()
        game = pey.startGame()
        frame1 = game.getFrame(1)
        frame10 = game.getFrame(10)
        self.assertEquals(1, frame1.no())
        self.assertEquals(10, frame10.no())

위와 같은 테스트 코드를 만들었다. game은 frame을 포함하며 프레임 번호로 해당 프레임 객체를 가져올 수 있어야 한다는 의도다. 또한 프레임 번호는 고유해야 한다.

통과하려면,

class Gamer:
    def startGame(self):
        return BowlingGame()

    def throwDown(self, frame, pin):
        pass


class BowlingGame:
    def getCurrentFrame(self):
        return Frame(0)

    def getFrame(self, index):
        return Frame(index)


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno

    def no(self):
        return self.frameno

위와 같은 코드가 만들어진다.

이제 TODO리스트는 다음과 같이 변경된다.

'''
TODO
===========================================================
* calculate each frame score
* calculate strike score
* calculate spare score
* throwdown increase frame no

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
'''

구현해야 할 TODO가 하나 생겼다. 게이머가 볼링핀을 쓰러뜨릴 때마다 frame은 증가하거나 그대로일 수 있다. 프레임 객체를 만들며 잠깐 생각난 것을 TODO에 그대로 추가한 것이다.

또 다른 테스트를 만들기 전에 해야 할 일이 있다. TDD의 흐름을 기억하는가?

* 무엇을 테스트할것인가?
* 실패하는 테스트 코드 작성
* 테스트 코드 통과
* 리팩토링

지금껏 작성한 코드를 보면 중복을 볼 수 있을 것이다. 중복이 보이는가? 중복을 제거한 코드는 다음과 같다.

class BowlingTest(unittest.TestCase):
    def setUp(self):
        self.pey = Gamer()
        self.game = self.pey.startGame()

    def testGameStart(self):
        frame = self.game.getCurrentFrame()
        self.pey.throwDown(frame, 1)

    def testGameFrame(self):
        frame1 = self.game.getFrame(1)
        frame10 = self.game.getFrame(10)
        self.assertEquals(1, frame1.no())
        self.assertEquals(10, frame10.no())

pey와 game객체가 두개의 테스트에 동일하게 사용되었기에 setUp메써드로 이동시켰다. 코드의 중복은 각 클래스간 의존성때문에 생긴다고 한다. 달리 얘기하면 코드의 중복을 제거하면 할수록 각 클래스간 독립성을 확보해 나갈 수 있을 것이다.

이제 "throwdown increase frame no"에 대한 TODO를 생각해보자. 프레임에 볼링공을 던질 때마다 프레임은 증가하기도 그대로이기도 하다. (스트라이크일 경우 프레임 번호는 증가할 것이다.)

프레임을 관리하는 클래스는 BowlingGame일 것이다.

객체이 대한 설계를 미루어 두고 테스트 코드를 바로 만들어보자.

def testFirstFrameNo(self):
    frame = self.game.getCurrentFrame()
    self.assertEquals(1, frame.no())

위와 같이 game객체가 현재의 프레임을 리턴해 주어야 한다는 것을 테스트 코드에 밝힌다. 위 테스트를 통과하기 위해서 실제 코드는 다음과 같이 변한다.

class BowlingGame:
    def getCurrentFrame(self):
        return Frame(1)

    def getFrame(self, index):
        return Frame(index)

무조건 현재 프레임을 1프레임으로 리턴해주지만 이것으로 테스트를 통과하기엔 충분하다. 하지만 잘못된 코드임을 물론 알 수 있다. 그렇다면 위 테스트를 실패하게 만들어야 겠다.

def testNextFrameByThrowDown(self):
    self.pey.throwDown(1)
    self.pey.throwDown(1)
    self.assertEquals(2, self.game.getCurrentFrame().no())

현재 프레임에서 볼링핀을 1개씩 두번 쓰러뜨린 경우라고 할 수 있겠다. 두번 던졌기 때문에 프레임 번호는 증가해야 할 것이다. 하지만 위 테스트는 실행오류가 난다. 이유는 pey객체의 throwDown메써드는 첫번째 인자로 현재 프레임을 받아야 하기 때문이다.

여기서 잠깐 생각해 볼것은, 게이머가 현재 프레임을 알아야 할 필요가 있는가? 이다. 본인은 게이머가 현재 프레임에 대한 책임을 가져야 할 필요가 없다고 느낀다. (물론 아닐 수도 있다.) 그래서 throwDown메써드는 넘어뜨린 핀의 갯수만 보내는 것이 명확하다.

그렇다면 테스트를 통과해보기로 하자. 테스트를 통과하려니 막막해지는 경우라고 할 수 있겠다. 지금 위와 같은 테스트를 통과하는 것은 대부분의 로직을 나약한 테스트로 통과하는 것과 같다고 할 수 있겠다.

testNextFrameByThrowDown 테스트는 잠시 보류하기로 하자. (메써드 이름을 xtestNextFrameByThrowDown로 바꾸면 테스트 대상에서 제외된다.) 조금 더 세밀한 테스트를 하기 위해 다음과 같은 테스트 코드를 만들 수 있었다.

def testFrameAvailability(self):
    frame = self.game.getCurrentFrame()
    self.assertEquals(1, frame.no())
    self.assertEquals(2, frame.availability)
    frame.availability = 0
    self.assertEquals(2, self.game.getCurrentFrame().no())

현재 프레임 번호는 1이고 최초 availability는 2이다. availability는 공을 던질 때마다 감소해야 하고, 스트라이크일 경우는 2라는 값이 감소해야 할 것이다.

위에서는 강제로 availability값을 0으로 감소시켰고, game객체의 현재프레임은 1만큼 증가된 2프레임이 나와야 한다는 의도이다.

위에서 해보려던 테스트보단 조금 축소된 느낌을 갖을 수 있을 것이다. 그리고 쉽게 통과할 수 있을 것 같은 예감이 든다. 그렇다면 빠르게 테스트를 통과하기로 하자.

조금은 많은 변화가 있었지만 그다지 어려운 느낌은 들지 않는다. 새롭게 바뀐 실제 코드는 다음과 같다.

class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.no = 1

    def getCurrentFrame(self):
        if not self.frames[self.no-1].availability:
            self.no += 1
        return self.frames[self.no-1]

    def getFrame(self, index):
        return Frame(index)


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2

    def no(self):
        return self.frameno

game객체는 생성되는 순간 10개의 프레임 객체들을 가지고 있게 되었고, getCurrentFrame호출시 현재 프레임의 availability가 0인 경우 다음 프레임을 리턴하게 된다.

또한 프레임객체는 최초 생성시 2개의 availability값을 가지고 있어야 한다.

일단 테스트는 통과했지만 눈에 거슬리는 몇가지가 보인다. 그것은 바로 availability라는 속성값을 그대로 사용했다는 점이고, 또한 코드가 명확하게 읽히지를 않는다. 그래서 다음과 같이 리팩토링을 했다.

class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        if not self.curframe.available():
            self.curframe = self.nextframe()
        return self.curframe

    def nextframe(self):
        return self.frames[self.curframe.no()]

    def getFrame(self, index):
        return Frame(index)


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2

    def no(self):
        return self.frameno

    def available(self):
        return self.availability != 0

Frame클래스는 availble()이라는 메써드가 생겼고, BowlingGame클래스의 self.no값은 사라졌다. 대신 nextframe이라는 메써드가 생성되었다.

이 모든것들은 하나씩 변경하며 테스트를 매번 통과하며 만들어졌음에 유의하자. 어떠한가? 이전 코드보다 훨씬 명확해 보이지 않는가? 다들 리팩토링의 힘을 느껴보기를 바란다.

자, 이제 우리가 보류해 놓았던 테스트 코드에 도전해 보도록 하자. x로 막아두었던 테스트 코드를 풀면

def testNextFrameByThrowDown(self):
    self.pey.throwDown(1)
    self.pey.throwDown(1)
    self.assertEquals(2, self.game.getCurrentFrame().no())

두번 던졌으니 프레임이 증가해야 한다는 의도이다. 이제 이 테스트를 통과하는건 그리 어렵지 않다.

실제 코드는 다음과 같이 변했다.

class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDown(self, pin):
        self.game.getCurrentFrame().availability -= 1

throwDown메써드가 호출될 때마다 현재 프레임의 availability값을 1씩 감소시켰다.

다음을 계속 진행하기 전에 테스트 코드를 보자. 테스트 메써드의 중복이 보인다. 이전에 만들었던 testGameStart, testFirstFrameNo메써드의 내용이 testFrameAvailability에 그대로 옮겨졌으므로 testGameStart와 testFirstFrameNo메써드를 삭제하도록 하자.

테스트 코드의 중복역시 제거해야 할 대상이다. 테스트 케이스가 많을수록 좋은게 아니라 분명 필요하고 실제코드를 적절히 테스트할 수 있어야 좋다는 것을 유념하자.

역시 availability값을 그대로 사용하는 것은 마음이 불편하므로 리팩토링 하였다.

자, 지금까지의 테스트 코드와 실제 코드를 다시 한번 보면 다음과 같다.

'''
TODO
===========================================================
* calculate each frame score
* calculate strike score
* calculate spare score

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
* throwdown increase frame no
* frame has available count and decrease by throwdown
'''

import unittest


class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDown(self, pin):
        self.game.getCurrentFrame().decrease()


class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        if not self.curframe.available():
            self.curframe = self.nextframe()
        return self.curframe

    def nextframe(self):
        return self.frames[self.curframe.no()]

    def getFrame(self, index):
        return Frame(index)


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2

    def no(self):
        return self.frameno

    def available(self):
        return self.availability != 0

    def decrease(self):
        self.availability -= 1


class BowlingTest(unittest.TestCase):
    def setUp(self):
        self.pey = Gamer()
        self.game = self.pey.startGame()

    def testGameFrame(self):
        frame1 = self.game.getFrame(1)
        frame10 = self.game.getFrame(10)
        self.assertEquals(1, frame1.no())
        self.assertEquals(10, frame10.no())

    def testFrameAvailability(self):
        frame = self.game.getCurrentFrame()
        self.assertEquals(1, frame.no())
        self.assertEquals(2, frame.availability)
        frame.availability = 0
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testNextFrameByThrowDown(self):
        self.pey.throwDown(1)
        self.pey.throwDown(1)
        self.assertEquals(2, self.game.getCurrentFrame().no())


if __name__ == '__main__':
    unittest.main()

이제 각 프레임의 스코어를 계산해야 한다. 가능한 테스트코드는 다음과 같다.

def testFrameScore(self):
    self.pey.throwDown(1)
    frame = self.game.getCurrentFrame()
    self.assertEquals(1, frame.getScore())

게이머가 볼링핀을 1개 쓰러뜨리고 game객체로 부터 얻은 프레임 점수는 1이어야 한다는 의도이다.

이제 조금씩 호흡을 빨리해 보도록 하자. 실제 코드는 다음과 같이 자연스럽게 만들어진다.

class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDown(self, pin):
        frame = self.game.getCurrentFrame()
        frame.decrease()
        frame.addScore(pin)

class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2
        self.score = 0

    def addScore(self, score):
        self.score += score

    def no(self):
        return self.frameno

    def available(self):
        return self.availability != 0

    def decrease(self):
        self.availability -= 1

    def getScore(self):
        return self.score

Frame클래스에 addScore, getScore라는 메써드가 추가 되었고 Gamer클래스의 throwDown메써드에서 addScore를 하게끔 하였다.

testFrameScore이 조금은 불안한 듯하여 조금 추가해 보았다.

def testFrameScore(self):
    self.pey.throwDown(1)
    frame = self.game.getCurrentFrame()
    self.assertEquals(1, frame.getScore())
    self.pey.throwDown(5)
    self.assertEquals(6, frame.getScore())

실제코드의 getFrame메써드가 잘못되었슴을 직감적으로 느낄 수 있었다. getFrame(프레임번호)가 늘 새로운 프레임을 리턴한다면 문제가 될 것이다. 그리고 getFrame메써드는 아직은 필요하다고 느낀다.

'''
TODO
===========================================================
* calculate strike score
* calculate spare score
* currentFrame and getFrame(index) must be same

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
* throwdown increase frame no
* frame has available count and decrease by throwdown
* calculate each frame score
'''

이 불안감을 명확함으로 바꾸어주는 테스트 코드를 작성해 보도록 하자.

def testFrameEquality(self):
    self.assertEquals(self.game.getFrame(1), self.game.getCurrentFrame())
    for i in range(3): self.pey.throwDown(1)
    self.assertEquals(self.game.getFrame(2), self.game.getCurrentFrame())

공을 던지지 않았을 때는 현재 프레임이 첫번째 프레임이 되어야 하고, 공을 3번 던졌을 때는 2번째 프레임이 되어야 한다는 의도이다. 통과하려면 getFrame메써드가 다음과 같이 변해야 할것이다.

class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        if not self.curframe.available():
            self.curframe = self.nextframe()
        return self.curframe

    def nextframe(self):
        return self.frames[self.curframe.no()]

    def getFrame(self, no):
        return self.frames[no-1]

'''
TODO
===========================================================
* calculate strike score
* calculate spare score
* frame no when strike
* calculate game score

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
* throwdown increase frame no
* frame has available count and decrease by throwdown
* calculate each frame score
* currentFrame and getFrame(index) must be same
'''

TODO가 계속 변하고 있다. 해야 할 일들이 대부분 DONE으로 이동했지만 남아있는 것들과 추가된 사항이 있다. 일단 스트라이크, 스페어일 경우의 프레임 점수, 스트라이크일 경우 프레임의 증가, 전체 게임 스코어등이 남아있다.

무엇을 먼저 해야 할까?

스트라이크일 경우 프레임이 증가하는지를 테스트해 보자.

def testStrikeIncreaseFrame(self):
    self.pey.throwDown(10)
    self.assertEquals(2, self.game.getCurrentFrame().no())

1프레임에서 10개의 핀을 쓰러뜨린 경우 현재의 프레임은 2가 되어야 한다는 의도이다. 통과하려면,

class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def isStrike(self, pin):
        return pin == 10

    def throwDown(self, pin):
        frame = self.game.getCurrentFrame()
        frame.decrease()
        if self.isStrike(pin): frame.decrease()
        frame.addScore(pin)

Gamer클래스를 위와같이 변경하여 테스트를 통과할 수 있었다.

실제 코드를 가만히 관찰해보니 Gamer클래스의 throwDown메써드가 왠지 잘못된 듯한 느낌이 든다. isStrike와 decrease같은 것들은 Frame클래스로 이동시키는 것이 현명할 것 같다. 리팩토링 해보면,

class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDown(self, pin):
        frame = self.game.getCurrentFrame()
        frame.addScore(pin)

class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2
        self.score = 0

    def isStrike(self):
        return self.availability == 1 and self.score == 10

    def addScore(self, score):
        self.score += score
        self.availability -= 1
        if self.isStrike(): self.availability = 0

    def no(self):
        return self.frameno

    def available(self):
        return self.availability != 0

    def getScore(self):
        return self.score

다음은 볼링게임 알고리즘의 핵심이 될 만한 부분이다. 스트라이크일 경우의 프레임별 점수를 계산하여 보자. 스트라이크를 칠 경우 다음 두번 던진 점수만큼을 스트라이크 친 프레임에 보너스 점수를 주게 되어있다.

위의 사항을 반영시켜 테스트 코드를 작성해 보면 다음과 같을 것이다.

def testStrikeScore(self):
    self.pey.throwDown(10)
    self.pey.throwDown(2)
    self.pey.throwDown(5)
    self.assertEquals(10+2+5, self.game.getFrame(1).getScore())
    self.assertEquals(2+5, self.game.getFrame(2).getScore())

첫 프레임에 스트라이크를 치고, 두번째 프레임에서 2, 5개의 핀을 쓰러뜨렸다면 첫번째 프레임의 점수는 스트라이크점수(10)+보너스점수(2+5)를 합한 점수가 되어야 한다.

통과하기 위한 실제 코드는 다음과 같이 만들어진다.

class Gamer:
    ...
    def throwDown(self, pin):
        frame = self.game.getCurrentFrame()
        frame.addScore(pin)

        prevframe = self.game.getFrame(frame.no()-1)
        if frame.no() != 1 and self.game.getFrame(frame.no()-1).hasBonus():
            prevframe.addBonus(pin)

class Frame:
    ...

    def addScore(self, score):
        self.score += score
        self.availability -= 1
        if self.isStrike():
            self.availability = 0
            self.bonus = 2

    def addBonus(self, score):
        self.score += score
        self.bonus -= 1

    ...

    def hasBonus(self):
        return self.bonus != 0

리팩토링을 한 전체코드는 다음과 같이 변한다.

'''
TODO
===========================================================
* calculate spare score
* calculate game score

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
* throwdown increase frame no
* frame has available count and decrease by throwdown
* calculate each frame score
* currentFrame and getFrame(index) must be same
* calculate strike score
* frame no when strike
'''

import unittest


class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDown(self, score):
        frame = self.game.getCurrentFrame()
        frame.addScore(score)
        if frame.no() != 1 :
            self.game.prevframe().addBonus(score)


class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        if not self.curframe.available():
            self.curframe = self.nextframe()
        return self.curframe

    def nextframe(self):
        return self.getFrame(self.curframe.no()+1)

    def prevframe(self):
        return self.getFrame(self.curframe.no()-1)

    def getFrame(self, no):
        return self.frames[no-1]


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2
        self.score = 0
        self.bonus = 0

    def addScore(self, score):
        self.score += score
        self.availability -= 1
        self.checkStrike()

    def checkStrike(self):
        if self.available() and self.score == 10:
            self.availability = 0
            self.bonus = 2

    def addBonus(self, score):
        if self.bonus:
            self.score += score
            self.bonus -= 1

    def no(self):
        return self.frameno

    def available(self):
        return self.availability

    def getScore(self):
        return self.score




class BowlingTest(unittest.TestCase):
    def setUp(self):
        self.pey = Gamer()
        self.game = self.pey.startGame()

    def testGameFrame(self):
        frame1 = self.game.getFrame(1)
        frame10 = self.game.getFrame(10)
        self.assertEquals(1, frame1.no())
        self.assertEquals(10, frame10.no())

    def testFrameAvailability(self):
        frame = self.game.getCurrentFrame()
        self.assertEquals(1, frame.no())
        self.assertEquals(2, frame.availability)
        frame.availability = 0
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testNextFrameByThrowDown(self):
        self.pey.throwDown(1)
        self.pey.throwDown(1)
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testFrameScore(self):
        self.pey.throwDown(1)
        frame = self.game.getCurrentFrame()
        self.assertEquals(1, frame.getScore())
        self.pey.throwDown(5)
        self.assertEquals(6, frame.getScore())

    def testFrameEquality(self):
        self.assertEquals(self.game.getFrame(1), self.game.getCurrentFrame())
        for i in range(3): self.pey.throwDown(1)
        self.assertEquals(self.game.getFrame(2), self.game.getCurrentFrame())

    def testStrikeIncreaseFrame(self):
        self.pey.throwDown(10)
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testStrikeScore(self):
        self.pey.throwDown(10)
        self.pey.throwDown(2)
        self.pey.throwDown(5)
        self.assertEquals(10+2+5, self.game.getFrame(1).getScore())
        self.assertEquals(2+5, self.game.getFrame(2).getScore())


if __name__ == '__main__':
    unittest.main()

이제 스페어일 경우의 점수를 계산해보자. 테스트 코드는 다음과 같이 만들어진다.

def testSpareScore(self):
    self.pey.throwDowns(5,5,5)
    self.assertEquals(5+5+5, self.game.getFrame(1).getScore())
    self.assertEquals(5, self.game.getFrame(2).getScore())

throwDown메써드의 확장형태인 throwDowns라는 메써드를 만들어야 하고, 스페어일 경우 한번의 보너스 점수가 있음을 의도하는 테스트 코드이다. throwDown(5)를 세번쓰는 것은 역시 중복의 한 형태이다.

위 테스트를 통과하려면 코드는 다음과 같이 변한다.

class Gamer:
    ...
    def throwDowns(self, *scores):
        for score in scores:
            self.throwDown(score)
    ...


class Frame:
    ...
    def addScore(self, score):
        self.score += score
        self.availability -= 1
        self.checkBonus()

    def isStrike(self):
        return self.score==10 and self.available()

    def isSpare(self):
        return self.score==10 and not self.available()

    def checkBonus(self):
        if self.isStrike(): self.bonus = 2; self.availability = 0
        elif self.isSpare(): self.bonus = 1
    ...

이제 마지막 남은 TODO인 게임점수를 구해 보도록 하자. 테스트는 다음과 같다.

def testGameScore(self):
    for i in range(10): self.pey.throwDowns(1,2)
    self.assertEquals(10*(1+2), self.game.getScore())

코드는 다음과 같이 만들어진다.

class BowlingGame:
    ...
    def getScore(self):
        result = 0
        for frame in self.frames:
            result += frame.getScore()
        return result

모두 스트라이크일 경우는 300점이 나온다는 사실을 알고 있는가? 테스트는 다음과 같다.

def testAllStrike(self):
    for i in range(12): self.pey.throwDown(10)
    self.assertEquals(300, self.game.getScore())

위의 테스트를 실행했을 때 심각한 버그를 발견할 수 있었다. 그 원인은 bonus점수를 현재 프레임 이전 프레임만 적용했기 때문이었다. 스트라이크를 연속 세번 쳤다고 가정하면 첫번째 프레임 점수는 30이 되어야 하는데 하나 이전의 프레임 보너스만 검사하므로 20이 되어 버리는 것이다.

위의 버그를 수정하고 테스트를 통과하도록 수정하였다. 코드는 다음과 같다.

class Gamer:
    def __init__(self):
        self.game = BowlingGame()

    def startGame(self):
        return self.game

    def throwDowns(self, *scores):
        for score in scores:
            self.throwDown(score)

    def throwDown(self, score):
        curframe = self.game.getCurrentFrame()
        curframe.addScore(score)
        self.game.checkBonus(score)


class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        if not self.curframe.available() and self.curframe.no() != 10:
            self.curframe = self.getFrame(self.curframe.no()+1)
        return self.curframe

    def getFrame(self, no):
        return self.frames[no-1]

    def getScore(self):
        result = 0
        for frame in self.frames:
            result += frame.getScore()
        return result

    def checkBonus(self, score):
        if self.curframe.no() == 1: return
        self.getFrame(self.curframe.no()-1).addBonus(score)
        self.getFrame(self.curframe.no()-2).addBonus(score)

다음은 dutchman을 테스트해 보자. dutch맨은 스트라이크와 스페어를 번갈아 가면서 친 경우를 말한다. dutchman일 경우 200점이라는 점수가 나온다.

테스트는 다음과 같다.

def testDutchMan(self):
    for i in range(5):
        self.pey.throwDown(10)
        self.pey.throwDowns(5,5)
    self.pey.throwDown(10)
    self.assertEquals(200, self.game.getScore())

코드의 수정없이 dutchman테스트가 통과됨을 볼 수 있을 것이다.

한가지 남은점은 볼링게임의 마무리 시점이다.

'''
TODO
===========================================================
* check gameover point
'''

볼링머신을 만드는 사람들은 게임이 종료되는 시점을 꼭 알아야만 할것이다. 다음과 같이 테스트를 작성해, 게임이 종료되어야함을 명시하자.

def assertGameover(self):
    try:
        curframe = self.game.getCurrentFrame()
        self.pey.throwDown(1)
        self.fail("should not reach here")
    except ThrowDownError:
        pass

def testGameOver1(self):
    for i in range(10): self.pey.throwDowns(2,3)
    self.assertGameover()

def testGameOver2(self):
    for i in range(9): self.pey.throwDowns(2,8)
    self.pey.throwDowns(10,2,3)
    self.assertGameover()

스트라이크나 스페어가 없을경우는 10번째 2투구를 하고 나서 게임이 종료되어야 한다. 10번째 프레임에 스트라이크를 친경우는 두번의 투구를 더 한후에 게임이 종료되어야 한다.

또한 위에서 테스트해보았던 all strike일 경우 dutchman일 경우도 게임이 종료하는지를 테스트했다. 다음은 게임종료 테스트를 포함해서 통과한 실제코드, 리팩토링된 최종 실제 코드이다. (중간 과정은 생략하기로 한다.)

최종 테스트와 코드는 아래와 같다.

'''
TODO
===========================================================

DONE
============================================================
* game contains each frame
* make bowling game start
* somebody throwdown bowling pin
* throwdown increase frame no
* frame has available count and decrease by throwdown
* calculate each frame score
* currentFrame and getFrame(index) must be same
* calculate strike score
* frame no when strike
* calculate spare score
* calculate game score
* check gameover point
* remove gamer class
'''

import unittest


### code    ##############################################################

class ThrowDownError(RuntimeError):
    pass

class BowlingGame:
    def __init__(self):
        self.frames = [Frame(i) for i in range(1, 11)]
        self.curframe = self.frames[0]

    def getCurrentFrame(self):
        return self.curframe

    def getFrame(self, no):
        return self.frames[no-1]

    def getScore(self):
        return reduce(lambda x,y:x+y,
            [frame.getScore() for frame in self.frames])

    def throwDowns(self, *scores):
        for score in scores:
            self.throwDown(score)

    def throwDown(self, score):
        if self.curframe.isGameOver(): raise ThrowDownError
        self.curframe.addScore(score)
        self.checkBonus(score)
        if self.curframe.isFrameOver():
            self.curframe = self.getFrame(self.curframe.no()+1)

    def checkBonus(self, score):
        if self.curframe.no() == 1: return
        for i in range(1,3):
            self.getFrame(self.curframe.no()-i).addBonus(score)


class Frame:
    def __init__(self, frameno):
        self.frameno = frameno
        self.availability = 2
        self.score = 0
        self.bonus = 0

    def addScore(self, score):
        self.score += score
        self.availability -= 1
        self.checkBonus()

    def checkBonus(self):
        if self.isStrike(): self.bonus = 2; self.availability = 0
        elif self.isSpare(): self.bonus = 1

    def isFrameOver(self):
        return self.frameno != 10 and self.availability == 0

    def isGameOver(self):
        return self.frameno == 10 and self.availability+self.bonus == 0

    def isStrike(self):
        return self.score==10 and self.availability

    def isSpare(self):
        return self.score==10 and not self.availability

    def addBonus(self, score):
        if self.bonus:
            self.score += score
            self.bonus -= 1

    def no(self):
        return self.frameno

    def getScore(self):
        return self.score


### test    ################################################################

class BowlingTest(unittest.TestCase):
    def setUp(self):
        self.game = BowlingGame()

    def testNextFrameByThrowDown(self):
        self.game.throwDowns(1,1)
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testFrameEquality(self):
        self.assertEquals(self.game.getFrame(1), self.game.getCurrentFrame())
        for i in range(3): self.game.throwDown(1)
        self.assertEquals(self.game.getFrame(2), self.game.getCurrentFrame())

    def testStrikeIncreaseFrame(self):
        self.game.throwDown(10)
        self.assertEquals(2, self.game.getCurrentFrame().no())

    def testStrikeScore(self):
        self.game.throwDowns(10,2,5)
        self.assertEquals(10+2+5, self.game.getFrame(1).getScore())
        self.assertEquals(2+5, self.game.getFrame(2).getScore())

    def testSpareScore(self):
        self.game.throwDowns(5,5,5)
        self.assertEquals(5+5+5, self.game.getFrame(1).getScore())
        self.assertEquals(5, self.game.getFrame(2).getScore())

    def testGameScore(self):
        for i in range(10): self.game.throwDowns(1,2)
        self.assertEquals(10*(1+2), self.game.getScore())

    def testAllStrike(self):
        for i in range(12): self.game.throwDown(10)
        self.assertEquals(300, self.game.getScore())
        self.assertGameover()

    def testDutchMan(self):
        for i in range(5):
            self.game.throwDown(10)
            self.game.throwDowns(5,5)
        self.game.throwDown(10)
        self.assertEquals(200, self.game.getScore())
        self.assertGameover()

    def assertGameover(self):
        try:
            curframe = self.game.getCurrentFrame()
            self.game.throwDown(1)
            self.fail("should not reach here")
        except ThrowDownError:
            pass

    def testNormalGameOver(self):
        for i in range(10): self.game.throwDowns(2,3)
        self.assertGameover()

    def testStrikeGameOver(self):
        for i in range(9): self.game.throwDowns(2,8)
        self.game.throwDowns(10,2,3)
        self.assertGameover()



if __name__ == '__main__':
    unittest.main()