코드 한 줄의 기록

Java 테스트 코드 작성법: Given-When-Then 패턴과 픽스처 관리 완벽 가이드 본문

JAVA

Java 테스트 코드 작성법: Given-When-Then 패턴과 픽스처 관리 완벽 가이드

CodeByJin 2025. 12. 28. 00:09
반응형

이 글을 쓰게 된 이유는 최근 제가 Java로 복잡한 비즈니스 로직을 테스트할 때 많은 문제에 부딪혔기 때문입니다. 처음에는 테스트 코드를 무작정 작성했는데, 시간이 지나면서 테스트 코드 자체가 유지보수하기 어려운 "레거시"가 되어버리는 경험을 했습니다. 특히 테스트 데이터를 어떻게 준비할 것인지, 테스트 코드를 어떻게 구조화할 것인지에 대한 명확한 기준이 없었습니다.


그래서 이 글에서는 제가 학습하고 적용해본 Given-When-Then 패턴효과적인 픽스처 관리 전략을 공유하려고 합니다. 이 두 가지는 테스트 코드를 훨씬 읽기 쉽고, 유지보수하기 좋고, 의도가 명확한 코드로 만들어줍니다.

테스트 코드는 왜 중요한가?

본론으로 들어가기 전에, 왜 우리가 테스트 코드를 제대로 작성해야 하는지 간단히 생각해보겠습니다.


많은 개발자들이 "테스트 코드는 선택이 아닌 필수"라고 말하지만, 저도 처음에는 그 이유를 명확하게 이해하지 못했습니다. 그러나 실제 프로젝트에서는 다음과 같은 이점을 경험했습니다.

  • 빠른 피드백: 코드 변경 후 모든 엣지 케이스를 수동으로 테스트할 필요가 없습니다.
  • 자신감 있는 리팩토링: 기존 기능이 깨지지 않았다는 확신 하에서 코드를 개선할 수 있습니다.
  • 살아있는 문서: 테스트 코드가 그 자체로 이 함수가 어떻게 동작해야 하는지 명확하게 보여줍니다.
  • 조기 버그 발견: 개발 중에 버그를 찾아내기 때문에 프로덕션에서의 문제를 줄일 수 있습니다.

이런 이점들을 충분히 누리려면, 테스트 코드 자체가 잘 작성되어야 합니다.

Part 1: Given-When-Then 패턴 이해하기

BDD(Behavior-Driven Development)와의 연결

Given-When-Then 패턴은 BDD(행위 주도 개발)에서 비롯된 테스트 구조입니다. 이 패턴의 가장 큰 특징은 비기술자도 이해할 수 있는 자연어로 테스트를 표현할 수 있다는 점입니다.


예를 들어, 제품 팀과 개발 팀이 다음과 같이 대화할 수 있습니다.

  • Given (주어진 상황): "사용자가 로그인되어 있고, 장바구니에 상품이 있을 때"
  • When (언제): "결제 버튼을 클릭하면"
  • Then (결과): "주문이 생성되고 결제가 처리된다"

이 대화가 그대로 테스트 코드로 변환될 수 있다는 것이 BDD의 장점입니다.

 

세 가지 단계 상세히 이해하기

Given - 준비 단계

Given 절에서는 테스트를 실행하기 위한 초기 상태를 설정합니다. 테스트가 실행되려면 필요한 객체들을 생성하고, 목 객체를 설정하고, 데이터베이스를 초기화하는 등의 준비 작업이 필요합니다.

@Test
@DisplayName("사용자가 상품을 장바구니에 담을 수 있다")

void addProductToCart() {
    // Given - 준비 단계
    User user = new User("testUser", "user@example.com");
    Product product = new Product("상품명", 10000);
    ShoppingCart cart = new ShoppingCart(user);
    
    // ... 테스트가 실행될 상황을 완벽하게 설정
}

 

좋은 Given 절의 특징

  • 명확한 초기 상태: 다른 개발자가 읽었을 때 어떤 상황에서 테스트가 실행되는지 즉시 이해할 수 있어야 합니다.
  • 필요한 것만 설정: 테스트와 무관한 데이터는 포함하지 않습니다.
  • 의미 있는 값: 무작위 값이나 1, 2, 3 같은 임시 값보다는 실제 의미 있는 값을 사용합니다.

When - 실행 단계

When 절에서는 테스트 대상이 되는 메서드를 실제로 실행합니다. 이 부분은 매우 간단해야 합니다. 보통 한 줄 정도의 코드만 포함됩니다.

    // When - 실행 단계
    cart.addProduct(product, 2); // 상품을 수량 2개로 장바구니에 추가

 

When 절의 핵심

  • 단 한 가지 행동만: 여러 메서드를 호출하면 안 됩니다. 정확히 하나의 메서드만 실행해야 합니다.
  • 간결함: 복잡한 로직은 Given에서 처리되어야 하고, When은 최대한 간단해야 합니다.
  • 명확한 의도: 이 줄이 무엇을 테스트하려는 건지 한눈에 보여야 합니다.

Then - 검증 단계

Then 절에서는 실행 결과가 예상대로 되었는지 확인합니다.

    // Then - 검증 단계
    assertThat(cart.getItems()).hasSize(1);
    assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(2);
    assertThat(cart.getTotalPrice()).isEqualTo(20000);

 

좋은 Then 절의 특징

  • 적절한 수의 assertion: 1-3개 정도가 이상적입니다. 너무 많으면 테스트가 복잡해집니다.
  • 명확한 의도: 각 assertion이 무엇을 검증하는지 명확해야 합니다.
  • 단일 책임: 한 가지 행동의 결과를 검증하는 테스트는 한 개만 작성하는 것이 좋습니다.

실제 예제로 보는 Given-When-Then

더 현실적인 예제를 보겠습니다. 주문 시스템에서 할인 코드를 적용하는 기능을 테스트한다고 가정해봅시다.

@Test
@DisplayName("유효한 할인 코드를 적용하면 주문 금액에서 할인이 적용되어야 한다")
void applyValidDiscountCode() {
    // Given - 준비 단계
    Order order = new Order();
    order.addItem(new Item("상품", 10000));
    order.addItem(new Item("상품2", 20000));

    DiscountCode discountCode = new DiscountCode("SAVE20", 20); // 20% 할인
    discountCode.setActive(true);

    // When - 실행 단계
    boolean isApplied = order.applyDiscountCode(discountCode);

    // Then - 검증 단계
    assertThat(isApplied).isTrue();
    assertThat(order.getTotalPrice()).isEqualTo(24000); // 30000 * 0.8
}

@Test
@DisplayName("무효한 할인 코드를 적용하면 주문 금액이 변하지 않아야 한다")
void applyInvalidDiscountCode() {
    // Given
    Order order = new Order();
    order.addItem(new Item("상품", 10000));

    DiscountCode discountCode = new DiscountCode("EXPIRED", 20);
    discountCode.setActive(false); // 비활성화된 코드

    // When
    boolean isApplied = order.applyDiscountCode(discountCode);

    // Then
    assertThat(isApplied).isFalse();
    assertThat(order.getTotalPrice()).isEqualTo(10000); // 변경 없음
}

 

주목해야 할 점은 두 테스트가 명확하게 다른 상황을 테스트하고 있다는 것입니다. 유효한 코드와 무효한 코드 - 이런 식으로 경계 케이스(edge case)를 나누어 테스트하는 것이 매우 중요합니다.

Part 2: 픽스처(Fixture) 관리의 중요성

픽스처란 무엇인가?

픽스처는 테스트가 실행되기 위해 필요한 초기 상태나 데이터를 말합니다. 위 예제에서 만든 `Order`, `Item`, `DiscountCode` 객체들이 모두 픽스처입니다.


픽스처 관리가 중요한 이유는 다음과 같습니다.

  1. 중복 제거: 여러 테스트에서 같은 형태의 객체를 반복해서 만들 필요가 없습니다.
  2. 일관성 유지: 테스트 데이터의 구조가 바뀔 때, 한 곳만 수정하면 됩니다.
  3. 테스트의 의도 명확화: 픽스처를 잘 설계하면, 각 테스트가 무엇을 테스트하는지 한눈에 보입니다.
  4. 유지보수성: 테스트 코드가 간단하고 읽기 쉬워집니다.

픽스처 관리의 문제점

많은 팀들이 처음에는 각 테스트 메서드 안에서 필요한 객체를 직접 만듭니다.

@Test
void testCreateOrder() {
    // 매번 이렇게 만듭니다...
    User user = new User("testUser", "test@example.com", "password123", true, LocalDate.of(2020, 1, 1));
    Order order = new Order(user);
    // ...
}

@Test
void testUpdateOrder() {
    // 또 동일한 코드...
    User user = new User("testUser", "test@example.com", "password123", true, LocalDate.of(2020, 1, 1));
    Order order = new Order(user);
    // ...
}

 

이렇게 되면 다음과 같은 문제가 발생합니다.

  1. 코드 중복: 같은 객체를 만드는 코드가 여러 곳에 산재됩니다.
  2. 변경의 어려움: User 클래스에 새로운 필수 필드가 추가되면, 모든 테스트를 수정해야 합니다.
  3. 테스트의 의도 모호함: 객체 생성 코드 때문에 테스트의 핵심 로직이 묻힙니다.

픽스처 관리의 패턴들

BeforeEach를 사용한 공통 설정

가장 간단한 방법은 `@BeforeEach` 어노테이션을 사용해서 각 테스트 실행 전에 공통된 설정을 하는 것입니다.

@DisplayName("주문 기능 테스트")
class OrderTest {
    private User testUser;
    private Order order;

    @BeforeEach
    void setUp() {
        // 모든 테스트가 실행되기 전에 이 메서드가 먼저 실행됩니다.
        testUser = new User("testUser", "test@example.com");
        order = new Order(testUser);
    }
    
    @Test
    void testCreateOrder() {
        // order는 이미 생성되어 있습니다.
        assertThat(order).isNotNull();
        assertThat(order.getUser()).isEqualTo(testUser);
    }

    @Test
    void testAddItem() {
        // order는 이미 생성되어 있습니다.
        order.addItem(new Item("상품", 10000));
        assertThat(order.getItems()).hasSize(1);
    }
}

 

장점

  • 간단하고 직관적입니다.
  • 공통 설정을 한 곳에서 관리할 수 있습니다.

단점

  • 모든 테스트가 같은 상태의 객체를 받습니다. 일부 테스트는 다른 초기 상태가 필요할 수 있습니다.
  • `testUser`나 `order`가 테스트 중간에 수정되면, 다른 테스트에 영향을 미칠 수 있습니다 (테스트 독립성 문제).

Object Mother 패턴

Object Mother 패턴은 테스트 객체를 생성하는 역할을 전담하는 클래스를 만드는 것입니다.

public class UserMother {
    public static User defaultUser() {
        return new User("testUser", "test@example.com");
    }

    public static User adminUser() {
        return new User("admin", "admin@example.com", true);
    }

    public static User inactiveUser() {
        User user = new User("inactive", "inactive@example.com");
        user.deactivate();
        return user;
    }

    public static User userWithName(String name) {
        return new User(name, name + "@example.com");
    }
}

public class OrderMother {

    public static Order emptyOrder() {
        return new Order(UserMother.defaultUser());
    }

    public static Order orderWithItems(int itemCount) {
        Order order = new Order(UserMother.defaultUser());
        for (int i = 0; i < itemCount; i++) {
            order.addItem(new Item("상품" + i, 10000 * (i + 1)));
        }

        return order;
    }
}

// 테스트에서는 이렇게 사용합니다.
@Test
void testCreateOrder() {
    // Given
    User adminUser = UserMother.adminUser();
    Order order = new Order(adminUser);

    // When & Then
    assertThat(order.getUser().isAdmin()).isTrue();
}

@Test
void testOrderWithItems() {
    // Given
    Order order = OrderMother.orderWithItems(3);

    // When & Then
    assertThat(order.getItems()).hasSize(3);
}

 

장점

  • 재사용 가능한 메서드들을 통해 의도가 명확한 이름으로 객체를 생성할 수 있습니다.
  • 객체 생성 로직이 한 곳에 집중되어 있어 유지보수하기 좋습니다.
  • 테스트 코드에서 "어떤 상황의 사용자가 필요한가"가 명확하게 표현됩니다.

단점

  • Mother 클래스를 별도로 만들어야 합니다.
  • 새로운 패턴을 팀에 이해시켜야 합니다.

Builder 패턴 (권장)

Builder 패턴은 Object Mother의 문제점을 보완합니다. 유연하게 필요한 속성만 설정할 수 있습니다.

public class UserBuilder {

    private String username = "testUser";
    private String email = "test@example.com";
    private String password = "password123";
    private boolean isAdmin = false;
    private LocalDate createdAt = LocalDate.now();

    public UserBuilder username(String username) {
        this.username = username;
        return this;
    }

    public UserBuilder email(String email) {
        this.email = email;
        return this;
    }

    public UserBuilder admin(boolean isAdmin) {
        this.isAdmin = isAdmin;
        return this;
    }

    public UserBuilder createdAt(LocalDate createdAt) {
        this.createdAt = createdAt;
        return this;
    }

    public User build() {
        return new User(username, email, password, isAdmin, createdAt);
    }
}

// 테스트에서는 이렇게 사용합니다.
@Test
void testAdminUser() {
    // Given - 필요한 부분만 변경
    User adminUser = new UserBuilder()
        .username("admin")
        .email("admin@example.com")
        .admin(true)
        .build();

    // When & Then
    assertThat(adminUser.isAdmin()).isTrue();
}

@Test
void testRegularUser() {
    // Given - 기본값으로 충분한 경우
    User user = new UserBuilder().build();

    // When & Then
    assertThat(user.isAdmin()).isFalse();
}

@Test
void testSpecificUser() {
    // Given - 특정 날짜의 사용자
    User user = new UserBuilder()
        .createdAt(LocalDate.of(2020, 1, 1))
        .build();

    // When & Then
    assertThat(user.getCreatedAt()).isEqualTo(LocalDate.of(2020, 1, 1));
}

 

장점

  • 매우 유연합니다. 각 테스트가 필요한 속성만 설정할 수 있습니다.
  • 기본값이 설정되어 있어, 불필요한 속성은 신경 쓸 필요가 없습니다.
  • 체인 방식으로 읽기 쉽게 작성할 수 있습니다.
  • 테스트의 의도가 명확합니다.

단점

  • Builder 클래스를 별도로 작성해야 합니다 (하지만 IDE가 자동 생성 기능을 제공합니다).

Fixture Monkey (라이브러리 활용)

복잡한 객체 그래프를 테스트할 때는 Fixture Monkey 같은 라이브러리를 사용할 수도 있습니다.

// build.gradle에 의존성 추가
testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.0'

@Test
void testWithFixtureMonkey() {
    // Given
    FixtureMonkey fixtureMonkey = FixtureMonkey.create();

    // 기본적으로 모든 필드를 자동으로 채운 User 객체 생성
    User user = fixtureMonkey.giveMeOne(User.class);

    // 특정 필드만 수정
    User customUser = fixtureMonkey.giveMeBuilder(User.class)
        .set("username", "customUser")
        .set("isAdmin", true)
        .sample();

    // When & Then
    assertThat(customUser.getUsername()).isEqualTo("customUser");
    assertThat(customUser.isAdmin()).isTrue();
}

픽스처 관리 시 주의해야 할 점

테스트 독립성 보장하기

픽스처 관리에서 가장 중요한 것은 테스트 간 독립성을 보장하는 것입니다. 만약 `@BeforeEach`에서 만든 객체를 테스트 중간에 수정하면, 다른 테스트에 영향을 미칠 수 있습니다.

// 주의: 이런 식으로 하면 안 됩니다!
@DisplayName("위험한 테스트 클래스")
class DangerousTest {

    private List<Item> items = new ArrayList<>(); // 공유되는 상태

    @BeforeEach
    void setUp() {
        items.clear();
        items.add(new Item("상품1", 10000));
    }

    @Test
    void test1() {
        items.add(new Item("추가상품", 5000)); // items를 수정!
        assertThat(items).hasSize(2);
    }

    @Test
    void test2() {
        // test1이 items를 수정했다면, 이 테스트는 실패할 수 있습니다!
        assertThat(items).hasSize(1);
    }
}

 

이 문제를 해결하려면

// 올바른 방법: 각 테스트가 독립적인 객체를 갖는다
@DisplayName("안전한 테스트 클래스")
class SafeTest {

    @Test
    void test1() {
        // Given - 각 테스트가 자신만의 객체를 만듭니다.
        List<Item> items = new ArrayList<>();
        items.add(new Item("상품1", 10000));
        items.add(new Item("추가상품", 5000));

        // When & Then
        assertThat(items).hasSize(2);
    }

    @Test
    void test2() {
        // Given
        List<Item> items = new ArrayList<>();
        items.add(new Item("상품1", 10000));

        // When & Then
        assertThat(items).hasSize(1);
    }
}

 

또는 Builder를 사용해서 더 간단하게

@DisplayName("Builder를 사용한 테스트")
class BuilderTest {

    @Test
    void test1() {
        // Given
        List<Item> items = new ItemListBuilder()
            .withItem("상품1", 10000)
            .withItem("추가상품", 5000)
            .build();
        
        // When & Then
        assertThat(items).hasSize(2);
    }

    @Test
    void test2() {
        // Given
        List<Item> items = new ItemListBuilder()
            .withItem("상품1", 10000)
            .build();

        // When & Then
        assertThat(items).hasSize(1);
    }
}

Part 3: 실무에서 적용하는 전략

작은 프로젝트: BeforeEach + Object Mother 조합

public class UserTestFixture {

    public static User createDefaultUser() {
        return new User("testUser", "test@example.com");
    }

    public static User createAdminUser() {
        return new User("admin", "admin@example.com", true);
    }
}

@DisplayName("사용자 서비스 테스트")
class UserServiceTest {
    private UserService userService;

    @BeforeEach
    void setUp() {
        // 데이터베이스나 의존성 초기화
        userService = new UserService();
    }

    @Test
    void canCreateUser() {
        // Given
        User user = UserTestFixture.createDefaultUser();

        // When
        User created = userService.createUser(user);

        // Then
        assertThat(created.getId()).isNotNull();
    }
}

중간~대규모 프로젝트: Builder 패턴 + java-test-fixtures

프로젝트 구조

src/
├── main/java/
│   └── com/example/order/
│       ├── User.java
│       ├── Order.java
│       └── Item.java
└── test/
    ├── java/
    │   └── com/example/order/
    │       └── OrderServiceTest.java
    └── testFixtures/
        └── java/
            └── com/example/order/fixtures/
                ├── UserBuilder.java
                ├── OrderBuilder.java
                └── ItemBuilder.java

 

build.gradle

plugins {
    id 'java'
    id 'java-test-fixtures'
}

dependencies {
    testImplementation testFixtures(project(':your-project'))
}

 

UserBuilder.java (testFixtures)

public class UserBuilder {
    private String username = "testUser";
    private String email = "test@example.com";
    private boolean isActive = true;

    public UserBuilder username(String username) {
        this.username = username;
        return this;
    }
    
    public UserBuilder email(String email) {
        this.email = email;
        return this;
    }

    public UserBuilder inactive() {
        this.isActive = false;
        return this;
    }

    public User build() {
        return new User(username, email, isActive);
    }
}

 

OrderServiceTest.java

@DisplayName("주문 서비스 테스트")
class OrderServiceTest {

    private OrderService orderService;

    @Test
    @DisplayName("주문을 생성할 수 있다")
    void canCreateOrder() {
        // Given
        User user = new UserBuilder()
            .username("john")
            .build();
            
        Order order = new OrderBuilder()
            .withUser(user)
            .withItem("상품", 10000)
            .build();

        // When
        Order created = orderService.createOrder(order);

        // Then
        assertThat(created.getId()).isNotNull();
        assertThat(created.getTotal()).isEqualTo(10000);
    }

    @Test
    @DisplayName("비활성 사용자는 주문을 할 수 없다")
    void cannotCreateOrderForInactiveUser() {
        // Given
        User inactiveUser = new UserBuilder()
            .inactive()
            .build();

        Order order = new OrderBuilder()
            .withUser(inactiveUser)
            .build();

        // When & Then
        assertThatThrownBy(() -> orderService.createOrder(order))
            .isInstanceOf(InvalidUserException.class);
    }
}

 

AssertJ를 사용한 BDD 스타일 검증

import static org.assertj.core.api.BDDAssertions.then; // 주목!
@Test
void testWithBDDStyle() {

    // Given
    int a = 2;
    int b = 3;
    Calculator calculator = new Calculator();

    // When
    int result = calculator.add(a, b);

    // Then
    then(result).isEqualTo(5); // assertThat 대신 then 사용
    then(result).isGreaterThan(4);
    then(result).isLessThan(6);

}

 

이렇게 `then`을 사용하면 Given-When-Then의 일관성을 시각적으로도 표현할 수 있습니다.

Part 4: 자주 하는 실수들

실수 1: Given에 너무 많은 코드를 넣기

// 나쁜 예
@Test
void testComplexOrder() {
    // Given - 너무 길다...
    User user = new User("user", "email@test.com");
    user.setAddress("서울시 강남구");
    user.setPhone("010-1234-5678");
    user.setPreferredDelivery("express");

    List<Item> items = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        items.add(new Item("상품" + i, 10000 * (i + 1)));
    }

    Order order = new Order(user);
    for (Item item : items) {
        order.addItem(item);
    }

    // ... 더 많은 설정
    // When
    order.process();

    // Then
    assertThat(order.isProcessed()).isTrue();
}

// 좋은 예
@Test
void testComplexOrder() {
    // Given
    User user = new UserBuilder()
        .withAddress("서울시 강남구")
        .withExpressDelivery()
        .build();

    Order order = new OrderBuilder()
        .withUser(user)
        .withRandomItems(10)
        .build();

    // When
    order.process();

    // Then
    assertThat(order.isProcessed()).isTrue();
}

 

실수 2: When에서 여러 메서드 호출하기

// 나쁜 예
@Test
void testMultipleOperations() {
    // Given
    Account account = new AccountBuilder().withBalance(10000).build();

    // When - 여러 작업을 한다?
    account.withdraw(1000);
    account.withdraw(2000);
    account.deposit(5000);

    // Then - 뭘 테스트하는 거지?
    assertThat(account.getBalance()).isEqualTo(12000);
}

// 좋은 예 - 각각을 별도의 테스트로
@Test
void testMultipleWithdrawals() {
    // Given
    Account account = new AccountBuilder().withBalance(10000).build();

    // When
    account.withdraw(1000);

    // Then
    assertThat(account.getBalance()).isEqualTo(9000);
}

@Test
void testWithdrawThenDeposit() {
    // Given
    Account account = new AccountBuilder().withBalance(10000).build();

    // When
    account.withdraw(3000);
    account.deposit(5000);

    // Then
    assertThat(account.getBalance()).isEqualTo(12000);
}

 

실수 3: Then에 너무 많은 assertion 넣기

// 나쁜 예 - 한 테스트가 너무 많은 걸 검증한다
@Test

void testOrderCreation() {
    // Given
    User user = new UserBuilder().build();

    // When
    Order order = orderService.createOrder(user);

    // Then - 이 모든 걸 한 테스트에서 검증해야 하나?
    assertThat(order.getId()).isNotNull();
    assertThat(order.getUser()).isEqualTo(user);
    assertThat(order.getCreatedAt()).isNotNull();
    assertThat(order.getStatus()).isEqualTo("PENDING");
    assertThat(order.getItems()).isEmpty();
    assertThat(order.getTotal()).isEqualTo(0);
}

// 좋은 예 - 각 관심사별로 분리
@Test
void testOrderInitialization() {
    // Given
    User user = new UserBuilder().build();

    // When
    Order order = orderService.createOrder(user);

    // Then
    assertThat(order.getId()).isNotNull();
    assertThat(order.getUser()).isEqualTo(user);
}

@Test
void testNewOrderHasEmptyItems() {
    // Given
    User user = new UserBuilder().build();

    // When
    Order order = orderService.createOrder(user);

    // Then
    assertThat(order.getItems()).isEmpty();
    assertThat(order.getTotal()).isEqualTo(0);
}

@Test
void testNewOrderPendingStatus() {
    // Given
    User user = new UserBuilder().build();

    // When
    Order order = orderService.createOrder(user);

    // Then
    assertThat(order.getStatus()).isEqualTo("PENDING");
}

 

실수 4: 테스트 간 의존성 만들기

// 나쁜 예
@Test
void testCreateUser() {
    User user = userService.createUser(new User("john", "john@test.com"));
    assertThat(user.getId()).isNotNull();
}

@Test
void testUpdateUser() {
    // 이 테스트는 위의 testCreateUser가 먼저 실행되어야 한다?
    // 이건 순서에 의존한다! 매우 위험하다
    User user = userService.getUserById(1L);
    user.setEmail("newemail@test.com");
    userService.updateUser(user);
    assertThat(user.getEmail()).isEqualTo("newemail@test.com");
}

// 좋은 예 - 각 테스트가 독립적이다
@Test
void testCreateUser() {
    User user = userService.createUser(new User("john", "john@test.com"));
    assertThat(user.getId()).isNotNull();
}

@Test
void testUpdateUser() {
    // Given - 필요한 데이터를 직접 만든다
    User user = userService.createUser(new User("jane", "jane@test.com"));
    user.setEmail("newemail@test.com");

    // When
    userService.updateUser(user);

    // Then
    User updated = userService.getUserById(user.getId());
    assertThat(updated.getEmail()).isEqualTo("newemail@test.com");
}

 

이 글에서 다룬 Given-When-Then 패턴과 픽스처 관리 전략들을 적용하면, 단순히 "테스트가 있다"는 것을 넘어 테스트 코드가 자산이 되는 경험을 할 수 있습니다.


처음에는 이런 구조를 따르는 것이 번거로워 보일 수 있습니다. 하지만 시간이 지나면서 다음과 같은 이점을 느낄 것입니다.

  1. 코드 리뷰가 빨라진다: 테스트 코드의 의도가 명확해지면, 동료 개발자들이 리뷰하기 쉬워집니다.
  2. 버그 수정이 자신감 있다: 기존 기능이 깨지지 않았다는 확신 하에서 버그를 고칠 수 있습니다.
  3. 새로운 팀원의 온보딩이 빠르다: 테스트 코드가 "살아있는 문서" 역할을 합니다.
  4. 리팩토링이 두렵지 않다: 아키텍처를 개선할 때도 테스트가 있으면 안심할 수 있습니다.

물론, 완벽한 테스트 코드는 없습니다. 프로젝트의 규모, 팀의 숙련도, 비즈니스 요구사항에 따라 적절한 균형을 찾는 것이 중요합니다. 하지만 이 글에서 제시한 원칙들은 어느 프로젝트에서나 도움이 될 것입니다.

혹시 이 글을 읽으면서 "우리 팀도 이렇게 하고 싶은데, 어떻게 도입하지?"라는 생각이 든다면, 작은 것부터 시작하세요. 한 팀, 한 클래스, 한 메서드씩 이 패턴을 적용해보면, 자연스럽게 전체 팀의 테스트 문화가 개선될 것입니다.

행운을 빕니다. 그리고 좋은 테스트 코드를 작성하는 여정을 응원합니다!

 

Java 코드 커버리지 메트릭, 진짜 알고 사용하고 있나요?

지난해 팀 프로젝트를 맡게 되면서 처음으로 코드 커버리지(Code Coverage)라는 개념을 제대로 마주쳤다. SonarQube 대시보드에 커다란 빨간 숫자 35%가 표시되어 있었고, 리더는 "최소한 80%까지는 올려

byteandbit.tistory.com

반응형