Notice
Recent Posts
Link
Tags
- springboot
- jpa
- java
- Docker
- string
- DI
- 문자열
- session
- PYTHON
- join
- 스프링부트
- spring boot
- @transactional
- 1차원 배열
- Git
- SSL
- spring
- 프로그래머스
- 스프링
- spring security 6
- select
- 데이터베이스
- spring mvc
- AWS
- mysql
- Django
- ORM
- 자바
- sql
- nginx
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Archives
개발하는 자몽
Java/Spring 테스트 - 2 본문
지난 포스팅에 이어서 작성합니다.
2024.10.02 - [Java] - Java/Spring 테스트 - 1
의존성
의존성
- 컴퓨터 공학에서 말하는 의존성(Dependency)은 결합(Coupling)과 같은 의미로, 다른 객체의 함수를 사용하는 상태를 말함 ⇒ A는 B를 사용하기만 해도 의존한다고 할 수 있음
- 의존성을 약하게 만드는 기술 중 하나가 의존성 주입
- 필요한 값을 `new`해서 직접 인스턴스화하는 것이 아닌 외부에서 넣어주는 것
- `new`를 이용하여 인스턴스화 하는 것은 하드 코딩
- 의존성 주입은 의존성을 약화시키는 것, 의존성을 완전히 없애는 방식이 아님
- 의존성 제거 == 객체 간의 협력 부정 / 시스템 간의 협력 부정
- 대부분의 디자인 패턴이나 설계는 어떻게 하면 의존성을 약화시킬 수 있는지 고민한 결과물
- 필요한 값을 `new`해서 직접 인스턴스화하는 것이 아닌 외부에서 넣어주는 것
의존성 역전
- 의존성 역전(DI) ≠ 의존성 주입(DIP)
- Dependency Inversion ≠ Dependency Injection Principle
- DIP
- 의존성 역전이란?
- 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존해서는 안된다. 세부 사항이 추상화에 의존해야 한다.
- 다형성의 원리 중 하나
- 모듈 간의 상하 관계를 포함하는 개념
- 화살표의 방향을 바꾸는 기술
- 의존성 역전은 고수준 코드에서 세부 사항에 의존해서는 안됨 <RF. 로버트. C. 마틴 저, 클린 아키텍처>
- `import`에 사용되는 경로에는 인터페이스나 추상 클래스만을 사용해야 함
- 인터페이스나 추상 클래스 같은 추상적인 선언을 정책으로 볼 수 있고, 변동성이 큰 구체적인 요소를 세부 사항으로 볼 수 있음
- 의존성 역전이란?
의존성과 테스트 (의존성과 테스트의 상관관계)
테스트를 잘하려면 다음의 것을 잘 다룰 수 있어야 한다.
- 의존성 주입
- 의존성 역전
예시 | 마지막 로그인 시간
가정 사용자가 로그인할 때마다, 마지막 로그인 시간을 기록해야 함
class User {
private long lastLoginTimestamp;
public void login() {
// ...
this.lastLoginTimestamp = Clock.systemUTC().millis();
}
}
- 의존성이 숨겨져 있는 경우에 해당
- 내부 로직에서 `login`은 `Clock`에 의존적
- 하지만 외부에서 `login` 함수를 호출할 때는 `login`함수가 `Clock`을 사용하는지 모르기 때문
- 일반적으로 의존성이 숨겨져 있으면 좋지 않은 신호
class UserTest { @Test public void login_테스트() { // given User user = new User(); // when user.login(); // then assertThat(user.getLastLoginTimestamp()).isEqualTo(???); } }
- 마지막으로 로그인한 시간을 비교해야 하는데 `???`에는 어떤 값이 들어가야 하는지 알 수 없음
- `login`을 호출한 시간과 결과를 비교하는 시점의 시간은 다를 수밖에 없음 → 테스트 방법 X
- 시간을 강제로 stub 해 주는 라이브러리가 있지만, 너무 부자연스러움 → 라이브러리가 없으면 테스트가 불가능함
- 이러한 코드를 테스트하는 것은 불가능하고 일관되게 유지하는 것도 어려움
예시의 문제점 해결
1. 의존성 주입으로 해결 - 시계를 외부에서 주입받는 경우
class User {
private long lastLoginTimestamp;
public void login(Clock clock) {
// ...
this.lastLoginTimestamp = clock.millis();
}
}
class UserTest {
@Test
public void login_테스트() {
// given
User user = new User();
Clock clock = Clock.fixed(Instant.parse("시간"), ZoneId.of("UTC"));
// when
user.login(clock);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(milliseconds로 환산한 시간);
}
}
필요한 값을 만들고, 그 값을 `login`에 전달함. 그다음 결과 값을 지정한 시간과 비교함.
🔹알게 된 점
- 숨겨진 의존성은 테스트하기 힘들게 만든다.
- 의존성은 드러내는 것이 좋다.
🔹문제점
- 외부에서 주입받아 해결했는데, 결국 `login`을 사용하는 곳에서도 `Clock`이라는 숨겨진 의존성을 사용해야 함
class UserService { public void login(User user) { // ... user.login(Clock.systemUTC()); // 숨겨진 의존성인 Clock 사용 } }
- 따라서 `UserService` 테스트가 어려워짐
class UserServiceTest { @Test public void login_테스트() { //given User user = new User(); UserService userService = new UserService(); //when userService.login(user); //then assertThat(user.getLastLoginTimestamp()).isEqualTo(???); } }
- 이를 해결하기 위해 또 다른 의존성 주입을 사용하는 것은 해결방법이 아님 → `UserService`를 사용하는 또 다른 코드에 `Clock`이 숨겨지는 결과. 결국 어딘가에서는 고정된 값을 입력하여 넣어줘야만 함 → 또 테스트하기 힘들어짐 ⇒ 폭탄 돌리기
2. 의존성 역전으로 해결 - 현재 시간을 알려주는 인터페이스 정의
interface ClockHolder {
long getMillis();
}
@Getter
class User {
private long lastLoginTimestamp;
public void login(ClockHolder clockHolder) {
// ...
this.lastLoginTimestamp = clockHolder.getMillis();
}
}
@Service
@RequiredArgsConstructor
class UserService {
private final ClockHolder clockHolder;
public void login(User user) {
// ...
user.login(clockHolder);
}
}
- `User`는 이제 `Clock`이 아닌 `ClockHolder`에 의존
- `ClockHolder`는 외부에서 주입받도록 함
- `UserService`는 `ClockHolder`를 멤버 변수로 받도록 되어 있음
- 유저가 로그인할 때 `clockHolder` 값을 건네줌
🔹장점
우선 `ClockHolder`의 두 가지 구현체를 생성해 보자.
@Component
class SystemClockHolder implements ClockHolder {
@Override
public long getMillis() {
return Clock.systemUTC().millis();
}
}
- 실제 배포 환경에서 사용할 `SystemClockHolder`
- 현재 시간을 요청하면 시스템 시간을 반환
@AllArgsConstructor
class TestClockHolder implements ClockHolder {
private Clock clock;
@Override
public long getMillis() {
return clock.millis();
}
}
- 위 구현체는 객체를 생성할 때 `Clock`을 받도록 함
🔹실제 테스트
class UserServiceTest {
@Test
public void login_테스트() {
// given
Clock clock = Clock.fixed(Instant.parse("시간"), ZoneId.of("UTC"));
User user = new User();
UserService userService = new UserService(new TestClockHolder(clock));
// when
userService.login(user);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(milliseconds로 환산한 시간);
}
}
- `UserService`를 생성할 때 `TestClockHolder`를 주입
- `User`가 받게 될 `ClockHolder`는 지정한 `Clock`만 내려주는 `ClockHolder`가 될 것 ⇒ 쉽게 깨지지 않는 테스트 코드. 항상 같은 결과만 내려주는, 일관된 테스트가 됨
- 배포 환경에서는 어떨까?
- `SystemClockHolder`는 스프링 빈으로 등록되어 있기 때문에 스프링이 `UserService`를 만들 때 알아서 잘 주입해 줄 것.
- 결과적으로 의존성 주입과, 의존성 역전 기술 모두 사용함
- 의존성 역전을 사용했을 뿐인데, 배포 환경과 테스트 환경을 분리할 수 있게 됨 ⇒ 추상화 의존 결과
- 의존성 역전 `Port-Adapter 패턴`으로도 부름
- 의존성 역전을 사용했을 뿐인데, 배포 환경과 테스트 환경을 분리할 수 있게 됨 ⇒ 추상화 의존 결과
Testability
Testability는 테스트 가능성으로, 얼마나 쉽게 input을 변경할 수 있고, 얼마나 쉽게 output을 검증할 수 있는가를 의미한다.
얼마나 쉽게 Input을 변경...
- Case1.1 - 의존성이 감춰져 있는 경우
class User { private long lastLoginTimestamp; public void login() { // ... this.lastLoginTimestamp = Clock.systemUTC().millis(); } }
- 위처럼 `Clock`같은 감춰진 의존성이 존재한다면 input을 외부에서 변경할 수 없음 ⇒ testability가 낮은 코드
- Case1.2 - Input이 숨겨져 있는 경우
@Getter @Builder(access = AccessLevel.PRIVATE) @RequiredArgsConstructor public class Account { private final String username; private final String authToken; public static Account create(String username) { return Account.builder() .username(username) .authToken(UUID.randomUUID().toString()) .build(); } }
- `Account` 내부에는 인스턴스를 생성하는 팩토리 메서드가 있는데 괜찮을까?
class AccountTest { @Test void create() { //given String username = "foobar"; // when Account account = Account.create(username); // then assertThat(account.getUsername()).isEqualTo("foobar"); } }
- `username` 말고도 `authToken`이 잘 생성되었는지 확인해야 함
- `authToken`은 UUID로 생성 — "UUID가 사용된다는 이야기는 처음 듣는데요?" → 숨겨진 input: 호출자는 모르는 정보
- 호출자가 해당 메서드를 타고 들어와서 내부 알고리즘을 확인함 → 객체지향의 핵심 가치 중 하나인 캡슐화가 깨짐
- 알고리즘을 확인했어도 만들어진 `authToken` 값이 제대로 된 값인지 확인할 방법이 없음
class AccountTest { @Test void create() { //given String username = "foobar"; String expectedAuthToken = "..."; PowerMockito.mockStatic(UUID.class); when(UUID.randomUUID()).thenReturn(UUID.fromString(expectedAuthToken)); // when Account account = Account.create(username); // then assertThat(account.getUsername()).isEqualTo("foobar"); assertThat(account.getAuthToken()).isEqualTo(expectedAuthToken); } }
- UUID의 `randomUUID()`는 static 메서드
- static 메서드를 mock 하는 건 `mockito` 만으로는 안됨 → java의 `PowerMock` 라이브러리를 활용해 봄
- 하지만 찾아보니 UUID는 final class → 어떻게든 방법을 찾으면 해결할 수도 있음 but 이는 테스트가 보내는 신호를 무시하는 것; 잘못된 설계
- Case 2.1 - 파일 경로 하드 코딩
public class Example { private static final File FILE = new File("data.txt"); public void processData() { //read from FILE and process data } }
- File path가 고정되어 있다면, 고정된 파일에 지나치게 의존하게 돼서 input 변경이 힘듦 ⇒ 낮은 Testability
- Case 2.2 - 외부 시스템 하드 코딩
public class Example { public void processData(String data) { String processData = data.toUpperCase(); // 데이터 처리 sendDataOverNetWork(processedData); } private void sendDataOverNetwork(String data) { // HTTP를 이용한 네트워크 요청 } }
- 스프링을 이용하다 보면 `WebClient`나 `RestTemplate` 같은 통신 클래스를 많이 사용하게 됨 → 이를 직접 사용하고 있을 것 → 테스트 어려움
얼마나 쉽게 output을 검증
- Case. 외부에서 결과를 볼 수 없는 경우
public class Example { public void processData(int[] numbers) { int sum = 0; for (int number : numbers) { sum += number; } System.out.println("Sum: " + sum); } }
- 외부에서 콘솔에 출력된 값이 어떤 값인지 확인할 수 없음 → 결과 확인 불가능 ⇒ 낮은 Testability
'Java' 카테고리의 다른 글
Java/Spring 테스트 - 1 (2) | 2024.10.02 |
---|---|
[Gradle] gradle build와 gradle bootJar의 차이 (0) | 2024.09.06 |
[Java] new Integer와 Integer.valueOf()의 차이점 (0) | 2024.08.11 |
Java에서 외부 프로그램 실행하기 (zt-exec) (0) | 2024.07.06 |
[TIL / JPA] 데이터베이스 스키마 자동 생성, UNIQUE 제약 조건 관련 TIL (0) | 2024.06.30 |
Comments