오봉이와 함께하는 개발 블로그

Spring Framework - 테스트 코드 작성 (기본 - mock, spy, MockBean, SpyBean, MockWebServer) 본문

BE/Spring

Spring Framework - 테스트 코드 작성 (기본 - mock, spy, MockBean, SpyBean, MockWebServer)

오봉봉이 2024. 3. 31. 23:17
728x90

FIRST 원칙

  1. Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야한다.(단위 테스트로 작성)
  2. Independent: 테스트는 독립적이며 서로 의존해서는 안 된다.
  3. Repetable: 반복하여 테스트가 가능해야한다.
  4. Self-Validating: 테스트는 성공 또는 실패로 bool값으로 결과를 내어 자체적으로 검증되어야한다.
    1. 예를 들어 로그나 System.out.println() 같이 콘솔에 찍히는 값을 비교하는 테스트는 지양
  5. Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.

Test double

  • 특정 Service 계층에서 외부 시스템에 의존하는 것들이 많아지면 테스트 코드 작성이 매우 어려워진다.
    • 예: DB, 테스트 대상이 아닌 API 서버 등
  • 특정 Service를 테스트할 때 다른 의존성에서 정상적으로 처리되었는지 관심 있는 것이 아니라 Service의 메서드 내부 로직에 대한 관심이기 때문에 특정 의존성의 메서드 호출 시 적절한 데이터만 반환되면 테스트에 문제가 생기지 않는다.
  • Test double을 만들기 위해 mock과 spy 등의 객체를 사용

stub

  • mock 객체나, spy 객체의 특정 행위를 실행했을 때 기대하는 동작을 작성하는 것
  • do**(Return, Answer, Throw)().when() 혹은 when().thenReturn() 등이 있음
    • BDDMockito 라이브러리를 사용하면 문법이 다르지만 동작은 동일.
public class Service {

public void someMethodA(){ ... } // 내부 구현 코드 생략

public void someMethodB(){ ... } // 내부 구현 코드 생략

public void someMethodC(){ ... } // 내부 구현 코드 생략

}

// 위 클래스에 stubbing 진행
public class ServiceTest {
  @Spy
  private Service service;

  @Test
  void serviceTest() {
    String someValue = "";

    // 1. when().thenReturn()
    when(service.someMethodA()).thenReturn(someValue);
    // 2. doReturn().when()
    doReturn(someValue).when(service).someMethodA();
    // 3. doAnswer().when()
    doAnswer(invocation -> {
      /*
       * 이곳에 특정 행위 stubbing
       * invocation을 통해 다양한 값 사용 가능
       * 예를 들어 invocation.getArgument()를 통해 해당 메서드 실행 시 필요한 파라미터 사용
       */
    }.when(service).someMethod()
    // 4. doThrow().when()
    doThrow(new RuntimeException("someMessage")).when(service).someMethod();    
  }        
}

mock

  • 테스트 대상의 의존성 주입을 위한 가짜 객체
  • mock을 통해 메서드를 수행하면 실제 내부 동작이 발생하지 않음
    • 실제 동작이 발생하지 않기 때문에 반환 값이 있는 경우 null을 반환
    • 반환 값이 없는 경우 아무 일도 수행하지 않음
  • mock을 통해 테스트 대상이 외부 시스템 영향 없이 독립적인 테스트 수행 가능
  • 일반적으로 when().thenReturn()을 사용하나, void 메서드일 경우 do*().when()을 사용

mock 객체 변화 예시

public class Service {

  public void someMethodA(){ ... } // 내부 구현 코드 생략

  public void someMethodB(){ ... } // 내부 구현 코드 생략

  public void someMethodC(){ ... } // 내부 구현 코드 생략

}
  • 위와 같이 특정 클래스에 메서드가 있을때 mocking 하게 되면 아래처럼 된다.
public class Service {

  public void someMethodA(){ } // 내부 구현 코드 없어짐

  public void someMethodB(){ } // 내부 구현 코드 없어짐

  public void someMethodC(){ } // 내부 구현 코드 없어짐

}
  • 내부 구현이 사라지기 때문에 테스트 대상에서 해당 메서드를 호출하는 경우 return 값을 지정해주어야 함
    • ex) when(Service.someMethodA()).thenReturn(someValue);
      • Service의 someMethodA()를 호출했을 때 someValue를 반환한다.
  • mock 객체의 stubbing을 하지 않을 시 Mockito는 해당 메서드 호출에 대한 기본 전략 적용
    • void 메서드의 경우 아무 동작도, 아무 반환도 하지 않음
    • primitive 타입 반환 메서드의 경우 0 반환
      • int일 경우 0, double, float일 경우 0.0, boolean일 경우 false
    • 객체 반환 메서드의 경우 null 반환
    • Collection 혹은 Optional 등의 객체일 경우 Empty 반환
      • 예를 들어 List일 경우 emptyList 반환
    • 더 자세한 전략 정보는 org.mockito.Answers.java 클래스 참고

mocking 방법

public class SomeClass {
  private SomeDependency someDependency;
}

// 테스트 대상은 SomeClass
// 위와 같은 클래스가 있을 때 테스트코드에서 아래와 같이 mocking 가능

/*
* 1. anootation 사용
* - annotation을 통해 mocking
* - @Mock을 통해 Mock 객체를 선언하고, @InjectMocks를 통해 해당 클래스애 의존성 주입
* */
@ExtendWith(MockitoExtension.class)
public class SomeClassTest {
  @Mock
  private SomeDependency someDependency;
  @InjectMocks
  private SomeClass someClass;    
}

/*
* 2. 명시적인 방법
* - 명시적으로 mock 객체를 생성 하여 필드 인스턴스에 주입
* - 명시적인 mock 객체를 주입하여 new를 통해 객체 생성 후 필드 인스턴스에 주입
* - @BeforeEach를 통해 각 메서드 실행 시 독립적인 테스트 가능
* */
public class SomeClassTest {
  private SomeDependency someDependency;
  private SomeClass someClass;

  @BeforeEach
  void setUp() {
    someDependency = mock(SomeDependency.class);
    someClass = new SomeClass(someDependency);
  }
}

spy

  • 실제 인스턴스 객체
  • 실제 인스턴스 객체이기 때문에 테스트 대상의 메서드를 재정의(stubbing) 하지 않으면 실제 동작이 발생
  • 테스트 대상의 메서드를 재정의 할 경우 재정의한 동작을 수행
  • 일반적으로 doAnswer().when() 혹은 doReturn().when() 사용
    • when().thenReturn() 메서드를 사용할 수 있으나, 예외가 발생할 수 있어 doReturn().when() 사용

Spy 객체 변화 예시

public class Service {

  public void someMethodA(){ ... } // 내부 구현 코드 생략

  public void someMethodB(){ ... } // 내부 구현 코드 생략

  public void someMethodC(){ ... } // 내부 구현 코드 생략

}
  • 위와 같이 특정 클래스에 메서드가 있을때 spy 객체로 선언하게 되면 기존 클래스와 변함없이 작동한다.
public class Service {

  public void someMethodA(){ ... } // 내부 구현 코드 생략

  public void someMethodB(){ ... } // 내부 구현 코드 생략

  public void someMethodC(){ ... } // 내부 구현 코드 생략

}
  • 기존 내부 구현이 그대로 존재하고 재정의하여 동작을 변경하는 경우에 해당 메서드가 재정의한 동작에 따라 값을 반환
  • 단, 코드 자체가 변경되는 것이 아닌, Mockito 라이브러리가 실행 시점을 파악하여 해당 메서드 실행 시점에 인터셉트하여 재정의한 동작을 실행

spy 방법

public class SomeClass {
  private SomeDependency someDependency;
}

// 테스트 대상은 SomeClass
// 위와 같은 클래스가 있을 때 테스트코드에서 아래와 같이 mocking 가능

/*
* 1. anootation 사용
* - annotation을 통해 mocking
* - @Mock을 통해 Mock 객체를 선언하고, @InjectMocks를 통해 해당 클래스애 의존성 주입
* */
@ExtendWith(MockitoExtension.class)
public class SomeClassTest {
  @Mock
  private SomeDependency someDependency;
  @Spy
  @InjectMocks  
  private SomeClass someClass; 


/*
* 2. anootation 사용 및 명시적인 방법
* - annotation을 통해 mocking
* - @Mock을 통해 Mock 객체를 선언하고, @InjectMocks를 통해 해당 클래스애 의존성 주입
* */
@ExtendWith(MockitoExtension.class)
public class SomeClassTest {
  @Mock
  private SomeDependency someDependency;

  private SomeClass someClass;

  @BeforeEach
  void setUp() {
    someClass = spy(new SomeClass(someDependency));
  }

}
}

/*
*32. 명시적인 방법
* - 명시적으로 mock 객체를 생성 하여 필드 인스턴스에 주입
* - 명시적인 mock 객체를 주입하여 new를 통해 객체 생성 후 필드 인스턴스에 주입
* - @BeforeEach를 통해 각 메서드 실행 시 독립적인 테스트 가능
* */
public class SomeClassTest {
  private SomeDependency someDependency;
  private SomeClass someClass;

  @BeforeEach
  void setUp() {
    someDependency = mock(SomeDependency.class);
    someClass = new SomeClass(someDependency);
  }
}
  • 위 선언된 spy 객체는 모두 stubbing을 하지 않으면 모두 실제 인스턴스와 같은 동작을 수행
    • spy 객체에 A(), B(), C() 메서드가 있고, A() 메서드만 stubbing 하면 A() 메서드에 대한 부분만 재정의 동작 수행

MockBean & SpyBean

  • 기본 컨셉은 mock & spy와 동일
  • 단, @SpringBootTest를 사용할 때 SpringBootApplication이 관리하는 IoC 컨테이너에 Bean을 등록하여 사용하게 해줌
    • 혹은 @WebMvcTest에도 적용
    • 핵심은 SpringBootApplication이 기동되는 테스트 진행 시 해당 어노테이션을 사용하여 SpringBean으로 등록하여 사용할 수 있다는 것
    • 스프링이 관리하기 때문에 의존성이 필요한 경우 의존성이 자동으로 주입
  • 기본 컨셉은 mock & spy와 동일하기 때문에 stubbing을 통해 재정의 가능

MockWebServer

  • Spring에서 외부 API에 의존성을 가지는 경우 사용할 수 있는 라이브러리
  • 프로덕션 코드에서 Spring WebFlux에서 제공하는 WebClient를 사용하는 경우 테스트 코드 작성에 도움을 주는 라이브러리
  • 실제 비즈니스 로직에서 외부 API를 호출하여 데이터를 사용하는 경우 해당 API 호출을 테스트할 수 있음
public class SomeClassTest {
  private MockWebServer mockWebServer;

  @BeforeEach
  void setUp() {
    mockWebServer = new MockWebServer();
    mockWebServer.start(/*{외부 API 서버 IP:port 정보}*/)
  }

  @AfterEach
  void tearDown() throws IOException {
    // FIRST 원칙 중 Independent를 고려하여 독립적인 테스트를 위해 사용
    luxiaWebClient.shutdown();
    marinerWebClient.shutdown();
  }
}
  • 주의 사항은 Queue를 통해 데이터를 반환하기 때문에 실제 외부 API 호출 순서에 따라 데이터를 세팅하는 순서가 맞아야 한다.
    • 테스트 대상 메서드가 비즈니스 로직 수행 중 특정 API를 세 번 호출하는 경우 코드 작성 순서도 API에서 반환을 기대하는 값과 동일하게 작성해야 함
void someMethod() {
  // API 호출하지 않는 일반 비즈니스 로직
  // 첫 번째 API 호출
  // API 호출하지 않는 일반 비즈니스 로직
  // 두 번째 API 호출
  // API 호출하지 않는 일반 비즈니스 로직
  // 세 번째 API 호출
}

// 위와 같은 메서드가 테스트 대상이라고 가정할 때

void someMethodTest() {
  String firstApiCallValue = "";
  String secondApiCallValue = "";
  String thirdApiCallValue = "";   
  // 기타 작업
  // 첫 번째 API 호출에 따른 기대값 stubbing
  mockWebServer.enqueue(new MockResponse()
  .setResponseCode(200)
  .setBody(firstApiCallValue)
  .addHeader("Content-Type", "application/json"));

  // 두 번째 API 호출에 따른 기대값 stubbing
  mockWebServer.enqueue(new MockResponse()
  .setResponseCode(200)
  .setBody(secondApiCallValue)
  .addHeader("Content-Type", "application/json"));

  // 세 번째 API 호출에 따른 기대값 stubbing
  mockWebServer.enqueue(new MockResponse()
  .setResponseCode(200)
  .setBody(secondApiCallValue)
  .addHeader("Content-Type", "application/json"));

}
  • body나 header 혹은 HTTPresponseCode등의 값들을 원하는 값으로 설정할 수 있다.
728x90
Comments