LostCatBox

TDD-테스트코드 도입기

Word count: 4.3kReading time: 27 min
2024/04/21 Share

왜?

  • 회사 내에서 테스트 코드는 불필요하다는 분위기가 형성되어있었고, SI, SM을 위주로 하다보니, 개발 시간이 우선순위가 높으므로, 유지보수, 안정성은 고려대상에서 제외되었다.
  • 하지만 내가 개발하고 싶은 방향은 유지보수+안정성확보+테스트코드를 통한 걱정없는 배포! 을 위한 개발을 하고싶었다.

TDD란?

  1. RED : 실패하는 테스트를 먼저 작성한다
  2. GREEN : 실패하는 테스트를 성공시킨다.
  3. BLUE : 리펙토링한다.

TDD 특징 및 장점

  • 깨지는 테스트를 먼저 작성 -> 메서드(행동) 및 인터페이스에 대한 생각 강제된다.
    • What/Who
  • 장기적인 관점에서 개발 비용 감소

TDD 도입 고민점들

  • 무의미한 테스트 -> 핵심적인 비즈니스 로직이 아닌 부분들은 구지 할 필요없다. 모든 메서드에 대한 테스트를 짜는게 아닌, 핵심 행동에 해당하는 테스트만 작성해야함

  • 테스트 불가한 코드 -> 책임 객체를 분리해야하는 신호가 될수있다. (@Mock 등을 사용한 테스트 진행하기 전 고민)

    • 불가능한 테스트 코드 예시

    • @Transactional
      public void login(long id) {
              UserEntity userEntity = userRepository.findById(id)
            .orElseThrow (() -> new ResourceNotFoundException("User", id));
      userEntity.setLastLoginAt(LocalTime.now());
      <!--0-->
      
      
      

SOLID 원칙은 좋은 테스트 코드를 짜는것과 비례

테스트 3분류

유닛, 통합, E2E테스트

  • 구글에서는 유닛, 통합, E2E테스트 -> small, medium, large 테스트로 바꿔부름

small, medium, large 테스트 정의

  • Small : 단일 서버, 단일 프로세스, 단일 스레드, 디스크 I/O 사용X, Blocking call 허용 X(80%)
  • Medium : 단일 서버, 멀티 프로세스, 멀티 스레드, h2 같은 테스트 DB 사용가능 (15%)
  • Large : 멀티 서버, End to end 테스트 (5%)

테코 사전개념

  • SUT : 테스트 대상
  • BDD : 행동 주의 개발 -> 스토리 중요 -> given, when, then
  • 상호 작용 테스트: 대상 함수의 구현을 호출하지 않으면서, 그 함수가 어떻게 호출되는지를 검증하는 방법 (verify…) -> 객체에 대한 간섭 증대… -> 객체 지향적이지않아? 비추!**
  • 상태 검증 vs 행위 검증
    • 상태 검증 : 행위에 따른 결과를 테스트함 (isTrue 등등)
    • 행위 검증 : 행위를 실행했는지 테스트함 (verify 등등)
  • 테스트 픽스처(Fixture) : 테스트에 필요한 자원 생성하는것
  • 비욘세 규칙 : 유지하고 싶은 상태가 있으면 전부 테스트로 작성 -> 그게 곧 정책이됩니다.
    • 예시) 5만원이상 금액 유지 -> 5만원 이하일때 fail테스트 작성
  • Testablility : 테스트 가능성(소프트웨어가 테스트 가능한 구조인가?)
  • Test double : 테스트 대역 (=Mock)
    • Dummy : 아무런 동작하지않고, 코드가 정상적으로 돌아가기 위해 전달하는 객체(빈 객체)
    • Fake : Local에서 사용하거나 테스트에서 사용하기위해 가짜 객체, 자체적인 로직이 있다는게 특징
    • Stub : 미리 준비된 값을 출력하는 객체
      • Mokito사용
    • Mock : 메소드 호출을 확인하기 위한 객체, 자가 검증 능력을 갖춤(요즘은 큰 의미로 사용)
    • Spy : 메소드 호출을 전부 기록했다가 나중에 확인하기 위한 객체

의존성과 테스트

의존성

  • Dependency : 어떤 객체가 다른객체의 일부를 사용한다면 의존성을 갖는다.
  • Dependency Injection(DI)(의존성 주입)
    • 단순히 주입하는것
  • Dependency Inversion(DIP)(의존성 역전 원칙) (=화살표 방향을 바꾸는 테크닉)
    • 첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화(정책)에 의존해야한다.
    • 둘째, 추상화(정책)는 세부 사항(구현체)에 의존해서는 안된다. 세부 사항이 추상화에 의존해야한다.
    • 예시
      • (역전 X) Chef -> Beef 는 Chef가 Beef에 의존한다. (Chef가 Beef사용 중)
      • (역전 O) Chef -> Meat<<interface>> <- Beef
        • Chef -> Meat 는 Chef가 Meat에 의존한다.
        • Beef -> Meat 는 Beef가 Meat에 의존한다.
        • 따라서 기존 Beef 입장에서 사용당하다가 이제 Meat를 사용하게 바꼇으므로 의존성 역전이 일어났다.

테스트

의존성 주입과 의존성 역전 예시

  • sut(테스트 대상) 메서드에 숨어있는 의존성(Clock) -> 추상화 및 의존성 역전으로 문제 해결가능
  • UserService에서는 알아서 Spring이 SystemclockHolder를 주입시킴(의존성 주입)

스크린샷 2024-04-22 오후 10.05.52

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserServiceTest {
@Test
public void login_테스트() {
// given
Clock clock = Clock.fixed(Instant.parse("2000-01-01T00:00:00.00Z"), Zoneld.of("UTC"));
User user = new User
UserService userService = new UserService(new TestClockHolder(clock)) ;
// when
userService.login(user);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(946684800000L);
}
}

Testablility

  • 테스트 가능성 : 얼마나 쉽게 input을 변경하고, output을 쉽게 검증할수있는가

빌더 패턴, 엔티티

엔티티

  • 도메인 엔티티(domain) : 소프트웨어에서 어떤 도메인이나 문제를 해결하기 위해 만들어진 모델, 비즈니스 로직을 들고 있고, 식별 가능하며, 일반적으로 생명 주기를 갖는다. (비즈니스 영역)
  • DB 엔티티 : 데이터베이스 분야에서 개체 또는 엔티티라고 하는것. 데이터베이스에 표현하려고 하는 유형, 무형의 객체로써 서로 구별되는 것을 뜻한다. (DB 영역)
  • 영속성 객체(Persistent object) : ORM (Object Relational (database) Mapping) (비즈니스 <-> DB 영역 연결)

기타조언

private 메소드는 테스트하지 않아도 된다.

  • 테스트 하고싶은 코드가 있다면, 설계 미스 -> 테스트하고 싶은 코드를 다른 클래스로 분리 필요

final 메소드를 stub하는 상황을 피해야 한다.

  • 왜냐면 final class란 이 클래스를 대체 할수 없게하겠다는 선언이기때문

DRY < DAMP

  • DRY : 코드 반복하지 않기
  • DAMP : 서술적이고 의미 있는 문구. 테스트할때는 중복되더라도 명시적인게 중요함

논리 로직을 피해라 (+, for, if, while등등)

  • + 등 논리 로직 사용후 휴먼에러로 테스트 의도와 다르게 동작 가능성 존재

실기 1부

Repositorty

  • TestPropertySource활용
  • Sql 을 통한 중복 코드 제거
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

@ExtendWith(SpringExtension.class)
@DataJpaTest(showSql = true)
@TestPropertySource("classpath:test-application.properties")
@Sql("/sql/user-repository-test-data.sql")
public class UserRepositoryTest {

@Autowired
private UserRepository userRepository;

@Test
void findByIdAndStatus_로_과거데이터_찾아올수있다(){

Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.ACTIVE);

//then
assertThat(result.isPresent()).isTrue();
}

@Test
void findByIdAndStatus_로_데이터가_없다면_Optional_empty(){

Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.PENDING);

//then
assertThat(result.isEmpty()).isTrue();
}


@Test
void findByEmailAndStatus_로_과거데이터_찾아올수있다(){
Optional<UserEntity> result = userRepository.findByEmailAndStatus("테스트@naver.com", UserStatus.ACTIVE);

//then
assertThat(result.isPresent()).isTrue();
}

@Test
void findByEmailAndStatus_로_데이터가_없다면_Optional_empty(){

Optional<UserEntity> result = userRepository.findByEmailAndStatus("테스트@naver.com", UserStatus.PENDING);

//then
assertThat(result.isEmpty()).isTrue();
}
}

Service

  • 특징, Clock이나, UUID는 함수내부에 의존성 존재해서 테스트 어려움
  • Send message 또한 Mock을 활용하여 테스트
  • private 메서드는 테스트하고싶지만 현재 단계에서는 무시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@SqlGroup({
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
public class UserServiceTest {

@Autowired
private UserService userService;
@MockBean
private JavaMailSender mailSender;

@Test
void getByEmail은_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
String email = "kok202@naver.com";

// when
UserEntity result = userService.getByEmail(email);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getByEmail은_PENDING_상태인_유저는_찾아올_수_없다() {
// given
String email = "kok303@naver.com";

// when
// then
assertThatThrownBy(() -> {
UserEntity result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void getById는_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
// when
UserEntity result = userService.getById(1);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getById는_PENDING_상태인_유저는_찾아올_수_없다() {
// given
// when
// then
assertThatThrownBy(() -> {
UserEntity result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void userCreateDto_를_이용하여_유저를_생성할_수_있다() {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("kok202@kakao.com")
.address("Gyeongi")
.nickname("kok202-k")
.build();
BDDMockito.doNothing().when(mailSender).send(any(SimpleMailMessage.class));

// when
UserEntity result = userService.create(userCreateDto);

// then
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING);
// assertThat(result.getCertificationCode()).isEqualTo("T.T"); // FIXME
}

@Test
void userUpdateDto_를_이용하여_유저를_수정할_수_있다() {
// given
UserUpdateDto userUpdateDto = UserUpdateDto.builder()
.address("Incheon")
.nickname("kok202-n")
.build();

// when
userService.update(1, userUpdateDto);

// then
UserEntity userEntity = userService.getById(1);
assertThat(userEntity.getId()).isNotNull();
assertThat(userEntity.getAddress()).isEqualTo("Incheon");
assertThat(userEntity.getNickname()).isEqualTo("kok202-n");
}

@Test
void user를_로그인_시키면_마지막_로그인_시간이_변경된다() {
// given
// when
userService.login(1);

// then
UserEntity userEntity = userService.getById(1);
assertThat(userEntity.getLastLoginAt()).isGreaterThan(0L);
// assertThat(result.getLastLoginAt()).isEqualTo("T.T"); // FIXME
}

@Test
void PENDING_상태의_사용자는_인증_코드로_ACTIVE_시킬_수_있다() {
// given
// when
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab");

// then
UserEntity userEntity = userService.getById(2);
assertThat(userEntity.getStatus()).isEqualTo(UserStatus.ACTIVE);
}

@Test
void PENDING_상태의_사용자는_잘못된_인증_코드를_받으면_에러를_던진다() {
// given
// when
// then
assertThatThrownBy(() -> {
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac");
}).isInstanceOf(CertificationCodeNotMatchedException.class);
}

}

Controller

  • MockMvc사용
  • 자세히 적을수록, 명세서와 같은 역할을 할수있다.(명확해짐)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
@SqlGroup({
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
public class UserControllerTest {

@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
private final ObjectMapper objectMapper = new ObjectMapper();

@Test
void 사용자는_특정_유저의_정보를_개인정보는_소거된채_전달_받을_수_있다() throws Exception {
// given
// when
// then
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("kok202@naver.com"))
.andExpect(jsonPath("$.nickname").value("kok202"))
.andExpect(jsonPath("$.address").doesNotExist())
.andExpect(jsonPath("$.status").value("ACTIVE"));
}

@Test
void 사용자는_존재하지_않는_유저의_아이디로_api_호출할_경우_404_응답을_받는다() throws Exception {
// given
// when
// then
mockMvc.perform(get("/api/users/123456789"))
.andExpect(status().isNotFound())
.andExpect(content().string("Users에서 ID 123456789를 찾을 수 없습니다."));
}

@Test
void 사용자는_인증_코드로_계정을_활성화_시킬_수_있다() throws Exception {
// given
// when
// then
mockMvc.perform(
get("/api/users/2/verify")
.queryParam("certificationCode", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab"))
.andExpect(status().isFound());
UserEntity userEntity = userRepository.findById(1L).get();
assertThat(userEntity.getStatus()).isEqualTo(UserStatus.ACTIVE);
}

@Test
void 사용자는_인증_코드가_일치하지_않을_경우_권한_없음_에러를_내려준다() throws Exception {
// given
// when
// then
mockMvc.perform(
get("/api/users/2/verify")
.queryParam("certificationCode", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac"))
.andExpect(status().isForbidden());
}

@Test
void 사용자는_내_정보를_불러올_때_개인정보인_주소도_갖고_올_수_있다() throws Exception {
// given
// when
// then
mockMvc.perform(
get("/api/users/me")
.header("EMAIL", "kok202@naver.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("kok202@naver.com"))
.andExpect(jsonPath("$.nickname").value("kok202"))
.andExpect(jsonPath("$.address").value("Seoul"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}

@Test
void 사용자는_내_정보를_수정할_수_있다() throws Exception {
// given
UserUpdateDto userUpdateDto = UserUpdateDto.builder()
.nickname("kok202-n")
.address("Pangyo")
.build();

// when
// then
mockMvc.perform(
put("/api/users/me")
.header("EMAIL", "kok202@naver.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userUpdateDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("kok202@naver.com"))
.andExpect(jsonPath("$.nickname").value("kok202-n"))
.andExpect(jsonPath("$.address").value("Pangyo"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
@SqlGroup({
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
public class UserCreateControllerTest {

@Autowired
private MockMvc mockMvc;
@MockBean
private JavaMailSender mailSender;
private final ObjectMapper objectMapper = new ObjectMapper();

@Test
void 사용자는_회원_가입을_할_수있고_회원가입된_사용자는_PENDING_상태이다() throws Exception {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("kok202@kakao.com")
.nickname("kok202")
.address("Pangyo")
.build();
BDDMockito.doNothing().when(mailSender).send(any(SimpleMailMessage.class));

// when
// then
mockMvc.perform(
post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userCreateDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").value("kok202@kakao.com"))
.andExpect(jsonPath("$.nickname").value("kok202"))
.andExpect(jsonPath("$.status").value("PENDING"));
}

}

개선해보기

기존 문제점

  • mokito, H2 를 써야만 하는 강결합된 테스트 -> 설계가 잘못되어있을 확률 높음
  • 레이어드 아키텍처
    • Controller, service, repository
    • 해당 아키텍처는 DB 주도 설계를 유도한다.
    • fat service 될 확률 높음
  • service 와 domain에 대해서 테스트하는건 최소 조건임 반드시 지켜야함

개선된 아키텍처에 대한 목표

  • Domain(lombok을 제외한 에너테이션없음) 객체(순수java코드)와, 영속성 객체의 분리
    • domain은 계층간 연결된 의존성 없음
  • Testability 증가.

스크린샷 2024-05-05 오후 7.26.26

  • 의존성 역전을 통한 테스트능력 증대.
    • 추상화에 의존하며, 구현체를 따로 개발

스크린샷 2024-05-05 오후 7.36.43

  • 예시
    • 의존성 역전 방식을 활용한다면, 의존도 낮추고, 테스트 용이성 높아짐.

스크린샷 2024-05-05 오후 7.38.51

스크린샷 2024-05-06 오후 3.37.35

  • Domain/layer 구조로 폴더 트리 -> MSA 구조로의 전환 유리 및 캡슐화
    • user
      • controller
      • service
      • repository
    • post
      • controller
      • service
      • repository
  • jpa엔티티와 도메인 모델과의 분리
    • jpa 엔티티(DB CRUD) 객체로만 사용
      • UserEntity
    • 도메인 모델(=비즈니스 모델)
      • User
      • 각각의 비즈니스 로직들을 포함함
  • CQRS : 명령과 질의로 로직 분리
    • 명령(Command)
      • 상태를 바꾸는 메소드
      • void 타입 반환이여야한다
    • 질의(Query)
      • 상태를 물어보는 메소드
      • 질의 메소드는 상태를 변경해선 안된다.
    • ![스크린샷 2024-05-06 오후 2.57.27](/Users/lostcatbox/Library/Application Support/typora-user-images/스크린샷 2024-05-06 오후 2.57.27.png)

폴더 트리 변경, 외부 연동 재정의 및 분리

  • Domain 기준으로 layer(controller, service, repository 재배치)

    • 외부 연동같은 경우, 원래는 service와 infrastructure간에 강결합 이였음

    • service에서 필요한 infrastructure들은 모두 인터페이스화 하여 service.port 패키지에 정의 -> 결국 infrastructure에 대한 의존성 낮춤, testability 높임

    • 기존 UserService에 섞여있던 mailsender 해주는 역할 -> CartificationSerivce로 책임분리하여, MailSender에 의존하며, send 기능 제공 -> MailSenderImpI에서는 MailSender의 기능 구체화

    • MailSenderImpl

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      @Component
      @RequiredArgsConstructor
      public class MailSenderImpl implements MailSender {
      private final JavaMailSender javaMailSender;

      @Override
      public void send(String email, String title, String content) {
      SimpleMailMessage message = new SimpleMailMessage();
      message.setTo(email);
      message.setSubject(title);
      message.setText(content);
      javaMailSender.send(message);
      }
      }
    • MailSender

      1
      2
      3
      public interface MailSender {
      void send(String email, String title, String content);
      }
    • CertificationService

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Service
      @RequiredArgsConstructor
      public class CertificationService {
      private final MailSender mailSender;

      public void send(String email, long userId, String certificationCode) {
      String certificationUrl = generateCertificationUrl(userId, certificationCode);
      String title = "Please certify your email address";
      String text = "Please click the following link to certify your email address: " + certificationUrl;
      mailSender.send(email, title, text);
      }
      private String generateCertificationUrl(long userId, String certificationCode) {
      return "http://localhost:8080/api/users/" + userId + "/verify?certificationCode=" + certificationCode;
      }
      }
  • 도메인 기준으로 폴더트리 재배치 및 책임분리
    스크린샷 2024-05-06 오후 4.30.38

이에 따른 CertificationServiceTest시 MailSender 의존성 역전 덕분에 FakeMailSender 사용하여 테스트가능

  • CertificationServiceTest

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class CertificationServiceTest {
    @Test
    void send_이메일_전송_성공() {
    FakeMailSender fakeMailSender = new FakeMailSender();
    CertificationService certificationService = new CertificationService(fakeMailSender);

    certificationService.send("kok202@naver.com", 1, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");

    Assertions.assertThat(fakeMailSender.email).isEqualTo("kok202@naver.com");
    Assertions.assertThat(fakeMailSender.title).isEqualTo("Please certify your email address");
    Assertions.assertThat(fakeMailSender.content).isEqualTo("http://localhost:8080/api/users/" + 1 + "/verify?certificationCode=" + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");

    }

    }
  • FakeMailSender

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class FakeMailSender implements MailSender {
    public String email;
    public String title;
    public String content;

    @Override
    public void send(String email, String title, String content) {
    this.email = email;
    this.title = title;
    this.content = content;
    }
    }

도메인과 영속성 객체 구분

  • 도메인(domain)과 영속성 객체(Entity) 구분하여, 비즈니스 로직을 Service 와 Domain에서만 갖게끔. 캡슐화.

실제 코드에서 Domain과 Entity 분리하는 예시

UserService

  • 변경 전
1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public UserEntity create(UserCreate userCreate) {
UserEntity userEntity = new UserEntity();
userEntity.setEmail(userCreate.getEmail());
userEntity.setNickname(userCreate.getNickname());
userEntity.setAddress(userCreate.getAddress());
userEntity.setStatus(UserStatus.PENDING);
userEntity.setCertificationCode(UUID.randomUUID().toString());
userEntity = userRepository.save(userEntity);
certificationService.send(userCreate.getEmail(), userEntity.getId(), userEntity.getCertificationCode());
return userEntity;
}
  • 변경후
1
2
3
4
5
6
7
@Transactional
public User create(UserCreate userCreate) {
User user = User.from(userCreate);
userRepository.save(user);
certificationService.send(userCreate.getEmail(), user.getId(), user.getCertificationCode());
return user;
}
1
2
3
4
5
6
7
8
9
public static User from(UserCreate userCreate){
return User.builder()
.email(userCreate.getEmail())
.nickname(userCreate.getNickname())
.address(userCreate.getAddress())
.status(UserStatus.PENDING)
.certificationCode(UUID.randomUUID().toString())
.build();
}

User 와 UserEntity를 나눴고, User 클래스에 비즈니스 로직이 포함되었고, UserService에서는 제거되었다. 객체지향적

  • User
    • 불변 객체의 장점(안정성, 예측성 높음) 때문에 -> user 필드들이 final로 변경
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Getter
@Setter
public class User {
private final Long id;
private final String email;
private final String nickname;
private final String address;
private final String certificationCode;
private final UserStatus status;
private final Long lastLoginAt;

@Builder
public User(Long id, String email, String nickname, String address, String certificationCode, UserStatus status, Long lastLoginAt) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.address = address;
this.certificationCode = certificationCode;
this.status = status;
this.lastLoginAt = lastLoginAt;
}

public static User from(UserCreate userCreate){
return User.builder()
.email(userCreate.getEmail())
.nickname(userCreate.getNickname())
.address(userCreate.getAddress())
.status(UserStatus.PENDING)
.certificationCode(UUID.randomUUID().toString())
.build();
}

public User update(UserUpdate userUpdate) {
return User.builder()
.id(id)
.email(email)
.nickname(userUpdate.getNickname())
.address(userUpdate.getAddress())
.certificationCode(certificationCode)
.status(status)
.lastLoginAt(lastLoginAt)
.build();
}

public User login() {
return User.builder()
.id(id)
.email(email)
.nickname(nickname)
.address(address)
.certificationCode(certificationCode)
.status(status)
.lastLoginAt(Clock.systemUTC().millis())
.build();
}

public User verifyEmail(String certificationCode) {
if (!certificationCode.equals(certificationCode)) {
throw new CertificationCodeNotMatchedException();
}
return User.builder()
.id(id)
.email(email)
.nickname(nickname)
.address(address)
.certificationCode(certificationCode)
.status(UserStatus.ACTIVE)
.lastLoginAt(Clock.systemUTC().millis())
.build();
}
}
  • UserService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;
private final CertificationService certificationService;

public User getByEmail(String email) {
return userRepository.findByEmailAndStatus(email, UserStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Users", email));
}

public User getById(long id) {
return userRepository.findByIdAndStatus(id, UserStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Users", id));
}

@Transactional
public User create(UserCreate userCreate) {
User user = User.from(userCreate);
userRepository.save(user);
certificationService.send(userCreate.getEmail(), user.getId(), user.getCertificationCode());
return user;
}

@Transactional
public User update(long id, UserUpdate userUpdate) {
User user = getById(id);
user.update(userUpdate);
userRepository.save(user);
return user;
}

@Transactional
public void login(long id) {
User user = userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Users", id));
User loginedUser = user.login();
userRepository.save(loginedUser);
}

@Transactional
public void verifyEmail(long id, String certificationCode) {
User user = userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Users", id));
User verifiedUser = user.verifyEmail(certificationCode);
userRepository.save(verifiedUser);
}
}

User가 다양한 비즈니스 로직을 갖게되므로써, 책임이 늘어났고, 테스트코드도 따라서 대응하여 개발되어야한다

의존성 역전을 통한 적절한 테스트 코드

기존코드1

  • Login() 성공시 현지 시근 lastLoginAt값으로 업데이트 -> Clock와 강결합 중
1
2
3
4
5
6
7
8
9
10
11
public User login() {
return User.builder()
.id(id)
.email(email)
.nickname(nickname)
.address(address)
.certificationCode(certificationCode)
.status(status)
.lastLoginAt(Clock.systemUTC().millis())
.build();
}

개선코드 1

  • Service에서 적절한 ClockHolder 구현체 선택해서 활용 및 User.from메서드 호출시 던저줌
  • Service, User 메서드 모두 추상화된 ClockHolder 인터페이스에 의존 -> 테스트도 쉬워짐
1
2
3
4
5
6
7
8
9
10
11
public User login(ClockHolder clockHolder) {
return User.builder()
.id(id)
.email(email)
.nickname(nickname)
.address(address)
.certificationCode(certificationCode)
.status(status)
.lastLoginAt(clockHolder.millis())
.build();
}

ClockHolder, TestClockHolder, SystemClockHolder

  • ClockHolder
    1
    2
    3
    4

    public interface ClockHolder {
    long millis();
    }
  • TestClcokHolder

    1
    2
    3
    4
    5
    6
    7
    8
    @RequiredArgsConstructor
    public class TestClockHolder implements ClockHolder {
    private final long testTime;
    @Override
    public long millis() {
    return testTime;
    }
    }
  • SystemClockHolder

    1
    2
    3
    4
    5
    6
    7
    8
    public class SystemClockHolder implements ClockHolder {

    @Override
    public long millis() {
    return Clock.systemUTC().millis();
    };

    }

개선점 1 테스트코드예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void 로그인을_할_수_있고_로그인시_마지막_로그인_시간이_변경된다() {
// given
User user = User.builder()
.id(1L)
.email("kok202@kakao.com")
.nickname("kok202")
.address("Seoul")
.status(UserStatus.ACTIVE)
.lastLoginAt(100L)
.certificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.build();

// when
user = user.login(new TestClockHolder(1678530673958L));

// then
assertThat(user.getLastLoginAt()).isEqualTo(1678530673958L);
}

기존코드 2

  • User 생성시 certificationCode 생성 로직 -> UUID와 강결합 중
1
2
3
4
5
6
7
8
9
public static User from(UserCreate userCreate){
return User.builder()
.email(userCreate.getEmail())
.nickname(userCreate.getNickname())
.address(userCreate.getAddress())
.status(UserStatus.PENDING)
.certificationCode(UUID.randomUUID().toString())
.build();
}

개선 코드 2

  • Service에서 적절한 UuidHolder 구현체 선택해서 활용 및 User.from메서드 호출시 던저줌
  • Service, User 메서드 모두 추상화된 UuidHolder 인터페이스에 의존 -> 테스트도 쉬워짐
1
2
3
4
5
6
7
8
9
public static User from(UserCreate userCreate, UuidHolder holder){
return User.builder()
.email(userCreate.getEmail())
.nickname(userCreate.getNickname())
.address(userCreate.getAddress())
.status(UserStatus.PENDING)
.certificationCode(holder.random())
.build();
}

Uuid 추상화

  • UuidHolder
1
2
3
public interface UuidHolder {
String random();
}
  • SystemUuidHolder (운영 서버에서 선택되는 구현체)
1
2
3
4
5
6
public class SystemUuidHolder implements com.example.demo.common.service.port.UuidHolder {
@Override
public String random() {
return UUID.randomUUID().toString();
}
}
  • TestUuidHolder(테스트시 사용)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @RequiredArgsConstructor
    public class TestUuidHolder implements UuidHolder {

    private final String randomString;
    @Override
    public String random() {
    return randomString;
    }
    }

개선점 2 테스트코드

  • User.from() 메서드에서 현재 시간, Uuid에 대한 테스트 가능해짐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public class UserTest {

@Test
public void User_는_UserCreate_로_새로운_객체생성가능하(){
UserCreate userCreate = UserCreate.builder()
.email("kok202@naver.com")
.nickname("kok202")
.address("Seoul")
.build();
User user = User.from(userCreate, new TestUuidHolder("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));

Assertions.assertThat(user.getEmail()).isEqualTo(userCreate.getEmail());
Assertions.assertThat(user.getAddress()).isEqualTo(userCreate.getAddress());
Assertions.assertThat(user.getNickname()).isEqualTo(userCreate.getNickname());
Assertions.assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
Assertions.assertThat(user.getCertificationCode()).isEqualTo("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");

}
}

Service 코드 테스트 개선

의존성 역전을 통한 중형 -> 소형 테스트로 변경 가능

기존 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@SqlGroup({
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
public class UserServiceTest {

@Autowired
private UserService userService;
@MockBean
private JavaMailSender mailSender;

@Test
void getByEmail은_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
String email = "kok202@naver.com";

// when
User result = userService.getByEmail(email);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getByEmail은_PENDING_상태인_유저는_찾아올_수_없다() {
// given
String email = "kok303@naver.com";

// when
// then
assertThatThrownBy(() -> {
User result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void getById는_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
// when
User result = userService.getById(1);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getById는_PENDING_상태인_유저는_찾아올_수_없다() {
// given
// when
// then
assertThatThrownBy(() -> {
User result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void userCreateDto_를_이용하여_유저를_생성할_수_있다() {
// given
UserCreate userCreate = UserCreate.builder()
.email("kok202@kakao.com")
.address("Gyeongi")
.nickname("kok202-k")
.build();
BDDMockito.doNothing().when(mailSender).send(any(SimpleMailMessage.class));

// when
User result = userService.create(userCreate);

// then
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING);
// assertThat(result.getCertificationCode()).isEqualTo("T.T"); // FIXME
}

@Test
void userUpdateDto_를_이용하여_유저를_수정할_수_있다() {
// given
UserUpdate userUpdate = UserUpdate.builder()
.address("Incheon")
.nickname("kok202-n")
.build();

// when
userService.update(1, userUpdate);

// then
User user = userService.getById(1);
assertThat(user.getId()).isNotNull();
assertThat(user.getAddress()).isEqualTo("Incheon");
assertThat(user.getNickname()).isEqualTo("kok202-n");
}

@Test
void user를_로그인_시키면_마지막_로그인_시간이_변경된다() {
// given
// when
userService.login(1);

// then
User user = userService.getById(1);
assertThat(user.getLastLoginAt()).isGreaterThan(0L);
// assertThat(result.getLastLoginAt()).isEqualTo("T.T"); // FIXME
}

@Test
void PENDING_상태의_사용자는_인증_코드로_ACTIVE_시킬_수_있다() {
// given
// when
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab");

// then
User user = userService.getById(2);
assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE);
}

@Test
void PENDING_상태의_사용자는_잘못된_인증_코드를_받으면_에러를_던진다() {
// given
// when
// then
assertThatThrownBy(() -> {
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac");
}).isInstanceOf(CertificationCodeNotMatchedException.class);
}

}

개선 코드

  • SpringBootTest 에너테이션 및 관련된 에너테이션없어짐 -> 속도 향상
  • Sub로 FakeUserRepository추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
public class UserServiceTest {
private UserService userService;

@BeforeEach
void init(){
FackUserRepository fakeUserRepository = new FackUserRepository();
userService = UserService.builder()
.certificationService(new CertificationService(new FakeMailSender()))
.userRepository(fakeUserRepository)
.clockHolder(new TestClockHolder(1234))
.uuidHolder(new TestUuidHolder("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"))
.build();

fakeUserRepository.save(User.builder()
.id(1L)
.email("kok202@naver.com")
.nickname("kok202")
.address("Seoul")
.certificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.status(UserStatus.ACTIVE)
.lastLoginAt(0L)
.build());
fakeUserRepository.save(User.builder()
.id(2L)
.email("kok303@naver.com")
.nickname("kok303")
.address("Seoul")
.certificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab")
.status(UserStatus.PENDING)
.lastLoginAt(0L)
.build());
}


@Test
void getByEmail은_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
String email = "kok202@naver.com";

// when
User result = userService.getByEmail(email);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getByEmail은_PENDING_상태인_유저는_찾아올_수_없다() {
// given
String email = "kok303@naver.com";

// when
// then
assertThatThrownBy(() -> {
User result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void getById는_ACTIVE_상태인_유저를_찾아올_수_있다() {
// given
// when
User result = userService.getById(1);

// then
assertThat(result.getNickname()).isEqualTo("kok202");
}

@Test
void getById는_PENDING_상태인_유저는_찾아올_수_없다() {
// given
// when
// then
assertThatThrownBy(() -> {
User result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}

@Test
void userCreateDto_를_이용하여_유저를_생성할_수_있다() {
// given
UserCreate userCreate = UserCreate.builder()
.email("kok202@kakao.com")
.address("Gyeongi")
.nickname("kok202-k")
.build();

// when
User result = userService.create(userCreate);

// then
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING);
assertThat(result.getCertificationCode()).isEqualTo("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); // FIXME
}

@Test
void userUpdateDto_를_이용하여_유저를_수정할_수_있다() {
// given
UserUpdate userUpdate = UserUpdate.builder()
.address("Incheon")
.nickname("kok202-n")
.build();

// when
userService.update(1, userUpdate);

// then
User user = userService.getById(1);
assertThat(user.getId()).isNotNull();
assertThat(user.getAddress()).isEqualTo("Incheon");
assertThat(user.getNickname()).isEqualTo("kok202-n");
}

@Test
void user를_로그인_시키면_마지막_로그인_시간이_변경된다() {
// given
// when
userService.login(1);

// then
User user = userService.getById(1);
assertThat(user.getLastLoginAt()).isGreaterThan(0L);
// assertThat(result.getLastLoginAt()).isEqualTo("T.T"); // FIXME
}

@Test
void PENDING_상태의_사용자는_인증_코드로_ACTIVE_시킬_수_있다() {
// given
// when
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab");

// then
User user = userService.getById(2);
assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE);
}

@Test
void PENDING_상태의_사용자는_잘못된_인증_코드를_받으면_에러를_던진다() {
// given
// when
// then
assertThatThrownBy(() -> {
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac");
}).isInstanceOf(CertificationCodeNotMatchedException.class);
}




}
  • FackUserRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class FackUserRepository implements UserRepository {
private AtomicLong idx = new AtomicLong(0);
private final List<User> data = Collections.synchronizedList(new ArrayList<>());
@Override
public Optional<User> findByIdAndStatus(long id, UserStatus userStatus) {
return data.stream().filter(com ->com.getId().equals(id)&&com.getStatus().equals(userStatus)).findAny();

}

@Override
public Optional<User> findByEmailAndStatus(String email, UserStatus userStatus) {
return data.stream().filter(com ->com.getEmail().equals(email)&&com.getStatus().equals(userStatus)).findAny();
}

@Override
public Optional<User> findById(long id) {
return data.stream().filter(com ->com.getId().equals(id)).findAny();
}

@Override
public User save(User user) {
if(user.getId()==null||user.getId()==0){

User savedUser = User.builder()
.id(idx.incrementAndGet())
.email(user.getEmail())
.nickname(user.getNickname())
.address(user.getAddress())
.certificationCode(user.getCertificationCode())
.status(user.getStatus())
.lastLoginAt(user.getLastLoginAt())
.build();
data.add(savedUser);
return savedUser;
}else{
data.removeIf(item -> item.getId().equals(user.getId()));
data.add(user);
return user;
}

}
}
CATALOG
  1. 1. 왜?
  2. 2. TDD란?
    1. 2.1. TDD 특징 및 장점
    2. 2.2. TDD 도입 고민점들
  3. 3. SOLID 원칙은 좋은 테스트 코드를 짜는것과 비례
  4. 4. 테스트 3분류
    1. 4.1. 유닛, 통합, E2E테스트
    2. 4.2. small, medium, large 테스트 정의
  5. 5. 테코 사전개념
  6. 6. 의존성과 테스트
    1. 6.1. 의존성
    2. 6.2. 테스트
      1. 6.2.1. 의존성 주입과 의존성 역전 예시
    3. 6.3. Testablility
    4. 6.4. 빌더 패턴, 엔티티
      1. 6.4.1. 엔티티
    5. 6.5. 기타조언
      1. 6.5.1. private 메소드는 테스트하지 않아도 된다.
      2. 6.5.2. final 메소드를 stub하는 상황을 피해야 한다.
      3. 6.5.3. DRY < DAMP
      4. 6.5.4. 논리 로직을 피해라 (+, for, if, while등등)
  7. 7. 실기 1부
    1. 7.1. Repositorty
    2. 7.2. Service
    3. 7.3. Controller
  8. 8. 개선해보기
    1. 8.1. 기존 문제점
    2. 8.2. 개선된 아키텍처에 대한 목표
    3. 8.3.
    4. 8.4. 폴더 트리 변경, 외부 연동 재정의 및 분리
      1. 8.4.1. 이에 따른 CertificationServiceTest시 MailSender 의존성 역전 덕분에 FakeMailSender 사용하여 테스트가능
    5. 8.5. 도메인과 영속성 객체 구분
    6. 8.6. 실제 코드에서 Domain과 Entity 분리하는 예시
      1. 8.6.1. UserService
      2. 8.6.2. User 와 UserEntity를 나눴고, User 클래스에 비즈니스 로직이 포함되었고, UserService에서는 제거되었다. 객체지향적
      3. 8.6.3. User가 다양한 비즈니스 로직을 갖게되므로써, 책임이 늘어났고, 테스트코드도 따라서 대응하여 개발되어야한다
    7. 8.7. 의존성 역전을 통한 적절한 테스트 코드
      1. 8.7.1. 기존코드1
      2. 8.7.2. 개선코드 1
      3. 8.7.3. ClockHolder, TestClockHolder, SystemClockHolder
      4. 8.7.4. 개선점 1 테스트코드예시
      5. 8.7.5. 기존코드 2
      6. 8.7.6. 개선 코드 2
      7. 8.7.7. Uuid 추상화
      8. 8.7.8. 개선점 2 테스트코드
  9. 9. Service 코드 테스트 개선
    1. 9.1. 의존성 역전을 통한 중형 -> 소형 테스트로 변경 가능
      1. 9.1.1. 기존 코드
      2. 9.1.2. 개선 코드