Skip to content

Latest commit

 

History

History
386 lines (278 loc) · 14.7 KB

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

File metadata and controls

386 lines (278 loc) · 14.7 KB

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

이번 장에서는 육각형 아키텍처에서의 테스트 전략에 대해 이야기한다. 아키텍처의 각 요소들을 테스트 할 수 있는 테스트 유형에 대해 논의한다.


테스트 피라미드

Untitled

  • 그림은 테스트의 종류와 어떤 종류의 테스트를 목표로 해야 하는지 결정하는 데 도움을 준다

  • 기본 전제

    1. 만드는 비용이 적어야 한다.
    2. 유지보수 하기 쉬워야 한다.
    3. 빨리 실행돼야 한다.
    4. 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다. ⇒ 하나의 단위(클래스)가 재대로 동작하는 지 확인할 수 있는 단위 테스트들이다.
    • 피라미드가 위로 올라갈 수록 테스트 비용이 더 비싸지고 실행이 더 느려지며, 설정 에러로 인해 깨지기 더 쉬워진다. ⇒ 테스트가 비싸질 수록 테스트 커버리지의 목표는 낮게 잡아야 한다. ⇒ 기능보다 테스트를 만드는데 시간을 더 쓰게되기 때문이다

피라미드 계층별 특징


  • 단위 테스트

    • 피라미드의 토대에 해당한다.
    • 하나의 클래스를 인스턴스화 하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. ** 다른 클레스에 의존하는 경우 의존되는 클래스를 인스턴스화 하지 않고 mock으로 대체한다.
  • 통합 테스트

    • 연결된 여러 유닛을 인스턴스화 한다 ⇒ 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 잘 작동 하는지 검증 ⇒ 두 계층 간의 경계를 걸쳐서 테스트 할 수 있다 ⇒ 객체 네트워크가 완전하지 않거나 어떤 시점에는 mock을 대상으로 수행한다
  • 시스템 테스트

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

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


  • 4장에서 본 Account entitiy 를 테스트한다
class AccountTest{
	@Test
	void withdrawalSucceeds(){
			//step1. Account 인스턴스화
			//step2. withdraw() 메서드 호출 => 출금 성공 검증
			//step3. Account 객체의 상태에 기대되는 부수효과 잘 일어났는지 확인
	}
}
  • 특징
    • 만들고 이해하기 쉽다. 아주 빠르고 간단하게 실행된다.
    • 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 적절한 방법이다.
    • 도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않음 ⇒ 도메인 엔티티의 검증에 있어 다른 종류의 테스트는 필요없다

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


  • 4장에서 본 SendMoneyService 의 테스트를 살펴본다.
class SendMoneyServiceTest{
	@Test
	void trasactionSucceeds(){
		/*
			출금계좌 lock (다른 트랜젝션에 의해 변경 X)
			필요한 만큼 출금
			입금계좌 lock , 출금된 만큼 입금
			두 계좌의 lock을 해제
			이후 결과 검증
		*/
		# Mockito 라이브러리를 이용해 given...() 메서드의 mock 객체를 생성
		# then() : mock 객체에 대해 특정 메서드 호출됐는지 검증하는 메서드 

		/*
			given/when/then 섹션으로 나눠서 테스트한다
		*/

		# given

			 입출금 Account 인스턴스를 각각 생성후 
			 given...()로 시작되는 메서드의 인자로 넣는다 
			(givenWithdrawalWillSucceed - 출금 ,givenDepositWillSucceed - 입금)

			 SendMoneyCommand 인스턴스 생성--> 유스케이스의 입력으로 사용

		# when

			 SendMoneyCommand 인스턴스를 인자로 받아 유스케이스의 sendMoney() 호출

		# then

			 then() 메서드를 사용하여...		
       트랜잭션의 성공 여부 검증
			 이후 Account 에 걸린 lock을 걸고 해제하는 메서드 작동 검증	
	}
}
  • 유스케이스 서비스는 상태가 없기 때문에 then 섹션에서 특정 상태를 검증할 수 없다 ⇒ 상호작용 여부만 검증한다. ⇒ 이는 테스트가 행동 , 코드 구조변경에 취약해짐을 의미한다 (코드의 변경이 테스트의 변경으로 이어진다)
    • 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다. ⇒ 모든 동작을 검증하기 보다는, 핵심 기능만 골라 집중해서 테스트 하는 것이 좋다

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


  1. 웹 어댑터는 JSON 문자열 등의 형태로 HTTP 를 통해 입력 받고 , 입력에 대한 유효성을 검증한다.
  2. 이후 유스케이스에서 사용할 수 있는 포맷으로 매핑하고 , 유스케이스에 전달한다.
  3. 유스케이스의 결과를 JSON으로 매핑하고 HTTP응답을 통해 클라이언트에 반환 한다.
  • 웹 어댑터의 테스트는 위의 모든 단계들이 기대한 대로 동작하는지 검증해야 한다.

    class SendMoneyControllerTest {
        @Autowired
        private MockMvc mockMvc;
    
        @MockBean
        private SendMoneyUseCase sendMoneyUseCase;
    
        @Test
        void sendMoney() throws Exception {
    
            long sourceAccountId = 1L;
            long targetAccountId = 2L;
            long amount = 500;
            ResultActions resultActions = mockMvc.perform(
                post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}"
    									,sourceAccountId, targetAccountId, amount)
                     .header("Content-Type","application/json"))
    								 .andExpect(statuc().isOK())	
            );
    
            then(sendMoneyUseCase).should()
                    .sendMoney(eq(new SendMoneyCommand(
                            new AccountId(sourceAccountId),
                            new AccountId(targetAccountId),
                            Money.of(amount)
                    )));
        }
    }
  • mockMvc

    Mocking 한 HTTP 요청을 웹 컨트롤러에 보낸다. 요청 바디는 JSON 문자열의 형태로 입력객체를 포함한다 ⇒ 실제 HTTP 프로토콜을 사용하는 것은 아니지만 ,실제 프로토콜에 대한 부분은 프레임워크가 적절히 수행하고 있다고 믿는다.

  • isOk()

    ⇒ HTTP 응답의 상태가 200임을 검증하고 , 모킹한 유스케이스의 호출을 검증한다.

  • then( …

  •   ⇒ 유스케이스의 입력 유효성과 , 실제 호출을 검증한다.
    

왜 단위 테스트가 아닌 통합 테스트일까?

컨트롤러 클래스만 테스트하는 것처럼 보이지만, @WebMvcTest가 스프링이 특정 요청 경로, 자바↔ JSON 매핑, 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 = 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 : 특정 객체가 이 네트워크에 추가됐다는 것을 명확하게 표현할 수 있다.

    ⇒ 이 객체들은 테스트 상에서 어댑터가 도메인 객체를 데이터 베이스 객체로 매핑하는 등의 작업에 필요함

  • loadAccount()

    ⇒ 어댑터 API 로 계좌를 가저온 후 SQL 스크립트에서 설정한 상태값과 일치하는지 검증한다

  • updateActivities()

    ⇒ 새로운 활동을 가진 Account 인스턴스를 어댑터로 전달 , 이후 잘 저장됐는지 확인한다

<데이터 베이스를 모킹하지 않았다>

이유는?

  • 데이터 베이스를 모킹했더라도 높은 커버리지(많은 코드라인을 테스트)를 보여줬을 것이다. 하지만 , 여전히 실제 데이터베이스와 연동했을 때 SQL 구문의 오류, 데이터베이스 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길 확률이 높아진다.
  • 스프링은 인메모리 데이터베이스를 테스트에서 사용한다 ⇒ 아무것도 설정할 필요가 없어 실용적임 하지만 , 실제 데이터 베이스에서 문제가 생길 가능성이 높다. ex) 데이터베이스 마다 고유한 SQL 문법으로 인한 문제

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


피라미드 최상단에 있는 시스템 테스트는 전체 어플리케이션을 띄우고 API를 통해 요청을 보내고 , 모든 계층이 조화롭게 잘 동작하는지 검증한다.

@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());
	}

	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 : 스프링이 어플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다.
  • @test method : 요청을 생성하여 어플리케이션에 보내고 , 응답 상태와 계좌의 새로운 잔고를 검증한다.
  • 웹 어댑터 테스트처럼 MockMve를 이용하는 것이 아니라 TestRestTemplate을 이용해 요청을 보낸다 . ⇒ 프로덕션 환경에 조금 더 가깝게 실제 HTTP 통신을 하는 것이다.
  • 다른 출력 어댑터 들도 필요할 수 있다 ⇒ 육각형 아키텍처 에서는 몇 개의 출력 포트 인터페이스만 모킹하면 된다
  • 단위 통합 테스트보다 ,실제 사용자를 잘 흉내 내기 때문에 사용자 관점에서 어플리케이션을 검증할 수 있다.
  • 단위, 통합 테스트에서 알아차리지 못했을 게층 간 매핑 버그 등 또 다른 종류의 버그를 발견하여 수정 할 수 있게 해준다

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


  • 라인 커버리지 : 얼마만큼의 실제 코드를 테스트가 커버하고 있는지 ⇒ 테스트의 성공을 축정하는 데 있어서는 잘못된 지표다.
  • 높은 커버리지 보다는 얼마나 마음 편하게 배포할 수 있느냐를 테스트의 성공 지표로 삼는 것이 낫다. ⇒ 자주 배포할 수록 테스트를 더 신뢰할 수 있다.
  • 잦은 배포 ⇒ 테스트가 잡지 못한 버그에 대해 생각 , 답변 기록 , 해당 케이스를 커버하는 테스트 추가 위 같은 상황이 반복 되면 , 남겨둔 기록으로 시간이 지날수록 상황이 개선되고 있음을 증명해 줄 것이다.

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


  • 도메인 엔티티를 구현할 때 ⇒ 단위 테스트로 커버
  • 유스케이스를 구현할 때 ⇒ 단위 테스트로 커버
  • 어댑터를 구현할 때 ⇒ 통합 테스트로 커버
  • 사용자가 취할 수 있는 중요 어플리케이션 경로 ⇒ 시스텝 테스트로 커버
💡 **코드의 변경에 큰 영향을 받지 않는 테스트 코드의 작성이 개발중에 이뤄지는 것이 가장 바람직 하다.**

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


  • 육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리한다. [명확한 테스트 전략을 정의할 수 있다]
    • 핵심 도메인 로직 ⇒ 단위 테스트
    • 어댑터 ⇒ 통합 테스트
  • 입/출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다.
  • 포트 인터페이스가 적은 메서드를 제공한다 ⇒ 모킹하는 것이 아주 쉬워진다.