올해 하나의 프로젝트에서 3번의 테스트 코드 리팩터링을 진행했다. 프로젝트 내에서 테스트 케이스의 수는 약 200여개로 유지되었는데, 200여개의 테스트 코드에서 3번의 리팩터링이 진행되며 코드의 구조가 바뀌었다. 테스트 코드를 많이 작성해보지 않은 개발자도 손쉽게 테스트 코드를 작성할 수 있게 하면서 프로젝트 초반에 테스트 코드를 가지고 가면서 개발 생산성을 높힐 수 있는 방법을 찾는 것이 목표였다. 따라서 다음과 같은 결론을 얻었다. 원하는 테스트를 하기 위해 필요한 데이터가 존재해야하는데, 이 데이터를 얼마나 손쉽게 만들 수 있느냐가 관건이었다. 수백번의 테스트 코드 작성과 3번의 리팩터링에서 깨달은 내용을 녹여보겠다.
이 글은 단위테스트, 통합테스트를 구분해서 설명하지 않는다. 좋은 테스트 작성과 테스트 데이터 세팅을 하기 쉬운 코드로 만들어나가지만 이는 단위테스트와 통합테스트 둘다 적용될 수 있는 내용이다. 다만, 특정 주제는 단위테스트, 통합테스트의 언급이 있을 수 있다. 그 부분은 따로 글에서 언급하겠다. 또한 코드를 구성하는 방법은 여러가지 방법을 제시한다. 정해진 정답이 있는 것이 아니다.
예시 코드는 자바, 스프링, JPA, Junit5 을 사용하였다. 개선한 코드 스타일은 언어와 상관없이 적용할 수 있지만, 일부 코드를 구성하는 방식은 언어와 프레임워크에 따라 동작하지 않을 수 있다.
1. 테스트를 위한 예제
흔히 볼 수 있는 주문 예제를 가지고 글을 작성한다.
Order 클래스가 있고, 이 Order는 Customer을 포함하고 있으며, Customer은 Address를 가지고 있다. Order는 하나이상의 OrderItem을 포함할 수 있다. Order에는 discountRate(할인율)과 couponCode(쿠폰 코드)가 포함될 수 도 있다.
2. 테스트 코드에서 반복적으로 나타나는 코드들
@Test
void 국내주소를_가진_주문을_생성한다() {
Address address = new Address("강남구", "서울", "12345", "한국");
Customer customer = new Customer(1L, "홍길동", address);
Order order = new Order(1L, customer, 0.0, null);
order.addOrderItem(new OrderItem("커피잔", 1));
// ...
}
@Test
void 외국주소를_가진_주문을_생성한다() {
Address address = new Address("1216 Clinton Street", "Philadelphia", "98765", "United States");
Customer customer = new Customer(1L, "Terry Tew", address);
Order order = new Order(1L, customer, 0.0, null);
order.addOrderItem(new OrderItem("Coffee mug", 1));
// ...
}
위 테스트 케이스에서 반복적으로 사용하는 코드들이 존재한다. Address, Customer등 다양한 객체를 생성하는 코드들이 반복된다. 개발자는 반복적인 작업을 싫어한다. 메서드로 추출해보자.
@Test
void 국내주소를_가진_주문을_생성한다() {
Address address = createAddress("강남구", "서울", "12345", "한국");
Customer customer = createCustomer(1L, address);
Order order = createOrder(1L, customer);
order.addOrderItem(createOrderItem());
// ...
}
@Test
void 외국주소를_가진_주문을_생성한다() {
Address address = createAddress("1216 Clinton Street", "Philadelphia", "98765", "United States");
Customer customer = createCustomer(1L, address);
Order order = createOrder(1L, customer);
order.addOrderItem(createOrderItem());
// ...
}
private Address createAddress(String street, String city, int postalCode, String contry) {
return new Address("1216 Clinton Street", "Philadelphia", "98765", "United States");
}
private Customer createCustomer(long id, Address address) {
return new Customer(id, "Terry Tew", address);
}
private Order createOrder(long id, Customer customer) {
return new Order(id, customer);
}
//...
위 코드와 같이 중복되는 코드들은 create 메서드 안에서 다루고 필요한 데이터만 받도록 리팩터링했다. 반복적인 코드를 메서드로 추출하고 필요한 데이터만 받도록하니 코드가 깔끔해지고 재사용성이 증가했다. 그런데 다른 테스트 클래스에서 테스트 코드를 작성하려고 보니까 방금 추출한 메서드들이 다른 클래스에서 필요하게 되었다. 그럼 다른 클래스에서도 재사용 가능한 코드들을 사용할 수 있도록 오브젝트 마더 패턴을 사용해 개선해보자.
3. 오브젝트 마더 패턴을 통한 중복 코드 개선
오브젝트 마더 패턴(Object Mother Pattern): 테스트에서 다양한 용도에 맞는 객체를 생성하기 위한 팩토리 메서드를 포함한 클래스이다. 테스트를 더 읽기 쉽게 만들고, 객체를 생성하는 코드를 숨긴다.
메서드로 추출된 코드를 오브젝트 마더 패턴을 적용할 클래스에 위임한다.
public class TestCustomers {
public static Customer createCustomer(long id, Address address) {
return new Customer(id, "Terry Tew", address);
}
}
public class TestAddresses {
public static Address createAddress(String street, String city, int postalCode, String contry) {
return new Address("1216 Clinton Street", "Philadelphia", "98765", "United States");
}
}
public class TestOrders {
// 할인률을 적용하지 않은 주문
public static Order createOrder(long id, Customer customer) {
return new Order(id, customer);
}
// 할인률을 적용한 주문
public static Order createOrderWithDiscountRate(long id, Customer customer, double discountRate) {
return new Order(id, customer, discountRate);
}
}
...
이제 오브젝트 마더 패턴을 적용한 팩토리 메서드를 호출한 테스트 코드를 보자.
@Test
void 국내주소를_가진_주문을_생성한다() {
Address address = TestAddresses.createAddress("강남구", "서울", "12345", "한국");
Customer customer = TestCustomers.createCustomer(1L, address);
Order order = TestOrders.createOrder(1L, customer);
order.addOrderItem(createOrderItem());
// ...
}
오브젝트 마더 패턴을 적용하면 테스트 간에 코드를 재사용하고 테스트 코드에서 보여지지 않아도 되는 코드들을 메서드 내부에 하드코딩함으로써 테스트를 더 읽기 쉽게 만든다. 또한 메서드명을 통해 의도를 나타낼 수 있다. 하지만 오브젝트 마더 패턴은 테스트 데이터가 달라질 때 유연하지 않다는 단점이 존재한다. 테스트 데이터의 작은 변경에도 새로운 팩토리 메서드가 필요하다. 다양한 상황을 구성해야할 경우 비슷한 메서드들을 중복으로 만들어야 한다.
4. 빌더 패턴을 통한 코드 개선
이번에는 오브젝트 마더 패턴이 아닌 빌더 패턴을 사용해 개선해본다.
public class OrderBuilder {
private Long orderId;
private Customer customer;
private List<OrderItem> orderItems = new ArrayList<>();
private Double discountRate;
private String couponCode;
public OrderBuilder withId(Long orderId) {
this.orderId = orderId;
return this;
}
public OrderBuilder withCustomer(Customer customer) {
this.customer = customer;
return this;
}
public OrderBuilder withOrderItem(OrderItem orderItem) {
this.orderItems.add(orderItem);
return this;
}
public OrderBuilder withDiscountRate(Double discountRate) {
this.discountRate = discountRate;
return this;
}
public OrderBuilder withCouponCode(String couponCode) {
this.couponCode = couponCode;
return this;
}
public Order build() {
Order order = new Order(orderId, customer, discountRate, couponCode);
orderItems.forEach(order::addOrderItem);
return order;
}
}
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = new OrderBuilder()
.withId(1L)
.withCustomer(new CustomerBuilder()
.withCustomerId(1L)
.withName("홍길동")
.withAddress(new AddressBuilder()
.withStreet("강남구")
.withCity("서울")
.withPostalCode("12345")
.withCountry("한국")
.build()
)
.build()
)
.withOrderItem(new OrderItemBuilder()
.withName("커피잔")
.withQuantity(1)
.build()
)
.build();
...
}
빌더 패턴을 적용한 테스트 코드를 보면 오브젝트 마더 패턴을 적용한 방식보다 가독성이 떨어지고 불필요한 데이터까지 보여준다. 이제 빌더 패턴을 개선해보자.
4-1. 객체의 필드값을 초기화 시키기
객체의 필드값이 필수로 초기화 되어야 하는 경우가 존재한다. 테스트 코드에서도 필수값을 제공해야한다. 하지만 테스트 코드에서 필수값을 제공 할 경우 테스트 코드의 가독성이 떨어진다. 또한 필수값은 테스트 케이스와 크게 관련이 없을 수 있다. 객체의 필드값을 초기화 시켜서 테스트와 무관한 값들을 숨길 수 있다.
public class OrderBuilder {
private Long orderId = 1L;
private Customer customer = new CustomerBuilder().build();
private List<OrderItem> orderItems = new ArrayList<>();
private Double discountRate = 0.0;
private String couponCode;
...
}
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = new OrderBuilder()
.withCustomer(new CustomerBuilder()
.withAddress(new AddressBuilder()
.withCountry("한국")
.build()
)
.build()
)
.withOrderItem(new OrderItemBuilder()
.withName("커피잔")
.withQuantity(1)
.build()
)
.build();
...
}
필요한 값을만 보여줌으로써 테스트 코드의 가독성을 높히고 테스트 케이스의 의도를 쉽게 파악할 수 있다.
4-2. 빌더 메서드의 매개변수로 객체 대신 Builder 받기
빌더 메서드의 매개변수로 다른 빌더를 받아 테스트 코드에서 build() 코드를 제거하여 가독성을 높힐 수 있다. 상황에 맞는 메서드를 사용하고자 기존 코드는 유지하였다.
public class OrderBuilder {
...
public OrderBuilder withCustomer(Customer customer) {
this.customer = customer;
return this;
}
public OrderBuilder withCustomer(CustomerBuilder customerBuilder) {
this.customer = customerBuilder.build();
return this;
}
...
}
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = new OrderBuilder()
.withCustomer(new CustomerBuilder()
.withAddress(new AddressBuilder()
.withCountry("한국"))
)
.withOrderItem(new OrderItemBuilder()
.withName("커피잔")
.withQuantity(1))
.build();
...
}
4.3. 가독성 있는 메서드명으로 변경
빌더 패턴을 시작하는 'new xxBuilder()' 대신 정적 팩토리 메서드를 사용하여 가독성 있는 이름으로 변경한다.
public class OrderBuilder {
...
private OrderBuilder() {}
// 메서드명 또한 가독성 있게 지어준다.
public static OrderBuilder anOrder() {
return new OrderBuilder();
}
...
}
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = OrderBuilder.anOrder()
.withCustomer(CustomerBuilder.aCustomer()
.withAddress(new AddressBuilder()
.withCountry("한국"))
)
.withOrderItem(OrderItemBuilder.anOrderItem()
.withName("커피잔")
.withQuantity(1))
.build();
...
}
추가로 정적 임포트를 사용하여 가독성을 더 높혀준다.
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = anOrder()
.withCustomer(
aCustomer().withAddress(anAddress().withCountry("한국"))
)
.withOrderItem(anOrderItem().withName("커피잔").withQuantity(1))
.build();
...
}
마지막으로 다른 객체를 매개변수로 받는 'withCustomer' 과 같은 메서드를 'with'로 바꿔준다.
@Test
void 국내주소를_가진_주문을_생성한다() {
Order order = anOrder()
.with(aCustomer().withAddress(anAddress().withCountry("한국")))
.with(anOrderItem().withName("커피잔").withQuantity(1))
.build();
...
}
빌더 패턴을 사용한 처음 버전보다 현재 버전이 가독성이 높아진 것을 볼 수 있다. 원하는 데이터만 보여줌으로써 테스트의 의도를 분명히 드러낼 수 있다.
5. 오브젝트 마더 패턴과 빌더 패턴의 장단점
오브젝트 마더 패턴과 빌더 패턴을 사용하여 테스트 코드를 개선해보았다.
두가지 방식 모두 각각의 장단점이 존재하여 무엇이 더 좋다라고 할 수 없다. 사람마다 선호하는 스타일에 따라 다를 수 있다.
두가지 방식 모두 사용해본 입장에서 장단점을 나열해보자면
| 오브젝트 마더 패턴 | 빌더 패턴 | |
| 가독성 | 큰 차이없다 | 큰 차이없다 |
| 코드의 유연성 | 원하는 데이터를 조합해야 할 경우 메서드를 만들어야한다 | 원하는 데이터만 조합할 수 있어 유연하다 |
| 컴파일 오류 발생 빈도 |
적다 | 높다 |
위 표와 같은 장단점이 존재한다. 컴파일 오류 발생 빈도에 대해서 좀 더 자세히 이야기 하자면, 프로덕션 코드의 도메인 코드가 수정일 될 경우 오브젝트 마더 패턴은 컴파일 오류의 범위가 오브젝트 마더 패턴으로 한정된다. 반면, 빌더 패턴을 사용할 경우 도메인 코드가 수정되면 빌더 패턴을 적용한 테스트 코드 전부에서 컴파일 오류가 발생한다.
코드의 유연성 관점에서 볼 때 오브젝트 마더 패턴은 원하는 데이터를 조합하는 메서드가 존재하지 않을 경우, 비슷한 메서드를 중복해서 만들어야한다. 반면, 빌더 패턴을 사용할 경우 빌더 메서드를 조합하여 사용하면 된다.
필자는 초기에는 오브젝트 마더 패턴을 사용하였고 현재는 빌더 패턴을 사용한다. 하지만 어떤 것이 더 좋다고 할 수는 없다. 지금은 테스트 코드에서 테스트하려는 의도 자체를 드러내려고 하기 떄문이다.
6. 통합테스트의 given절 개선하기
지금까지 반복적인 코드를 줄이고 가독성 있는 코드를 작성하도록 개선해왔다. 통합테스트에서는 원하는 테스트를 하기 위해서 데이터를 영속화시켜야한다. 원하는 데이터를 DB에 저장하고 테스트를 해야한다. 다음과 같이 코드를 작성할 수 있겠다.
@Test
void 국내주소를_가진_주문을_생성한다() {
// Address 객체를 생성하고 DB에 저장한다.
Address address = anAddress().withCountry("한국").save();
// Customer 객체를 생성하고 DB에 저장한다.
Customer customer = customerRepository.save(
aCustomer().withAddress(address)
);
// Order 객체를 생성하고 DB에 저장한다.
Order order = orderRepository.save(
anOrder().with(customer).build()
);
OrderItem orderItem = orderItemRepository.save(
anOrderItem().withName("커피잔").with(order).withQuantity(1)
);
order.addOrderItem(orderItem);
}
객체를 생성하고 저장하는 코드가 생긴다. 테스트의 목적을 달성하기 위해 필요한 여러 데이터들을 DB에 저장해주어야만 한다. 생성하고 저장하는 반복적인 코드를 개선해보자.
6-1. setter 주입을 통해 빌더 클래스의 필드에 Repository 주입받기
public class OrderBuilder {
...
public Order build() {
return ...
}
public static class OrderRepositoryHolder {
// static 필드이다.
private static OrderRepository orderRepository;
// static 메서드이다.
public static void setOrderRepository(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
public Order save() {
Order order = build();
return OrderRepositoryHolder.orderRepository.save(order);
}
...
}
OrderRepository를 setter 를 통해 주입받고 save() 메서드 내부에서 build() 메서드를 통해 객체를 생성하고 DB에 저장했다. inner static class 로 정의한 OrderRepositoryHolder 를 통해 OrderBuilder 클래스 안에서 Repository 를 관리하도록 분리시켰다. inner static 클래스인 OrderReopsitoryHolder 를 정의하지 않고 OrderBuilder 의 필드로 가지고 있더라도 OrderRepository 는 static 필드로 정의해야한다. OrderBuilder의 인스턴스를 사용하지 않고 OrderRepository를 초기화를 시켜야하기 때문이다. 동작원리가 궁금하면 static 키워드 또는 JVM 구조에 대해서 찾아보자.
class OrderIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private CustomerRepository customerRepository;
@Autowired
private AddressRepository addressRepository;
@BeforeEach
public void init() {
OrderBuilder.OrderRepositoryHolder.setRepository(orderRepository);
CustomerRepository.CustomerRepositoryHolder.setRepository(customerRepository);
AddressRepository.AddressRepositoryHolder.setRepository(addressRepository);
}
@Test
void 국내주소를_가진_주문을_생성한다() {
// Address 객체를 생성하고 DB에 저장한다.
Address address = anAddress().withCountry("한국").save();
// Customer 객체를 생성하고 DB에 저장한다.
Customer customer = aCustomer().withAddress(address).save();
// Order 객체를 생성하고 DB에 저장한다.
Order order = anOrder()
.with(customer)
.save();
...
OrderItem orderItem = anOrderItem()
.withName("커피잔")
.withQuantity(1)
.with(order)
.save();
order.addOrderItem(orderItem);
}
}
이 방식을 사용할 경우 @BeforeEach 를 통해 매번 Repository를 주입해야한다.
스프링 프레임워크는 빈을 인스턴스로 생성한 뒤 필드에 주입시킨다. Junit5의 @BeforeAll 은 static 메서드만 적용할 수 있는데 static 키워드가 붙은 코드는 인스턴스 생성전에 실행된다. 따라서 @BeforeEach를 사용해야한다. Repository 를 setter 로 주입받는 방식은 언어, 프레임워크에 따라 동작하지 않을 수 있다.
setter 를 통해 Repository 를 주입하는 방식은 1. 영속화하지 않은 객체 생성 2. 영속화된 객체 생성 두가지 방식을 사용할 수 있다. 하지만 이 방식은 단일 책임 원칙을 위반한다. 이 글의 목적은 테스트 코드 작성을 쉽게하기 위해 given 절을 쉽게 구성하는 방법을 배운다. 단일 책임 원칙을 위반하더라도 테스트 코드의 구성이 쉬워진다. 필자도 상황에 따라 이 방식을 사용한다.
@Test
void 국내주소를_가진_주문을_생성한다() {
// DB에 저장된 객체
Order order = anOrder()
.save();
// DB에 저장되지 않은 객체
Order order = anOrder()
.build();
...
}
6-2. 중복 코드를 추상 클래스에 위임하기
@Autowired, @BeforeEach 를 적용한 코드는 클래스마다 중복된다. 추상 클래스에게 코드를 옮겨 테스트 클래스가 상속받을 수 있도록 한다.
public abstract class AbstractTest {
@Autowired
private OrderRepository orderRepository;
@BeforeEach
public void init() {
OrderBuilder.OrderRepositoryHolder.setRepository(orderRepository);
}
}
class OrderIntegrationTest extends AbstractTest {
...
}
6-3. 생명주기가 같은 객체를 묶어 문맥(Context) 만들기
테스트 코드를 다시보자.
@Test
void 국내주소를_가진_주문을_생성한다() {
...
// Order 객체를 생성하고 DB에 저장한다.
Order order = anOrder()
.with(customer)
.save();
...
OrderItem orderItem = anOrderItem()
.withName("커피잔")
.withQuantity(1)
.with(order)
.save();
order.addOrderItem(orderItem);
}
Order 가 저장될 때 OrderItem 도 저장된다. Order 와 OrderItem 의 생명주기는 동일하다. Order 가 지워지면 OrderItem 도 지워져야한다. 이렇게 생명주기가 동일하면 Order 가 저장될 때 OrderItem 도 같이 저장되도록 만든다.
기존 코드를 다시 보자.
public class OrderBuilder {
...
public Order save() {
Order order = build();
return OrderRepositoryHolder.orderRepository.save(order);
}
...
}
public class OrderItemBuilder {
...
public OrderItem save() {
OrderItem orderItem = build();
return OrderItemRepositoryHolder.orderItemRepository.save(orderItem);
}
...
}
OrderBuilder 에서 OrderItemBuilder 를 사용하여 하나의 문맥으로 만든다.
public class OrderBuilder {
private Long orderId;
private Customer customer;
private List<OrderItem> orderItems = new ArrayList<>();
private Double discountRate;
private String couponCode;
// Order를 저장할 때 OrderItem도 같이 저장하기 위한 Map
private Map<String, Integer> orderItems = new HashMap<>();
...
public OrderBuilder includeOrderItem(String name, int amount) {
orderItems.put(name, amount);
return this;
}
public Order save() {
Order order = build();
OrderRepositoryHolder.orderRepository.save(order);
// Order에서 저장된 Order를 가지고 OrderItem의 Order를 넣어준 뒤 저장한다.
for(String key : orderItem.keySet()) {
anOrderItem
.withName(key)
.withQuantity(orderItems.get(key)
.withOrder(order)
.save();
}
return order;
}
...
}
public class OrderItemBuilder {
...
public OrderItem save() {
OrderItem orderItem = build();
return OrderItemRepositoryHolder.orderItemRepository.save(orderItem);
}
...
}
OrderBuilder 가 OrderItem 을 의존하여 코드를 작성했다. OrderItem 의 key 와 value 는 includeOrderItem() 메서드를 통해 받아서 Order를 저장하고 나서 OrderItem 또한 저장한다. 하지만 Order 와 OrderItem 을 문맥으로 만들어버리면 문제가 발생한다. JPA 엔티티를 사용할 때 Order와 OrderItem을 일대다 관계로 구성했다면, Order를 저장하고 반환하는 Order 객체안에 OrderItem 이 존재하지 않는다. 따로 메서드를 통해 Order 안에 OrderItem 을 주입시켜줘야한다. when 절에서 테스트하는 대상의 메서드를 실행할 때 이 부분을 간과하면 테스트가 실패할 수 있다.
JPA를 사용할 경우 Builder 의 save() 메서드를 실행할 때 엔티티를 저장한 후에 fetch 조인을 통해서 다시 엔티티를 조회해 데이터를 같이 가져오는 방법을 적용할 수 있다. 하지만 이러한 방식은 테스트 코드의 유지보수가 어려워 질 수 있다.
@Test // 기존 테스트
void 국내주소를_가진_주문을_생성한다() {
...
// Order 객체를 생성하고 DB에 저장한다.
Order order = anOrder()
.with(customer)
.save();
...
OrderItem orderItem = anOrderItem()
.withName("커피잔")
.withQuantity(1)
.with(order)
.save();
order.addOrderItem(orderItem);
}
@Test // 문맥화시킨 테스트
void 국내주소를_가진_주문을_생성한다() {
...
// Order 객체를 생성하고 DB에 저장한다.
Order order = anOrder()
.with(customer)
.includeOrderItem("커피잔", 1)
.save();
...
}
기존 테스트에서 문맥화 시킨 테스트를 볼 수 있다. 좀 더 간결해진 것을 볼 수 있다.
7. 다음으로
이 글의 목적은 테스트 코드를 읽기 쉬우면서 작성하기 쉽게 만들기 위함이다. 편하게 작성하도록 만들어놓은 Builder 클래스의 문제가 있으면 테스트는 실패할 것이다. 필자는 테스트 경험이 많지 않은 사람도 테스트에 빠르게 익숙해질 수 있도록 given 절을 구성하는 방법을 제시한 것이다. 위에 작성한 방식은 완벽하지 않고 구성방식에 따라 유지보수하기 어렵다고 생각이 들 것이다. 필자도 인지하고 있고 지속적인 고민을 통해 개선해나갈 것이다.
다음 글에서는 테스트 코드를 많이 작성해보고 리팩터링해보면서 얻은 인사이트에 대해서 다뤄보겠다.
참고 문서
https://www.arhohuttunen.com/test-data-builders/#-make-construction-easier-with-the-builder-pattern
'테스트' 카테고리의 다른 글
| 테스트 코드 개선기 - 테스트 코드에서 얻을 수 있는 인사이트 (0) | 2024.12.23 |
|---|---|
| 자바의 LocalTime와 MariaDB(MySQL)의 TIME 의 관계 (0) | 2024.07.06 |
| Jpa의 영속성 전이와 테스트의 스프링 트랜잭션 전파 (0) | 2024.06.21 |
| [단위 테스트] 단위 테스트의 세 가지 스타일 (0) | 2024.06.18 |
| [단위 테스트] 리팩토링 내성 (좋은 단위 테스트의 요소) (0) | 2024.06.17 |