‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
7장 - 아키텍처 요소 테스트하기
테스트 피라미드
출처: https://jandari91.tistory.com/57
- 기본 전제는 만드는 비용이 적고, 유지보수하기 쉽고, 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지하는 것이다. (단위 테스트)
- 여러 개의 단위와 단위를 넘는 경계, 아키텍처 경계, 시스템 경계를 결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려지며 깨지기 더 쉬워진다.(피라미드에서 위로 갈수록)
- 단위 테스트는 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 테스트 중인 클래스가 다른 클래스에 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 목(mock)으로 대체한다.
- 통합 테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작되는지 검증한다. 두 계층 간의 경계를 걸쳐서 테스트할 수 있기 때문에 객체 네트워크가 완전하지 않거나 어떤 시점에는 목을 대상으로 수행해야 한다.
- 시스템 테스트는 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다. (ex. UI를 포함한 엔드투엔드 테스트층)
단위 테스트로 도메인 엔티티 테스트하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AccountTest {
@Test
void withdrawalSucceeds () {
AccountId accountId = new AccountId(1L); Account account = defaultAccount
.withAccountId(accountId)
.withBaselineBalance (Money.of (555L))
•withActivityWindow(new ActivityWindow(
defaultActivity()
.withTargetAccount(accountId)
.withMoney (Money.of (999L))
.build(),
defaultActivity()
.withTargetAccount(accountId)
.withMoney(Money.of(IL))
.build()))
.build();
boolean success = account.withdraw(Money.of(555L), new AccountId (99L));
assertThat(success).isTrue ();
assertThat (account.getActivityWindow().getActivities()).hasSize(3);
assertThat(account.calculateBalance()).isEqualTo(Money.of (1000L));
}
}
- 특정 상태의 Account 를 인스턴스화하여 withdraw 함수를 호출하여 출금이 성했는지 검증하고, Account 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단순 단위 테스트다.
- 이해하는것도 쉬운편이고 아주 빠르게 실행되고 간단하다.
단위 테스트로 유스케이스 테스트하기
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
class SendMoneyServiceTest {
private final LoadAccountPort loadAccountPort =
Mockito.mock(LoadAccountPort.class);
private final AccountLock accountLock =
Mockito.mock(AccountLock.class);
private final UpdateAccountStatePort updateAccountStatePort =
Mockito.mock(UpdateAccountStatePort.class);
private final SendMoneyService sendMoneyService =
new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());
@Test
void transactionSucceeds() {
Account sourceAccount = givenSourceAccount();
Account targetAccount = givenTargetAccount();
givenWithdrawalWillSucceed(sourceAccount);
givenDepositWillSucceed(targetAccount);
Money money = Money.of(500L);
SendMoneyCommand command = new SendMoneyCommand(
sourceAccount.getId().get(),
targetAccount.getId().get(),
money);
boolean success = sendMoneyService.sendMoney(command);
assertThat(success).isTrue();
AccountId sourceAccountId = sourceAccount.getId().get();
AccountId targetAccountId = targetAccount.getId().get();
then(accountLock).should().lockAccount(eq(sourceAccountId));
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
then(accountLock).should().releaseAccount(eq(sourceAccountId));
then(accountLock).should().lockAccount(eq(targetAccountId));
then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
then(accountLock).should().releaseAccount(eq(targetAccountId));
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
}
private void thenAccountsHaveBeenUpdated(AccountId... accountIds){
ArgumentCaptor<Account> accountCaptor = ArgumentCaptor.forClass(Account.class);
then(updateAccountStatePort).should(times(accountIds.length))
.updateActivities(accountCaptor.capture());
List<AccountId> updatedAccountIds = accountCaptor.getAllValues()
.stream()
.map(Account::getId)
.map(Optional::get)
.collect(Collectors.toList());
for(AccountId accountId : accountIds){
assertThat(updatedAccountIds).contains(accountId);
}
}
private void givenDepositWillSucceed(Account account) {
given(account.deposit(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private void givenWithdrawalWillSucceed(Account account) {
given(account.withdraw(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private Account givenTargetAccount(){
return givenAnAccountWithId(new AccountId(42L));
}
private Account givenSourceAccount(){
return givenAnAccountWithId(new AccountId(41L));
}
private Account givenAnAccountWithId(AccountId id) {
Account account = Mockito.mock(Account.class);
given(account.getId())
.willReturn(Optional.of(id));
given(loadAccountPort.loadAccount(eq(account.getId().get()), any(LocalDateTime.class)))
.willReturn(account);
return account;
}
private MoneyTransferProperties moneyTransferProperties(){
return new MoneyTransferProperties(Money.of(Long.MAX_VALUE));
}
}
- 의존성이 있는 포트들을 모킹해서 해당 유스케이스가 정상적으로 실행되었는지 그리고 트랜잭션이 성공적이었는지, 출금 및 입금 Account, 그리고 계좌에 락을 걸고 해제하는 책임을 가진 AccountLock 에 대해 특정 메서드가 호출됐는지 검증한다.
- 테스트는 서비스가 (모킹된) 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 이는 테스트가 코드의 행동 변경 뿐만 아니라 구조 변경에도 취약해지게되고 리팩터링되면 테스트도 변경될 확률이 높아진다.
- 그렇기 때문에, 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야하고 위 테스트코드처럼 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는것이 필요하다.
통합 테스트로 웹 어댑터 테스트하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class SendMoneyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SendMoneyUseCase sendMoneyUseCase;
@Test
void testSendMoney() throws Exception {
mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
41L, 42L, 500)
.header("Content-Type", "application/json"))
.andExpect(status().isOk());
then(sendMoneyUseCase).should()
.sendMoney(eq(new SendMoneyCommand(
new AccountId(41L),
new AccountId(42L),
Money.of(500L))));
}
}
- HTTP 요청 결과가 200임을 검증하고, 모킹한 유스케이스가 잘 호출됐는지를 검증한다. 웹 어댑터 책임 대부분은 이 테스트로 커버된다.
- MockMvc 객체를 이용해 모킹했기 떄문에 실제 HTTP 프로토콜을 통해 테스트한것은 아니다. 프레임워크가 HTTP 프로토콜에 맞게 모든것을 적절히 잘 변환한다고 믿는것이다.
- 입력을 JSON 에서 SendMonyCommand 객체 매핑하는 전 과정은 다루고 있다. 만약 SendMoneyCommand 객체에 validation 로직이 있다면 해당 검증 로직도 확인이 필요할것이다.
- 이 테스트가 단위 테스트가 아닌 통합 테스트인 이유는 단순하게 컨트롤러 클래스만 테스트한것이 아니라 @WebMvcTest 애너테이션은 스프링이 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만든다. 그리고 테스트에서는 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.
- 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여있으므로 격리된 상태로 테스트하기보단 프레임워크와 통합된 상태로 테스트하는것이 합리적이다.
통합 테스트로 영속성 어댑터 테스트하기
- 영속성 어댑터도 스프링 프레임워크에 강하게 묶여있으므로 단위 테스트보단 통합 테스트를 적용하는것이 합리적이다.
- 단순 어댑터 로직만 검증하고 싶은게 아니라 데이터베이스 매핑도 검증하고 싶기 때문이다.
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
@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {
@Autowired
private AccountPersistenceAdapter adapterUnderTest;
@Autowired
private ActivityRepository activityRepository;
@Test
@Sql("AccountPersistenceAdapterTest.sql")
void loadsAccount() {
Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
}
@Test
void updatesActivities() {
Account account = defaultAccount()
.withBaselineBalance(Money.of(555L))
.withActivityWindow(new ActivityWindow(
defaultActivity()
.withId(null)
.withMoney(Money.of(1L)).build()))
.build();
adapterUnderTest.updateActivities(account);
assertThat(activityRepository.count()).isEqualTo(1);
ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
assertThat(savedActivity.getAmount()).isEqualTo(1L);
}
}
@DataJpaTest 애너테이션
은 스프링 데이터 리포지토리들을 포함해서 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화해야 한다고 스프링에 전달해준다.@Import 애너테이션
은 특정 객체가 이 네트워크에 추가됐다는것을 명확히 표현할수 있게해준다.- 여기서 중요한점은 DB를 모킹하지 않고 실제 DB로 테스트했다는 점이다.
- 실제 DB와 연동했을때 SQL 구문 오류나 DB 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길확률이 높아지기 때문이다.
참고로 스프링에선 기본적으로 인메모리 데이터베이스(h2)를 적용해준다.
- 프로덕션 환경에선 인메모리 데이터베이스를 사용하지 않는 경우가 많으므로 인메모리 데이터베이스에서 테스트가 완벽하게 통과했더라도 실제 데이터베이스에는 문제가 생길 가능성이 높다. 에를 들면 DB마다 고유한 SQL 문법이 있어서 이 부분이 문제가 되는 식으로 말이다.
- 이러한 이유로 영속성 어댑터 테스트는 싲레 데이터베이스를 대상으로 진행해야 한다. Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있기 때문에 아주 유용하다.
- 두 개의 DB 시스템을 신경 쓸 필요가 없다는 장점도 생긴다. 쉽게 생각하면 마이그레이션을 양쪽 따로 신경쓸 걱정도 사라지게 된다.
시스템 테스트로 주요 경로 테스트하기
- 시스템 테스트는 전체 애플리케이션을 띄워 API 를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다.
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
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SendMoneySystemTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private LoadAccountPort loadAccountPort;
@Test
@Sql("SendMoneySystemTest.sql")
void sendMoney() {
Money initialSourceBalance = sourceAccount().calculateBalance();
Money initialTargetBalance = targetAccount().calculateBalance();
ResponseEntity response = whenSendMoney(
sourceAccountId(),
targetAccountId(),
transferredAmount());
then(response.getStatusCode())
.isEqualTo(HttpStatus.OK);
then(sourceAccount().calculateBalance())
.isEqualTo(initialSourceBalance.minus(transferredAmount()));
then(targetAccount().calculateBalance())
.isEqualTo(initialTargetBalance.plus(transferredAmount()));
}
private Account sourceAccount() {
return loadAccount(sourceAccountId());
}
private Account targetAccount() {
return loadAccount(targetAccountId());
}
private Account loadAccount(AccountId accountId) {
return loadAccountPort.loadAccount(
accountId,
LocalDateTime.now());
}
private ResponseEntity whenSendMoney(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
HttpEntity<Void> request = new HttpEntity<>(null, headers);
return restTemplate.exchange(
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
HttpMethod.POST,
request,
Object.class,
sourceAccountId.getValue(),
targetAccountId.getValue(),
amount.getAmount());
}
private Money transferredAmount() {
return Money.of(500L);
}
private Money balanceOf(AccountId accountId) {
Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
return account.calculateBalance();
}
private AccountId sourceAccountId() {
return new AccountId(1L);
}
private AccountId targetAccountId() {
return new AccountId(2L);
}
}
@SpringBootTest
애너테이션은 모든 스프링빈을 빈컨테이너에 등록하고 애플리케이션을 기동시킨다.- 웹 어댑터에서처럼 MockMvc를 이용해 요청을 보내는것이 아니라 TestRestTemplate 을 이용해 요청을 보낸다. 테스트를 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 하는것이다.
- 실제 HTTP 통신을 하는것처럼 출력 어댑터도 이용한다. 예제에서 출력 어댑터는 영속성 어댑터뿐이다. 다른 시스템과 통신하는 (ex. 마이크로서비스간 내부 통신) 애플리케이션의 경우 다른 출력 어댑터들도 잇을수 있다. 시스템 테스트라고 하더라도 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야할때도 있다. 육각형 아키텍처는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹하면 되기에 아주 쉽게 해결 가능하다.
- 참고로 테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드안으로 감췄다. 이제 이 헬퍼 메서드들은 여러 가지 상태를 검증할때 사용할 수 있는 도메인 특화 언어(DSL)를 형성한다.
- 단위 테스트와 통합테스트를 만들었다면 시스템 테스트와 겹치는 부분이 많을것이다. 그럼에도 추가적인 다른 장점도 있다. 예를 들면 또 다른 종류의 버그를 발견해서 수정할 수 있게 해준다. 단위 테스트나 통합 테스트만으론 알아차리지 못했을 계층간 매핑 버그 같은 것들 말이다.
- 여러 개의 유스케이스를 결합하여 시나리오를 만들때 더 빛이 난다. 각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 API 모음들을 의미한다. 시스템 테스틀르 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 기존 형상에 영향이 가지 않았음을 확신할수 있어 제품 안정성을 지킬수 있다.
얼마만큼의 테스트가 충분할까?
- 라인 커버리지는 테스트 성공을 측정하는데 잘못된 지표다. 코드의 중요한 부분이 전혀 커버되지 않을수 있기에 100%를 제외한 어떤 목표도 완전 무의미하다. 심지어 100%라도 버그가 잘 잡혔는지 확신할 수 없다.
- 저자는 얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다고 생각한다. 테스트를 실행후 배포해도될만큼 테스트를 신뢰한다면 말이다.
- 처음 몇번의 배포에는 믿음의 도약이 필요하다. 그렇지만 프로덕션 버그를 수정하고 이로부터 배우는것을 우선순위로 삼으면 제대로 가고 있는것이다.
- 각각의 프로덕션 버그에 대해 “테스트가 왜 이버그를 잡지 못했지?”를 생각하고 답변을 기록하며, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다. 시간이 지나면 이 작업들이 배포할때 마음을 편안하게 해줄것이고, 남겨둔 기록은 시간이 지날수록 상황이 개선되고 있음을 증명해줄것이다.
- 육각형 아키텍처에서 사용하는 테스트 전략은 다음과 같다.
- 1)도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
- 2)유스케이스를 구현할 때는 단위 테스트로 커버하자
- 3)어댑터를 구현할 때는 통합 테스트로 커버하자
- 4)사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자
- ‘구현할 때는’ 이라는 문구에 주목하자. 테스트가 기능 개발후가 아닌 개발 중에 이뤄진다면 하기 싫은 귀찮은 작업이 아니라 개발 도구로 느껴질것이다.
- 하지만 새로운 필드를 추가할때마다 테스트를 고치는데 한 시간을 써야 한다면 뭔가 잘못된것이다. 아마도 테스트가 구조적 변경에 너무 취약할것이므로 어떻게 개선할지 살펴봐야한다. 리팩터링 할때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다. (각 계층별 테스트에 대한 목적을 뚜렷하게 하지 않으면 이런 상황이 발생하지 않을까 싶다)
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.
- 입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다.
- 모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 경고 신호다. 이런 측면에서 테스트는 아키텍처의 문제점을 경고해주고 좋은 구조로 이끌도록 도와준다.