From c456db0024dacbdb971adb7cf542b178baa0603b Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 2 Sep 2026 00:23:12 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9C=A0=EB=8B=9B=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20BDDMockito=EB=A1=9C=20=ED=8F=AC?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // given 블록 안에 when( 이 와서 같은 단어가 반대 뜻으로 쓰이는 충돌을 없애기 위해 classic Mockito 를 BDDMockito 로 전환한다. 16개 파일, 동작 변경 없음 (476개 통과 유지). - when().thenReturn() -> given().willReturn() - when().thenThrow() -> given().willThrow() - doThrow().when() -> willThrow().given() - verify() -> then().should() - verify(m, never()) -> then(m).should(never()) - verifyNoInteractions() -> then().shouldHaveNoInteractions() - order.verify() -> then().should(order) lenient().when() 과 order.verifyNoMoreInteractions() 는 BDDMockito 에 대응 API 가 없어 classic 을 유지한다. --- .../badge/BadgeCacheInvalidatorTest.java | 22 +-- .../badge/service/BadgeGrantServiceTest.java | 4 +- .../domain/user/service/UserServiceTest.java | 4 +- .../exception/GlobalExceptionHandlerTest.java | 6 +- .../audit/aspect/RequestAuditAspectTest.java | 23 +-- .../service/RequestAuditServiceTest.java | 12 +- .../feature/auth/service/AuthServiceTest.java | 53 +++--- .../AuthServiceUsernameValidationTest.java | 4 +- .../service/GistVerificationServiceTest.java | 10 +- .../badge/controller/BadgeControllerTest.java | 8 +- .../badge/service/BadgeQueryServiceTest.java | 170 +++++++++--------- .../badge/service/BadgeRenderServiceTest.java | 116 ++++++------ .../reset/service/ResetOrchestratorTest.java | 59 +++--- .../reset/service/ResetServiceTest.java | 18 +- .../sync/service/SyncOrchestratorTest.java | 25 +-- .../service/TokenUsageRecordServiceTest.java | 33 ++-- 16 files changed, 285 insertions(+), 282 deletions(-) diff --git a/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java b/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java index b02e987..1604af1 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java @@ -15,11 +15,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** * BadgeCacheInvalidator 의 evict 동작(Redis 키 삭제, SCAN 순회)을 검증한다. @@ -48,16 +48,16 @@ class Evict { @SuppressWarnings("unchecked") Cursor cursor = (Cursor) mock(Cursor.class); - when(cursor.hasNext()).thenReturn(true, true, false); - when(cursor.next()).thenReturn(key1, key2); - when(redis.scan(any(ScanOptions.class))).thenReturn(cursor); + given(cursor.hasNext()).willReturn(true, true, false); + given(cursor.next()).willReturn(key1, key2); + given(redis.scan(any(ScanOptions.class))).willReturn(cursor); // when invalidator.evict(username); // then — SCAN(badge:octocat:*)으로 찾은 실제 키를 일괄 삭제한다 - verify(redis).delete(List.of(key1, key2)); - verify(cursor).close(); + then(redis).should().delete(List.of(key1, key2)); + then(cursor).should().close(); } @Test @@ -68,15 +68,15 @@ class Evict { @SuppressWarnings("unchecked") Cursor cursor = (Cursor) mock(Cursor.class); - when(cursor.hasNext()).thenReturn(false); - when(redis.scan(any(ScanOptions.class))).thenReturn(cursor); + given(cursor.hasNext()).willReturn(false); + given(redis.scan(any(ScanOptions.class))).willReturn(cursor); // when invalidator.evict(username); // then - verify(redis, never()).delete(anyCollection()); - verify(cursor, times(1)).close(); + then(redis).should(never()).delete(anyCollection()); + then(cursor).should(times(1)).close(); } } } diff --git a/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java index 6d527ad..18932a6 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; import com.tokenphage.api.domain.badge.repository.UserBadgeGrantRepository; import com.tokenphage.api.domain.badge.repository.projection.BadgeGrantRow; @@ -145,7 +145,7 @@ class ResolveGrantTest { sut.resolveGrant(USERNAME, PUBLIC_CODE); // then - verify(grantRepo).findGrant(usernameCaptor.capture(), codeCaptor.capture()); + then(grantRepo).should().findGrant(usernameCaptor.capture(), codeCaptor.capture()); assertThat(usernameCaptor.getValue()).isEqualTo(USERNAME); assertThat(codeCaptor.getValue()).isEqualTo(PUBLIC_CODE); } diff --git a/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java b/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java index 9429893..54db969 100644 --- a/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java @@ -8,7 +8,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; /** * UserService.saveUser 동작을 검증한다. @@ -33,6 +33,6 @@ class UserServiceTest { userService.saveUser(githubId, username); // then - verify(userRepo).upsert(githubId, username); + then(userRepo).should().upsert(githubId, username); } } diff --git a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java index 3e851a7..7f46114 100644 --- a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java @@ -14,8 +14,8 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * GlobalExceptionHandler의 예외 → HTTP 응답 매핑을 검증한다. @@ -31,8 +31,8 @@ class GlobalExceptionHandlerTest { // given MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); BindingResult bindingResult = mock(BindingResult.class); - when(ex.getBindingResult()).thenReturn(bindingResult); - when(bindingResult.getFieldErrors()).thenReturn(List.of( + given(ex.getBindingResult()).willReturn(bindingResult); + given(bindingResult.getFieldErrors()).willReturn(List.of( new FieldError("syncRequest", "deviceId", "deviceId must be a valid UUID"))); // when diff --git a/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java b/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java index 948db2e..4679062 100644 --- a/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java +++ b/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java @@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -77,7 +78,7 @@ void tearDown() { @DisplayName("캡처_성공응답_상태와기능정보기록") void 캡처_성공응답_상태와기능정보기록() throws Throwable { // given - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -100,7 +101,7 @@ void tearDown() { // given // 200으로 응답하지만 도메인상 거부인 경우(자격 없는 배지)를 구분하기 위한 통로다. currentRequest().setAttribute(AuditOutcome.ATTRIBUTE_KEY, AuditOutcome.BADGE_GRANT_DENIED); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -117,7 +118,7 @@ void tearDown() { // given // AuditOutcome 타입만 받는다. 문자열을 심어도 무시하고 기본값으로 떨어져야 한다. currentRequest().setAttribute(AuditOutcome.ATTRIBUTE_KEY, "badge_grant_denied"); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -130,7 +131,7 @@ void tearDown() { @DisplayName("캡처_결과미지정_success로기록") void 캡처_결과미지정_success로기록() throws Throwable { // given - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -147,7 +148,7 @@ void tearDown() { MockHttpServletRequest request = currentRequest(); request.setRequestURI("/badge/example-user"); request.setQueryString("theme=contributor&mode=dark"); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -165,7 +166,7 @@ void tearDown() { MockHttpServletRequest request = currentRequest(); request.setRequestURI("/badge/example-user"); request.setQueryString("theme=" + "x".repeat(400)); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -186,7 +187,7 @@ private MockHttpServletRequest currentRequest() { @DisplayName("캡처_AppException_에러코드상태와코드기록후재전파") void 캡처_AppException_에러코드상태와코드기록후재전파() throws Throwable { // given — gist 도용 시나리오(401, AUTH_007) - when(joinPoint.proceed()).thenThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); + given(joinPoint.proceed()).willThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); // when / then — 예외는 그대로 재전파되어야 한다 assertThatThrownBy(() -> aspect.audit(joinPoint)).isInstanceOf(AppException.class); @@ -200,7 +201,7 @@ private MockHttpServletRequest currentRequest() { @DisplayName("캡처_미인증요청_식별필드null") void 캡처_미인증요청_식별필드null() throws Throwable { // given — SecurityContext 미설정 - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -222,7 +223,7 @@ private MockHttpServletRequest currentRequest() { .build(); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(jwt, null)); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -235,7 +236,7 @@ private MockHttpServletRequest currentRequest() { private RequestAuditCommand captureRecorded() { ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditCommand.class); - verify(requestAuditService).record(captor.capture()); + then(requestAuditService).should().record(captor.capture()); return captor.getValue(); } } diff --git a/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java b/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java index 3d265b0..ea06bde 100644 --- a/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java @@ -14,8 +14,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; /** * RequestAuditService.record 의 매핑 위임과 best-effort 예외 처리를 검증한다(실 DB 미접촉). @@ -44,7 +44,7 @@ class RequestAuditServiceTest { // then ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getFeature()).isEqualTo("auth"); assertThat(saved.getAction()).isEqualTo("verify"); @@ -66,7 +66,7 @@ class RequestAuditServiceTest { RequestAuditCommand command = new RequestAuditCommand( "sync", "sync", "POST", "/api/sync", 500, 10, "203.0.113.8", -1002L, "octocat", "tokenphage-cli/1.0", "error"); - doThrow(new RuntimeException("db down")).when(auditLogRepo).save(any()); + willThrow(new RuntimeException("db down")).given(auditLogRepo).save(any()); // when / then — best-effort: 예외를 전파하지 않는다 assertThatCode(() -> requestAuditService.record(command)).doesNotThrowAnyException(); @@ -87,7 +87,7 @@ class RequestAuditServiceTest { // then — 컬럼 한계(255)로 잘려 저장된다(INSERT 실패·유실 방지) ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getUserAgent()).hasSize(255); assertThat(saved.getRequestPath()).hasSize(255); @@ -106,7 +106,7 @@ class RequestAuditServiceTest { // then ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getGithubId()).isNull(); assertThat(saved.getUsername()).isNull(); diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java index dbe62eb..4cf8aa2 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java @@ -28,11 +28,10 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; 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.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** * AuthService 의 createChallenge "저장 동작"과 verify() 플로우를 검증한다. @@ -60,7 +59,7 @@ void setUp() { jwtIssuer = mock(JwtIssuer.class); redis = mock(StringRedisTemplate.class); valueOps = mock(ValueOperations.class); - when(redis.opsForValue()).thenReturn(valueOps); + given(redis.opsForValue()).willReturn(valueOps); // 생성자 인자 순서 = 필드 선언 순서: UserService, GistVerificationService, JwtIssuer, StringRedisTemplate authService = new AuthService(userService, gistVerification, jwtIssuer, redis); @@ -85,7 +84,7 @@ class CreateChallenge { assertThat(response.challenge()).startsWith("tknphg_"); assertThat(response.expiresAt()).isNotNull(); // Redis set 이 키 prefix + TTL 과 함께 호출됐는지 확인 - verify(valueOps).set( + then(valueOps).should().set( eq("auth:challenge:octocat"), eq(response.challenge()), eq(Duration.ofMinutes(TTL_MINUTES)) @@ -103,7 +102,7 @@ class CreateChallenge { authService.createChallenge(req); // then: 캡처한 키가 소문자화된 형태인지 확인 - verify(valueOps).set(keyCaptor.capture(), anyString(), any(Duration.class)); + then(valueOps).should().set(keyCaptor.capture(), anyString(), any(Duration.class)); assertThat(keyCaptor.getValue()).isEqualTo("auth:challenge:octocat"); } @@ -113,14 +112,14 @@ class CreateChallenge { // given: 동일 사용자의 기존 challenge 가 Redis 에 남아 있다 (검증 실패 후 재시도 상황) ChallengeRequest req = new ChallengeRequest("octocat"); String existing = "tknphg_existing0challenge"; - when(valueOps.get("auth:challenge:octocat")).thenReturn(existing); + given(valueOps.get("auth:challenge:octocat")).willReturn(existing); // when ChallengeResponse response = authService.createChallenge(req); // then: 반환 challenge 가 기존 값과 동일하고, set 은 같은 값 + 갱신된 TTL 로 호출된다 assertThat(response.challenge()).isEqualTo(existing); - verify(valueOps).set( + then(valueOps).should().set( eq("auth:challenge:octocat"), eq(existing), eq(Duration.ofMinutes(TTL_MINUTES)) @@ -137,7 +136,7 @@ class Verify { void 검증_챌린지없음_CHALLENGE_EXPIRED() { // given: get 이 null 을 반환하도록 stub VerifyRequest req = new VerifyRequest("octocat", "gist-123"); - when(valueOps.get("auth:challenge:octocat")).thenReturn(null); + given(valueOps.get("auth:challenge:octocat")).willReturn(null); // when & then assertThatThrownBy(() -> authService.verify(req)) @@ -146,8 +145,10 @@ class Verify { .isEqualTo(AuthErrorCode.CHALLENGE_EXPIRED); // gist 검증/저장/발급 단계는 진입하지 않아야 한다 - verifyNoInteractions(gistVerification, jwtIssuer, userService); - verify(redis, never()).delete(anyString()); + then(gistVerification).shouldHaveNoInteractions(); + then(jwtIssuer).shouldHaveNoInteractions(); + then(userService).shouldHaveNoInteractions(); + then(redis).should(never()).delete(anyString()); } @ParameterizedTest(name = "무효 입력: username=\"{0}\", gistId=\"{1}\"") @@ -171,8 +172,10 @@ class Verify { .isEqualTo(AuthErrorCode.INVALID_USERNAME); // 어떤 의존성도 사용되지 않아야 한다 (Redis get 도 호출 안 됨) - verifyNoInteractions(gistVerification, jwtIssuer, userService); - verify(valueOps, never()).get(anyString()); + then(gistVerification).shouldHaveNoInteractions(); + then(jwtIssuer).shouldHaveNoInteractions(); + then(userService).shouldHaveNoInteractions(); + then(valueOps).should(never()).get(anyString()); } @Test @@ -188,9 +191,9 @@ class Verify { new GistOwnerResponse(-42L, "octocat"), null ); - when(valueOps.get("auth:challenge:octocat")).thenReturn(challenge); - when(gistVerification.verify(req, challenge)).thenReturn(gist); - when(jwtIssuer.issue(-42L, "octocat")).thenReturn(issuedToken); + given(valueOps.get("auth:challenge:octocat")).willReturn(challenge); + given(gistVerification.verify(req, challenge)).willReturn(gist); + given(jwtIssuer.issue(-42L, "octocat")).willReturn(issuedToken); // when TokenResponse response = authService.verify(req); @@ -200,9 +203,9 @@ class Verify { assertThat(response.githubId()).isEqualTo(-42L); assertThat(response.username()).isEqualTo("octocat"); // Redis 챌린지 삭제 + 사용자 저장 + JWT 발급 호출 검증 - verify(redis).delete("auth:challenge:octocat"); - verify(userService).saveUser(-42L, "octocat"); - verify(jwtIssuer).issue(-42L, "octocat"); + then(redis).should().delete("auth:challenge:octocat"); + then(userService).should().saveUser(-42L, "octocat"); + then(jwtIssuer).should().issue(-42L, "octocat"); } @Test @@ -211,9 +214,9 @@ class Verify { // given: gistVerification 이 OWNER_MISMATCH 를 던지도록 stub VerifyRequest req = new VerifyRequest("octocat", "gist-123"); String challenge = "tknphg_deadbeefcafe1234"; - when(valueOps.get("auth:challenge:octocat")).thenReturn(challenge); - when(gistVerification.verify(req, challenge)) - .thenThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); + given(valueOps.get("auth:challenge:octocat")).willReturn(challenge); + given(gistVerification.verify(req, challenge)) + .willThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); // when & then assertThatThrownBy(() -> authService.verify(req)) @@ -222,9 +225,9 @@ class Verify { .isEqualTo(AuthErrorCode.OWNER_MISMATCH); // 검증 실패 이후 단계는 수행되지 않아야 한다 - verify(redis, never()).delete(anyString()); - verify(userService, never()).saveUser(anyLong(), anyString()); - verify(jwtIssuer, never()).issue(anyLong(), anyString()); + then(redis).should(never()).delete(anyString()); + then(userService).should(never()).saveUser(anyLong(), anyString()); + then(jwtIssuer).should(never()).issue(anyLong(), anyString()); } } } diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java index 78b77d5..3337481 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java @@ -12,8 +12,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * GitHub 사용자명 정책 검증을, 공개 진입점 createChallenge 를 통해 확인하는 테스트. @@ -71,7 +71,7 @@ class AuthServiceUsernameValidationTest { @SuppressWarnings("unchecked") private StringRedisTemplate redisMock() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.opsForValue()).thenReturn(mock(ValueOperations.class)); + given(redis.opsForValue()).willReturn(mock(ValueOperations.class)); return redis; } } diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java index 8d08655..8a32eac 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java @@ -23,8 +23,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * GistVerificationService.verify 의 분기별 성공/실패 및 경계 동작을 검증하는 테스트. @@ -53,14 +53,14 @@ void setUp() { /** Gist API 종단 body() 호출이 주어진 응답을 반환하도록 stub 한다. */ private void stubGistResponse(GistResponse response) { - when(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) - .thenReturn(response); + given(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) + .willReturn(response); } /** Gist API 종단 body() 호출이 주어진 예외를 던지도록 stub 한다. */ private void stubGistThrows(Throwable throwable) { - when(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) - .thenThrow(throwable); + given(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) + .willThrow(throwable); } /** 공개+소유자일치+검증파일에 challenge 가 담긴 정상 Gist 응답을 만든다. */ diff --git a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java index 586f040..94560c9 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java @@ -5,7 +5,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -137,7 +137,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(eq(USERNAME), themeCaptor.capture(), modeCaptor.capture()); + then(badgeRenderService).should().getSvg(eq(USERNAME), themeCaptor.capture(), modeCaptor.capture()); assertThat(themeCaptor.getValue()).isEqualTo("gpu"); assertThat(modeCaptor.getValue()).isEqualTo("light"); } @@ -156,7 +156,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(USERNAME, "grass-claude", "dark"); + then(badgeRenderService).should().getSvg(USERNAME, "grass-claude", "dark"); } @Test @@ -174,7 +174,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(USERNAME, "beta-tester", "green"); + then(badgeRenderService).should().getSvg(USERNAME, "beta-tester", "green"); } } diff --git a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java index de6d73c..832fa8c 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java @@ -31,12 +31,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.within; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; /** * BadgeQueryService.query / calcCacheHitRate 검증: 사용자 미존재 예외, 정상 응답 조립, @@ -88,16 +88,16 @@ private User user() { /** DailyUsageRow 프로젝션 mock (날짜/총합 getter stub). */ private DailyUsageRow dailyRow(String date, long total) { DailyUsageRow row = mock(DailyUsageRow.class); - when(row.getDate()).thenReturn(date); - when(row.getTotal()).thenReturn(total); + given(row.getDate()).willReturn(date); + given(row.getTotal()).willReturn(total); return row; } /** ModelUsageRow 프로젝션 mock (모델/총합 getter stub). */ private ModelUsageRow modelRow(String model, long total) { ModelUsageRow row = mock(ModelUsageRow.class); - when(row.getModel()).thenReturn(model); - when(row.getTotal()).thenReturn(total); + given(row.getModel()).willReturn(model); + given(row.getTotal()).willReturn(total); return row; } @@ -119,7 +119,7 @@ class Query_UserLookup { @DisplayName("배지조회_사용자없음_USER_NOT_FOUND예외") void 배지조회_사용자없음_USER_NOT_FOUND예외() { // given - when(userRepo.findByUsername("ghost")).thenReturn(Optional.empty()); + given(userRepo.findByUsername("ghost")).willReturn(Optional.empty()); // when // then @@ -128,7 +128,7 @@ class Query_UserLookup { .satisfies(ex -> assertThat(((AppException) ex).getErrorCode()) .isEqualTo(BadgeErrorCode.USER_NOT_FOUND)); // 사용자 미존재 시 토큰 집계 조회는 전혀 일어나지 않아야 한다 - verifyNoInteractions(tokenRepo); + then(tokenRepo).shouldHaveNoInteractions(); } } @@ -146,14 +146,14 @@ class Query_HappyPath { ModelUsageRow opus = modelRow("opus", 700L); ModelUsageRow sonnet = modelRow("sonnet", 300L); CacheTokenSum cache = cacheRow(70L, 20L, 10L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(1_000L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of(daily)); - when(tokenRepo.findTop5Models(GITHUB_ID)) - .thenReturn(List.of(opus, sonnet)); - when(tokenRepo.sumCacheTokens(GITHUB_ID)) - .thenReturn(List.of(cache)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(1_000L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of(daily)); + given(tokenRepo.findTop5Models(GITHUB_ID)) + .willReturn(List.of(opus, sonnet)); + given(tokenRepo.sumCacheTokens(GITHUB_ID)) + .willReturn(List.of(cache)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -176,12 +176,12 @@ class Query_HappyPath { void 배지조회_상위모델없음_빈리스트반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -200,12 +200,12 @@ class Query_TotalTokens { void 누적토큰_sumTotalTokensNull_0반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(null); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(null); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -219,12 +219,12 @@ class Query_TotalTokens { void 누적토큰_정상값_그대로반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(987_654L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(987_654L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -249,13 +249,13 @@ class Query_Heatbar { // 중첩 stubbing 방지를 위해 mock row를 먼저 생성 DailyUsageRow midRow = dailyRow(midStr, 42L); DailyUsageRow todayRow = dailyRow(todayStr, 99L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); // 30일 중 2일치만 데이터가 존재 - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, from, today)) - .thenReturn(List.of(midRow, todayRow)); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, from, today)) + .willReturn(List.of(midRow, todayRow)); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -279,12 +279,12 @@ class Query_Heatbar { void 히트바_조회결과없음_전부0인30개구성() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -302,12 +302,12 @@ class Query_CacheHitRate { /** 캐시 히트율 검증을 위한 공통 stub: 사용자/누적/히트바/모델은 비우고 cacheRows만 주입. */ private void stubBaseWith(List cacheRows) { LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(cacheRows); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(cacheRows); } @Test @@ -375,9 +375,9 @@ class Query_NeedsGating { // given // 연간 계열(DAILY_1Y/STREAK_DAYS/YEAR_TOKENS)만 요구 → 일별 쿼리는 365일 창으로 1회 LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of()); // when service.query(USERNAME, BADGE_CODE, EnumSet.of( @@ -385,10 +385,10 @@ class Query_NeedsGating { // then // 일별 쿼리는 365일 창으로 1회 호출되고, 통계 3쿼리는 전혀 실행되지 않아야 한다 - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); - verify(tokenRepo, never()).findTop5Models(GITHUB_ID); - verify(tokenRepo, never()).sumCacheTokens(GITHUB_ID); - verify(tokenRepo, never()).sumTotalTokens(GITHUB_ID); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); + then(tokenRepo).should(never()).findTop5Models(GITHUB_ID); + then(tokenRepo).should(never()).sumCacheTokens(GITHUB_ID); + then(tokenRepo).should(never()).sumTotalTokens(GITHUB_ID); } @Test @@ -397,18 +397,18 @@ class Query_NeedsGating { // given // gpu 기본 4종 → 히트바 30일 창 유지, 연간 필드는 빈값 LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today); assertThat(res.daily1y()).isEmpty(); assertThat(res.streakDays()).isZero(); assertThat(res.yearTokens()).isZero(); @@ -420,9 +420,9 @@ class Query_NeedsGating { // given LocalDate today = today(); DailyUsageRow todayRow = dailyRow(today.toString(), 500L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of(todayRow)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of(todayRow)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_1Y)); @@ -448,16 +448,16 @@ class Query_NeedsGating { String midStr = today.minusDays(15).toString(); DailyUsageRow midRow = dailyRow(midStr, 42L); DailyUsageRow todayRow = dailyRow(todayStr, 99L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of(midRow, todayRow)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of(midRow, todayRow)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_30D, BadgeDataNeed.DAILY_1Y)); // then - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); assertThat(res.daily30d()).hasSize(30); assertThat(res.daily30d().get(29).date()).isEqualTo(todayStr); assertThat(res.daily30d().get(29).total()).isEqualTo(99L); @@ -472,9 +472,9 @@ class Query_Streak { /** streak 요구(YEAR 계열) 시나리오 공통 stub: 365일 창 일별 rows만 주입. */ private void stubYearRows(LocalDate today, List rows) { - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(rows); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(rows); } @Test @@ -593,8 +593,8 @@ class Query_Snapshot { void 스냅샷_요구시_PK조회1회payload반환() { // given String payload = "{\"signupRank\":1}"; - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).thenReturn(payload); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).willReturn(payload); // when BadgeResponse res = service.query(USERNAME, "beta-tester", @@ -603,8 +603,8 @@ class Query_Snapshot { // then // 스냅샷만 요구하면 토큰 집계 쿼리는 전혀 실행되지 않는다 assertThat(res.snapshot()).isEqualTo(payload); - verify(snapshotRepo).findPayload(GITHUB_ID, "beta-tester"); - verifyNoInteractions(tokenRepo); + then(snapshotRepo).should().findPayload(GITHUB_ID, "beta-tester"); + then(tokenRepo).shouldHaveNoInteractions(); } @Test @@ -612,14 +612,14 @@ class Query_Snapshot { void 스냅샷_미요구_조회안함() { // given // BADGE_SNAPSHOT을 요구하지 않는 테마의 경로 (경계) - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.noneOf(BadgeDataNeed.class)); // then assertThat(res.snapshot()).isEmpty(); - verifyNoInteractions(snapshotRepo); + then(snapshotRepo).shouldHaveNoInteractions(); } @Test @@ -627,8 +627,8 @@ class Query_Snapshot { void 스냅샷_행없음_빈문자열() { // given // 자격은 있는데 스냅샷 적재가 누락된 사용자 (경계) - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).thenReturn(null); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).willReturn(null); // when BadgeResponse res = service.query(USERNAME, "beta-tester", diff --git a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java index bd9d4d9..12582fc 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java @@ -33,9 +33,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** * BadgeRenderService.getSvg()의 자격 확인, 캐시 키 정규화, 캐시 적중·미적중, 예외 전파를 검증한다. @@ -91,14 +91,14 @@ private BadgeResponse sampleData() { /** theme이 이미 정규화된 값(gpu)이고 mode가 LIGHT로 확정될 때의 정규화 스텁. */ private void stubNormalizeIdentity() { - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(svgBuilder.resolveMode(THEME, MODE)).thenReturn(BadgeMode.LIGHT); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(svgBuilder.resolveMode(THEME, MODE)).willReturn(BadgeMode.LIGHT); } /** 자격이 있는 정상 경로 스텁. 이 스텁이 없으면 Mockito 기본값(null)으로 NPE가 난다. */ private void stubGranted() { - when(badgeGrantService.resolveGrant(anyString(), anyString())) - .thenReturn(BadgeGrantResult.allow()); + given(badgeGrantService.resolveGrant(anyString(), anyString())) + .willReturn(BadgeGrantResult.allow()); } @Nested @@ -110,12 +110,12 @@ class GrantGate { void 배지조회_자격없음_잠금SVG반환하고조회캐시생략() { // given String lockedSvg = "locked"; - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(badgeGrantService.resolveGrant(USERNAME, THEME)) - .thenReturn(BadgeGrantResult.deny("Contributor", "PR을 보내주세요")); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.deny("Contributor", "PR을 보내주세요")); // 잠금 렌더는 locked 테마 자신의 지원 집합으로 모드를 다시 정규화한다 - when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); - when(lockedBadgeTheme.render("Contributor", "PR을 보내주세요", false)).thenReturn(lockedSvg); + given(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).willReturn(BadgeMode.LIGHT); + given(lockedBadgeTheme.render("Contributor", "PR을 보내주세요", false)).willReturn(lockedSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -124,27 +124,27 @@ class GrantGate { assertThat(result.svg()).isEqualTo(lockedSvg); assertThat(result.granted()).isFalse(); // 자격 거부 경로는 집계 조회·SVG 빌드·캐시 저장을 전부 건너뛴다 - verify(queryService, never()).query(anyString(), anyString(), any()); - verify(svgBuilder, never()).build(any(), anyString(), anyString()); - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(queryService).should(never()).query(anyString(), anyString(), any()); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } @Test @DisplayName("배지조회_자격없음_캐시를조회조차하지않는다") void 배지조회_자격없음_캐시를조회조차하지않는다() { // given - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(badgeGrantService.resolveGrant(USERNAME, THEME)) - .thenReturn(BadgeGrantResult.deny(null, null)); - when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); - when(lockedBadgeTheme.render(null, null, false)).thenReturn("locked"); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.deny(null, null)); + given(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).willReturn(BadgeMode.LIGHT); + given(lockedBadgeTheme.render(null, null, false)).willReturn("locked"); // when service.getSvg(USERNAME, THEME, MODE); // then // 게이트가 캐시보다 앞에 있어야 캐시 키와 내용 불일치가 생기지 않는다 - verify(redis, never()).opsForValue(); + then(redis).should(never()).opsForValue(); } @Test @@ -153,15 +153,15 @@ class GrantGate { // given stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn("cached"); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn("cached"); // when service.getSvg(USERNAME, THEME, MODE); // then // 판정을 캐시 뒤로 미루면 자격 회수가 최대 60분간 반영되지 않는다 - verify(badgeGrantService).resolveGrant(USERNAME, THEME); + then(badgeGrantService).should().resolveGrant(USERNAME, THEME); } } @@ -176,8 +176,8 @@ class CacheHit { String cachedSvg = "cached"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(cachedSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(cachedSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -185,10 +185,10 @@ class CacheHit { // then assertThat(result.svg()).isEqualTo(cachedSvg); assertThat(result.granted()).isTrue(); - verify(queryService, never()).query(anyString(), anyString(), any()); - verify(svgBuilder, never()).build(any(), anyString(), anyString()); + then(queryService).should(never()).query(anyString(), anyString(), any()); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); // 캐시 히트 시 set은 호출되지 않아야 한다 - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } } @@ -204,11 +204,11 @@ class CacheMiss { String builtSvg = "built"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, THEME, MODE)).willReturn(builtSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -216,8 +216,8 @@ class CacheMiss { // then assertThat(result.svg()).isEqualTo(builtSvg); assertThat(result.granted()).isTrue(); - verify(queryService).query(USERNAME, THEME, GPU_NEEDS); - verify(svgBuilder).build(data, THEME, MODE); + then(queryService).should().query(USERNAME, THEME, GPU_NEEDS); + then(svgBuilder).should().build(data, THEME, MODE); } @Test @@ -228,11 +228,11 @@ class CacheMiss { String builtSvg = "built"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, THEME, MODE)).willReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); @@ -241,7 +241,7 @@ class CacheMiss { service.getSvg(USERNAME, THEME, MODE); // then - verify(valueOps).set(keyCaptor.capture(), eq(builtSvg), ttlCaptor.capture()); + then(valueOps).should().set(keyCaptor.capture(), eq(builtSvg), ttlCaptor.capture()); assertThat(keyCaptor.getValue()).isEqualTo(EXPECTED_CACHE_KEY); assertThat(ttlCaptor.getValue()).isEqualTo(Duration.ofMinutes(CACHE_TTL_MINUTES)); } @@ -252,15 +252,15 @@ class CacheMiss { // given: 임의 theme/mode가 svgBuilder 정규화로 gpu/light로 수렴 BadgeResponse data = sampleData(); String builtSvg = "built"; - when(svgBuilder.normalizeTheme("ZZZ")).thenReturn("gpu"); - when(svgBuilder.resolveMode("gpu", "GARBAGE")).thenReturn(BadgeMode.LIGHT); + given(svgBuilder.normalizeTheme("ZZZ")).willReturn("gpu"); + given(svgBuilder.resolveMode("gpu", "GARBAGE")).willReturn(BadgeMode.LIGHT); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); // needsOf는 정규화된 theme("gpu")로 호출된다 - when(svgBuilder.needsOf("gpu")).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, "gpu", "light")).thenReturn(builtSvg); + given(svgBuilder.needsOf("gpu")).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, "gpu", "light")).willReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); @@ -269,11 +269,11 @@ class CacheMiss { // then: raw 입력과 무관하게 정규화된 단일 키로 수렴 (캐시 증식 방지) assertThat(result.svg()).isEqualTo(builtSvg); - verify(valueOps).set(keyCaptor.capture(), eq(builtSvg), any(Duration.class)); + then(valueOps).should().set(keyCaptor.capture(), eq(builtSvg), any(Duration.class)); assertThat(keyCaptor.getValue()).isEqualTo(EXPECTED_CACHE_KEY); - verify(svgBuilder).build(data, "gpu", "light"); + then(svgBuilder).should().build(data, "gpu", "light"); // 자격 판정도 정규화된 theme으로 이뤄져야 한다 - verify(badgeGrantService).resolveGrant(USERNAME, "gpu"); + then(badgeGrantService).should().resolveGrant(USERNAME, "gpu"); } } @@ -287,11 +287,11 @@ class Failure { // given stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)) - .thenThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); // when & then assertThatThrownBy(() -> service.getSvg(USERNAME, THEME, MODE)) @@ -299,8 +299,8 @@ class Failure { .hasMessage(BadgeErrorCode.USER_NOT_FOUND.getMessage()); // 예외 발생 시 SVG 생성과 캐시 저장은 일어나지 않아야 한다 - verify(svgBuilder, never()).build(any(), anyString(), anyString()); - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } } } diff --git a/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java b/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java index 7973d1f..1e2a444 100644 --- a/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java +++ b/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java @@ -24,12 +24,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** * ResetOrchestrator.reset(Jwt) 검증: 쿨다운 게이트 → DB 삭제 → 배지 캐시 무효화 흐름과 실패 격리 동작. @@ -65,8 +64,8 @@ void setUp() { orchestrator = new ResetOrchestrator(resetService, badgeCacheInvalidator, redis); jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn(GITHUB_ID); - when(jwt.getClaimAsString("username")).thenReturn(USERNAME); + given(jwt.getSubject()).willReturn(GITHUB_ID); + given(jwt.getClaimAsString("username")).willReturn(USERNAME); } @Nested @@ -77,25 +76,25 @@ class ResetSuccess { @DisplayName("전체초기화_쿨다운선점성공_삭제와캐시무효화수행") void 전체초기화_쿨다운선점성공_삭제와캐시무효화수행() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); // when orchestrator.reset(jwt); // then - verify(resetService).resetUsage(-12345L); - verify(badgeCacheInvalidator).evict(USERNAME); + then(resetService).should().resetUsage(-12345L); + then(badgeCacheInvalidator).should().evict(USERNAME); // 정상 흐름에서는 쿨다운 해제(delete)를 호출하지 않는다. - verify(redis, never()).delete(anyString()); + then(redis).should(never()).delete(anyString()); } @Test @DisplayName("전체초기화_쿨다운키검증_githubId기반키사용") void 전체초기화_쿨다운키검증_githubId기반키사용() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(keyCaptor.capture(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(keyCaptor.capture(), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); // when orchestrator.reset(jwt); @@ -113,8 +112,8 @@ class ResetCooldown { @DisplayName("전체초기화_쿨다운미선점_RESET_COOLDOWN발생") void 전체초기화_쿨다운미선점_RESET_COOLDOWN발생() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.FALSE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.FALSE); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) @@ -123,8 +122,8 @@ class ResetCooldown { .isEqualTo(ResetErrorCode.RESET_COOLDOWN); // 쿨다운으로 거부되면 DB 삭제와 캐시 무효화는 일어나지 않는다. - verifyNoInteractions(resetService); - verifyNoInteractions(badgeCacheInvalidator); + then(resetService).shouldHaveNoInteractions(); + then(badgeCacheInvalidator).shouldHaveNoInteractions(); } @Test @@ -132,14 +131,14 @@ class ResetCooldown { void 전체초기화_setIfAbsent반환null_쿨다운거부() { // given // setIfAbsent가 null을 반환해도 Boolean.TRUE.equals(null)==false 이므로 거부되어야 한다(경계 케이스). - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(null); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(null); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) .isInstanceOf(AppException.class); - verify(resetService, never()).resetUsage(any()); + then(resetService).should(never()).resetUsage(any()); } } @@ -151,18 +150,18 @@ class ResetDbFailure { @DisplayName("전체초기화_DB삭제실패_쿨다운해제와예외전파") void 전체초기화_DB삭제실패_쿨다운해제와예외전파() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); RuntimeException dbError = new RuntimeException("DB write failed"); - doThrow(dbError).when(resetService).resetUsage(-12345L); + willThrow(dbError).given(resetService).resetUsage(-12345L); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) .isSameAs(dbError); // 서버 오류로 24시간 잠기지 않도록 쿨다운 키를 해제하고, 캐시 무효화는 수행하지 않는다. - verify(redis).delete(COOLDOWN_KEY); - verifyNoInteractions(badgeCacheInvalidator); + then(redis).should().delete(COOLDOWN_KEY); + then(badgeCacheInvalidator).shouldHaveNoInteractions(); } } @@ -174,17 +173,17 @@ class ResetCacheEvictionFailure { @DisplayName("전체초기화_캐시무효화실패_예외없이완료") void 전체초기화_캐시무효화실패_예외없이완료() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); - doThrow(new RuntimeException("cache eviction failed")).when(badgeCacheInvalidator).evict(USERNAME); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); + willThrow(new RuntimeException("cache eviction failed")).given(badgeCacheInvalidator).evict(USERNAME); // when & then // DB 삭제는 이미 커밋됐고 캐시 무효화는 best-effort이므로 reset 자체는 성공해야 한다. assertThatCode(() -> orchestrator.reset(jwt)).doesNotThrowAnyException(); // 삭제는 정상 수행, 캐시 실패로 쿨다운을 해제하지 않는다(delete 미호출). - verify(resetService).resetUsage(-12345L); - verify(redis, never()).delete(anyString()); + then(resetService).should().resetUsage(-12345L); + then(redis).should(never()).delete(anyString()); } } } diff --git a/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java b/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java index 0f1eed7..bccac0f 100644 --- a/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository; import org.junit.jupiter.api.DisplayName; @@ -35,38 +35,38 @@ class ResetUsage { @DisplayName("정상 초기화: 사용자의 토큰 사용량을 삭제한다") void 초기화_정상호출_토큰삭제() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)).thenReturn(3); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)).willReturn(3); // when resetService.resetUsage(GITHUB_ID); // then - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } @Test @DisplayName("경계값: 삭제된 행이 0이어도 예외 없이 정상 동작한다") void 초기화_삭제행0_예외없음() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)).thenReturn(0); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)).willReturn(0); // when / then assertThatCode(() -> resetService.resetUsage(GITHUB_ID)).doesNotThrowAnyException(); - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } @Test @DisplayName("실패 전파: 토큰 삭제가 예외를 던지면 그대로 전파된다") void 초기화_삭제예외_전파() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)) - .thenThrow(new RuntimeException("DB delete failed")); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)) + .willThrow(new RuntimeException("DB delete failed")); // when / then assertThatThrownBy(() -> resetService.resetUsage(GITHUB_ID)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("DB delete failed"); - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } } } diff --git a/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java b/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java index a9aaeaa..9d3719a 100644 --- a/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java +++ b/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java @@ -19,11 +19,12 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.doThrow; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; /** * SyncOrchestrator.sync 의 조율 흐름(사용자 저장 → 레코드 저장 → 배지 캐시 무효화)과 @@ -81,9 +82,9 @@ class Sync { // then InOrder order = inOrder(userService, tokenUsageRecordService, badgeCacheInvalidator); - order.verify(userService).saveUser(GITHUB_ID, USERNAME); - order.verify(tokenUsageRecordService).saveRecords(jwt, req); - order.verify(badgeCacheInvalidator).evict(USERNAME); + then(userService).should(order).saveUser(GITHUB_ID, USERNAME); + then(tokenUsageRecordService).should(order).saveRecords(jwt, req); + then(badgeCacheInvalidator).should(order).evict(USERNAME); order.verifyNoMoreInteractions(); } @@ -97,9 +98,9 @@ class Sync { assertThatCode(() -> syncOrchestrator.sync(jwt, req)).doesNotThrowAnyException(); InOrder order = inOrder(userService, tokenUsageRecordService, badgeCacheInvalidator); - order.verify(userService).saveUser(GITHUB_ID, USERNAME); - order.verify(tokenUsageRecordService).saveRecords(jwt, req); - order.verify(badgeCacheInvalidator).evict(USERNAME); + then(userService).should(order).saveUser(GITHUB_ID, USERNAME); + then(tokenUsageRecordService).should(order).saveRecords(jwt, req); + then(badgeCacheInvalidator).should(order).evict(USERNAME); } } @@ -112,8 +113,8 @@ class SyncFailure { void 동기화_레코드저장실패_후속호출없이예외전파() { // given SyncRequest req = requestWithOneRecord(); - doThrow(new RuntimeException("DB write failed")) - .when(tokenUsageRecordService).saveRecords(jwt, req); + willThrow(new RuntimeException("DB write failed")) + .given(tokenUsageRecordService).saveRecords(jwt, req); // when / then assertThatThrownBy(() -> syncOrchestrator.sync(jwt, req)) @@ -121,8 +122,8 @@ class SyncFailure { .hasMessageContaining("DB write failed"); // then: saveUser는 실패 이전이므로 호출됨, 이후 단계는 호출되지 않아야 함 - verify(userService).saveUser(GITHUB_ID, USERNAME); - verify(badgeCacheInvalidator, never()).evict(USERNAME); + then(userService).should().saveUser(GITHUB_ID, USERNAME); + then(badgeCacheInvalidator).should(never()).evict(USERNAME); } } } diff --git a/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java b/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java index 116abd3..1661ed4 100644 --- a/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java @@ -24,12 +24,11 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; 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.never; import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** * TokenUsageRecordService.saveRecords의 레코드 저장(upsert) 흐름과 입력 파싱 실패를 검증한다. @@ -55,7 +54,7 @@ class SaveRecordsSuccess { void 레코드저장_정상2건_upsert2회호출() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); TokenRecordRequest r1 = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L); TokenRecordRequest r2 = new TokenRecordRequest("2026-06-02", "claude-sonnet", 1L, 2L, 3L, 4L); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r1, r2)); @@ -64,7 +63,7 @@ class SaveRecordsSuccess { service.saveRecords(jwt, req); // then - verify(tokenRepo, times(2)).upsertRecord( + then(tokenRepo).should(times(2)).upsertRecord( anyLong(), any(UUID.class), any(LocalDate.class), anyString(), anyLong(), anyLong(), anyLong(), anyLong()); } @@ -74,7 +73,7 @@ class SaveRecordsSuccess { void 레코드저장_첫레코드인자_정확히전달() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); TokenRecordRequest r1 = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L); TokenRecordRequest r2 = new TokenRecordRequest("2026-06-02", "claude-sonnet", 1L, 2L, 3L, 4L); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r1, r2)); @@ -91,7 +90,7 @@ class SaveRecordsSuccess { ArgumentCaptor outputCaptor = ArgumentCaptor.forClass(Long.class); ArgumentCaptor cacheReadCaptor = ArgumentCaptor.forClass(Long.class); ArgumentCaptor cacheCreateCaptor = ArgumentCaptor.forClass(Long.class); - verify(tokenRepo, times(2)).upsertRecord( + then(tokenRepo).should(times(2)).upsertRecord( githubIdCaptor.capture(), deviceIdCaptor.capture(), dateCaptor.capture(), @@ -121,14 +120,14 @@ class SaveRecordsBoundary { void 레코드저장_빈목록_호출없음() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of()); // when service.saveRecords(jwt, req); // then - verify(tokenRepo, never()).upsertRecord( + then(tokenRepo).should(never()).upsertRecord( anyLong(), any(UUID.class), any(LocalDate.class), anyString(), anyLong(), anyLong(), anyLong(), anyLong()); } @@ -138,7 +137,7 @@ class SaveRecordsBoundary { void 레코드저장_단건_정확히1회호출() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); TokenRecordRequest only = new TokenRecordRequest("2026-06-01", "claude-opus", 5L, 6L, 7L, 8L); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(only)); @@ -146,7 +145,7 @@ class SaveRecordsBoundary { service.saveRecords(jwt, req); // then - verify(tokenRepo, times(1)).upsertRecord( + then(tokenRepo).should(times(1)).upsertRecord( eq(-42L), eq(UUID.fromString(VALID_DEVICE_ID)), eq(LocalDate.parse("2026-06-01")), @@ -167,14 +166,14 @@ class SaveRecordsFailure { void 레코드저장_잘못된UUID_예외발생() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); TokenRecordRequest r = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L); SyncRequest req = new SyncRequest("not-a-valid-uuid", List.of(r)); // when / then assertThatThrownBy(() -> service.saveRecords(jwt, req)) .isInstanceOf(IllegalArgumentException.class); - verifyNoInteractions(tokenRepo); + then(tokenRepo).shouldHaveNoInteractions(); } @Test @@ -182,14 +181,14 @@ class SaveRecordsFailure { void 레코드저장_잘못된날짜_예외발생() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("-42"); + given(jwt.getSubject()).willReturn("-42"); TokenRecordRequest badDate = new TokenRecordRequest("2026-13-99", "claude-opus", 10L, 20L, 30L, 40L); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(badDate)); // when / then assertThatThrownBy(() -> service.saveRecords(jwt, req)) .isInstanceOf(DateTimeParseException.class); - verifyNoInteractions(tokenRepo); + then(tokenRepo).shouldHaveNoInteractions(); } @Test @@ -197,14 +196,14 @@ class SaveRecordsFailure { void 레코드저장_subject비숫자_예외발생() { // given Jwt jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn("not-a-number"); + given(jwt.getSubject()).willReturn("not-a-number"); TokenRecordRequest r = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L); SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r)); // when / then assertThatThrownBy(() -> service.saveRecords(jwt, req)) .isInstanceOf(NumberFormatException.class); - verifyNoInteractions(tokenRepo); + then(tokenRepo).shouldHaveNoInteractions(); } } }