목차

기능 명세

설계 과정을 지원하는 TDD

기능 명세 구체화

 

기능 명세

설계는 기능 명세로부터 시작한다.

 

기능은 입력과 결과를 가진다.

입력 - 기능을 실행하는데 필요한 값

결과 - 기능을 수행한 결과로 상황에 따라 달라질 수 있다.

  • 리턴 값
  • 익셉션 : throw 된 예외
  • 변경 : DB/ 시스템 에 변경된 사항

 

다양한 형태의 요구사항 문서를 이용해서 기능 명세를 구체화

▶ 기능 명세를 구체화하는 동안 입력과 결과를 구상

도출한 기능 명세를 코드에 반영

설계 과정을 지원하는 TDD

TDD 는  테스트 코드 만들기 → 테스트를 통과시키기 위해 코드 구현 → 리팩토링 의 반복.

 

테스트 코드를 만들기 위해 필요한 사항

  • 테스트할 기능을 실행
  • 실행 결과를 검증

 
기능을 실행할 수 없으면 테스트를 할 수 없다.
  • 테스트에서 실행할 수 있는 객체나 함수가 필요

  • 실행할 객체가 존재하려면 클래스 와 메서드도 필요

    • 클래스나 메서드를 정의하기 위한 이름을 결정

    • 메서드를 실행할 때 사용할 인자의 타입과 개수를 결정

예를 들어 "암호 강도 측정과 만료일 계산 예제" 에서 결정한 사항

  • 클래스 이름
  • 메서드 이름
  • 메서드 파라미터
  • 실행 결과

이 과정에서 이름(기능을 정확하게 표현하는 이름)을 고민하고 파라미터 타입과 리턴 타입을 고민했다. 

→ 이러한 과정이 설계 과정

 

구현하는 기능을 정확하게 표현하는 이름을 사용하는 것이 중요하다.
→ 이름을 통해서 어떻게 동작하는지 예상이 가능 해야한다.

이름을 고민하는 시간을 아까워하지 말자.

 

 

■ 필요한 만큼 설계하기

  • TDD는 테스트를 통과할 만큼만 코드를 작성한다.
  • 필요한 것으로 예측해서 미리 코드를 만들지 않는다.
  • 실제 예외처리가 필요한 시점에서 익셉션을 도출

기능 명세 구체화

테스트 코드를 작성하려면 입력과 결과가 명확해야 한다.

  • 전달받은 요구사항에서 애매한 점을 발견?! → 해당 담당자에게 질문을 하여 구체적으로 정리

(예시) 만료일 계산 기능

 

요구사항

  • 서비스를 사용하려면 매달 1만 원을 선불로 납부한다. 납부일 기준으로 한 달 뒤가 서비스 만료일이 된다.
  • 2개월 이상 요금을 납부할 수 있다.
  • 10만 원을 납부하면 서비스를 1년 제공한다

개발자 :  한 달 뒤가 어느 시점인지 애매하다.

질문

  • 4월 1일에 만원을 납부하면 만료일은 언제예요? 4월 30일인가요? 5월 1일인가요?
  • 1월 31일에 만원을 납부하면 만료일은 언제인가? 2월 28일인가요? 아니면 30일 뒤인 3월 2일인가요?
  • 만약 윤년인 경우는 어떻게 되나요?
  • 1월 29일이나 1월 30일에 만원을 납부해도 2월 28일인가요? 윤년이면 2월 29일이 되구요?
  • 1월 31일이나 5월 31일처럼 만원을 납부한 일자가 다음 달 말일 일자보다크면 이때 만료일은 다음 달 말일이라고 생각면 될까요?

이와 같이 애매한 점을 질문하여 구체적인 예를 이용하여 테스트 코드를 만든다.

또한 테스트 코드를 만들면서 애매한 점이 있으면 질문을 하여 구체적인 예를 통해 모호함을 해결해야한다.

 

복잡한 로직을 구현해야 하는 것은 결국 개발자이므로
개발자는 최대한 예외적인 상황이나 복잡한 상황에 해당하는 구체적인 예를 끄집어내야한다.
이를 위한 가장 좋은 방법은 담당자와 대화를 하는 것이다.

목차

좋은 테스트 코드를 만들기 위해 주의해야 할 사항

테스트코드와 유지보수

테스트 코드는 그 자체로 코드이기 때문에 제품 코드와 동일하게 유지보수 대상이 된다.

실패한 테스트를 통과시키기 위해 많은 노력이 필요하면 점점 테스트 코드에서 멀지고 TDD에서도 멀어진다.

 

악순환 예시

  • 테스트 코드를 유지보수하는데 시간이 많이 든다 → 실패하는 테스트 증가 →실패한 테스트에 무감각 → 깨지는 테스트를 방치(주석처리) → 테스트 실패 여부에 상관없이 빌드 배포 

이를 막기위해

테스트 코드 자체의 유지 보수성이 좋아야한다.

 

좋은 테스트 코드를 만들기 위해 주의해야 할 사항

1.  변수나 필드를 사용해서 기댓값 표현하지 않기   실제 값을 사용하자

(예시) 기대하는 값에 변수를 사용한 코드

// 기대하는 값에 변수 사용
@Test
void dateFromat(){
	LocalDate date = LocalDate.of(1945,8,15);
	String dateStr = formatDate(date)
	assertEquals(data.getYear() + "년 " +
		date.getMonthValue() + "월 " +
		date.getDayOfMonth() + "일 ", dateStr);
}


// 기대하는 값에 문자열 사용
...
assertEquals("1945년 8월 15일", dateStr);
...
  • 변수를 사용하면 메서드를 잘 못 사용했을 때 테스트가 깨져야 실수를 알 수 있다.
  • 문자열을 사용한 예시는 기대하는 값이 명확하다.
    • 메서드를 잘 못 사용할 일 없음
    • 테스트가 깨지면 formatDate() 메서드만 확인하면 해결

(예시) 단언과 객체 생성에 필드와 변수를 사용한 코드

private List<Integer> answers = Arrays.asList(1,2,3,4);
@Test
void arrayCheck(){
	// 어레이 저장 코드 : answers 을 사용
	// 저장 결과를 확인 : savedArray
	assertALL(
    	() -> assertEquals(answers.get(0), savedArray.get(0))
    	() -> assertEquals(answers.get(1), savedArray.get(1))
    	() -> assertEquals(answers.get(2), savedArray.get(2))
    	() -> assertEquals(answers.get(2), savedArray.get(3))
}

// 개선한 코드
@Test
void arrayCheck(){
	// 어레이 저장 코드 : Arrays.asList(1,2,3,4) 을 사용
	// 저장 결과를 확인 : savedArray
	assertALL(
    	() -> assertEquals(1, savedArray.get(0))
    	() -> assertEquals(2, savedArray.get(1))
    	() -> assertEquals(3, savedArray.get(2))
    	() -> assertEquals(4, savedArray.get(3))
}

 

 

첫 번째 테스트 코드 처럼 작성을 하면 메스드가 많은 경우

테스트를 이해하기 위해서 객체와 변수를 확인하기 위해 필드와 변수를 계속 확인 해야한다

 

개선된 코드의 경우 실제 값을 사용

가독성이 올라가고 생성된 객체와 변수를 확인하기 위해서 필드와 변수를 오갈 필요가 없다.

 

2.  두 개 이상을 검증하지 않기   검증 대상을 명확하게 구분 → 유지보수에 유리

하나의 테스트에 두 개 이상을 검증하면,

테스트에 실패 했을 때, 둘 중 무엇이 실패했는지 확인해야 한다.

→ 서로 다른 내용을 검증한다면(검증 대상이 명확하게 구분되다면),
          각 검증을 대상을 별도로 분리 → 테스트의 집중도 향상, 유지보수에 유리

3.  정확하게 일치하는 값으로 모의 객체 설정하지 않기   범용적인 값을 사용 

모의 객체는 가능하 범용적인 값을 사용

  • 한정된 값에 일치하도록 모의객체를 사용? →  약간의 코드 수정만으로도 테스트 실패
    • 이 경우 테스트 코드의 일부 값을 수정하면 모의 객체 관련 코드도 수정 필요

4.  과도하게 구현 검증하지 않기   실행 결과를 검증 

테스트 대상의 내부 구현을 검증을 하면?

  • 구현이 조금만 변경되어도 테스트가 실패한다

→ 내부 구현은 언제든지 바뀔수 있으므로 실행 결과를 검증해야한다.

(예) 테스트 대상이 "비밀번호의 강도"를 확인하는 코드면  → 실행 결과가 정확하게 약하다, 강하다를 반환하는지 검증하면 된다. 

 

5.  셋업을 이용해서 중복된 상황을 설정하지 않기   상황 구성 코드가 테스트 메서드 안에 작성

테스트 코드를 작성하다 보면 각 테스트 코드에서 동일한 상황이 필요할 때가 있다.

이경우 중복된 코드를 제거하기 위해 → @BeforeEach 메서드 ( 상황설정 메서드)를 이용해서 상황을 구성할 수 있다.

중복을 제거하고 코드 길이도 짧아져서 코드 품질이 좋아졌다고 생각할 수 있지만, 테스트 코드에서는 상황이 달라진다.

 

메서드가 문제가 생겨서 몇 달만에 테스트 코드를 다시 본 경우

  • @BeforeEach 에 설정된 상황?을 다시 확인해가면서 문제의 원인을 분석해야함 (위아래로 반복적으로 이동)
  • 모든 테스트 메서드가 동일한 상황을 공유하므로 이전에 한 변경으로 인해 테스트가 실패할 수 있다.
각 테스트 메서드는 검증을 목표로 하는 하나의 완전한 프로그램이여야 한다
→ 검증 내용을 스스로 잘 설명할 수 있어야 한다
→ 상황 구성 코드가 테스트 메서드 안에 위치해야 한다.

 

▶ 통합 테스트에서 데이터 공유 주의하기 → 

   통합 테스트 메서드는 데이터 초기화를 위한 코드를 작성하지 않아도 된다.

    (이유) 초기화를 위한 쿼리 파일을 조금만 변경해도 많은 테스트가 실패할 수 있다.

 

   통합 테스트 코드를 만들 때는 다음의 두 가지로 초기화 데이터를 나눠서 생각

     -  모든 테스트가 같은 값을 사용하는 데이터: 예) 코드값 데이터

     -  테스트 메서드에서만 피룡한 데이터 : 예) 중복 ID 검사를 위한 회원 데이터

 

▶ 통합 테스트의 상황 설정을 위한 보조 클래스 사용하기

   각 테스트 메서드에서 직접 상황을 구성하다 보면 여러 테스트 코드에서 중복되는 경우가 있다.

      이럴 때 상황 설정을 위한 클래스를 이용하면 편리하다.

public class UserGivenHelper{
	...
    public void givenUser(Stering id, String pw, String email){
    	// 코드
    }
}

@BeforeEach
void setUp() {
	given = new UserGivenHelper(jdbcTemplate);
}

@Test
void 동일ID가_이미_존재하면_익셉션() {
	given.givenUser("cbk", "pw", "cbk@cbk.com");

	// 실행, 결과 확인
	assertThrows(DupIdException.class,
            () -> register.register("cbk", "strongpw", "email@email.com")
	);
}

6. 실행 환경이 다르다고 실패하지 않기 → 프로젝트 폴더 기준 절대경로 사용

실행 환경에 따라 문제가 되는 가장 전형적인 이유는 파일 경로 → 프로젝트 폴더를 기준으로 절대경로 를 이용하자. 

 

7.  실행 시점이 다르다고 실패하지 않기 

시간 문제 : (예시) 회원의 만료여부를 확인하는 기능 

@Test
void notExpired() {
   LocalDateTime expiry = LocalDateTime.of(2024,10,15,0,0,0);
   Member m = Member.builder().expiryDate(expiry).build();
   assertFalse(m.isExpired()); // false 를 반환해야함
}
  •  2024.10.16일에 테스트하면 테스트 실패 

해결책

  • 코드에서 명시적으로 시간을 제어할 수 있는 방법을 선택
  • 시간 클래스를 작성
@Test
void notExpired() {
   LocalDateTime expiry = LocalDateTime.of(2024,10,15,0,0,0);
   Member m = Member.builder().expiryDate(expiry).build();
   assertFalse(m.passedExpiryDate(LocalDateTime.of(2024,10,14,0,0,0)));
}

명시적으로 시간을 제어하면 언제 테스트를 하던지 테스트가 성공

 

랜덤 값 문제 : (예시) 숫자 야구 게임 

문제 : 테스트를 통과시킬 수 있는 값이 매번 바뀜 → 테스트는 랜덤하게 실패

해결법 

  • 직접 랜덤 값을 생성하지 말고, 생성자를 통해 값을 받거나 랜덤 값 생성을 다른 객체에게 위임하게 바꾼다.
    • 숫자 생성을 위한 클래스를 별도로 구현  등등

8. 필요하지 않은 값은 설정하지 않기 

  • 검증할 내용에 관련없는 값들은 해당 테스트 메서드에 있을 필요가 없다.
  • 테스트할 범위에 꼭 필요한 값들만 설정하면 테스트 코드도 짧아지고 가독성도 높아진다.

▶ 단위 테스트를 위한 객체 생성 보조 클래스

  • 상황 구성을 위해 필요한 데이터가 다소 복잡한 경우 -> 테스트를 위한 객체 생성 클래스 구현하는 편이 좋다.
    • 기본 값을 설정하고 변경하고 싶은 속성만 변경하도록 설정 가능해진다.

9.  조건부로 검증하지 않기 

테스트는 성공하거나 실패해야한다. 

조건을 단언하지 않으면 그 테스트는 성공하지도 실패하지도 않은 테스트가 된다. 

→ 조건도 단언을 하도록 수정하여 실패인지 성공인지 여부를 테스트해야 한다.

10.  통합 테스트는 필요하지 않은 범위까지 연동하지 않기 

  • 통합 테스트 실행시에 전체 애플리케이션을 구동하면 필요하지 않은 객체까지 생성하게 되어 테스트 실행이 느려진다.
  • 테스트에 필요한 연동 대상만 생성하면 테스트 수행 시간이 짧아진다. 

11. 더 이상 쓸모 없는 테스트 코드 → 제거

  • 소프트웨어가 제공해야 하는 기능을 테스트하는 코드가 아니라면 삭제한다.
    • 특정 클래스의 사용법을 익히기 위해 작성한 테스트 코드 → 삭제
  • 단지 테스트 커버리지를 높이기 위한 목적으로 작성한 테스트 코드는 실제 코드 유지보수에는 아무런 도움이 되지 않으므로 삭제한다.
    • User 클래스에 있는 단순한 메서드( getId, getName, ..)  → 삭제

앞번의 암호 강도 측정기능의 테스트 예시 에서 보면,

테스트 코드를 작성한 순서는 다음과 같다.

  1. 모든 규칙을 충족하는 경우
  2. 길이만 8글자 미만이고 나머지 조건은 충족하는 경우
  3. 숫자를 포함하지 않고 나머지 조건은 충족하는 경우
  4. 값이 없는 경우
  5. 대문자를 포함하지 않고 나머지 조건을 충족하는 경우
  6. 길이가 8글자 이상인 규칙만 충족하는 경우
  7. 숫자 포함 조건만 충족하는 경우
  8. 대문자 포함 조건만 충족하는 경우
  9. 아무 조건도 충족하지 않는 경우

위의 순서는 다음 규칙을 따른 것이다.

  • 쉬운 경우 → 어려운 경우
  • 예외적인 경우 → 정상적인 경우

초반에 복잡한(어려운) 테스트부터 시작하면 안 되는 이유

만약에 테스트 코드 작성 순서를 다음과 같이 진행했다고 하자. 

  1. 대문자 포함 조건만 충족하는 경우
  2. 모든 규칙을 충족하는 경우
  3. 숫자를 포함하지 않고 나머지 규칙은 충족하는경우

이렇게 진행하다보면 첫 번째 테스트는 쉽게 넘어가지만, 두 번째는 모든 규칙을 검증하는 코드를 구현해야 할것 같은 느낌이 든다.

 

한번에 완벽한 코드를 만들다 보면

▶ 나도 모르게 버그를 만든다.

    → 버그를 잡는데 많은 시간을 허비한다.

    → 테스트 통과 시간도 길어진다

→ 집중력이 떨어진다

 

구현하기 쉬운 테스트부터 시작하기

암호 강도 측정기능의 테스트 를 한다고 하면 가장 쉬워보이는 것은 다음과 같다.

  • 모든 조건을 충족하는 경우
  • 모든 조건을 충족하지 않는 경우

두 가지 경우다 그냥 단순히 해당 값을 리턴하면 되기 때문이다.

  • 모든 조건을 충족하는 경우  → return STRONG
  • 모든 조건을 충족하지 않는 경우 → return WEEK

먼저 "모든 조건을 충족하는 경우"  부터 시작했다고 하면, 다음으로 생각해 볼수 있는 테스트는 다음과 같다.

  • 모든 조건을 충족하지 않는 경우 → 모든 조건을 검증하는 코드 필요
  • 한 가지 조건만 충족하는 경우 → 한 가지 규칙을 충족하는지 검증하는 코드 필요
  • 두 가지 조건을 충족하는 경우 두 가지 규칙을 충족하는지 검증하는 코드 필요

이렇게 보면 "한 가지 조건만 충족하는 경우" 가 더 구현하기 쉽다.

 

이런 식으로 한 가지 테스트를 통과했으면 그 다음으로 구현하기 쉬운 테스트를 선택하여 진행해야 한다.

 

예외 상황을 먼저 테스트해야 하는 이유

초기 설계 단계에서 예외 처리를 고려하는 것은 프로그래밍의 중요한 부분

 

예외 처리를 무시하고 코드를 작성할 경우,

나중에 예외 상황을 반영하기 위해 코드 전체를 개편해야 할 수도 있음

복잡한 조건문을 추가 해야 할 수도 있음

코드의 복잡성을 증가시키고 버그 발생 가능성 상승

 

완급 조절

한번에 얼마만큼의 코드를 작성할 것인가?

  1. 정해진 값을 리턴
  2. 값 비교를 이용해서 정해진 값을 리턴
  3. 다양한 테스트를 추가하면서 구현을 일반화

뻔한 구현이라도 위 단계를 거쳐서 연습을 하면,

구현이 막막해도 조금씩 기능을 구현해 나갈 수 있다.

 

리팩토링?  ▶  코드의 가독성 향상

코드 중복 발생, 코드가 길어진다

▶ 메서드 추출과 같은 기법을 이용하여 리팩토링  

▶ 메서드 이름으로 코드의 의미 표현

 

 

리팩토링

1. 동작하는 코드를 먼저 작성

2. 변경하기 쉬운구조로 코드를 구성

3. 필요한 부분은 리팩토링

4. 메서드의 구조에 영향을 주는 리팩토링은 큰 틀에서 구현 흐름이 눈에 들어오기 시작한 뒤에 진행 (코드의 의미와 구조가 명확해지만 리팩토링 진행)

5. 범위가 큰 리팩토링은 시간이 오래 걸리므로 먼저 테스트를 통과 시키는데 집중

 

테스트

1. 테스트할 목록을 미리 정하기

2. 정한 테스트 목록중 구현이 쉽고 예외적인지 예측

3. 새로운 테스트 사례는 바로 목록에 기록

4. 목록을 하나씩 통과하면서 진행한다.

 

구현이 막막할때

1. 검증하는 코드부터 작성해 본다.

 

구현이 막힐때

1. 과감하게 지우고 다시 시작해보자

검사할 규칙

  • 길이가 8글자 이상
  • 0부터 9사이의 숫자를 포함
  • 대문자 포함
  • 세 규칙을 모두 충족하면 암호는 강함이다.
  • 2개의 규칙을 충족하면 암호는 보통이다.
  • 1개 이하의 규칙을 충족하면 암호는 약함이다.

첫 번째 테스트: 모든 규칙을 충족하는 경우

Test 코드

package chap02;

import org.junit.jupiter.api.Test;

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

public class PasswordStrengthMeterTest {

    @Test
    void meetsAllCriteria_Then_Strong(){
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("ab12!@AB");
        assertEquals(PasswordStrength.STRONG, result);
    }
}

 

암호검사기 코드

 

PasswordStrength

package chap02;

public enum PasswordStrength {
    STRONG
}

 

PasswordStrengthMeter

 

1 ) PasswordStrengthMeter 클래스를 작성하여 컴파일 에러를 먼저 해결

package chap02;

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        return null;
    }
}

▶▶ 테스트 실행시 null 을 반환하므로 실패 

 

2) 테스트를 성공시키기 위해서 STRONG 을 반환하도록 수정

package chap02;

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        return PasswordStrength.STRONG;
    }
}

 

▶▶ 테스트 성공

 

3) 테스트 코드에 모든 규칙을 충족하는 예를 하나 더 추가

package chap02;

import org.junit.jupiter.api.Test;

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

public class PasswordStrengthMeterTest {

    @Test
    void meetsAllCriteria_Then_Strong(){
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("ab12!@AB");
        assertEquals(PasswordStrength.STRONG, result);

        PasswordStrength result2 = meter.meter("abc1!ABD");
        assertEquals(PasswordStrength.STRONG, result2);
    }
}

▶▶ 테스트 성공

 

위와 같은 방식을 따라

아래와 같이 (가능한 모든)조건을 추가해가면서

즉, 검증하는 범위를 추가해가면서 코드를 완성하면 된다.

  • 두 번째 테스트 - 길이만 8글자 미만이고 나머지 조건은 충족하는 경우
  • 세 번째 테스트 - 숫자를 포함하지 않고 나머지 조건은 충족하는 경우
  • 네 번째 테스트 - 값이 없는 경우
  • 다섯 번째 테스트 - 대문자를 포함하지 않고 나머지 조건을 충족하는 경우
  • 여섯 번째 테스트 - 숫자 포함 조건만 충족하는 경우
  • 일곱 번째 테스트 - 숫자 포함 조건만 충족하는 경우
  • 여덟 번째 테스트 - 대문자 포함 조건만 충족하는 경우
  • 아홉 번째 테스트 - 아무 조건도 충족하지 않는 경우

※ 테스트 하는 도중에 지속적으로 코드 정리(리팩토링)도 필요하다.

  • 테스트 성공 후 리펙토링할 대상이 보이면리팩토링
  • 테스트 성공 후 리펙토링할 대상이 안보이면 ▷ 다음 테스트 진행

리팩토링시 가독성을 높이는 것이 목표

  • 처음보는 개발자도 알 수 있게 수정
  • 전반적인 로직을 알기 쉽게 수정

TDD 란?

먼저 테스트를 하고 그다음에 구현하는 방식.

 

테스트를 먼저 작성하고 테스트에 실패하면
테스트를 통과 시킬 만큼 코드를 추가하는 과정을 반복하면서
점진적으로 기능을 완성해 나가는 방식을 추구한다.

 

TDD 기본흐름 예시

덧셈 기능을 검증하기 위한 테스트

 

CalculatorTest.java

package chap02;

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

public class CalculatorTest {
    @Test
    void plus() {
        int result = Calculator.plus(1, 2);
        assertEquals(3, result);
    }
}

 

 

step 1 ) 클래스 생성하고 0을 리턴하는 plus() 메서드를 추가

Calculator.java

package chap02;

public class Calculator {
    public static int plus(int a1, int a2) {
        return 0;
    }
}

 → CalculatorTest.java 실행 → return 값이 0 이여서 테스트 실패 

 

step 2 ) 테스트를 통과하기 위해 3 을 return 

Calculator.java

package chap02;

public class Calculator {
    public static int plus(int a1, int a2) {
        return 3;
    }
}

 → CalculatorTest.java 실행 → 테스트 성공

 

step 3) 추가 검증코드를 추가

CalculatorTest.java

package chap02;

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

public class CalculatorTest {
    @Test
    void plus() {
        int result = Calculator.plus(1, 2);
        assertEquals(3, result);
        assertEquals(5, Calculator.plus(4, 1));
    }
}

 

Calculator.java

package chap02;

public class Calculator {
    public static int plus(int a1, int a2) {
        return a1 + a2;
    }
}

 → CalculatorTest.java 실행 → 테스트 성공

+ Recent posts