🌱 들어가기 전
이번 포스팅에서는 테스트에 활용되는 의존성 주입과 의존성 역전, 그리고 테스트 가능성을 높이는 과정을 살펴보자.
- 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공부하고 정리한 글입니다. ✏️
지난 포스팅과 이어집니다 !
https://deeper-dev.tistory.com/11
[테스트 코드와 설계] 테스트에 대한 개발자의 고민과 이론 및 개념
🌱 들어가기 전이번 포스팅에서는 테스트에 대한 개발자의 고민 내용과 이론 및 개념을 살펴보자. - 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공부하고 정리한 글
deeper-dev.tistory.com
<의존성과 Testability>
🌱 의존성
💭 의존성
컴퓨터 공학에서 의존성은 결합이다. 다른 객체의 함수를 사용하는 상태를 말한다.
A는 B를 사용하기만 해도 A는 B에 의존한다 할 수 있다.
ex)
class Chef {
public Hamburger makeHamburger() {
Bread bread = new Bread();
Meat meat = new Meat();
Lettuce lettuce = new Lettuce();
Source source = new Source();
}
}
Chef는 Hamburger, Bread, Meat, Lettuce, Source를 의존한다.
💭 의존성 주입 (Dependency Injection)
의존성을 약화시키는 테크닉
필요한 값을 new해서 직접 인스턴스화하는게 아니라, 외부에서 넣어주는 것이다.
ex) 기존
class Chef {
public Hamburger makeHamburger() {
Bread bread = new Bread();
Meat meat = new Meat();
Lettuce lettuce = new Lettuce();
Source source = new Source();
return Hamburger.builder()
.bread(bread)
.meat(meat)
.lettuce(lettuce)
.source(source)
.build();
}
}
ex) 의존성 주입
class Chef {
public Hamburger makeHamburger(
Bread bread,
Meat meat, // 돼지고기, 소고기.. 가능
Lettuce lettuce,
Source source) {
return Hamburger.builder()
.bread(bread)
.meat(meat)
.lettuce(lettuce)
.source(source)
.build();
}
}
의존성 주입은 의존성을 약화시킨 것이지 완전히 없애는 건 아니다.
Chef는 여전히 Hamburger, Bread, Meat, Lettuce, Source를 사용하고 있다.
소프트웨어에서 말하는 의존성을 완전히 제거할 수 없다. 의존성을 제거하는 것은 객체 간(시스템 간)의 협력을 부정하는 것이다. 그래서 대부분의 디자인 패턴이나 설계는 어떻게하면 의존성을 약화시킬 수 있는지를 고민한 결과물이다.
인스턴스를 만드는 것보다 의존성 주입을 받는게 좋은 이유
new는 하드 코딩이다.
위의 기존 코드 예시를 보면, Hamburger를 만들 때 영락없이 Meat객체를 사용해야 한다. 그러나 의존성 주입 코드 예시를 보면 Meat를 외부에서 주입받기 때문에 Hamburger를 만들 때 돼지고기가 될 수도 있고 소고기가 될 수도 있다.
💭 의존성 역전 (Dependency Inversion)
상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
화살표 바꾸는 테크닉
- 기존
Chef → Beef
: Beef라는 세부사항에 의존하고 있다.
- 의존성 역전 적용
Chef → Meat <<interface>> ← Beef
: 인터페이스와 구현을 분리하여 인터페이스를 통해 통신한다. Beef가 인터페이스 구현을 만들게 하며, Beef라는 세부사항이 Meat 추상화에 의존하고 있다. Chef는 인터페이스를 통해 일을 시키는 것 뿐이다.
고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 의존해서는 안된다. 대신 세부사항이 정책에 의존해야 한다.
자바에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다.
우리가 의존하지 않도록 피하고자 하는 것은 변동성이 큰 구체적인 요소다.
🌱 의존성과 테스트
💭 갑자기 의존성은 왜?
테스트를 잘 하려면 의존성 주입과 의존성 역전을 잘 다룰 수 있어야 한다.
ex) 마지막 로그인 시간
class User {
private long lastLoginTimestamp;
public void login() {
// ...
this.lastLoginTimestamp = Clock.systemUTC().millis();
}
}
내부 로직을 보면 login()은 Clock에 의존적이다.
user.login();
외부에서 보면 login이 Clock에 의존하고 있는지를 알 수 없다.
의존성이 숨겨져있다. 의존성이 숨겨져 있는 것은 좋지 않은 신호이다.
class UserTest{
@Test
public void login_테스트 {
// given
User user = new User();
// when
user.login();
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(???);
}
}
로그인 메서드를 호출한 시간이랑 결과를 비교하는 시간은 다를 수 밖에 없다. 즉 테스트 할 방법이 없는 것이다.
강제로 시간을 stub 해주는 라이브러리가 있긴하지만, 라이브러리가 없으면 테스트가 안된다는 뜻이라 부자연스럽다.
이렇게 테스트를 짜면서 '이건 테스트가 불가능한데?' 'mock 없이는 테스트가 불가능한데?'라는 생각이 들었다면, 테스트가 보내는 신호다.
테스트가 보내는 신호 해결
1. 시간을 의존성 주입으로 해결
class User {
private long lastLoginTimestamp;
public void login(Clock clock) { // Clock을 외부에서 주입받는다.
// ...
this.lastLoginTimestamp = clock.millis();
}
}
class UserTest{
@Test
public void login_테스트 {
// given
User user = new User();
Clock clock = Clock.fixed(Instant.parse("2000-01-01T00:00:00.00Z"), ..// 이후 생략
// when
user.login(clock);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(946684800000L); // 2000-01-01T00:00:00.00Z를 ms로 환산한 값
}
}
숨겨진 의존성을 테스트하기 힘들게 만든다. 의존성을 드러내는게 좋다.
문제: 진짜 해결한 것인가?
User의 login()을 사용하는 어딘가의 코드(UserService)에서는 또 Clock이라는 숨겨진 의존성을 사용할 것이다. 결국 또 테스트하기 힘들다.
한 번 더 의존성 주입을 사용할까? 그건 아니다. 결국 UserService를 사용하는 또 다른 코드에서 Clock이 숨겨질테니까.
폭탄을 넘겨줬을 뿐, 어딘가에서 고정된 값을 넣어줘야 한다.
해결: 의존성 역전을 사용한다.
2. 의존성 주입 + 의존성 역전
User → ClockHolder <<interface>>
interface ClockHolder { // 현재 시간을 알려주는 인터페이스
long getMillis();
}
@Getter
class User {
private long lastLoginTimestamp;
public void login(ClockHolder clockHolder) { // User는 ClockHolder를 의존한다. 외부에서 주입받는다.
// ...
this.lastLoginTimestamp = clockHolder.getMillis();
}
}
@Service
class UserService() {
private final ClockHolder clockHolder; // 멤버변수
public void login(User user) {
// ...
user.login(clockHolder);
}
}
User → ClockHolder <<interface>> ← SystemClockHolder, TestClockHolder (구현체 2개: 프로덕션용, 테스트용 따로 생성)
@Component
class SystemClockHolder implements ClockHolder {
@Override
public long getMillis() {
return Clock.systemUTC().millis();
}
}
@AllArgsConstructor // 객체 만들 때 Clock을 받는다.
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("2000-01-01T00:00:00.00Z"), ..// 이후 생략
User user = new User();
UserService userService = new UserService(new TestClockHolder(clock)); // 직접 지정한 시각만 내려주는 ClockHolder가 된다.
// when
userService.login(user);
// then
assertThat(user.getLastLoginTimestamp()).isEqualTo(946684800000L); // 2000-01-01T00:00:00.00Z를 ms로 환산한 값
}
}
테스트가 훨씬 쉬워진다. 테스트 코드도 쉽게 깨지지 않게 된다. 일관된 테스트가 된다.
프로덕션 환경에서는 스프링을 쓰면 알아서 잘 주입될 것이다. @Component를 사용하여 SystemClockHolder가 스프링 빈으로 등록되어있기 때문이다,
의존성 역전을 사용했을 뿐인데, 배포환경과 테스트 환경을 분리할 수 있게 됐다. 이게 가능한 이유는 추상화에 의존했기 때문이다. 다형성의 원리를 이용해 모듈을 갈아끼우듯이 변경할 수 있게 된 것이다.
그런 의미에서 의존성 역전은 Port-Adapter 패턴이라고도 부른다.
💭 SOLID의 DIP 원칙: 의존성 역전 원칙
대부분의 소프트웨어 문제는 의존성 역전으로 해결이 가능하다.
🌱 Testability
💭 테스트 가능성
얼마나 쉽게 input을 변경하고, output을 쉽게 검증할 수 있는가?
먼저 input에 관하여 살펴보자.
감춰진 의존성
호출자는 모르는 입력이 존재한다.
ex 1)
class User {
private long lastLoginTimestamp;
public void login() {
// ...
this.lastLoginTimestamp = Clock.systemUTC().millis();
}
}
감춰진 의존성이 존재하면, input을 외부에서 변경할 수 없다. Clock같은 숩겨진 input을 제어할 수 없다. 즉 Testability가 낮은 코드이다.
ex 2) Account 내부에는 인스턴스 생성하는 팩토리 메서드가 있는데, 괜찮을까?
@Getter
@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();
}
}
이 코드를 호출하는 외부 호출자 입장에서 생각해보자.
@Getter
@RequiredArgsConstructor
public class Account {
private final String username;
private final String authToken;
public static Account create(String username) {
}
}
클래스를 이렇게 받아들이고 있을거다.
class Account {
@Test
void create() {
// given
String usernamme = "test";
// when
Account account = Account.create(username):
// then
assertThat(account.getUsername()).isEqualTo("test");
}
}
이렇게 테스트 코드를 작성하다 보면, 문득 '어?? 권한 인증에 사용되는 토큰은 어떻게 만들어지는거지?' 라는 의문이 들 것이다.
Account 클래스에 authToken 변수는 있는데, 어떻게 생성되는건지 모르겠다. username말고 authToken도 제대로 만들어졌는지 확인해야 하는데말이다.
코드를 타고 들어가서 보니, UUID를 사용하고 있다는 것을 알게된다. 결과적으로 숨겨진 input인 것이다. 호출자는 모르는 정보이기 때문이다.
그리고 호출자가 메소드를 타고 들어와서 내부 알고리즘을 확인해야 하는 것은 객체지향의 캡슐화가 깨진 것이다.
심지어 로직을 확인했어도, 랜덤값이라 만들어진 토큰값이 제대로 된 값인지 확인할 방법이 없다.
억지로 Mockito를 사용해서 stub하려 하면, 제대로 동작하지 않을 것이다. final 클래스가 stub을 지원해서는 안되는데, UUID는 final 클래스이기 때문이다.
뭔가 잘못됐음을 느끼고 설계를 고쳐야 한다. 의존성 역전을 활용하자.
이하 해결 내용은 강의에 없는, 스스로 작성한 것 입니다.
- 기존
: Account → UUID
- '의존성 주입 + 의존성 역전' 적용 후
: Account → UuidHolder <<interface>> ← MockUuidHolder, SystemUuidHolder (구현체 2개) → UUID
interface UuidHolder {
String randomUuid();
}
@Getter
@RequiredArgsConstructor
public class Account {
private final String username;
private final String authToken;
public static Account create(String username, UuidHolder uuidHolder) { // 팩토리 메서드
return Account.builder()
.username(username)
.authToken(uuidHolder.randomUuid())
.build();
}
}
@Component
class SystemUuidHolder implements UuidHolder {
@Override
public String randomUuid() {
return UUID.randomUUID().toString();
}
}
@AllArgsConstructor // 객체 만들 때 랜덤 값을 받는다.
class MockUuidHolder implements UuidHolder {
private final String uuid;
@Override
public String randomUuid() {
return uuid;
}
}
이렇게 의존성 주입과 의존성 역전을 이용하면, 아래와 같이 테스트를 작성할 수 있다.
class Account {
@Test
void create() {
// given
String usernamme = "test";
String uuid = "550e8400-e29b-41d4-a716-446655440000";
// when
Account account = Account.create(username, new MockUuidHolder(uuid)):
// then
assertThat(account.getUsername()).isEqualTo("test");
assertThat(account.getAuthToken()).isEqualTo(uuid);
}
}
하드 코딩
public class Example {
private static final File file = new File("data.txt");
}
파일이 존재하지 않을 때를 테스트할 수 없다.
file path가 고정되어있으면 지나치게 의존하게 되어서 input 변경이 힘들어진다.
외부 시스템
Spring을 사용하다 보면, WebClient나 Rest Template 같은 통신 클래스를 이용해서 코드를 많이 짜는데, 이 부분은 테스트하기 힘들 것이다.
이제 output에 관해 살펴보자.
감춰진 결과
외부에서 결과를 볼 수 없는 경우
public class Example {
public void process () {
int sum = 0;
// ...
System.out.println("sum: " + sum);
}
}
외부에서는 콘솔에 출력된 값이 어떤 값인지 확인할 길이 없다. 이처럼 결과를 확인할 수 없으면, Testability가 낮다.
'Back-End > Test Code' 카테고리의 다른 글
[테스트 코드와 설계] 프로젝트 Test Code 작성과 문제 해결 (0) | 2024.09.04 |
---|---|
[테스트 코드와 설계] 빌더 패턴과 엔티티, 그리고 테스트에 대한 조언 (0) | 2024.09.04 |
[테스트 코드와 설계] 테스트에 대한 개발자의 고민과 이론 및 개념 (1) | 2024.09.03 |
[테스트 코드와 설계] 테스트 코드 작성과 리팩토링 (0) | 2024.08.30 |
[테스트 코드와 설계] 실패하는 테스트 코드 (0) | 2024.08.29 |