프로그래밍 요구사항을 보면 함수 15라인으로 제안하는 요구사항이 있다. 이때 공백 라인도 한 라인에 해당한다. 15라인이 넘어간다면 함수 분리를 위한 고민을 한다.
정상적인 경우를 구현하는 것보다 예외 상황을 모두 고려해 프로그래밍하는 것이 더 어렵다. 예외 상황을 고려해 프로그래밍하는 습관을 들인다. 예를 들어 로또 미션의 경우 아래와 같은 예외 상황을 고민해 보고 해당 예외에 대해 처리를 할 수 있어야 한다.
비즈니스 로직과 UI 로직을 한 클래스가 담당하지 않도록 한다. 단일 책임의 원칙에도 위배된다.
class Lotto {
#numbers
// 로또 숫자가 포함되어 있는지 확인하는 비즈니스 로직
contains(numbers) {
...
}
// UI 로직
print() {
...
}
}
필드는 private class 필드로 구현한다. 객체의 상태를 외부에서 직접 접근하는 방식을 최소화 하는 이유에 대해서는 스스로 찾아본다.
class WinningLotto {
#lotto
#bonusNumber
constructor(lotto, bonusNumber) {
this.#lotto = lotto
this.#bonusNumber = bonusNumber
}
}
Lotto 클래스는 numbers를 상태 값으로 가진다. 그런데 이 객체는 로직에 대한 구현은 하나도 없고, numbers에 대한 getter 메서드만을 가진다.
class Lotto {
#numbers
constructor(numbers) {
this.#numbers = numbers
}
getNumbers() {
return this.#numbers
}
}
class LottoGame {
play() {
const lotto = new Lotto(...)
// 숫자가 포함되어 있는지 확인한다.
lotto.getNumbers().contains(number)
// 당첨 번호와 몇 개가 일치하는지 확인한다.
lotto.getNumbers().stream()...
}
}
Lotto에서 데이터를 꺼내지(get) 말고 메시지를 던지도록 구조를 바꿔 데이터를 가지는 객체가 일하도록 한다.
class Lotto {
#numbers
constructor(numbers) {
this.#numbers = numbers
}
contains(number) {
// 숫자가 포함되어 있는지 확인한다.
return ...
}
matchCount(other) {
// 당첨 번호와 몇 개가 일치하는지 확인한다.
return ...
}
}
class LottoGame {
play() {
const lotto = new Lotto(...)
lotto.contains(number)
lotto.matchCount(...)
}
}
(참고. getter를 사용하는 대신 객체에 메시지를 보내자)
필드의 수가 많은 것은 객체의 복잡도를 높이고, 버그 발생 가능성을 높일 수 있다. 필드에 중복이 있거나, 불필요한 필드가 없는지 확인해 필드의 수를 최소화한다.
예를 들어 총 상금 및 수익률을 구하는 다음 객체를 보자.
class LottoResult {
#result = new Map()
#profitRate
#totalPrize
}
위 객체의 profitRate와 totalPrize는 등수 별 당첨 내역(result)만 있어도 모두 구할 수 있는 값이다. 따라서 위 객체는 다음과 같이 하나의 필드만으로 구현할 수 있다.
class LottoResult {
#result = new Map()
calculateProfitRate() { ... }
calculateTotalPrize() { ... }
}
테스트를 작성하면 성공하는 케이스에 대해서만 고민하는 경우가 있다. 하지만 예외에 대한 부분 또한 처리해야 한다. 특히 프로그램에서 결함이 자주 발생하는 부분 중 하나는 경계값이므로 이 부분을 꼼꼼하게 확인해야 한다.
test("보너스 번호가 당첨 번호와 중복되는 경우에 대한 예외 처리", () => {
mockQuestions( ["1000", "1,2,3,4,5,6", "6"]);
expect(() => {
const app = new App();
app.play();
}).toThrow("[ERROR]");
});
테스트 코드도 코드이므로 리팩터링을 통해 개선해나가야 한다. 특히 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 예를 들어 단순히 파라미터의 값만 바뀌는 경우라면 아래와 같이 테스트할 수 있다.
test.each([["999"], ["0"], ["-123"]])("천원 미만의 금액에 대한 예외 처리", (input) => {
expect((input) => {
const app = new App(input);
app.play();
}).toThrow();
}
);
테스트를 위한 편의 메서드를 구현 코드에 구현하지 마라. 아래의 예시처럼 테스트를 통과하기 위해 구현 코드를 변경하거나 테스트에서만 사용되는 로직을 만들지 않는다.
# prefix
를 바꾸는 경우아래 코드는 Random 때문에 Lotto에 대한 단위 테스트를 하기 힘들다. 단위 테스트가 가능하도록 리팩터링한다면 어떻게 하는 것이 좋을까?
const MissionUtils = require("@woowacourse/mission-utils");
class Lotto {
#numbers
constructor() {
this.#numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
}
}
---
class LottoMachine {
execute() {
const lotto = new Lotto()
}
}
올바른 로또 번호가 생성되는 것을 테스트하기 어렵다. 테스트하기 어려운 것을 클래스 내부가 아닌 외부로 분리하는 시도를 해 본다.
const MissionUtils = require("@woowacourse/mission-utils");
class Lotto {
#numbers
constructor(numbers) {
this.#numbers = numbers
}
}
class LottoMachine {
execute() {
const numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
const lotto = new Lotto(numbers)
}
}
위 코드는 A 상황을 B로 바꾼 것이다.
A.
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 어려움)
⬇️
Lotto(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)
B.
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)
⬇️
Lotto(테스트하기 쉬움)
(참고. 메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기)
이처럼 단위 테스트를 할 때 테스트하기 어려운 부분은 분리하고 테스트 가능한 부분을 단위 테스트한다. 테스트하기 어려운 부분은 단위 테스트하지 않아도 된다. 남은 LottoMachine은 어떻게 테스트하기 쉽게 바꿀 수 있을지 고민해 본다.