일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- context switching
- 프로그래머스
- node.js
- 자바
- REACT
- python algorithm
- 파이썬
- 파이썬 알고리즘 인터뷰
- JavaScript
- java
- 알고리즘
- Computer Science
- algorithm
- Operating System
- 자바스크립트
- 자료구조
- 운영체제
- 글또
- 컴퓨터공학
- Python
- execution context
- typeScript
- OS
- useState
- 비동기
- react 기초
- Zerobase
- codestates
- 코드스테이츠
- 개발공부
- Today
- Total
Back to the Basics
[TEST]테스트 원칙에 대하여 - 소프트웨어 테스트의 7원칙과 FIRST원칙 본문
이전 회사에서도 테스트 코드 작성에 대한 명확한 지침이 없었다. 그래서 주로 기존에 작성된 테스트 코드들을 참고하며 비슷한 방식으로 작성했다.
테스트 코드를 작성하면서 종종 '이 테스트가 과연 의미가 있을까?' 하는 의구심이 들기도 했다. 특히 새로운 기능을 추가하거나 리팩토링을 할 때마다 테스트 코드를 상당 부분 수정해야 하는 경우도 많았다. 이런 비효율로 인해 업무가 많을 때는 테스트 코드 작성을 미루거나 건너뛰기도 했다. 이런 패턴이 반복되다 보니 '내가 테스트를 제대로 작성하고 있는 걸까?' 하는 의구심이 들었다.
물론 테스트 코드의 가치는 충분히 이해하고 있다. 기능을 변경했을 때 버그 여부를 빠르게 확인할 수 있고, 테스트를 통해 비즈니스 로직을 더 잘 이해할 수 있다. 개발 과정에서 버그를 조기에 발견하는 데도 도움이 되었다.
하지만 이런 장점들을 제대로 활용하고 있는지는 확인해볼 필요가 있었다. 그래서 효과적인 테스트 코드 작성을 위한 다양한 원칙들을 찾아보았다.
이번 포스팅에서는 조사했던 두 가지 테스트 원칙에 대해 정리해 보았다.
목차는아래와 같다
[목차]
1. 소프트웨어 테스트의 7원칙
2. FIRST 원칙
1. 소프트웨어 테스트의 7원칙
ISTQB(International Software Testing Qualifications Board)에 나와있는 테스팅의 7원칙이 있다.
- Testing shows the presence of defects(테스팅은 결함이 존재함을 밝히는 행동이다)
- Exhaustive testing is impossible(완벽한 테스팅은 불가능하다)
- Early testing(조기 테스팅)
- Defect clustering(결함은 집중된다)
- Pesticide parado(살충제 패러독스)
- Testing is context dependent(테스팅은 문맥에 의존한다)
- Absence-of-errors fallacy(부재 오류의 오류) - "오류가 없다는 착각"
이런 7원칙이 존재한다. 테스트코드 작성에서 이 원칙들이 어떤 의미를 갖는지 분석해 보자 (예시 코드를 파이썬으로 작성하였다)
1. Testing shows the presence of defects(테스팅은 결함이 존재함을 밝히는 행동이다)
테스트 코드의 존재 목적은 애플리케이션이 "결함이 없다"는 것을 보장하는 것이 아니라 결함이 실제로 존재할 수 있음을 확인
하는 것이 목적이다.
모든 테스트가 통과한다고 해서 버그가 전혀 없다고 할 수 없다.
예를 들어 아래와 같이 나눗셈을 하는 함수를 테스트한다고 해보자
class Calculator:
def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
## TEST
calc = Calculator()
# 0으로 나누면 valueError를 발생시킨다
with self.assertRaise(ValueError):
calc.devide(10,0)
# 정상적인 테스트
self.assertEqual(calc.divide(10, 2), 5)
예외 케이스와 정상 케이스를 모두 통과했다고 해서 이 코드는 결함이 없다고 할 수 있을까?
음수가 들어오는 경우가 있을 수 있고 너무 큰 값이 들어와 처리를 하지 못하는 경우와 같은 엣지 케이스도 들어올 수 있다.
따라서 모든 테스트가 통과되었다고 해서 완벽하다고 생각해서는 안된다.
2. Exhaustive testing is impossible(완벽한 테스팅은 불가능하다)
코드의 모든 경우를 테스트 할 수 있을까? 예전에 테스트코드를 처음 접하고 적용하였을 때 최대한 많은 케이스를 테스트하려고 했던 적이 있다. 당시 개인 프로젝트를 하던 때였는데 테스트코드만 몇 시간을 짰던 기억이 있다. 그리고 너무 많은 케이스를 테스트하려고 하다 보니 테스트코드가 오히려 복작해져서 읽기 어려운 지경에 이르렀다. 테스트 코드는 비즈니스를 이해하기 위한 문서의 역할도 하는데 복잡하면 전혀 도움이 되지 않는다.
테스트코드를 많이 짜라고 하면 정말 사소한 것까지 담을 수 있다. 하지만 이런 테스트들이 과연 의미가 있을까? 이런 테스트를 위해 많은 시간을 소비한다는 것은 너무 비효율적이다. 따라서 테스트코드를 작성할 때에는 우선순위에 집중하여 테스트코드를 작성해야 하고 특히 가장 중요한 비즈니스 로직에 집중해야 한다.
가령 사용자 인증, 결제 처리 등 중요 기능에 대한 테스트를 우선해야 하고 필수 사항이 아닌 부분들에 대해서는 테스트를 최소화할 수 있다.
예를 들어 사용자 인증로직에서 닉네임 필드가 선택적인 경우, 닉네임이 있는 경우와 없는 경우에 대한 테스트코드는 반드시 필요하지 않다. 즉, 우선순위에 따라 테스트코드를 작성해야 한다 특히 비즈니스 로직은 꼭 !
3. Early testing(조기 테스팅)
이 원칙은 TDD를 연상시킨다. 이 원칙에 따르면 소프트웨어는 수명주기의 제일 마지막이 아닌 초기 단계에서 수행해야 함을 의미한다.
개인적으로 테스트코드를 먼저 작성한다는 것은 익숙하지 않다. 최대한 테스트코드를 먼저 작성하려고 해 보지만 쉽지만은 않다. 그래서 보통 기능 하나 구현하고 테스트코드를 추가하는데 추가하면서 버그를 발견하곤 한다. 만약 많은 기능을 완성하고 테스트코드를 작성한다면 넓은 범위의 버그를 수정해야 하므로 추적과 시간이 더 걸릴 수 있다.
TDD(Test Driven Development) 방식을 채택한다면 코드를 작성하기 전에 테스트를 먼저 작성하기 때문에 결함을 조기에 발견하기 쉽고 빠르게 수정할 수 있다는 장점이 있다. 이는 개발 후반부에 발생하는 문제를 줄일 수 있는 효과 또한 있겠다.
즉, 마지막 단계가 아닌 초기 단계에 테스트를 수행
함을 잊지 말자
하지만 TDD는 어렵다,, 관련 스터디를 해보았으면 좋겠다
4. Defect clustering(결함은 집중된다)
결함은 특정 모듈이나 기능에 집중되어 발생되는 경향이 있다는 원칙이다. 전체 결함의 80%의 원인이 20%에서 일어나는 '파레토 법칙'이 적용된다.
이 원칙에 따르면 테스트를 작성하면서 특정 모듈에 자주 결함이 발생한다면 그 모듈을 중심으로 추가적인 테스트를 강화하는 것이 좋다.
이는 테스트를 강화할 수 있는 중요한 힌트가 되기도 한다. 이런 모듈이 있다면 테스트를 그 모듈 중심으로 강화해 보도록 하자.
가령 주문 서비스에 대한 테스트를 한다고 가정해 보자 과거 이력을 보았을 때 자주 발생되는 예외들이 있을 수 있다.
이런 경우 테스트를 강화하여 테스트코드를 보충할 수 있다
def test_order_validation_error_prone_cases(self):
"""
결함이 집중되는 부분에 대한 테스트
- 주문 검증에서 자주 발생하는 오류 케이스들을 집중적으로 테스트
"""
# 이전 경험상 문제가 자주 발생했던 케이스들을 집중적으로 테스트
problem_cases = [
(Decimal('-1'), "NORMAL", False), # 음수 금액
(Decimal('0'), "NORMAL", False), # 0원 주문
(Decimal('1000'), "INVALID", False), # 잘못된 사용자 타입
(Decimal('1000'), "VIP", True), # 정상 케이스
]
for amount, user_type, expected in problem_cases:
with self.subTest(amount=amount, user_type=user_type):
order = Order(amount, user_type)
self.assertEqual(order.validate(), expected)
즉, 경험상 자주 발생했던 예외가 있는 경우, 복잡한 로직이나 의존성이 많은 모듈이 있는 경우
다양한 테스트케이스를 추가하자..!
5. Pesticide paradox(살충제 패러독스)
동일한 살충제를 계속해서 사용하면 결국 살충제에 대한 내성이 생기고 효과가 없게 된다. 테스트도 이와 비슷하다.
동일한 테스트 케이스만 반복 실행하는 경우 기존의 결함은 계속 발견할 수 있지만 새로운 결함이 숨겨질 수 있다.
아래의 예시를 보자
아래와 같이 Product, User, Order 클래스가 있다고 가정해 보자
class Product:
def __init__(self, name: str, price: Decimal, quantity: int):
self.name = name
self.price = price
self.quantity = quantity
class User:
def __init__(self,user_type: str):
self.user_type = user_type
self.point = Decimal('0')
class Order:
def __init__(self, products: List[Product], user: User):
self.products = products
self.user = user
self.total_amount = sum(p.price * p.quantity for p in products)
아래와 같이 간단하게 테스트코드를 작성할 수 있다.
이 테스트는 Order가 정상적으로 생성이 되었는지 테스트하는 코드이다.
def setUP(self):
self.user = User("NORMAL")
self.product = Product("Test Product",Decimal('1000'), 1)
def test_order_calculation(self):
# 하나의 케이스에 대해서만 확인한다
order = Order([self.product], self.user)
self.assertEqual(order.total_amount, Decimal('1000'))
def test_order_user(self):
# 일반 사용자 케이스만 확인한다
order = Order([self.product], self.user)
self.assertEqual(order.user.user_type, "NORMAL")
위의 테스트 order를 생성했을 때의 결괏값을 확인한다. 테스트코드는 결괏값만 확인하면 되기 때문에 틀리지 않았다. 하지만 너무 단순하고 기본적인 테스트만 하고 있다는 생각이 든다.
이 Order라는 클래스는 product를 리스트로 받는다. 그리고 User 또한 하나의 타입이 아닌 다양한 타입을 갖지만 위의 테스트코드에는 setUp으로 하나의 Product, 하나의 User타입을 갖는 객체만을 사용하고 있다.
하지만 입력으로 들어온 Product가 아예 없을 수도 있고 일반 사용자인지 VIP 사용자인지 사용자 타입에 따라 다른 결과가 나올 수 있다. 또는 어떤 새로운 비즈니스 규칙이 중간에 생길 수도 있다.
새로운 요구사항으로 VIP사용자가 생겼다고 해보자. 위의 테스트코드를 그대로 상용한다면 테스트는 성공하겠지만 VIP사용자에 대한 테스트코드가 없기 때문에 새로운 추가사항으로 인한 결함은 이 테스트코드에서는 발견할 수 없게 되었다.
이를 수정한다면 아래와 같이 수정할 수 있을 것 것 같다.
def setUp(self):
self.normal_user = User("NORMAL")
self.vip_user = User("VIP")
def test_order_with_different_user_types(self):
"""다양한 사용자 유형 테스트"""
product = Product("Test Product", Decimal('1000'), 1)
# 일반 사용자 주문
normal_order = Order([product], self.normal_user)
self.assertEqual(normal_order.total_amount, Decimal('1000'))
# VIP 사용자 주문 (포인트 적립 등 추가 기능 테스트)
vip_order = Order([product], self.vip_user)
self.vip_user.point += vip_order.total_amount * Decimal('0.1') # VIP 10% 포인트 적립
self.assertEqual(self.vip_user.point, Decimal('100'))
즉, 비슷한 케이스만 반복적으로 하는 것이 아니라 다양한 테스트 시나리오
에 대해 테스트를 하도록 하자
6. Testing is context dependent(테스팅은 문맥에 의존한다)
테스트코드는 각 코드의 내용에 종속된다. 예를 들어, 이커머스 도메인에서 결제나 주문 처리와 같은 기능에 집중해야 하며, 금융 도메인에서는 보안성과 정확성에 우선순위를 두어야 한다. 따라서 해당 애플리케이션의 도메인과 특성에 따라 맞춤형 테스트 케이스를 설계하는 것이 중요하다.
아래의 예를 확인해 보자.
class BankOrder(Order):
def validate(self) -> bool:
# 은행 도메인에서는 추가적인 보안 검증이 필요하다
return (super().validate() and
self.total_amount <= Decimal('1000000') and # 거래 한도
self.created_at.hour between 9 and 16) # 영업 시간
class EcommerceOrder(Order):
def validate(self) -> bool:
# 이커머스에서는 다른 제약사항이 적용된다
return (super().validate() and
self.total_amount >= Decimal('1000')) # 최소 주문금액
두 클래스 모두 Order이지만 각 도메인에 따라 중점적으로 다루어야 하는 부분이 다르다. Test 코드 또한 이에 따라 작성되어야 한다.
7. Absence-of-errors fallacy(부재 오류의 오류) - "오류가 없다는 착각"
이 원칙은 소프트웨어의 테스트는 단지 결함을 찾는 것이 다가 아닌 사용자의 요구사항을 만족하는지 확인
하는 것이라는 의미이다.
요구사항에 부합하지 않는 기능의 결함을 찾고 수정하는 것은 테스트의 의미가 없다. 예전에 처음 테스트코드를 적용할 때 선택 인수까지 테스트했던 것이 생각난다 :) ..
이렇듯 테스트코드는 사용자가 실제로 기대하는 요구사항을 만족하는지 검증하는 과정이기 때문에, 결함이 발견되지 않았다고 해서 모든 요구사항을 충족했다고 볼 수는 없다. 테스트 코드 작성 시 사용자의 요구사항과 비즈니스 요구사항을 바탕으로 케이스를 구성하여, 결함이 없더라도 요구사항을 만족하지 않는 기능을 놓치지 않도록 노력해야 한다.
즉, 의미 있는 테스트코드는 사용자의 요구사항을 모두 반영한 코드
이다.
간단한 예를 들어보자
VIP 고객의 경우 주문 최소 금액이 10,000원 이상이어야 한다는 비즈니스 요구사항이 있다고 해보자
class OrderProcessor:
def process_order(self, order: Order) -> bool:
if not order.validate():
return False
# 비즈니스 요구사항 검증
if order.user_type == "VIP" and order.total_amount < Decimal('10000'):
return False # VIP 최소 주문금액 요구사항 미충족
return True
# TEST
processor = OrderProcessor()
order = Order(Decimal('5000'), "VIP")
# 에러는 없지만 비즈니스 요구사항 미충족(비즈니스 요구사항을 확인)
self.assertFalse(processor.process_order(order)) # VIP 최소금액 미달임을 테스트
위의 코드는 예외는 없지만 비즈니스 요구사항을 확인하고 있다.
이와 같이 테스트코드는 사용자의 요구사항, 비즈니스 요구사항을 확인해야 한다.
즉, 오류가 없다고 해서 정말로 없다고 착각해서는 안된다
약간, 큰 그림(?)의 테스트 원칙을 알아보았다. 찾아보니 Unit test에 대한 원칙들이 몇 가지 있어 추가적으로 알아보았다.
2. F.I.R.S.T 원칙
FIRST원칙은 한 번은 들어봤을 책 "Robert C. Martin in his book "Clean Code: A Handbook of Agile Software Craftsmanship"에서 소개되었다. 이 원칙은 유닛 테스트의 품질을 높이기 위한 지침으로 유명한 지침이라고 한다.
클린코드를 유니어(유년기 주니어, 지금은..초니어..?)일 때 주변 말만 듣고 사놓은 책이다. 가끔 궁금한 부분만 펼쳐서 읽곤 했는데 이 TEST부분은 읽지 않았던 모양이다. 기억이 안 난다.. 그래서 겸사겸사 꺼내서 다시 읽어보았다.
각 원칙에 대해 하나씩 간단하게 짚어보자
F. Fast : 유닛 테스트는 빨라야 한다
보통 코드를 작성하고 테스트를 수시로 돌려본다. 그런데 유닛 테스트가 너무 느리다면 작업에 지장이 있을 수밖에 없다. 한 번 돌리는데 1분만 넘어가도 작업에 지장이 간다. 더 느려진다면 아마 테스트를 돌리는 횟수가 줄어들게 되고 초반에 문제를 찾지 못하게 될 수도 있다.
또, 유닛 테스트는 CI 프로세스에 포함된다. PR을 올리거나 push를 할 때, 배포를 할 때 CI 과정 중 하나로 보통 이 unit test가 동작하도록 되어있다.
그러므로, 유닛 테스트는 빨라야 한다.
I. Isolated : 테스트는 다른 테스트에 종속적이면 안된다
다른 테스트에 종속적인 테스트라면 테스트에 순서가 생긴다. 그리고 이는 테스트코드에 대한 추가적인 관리를 불러온다. 먼저 실행해야. 할 테스트가 실패하면 다음 테스트도 실패하게 된다. 테스트는 다른 테스트에 종속성이 존재해서는 안된다. 아마 누구나 이해하는 원칙이라 생각한다
R. Repeatable : 같은 테스트는 실행할 때마다 같은 결과를 만들어야 한다.
동일한 테스트코드는 어떤 환경에서든 결과가 같아야 한다. 테스트는 환경에 정속적이면 안된다.
S. Self-Validating : 테스트는 스스로 결과물이 옳은지 그른지 판단할 수 있어야 한다.
테스트의 결과는 True or False , 성공 아니면 실패만 존재해야 한다.
즉, 테스트는 명확한 검증 조건(assert)을 사용해서 결과를 자동으로 판단할 수 있어야 한다는 의미이다.
예를 들면 아래와 같이 실제 함수가 호출된 경과를 확인하기 위해 log를 확인하거나 하는 것이 아니라,
def test_calculate_sum_bad_case():
result = calculate_sum(2, 3)
print("Result:", result)
아래와 같이 assert문을 이용하여 결과가 예상한 값과 동일한지 자동으로 평가하도록 해야 한다.
만약 결과와 다른 경우 AssertionError를 발생시켜 테스트가 실패하였다는 것을 알 수 있다.
def test_calculate_sum_good_case():
result = calculate_sum(2, 3)
assert result == 5
T. Timely/Thorough : 유닛 테스트는 실제 코드가 구현하기 직전에 구현해야 한다.
이 원칙은 테스트 코드는 실제 코드보다 먼저 작성되어야 한다는 의미이다. TDD에 가까운 것 같은 원칙이다.
책에서는 실제 코드를 작성하고 테스트코드를 만들면 실제 코드가 너무 복잡하거나 테스트하기 어려운 구조인 경우엔 테스트코드 작성하기가 쉽지 않다는 예를 들고 있다. 나도 이런 경우가 종종 있긴 했었기 때문에 어떤 의미인지 이해되는 부분도 있다.
정리
이 두 가지 원칙을 하나로 다시 나열해 보면 아래와 같다.
1. 7원칙
- Testing shows the presence of defects(테스팅은 결함이 존재함을 밝히는 행동이다)
- Exhaustive testing is impossible(완벽한 테스팅은 불가능하다)
- Early testing(조기 테스팅)
- Defect clustering(결함은 집중된다)
- Pesticide parado(살충제 패러독스)
- Testing is context dependent(테스팅은 문맥에 의존한다)
- Absence-of-errors fallacy(부재 오류의 오류) - "오류가 없다는 착각"
2. FIRST 원칙
- Fast : 유닛 테스트는 빨라야 한다
- Isolated : 테스트는 다른 테스트에 종속적이면 안된다
- Repeatable : 같은 테스트는 실행할 때마다 같은 결과를 만들어야 한다.
- Self-Validating : 테스트는 스스로 결과물이 옳은지 그른지 판단할 수 있어야 한다.
- Timely/Thorough : 유닛 테스트는 실제 코드가 구현하기 직전에 구현해야 한다.
테스트코드를 작성할 때 이 원칙들을 상기하면서 작성해 보려고 한다. 추가적으로 Best practice는 없을까 싶어서 이런저런 정보를 찾다 보니 AAA 패턴, DRY(Don't Reapeat Yourself)와 같은 원칙들에 대해서 조사하게 되었다. 이번 포스팅에서 같이 정리하고 싶은 것들이 많았는데, 글을 작성하다 보니 너무 길어지는 것 같아서 이번 포스팅은 여기까지 마무리하기로 하였다. 아마 조금씩 이 포스팅에 추가할 것 같다.
참고사이트들
https://tech.inflab.com/20230404-test-code/
https://dzone.com/articles/first-principles-solid-rules-for-tests
https://product.kyobobook.co.kr/detail/S000001032980
읽어보면 좋을 것 같아서 첨부
https://martinfowler.com/articles/practical-test-pyramid.html
'Backend Development > General' 카테고리의 다른 글
[TEST]테스트 코드를 어떻게 시작하면 좋을까 (6) | 2024.11.10 |
---|