단위 테스트로 리팩터링 중 실수 방지하기

2023. 12. 3. 14:49리팩토링

내 코드가 그렇게 이상한가요? 를 참고하여 쓴 글 입니다.

리팩터링이란 실질적인 동작은 유지하면서, 구조만 정리하는 작업이다.
리팩터링을 하기 위해 코드를 변경할 때 실질적인 동작까지 바뀌어 버린다면, 이는 리팩터링이라 할 수 없다.
그렇기 위해서 단위 테스트를 통해 실질적인 동작이 변하지 않았는지 확인해야한다.

단위 테스트는 리팩토링 중에 동작을 변경하는 실수를 줄일 수 있는 방법이다.
단위 테스트는 작은 기능 단위로 동작을 검증하는 테스트로, 테스트 코드를 활용해서 메서드 단위로 동작을 검증하는 방법이라고 생각해도 괜찮다.

먼저 예시코드를 살펴보자.

class DeliveryManager {
  static getDeliveryCharge(products) {
    let charge = 0;
    let totalPrice = 0;
    products.forEach((product) => {
      totalPrice += product.price;
    });
    if (totalPrice < 20_000) {
      charge = 5000;
    } else {
      charge = 0;
    }

    return charge;
  }
}

이 코드에서 살펴볼 수 있는 몇 가지 문제점들이 있다.

  1. 메서드가 static으로 정의되어 있기 때문에 데이터와 데이터를 조작하는 로직을 분리해서 정의할 수 있게된다. 이렇게 되면 응집도가 낮아질 가능성이 높다.
  2. 배송비는 금액을 나타내는 개념이므로 값 객체를 활용할 수 있다. 이때 주의할 점은 단순히 배송비를 외부에서 getter를 통해 가져와 계산하는 로직을 외부에서 정의하지 않아야한다.
  3. 상품 합계 금액은 다양한 상황에서 사용될 수 있다. 이 말은 즉, 합계 금액을 계산하는 로직이 각각의 클래스에서 따로 구현되어 중복될 가능성이 높아질 수 있다는 말이다. 때문에 별도의 클래스로 관리하는 것이 좋다.

🔨테스트 코드를 사용한 리팩터링 흐름

꼭 이 방법만이 정답은 아니다.
안전하게 리팩터링하기 위한 테스트 코드 추가 방법은 여러가지이다.
이 방법은 이상적인 구조를 어느 정도 알고 있을 때 유용한 방법이다.

  1. 이상적인 구조의 클래스 기본 형태를 어느 정도 잡는다.
  2. 이 기본 형태를 기반으로 테스트 코드를 작성한다.
  3. 테스트를 실패시킨다.
  4. 테스트를 성공하기 위한 최소한의 코드를 작성한다.
  5. 기본 형태의 클래스 내부에서 리팩터링 대상 코드를 호출한다.
  6. 테스트가 성공할 수 있도록, 조금씩 로직을 이상적인 구조로 리팩터링한다.

이상적인 구조의 클래스 기본형태 잡기

class ShoppingCart {
  #products;

  constructor(products) {
    if (!Array.isArray(products)) {
      this.#products = [];
    } else {
      this.#products = products;
    }
  }

  getProducts() {
    return this.#products;
  }

  add(product) {
    const adding = this.#products;
    adding.push(product);

    return new ShoppingCart(adding);
  }
}

module.exports = ShoppingCart;
class Product {
  #id;
  #name;
  #price;

  constructor(id, name, price) {
    this.#id = id;
    this.#name = name;
    this.#price = price;
  }

  getPrice() {
    return this.#price;
  }
}

module.exports = Product;
class DeliveryCharge {
  #amount;

  constructor(shoppingCart) {
    this.#amount = -1;
  }

  getAmount() {
    return this.#amount;
  }
}

우선 다음과 같은 이상적인 구조의 클래스를 만들어본다.
아직 완성된 상태가 아니기 때문에 하나씩 DeliveryManager의 로직을 옮겨주면 된다.


테스트 코드 작성하기

테스트해야할 항목은 다음과 같다.

  • 상품 합계 금액이 20,000원 미만이면 배송비는 5,000원
  • 상품 합계 금액이 20,000원 이상이면 배송비는 무료
const Product = require('../src/shopping/Product');
const ShoppingCart = require('../src/shopping/ShoppingCart');
const DeliveryCharge = require('../src/shopping/DeliveryCharge');

describe('배송비 테스트', () => {
  test('상품 합계 금액이 20,000원 미만이면 배송비는 5,000원', () => {
    const shoppingCart = new ShoppingCart();

    const oneProductAdded = shoppingCart.add(new Product(1, '상품A', 5000));
    const twoProductAdded = oneProductAdded.add(new Product(2, '상품B', 10000));

    expect(new DeliveryCharge(twoProductAdded).getAmount()).toBe(5000);
  });

  test('상품 합계 금액이 20,000원 이상이면 배송비는 무료', () => {
    const shoppingCart = new ShoppingCart();
    const oneProductAdded = shoppingCart.add(new Product(1, '상품A', 10000));
    const twoProductAdded = oneProductAdded.add(new Product(2, '상품B', 10000));

    expect(new DeliveryCharge(twoProductAdded).getAmount()).toBe(0);
  });
});

테스트 실패시키기

단위 테스트는 프로덕션 코드를 구현하기 전에, 실패와 성공을 확인해야 한다. 기대한 대로 실패 혹은 성공하지 않는다면 테스트 코드나 프로덕션 코드 중 어딘가에 오류가 있다는 방증이기 때문이다.

그래서 우선은 테스트를 실패시킨다. 배송비를 계산하는 로직이 구현되지 않았으므로 현재 위 테스트 코드는 실패한다.


테스트 성공시키기

처음부터 본격적으로 구현하는 것이 아닌 테스트를 성공시키기 위한 최소한의 코드만 구현한다.
테스트 코드를 작성하는 이유 중 하나는 구현한 기능에 대한 빠른 피드백을 받는 것이기도 하다.

class DeliveryCharge {
  #amount;

  constructor(shoppingCart) {
    const totalPrice = shoppingCart.getProducts()[0].getPrice() + shoppingCart.getProducts()[1].getPrice();
    if (totalPrice < 20_000) {
      this.#amount = 5000;
    } else {
      this.#amount = 0;
    }
  }

  getAmount() {
    return this.#amount;
  }
}

module.exports = DeliveryCharge;

리팩터링하기

테스트 코드가 의도대로 잘 동작한다면 리팩터링을 한다.
우선 리팩토링 대상인 DeliveryManager의 로직을 DeliveryCharge 생성자에 호출해서 정상 작동하는지 확인한다.

const DeliveryManager = require('./DeliveryManager');

class DeliveryCharge {
  #amount;

  constructor(shoppingCart) {
    this.#amount = DeliveryManager.getDeliveryCharge(shoppingCart.getProducts());
  }

  getAmount() {
    return this.#amount;
  }
}

module.exports = DeliveryCharge;

그리고 상품의 총 금액을 계산하는 로직은 ShoppingCart에게 책임을 맡기자.

class ShoppingCart {
  #products;

  constructor(products) {
    if (!Array.isArray(products)) {
      this.#products = [];
    } else {
      this.#products = products;
    }
  }

  getProducts() {
    return this.#products;
  }

  calculateTotalPrice() {
    let totalPrice = 0;
    this.#products.forEach((product) => {
      totalPrice += product.getPrice();
    });

    return totalPrice;
  }

  add(product) {
    const adding = this.#products;
    adding.push(product);

    return new ShoppingCart(adding);
  }
}

module.exports = ShoppingCart;

그런 다음 DeliveryManager에서 ShoppingCartcalculateTotalPrice를 호출하도록 변경한다.

이제 DeliveryManager에서 직접 총 주문금액을 계산하지 않게 된다.

class DeliveryManager {
  static getDeliveryCharge(products) {
    let charge = 0;
    const totalPrice = products.calculateTotalPrice();

    if (totalPrice < 20_000) {
      charge = 5000;
    } else {
      charge = 0;
    }

    return charge;
  }
}

module.exports = DeliveryManager;

그런 다음 배송비 계산로직을 DeliveryCharge로 옮긴다.

생성자에서 파라미터로 shoppingCart를 받기 때문에 이에 맞게 변경한다.

class DeliveryCharge {
  #amount;

  constructor(shoppingCart) {
    const totalPrice = shoppingCart.calculateTotalPrice();

    if (totalPrice < 20_000) {
      this.#amount = 5000;
    } else {
      this.#amount = 0;
    }
  }

  getAmount() {
    return this.#amount;
  }
}

module.exports = DeliveryCharge;

그 외에도 매직 넘버를 피하기 위해 상수로 변경하거나 더 좋은 리팩터링을 이어나갈 수 있다.

테스트 코드가 있으니 리팩터링 중간에 실수로 로직을 잘못 작성하면, 테스트가 실패하기 때문에 곧바로 알아차릴 수 있다.

따라서 안전하게 로직을 변경할 수 있다.