Describe - Context - It 패턴

내가 선호하는 BDD 테스트 코드 작성 패턴이다.

이 패턴은 코드를 설명하는 테스트 코드를 작성하는 패턴이다. Better Specs에 잘 설명되어 있으므로, 관심이 있다면 정독한다.

이 패턴은 Describe, Context, It 세 단어를 핵심 키워드로 삼는다.

  • Describe는 설명할 테스트 대상을 명시하는 역할을 한다.
  • Context는 테스트 대상이 특정 상황에 놓인 경우를 명시한다.
    • 영어로 Context문을 작성할 때에는 반드시 with 또는 when으로 시작하도록 한다.
  • It은 테스트 대상의 행동을 설명한다.
    • It returns true, It responds 404와 같이 심플하게 설명할수록 좋다.

이 방식은 테스트 코드를 계층 구조로 만들어 주기 때문에 테스트 코드를 작성하거나 읽거나 분석할 때 스코프 범위만 신경쓰면 된다는 장점이 있다. 한편 "빠뜨린" 테스트 코드를 찾기 쉽기 때문에 높은 테스트 커버리지가 필요한 경우 큰 도움이 된다.

JUnit5의 @Nested를 사용한 계층 구조의 D-C-I 테스트 코드를 작성

반드시 계층 구조가 아니어도 D-C-I 패턴의 테스트 코드를 작성하는 것은 가능하다.

그러나 테스트 코드가 계층 구조를 이루면 테스트 결과를 보기 좋다는 장점이 있다.

Java에서는 다른 언어와 달리 메소드 내부에 메소드를 곧바로 만들 수가 없고, JUnit4가 이너 클래스로 작성한 테스트 코드를 특별히 처리하지 않아서 애매한 느낌이었는데 JUnit5의 @Nested를 사용하면 계층 구조의 테스트 코드를 작성할 수 있다는 것을 알게 되었다.

다음은 가볍게 작성한 클래스 하나를 테스트하는 코드를 IntelliJ에서 돌려본 결과를 캡처한 것이다.

계층 구조이기 때문에 특정 범위를 폴드하는 것도 가능하다.

위의 테스트를 가동하는 데에 사용한 소스코드는 내 저장소에서 볼 수 있다.

참고: DescribeContext는 생략해도 무방하다. 다른 언어의 BDD 프레임워크는 보통 DescribeContext가 함수 이름이기 때문에 굳이 설명으로 작성하지 않는다.

한국어로 테스트 설명을 작성하기

앞에서 소개한 테스트 코드는 Describe, Context, It 과 같은 단어가 불필요하게 추가되어 있는 느낌이 강했다.

한국어로 작성한다면 다음과 같은 간단한 기준을 따르면 될 것 같다.

  • Describe는 테스트 대상을 명사로 작성한다.
  • Context~인 경우, ~할 때, 만약 ~ 하다면 과 같이 상황 또는 조건을 기술한다.
  • It은 위에서 명사로 작성한 테스트 대상의 행동을 작성한다.
    • 테스트 대상의 행동은 ~이다, ~한다, ~를 갖는다가 적절한다.
    • ~된다 같은 수동형 표현은 좋지 않다.

새롭게 작성한 테스트 문구

즉, BDD가 테스트 대상의 행동을 묘사하는 방식이라는 것을 염두에 두고 작성하면 된다.

구체적으로는 다음과 같이 이어서 읽었을 때 비문이 아닌 하나의 좋은 문장이 되도록 작성하는 것이 중요하다.

"ComplexNumber 클래스의 toString 메소드 실수값과 허수값이 있다면, 실수부 + 허수부i 형식으로 표현한 문자열을 리턴한다"

보통 저지르기 쉬운 실수는 다음과 같은 것이다.

@DisplayName("toString 메소드")
// 생략
@DisplayName("만약 실수값만 있고 허수값이 없다면")
// 생략
@DisplayName("실수부만 표현한 문자열이 된다")
// 생략

이와 같이 작성하면, 다음과 같은 이상한 문장이 된다.

"ComplexNumber 클래스의 toString 메소드 만약 실수값만 읽고 허수값이 없다면, 실수부만 표현한 문자열이 된다"

"toString 메소드는… 문자열이 된다" 이므로 올바른 문장이 아니다.

이와 같이 하나의 완전한 문장이 되는지 체크하며 작성하는 습관을 기를 필요가 있다.

다음은 코드 전문이다.

package com.johngrib.example;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("ComplexNumber 클래스")
class ComplexNumberKoTest {

  @Nested
  @DisplayName("of 메소드")
  class DescribeAdd {
    private final double givenReal = 3d;
    private final double givenImagine = 3d;

    @Nested
    @DisplayName("만약 실수값만 주어지고 허수값은 없다면")
    class Context_with_natural_only {
      @Test
      @DisplayName("i 값이 0 인 복소수를 리턴한다")
      void it_has_0_imagine_value() {
        final ComplexNumber given = ComplexNumber.of(givenReal);

        assertThat(given.getImagine(), is(0d));
        assertThat(given.getReal(), is(givenReal));
      }
    }
    @Nested
    @DisplayName("만약 실수값과 허수값이 주어진다면")
    class Context_with_imagine_only {
      @Test
      @DisplayName("주어진 실수값과 허수값을 갖는 복소수를 리턴한다")
      void it_has_0_imagine_value() {
        final ComplexNumber given = ComplexNumber.of(givenReal, givenImagine);

        assertThat(given.getReal(), is(givenReal));
        assertThat(given.getImagine(), is(givenImagine));
      }
    }
  }

  @Nested
  @DisplayName("Sum 메소드")
  class DescribeSum {
    @Nested
    @DisplayName("만약 실수부와 허수부가 있는 두 복소수가 주어진다면")
    class Context_with_two_complex {
      private ComplexNumber a, b;

      @BeforeEach
      void prePareNumbers() {
        a = ComplexNumber.of(1d, 2d);
        b = ComplexNumber.of(32d, 175d);
      }

      ComplexNumber subject() {
        return ComplexNumber.sum(a, b);
      }

      @Test
      @DisplayName("두 실수 값의 합을 실수값으로 갖는 복소수를 리턴한다")
      void it_returns_complex_has_each_real_sum() {
        final double expect = a.getReal() + b.getReal();
        final double result = subject().getReal();

        assertThat(result, is(expect));
      }

      @Test
      @DisplayName("두 허수 값의 합을 허수값으로 갖는 복소수를 리턴한다")
      void it_returns_complex_has_each_imagine_sum() {
        final double expect = a.getImagine() + b.getImagine();
        final double result = subject().getImagine();

        assertThat(result, is(expect));
      }
    }
  }

  @Nested
  @DisplayName("toString 메소드")
  class GivenToString {
    @Nested
    @DisplayName("만약 실수값만 있고 허수값이 없다면")
    class Context_with_naturals {
      private final double givenNatual = 3d;
      private final String expectPattern = "^3(?:\\.0+)?$";
      private ComplexNumber given = ComplexNumber.of(givenNatual);

      @Test
      @DisplayName("실수부만 표현한 문자열을 리턴한다")
      void it_has_0_imagine_value() {
        assertTrue(given.toString().matches(expectPattern));
      }
    }

    @Nested
    @DisplayName("실수값과 허수값이 있다면")
    class Context_with_imagine {
      private final double givenNatual = 3d;
      private final double givenImagine = 7d;
      private ComplexNumber given = ComplexNumber.of(givenNatual, givenImagine);
      private String expectPattern = "^3(?:\\.0+)?\\+7(?:\\.0+)?i$";

      @Test
      @DisplayName("실수부 + 허수부i 형식으로 표현한 문자열을 리턴한다")
      void it_has_0_imagine_value() {
        assertTrue(given.toString().matches(expectPattern));
      }
    }
  }
}

한편 html 보고서를 출력하면 다음과 같이 나온다.

subject 메소드의 사용

위의 예제를 잘 살펴봤다면 subject 메소드를 발견했을 것이다.

ComplexNumber subject() {
  return ComplexNumber.sum(a, b);
}

@Test
@DisplayName("두 실수 값의 합을 실수값으로 갖는 복소수를 리턴한다")
void it_returns_complex_has_each_real_sum() {
  final double expect = a.getReal() + b.getReal();
  final double result = subject().getReal();

  assertThat(result, is(expect));
}

@Test
@DisplayName("두 허수 값의 합을 허수값으로 갖는 복소수를 리턴한다")
void it_returns_complex_has_each_imagine_sum() {
  final double expect = a.getImagine() + b.getImagine();
  final double result = subject().getImagine();

  assertThat(result, is(expect));
}

subject 메소드는 테스트 대상을 실행하는 코드를 캡슐화하는 역할을 한다.

이를 통해 테스트 대상이 되는 코드의 시그니처가 변경되었을 때 여러 개의 테스트가 줄줄이 깨져나가는 상황을 쉽게 고칠 수 있다.

만약 위의 예에서 subject 메소드를 사용하지 않고 it_returns_complex_has_each_real_sum 메소드와 it_returns_complex_has_each_imagine_sum 메소드에 그냥 ComplexNumber.sum(a,b)가 있었다면, ComplexNumber.sum 메소드의 인자 타입이나 갯수가 변경될 경우 두 테스트 코드를 수정해야 했을 것이다. 그러나 subject 메소드가 있다면 subject 하나만 고치면 된다.

subject는 특히 테스트 코드가 많이 딸린 복잡한 코드의 테스트에서 빛을 발한다.

또한, subject 를 잘 정의하면 복잡한 Context가 많을 경우 핵심 부분만 뽑아낸 Context를 상속하여 해당 Context의 조건을 오버라이드하는 방식으로 사용할 수도 있다.

타 언어 테스트 프레임워크의 D-C-I 패턴

Ruby

다음은 Better Specs에서 인용한 것이다.

describe '#destroy' do

  context 'when resource is found' do
    it 'responds with 200'
    it 'shows the resource'
  end

  context 'when resource is not found' do
    it 'responds with 404'
  end

  context 'when resource is not owned' do
    it 'responds with 404'
  end
end

Go - Ginkgo

Go 언어에서는 Ginkgo 테스트 프레임워크를 사용해 Describe - Context - It 패턴의 테스트 코드를 작성할 수 있다.

다음은 Ginkgo: A Golang BDD Testing Framework에서 인용한 것이다.

var _ = Describe("Book", func() {
    var (
        longBook  Book
        shortBook Book
    )

    BeforeEach(func() {
        longBook = Book{
            Title:  "Les Miserables",
            Author: "Victor Hugo",
            Pages:  1488,
        }

        shortBook = Book{
            Title:  "Fox In Socks",
            Author: "Dr. Seuss",
            Pages:  24,
        }
    })

    Describe("Categorizing book length", func() {
        Context("With more than 300 pages", func() {
            It("should be a novel", func() {
                Expect(longBook.CategoryByLength()).To(Equal("NOVEL"))
            })
        })

        Context("With fewer than 300 pages", func() {
            It("should be a short story", func() {
                Expect(shortBook.CategoryByLength()).To(Equal("SHORT STORY"))
            })
        })
    })
})

PHP - Kahan

Php 언어에서는 Kahlan으로 Describe - Context - It 패턴의 테스트 코드를 작성할 수 있다.

Kahlan을 쓰면 매우 세련된 느낌의 테스트 코드를 작성할 수 있었다. Php가 주력 언어가 아닌데도 Kahlan을 사용하면서 굉장히 좋다는 느낌을 받았었다.

다음은 Kahlan github의 README.md에서 인용한 것이다.

<?php

describe("Example", function() {

    it("makes an expectation", function() {

         expect(true)->toBe(true);

    });

    it("expects methods to be called", function() {

        $user = new User();
        expect($user)->toReceive('save')->with(['validates' => false]);
        $user->save(['validates' => false]);

    });

    it("stubs a function", function() {

        allow('time')->toBeCalled()->andReturn(123);
        $user = new User();
        expect($user->save())->toBe(true)
        expect($user->created)->toBe(123);

    });

    it("stubs a class", function() {

        allow('PDO')->toReceive('prepare', 'fetchAll')->andReturn([['name' => 'bob']]);
        $user = new User();
        expect($user->all())->toBe([['name' => 'bob']]);

    });

});

Kotlin - Spek

Kotlin에는 Spek이 있다.

object CalculatorSpec: Spek({
    describe("A calculator") {
        val calculator by memoized { Calculator() }

        describe("addition") {
            it("returns the sum of its arguments") {
                assertEquals(3, calculator.add(1, 2))
            }
        }
    }
})