끊임없이 검증하라

나에게 당연할지라도

Project

P2_페이지 내 하이퍼 링크 달아주는 코드_4_TDD(2)

fadet 2022. 7. 2. 23:35

이 포스트는 학습 과정에서 그 내용을 기록한 글이기에 부정확한 정보가 포함될 수 있습니다.

따라서 해당 글은 참고용으로만 봐주시고 틀린 부분이 있다면 알려주시면 감사하겠습니다. 

 


 

이전 포스트에 이어 테스트코드를 마저 작성하겠습니다.

 

 

테스트 코드 예시 - domain, repository 나머지

 

저번 포스트에는 테스트 메서드인 코드입력()을 자세히 살펴봤습니다. 이제 다음으로는 TDD에서 중요한 내가 얼마만큼의 테스트코드를 작성하는 것이 적절한가?를 판단하는 작업을 해보겠습니다.

 

# 준비 - TestData

 

역시 테스트 코드를 작성하려고 보니 실제 로직에 사용할 allCode의 길이가 너무 길어 테스트 코드에 다 담기는 부적절할 것 같습니다. 따라서 더미 데이터용 TestData 클래스를 하나 따로 만들겠습니다. 프로덕트 코드가 아니기 때문에 갖다쓰기 쉽게 public으로 열어두고 static 필드로 설정하겠습니다.

 

public class TestData {

    public static String testAllCode = "<p data-ke-size=\"size16\"><span style=\"color: " +
            ... + "<span>테스트 내용2</span>";

    public static String testTitleHtmlKeyword = "<blockquote data-ke-size=\"size16\" data-ke-style=\"style1\">";

    public static String testIndexHtmlKeyword = "<blockquote data-ke-style=\\\"style2\\\">Index</blockquote>";
    
}

 

 

#1 repository -코드입력() [+Code의 init() 포함]

 

이전 포스트에서 했는데 왜 또 언급하는가 궁금하실 수도 있는데, 아직 save()가 제대로 완성되지 않았습니다. 지난 명세를 살펴보면 서버는 검증사항을 계산해서 렌더링 해줘야하기에 코드가 입력되면 타이틀 리스트나 인덱스 중복 여부 등의 나머지 검증사항을 계산해주어야 합니다. 따라서 save()에 newOne.init() 메서드를 추가합니다.

이제 이 init()를 정의해주러 Code로 이동합니다.

 

Code 클래스에서 init()를 통해 나머지 검증 사항을 계산하기 위해 검증 사항을 필드로 추가합니다. 이전 포스트에선 검증사항에 대한 필드가 서로 종속적이었습니다. 그렇기에 쓸데없이 너무 많았죠. 이를 두 개로 줄이겠습니다.

// 이전 포스트에서의 검증사항 필드
private Long titleCount;

private List<String> oldTitleList;
private List<String> newTitleList;
private int indexOver;

검증사항 중 타이틀 개수는 titleList.size()로 쉽게 구해지니 titleCount를 삭제하고 old와 new를 합치면 아래와 같습니다.

public class Code {
...
// 검증 사항
private List<String> titleList;
private int indexOver;

이제 init() 메서드를 정의할텐데 여기서 왜 Reposiory에 init() 두지 않고 도메인에 이 메서드를 정의하냐고 물으실 분이 있을 것 같은데 init() 같은 비즈니스 로직은 도메인에 작성하고 DB 관련 메서드를 리파지토리에 작성해야하기 때문입니다. 사실 실무에서는 도메인 내에 작성하는 비즈니스 로직을 정말 추후 수정하지 않을만한 필수 메서드만 작성하고 나머지는 DTO나 VO를 통해 전달받지만 그 내용을 설명하긴 너무 산으로 가기 때문에 일단은 나중에 이에 대해 추가 포스트를 ㅈ작성하겠습니다.  

 

init() 메서드는 저번 포스트와 크게 로직이 다르지 않지만 필드가 준 것만 유의하면 됩니다. init()를 완성하고 Code 클래스의 테스트를 따로 만들어 주고 init()가 잘 작동하는지 테스트합니다.

package fadet.newPostLink.domain;

class CodeTest {

    @Test
    void 검증사항계산() {
        //given
        Code newOne = new Code(TestData.testAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);
        //when
        newOne.init();
        //then
        assertThat(newOne.getTitleList().get(0)).isEqualTo("테스트 목차1");
        assertThat(newOne.getTitleList().get(1)).isEqualTo("테스트 목차2");
        assertThat(newOne.getIndexOver()).isEqualTo(2);
    }

여기까지 했으면 다시 코드입력()으로 돌아가서 메서드를 init() 결과까지 확인하도록 수정해줍니다.

//CodeRepositoryTest
...
@Test
void 코드입력() {
    //given
    Code newOne = new Code(TestData.testAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);

    //when
    Code savedCode = codeRepository.save(newOne);

    //then
    assertThat(savedCode.getId()).isNotNull();

    assertThat(savedCode.getAllCode()).isEqualTo(TestData.testAllCode);
    assertThat(savedCode.getTitleHtmlKeyword()).isEqualTo(TestData.testTitleHtmlKeyword);
    assertThat(savedCode.getIndexHtmlKeyword()).isEqualTo(TestData.testIndexHtmlKeyword);

    assertThat(savedCode.getTitleList().get(0)).isEqualTo("테스트 목차1");
    assertThat(savedCode.getIndexOver()).isEqualTo(2);
}

 

 

#2 repository - 마지막저장데이터불러오기()

 

메서드를 작성하려고보니 수정할 점 하나가 눈에 보입니다. 현재 시점에서 CodeRepository에는 findOne() 메서드가 있지만 우리는 앞서 명세로 덮어쓴 최종 코드만 불러오는 것으로 정했습니다. 따라서 findLastOne() 메서드로 이를 바꾸며 나머지 부분도 다듬겠습니다.

 

findLastOne() 메서드만 수정하면 테스트는 정상적으로 작동합니다.

//CodeRepositoryImpl
...
@Override
public Code findLastOne() {
    Long size = (long)store.size();
    return store.get(size);
}
//CodeRepositoryTest
...
@Test
void 마지막저장데이터불러오기() {
    //given
    Code newOne1 = new Code("allCode1", "titleHtmlKeyword1", "indexHtmlKeyword1");
    Code newOne2 = new Code("allCode2", "titleHtmlKeyword2", "indexHtmlKeyword2");

    codeRepository.save(newOne1);
    codeRepository.save(newOne2);
    //when
    Code savedCode = codeRepository.findLastOne();

    //then
    assertThat(savedCode.getId()).isEqualTo(2);
    assertThat(savedCode.getAllCode()).isEqualTo("allCode2");
}

 

 

#3 repository - 수정된코드덮어쓰기()

 

앞서 했던 것과 마찬가지이므로 어려울 것 없으므로 코드만 올리면 될 것 같네요. 역시 테스트 통과를 위해 repository에 update() 메서드를 추가했습니다.

    @Test
    void 수정된코드덮어쓰기() {
        //given
        Code oldOne = new Code("allCode1", "titleHtmlKeyword1", "indexHtmlKeyword1");
        codeRepository.save(oldOne);

        Code newOne = new Code("allCode2", "titleHtmlKeyword2", "indexHtmlKeyword2");
        //when
        Code savedNewOne = codeRepository.update(newOne);

        //then
        assertThat(savedNewOne.getId()).isEqualTo(1);
        assertThat(savedNewOne.getAllCode()).isEqualTo("allCode2");
    }

 

#4 repository - 결과코드생성()

 

마지막으로 수정사항을 반영한 코드를 생성해주는 메서드입니다. 역시 이전 포스트와 로직은 같지만 중요한 점을 한 가지 언급해야만 합니다. 이전 포스트의 도메인은 oldCode, newCode 두 개였고 그 중 newCode는 아래와 같았습니다.

 

public class NewCode {
    private String allNewCode;

    public void init(OldCode oldCode) {
    ...

보시면서 위화감을 느끼셨어야 맞습니다. 반드시 스프링에서 도메인은 반드시 서로를 의존해선 안됩니다! 이 코드는 init() 메소드부터가 oldCode를 의존하고 있습니다. 만약 프로젝트가 커졌을때 OldCode를 조금이라도 수정한다면 NewCode에 어떤 sideeffect를 줄지 우리는 예측하기 매우 어렵기때문에 이런 코딩은 매우 잘못된 습관입니다.

 

이를 해결하기 위해 종속 관계인 OldCode와 NewCode를 하나로 합치는 것을 고려할 수 있습니다. 하지만 이번 포스트에선 그 대신 Code와 ResultCode를 사용하겠습니다. ResultCode는 NewCode와 구조는 비슷합니다만 인자로 받던 oldCode를 String으로 교체하여 의존성을 아예 없앴습니다. 이렇게하면 OldeCode가 수정되어도 NewCode는 영향을 받지 않습니다.(물론 String 등의 필드 입력이 달라지는 정도의 영향은 받을 수 있지만 큰 문제가 되진 않습니다.)

 

public class ResultCode {
    private String resultAllCode;

    public void init(String allCode, String titleHtmlKeyword, String indexHtmlKeyword, List<String> titleList) {
    ...

이것만 유의하면 나머지 로직은 동일합니다. 테스트 성공을 위해 modify()를 정의해주고 TestData에 testResultCode를 넣으면 끝입니다.

@Test
void 결과코드생성() {
    //given
    Code newOne = new Code(TestData.testAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);
    codeRepository.save(newOne);

    //when
    ResultCode resultCode = codeRepository.modify();

    //then
    assertThat(resultCode.getResultAllCode()).isEqualTo(TestData.testResultCode);
}

 

마지막으로 모든 테스트를 한 번 돌려보도록 하죠. 아마 다 성공하지 않았을겁니다.

 

왜냐하면 테스트마다 썼던 변수들이 그대로 남아있기 때문입니다. 따라서 @AfterEach를 통해 테스트마다 끝나고 store를 비워주도록 합시다. 여기까지 하셨다면 모든 테스트가 정상적으로 통과합니다.

@AfterEach
void 후처리(){
    codeRepository.clear();
}

 

 

 

테스트 코드 - Service

 

이제 나머지 컴포넌트들의 테스트 코드도 마저 작성하겠습니다. 이제부터는 빠른 진행을 위해 상세한 설명은 생략토록 하겠습니다. 우선 서비스부터 시작합니다. 이전 포스트는 설명을 위해 해당 테스트를 생략했는데 사실 맨 처음에 스프링 빈의 주입부터 테스트하는 것이 좋습니다.

 

추가로 원래 입력되어야할 코드는 TestData처럼 실제 데이터같이 길어야  저장시에 검증사항을 init할 수 있지만 우리는 이미 repositoryTest에서 init()가 잘 작동하는 것을 확인했기 때문에 이후 테스트에선 allCode1 과 같은 단순한 입력값만 인자로 써도 됩니다. 이것 또한 TDD의 장점이겠죠.

 

#1 service - 서비스주입확인()

@ExtendWith(MockitoExtension.class)
class CodeServiceTest {

    @InjectMocks
    private CodeService codeService;

    @Test
    void 서비스주입확인(){
        assertThat(codeService).isNotNull();
    }

당연히 codeService는 인터페이스라 테스트를 실패하니 구현체를 만들어줍니다. 또한 이 테스트는 맨 처음 통과하는걸 확인한 후 더이상은 안쓰기때문에 지워도 무방합니다.

//수정
@InjectMocks
private CodeServiceImpl codeService;

#2 service - 코드저장()

 

기본적으로 service는 repository의 메서드를 의존하므로 테스트 코드도 비슷합니다. 기본적으로 도메인 모델 방식의 코딩을 지향하기에 비즈니스 로직을 서비스에서 구현하는 것을 최대한으로 줄입니다. 따라서 서비스 테스트는 정의된 메소드의 순서만 작성되듯 매우 간단한게 좋습니다.

 

또한 서비스에는 반드시 @Transactional이 붙어야한다고 아시는 분들이 있을 것 같은데, 이 프로젝트는 DB와 연결되어 있지 않으므로 데이터 정합성을 지킬 필요가 없으므로 필요치 않습니다. 다만 DB 연결을 하는 순간부터는 무조건 쿼리가 트랜잭션 내에서 질의되어야 함은 당연합니다.

 

마찬가지로 위에서부터 수정해줍니다. 우선 service에 saveCode()를 정의해줍니다. 또한 service는 지금 codeRepository를 모르니 이를 주입해주어야 합니다. 일반적인 생성자 주입을 사용하면 되고 이를 잘 모르신다면 다른 글을 참고해주세요.

@Service
@RequiredArgsConstructor
public class CodeServiceImpl implements CodeService {

    private final CodeRepository codeRepository;

...

public Code saveCode(Code newOne) {
    return codeRepository.save(newOne);
}

두번째로 테스트에 codeRepository가 주입되지 않았기에 넣어줍니다. 이제 테스트가 통과하게 됩니다.

//CodeServiceTest
..
@Autowired
private CodeService codeService;
@Autowired
private CodeRepository codeRepository;

 

#3 service - 마지막코드불러오기()

@Test
void 마지막코드불러오기() {
    //given
    Code newOne = new Code("allCode1", "titleHtmlKeyword1", "indexHtmlKeyword1");
    codeService.saveCode(newOne);

    //when
    Code savedOne = codeService.findLastOne();

    //then
    assertThat(savedOne.getId()).isEqualTo(1);
    assertThat(savedOne.getAllCode()).isEqualTo("allCode");
}

위 코드에서는 findLastOne만 정의해주면 됩니다. 간단하니 생략하겠습니다.

 

#4 service - 코드수정()

@Test
void 코드수정() {
    //given
    Code newOne = new Code("allCode1", "titleHtmlKeyword1", "indexHtmlKeyword1");
    codeService.saveCode(newOne);

    Code updateOne = new Code("newAllCode", "newTHK", "newIHK");

    //when
    Code savedUpdateOne = codeService.updateCode(updateOne);

    //then
    assertThat(savedUpdateOne.getId()).isEqualTo(1);
    assertThat(savedUpdateOne.getAllCode()).isEqualTo("newAllCode");
}

이전 메소드와 마찬가지로 repository를 그대로 사용하면 됩니다.

 

#5 service - 코드변환실패1_THK오류()

 

드디어 실패 테스트를 작성하게 되었습니다. 이전 포스트에선 검증사항이 이상하면 view에서 변환 버튼을 없애는 임시방편을 사용했습니다. 하지만 이러면 실무에서는 버튼이 없어도 그 url로 요청이 오면 그 페이지를 사용자에게 노출할 수 밖에 없으므로 문제가 발생합니다. 따라서 이런 것을 원천 차단하기 위해 실패하는 테스트를 작성하고 사용자에게 잘못된 요청이 오면 거부하는 로직을 작성해야만 합니다. 

 

실패하는 로직은 보통 when절에서 exception을 띄우고 그것을 then절에서 assertThrow로 검증하는 과정으로 진행됩니다. 테스트의 가독성을 위해 assertj에서 제공하는 assertThatThrownBy를 사용하는게 편하지만 given/when/then 문법에는 별로 안 어울려서 일단은 junit 기본인 assertThrows를 사용하겠습니다. 모르시는 분들을 위해 말하자면 assertj는 가독성을 높여주는 테스트코드 라이브러리이며 우리가 주로 사용하는 assertThat도 assertj에서 가져옵니다.

 

아래 THK는 titleHtmlKeyword의 축약으로 해당 오류는 사용자가 입력한 html 코드가 타이틀 리스트를 구분할 수 없을 경우 발생합니다. 일단 우리 프로젝트에선 타이틀의 개수가 0만 아니면 변환이 가능하므로 타이틀 리스트가 0개일 경우 IllegalStateException을 발생시켜주면 될 것 같습니다.

@Test
void 코드변환실패1_THK오류() {
    //given
    Code newOne = new Code(TestData.testFailTAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);

    codeService.saveCode(newOne);

    //when
    IllegalStateException e = assertThrows(IllegalStateException.class, () -> codeService.modifyCode());

    //then
    assertThat(e.getMessage()).isEqualTo("타이틀 리스트 없음");

}

해당 테스트 코드를 성공으로 만들기 위해선 1 TestData에 THK오류로 실패할 testFailTAllCode를 추가 2 modifyCode()해당 오류 검증 메서드를 추가 해줘야합니다. 1은 간단하니 2만 더 언급하면 아래와 같이 검증 메서드를 정의해주고

@Override
public void validateTitleHtmlKeyword() {
    Code savedOne = codeRepository.findLastOne();
    if (savedOne.getTitleList().size() == 0) {
        throw new IllegalStateException("타이틀 리스트 없음");
    }
}

modifyCode()에 해당 메서드를 추가 해주기만 하면 됩니다. 여기서 타이틀 리스트가 음수가 된다던가하는 에외 사항은 이미 앞선 테스트 코드로 검증했으므로 안심하고 작성해도 됩니다.

@Override
public ResultCode modifyCode() {
    validateTitleHtmlKeyword();
    return codeRepository.modify();
}

 

#6 service - 코드변환실패2_IHK오류()

@Test
void 코드변환실패2_IHK오류() {
    //given
    Code newOne = new Code(TestData.testFailIAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);

    codeService.saveCode(newOne);

    //when
    IllegalStateException e = assertThrows(IllegalStateException.class, () -> codeService.modifyCode());

    //then
    assertThat(e.getMessage()).isEqualTo("인덱스 중복");

}
@Override
public void validateIndexHtmlKeyword() {
    Code savedOne = codeRepository.findLastOne();
    if (savedOne.getIndexOver() != 2) {
        throw new IllegalStateException("인덱스 중복");
    }
}

앞서 THK 오류와 마찬가지로 작성하시면 됩니다.

 

이 실패 테스트 두 개를 보시면서 공부를 잘하신 분이라면 두가지 의문점이 드실 수 있습니다. 1 인자가 입력이 잘못된 케이스도 실패로 작성해둬야 하는 것 아닌가? 2 분명 도메인 모델 패턴 위주로 개발한다고 했는데 그러면 서비스에 비즈니스 로직이 들어가면 안되는 것 아닌가?

 

둘다 좋은 질문이며 1의 경우 뒤에 Validation 파트를 진행하며 같이 다룰 것이니 넘어가시면 됩니다. 2의 경우는 정말 좋은 질문이며 다음 포스트에서 설명할 예정입니다.

 

#7 service - 코드변환성공()

 

실패 테스트를 작성했다면 이제 성공하는 테스트도 역시 작성해야겠죠?

@Test
void 코드변환성공() {
    //given
    Code newOne = new Code(TestData.testAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);
    codeService.saveCode(newOne);

    //when
    ResultCode resultCode = codeService.modifyCode();

    //then
    assertThat(resultCode.getResultAllCode()).isEqualTo(TestData.testResultCode);
}

 

위에서 맞게 작성하셨다면 해당 코드는 실패없이 통과됩니다. 이것으로 Service의 모든 테스트 코드를 작성했고 마지막으로 기분 좋게 올 초록불을 보시면 마무리됩니다.

 


다음 포스트에선 조금 언급할 내용이 있는 ControllerTest를 마저 작성하고 테스트 코드를 리팩토링하겠습니다.