왜?
- 테스트 코드는 불필요하다는 분위기가 형성되어있었고, SI, SM을 위주로 하다보니, 개발 시간이 우선순위가 높으므로, 유지보수, 안정성은 고려대상에서 제외되었다.
- 하지만 내가 개발하고 싶은 방향은 유지보수+안정성확보+테스트코드를 통한 걱정없는 배포! 을 위한 개발을 하고싶었다.
TDD란?
- RED : 실패하는 테스트를 먼저 작성한다
- GREEN : 실패하는 테스트를 성공시킨다.
- BLUE : 리펙토링한다.
TDD 특징 및 장점
- 깨지는 테스트를 먼저 작성 -> 메서드(행동) 및 인터페이스에 대한 생각 강제된다.
- What/Who
- 장기적인 관점에서 개발 비용 감소
TDD 도입 고민점들
무의미한 테스트 -> 핵심적인 비즈니스 로직이 아닌 부분들은 구지 할 필요없다. 모든 메서드에 대한 테스트를 짜는게 아닌, 핵심 행동에 해당하는 테스트만 작성해야함
테스트 불가한 코드 -> 책임 객체를 분리해야하는 신호가 될수있다. (@Mock 등을 사용한 테스트 진행하기 전 고민)
불가능한 테스트 코드 예시
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를 주입시킴(의존성 주입)
1 | class UserServiceTest { |
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 |
|
Service
- 특징, Clock이나, UUID는 함수내부에 의존성 존재해서 테스트 어려움
- Send message 또한 Mock을 활용하여 테스트
- private 메서드는 테스트하고싶지만 현재 단계에서는 무시
1 |
|
Controller
- MockMvc사용
- 자세히 적을수록, 명세서와 같은 역할을 할수있다.(명확해짐)
1 |
|
1 |
|
개선해보기
기존 문제점
- mokito, H2 를 써야만 하는 강결합된 테스트 -> 설계가 잘못되어있을 확률 높음
- 레이어드 아키텍처
- Controller, service, repository
- 해당 아키텍처는 DB 주도 설계를 유도한다.
- fat service 될 확률 높음
- service 와 domain에 대해서 테스트하는건 최소 조건임 반드시 지켜야함
개선된 아키텍처에 대한 목표
- Domain(lombok을 제외한 에너테이션없음) 객체(순수java코드)와, 영속성 객체의 분리
- domain은 계층간 연결된 의존성 없음
- Testability 증가.
- 의존성 역전을 통한 테스트능력 증대.
- 추상화에 의존하며, 구현체를 따로 개발
- 예시
- 의존성 역전 방식을 활용한다면, 의존도 낮추고, 테스트 용이성 높아짐.
- Domain/layer 구조로 폴더 트리 -> MSA 구조로의 전환 유리 및 캡슐화
- user
- controller
- service
- repository
- post
- controller
- service
- repository
- user
- jpa엔티티와 도메인 모델과의 분리
- jpa 엔티티(DB CRUD) 객체로만 사용
- UserEntity
- 도메인 모델(=비즈니스 모델)
- User
- 각각의 비즈니스 로직들을 포함함
- jpa 엔티티(DB CRUD) 객체로만 사용
- 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)
- 명령(Command)
폴더 트리 변경, 외부 연동 재정의 및 분리
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
public class MailSenderImpl implements MailSender {
private final JavaMailSender javaMailSender;
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
3public 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
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;
}
}
- 도메인 기준으로 폴더트리 재배치 및 책임분리
이에 따른 CertificationServiceTest시 MailSender 의존성 역전 덕분에 FakeMailSender 사용하여 테스트가능
CertificationServiceTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class CertificationServiceTest {
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
12public class FakeMailSender implements MailSender {
public String email;
public String title;
public String content;
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 |
|
- 변경후
1 |
|
1 | public static User from(UserCreate userCreate){ |
User 와 UserEntity를 나눴고, User 클래스에 비즈니스 로직이 포함되었고, UserService에서는 제거되었다. 객체지향적
- User
- 불변 객체의 장점(안정성, 예측성 높음) 때문에 -> user 필드들이 final로 변경
1 |
|
UserEntity
- 순수 영속성 클래스
- from(), toModel()을 메서드를 정의하여, domain layer에서는 persistence 객체들을 건들수없음. 안전성 확보
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
"users") (name =
public class UserEntity {
(strategy = GenerationType.IDENTITY)
private Long id;
"email") (name =
private String email;
"nickname") (name =
private String nickname;
"address") (name =
private String address;
"certification_code") (name =
private String certificationCode;
"status") (name =
(EnumType.STRING)
private UserStatus status;
"last_login_at") (name =
private Long lastLoginAt;
public static UserEntity from(User user) {
UserEntity userEntity = new UserEntity();
userEntity.id = user.getId();
userEntity.email = user.getEmail();
userEntity.nickname = user.getNickname();
userEntity.address = user.getAddress();
userEntity.certificationCode = user.getCertificationCode();
userEntity.status = user.getStatus();
userEntity.lastLoginAt = user.getLastLoginAt();
return userEntity;
}
public User toModel() {
return User.builder()
.id(id)
.email(email)
.nickname(nickname)
.address(address)
.certificationCode(certificationCode)
.status(status)
.lastLoginAt(lastLoginAt)
.build();
}
}
- UserService
1 |
|
- UserRepository
User가 다양한 비즈니스 로직을 갖게되므로써, 책임이 늘어났고, 테스트코드도 따라서 대응하여 개발되어야한다
의존성 역전을 통한 적절한 테스트 코드
기존코드1
- Login() 성공시 현지 시근 lastLoginAt값으로 업데이트 -> Clock와 강결합 중
1 | public User login() { |
개선코드 1
- Service에서 적절한 ClockHolder 구현체 선택해서 활용 및 User.from메서드 호출시 던저줌
- Service, User 메서드 모두 추상화된 ClockHolder 인터페이스에 의존 -> 테스트도 쉬워짐
1 | public User login(ClockHolder clockHolder) { |
ClockHolder, TestClockHolder, SystemClockHolder
- ClockHolder
1
2
3
4
public interface ClockHolder {
long millis();
}
TestClcokHolder
1
2
3
4
5
6
7
8
public class TestClockHolder implements ClockHolder {
private final long testTime;
public long millis() {
return testTime;
}
}SystemClockHolder
1
2
3
4
5
6
7
8public class SystemClockHolder implements ClockHolder {
public long millis() {
return Clock.systemUTC().millis();
};
}
개선점 1 테스트코드예시
1 |
|
기존코드 2
- User 생성시 certificationCode 생성 로직 -> UUID와 강결합 중
1 | public static User from(UserCreate userCreate){ |
개선 코드 2
- Service에서 적절한 UuidHolder 구현체 선택해서 활용 및 User.from메서드 호출시 던저줌
- Service, User 메서드 모두 추상화된 UuidHolder 인터페이스에 의존 -> 테스트도 쉬워짐
1 | public static User from(UserCreate userCreate, UuidHolder holder){ |
Uuid 추상화
- UuidHolder
1 | public interface UuidHolder { |
- SystemUuidHolder (운영 서버에서 선택되는 구현체)
1 | public class SystemUuidHolder implements com.example.demo.common.service.port.UuidHolder { |
TestUuidHolder(테스트시 사용)
1
2
3
4
5
6
7
8
9
public class TestUuidHolder implements UuidHolder {
private final String randomString;
public String random() {
return randomString;
}
}
개선점 2 테스트코드
- User.from() 메서드에서 현재 시간, Uuid에 대한 테스트 가능해짐
1 |
|
Service 코드 테스트 개선
의존성 역전을 통한 중형 -> 소형 테스트로 변경 가능
기존 코드
1 |
|
개선 코드
- SpringBootTest 에너테이션 및 관련된 에너테이션없어짐 -> 속도 향상
- Sub로 FakeUserRepository추가
1 | public class UserServiceTest { |
- FackUserRepository
1 | public class FackUserRepository implements UserRepository { |
개선된 컨트롤러 테스트
- 테스트 코드 작성전 -> Service들은 추상화되어있지않음으로. 추진해야 테스트짜기 편하다.
- FakeService 들을 만들기에는 아주 힘들다.
- Service를 Fake로 할려면 결국에 모든 Service의 모든 로직에 대해서 다시 검토하여 Fake에 담아내야한다. -> 유지보수성이 매우 낮아진다.
- 따라서, TestContainer 클래스를 만들고, r,s,c 구조를 모두 빈처럼 가진 TestContainer를 사용하고, 테스트내부에서는
new TestContainer()
로 테스트 짜면 편하다- FakeService를 만들지 않고, ServiceImpl를 그대로 활용할수있으며, 나머지 의존성은 Fake로 심어주면된다.
UserControllerTest
1 |
|
TestContainer
1 | public class TestContainer { |
참고자료
- 참조할만한 폴더 트리
- controller
- domain
- model
- service
- 비즈니스 로직 등등
- 나머지로 나눳다.