본문 바로가기
Study/테스트 주도 개발

[TDD] 스터디 1주차 (기본적인 테스트 방법, 1~2장 정리)

by Nahwasa 2022. 12. 18.

스터디 메인 페이지

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

     

     


     

    ☆ 스터디 킥오프 (테스트 관련 랜덤 팁)

      이하 내용은 책 내용 스터디 들어가기 전에 스터디 팀원들끼리 공유한 내용이다.

     

    [ Octoping925님의 발표 자료 : https://github.com/Octoping925/TestCodePresent ]

     

    실제 구현부와 테스트쪽의 패키지, 클래스명 구조를 동일시 하는게 중요하다.

     

    GWT, AAA

    • Given : 특정한 값을 가지고
    • When : 로직을 실행했을 때
    • Then : 잘 나왔는지 검증한다.
    • AAA : Assignment(=Given) / Act(=When) / Assert(=Then). 용어 차이이므로 어느쪽 쓸건진 취향인듯함.

     

    @ParameterizedTest 를 사용하면 인자 넣어서 테스트가능.

     

    @SpringBootTest 사용 시 autowired로 주입받을 수 있다. 다만 이 경우 모든 bean을 등록해야 하므로 오래걸린다.

     

    원하는 bean만 등록해서 가능하다. 하지만 지정한 클래스가 다른 또다른 테스트가 존재한다면(ApplicationContext가 똑같으면 다른 클래스의 테스트여도 그걸 재사용하지만 모킹 빈을 쓰든, 특정 몇 개만 올린 테스트든 ApplicationContext가 달라지면 그 Application Context를 위해 새 스프링을 띄운다.) 스프링이 여러번 실행되어 비효율적이다. (@SpringBootTest만 쓴 클래스가 100개 있다치면은 요건 하나가지고 돌려씀)

     

    또한 이하와 같은 테스트의 경우엔 DB에 실제로 데이터가 들어가게되는 문제가 있다. 따라서 처음 실행시엔 성공하는데, DB에 들어가면 실패하게 된다. @AfterEach로 지워줘도 되지만, 테스트 코드에서 @Transactional을 넣어주면 rollback이 기본이다. 롤백 안되게 하려면 @Rollback(false)도 추가한다.

     

    최종 코드

     

    ⚈ 하지만 어쨌든 운영DB에서 실험하는건 안좋다. 테스트용 DB를 따로 두자(h2 같은 메모리 db). test쪽에 resouces에 application.properties를 추가해서.

     

    위 내용 관련된 추가설명

     

    세미나 중 DB 테스트 자체 보다는 Mock으로 처리하는게 낫다는 의견도 있었다. 원래 컨트롤러 테스트 따로, 서비스 테스트 따로, 레포지토리 테스트 따로 진행. 그리고 각각 테스트 할 때에는 예를 들어 컨트롤러테스트 할 때에는 컨트롤러가 잘 작동하는건지만 보고 싶은 거기 때문에 서비스, 레포지토리는 모킹해버림. 서비스 테스트도 레포지토리에 잘 저장하는지는 확인 안 하고, 그냥 서비스의 비즈니스 로직이 잘 돌아가는지만 테스트합니다 (레포지토리는 모킹할테구요)

     

     

    ⚈ 이메일서비스에서 정말 메일이 날아가는게 싫다. 이런 경우 모킹 해줘야 한다. 이하 코드는 "emailService에 sendEmail 실행 시 doAnswer 안쪽의 작업을 해줘라." 라는 의미이다. 단 이 경우 스프링은 또 하나가 더 올라갈 것이다. (MockBean이 아닌 emailService, MockBean인 emailService가 다르므로)

     

    ⚈ MockBean을 안쓸려고 이번엔 stub. 생성자 주입으로 해야 stub 가능.

     

    ⚈ 스파이를 추가해 이메일이 한번만 날아갔는지 확인

     

    ⚈ 일요일엔 경험치가 2배인 코드를 보자. 

     

    ⚈ 위의 경우 일요일엔 실패하고 나머지 날엔 성공할 것이다.

    • 문제점 : huntMonster는 exp뿐 아니라, '시간'이라는 숨겨진 입력값을 받고 있기 때문에 given을 제대로 설정하지 못했다. 그러니 테스트 하기 좋지 못한 구조이다.

     

    ⚈ 인자로 받아주도록 처리하면 해결됨.

     

    ⚈ private은 테스트하지 않고, private을 사용하는 public을 테스트하자.

     

    ⚈ gradle에서 빌드 시 테스트 코드가 제품이 딸려가진 않는다.

     

     

     

     

    [ 이하 추가 내용 관련 : https://github.com/nahwasa/springboot-test-study  에서 branch 1, 2 관련 내용임. ]

     

    ⚈ 스프링부트에서 JUnit에 대해 아무런 설정도 없는데 위 octoping님 설명처럼 사용이 가능한 이유 : 그래들 디펜던시 보면 스프링부트 스타터에 이미 포함되어 있음.

     

    ⚈ 위에보면 JUnit이 아니고 jupiter라고 되어있는데, JUnit은 테스트 명세라 보면 되고 그 구현체로 Vintage와 Jupiter같은게 있음. Vintage는 JUnit 3,4용이고 Jupiter는 JUnit 5 용임.

     

    ⚈ 스프링부트 2.2.x부터 JUnit5가 기본적으로 채택되었고, 그 미만 버전을 쓸 일이 없으므로 우린 JUnit5로 스터디 하면 되고, 구현체로 Jupiter 써서 한다고 보면 됨. 참고로 4랑 5는 어노테이션 등 차이가 있음. (After -. AfterEach 처럼)

    ⚈ assert 함수로 테스트 시 마지막 인자로 테스트 실패 시 띄울 메시지를 넣을 수 있다. 이 때 String과 Supplier 두 가지 형태로 넣을 수 있는데, 이하와 같이 연산의 결과를 출력하고자 하는 경우(아래에선 String '+' 연산), 그냥 String 형태로 넣을 경우 해당 코드 실행 시 무조건 추가 연산이 일어난다. 실패 시에만 추가 연산이 일어나고 싶다면 Supplier 형태로 넣으면 되는데, 그냥 람다식으로 넣으면 알아서 Supplier 형태로 들어간다.

    assertEquals(StudyStatus.INIT, study.getStatus(), () -> "Study 생성시 초기값은 " + StudyStatus.INIT + " 이여야 함.");

     

    ⚈ 여러 테스트를 한꺼번에 진행하고 싶은 경우, 함수를 아예 나누는 방법도 있지만 동일한 함수 내에서 한꺼번에 실행하고 싶을 수 있다. 이 경우 위와 같이 그냥 넣게되면 중간에 실패하는 테스트가 발생 시 해당 함수는 종료된다. 따라서 assertAll을 사용해서 여러개를 넣어주면 된다. 이 경우 하나가 실패하더라도 모든 테스트가 실행되며, N개 중 몇 개가 실패했는지도 알려준다.

     

    위 내용 관련된 팀원의 추가 팁 : assertSoftly는 assertAll과 동일한 기능을 하지만 추가로 에러가 난 부분이 몇 번째 줄인지도 알려주므로 더 유용하다.

     

     

     


     

     

    테스트 주도 개발 책 1장 이전 내용

    ☆ 1장 들어가기 전 내용들 중 개인적으로 좋았던 내용을 정리했습니다.

     

    ⚈ 품질에 대한 책임은 그 누구보다도 작업자에게 맡겨야 한다.

     

    ⚈ 빨리 테스트를 통과시키려고, 혹은 프로그램을 빨리 작성하려고 너무 조바심 내지 마세요. TDD를 쫒아가려고 하지 마시고, TDD가 자신을 따라오게 하세요.

     

    ⚈ TDD의 특성상 완성된 프로그램 코드를 보거나, 간단한 메뉴얼 정도로는 TDD를 익힐 수 없습니다. 그 과정을 하나하나 따라가야 합니다.

     

    ⚈ "분명 엄청나게 좌절할 것입니다." (☆ ㄹㅇㅋㅋ)

     

    ⚈ 켄트 백(저자) : "저는 테스트 없이 프로그래밍하면 확신이 덜 생기고 시간은 더 듭니다." / "코드를 어떻게 테스트해야 할지 알아내기 전까지는 결코 만족하지 않습니다. 저는 이런 실천을 '끈기'라고 부르겠습니다."

    • ☆ 내가 이 스터디를 연 목적은 개인적으로 정말 시간이 없어도 테스트코드 짜면서 진행하는게 빠른지에 대한 확신이 없기 때문이다. 아직은 시간없는데 테스트코드까지 짜면 당연히 느리지 않을까 생각이 든다. 남이 말해봤자 아직 이해가 잘 안되는걸로보아 직접 확인해봐야할듯하다. 직접 확인해보려면 어느정도 능숙하게 다룰 수 있어야 하므로 공부해서 직접 확인해보기 위해 스터디를 열었다.

     

    ⚈ 테스트 주도 개발의 궁극적인 목표 : 작동하는 깔끔한 코드 (clean code that works)

     

    TDD의 주문

    • 빨강 : 실패하는 작은 테스트를 작성한다. 처음에는 컴파일조차 되지 않을 수 있다.
    • 초록 : 빨리 테스트가 통과하게끔 만든다. 이를 위해 어떤 죄악을 저질러도 좋다.
    • 리팩토링 : 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거한다.

     


     

    1장. 다중 통화를 지원하는 Money 객체

    [ 작업해야 할 테스트 목록 만들기 ]

    ⚈ 아래와 같은 보고서가 있다고 하자.

     

    ⚈ 책에서 얘기하는 다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야하고, 환율도 명시해야 한다.

     

     어떤 테스트가 있어야 새로운 보고서가 완성됐다는걸 확신할 수 있을까?

    • 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변한 금액을 결과로 얻을 수 있어야 한다.
    • 어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.

     


    [ 이야기를 코드로 표현 ]

    (오퍼레이션이 외부에서 어떻게 보이길 원하는지 말해주는 이야기를 코드로 표현한다.)

     

     곱하기를 먼저 다루려 한다. 객체를 만들면서 시작하는게 아니라 테스트를 먼저 만들어야 한다.

    • 작은 단계로 시작하자.
    • 간단한 곱셈 테스트의 예
    public class MoneyTest {
        @Test
        @DisplayName("5달러를 2배하면 10달러여야 한다.")
        void dollar_multiplication() {
            Dollar five = new Dollar(5);
            five.times(2);
            assertEquals(10, five.amount);
        }
    }

    스프링부트로 생성 후 기본적으로 생성된 클래스들을 모두 삭제한 상태이다. test쪽에 MoneyTest만 생성해 해보고 있는 상황이다. 스프링부트3.0을 사용했다.

     

     아직은 컴파일조차 되지 않는다.

     


    [ 새로운 할일은 할일 목록에 추가하고 넘어가자 ]

     생각나는 문제들은 적어 놓고 계속 진행하자. 지금 우리에겐 빨리 실패하는 테스트를 성공하시는걸 하고 싶을 뿐이다.

    5달러 + 10CHF = 10달러 (환율이 2:1일 경우)
    5달러 * 2 = 10달러 (1장에서 진행하는 것)
    amount를 private으로 만들기
    Dollar의 side effect?
    Money 반올림?

     


    [ 스텁 구현을 통해 테스트를 컴파일 ]

     실행은 안 되더라도 컴파일은 되게 만들고 싶다.

    • ☆ 인텔리제이의 경우 위와 같이 추천 action이 뜨므로 더 쉽게 작성 가능하다.
    • Dollar 클래스가 없음
    public class Dollar {
    }
    • 생성자가 없음
    public class Dollar {
        public Dollar(int amount) {
        }
    }
    • times(int) 메서드가 없음
    public class Dollar {
        public Dollar(int amount) {
        }
    
        public void times(int multiplier) {
        }
    }
    • amount 필드가 없음
    public class Dollar {
        public int amount;
    
        public Dollar(int amount) {
        }
    
        public void times(int multiplier) {
        }
    }


    ⚈ 이제 컴파일은 가능하지만 당연히 테스트는 실패한다.

    • 이것도 일종의 진척이다. 이제 실패에 대한 구체적인 척도가 생겼다.
    • 우리의 문제는 '다중 통화 구현'에서 '이 테스트를 통과시킨 후 나머지 테스트들도 통과시키기'로 변형되었다.`
    • 훨씬 간단하다. 범위도 훨씬 적어서 걱정이 줄었다.
    • ☆ 문제 해결을 위해 사용하는 저장소는 장기 기억이 아닌 단기 기억이다. 문제 해결을 위해 필요한 요소수가 단기 기억의 용량을 초과하는 순간 문제 해결능 력은 급격히 떨어진다(인지 부조화). 오브젝트 책에서 추상화를 설명하는 문장이지만 이 상황에도 적용될 듯 하다.

     


    [ 끔찍한 죄악을 범하여 테스트를 통과시키기 ]

     테스트 주기

    1. 작은 테스트를 하나 추가한다.
    2. 모든 테스트를 실행해서 테스트가 실패하는 것을 확인한다.
    3. 조금 수정한다.
    4. 모든 테스트를 실행해서 테스트가 성공하는 것을 확인한다.
    5. 중복을 제거하기 위해 리팩토링 한다.

     

     일단 성공시켜보자!

    public class Dollar {
        public int amount = 10;
    
        public Dollar(int amount) {
        }
    
        public void times(int multiplier) {
        }
    }

    • ☆ 로직상 어무런 의미가 없어보이지만, 당연히 아무튼 성공한다.
    • 이제 위 테스트 주기의 1~4를 진행한 것이다.

     


    [ 점진적으로 일반화 ]

     중복이 테스트에 있는 데이터와 코드에 있는 데이터 사이에 존재한다.

    • amount = 10 = 5*2 이다.
    • 5와 2는 사실 테스트쪽에서 넘어온 값이므로 중복이다.

     

     객체의 초기화 단계에 있는 설정 코드를 times()  메서드 안으로 옮겨보자.

    public class Dollar {
        public int amount;
    
        public Dollar(int amount) {
        }
    
        public void times(int multiplier) {
            amount = 5 * 2;
        }
    }
    • 테스트는 여전히 통과한다. (☆ 중복은 해결되지 않았지만, 아무튼 약간이라도 바뀌었다.)
    • TDD의 핵심은 이런 작은 단계를 밟아야 한다는 것이 아니라, 이런 작은 단계를 밟을 능력을 갖추어야 한다는 것이다.

     

     정말 작은 단계로 작업하는 방법을 배우면, 저절로 적절한 크기의 단계로 작업할 수 있게 될 것이다.

     

     계속 진행하자.

    • '5'는 생성자에서 넘어오는 값이므로 그걸 amount에 두자.
    • '2'는 multiplier의 값이므로 리터럴 값을 이 인자로 대체하자.
    public class Dollar {
        public int amount;
    
        public Dollar(int amount) {
            this.amount = amount;
        }
    
        public void times(int multiplier) {
            amount *= multiplier;
        }
    }

     

     이제 $5 x 2 = $10 테스트에 완료 표시를 할 수 있게 됐다.

    5달러 + 10CHF = 10달러 (환율이 2:1일 경우)
    5달러 * 2 = 10달러
    amount를 private으로 만들기
    Dollar의 side effect?
    Money 반올림?

     

     


     

    2장. 타락한 객체

    ⚈ 일반적인 TDD 주기

    1. 테스트를 작성한다. 올바른 답을 얻기 위해 필요한 이야기의 모든 요소를 포함시켜라.
    2. 실행 가능하게 만든다. 빨리 초록 막대를 보는 것은 모든 죄를 사해준다. 하지만 아주 잠시 동안만이다.
    3. 올바르게 만든다. 직전에 저질렀던 죄악을 수습하자.

     

    ⚈ 우리의 목적 : 작동하는 깔끔한 코드를 얻는 것. '작동하는'을 먼저 해결하고 '깔끔한 코드' 부분을 해결하자.

     

     할일 목록

    5달러 + 10CHF = 10달러 (환율이 2:1일 경우)
    5달러 * 2 = 10달러
    amount를 private으로 만들기
    Dollar의 side effect? (2장에서 하려는 것)
    Money 반올림?

     

     1장에서 통과한 테스트의 문제점

    • Dollar에 대해 연산을 수행한 후에 해당 Dollar의 값이 바뀌는 점
    • 즉, 아래와 같이 테스트하고 싶다. -> 현재는 times()를 처음 호출한 이후에 five는 더 이상 5가 아니다.
    public class MoneyTest {
        @Test
        @DisplayName("5달러를 2배하면 10달러여야 한다.")
        void dollar_multiplication() {
            Dollar five = new Dollar(5);
            five.times(2);
            assertEquals(10, product.amount);
            five.times(3);
            assertEquals(15, product.amount);
        }
    }

     

     times()에서 새로운 객체를 반환하게 해보자.

    • 그럼 five는 항상 5이다.
    • Dollar의 인터페이스를 수정해야 하고, 테스트도 수정해야 한다. -> 문제될 건 없다. 어떤 구현이 올바른가에 대한 우리의 추측이 완벽하지 못한 것과 마찬가지로 올바른 인터페이스에 대한 추측 역시 절대 완벽하지 못하다.
    public class MoneyTest {
        @Test
        @DisplayName("5달러를 2배하면 10달러여야 한다.")
        void dollar_multiplication() {
            Dollar five = new Dollar(5);
            Dollar product = five.times(2);
            assertEquals(10, product.amount);
            product = five.times(3);
            assertEquals(15, product.amount);
        }
    }

     

     당연히 아직은 컴파일조차 안된다.

     

     우선 컴파일은 되게 하자.

    public class Dollar {
        public int amount;
    
        public Dollar(int amount) {
            this.amount = amount;
        }
    
        public Dollar times(int multiplier) {
            amount *= multiplier;
            return null;
        }
    }
    • 물론 NullPointerException이 뜰 것이다.

    • 그래도 한 걸음 나아간 것이다.

     

     테스트를 통과하기 위해 올바른 금액을 갖는 새 Dollar를 반환하자.

    public Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
    • 이제 잘 된다!

     

     할일 목록 하나 더 해결!

    5달러 + 10CHF = 10달러 (환율이 2:1일 경우)
    5달러 * 2 = 10달러
    amount를 private으로 만들기
    Dollar의 side effect?
    Money 반올림?

     

     최대한 빨리 초록색(테스트 성공)을 보기 위해 취할 수 있는 전략

    • 가짜로 구현하기 : 상수를 반환하게 만들고 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 바꾸어 간다. -> 1장에서 했었던거.
    • 명백한 구현 사용하기 : 실제 구현을 입력한다. -> 2장에서 한게 이것. ☆ 자기가 아는 명백한 방식으로 구현하라는 것. 
    • 모든 일이 자연스럽게 잘 진행되고 내가 뭘 입력해야 할지 알 때는 명백한 구현을 계속 더해 나간다. -> 예상치 못한 빨간 막대(테스트 실패)를 만나면 가짜로 구현하기 방법을 사용하면서 올바른 코드로 리팩토링한다. -> 다시 자신감을 되찾으면 명백한 구현 사용하기 모드로 돌아온다.

    댓글