diff --git a/images/clock_mocking.png b/images/clock_mocking.png new file mode 100644 index 00000000..9d55747a Binary files /dev/null and b/images/clock_mocking.png differ diff --git a/images/fixed_clock_class.png b/images/fixed_clock_class.png new file mode 100644 index 00000000..df3ef81d Binary files /dev/null and b/images/fixed_clock_class.png differ diff --git a/images/fixed_clock_method.png b/images/fixed_clock_method.png new file mode 100644 index 00000000..3ca7aa3c Binary files /dev/null and b/images/fixed_clock_method.png differ diff --git a/images/localdatetime.png b/images/localdatetime.png new file mode 100644 index 00000000..90f8776f Binary files /dev/null and b/images/localdatetime.png differ diff --git a/images/localdatetime_clock.png b/images/localdatetime_clock.png new file mode 100644 index 00000000..fa8e0dcb Binary files /dev/null and b/images/localdatetime_clock.png differ diff --git a/images/localdatetime_wrapper.png b/images/localdatetime_wrapper.png new file mode 100644 index 00000000..ff619e73 Binary files /dev/null and b/images/localdatetime_wrapper.png differ diff --git a/images/mockStatic.png b/images/mockStatic.png new file mode 100644 index 00000000..4c7524c8 Binary files /dev/null and b/images/mockStatic.png differ diff --git a/images/mockStatic_RestAssured.png b/images/mockStatic_RestAssured.png new file mode 100644 index 00000000..53644d66 Binary files /dev/null and b/images/mockStatic_RestAssured.png differ diff --git a/images/mockStatic_WebMvc.png b/images/mockStatic_WebMvc.png new file mode 100644 index 00000000..6325aa84 Binary files /dev/null and b/images/mockStatic_WebMvc.png differ diff --git a/technical_writing.md b/technical_writing.md new file mode 100644 index 00000000..e644eb55 --- /dev/null +++ b/technical_writing.md @@ -0,0 +1,424 @@ +# 어노테이션 하나로 테스트에서 *LocalDateTime.now()* 제어하기 + +'테스트 실패해요.' + +땅콩 프로젝트는 `LocalDateTime.now()`로 현재 시간을 가져와서 비교하는 비즈니스 로직이 있습니다. 단위 테스트를 작성하고 자신있게 Pull Request를 올렸지만 CI에서 테스트가 실패했습니다. 테스트를 실행할 때마다 현재 시간이 달라져 어느 시점부터 완전히 실패하는 테스트가 되었기 때문이었습니다. + +좋은 단위 테스트는 [F.I.R.S.T 원칙](https://howtodoinjava.com/best-practices/first-principles-for-good-tests/)을 따릅니다. 하지만 제가 구현한 테스트는 반복 가능한 테스트, 즉 **Repeatable** 원칙을 만족하지 못하고 있었습니다. + +현재 시간과 같은 랜덤 요소를 제어하는 것은 테스트에서 매우 중요합니다. 저는 '랜덤한 시간을 제어해서 반복 가능한 테스트 만들기'를 넘어 두 가지도 함께 고민했습니다. + +1. 테스트 가독성 높이기 +2. 다른 팀원들도 테스트에서 쉽게 시간 제어하기 + +위 고민을 해결하기 위해 어떤 시도를 했는지, 그리고 어노테이션 하나로 시간을 어떻게 제어했는지 소개하려고 합니다. + +
+ +## 테스트에서 시간을 어떻게 제어하면 좋을까? + +Mock이란 [테스트 더블](https://www.javacodegeeks.com/2019/04/introduction-to-test-doubles.html) 방법 중 하나로, 테스트에서 실제 객체와 동일한 mock 객체를 만들어 특정 동작을 검증하거나 제어할 수 있게 하는 방법입니다. 이와 같은 과정을 모킹(Mocking)이라고 합니다. +스프링 부트에서는 `spring-boot-starter-test` 의존성에 포함된 [Mockito](https://site.mockito.org/) 프레임워크를 사용해서 객체를 쉽게 모킹할 수 있습니다. + +그렇다면 `LocalDateTime.now()`를 모킹해서 원하는 시간을 반환하면 쉽게 해결되지 않을까요? 아쉽게도 `LocalDateTime.now()`는 static 메서드이기 때문에 `Mockito.mock()`과 같은 일반적인 모킹 방법으로는 제어하기 어렵습니다. + +### 1. MockedStatic 사용하기 + + + +[Mockito 3.4.0](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html#48) 버전 이상부터 MockedStatic으로 static 메서드를 모킹할 수 있습니다. + +
+ +```java +@RestController +public class TimeController { + + @GetMapping("/time") + public String time() { + LocalDateTime now = LocalDateTime.now(); + System.out.println("현재 시간: %s".formatted(now)); + return now.toString(); + } +} + +``` + +```java +@WebMvcTest(TimeController.class) +class TimeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 현재_시간_모킹_테스트() throws Exception { + // given + LocalDateTime now = LocalDateTime.parse("2024-10-31T12:30:15"); + MockedStatic localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class); + localDateTimeMockedStatic.when(LocalDateTime::now).thenReturn(now); + + // when & then + mockMvc.perform(get("/time")) + .andExpect(jsonPath("$").value("2024-10-31T12:30:15")); + + localDateTimeMockedStatic.close(); + } +} +``` +간단한 컨트롤러와 테스트를 작성해보겠습니다. MockedStatic으로 LocalDateTime을 모킹한 후 `now()`를 호출했을 때 고정된 시간을 반환하도록 합니다. + +
+ + + +테스트를 실행하면 고정된 시간을 잘 반환하고 있습니다. 문제를 해결했나 싶었지만 MockedStatic은 스레드 로컬로 동작하기 때문에 문제점이 있습니다. + +리소스를 해제하지 않으면 MockedStatic이 스레드에 활성 상태로 남아있게 되고, 같은 스레드를 재사용하는 다른 테스트에 영향을 줄 수 있습니다. 그래서 try-with-resources 구문을 사용하거나 `close()`를 명시적으로 호출해서 **항상 리소스를 해제**해야 합니다. + +[@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)](https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html)을 사용하면 HTTP 클라이언트가 테스트와 별도의 스레드에서 실행되기 때문에 스레드 로컬로 처리되는 MockedStatic이 반영되지 않습니다. 땅콩은 컨트롤러 테스트로 RestAssured와 WebEnvironment.RANDOM_PORT를 사용하기 때문에 이 방식으로는 문제를 해결할 수 없습니다. + +```java +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class TimeControllerTest { + + private static final Logger log = LoggerFactory.getLogger(TimeControllerTest.class); + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + void 현재_시간_모킹_테스트() { + // given + LocalDateTime now = LocalDateTime.parse("2024-10-31T12:30:15"); + log.info("모킹한 시간: {}", now); + MockedStatic localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class); + localDateTimeMockedStatic.when(LocalDateTime::now).thenReturn(now); + + // when + RestAssured.when() + .get("/time"); + + localDateTimeMockedStatic.close(); + } +} + +``` + + + +실제로 테스트를 해보면 서로 다른 스레드에서 실행되어 모킹이 적용되지 않음을 확인할 수 있습니다. + +### 2. LocalDateTime을 래핑하는 클래스 + +```java +@Component +public class LocalDateTimeWrapper { + + public LocalDateTime now() { + return LocalDateTime.now(); + } +} +``` + +LocalDateTime을 한 번 감싸는 래핑 클래스를 생성해서 테스트 더블을 사용하는 방법입니다. + +```java +@Service +@RequiredArgsConstructor +@Slf4j +public class TimeService { + + private final LocalDateTimeWrapper localDateTimeWrapper; + + public void printCurrentTime() { + LocalDateTime now = localDateTimeWrapper.now(); + log.info("현재 시간: {}", now); + } +} +``` + +```java +@SpringBootTest +public class TimeServiceTest { + + private static final Logger log = LoggerFactory.getLogger(TimeServiceTest.class); + + @Autowired + private TimeService timeService; + + @MockBean + private LocalDateTimeWrapper localDateTimeWrapper; + + @Test + void 현재_시간_모킹_테스트() { + // given + LocalDateTime now = LocalDateTime.parse("2024-12-12T00:00:00"); + log.info("모킹한 시간: {}", now); + when(localDateTimeWrapper.now()).thenReturn(now); + + // when + timeService.printCurrentTime(); + } +} +``` + + + +가장 간단한 방법이지만 일반적이지 않은 코드라서 팀원들의 인지 비용이 발생할 것이라 생각했습니다. + +### 3. Clock 객체를 bean으로 등록 후 모킹 + + + +`LocalDateTime.now()`의 내부를 살펴보면 Clock을 인자로 받는 메서드를 호출하고 있습니다. + + + +내부적으로만 사용하는 줄 알았는데 접근제어자가 public이네요! JavaDoc을 보니까 테스트를 위해 대체 Clock을 사용할 수 있다고 안내하고 있습니다. 이 메서드를 사용하면 시간을 쉽게 제어할 수 있어 보입니다. + +> - Instant
+타임라인에서 한 지점을 나타내는 순간을 나타내며, UTC 기준 `1970-01-01T00:00:00`를 0(epoch)으로 정하고 이로부터 경과된 시간을 양수 또는 음수로 표현합니다. +> - ZoneId
+UTC, Asia/Seoul 등 특정 지역의 시간대 정보를 나타내는 타임존입니다. +> - Clock
+Instant와 ZoneId를 사용해 현재 날짜, 시간을 제공하는 추상클래스입니다. + +
+ +```java +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} +``` +먼저 Clock을 bean으로 등록합니다. + +```java +@Service +@RequiredArgsConstructor +@Slf4j +public class TimeService { + + private final Clock clock; + + public void printCurrentTime() { + LocalDateTime now = LocalDateTime.now(clock); + log.info("현재 시간: {}", now); + } +} +``` +Clock bean을 의존성 주입 후 `LocalDateTime.now(clock)`로 변경합니다. + +```java +@SpringBootTest +public class TimeServiceTest { + + private static final Logger log = LoggerFactory.getLogger(TimeServiceTest.class); + + @Autowired + private TimeService timeService; + + @MockBean + private Clock clock; + + @Test + void 현재_시간_모킹_테스트() { + Instant now = Instant.parse("2024-12-31T00:00:00Z"); + log.info("모킹한 시간: {}", now); + when(clock.instant()).thenReturn(now); + when(clock.getZone()).thenReturn(ZoneOffset.UTC); + + timeService.printCurrentTime(); + } +} +``` + +테스트에서는 Clock을 MockBean으로 주입하고 현재 시간을 만들어낼 때 사용하는 Instant를 원하는 값으로 반환합니다. + +**주의할 점**은 Zone에 따라 Instant에 작성한 시간을 변환하기 때문에 Zone이 UTC가 아니면 `LocalDateTime.now(clock)`에서 예상하지 않은 시간이 반환됩니다. + + + +테스트를 실행하면 고정된 시간을 반환하고 있습니다. 하지만 Clock을 사용하는 테스트마다 모킹하는 보일러플레이트 코드를 작성해야 하는 점이 매우 번거롭습니다. + +`@TestConfiguration`을 사용하면 **고정된 Clock 객체**를 primary bean으로 등록해서 테스트 전역으로 Clock을 제어할 수 있습니다. 가짜 객체가 진짜 객체처럼 행동하는 테스트 더블의 Fake 방법입니다. + +```java +@TestConfiguration +public class TestConfig { + + @Primary + @Bean + public Clock testClock() { + return Clock.fixed(Instant.parse("2024-12-31T00:00:00Z"), ZoneOffset.UTC); + } +} +``` +```java +@SpringBootTest +@Import(TestConfig.class) +public class TimeServiceTest { + + @Autowired + private TimeService timeService; + + @Test + void 현재_시간_모킹_테스트() { + timeService.printCurrentTime(); + } +} +``` +`@Import`로 설정을 적용하면 고정된 Clock 객체를 사용합니다. 반복되는 보일러플레이트 코드가 모두 사라졌습니다! + +
+ +## 커스텀 어노테이션으로 현재 시간을 제어할 수 없을까? + +`@TestConfiguration`을 사용해서 Clock bean을 전역으로 제어했지만 테스트를 작성할 때 여전히 불편함이 있었습니다. +1. 매번 TestConfiguration에 고정된 시간을 확인하면서 테스트를 작성해야 함 ('시간 언제로 고정되어 있었지?') +2. 테스트를 유연하게 작성하기 어려움 ('이 테스트에서는 다른 시간으로 고정해야 하는데...') +3. 테스트에서 데이터를 왜 x시간으로 저장했는지 한 번에 읽히지 않음 ('이 테스트는 왜 x시간으로 저장하지?') + +### JUnit 5의 extension 사용 +`@TestConfiguration`의 불편함을 극복하기 위해서 extension 기능을 활용했습니다. JUnit 5부터 도입된 extension은 테스트 라이프사이클의 다양한 단계에 특정 동작을 확장할 수 있는 기능입니다. + +extension 중에서 라이프사이클 콜백을 사용하면 테스트 전, 후로 메서드를 실행할 수 있습니다. 실행 순서는 다음과 같습니다. +``` +1. BeforeAllCallback +2. @BeforeAll +3. BeforeEachCallback +4. @BeforeEach +5. BeforeTestExecutionCallback +6. Test 실행 +7. AfterTestExecutionCallback +8. @AfterEach +9. AfterEachCallback +10. @AfterAll +11. AfterAllCallback +``` + +여기서 BeforeEachCallback 인터페이스를 구현해서 Clock bean을 모킹하겠습니다. + +```java +public class FixedClockExtension implements BeforeEachCallback { + + private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + private static final Pattern TIME_PATTERN = Pattern.compile("\\d{2}:\\d{2}:\\d{2}"); + + @Override + public void beforeEach(ExtensionContext context) { + Clock clock = SpringExtension.getApplicationContext(context).getBean(Clock.class); + FixedClock fixedClockAnnotation = getFixedClockAnnotation(context); + + String date = getDate(fixedClockAnnotation); + String time = getTime(fixedClockAnnotation); + when(clock.instant()).thenReturn(Instant.parse("%sT%sZ".formatted(date, time))); + when(clock.getZone()).thenReturn(ZoneOffset.UTC); + } + + private FixedClock getFixedClockAnnotation(ExtensionContext context) { + FixedClock fixedClockAnnotation = context.getRequiredTestMethod().getDeclaredAnnotation(FixedClock.class); + if (fixedClockAnnotation == null) { + fixedClockAnnotation = context.getRequiredTestClass().getDeclaredAnnotation(FixedClock.class); + } + return fixedClockAnnotation; + } + + private String getDate(FixedClock fixedClockAnnotation) { + String date = fixedClockAnnotation.date(); + if (!DATE_PATTERN.matcher(date).matches()) { + throw new IllegalArgumentException("yyyy-MM-dd의 date 포맷이어야 합니다. invalid date: %s".formatted(date)); + } + return date; + } + + private String getTime(FixedClock fixedClockAnnotation) { + String time = fixedClockAnnotation.time(); + if (!TIME_PATTERN.matcher(time).matches()) { + throw new IllegalArgumentException("HH:mm:ss의 time 포맷이어야 합니다. invalid time: %s".formatted(time)); + } + return time; + } +} +``` +FixedClock은 뒤에 설명할 커스텀 어노테이션입니다. 리플렉션으로 테스트 메서드나 테스트 클래스를 읽어서 `@FixedClock` 어노테이션을 찾습니다. 이때 메서드에 작성된 어노테이션이 클래스에 작성된 어노테이션보다 우선적으로 적용됩니다. Application Context에 존재하는 Clock bean을 찾아서 어노테이션에 작성된 날짜와 시간으로 모킹합니다. + +### 커스텀 어노테이션 생성 +```java +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(FixedClockExtension.class) +public @interface FixedClock { + + String date(); + + String time(); +} +``` +테스트에서 사용할 커스텀 어노테이션입니다. +extension은 `@ExtendWith` 어노테이션을 작성하면 적용됩니다. 여기서는 `@FixedClock` 어노테이션에 포함했기 때문에 `@FixedClock`을 사용하면 extension이 자동으로 동작하게 됩니다. + +### 테스트 적용 +```java +@SpyBean(Clock.class) +@FixedClock(date = "2025-01-01", time = "00:00:00") +@SpringBootTest +public class TimeServiceTest { + + @Autowired + private TimeService timeService; + + @Test + void 현재_시간_모킹_테스트1() { + timeService.printCurrentTime(); + } + + @Test + @FixedClock(date = "2024-12-25", time = "00:00:00") + void 현재_시간_모킹_테스트2() { + timeService.printCurrentTime(); + } +} +``` +Clock 객체는 테스트 클래스에서 실제 객체 또는 mock 객체로 모두 사용되기 때문에 SpyBean으로 등록합니다. + + + +첫 번째 테스트는 클래스 레벨에 있는 `@FixedClock`의 현재 시간을 반환합니다. + + + +두 번째 테스트는 메서드 레벨에 있는 `@FixedClock`의 현재 시간을 반환합니다. + +>`@SpyBean` 어노테이션은 클래스 또는 필드에서만 사용할 수 있습니다. 만약 `@FixedClock`을 클래스에서만 사용할 수 있도록 제한하면 `@SpyBean(Clock.class)`도 `@FixedClock`에 포함할 수 있습니다.
+> 현재 구현은 `@FixedClock`을 메서드에서도 사용할 수 있기 때문에 어노테이션이 메서드 레벨에만 사용됐을 경우 `@SpyBean`이 동작하지 않아 예외가 발생합니다. + +이제 `@FixedClock` 어노테이션만 명시하면 어노테이션에 작성한 날짜, 시간으로 현재 시간을 반환할 수 있게 되었습니다! + +
+ +## 마치며 +지금까지 테스트에서 현재 시간을 제어하는 여러 가지 방법과 어노테이션을 사용해서 제어하는 방법까지 알아보았습니다. 땅콩은 어노테이션 기반 제어 방법을 적용해서 세 가지의 장점을 얻을 수 있었습니다. +1. 어노테이션 하나만 사용하면 현재 시간을 쉽게 제어할 수 있다. +2. 테스트마다 독립적으로 고정된 시간을 사용해서 유연하게 테스트를 작성할 수 있다. +3. 고정된 시간이 무엇인지 명확히 보여주기 때문에 가독성이 향상된다. + +다양한 방법을 비교해 보고 자신 또는 팀에 적합한 방법을 선택하는 것이 중요하다고 생각합니다. 부족한 글이지만, 저와 비슷한 고민을 했던 개발자분들에게 조금이나마 도움이 되었으면 좋겠습니다. 감사합니다. + +
+ +## 레퍼런스 +- https://www.baeldung.com/mockito-mock-static-methods +- https://github.com/mockito/mockito/issues/1013 +- https://docs.oracle.com/javase/8/docs/api/java/time/LocalDateTime.html#now-java.time.Clock- +- https://www.baeldung.com/junit-5-extensions