test: add unit test infrastructure and core-logic tests

First tests in the project (src/test was absent). Add testRuntimeOnly
junit-platform-launcher (required by Gradle 9 to load the JUnit Platform),
then cover:
- VideoMetrics: duration parse, isShorts boundary, viewsPerHour clamp/
  division, viewsPerSubRatio rounding & zero-guards (9 cases, pure)
- DashboardService.summary(): composes each domain service under the
  right key (Mockito)
- PublishService.dashboardSummary(): status counts + recent capped at 5

12 tests, all green. No Spring context / DB needed — fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-05-31 13:37:52 +09:00
parent 9828918b97
commit 2cdae03998
4 changed files with 196 additions and 0 deletions

View File

@ -37,6 +37,7 @@ dependencies {
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0' implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'

View File

@ -0,0 +1,75 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 순수 지표 계산 유틸 단위 테스트 (DB/Spring 불필요).
*/
class VideoMetricsTest {
@Test
void parseDurationSec_parsesIso8601() {
assertThat(VideoMetrics.parseDurationSec("PT1M5S")).isEqualTo(65);
assertThat(VideoMetrics.parseDurationSec("PT3M")).isEqualTo(180);
assertThat(VideoMetrics.parseDurationSec("PT1H")).isEqualTo(3600);
}
@Test
void parseDurationSec_returnsNullForBlankOrInvalid() {
assertThat(VideoMetrics.parseDurationSec(null)).isNull();
assertThat(VideoMetrics.parseDurationSec("")).isNull();
assertThat(VideoMetrics.parseDurationSec(" ")).isNull();
assertThat(VideoMetrics.parseDurationSec("not-a-duration")).isNull();
}
@Test
void isShorts_trueWhenAtMost65Seconds() {
assertThat(VideoMetrics.isShorts(60)).isTrue();
assertThat(VideoMetrics.isShorts(65)).isTrue(); // boundary inclusive
assertThat(VideoMetrics.isShorts(66)).isFalse();
assertThat(VideoMetrics.isShorts(null)).isFalse();
}
@Test
void viewsPerHour_zeroWhenInputMissing() {
assertThat(VideoMetrics.viewsPerHour(null, LocalDateTime.now())).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(VideoMetrics.viewsPerHour(1000L, null)).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
void viewsPerHour_clampsElapsedToAtLeastOneHour() {
// published just now -> elapsed < 1h -> treated as 1h -> ratio == viewCount
assertThat(VideoMetrics.viewsPerHour(500L, LocalDateTime.now()))
.isEqualByComparingTo(new BigDecimal("500.00"));
}
@Test
void viewsPerHour_dividesByElapsedHours() {
assertThat(VideoMetrics.viewsPerHour(1000L, LocalDateTime.now().minusHours(10)))
.isEqualByComparingTo(new BigDecimal("100.00"));
}
@Test
void viewsPerSubRatio_dividesViewsBySubscribers() {
assertThat(VideoMetrics.viewsPerSubRatio(1000L, 100L))
.isEqualByComparingTo(new BigDecimal("10.00"));
}
@Test
void viewsPerSubRatio_roundsHalfUp() {
assertThat(VideoMetrics.viewsPerSubRatio(10L, 3L))
.isEqualByComparingTo(new BigDecimal("3.33"));
}
@Test
void viewsPerSubRatio_zeroWhenSubscribersMissingOrZero() {
assertThat(VideoMetrics.viewsPerSubRatio(1000L, 0L)).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(VideoMetrics.viewsPerSubRatio(1000L, null)).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(VideoMetrics.viewsPerSubRatio(null, 100L)).isEqualByComparingTo(BigDecimal.ZERO);
}
}

View File

@ -0,0 +1,67 @@
package com.hlab.yanalyst.domain.publish;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 발행 대시보드 요약: 상태별 카운트 + 최근 5건(전체는 많아도 5개로 잘림).
*/
@ExtendWith(MockitoExtension.class)
class PublishServiceTest {
@Mock PublishPackageRepository repository;
@InjectMocks PublishService publishService;
@Test
void dashboardSummary_countsByStatusAndTakesFirstFiveAsRecent() {
when(repository.countByStatus("DRAFT")).thenReturn(2L);
when(repository.countByStatus("READY")).thenReturn(3L);
when(repository.countByStatus("PUBLISHED")).thenReturn(4L);
// list(null) -> repository.findAllSorted(sort); return 7 so recent should cap at 5
List<PublishPackage> seven = Collections.nCopies(7, mock(PublishPackage.class));
when(repository.findAllSorted(any(Sort.class))).thenReturn(seven);
Map<String, Object> summary = publishService.dashboardSummary();
@SuppressWarnings("unchecked")
Map<String, Long> byStatus = (Map<String, Long>) summary.get("byStatus");
assertThat(byStatus)
.containsEntry("DRAFT", 2L)
.containsEntry("READY", 3L)
.containsEntry("PUBLISHED", 4L);
assertThat(summary.get("total")).isEqualTo(7L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(5);
}
@Test
void dashboardSummary_recentKeepsAllWhenFiveOrFewer() {
when(repository.countByStatus(any())).thenReturn(0L);
when(repository.findAllSorted(any(Sort.class)))
.thenReturn(Collections.nCopies(3, mock(PublishPackage.class)));
Map<String, Object> summary = publishService.dashboardSummary();
assertThat(summary.get("total")).isEqualTo(3L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(3);
}
}

View File

@ -0,0 +1,53 @@
package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.category.CategoryService;
import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
import com.hlab.yanalyst.domain.publish.PublishService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 대시보드 단일 집계가 도메인 서비스 결과를 올바른 키로 조합하는지 검증.
*/
@ExtendWith(MockitoExtension.class)
class DashboardServiceTest {
@Mock ChannelVideoCurationService curationService;
@Mock CategoryService categoryService;
@Mock PublishService publishService;
@InjectMocks DashboardService dashboardService;
@Test
void summary_composesEachServiceUnderItsKey() {
Map<String, Object> pipeline = Map.of("total", 200L);
Map<String, Object> categories = Map.of("uncategorized", 200L);
Map<String, Object> publish = Map.of("total", 0L);
List<ChannelVideo> outperformers = List.of(mock(ChannelVideo.class), mock(ChannelVideo.class));
when(curationService.pipelineStats()).thenReturn(pipeline);
when(categoryService.distribution()).thenReturn(categories);
when(publishService.dashboardSummary()).thenReturn(publish);
when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers);
Map<String, Object> result = dashboardService.summary();
assertThat(result).containsOnlyKeys("pipeline", "categories", "publish", "outperformers");
assertThat(result.get("pipeline")).isSameAs(pipeline);
assertThat(result.get("categories")).isSameAs(categories);
assertThat(result.get("publish")).isSameAs(publish);
assertThat(result.get("outperformers")).isSameAs(outperformers);
}
}