JUnit5과 Spring boot 테스트 코드 작성-Test Double 사용에 따른 Trade-Off와 Service Layer 단위 테스트(3)

taehee kim·2023년 3월 27일
6

0. 서론

  • Service Layer를 단위 테스트를 진행하면서 테스트 코드의 설정을 어떻게 할 지(Test double을 적용할지 여부, Spring Context환경, 순수한 Java환경) 그리고 이에 대한 트레이드 오프에 대해서 고민을 하면서 테스트 목적에 맞게 구성할 수 있는 판단을 할 수 있게 되었습니다.
  • 제가 고민했던 부분을 공유하려고 합니다.
  • 테스트 코드를 작성하면서 Spring framework에 대해서 더 자세히 알 수 있었습니다.

1. Service Layer Unit testing시 고민 해봐야할 지점들.

1-1. Test Double 사용에 따른 Trade-Off

  • Test Double이란 테스트 하려는 코드 부분을 제외한 나머지 부분의 코드로 부터 영향을 받지 않고 테스트르 진행할 수 있게 하기 위하여 가상의 객체를 생성하여 주입하는 것을 말한다.
  • Test Double을 활용할 경우 테스트에 의존적인 객체, DB, 여러 설정정보들로 부터 격리시켜 테스트 속도를 높이고 테스트 하려는 부분만 검증할 수 있다.

1-1-1. Test Double 종류

Dummy

  • 필요한 메서드 인터페이스 조차 존재하지 않고 단순히 객체 자체로 넣어놓기 위해 만드는 객체이다.

Fake

  • 실제 동작하는 구현을 가지고 있지만 프로덕션의 동작과는 다른 객체이다.
  • DB Repository를 임시적으로 Collection Repository로 구현하는 경우

Stub

  • 각 메서드의 입력값에 따른 출력값을 정의 미리 정의해놓은 객체이다.
  • Fake와 다르게 로직은 존재하지 않는다.
  • Mockito에서 @Mock, @MockBean 을 활용하여 생성한 객체가 이에 해당한다.

Spy

  • 프록시 객체를 생성하고 이 객체의 메서드가 프로덕션 객체를 대리 호출하도록 하는 방식으로 구현한다.
  • 동작 방식을 정의한 Spy 객체의 메서드는 Spy객체의 로직이 수행되고 그렇지 않은 경우 원본 객체의 행위를 수행한다.

1-1-2. Test Double의 장점

  • 컨트롤 할 수 없는 코드 영역의 경우 Test Double을 활용하지 않으면 테스트가 불가능한 경우가 있다.
  • 외부 의존성 때문에 테스트 수행 시점이나 경우에 따라 같은 결과가 보장되지 않는 것을 막을 수 있다.
  • 기본적으로 테스트 속도를 증가시키도록 Context를 구성할 수 있게 하기 때문에 빠른 피드백을 받을 수 있다.

1-1-3. Test Double을 왜 신중하게 사용해야할까

테스트하려는 내용의 구체적인 구현에 의존하게 만든다(리팩토링 내성 감소)

  • 기본적으로 구체적인 구현을 검증하는 코드는 좋은 테스트 코드라고 볼 수 없다.
  • Mocking 하는 코드는 원본 코드의 내부 구현에 관한 정보들을 테스트 코드가 의존하게 만들어 이것이 변경 될 경우 테스트가 깨지는 거짓 양성이 발생하게 만든다.
  • 예를들어 Repository객체를 Mocking 했는데 이 메서드의 인터페이스에서 인자가 하나 더 들어오는 것으로 변경된다면 모든 Mocking코드를 다 변경해주어야한다.

테스트 하려는 코드보다 Mocking을 작성하는데 시간이 더 걸려 주객전도가 되는 경우가 있다

  • Mocking된 코드의 결과가 테스트 내용에 주된 영향을 주는 경우 production코드에서 검증하려는 양에 비해 Mocking에 소요되는 비용이 커서 비효율적인 경우가 있다.

Mocking이 정의된 코드는 그렇지 않은 코드에 비해서 가독성이 떨어진다.

  • 테스트 하려는 관심사 외적인 부분을 테스트 코드에 작성해야하는 것이기 때문에 가독성이 상대적으로 떨어지고 코드가 길어진다.

1-1-4. 언제 Test Double을 사용해야할까

  • 확실한 경우는 컨트롤 할 수 없는 코드 예를 들면, 외부 API, 랜덤성격의 코드, 실행 시점 시간에 따라 달라지는 코드등은 Test Double을 거의 의무적으로 도입하는 것이 바람직하다.
  • 하지만, 이미 어느정도 검증이 수행된 Dao의 코드등은 Mocking을 하지 않는 것이 좋다고 생각한다.

1-2. Spring의 ApplicationContext vs MockitoExtension 환경 vs 순수한 Java환경

  • 두번째 세번째 경우 DI 컨테이너가 없기 때문에 AOP가 적용 되지 않고 @Transactional이 동작하지 않는다.

1-2-1.Spring의 ApplicationContext

  • 첫번재 Spring DI Container를 활용하여 자동 객체 생성과 주입을 할 수 있다. 반면 이 때문에 테스트가 실행되는데에 시간이 비교적 오래걸린다.
  • JUnit5의 경우 @ExtendWith(SpringExtension.class)를 붙이면 된다.
  • @WebMvcTest, @DataJpaTest, @SpringBootTest에 위의 Annotation이 붙어있다.
  • @ContextConfiguration은 로딩할 빈 전체를 정의한다.
  • @Import를 활용할 경우 추가적으로 로딩할 빈을 지정한다.
  • Mocking을 활용하는 경우 @MockBean, @SpyBean을 활용한다.

1-2-2.MockitoExtension 환경

  • 두번째 경우 Spring DI Container가 없기 때문에 테스트 실행 속도가 매우 빠르며 세번째와 다르게 @Mock, @InjectMock을 활용하여 Mocking을 간편하게 할 수 있기 때문에 객체 생성 및 주입 면에서 세번째 경우보다 편리할 수 있다.
  • @ExtendWith(MockitoExtension.class)
  • Mocking을 하는 경우 @Mock, @InjectMock을 활용한다.

1-2-3.순수한 java 테스트 환경

  • @Test를 테스트하려는 메서드에 붙여준다.
  • 세번재 경우 순수한 Java코드를 작성하는 경우와 동일하게 테스트하며 주로 Domain Model의 로직을 검증할 때 활용한다. 테스트 속도가 매우 빠르다.

1-3. 테스트할 수 없는 코드

  • 테스트 수행 시점마다 테스트 결과를 다르게 만들 수 있는 코드
  • 랜덤 로직, 외부 API, 현재 시간 관련 로직

1-3-1. 테스트 할 수 없는 코드의 영향을 축소시키는 방법

  • 테스트 할 수 없는 코드를 수행한 결과를 최대한 상위 모듈에서 반환받도록 코드를 작성한다.
  • 예를 들어 Service에서 현재 시각을 구해서 로직을 수행해야하는 경우가 있다고 하자.
    @Transactional
    public List<RandomMatch> createRandomMatch(String username,
        RandomMatchDto randomMatchDto) {
		LocalDateTime now = LocalDateTime.now();
		verifyAlreadyApplied(randomMatchDto.getContentCategory(), member, now);
}
    @Transactional
    public List<RandomMatch> createRandomMatch(String username,
        RandomMatchDto randomMatchDto, LocalDateTime now) {
 		verifyAlreadyApplied(randomMatchDto.getContentCategory(), member, now);
}
  • 첫번째 코드는 테스트하기 힘든 코드이다. 올바르게 테스트가 불가능할 수도 있다.
    • 현재 시간을 구하는 코드가 메서드 내에 있기 때문에 테스트 코드를 실행하는 시점에 따라 결과가 달라질 것이다.
  • 반면 두번째 코드는 시간을 인자로 받는 식으로 구현했기 때문에 테스트 시에 적절한 시간을 선택하여 넣어줄 수 있다.
  • 두 코드의 차이는 해당 메서드를 호출하는 상위 모듈에서 시간이라는 값을 구하도록 한 것이다.

1-4. 비니지스 로직 Domain Model에 응집(ORM을 충분히 활용해야함)

  • 비지니스 로직은 가능하면 Domain Model의 Entity나 Value Object에서 순수한 Java 객체 형태로 구성하는 것이 좋다고 하는데 Service 테스트 관점에서도 유리하다고 생각한다.
  • Domain Model에서 먼저 테스트가 이루어지기 때문이다.
  • 이를 위해서는 ORM을 적극적으로 활용하여 Repository메서드 사용을 최소화 해야한다.
    • ex) save, delete시 cascade사용
  • 예시
    • 첫 번째 코드에서 Service 내의 private method들이 Match Entity내의 메서드로 옮겨가고 makeReview의 에서는 이를 호출하는 방식으로 바뀌었기 때문에 domain을 테스트할 때 비지니스로직을 미리 검증할 수 있다.

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MatchService {

    private finalMatchRepositorymatchRepository;
    private finalUserRepositoryuserRepository;

    private finalMemberRepositorymemberRepository;
    private finalActivityRepositoryactivityRepository;

    private finalMemberMappermemberMapper;

    @Transactional
    public ResponseEntity<Void> makeReview(String username, String matchId,
        MatchReviewRequest request) {
        verifyCallerParticipatedInMatch(username,matchId);

        User caller = userRepository.findByUsername(username)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
        verifyReviewedMemberInMatchAndNotCaller(matchId,request.getMemberReviewDtos().stream()
            .map(MemberReviewDto::getNickname)
            .collect(Collectors.toList()), caller);

        Match match = matchRepository.findByApiId(matchId)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));

        match.review(caller.getMember());

//리뷰 작성자 참여 점수 추가.
Member memberReviewAuthor = memberRepository.findByNickname(username)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
        activityRepository.save(
            Activity.of(memberReviewAuthor,
                match.getContentCategory(),
ActivityMatchScore.MAKE_MATCH_REVIEW));
/**
         *리뷰에 따라 점수 추가
*/
List<Activity> activities =request.getMemberReviewDtos().stream()
            .map((memberReviewDto) -> {
                Member member = memberRepository.findByNickname(memberReviewDto.getNickname())
                    .orElseThrow(() ->
                        new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
                return Activity.of(member, match.getContentCategory(),
memberReviewDto.getActivityMatchScore());
            })
            .collect(Collectors.toList());
        activityRepository.saveAll(activities);
        return ResponseEntity.ok().build();
    }

    private void verifyReviewedMemberInMatchAndNotCaller(StringmatchId,List<String>nicknames, Usercaller) {
        Match match = matchRepository.findByApiId(matchId)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));

Set<Member> memberSet = match.getMatchMembers()
            .stream()
            .map(MatchMember::getMember)
            .collect(Collectors.toSet());

        memberRepository.findAllByNicknameIn(nicknames)
            .forEach((member) -> {
                if (!memberSet.contains(member)) {
                    throw new BusinessException(ErrorCode.REVIEWED_MEMBER_NOT_IN_MATCH);
                }else if(caller.getMember().equals(member)){
                    throw new BusinessException(ErrorCode.REVIEWING_SELF);
                }
            });
    }

//자기 매치인지 확인
private void verifyCallerParticipatedInMatch(Usercaller, StringmatchId) {

        Match match = matchRepository.findByApiId(matchId)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
        if (!caller.getUserRoles().stream()
            .map(UserRole::getRole)
            .map(Role::getValue)
            .collect(Collectors.toSet())
            .contains(RoleEnum.ROLE_ADMIN) &&
            !match.getMatchMembers().stream()
                .map(MatchMember::getMember)
                .collect(Collectors.toSet())
                .contains(caller.getMember())) {
            throw new BusinessException(ErrorCode.NOT_MATCH_PARTICIPATED);
        }
    }
}
  • 수정 후
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MatchService {

    private final MatchRepository matchRepository;
    private final UserRepository userRepository;

    private final MemberRepository memberRepository;
    private final ActivityRepository activityRepository;

    private final MemberMapper memberMapper;

    
    @Transactional
    public List<Activity> makeReview(String username, String matchId,
        MatchReviewRequest request) {
        Match match = matchRepository.findByApiId(matchId)
            .orElseThrow(() ->
                new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
        User reviewingUser = getUserByUsernameOrException(username);
        Map<String, ActivityMatchScore> nicknameActivityScoreMap = request.getMemberReviewDtos().stream()
            .collect(Collectors.toMap(MemberReviewDto::getNickname,
                MemberReviewDto::getActivityMatchScore));
        List<Member> reviewedTargets = memberRepository.findAllByNicknameIn(
            new ArrayList<>(nicknameActivityScoreMap.keySet()));
        // request의 nickname이 member에 없는 경우
        if (reviewedTargets.size() != nicknameActivityScoreMap.size()) {
            throw new NoEntityException(ErrorCode.ENTITY_NOT_FOUND);
        }
        List<Activity> createdActivities = match.makeReview(reviewingUser.getMember(),
            reviewedTargets.stream()
                .collect(Collectors.toMap(m -> m,
                    m -> nicknameActivityScoreMap.get(m.getNickname()))));
        return activityRepository.saveAll(createdActivities);
    }

}
@Builder(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "MATCHS", uniqueConstraints = {
    @UniqueConstraint(name = "API_ID_UNIQUE", columnNames = {"apiId"}),
})

@Entity
public class Match extends BaseEntity {

    /********************************* 비니지스 로직 *********************************/
    public List<Activity> makeReview(Member reviewer, Map<Member, ActivityMatchScore> reviewedMemberScoreMap) {

        verifyMemberParticipatedInMatchOrAdmin(reviewer);
        verifyReviewedMemberIsInMatchAndNotReviewer(reviewer, reviewedMemberScoreMap.keySet());
        //리뷰 작성자 매칭 참여 여부 true로 변경
        matchMembers.stream()
            .filter(mm ->
                mm.getMember().equals(reviewer))
            .findAny()
            .orElseThrow(() ->
                new IllegalArgumentException("해당 매치에 참여한 멤버가 아닙니다."))
            .updateisReviewedToTrue();

        List<Activity> activities = new ArrayList<>();
        //리뷰 작성자 참여 점수 추가.
        Activity reviewerActivity = Activity.of(reviewer,
            contentCategory,
            ActivityMatchScore.MAKE_MATCH_REVIEW);
        activities.add(reviewerActivity);

        // 리뷰에 따라 점수 추가
        reviewedMemberScoreMap.forEach((member, score) -> {
            Activity activity = Activity.of(member,
                contentCategory,
                score);
            activities.add(activity);
        });
        return activities;
    }
    /**
     * 자기가 참여한 매치인지 확인
     * @param member
     */
    public void verifyMemberParticipatedInMatchOrAdmin(Member member) {
        if (member.getUser().hasRole(RoleEnum.ROLE_ADMIN)){
            return ;
        }
        if (!matchMembers.stream()
                .map(MatchMember::getMember)
                .collect(Collectors.toSet())
                .contains(member)) {
            throw new BusinessException(ErrorCode.NOT_MATCH_PARTICIPATED);
        }
    }

    public Boolean isMemberReviewingBefore(Member member){
        return getMatchMembers().stream()
            .filter((mm) ->
                member.equals(mm.getMember())
            ).findFirst()
            .orElseThrow(() ->
                new IllegalArgumentException("해당 매치에 참여한 멤버가 아닙니다."))
            .getIsReviewed();
    }

    /**
     * 리뷰 대상자가 자기 자신이 아니고 매칭에 포함되어있는지 확인
     * @param reviewer
     * @param reviewedMemberSet
     */
    private void verifyReviewedMemberIsInMatchAndNotReviewer(Member reviewer, Set<Member> reviewedMemberSet) {

        Set<Member> memberSet = this.getMatchMembers()
            .stream()
            .map(MatchMember::getMember)
            .collect(Collectors.toSet());

        reviewedMemberSet
            .forEach((member) -> {
                if (!memberSet.contains(member)) {
                    throw new BusinessException(ErrorCode.REVIEWED_MEMBER_NOT_IN_MATCH);
                }else if(reviewer.equals(member)){
                    throw new BusinessException(ErrorCode.REVIEWING_SELF);
                }
            });
    }
}

    @Builder.Default
    @OneToMany(mappedBy = "match", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<MatchMember> matchMembers = new ArrayList<>();

    @Builder.Default
    @OneToMany(mappedBy = "match", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<MatchConditionMatch> matchConditionMatches = new ArrayList<>();

2. Service Layer 테스트 코드 작성법

  • 위에서 설명한 내용들을 고려하고 여러 테스트 작성법을 고려했을 때 효과적이라고 생각된 방법을 정리해보았습니다.

2-1. Domain Model, Repository를 먼저 테스트한다.

  • Domain Model테스트 시에 가벼운 테스트로 최대한 비니지스 로직을 검증한다.

2-2. 검증된 Dao객체는 Mocking을 하지 않는다.

  • 무분별한 Mocking은 리팩토링 내성을 저하시키므로 Dao객체는 주입받아 사용한다.

2-3. 통제할 수 없는 객체나 @DataJpaTest환경에서 제대로 동작할 수 없는 객체는 @MockBean으로 Test Double을 생성해준다.

  • 외부 API, Random, 시간 관련
  • KafkaProducer

2-4. Spring Application Context를 활용하고 @DataJpaTest Annotation과 @Import를 활용하여 Context를 로딩한다.

  • 통합 테스트의 문제점은 모든 Bean을 등록하기 때문에 DB, Redis, Kafka등 이 밖의 모든 환경이 구성되어야 하고 이 과정이 오래 걸린다는 점이다.
  • 이 단점들을 보완하기 위해 @DataJpaTest를 활용하여 Repository만을 로딩하고 DB만 실행하여 테스트를 할 수 있게 하여 Repositorty 단위 테스트와 동일한 수준으로 맞춰준다.

2-5. 테스트 DB는 Embedded DB가 아닌 local DB를 활용한다.

  • DB 종류에 따라 테스트 결과가 달라질 수 있기 때문이다.
  • 단, DB상태에 따라 테스트 결과가 달라지지 않도록 효과적인 데이터 제거와 초기화에 신경쓴다.

2-6. ddl-auto:create or create-drop을 활용하고 DB의 초기 데이터는 @TestConfiguration 클래스의 @PostConstruct를 활용하여 설정한다.

  • 데이터가 남아있을 수 있고 스키마가 변경될 수 있기 때문에 테스트 시 테이블을 재생성하도록 한다.

  • 이 경우 DB의 초기 데이터를 미리 생성해놓는 것은 힘들기 때문에 테스트 실행 시 항상 초기화 될 수 있도록 해준다.

2-6-1. @BeforeEach를 활용한 초기 데이터세팅

  • @BeforeEach를 활용하여 메서드 호출전에 데이터를 세팅해줄 수 있다.
  • 하지만, 이 방법은 메서드 호출 횟수만큼 초기 데이터를 여러번 생성해주어야 하기 때문에 테스트 속도를 저하시킬 수 있다.

2-6-2. @TestConfiguration + @PostConstruct

  • 이렇게 할 경우 Application Context가 로딩되는 최초에만 초기데이터가 저장된다.
@TestConfiguration
@Slf4j
@RequiredArgsConstructor
public class TestBootstrapConfig {
    @Value("${spring.jpa.hibernate.data-loader}")
    private Integer dataLoader;
    private final BootstrapDataLoader bootstrapDataLoader;
    @Transactional
    @PostConstruct
    public void initDB() {
        if (dataLoader.equals(2)){
            bootstrapDataLoader.createDefaultUsers();
            bootstrapDataLoader.createMatchCondition();
        }
    }
}

3. 테스트 작성 예시

3-1. 설정

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({ArticleService.class, MemberMapperImpl.class,
    Auditor.class, QuerydslConfig.class, JpaAndEntityPackagePathConfig.class,
    TestBootstrapConfig.class, BootstrapDataLoader.class})
class ArticleServiceWithDaoTest {
    @MockBean
    private AlarmProducer alarmProducer;
  @EnableJpaRepositories(basePackages =  "partner42.modulecommon.repository")
@EntityScan(basePackages = "partner42.modulecommon.domain")
@Configuration
public class JpaAndEntityPackagePathConfig {
}
  • TestBootstrapConfig.class, BootstrapDataLoader.class: 초기데이터 로딩
  • AlarmProducer는 kafka producing 로직이 있는 객체이다. 이에 필요한 bean들이 없고 kafka 자체도 실행되지 않았기 때문에 @MockBean을 통해 mocking해준다.
  • Import를 통해 DataJpaTest를 통해 등록되지 않는 빈을 등록해준다. ArticleService.class, MemberMapperImpl.class를 제외한 요소들은 모든 Service 테스트 시 공통적으로 필요하다.
    • Auditor.class : @EnableAuditing
    • QuerydslConfig.class: JpaQueryFactory Bean
    • JpaAndEntityPackagePathConfig.class: 멀티 모듈프로젝트이기 때문에 필요.

서비스를 테스트 할때 Repository Layer만 통합해 테스트 하는 경우 공통적으로 Bean으로 등록되어야 하는 Class들 TestConfiguration으로 분리.

  • 공통적으로 설정되어야 하는 Bean들이 변하면 모든 설정이 바뀌기 때문에 TestConfiguration으로 설정해줍니다.
@TestConfiguration
@Configuration
@Import({Auditor.class, QuerydslConfig.class, JpaAndEntityPackagePathConfig.class,
    TestBootstrapConfig.class, BootstrapDataLoader.class, BCryptPasswordEncoder.class})
public class ServiceWithDAOTestDefaultConfig {

}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({ArticleService.class, MemberMapperImpl.class,
    ServiceWithDAOTestDefaultconfig.class})
class ArticleServiceWithDaoTest {
    @MockBean
    private AlarmProducer alarmProducer;

3-2.테스트 메서드

  • 객체 생성및 영속화, 테스트 하려는 메서드 동작, Assertj활용 순으로 테스트한다.
  • 테스트 목적에 따라 테스트 메서드를 여러개로 분리한다.
  • 테스트 메서드 명은 (메서드명가정예상되는결과)로 짓는다.
  @DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({ArticleService.class, MemberMapperImpl.class,
    ServiceWithDAOTestDefaultConfig.class,
})
class ArticleServiceWithDaoTest {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private ArticleRepository articleRepository;
    @Autowired
    private ArticleService articleService;
      @Autowired
      private ArticleMemberRepository articleMemberRepository;
      @Autowired
      private ArticleMatchConditionRepository articleMatchConditionRepository;
      @Autowired
      private MatchConditionRepository matchConditionRepository;
      @Autowired
      private MatchRepository matchRepository;

      @BeforeEach
      void setUp() {
      }

      @Test
      void createArticle() {
          //given
          User takim = userRepository.findByUsername("takim").get();
          LocalDate date = LocalDate.now().plusDays(1);
          ArticleDto dto = ArticleDto.builder()
              .contentCategory(ContentCategory.MEAL)
              .date(date)
              .participantNumMax(3)
              .content("content")
              .title("title")
              .anonymity(false)
              .matchConditionDto(
                  MatchConditionDto.builder()
                      .placeList(List.of(Place.GAEPO, Place.SEOCHO))
                      .timeOfEatingList(List.of(TimeOfEating.DINNER, TimeOfEating.LUNCH))
                      .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                      .typeOfStudyList(List.of(TypeOfStudy.INNER_CIRCLE))
                      .build()
              ).build();

          //when
          ArticleOnlyIdResponse response = articleService.createArticle(takim.getUsername(), dto);
          Article article = articleRepository.findByApiIdAndIsDeletedIsFalse(response.getArticleId())
              .get();
          //then
          assertThat(article).extracting(Article::getDate, Article::getTitle, Article::getContent,
                  Article::getAnonymity, Article::getIsComplete, Article::getParticipantNum,
                  Article::getParticipantNumMax, Article::getContentCategory)
              .containsExactly(date, "title", "content", false, false, 1, 3, ContentCategory.MEAL);
          assertThat(article.getArticleMatchConditions()).extracting(
                  ArticleMatchCondition::getMatchCondition)
              .extracting("value", "conditionCategory")
              .containsExactlyInAnyOrder(
                  tuple(Place.GAEPO.name(), ConditionCategory.Place),
                  tuple(Place.SEOCHO.name(), ConditionCategory.Place),
                  tuple(TimeOfEating.DINNER.name(), ConditionCategory.TimeOfEating),
                  tuple(TimeOfEating.LUNCH.name(), ConditionCategory.TimeOfEating),
                  tuple(WayOfEating.DELIVERY.name(), ConditionCategory.WayOfEating),
                  tuple(TypeOfStudy.INNER_CIRCLE.name(), ConditionCategory.TypeOfStudy)
              );
          assertThat(article.getArticleMembers())
              .extracting(ArticleMember::getMember, ArticleMember::getIsAuthor)
              .containsExactly(tuple(takim.getMember(), true));
      }

      @Test
      void deleteArticle() {
          User takim = userRepository.findByUsername("takim").get();
          LocalDate date = LocalDate.now().plusDays(1);

          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          ArticleMatchCondition amc = articleMatchConditionRepository.save(
              ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
                  article));
          articleService.deleteArticle(takim.getUsername(), article.getApiId());
          Optional<Article> optionalArticle = articleRepository.findByApiId(article.getApiId());
          //then
          assertThat(optionalArticle).isNotPresent();
      }

      @Test
      void deleteArticle_whenNotAuthorUserDelete_thenThrow() {
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

          LocalDate date = LocalDate.now().plusDays(1);

          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          ArticleMatchCondition amc = articleMatchConditionRepository.save(
              ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
                  article));

          Optional<Article> optionalArticle = articleRepository.findByApiId(article.getApiId());
          //then
          assertThatThrownBy(() ->
              articleService.deleteArticle(sorkim.getUsername(), article.getApiId()))
              .isInstanceOf(InvalidInputException.class);
      }

      @Test
      void softDelete() {
          User takim = userRepository.findByUsername("takim").get();
          LocalDate date = LocalDate.now().plusDays(1);

          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          ArticleMatchCondition amc = articleMatchConditionRepository.save(
              ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
                  article));
          articleService.softDelete(takim.getUsername(), article.getApiId());
          //then
          articleRepository.findByApiId(article.getApiId()).ifPresent(
              (at) ->
                  assertThat(at).extracting(Article::getIsDeleted)
                      .isEqualTo(true)
          );
      }

      @Test
      void softDelete_whenNotAuthorUserDelete_thenThrow() {
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

          LocalDate date = LocalDate.now().plusDays(1);

          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          ArticleMatchCondition amc = articleMatchConditionRepository.save(
              ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name())
                  .get(), article));
          //then
          assertThatThrownBy(() ->
              articleService.softDelete(sorkim.getUsername(), article.getApiId()))
              .isInstanceOf(InvalidInputException.class);

      }

      @Test
      void updateArticle() {
          //given
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

          LocalDate date = LocalDate.now().plusDays(1);
          ArticleDto dto = ArticleDto.builder()
              .contentCategory(ContentCategory.MEAL)
              .date(date)
              .participantNumMax(3)
              .content("content")
              .title("title")
              .anonymity(false)
              .matchConditionDto(
                  MatchConditionDto.builder()
                      .placeList(List.of(Place.GAEPO, Place.SEOCHO))
                      .timeOfEatingList(List.of(TimeOfEating.DINNER, TimeOfEating.LUNCH))
                      .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                      .typeOfStudyList(List.of(TypeOfStudy.INNER_CIRCLE))
                      .build()
              ).build();
          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", true, 4, ContentCategory.STUDY));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          articleMatchConditionRepository.saveAll(
              List.of(
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article)
              )
          );

          ArticleOnlyIdResponse articleOnlyIdResponse = articleService.updateArticle(dto,
              takim.getUsername(), article.getApiId());
          Article updatedArticle = articleRepository.findByApiId(articleOnlyIdResponse.getArticleId())
              .get();

          //then
          assertThat(updatedArticle).extracting(Article::getDate, Article::getTitle,
                  Article::getContent,
                  Article::getAnonymity, Article::getIsComplete, Article::getParticipantNum,
                  Article::getParticipantNumMax, Article::getContentCategory)
              .containsExactly(date, "title", "content", false, false, 1, 3, ContentCategory.MEAL);
          assertThat(article.getArticleMatchConditions()).extracting(
                  ArticleMatchCondition::getMatchCondition)
              .extracting(MatchCondition::getValue,
                  MatchCondition::getConditionCategory)
              .containsExactlyInAnyOrder(
                  tuple(Place.GAEPO.name(), ConditionCategory.Place),
                  tuple(Place.SEOCHO.name(), ConditionCategory.Place),
                  tuple(TimeOfEating.DINNER.name(), ConditionCategory.TimeOfEating),
                  tuple(TimeOfEating.LUNCH.name(), ConditionCategory.TimeOfEating),
                  tuple(WayOfEating.DELIVERY.name(), ConditionCategory.WayOfEating),
                  tuple(TypeOfStudy.INNER_CIRCLE.name(), ConditionCategory.TypeOfStudy)
              );
          assertThat(article.getArticleMembers())
              .extracting(ArticleMember::getMember, ArticleMember::getIsAuthor)
              .containsExactly(tuple(takim.getMember(), true));
      }

      @Test
      void updateArticle_whenNotAuthorUserUpdate_thenThrow() {
          //given
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

          LocalDate date = LocalDate.now().plusDays(1);
          ArticleDto dto = ArticleDto.builder()
              .contentCategory(ContentCategory.MEAL)
              .date(date)
              .participantNumMax(3)
              .content("content")
              .title("title")
              .anonymity(false)
              .matchConditionDto(
                  MatchConditionDto.builder()
                      .placeList(List.of(Place.GAEPO, Place.SEOCHO))
                      .timeOfEatingList(List.of(TimeOfEating.DINNER, TimeOfEating.LUNCH))
                      .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                      .typeOfStudyList(List.of(TypeOfStudy.INNER_CIRCLE))
                      .build()
              ).build();
          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", true, 4, ContentCategory.STUDY));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          articleMatchConditionRepository.saveAll(
              List.of(
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article)
              )
          );
          //then
          assertThatThrownBy(() ->
              articleService.updateArticle(dto,
                  sorkim.getUsername(), article.getApiId()))
              .isInstanceOf(InvalidInputException.class);
      }

      @Test
      void readOneArticle() {
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

          LocalDate date = LocalDate.now().plusDays(1);

          //when
          Article article = articleRepository.save(
              Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
          ArticleMember am = articleMemberRepository.save(
              ArticleMember.of(takim.getMember(), true, article));
          articleMatchConditionRepository.saveAll(
              List.of(
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                      article),
                  ArticleMatchCondition.of(
                      matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article)
              )
          );
          ArticleReadOneResponse articleReadOneResponse = articleService.readOneArticle(
              takim.getUsername(), article.getApiId());
          //then
          assertThat(articleReadOneResponse)
              .extracting(ArticleReadOneResponse::getArticleId, ArticleReadOneResponse::getUserId,
                  ArticleReadOneResponse::getTitle, ArticleReadOneResponse::getDate,
                  ArticleReadOneResponse::getCreatedAt, ArticleReadOneResponse::getContent,
                  ArticleReadOneResponse::getAnonymity, ArticleReadOneResponse::getIsToday,
                  ArticleReadOneResponse::getParticipantNumMax,
                  ArticleReadOneResponse::getParticipantNum,
                  ArticleReadOneResponse::getContentCategory)
              .containsExactly(article.getApiId(), takim.getApiId(), "a", date,
                  article.getCreatedAt(), "a", false, false, 3, 1,
                  ContentCategory.MEAL);
          assertThat(articleReadOneResponse)
              .extracting(ArticleReadOneResponse::getMatchConditionDto)
              .extracting(MatchConditionDto::getPlaceList,
                  MatchConditionDto::getTimeOfEatingList,
                  MatchConditionDto::getWayOfEatingList,
                  MatchConditionDto::getTypeOfStudyList
              )
              .usingRecursiveComparison().ignoringAllOverriddenEquals().ignoringCollectionOrder()
              .isEqualTo(List.of(List.of(Place.GAEPO),
                  List.of(TimeOfEating.MIDNIGHT, TimeOfEating.BREAKFAST),
                  List.of(WayOfEating.TAKEOUT),
                  List.of()
              ));

          assertThat(articleReadOneResponse)
              .extracting(ArticleReadOneResponse::getParticipantsOrAuthor)
              .usingRecursiveComparison().ignoringAllOverriddenEquals().ignoringCollectionOrder()
              .isEqualTo(List.of(
                  MemberDto.builder()
                      .nickname(takim.getMember().getNickname())
                      .isAuthor(true)
                      .isMe(true)
              ));

      }

      @Test
      void readOneArticle_whenReadByOtherUserOrNotAuthenticatedUser_thenIsMeFalse() {
          User takim = userRepository.findByUsername("takim").get();
          User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
        ArticleMember am = articleMemberRepository.save(
            ArticleMember.of(takim.getMember(), true, article));
        articleMatchConditionRepository.saveAll(
            List.of(
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                    article),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                    article),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article)
            )
        );
        ArticleReadOneResponse articleReadOneResponseBySorkim = articleService.readOneArticle(
            sorkim.getUsername(), article.getApiId());
        ArticleReadOneResponse articleReadOneResponseByUnAuthenticated = articleService.readOneArticle(
            sorkim.getUsername(), article.getApiId());

        //then
        assertThat(articleReadOneResponseBySorkim)
            .extracting(ArticleReadOneResponse::getParticipantsOrAuthor)
            .usingRecursiveComparison().ignoringAllOverriddenEquals().ignoringCollectionOrder()
            .isEqualTo(List.of(
                MemberDto.builder()
                    .nickname(takim.getMember().getNickname())
                    .isAuthor(true)
                    .isMe(false)
            ));

        assertThat(articleReadOneResponseByUnAuthenticated)
            .extracting(ArticleReadOneResponse::getParticipantsOrAuthor)
            .usingRecursiveComparison().ignoringAllOverriddenEquals().ignoringCollectionOrder()
            .isEqualTo(List.of(
                MemberDto.builder()
                    .nickname(takim.getMember().getNickname())
                    .isAuthor(true)
                    .isMe(false)
            ));

    }

    @Test
    void readAllArticle() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);

        //when
        Article article1 = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
        articleMemberRepository.save(
            ArticleMember.of(takim.getMember(), true, article1));
        articleMatchConditionRepository.saveAll(
            List.of(
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                    article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                    article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(),
                    article1)
            )
        );

        Article article2 = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.STUDY));
        articleMemberRepository.save(
            ArticleMember.of(sorkim.getMember(), true, article2));
        articleMatchConditionRepository.saveAll(
            List.of(
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article2),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(),
                    article2)
            )
        );
        List<ArticleReadResponse> articleReadResponses = articleService.readAllArticle(
            PageRequest.of(0, 10), new ArticleSearch()).getContent();
        //then
        assertThat(articleReadResponses)
            .extracting(ArticleReadResponse::getNickname, ArticleReadResponse::getUserId,
                ArticleReadResponse::getArticleId, ArticleReadResponse::getTitle,
                ArticleReadResponse::getContent, ArticleReadResponse::getDate,
                ArticleReadResponse::getCreatedAt, ArticleReadResponse::getAnonymity,
                ArticleReadResponse::getIsComplete, ArticleReadResponse::getIsToday,
                ArticleReadResponse::getParticipantNumMax,
                ArticleReadResponse::getParticipantNum, ArticleReadResponse::getContentCategory)
            .containsExactlyInAnyOrder(
                tuple("takim", takim.getApiId(), article1.getApiId(), "a", "a", date,
                    article1.getCreatedAt(), false, false, false, 3, 1, ContentCategory.MEAL),
                tuple("sorkim", sorkim.getApiId(), article2.getApiId(), "a", "a", date,
                    article2.getCreatedAt(), false, false, false, 3, 1, ContentCategory.STUDY)
            );
        assertThat(articleReadResponses)
            .extracting(ArticleReadResponse::getMatchConditionDto)
            .extracting(MatchConditionDto::getPlaceList, MatchConditionDto::getTimeOfEatingList,
                MatchConditionDto::getWayOfEatingList, MatchConditionDto::getTypeOfStudyList)
            .containsExactlyInAnyOrder(
                tuple(
                    List.of(Place.GAEPO),
                    List.of(TimeOfEating.MIDNIGHT, TimeOfEating.BREAKFAST),
                    List.of(WayOfEating.TAKEOUT),
                    List.of()
                ),
                tuple(
                    List.of(Place.GAEPO),
                    List.of(),
                    List.of(WayOfEating.TAKEOUT),
                    List.of()
                )
            );

    }

    @Test
    void readAllArticle_whenPageSizeNearFindAllSize_thenSliceHasNext() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);

        //when
        Article article1 = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
        articleMemberRepository.save(
            ArticleMember.of(takim.getMember(), true, article1));
        articleMatchConditionRepository.saveAll(
            List.of(
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(),
                    article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(),
                    article1),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(),
                    article1)
            )
        );

        Article article2 = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.STUDY));
        articleMemberRepository.save(
            ArticleMember.of(sorkim.getMember(), true, article2));
        articleMatchConditionRepository.saveAll(
            List.of(
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article2),
                ArticleMatchCondition.of(
                    matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(),
                    article2)
            )
        );
        SliceImpl<ArticleReadResponse> articleReadResponsesUnder = articleService.readAllArticle(
            PageRequest.of(0, 1), new ArticleSearch());
        SliceImpl<ArticleReadResponse> articleReadResponsesEquals = articleService.readAllArticle(
            PageRequest.of(0, 2), new ArticleSearch());
        SliceImpl<ArticleReadResponse> articleReadResponsesOver = articleService.readAllArticle(
            PageRequest.of(0, 3), new ArticleSearch());
        //then
        assertThat(articleReadResponsesUnder.hasNext()).isTrue();
        assertThat(articleReadResponsesEquals.hasNext()).isFalse();
        assertThat(articleReadResponsesOver.hasNext()).isFalse();
    }

    @Test
    void participateArticle() {
        //given
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);
        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);

        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        ArticleOnlyIdResponse articleOnlyIdResponse = articleService.participateArticle(
            sorkim.getUsername(), article.getApiId());
        Article participatedArticle = articleRepository.findByApiId(
                articleOnlyIdResponse.getArticleId()).get();
        //then

        assertThat(participatedArticle).extracting(Article::getParticipantNum)
            .isEqualTo(2);
        assertThat(participatedArticle.getArticleMembers())
            .extracting(ArticleMember::getIsAuthor, ArticleMember::getMember)
            .containsExactlyInAnyOrder(tuple(true, takim.getMember()),
                tuple(false, sorkim.getMember()));
    }

    @Test
    void participateArticle_verifyAlarmProducer_SendIsCalled() {
        //given
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);
        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, 3, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);

        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
            article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        ArticleOnlyIdResponse articleOnlyIdResponse = articleService.participateArticle(
            sorkim.getUsername(), article.getApiId());
        Article participatedArticle = articleRepository.findByApiId(
            articleOnlyIdResponse.getArticleId()).get();
        //then
        verify(alarmProducer).send(
            new AlarmEvent(AlarmType.PARTICIPATION_ON_MY_POST, AlarmArgs.builder()
                .opinionId(null)
                .articleId(articleOnlyIdResponse.getArticleId())
                .callingMemberNickname(sorkim.getMember().getNickname())
                .build(), article.getAuthorMember().getUser().getId(), SseEventName.ALARM_LIST));
    }

    @Test
    void participateArticle_whenArticleIsFullOrAlreadyParticipatedUser_thenThrow() {
        //given
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 2;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        ArticleOnlyIdResponse articleOnlyIdResponse = articleService.participateArticle(
            sorkim.getUsername(), article.getApiId());
        //then


        assertThatThrownBy(() -> articleService.participateArticle(sorkim.getUsername(), article.getApiId()))
            .isInstanceOf(InvalidInputException.class);

        assertThatThrownBy(() -> articleService.participateArticle(hyenam.getUsername(), article.getApiId()))
            .isInstanceOf(BusinessException.class)
            .hasMessage(ErrorCode.FULL_ARTICLE.getMessage());
    }


    @Test
    void participateCancelArticle() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 2;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        ArticleOnlyIdResponse articleOnlyIdResponse = articleService.participateArticle(
            sorkim.getUsername(), article.getApiId());
        ArticleOnlyIdResponse articleOnlyIdResponseCancel = articleService.participateCancelArticle(
            sorkim.getUsername(), article.getApiId());
        Article participatedArticle = articleRepository.findByApiId(
            articleOnlyIdResponse.getArticleId()).get();
        //then

        assertThat(participatedArticle).extracting(Article::getParticipantNum)
            .isEqualTo(1);

        assertThat(participatedArticle.getArticleMembers())
            .extracting(ArticleMember::getIsAuthor, ArticleMember::getMember)
            .containsExactlyInAnyOrder(tuple(true, takim.getMember()));

    }

    @Test
    void participateCancelArticle_verifyAlarmProducer_sendIsCalled() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 2;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
            article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        ArticleOnlyIdResponse articleOnlyIdResponse = articleService.participateArticle(
            sorkim.getUsername(), article.getApiId());
        ArticleOnlyIdResponse articleOnlyIdResponseCancel = articleService.participateCancelArticle(
            sorkim.getUsername(), article.getApiId());

        //then
        verify(alarmProducer).send(
            new AlarmEvent(AlarmType.PARTICIPATION_CANCEL_ON_MY_POST, AlarmArgs.builder()
                .opinionId(null)
                .articleId(articleOnlyIdResponse.getArticleId())
                .callingMemberNickname(sorkim.getMember().getNickname())
                .build(), article.getAuthorMember().getUser().getId(), SseEventName.ALARM_LIST));
    }

    @Test
    void participateCancelArticle_whenNotParticipatedUserCancel_thenThrow() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 2;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);
        articleService.participateArticle(sorkim.getUsername(), article.getApiId());

        //then
        assertThatThrownBy(() ->
            articleService.participateCancelArticle(
                hyenam.getUsername(), article.getApiId()))
            .isInstanceOf(InvalidInputException.class)
            .hasMessage(ErrorCode.NOT_PARTICIPATED_MEMBER.getMessage());

    }

    @Test
    void participateCancelArticle_whenAuthorMember_thenThrow() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 2;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        //then
        assertThatThrownBy(() ->
            articleService.participateCancelArticle(
                takim.getUsername(), article.getApiId()))
            .isInstanceOf(InvalidInputException.class)
            .hasMessage(ErrorCode.NOT_ALLOW_AUTHOR_MEMBER_DELETE.getMessage());
    }

    @Test
    void completeArticle() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 3;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        articleService.participateArticle(sorkim.getUsername(), article.getApiId());

        EmailDto<MatchOnlyIdResponse> matchOnlyIdResponseEmailDto = articleService.completeArticle(
            takim.getUsername(), article.getApiId());
        Match match = matchRepository.findByApiId(
            matchOnlyIdResponseEmailDto.getResponse().getMatchId()).get();

        //then
        assertThat(match).extracting(Match::getMatchStatus, Match::getContentCategory,
            Match::getMethodCategory, Match::getParticipantNum)
            .containsExactly(MatchStatus.MATCHED, ContentCategory.MEAL, MethodCategory.MANUAL,
                2);
        assertThat(match.getMatchMembers())
            .extracting(MatchMember::getMember, MatchMember::getIsAuthor,
                MatchMember::getIsReviewed)
            .containsExactly(
                tuple(takim.getMember(), true, false),
                tuple(sorkim.getMember(), false, false)
            );
        assertThat(match.getMatchConditionMatches())
            .extracting(MatchConditionMatch::getMatchCondition)
            .extracting(MatchCondition::getValue)
            .containsExactlyInAnyOrder(
                Place.GAEPO.name(),
                TimeOfEating.MIDNIGHT.name(),
                TimeOfEating.BREAKFAST.name(),
                WayOfEating.TAKEOUT.name()
            );
        assertThat(matchOnlyIdResponseEmailDto.getEmails())
            .containsExactlyInAnyOrder(
                takim.getEmail(),
                sorkim.getEmail()
            );
    }

    @Test
    void completeArticle_whenCompleteTwice_thenThrow() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 3;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
            article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        EmailDto<MatchOnlyIdResponse> matchOnlyIdResponseEmailDto = articleService.completeArticle(
            takim.getUsername(), article.getApiId());
        assertThatThrownBy(() ->
            articleService.completeArticle(
                takim.getUsername(), article.getApiId()))
            .isInstanceOf(BusinessException.class)
            .hasMessage(ErrorCode.COMPLETED_ARTICLE.getMessage());
    }

    @Test
    void completeArticle_whenNotAuthorComplete_thenThrow() {
        User takim = userRepository.findByUsername("takim").get();
        User sorkim = userRepository.findByUsername("sorkim").get();
        User hyenam = userRepository.findByUsername("hyenam").get();

        LocalDate date = LocalDate.now().plusDays(1);
        int participantNumMax = 3;

        //when
        Article article = articleRepository.save(
            Article.of(date, "a", "a", false, participantNumMax, ContentCategory.MEAL));
        ArticleMember.of(takim.getMember(), true, article);
        ArticleMatchCondition.of(matchConditionRepository.findByValue(Place.GAEPO.name()).get(),
            article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.MIDNIGHT.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(TimeOfEating.BREAKFAST.name()).get(), article);
        ArticleMatchCondition.of(
            matchConditionRepository.findByValue(WayOfEating.TAKEOUT.name()).get(), article);

        articleService.participateArticle(sorkim.getUsername(), article.getApiId());
        //then
        assertThatThrownBy(() ->
            articleService.completeArticle(
                sorkim.getUsername(), article.getApiId()))
            .isInstanceOf(InvalidInputException.class)
            .hasMessage(ErrorCode.NOT_ARTICLE_AUTHOR.getMessage());
    }
}

4. Dao를 Mocking하는 테스트 방식

  • Spring Application Context를 로딩하지 않기 때문에 테스트 수행 속도가 빠르고 DB와 상관없이 객체 레벨에서 검증할 수 있다.
  • 하지만, 위에서 설명한 것처럼 Dao인터페이스가 변하는것에 취약하다.

@Slf4j
@ExtendWith(MockitoExtension.class)
class RandomMatchServiceTest {

    @InjectMocks
    private RandomMatchService randomMatchService;
    @Mock
    private RandomMatchRepository randomMatchRepository;
    @Mock
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
    }

    @Test
    void createRandomMatch_whenMatchConditionRandomMatchDtoProper_ThenRandomMatchList()
        throws Exception {
        //given
        User takim = User.of("takim", null, null, null, null, Member.of("takim"));
        LocalDateTime now = CustomTimeUtils.nowWithoutNano();

        String username = "takim";
        RandomMatchDto randomMatchDto = RandomMatchDto.builder()
            .contentCategory(ContentCategory.MEAL)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                .build())
            .build();

        RandomMatchDto randomMatchDtoEmptyList = RandomMatchDto.builder()
            .contentCategory(ContentCategory.MEAL)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .wayOfEatingList(List.of())
                .build())
            .build();

        RandomMatchDto randomMatchStudyDto = RandomMatchDto.builder()
            .contentCategory(ContentCategory.STUDY)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .typeOfStudyList(List.of(TypeOfStudy.INNER_CIRCLE))
                .build())
            .build();

        RandomMatchDto randomMatchStudyDtoEmptyList = RandomMatchDto.builder()
            .contentCategory(ContentCategory.STUDY)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .typeOfStudyList(List.of())
                .build())
            .build();

        given(randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
            any())).willReturn(List.of());
        given(userRepository.findByUsername(anyString())).willReturn(Optional.ofNullable(takim));

        //when
        List<RandomMatch> randomMatches = randomMatchService.createRandomMatch(username,
            randomMatchDto, now);
        List<RandomMatch> randomMatchesWithEmptyList = randomMatchService.createRandomMatch(
            username, randomMatchDtoEmptyList, now);

        List<RandomMatch> randomMatchesStudy = randomMatchService.createRandomMatch(username,
            randomMatchStudyDto, now);
        List<RandomMatch> randomMatchesStudyWithEmptyList = randomMatchService.createRandomMatch(
            username, randomMatchStudyDtoEmptyList, now);

        //then
        assertThat(randomMatches).hasSize(1);
        //모든 필드와 객체가 적절하게 생성되는지
        assertThat(randomMatches).extracting(RandomMatch::getIsExpired)
                .containsExactly(false);
        assertThat(randomMatches).extracting(RandomMatch::getRandomMatchCondition)
            .extracting(RandomMatchCondition::getPlace, RandomMatchCondition::getContentCategory,
                RandomMatchCondition::getTypeOfStudy, RandomMatchCondition::getWayOfEating)
            .containsExactlyInAnyOrder(
                tuple(Place.GAEPO, ContentCategory.MEAL, null, WayOfEating.DELIVERY)
            );
        //조건 필드가 빈 List인 경우 모든 조건으로 RandomMatch반환.
        assertThat(randomMatchesWithEmptyList).extracting(RandomMatch::getRandomMatchCondition)
            .extracting(RandomMatchCondition::getWayOfEating)
            .containsOnly(WayOfEating.values());

        //study(다른 ContentCategory)
        assertThat(randomMatchesStudy).extracting(RandomMatch::getIsExpired)
            .containsExactly(false);
        assertThat(randomMatchesStudy).extracting(RandomMatch::getRandomMatchCondition)
            .extracting(RandomMatchCondition::getPlace, RandomMatchCondition::getContentCategory,
                RandomMatchCondition::getTypeOfStudy, RandomMatchCondition::getWayOfEating)
            .containsExactlyInAnyOrder(
                tuple(Place.GAEPO, ContentCategory.STUDY, TypeOfStudy.INNER_CIRCLE, null)
            );
        //조건 필드가 빈 List인 경우 모든 조건으로 RandomMatch반환.
        assertThat(randomMatchesStudyWithEmptyList).extracting(RandomMatch::getRandomMatchCondition)
            .extracting(RandomMatchCondition::getTypeOfStudy)
            .containsOnly(TypeOfStudy.values());
    }

    @Test
    void createRandomMatch_whenUsernameNotExist_ThenThrowException() throws Exception {
        //given
        User takim = User.of("takim", null, null, null, null, Member.of("takim"));

        LocalDateTime now = CustomTimeUtils.nowWithoutNano();
        RandomMatchDto randomMatchDto = RandomMatchDto.builder()
            .contentCategory(ContentCategory.MEAL)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                .build())
            .build();
        String notExistUsername = "notExistUsername";

        given(userRepository.findByUsername(anyString())).willReturn(Optional.ofNullable(takim));
        given(userRepository.findByUsername(notExistUsername)).willReturn(Optional.empty());
        //when
        //then
        assertThatThrownBy(() ->
            randomMatchService.createRandomMatch(notExistUsername, randomMatchDto, now))
            .isInstanceOf(NoEntityException.class);
    }

    @Test
    void createRandomMatch_whenSameUserAlreadyApplySameCategoryRandomMatch_ThenException()
        throws Exception {
        //given
        Member takimMember = Member.of("takim");
        User takim = User.of("takim", null, null, null, null, takimMember);
        takimMember.setId(1L);
        Member unknownMember = Member.of("unknown");
        User unknown = User.of("unknown", null, null, null, null, unknownMember);
        unknownMember.setId(2L);

        LocalDateTime now = CustomTimeUtils.nowWithoutNano();

        RandomMatchDto randomMatchDto = RandomMatchDto.builder()
            .contentCategory(ContentCategory.MEAL)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .wayOfEatingList(List.of(WayOfEating.DELIVERY))
                .build())
            .build();

        RandomMatchDto randomMatchDtoEmptyList = RandomMatchDto.builder()
            .contentCategory(ContentCategory.MEAL)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .wayOfEatingList(List.of())
                .build())
            .build();

        RandomMatchDto randomMatchStudyDto = RandomMatchDto.builder()
            .contentCategory(ContentCategory.STUDY)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .typeOfStudyList(List.of(TypeOfStudy.INNER_CIRCLE))
                .build())
            .build();

        RandomMatchDto randomMatchStudyDtoEmptyList = RandomMatchDto.builder()
            .contentCategory(ContentCategory.STUDY)
            .matchConditionRandomMatchDto(MatchConditionRandomMatchDto.builder()
                .placeList(List.of(Place.GAEPO))
                .typeOfStudyList(List.of())
                .build())
            .build();

        given(
            randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
                RandomMatchSearch.builder()
                    .contentCategory(ContentCategory.MEAL)
                    .memberId(takimMember.getId())
                    .isExpired(false)
                    .createdAt(now.minusMinutes(30))
                    .build()))
            .willReturn(List.of()).willReturn(
                List.of(RandomMatch.of(null, null)));

        given(
            randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
                RandomMatchSearch.builder()
                    .contentCategory(ContentCategory.STUDY)
                    .memberId(takimMember.getId())
                    .isExpired(false)
                    .createdAt(now.minusMinutes(30))
                    .build()))
            .willReturn(List.of()).willReturn(
                List.of(RandomMatch.of(null, null)));
        given(
            randomMatchRepository.findByCreatedAtAfterAndIsExpiredAndMemberIdAndContentCategory(
                RandomMatchSearch.builder()
                    .contentCategory(ContentCategory.MEAL)
                    .memberId(unknownMember.getId())
                    .isExpired(false)
                    .createdAt(now.minusMinutes(30))
                    .build()))
            .willReturn(List.of()).willReturn(
                List.of(RandomMatch.of(null, null)));

        given(userRepository.findByUsername("takim")).willReturn(Optional.ofNullable(takim));
        given(userRepository.findByUsername("unknown")).willReturn(Optional.ofNullable(unknown));

        //when

        List<RandomMatch> randomMatches = randomMatchService.createRandomMatch(takim.getUsername(),
            randomMatchDto, now);

        //then
        assertThatThrownBy(() ->
            randomMatchService.createRandomMatch(takim.getUsername(), randomMatchDto, now))
            .isInstanceOf(RandomMatchAlreadyExistException.class);
        assertThatThrownBy(() ->
            randomMatchService.createRandomMatch(takim.getUsername(), randomMatchDtoEmptyList, now))
            .isInstanceOf(RandomMatchAlreadyExistException.class);

        assertThatNoException().isThrownBy(() -> randomMatchService.createRandomMatch(
            takim.getUsername(),
            randomMatchStudyDto, now));

        assertThatThrownBy(() ->
            randomMatchService.createRandomMatch(takim.getUsername(),
                randomMatchStudyDto, now))
            .isInstanceOf(RandomMatchAlreadyExistException.class);

        assertThatNoException().isThrownBy(() ->
            randomMatchService.createRandomMatch(unknown.getUsername(),
                randomMatchDto, now));

        assertThatThrownBy(() ->
            randomMatchService.createRandomMatch(unknown.getUsername(),
                randomMatchDto, now)
        )
            .isInstanceOf(RandomMatchAlreadyExistException.class);
    }

}
profile
Fail Fast

0개의 댓글