단위 테스트

이전까지의 단위 테스트란 프로그램이 '돌아간다' 라는 사실만 확인하는 정도였다.
현재는 TDD와 애자일 덕분에 단위 테스트를 자동화하는 프로그래머들이 이미 많아졌으며, 늘어나는 추세다.

TDD 법칙 3가지

요즘엔 실제 코드를 작성하기 전에 단위 테스트부터 짜라고 요구한다!

  1. 실패하는 단위 테스트를 작성할 때까지 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 코드를 작성한다.

이렇게 일하면 매일 수십개에서 매년 수천개의 테스트 케이스가 나온다.
하지만 정말 많아진 테스트코드는 심각한 관리 문제를 유발한다.

깨끗한 테스트 코드 유지하기

기존에 작성한 테스트 케이스를 위한 코드를 짰지만, 실제 코드는 계속해서 변경된다.
실제 코드가 변경됨으로써 이전에 작성한 테스트 케이스가 실패한다면 테스트 케이스를 통과시키기는 점점 더 어려워진다.
그렇게 유지되다보면 결국에는 테스트 케이스들을 모두 버리는 상황에 처한다.

BUT, 버린다면 실제 코드가 정상적으로 동작하는 지 확인할 수가 없다.
따라서 테스트 코드 또한 실제 코드만큼이나 중요하다!

테스트는 유연성, 유지보수성, 재사용성을 제공한다.

테스트 코드를 깨끗하게 유지하지 않으면 잃어버린다.
유연성, 유지보수성, 재사용성을 제공하는 버팀목이 바로 단위 테스트이다. -> 테스트 케이스가 있으면 코드 변경이 두렵지 않으므로
하지만, 코드를 잘 짜도, 아키텍처를 유연해도, 설계를 잘 하더라도 테스트 케이스가 없으면 버그를 잡아내기 힘들다.

테스트 케이스의 커버리지가 넓을수록 코드 변경에 대한 두려움은 사라진다.

깨끗한 테스트 코드

세가지가 필요하다

  1. 가독성
  2. 가독성
  3. 가독성
    (;;;;; 뭐야 왜이래)

테스트 코드에서의 가독성을 굉장히 중요하다.
테스트 함수 내에 테스트를 위한 사전 작업들 같은 경우는 별도의 함수를 통해 호출하여 가독성을 높인다.

이중 표준

테스트 환경에서는 성능을 크게 신경쓰지 않아도 된다.
성능보다는 가독성과 테스트에만 집중한다.

테스트당 assert 하나

굳이 지킬 필요는 없다

테스트당 개념 하나

이게 차라리 맞다.

F.I.R.S.T

  • Fast: 테스트는 빨라야 한다. 초반에 자주 돌려야 문제를 찾기 쉽다.

  • Independent: 각 테스트는 서로 의존하면 안된다. 한 테스트가 다른 테스트의 환경을 준비해서는 안된다. 테스트 함수는 순서와 상관없이 보장되어야 한다.

  • Repeatable: 테스트는 반복 가능해야 한다. 실제 환경, QA 환경, 네트워크가 없는 노트북 환경에서도 실행되어야 한다.

  • Self-Validating: 테스트는 Bool 값으로 결과를 내야 한다. 성공 아니면 실패!
    통과 여부를 Bool로 내지 않으면 판단은 주관적이 된다.

  • Timely: 테스트는 적시에 작성해야 한다. 실제 코드를 구현하기 이전에! 작성되어야 한다.

결론

사실상 깨끗한 테스트 코드라는 주제는 정말 끝이 없는 주제이다.
실제 코드보다 더 중요할 수도 있다.
테스트 코드를 깨끗하게 유지하자.

경계

시스템에 들어가는 모든 SW를 개발하기는 드물다.
때로는 패키지를 사고, 때로는 오픈 소스를 사용한다. 또는 사내 다른 팀의 컴포넌트를 이용하기도 한다.

외부 코드 사용하기

인터페이스 제공자와 사용자 사이에는 긴장이 존재한다.
제공자는 적용성을 최대한 넓히려 애쓴다. -> 커버리지가 커야 많은 사용자가 구매하니까
반대로 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.

하나의 예로 java.util.Map 에는 clear() 메소드가 존재한다.
Map을 이리저리로 옮기면서 clear()라는 메소드를 누구나, 어디서든 호출할 수 있다.

ex) 센서를 담는 Map을 생성하자

Map sensors = new HashMap();
Sensor s = (Sensor)sensors.get(sensorId);

해당 맵에는 어떤 객체라도 들어갈 수 있다.

-> 제네릭을 사용해보자

Map<String, Sensor> sensors = new HsahMap<Sensor>();
Sensor s = sensors.get(sensorId);

하지만 Map의 기본 API인 clear()와 같은 기능은 제어하지 못한다. (즉, 여전히 누구나 사용할 수 있다.)

-> 다른 방법을 사용해보자

public class Sensors{
    private Map sensors = new HashMap();

    public Sensor getById(String id){
        return (Sensor) sensors.get(id);
    }
    ...
}

제네릭을 사용하지 않더라도 사용하는 입장에서는 문제가 되지 않는다.
또한 필요한 API만 사용자에게 제공이 가능하다.(clear()와 같은 불필요한 API는 제공하지 않아도 된다.)
Map을 사용할 때마다 캡슐화하라는 소리가 아니다. Map을 여기저기 넘기지 말라는 말이다.

경계 살피고 익히기

외부 코드를 사용하면 적은 시간에 많은 기능을 출시하기 쉬워진다.
외부 패키지 테스트가 우리의 책임은 아니다. 하지만 우리가 사용할 코드를 테스트하는 것은 우리의 책임이다.

  1. 외부 라이브러리를 가져온다.
  2. 며칠 문서를 보며 사용법을 익힌다.
  3. 우리쪽 코드를 작성해 라이브러리를 검증한다.
  4. 우리 버그인지 라이브러리 버그인지 찾아내느라 시간이 정말 많이 걸린다.

이처럼 외부 코드를 익히기는 어렵다. 통합하기도 어렵다.
그렇다면 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 간단한 테스트 케이스를 작성하며 익히는 것은 어떤가?
-> 이를 학습 테스트라 부른다.

깨끗한 경계

경계에서는 흥미로운 일이 많이 벌어진다.
변경이 대표적인 예다.
SW 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다.
통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의지하는 편이 훨씬 좋다.

외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.
Map에서 봤듯이, 새로운 클래스로 경계를 감싸거나, ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환하자.

오류 처리

오류 처리는 프로그램에 반드시 필요한 요소 중 하나일 뿐이다.
입력이나 디바이스가 실패할 수도 있기 때문이다.
내 생각엔 틀릴 수 없는 코드더라도 분명 가능성은 늘 존재한다.

여기저기 흩어진 오류 처리 코드로 인해 실제 코드가 하는 일을 파악하기가 어려울 수 있다.
오류 처리 코드로 인해 논리를 이해하기 어렵다면 깨끗한 코드가 아니다.

오류 코드보다 예외를 사용하라

Try-Catch-Finally 문부터 작성하라

try 블록에 들어가는 코드를 실행하면 어느 시점에서든 catch 블록으로 넘어갈 수 있다.

미확인 예외를 사용하라

함수1 -> 함수2 -> 함수3 -> 함수4(새로운 예외 발생)
이 때,

1) 함수3 부터 함수2, 함수1 까지 새로운 예외를 catch로 처리해야 하거나,
2) throw 키워드를 모두 붙여줘야 한다.
이러한 건 OOP의 OCP(개발-폐쇄 원칙)을 위반한다.

개방-폐쇄 원칙
객체는 확장에는 개발, 수정에는 폐쇄되어야 한다.

-> (하지만, 확인된 예외를 처리함으로써 어떤 예외인지 알아채기 쉽고, 보다 유지보수하기가 편리해지는 것 아닐까? 하는 생각이 든다)

예외에 의미를 제공하라

오류 메세지에 정보를 담아 예외와 함께 던진다.
실패한 연산 이름, 실패 유형 등...
오류가 발생한 원인과 위치를 더 찾기 쉬워진다.

호출자를 고려해 예외 클래스를 정의하라

정상 흐름을 정의하라

앞 절에서의 지침들을 따른다면 비즈니스 논리와 오류 처리가 잘 분리된 코드가 나온다.

/*
* 식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더한다.
* 식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.
*/
//1번
try{
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e){
    m_total += getMealPerDiem();
}

//2번
public class PerDiemMealExpenses implements MealExpenses{
    public int getTotal(){
        //청구값이 있다면 청구값을 반환
        //청구값이 없다면 기본값을 반환
    }
}

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

1번에서 2번으로 변경하게 되면 예외 상황을 처리할 필요가 없어진다.

null을 반환하지 마라

반환 타입이 nullable이라면 지속적으로 null check를 해야 한다.
만약 1번이라도 null check 코드가 빠졌다면 NPE가 발생한다.
nullable 타입을 반환하기보다 예외나 특수 사례 객체를 반환하라
외부 객체가 null을 반환한다면 감싸기 메서드를 구현해 예외를 던지거나 특수 사례 객체를 반환하라

//1번
List<Employee> employees = getEmployees();
if (employees != null){
    for(Employee e : employees){
        totalPays += e.getPay();
    }
}

//2번
List<Employee> employees = getEmployees();
for(Employee e : employees){
    totalPays += e.getPay();
}

굳이 null을 반환해야 할까?, 빈 리스트를 넘겨서 로직을 수행하지 않아도 되지 않은가?
이로써 쓸데없는 코드 2줄이 줄고, 보기에도 깔끔해졌다.

null을 전달하지 마라

메서드에서 null을 반환하는 방식도 나쁘지만, 메서드로 null을 전달하는 방식은 더 나쁘다.

대다수 프로그래밍 언어는 호출자가 실수로 null을 넘기면 적절히 처리하는 방법이 없다.
애초에 null을 넘기지 못하도록 금지하는 정책이 합리적이다.
인수로 null이 넘어오면 코드에 문제가 있다는 뜻이다.

결론

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
이 둘은 상충하는 목표가 아니다.
오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다.

객체와 자료 구조

변수를 private으로 선언하는 이유는 남들이 변수에 의존하지 않게 만들고 싶어서다.
변수 타입, 구현을 맘대로 바꾸고 싶어서다.

자료 추상화

두 코드를 비교해보자
한 클래스는 구현을 외부로 노출하고 다른 클래스는 구현을 완전히 숨긴다.

//1번 - 구체적인 Point Class
public class Point{
    public double x;
    public double y;
}

//2번 - 추상적인 Point Class
public interface Point{
    double getX();
    double getY();
    void setCartesian(double x, double y);
    double getR();
    double getTheta();
    boid setPolar(double r, double theta);
}

2번은 직교좌표계인지 극좌표계인지 모른다.
자료 구조 이상을 표현한다. 클래스 메서드가 접근 정책을 강제한다.
1번은 직교좌표계를 사용한다. 개별적으로 값을 읽고 설정하게 강제한다.
구현을 노출한다. -> 변수를 private으로 하더라도 get/set이 노출된다면 구현이 외부로 노출되는 셈이다.

//3번
public interface Vehicle{
    double getFuelTankCapacityInGallons();
    double getGallonsOfGasoline();
}

//4번
public interface Vehicle{
    double getPercentFuelRemaining();
}

3번가 4번 사이에서는 4번이 더 좋다.
자료를 세세하게 공개하기보다는 추상적인 개념으로 표현하는 편이 좋다.

자료/객체 비대칭

앞서 소개한 두 예제는 객체와 자료구조의 차이를 보여준다.
객체는 추상화 뒤로 자료를 숨긴채 자료를 다루는 함수만 공개한다.
자료구조는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다.

//절차적인 코드
public class Rectangle{
    public Point topLeft;
    public double height;
    public double width;
}

public class Circle{
    public Point center;
    public double radius;
}

public class Geometry{
    public double area(Object shape) throws NoSuchShapeException{
        if (shape instanceof Rectangle){
            Rectangle r = (Rectangle)shape;
            return r.height * r.width;
        }else if(shape instanceof Circle){
            Circle c = (Circle)shape;
            return 3.14 * c.radius * c.radius;
        }else{
            throw new NoSuchShapeException();
        }
    }
}

//객체 지향 코드
public class Rectangle implements Shape{
    private Point topLeft;
    private double height;
    private double width;
    public double area(){
        return height*width;
    }
}

public class Circle implements Shape{
    private Point center;
    private double radius;

    public double area(){
        return 3.14 * radius * radius;
    }
}

객체 지향 vs 절차적인 코드

  • 객체 지향 코드에서는 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.

  • 절차적인 코드에서는 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다.

  • 객체 지향 코드에서는 새로운 함수를 추가하기 어렵다. 그러려면 모든 클래스를 고쳐야 한다.

  • 절차적인 코드에서는 새로운 자료 구조를 추가하기 어렵다. 그러려면 모든 함수를 고쳐야 한다.

개발을 하다보면 새로운 함수가 아니라 자료 타입이 필요한 경우가 필요하다 -> 객체 지향
새로운 자료 타입이 아닌 새로운 함수가 필요하다 -> 절차적인 코드 + 자료구조

디미터 법칙

디미터 법칙은 휴리스틱으로 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다.
객체는 자료를 숨기고 함수를 제공한다. 객체는 조회 함수로 내부 구조를 공개하면 안된다는 의미다.

클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다

  1. 클래스 C
  2. f가 생성한 객체
  3. f 인수로 넘어온 객체
  4. C 인스턴스 변수(프로퍼티)에 저장된 객체

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

기차 충돌

위와 같은 코드를 기차 충돌이라 부른다.
위 코드는 나누는 편이 좋다.

Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final Strin goutputDir = scratchDir.getAbsolutePath();

자료 전달 객체

자료 구조체의 전형적인 형태는 공개 변수만 있고 함수가 없는 클래스다.
때로는 DTO라 일컫는다.
특히 DB와 통신 or 소켓으로 받은 Msg를 분석할 때 유용하다.

-> DTO는 자료구조이므로 함수를 제공하지 않는다.

결론

객체는 동작을 공개하고 자료를 숨긴다.
기존 동작을 변경하지 않으면서 새 객체 타입을 추가하기는 쉽다.
하지만 기존 객체에 새 동작을 추가하기는 어렵다.

자료구조는 별다른 동작없이 자료를 노출한다.
그래서 기존 자료 구조에 새 동작을 추가하기는 쉽다.
하지만 기존 함수에 새 자료 구조를 추가하기는 어렵다.

+ Recent posts