Java

Java/Spring 테스트 - 1

jaamong 2024. 10. 2. 20:30

이 글은 아래 인프런 강의를 듣고 기록을 남기고자 작성하였습니다.

 

Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 강의 | 김우근 - 인프런

김우근 | Spring에 테스트를 넣는 방법을 알려드립니다! 더 나아가 자연스러운 테스트를 할 수 있게 스프링 설계를 변경하는 방법을 배웁니다., 프로젝트 설계를 발전시키는 테스트의 본질을 짚

www.inflearn.com

 

 

 

🔖목차🔖

 

 

TDD

  • 회귀 버그(Regression Bug) → 테스트 자동화
  • 테스트 추가
    • 소프트웨어의 내적 품질 향상 O, 사용자가 체감할 수 있는 외적 품질 향상 X
    • 가시적 성과 지표 → 커버리지(Coverage) 
  • 비결정적인 테스트
    • 테스트를 실행할 때마다 테스트 결과가 다름
    • 어제는 테스트를 통과했는데 오늘은 통과하지 못하는 경우 
  • 레거시 코드에 테스트를 넣는 것 → TDD가 아님
    • 레거시 코드에 테스트를 넣기만 하는 것은 크게 의미가 없을 수도
    • 레거시 코드에 테스트를 넣는 과정은 코드 개선을 하면서 진행해야 의미가 있다 (외부 시스템 의존도 ↓ & 설계 진화)
  • 커버리지 집착 X
    • 생각보다 테스트가 필요 없는 코드가 있을 수도 있다. 커버리지 목적이 아닌, 테스트를 추가했을 때 얻을 수 있는 효용성에 집중하자.
  • ✅테스트가 줄 수 있는 가치
    • 회귀 버그 방지
    • 유연한 설계 (매우 도전적)
      • 테스트가 계속 신호를 보냄
        • 프로젝트가 스프링이나 JPA에 의존적인 상태일 확률 높음
        • 외부 시스템에 의존하는 상황을 피해야 함 → 설계 진화
      • 테스트와 설계는 긴밀한 관계
  • 왜 테스트를 해야 하고, 어떻게 해야 하는지 고민할 것
  • 방향성 - TDD를 논하기 전에 테스트가 가능한 구조로 변경되어야 함

 

 

테스트란

테스트

⇒ 어떤 시스템이 제대로 동작하는지 검증하는 과정

  1. 인수 테스트사람이 직접 사용해 보면서 준비된 체크 리스트를 진행하는 테스트
  2. 자동 테스트테스트 코드라고 불리는 미리 작성된 코드를 보면서 결괏값과 예상값을 비교하는 테스트
    • 주로 `when/given/then` 패턴으로 작성됨

TDD

📌아래 과정을 무한히 반복

  • `RED` : 실패하는 테스트를 먼저 작성한다.
    • 컴파일되는 코드를 만들고 테스트가 실패하는 것까지 확인해야 함
    • 실패하는 테스트를 먼저 작성해야 하므로, 인터페이스를 먼저 만드는 것이 강제됨 → OOP 핵심 원리 중 하나인 행동에 집중하는 것과 동일 ⇒ What/Who 사이클을 고민하게 도와줌
  • `GREEN` : 위 실패하는 테스트를 성공시킨다.
    • 실제로 구현하는 단계
    • 이후 테스트를 실행하면 성공
  • `BLUE` : 리팩토링 한다.

 

장단점

  • 장기적인 관점에서의 장점이 확실함
    • 인터페이스를 먼저 만드는 것이 강제됨
    • 개발 비용 감소
  • 장기적으로 봤을 때 장점이 뚜렷하므로 요구사항이 명확하지 않거나, 서비스의 흥망성쇠가 눈에 보이지 않는 경우에는 적용하기 부담스러움(Ex. 스타트업)
  • 적용 난이도 높음

 

개발자의 고민

  • 무의미한 테스트
    • OOP에서 말하는 행동은 메서드나 함수를 의미하는 것이 아니다. 따라서 모든 메서드를 테스트하는 것보다 중요한 로직을 잘 구분하여 해당 코드를 테스트하는 것이 낫다.
  • 테스트가 불가능한 코드
    • Ex. 사용자가 로그인하고 마지막 로그인 시간을 기록하는 코드 등...
    • 테스트가 불가함 → 설계가 잘못되었을 가능성 높음 ⇒ 설계 발전
  • 느리고 쉽게 깨지는 코드
    • 코드 자체는 별 것 아니지만, 실행 시간이 오래 걸리는 경우
      • 100개가 넘어가는 테스트 코드 중에 이와 같은 경우가 있다면 시간적으로 부담됨

 

테스트 코드의 필요성

좋은 아키텍처란

  • SOLID와 테스트는 굉장히 긴밀한 상관관계를 갖는다 → 서로에게 상호보완적
  • `TEST` for 회귀버그 방지 ↔ `SOLID` for 좋은 아키텍처
    • SOLID 원칙이 지켜지면 경계가 만들어짐 → 이로 인해 회귀 버그를 막을 수 있음
    • 단일 책임 원칙(SRP)
      • 테스트는 명료하고 간단하게 작성해야 하기 때문에, 단일 책임 원칙을 지키게 됨
      • 테스트가 너무 많아져서 어떤 목적의 클래스인지 파악하기 어려운 지점이 생김. 이는 해당 클래스의 책임 과다 문제일 수 있음. → 클래스를 분할해야 하는 시점 ⇒ 자연스러운 책임 분배
    • 개방 폐쇄 원칙(OCP)
      • 테스트 컴포넌트와 프로덕션 컴포넌트를 나눠 작성하게 되고, 필요에 따라 이 컴포넌트를 자유자재로 탈부착이 가능하도록 개발하게 됨
    • 리스코프 치환 원칙(LSP)
      • 이상적으로 테스트는 모든 케이스에 대해커버하고 있으므로, 서브 클래스에 대한 치환 여부를 테스트가 알아서 판단 
    • 인터페이스 분리 원칙(ISP)
      • 테스트는 그 자체로 인터페이스를 직접 사용해 볼 수 있는 환경. 불필요한 의존성을 실제로 확인할 수 있는 샌드박스.
    • 의존관계 역전 원칙(DIP)
      • 가짜 객체를 이용하여 테스트를 작성하려면 의존성이 역전되어 있어야 하는 경우가 생김 

 

필요성

  • 테스트를 어떻게 바라보냐에 따라 테스트의 가치는 다르게 측정됨
    • 낮은 가치: "품질 보증을 위한 도구/회귀 버그 방지"만 바라보고 테스트 코드 작성
    • 높은 가치: "회귀 버그 방지"와 "설계를 위한 도구"를 위한 테스트 코드 작성
RF. Effective unit testing

 

 

테스트 3분류

  • 단위 테스트 → Small(소형) 테스트 (80%)  중요
    • `단일 서버 / 단일 프로세스/ 단일 스레드`에서 실행되는 테스트를 의미
    • `Disk IO`와 `Blocking call`이 있으면 안 됨
      • Ex. `Thread.sleep`이 테스트에 있으면 소형 테스트가 아님
    • 이러한 조건 때문에, 소형 테스트는 결과가 항상 결정적(Deterministic)이고 테스트 속도가 빠름
      • 이러한 테스트를 여러 개 만들어서 코드 커버리지를 높여야 함
      • 프로젝트에 아직 테스트가 없다면, 소형 테스트를 늘릴 수 있는 환경을 만들고, 소형 테스트를 늘려야 함.
  • 통합 테스트 → Medium(중형) 테스트 (15%)
    • 소형 테스트보다 완화된 기준
    • `단일 서버 / 멀티 프로세스 / 멀티 스레드` 사용 가능
      • H2와 같은 테스트용 DB 사용 가능
        • 다시 말하면, 소형 테스트에서는 DB(H2) 사용 불가
      • 소형 테스트보다 느림
      • 멀티 스레드 환경에서 어떻게 동작할지 모르기 때문에 결과가 항상 같다는 보장을 할 수 없음
        • 테스트 결과가 H2 같은 외부 모듈의 동작에 따라서 달라짐
  • API 테스트 → Large(대형) 테스트 (5%)
    • `멀티 서버` 가능 → E2E(End to End) 테스트
RF. Software Engineering at Google

 

 

개념

SUT

  • System Under Test
  • 테스트하려는 대상
  • Ex. 북마크 결과가 제대로 됐는지 기록하는 테스트 코드가 있을 때, `SUT`는 user

 

BDD

  • Behaviour driven development (given - when - then 또는 3A)
  • 테스트를 작성하다 보면 어느 순간 "어디에 어떻게 테스트를 넣어야 하지?"라는 질문을 마주하게 된다. 그때 BDD가 "생동에 집중해야 한다"고 알려준다.
  • 유저가 시스템을 사용하는 user story를 강조하고, 시나리오를 강조한다. 이를 지키기 위한 뼈대로 아래와 같은 방식을 사용하라고 권유함
    • 어떤 상황이 주어졌을 때(given)
    • 이 행동을 하면(when)
    • 결과가 이렇다(then)

 

상호 작용 테스트(Interaction Test)

  • 메서드가 실제로 호출이 됐는지 검증하는 테스트 
    • 일반적으로, 메서드가 실제로 호출됐는지 검증하는 방법은 좋지 않음 
      • 내부 구현을 어떻게 했는지 감시하는 것이므로  ⇒ 캡슐화 위배
    • 객체에게 위임한 책임을 해당 객체가 제대로 수행했는지 확인만 하면 되는데 구현에 집착하게 된다.

 

상태 검증 vs 행위 검증

  • 상태 기반 검증(state-based-verification)
    • 어떤 값을 시스템에 넣었을 때, 나오는 결괏값을 기댓값과 비교하는 방식
  • 행위 기반 검증(behaviour-based-verification)
    • == 상호 작용 테스트
    • 어떤 값을 시스템에 넣었을 때, 협력 객체의 어떤 메서드를 실행하는가
    • BDD에서 말하는 `행위`와는 다른 의미. 행위 기반 검증이 BDD를 말하는 것이 아님.

 

테스트 픽스처

  • fixture = 비품, 설비
  • 테스트를 하기 위해 필요한 자원이 있다면 미리 생성해 두는 것을 테스트 픽스처라고 함
    • `@Before`를 적용한 메서드에 테스트를 하기 위한 `sut`을 미리 생성해 둠 ⇒ `sut = 테스트 픽스처`
  • 테스트 픽스처는 sut가 될 수 있고, sut에 들어가야 하는 의존성 일부가 될 수도 있음.

 

비욘세 규칙

  • 구글에서 만든 규칙(대중적이지 않음)이지만, 꽤 의미 있는 규칙
  • "유지하고 싶은 상태나 정책이 있다면, 알아서 테스트를 만들어야 한다."
    • 유지하고 싶은 상태가 있으면 전부 테스트로 작성 → 그게 곧 정책이 됨
    • 유저 아이디가 이메일 형식이길 원한다면, 유저 아이디가 이메일이 아닐 때 예외를 던지는 테스트를 작성하면 됨
    • 마일리지 쿠폰이 5만 원 이상일 때만 사용 가능하게 하고 싶다면, 5만원 미만일 때 예외를 던지는 테스트를 작성하면 됨
  • 협업 시에도 큰 도움이 됨
    • 개발 문서보다 더 효율적일 때도 있다.

 

Testability (다른 글에서 다시 정리)

  • 테스트 가능성. 소프트웨어가 테스트 가능한 구조인가?

 

Test Double

  • double == 스턴트맨과 같은 '대역'
  • 회원 가입을 할 때 인증 메일을 보내는 시스템이 있을 때, 회원 가입하는 코드를 테스트할 때마다 인증 메일이 발송되어야 할까? 그럴 수는 없음 → 대신 이메일을 발송하는 부분에 가짜 객체(Test Double)를 넣어준다.

 

대역 종류

  • Dummy
    • 일을 시켜도 아무런 동작도 하지 않는 객체
  • Fake
    • Dummy와 달리 자체 로직을 갖고 있음
    • 회원 가입 메일 내용이 제대로 만들어졌는지 테스트하고 싶음 → Fake 사용
      • 발송한 메시지를 기록하는 로직을 가지고 있음
    • 잘 만들어진 Fake는 테스트할 때 말고도, 로컬 개발 시에도 사용할 수 있음
  • Stub
      • 주로 외부 연동하는 컴포넌트에 많이 사용함
      • 객체에 어떤 일을 시켰을 때 미리 준비된 값을 제공
        class SubUserRepository implements UserRepository {
        	public User getByEmail(String email) {
        		if (email.equals("foo@bar.com")) {
        			return User.Builder()
        					.email("foo@bar.com")
        					.status("PENDING")
        					.build();
        			}
        		throw new UsernameNotFoundException(email);
        	}
        }
      • 보통 `mokito` 프레임워크를 이용하여 구현됨
        //given
        given(userRepository.getByEmail("foo@bar.com")).willReturn(User.builder())
        		.emamil("foo@bar.com")
        		.status("PENDING")
        		.build();
        // when
        // ...
        
        // then
        // ...
  • Mock
    • 사실상 테스트 더블과 동일한 의미로 사용됨
    • stub → mock, dummy → mock, fake → mock으로 부름
    • 메서드 호출을 확인하기 위한 객체
  • Spy
    • 모든 메서드 호출을 낱낱이 기록해 두고 있는 객체 
      • 메서드가 몇 번 호출됐는지, 잘 호출됐는지 등을 검증