TDD(Test-Driven Development)의 기본 이해

TDD의 개념

TDD(Test-Driven Development)는 테스트 주도 개발이라고 하며, 실제 기능 코드를 작성하기 전에 테스트 코드를 먼저 작성하고 테스트를 통과시키는 방향으로 개발을 진행하는 방법론이다.

일반적인 개발 방식은 기능을 구현한 뒤 테스트를 통해 정상 동작 여부를 확인한다. 반면 TDD는 구현하려는 기능의 기대 동작을 테스트 코드로 먼저 정의하고, 해당 테스트를 통과하도록 기능을 구현한다.

TDD의 핵심은 테스트 코드의 양을 늘리는 것이 아니다. 요구사항을 명확하게 정의하고, 코드의 동작을 검증하며, 지속적인 리팩터링을 가능하게 하는 개발 사이클을 만드는 것이다.

TDD의 핵심 사이클

TDD는 일반적으로 Red, Green, Refactor의 세 단계를 반복한다.

Red — 실패하는 테스트 작성

구현하려는 기능의 요구사항을 테스트 코드로 작성한다.

아직 기능이 구현되지 않았으므로 테스트는 실패해야 한다. 이 과정에서 기능이 어떤 조건에서 어떻게 동작해야 하는지 명확하게 정의한다.

예를 들어 캐릭터의 체력이 100일 때 30의 피해를 받으면 체력이 70이 되어야 한다는 요구사항을 테스트로 작성할 수 있다.

Green — 테스트 통과

작성한 테스트를 통과시키기 위한 최소한의 기능 코드를 구현한다.

이 단계에서는 불필요한 최적화나 과도한 추상화를 피하고, 현재 정의된 요구사항을 만족하는 데 집중한다.

테스트가 통과하면 구현한 기능이 적어도 해당 테스트에서 정의한 조건을 만족한다는 것을 확인할 수 있다.

Refactor — 코드 개선

테스트가 통과하는 상태를 유지하면서 코드의 구조와 품질을 개선한다.

중복 코드 제거, 책임 분리, 변수 및 메서드 이름 개선, 불필요한 의존성 제거 등이 여기에 해당한다.

리팩터링 이후에도 테스트가 통과한다면 기존에 정의한 동작을 유지하면서 코드 구조를 개선했는지 확인할 수 있다.

이 세 단계를 작은 단위로 반복하면서 기능을 점진적으로 완성한다.

TDD 예제: C# 체력 시스템 구현

게임에서 캐릭터의 체력을 관리하는 시스템을 구현한다고 가정한다.

요구사항은 다음과 같다.

  • 캐릭터의 초기 체력은 100이다.
  • 피해를 받으면 체력이 감소한다.
  • 체력은 0 미만으로 내려가지 않는다.

테스트 코드 작성

Unity에서 사용할 수 있는 NUnit 기반 테스트 예제다.

using NUnit.Framework;

public class HealthTests
{
    [Test]
    public void TakeDamage_ReducesHealth()
    {
        var health = new Health(100);

        health.TakeDamage(30);

        Assert.AreEqual(70, health.CurrentHealth);
    }

    [Test]
    public void TakeDamage_DoesNotGoBelowZero()
    {
        var health = new Health(100);

        health.TakeDamage(150);

        Assert.AreEqual(0, health.CurrentHealth);
    }
}

이 테스트는 Health 클래스가 특정 입력에 대해 어떤 결과를 반환해야 하는지 정의한다.

아직 Health 클래스가 없다면 코드를 컴파일하고 테스트할 수 없다. TDD에서는 이러한 테스트를 먼저 작성하고, 테스트가 요구하는 인터페이스와 동작을 구현해 나간다.

기능 구현

테스트를 통과하기 위한 최소한의 구현은 다음과 같다.

public class Health
{
    public int CurrentHealth { get; private set; }

    public Health(int initialHealth)
    {
        CurrentHealth = initialHealth;
    }

    public void TakeDamage(int damage)
    {
        CurrentHealth -= damage;

        if (CurrentHealth < 0)
        {
            CurrentHealth = 0;
        }
    }
}

이제 테스트를 실행하면 두 테스트 모두 통과한다.

하지만 여기서 개발을 끝내면 안 된다. 현재 구현에는 음수 피해를 받았을 때 체력이 증가하는 문제가 존재한다.

추가 테스트와 리팩터링

음수 피해를 무시해야 한다는 요구사항을 추가한다면 다음과 같은 테스트를 작성할 수 있다.

[Test]
public void TakeDamage_NegativeDamage_DoesNotIncreaseHealth()
{
    var health = new Health(100);

    health.TakeDamage(-20);

    Assert.AreEqual(100, health.CurrentHealth);
}

이 테스트는 기존 구현에서 실패한다. 따라서 구현을 수정해야 한다.

public void TakeDamage(int damage)
{
    if (damage <= 0)
    {
        return;
    }

    CurrentHealth = System.Math.Max(
        0,
        CurrentHealth - damage
    );
}

이제 음수 피해를 방지하면서 체력이 0 미만으로 내려가지 않도록 처리할 수 있다.

이 예제에서 중요한 점은 처음부터 모든 예외 상황을 완벽하게 예측하는 것이 아니다. 요구사항을 테스트로 구체화하고, 테스트 결과를 바탕으로 구현을 개선하는 과정을 반복하는 것이다.

TDD와 일반적인 개발 방식의 차이

구분일반적인 개발TDD
개발 시작점기능 코드 작성테스트 코드 작성
개발 순서구현 후 검증테스트, 구현, 리팩터링
요구사항 정의구현 과정에서 구체화될 수 있음테스트로 기대 동작을 먼저 명시
회귀 테스트별도 테스트 체계에 따라 달라짐기존 테스트를 반복 실행
초기 개발 비용상대적으로 적을 수 있음테스트 작성 비용이 추가됨
유지보수테스트와 설계 품질에 따라 달라짐테스트가 잘 설계되면 변경 검증에 유리

TDD가 모든 상황에서 더 빠르거나 효율적인 것은 아니다. 테스트 작성과 유지보수에도 비용이 발생한다.

그러나 복잡한 규칙이 존재하거나 기능 변경이 잦은 시스템에서는 테스트를 통한 검증이 디버깅 비용과 회귀 버그를 줄이는 데 도움이 될 수 있다.

TDD를 적용하기 좋은 영역

게임 로직

게임 개발에서는 입력과 출력이 명확한 로직에 TDD를 적용하기 좋다.

  • 데미지 및 방어력 계산
  • 경험치와 레벨업 시스템
  • 인벤토리 및 아이템 사용 규칙
  • 스킬 쿨다운
  • 전투 판정 및 상태 전이
  • 경제 시스템과 보상 계산

이러한 로직은 특정 입력에 대해 기대하는 결과를 명확하게 정의할 수 있으므로 자동화 테스트를 작성하기 쉽다.

서버 및 API

서버 개발에서도 TDD를 활용할 수 있다.

  • 요청 데이터 검증
  • 사용자 권한 검사
  • 결제 상태 처리
  • 데이터 변환 및 직렬화
  • 예외 처리
  • 비즈니스 규칙 검증

특히 결제나 보상 지급처럼 오류가 데이터 불일치로 이어질 수 있는 영역은 다양한 경계 조건을 테스트하는 것이 중요하다.

핵심 도메인 로직

복잡한 비즈니스 규칙을 처리하는 코드에도 적합하다.

요구사항이 명확하고, 입력과 출력이 정의되어 있으며, 기능 변경으로 인한 오류가 큰 비용을 발생시키는 영역일수록 테스트 주도 개발의 이점을 얻기 쉽다.

TDD를 적용하기 어려운 영역

모든 코드를 TDD로 개발해야 하는 것은 아니다.

애니메이션 연출, UI 배치, 시각 효과, 사운드 타이밍처럼 시각적 또는 감각적인 판단이 필요한 영역은 단위 테스트만으로 정상 동작을 충분히 검증하기 어렵다.

이러한 영역에서는 플레이 테스트, 통합 테스트, 시각적 검증 등을 함께 사용하는 편이 적절하다.

또한 외부 API, 데이터베이스, 네트워크, Unity 엔진 기능에 강하게 의존하는 코드에서는 테스트 환경을 구성하는 비용이 커질 수 있다. 이 경우 핵심 로직과 외부 의존성을 분리하면 테스트를 작성하고 유지하기 쉬워진다.

TDD와 테스트 자동화의 차이

TDD와 테스트 자동화는 밀접하게 관련되어 있지만 같은 개념은 아니다.

테스트 자동화는 테스트의 실행과 결과 검증을 자동화하는 것이다.

단위 테스트는 작은 코드 단위의 동작을 독립적으로 검증하는 테스트다.

TDD는 테스트를 먼저 작성하는 개발 순서를 통해 설계와 구현을 주도하는 방법론이다.

따라서 기능을 모두 구현한 다음 자동화 테스트를 작성해도 테스트 자동화는 가능하다. 하지만 테스트를 먼저 작성하지 않았다면 일반적으로 TDD 방식으로 개발했다고 보기는 어렵다.

TDD를 적용한다고 해서 모든 테스트가 반드시 단위 테스트여야 하는 것도 아니다. 다만 짧은 피드백 사이클을 유지하기 위해 실행 속도가 빠르고 독립적인 단위 테스트를 중심으로 활용하는 경우가 많다.

TDD의 장점과 한계

TDD의 주요 장점은 다음과 같다.

  • 요구사항과 기대 동작을 명확하게 정의할 수 있다.
  • 기능 변경으로 인한 회귀 버그를 조기에 발견할 수 있다.
  • 테스트 가능한 구조를 설계하도록 유도한다.
  • 리팩터링 과정에서 기존 동작을 반복적으로 검증할 수 있다.
  • 테스트 코드가 기능의 사용 방법과 동작 조건을 설명하는 문서 역할을 할 수 있다.

반면 다음과 같은 한계도 존재한다.

  • 테스트를 작성하고 유지하는 데 추가 비용이 발생한다.
  • 테스트가 부실하면 실제 결함을 발견하지 못할 수 있다.
  • 구현 세부사항에 지나치게 의존하는 테스트는 리팩터링을 어렵게 만들 수 있다.
  • 외부 시스템이나 시각적 결과에 대한 검증을 단위 테스트만으로 해결하기 어렵다.
  • 테스트 통과가 프로그램 전체의 정확성을 보장하지는 않는다.

따라서 TDD의 효과는 테스트 개수 자체보다 테스트가 실제 요구사항을 얼마나 정확하게 검증하는지에 달려 있다.

실무에서 TDD를 적용하는 방법

실무에서는 모든 기능을 한 번에 테스트하려고 하기보다, 핵심 로직 하나를 선택해 작은 사이클로 시작하는 것이 효율적이다.

먼저 요구사항을 구체적인 입력과 기대 결과로 정의한다. 다음으로 실패하는 테스트를 작성하고, 테스트를 통과시키는 최소한의 구현을 진행한다. 이후 테스트를 다시 실행하면서 코드 구조를 개선한다.

테스트가 충분히 쌓이면 CI 환경에서 자동으로 실행하도록 구성할 수 있다. 그러면 코드 변경이나 병합 과정에서 기존 테스트가 실패하는지 지속적으로 확인할 수 있다.

게임 개발이라면 데미지 계산이나 인벤토리처럼 독립적으로 검증하기 쉬운 로직부터 적용하고, 이후 서버 통신이나 복잡한 시스템으로 범위를 넓히는 방식이 현실적이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다