테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI

2025. 7. 1. 18:49·프로젝트

오늘은 테스트 코드에 대해서 알아보겠습니다.

공부를 해 나가는 만큼 추후에는 더 좋은 방법을 찾고 생각이 달라질 수도 있겠지만, 
여러 Reference를 공부하고 또 직접 코드를 작성해 보면서 느꼈던 부분들과 찾았던 개인적인 인사이트에 대해서 작성하고자 합니다!

📌 필수 개념 정립하기

✅ 테스트 코드는 왜 작성해야 할까요?

간단히 팀 프로젝트로 구현을 해보는 경우, 테스트 코드를 작성하지 않는 경우도 있습니다. Swagger나 Postman처럼 직접 API 요청을 보내고 결과를 확인만 하고 어느 정도 작동하는 것을 확인한 채로 말이죠. 

 

그렇게 생각하게 된다면, 테스트 코드는 불편한 숙제가 될수도 있습니다. 이미 작성한 코드에 대해서 또 테스트를 하는 코드를 작성하는 느낌이라면 말이죠. 하지만, 실제로 테스트 코드는 단순히 테스트를 하는 역할보다는 더 큰 편의성을 가져오게 됩니다.

  • 기존에 구현한 부분의 코드를 수정하는 새로운 기능을 구현하는 경우 파생되는 모든 효과를 직접 고려해야한다.
  • 기존 코드를 리펙토링 하는 경우 리펙토링 후에 모든 작업들이 잘 작동하는지 다시 확인해야 한다.
  • 다른 사람들과 팀으로써 작업하는 경우에도 타 팀원과 100% 소통이 되고 있는지 확인해야 한다.
  • 가장 중요한 점으로, Test가 되면서 기능이 정상적으로 작동하는지 확인할 수 있습니다.

언급하지 않은 것들을 제외하고도 여러 가지 장점들이 있습니다. 

 

결과적으로 테스트 코드는, 불편한 숙제가 아니라 유지 보수성을 높여줄 수 있는 유용한 도구라고 생각합니다.

 

테스트 코드의 역할과 장점에 대해서 잘 알고 있다면, 그것을 더욱 잘 반영하는 테스트 코드를 작성할 수 있다고 생각하기 때문에 테스트 코드 자체에 대해서 이해를 하는 마음가짐을 가지고 작성한 다면 더 좋은 코드를 작성할 수 있다고 생각합니다.

✅  Unit Test & Integration Test

 

테스트 코드를 작성하면서 가장 많이 마주할 두 가지 테스트 종류입니다.

  • Unit Test(단위 테스트) : 가장 작은 단위(함수, 메서드, 클래스 등)의 동작이 예상대로 작동하는지 격리된 환경에서 검증하는 테스트. 외부 의존성(DB, API, 파일 등)을 Mock, Stub 등으로 대체합니다.
  • Integration Test(통합 테스트) : 서로 다른 모듈이나 계층(Service, Repository, DB 등)이 정상적으로 연동되는지 검증하는 테스트 실제 DB, API, 메시지 브로커 등을 사용한다.
우선 저는 개인적으로 이 개념을 정립하는 부분이 가장 어려웠습니다. 테스트는 보통 DDD의 레이어별로 진행하게 됩니다.

"단위 테스트"라는 어감이 굉장히 저수준의 테스트이고 당연히 하위 Layer ( domain과 Repository ) 등에서 쓰일 것 같다는 생각이 들었기 때문입니다.

특히나, Repository 단 테스트를 단위 테스트라고 칭하는 경우가 많아서 어려웠습니다 (Repository는 보통 DB를 연결해서 하는 경우가 많은데 이게 왜 단위 테스트..?라는 의문이..). 

 

정확히 말하면, 단위 테스트는 외부 의존성을 사용하지 않고 별도의 런타임 인프라를 사용하지 않는다고 정의되어 있습니다.

 

Spring Docs  🔗

True unit tests typically run extremely quickly, as there is no runtime infrastructure to set up. Emphasizing true unit tests as part of your development methodology can boost your productivity.

 

따라서, 정확히 말하면 다음과 같습니다.

  • Controller Layer : 보통 @WebMvcTest 사용합니다. 서버가 뜨니까 통합 테스트는 맞지만, Service를 Mock 하는 경우가 많음.
  • Service Layer : 통합 테스트 or Mock을 통한 단위 테스트 ( Mockist vs Classicist ) 
  • Repository Layer : 통합 테스트 (@SpringBootTest or @DataJpaTest)

Repository Layer는 저수준의 레이어임에도 불구하고 통합 테스트를 진행하며, Service는 오히려 단위 테스트로 진행되는 경우도 있습니다. 그리고 WebMvcTest는 통합 테스트임에도 불구하고 단위 테스트처럼 Mock을 사용하기도 하니까 정말 어렵죠.

 

저는 개인적으로 위의 정확한 개념은 가져가되, 이 정도 느낌으로 이해하면 된다고 생각합니다.

  • 단위 테스트를 진행할 거야!  ▶️  의존성이 필요한 부분을 Mock 하겠다.
  • 통합 테스트를 진행할거야 ! ▶️  실제 의존성을 주입해서 테스트하겠다.

나중에 또 중요하게 다룰 예정이지만,  Service Layer에서 "통합 테스트 or Mock을 통한 단위 테스트"라고 작성해 놓은 이유는..
Service Layer에서 실제 Repository들을 주입해서 테스트할 수도 있고 Mock을 통해서 단위 테스트를 진행할 수도 있기 때문입니다. 

✅  의존성 주입과 Mock

Mock은 '흉내' 또는 '가짜의'이라는 뜻을 가지고 있습니다. 실제로도 정의에 부합하는 기능을 가지고 있습니다.

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService; // 컨트롤러가 사용하는 의존성 Mock

    @Test
    void getUser_ReturnsUsername() throws Exception {
        // given
        given(userService.getUsername(1L)).willReturn("mockUser");

        // when & then
        mockMvc.perform(get("/users/1"))
               .andExpect(status().isOk())
               .andExpect(content().string("mockUser"));
    }
}

위의 코드에서 처럼, 실제 존재하는 인터페이스 또는 클래스를 대체하는 가짜를 주입하고 특정 메서드를 호출했을 경우 어떤 값을 반환할지 정해줄 수 있는 기능입니다. 

 

이를 통해서 다음과 같은 장점이 있습니다

  • 의존성 주입 없이 간략하게 테스트하고 싶은 부분만 테스트 가능하다.
  • 의존성 주입이 어려운 부분(예를 들어, 외부 API)들을 제외하고 테스트가 가능하다.

의존성 주입이 어려운 부분을 대체하는 기능 또한 수행하기 때문에 꼭 단위 테스트에서만 사용되는 것은 아니며, 통합 테스트에서도 의존성 주입이 어려운 부분들을 대체하는 것에 사용됩니다.

📌  좋은 테스트를 작성하기 위한 TMI

✅  테스트 환경을 독립적으로 분리하자.

테스트를 진행하는 과정에서 당연하게도 운영상의 DB를 대상으로 진행하면 안 됩니다.  원하는 상황을 만들어 놓고 테스트를 하고 싶은데 그렇다고 실제 운영 DB를 조작할 수는 없으니까요.

 

보통은 H2 Database를 사용하며 임베디드 모드 + 인메모리 방식을 많이 사용합니다.

 

임베디드 모드를 사용하게 되면 애플리케이션과 동일한 JVM 안에서 데이터베이스를 열게 되며 H2 의존성만 추가하면 따로 DB를 설치하거나 세팅할 필요가 없어집니다. 또한,  인메모리 방식을 함께 사용하게 되면 메모리 위에서만 저장이 처리되고 끝나면 내용이 모두 사라지게 되어 매우 편리합니다.

 

그리고 다음과 같이 yml 파일을 설정해 주시면 됩니다.

spring:
  profiles:
    active: local # 기본 프로필 설정

---
spring:
  config:
    activate:
      on-profile: local
  datasource:
    url: jdbc:mysql://localhost:3306/my_local_db
    username: root
    password: root1234
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

---
spring:
  config:
    activate:
      on-profile: test
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

test 환경에서는 local 환경 또는 운영 환경과 분리하여 h2를 적용해 줄 것입니다.

  • ddl-auto : create - drop
  • datasource의 jdbc:h2:mem: 접두사는 인메모리 모드를 실행한다는 뜻이고 DB_CLOSE_DELAY=-1는 테스트가 끝나기 전까지만 DB를 유지한다는 의미입니다.

☑️ S3와 같이 주입하기 힘든 다른 의존성들도 사용된다면 이는 Mock을 통해서 해결해 줍니다.

@SpringBootTest
@Transactional
@ActiveProfiles("test")
public class exampleTest {
}

그리고 위와 같이 테스트 코드를 작성하는 과정에서 test 전용 프로필을 사용하며 운영 또는 로컬과 분리된 임베디드 H2 데이터 베이스를 테스트 환경에서만 사용할 수 있습니다. 테스트가 시작할 때 생성되고, 끝나면 사라지니 정말 편하게 사용할 수 있습니다.

✅  테스트 간 의존성을 줄이자 (데이터 주입).

테스트를 진행하기에 앞서서 필요한 데이터를 넣고 테스트를 실행해야 합니다. 이때 다음과 같은 방법을 사용하고자 할 수도 있습니다.

 

1. data.sql을 통한 데이터 주입

:  yml에서 defer- datasource-initialization : true

 

2. @BeforeAll 어노테이션 사용

: 한 테스트 클래스에서 필요한 데이터를 주입하고 사용

 

위에서 언급한 방법들은 지양하는 것이 좋습니다. 그 이유는 테스트 코드 간 의존성이 생기기 때문입니다. 예를 들어서, 10개의 테스트 상황이 하나의 데이터를 기준으로 진행하게 된다면..

  • 테스트 데이터가 인용되는 모든 테스트 케이스를 고려해서 주입되어야 한다.
  • 하나의 테스트가 수정되면 의존되는 모든 테스트 케이스에 영향이 갈 수 있다.

위와 같은 문제들이 생기게 되며 저런 이유를 방지하기 위해서 테스트 간 공유되는 데이터를 최소화하는 것이 좋다고 생각합니다. 테스트 코드는 유지 보수성을 높여주는 유용한 수단인 만큼 테스트 코드 자체도 유지 보수성이 좋아야 합니다.

 

❌ (지양) : @BeforeAll을 통한 공용 데이터 주입 방식

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS) 
class MemberServiceTest {

    @Autowired
    private MemberRepository memberRepository;
    
    @BeforeAll
    void setup() {
        memberRepository.save(
            new Member("user1@example.com", "User1")
        );
    }

    @Test
    void testFindById() {
        Member found = memberRepository.findById(savedMember.getId()).orElseThrow();
        assertThat(found.getEmail()).isEqualTo("user1@example.com");
    }

    @Test
    void testUpdateMember() {
        savedMember.updateNickname("Updated");
        memberRepository.save(savedMember);

        Member updated = memberRepository.findById(savedMember.getId()).orElseThrow();
        assertThat(updated.getNickname()).isEqualTo("Updated");
    }
}

 

✅ (권장) : 각 테스트가 독립적으로 데이터를 생성하고 @AfterEach로 정리하는 방식

@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberRepository memberRepository;

    @AfterEach
    void cleanUp() {
        memberRepository.deleteAll(); 
    }

    @Test
    void testFindById() {
        Member member = memberRepository.save(new Member("user1@example.com", "User1"));

        Member found = memberRepository.findById(member.getId()).orElseThrow();
        assertThat(found.getEmail()).isEqualTo("user1@example.com");
    }

    @Test
    void testUpdateMember() {
        Member member = memberRepository.save(new Member("user2@example.com", "User2"));

        member.updateNickname("Updated");
        memberRepository.save(member);

        Member updated = memberRepository.findById(member.getId()).orElseThrow();
        assertThat(updated.getNickname()).isEqualTo("Updated");
    }
}

 

처음 작성할 때에는 일이 많겠지만, 위와 같이 테스트마다 독립적으로 수행하는 것이 추후에 수정하고 유지 보수하는 시간을 많이 절약할 수 있어요!

✅  테스트 간 의존성을 줄이자 (순서에 의존하게 하지 말자) → @Dynamic Test

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MemberServiceTest {

    @Autowired
    private MemberRepository memberRepository;

    private Member savedMember;

    @BeforeAll
    void setup() {
        savedMember = memberRepository.save(new Member("user1@example.com", "User1"));
    }

    @Test
    void testFindMember() {
        Member found = memberRepository.findById(savedMember.getId()).orElseThrow();
        assertThat(found.getNickname()).isEqualTo("User1"); 
    }

    @Test
    void testUpdateMemberNickname() {
        savedMember.updateNickname("Updated");
        memberRepository.save(savedMember);
    }
}

공유되는 데이터를 없애게 된다면 함께 없어지는 문제이기도 합니다. testFindMember와 testUpdateMemberNickname의 순서가 바뀌게 되면 테스트는 실패하게 됩니다. 따라서, 위와 같은 구조는 지양해야 합니다.

 

만약, 순서의 흐름을 보여주고 싶다면 @Dynamic Test를 사용할 수 있습니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.List;
import java.util.Collection;

import static org.assertj.core.api.Assertions.*;

class StockTest {

    @DisplayName("재고 차감 흐름 테스트")
    @TestFactory
    Collection<DynamicTest> stockQuantityFlowTest() {
        // given
        Stock stock = new Stock(1);

        return List.of(
            DynamicTest.dynamicTest("1개 재고에서 1개를 차감하면 재고는 0이 된다", () -> {
                // given
                int quantityToDeduct = 1;

                // when
                stock.deductQuantity(quantityToDeduct);

                // then
                assertThat(stock.getQuantity()).isZero();
            }),

            DynamicTest.dynamicTest("재고가 0일 때 차감 시도하면 예외가 발생한다", () -> {
                // given
                int quantityToDeduct = 1;

                // when & then
                assertThatThrownBy(() -> stock.deductQuantity(quantityToDeduct))
                    .isInstanceOf(IllegalArgumentException.class)
                    .hasMessage("차감할 재고 수량이 없습니다.");
            })
        );
    }
}

예를 들어서 재고라는 엔티티가 있고 0 미만으로 줄이려고 하면 에러가 난다고 생각해 보겠습니다.

그렇다면, 위와 같이 흐름을 보여주는 코드를 작성할 수 있습니다.

✅  Domain Test : 단위 테스트를 작성하자.

domain의 경우 별도의 의존성이 필요 없이 단위 테스트를 작성하면 됩니다.

 

도메인 예시

import lombok.Builder;
import lombok.Getter;

@Getter
public class Member {

    private final String name;
    private final int age;

    @Builder(access = AccessLevel.PRIVATE)
    private Member(String name, int age) {
        validateName(name);
        validateAge(age);
        this.name = name;
        this.age = age;
    }

    public static Member createMember(String name, int age) {
        return Member.builder()
                     .name(name)
                     .age(age)
                     .build();
    }

    private void validateName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다.");
        }
    }

    private void validateAge(int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("나이는 0~150 사이여야 합니다.");
        }
    }
}

 

테스트 예시

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class MemberTest {

    // 성공 : happy case
    @DisplayName("멤버를 정상적으로 생성할 수 있습니다.")
    @ParameterizedTest(name = "name={0}, age={1}")
    @CsvSource(
            value = {
                    "예시멤버,150",
                    "예시멤버,0" 
            }
    )
    void 정상적으로_멤버를_생성할_수_있다(String name, int age) {
        // Given & When
        Member member = Member.createMember(name, age);

        // Then
        assertThat(member.getName()).isEqualTo(name);
        assertThat(member.getAge()).isEqualTo(age);
    }
    
    

    // 실패는 edge case로 ( 경계값 테스트 )
    @DisplayName("나이가 음수면 예외 발생")
    @Test
    void 나이가_음수이면_예외가_발생한다() {
        // Given
        String name = "홍길동";
        int age = -1;

        // When & Then
        assertThatThrownBy(() -> Member.createMember(name, age))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("나이는 0~150 사이여야 합니다.");
    }

    @DisplayName("나이가 150초과이면 예외 발생")
    @Test
    void 나이가_150초과이면_예외가_발생한다() {
        // Given
        String name = "홍길동";
        int age = 151;

        // When & Then
        assertThatThrownBy(() -> Member.createMember(name, age))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("나이는 0~150 사이여야 합니다.");
    }
}

위와 같이 도메인 하나의 테스트는 거의 다른 의존성이 필요없이 단위 테스트로 작성하게 됩니다.

해당 예시를 바탕으로 몇 가지 더 이야기해보겠습니다.

✅  BDD 방식을 사용하자 ( given, when, then)

BDD는 Behavior-Driven Development의 약자로 행위를 기준으로 테스트 코드를 작성하는 방법입니다.

 

  • Given: 주어진 조건에서
  • When: 어떤 행동을 했을 때
  • Then: 어떤 결과가 나와야 한다

라는 방식으로 작성해 주면 가독성이 좋은 테스트를 작성할 수 있습니다.

✅  경계값(Edge Case)을 기준으로 테스트를  작성하자.

도메인의 예시를 보면 에러가 던져지는 기준인 0과 150을 기준으로 성공하는 케이스를 작성하고 그 경계값을 기준으로 실패하는 케이스를 작성했습니다.

 

위와 같은 방식으로 경계값을 기준으로 테스트를 작성하는 것이 좋습니다.

✅  분기 대신 Parameterized Test를 적극적으로 사용하자

if문을 사용해서 테스트를 분기하면서 작성하면 가독성이 떨어지게 됩니다.

 

다음과 같은 대안으로 분기 처리를 제거할 수 있습니다.

  • 상황에 따라서 테스트 함수 자체를 분리한다
  • 같은 맥락의 다양한 case를 테스트하는 경우 parameterized test를 사용.
@DisplayName("멤버를 정상적으로 생성할 수 있습니다.")
@ParameterizedTest(name = "name={0}, age={1}")
@CsvSource(
        value = {
                "예시멤버,150",
                "예시멤버,0" 
        }
)
void 정상적으로_멤버를_생성할_수_있다(String name, int age) {
    // Given & When
    Member member = Member.createMember(name, age);

    // Then
    assertThat(member.getName()).isEqualTo(name);
    assertThat(member.getAge()).isEqualTo(age);
}

저는 이렇게 @CsvSource를 많이 사용합니다.

 

이외에도 생성 로직을 적용하는 @MethodSource나 한 종류의 데이터만 주입할 경우 @ValueSource를 사용할 수도 있습니다.

그리고 제가 멤버의 이름에 대한 검증은 예시에 포함하지 않았는데 @NullSource와 @EmptySource를 함께 이용하면 가독성이 좋은 테스트를 작성할 수 있습니다.

 

좀 더 자세한 문법에 대해서 알고 싶다면 junit user guide를 참고해 주세요 🔗

 

✅  DisplayName을 자세하게 작성하자.

 

 

✅  Repository Test : @DataJpaTest를 사용하자 (JPA를 쓰는 경우)

@SpringBootTest와 @DataJpaTest는 모두 통합 테스트이지만, @DataJpaTest는 전체 Spring Application Context를 로딩해서 테스트하는 것이 아닌 Jpa Repository 테스트에 필요한 최소한의 빈만 로딩합니다. 그래서, 더 효율적이기 때문에 유용하게 사용할 수 있습니다.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.assertj.core.api.Assertions.*;

@DataJpaTest
class MemberRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    void 이름으로_회원_조회_테스트() {
        // Given
        Member saved = memberRepository.save(new Member("홍길동", 30));

        // When
        Member found = memberRepository.findByName("홍길동");

        // Then
        assertThat(found).isNotNull();
        assertThat(found.getName()).isEqualTo("홍길동");
        assertThat(found.getAge()).isEqualTo(30);
    }
}

Repository는 다음과 같이 DataJpaTest를 쓰는 것이 빠르고 @DataJpaTest에서는 자동으로 test마다 트랜잭션을 열어주고 롤백을 해주기 때문에 매우 편리합니다. 

 

이는 @SpringBootTest와의 가장 큰 차이점이기도 합니다.

✅  @SpringBootTest vs @DataJpaTest 

위에서 설명한 것처럼 필요한 의존성을 로딩하는 것 이외에도 DataJpaTest와 SpringBootTest는 결정적인 차이가 존재합니다.

 

바로 "자동으로 Transaction을 열어주냐"에 대한 부분입니다.

@SpringBootTest는 자동으로 트랜잭션을 열어주지 않고 @DataJpaTest는 자동으로 트랜잭션을 열어준다는 점입니다.

 

따라서,

@SpringBootTest는 각 테스트마다 자동으로 롤백이 되지 않습니다. 

@SpringBootTest
@Transactional
class MemberServiceTest {

위와 같이 @Transactional을 추가해 주면 각 테스트마다 Transaction을 열어주며 테스트 단위가 끝날 때 롤백이 됩니다.

 

다만, 위와 같이 Transactional을 사용하게 되면 Service에서 Transaction을 잘 사용하고 있는지는 테스트하기 어려울 수도 있다는 단점이 있습니다. 따라서, 트랜잭션 롤백 테스트가 아니라 @SpringBootTest + @AfterEach를 통한 테스트를 사용하는 경우도 있습니다.

@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @AfterEach
    void cleanUp() {
        // 테스트 이후 수동으로 정리해줘야 함
        memberRepository.deleteAll();
    }

    @Test
    void saveMember_실제_저장된다() {
        memberService.save("kim");
        assertEquals(1, memberRepository.count());
    }
}

✅  @AfterEach에서 클렌징하기 deleteAll() vs deleteAllInBatch

deleteAll()을 사용할 경우 매핑되어 있는 것까지 모두 삭제해 줍니다.

 

예를 들어서, Member와 Book은 MemberBook이라는 매핑 테이블을 공유하고 있다고 생각해 봅시다.

Member를 deleteAll()로 지울 경우, MemberBook이 양방향 매핑이 되어 있다면 함께 지워줍니다.

 

deleteAllInBatch()를 사용할 경우, 매핑되어 있는 필드를 함께 지워주지는 않습니다.

하지만, deleteAll()의 경우 전체 엔티티를 조회 후에 하나씩 삭제하는 반면 deleteAllInBatch()는 한 번에 처리하기 때문에 성능적으로 더 큰 이점을 가지고 있습니다.

 

평소에는 @Transaction을 통해서 롤백 테스트를 편하게 사용하면 된다. 만약, Spring Batch 테스트를 실행하여 Transaction이 여러 개 열려서 클렌징 메서드를 사용하기 어려운 경우 deleteAllInBatch를 사용해 보자.

⭐ Service Layer Test : 단위 테스트 vs 통합 테스트

이건 처음 개념에서도 언급했던 Mockist vs Classicist로도 유명한 부분입니다.

 

Mock을 사용하는 경우, 

Repository 레이어가 테스트가 되었기 때문에 정상 작동한다는 가정을 하고 테스트를 하게 됩니다.

 

통합 테스트를 사용하는 경우,

Repository 레이어의 각각의 기능에 대해서는 테스트가 되었지만, 함께 사용되며 파생되는 효과를 완전히 예측하기 어렵기 때문에 통합 테스트를 사용한다는 입장입니다.

 

저 같은 경우는 classicist에 가까운 것 같아요! 

 

실제 파생되는 효과를 예측하기 어렵고, 오히려 Repository 메서드가 바뀌는 경우 Mock에 대한 코드 또한 리펙토링을 수행해 주어야 한다는 단점이 존재한다고 생각합니다. 

✅  List를 검증하는 좋은 방법.

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.groups.Tuple.tuple;

import java.util.List;

import org.junit.jupiter.api.Test;

class MemberServiceTest {

    @Test
    void 멤버_리스트_검증() {
        List<Member> members = List.of(
            new Member(1L, "kim"),
            new Member(2L, "lee")
        );

        assertThat(members)
            .hasSize(2)
            .extracting("id", "name")
            .containsExactlyInAnyOrder(
                tuple(1L, "kim"),
                tuple(2L, "lee")
            );
    }
}

다음과 같이 hasSize를 통해서 크기를 검증하고 extracting을 통해서 내용을 점검하면 깔끔한 테스트 코드 작성이 가능합니다.

  • 순서를 검증하고자 하면 containsExactlyInOrder 사용 가능

✅  통제하지 못하는 부분을 외부로 빼서 테스트를 용이하게 만들자

public class PromotionService {
    public boolean isEligibleForMorningDiscount() {
        LocalDateTime now = LocalDateTime.now();
        return now.getHour() < 6;
    }
}

예를 들어 6시까지만 할인을 해주는 것을 판별하는 서비스가 있다고 한다면, 내부에 LocalDateTime.now()가 있어서 테스트를 돌리는 시간에 따라서 결과가 달라질 수 있습니다.

 

저런 부분은 외부로 빼주면 테스트를 하기 훨씬 편하게 됩니다.

public class PromotionService {
    public boolean isEligibleForMorningDiscount(LocalDateTime now) {
        return now.getHour() < 6;
    }
}

계속 상단의 레이어로 올리게 된다면 어느 순간, 더 올릴 수는 없겠지만 너무 하위 레이어에 통제 하지 못하는 부분이 존재하면 테스트가 용이하지 못합니다.

✅  Error를 테스트하는 좋은 방법

class MemberServiceTest {

    @Test
    void findMember_아이디_null이면_예외발생() {
        MemberService service = new MemberService();

        assertThatThrownBy(() -> service.findMember(null))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("ID는 null일 수 없습니다");
    }
}

assertThatThrownBy() + isInstanceOf() + hasMessage() 조합 사용

✅  CQRS 분리와 테스트 Transaction

읽기 전용으로 사용되는 @Transaction(readOnly = true)를 사용하게 되면 dirty checking과 CRUD 스냅샷을 저장하지 않는 등 더 효율적인 설계가 가능합니다.

 

이에 따라서, test 또한 필요한 Transaction을 적용해 주는 것이 효율적입니다.

@SpringBootTest
@Transactional(readOnly = true) // 기본은 모두 읽기 전용 트랜잭션
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Test
    void 회원_조회는_성공해야_한다() {
        Member member = memberService.findById(1L);
        assertThat(member.getName()).isEqualTo("example");
    }

    @Test
    @Transactional // 여기서는 readOnly = false 로 오버라이딩됨
    void 회원_등록은_성공해야_한다() {
        MemberCreateRequest req = new MemberCreateRequest("example", "jun@example.com");
        Long id = memberService.register(req);
        assertThat(id).isNotNull();
    }
}

제가 참고한 강의에서는 다음과 같이 맨 위에는 readOnly = true를 걸어주고 메서드에서 다시 트랜잭션을 걸어주는 방식을 추천했습니다.

✅  Controller Layer MockMvcTest

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService; // Service 계층은 Mock 처리

    @Autowired
    private ObjectMapper objectMapper; // JSON 직렬화에 필요

    @DisplayName("신규 상품을 등록한다.")
    @Test
    void createProduct() throws Exception {
        // given
        ProductCreateRequest request = ProductCreateRequest.builder()
                .type(ProductType.HANDMADE)
                .sellingStatus(ProductSellingStatus.SELLING)
                .name("아메리카노")
                .price(4000)
                .build();

        // when & then
        mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/products/new")
                        .content(objectMapper.writeValueAsString(request)) // JSON 변환
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());
    }
}

Controller 테스트의 예시입니다.  Json 변환이 필요하기 때문에 ObjectMapper를 주입해야 합니다.

 mockMvc.perform(post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isBadRequest()) // HTTP 상태 400
            .andExpect(jsonPath("$.code").value("400")) // JSON 내 code 필드
            .andExpect(jsonPath("$.status").value("BAD_REQUEST")) // JSON 내 status 필드
            .andExpect(jsonPath("$.message").value("상품 타입은 필수입니다.")); // 커스텀 메시지 예시

다음과 같은 방식으로 Json 파씽 검사도 가능합니다.

 

그리고, MockMvcTest의 경우에는 Jpa관련 Bean을 로딩하지 않기 때문에 메인 어플리케이션 위에 @EnableJpaAuditing을 설정해 놓은 경우 오류가 날 수 있습니다.

 

문제 상황

@SpringBootApplication
@EnableJpaAuditing
public class ServerApplication {

    public static void main(String[] args) {
       SpringApplication.run(ServerApplication.class, args);
    }

}

이런 경우, 미리미리 따로 Config를 빼주는 것이 좋습니다.

 

Config 분리

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}

✅  Mockito를 통한 Stubbing

@Test
void 메일_전송에_성공하면_true를_반환한다() {
    // given 
    Mockito.when(mailSendClient.sendEmail(
            anyString(), anyString(), anyString(), anyString())
    ).thenReturn(true);

    // when
    boolean result = mailService.sendJoinEmail("test@example.com", "홍길동");

    // then
    assertThat(result).isTrue();
}

다음과 같이 Mail을 보내는 등 외부 API를 부르는 서비스는 Transaction을 걸지 말고 Stubbing을 해주는 것이 좋습니다.

✅  (참고) BDD Mockito

위에서 보면 Mockito.when()이 // given에 들어간 느낌이라  BDD에 조금 더 부합하게 사용할 수 있는 BDD Mockito도 존재합니다.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void 유저_ID로_정상조회_성공() {
        // given
        User user = new User(1L, "example");
        given(userRepository.findById(1L)).willReturn(Optional.of(user));

        // when
        User result = userService.getUser(1L);

        // then
        assertThat(result.getName()).isEqualTo("example");
        then(userRepository).should(times(1)).findById(1L);
    }
}

✅  given에서 실패하지 말자!

  • 항상 when과 then에서 에러를 반환하도록 신경 써야 합니다.

✅  테스트 코드만을 위한 메서드를 만드는 경우

만들어도 괜찮지만 보수적으로 접근하는 것이 좋다고 합니다.

  • 기준 : 어떤 객체가 마땅히 가져도 될 행위이고 미래에 사용할 만 한가?

✅  private 메서드는 테스트할 필요가 있을까?

애초에 public 메서드를 테스트하는 과정에서 검증이 되기 때문에 검사할 필요가 없다.

그래도 private를 테스트하고 싶다면 객체를 분리하는 것을 고려해 보는 것이 좋다.

📌  효율적인 Test를 수행하기 위한 Tip

✅  테스트 환경을 통합하자.

@SpringBootTest나 @DataJpaTest를 수행하는 경우, Spring 서버가 열리게 됩니다. 그런데, 통합되지 않은 환경에서 수행하게 될 경우 어노테이션 하나당 Spring 서버가 올라오게 됩니다. 

 

따라서, 하나로 통합해 주는 것이 좋습니다.

package sample.cafekiosk.spring;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@ActiveProfiles("test") 
@SpringBootTest          
public abstract class IntegrationTestSupport {
}
class ProductServiceTest extends IntegrationTestSupport {

    @Autowired
    private ProductService productService;

    @Test
    void 상품등록_정상작동() {
        // given, when, then
    }
}

다음과 같이 abstract class를 만들고 통합 테스트 환경에서 상속받는 방향으로 수행해 주시면 됩니다.

 

저는 개인적으로

  • Repository Layer : @DataJpaTest
  • Service Layer : @SpringBootTest
  • Controller Layer : @MockMvcTest

를 사용할 것 같은데요, 레이어 별로 통일해 줄 예정입니다.

@WebMvcTest(controllers = {
        OrderController.class,
        ProductController.class
})
public abstract class ControllerTestSupport {

    @Autowired
    protected MockMvc mockMvc;
}

다만 WebMvcTest의 경우 상속받을 Controller들을 명시해 주어야 하기 때문에 약간의 의존성이 생기는 부분은 어쩔 수 없는 것 같습니다.

'프로젝트' 카테고리의 다른 글

JPA 최적화는 어떻게 해야할까?  (0) 2025.05.28
배포의 모든 것 - 5. Blue-Green 무중단 배포 적용  (2) 2025.05.01
Clokey 프로젝트 리펙토링 - Github Actions & Docker 최적화  (0) 2025.04.29
Docker에 대해서 알아보자 - 3. docker build & docker compose  (0) 2025.04.26
Docker에 대해서 알아보자 - 2.Dockerfile  (1) 2025.04.26
'프로젝트' 카테고리의 다른 글
  • JPA 최적화는 어떻게 해야할까?
  • 배포의 모든 것 - 5. Blue-Green 무중단 배포 적용
  • Clokey 프로젝트 리펙토링 - Github Actions & Docker 최적화
  • Docker에 대해서 알아보자 - 3. docker build & docker compose
potato-farm
potato-farm
개발 혼자 공부하기
  • potato-farm
    감자밭
    potato-farm
  • 전체
    오늘
    어제
    • 분류 전체보기 (27)
      • ETC (2)
      • 알고리즘 (0)
      • Java (0)
      • DB (2)
      • Spring (0)
      • 프로젝트 (15)
      • Server (3)
      • CS (0)
        • 운영체제 (0)
      • Infra (4)
        • IAC (1)
        • AWS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • hELLO· Designed By정상우.v4.10.3
potato-farm
테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI
상단으로

티스토리툴바