오늘 한일

ATDD Q&A 4단계 구현 완료

요구사항

핵심 비지니스 로직을 QnaService에 구현하지 말고, User, Question, Answer 도메인 객체가 서로 협력해야한다.

  1. 질문 데이터를 완전히 삭제하는 것이 아니라 데이터의 상태를 삭제 상태(deleted - boolean type)로 변경한다.
  2. 로그인 사용자와 질문한 사람이 같은 경우 삭제 가능하다.
  3. 답변이 없는 경우 삭제가 가능하다.
  4. 질문자와 답변 글의 모든 답변자 같은 경우 삭제가 가능하다.
  5. 질문을 삭제할 때 답변 또한 삭제해야 하며, 답변의 삭제 또한 삭제 상태(deleted)를 변경한다.
  6. 질문자와 답변자가 다른 경우 답변을 삭제할 수 없다.
  7. 질문과 답변 삭제 이력에 대한 정보를 DeleteHistory를 활용해 남긴다.

1, 2는 3단계까지 구현하면서 자연스럽게 만족됐다.

답변이 없는 경우 삭제가 가능하다.

질문자와 답변 글의 모든 답변자 같은 경우 삭제가 가능하다.

// 테스트
@Test(expected = CannotDeleteException.class)
public void delete_question_another() throws Exception {
    Question question = anotherQuestion();
    when(questionRepository.findById(ANOTHER_QUESTION_ID)).thenReturn(Optional.of(question));

    qnaService.deleteQuestion(SELF_USER, question.getId());
}

@Test(expected = CannotDeleteException.class)
public void delete_question_self_contains_another_answers() throws Exception {
    Question question = selfQuestion();
    question.addAnswer(ANOTHER_ANSWER);
    when(questionRepository.findById(ANOTHER_QUESTION_ID)).thenReturn(Optional.of(question));

    qnaService.deleteQuestion(SELF_USER, ANOTHER_QUESTION_ID);
}

@Test
public void delete_question_self_only_self_answers() throws Exception {
    Question question = selfQuestion();
    question.addAnswer(SELF_ANSWER);
    when(questionRepository.findById(SELF_QUESTION_ID)).thenReturn(Optional.of(question));

    qnaService.deleteQuestion(SELF_USER, SELF_QUESTION_ID);

    softly.assertThat(question.isDeleted()).isTrue();
}

// 도메인
public void delete(User writer) throws CannotDeleteException {
    isOwner(writer).orElseThrow(() -> new CannotDeleteException(MSG_NOT_OWNER));

    if (!containsOnlySelfAnswers()) {
        throw new CannotDeleteException("Answers should contain only answer wrote by owner of question or empty.");
    }

    this.deleted = true;
}

private Optional<User> isOwner(User writer) {
    return Optional.of(writer)
            .filter(user -> this.writer.equals(writer));
}

private boolean containsOnlySelfAnswers() {
    return answers.stream()
            .allMatch(answer -> answer.isOwner(writer));
}

service를 테스트하는 부분에서 repository를 mocking하여 테스트를 수행한다. 도메인에 delete 로직이 들어있기 때문에 mocking만 없을 뿐, 비슷한 방식으로 테스트할 수 있다.

질문을 삭제할 때 답변 또한 삭제해야 하며, 답변의 삭제 또한 삭제 상태(deleted)를 변경한다.

public void delete(User writer) throws CannotDeleteException {
    // ...
    this.deleted = true;
    deleteAnswers(writer);
}

private void deleteAnswers(User writer) throws CannotDeleteException {
    for (Answer answer : this.answers) {
        answer.delete(writer);
    }
}

질문자와 답변자가 다른 경우 답변을 삭제할 수 없다

// 테스트
@Test(expected = CannotDeleteException.class)
public void delete_another() throws Exception {
    Answer answer = selfAnswer();
    answer.delete(ANOTHER_USER);
}

@Test(expected = CannotDeleteException.class)
public void delete_self_different_owner_from_question() throws Exception {
    Question question = anotherQuestion();
    Answer answer = selfAnswer();
    question.addAnswer(answer);

    answer.delete(SELF_USER);
}

@Test
public void delete_self_same_owner_from_question() throws Exception {
    Question question = selfQuestion();
    Answer answer = selfAnswer();
    question.addAnswer(answer);

    answer.delete(SELF_USER);
    softly.assertThat(answer.isDeleted()).isTrue();
}

// 도메인
public void delete(User loginUser) throws CannotDeleteException {
    if (!isOwner(loginUser) || !sameOwnerFromQuestion(loginUser)) {
        throw new CannotDeleteException(MSG_NOT_OWNER);
    }

    this.deleted = true;
}

private boolean sameOwnerFromQuestion(User loginUser) {
    return question.isOwner(loginUser).isPresent();
}

처음에는 요구사항이 이해가 가질 않았는데, 4번 요구사항의 반대라고 생각하니까 금방 이해가 됐다. 답변은 본인만 삭제가 가능하면서, 질문의 작성자인 경우에만 삭제가 가능하다.

질문과 답변 삭제 이력에 대한 정보를 DeleteHistory를 활용해 남긴다.

// 테스트
@Test
public void delete_login_self_contains_only_self_answers() {
    // Given
    User loginUser = anotherUser();
    Question question = anotherQuestion();

    // When
    ResponseEntity<Void> response = RestApiCallUtils.deleteResource(
            basicAuthTemplate(loginUser), getUrl(question));

    // Then
    softly.assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    softly.assertThat(deleteHistoryRepository.findAllByContentType(ContentType.QUESTION))
        .hasSize(1);
}

// 도메인
public List<DeleteHistory> delete(User writer) throws CannotDeleteException {
    // ...

    List<DeleteHistory> deleteHistories = deleteAnswers(writer);

    this.deleted = true;
    deleteHistories.add(createDeleteHistory());

    return deleteHistories;
}

private List<DeleteHistory> deleteAnswers(User writer) throws CannotDeleteException {
    List<DeleteHistory> deleteHistories = new ArrayList<>();

    for (Answer answer : this.answers) {
        deleteHistories.add(answer.delete(writer));
    }

    return deleteHistories;
}


4단계에서는 크게 어려운 부분은 없었다. 다만, 고민되는 부분은 같은 인수 -> 서비스 -> 도메인 순으로 이어지는 TDD 흐름에서 요구사항에 대한 테스트가 모든 계층에서 중복된다.

예를 들어 질문을 삭제하는 기능을 구현할 때, 다음과 같은 세가지 경우의 테스트를 각 계층에서 테스트해야 한다.

  • 본인의 질문이 아닌 질문을 삭제하는 경우
    • delete_question_another.....
  • 본인의 질문을 삭제하는데, 답변에 본인이 작성하지 않은 답변이 포함된 경우
    • delete_question_self_contains_another_answer......
  • 본인의 질문을 삭제하는데, 답변 또한 모두 본인이 작성한 경우
    • delete_question_self_only_self_answers ...
  • 인수테스트 한정
    • 로그인하지 않은 사용자가 요청한 경우

백준 DP: 가장 큰 증가 부분 수열 , 가장 긴 감소하는 부분 수열

두 문제 모두 이전 문제와 같은 유형이며 조금만 변형하면 풀 수 있는 문제라서 금방 풀 수 있었다. OKKY에서 보니까, 코딩테스트를 볼 때 클린코드와 OOP 지향적인 코드도 중요하게 보는 것 같다. 하나의 메소드에 몰아서 풀지 말고 객체와 작은 메소드로 나눠서 풀어보는 습관을 들여야겠다.

오늘 느낀점

곰곰히 생각해보니 웹 개발에서 인수 테스트는 일종의 컨트롤러 테스트가 아닌가? 라는 생각이 들었다. 인수 테스트에서는 요청과 응답에 대한 테스트를 작성하고, 서비스 레이어 테스트는 mocking을 통해 DB 관련 로직에 대한 로직에 대한 테스트를 작성하고, 도메인 레이어 테스트는 실제 비즈니스 로직에 대한 테스트를 작성해야 하는 것이 아닌가 싶다.

내일 할일

  • ATDD Q&A 4단계 피드백 반영
  • 백준 DP 2문제 풀이
  • nextstep 5주차 수업 참여 (마지막)