만들면서 배우는 클린 아키텍처 7, 8장

허석문·2024년 6월 1일
0
post-thumbnail

07 아키텍처 요소 테스트하기


테스트 피라미드

기본 전제는 만드는 비용이 적고, 유지보수하기 쉽고. 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지

책에서의 정의

단위 테스트

  • 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트
  • 의존하는 클래스들을 인스턴스화 하지 않고 mock을 이용

통합 테스트

  • 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증
  • 객체 네트워크가 완전하지 않거나 어떤 시점에는 목을 대상으로 수행

시스템 테스트

  • 엔드투엔드 테스트

단위 테스트로 도메인 엔티티 테스트하기

class AccountTest {

	@Test
	void withdrawalSucceeds() {
		Accountld accountld = new Accountld(lL);
		Account account = defaultAccount()
			.withAccountld(accountld)
			.withBaselineBalance(Money.of(555L))
			.withActivityWindow(new ActivityWindow(
				defaultActivity()
					.withTargetAccount(accountld)
					.withMoney(Money.of(999L)).build(),
				defaultActivity()
					.withTargetAccount(accountld)
					.withMoney(Money.of(lL)).build()))
			.buildQ;
		
		boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
		assertThat(success).isTrue();
		assertThat(account.getActivityWindow().getActivities()).hasSize(3);
		assertThat(account.calculateBalanceO).isEqualTo(Money.of(1000L));
	}
}

위 테스트가 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 가장 적절한 방법 -> 단위 테스트

다른 클래스에 의존하지 않는다.

단위 테스트로 유스케이스 테스트하기

class SendMoneyServiceTest {

	// 필드 선언은 생략
	
	@Test
	void transactionSucceeds() {
		Account sourceAccount = givenSourceAccount();
		Account targetAccount = givenTargetAccount();

		//givenWithdrawalWillSucceed(sourceAccount);  
		//givenDepositWillSucceed(targetAccount);
		given(account.withdraw(any(Money.class), any(AccountId.class)))  
	       .willReturn(true);
		given(account.deposit(any(Money.class), any(AccountId.class)))  
	       .willReturn(true);
	
		Money money = Money.of(500L);
		
		SendMoneyCommand command = new SendMoneyCommand(
			sourceAccount .getldO,
			targetAccount.getld(),
			money);
	
		boolean success = SendMoneyService.sendMoney(command);
		assertThat(success).isTrue();
		
		Accountld sourceAccountld = sourceAccount.getId();
		Accountld targetAccountld = targetAccount.getId();
		
		then(accountLock).should().lockAccount(eq(sourceAccountld));
		then(sourceAccount).should().withdraw(eq(money), eq(targetAccountld));
		then(accountLock).should().releaseAccount(eq(sourceAccountld));
		then(accountLock).should().lockAccount(eq(targetAccountld));
		then(targetAccount).shouldO.deposit(eq(money), eq(sourceAccountld));
		then(accountLock).should().releaseAccount(eq(targetAccountId));
		thenAccountsHaveBeenllpdated(sourceAccountld, targetAccountld);
	}

	// 헬퍼 메서드는 생략
}

방식
행동—주도 개발(behavior driven development) -> given/when/then 패턴

책에서는 given 부분을 private으로 빼서 따로 만들었는데 따로 안빼는게 직관적이지 않나..

유스케이스 서비스는 상태가 없기 때문에 상태 검증은 할 수 없다. -> 특정 메서드와 상호작용 여부를 검사(then) -> 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약 -> 테스트 변경시 리팩토링 확률이 높다

위의 테스트는 단위 테스트와 통합 테스트 그 사이...

통합 테스트로 웹 어댑터 테스트하기

@WebMvcTest(controllers = SendMoneyController.class)
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) // Object로 받음 이런 형태는 첨 봤다.
		.header("Content-Type", "application/json"))
		.andExpect(status().isOk());
		
		then(sendMoneyUseCase).should()
			.sendMoney(eq(new SendMoneyCommand(
				new Accountld(41L),
				new AccountId(42L),
				Money.of(500L))));
	}
}

MockMvc 객체를 이용해 모킹했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니다

웹 어댑터의 유효성 검증은 유스케이스 입력 모델로 변환 가능성을 검토 하는 것
입력을 JSON에서 SendMoneyCommand 객체로 매핑하는 전 과정 -> SendMoneyCommand 객체를 자체 검증 커맨드로 만들었다면 이 매핑이 유스케이스에 구문적으로 유효한 입력을 생성했는지도 확인할 것

웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 때문에 격리된 상태로 테스트하기 보다는 이 프레임워크와 통합된 상태로 테스트하는 것이 합리적

통합 테스트로 영속성 어댑터 테스트하기

영속성 어댑터의 테스트에는 단위 테스트보다는 통합 테스트를 적용하는 것이 합리적 -> 데이터 베이스 매핑을 검증 하는 것이 중요

@DataJpaTest
©Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {

	@Autowired
	private AccountPersistenceAdapter adapterUnderTest;
	@Autowired
	private ActivityRepository activityRepository;

	// Account 엔티티를 데이터베이스로부터 가져오는 메서드 
	@Test
	@Sql("AccountPersistenceAdapterTest.sql")
	void loadsAccount() {
		Account account = adapter.loadAccount(
			new Accountld(lL),
			LocalDateTime.of(2018, 8, 10, 0, 0));
			
		assertThat(account.getActivityWindow().getActivities()).hasSize(2);
		assertThat(account.calculateBalance()).isEqualTo(Money.of(500))j
	}

	// 새로운 계좌 활동을 데이터베이스에 저장하는 메서드	
	@Test
	void updatesActivities() {
		Account account = defaultAccount()
			.withBaselineBalance(Money.of(555L))
			.withActivityWindow(new ActivityWindow(
				defaultActivity()
			.withld(null)
			.withMoney(Money.of(IL)).build()))
			.build();
	
		adapter.updateActivities(account);
		
		assertThat(activityRepository.count()).isEqualTo(l);
		ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
		assertThat(savedActivity.getAmount()).isEqualTo(lL);
	}
}

loadAccount() 메서드에 대한 테스트에서는 SQL 스크립트를 이용해 데이터베이스를 특정 상태로 만든다 -> 어댑터 API를 이용해 계좌를 가져온 후 SQL 스크립트에서 설정한 상태값을 가지고 있는지 검증

updateActivities()는 새로운 계좌 활동을 가진 Account 객체를 만들어서 저장하기 위해 어댑터로 전달한다 ->그러고 나서 AcitivtyRepository의 API를 이용해 이 활동이 데이터베이스에 잘 저장됐는지 확인

데이터베이스를 모킹하지 않았다는 점이 중요

데이터베이스마다 고유한 SQL 문법이 있어서이 부분이 문제가 되는 경우 많다 -> 영속성 어댑터 테스트는 실제 데이터베이스를 대상으로 진행해야 한다.

Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있기 때문에 이런 측면에서 아주 유용

시스템 테스트로 주요 경로 테스트하기

@SpringBootTest(webEnvironment = 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());
    }
    
    // 일부 헬퍼 메서드는 생략
}

TestRestTemplate을 이용해서 요청을 보낸다. 테스트를 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신

실제 HTTP 통신을 하는 것처럼 실제 출력 어댑터도 이용, 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야 할 때도 있다 (ex PG사)

시스템 테스트 -> 단위 테스트나 통합 테스트만으로는 알아차리지 못했을 계층 간 매핑 버그 같은 것을 찾아준다.

얼마만큼의 테스트가 충분할까?

라인 커버리지(line coverage)는 테스트 성공을 측정하는 데 있어서는 잘못된 지표

책에서는 얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다고 생각

프로덕션의 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼으면 제대로 가고 있는 것

육각형 아키텍처에서 사용하는 전략

  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
  • 유스케이스를 구현할 때는 단위 테스트로 커버하자
  • 어댑터를 구현할 때는 통합 테스트로 커버하자
  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자

리팩터링할 때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다 -> 개선 해야함

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리 -> 핵심 도메인 로직은 단위 테스트로. 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의

입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점

모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 경고 신호 -> 아키텍처 문제

08 경계 간 매핑하기


매핑에 찬성하는 개발자:

  • 두 계층 간에 매핑을 하지 않으면 양 계층에서 같은 모델을 사용해야 하는데 이렇게 하면 두 계층이 강하게 결합됩니다.

매핑에 반대하는 개발자:

  • 두 계층 간에 매핑을 하게 되면 보일러플레이트 코드를 너무 많이 만들게 돼요. 많은 유스케이스들이 오직 CRUD만 수행하고 계층에 걸쳐 같은 모델을 사용하기 때문에 계층 사이의 매핑은 과합니다.

‘매핑하지 않기’ 전략

전혀 매핑을 하고 있지

Account 클래스는 웹, 애플리케이션, 영속성 계층과 관련된 이유로 인해 변경돼야 하기 때문에 단일 책임 원칙을 위반 (Json, JPA Annotation 관련)

모든 계층이 정확히 같은 구조의, 정확히 같은 정보를 필요로 한다면 ‘매핑하지 않기’ 전략은 완벽한 선택지 -> 간단한 CRUD

‘양방향’ 매핑 전략

'양방향’ 매핑의 또 다른 장점은 개념적으로는 ‘매핑하지 않기’ 전략 다음으로 간단한 전략 -> 매핑 책임이 명확

단점

  • 너무 많은 보일러플레이트 코드
  • 도메인 모델이 계층 경계를 넘어서 통신하는 데 사용 -> 바깥쪽 계층의 요구에 따른 변경에 취약

‘완전’ 매핑 전략

각 연산마다 별도의 입출력 모델을 사용

웹 계층은 입력을 애플리케이션 계층의 커맨드 객체로 매핑할 책임을 가지고 있다

애플리케이션 계층은 커맨드 객체를 유스케이스에 따라 도메인 모델을 변경하기 위해 필요한 무엇인가로 매핑할 책임을 가진다

웹 계층(혹은 인커밍 어댑터 종류 중 아무거나)과 애플리케이션 계층 사이에서 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 빛을 발한다 -> 전역 X, 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드 때문에 사용하지 않는 것이 좋다

연산의 입력 모델에 대해서만 이 매핑을 사용하고. 도메인 객체를 그대로 출력 모델로 사용하는 것도 좋다

‘단방향’ 매핑 전략

모든 계층의 모델들이 같은 인터페이스를 구현

도메인 모델 자체는 풍부한 행동을 구현할 수 있고, 애플리케이션 계층 내의 서비스에서 이러한 행동에 접근할 수 있다 -> 도메인 객체를 바깥 계층으로 전달하고 싶으면 매핑 없이할수 있다 -> 도메인 객체가 인커밍/아웃고잉 포트가 기대하는 대로 상태 인터페이스를 구현하고 있기 때문

팩터리(factory)라는 DDD 개념과 잘 어울린다. DDD 용어인 팩터리는 어떤 특정한 상태로부터 도메인 객체를 재구성할 책임을 가지고 있다

층 간의 모델이 비슷할 때 가장 효과적

언제 어떤 매핑 전략을 사용할 것인가?

언제 어떤 전략을 사용할지 결정하려면 팀 내에서 합의할 수 있는 가이드라인을 정해둬야 한다

왜 해당 전략을 최우선으로 택해야 하는지도 설명할 수 있어야 한다

매핑 전략은 개발 시간과 유지 보수성에 대한 트레이드오프 인듯

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

상황별로 매핑 전략을 선택하는 것은 모든 상황에 같은 매핑 전략을 사용하는 것보다 분명 더 어렵고 더 많은 커뮤니케이션을 필요로 하겠지만 매핑 가이드라인이 있는한, 코드가 정확히 해야 하는 일만 수행하면서도 더 유지보수하기 쉬운 코드로 팀에 보상이 되어돌아올 것

profile
항상 노력하는 백엔드 개발자

0개의 댓글