본문 바로가기
📙/Study

[만들면서 배우는 클린 아키텍처] 07. 아키텍처 요소 테스트하기

by kiwi_wiki 2024. 10. 15.

육각형 아키텍처의 테스트 전략

테스트 피라미드

비용이 많이 드는 테스트는 지양하고 비용이 적게 드는 테스트를 많이 만들어야 한다. 이 테스트는 하나의 단위(일반적으로 하나의 클래스)가 제대로 동작하는지 확인할 수 있는 단위 테스트들이다.

 

테스트 피라미드는 테스트가 비싸질수록 테스트의 커버리지 목표는 낮게 잡아야 한다는 것을 보여준다. 그러지 않으면 기능을 만드는 것보다 테스트를 만드는 데 시간을 더 쓰게 되기 때문이다.

 

 

 

단위 테스트

피라미드의 토대. 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 목(mock)으로 대체한다.

통합 테스트

연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한 대로 잘 동작하는지 검증한다. 두 계층 간의 경계를 걸쳐서 테스트할 수 있기 때문에 어떤 시점에는 목을 대상으로 수행해야 한다.

시스템 테스트

애플리케이션을 구성하는 모든 객체 네트워크를 가동해 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다.

* 엔드투엔드 테스트: 애플리케이션의 UI를 포함하는 테스트

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

Account를 인스턴스화하고 withdraw() 메서드를 호출해서 출금이 성공했는지 검증하고, Account 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단위 테스트

도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않기 때문에 단위 테스트가 비즈니스 규칙을 검증하기에 가장 적절한 방법이다.

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(1L)).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));
    }
}

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

SendMoney 유스케이스는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 lock을 건다. 출금 계좌에서 돈이 출금되고 나면 똑같이 입금 계좌에 락을 걸고 돈을 입금시킨다. 그리고 두 계좌에서 모두 락을 해제한다.

class SendMoneyServiceTest { 

	// 필드선언은생략
	
	@Test
	void transactionSucceeds() {
		// given: 인스턴스 생성, 유스케이스 입력으로 사용
		Account sourceAccount = givenSourceAccount(); 
		Account targetAccount = givenTargetAccount();

		givenWithdrawalWillSucceed(sourceAccount); 
		givenDepositWillSucceed(targetAccount);

		Money money = Money.of(500L);

		SendMoneyCommand command = new SendMoneyCommand(
			sourceAccount.getId(),
			targetAccount.getId(), 
			money);
            
		// when: 유스케이스 실행
		boolean success = sendMoneyService.sendMoney(command);

		// then: 성공 확인, 특정 메서드가 호출됐는지 검증
		assertThat(success).isTrue();

		AccountId sourceAccountId = sourceAccount.getId(); 
		AccountId targetAccountId = targetAccount.getId();

		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); 
    }
    
    // 헬퍼 메서드는 생략
}

테스트에서 서비스가 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증하고 있다. 이는 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다. 코드가 리팩터링 되면 테스트도 변경될 확률이 높아진다.

테스트에서 어떤 상호작용을 검증하고 싶은지 생각해야 한다. 모든 동작을 검증하는 대신 중요한 핵심만 골라 테스트하는 것이 좋다. 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다  테스트를 변경해야 한다. 이는 테스트의 가치를 떨어뜨리는 일이다.

 

이 테스트는 단위 테스트이긴 하지만 의존성의 상호작용을 테스트하고 있기 때문에 통합테스트에 가깝다. 그렇지만 mock으로 작업하고 있고 실제 의존성을 관리해야 하는 것은 아니기 때문에 통합 테스트에 비해 만들고 유지보수하기 쉽다.

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

웹 어댑터는 JSON 문자열 등의 형태로 HTTP를 통해 입력을 받고, 입력에 대한 유효성을 검증하고, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달한 뒤에 유스케이스의 결과를 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트에 반환한다.

웹 어댑터 테스트에서는 위 단계들이 기대한 대로 동작하는지 검증해야 한다.

@WebMvcTest(controllers= SendMoneyController.class)
class SendMoneyControllerTest {
	
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private SendMoneyUseCase sendMoneyUseCase;
    
    @Test
    void testSendMoney() throws Exception {
    	// 입력 객체를 만들고 목 http 요청을 웹 컨트롤러에 보낸다.
    	mockMvc.perform(
        	post("/accounts/send/{sourceAccountId}/{targetAccountId}/{ammount}", 41L, 42L, 500)
            	.header("Content-Type", "application/json")) // 요청 body 는 json 문자열
                .andExpect(status().isOk()); // 응답의 상태가 200임을 검증
        
        // 모킹한 유스케이스가 잘 호출됐는지 검증
        then(sendMoneyUseCase).should()
        	.sendMoney(eq(new SendMoneyCommand(new AccountId(41L), new AccountId(42L), Money.of(500L))));
    }
}

MockMvc 객체를 이용해 모킹했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니다. 그러나 입력을 JSON에서 SendMoneyCommand 객체로 매핑하는 전 과정은 다루고 있다. 또한 유스케이스가 실제로 호출됐는지 검증했고, HTTP 응답이 기대한 상태를 반환했는지도 검증했다.

 

이 테스트는 하나의 웹 컨트롤러 클래스만 테스트한 것처럼 보이지만 @WebMvcTest 애너테이션은 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만든다. 그리고 테스트에서는 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.

 

웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 때문에 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다. 웹 컨트롤러를 단위 테스트로 테스트하면 모든 매핑, 유효성 검증, HTTP 항목에 대한 커버리지가 낮아지고, 프레임워크를 구성하는 요소들이 운영 환경에서 정상적으로 작동할지 확신할 수 없게 된다.

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

단순히 어댑터의 로직만 검증하고 싶은 게 아니라 데이터베이스 매핑도 검증하고 싶기 때문에 통합 테스트를 적용하는 것이 좋다.

@DataJpaTest // 스프링 데이터 리포지토리들을 포함해서 데이터베이스 점근에 필요한 객체를 인스턴스화
@Import({AccountPersistenceAdapter.class, AccountMapper.class}) // 특정 객체가 이 네트워크에 추가됐음을 명확하게 표현
class AccountPersistenceAdapterTest {

	@Autowired
	private AccountPersistenceAdapter adapterUnderTest;

	@Autowired
	private ActivityRepository activityRepository;

	@Test 
	@Sql("AccountPersistenceAdapterTest.sql") // 데이터베이스를 특정 상태로 만든다
	void loadsAccount() { // 계좌를 가져온 후 스크립트에서 설정한 상태값을 가지고 있는지 검증
		Account account = adapter.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();

		adapter updateActivities(account);

		assertThat(activityRepository.count()).isEqualTo(1);

		ActivityJpaEntity savedActivity = activityRepository.findAll).get(0); 
		assertThat(savedActivity.getAmount()).isEqualTo(1L);
	} 
}

데이터베이스를 모킹하지 않았기 때문에 테스트가 실제로 데이터베이스에 접근한다.

스프링에서는 기본적으로 아무 설정 필요 없이 바로 테스트할 수 있는 인메모리 데이터베이스를 테스트에서 사용한다. 하지만 운영 환경에서는 인메모리 데이터베이스를 사용하지 않는 경우가 많기 때문에 인메모리 데이트베이스에서 테스트가 통과했더라도 실제 데이터베이스에서는 문제가 생길 가능성이 높다.

실제 데이터베이스를 대상으로 테스트를 실행하면 두 개의 다른 데이터베이스 시스템을 신경 쓸 필요가 없다는 장점도 있다. 만약 테스트에서 인메모리 데이터베이스를 사용하면 특정 방식으로 데이터베이스를 설정하거나 데이터베이스별로 두 가지 버전의 데이터베이스 마이그레이션 스크립트를 만들어 둬야 한다.

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

전체 애플리케이션을 띄우고 API를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다.

// 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다. 랜덤 포트로 이 애플리케이션을 띄우도록 설정하고 있다
@SpringBootTest(webEnvironment = WebEnvironment. RANDOM_PORT) 
class SendMoneySystemTest {

	@Autowired
	private TestRestTemplate restTemplate; // 테스트를 프로덕션 환경에 더 가깝게 만들기 위해 실제 http 통신을 한다

	@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 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/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}",
		HttpMethod.POST,
		request,
		Object.class, 
		sourceAccountId.getValue(),
		targetAccountId.getValue(), 
		amount.getAmount());
	}
    
	// 일부 헬퍼메서드는 생략 
}

실제 HTTP 통신을 하는 것처럼 실제 출력 어댑터도 이용한다. 다른 시스템가 통신하는 애플리케이션의 경우에는 다른 출력 어댑터들도 있을 수 있다. 시스템 테스트라고 하더라도 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야 할 때도 있다. 이 경우 몇 개의 출력 포트 인터페이스만 모킹 하면 된다.

 

테스트의 가독성을 높이기 위해 로직들을 헬퍼 메서드 안으로 감췄다. 헬퍼 메서드들은 여러 가지 상태를 검증할 때 사용할 수 있는 도메인 특화 언어를 형성한다. 시스템 테스트는 실제 사용자 관점에서 애플리케이션을 검증할 수 있다. 적절한 어휘를 사용하면 프로그래머가 아닌 전문가가 테스트에 대해 생각하고 피드백을 줄 수 있다.

 

시스템 테스트는 단위 테스트와 통합 테스트가 발견하는 버그와는 다른 종류의 계층 간 매핑 버그 등을 발견해서 수정할 수 있게 해준다.

여러 개의 유스케이스를 결합해서 시나리오를 만들 수 있다. 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정할 수 있다.

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

얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다. 더 자주 배포할수록 테스트를 더 신뢰할 수 있다. 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼아야 한다.

 

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

- 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자

- 유스케이스를 구현할 때는 단위 테스트로 커버하자

- 어댑터를 구현할 때는 통합 테스트로 커버하자

- 사용자가 취할 수 있는 중요 애플리케이션 경로(시나리오)는 시스템 테스트로 커버하자

 

새로운 필드를 추가할 때마다 테스트를 고치는데 오랜 시간이 든다면 뭔가 잘못된 것이다. 리팩터링 할 때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다

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

육각형 아키텍처는 도메인 로직과 어댑터를 깔끔하게 분리한다. 덕분에 핵심 도메인 로직은 단위테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다. 입출력 포트는 테스트에서 뚜렷한 모킹 지점이 된다. 포트 인터페이스가 더 적은 메서드를 제공할수록 어떤 메서드를 모킹해야 할지 덜 헷갈린다.

모킹 하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다는 이는 경고 신호다. 테스트는 아키텍처의 문제에 대해 경고하고 유지보수 가능한 코드를 만들기 위한 길로 인도하는 역할도 한다고 할 수 있다.

728x90
반응형