diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml new file mode 100644 index 0000000..8600fb5 --- /dev/null +++ b/.github/workflows/cicd.yaml @@ -0,0 +1,19 @@ +name: "CI/CD" + +on: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: test + run: | + echo "test" + +# TODO: ghcr.io & Argo CD (related: #22) diff --git a/.github/workflows/codecov.yaml b/.github/workflows/codecov.yaml new file mode 100644 index 0000000..fe5fd63 --- /dev/null +++ b/.github/workflows/codecov.yaml @@ -0,0 +1,41 @@ +name: "Spring Test" + +on: + push: + branches: + - "*" + pull_request: + branches: + - main + +permissions: + contents: write + actions: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "adopt" + + - name: Spring test + run: | + ./gradlew clean test + env: + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload results to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 19afed2..69af8cb 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,6 @@ - [Package 구조](docs/package-structure.md) - [Service layer 개발 방법](docs/service.md) - [Controller 개발 방법](docs/controller.md) +- [Test 코드 개발 방법](docs/test.md) - [Docker Compose 사용 방법](docs/docker-compose.md) - [Kubernetes 사용 방법](docs/kubernetes.md) diff --git a/build.gradle b/build.gradle index bbcb8c6..ed3098d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' } group = 'co-co-gong' @@ -33,6 +34,8 @@ dependencies { // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' // MapStruct implementation 'org.mapstruct:mapstruct:1.5.5.Final' @@ -56,4 +59,34 @@ dependencies { tasks.named('test') { useJUnitPlatform() + finalizedBy 'jacocoTestReport' } + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.outputLocation = layout.buildDirectory.dir("jacocoHtml") + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + // minimum = 0.80 + minimum = 0.0 + } + } + } +} + +test { + finalizedBy jacocoTestCoverageVerification + testLogging { + events "passed", "failed", "skipped" + exceptionFormat "full" + } +} + diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..ebc5961 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,32 @@ +coverage: + status: + project: + default: + target: 0% # FIXME: Test 완성 후 변경 + patch: + default: + target: 0% # FIXME: Test 완성 후 변경 + +comment: + layout: "header, diff, flags, components, files, footer" + behavior: default + require_changes: false + require_base: false + require_head: false + hide_project_coverage: false + +component_management: + default_rules: + statuses: + - type: project + target: 0% # FIXME: Test 완성 후 변경 + - type: patch + target: 0% # FIXME: Test 완성 후 변경 + individual_components: + - component_id: user + name: user + paths: + - src/test/java/com/server/domain/user/service/** +# TODO: Coverage 측정 제외 대상에 대해 정의 +# ignore: +# - diff --git a/docs/images/jacoco.png b/docs/images/jacoco.png new file mode 100644 index 0000000..1cddb51 Binary files /dev/null and b/docs/images/jacoco.png differ diff --git a/docs/test.md b/docs/test.md new file mode 100644 index 0000000..e31826c --- /dev/null +++ b/docs/test.md @@ -0,0 +1,48 @@ +## Spring test 규약 + +- `JUnit`: Java 단위 test framework + - 목적: Code 검증 + - 사용법: `@Test`, `@BeforeEach` 등 annotation으로 test 구성 +- `Mockito`: Mocking library + - 목적: 의존성 격리 + - 사용법: 가짜 객체 생성으로 독립적 test 수행 +- `JaCoCo`: Code coverage 측정 도구 + - 목적: Test coverage 분석 + - 사용법: Test 후 coverage report 제공 + +## Spring test 시 debugging + +1. `@Slf4j` annotation을 추가한다. + +```java +@Slf4j +class UserServiceTest { +``` + +2. 아래와 같이 `--info`를 통해 logger 수준을 설정한다. + +```shell +$ ./gradlew clean test --info | grep ERROR + 2025-01-07T21:21:19.197+09:00 ERROR 13537 --- [co-co-gong-server] [ Test worker] c.s.domain.user.service.UserServiceTest : ----------------- +``` + +## JaCoCo + +```shell +$ cd ./build/jacocoHtml && python -m http.server 8000 +Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET / HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/report.css HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/sort.js HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/redbar.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/greenbar.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/session.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/report.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/sort.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/down.gif HTTP/1.1" 200 - +{IP}.{IP}.{IP}.{IP} - - [07/Jan/2025 21:44:45] "GET /jacoco-resources/package.gif HTTP/1.1" 200 - +``` + +![jacoco](./images/jacoco.png) + +## [Codecov](https://app.codecov.io/gh/co-co-gong/co-co-gong-server) diff --git a/src/main/java/com/server/domain/oauth/dto/GithubDto.java b/src/main/java/com/server/domain/oauth/dto/GithubDto.java index b51e334..d586b0b 100644 --- a/src/main/java/com/server/domain/oauth/dto/GithubDto.java +++ b/src/main/java/com/server/domain/oauth/dto/GithubDto.java @@ -1,10 +1,15 @@ package com.server.domain.oauth.dto; import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +@AllArgsConstructor +@Builder @Getter @NoArgsConstructor @Setter diff --git a/src/main/java/com/server/global/error/exception/BaseException.java b/src/main/java/com/server/global/error/exception/BaseException.java index cc73a33..f3e5ba8 100644 --- a/src/main/java/com/server/global/error/exception/BaseException.java +++ b/src/main/java/com/server/global/error/exception/BaseException.java @@ -6,10 +6,12 @@ @Getter public class BaseException extends RuntimeException { + private final ErrorCode errorCode; private final int status; private final String message; public BaseException(ErrorCode errorCode) { + this.errorCode = errorCode; this.status = errorCode.getStatus(); this.message = errorCode.getMessage(); } diff --git a/src/test/java/com/server/domain/user/service/UserServiceTest.java b/src/test/java/com/server/domain/user/service/UserServiceTest.java new file mode 100644 index 0000000..6edc56e --- /dev/null +++ b/src/test/java/com/server/domain/user/service/UserServiceTest.java @@ -0,0 +1,218 @@ +package com.server.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +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 java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.server.domain.oauth.dto.GithubDto; +import com.server.domain.user.dto.GetUserOutDto; +import com.server.domain.user.entity.User; +import com.server.domain.user.mapper.UserMapper; +import com.server.domain.user.repository.UserRepository; +import com.server.global.error.code.UserErrorCode; +import com.server.global.error.exception.BusinessException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("새로운 사용자가 로그인하는 상황에 새롭게 DB에 생성하고 반환") + void loginOrRegister_newUser_registersAndReturnsUser() { + /* given */ + String githubToken = "gho_nGh9jQ4tXYi9GIPe9cgbur5Mf2RHPW2x8iOn"; + GithubDto githubDto = GithubDto.builder() + .username("newuser") + .email("newuser@example.com") + .thumbnail("https://avatars.githubusercontent.com/u/0?v=4") + .build(); + given(userRepository.findByUsername(githubDto.getUsername())).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + /* when */ + User result = userService.loginOrRegister(githubDto, githubToken); + + /* then */ + then(userRepository).should().save(any(User.class)); + assertThat(result.getUsername()).isEqualTo(githubDto.getUsername()); + assertThat(result.getGithubToken()).isEqualTo(githubToken); + } + + @Test + @DisplayName("이미 존재하는 사용자가 로그인하는 상황에 새롭게 DB에 생성하지 않고 조회하여 반환") + void loginOrRegister_existingUser_returnsUser() { + /* given */ + String username = "User1"; + String email = "user1@examle.com"; + String thumbnail = "https://avatars.githubusercontent.com/u/0?v=4"; + String githubTokenOld = "gho_nGh9jQ4tXYi9GIPe9cgbur5Mf2RHPW2x8iOn"; + String githubTokenNew = "gho_dCZucktIcoFI2hqhCm6ATBw7Z08ecT20dJt3"; + GithubDto githubDto = GithubDto.builder() + .username(username) + .email(email) + .thumbnail(thumbnail) + .build(); + User existingUser = User.builder() + .username(username) + .email(email) + .thumbnail(thumbnail) + .oauth("github") + .githubToken(githubTokenOld) + .build(); + given(userRepository.findByUsername(username)).willReturn(Optional.of(existingUser)); + + /* when */ + User result = userService.loginOrRegister(githubDto, githubTokenNew); + + /* then */ + then(userRepository).should(never()).save(any(User.class)); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("githubToken") + .isEqualTo(existingUser); + // FIXME: 해당 시나리오에서 제대로 동작하고 있지 않음. (related: #23) + // 이미 존재하는 사용자가 다시 로그인할 때 새로운 깃허브 토큰을 사용해야하는데 잘 이뤄지지 않고 있음. + // assertThat(result.getGithubToken()).isNotEqualTo(existingUser.getGithubToken()); + // assertThat(result).isNotEqualTo(existingUser); + } + + @Test + void saveRefreshToken_existingUser_updatesToken() { + /* given */ + User user = User.builder() + .username("user1") + .email("user1@example.com") + .thumbnail("https://avatars.githubusercontent.com/u/0?v=4") + .oauth("github") + .githubToken("gho1234") + .build(); + given(userRepository.findByUsername("user1")).willReturn(Optional.of(user)); + + /* when */ + userService.saveRefreshToken("user1", "new_refresh_token"); + + /* then */ + assertThat(user.getRefreshToken()).isEqualTo("new_refresh_token"); + verify(userRepository).save(user); + } + + @Test + void saveRefreshToken_nonExistingUser_throwsException() { + /* given */ + given(userRepository.findByUsername("unknownUser")).willReturn(Optional.empty()); + + /* when & then */ + assertThatThrownBy(() -> userService.saveRefreshToken("unknownUser", "refreshToken")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("User not found"); + } + + @Test + void getUserWithPersonalInfo_existingUser_returnsUser() { + /* given */ + User user = User.builder() + .username("user1") + .email("user1@example.com") + .thumbnail("https://avatars.githubusercontent.com/u/0?v=4") + .oauth("github") + .githubToken("gho1234") + .build(); + + given(userRepository.findByUsername("user1")).willReturn(Optional.of(user)); + + /* when */ + User result = userService.getUserWithPersonalInfo("user1"); + + /* then */ + assertThat(result).isEqualTo(user); + } + + @Test + void getUserWithPersonalInfo_nonExistingUser_throwsBusinessException() { + /* given */ + given(userRepository.findByUsername("unknownUser")).willReturn(Optional.empty()); + + /* when & then */ + assertThatThrownBy(() -> userService.getUserWithPersonalInfo("unknownUser")) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.NOT_FOUND); + } + + @Test + void getUserWithoutPersonalInfo_existingUser_returnsDto() { + /* given */ + User user = User.builder() + .username("user1") + .email("user1@example.com") + .thumbnail("https://avatars.githubusercontent.com/u/0?v=4") + .oauth("github") + .githubToken("gho1234") + .build(); + GetUserOutDto dto = new GetUserOutDto(); + dto.setUsername(user.getUsername()); + dto.setEmail(user.getEmail()); + given(userRepository.findByUsername("user1")).willReturn(Optional.of(user)); + given(userMapper.toGetUserOutDto(user)).willReturn(dto); + + /* when */ + GetUserOutDto result = userService.getUserWithoutPersonalInfo("user1"); + + /* then */ + assertThat(result).isEqualTo(dto); + } + + @Test + void updateEmail_updatesEmailAndSavesUser() { + /* given */ + // given + User user = new User("user1", "oldemail@example.com", "thumbnail.png", "github", "some_token"); + given(userRepository.save(user)).willReturn(user); + + /* when */ + User updatedUser = userService.updateEmail(user, "newemail@example.com"); + + /* then */ + verify(userRepository).save(user); + assertThat(updatedUser.getEmail()).isEqualTo("newemail@example.com"); + } + + @Test + void deleteUser_deletesUser() { + /* given */ + User user = new User("user1", "user1@example.com", "thumbnail.png", "github", "some_token"); + + /* when */ + userService.deleteUser(user); + + /* then */ + verify(userRepository).delete(user); + } +}