개발이군고구마

[사면초가 TDD/OOP] 2. 단위 테스트 (테스트 하고 있는 메서드만 생각한다) 본문

SERVER/Architecture

[사면초가 TDD/OOP] 2. 단위 테스트 (테스트 하고 있는 메서드만 생각한다)

김구황 2025. 5. 30. 07:20
728x90

 

❇ 현재 진행단계. 

[OOP 설계 프로세스]
 1. 설계 원칙 확립 < 구현 1단계
2. TDD 구현 단위 < 구현 2단계
3. TDD 구현 통합
4. 이터레이션과 현실

 

 

단위 테스트는 아래와 같은 로직으로 실행된다. 

1. 도메인 / 서비스 
1) (테스트 하는 클래스) 를 기준으로 뼈대 잡기  클래스/메서드 뼈대 선언 도메인, 서비스, 레포의 인터페이스만 미리 

2) 테스트 코드 작성 (아직 구현은 거의 없음) 성공하는 테스트를 먼저

3) 필요한 최소한의 구현 코드 추가:  실패테스트 → 구현 → 테스트 → 구현 반복
ㄴ"실행"만 되었는지 확인하는 정도로

2. 컨트롤러 API→ 인터페이스/클래스 뼈대 먼저 만들고 테스트 붙이기

 


1. 도메인 테스트  FileEntityTest.java

🌐 객체 생성과 수정 

 

1) FileEntity (테스트 하는 클래스) 를 기준으로 뼈대 잡기 

대략적인 필드 값과 생성자를 잡아둠 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) // Entity가 default로 제공해주는 생성자에 제한
@Getter
@Table(name = "files")
@DynamicUpdate
public class FileEntity extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String fileKey; // 실제 DB 에 저장되는 값

    // vo의 것들을 embed
    @Embedded
    private FileMetaData metaData;

    /// enum 클래스를 가지고 옴
    @Enumerated(EnumType.STRING)
    private Purpose purpose;

    @Enumerated(EnumType.STRING)
    private FileStatus fileStatus;

    protected FileEntity(FileMetaData metaData, String fileKey, Purpose purpose, FileStatus temporary) {
        // 검증
        this.metaData = metaData;
        this.fileKey = fileKey;
        this.purpose = purpose;
        this.fileStatus = FileStatus.TEMPORARY; // 임시 상태로 생성 (코드 값으로)
    }
}
  • protected 로 생성자 - builder 굳이 안써도 됨 (따로 builder 패턴으로 생성할 필요가 없기 떄문)
  • FileMetatData를 embed 시킴으로써 테스트의 용이성을 확보함
    • mock으로 주입하여 entity를 테스트하고, 후에 fileMetatadata만 따로 테스트 
    • 즉, FileEntity > FileMetadata 각자의 역할만 한다. 

 

 

2) entity 생성 수정에 관한 테스트 생각해보기 무엇이 필요할까 

a. 단순 객체를 생성한다. (성공테스트)
b. 객체 생성시에 꼭 필요한 변수들이 없거나 이상한지 여부 (실패테스트 - 무엇이 불안한가?)
c. 객체 수정/삭제시에  상태 변경 제대로 하는지 확인 

 

 

2-a) 단순 객체를 생성한다. 

 

Q. 객체를 생성할 때는 어떻게? 

A. 생성자를 만드는 방식으로 만들어보자. create 라는 메서드를 하나 추출하여 만든다. 

FileMetatData 를 분리해두었기 때문에 mock으로만 받음 (편리)

 

🔵 테스트 코드 

@Test
void testFileEntityCreation() {
    // Given
    FileMetaData metaData = mock(FileMetaData.class);
    Purpose purpose = mock(Purpose.class);
    String fileKey = "fileKey123";

    // When
    FileEntity fileEntity = FileEntity.create(metaData, fileKey, purpose);
}

 

🔴 실제코드 

public static FileEntity create(FileMetaData metaData, String fileKey, Purpose purpose) {
    return new FileEntity(metaData, fileKey, purpose, FileStatus.TEMPORARY);
}

 

 

Q. 객체를 생성시에 필요한 검증은 ?

A. 테스트의 편의성을 위해선 메서드를 하나씩 추출해서 만드는 것이 좋다. 

메서드를 추출하여 받아오는 변수를 검증하는 로직을 추가해보자.  

 

🔵 테스트 코드 

변함없음

 

🔴 실제코드 

protected FileEntity(FileMetaData metaData, String fileKey, Purpose purpose, FileStatus temporary) {
    validateBasicConstruction(metaData, fileKey, purpose); // 한번 검증을 하자 
    this.metaData = metaData;
    this.fileKey = fileKey;
    this.purpose = purpose;
    this.fileStatus = FileStatus.TEMPORARY; // 임시 상태로 생성 (코드 값으로)
}

private void validateBasicConstruction(FileMetaData metaData, String fileKey, Purpose purpose) {
    if(metaData == null) {
        throw new FileException(FileExceptionMessage.REQUIRED_FILE_META_DATA);
    }
    if(fileKey == null || fileKey.trim().isEmpty()) {
        throw new FileException(FileExceptionMessage.REQUIRED_FILE_KEY);
    }
    if(purpose == null) {
        throw new FileException(FileExceptionMessage.REQUIRED_PURPOSE);
    }
}

 

 

Q. 최종적으로 비교할 값들은? 

A. mock으로 집어넣어서 임의로 준 값들이 제대로 들어가서 객체가 생성되었는지. then 

 

🔵 테스트 코드 

assertNotNull(fileEntity);
// FileEntity의 상태가 TEMPORARY로 설정
assertThat(fileEntity.getFileStatus()).isEqualTo(FileStatus.TEMPORARY);
assertThat(fileEntity.getMetaData()).isEqualTo(metaData);
assertThat(fileEntity.getFileKey()).isEqualTo(fileKey);
assertThat(fileEntity.getPurpose()).isEqualTo(purpose);

 

🔴 실제코드 

변함없음

 

 

2-b) 변수에 이상이 있다면 (실패테스트)

 

Q. 받아온 파일데이터가 이상하다면? 

A. 예외를 던져야한다. 정확하게 어떤 예외를 던져야하는지는FileEntity가 정의해주고 있다.  

 

🔵 테스트 코드

@Test
void testFileEntityCreationWithNullMetaData() {
    // Given
    String fileKey = "fileKey123";
    Purpose purpose = mock(Purpose.class);

    // When & Then
    // Exception을 커스텀 하여 던져보자
    // 실패 현재는 Throwable로 던지고 있음
    assertThatThrownBy(() -> FileEntity.create(null, fileKey, purpose))
        .isInstanceOf(FileException.class)
        .hasMessageContaining("FileMetaData is required");
}

 

🔴 실제코드 

변함없음 

 


Q. 파일키가 이상하다면? 

A. 도메인 테스트로 @ParameterizedTest 준비한다. null 이거나 "" 이거나 

 

🔵 테스트 코드

@ParameterizedTest // 파라미터 테스트
@NullAndEmptySource // null과 빈 문자열을 테스트
@ValueSource(strings = {"      "}) // 공백 문자열을 테스트
void testFileEntityCreationWithInvalidFileKey(String fileKey) {
    // fileKey가 null, 빈 문자열, 공백 문자열인 경우를 테스트
    // Given
    FileMetaData metaData = mock(FileMetaData.class);
    Purpose purpose = mock(Purpose.class);

    // When & Then
    assertThatThrownBy(() -> FileEntity.create(metaData, fileKey, purpose))
        .isInstanceOf(FileException.class)
        .hasMessageContaining("FileKey is required");
}

 

🔴 실제코드 

변함없음 

 


Q. purpose 상태 값이 이상하다면? 

A. purpose의 값이 들어오지 않았을 때를 테스트 한다. 

 

🔵 테스트 코드

@Test
void testFileEntityCreationWithNullPurpose() {
    // Given
    FileMetaData metaData = mock(FileMetaData.class);
    String fileKey = "fileKey123";

    // When & Then
    assertThatThrownBy(() -> FileEntity.create(metaData, fileKey, null))
        .isInstanceOf(FileException.class)
        .hasMessageContaining("Purpose is required");
}

 

🔴 실제코드 

변함없음 

 

 

 

2-c) 상태값을 잘 변경하는지?

 

Q. 객체 상태 수정이 제대로 되었는가?  

A. 상태 수정 메서드를 제대로 호출하였는지 확인한다. 이를 위해 우선 파일 객체 내부의 상태 수정 메서드를 만들고 구현한다. 

 

🔵 테스트 코드

@Test
void testFileEntityCreationWithPermenantFile() {
    // Given
    FileMetaData metaData = mock(FileMetaData.class);
    Purpose purpose = mock(Purpose.class);
    String fileKey = "fileKey123";
    FileEntity fileEntity = FileEntity.create(metaData, fileKey, purpose);

    // When
    // 이미 만들어진 객체에서 변경 (이미 만들어진 객체를 찾는 부분은 서비스)
    // *** Entity의 역할은 상태만 변경하는 것 ***
    fileEntity.markAsPermanent();

    // Then
    assertThat(fileEntity.getFileStatus()).isEqualTo(FileStatus.PERMANENT);
}

 

 

🔴 실제코드 

File과 관련된 메서드 

public void markAsPermanent() {
    if (isDeleted()) {
        throw new FileException(FileExceptionMessage.CANNOT_RESTORE_DELETED_FILE);
    }
    this.fileStatus = FileStatus.PERMANENT;
}

 

 

 


2. 서비스 테스트 FileServiceTest.java

🌐 의존성을 끊어내는 것이 제일 중요함 

  • 결과값을 잘 반환하는지 
  • 메서드를 잘 호출했는지 (메서드 분리 중요) 
  • X 상태값 변경 
  • 의사코드 명확히 해야 진행하면서 실제 코드 구현이 가능함 


1) FileService(테스트 하는 클래스) 를 기준으로 뼈대 잡기 

  • File - Storage는 다른 모듈사이의 연결, 향후 확장 가능성 => interface 도입
  • image 검증 메서드는 많기 때문에 메서드만 정리되어있는 클래스 생성 
public interface StorageHelper {
    // 이미지를 업로드
    void uploadImage(String key, MultipartFile imageFile);

    // fileKey를 생성
    String generateFileKey(String value, String getextension);

    // imageUrl 을 생성
    String getFullImageUrl(String key);

    // 프론트에서 받아올 imageUrl을 넣으면 imagekey를 찾을 메서드
    String extractImageKey(String imageUrl);


}
@Component
public class ImageValidator {
	....앞으로 채워넣어질 예정
}

 

 

 

2) Service 메서드 테스트 무엇이 필요할까? 

a. 파일 객체 받아와서 이미지 url 반환한다. (성공테스트)
b. 이미지 업로드시에 발생할 수 있는 예외를 제대로 반영하는지 확인한다. (실패테스트 - 무엇이 불안한가?)
c. 이미지키 추출 및 검증 과정에서 예외가 제대로 나오고 있는지 확인한다. 
d. 이미지 확정/삭제 이벤트를 제대로 처리하고 있는지 확인한다. 

 

 

2-a) 파일 객체 받아와서 이미지 url 반환한다. (성공테스트)

 

Q. 파일을 스토리지에 업로드하려면 무엇이 필요할까?   

A. 우선 필요한 인터페이스나 repository는 실행이 되었는지만 확인하기 때문에 mock으로 주입시키다. 

스토리지 업로드를 위해서는 우선 file객체와 (모듈별로 함께쓰는 서비스이기 때문에) 어떤 모듈에서 왔는지도 보내주어야 한다.

 

🔵 테스트 코드

@Mock
private StorageHelper storageHelper;

@Mock
private FileJpaRepository fileJpaRepository;

@Mock
private ImageValidator imageValidator;

// Mock으로 주입된 객체들을 실제로 사용하기 위해 @InjectMocks를 사용
@InjectMocks
private FileService fileService;

@Test
void 이미지파일넘기면_url반환한다() {
    // 우선 이미지 파일을 받아야겠지
    MultipartFile imageFile = mock(MultipartFile.class);
    // stub
    when(imageFile.getOriginalFilename()).thenReturn("test.png");
    when(imageFile.getContentType()).thenReturn("image/png");
    when(imageFile.getSize()).thenReturn(1000L);

    // enum 파일에 있는 생성자의 value를 불러와서 image url 에 생성
    Purpose purpose = mock(Purpose.class);
    String purposeValue = "purposeValue";
    // stub
    when(purpose.getValue()).thenReturn(purposeValue);


    String imageKey = "imageKey";
    // stub
    when(storageHelper.generateFileKey(eq(purposeValue), anyString())).thenReturn(imageKey);


    String imageUrl = "imageUrl";
    // stub
    when(storageHelper.getFullImageUrl(eq(imageKey))).thenReturn(imageUrl);


    String result = fileService.uploadImage(imageFile, purpose);
}

 

 

Q. 파일업로드 실제 로직은 그러면 어떻게 짜야할까?   

A. 의사코드를 바탕으로 검증과 예외를 적절히 넣어서 비즈니스 로직을 구현해본다. 

 

🔴 실제코드 

  • fileService의 업로드 메서드만 테스트 한다 는 전제를 지키자. 즉, 
    • validate 이미지 안에 내용을 지금 구체적으로 작성할 필요는 없다. 메서드 정도 만들어주고
    • 예외 던지는 메서드 검증은 빌딩하면서 계속해서 
    • proceedUpload도 메서드 정도만 만들고 계속 빌딩
    • getFullImageUrl도 반환값 고정하고 계속해서 빌딩함 
  • FileMetaData 와 FileEntity는 이미 정의가 되어있다. 
public String uploadImage(MultipartFile imageFile, Purpose purpose) {
    imageValidator.validate(imageFile);

    FileMetaData fileMetaData = FileMetaData.from(imageFile);
    String imageKey = storageHelper.generateFileKey(purpose.getValue(), fileMetaData.getExtension());
    FileEntity fileEntity = FileEntity.create(fileMetaData, imageKey, purpose);
    fileJpaRepository.save(fileEntity);

    processUpload(imageKey, imageFile);

    return storageHelper.getFullImageUrl(imageKey);
}

private void processUpload(String imageKey, MultipartFile imageFile) {
    ... 나중에 구현해도 됨 
}

* FileMetaData.from(imageFile) : 파일 객체에서 메타데이터를 추출하고 

* FileEntity : 파일 데이터를 저장하는 역할만 한다. 

 

👋🏻 여기서 잠깐 문법 학습

❓  new 인스턴스 (dependency (use)) 와 DI 필드(association) 의 상황상 쓰임 
* StorageHelper, FileJpaRepository, ImageValidator :
- 생성자(또는 스프링 등 DI 컨테이너)에서 주입되어 필드로 “가지고 있는(보유하는)” 관계
- FileService 객체가 생성된 순간부터 해당 참조가 계속 유지

* FileEntity
- 메서드(로직) 내부에서 잠깐 쓰는 객체
- 메서드 로직이 끝나면 사라짐 
- 파라미터/로컬 변수로만 사용

 

 

Q. 최종적으로 무엇을 확인하면 될까? 

A. mock으로 주입한 객체의 메서드들이 실제 실행되었는지만 확인해본다. (verify) 

assertThat(result).isEqualTo(imageUrl);
verify(imageValidator).validate(imageFile);
verify(fileJpaRepository).save(any(FileEntity.class));
verify(storageHelper).uploadImage(imageKey, imageFile);

 

 

 

2-b) 이미지 업로드시에 발생할 수 있는 예외를 제대로 반영하는지 확인한다. (실패 테스트) - imageValidator

 

Q. 예외를 던지는 모든 케이스는?   

A. 의사코드를 바탕으로 검증과 예외를 적절히 넣어서 비즈니스 로직을 구현해본다. (본격적인 비즈니스 코드 작성) 

 

🔴 실제코드 

imageValidate 안 메서드 

의사코드를 바탕으로 

@Component
public class ImageValidator {

    private static final List<String> ALLOWED_CONTENT_TYPES = Arrays.asList("image/jpeg", "image/png");
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList(".jpg", ".jpeg", ".png");

    private final UriValidator uriValidator;
    private final long maxImageSize;

    public ImageValidator(
       @Value("${spring.servlet.multipart.max-file-size}") final DataSize maxImageSize,
       final UriValidator uriValidator
    ) {
       this.maxImageSize = maxImageSize.toBytes();
       this.uriValidator = uriValidator;
    }
    
    public void validate(MultipartFile imageFile) {
        validateImageFile(imageFile);
        String originalFilename = imageFile.getOriginalFilename();
        validateOriginalFileName(originalFilename);
        validateExtension(originalFilename.toLowerCase());
        validateImageContentType(imageFile.getContentType());
        validateImageSize(imageFile.getSize());
    }
    .... 각종 검증
    
}

 

 

 

2-c) 파일 객체 받아와서 이미지 url 반환한다. (실패 테스트) - storageHelper

Q. 예외를 던지는 모든 케이스는?   

A. 의사코드를 바탕으로 검증과 예외를 적절히 넣어서 구현체 비즈니스 로직을 구현해본다. (본격적인 비즈니스 코드 작성) 

 

🔴 실제코드 

@Override
public String extractImageKey(String imageUrl) {
    String imageUrlPrefix = getImageUrlPrefix();
    if (!imageUrl.startsWith(imageUrlPrefix)) {
       throw new IllegalArgumentException("invalid url: " + imageUrl);
    }
    return imageUrl.substring(imageUrlPrefix.length() + 1);
}

 

 

2-d) 이미지 확정/삭제 이벤트를 제대로 처리하고 있는지 확인한다. 

 

Q. 이미지 url을 반환한 후에 저장을 하려면 어떻게 ?   

A. 이전에 부수적인 것들은 stub을 통해 해결하고, 영구확정 메서드를 작성한ㄷ. 

 

🔵 테스트 코드

@Test
void imageUrl의_이미지_정보_사용을_확정한다() {
    // given
    String imageUrl = "imageUrl";
    String imageKey = "imageKey";
    when(storageHelper.extractImageKey(imageUrl)).thenReturn(imageKey);

    FileEntity fileEntity = mock(FileEntity.class);
    when(fileJpaRepository.findByFileKey(eq(imageKey))).thenReturn(Optional.of(fileEntity));

    // when
    fileService.confirmUsingImage(imageUrl);

}

 

🔴 실제코드 

@Override
public void confirmUsingImage(String imageUrl) {
    FileEntity fileEntity = getFileEntityFromImageUrl(imageUrl);
    fileEntity.markAsPermanent();
}

 

 

Q. 최종적으로 확인해볼 것?

🔵 테스트 코드

// then
verify(imageValidator).validateUrl(imageUrl);
verify(fileEntity).markAsPermanent();

 

 

 

3. 슬라이스 테스트 (컨트롤러)

🌐 실제 api response를 잘 받아오는 지에 대한 테스트 

  • MockMvc 를 Controller와 연결함 
  • apiResponse 대로 확인함 
@Test
void API_응답을_가져온다() throws Exception {
    mockMvc.perform(get("/api-response"))
       .andExpect(status().isOk())
       .andExpect(jsonPath("$.success").value(true))
       .andExpect(jsonPath("$.message").value("성공"))
       .andExpect(jsonPath("$.result").value("OK"));
}