'TDD'보다는 'Testable' | 항해플러스 2기

Heechan Kang·2023년 10월 24일
0

항해플러스

목록 보기
1/2

이 글은 도서 <쏙쏙 들어오는 함수형 코딩> 을 읽고, 항해플러스 과정 중 TDD 챕터를 진행하며 발표한 자료를 정리한 글입니다.
도서의 내용중 '액션, 계산, 데이터' 에 관한 부분을 주로 인용하였습니다

배경

저는 개발자로 전직하기 전에, 약 5년간 육군 공병 장교로 복무했습니다.

그래서 지뢰/폭파와 관련된 훈련들을 자주 접했고, 청자분들의 흥미를 조금이라도 끌어보기 위해 이전 직장의 지식을 살짝 끌어왔습니다.

실물 폭파 훈련을 기획한다면!?


<출처 - 국방TV 유튜브 채널>

위에 보이는 사진은 MICLIC이라는 국군의 지뢰제거 장비입니다.

다른건 중요하지 않고 한가지만 생각해보면 되겠습니다.

  • 실물 폭파 훈련을 준비하기 위해, 매번 저런 폭발을 일으킬 수 있는가?
    • 위험성
    • 굉음, 산림파괴
    • 1발당 가격 약 2억 4천만원

상식적으로 매 연습마다 저런걸 직접 터뜨릴 순 없겠죠?

그렇기 때문에 실제 폭파에 필요한 모든 요소들은 단위별로 구분되어있고, 구분된 요소들을 각 수준별로 하나씩 테스트하고 모두 성공한다면 결과적으로 전체 폭파 훈련은 순조롭게 진행될 것입니다.

이처럼, 생각보다 '테스트'의 개념은 우리 생활에 밀접하게 연관되어 있었습니다.
아래에서 위와 같은 실생활에 와닿는 예제로 한번 Testable에 대해 이야기 해 보겠습니다.

Testable이란?

  • 코드가 예상대로 동작하는지 쉽게 검증 할 수 있는 코드
  • 이는 코드의 기능을 자동화된 테스트를 통해 신속하고 정확하게 확인 할 수 있게 만드는 것을 목표로 함

이 정의들이 우리가 개발자로서 흔히 접하는 'Testable'에 관한 설명입니다.

사전지식

좀 더 자세히 알아보기 위해 위에서 말한대로 구체적인 예시를 좀 들어보겠습니다.

그 전에 먼저 몇가지 간단한 개념에 대해 짚고넘어가겠습니다.


<만화에서 많이 보셨죠? 물론 군용은 다르게 생겼습니다>

  • 점화기(Detonator): 폭파를 위한 스위치라고 생각하시면 됩니다.

  • 도전선(Wire): 그냥 전선입니다. 뇌관까지 전류를 흘려줍니다.
  • 뇌관(Primer): 일부 실제 군욕 폭약들은 불속에 넣어도 터지지 않을만큼 안정적입니다. 이를 유폭시키기 위한 가장 먼저 점화되는 폭발물이라고 보시면 됩니다.
  • 폭약(Dynamite, TNT): 우리가 원하는 최종 결과물입니다. 위력이 매우 강해요.

정말 단순하게, 위쪽 Wire의 노란 부분을 점화기에 연결하고 폭파시킨다! 만 알고계시면 되겠고, 도식화 하면 아래와 같습니다

+-------+     +-----------+     +--------+     +-------+     +-----+
| bomber| --> | detonator | --> |  wire  | --> | primer| --> | TNT |
+-------+     +-----------+     +--------+     +-------+     +-----+

Testable하지 못한 코드

let isCharged = false;

/**
 * 점화기를 준비하고 폭약을 폭발시킨다. - 액션
 */
export function chargeAndExplode() {
  // TNT를 준비하고, 뇌관을 폭약에 장전합니다
  let tnt = 3;
  console.log('Primer - Arming'); // 장전

  console.log('Bomber: Charging');

  console.log('Detonator - Charged'); // 충전됨

  console.log('Bomber: Detonating'); // 점화

  // 점화기를 충전합니다.
  isCharged = true;

  let targetTerminated = false;

  if (isCharged) {
    console.log('\x1b[31m%s\x1b[0m', 'Detonator - Detonate');

    console.log('\x1b[31m%s\x1b[0m', 'Wire - Detonate');

    console.log('\x1b[31m%s\x1b[0m', 'Primer - Detonate');
    if (tnt--) {
      console.log('\x1b[31m%s\x1b[0m', 'TNT - Boom!');
    }
    isCharged = false;
    targetTerminated = true;
  } else {
    console.log('Nothing happened...');
  }

  return {
    targetTerminated,
    statusOfDetonator: isCharged,
    numOfTntLeft: tnt,
  };
}

// 함수를 실행합니다
const resultOfExplosion = chargeAndExplode();

console.log('target terminated:', resultOfExplosion.targetTerminated);
console.log('status of detonator:', resultOfExplosion.statusOfDetonator);
console.log('num of tnt left:', resultOfExplosion.numOfTntLeft);

실행 결과

일단 정상적으로 폭파는 진행되었습니다.

  • 뇌관을 폭약에 장전(설치)했고,
  • 폭파병이 점화기를 충전하고,
  • 점화기는 정상적으로 충전되었으며,
  • 폭파병이 점화기를 작동시키는 순간,

detonate가 전파되면 폭발이 일어납니다.

폭발의 결과도 좋습니다.

  • 타겟은 제거되었고,
  • 점화기는 다시 방전되었으며,
  • 폭약은 2발 남았습니다.

그런데 문제는, 이를 테스트하려면 어떻게 해야할까요?

Testable하지 못한 코드의 테스트

import { chargeAndExplode } from '../src/explosive-before';

describe('Explosion Test: Before', () => {
  it('모든 준비가 완료되면 목표가 폭파된다.', () => {
    const result = chargeAndExplode();
    expect(result.targetTerminated).toBe(true);
    expect(result.statusOfDetonator).toBe(false);
    expect(result.numOfTntLeft).toBe(2);
  });
});

제 머리로는 아무리 생각해도 테스트가 이 하나밖에 나오지 않는것 같아요.
심지어 테스트를 위해서 실제 폭파를 시켜야 합니다.


<불꽃놀이 사진입니다>

물론 TNT를 모킹 해준다면 큰 피해 없이 테스트 할 수는 있곘지만, 여전히 세부 로직은 검증할수가 없네요.

Testable한 코드를 만드는 방법

그래서, 테스터블한 코드를 어떻게 만드느냐를 알아보자면

  1. 작은 함수를 만든다.
  2. 순수한 함수를 만든다.
  3. 의존성 없는 함수를 만든다.

일반적으로 위와 같은 방법이 소개됩니다.
그런데 저는 사실, 잘 와닿지가 않더라구요.

그러던 중, 처음에 말씀드린 도서 <쏙쏙 들어오는 함수형 코딩> 에서 조금 와닿는 답을 얻을 수 있었습니다.

코드는 액션, 계산, 데이터로 구분된다

함수형 프로그래밍에서는 코드를 세 가지로 분류합니다

설명만 본다면, 모든게 다 계산이면 좋지 않을까? 싶을 수 있지만 결과적으로 프로그램의 목적은 액션에 있다는 점을 저자는 계속 강조하고있습니다.

아래는 도서에서 예로 들고있는 '액션에서 계산을 추출하는' 실생활 속 예시입니다.

즉, 점화기 테스트, 전선 테스트, 뇌관 테스트(계산, 반복가능한 테스트)를 모두 기가막히게 시행하다고 해도 결국 실물폭파(액션, 부수효과)를 산출하지 않으면 훈련(프로그램)은 의미가 없어집니다. 폭발 없는 실물폭파 훈련은 어불성설이죠.

Testable한 코드

export interface IExplodable {
  explode(): void;
}

export class TNT implements IExplodable {
  // TNT가 폭발한다 - 액션
  explode() {
    console.log('\x1b[31m%s\x1b[0m', 'TNT - Boom!');
    // 전역변수를 사용하는것을 통해, '부득이하게 mocking이 필요한 기능'을 표현
    tnts.pop();
    numOfTnt--;
    return true;
  }
}

export class Primer {
  private tnt?: IExplodable | null = null;

  // 폭발물을 준비한다 - 계산
  arm(tnt: IExplodable) {
    console.log('Primer - Arming');
    this.tnt = tnt;
    return true;
  }

  // 뇌관을 폭발시킨다 - 액션(계산)
  detonate() {
    console.log('\x1b[31m%s\x1b[0m', 'Primer - Detonate');
    if (this.tnt) {
      return this.tnt.explode();
    } else {
      return false;
    }
  }
}

export class Wire {
  constructor(private primer: Primer) {}

  // 전선을 폭발시킨다 - 액션(계산)
  detonate() {
    console.log('\x1b[31m%s\x1b[0m', 'Wire - Detonate');
    return this.primer.detonate();
  }
}

export class Detonator {
  private _charged = false;

  constructor(private wire: Wire) {}

  // 점화기를 준비한다 - 계산
  charge() {
    console.log('Detonator - Charged');
    this._charged = true;
    return true;
  }

  // 점화기를 폭발시킨다 - 액션(계산)
  detonate() {
    let targetTerminated = false;
    if (this._charged) {
      console.log('\x1b[31m%s\x1b[0m', 'Detonator - Detonate');
      this._charged = false;
      targetTerminated = this.wire.detonate() as boolean;
    } else {
      console.log('Nothing happened...');
    }
    return {
      targetTerminated,
      statusOfDetonator: this._charged,
    };
  }
}

export class Bomber {
  constructor(private detonator: Detonator) {}

  // 점화기를 충전한다 - 계산
  charge() {
    console.log('Bomber: Charging');
    this.detonator.charge();
    return true;
  }

  // 점화기를 작동시킨다 - 액션(계산)
  detonate() {
    console.log('Bomber: Detonating');
    return this.detonator.detonate();
  }
}

// 전역변수 - 데이터
let numOfTnt = 3;
const tnts: IExplodable[] = [];
for (let i = 0; i < numOfTnt; i++) {
  tnts.push(new TNT());
}

/**
 * 점화기를 준비하고 폭약을 폭발시킨다. - 액션
 */
export function chargeAndExplode2() {
  // Create TNT and arm the primer
  const tnt = tnts[0];
  const primer = new Primer();
  primer.arm(tnt);

  const detonator = new Detonator(new Wire(primer));
  const bomber = new Bomber(detonator);

  bomber.charge();
  return bomber.detonate();
}

const resultOfExplosion2 = chargeAndExplode2();

console.log('target terminated:', resultOfExplosion2.targetTerminated);
console.log('status of detonator:', resultOfExplosion2.statusOfDetonator)
console.log('num of tnt left:', tnts.length)

간소화를 위해, 일부 편의대로 작성한 부분이 있으니 양해부탁드립니다

실행 결과는 이전 함수와 동일하니 넘어가겠습니다~

위처럼 각 단계별로 함수를 구분해서 액션에서 계산을 분리해 줍니다.

  • 사실 엄밀히 말하면 explode를 호출한다는 점에서 detonate함수들도 액션이긴 합니다만, 상대적으로 쉽고 저렴하게 Mocking할 수 있다는 점에서, 이해를 돕기위해 계산으로 표시하였습니다.

이러한 함수의 분리/절차의 세분화를 통해, 아래와 같이 좀 더 세분화된 테스트를 진행 할 수 있습니다.

Testable한 코드의 테스트

import { IExplodable, Primer, Wire, Detonator, Bomber } from '../src/explosive-after';

describe('Explosion Test: After', () => {
  let tnt: IExplodable;
  let primer: Primer;
  let wire: Wire;
  let detonator: Detonator;
  let bomber: Bomber;

  beforeEach(() => {
    // Mocking the explode method of TNT to avoid console.warn
    tnt = {
      explode: jest.fn(() => true),
    };

    primer = new Primer();
    wire = new Wire(primer);
    detonator = new Detonator(wire);
    bomber = new Bomber(detonator);
  });

  it('primer로 TNT를 장전할 수 있어야 한다.', () => {
    const result = primer.arm(tnt);
    
    expect(result).toEqual(true);
  });

  it('primer가 장전되면 TNT도 폭파되어야 한다.', () => {
    primer.arm(tnt);
    const result = primer.detonate();

    expect(tnt.explode).toHaveBeenCalled();
    expect(result).toEqual(true);
  });

  it('wire가 점화되면 TNT도 폭파되어야 한다.', () => {
    primer.arm(tnt);
    const result = wire.detonate();

    expect(tnt.explode).toHaveBeenCalled();
    expect(result).toEqual(true);
  });

  it('detonator는 충전이 가능해야 한다.', () => {
    const result = detonator.charge();

    expect(result).toEqual(true);
  });

  it('TNT를 장전하지 않고 detonator를 작동하면 target이 폭파되지 않아야 한다.', () => {
    detonator.charge();
    const result = detonator.detonate();

    console.log(result);

    expect(result.targetTerminated).toBeFalsy();
    expect(result.statusOfDetonator).toBeFalsy();
  });

  it('TNT를 장전하고 detonator를 작동하면 target이 폭파되어야 한다.', () => {
    primer.arm(tnt);
    detonator.charge();
    const result = detonator.detonate();

    console.log(result);

    expect(result.targetTerminated).toBeTruthy();
    expect(result.statusOfDetonator).toBeFalsy();
  });

  it('bomber가 detonator를 충전할 수 있어야 한다.', () => {
    const result = bomber.charge();

    expect(result).toEqual(true);
  });

  it('TNT를 장전하지 않고 bomber에게 점화를 지시하면 target이 폭파되지 않아야 한다.', () => {
    bomber.charge();
    const result = bomber.detonate();

    expect(result.targetTerminated).toBeFalsy();
    expect(result.statusOfDetonator).toBeFalsy();
  });

  it('TNT를 장전하고 bomber에게 점화를 지시하면 target이 폭파되어야 한다.', () => {
    primer.arm(tnt);
    bomber.charge();
    const result = bomber.detonate();

    expect(result.targetTerminated).toBeTruthy();
    expect(result.statusOfDetonator).toBeFalsy();
  });
});

결론

결론을 요약하면 아래와 같습니다

  • 하나의 큰 액션은 테스트하기가 어렵다.
  • 액션의 내부를 잘 들여다보면, 테스트가 쉬운 테스트를 뽑아 낼 수 있다.

읽어주셔서 감사합니다.
수정사항은 피드백 주시면 감사드리겠습니다!
좋은 하루 보내세요!


코드 깃허브 - https://github.com/HC-kang/hhp-presentation

profile
안녕하세요

2개의 댓글

comment-user-thumbnail
2023년 11월 14일

안녕하세요 신입 개발자 인데요, 항해 플러스에 관심을 갖고 있어서 후기 찾아보던 중 발견해서 문의글 남겨보아요. 아직 실력이 부족한 상태인데 좀 더 기를 모은 다음 다음 내년에 신청하는게 좋을지 고민중입니다. 커리큘럼이 어느 정도 실무를 경험한 사람에게 적합하다고 보시는지 의견 여쭙고 싶습니다.

1개의 답글