Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chapter 6: Testing #58

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 303 additions & 0 deletions The_Missing_Readme/6장_테스트!_개발자의_든든한_지원군.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
# 6장 테스트! 개발자의 든든한 지원군

- 테스트를 작성하고 실행하고 수정하는 일은 쓸모없는 작업처럼 보일 수 있다.

- 좋지 않은 테스트는 아무런 효과없이 개발자의 업무 부하만 가중시킬 수 있다.

## 테스트를 꼭 해야 할까

- 대부분의 개발자는 테스트란 코드의 동작을 확인하는 것임을 잘 알고 있지만 그 것이 테스트의 목적은 아니다.

- 테스트의 목적은 나중에 의도치 않게 코드의 변경이 바뀌는 것을 방지하고 깔끔한 코드를 작성하게 도와준다.

- 테스트는 소프트웨어가 원하는 대로 동작하는지를 검증하고 프로그램의 인터페이스와 실제 구현에 대해 고민해볼 수 있다.

- 오래된 테스트가 실패하면 개발자가 동작을 의도적으로 변경한건지 버그가 발생한건지 판단해야 한다.

- TDD(Test-Driven Development) 원칙을 따르게 되면, 코드 작성 전 코드 동작이나 인터페이스 설계, 다른 코드와의 통합 등을 먼저 고려하게 된다.

- 경험이 많은 프로그래머는 새로운 코드베이스를 만날 경우 코드를 이해하기 위해 가장 먼저 읽는 것이 테스트 코드다.

- 테스트에 디버거를 붙여 코드를 한 줄 한 줄 실행해 볼 수 있다.

## 테스트의 유형과 기법

- 단위 테스트(unit test): 코드의 '단위', 즉 메서드나 동작 하나를 검증한다. 빠르고 작으며 집중적이여야 한다.

- 통합 테스트(integration test): 여러 컴포넌트가 함께 어우러져 동작하는지 검증한다. 단위 테스트보다 느리며 정교한 셋업이 필요하며 피드백 루프가 길다.

- 시스템 테스트(system test): 시스템 전체를 검증하며 종단 간(end-to-end; e2e) 워크플로우는 프로덕션 환경의 전반적인 단계에서 실제 사용자의 동작을 시뮬레이션한다.

- 성능 테스트(performance test): 주어진 설정하에 시스템의 성능을 측정하여 용량 계획과 SLO(service-level objective; 서비스 수준 목표) 설정에 도움된다.

- 부하 테스트(load test): 시스템에 다양한 수준의 부하를 걸고 성능을 측정한다.

- 스트레스 테스트(stress test): 장애가 발생하는 수준까지 부하를 올려 부하가 걸릴 시 어떤 일이 벌어지는지 확인한다.

- 인수 테스트(acceptance test): 공식 인수 테스트 및 조건이 계약에 포함된 엔터프라이즈 소프트웨어 대상으로 인수 조건에 만족하는지 검증하는 테스트로 ISO(International Standards Organization; 국제 표준화 기구) 보안 표준의 일부로서 요구한다.

## 테스트 도구

- 모킹(mocking) 라이브러리 같은 테스트 작성 도구를 이용하면 깔끔하고 효율적인 테스트를 작성할 수 있다.

- 테스트 프레임워크는 셋업부터 마무리까지 테스트의 수명주기를 모델링해서 테스트를 실행하게 해주는 도구다.

- 코드 품질 도구는 빌드나 컴파일 단계에서 코드 커버리지(coverage)와 복잡도 등을 분석하거나 정적 분석을 통한 버그 발견, 코딩 스타일 에러 감사 등에 사용한다.

- 복잡도에 대한 트레이드오프를 정당화할 수 있으면서 팀이 동의하기 전까지는 잘 알려지지 않은 도구를 도입하는 것은 지양하는 것이 좋다.

### 모킹 라이브러리

- 객체지향 코드의 단위 테스트에 주로 사용한다.

- 모의 객체(mock)은 입력에 대해 하드코딩된 응답을 리턴함으로써 테스트에 필요한 기능을 구현한다.

- 외부 의존성을 줄이면 단위 테스트를 더 빠르고 집중적으로 수행할 수 있다.

- 셋업이 간편해지고 테스트가 네트워크 호출을 직접 만들지 않아도 되면서 작업이 느려지는 것을 피할 수 있다.

- 모의 객체에 복잡한 로직을 넣으면 테스트가 불안정해지고 난해해진다.

- 모의 객체가 필요한 시점이 되면 모의 시스템에 대한 의존성을 제거하도록 리팩토링이 가능한지부터 고민해보자.

### 테스트 프레임워크

- 테스트 코드를 작성하고 실행하는 도구로 다음 역할을 수행한다.

- 테스트 셋업과 해제

- 테스트 실행 및 조율(orchestration): 테스트를 격리하면서 속도를 조정할 수 있으면서 직렬 또는 병렬로 실행할 수 있다

- 테스트 결과 보고서 생성: 개발자가 실패한 빌드를 디버깅하는데 도움이 되지만 테스트 결과의 저장 위치가 명확하지 않을 수 있다

- 추가 검증 메서드 등의 도구 제공

- 코드 커버리지 도구와의 통합

### 코드 품질 도구

- 린터(linter)는 코드 품질 규칙을 강제하는 도구로 정적 분석과 스타일 검사를 실행한다.

- 정적 코드 분석기(static code analyzer)는 문법 오류를 검사하는 컴파일러가 없는 동적 언어(python, javascript 등)를 사용할 때 중요하다.

- 분석기는 코드 악취나 의심스러운 코드를 강조하는 기능을 제공하고 코드 애노테이션을 통해 [거짓 양성(false positive)](https://ko.wikipedia.org/wiki/%EA%B1%B0%EC%A7%93_%EC%96%91%EC%84%B1%EA%B3%BC_%EA%B1%B0%EC%A7%93_%EC%9D%8C%EC%84%B1) 보고의 가능성을 줄여야 한다.

- 코드 스타일 검사기(code style analyzer)를 사용하면 모든 소스 코드를 동일한 방식으로 작성하여 일관성을 유지할 수 있다.

- 코드 복잡도 도구(code complexity tool)는 순환 복잡도(cyclomatic complexity)를 계산하거나 코드의 분기 횟수로 지나치게 복잡한 로직을 작성하지 않게 한다.

- 코드 커버리지 도구(code coverage tool)는 테스트가 몇 줄의 코드를 실행했는지를 측정하는 도구로 적절한 수준(65%~85%)을 유지하자.

- 코드 품질 도구가 찾은 품질 이슈가 반드시 실질적인 문제라는 보장이 없으므로 코드 품질 지표에 집착하지 말자.

## 개발자 스스로 직접 테스트를 작성하자

- QA(quality assurance)팀은 작성한 코드가 의도대로 동작하게 만드는 역할 외에 다음 역할을 맡고 있다.

- 블랙박스(black-box) 또는 화이트박스(white-box) 테스트 작성

- 성능 테스트 작성

- 통합 테스트, 사용자 인수 테스트, 시스템 테스트 수행

- 테스트 도구 제공 및 유지 보수

- 테스트 환경과 인프라(infrastructure) 유지 보수

- 정식 테스트 인증과 릴리즈 절차 정의

- QA팀이 모든 테스트를 수행하게 해서는 안되며 협업하는 방법에 대해 알아야 한다.

### 테스트는 깔끔하게 작성하자

- 테스트도 코드의 일부므로 유지보수가 필요하고 시간이 지나면 리팩토링도 해야 한다.

- 편법으로 작성한 테스트는 안정성을 떨어트리고 유지보수 비용을 높여 향후 개발 속도를 저해하는 요인이 될 수 있다.

- 소프트웨어 설계 권장 기법을 적용해 테스트의 응집력을 유지하면서 결합력을 낮추자.

- 상세 구현보다 근본적인 기능을 테스트하는데 중점을 두어야 한다.

### 과도한 테스트는 삼가자

- 테스트는 실패했을 때 충분한 의미를 갖도록 작성하자.

- 코드에 내포된 위험에 가장 큰 영향을 미치는 테스트를 작성하는데 주력하자.

- 코드 커버리지는 높다고 코드가 옳은 것을 보장하지 않으므로 가이드 정도로 염두해두자.

- 자동으로 생성되는 코드에 대한 테스트는 하지 말자.

- 에러 발생 가능성이 높고 영향도가 큰, 즉 가장 가치있는 테스트에 집중하는 것이 비용대비 효과가 크므로 [위험 매트릭스(risk matrix)](https://en.wikipedia.org/wiki/Risk_matrix)를 이용해보자.

## 테스트 결정성: 항상 동일한 테스트 결과를 만드려면

- 결정적 코드(deterministic code)란 입력이 같으면 출력이 항상 동일한 코드를 의미한다.

- 네트워크 소켓을 통해 원격 웹서비스를 호출하는 단위 테스트 등 비결정적 코드(nondeterministic code)는 재현과 디버깅이 어려우므로 테스트의 가치를 떨어트린다.

- 간헐적으로 실패하는 테스트가 발생할 경우 즉시 수정하거나 비활성화시켜야 한다.

- 간헐적으로 실패하는 경우, 테스트 간의 간섭이나 특정 머신 설정에 의해 발생할 수도 있으므로 다방면으로 실험해봐야 한다.

### 난수 생성기에 적절한 시드값을 사용하자

- 기본적으로 난수 생성기(random number generator)는 시스템 클럭(system clock)을 사용하므로 테스트마다 다른 결과를 반환(비결정성)하게 된다.

- 난수생성기가 매번 같은 값을 결정적으로 생성하도록 상수를 시드값으로 지정하자.

### 단위 테스트에서 원격 시스템을 호출해서는 안 된다

- (느린) 원격 호출을 제거하면 단위 테스트의 속도와 이식성(portability)를 유지할 수 있다.

### 클럭(clock)을 주입하자

- 특정한 간격의 시간에 의존하는 코드는 제대로 처리하지 않으면 비결정성을 유발한다.

### 슬립(sleep)과 타임아웃(timeout)을 삼가자

- 다른 스레드의 실행이 어느 시점이 지나면 완료될 것이라고 가정에 의존해서는 안 된다.

- 동시적(concurrent) 또는 비동기적(asynchronous) 코드의 테스트가 항상 일관적이지는 않다.

### 네트워크 소켓과 파일 핸들을 닫자

- 테스트 실행 프레임워크는 하나의 프로세스에서 여러 테스트를 실행하므로 리소스(네트워크 소켓, 파일 핸들 등)에 누수가 발생하는 경우 그 즉시 정리되지 않는다.

- 리소스 누수가 발생하면 비결정성을 일으킨다.

- 또한 운영체제에서 열 수 있는 소켓과 파일 핸들 수는 정해져 있어 더 이상 새로운 리소스를 할당하지 못해 테스트가 실패할 수 있다.

- `try-with-resource`나 `with` 블록같이 리소스의 사용 범위를 좁힐 수 있는 표준 리소스 관리 기법을 사용하자.

### 파일과 데이터베이스에 대해 고유한 경로를 생성하자

- 동일한 파일 경로와 데이터베이스 위치를 사용하면 각 테스트가 서로 간섭하게 된다.

- 정적으로 선언한 위치에 데이터를 기록하면 테스트 순서가 바뀌거나 병렬로 실행될 경우 에러를 발생시킬 수 있다.
```java
// Before
public class UserServiceTest {
private static List<User> testUsers = new ArrayList<>();

@Test
public void testAddUser() {
UserService service = new UserService();
User newUser = new User("John");
service.addUser(newUser);
testUsers.add(newUser);
assertEquals(1, testUsers.size());
}

@Test
public void testRemoveUser() {
UserService service = new UserService();
User user = testUsers.get(0); // 무조건 testAddUser가 먼저 실행되어야 함
service.removeUser(user);
testUsers.remove(user);
assertEquals(0, testUsers.size());
}
}

// After
public class UserServiceTest {
private UserService service;
private List<User> testUsers; // 인스턴스 변수로 변경하여 각 테스트 메서드마다 독립적인 리스트 사용

@Before
public void setUp() {
service = new UserService();
testUsers = new ArrayList<>();
}

@Test
public void testAddUser() {
User newUser = new User("John");
service.addUser(newUser);
testUsers.add(newUser);
assertEquals(1, testUsers.size());
}

@Test
public void testRemoveUser() {
User user = new User("Jane");
testUsers.add(user);
service.addUser(user);

service.removeUser(user);
testUsers.remove(user);
assertEquals(0, testUsers.size());
}

@After
public void tearDown() {
testUsers.clear();
}
}
```

- 파일명, 디렉토리 경로, 데이터베이스나 테이블 이름은 동적으로 생성하자.

### 이전 테스트의 상태를 격리하고 해제하자

- 하나의 테스트가 다른 테스트의 성능과 안정성에 영향을 미칠 수 있다.

- 테스트의 성공 여부를 떠나 상태는 반드시 리셋해야 한다.

### 테스트의 실행 순서에 의존하지 말자

- 순서에 대한 의존성을 가지는 패턴은 다음 문제를 갖고 있다.

- 첫 번째 테스트가 실패하면 두 번째 테스트 역시 실패한다.

- 두 번째 테스트는 첫 번째 테스트가 완료되기 전까지 실행할 수 없으므로 테스트의 병렬 실행이 어렵다.

- 첫 번째 테스트를 수정하면 의도치 않게 두 번째 테스트가 실패할 수 있다.

- 테스트 실행기의 변경으로 테스트가 다른 순서로 실행될 수 있다.

- 테스트 사이에 로직을 공유하려면 셋업과 해제 메서드를 사용할 수 있다.

## 개발자의 필수 체크리스트

### 이것만은 지키자

- 버그가 재현되는지 테스트를 이용해 확인해보자

- 단위 테스트는 모의 객체 도구를 이용해 작성하자

- 코드 품질 도구를 이용해 커버리지, 스타일, 복잡도를 검증하자

- 테스트가 사용하는 난수생성기에는 적절한 시드값을 적용하자

- 테스트에서 사용한 네트워크 소켓과 파일은 반드시 닫아주자

- 테스트에 고유한 파일 경로롸 데이터베이스 ID를 생성하자

- 테스트 실행 사이에 남겨진 상태는 정리하자

### 이것만은 피하자

- 새로운 테스트 도구를 추가하는 데 드는 비용을 무시하지 말자

- 다른 사람이 여러분을 대신해 테스트를 작성해주길 기대해서는 안 된다

- 코드 커버리지만으로 품질을 측정하지 말자

- 테스트에서 불필요한 sleep() 메서드와 타임아웃을 사용해서는 안 된다

- 단위 테스트에서 원격 시스템을 호출하지 말자

- 테스트 실행 순서에 의존하지 말자

## 레벨업을 위한 읽을거리

- 특정 테스트 기법을 선택해 그에 맞는 책을 선택하는 것을 권한다.

- 단위 테스트(Vladimir Khorikov 집필, 임준혁 옮김): 테스트에 대한 권장 기법에 대해 좀 더 알 수 있다.

- 테스트 주도 개발(Kent Beck 집필, 김창준 옮김): TDD에 대해 상세하게 다룬다.

- 실용주의 프로그래머(Andrew Hunt, David Thomas 집필, 정지용 옮김): 속성 기반 테스팅(property based testing)에 대해 설명한다.

- 탐험적 테스팅(Elisabeth Hendrickson 집필, 오광신 옮김): 탐험적 테스트를 통해 코드에 대해 학습하는 기법을 설명한다.