Spring

TDD , 테스트 팁

sehunbang 2024. 3. 20. 12:14

Intro

 

1. 테스트 이론

단위 테스트와 통합 테스트

  • 단위 테스트 : 가장 작은 단위의 테스트 방식. 메서드 단위로 테스트를 수행하고, 의도한 결과값이 나오는지 확인하는 수준 (ex: controller, service, repository… 격리해서 따로따로)
  • 통합 테스트 : 어플리케이션이 정상적으로 동작하는지 확인. 여러모듈을 함께 테스트 (ex: controller 메소드를 호출하면 controller → service→ repository 모두 수행)

TDD (Test Driven Development)

  • 코드 유지보수 및 운영 환경에서의 에러를 방지하기 위해 단위별로 메소드를 검증하는 개발방법
    • TDD를 하면 버그가 없나요? No!
    • TDD는 모두한테 필요한가요? No!

 

F.I.R.S.T 원칙

💡 좋은 테스트를 위한 F.I.R.S.T 원칙

  • Fast : 단위 테스트는 빨라야 한다.
  • Independent : 단위 테스트는 각각의 테스트가 연관되지 않고, 고립되어야 한다. 즉 독립적 이어야 한다.
  • Repeatable : 단위테스트는 반복 가능해야한다. 반복적으로 시행해도 결과는 같아야 한다.
  • Self-validating : 자체적으로 테스트 결과가 도출되야한다. (개발자가 print 찍어서 비교하는게 아니고 자동으로 수행되며 성공/실패 의 결과가 나오는 것)
  • Timely : 단위 테스트는 실제 코드보다 먼저 구현해야한다. (TDD일 경우에만 해당) 

Given-When-Then 패턴

💡 Test 실행을 위한 Given-When-Then 패턴

  • Given : 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황 또는 행동을 정의합니다.
  • When: 실제 테스트를 하는 메소드가 호출되며 테스트를 통한 결과값을 가져옵니다.
  • Then: When 단계에서 나온 결과값을 검증하는 작업을 수행합니다. 

 

JUnit + Mockito

  • JUnit : Java 에서 사용되는 대표적인 테스트 프레임워크. 자바 개발자 93% 가 사용한다는 통계가 있습니다. Assertion을 이용하여 예상하는 값과 실제 도출된 값을 검증할 수 있는 기능을 지원합니다.
  • Mockito : Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크 입니다. Mock 객체는 진짜 객체와 비슷하게 동작하지만 프로그래머가 직접 행동을 관리하는 객체입니다.

 

 

💡 Mockito에서 Mock(가짜) 객체의 의존성 주입을 위해서는 크게 3가지 어노테이션이 사용됩니다.

  • @Mock: Mock 객체를 만들어 반환해주는 어노테이션
  • @Spy: Stub하지 않은 메소드들은 원본 메소드 그대로 사용하는 어노테이션
  • @InjectMocks: @Mock 또는 @Spy로 생성된 가짜 객체를 자동으로 주입시켜주는 어노테이션
  • @MockBean: 기존에 사용되던 Bean의 껍데기만 가져오고 내부의 구현 부분은 모두 사용자에게 위임 (Spring 에 자동으로 Bean 으로 주입됨)
  • @SpyBean: given에서 선언한 코드 외에는 전부 실제 객체의 것을 사용합니다.
    • @MockBean은 given에서 선언한 코드 외에는 전부 사용할 수 없습니다.

예를 들어 UserController에 대한 단위 테스트를 작성하고자 할 때, UserService를 사용하고 있다면 @Mock 어노테이션을 통해 가짜 UserService를 만들고, @InjectMocks를 통해 UserController에 이를 주입시킬 수 있습니다.

 

 

테스트를 작성해야 하는 이유?

  • 디버깅 감소
    • 테스트를 한 번 작성해두면 프로젝트가 살아 있는 내내 값비싼 결함을 예방해주고, 짜증 나는 디버깅에서 해방시켜준다.
  • 자신 있는 변경
    • 좋은 테스트로 무장한 프로젝트는 자신감을 갖고 변경하고 리팩토링 할 수 있다.
  • 더 나은 문서자료
    • 하나의 행위만 집중해 검증하는 명확한 테스트는 마치 실행 가능한 문서와 같다.
  • 더 단순한 리뷰
    • 리뷰어가 변경된 코드가 제대로 작동하는지 검증하는 시간을 줄여준다.
  • 사려 깊은 설계
    • 새로 작성한 코드의 테스트를 작성하는 일은 실질적으로 해당 코드의 API가 잘 설계되었는지를 시험하는 행위이다.
  • 고품질의 릴리스를 빠르게
    • 건실한 자동 테스트 스위트를 갖춘 팀은 새로운 버전을 릴리즈하며 불안에 떨지 않는다.

 

Test Suite 설계하기

구글에서 정의 하는 테스트

모든 테스트 케이스에는 두 가지 독립된 요소가 있습니다. 바로 크기범위입니다.

  • 크기 : 테스트 케이스 하나를 실행하는데 필요한 자원. (메모리, 프로세스, 시간 등..)
  • 범위 : 검증하는 코드량
  • 크기 관점에서 테스트 종류 (구글은 전통적인 용어인 “단위 테스트”와 “통합 테스트”대신 이 정의를 사용합니다.)
    • 작은 테스트
      • 테스트를 느려지게 하거나 비결정적으로 만드는 주요 원인을 제거한 테스트
      • 제약사항
        • 하나의 프로세스에서 실행되어야 한다. (언어에 따라 하나의 스레드까지 좁히는 경우도 많다.)
        • sleep, I/O 연산 같은 블로킹 호출을 사용해서는 안 된다.
          • 블로킹 호출이 필요하다면, 테스트 대역을 사용해야 한다.
    • 중간 크기 테스트
      • 작은 테스트보다 느리고 비결정적이지만 더 다양한 케이스를 테스트 할 수 있다.
      • 제약사항
        • 여러 프로세스와 스레드를 활용할 수 있다.
        • 로컬 호스트로의 네트워크 호출 같은 블로킹 호출도 이용할 수 있다.
        • 외부 시스템과의 통신은 불가능하다.
    • 큰 테스트
      • 더 유연해지는 만큼 테스트 속도가 느려지고 비결정성이 커진다. 주로 종단간 테스트에 사용
      • 제약사항 없음
      • 구글은 큰 테스트를 작은 테스트나 중간 테스트와 분리하여, 빌드나 릴리스 때만 수행되도록 함.
  • 범위 관점에서 테스트 종류
    • 좁은 범위 테스트
      • 독립된 클래스나 메서드같이 코드베이스 중 작은 일부 로직 검증
    • 중간 범위 테스트
      • 적은 수의 컴포넌트들 사이의 상호작용을 검증하도록 설계
    • 넓은 범위 테스트
      • 시스템의 서로 다른 부분들 사이의 상호작용 검증

 

이상적인 Test Suite

 

구글은 되도록 작은 테스트를 추구하며, 마찬가지로 좁은 범위 테스트를 추구한다. 실제로 구글은 비지니스 로직 대부분을 검증하는 좁은 범위의 단위 테스트가 80%, 둘 이상의 구성요소 간 상호작용을 검증하는 중간 범위의 통합 테스트가 15%, 전체 시스템을 검증하는 종단간 테스트가 5% 정도가 되도록 합니다.

 

 

이상적인 test suite

 

안 좋은 test suite

 

 

  • 테스트 스위트 품질 높이기
    • 테스트 스위트 품질 지표로 코드 커버리지를 기준으로 삼는건 바람직하지 않다.
    • 다음의 질문을 던져보자.
      • 고객이 이용할 모든 기능이 제대로 동작한다고 확신하나요?
      • 의존하는 외부 시스템이나 모듈에 파괴적인 변경이 일어났을, 즉 호환성이 깨졌을 때 바로 인지하고 대응할 수 있을까요?
      • 작성해둔 테스트는 안정적이고 믿을만한가요?

 

 

2. 생산성을 높여주는 단위 테스트

단위 테스트

  • 일반적으로 작고, 범위가 좁은 테스트를 의미한다.
  • 테스트의 가장 중요한 목적
    • 버그 예방
    • 엔지니어의 생산성 개선

 

유지보수하기 쉬운 단위 테스트 작성

테스트는 개발자가 믿을 수 있을 때 의미있습니다. 자주 깨져 생산성을 저하시키는 테스트는 오히려 독이 됩니다.

유지보수 하기 어려운 테스트(엔지니어의 생산성을 저하시키는 테스트)는 다음 2가지 특징을 가지고 있습니다.

  • 깨지기 쉬운 테스트 : 검증 대상과 관련 없는 변경 때문에 실패하는 테스트
  • 불명확한 테스트 : 무엇이 잘못되어 실패했는지, 어떻게 고쳐야 하는지를 파악하기 어려운 테스트

깨지기 쉬운 테스트 예방하기

  • 이상적인 테스트
    • 한 번 작성한 후로는 대상 시스템의 요구사항이 바뀌지 않는 한 절대 수정할 일이 없어야 한다.
    • ex)
      • 순수 리팩터링(외부 인터페이스는 놔두고 내부만 리팩터링하는 경우) → 테스트는 변경되지 않아야한다.
      • 새로운 기능 추가 → 테스트는 변경되지 않아야 한다. & 새로운 테스트 추가
      • 버그 수정 → 기존 테스트 변경 X & 누락된 테스트 추가
      • 행위 변경 → 기존 테스트 변경
  • 공개 API를 이용해 테스트하기
    • 테스트도 시스템을 다른 사용자 코드와 똑같은 방식으로 호출하기
public void processTransaction(Transaction transaction) {
  if (isValid(transaction) {
    saveToDatabase(transaction);
  }
}

private boolean isValid(Transactiont t) {
  return t.getAmount() < t.getSender().getBalance();
}

private void saveToDatabase(Transaction t) {
  String s = t.getSender() + "," + t.getReceipient() + "," + t.getAmount();
  database.put(t.getId(), s);
}

public void setAccountBalance(String accountName, int balance) {
  // 잔고를 데이터베이스에 직접 기록한다.
}

public void getAccountBalance(String accountName) {
  // 계좌 잔고를 확인하기 위해 데이터베이스로부터 거래 정보를 읽어온다.
}

 

 

Bad Practice (내부 구현에 의존적인 테스트)

@Test
public void emptyAccountShouldNotBeValid() {
  assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT)
      .isFalse();
}

@Test
public void shouldSaveSerializeData() {
  processor.saveDatabase(new Transaction()
      .setId(123)
      .setSender("me)
          .setRecipient("you)
              .setAmount(100));
  assert(database.get(123)).isEqualTo("me,you,100");
}
  • 깨지기 쉬운 테스트다.
    • 내부 메서드의 이름을 바꾸거나 일부 로직을 도우미 클래스로 빼거나 직렬화 포맷을 바꾸는 등. 리팩토링을 할 때마다 매번 실패할 것이다. → 생산성 저하..

Best Practice (공개 API로 테스트)

@Test
public void shouldTransferFunds() {
  processor.setAccountBalance("me", 150);
  processor.setAccountBalance("you", 20);

  processor.processTransaction(newTransaction()
      .setSender("me")
      .setRecipient("you")
      .setAmount(100));

  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(120);
}

@Test
public void shouldNotPerformInvalidTransactions() {
  processor.setAccountBalance("me", 50);
  processor.setAccountBalance("you", 20);

  processor.processTransction(newTransaction()
      .setSender("me")
      .setReceipient("you")
      .setAmount(100));

  assertThat(processor.getAccountBalance("me").isEqualTo(50);
  assertThat(processor.getAccountBalance("you").isEqualTo(20);
}
  • 현실적이라 잘 깨지지 않는 테스트다.
    • 실제로 행위가 변경될 경우에만 테스트가 깨진다.
  • 공개 API 정하는 Tip
    • 소수의 다른 클래스를 보조하는 용도가 다인 메서드나 클래스라면 독립된 단위로 생각하지 않는게 좋다. 즉, 직접 테스트하지 말고 이들이 보조하는 클래스를 통해 우회적으로 테스트하는 것이 좋다.
    • 소유자의 통제 없이 누구도 접근할 수 있게 설계된 패키지나 클래스라면 거의 예외 없이 직접 테스트해야 하는 단위이다.
    • 소유자만이 접근할 수 있지만 다방면으로 유용한 기능을 제공하도록 설계된 패키지나 클래스는 직접 테스트해야 하는 단위로 봐야 한다.
  • 상호작용이 아니라 상태를 테스트하기
    • 상태 테스트 : 메소드 호출 후 시스템 자체를 관찰한다.
    • 상호작용 테스트 : 호출을 처리하는 과정에서 시스템이 다른 모듈들과 협력하여 기대한 일련의 동작을 수행하는지를 확인 한다.
    • 대체로 상호작용 테스트는 상태 테스트보다 깨지기 쉽습니다. 왜냐하면 내부 구현에 의존하기 때문입니다.
    • example
      • 상호 작용 테스트 (깨지기 쉽다.)
@Test
public void shouldWriteToDatabase() {
  account.createUser("foobar");
  verify(database).put("foobar");
}
  • 테스트가 잘 동작하지 않는 케이스
    • 시스템에 버그가 있어 레코드가 쓰인 직후 삭제돼도 테스트는 성공할 것이다. (실패해야 하는 상황)
    • 시스템을 리팩터링하여 같은 기능을 다른 API를 호출해 수행하도록 바꿨다면 이 테스트는 실패합니다. (성공해야 하는 케이스)
  • 상태 테스트
@Test
public void shouldCreateUser() {
  accounts.createUser("foobar");
  assertThat(account.getUser("foobar").isNotNull);
}

 

 

명확한 테스트 작성하기

테스트가 실패한다는 건 엔지니어에게 유용한 신호를 주는 것이며, 테스트의 존재 가치를 증명하는 가장 주요한 수단 중 하나입니다.

  • 테스트 실패하는 케이스
    • 대상 시스템에 문제가 있거나 불완전함. (테스트가 자신의 역할을 하고 있는 상황)
    • 테스트 자체에 결함이 있는 경우. 이 때 대상 시스템에는 아무런 문제가 없다. (테스트가 깨지기 쉬운 테스트의 경우)

따라서 테스트가 깨지면 위 2 케이스 중 어떤 케이스인지 빠르게 판단해야 합니다. 그리고 이 일을 얼마나 빠르게 마치느냐는 테스트의 명확성에 달렸습니다.

  • 명확한 테스트 : 존재 이유와 실패 원인을 엔지니어가 곧바로 알아 차릴 수 있는 테스트
  • 완전하고 간결하게 만들기
    • 완전한 테스트 : 결과에 도달하기까지의 논리를 읽는 이가 이해하는 데 필요한 모든 정보를 본문에 담고 있는 테스트
    • 간결한 테스트 : 코드가 산만하지 않고, 관련 없는 정보는 포함하지 않은 테스트
  • 테스트의 구조는 행위가 부각되도록 구성
    • given : 시스템의 설정
    • when : 시스템이 수행할 작업
    • then : 결과를 검증
  • 테스트에 논리를 넣지 말자
    • 테스트 코드에서는 스마트한 로직보다 직설적인 코드를 고집해야 합니다.
    • 서술적이고 의미 있는 테스트를 만들기 위한 약간의 중복은 허용하는 것이 좋습니다.

실패 메시지를 명확하게 작성하자

 

 

테스트와 코드 공유

  • DRY가 아니라 DAMP
    • 테스트는 DRY(Don’t Repeat Yourself)를 고집하는 대신 DAMP(Descriptive And Meaningful Phrase)가 되도록 노력해야 한다.
      • 테스트에서는 DRY가 주는 혜택이 그리 크지 않습니다.

 

 💡 JUnit 생명주기를 이용해 테스트 소스코드 품질을 향상시켜 보기.

  • @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드
  • @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드
  • @AfterAll : 테스트를 종료하면서 호출되는 메서드
  • @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드 </aside>

 

 

4. 결론

  1. 안정적으로 서비스를 개발하기 위해선 테스트는 필수
  2. 테스트를 작성할 때 가장 중요한 것은 개발자가 믿을 수 있는 테스트, 개발자의 생산성을 증가시켜주는 테스트를 작성해야 한다.
    • 깨지지 않는 테스트 & 명확한 테스트를 작성해야 한다.
  3. Repository, Service, Controller에 작성되어 있는 다른 메소드들에 대한 성공 테스트 코드를 작성하기.
  4. 실패하는 테스트 시나리오를 작성해 테스트를 만들기.