프로그래밍

TDD(Test-Driven Development) 공부 정리

노력의천재 2022. 9. 7. 22:15

TDD(Test-Drivent Development)란?

테스트 주도 개발, 테스트로부터 시작하는 개발 방식을 의미한다.

  • 실패하는 테스트 코드를 작성하고,
  • 테스트를  통과시킬 만큼 구현한 후,
  • 리팩토링하는 작업을 반복한다.

 

TDD의 장점

  • 테스트 코드가 있으면 문제 범위를 좁혀서 디버깅하는게 수월하다.
  • 리팩토링 등 코드를 수정할 때 실패하는 테스트 코드가 발생하면 문제를 빨리 찾아낼 수 있다.
  • 테스트가 가능하려면 의존 대상을 대역(Mock)으로 교체할 수 있어야 하는데, 대역으로 교체할 수 있는 구조는 역할별로 잘 분리되어 있는 설계 구조를 가졌다고 볼 수 있다. 

 

예제 코드 1

/**
 * 암호화 검사기 TDD 예제
 * 
 * 사용하는 규칙
 *  - 길이가 8글자 이상
 *  - 0 ~ 9 사이 숫자 포함
 *  - 대문자 포함
 * 세 규칙을 모두 충족하면 '강함'
 * 두 규칙을 충족하면 '보통'
 * 하나 이하의 규칙을 충족하면 '약함'
 * 
 * 시연 진행 순서
 * 1. 테스트 클래스와 메서드 작성, 실행
 * 2. null 입력에 대한 테스트로 시작 (테스트 대상 타입 정의, 메서드 정의, 결과 타입 정의)
 * 3. 빈 값에 대한 테스트
 * 4. 모든 조건을 충족하는 테스트
 * 5. 두 조건을 충족하는 테스트
 * 6. 한 조건을 충족하는 테스트
 * 7. 아무 조건도 충족하지 않는 테스트
 */
class PasswordMeterTest {

    @DisplayName("null 입력에 대한 테스트")
    @Test
    void nullInputTest() {
        assertPasswordStrength(null, PasswordStrength.INVALID);
    }

    @DisplayName("빈 값에 대한 테스트")
    @Test
    void emptyInputTest() {
        assertPasswordStrength("", PasswordStrength.INVALID);
    }

    @DisplayName("모든 조건을 충족하는 테스트")
    @Test
    void meetAllRulesTest() {
        assertPasswordStrength("abcdABCD123", PasswordStrength.STRONG);
        assertPasswordStrength("123ABCD123", PasswordStrength.STRONG);
        assertPasswordStrength("abcd123ABCD", PasswordStrength.STRONG);
    }
    
    @DisplayName("두 조건을 충족하는 테스트(길이 규칙 위반)")
    @Test
    void meet2RulesExceptForLengthRuleTest() {
        assertPasswordStrength("abc12AB", PasswordStrength.NORMAL);
        assertPasswordStrength("AB12a", PasswordStrength.NORMAL);
        assertPasswordStrength("12ABab", PasswordStrength.NORMAL);
    }

    @DisplayName("두 조건을 충족하는 테스트(숫자 규칙 위반)")
    @Test
    void meet2RulesExceptForDigitRuleTest() {
        assertPasswordStrength("abcdABCD", PasswordStrength.NORMAL);
        assertPasswordStrength("ABCDabcd", PasswordStrength.NORMAL);
        assertPasswordStrength("abABcdCD", PasswordStrength.NORMAL);
    }

    @DisplayName("두 조건을 충족하는 테스트(대문자 규칙 위반)")
    @Test
    void meet2RulesExceptForUpperCaseRuleTest() {
        assertPasswordStrength("abcd1234", PasswordStrength.NORMAL);
        assertPasswordStrength("abcd1234", PasswordStrength.NORMAL);
        assertPasswordStrength("ab12cd34", PasswordStrength.NORMAL);
    }

    @DisplayName("오직 한 조건을 충족하는 테스트")
    @Test
    void meetOnlyLengthRuleTest() {
        assertPasswordStrength("abcdefghijk", PasswordStrength.WEAK);
        assertPasswordStrength("abcdefgh", PasswordStrength.WEAK);
        assertPasswordStrength("abcd!@#$%", PasswordStrength.WEAK);
    }

    @DisplayName("아무 조건도 충족하지 않는 테스트")
    @Test
    void meetNoRuleTest() {
        assertPasswordStrength("abc", PasswordStrength.WEAK);
        assertPasswordStrength("abcd", PasswordStrength.WEAK);
        assertPasswordStrength("abcd!@", PasswordStrength.WEAK);
    }

    private void assertPasswordStrength(String password, PasswordStrength expected) {
        PasswordMeter passwordMeter = new PasswordMeter();
        PasswordStrength result = passwordMeter.meter(password);
        assertThat(result).isEqualTo(expected);
    }
}

 

public class PasswordMeter {

    private final Pattern digitPattern = Pattern.compile("[0-9]");
    private final Pattern upperCasePattern = Pattern.compile("[A-Z]");

    public PasswordStrength meter(String password) {
        if (password == null || password.isEmpty()) {
            return PasswordStrength.INVALID;
        }

        int meetCnt = getMeetCnt(password);

        if (meetCnt == 0 || meetCnt == 1) {
            return PasswordStrength.WEAK;
        }

        if (meetCnt == 2) {
            return PasswordStrength.NORMAL;
        }

        return PasswordStrength.STRONG;
    }

    private boolean hasDigit(String password) {
        return digitPattern.matcher(password).find();
    }

    private boolean hasUpperCase(String password) {
        return upperCasePattern.matcher(password).find();
    }

    private boolean meetLength(String password) {
        return password.length() >= 8;
    }

    private int getMeetCnt(String password) {
        int meetCnt = 0;
        if (meetLength(password)) {
            meetCnt++;
        }

        if (hasDigit(password)) {
            meetCnt++;
        }

        if (hasUpperCase(password)) {
            meetCnt++;
        }
        return meetCnt;
    }
}

 

예제 코드 2

/**
 * 회원 승인 API
 *
 * 대기 상태의 회원을 승인하면 회원 상태로 활성화 됨
 *
 *                              / - MemberRepository - MemoryMemberRepository
 * API - ConfirmMemberService
 *                              \ - Member
 *                              
 */
@SpringBootTest
@AutoConfigureMockMvc
public class MemberApiIntegrationTest {

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private MockMvc mockMvc;

    @DisplayName("")
    @Test
    void confirm() throws Exception {
        // arrange (given)
        memberRepository.save(new Member("id", MemberStatus.WAITING));

        // act (when)
        mockMvc.perform(post("/members/{id}/confirm", "id"))
                .andExpect(status().isOk());

        // assert (then)
        Member m = memberRepository.findById("id");
        assertThat(m.getStatus()).isEqualTo(MemberStatus.ACTIVE);
    }
}

 

@RestController
public class MemberApi {

    private final ConfirmMemberService confirmMemberService;

    public MemberApi(ConfirmMemberService confirmMemberService) {
        this.confirmMemberService = confirmMemberService;
    }

    @PostMapping("/members/{id}/confirm")
    public ResponseEntity<?> confirm(@PathVariable("id") String id) {
        confirmMemberService.confirm(id);
        return ResponseEntity.ok("OK");
    }
}

 

@MockBean(JpaMetamodelMappingContext.class)
@WebMvcTest(MemberApi.class)
public class MemberApiTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ConfirmMemberService confirmMemberService;

    @DisplayName("")
    @Test
    void shouldCallService() throws Exception {
        // when(테스트 실행)
        mockMvc.perform(post("/members/{id}/confirm", "id"))
                .andExpect(status().isOk());

        // then(검증)
        BDDMockito.then(confirmMemberService)
                .should()
                .confirm("id");
    }
}

 

@Service
public class ConfirmMemberService {

    private final MemberRepository memberRepository;

    public ConfirmMemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void confirm(String id) {
        Member member = memberRepository.findById("id");
        if (member == null) {
            throw new MemberNotFoundException();
        }
        member.confirm();
    }
}

 

public class ConfirmMemberServiceTest {

    public static final String ID = "id";
    private MemberRepository memberRepository = new MemoryMemberRepository();
    private ConfirmMemberService confirmMemberService = new ConfirmMemberService(memberRepository);

    @BeforeEach
    public void initData() {
        memberRepository.deleteAll();
    }

    @DisplayName("")
    @Test
    void noMember() {
        Assertions.assertThatCode(() -> {
            confirmMemberService.confirm(ID);
        }).isInstanceOf(MemberNotFoundException.class);
    }

    @DisplayName("")
    @Test
    void memberAleadyActivated() {
        memberRepository.save(new Member(ID, MemberStatus.ACTIVE));

        Assertions.assertThatCode(() -> {
            confirmMemberService.confirm(ID);
        }).isInstanceOf(MemberAlreadyActivatedException.class);
    }

    @DisplayName("")
    @Test
    void confirm() {
        // arrange (given)
        memberRepository.save(new Member(ID,MemberStatus.WAITING));

        // act (when)
        confirmMemberService.confirm(ID);

        // assert (then)
        Member m = memberRepository.findById(ID);
        assertThat(m.getStatus()).isEqualTo(MemberStatus.ACTIVE);
    }
}