프론트엔드에서 TDD 경험해보기 (1) TDD란?

Jane·2023년 12월 8일
4

FE_Study_Notes

목록 보기
2/3
post-thumbnail

📝 TDD 목차


1. TDD란?
2. 단위 테스트
3. 통합 테스트
4. 시각적 요소의 테스트
5. E2E 테스트

📝 테스트와 TDD

🧐 테스트란 무엇인가!

  • 소프트웨어 관점에서의 테스트란 애플리케이션이 요구 사항에 맞게 동작하는지 검증하는 행위이다.

💡 테스트 유형

정적 테스트 (Static Test)

  • 버그를 유발할 수 있는 코드를 미리 잡아줄 수 있다.
  • 타입 오류, 린트 오류 체크 등이 해당한다.
  • 예: TypeScript, ESLint

단위 테스트 (Unit Test)

  • 프로그램의 기본 단위가 되는 모듈을 테스트하는 것을 의미한다.
  • 다시 말해, 최소 단위의 util 함수, custom hook이나 하나의 컴포넌트 등 작은 단위를 전체 애플리케이션에서 떼어 내어 분리된 환경에서 테스트 한다.
  • 개발한 모듈이 의도한 대로 동작하는 지에 초점을 둔다.
  • 테스트 방식
    • 모듈 내의 데이터 흐름에 대한 예외 케이스를 작성하고 통과 여부를 확인한다.
    • 필요 시 Mock 객체를 사용해 테스트를 진행한다.
  • 장점
    • 분리된 상태의 테스트이므로 하나의 모듈, 클래스에 대한 세밀한 테스트가 가능하다.
    • 넓은 범위에서 테스트할 때보다 훨씬 빠르게 실행할 수 있다.
  • 단점
    • 의존성이 있는 모듈 제어를 위해 필연적으로 Mock(모의 객체)을 사용할 수밖에 없다.
      • 이 때 각 모듈이 실제로 잘 연결되어 상호작용하는지에 대해서는 검증할 수 없다.
    • 각 모듈의 사소한 API 변경에도 영향을 받으므로 작은 단위의 리팩터링에도 쉽게 문제가 생길 수 있다.

통합 테스트 (Integration Test)

  • 어플리케이션에서 여러 개의 요소를 함께 돌렸을 때의 동작을 테스트한다.
  • 보통 두 개 이상의 모듈이 실제로 연결된 상태를 테스트한다.
  • 단위 테스트가 끝난 모듈과 개발자가 변경할 수 없는 외부 라이브러리, DB 등의 모듈에 대해 함께 진행한다.
  • 모듈 간 상호작용이 정상적으로 수행되는 지에 초점
  • 장점
    • 여러 개의 모듈이 동시에 상호작용하는 것을 테스트하므로 단위 테스트에 비해 Mock의 사용이 적다.
    • 모듈 간의 연결에서 발생하는 에러를 검증할 수 있다.
    • 비교적 넓은 범위의 API 변경에만 영향을 받는다.
      • 단위 테스트에 비해 리팩토링 시 쉽게 깨지지 않는다.
  • 단점
    • 단일 모듈이 복잡한 알고리즘이나 분기문을 가질 경우 단위 테스트에 비해 테스트가 어렵다.
    • 테스트의 중복이 발생할 확률이 높다.

E2E 테스트 (End-To-End Test)

  • 사용자가 웹앱을 사용하는 것처럼 시뮬레이션한다.
  • 서버, 인프라, 웹앱을 모두 테스트한다.
  • 실제 사용자의 관점에서 테스트를 진행한다.
    • 내부 구조를 알고 있는 개발자의 관점에서 제품 일부분을 선택해 테스트하는 단위 테스트와 통합 테스트와는 차이가 있다.
  • 장점
    • 사용자의 실행 환경과 거의 동일한 환경에서 테스트를 진행한다.
      • 이 때문에 실제 상황에서 발생할 수 있는 에러를 사전에 발견할 수 있다.
    • 브라우저를 외부에서 직접 제어할 수 있어 JS의 API만으로는 제어할 수 없는 행위에 대한 테스트도 가능한다.
      • 예: 브라우저의 크기 변경, 실제 키보드 입력 등
    • 테스트 코드가 실제 코드 내부 구조에 영향을 받지 않는다.
      • 큰 범위의 리팩토링에도 깨지지 않으므로 자신감 있는 코드 개선이 가능해진다.
  • 단점
    • 단위 테스트나 통합 테스트에 비해 실행 속도가 느리다.
      • 이 때문에 개발 단계에서 빠른 피드백을 받기 어렵다.
    • 세부 모듈들이 갖는 다양한 상황들의 조합을 고려해야 해서 테스트 작성이 어렵다.
    • 큰 단위의 기능을 작은 기능으로 나누어 테스트할 수 없다.
      • 필연적으로 테스트 사이에 중복이 발생한다.
    • 통제된 샌드 박스 환경에서의 테스트가 아니다.
      • 테스트 실행 환경의 예상치 못한 문제들로 인해 테스트가 실패할 수 있다.
      • 예: 네트워크 오류, 프로세스 대기로 인한 timeout
      • 이 때문에 테스트를 완전히 신뢰하기 어려울 수 있다.

트레이드 오프 (Trade Off)

  • 현실적으로 위의 모든 테스트를 완벽하게 작성하고 관리하는 것은 매우 어렵다.
  • 정적 테스트에서 E2E 테스트로 올수록 테스트 비용이 증가한다.
    • 테스트를 진행하는 데 있어 실제 컴퓨팅 자원을 많이 사용한다.
    • 테스트 코드 작성 및 유지보수에 더 많은 노력을 요한다.
  • 정적 테스트에서 E2E 테스트로 올수록 테스트 속도가 느려진다.
    • E2E 테스트는 사용자의 실제 사용 흐름을 만들어야 하므로 느리다.
  • 정적 테스트에서 E2E 테스트로 올수록 신뢰도가 높아진다.
    • 단위 테스트를 많이 작성한다고 해도 합쳐졌을 때는 또 다른 결과를 낼 수 있다.
    • 테스트가 사용자의 사용 방식과 유사할수록 테스트 결과에 대한 신뢰도가 높아진다.
👩‍🏫 
이처럼 테스트 코드는 각 유형에 대해 트레이드 오프를 가지고 있으므로
팀의 상황에 따라 최적화된 테스트 전략을 세우기 위해 고민해야 합니다!

💡 좋은 테스트 코드, FIRST

  • FIRSTClean Code에서 제시한 좋은 테스트 코드 작성을 위한 원칙이다.

Fast

  • 테스트는 자주 실행시키기 부담스럽지 않을 정도로 빨라야 한다.

Independent

  • 각각의 테스트는 독립적이어야 한다.
  • 의존성이 생길 경우 다른 테스트에도 영향을 미칠 수 있게 되기 때문이다.

Repeatable

  • 어떤 환경에서도 반복할 수 있어야 한다.
  • 어떤 환경에서든 같은 결과값을 반환해야 신뢰성을 가질 수 있기 때문이다.

Self-Validating

  • 테스트의 결과 값은 boolean 타입이어야 하고, 이 결과로 자체 검증이 가능해야 한다.
  • 이를 통해 테스트 코드를 위한 불필요한 로그, 데이터 추적이 불필요해질 수 있다.

Timely

  • 테스트 코드는 적시(서비스 코드를 구현하기 전)에 작성되어야 한다.
  • TDD가 가능하려면 테스트 코드 작성이 선행되어야 하기 때문이다.

🧐 적절한 테스트 대상, Right-BICEP

  • Right-BICEP 란 무엇을 테스트해야 할지에 대한 가이드 용어이다.

Right

  • 올바른 결과를 보여주는지
  • 일어날 상황들에 대해서만 적절하게 테스트를 수행했는지를 의미한다.

Boundary-conditions

  • 아래의 경계 조건을 준수했는지를 의미한다.
경계 조건의미
Conformance값이 예상한 형식과 일치하는지
Ordering값의 집합이 적절하게 정렬되거나 정렬되지 않았는지
Range값이 예상된 범위 내에 있는지
Reference직접 제어할 수 없는 외부 항목을 참조하고 있진 않은지
Existance값이 존재하는지 (null check)
Cardinality값이 없거나/하나이거나/엄청 많을 때의 수행을 확인했는지
Time모든 작업이 적시에 시간 순으로 수행되는지

Inverse-relationships

  • 역 관계 검사가 가능한지
  • 테스트의 수행에 대해 역 방향으로도 검사할 수 있는지를 의미한다.
    • 예: 뺄셈 기능에 대해 반대로 더하면 다시 초기 값으로 돌아가는지

Cross-checking-using-other-means

  • 다른 수단을 통한 교차 검증이 가능한지
  • 라이브러리 또는 다른 테스트 코드를 사용해 해당 테스트 코드를 교차 검증할 수 있는지를 의미한다.

Error-conditions

  • 오류 조건을 강제 발생시킬 수 있는지
  • 테스트하려는 서비스 코드에서 발생하는 에러를 테스트 코드에서 강제로 발생시킬 수 있는지를 의미한다.

Performance-characteristics

  • 성능 조건이 기준에 부합하는지
  • 성능 측정에 대한 결과가 원하는 기준에 맞는지를 의미한다.

👓 프론트엔드 관점에서의 테스트 대상

시각적 요소

  • FE 개발자는 디자이너로 전달받은 디자인을 HTML로 마크업한 뒤 CSS로 해당 컴포넌트에 스타일을 더하는 작업을 수행한다.
  • 이때 디자인이 의도한 대로 잘 구현되었는지를 아래의 테스트를 통해 확인할 수 있다.
  • 스냅샷 테스트
    • HTML 구조가 의도한 대로 나타나는지를 테스트한다.
    • Jest를 통해 구현할 수 있다.
  • 시각적 회귀 테스트 (Visual Regression Test)
    • HTML에 CSS를 덧입혀 컴포넌트가 실제로 브라우저에서 렌더링되는 모습이 의도한 모습과 같은지를 테스트한다.
    • Storybook에서 만든 Chomatic을 통해 구현할 수 있다.

사용자 이벤트 처리

  • FE 개발자는 사용자의 입력 이벤트를 적절한 이벤트 핸들러로 처리한다.
  • JS API 또는 React 등에서 제공하는 테스트 유틸리티를 활용하여 이를 시뮬레이션 해볼 수 있다.
    • Node.js에서 테스트하는 경우 React에서 권장하는 테스트 도구인 react-testing-library 패키지를 활용해 보다 효율적인 사용자 이벤트 시뮬레이션이 가능하다.
  • 또는 E2E 테스트를 통해 실제 브라우저 상에서 이벤트를 발생시켜 테스트할 수도 있다.

API 통신

  • FE 개발자는 브라우저 API 또는 라이브러리를 활용해 API 서버와 통신하고 애플리케이션 상태를 동기화한다.
  • 아래의 방법을 통해 이렇게 구현한 API 서버와의 통신을 테스트할 수 있다.
    • 1) 실제 API 서버를 이용한다. ➡️ 주로 E2E 테스트에서 사용된다.
    • 2) 테스트 API 서버를 구축하거나 API 클라이언트를 mocking한다.
      • API 요청에 대한 응답을 원하는 대로 수정할 수 있어 다양한 상황에 대한 테스트가 가능하다.
      • Jest를 통해 모듈을 간단한 방법으로 mocking할 수 있다.
      • 이 때문에 React 팀에서는 Jest를 테스트 도구로 활용할 것을 권장하고 있다.

🪆 Test Double (테스트 대역)

  • 테스트하려는 객체와 연관된 객체를 사용하기 어려울 때 대신해서 사용해줄 수 있는 객체를 의미한다.

💫 테스트 대역이 필요한 이유

  • 테스트 대상에 의존성이 걸쳐 있어 테스트가 어려워지는 것을 막기 위해 사용한다.
  • 테스트 작성 시 외부 요인이 필요한 사례
    • 테스트 대상에서 파일의 시스템을 사용할 때
    • 테스트 대상이 DB로부터 데이터를 조회하거나 데이터를 추가할 때
    • 테스트 대상에서 외부의 HTTP 서버와 통신할 때
  • 이처럼 외부 요인이 포함될 경우 테스트 작성이 어려워지고 테스트 결과 예측도 힘들어진다.
    • 예를 들어 연결된 API Key가 시간이 지나 만료된다면 테스트가 실패할 수 있다.
    • 또한 API 서버에 일시적인 장애가 생기는 경우 테스트가 힘들어진다.
👩‍🏫 이렇게 의존하는 요소로 인해 테스트가 어려워질 때 대역을 써서 테스트를 할 수 있습니다!
  • 이처럼 테스트 대역을 사용하면 아래와 같은 상황을 피할 수 있다.
    • 로그인 테스트 시 인증 메일을 직접 메일함에서 확인한다.
    • API의 에러 상황 테스트를 위해 외부 API가 잘못된 응답을 할 때까지 기다린다.
    • 테스트를 위해 필요한 연결된 작업이 완료될 떄까지 기다렸다가 함께 테스트한다.
  • 이는 대기 시간을 줄여주고, 개발 속도를 향상하는 데 도움이 된다.

💫 대역의 종류

Dummy

  • 가장 기본적인 테스트 대역으로, 구현체만 필요하고 기능은 필요 없는 경우 사용한다.
  • 일반적으로 매개 변수의 목록을 채우기 위해 사용한다.

Fake

  • 동작은 하지만 실제 사용되는 객체처럼 정교하게 동작하지는 않는 객체를 의미한다.
  • 예: 테스트 시 실제 DB를 연결해 테스트 하는 대신 Fake DB를 만들어 객체에 주입하여 실제 DB에 의존하지 않을 수 있게 하는 것
  • 일반적인 production에는 적합하지 않다.

Stub

  • 상태 검증 (State Verification) 사용
    • 메소드가 수행된 후 객체의 상태를 확인해 올바르게 동작했는지를 확인하는 검증법
  • 테스트 중 만들어진 호출에 대해 미리 준비된 답변을 제공한다.
  • Dummy 객체가 실제 동작하는 것처럼 보이게 만들어 놓은 객체이다.
  • 보통 테스트를 위해 프로그래밍된 것 이외에는 전혀 응답하지 않는다.
  • 실제 반환값이 변경될 경우 Stub 객체도 함께 수정해야 한다는 단점이 있다.
  • life cycle
    테스트 준비(setup) 
    ➡️ 테스트(exercise) 
    ➡️ 상태 검증(verify state) 
    ➡️ 리소스 정리(teardown)

Spy

  • 어떻게 호출 받았는지에 따라 일부 정보를 기록하는 stub이다.
  • 테스트 대역으로 구현된 객체에 자기 자신이 호출되었을 때의 방법이나 과정처럼 추후 확인이 필요한 부분을 기록하도록 구현된 것이다.
  • 함수가 실행될 때마다 그 응답값, argument 등을 기록한다.
  • 특정 액션을 통해 원하는 함수가 실행되는지를 검사할 수 있다.
  • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에서는 stub으로 만들어 동작을 지정하는 것도 가능하다.

Mock

  • 행위 검증 (Behavior Verification) 사용
    • 메소드의 반환 값으로 판단할 수 없는 경우 특정 동작을 수행하는지를 확인하는 검증법
  • 예상되는 기대값으로 미리 프로그래밍한 객체이다.
  • 호출 시 사전에 정의된 대로 결과를 반환하도록 미리 설정되어 있다.
  • 테스트 작성을 위한 환경 구축이 어려울 때 테스트하고자 하는 코드와 엮인 객체들을 대체하여 사용할 수 있다.
  • 예상치 못한 호출이 있을 경우 예외 값을 반환한다.
    • 이를 통해 예상된 호출이었는지를 판단할 수 있다.
  • life cycle
    데이터 준비(setup data) 
    ➡️ 예상되는 결과 준비(setup expectations) 
    ➡️ 테스트(exercise) 
    ➡️ 예상 검증(verify expectations) 
    ➡️ 상태 검증(verify state) 
    ➡️ 리소스 정리(teardown)

💻 TDD

🎞️ TDD의 의미와 도입 배경

  • TDD (Test Driven Development)
    • 테스트 주도 개발
    • 테스트를 먼저 만든 뒤 테스트를 통과하기 위한 코드를 짜는 개발 방식
    • 개발 과정에서 테스트를 먼저 작성하고 이를 통과하는 코드를 구현하는 방식으로 동작 여부에 대한 피드백을 적극적으로 받는 것이다.
  • TDD란 결정과 피드백 사이의 GAP에 대한 인식이자 GAP을 조절하기 위한 기술이다.
    • by 테스트 주도 개발의 저자 Kent Beck
    • 결정
      • 프로그램 구현 시 어떠한 방식으로, 무엇을 이용해서 코드를 작성할지에 대한 것들을 정하는 것
    • 피드백
      • 코드의 성공과 실패(error!)
      • 프로그램을 실행한 결과
    • 이 둘 사이의 GAP을 인식하고 있다면 TDD를 하고 있는 것이다.

TDD의 도입 배경

  • 보통 테스트란 개발과 별도의 영역으로 분류되어 QA(Quality Assuarance) 과정에서 진행된다.
  • 하지만 2000년대 이후 애자일 방법론과 익스트림 프로그래밍에서 강조하는 TDD의 대중화로 테스트가 점점 개발 단계 중 하나로 받아들여지고 있는 추세이다.
    • 여기서의 테스트란 코드로 작성된 자동화 테스트를 의미한다.

🔄️ TDD 개발 주기

이미지 출처

  • TDD는 짧은 개발 주기의 반복에 의존하는 개발 프로세스이다.
  • Extreme Programming의 'Test-First' 개념에 기반을 둔 단순한 설계를 중시한다.

If we pull back to the minute by minute scale we see the micro-cycle that experienced TDDers follow. The Red/Green/Refactor cycle. - Robert C. Martin(Uncle Bob)

1. RED

  • 실패하는 테스트 코드를 작성한다.

2. GREEN

  • 테스트 코드를 성공시키기 위한 실제 코드를 작성한다.
  • 이 과정에서는 테스트를 통과하기 위해 단순 복사하기+붙여넣기 또는 설계를 무시한 개발 등을 해도 된다.

3. REFACTOR

  • 위의 과정에서 발생한 중복 코드 제거, 올바르지 못한 구조 해결, 일반화 등의 리팩토링을 수행한다.

⚠️ 중요한 것

Write a test, make it run, make it right. To make it run, one is allowed to violate principles of good design. Making it right means to refactor it. - KentBeck

  • 먼저 실패하는 테스트 코드를 작성한다.
  • 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 테스트 코드를 작성한다.
  • 현재 실패하는 테스트 코드를 통과한 코드만 실제 코드에 작성한다.
👩‍🏫 우리는 이를 통해 실제 코드에 대해 기대하는 바를 명확하게 정의하여 
불필요한 설계 대신 정확한 요구 사항에 집중할 수 있습니다.

🙄 일반 개발 과정과의 비교

  • 아래와 같은 형태의 개발 주기를 갖는다.
  1️⃣ 요구 사항 분석
  2️⃣ 설계
  3️⃣ 개발
  4️⃣ 테스트
  5️⃣ 배포
  • 프로젝트의 초기 설계가 무조건 완벽할 수는 없다.
    • 이 때문에 요구 사항, 오류 등 다양한 조건에 의한 재설계를 거쳐 프로그램은 완벽한 설계로 개선되는 것이다.
    • 그리고 이러한 과정에서 불필요한 코드가 남거나 중복 처리를 하게 될 수 있다.
👩‍🏫 결론적으로 이러한 코드들은 재사용이 어렵고, 유지보수하기도 힘듭니다.
  • 작은 기능을 수정하더라도 모든 부분을 테스트해야 하므로 전체적인 버그 검출이 어렵다.
    • 따라서 자체 버그 검출 능력이 저하된다.
    • 이는 곧 잘못된 코드까지 고치지 못하여 소스코드의 품질이 저하되는 결과를 야기할 수 있다.
    • 또한 작은 수정에도 모든 기능을 다시 테스트해야 하므로 자체 테스트 비용도 증가한다.
  • TDD는 테스트 코드를 작성한 뒤 실제 코드를 작성한다.
    • 따라서 설계 단계에서 프로그래밍의 목적을 미리 정의해야 한다.
    • 또한 무엇을 테스트해야 하는지도 미리 고민해 결정해야 한다. (테스트 케이스 작성)
  • 이렇게 테스트 코드를 작성하는 과정에서 발생하는 버그 등의 예외 사항은 테스트 케이스에 추가한 뒤 설계를 개선한다.
  • 이후 테스트가 통과된 코드만을 코드 개발 단계에서 실제 코드로 사용한다.
👩‍🏫 이러한 단계가 반복적으로 진행되며 코드의 버그는 자연스럽게 줄고, 소스코드는 간결해집니다.
또한 테스트 케이스 작성을 통한 설계 개선으로 재설계 시간이 단축됩니다.

🟢 TDD의 장점

객체 지향적인 코드 구현

  • TDD로 개발 시 기능 별로 모듈화가 철저하게 이루어진다.
    • TDD는 코드의 재사용을 보장하기 때문이다.
  • 이를 통해 종속성과 의존성이 낮은 모듈로 조합된 개발이 가능해진다.
    • 필요에 따라 추후 모듈을 추가하거나 제거하더라도 소프트웨어 전체 구조에 영향을 미치지 않는다.
  • 테스트 친화적인 코드를 작성하다보면 결국 좋은 코드를 설계할 수 있다.
    • 테스트하기 좋은 코드인 계산 형태의 코드를 작성하려고 하다 보면 값의 불변성이 보장되는 코드를 작성할 수 있다.
    • 간략한 테스트 코드를 위해서는 구현하고자 하는 코드가 단순해져야 하므로 단일 책임 원칙(SRP)이 지켜진 코드를 작성할 수 있다.
    • 테스트할 기능들이 서로에게 의존하지 않도록 하다보면 각각의 기능을 독립적으로 작성할 수 있다.

재설계 시간 단축

  • 테스트 코드를 먼저 작성하므로 무엇을 해야 하는지 분명히 정의한 후 개발에 착수할 수 있다.
  • 테스트 시나리오를 작성하며 예외 사항에 대해 고민해볼 수 있다.
    • 이를 통해 개발 중 소프트웨어의 전반적인 설계가 변경되지 않도록 예방할 수 있다.

디버깅 시간 단축

  • TDD의 경우 자동화된 유닛 테스트를 전제하므로 특정한 버그를 쉽게 찾아낼 수 있다.
    • TDD가 아니라면 사용자의 데이터가 잘못 되었을 때 DB, 비즈니스 레이어, UI 중 문제가 있는 부분을 찾기 위해 모든 레이어에 대한 디버깅을 진행해야 한다.
  • 이는 특히 유닛 테스트의 이점에 해당한다.

추가 구현 시 용이함

  • TDD의 경우 자동화된 유닛 테스트를 전제하므로 테스트 시간을 획기적으로 단축할 수 있다.
    • 해당 기능이 기존 코드에 어떤 영향을 미칠 지 불안해하지 않아도 된다.

더 좋은 문서화 가능

  • 잘 짜인 테스트 코드는 그 자체로 문서가 된다.
  • 시스템이 어떻게 동작해야 하는지에 대한 문서를 자동으로, 최신 상태로 작성할 수 있다.

팀 내 커뮤니케이션 촉진

  • 팀원들이 작성 중인 코드를 같은 시야에서 바라볼 수 있게 도와준다.
  • 잘못된 의사소통으로 인한 오해나 오류를 예방할 수 있다.

🔴 TDD의 단점

생산성 저하

  • 비즈니스 로직이 라이브러리에 의존할 경우 테스팅 방법이 일관적이지 않을 수 있다.
  • 테스트 코드에 익숙하지 않으면 기능 구현보다도 시간을 오래 소요하게 될 수 있다.

러닝 커브

  • 테스트 코드를 작성하고 TDD 개발 주기를 적용함에 있어 적응 기간이 필요하다.

🫠 TDD를 적절히 활용하기

  • TDD와 관련된 다양한 기술 활용하기
    • 적절한 Mock 프레임워크를 통해 자동화가 가능한 부분을 분리하여 활용해야 한다.
  • TDD 개발 주기 속 'Refactor' 단계에서 다양한 리팩토링 기법 적용하기
    • TDD는 이처럼 개별적이고 구체적인 예시로부터 리팩토링을 통해 코드를 일반화하며 완성된다고 볼 수 있다.
  • 다른 개발 프로세스의 활용 고려하기
    • BDD(Behavior Driven Development)
      • 행위 주도 개발
      • 개발자 관점에서의 기능의 동작에 중점을 둔다.
      • 아직 존재하지 않는 코드에 대해 테스트를 작성하는 대신 비기술적 언어를 사용해 행위에 대한 명세를 작성한다.
      • 구체적인 테스트 코드 작성 전 구조화되고 특화된 공통 언어를 통해 요구 사항을 시나리오 형태로 정의해 관리하는 개발 프로세스
    • ATDD(Acceptance Test Driven Development)
      • 인수 테스트 주도 개발
      • 인수 테스트: 사용자 입장에서 요구 사항을 충족하는지 검증하는 테스트
      • 사용자 시나리오 관점에서 요구 사항을 정확히 캡쳐하는지에 중점을 둔다.
      • 코드 구현 전 사용자, 테스터, 개발자가 Acceptance Criteria를 정의하고 이를 기반으로 모든 프로젝트 구성원이 수행할 작업과 요구 사항을 이해한다.
  • 테스트의 기회비용 고려하기
    • TDD의 창시자인 Kent Back도 테스트에 대한 StackOverflow의 질문에 대해 위와 같이 대답하였다.
    • 테스트 코드를 작성하고 유지 보수하는 데에는 비용이 들기 때문에 투입된 비용에 비해 얻을 수 있는 효과가 적다면 수동으로 테스트하는 것이 낫다.
    • 테스트 coverage를 억지로 100%로 맞추려 한다거나, 중요한 로직이 없는 단순한 코드까지 테스트하려고 하고 있진 않은지 고민해보아야 한다.
    • 또한 이미 작성되어 있는 코드 중에서도 불필요한 부분은 제거해주는 것이 유지 보수 비용 측면에서도 더 좋다.

🗂️ 테스트에 사용되는 용어들

Instrumentation

  • 테스트 코드가 커버하는 범위를 계산할 때 전체 코드베이스에서 몇 줄이, 각 라인에서 몇 번 실행되는지 측정하기 위해 모든 파일을 감싸는 과정을 말한다.
  • 보통 istanbul이라는 라이브러리를 사용해 babel 설정에서 수행한다.
    • ⚠️ production 환경에서 절대 사용하지 않도록 주의하자!

Snapshot Test

  • 특정 컴포넌트가 렌더링 시 어떤 아웃풋이 나오는지 파일로 기록한다.
  • 코드 수정 후 렌더링 되는 결과물이 변경될 경우 테스트는 실패한다.
    • 로직만 수정했다고 생각했지만 DOM까지 건드리는 상황을 예방할 수 있다.

💡 JS 테스트 도구와 그 기능들

  • 대표적인 테스팅 라이브러리는 아래와 같다.
  • 위의 테스팅 라이브러리들은 테스트를 위한 다양한 기능을 통합하여 제공하고 있다.
    • 이 중 무엇을 사용할지 선택하기 위해서는 제공되는 기능들이 무엇인지 아는 것이 필요하다.

테스트 러너

  • 이전에는 JS를 브라우저에 한정하여 실행할 수 있었다.
    • 이때는 작성된 테스트 코드를 직접 브라우저에서 실행한 뒤 웹페이지나 브라우저 콘솔을 통해서 결과를 확인해야 했다.
  • 하지만 Node.js의 등장으로 브라우저 없이도 JS 코드를 손쉽게 실행할 수 있게 되었다.
    • 이 덕분에 테스트 러너 도구를 통해 이러한 과정을 자동화할 수 있다.
  • 제공하는 기능
    • 테스트 파일을 읽어들여 작성한 코드를 실행하고, 실행 결과를 특정 형식으로 출력한다.
    • Reporter을 지정해 수행 결과를 원하는 형태로 출력할 수 있다.
    • Watcher를 통해 테스트 코드나 소스 코드 변경 시 영향을 받는 테스트를 자동 재실행해줄 수도 있다 (부가적).
  • 지원하는 라이브러리
    • 브라우저 기반: Karma
    • Node.js 기반: Jest
      • 러너의 실행 환경과 코드의 실행 환경을 구분할 필요가 없으므로 대부분 테스트 프레임워크와 통합된 형태로 제공된다.

테스트 프레임워크

  • 사용자가 테스트 코드를 작성할 수 있는 기반을 제공해준다.
  • 프레임워크가 제공하는 함수를 사용해 테스트코드를 작성하면 프레임워크가 테스트코드를 자동으로 실행한 뒤 성공/실패에 대한 결과를 반환한다.
  • 지원하는 라이브러리: Mocha, Jasmin, AVA, Jest

단언 라이브러리

  • 테스트 코드는 주로 '테스트를 위한 초기화'와 '단언'으로 이루어진다.
  • 단언(Assertion)
    • 원하는 결과를 받고 있는지 확인하기 위한 기능이다.
    • 개별 테스트가 통과하기 위한 조건을 명확하게 기술하기 위해 사용된다.
  • 보통 테스트 프레임워크에서 다양한 방식의 단언 API를 기본 제공한다. (Mocha 제외)
  • 초기의 단언 라이브러리들은 JUnit과 유사한 방식의 API를 많이 따랐다.
    • JUnit: Java에서 독립된 단위테스트를 지원해주는 프레임워크
  • 최근 가장 많이 사용되는 Chai, Jasmine 등에서는 조금 더 자연어에 가까운 BDD 방식의 API가 사용된다.
  • 대부분의 단언 라이브러리들은 사용자들이 필요에 따라 스스로 단언을 추가하여 사용할 수 있는 플러그인 확장 기능을 제공한다.

테스트 대역(더블) 라이브러리

  • 위에서 설명한 테스트 대역을 더 쉽게 만들 수 있도록 도와주는 라이브러리를 의미한다.
  • 대부분 단언과 마찬가지로 테스트 더블을 위한 함수들도 테스트 프레임워크에서 기본 제공된다. (Mocha 제외)
  • 일반적으로 JS 객체 혹은 함수를 직접 변경하거나 생성하는 형태로 사용된다.
    • Jest에서는 모듈 단위로 사용할 수 있는 기능도 제공한다.
    • Axios-mock-adapter와 같이 별도로 구현된 Mock 라이브러리도 존재하므로 관련된 라이브러리를 먼저 찾아보는 것도 좋다.

💡 컴포넌트 렌더링 라이브러리

  • 제공하는 기능
    • 컴포넌트를 렌더링한다.
    • 렌더링된 컴포넌트에서 원하는 element를 찾을 수 있는 selector 기능을 제공한다.
    • element에서 onClick과 같은 다양한 이벤트를 호출할 수 있게 해준다.
  • 사용 가능한 라이브러리: @Testing Library, enzyme, react-test-renderer, @vue/test-utils
  • Testing Library의 경우 React 외의 다른 프레임워크도 지원한다.

📝 테스트 실행 환경

  • JS의 테스트는 브라우저와 Node.js 환경에서 모두 실행할 수 있다.
  • 두 환경 모두 뚜렷한 장단점을 가지므로 상황에 맞게 적절한 테스트 러너를 선택해야 한다.

✨ 브라우저

  • 실제 브라우저를 실행해서 테스트 코드를 실행하는 방식
  • 현재는 Karma를 사용하는 것이 유일한 방법이다. (E2E 테스트 도구 제외)
    • Karma는 테스트 러너 역할만 지원하므로 Jasmine과 같은 별도의 테스트 프레임워크가 필요하다.
  • 구동 방식
0️⃣ command line에서 Karma를 실행한다.
1️⃣ Karma는 자체 웹서버를 구동한 후 테스트 실행을 위한 HTML 페이지를 만든다.
2️⃣ 작성된 테스트 코드 및 소스 코드 전부를 만들어진 페이지에 로드한다.
3️⃣ 브라우저를 직접 실행해서 해당 웹 페이지에 접속하면 로드된 코드가 실행된다.
4️⃣ 테스트의 실행 결과를 브라우저 콘솔에 출력한다.
5️⃣ Karma는 이 정보를 받아와 지정된 리포터를 통해 결과를 정리한 뒤 command line에 보여준다.

장점

  • 실제 브라우저 환경에서 테스트하므로 브라우저의 모든 기능을 활용해 테스트할 수 있다.
  • Selenium 등을 사용해 동일한 테스트 코드를 다양한 환경(운영체제, 브라우저)에서 실행하여 브라우저 호환성 및 기기 환경에 대한 테스트를 진행할 수 있다.
    • Selenium: 웹 애플리케이션 자동화 및 테스트를 위한 포터블 프레임워크

단점

  • 프로세스가 Node.js의 프로세스보다 무거워 테스트의 초기 구동 속도가 더 느리다.

  • 브라우저 런처 등을 추가로 설치해주어야 해서 번거롭다.

    • 브라우저라는 별도의 애플리케이션을 추가로 실행해야 하기 때문이다.
  • 크로스 브라우징 테스트 등을 위해 별도의 환경을 구축하고 유지보수하기 위한 코스트가 소요된다.

  • 이를 극복하기 위해서는 개발 단계에서 헤드리스 브라우저를 통한 빠른 피드백이 가능하도록 하고, 개발 완료 혹은 배포 시에만 CI 서버와 통합해 크로스 브라우징 테스트를 하는 방식이 권장된다.

    • CI(Continuous Integration)
      • 변경 내용을 뒤늦게 통합하는 데서 비롯되는 문제들을 방지하도록 설계된 DevOps 방식
      • 모든 사람의 코드 변경 내용을 자동으로 커밋, 빌드, 테스트할 수 있다.
      • 프로젝트 전체에 걸쳐 통합을 자주 해줌으로써 충돌을 최소화하고, 모든 사람의 변경 내용이 상호작용하는 방식을 확인하며 다른 기능들이 버그에 의존하기 전에 문제 해결이 가능해진다.
  • Browser Stack 등의 외부 서비스를 사용해 크로스 브라우징을 위한 환경 구축 없이도 Karma와 연동해 사용하는 것도 가능하다.

✨ node.js

  • Node.js 환경에서 테스트 코드를 실행하는 방식
  • 최근에는 Jest와 Mocha가 가장 많이 사용된다.

장점

  • 테스트 러너와 테스트 프레임워크가 통합되어 있어 설치 및 실행이 비교적 간단하다.
  • Node.js의 프로세스가 브라우저의 프로세스에 비해 훨씬 가벼우므로 실행 속도가 빠르다.
  • 모듈 단위의 테스트 실행이 어려워 webpack 등의 번들러가 필요한 브라우저와 달리 개별 프로세스에서 원하는 모듈만 import해서 테스트할 수 있다.
    • 훨씬 간단하고 안전한 방식으로 테스트할 수 있다.

단점

  • 브라우저의 모든 API를 제대로 활용할 수 없다.
    • Node.js에는 브라우저에서 제공하는 DOM이나 BOM 등의 API가 없다.
    • 렌더링 엔진이 없어 UI 요소의 레이아웃에 대한 테스트가 불가능하다.
    • 네비게이션 관련 동작을 사용할 수 없다.
  • jsdom 등의 라이브러리를 통해 브라우저 환경을 가상으로 구현할 수 있지만 실제 브라우저의 동작을 완전히 구현하지는 못한다.
  • 크로스 브라우징 관련 테스트가 불가능하다.

🧐 어떤 환경을 선택해야 할까?

  • 브라우저 환경 사용이 꼭 필요한 경우

    • 크로스 브라우징 테스트가 반드시 필요한 경우
    • 브라우저의 실제 동작에 대한 테스트가 필요한 경우 (렌더링, 네트워크 IO, 네비게이션, ...)
  • 최근 들어 크로스 브라우징 테스트의 필요성이 많이 감소했다.

    • 최신 브라우저들 간 표준 명세 구현에 대한 차이가 거의 없어졌다.
    • 최신 JS 문법에 대한 호환성 지원은 트랜스파일러 도구(babel 등)가 대신해준다.
    • DOM을 직접 조작하는 것도 프레임워크(React, Vue, ...)가 대신 해주는 경우가 많다.
      ➡️ 프로젝트가 지원해야하는 브라우저 범위, 사용하는 도구 등을 고려하여 필요성을 검토해볼 필요가 있다.
  • TDD 관련 포스팅에서는 Jest를 사용하므로 Node.js 기반 환경에서의 테스팅을 진행할 예정이다.

🤖 TDD in React

🤠 CRA에는 TDD를 위한 기본 내장 기능이 존재합니다!

CRA를 통해 React 프로젝트를 구상하면 Jest와 React Testing Library가 기본 내장되어 있으므로, 별도의 설치가 필요하지 않다.

🤔 둘 다 써야 하나요?
👩‍🏫 그렇습니다!

👢 Jest

  • 페이스북에서 만든 오픈소스 테스트 프레임워크로 개발자가 JS와 TS 코드에 대한 테스트를 실행할 수 있게 해준다.
  • 최근 안정성 및 성능이 눈에 띄게 좋아지며 FE 개발에서 가장 활발하게 사용되는 테스트 도구이다.
  • 테스트 케이스 등을 작성할 수 있는 기능을 제공하며 테스트를 찾아서 실행하고 테스트가 통과하는지 검사한다.

Jest의 파일 이름 규칙
1. test 폴더에 .js 접미사가 있는 파일
2. .test.js 접미사가 있는 파일
3. .spec.js 접미사가 있는 파일

  • Jest는 위의 파일들을 src 폴더 하위의 모든 depth에서 찾을 수 있다.
  • CRA 공식 페이지에서는 .test 파일을 테스트 중인 코드 옆에 배치할 것을 추천한다.
    • 같은 수준(동일한 경로)에 파일이 위치해 가져올 때 코드가 짧기 때문이다.

Jest의 장점

1. 설치와 실행이 쉽다.

  • 테스트 러너의 기능 뿐 아니라 단언, 테스트 더블, 코드 커버리지 등 테스트에 필요한 모든 기능을 지원하므로 별도의 추가 설치가 필요하지 않다.

2. jsdom을 내장하고 있다.

  • 이를 통해 테스트를 실행할 때마다 필요한 환경들을 자동으로 설정해 제공한다.
  • 별다른 추가 작업 없이 브라우저 환경인 것처럼 테스트 작성이 가능하다.

3. 스냅샷 테스트를 지원한다.

4. 테스트 파일 필터링 기능을 지원한다.

  • 테스트 대상 파일을 구체적으로 지정할 수 있다.
  • Jest는 버전 관리 도구(Git 등)와 연동해 마지막 커밋 이후 변경 사항이 있는 파일만 테스트 대상에 포함한다.
  • 이미 검증된 파일에 대한 불필요한 테스트를 막을 수 있다.
  • 한 번 실행한 이후 추가 명령이 불가능한 보통의 러너들과 달리 Jest는 인터렉티브한 command line 인터페이스를 제공(이미지 참조)한다.
  • 이를 통해 실행 이후에도 키 입력을 통해 테스트 대상 파일을 변경할 수 있다.
    • a를 통해 전체 파일 테스트, q를 통해 현재 진행 중인 테스트 취소, p를 통해 특정 파일명으로 필터링 등의 명령이 가능하다.
  • 또한 스냅샷 테스트 실패 시 결과 확인 후 바로 스냅샷을 갱신하도록 하는 것도 가능하다.

5. 샌드박스 병렬 테스트가 가능하다.

  • Node.js 환경에서의 테스트는 빠르다.
    • Node.js 환경에서 테스트를 하는 것의 가장 큰 메리트는 브라우저의 프로세스보다 가벼운 프로세스 덕분에 초기 구동 속도가 빠르다는 것이다.
  • Jest는 이러한 장점을 이용해 각각의 테스트 파일을 독립된 프로세스에서 실행한다.
    • 각각의 테스트가 사용하는 전역 객체나 모듈의 상태가 서로에게 영향을 미치지 않아 마치 샌드박스 내부에 있는 것처럼 안전한 테스트가 가능하다.
    • 샌드박스
      • 아이를 모래밭 밖에서 놀게 하지 않는다는 말에서 유래했다.
      • 애플리케이션 실행을 위해 엄격히 제어된 환경을 말한다.
      • 외부 요인에 의해 문제가 발생하지 않도록 다른 파일이나 프로세스로부터 격리된 보호된 영역 내에서 프로그램을 동작시킨다.
🤔 
테스트를 순차적으로 실행하면서 개별 테스트마다 별도의 자식 프로세스를 생성하게 되면
단일 프로세스에서 실행하는 것보다 오히려 느려지진 않나요?
  • Jest는 다수의 프로세스를 병렬로 실행하는 방식을 사용하여 속도를 향상시킨다.
  • 또한 내부적으로 CPU 코어 수 등을 고려해 동시에 실행되는 프로세스의 개수를 적절하게 조절한다.
  • 그리고 앞의 테스트 파일 필터링 기능을 통해 불필요한 테스트 실행을 방지할 수 있다.
👩‍🏫 
Jest는 이러한 방식을 사용해 테스트 실행을 최적화된 속도로 유지하면서도 
훨씬 더 안전한 테스트 환경을 제공합니다!

🐙 React Testing Library

Testing Library

  • UI를 사용자 관점에서 테스트할 수 있도록 도와주는 라이브러리이다.
  • 2018년에 처음 등장했으나 JS 생태계 설문 조사 행사인 State of JS에서 여러 테스트 라이브러리를 제치고 1위를 차지할 정도로 FE 테스트 환경에 큰 역할을 하고 있다.

이미지 출처

  • 기존의 FE 테스트는 컴포넌트의 내부 상태 변경과 같은 상세한 구현을 테스트한다는 문제가 있었다.
    • 이렇게 테스트할 경우 리팩토링 시 기능이 아닌 구현을 수정할 때에도 테스트를 깨지게 만들었다.
    • 이는 곧 개발 속도와 생산성 저하로 이어질 수 있다.
  • Testing Library는 사용자가 소프트웨어를 사용하는 것과 비슷하게 개발자가 테스트할 수 있게 함으로써 자신감을 주는 것을 슬로건으로 내세우고 있다.
  • 사용자가 컴포넌트 내부의 상태가 어떻게 바뀌고 내부에서 메서드가 어떻게 호출되는지 전혀 모르는 것처럼 테스트 코드 작성 시에도 이러한 부분은 테스트하지 않고, 사용자가 관심을 가지는 부분에 대해서만 테스트를 진행한다는 의미이다.
  • 이를 통해 개발자는 더 자신감을 가지고 리팩토링을 진행할 수 있다.

React Testing Library

  • React Component를 테스트하기 위해 특별히 제작된 JS 테스트 유틸리티이다.
  • 각각의 구성요소에 대한 사용자 상호작용을 테스트하고, UI의 올바른 작동을 확인할 수 있다.
    • 예를 들어, 버튼 클릭 이벤트 발생 시 div 존재 여부 등을 테스트하기 위한 가상 DOM을 제공한다.

🏃‍♀️ React에서 첫 테스트 해보기

  • 물론 테스트 코드를 너무 간단한 기능에 적용하는 것은 지양하는 것이 좋아 보이지만, 테스트를 처음 배우는 것이어서 최대한 간단한 기능을 구현해보려 하였다.
  • 양쪽에 +, - 버튼이 있어 화면에 보이는 숫자를 조정하고, on/off 버튼으로 기능의 활성화 여부를 설정할 수 있다.
  • 전체 코드를 넣기에는 너무 길어질 것 같아 on/off 버튼에 관련된 부분에 대해서만 작성해보고자 한다.

애플리케이션 생성하기

  • CRA로 프로젝트를 생성해주면 자동으로 Jest와 RTL을 사용할 수 있다.
npx create-react-app 프로젝트명

Prettier와 ESLint 적용하기

  • Prettier와 ESLint를 테스트 코드에 적용함으로써 코드 품질을 향상시키고 오류를 사전에 방지할 수 있다.
// .eslintrc.json 에 아래의 내용을 설정해주어야 한다.
{
	"plugins": ["testing-library", "jest-dom"],
	"extends": [
		"react-app",
		"plugin:testing-library/react",
		"plugin:jest-dom/recommended"
	]
}

테스트할 컴포넌트 만들기

  • on/off 관련 비즈니스 로직은 useButton이라는 커스텀 훅으로 분리해주었다.
import { useState } from 'react'

const useButton = () => {
	const [disabled, setDisabled] = useState(false)
	const handleActiveMode = () => setDisabled(prev => !prev)
    // ...
	return {
		disabled,
		handleActiveMode,
      // ...
	}
}

export default useButton
  • 실제 Button 컴포넌트에는 뷰 관련 코드만 남아있게 하였다.
import useButton from './_hooks/useButton'

const Button = () => {
	const { disabled, handleActiveMode } =
		useButton()

	return (
		<div>
			...
			<div>
				<button
					data-testid="onoffBtn"
					style={{ backgroundColor: 'black', color: 'white' }}
					onClick={handleActiveMode}
				>
					{cnt}
				</button>
			</div>
		</div>
	)
}

export default Button

테스트 코드 작성하기

  • 버튼 컴포넌트가 구현된 파일과 같은 depth에 Button.test.js 를 작성하였다.
// onoff 버튼의 스타일 테스트
test('onoff button', () => {
  // 버튼 컴포넌트를 렌더링한다.
	render(<Button />)
  // 화면에서 data-testid 사용하여 해당 값을 갖는 요소를 찾는다.
	const onoffBtnElement = screen.getByTestId('onoffBtn')
  // 해당 요소가 원하는 스타일을 가지는지 확인한다.
	expect(onoffBtnElement).toHaveStyle({
		backgroundColor: 'black',
		color: 'white',
	})
})
  • Jest 공식 문서에서는 user-event를 사용할 것을 권장하고 있다.
  • 하지만 fireEvent와 어떤 차이가 있는지 알아두면 좋을 것 같아 간단한 코드에는 fireEvent를 적용해보고, 뒤의 본격적인 기능 구현 시에는 user-event를 적용해본 뒤 둘을 비교해보고자 한다.
// onoff 버튼의 클릭 이벤트 테스트
test('handle whether the button is disabled with the onoff button click', () => {
	render(<Button />)
	const onoffBtnElement = screen.getByTestId('onoffBtn')
  // fireEvent를 사용해 버튼 클릭 이벤트를 테스트한다.
	fireEvent.click(onoffBtnElement)
	const plusBtnElement = screen.getByTestId('plusBtn')
	const minusBtnElement = screen.getByTestId('minusBtn')

    // 해당 값이 비활성화 상태인지 확인한다.
	expect(plusBtnElement).toBeDisabled()
	expect(minusBtnElement).toBeDisabled()
})

실행하기

  • 테스트를 실행한다.
npm run test
  • a를 입력하여 전체 파일을 테스트한다.
  • 아래와 같은 결과를 받을 수 있다.


여러 TDD 관련 아티클을 읽으면서 모든 경우의 수를 떠올려 통과할 수 있도록 테스트 코드를 작성하는 것보다는 적절한 수준에서 프로젝트에 꼭 필요한 기능이 무엇인지 파악하고, 테스트에 적합한 코드를 작성하여 결함이 없도록 구현하는 것이 더 중요할 것 같다는 생각이 들었다. 모든 요소를 100% 만족시키는 테스트를 작성하는 것이란 불가능한 것 같고, 프로젝트의 특성과 상황을 고려하고 여러 환경에서의 테스트를 실행해보면서 스스로 테스트 관련 전략을 세울 수 있게 되도록 연습해봐야겠다!

다음 포스팅부터는 직접 프로젝트에 테스트 코드를 작성해보면서
각 테스트 유형에 대해 조금 더 세부적으로 공부해볼 예정이다!

🔎 References

profile
An investment in knowledge pays the best interest🙃

0개의 댓글