아키텍처 요소 테스트하기

테스트 피라미드

비용이 많이 드는 테스트는 지양하고 비용이 적게 드는 테스트를 많이 만들어야 한다. 만드는 비용이 적고, 유지보수하기 쉽고, 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다는 것이다.

💡 좋은 테스트의 속성(FIRST) F(Fast) : 빠르게 동작해라. I(Isolated) : 고립시켜라. R(Repeatable): 반복 가능해야 한다. S(Self-validating) : 스스로 검증 가능해야 한다. T(Timely): 적시에 사용해야 한다.

  • 단위 테스트(Unit)

    • 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다.

    • 테스트 중인 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 mock으로 대체한다.

  • 통합테스트(Integration)

    • 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한 대로 잘 동작하는지 검증한다.

  • )시스템 테스트(E2E)

    • 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증

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

package com.book.cleanarchitecture.buckpal.account.domain;

import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static com.book.cleanarchitecture.buckpal.common.AccountTestData.defaultAccount;
import static com.book.cleanarchitecture.buckpal.common.ActivityTestData.defaultActivity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

class AccountTest {

    private final AccountId accountId = new AccountId(1L);
    private final 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();

    @Test
    @DisplayName("계좌의 금액 계산하는 메서드")
    void calculateBalance() {
        Money balance = account.calculateBalance();

        assertThat(balance).isEqualTo(Money.of(1555L));
    }

    @Test
    @DisplayName("계좌 송금 성공 테스트")
    void withdrawalSucceeds() {
        boolean success = account.withdraw(Money.of(555L), new AccountId(99L));

        assertAll(
                () -> assertThat(success).isTrue(),
                () -> assertThat(account.getActivities()).hasSize(3),
                () -> assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L))
        );
    }

    @Test
    @DisplayName("계좌 송금 실패 테스트 (초과된 금액)")
    void withdrawalFailure() {
        boolean failure = account.withdraw(Money.of(1556L), new AccountId(99L));

        assertAll(
                () -> assertThat(failure).isFalse(),
                () -> assertThat(account.getActivities()).hasSize(2),
                () -> assertThat(account.calculateBalance()).isEqualTo(Money.of(1555L))
        );
    }

    @Test
    @DisplayName("계좌 예금 성공 테스트")
    void depositSuccess() {
        boolean success = account.deposit(Money.of(445L), new AccountId(99L));

        assertAll(
                () -> assertThat(success).isTrue(),
                () -> assertThat(account.getActivities()).hasSize(3),
                () -> assertThat(account.calculateBalance()).isEqualTo(Money.of(2000L))
        );
    }
}

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

package com.book.cleanarchitecture.buckpal.account.application.service;

import com.book.cleanarchitecture.buckpal.account.application.port.in.SendMoneyCommand;
import com.book.cleanarchitecture.buckpal.account.application.port.out.AccountLock;
import com.book.cleanarchitecture.buckpal.account.application.port.out.LoadAccountPort;
import com.book.cleanarchitecture.buckpal.account.application.port.out.UpdateAccountStatePort;
import com.book.cleanarchitecture.buckpal.account.domain.Account;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;

class SendMoneyServiceTest {
    private final LoadAccountPort loadAccountPort = mock(LoadAccountPort.class);
    private final AccountLock accountLock = mock(AccountLock.class);
    private final UpdateAccountStatePort updateAccountStatePort = mock(UpdateAccountStatePort.class);
    private final SendMoneyService sendMoneyService = new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());

    @Test
    @DisplayName("계좌 송금 실패 테스트")
    void givenWithdrawalFails_thenOnlySourceAccountIsLockedAndReleased() {
        AccountId sourceAccountId = new AccountId(41L);
        Account sourceAccount = givenAnAccountWithId(sourceAccountId);

        AccountId targetAccountId = new AccountId(42L);
        Account targetAccount = givenAnAccountWithId(targetAccountId);

        givenWithdrawalWillFail(sourceAccount);
        givenDepositWillSucceed(targetAccount);

        SendMoneyCommand command = new SendMoneyCommand(sourceAccountId, targetAccountId, Money.of(300L));

        boolean success = sendMoneyService.sendMoney(command);

        assertThat(success).isFalse();

        then(accountLock).should().lockAccount(eq(sourceAccountId));
        then(accountLock).should().releaseAccount(eq(sourceAccountId));
        then(accountLock).should(times(0)).lockAccount(eq(targetAccountId));
    }

    @Test
    @DisplayName("계좌송금 성공 테스트")
    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)
                .filter(Optional::isPresent)
                .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 givenWithdrawalWillFail(Account account) {
        given(account.withdraw(any(Money.class), any(AccountId.class))).willReturn(false);
    }

    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 = 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));
    }
}
  • 테스트 중인 유스케이스 서비스는 상태가 없기 때문에 then 섹션에서 특정 상태를 검증할 수 없다. 대신 테스트는 서비스가 (모킹된) 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 이는 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다. 자연스럽게 코드가 리팩터링되면 테스트도 변경될 확률이 높아진다. 그렇기 때문에, 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다. 앞의 예제처럼 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는 것이좋다. 만약 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 한다. 이는 테스트의 가치를 떨어뜨리는 일이다.

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

package com.book.cleanarchitecture.buckpal.account.adapter.in.web;

import com.book.cleanarchitecture.buckpal.account.application.port.in.SendMoneyCommand;
import com.book.cleanarchitecture.buckpal.account.application.port.in.SendMoneyUseCase;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(controllers = SendMoneyController.class)
class SendMoneyControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private SendMoneyUseCase sendMoneyUseCase;

    @Test
    @DisplayName("송금 테스트")
    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))));
    }
}
  • 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 때문에 격리된 상태로 테스트하기보다는 이 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다.

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

INSERT INTO accounts (id)
VALUES (1);
INSERT INTO accounts (id)
VALUES (2);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1, '2018-08-08 08:00:00.0', 1, 1, 2, 500);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (2, '2018-08-08 08:00:00.0', 2, 1, 2, 500);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (3, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (4, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (5, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (6, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (7, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (8, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import com.book.cleanarchitecture.buckpal.account.domain.Account;
import com.book.cleanarchitecture.buckpal.account.domain.ActivityWindow;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;

import java.time.LocalDateTime;

import static com.book.cleanarchitecture.buckpal.common.AccountTestData.defaultAccount;
import static com.book.cleanarchitecture.buckpal.common.ActivityTestData.defaultActivity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

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

        assertAll(
                () -> assertThat(account.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);

        ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);

        assertAll(
                () -> assertThat(activityRepository.count()).isEqualTo(1),
                () -> assertThat(savedActivity.getAmount()).isEqualTo(1L)
        );
    }
}
  • 더미데이터로 테스트 하는 것이 더 효율적이다.

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

INSERT INTO accounts (id)
VALUES (1);
INSERT INTO accounts (id)
VALUES (2);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1001, '2018-08-08 08:00:00.0', 1, 1, 2, 500);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1002, '2018-08-08 08:00:00.0', 2, 1, 2, 500);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1003, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1004, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1005, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1006, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1007, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);

INSERT INTO activities (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
VALUES (1008, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
package com.book.cleanarchitecture.buckpal;

import com.book.cleanarchitecture.buckpal.account.application.port.out.LoadAccountPort;
import com.book.cleanarchitecture.buckpal.account.domain.Account;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.jdbc.Sql;

import java.time.LocalDateTime;

import static org.assertj.core.api.BDDAssertions.then;

@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<Object> 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<Object> 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 AccountId sourceAccountId() {
        return new AccountId(1L);
    }

    private AccountId targetAccountId() {
        return new AccountId(2L);
    }
}
  • 일반적으로 시스템 테스트는 단위 테스트와 통합 테스트가 발견하는 버그와는 또 다른 종류의 버그를 발견해서 수정할 수 있게 해준다. 예를 들어, 단위 테스트나 통합 테스트만으로는 알아차리지 못했을 계층 간 매핑 버그 같은 것들 말이다.

  • 시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들 때 더 빛이 난다. 각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 경로를 의미한다. 시스템 테스트를 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정할 수 있고, 배포될 준비가 됐다는 확신을 가질 수 있다.

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

  • 라인 커버리지는 테스트 성공을 측정하는 데 있어서는 잘못된 지표다.

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

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

  • 유스케이스를 구현할 떄는 단위 테스트로 커버하자.

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

  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자.

Last updated