소프트웨어 테스트는 작성한 코드가 요구사항대로 동작하는지 확인하고, 변경 이후 기존 기능이 깨지지 않았는지 검증하는 과정이다.
테스트 설계(Test Design)는 단순히 테스트 코드를 작성하는 것보다 범위가 넓다. 어떤 기능을 어떤 조건에서 검증할 것인지, 어떤 입력을 사용하고 어떤 결과를 기대할 것인지, 어디까지 자동화할 것인지를 결정하는 작업이다.
코드가 복잡해질수록 모든 경우를 직접 실행해서 확인하는 것은 현실적으로 어렵다. 그래서 테스트는 입력과 상태를 체계적으로 나누고, 오류가 발생할 가능성이 높은 경계를 찾아 검증하는 방향으로 설계한다.
테스트의 목적은 버그가 없다는 것을 증명하는 것이 아니다.
실패할 가능성이 있는 지점을 찾아 시스템의 동작을 검증하는 것에 가깝다.
테스트의 기본 구조
테스트는 기본적으로 다음 흐름을 가진다.
Setup -> Execute -> Verify -> Cleanup
또는 테스트 코드에서 흔히 사용하는 형태로 표현하면
Arrange -> Act -> Assert
Arrange
테스트에 필요한 상태와 입력을 준비한다.
Act
테스트하려는 동작을 실행한다.
Assert
실행 결과가 기대한 결과와 같은지 검증한다.
예를 들어 체력 감소 기능이라면
Arrange -> Player 생성
Act -> TakeDamage(30)
Assert -> Health == 70
구조가 명확하다.
테스트 코드를 읽었을 때 무엇을 검증하는지 바로 파악할 수 있어야 한다.
테스트 케이스 설계
테스트에서 가장 중요한 부분 중 하나는 “무엇을 테스트할 것인가”다.
단순히 정상적인 입력만 테스트해서는 충분하지 않다.
예를 들어 체력 감소 함수가 다음과 같다고 하자.
void TakeDamage(int damage)
{
health = std::max(health - damage, 0);
}
테스트해야 할 입력은 단순히 30 하나가 아니다.
damage = 0 / damage = 1 / damage = health / damage = health + 1 / damage < 0
이런 입력을 통해 구현이 예상대로 동작하는지 확인할 수 있다.
정상 조건과 비정상 조건
테스트 케이스는 크게 정상적인 입력과 비정상적인 입력으로 나눌 수 있다.
예를 들어 회원가입 기능이라면
정상 -> 올바른 ID / 올바른 비밀번호 / 사용 가능한 이메일
비정상 -> 빈 ID / 너무 긴 ID / 잘못된 이메일 / 중복 ID / 잘못된 비밀번호
를 구분해서 테스트할 수 있다.
실제 버그는 정상적인 입력보다 예외적인 상황에서 발생하는 경우가 많다.
따라서 테스트 설계에서는 실패 조건을 의도적으로 찾아야 한다.
경계값 분석
경계값 분석(Boundary Value Analysis)은 입력 범위의 경계에서 오류가 발생하기 쉽다는 점을 이용한 테스트 기법이다.
예를 들어 레벨이 1~100까지 허용된다고 하자.
단순히 50만 테스트하는 것보다
0 / 1 / 2 / 99 / 100 / 101
을 테스트하는 것이 의미가 있다.
범위의 내부보다 경계에서 조건문 오류가 발생할 가능성이 높기 때문이다.
if (level >= 1 && level <= 100)
{
// valid
}
이런 코드에서는 0, 1, 100, 101 같은 값이 특히 중요하다.
경계값 분석은 숫자뿐만 아니라 문자열 길이, 배열 크기, 파일 크기, 시간 범위 등에도 적용할 수 있다.
동등 분할
동등 분할(Equivalence Partitioning)은 입력값을 동일한 방식으로 처리되는 그룹으로 나누고 각 그룹을 대표하는 값을 선택하는 방법이다.
예를 들어 레벨 1~100이 유효하다면
유효한 영역 -> 1 ~ 100
잘못된 영역 -> 0 이하
잘못된 영역 -> 101 이상
으로 나눌 수 있다.
모든 숫자를 테스트할 필요는 없다.
각 그룹에서 대표값을 선택한다.
0 / 50 / 101
정도로 테스트할 수 있다.
경계값 분석과 함께 사용하면 테스트 케이스를 줄이면서도 의미 있는 범위를 검증할 수 있다.
결정 테이블
조건이 여러 개이고 조건 조합에 따라 결과가 달라지는 경우에는 결정 테이블(Decision Table)을 사용할 수 있다.
예를 들어 아이템 사용 조건이
플레이어가 살아 있어야 한다.
아이템을 가지고 있어야 한다.
레벨이 충분해야 한다.
라고 하자.
조건을 조합하면 다음처럼 생각할 수 있다.
Alive = Y / HasItem = Y / LevelValid = Y -> Success
Alive = Y / HasItem = Y / LevelValid = N -> Fail
Alive = Y / HasItem = N / LevelValid = Y -> Fail
Alive = N / HasItem = Y / LevelValid = Y -> Fail
조건이 많아질수록 단순한 테스트 케이스 목록보다 이런 형태로 정리하는 것이 도움이 된다.
모든 조합을 반드시 테스트해야 한다는 뜻은 아니다.
조합 수가 많아지면 테스트 비용도 기하급수적으로 증가하기 때문에 실제 위험도와 요구사항을 기준으로 중요한 조합을 선택해야 한다.
상태 전이 테스트
상태가 존재하는 시스템에서는 상태 자체보다 상태가 어떻게 변경되는지를 테스트해야 한다.
예를 들어 플레이어 상태가
Idle / Move / Attack / Dead
라고 하자.
가능한 전이를 생각하면
Idle -> Move -> Attack -> Idle
그리고
Attack -> Dead
같은 흐름이 존재할 수 있다.
테스트에서는 단순히 각 상태를 한 번씩 확인하는 것보다
현재 상태 -> 입력 -> 다음 상태
를 검증한다.
예를 들어
Idle -> (MoveInput) -> Move
Move -> (AttackInput) -> Attack
Dead -> (MoveInput) -> Dead
처럼 테스트할 수 있다.
FSM, 게임 캐릭터, 네트워크 연결 상태, 로그인 상태 등에서 유용하다.
테스트 피라미드
테스트는 하나의 종류만 사용하는 것이 아니다.
대표적으로 테스트 피라미드라는 개념을 사용한다.
Unit -> Integration -> E2E
아래쪽 테스트일수록 빠르고 많이 실행하고, 위쪽으로 갈수록 느리고 비용이 커진다.
Unit Test
하나의 함수나 클래스처럼 작은 단위를 독립적으로 검증한다.
DamageCalculator / Inventory / Pathfinding / CharacterStats
같은 코드가 대상이 될 수 있다.
빠르고 실패 원인을 찾기 쉽다.
Integration Test
여러 컴포넌트가 실제로 제대로 연결되는지 검증한다.
예를 들어
Game Server -> Repository -> Database
가 실제로 정상적으로 동작하는지 확인할 수 있다.
End-to-End Test
사용자의 실제 사용 흐름에 가까운 형태로 전체 시스템을 검증한다.
예를 들어 웹 서비스라면
로그인 -> 상품 조회 -> 장바구니 -> 결제 -> 주문 완료
전체 흐름을 테스트한다.
E2E 테스트는 실제 환경과 가까운 만큼 가치가 있지만 실행 비용과 유지보수 비용도 높다.
따라서 모든 테스트를 E2E로 만들기보다 작은 단위의 테스트를 충분히 구성하고 필요한 영역에 통합 테스트와 E2E 테스트를 배치하는 것이 일반적이다.
Unit Test
Unit Test는 테스트 대상의 범위를 작게 유지하는 것이 중요하다.
예를 들어
int CalculateDamage(int attack, int defense)
{
return std::max(attack - defense, 0);
}
라면 DB나 네트워크 없이 계산 결과만 검증할 수 있다.
TEST(DamageTest, DefenseReducesDamage)
{
EXPECT_EQ(CalculateDamage(100, 30), 70);
}
이 테스트의 장점은 실패 원인이 명확하다는 것이다.
DB도 사용하지 않고 네트워크도 사용하지 않는다.
따라서 실패했다면 CalculateDamage()의 문제를 우선적으로 확인하면 된다.
테스트 더블
Unit Test에서는 실제 외부 시스템을 그대로 사용하기 어려운 경우가 있다.
이때 테스트 더블(Test Double)을 사용할 수 있다.
대표적으로
Stub / Mock / Fake / Spy / Dummy
등이 있다.
Stub
미리 정해진 값을 반환하는 가짜 객체다.
GetPlayer() -> 항상 특정 Player 반환
Mock
특정 함수가 호출되었는지와 호출 방식 등을 검증하는 데 사용한다.
SavePlayer() -> 정확히 한 번 호출되었는가?
Fake
실제 구현보다 단순하지만 실제처럼 동작하는 구현이다.
예를 들어 실제 DB 대신 메모리 자료구조를 사용하는 Repository를 만들 수 있다.
IPlayerRepository -> MySQLPlayerRepository / FakePlayerRepository
테스트 더블을 사용하면 테스트 속도를 높이고 외부 환경에 대한 의존성을 줄일 수 있다.
Mock을 남용하면 생기는 문제
Mock은 유용하지만 많이 사용할수록 테스트가 구현 세부사항에 묶일 수 있다.
예를 들어
EXPECT_CALL(repository, FindPlayer(10))
.Times(1);
EXPECT_CALL(repository, SavePlayer(_))
.Times(1);
이 테스트는 결과보다 내부 호출 구조를 강하게 검증한다.
나중에 내부 구현을
FindPlayer -> Modify -> Save
에서
GetPlayer -> Update
로 바꿨을 뿐인데 실제 기능은 정상인데 테스트가 깨질 수 있다.
따라서 테스트는 가능하면 구현 방법보다 외부에서 관찰 가능한 결과와 중요한 행위를 검증하는 방향이 좋다.
테스트 격리
테스트는 서로 영향을 주면 안 된다.
나쁜 테스트 구조에서는
Test A -> DB 상태 변경 -> Test B의 DB 상태에 영향
처럼 된다.
그러면 Test A를 먼저 실행했을 때는 성공하지만 Test B만 실행하면 실패하는 문제가 생긴다.
테스트는 가능하면
Test A -> 독립적으로 실행 가능
Test B -> 독립적으로 실행 가능
Test C -> 독립적으로 실행 가능
해야 한다.
테스트 데이터, 파일, DB, 전역 상태, 싱글톤 등의 공유 상태를 특히 주의해야 한다.
결정론적 테스트
같은 코드와 같은 조건에서 실행했을 때 결과가 항상 같아야 테스트를 신뢰할 수 있다.
다음과 같은 요소는 테스트를 불안정하게 만들 수 있다.
현재 시간 / 랜덤 값 / 네트워크 / 외부 API / 스레드 실행 순서 / 실제 DB 상태 / 파일 시스템
예를 들어
int GetRandomReward();
를 테스트할 때 실제 랜덤값에 의존하면 결과가 매번 달라질 수 있다.
테스트에서는 Random Generator를 주입하고 고정된 값을 반환하도록 만들 수 있다.
Game Logic -> IRandom -> RealRandom / FakeRandom
이런 구조는 테스트 가능성을 높이는 동시에 의존성도 명확하게 만든다.
테스트 가능한 코드
테스트하기 쉬운 코드는 대체로 의존성이 명확하다.
예를 들어 함수 내부에서 직접 전역 DB 객체를 가져오는 코드보다
class PlayerService
{
public:
PlayerService(IPlayerRepository& repository)
: repository(repository)
{
}
private:
IPlayerRepository& repository;
};
처럼 필요한 의존성을 외부에서 주입받는 구조가 테스트하기 쉽다.
테스트에서는
PlayerService -> FakePlayerRepository
를 넣을 수 있기 때문이다.
이것이 Dependency Injection이 테스트 설계와 연결되는 이유다.
테스트 커버리지
테스트 커버리지는 테스트가 실제 코드의 어느 정도를 실행했는지를 나타내는 지표다.
대표적으로
Line Coverage / Branch Coverage / Function Coverage / Condition Coverage
등이 있다.
예를 들어
if (player.IsAlive())
{
Attack();
}
else
{
return;
}
가 있다면 단순히 이 코드를 한 번 실행했다고 Branch Coverage가 충분히 확보되는 것은 아니다.
IsAlive == true -> Attack 경로
IsAlive == false -> return 경로
두 경로를 모두 실행해야 한다.
하지만 커버리지가 높다고 좋은 테스트라는 보장은 없다.
100% Coverage != 100% Correctness
코드를 실행하기만 하고 결과를 제대로 검증하지 않는 테스트도 만들 수 있기 때문이다.
커버리지는 테스트 품질을 판단하는 절대적인 기준이 아니라 검증되지 않은 코드 영역을 찾는 지표로 보는 것이 좋다.
회귀 테스트
Regression Test는 기존에 정상적으로 동작하던 기능이 변경 이후 깨지지 않았는지 확인하는 테스트다.
예를 들어 아이템 시스템을 수정했는데
아이템 사용 -> 정상
이었던 기능이 업데이트 이후
아이템 사용 -> 실패
하게 될 수 있다.
이런 문제를 다시 발견할 수 있도록 기존 테스트를 계속 유지한다.
새로운 버그가 발생하면
Bug 발생 -> 원인 수정 -> 해당 상황을 재현하는 테스트 추가
라는 흐름으로 가져가는 것이 좋다.
그러면 같은 버그가 다시 발생했을 때 자동으로 잡을 수 있다.
테스트 주도 개발
TDD(Test-Driven Development)는 테스트를 먼저 작성하고 구현하는 개발 방식이다.
대표적인 흐름은
Red -> Green -> Refactor
다.
Red
실패하는 테스트를 작성한다.
Green
테스트를 통과할 최소한의 코드를 작성한다.
Refactor
동작을 유지하면서 코드를 개선한다.
예를 들어
요구사항 -> "100의 공격력이 30의 방어력을 만나면 70의 피해를 준다." -> 테스트 작성 -> 구현
를 테스트로 먼저 표현한다.
EXPECT_EQ(CalculateDamage(100, 30), 70);
TDD는 모든 코드에 반드시 적용해야 하는 규칙은 아니다.
복잡한 UI나 빠르게 실험해야 하는 프로토타이핑에서는 테스트 우선 개발이 비효율적일 수도 있다.
테스트 설계에서 중요한 기준
테스트를 작성할 때는 단순히 테스트 개수를 늘리는 것보다 테스트의 가치가 중요하다.
다음과 같은 질문을 기준으로 보면 된다.
이 기능에서 실패할 가능성이 높은 부분은 어디인가?
입력의 경계는 어디인가?
예외적인 상태는 무엇인가?
서로 다른 조건이 조합되는 부분은 어디인가?
외부 시스템과 연결되는 부분은 어디인가?
이 기능이 깨지면 영향 범위가 큰 부분은 어디인가?
특히 위험도가 높은 로직부터 테스트하는 것이 효율적이다.
예를 들어 게임에서 단순한 UI 텍스트보다
Damage Calculation / Inventory Transaction / Currency Update / Match Result / Player State / Save / Load
같은 로직이 잘못되었을 때 훨씬 큰 문제가 발생한다.
테스트 역시 위험 기반으로 우선순위를 정할 필요가 있다.
좋은 테스트의 특징
좋은 테스트는 읽었을 때 무엇을 검증하는지 명확하다.
TEST(InventoryTest, CannotUseItemWhenInventoryIsEmpty)
{
Inventory inventory;
EXPECT_FALSE(inventory.UseItem(100));
}
테스트 이름만 봐도 의도를 알 수 있다.
또한 실패했을 때 원인을 추적하기 쉬워야 한다.
하나의 테스트에서
회원가입 -> 로그인 -> 상품 구매 -> 결제 -> 이메일 발송
을 모두 검증하면 실패했을 때 어느 부분이 문제인지 알기 어렵다.
가능하면 하나의 테스트가 하나의 명확한 행동과 결과를 검증하도록 구성하는 것이 좋다.
테스트 설계와 아키텍처
테스트 가능성은 아키텍처와 직접 연결된다.
다음 구조는 테스트하기 어렵다.
GameLogic -> 직접 DB 접근 / 직접 네트워크 접근 / 전역 상태 접근 / 직접 파일 접근
반대로 외부 의존성을 분리하면
GameLogic -> IRepository / INetwork / IFileSystem
테스트에서는
FakeRepository / FakeNetwork / FakeFileSystem
등으로 대체할 수 있다.
따라서 테스트를 나중에 추가하려고 하면 이미 구조가 굳어 있어서 어려울 수 있다.
테스트 가능성은 코드 작성 방식과 아키텍처 설계 단계에서부터 결정된다.
테스트 자동화
테스트는 사람이 직접 실행하는 것보다 자동화할수록 반복 검증에 유리하다.
일반적인 CI 환경에서는
Code Commit -> Build -> Unit Test -> Integration Test -> Test Result -> Deploy
같은 파이프라인을 구성할 수 있다.
개발자가 코드를 변경할 때마다 테스트를 실행하면 회귀 버그를 빠르게 발견할 수 있다.
특히 규모가 커질수록 테스트를 수동으로 실행하는 것은 현실적으로 어렵다.
자동화된 테스트는 개발 과정의 일부가 되어야 한다.
테스트를 어디까지 해야 하는가
모든 코드를 동일한 수준으로 테스트할 필요는 없다.
예를 들어 단순한 getter보다 다음과 같은 코드가 훨씬 높은 테스트 가치를 가진다.
결제 / 인벤토리 / 재화 / 권한 / 인증 / 전투 계산 / 저장 / 매칭 / 네트워크 상태
테스트 비용도 고려해야 한다.
테스트 가치 / 테스트 작성 비용 / 실패했을 때의 피해
이 세 가지를 비교해서 우선순위를 잡는 것이 현실적인 접근이다.
테스트가 많다고 무조건 좋은 시스템이 아니다.
유지보수가 불가능할 정도로 테스트가 복잡해지면 테스트 자체가 새로운 기술 부채가 된다.
정리
테스트 설계는 테스트 코드를 많이 작성하는 작업이 아니다.
어떤 상황에서 시스템이 잘못될 수 있는지를 찾아내고, 그 위험을 가장 효율적으로 검증하는 과정에 가깝다.
전체적인 구조를 정리하면 다음과 같다.
요구사항 분석 -> 입력과 상태 파악 -> 정상 / 비정상 조건 분리 -> 경계값 분석 -> 조건 조합 분석 -> 테스트 케이스 작성 -> Unit Test -> Integration Test -> E2E Test -> 자동화 -> 회귀 테스트
대표적인 테스트 전략을 보면
동등 분할 -> 입력 영역을 그룹으로 나눈다.
경계값 분석 -> 경계에서 테스트한다.
결정 테이블 -> 조건 조합을 검증한다.
상태 전이 테스트 -> 상태 변경과 전이를 검증한다.
Unit Test -> 작은 단위의 동작을 검증한다.
Integration Test -> 여러 구성요소의 연결을 검증한다.
E2E Test -> 실제 사용자 흐름에 가까운 전체 시스템을 검증한다.
그리고 테스트 품질은 단순한 커버리지 숫자로 판단하기 어렵다.
중요한 것은 실제로 위험한 부분을 테스트하고 있는가, 실패했을 때 원인을 찾을 수 있는가, 코드 변경 후에도 테스트를 유지할 수 있는가다.
테스트 설계를 제대로 하려면 테스트 기법만 공부해서는 부족하다. 요구사항을 분석하고, 시스템의 경계를 찾고, 어떤 상태와 입력이 위험한지 판단하는 능력이 함께 필요하다.
결국 테스트는 코드를 믿기 위한 장치가 아니라, 코드가 어떤 조건에서 깨질 수 있는지를 계속 드러내는 장치라고 보는 편이 맞다.