좋은 코드, 나쁜 코드

좋은 코드, 나쁜 코드

프로그래머의 코드 품질 개선법

Chapter 1. 코드 품질

코드 품질의 핵심 요소

엔지니어들이 코드를 작성할 때 내리는 일상적인 결정은 그것만 보면 작고 때로는 보잘것없어 보일 수도 있지만, 좋은 소프트웨어인지 그렇지 않은지는 그 모든 작은 결정들이 모여서 이루어진다

코드는 읽기 쉬워야 한다

코드의 가독성이 떨어진다면 다른 개발자가 그 코드를 이해하는데 많은 시간을 들여야 한다. 또한 코드의 기능에 대해 잘못 이해하거나 몇 가지 중요한 세부 사항을 놓칠 가능성 역시 크다

코드는 예측 가능해야 한다

함수 이름이 A를 읽는다 라면 그 함수는 정말 A를 읽는 일만 해야 한다

코드를 오용하기 어렵게 만들라

TV 제조업체들은 HDMI 소켓에 전원 코드를 꽂지 못하도록 HDMI 소켓과 전원 코드의 모양이 서로 다르도록 만든다

근데 언어 자체가 타입을 강제하지 못한다면 이 문제는 어떻게 해결할 것인가 ? 🤔

코드를 모듈화하라

코드가 잘 모듈화되어 있다면 코드를 재사용하기 쉬워지고, 코드의 유연성이 극대화된다. 이는 곧 요구사항의 변경에 민첩하게 대응할 수 있다는 말과 같다.

코드를 재사용 가능하고 일반화할 수 있게 작성하라

  • 재사용성(reusablility): 어떤 문제를 해결하기 위한 무언가가 여러 가지 다른 상황에서도 사용될 수 있음을 의미한다. 핸드 드릴은 벽, 바닥 판 및 천장에 구멍을 뚫는데 사용할 수 있기 때문에 재사용 가능한 도구다. 문제는 동일하지만(드릴로 구멍을 뚫어야 한다), 상황은 다르다(벽을 뚫는 것과 바닥을 뚫는 것과 천장을 뚫는 것)
  • 일반화성(generalizability): 개념적으로는 유사하지만 서로 미묘하게 다른 문제들을 해결할 수 있음을 의미한다. 핸드 드릴은 구멍을 뚫는 데 사용될 뿐만 아니라 나사를 박을 때도 사용될 수 있어서 일반화성을 갖는다.

테스트가 용이한 코드를 작성하고, 제대로 테스트하라

테스트 코드가 필요한 이유는 다음과 같다

  • 버그나 제대로 동작하지 않는 기능을 갖는 코드가 코드베이스에 병합되지 않도록 방지
  • 버그나 제대로 동작하지 않는 기능을 갖는 코드가 배포되지 않도록 막고 서비스 환경에서 실행되지 않도록 보장

고품질 코드 작성은 일정을 지연시키는가 ?

단기적으로는 고품질의 코드를 작성하기 위해 시간이 더 걸릴수 있다. 하지만 고품질 코드를 계속 작성해나간다면 중장기적으로는 개발 시간을 단축시켜준다.

Chapter 2. 추상화 계층

인터페이스를 통해 패키지의 의존성을 관리하는 예

image

클래스 레벨에서 추상화를 구현

image

함수는 한 가지 일만 해야 한다

함수가 한 가지 일만 하기 어렵다면 더 큰 개념으로 추상화하고 함수로 구현한다. 상위 개념의 추상화는 하위 개념의 여러 추상화로 이루어진다. 예를 들어 라면을 끓인다의 하위 개념은 물을 끓인다라면을 넣는다이다. 그리고 이 두 개념은 역시 또 다시 하위 개념으로 나뉠 수 있다.

Chapter 3. 다른 개발자와 코드 계약

image

자신에게 분명하다고 해서 다른 사람에게도 분명한 것은 아니다

다른 개발자가 작성한 코드와 상호작용하거나, 코드를 변경하거나, 의존하고 있는 코드를 변경할 수 있다는 것을 기억해야 한다. 코드를 작성할 당시에 너무도 분명해 보였던 것들이 그들에게는 분명하지 않을 것이다.

다른 개발자는 무의식중에 여러분의 코드를 망가뜨릴 수 있다

작성한 코드는 다른 코드로부터 전혀 영향을 받지 않은 채 독립적으로 있는 것이 아니라, 끊임없이 변화하는 코드 위에 놓여 있고, 여러분의 코드를 기반으로 계속해서 변화하는 코드 역시 끊임없이 작성된다. 코드 베이스가 작동되지 않거나 오용되는 결과를 막기 위해서 코드 컴파일이 중지되거나 테스트가 실패하도록 만든다.

시간이 지나면 자신의 코드를 기억하지 못한다

배경지식이 거의 없거나 전혀 없는 사람에게도 자신의 코드가 이해하기 쉬워야 하고 잘 작동하던 코드에 버그가 발생하는 것이 어려워야 한다.

코드 계약

서로 다른 코드 간의 상호작용을 마치 계약처럼 생각한다. 어떤 코드를 호출하는 코드는 특정 요건을 충족해야 하며 호출되는 코드는 원하는 값을 반환하거나 일부 상태를 수정한다.

  • 선결 조건: 코드를 호출하기 전에 사실이어야 하는 것
  • 사후 조건: 코드가 호출된 후에 사실이어야 하는 것
  • 불변 사항: 코드가 호출되기 전과 후에 시스템 상태를 비교해서 변경되지 않아야 하는 사항

Chapter 4. 오류

복구 가능한 오류

많은 소프트웨어 오류는 치명적이지 않으며, 오류가 발생하더라도 사용자는 알아채지 못하도록 적절하게 처리한다면 작동을 계속할 수 있는 합리적인 방법이 있다. 한 가지 예로 사용자가 잘못된 입력을 제공하는 경우가 있다. 예를 들어 사용자가 유효하지 않은 전화번호를 입력할 때 저장되지 않은 이전의 작업들을 잃어버리면서 전체 시스템이 작동을 멈춘다면 훌륭한 사용자 경험은 아니다. 대신 사용자에게 전화번호가 유효하지 않다는 오류 메시지를 제공하고 올바른 번호를 입력하도록 재요청하는 것이 더 낫다.

실패는 빠르고, 요란할수록 좋다

복구할 수 없는 오류도 존재한다. 예를 들자면 개발자가 코드를 명확하게 잘못 작성한 경우를 들 수 있다. 이러한 경우는 절대적으로 코드를 수정하여 다시 배포를 해야만 하는데, 이러한 오류로 인한 피해를 최소화 하기 위해서는 최대한 빠르게 코드가 수정되고 배포가 진행되어야만 한다. 빠르게 실패하고 최대한 요란하게 실패할수록 이러한 문제들이 빨리 발견될 수 있다.

  • 빠르게 실패
    • 빠르게 실패할수록 실패 위치가 원인에서 가까워진다. 느리게 실패한다면 실질적으로 문제가 되는 코드보다 수백라인 이후, 아예 다른 코드 파일에서 실패가 발생할 수 있다. 이러면 근본 원인을 찾는게 더 어려워진다.
  • 요란하게 실패
    • 극단적인 예이지만, 실패하는 순간 애플리케이션이 작동을 멈춰버린다면 그 누구라도 뭔가 문제가 생겼다는 것을 인지할 수 있을 것이다.

오류 처리는 고수준에서 한다

예를 들어 사용자가 웹 페이지에서 전화번호를 입력했다고 가정하면, 웹 페이지는 서버로 전화번호를 보낼 것이고, 서버는 전화번호의 유효성 검사를 진행할 것이다. 이때 전화번호가 유효하지 않다면 서버는 어떤 결과를 응답해주어야 할까? 서버에서 이러한 잘못된 입력을 자체적으로 처리하기는 불가능하다. 서버에서 할 수 있는 최선은 입력된 전화번호가 유효하지 않다는 응답을 보내주고, 웹 페이지에서 전화번호를 다시 입력하라는 UI를 노출시키는게 합리적일것이다. 이렇게 저수준에서는 고수준으로 예외를 던지고, 고수준에서 예외처리를 진행하는게 좋다.

오류를 먹지 마라

다음과 같은 코드가 있다. 무엇이 문제인가?

try {
    doSomething();
} catch(Exception e) {
    // do nothing
}
  1. 최상위 예외인 Exception을 통해 예외를 받으므로 저수준에서 정확히 어떤 예외가 발생하는지 명확하지 않다.
  2. 예외를 잡았으나, 아무런 처리도 하지 않고 있다

이런 코드가 있다면 로그가 없기 때문에 예외가 발생했을 때 어떤 문제가 발생했는지 인지하는것조차 어려우며, 어떻게 어떻게 문제를 인지하더라도 이후 어떤 처리들이 진행될지 명확하지 않다.

위의 코드를 개선하면 다음과 같을 수 있다.

try {
    doSomething();
} catch(IllegalArgumentException e) {
    log.error(e.getMessage());
    handle();
} catch(IOException e) {
    log.error(e.getMessage());
    handle();
}

Chapter 5. 가독성 높은 코드를 작성하라

서술적이지 않은 이름은 코드를 읽기 어렵게 만든다

변수나 함수등의 이름을 지을 때 서술적인 이름을 짓기 위한 노력을 충분히 기울이지 않는다면 코드가 어떻게 보일지 보여주는 다소 극단적인 예다. 다음 코드를 30초정도 살펴보고 이 코드가 무슨일을 하고 있는지 맞춰보자.

class T {
    Set<String pns = new Set();
    Int s = 0;
    
    ...
    
    Boolean f(String n) {
        return pns.contains(n);
    }
    
    Int getS() {
        return s;
    }
    
    Int? s(List<T> ts, String n) {
        for ( T t in ts) {
            if (t.f(n)) {
                return t.getS();
            }
        }
        return null;
    }
}

적절한 주석문을 사용하는게 좋다

fun generateId(firstName: String, lastName: String): String {
    // "{이름}.{성}"의 형태로 ID를 생성한다
    return "${firstName}.${lastName}"
}

위의 주석문이 유의미한가? 이미 함수 시그니처와 함수의 세부 구현으로 모든게 명확하게 설명되고 있다고 생각되지 않는가? 주석이 없다면 코드가 잘 이해되지 않는지 주석을 지우고 다시 보자.

fun generateId(firstName: String, lastName: String): String {
    return "${firstName}.${lastName}"
}

반대로 주석이 유용한 경우도 확실히 존재한다.

아래의 코드는 어떤 객체의 유효시간을 의미한다.

private static final long EXPIRY_TIME_IN_MS = 691200000;

변수명을 통해 단위가 ms임을 명시하긴 하였으나 691,200,200이 정확히 앞으로 얼마의 시간동안 유효한지가 직관적으로 떠오르지 않는다. 일단 가독성을 좋게 하기 위해 다음과 같은 절차들을 밟을 수 있다.

private static final long EXPIRY_TIME_IN_MS = 691_200_000;

우선 3자리 단위로 끊어볼 수 있다. 하지만 여전히 얼마의 시간인지는 명확하지 않다.

private static final long EXPIRY_TIME_IN_MS = 1_000 * 60 * 60 * 24 * 8;

위와 같이 단위를 구분지어 표현할수도 있다. 이 경우엔 상당히 명확해진다. 1,000ms는 1초이며, 60초는 1분, 60분은 1시간, 24시간은 하루이므로 위의 691,200,00ms 라는 값이 총 8일을 의미함을 알 수 있다.

그래도 여전히 개발자가 코드를 보며 계산을 하기는 해야 한다. 다음과 같다면 어떨까?

private static final long EXPIRY_TIME_IN_MS = 1_000 * 60 * 60 * 24 * 8; // 8 days

이외에도 해당 코드가 작성되어야만 했던 맥락이 담긴 문서등은 주석으로 작성할 가치가 충분하다. 이렇게 가독성 높은 코드와 함께 적절히 사용된 주석은 분명 유의미한 가치가 있다. 하지만 근본적으로 주석문만으로 가독성 높은 코드를 대체할수는 없다.

코드 라인이 짧고 간결하다고 좋은 코드는 아니다

다음의 간결한 코드가 정확히 어떤것을 하고 있는지 파악해보자.

boolean isIdValid(UInt16 id) {
    return countSetBits(id & 0x7FFF) % 2 == ((id & 0x8000) >> 15);
}

코드라인이 한줄밖에 되지 않는 간결한 함수다. 파악이 되는가? 대부분의 개발자가 위 함수가 어떤것을 하고 있는지 제대로 파악하지 못할거라 생각한다. 대부분의 코드는 작성되는 시간보다 이후 읽히는 시간이 압도적으로 많기 때문에, 가독성을 위해 코드 라인이 길어지는게 오히려 더 좋은 경우가 많다. (단, 컴퓨팅 리소스가 한정된 상황에서 개발을 진행하고 있다면 이 조언이 맞지 않을 수 있다. (예: 임베디드 프로그래밍 등))

일관된 코딩 스타일을 고수해라

class GroupChat {

    ...
    
    void end() {
        connectionManager.terminateAll();
    }
}

위의 코드가 실패했다. 이유가 무엇일까? 위 코드를 보는 대부분의 자바개발자는 connectionManagerGroupChat의 인스턴스 변수일것으로 예상했을 것이다. 하지만 실제로 connectionManager는 클래스였으며, terminateAll()은 connectionManager의 static 함수였다.

class connectionManager {

    ...
    
    static terminateAll() {
        ...
    }
}

이와 같이 절대 다수의 개발자들이 준수하고 있는 특정 생태계의 스타일 가이드를 따르는것은 매우 중요하다. 다른 개발자들이 동의하고 따르고 있는 스타일 가이드를 지키지 않고 혼자 다른 스타일의 코드를 작성한다면 다른 개발자들에게 혼동을 줄 여지가 매우 높다. 모든 사람들이 하늘에 떠있는 해를 바라보며 해라고 부르는데, 혼자 달이라고 부른다면 다른 사람들과의 의사소통에 많은 문제가 생기지 않을까?

언어의 새로운 기능을 적절하게 활용해라

다음 코드는 문자열 리스트를 입력받아 빈 문자를 걸러내는 작업을 수행하는 전통적인 자바 함수이다.

List<String> getNoneEmptyStrings(List<String> strings) {
    List<String> nonEmptyStrings = new ArrayList(strings.size());
    for (String str : strings) {
        if (!str.isBlank()) {
            nonEmptyStrings.add(str);
        }
    }
    return nonEmptyStrings;
}

자바8부터 도입된 람다와 스트림 API를 사용한다면 다음과 같이 간결하고 직관적이게 표현할 수 있다.

List<String> getNoneEmptyStrings(List<String> strings) {
    return strings.stream()
           .filter(str -> !str.isBlank())
           .collect(Collectors.toList());
}

새로운 기능들은 코드를 더 유지보수하기 쉽게 개발하는데 큰 도움을 줄 수 있다.

Chapter 6. 예측 가능한 코드를 작성하라

다른 개발자가 작성하는 코드는 종종 우리가 작성하는 코드에 의존한다. 다른 개발자가 우리 코드의 기능을 잘못 해석하거나 처리해야 하는 특수한 경우를 발견하지 못하면, 우리가 작성한 코드에 기반한 그 코드에서 버그가 발생할 가능성이 크다. 코드를 호출하는 쪽에서 예상한대로 동작하기 위한 좋은 방법 중 하나는 중요한 세부 사항이 코드 계약의 명백한 부분에 포함되도록 하는 것이다.

우리가 사용하는 코드에 대해 허술하게 가정을 하면 예상을 벗어나는 또 다른 결과를 볼 수 있다. 예를 들어 열거형에 추가되는 새 값을 예상하지 못한 경우다. 의존해서 사용 중인 코드가 가정을 벗어날 경우, 코드 컴파일을 중지하거나 테스트가 실패하도록 하는 것이 중요하다.

테스트만으로는 예측을 벗어나는 코드의 문제를 해결할 수 없다. 다른 개발자가 코드를 잘못 해석하면 테스트해야 할 시나리오도 잘못 이해할 수 있다.

Chapter 7. 코드를 오용하기 어렵게 만들라

코드가 오용되기 쉽게 작성되고 나면 어느 시점에선가는 오용될 가능성이 크고 이것은 버그로 이어질 수 있다.

코드가 오용되는 몇 가지 일반적인 사례는 다음과 같다.

  • 호출하는 쪽에서 잘못된 입력을 제공
  • 다른 코드에서 일어나는 부수 효과
  • 함수 호출 시점이 잘못되거나 올바른 순서로 호출되지 않은 경우
  • 원래의 코드에 연관된 코드를 수정할 때 원래의 코드가 내포한 가정과 어긋나게 수정하는 경우

오용이 어렵거나 불가능하도록 코드를 설계하고 구조화하는 것이 종종 가능하다. 이를 통해 버그 발생 가능성이 크게 줄어들고 중장기적으로 개발자의 시간을 많이 절약할 수 있다.

Chapter 8. 코드를 모듈화하라

코드가 모듈화되어 있으면 변경된 요구 사항을 적용하기 위한 코드를 작성하기가 쉽다. 모듈화의 주요 목표 중 하나는 요구 사항의 변경이 해당 요구 사항과 직접 관련된 코드에만 영향을 미치도록 하는 것이다. 코드를 모듈식으로 만드는 것은 간결한 추상화 계층을 만드는 것과 깊은 관련이 있다.

다음의 기술을 사용하여 코드를 모듈화 할 수 있다.

  • 의존성 주입
  • 구체적인 클래스가 아닌 인터페이스에 의존
  • 클래스 상속 대신 인터페이스 및 구성의 활용
  • 클래스는 자신의 기능만 처리
  • 관련된 데이터의 캡슐화
  • 반환 유형 및 예외 처리 시 구현 세부 정보 유출 방지

Chapter 9. 코드를 재사용하고 일반화할 수 있도록 해라

  • 동일한 하위 문제가 자주 발생하므로 코드를 재사용하면 미래의 자신과 팀 동료의 시간과 노력을 절약할 수 있다.
  • 다른 개발자가 여러분이 해결하려는 문제와는 다른 상위 수준의 문제를 해결하더라도 특정 하위 문제에 대해서는 여러분이 작성한 해결책을 재사용할 수 있도록 근본적인 하위 문제를 식별하고 코드를 구성하도록 노력해야 한다.
  • 간결한 추상화 계층을 만들고 코드를 모듈식으로 만들면 코드를 재사용하고 일반화하기가 훨씬 쉽고 안전해진다.
  • 가정을 하게 되면 코드는 종종 더 취약해지고 재사용하기 어렵다는 측면에서 비용이 발생한다.
  • 가정을 하는 경우의 이점이 비용보다 큰지 확인하라.
  • 가정을 해야 할 경우 그 가정이 코드의 적절한 계층에 대해 이루어지는 것인지 확인하고 가능하다면 가정을 강제적으로 적용하라.
  • 전역 상태를 사용하면 특히 비용이 많이 발생하는 가정을 하는 것이 되고 재사용하기에 전혀 안전하지 않은 코드가 된다. 대부분의 경우 전역 상태를 피하는 것이 가장 바람직하다.

Chapter 10. 단위 테스트의 원칙

블랙박스 테스트

테스트설명
동치 분할 테스트(Equivalence Partitioning Test)프로그램의 입력 데이터를 여러 분류로 나누어 검사
경계값 분석 (Boundary Value Analysis)입력값의 경계값을 중심으로 예외 발생 검사
원인-결과 그래프 기법(Cause-effect Graphing)입력데이터 간의 관계, 출력에 미치는 영향의 분석 그래프 이용
오류 예측검사 (Fault Based Testing)테스터의 감각이나 경험, 지식을 통해 에러케이스를 예측
비교 검사 (Comparison Testing)테스트 대상과 비교 대상 프로그램에 같은 입력값을 넣어 데이터를 비교

테스트 더블

TestDouble - 마틴 파울러

제라드 메스자로스는 다양한 Xunit 프레임워크를 사용하는 패턴을 모아 놓은 책을 만드는 중입니다. 그가 부딪힌 문제 중 하나는 시스템의 일부를 테스트하기 위해 대체하는데 사용되는 스텁(stubs), 목(mock), 페이크(fake), 더미(dummy) 등의 다양한 이름들입니다. 이를 해결하기 위해 그는 자신만의 용어를 만들었으며, 이는 더욱 널리 알리면 좋을 것이라 생각됩니다.

그가 사용하는 일반적인 용어는 ‘테스트 더블’(stunt double을 생각하면 됩니다)입니다. ‘테스트 더블’은 테스트 목적으로 제작 객체를 대체하는 일반적인 용어입니다. 제라드가 나열한 여러 종류의 더블은 다음과 같습니다. 더미 객체는 전달되지만 실제로 사용되지는 않습니다. 보통 매개변수 목록을 채우기 위해 사용됩니다.

페이크 객체는 실제 구현을 가지고 있지만, 일반적으로 프로덕션에 적합하지 않은 지름길을 사용합니다.(InMemoryTestDatabase가 좋은 예임) 스텁은 테스트 중에 호출된 호출에 대한 canned answer(일정한 대답)을 제공하며, 테스트에 프로그래밍된 것 이외의 것에는 대응하지 않습니다. 스파이는 호출된 방식에 따라 일부 정보를 기록하는 스텁입니다. 이 중 하나는 얼마나 많은 메시지가 전송되었는지를 기록하는 이메일 서비스일 수 있습니다. 목(mock)은 호출되기를 기대하는 명세를 형성하는 기대치로 미리 프로그래밍됩니다. 기대하지 않은 호출을 받으면 예외를 던질 수 있으며, 확인 중에 확인하여 예상했던 모든 호출을 받았는지 확인할 수 있습니다.

이를 코드로 보면 다음과 같을 수 있다. (sut는 system under test의 약자로, 테스트중인 시스템을 의미한다.)

@ExtendWith(MockitoExtension.class)
class ExampleServiceTests {
    @Test
    void withMock(@Mock ExampleRepository exampleRepository) {
        // given
        when(exampleRepository.findAll()).thenReturn(List.of("Hello World"));
        
        var sut = new Example(exampleRepository);

        // when
        var result = sut.findAll();

        // then
        assertThat(result)
                .hasSize(1)
                .containsExactly("Hello World");
    }

    @Test
    void withStub() {
        // given
        ExampleRepository stub = new ExampleRepository() {
            @Override
            public void save(String value) {
                // do nothing
            }

            @Override
            public List<String> findAll() {
                return List.of("Hello World");
            }
        }; 
        
        var sut = new ExampleService(stub);
        
        // when
        var result = sut.findAll();

        // then
        assertThat(result)
                .hasSize(1)
                .containsExactly("Hello World");
    }

    @Test
    void withFake() {
        // given
        var sut = new ExampleService(new FakeExampleRepository());
        sut.save("Hello World");

        // when
        var result = sut.findAll();

        // then
        assertThat(result)
                .hasSize(1)
                .containsExactly("Hello World");
    }
}

public class ExampleService {
    private final ExampleRepository exampleRepository;

    public ExampleService(ExampleRepository exampleRepository) {
        this.exampleRepository = exampleRepository;
    }

    public void save(String value) {
        this.exampleRepository.save(value);
    }

    public List<String> findAll() {
        return this.exampleRepository.findAll();
    }
}

public interface ExampleRepository {
    void save(String value);

    List<String> findAll();
}

public class FakeExampleRepository implements ExampleRepository {
    private final List<String> db = new ArrayList<>();

    @Override
    public void save(String value) {
        this.db.add(value);
    }

    @Override
    public List<String> findAll() {
        return new ArrayList<>(this.db);
    }
}

좋은 단위 테스트는 어떻게 작성 할 수 있는가?

액면 그대로의 단위 테스트는 매우 간단해 보일지 모른다. 실제 코드가 작동하는지 확인하기 위해 테스트 코드를 작성하기만 하면된다. 안타깝게도 이는 기만적인것이며, 수 년 동안 많은 개발자가 쉽게 단위 테스트를 잘못된 방식으로 작성해왔다.

단위 테스트에서 문제가 발생하면 유지 관리가 매우 어렵고, 버그가 테스트 코드에서 발견되지 못하고 배포한 뒤에 발생 할 수도 있다. 그러므로 어떻게해야 좋은 단위 테스트가 되는지 생각해 보는것이 중요하다. 이를 위해 좋은 단위 테스트가 가져야 할 5가지 주요 기능을 정의한다.

  • 훼손의 정확한 감지: 코드가 훼손되면 테스트가 실패한다. 그리고 테스트는 코드가 실제로 훼손 된 경우에만 실패해야 한다.
  • 세부 구현 사항에 독립적: 세부 구현 사항을 변경하더라도 테스트 코드는 변경하지 않는것이 이상적이다.
  • 잘 설명되는 실패: 코드가 잘못되면 테스트는 실패의 원인과 문제점을 명확하게 설명해야한다.
  • 이해 할 수 있는 테스트 코드: 다른 개발자들이 테스트 코드가 정확히 무엇을 테스트하기 위한 것이고 테스트가 어떻게 수행 되는지 이해할 수 있어야 한다.
  • 쉽고 빠르게 실행: 개발자는 일상 작업 중에 단위 테스트를 자주 실행한다. 단위 테스트가 느리거나 실행이 어려우면 개발 시간이 낭비된다.

Chapter 11. 단위 테스트의 실제

  • 코드의 모든 동작을 효과적으고 신뢰성 있게 테스트하기
  • 이해하기 쉽고 실패가 잘 설명되는 테스트 코드의 작성
  • 의존성 주입을 사용하여 테스트가 용이한 코드의 작성

기능뿐만 아니라 동작을 시험하라

코드를 테스트하는 것은 할 일 목록을 만들어 작업하는 것과 약간 비슷하다. 그러나 다른 할 일 목록과 마찬가지로 성공적인 결과는 실제로 목록에 있는 것들이 얼마나 올바른지 달려 있다.

테스트만을 위해 퍼블릭으로 만들지 말라

프라이빗 함수는 구현 세부 사항이며 클래스 외부 코드가 인지하거나 직접 사용하는 것이 아니다. 구현 세부 사항과 밀접하게 연관된 테스트가 될 수 있고 궁극적으로 우리가 신경써야 하는 코드의 동작을 테스트하지 않을 수 있다.

퍼블릭 API를 통해 테스트하라

비교적 간단한 클래스의 경우 퍼블릭 API만을 사용하여 모든 동작을 테스트하기가 매우 쉽다. 그러나 클래스가 더 복잡하거나 많은 논리를 포장하면 퍼블릭 API를 통해 모든 동작을 테스트하는 것이 까다로울 수 있다. 이 경우는 코드를 더 작은 단위로 분할하는 것이 유익하다.

코드를 더 작은 단위로 분할하라

image

  • 각 함수를 테스트하는 것에 집중하다 보면 테스트가 충분히 되지 못하기 쉽다. 보통은 모든 중요한 행동을 파악하고 각각의 테스트 케이스를 작성하는 것이 더 효과적이다.
  • 결과적으로 중요한 동작을 테스트 해야 한다. 프라이빗 함수를 테스트하는 것은 거의 대부분 결과적으로 중요한 사항을 테스트하는 것이 아니다.
  • 한 번에 한 가지씩만 테스트하면 테스트 실패의 이유를 더 잘 알 수 있고 테스트 코드를 이해하기가 더 쉽다.
  • 테스트 설정 공유는 양날의 검이 될 수 있다. 코드 반복이나 비용이 큰 설정을 피할 수 있지만 부적절하게 사용할 경우 효과적이지 못하거나 신뢰할 수 없는 결과를 초래할 수 있다.
  • 의존성 주입을 사용하면 코드의 테스트 용이성이 상당히 향상될 수 있다.
  • 단위 테스트는 개발자들이 가장 자주 다루는 테스트 수준이지만 이것만이 유일한 테스트는 아니다. 높은 품질의 소프트웨어를 작성하고 유지하려면 여러가지 테스트 기술을 함께 사용해야 할 때가 많다.

© 2022. All rights reserved.