본문 바로가기
개발

테스트를 작성하는 방법

by Kingbbode 2021. 6. 14.

이 글은 .NET Core 및.NET 표준을 사용하는 단위 테스트 모범 사례라는 글에 영감을 받았습니다. 글에서 제시하는 맥락에 어느정도 동의하며 이 중 자바 관점으로의 전환이 필요한 내용과 자바 개발자 사이에서 지속적으로 발견되는 문제에 대한 경험을 종합하여 작성된 글입니다.

2021.6.15
- "9. 제어 가능한 테스트" 주의사항 보완
- "4. 테스트 구성요소의 위치" 예제 버그 수정
- "4. 테스트 구성요소의 위치" 에서 "5. 테스트 환경" 내용 분리 작성
2021.6.16
- "4. 테스트 구성요소의 위치" '도우미 메서드' 에서 "메서드 추출" 로 용어 변경
2021.6.20
- "4. 테스트 구성요소의 위치" "xunitpatterns" 에서 소개하는 Implicit Setup 의 단점을 첨부


테스트는 우리가 작성한 코드가 주어진 요구사항을 해결할 수 있는지 회귀를 통하여 검증할 수 있도록 돕고, 주어진 요구사항을 테스트하므로 작성된 테스트가 문서로서 작동할 수 있으며, 의미 있는 단위의 테스트를 고민함으로써 좋은 디자인을 만드는 데에도 도움을 줍니다. 또한 테스트로 보호된 코드는 나와 동료들에게 코드의 변경을 거침없이 시도할 수 있도록 안정감과 자신감을 줍니다.

그러나 잘못 작성된 테스트는 위와 같은 테스트가 주는 장점을 수용하지 못합니다. 잘못된 테스트는 구현이 조금만 변경되어도 테스트 코드가 손상되고, 무엇을 어떻게 테스트하는지를 확인하기 위해 시간을 많이 쏟아야 하는 등 지속 가능하지 않은 코드 덩어리를 생산합니다.

이 글에서는 의미있고 지속 가능한 테스트를 만들기 위한 몇 가지 기준을 제시합니다.

1. 테스트명

테스트명은 테스트의 의도가 명확하게 드러나도록 작성합니다. 테스트의 의도는 기능적 요구사항이나 예상되는 동작을 포함합니다. (기능적 요구사항은 기획서에 표기된 요구사항이 그대로 옮겨질 수 있습니다.)

테스트 의도가 드러나지 않는 테스트명은 어떤 요구사항들이 테스트되었는지를 명확히 할 수 없으며, 모든 요구사항이 테스트되었는지 확인하기 어렵습니다. 동일한 코드라인을 사용하면서 다르게 동작할 것을 기대하는 요구사항은 충분히 발생할 수 있기 때문에 코드 커버리지로는 해소될 수 없습니다.

테스트명에 테스트의 의도를 잘 드러나면 테스트 리포트를 요구사항이 정리된 문서로 활용할 수 있으며, 테스트가 실패하였을 때 빠르게 대응할 수 있습니다.

  • 테스트 의도가 드러나는 테스트명을 사용한다면, 테스트 리포트가 요구사항이 정리된 문서로 활용될 수 있습니다.
  • 테스트의 코드와 실행 로그를 확인하지 않고도 동작을 유추할 수 있으므로 요구사항이 빠르게 공유될 수 있으며, 누락되거나 잘못된 요구사항을 빠르게 확인할 수 있습니다.
  • 테스트명을 통하여 기대치를 충족하지 못하는 시나리오를 정확히 확인할 수 있으므로 영향 범위를 빠르게 파악할 수 있으며 유추를 통하여 빠른 복구를 할 수 있습니다.

Bad

@Test
void 숫자_한개_테스트() {
    StringCalculator stringCalculator = new StringCalculator();

    int actual = stringCalculator.add("0");

    assertThat(actual).isEqualTo(0);
}

Better

@Test
void 숫자_하나를_입력_했을_때_해당_숫자를 반환한다() {
    StringCalculator stringCalculator = new StringCalculator();

    int actual = stringCalculator.add("0");

    assertThat(actual).isEqualTo(0);
}

2. 테스트 간의 관계

테스트는 상호 독립적으로 작성합니다. 독립적으로 작성된 테스트는 요구사항에 집중하는 시각을 제공하여, 객관적이고 빠른 테스트를 할 수 있도록 합니다.

  • 테스트를 추가, 제거, 수정할 때 작성되어있는 다른 테스트의 배경과 내용을 이해할 필요가 없습니다.
  • 테스트에 상호 관계가 있다면 테스트의 추가, 수정, 제거에서 전체 테스트의 맥락을 수정하여야 하는 일이 빈번하게 발생할 수 있습니다. 다른 테스트들의 배경과 내용을 파악하는 데에 많은 시간을 들이게 됩니다.

Bad

static Wallet wallet = new Wallet();

@Test
void 금액을_저장한다() {
    wallet.save(1000);

    assertThat(wallet.get()).isEqualTo(1000);
}

@Test
void 금액을_지불한다() {
    wallet.pay(500);

    assertThat(wallet.get()).isEqualTo(500);
}

@Test
void 지갑에_있는_돈보다_큰_금액을_지불할_수_없다() {
    assertThatThrownBy(() -> wallet.pay(600))
        .isInstanceOf(IllegalStateException.class);
}
  • 언뜻 보기에는 테스트 환경을 공유하는 것으로 보이지만, 상태를 공유하므로 각 테스트에 대한 조건을 공유하게 됩니다.
    • 금액을_저장한다 : wallet 에 0원이 있다는 조건
    • 금액을_지불한다 : wallet 에 1000원이 있다는 조건
    • 지갑에_있는_돈보다_큰_금액을_지불할_수_없다 : wallet에 600원 이하의 금액이 있다는 조건

Better

@Test
void 금액을_저장한다() {
    Wallet wallet = new Wallet(0);

    wallet.save(1000);

    assertThat(wallet.get()).isEqualTo(1000);
}

@Test
void 금액을_지불한다() {
    Wallet wallet = new Wallet(1000);

    wallet.pay(500);

    assertThat(wallet.get()).isEqualTo(500);
}

@Test
void 지갑에_있는_돈보다_큰_금액을_지불할_수_없다() {
    Wallet wallet = new Wallet(500);

    assertThatThrownBy(() -> wallet.pay(600))
        .isInstanceOf(IllegalStateException.class);
}

3. 테스트의 구성 요소

테스트는 조건, 행위, 검증으로 구성됩니다. 테스트 안에서 조건, 행위, 검증이 명확히 드러나도록 작성합니다.

  • 조건: 요구사항이 동작하기 위한 값이나 객체, 상태입니다.
  • 행위: 테스트하고자 하는 대상의 행위입니다.
  • 검증: 행위를 통한 기대 효과입니다.

조건, 행위, 검증이 드러나는 테스트 코드는 가독이 좋습니다. 테스트에 필요한 값, 상태를 명확히 강조하여 어떤 상태에서 테스트가 진행되는지, 무엇을 테스트하는지, 어떤 기대 효과를 원하는지를 명확히 할 수 있습니다.

Bad

@Test
void 숫자_하나를_입력_했을_때_해당_숫자를 반환한다() {
    StringCalculator stringCalculator = new StringCalculator();

    assertThat(stringCalculator.add("0")).isEqualTo(0);
}

Better

@Test
void 숫자_하나를_입력_했을_때_해당_숫자를 반환한다() {
	//given
    StringCalculator stringCalculator = new StringCalculator();

	//when
    int actual = stringCalculator.add("0");

	//then
    assertThat(actual).isEqualTo(0);
}

4. 테스트 구성요소의 위치

테스트의 구성요소(조건, 행위, 검증)는 테스트 안에 작성합니다.

  • 모든 코드가 각 테스트 내에서 볼 수 있기 때문에 테스트를 읽을 때 혼동이 적습니다.
  • 지정된 테스트에 대해 너무 많거나 너무 적게 설정될 가능성이 줄어듭니다.
  • 테스트 간 상태 공유로 테스트 간에 원치 않는 종속성이 생길 가능성이 줄어듭니다.

중복코드를 제거하는 것이 필요하다면 메서드로 추출하여 각 테스트의 맥락에서 해소할 수 있습니다.

Bad

School school;
Student student1;
Student student2;

@BeforeEach
void setUp() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    school = new School(studentRepository);
    student1 = studentRepository.save(new Student("king"));
    student2 = studentRepository.save(new Student("bbode"));
}

@Test
void 모든_학생을_조회한다() {
    List<Student> students = school.getAllStudent();

    assertThat(students).containsAll(Arrays.asList(student1, student2));
}

@Test
void 특정_학생을_조회한다() {
    Student student = school.getStudent(student1.getId())
        .orElseGet(() -> Assertions.fail("not exist"));

    assertThat(student).isEqualTo(student1);
}

@Test
void 동일한_이름의_학생은_등록할_수_없다() {
    assertThatThrownBy(() -> school.register(student1))
        .isInstanceOf(DuplicatedException.class);
}

"xunitpatterns" 에서 소개하는 Implicit Setup 의 단점을 첨부합니다.

There are several disadvantages to this approach because we are not organizing our Test Methods around a Testcase Class per Fixture. (We are using Testcase Class per Feature here.) All the Test Methods on the Testcase Class must be able to make do with the same fixture (at least as a starting point) as evidenced by the partially overridden fixture setup in the second test. The fixture is also not very obvious in these tests. Where does the flight come from? Is there anything special about it? We cannot even rename the instance variable to communicate the nature of the flight better because we are using it to hold flights with different characteristics in each test.

Better

@Test
void 모든_학생을_조회한다() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    Student student1 = studentRepository.save(new Student("king"));
    Student student2 = studentRepository.save(new Student("bbode"));

    List<Student> students = school.getAllStudent();

    assertThat(students).containsAll(Arrays.asList(student1, student2));
}

@Test
void 특정_학생을_조회한다() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    Student student1 = studentRepository.save(new Student("king"));

    Student student = school.getStudent(student1.getId())
        .orElseGet(() -> Assertions.fail("not exist"));

    assertThat(student).isEqualTo(student1);
}

@Test
void 동일한_이름의_학생은_등록할_수_없다() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    studentRepository.save(new Student("king"));

    assertThatThrownBy(() -> school.register(new Student("king")))
        .isInstanceOf(DuplicatedException.class);
}

5. 테스트 환경

테스트의 환경과 테스트 조건을 분리하여 생각합니다.

  • 테스트 환경 : 테스트 세트에서 공통 혹은 공유하여 사용될 수 있는 요소
  • 테스트 조건 : 각 테스트의 목적에 맞게 설정된 요소

테스트 환경은 전역으로 공유될 수 있으며, "setup", "tearDown" 을 통해 초기화 및 설정될 수 있습니다. 주로 테스트 환경으로 다루어지는 것은 아래와 같습니다.

  • 프레임워크의 컨텍스트 (spring framework context 등)
  • 테스트 도구 컨텍스트 (mokito context, mockmvc 등)
  • 저장소

Example

@Tag("restdocs")
@ExtendWith(RestDocumentationExtension::class)
abstract class ExampleSimpleRestDocsTest {
    private lateinit var restDocumentation: RestDocumentationContextProvider

    @BeforeEach
    fun setUp(restDocumentation: RestDocumentationContextProvider) {
        this.restDocumentation = restDocumentation
    }

    protected fun mockMvc(
        controller: Any
    ): MockMvcRequestSpecification {
        val mockMvc = createMockMvc(controller)
        return RestAssuredMockMvc.given().mockMvc(mockMvc)
    }

    private fun createMockMvc(
        controller: Any
    ): MockMvc {
        return MockMvcBuilders.standaloneSetup(controller)
            .apply<StandaloneMockMvcBuilder>(MockMvcRestDocumentation.documentationConfiguration(restDocumentation))
            .build()
    }
}

(출처 : 느려터진 Spring Rest Docs Test? SpringContext, @MockBean 없이 빠르고 효과적으로 사용하기)

테스트의 환경과 테스트 조건은 다소 구분하기 어려울 수 있습니다. 테스트 안에서 변경될 여지가 있다면 그것은 테스트의 조건일 가능성이 높습니다. 테스트 조건이 테스트 환경으로 취급되면 "2. 테스트 간의 관계", "4. 테스트 구성요소의 위치" 에서 언급되는 문제가 발생될 수 있습니다.

 

6. 테스트 조건

동일한 테스트 세트에서 테스트 대상이 되는 메서드를 테스트 조건으로 사용하지 않습니다.

  • 조건으로 사용한 메서드가 가지는 요구사항 테스트가 실패할 때 해당 테스트도 반드시 실패하게 되므로 무엇이 실패하는지 명확히 알 수 없습니다.

테스트는 특정 단위의 모듈이나 레이어에서 요구사항을 드러내는 인터페이스를 테스트합니다. 테스트 대상 외의 다른 코드들은 테스트되어 제공된다는 가정이 있기 때문에 다른 모듈을 신뢰할 수 있습니다. 반대로 현재 테스트하려는 대상 모듈은 테스트되지 않았으므로 신뢰할 수 없습니다. (다른 모듈을 사용하기 위한 배경을 파악할 때 너무 많은 맥락이 테스트에 주입되거나, 타 모듈의 테스트 결과가 대상 모듈의 테스트 결과에도 영향을 미치는 것을 원치 않는 경우 "Fake" 를 사용하는 것을 권장합니다.)

Bad

@Test
void 모든_학생을_조회한다() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    Student student1 = school.register(new Student("king"));
    Student student2 = school.register(new Student("bbode"));

    List<Student> students = school.getAllStudent();

    assertThat(students).containsAll(Arrays.asList(student1, student2));
}

Better

@Test
void 모든_학생을_조회한다() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    Student student1 = studentRepository.save(new Student("king"));
    Student student2 = studentRepository.save(new Student("bbode"));

    List<Student> students = school.getAllStudent();

    assertThat(students).containsAll(Arrays.asList(student1, student2));
}

7. 테스트 검증

테스트당 한 가지 목적의 검증을 수행하도록 작성합니다.

  • 하나의 검증이 실패하면 후속 검증은 평가되지 않으므로 전체 테스트에 대한 현황을 확인할 수 없습니다.
  • 여러 가지 목적이 검증되는 테스트는 여러가지 테스트 의도를 포함하는 테스트이므로, 테스트 의도가 명확하게 작성되지 않았을 가능성이 큽니다.

케이스를 확장할 때는 "@ParameterizedTest" 를 사용합니다.

  • 값이 할당된 각 케이스를 단일 테스트 취급하여 테스트 의도가 명확해질 수 있습니다. 테스트의 코드와 실행 로그를 확인하지 않고도 조건과 동작을 유추할 수 있으므로 요구사항이 빠르게 공유될 수 있으며, 누락되거나 잘못된 요구사항을 빠르게 확인할 수 있습니다.

Bad

@Test
void 숫자_두개를_컴마_구분자로_입력할_경우_두_숫자의_합을_반환한다() {
    StringCalculator stringCalculator = new StringCalculator();

    int actual1 = stringCalculator.calculate("0,1");
    assertThat(actual1).isEqualTo(1);

    int actual2 = stringCalculator.calculate("1,2");
    assertThat(actual2).isEqualTo(3);

    int actual3 = stringCalculator.calculate("2,3");
    assertThat(actual3).isEqualTo(5);
}

Better

@CsvSource({
	"'0,1', 1",
	"'1,2', 3",
	"'2,3', 5"
})
@ParameterizedTest
void 숫자_두개를_컴마_구분자로_입력할_경우_두_숫자의_합을_반환한다(String input, int expected) {
    StringCalculator stringCalculator = new StringCalculator();

    int actual = stringCalculator.calculate(input);
    assertThat(actual).isEqualTo(expected);
}

 

상태 공유가 반드시 필요한 시나리오 테스트라면 "@DynamicTest" 를 사용합니다. 

  • 중첩 구조로 상태를 공유할 수 있는 영역을 제공하며, 의도가 담긴 케이스를 단일 테스트 취급하여 테스트 의도가 명확해질 수 있습니다. 테스트의 코드와 실행 로그를 확인하지 않고도 조건과 동작을 유추할 수 있으므로 요구사항이 빠르게 공유될 수 있으며, 누락되거나 잘못된 요구사항을 빠르게 확인할 수 있습니다.

Bad

@Test
void 학생_등록() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);

    school.register(new Student("king"));

    List<Student> students = studentRepository.findAll();
    assertThat(students).hasSize(1)
        .extracting(Student::getName).containsExactly("king");

    assertThatThrownBy(() -> school.register(new Student("king")))
        .isInstanceOf(DuplicatedException.class);
}

Better

@TestFactory
Collection<DynamicTest> 학생_등록_시나리오() {
    StubStudentRepository studentRepository = new StubStudentRepository();
    School school = new School(studentRepository);
    return Arrays.asList(
        DynamicTest.dynamicTest("학생을 등록할 수 있다.", () -> {
            school.register(new Student("king"));

            List<Student> students = studentRepository.findAll();
            assertThat(students).hasSize(1)
                .extracting(Student::getName).containsExactly("king");
        }),
        DynamicTest.dynamicTest("동일한 이름을 갖는 학생은 등록할 수 없다. (throw DuplicatedException)", () -> {
            assertThatThrownBy(() -> school.register(new Student("king")))
                .isInstanceOf(DuplicatedException.class);
        })
    );
}

8.  테스트 코드

테스트에 작성되는 코드도 테스트의 의도가 잘 드러나도록 명확하게 작성되어야 합니다. 여러 상황을 하나의 테스트에 녹이려는 의도로 유연하게 작성된 테스트는 언제, 어디에서 실행되느냐에 따라 다른 기능을 검증하는 등 버그를 발생시킬 가능성이 큽니다.

  • 테스트가 매번 다르게 실행될 가능성이 생기므로, 의도를 모두 검증하고 있다고 신뢰할 수 없습니다.
  • 테스트의 의도를 쉽게 파악할 수 없습니다.
  • 테스트가 실패할 때 무엇으로부터 테스트가 실패하는지 명확히 알 수 없습니다.

아래 내용에 포함된다면 테스트가 유연하게 작성되어 위험한 상태가 아닌지 의심해보아야 합니다.

  • if, for, switch 등의 논리를 사용하는 테스트
  • 테스트 의도에 ~이고(and), ~이거나(or) 논리가 설정된 테스트
  • 현재 시간에 의존되는 테스트
  • 특정 실행환경(OS 등)에 의존되는 테스트

이러한 테스트는 두 개 이상의 다른 테스트로 분할하는 것이 좋습니다.

Bad

@CsvSource({
	"'0,1', 1",
	"'1,2', 3",
	"'', 0",
	", 0",
})
@ParameterizedTest
void 숫자_두개를_컴마_구분자로_입력할_경우_두_숫자의_합을_반환하며_빈_문자열_또는_null_값은_입력할_수_없다(String input, int expected) {
	StringCalculator stringCalculator = new StringCalculator();

	if(StringUtils.isEmpty(input)) {
		assertThatThrownBy(() -> stringCalculator.calculate(input))
			.isInstanceOf(IllegalArgumentException.class);
	} else {
		int actual = stringCalculator.calculate(input);
		assertThat(actual).isEqualTo(expected);
	}
}

Better

@CsvSource({
	"'0,1', 1",
	"'1,2', 3"
})
@ParameterizedTest
void 숫자_두개를_컴마_구분자로_입력할_경우_두_숫자의_합을_반환한다(String input, int expected) {
	StringCalculator stringCalculator = new StringCalculator();

	int actual = stringCalculator.calculate(input);

	assertThat(actual).isEqualTo(expected);
}

@NullAndEmptySource
@ParameterizedTest
void 빈_문자열_또는_null_값을_입력할_수_없다(String input) {
	StringCalculator stringCalculator = new StringCalculator();

	assertThatThrownBy(() -> stringCalculator.calculate(input))
		.isInstanceOf(IllegalArgumentException.class);
}

9. 제어 가능한 테스트

테스트 중인 시스템을 완전히 제어할 수 있어야 합니다. 

  • 제어할 수 없는 영역이 테스트 대상에 포함된다면 테스트가 어떤 상태에서 실행될지 모르게 되므로, 의도를 모두 검증하고 있다고 신뢰할 수 없습니다.

아래와 같은 영역을 제어할 수 없다고 정의합니다.

  • 현재 시간을 사용 (LocalDateTime.now())
  • 확률(랜덤 등)을 사용
  • 외부 시스템을 사용

제어할 수 없는 영역을 갖는 대상은 테스트를 할 수 없습니다. 대상에서 제어할 수 없는 영역을 분리하는 기법 중 2가지를 소개합니다.

다만 이것은 테스트를 가능하게 하기 위하여 비지니스 코드가 변경되는 경우입니다. 두 기법이 테스트를 위한 변경이 되지 않도록 주의해야 합니다. 테스트로 인하여 분리하거나 추상화를 고민하는 것이 테스트 대상이 가져야 할 역할과 책임에 기반하여 올바른 설계가 되는 것인지를 고민하여야 합니다. 

테스트가 좋은 디자인을 만드는 데에도 도움을 준다는 것은 "테스트 가능하게 만들다보면 좋은 디자인이 된다"가 아니라 "테스트를 만들다보면 현재의 디자인이 문제가 있는지 혹은 개선이 가능한지를 의심해 볼 수 있게 된다" 입니다.

(참고: [OKKYCON: 2018] 정진욱 - 테스트하기 쉬운 코드로 개발하기 , 스프링캠프 2019 : 무엇을 테스트할 것인가? 어떻게 테스트할 것인가? )

"Boundary Layer" 까지 제어할 수 없는 대상을 올려 테스트 가능한 영역을 넓히는 방법

Bad

class Scheduler {
	private final List<Schedule> schedules;

	public Scheduler(List<Schedule> schedules) {
		this.schedules = schedules;
	}

	public List<Schedule> getTodaySchedule() {
		return schedules.stream()
			.filter(schedule -> schedule.time.toLocalDate().equals(LocalDate.now()))
			.collect(Collectors.toList());
	}
}

@Test
void 오늘_스케줄을_조회한다() {
	LocalDate now = LocalDate.now();
	Schedule schedule = new Schedule("얀센 접종", now.atTime(LocalTime.of(11, 0)));
	Schedule schedule2 = new Schedule("타이레놀 복용", now.atTime(LocalTime.of(14, 0)));
	Schedule schedule3 = new Schedule("타이레놀 복용", now.atTime(LocalTime.of(22, 0)));
	Schedule schedule4 = new Schedule("타이레놀 복용", now.plusDays(1).atTime(LocalTime.of(6, 0)));
	Scheduler scheduler = new Scheduler(Arrays.asList(schedule, schedule2, schedule3, schedule4));

	List<Schedule> todaySchedule = scheduler.getTodaySchedule();

	assertThat(todaySchedule).containsExactly(schedule, schedule2, schedule3);
}
  • 이 테스트는 평소에는 성공하지만, 자정이 넘어갈 때 가끔 실패합니다.

Better

class Scheduler {
	private final List<Schedule> schedules;

	public Scheduler(List<Schedule> schedules) {
		this.schedules = schedules;
	}

	public List<Schedule> getScheduleAt(LocalDate at) {
		return schedules.stream()
			.filter(schedule -> schedule.time.toLocalDate().equals(at))
			.collect(Collectors.toList());
	}
}

@Test
void 특정_날짜의_스케줄을_조회한다() {
	LocalDate at = LocalDate.of(2021,3,15);

	Schedule schedule = new Schedule("얀센 접종", at.atTime(LocalTime.of(11, 0)));
	Schedule schedule2 = new Schedule("타이레놀 복용", at.atTime(LocalTime.of(14, 0)));
	Schedule schedule3 = new Schedule("타이레놀 복용", at.atTime(LocalTime.of(22, 0)));
	Schedule schedule4 = new Schedule("타이레놀 복용", at.plusDays(1).atTime(LocalTime.of(6, 0)));
	Scheduler scheduler = new Scheduler(Arrays.asList(schedule, schedule2, schedule3, schedule4));

	List<Schedule> todaySchedule = scheduler.getScheduleAt(at);

	assertThat(todaySchedule).containsExactly(schedule, schedule2, schedule3);
}

 

제어할 수 없는 대상을 인터페이스로 분리하여 테스트 대상에서 제외시키는 방법

Bad

@RepeatedTest(1000)
void 중복되지않은_무작위_숫자로_로또_티켓을_발행한다() {
	LottoMachine lottoMachine = new LottoMachine();

	List<Integer> actual = lottoMachine.generateTicket();

	assertThat(new HashSet<>(actual)).hasSize(actual.size());
}
  • 아무리 많은 반복 테스트를 하여도 테스트 의도에 확률이 담겨 있으므로, 신뢰할 수 없는 테스트입니다.

Better

interface NumberGenerator {
	List<Integer> generate(int size);
}

static class LottoMachine {
	private final NumberGenerator numberGenerator;

	public LottoMachine(NumberGenerator numberGenerator) {
		this.numberGenerator = numberGenerator;
	}
    ...
}

@Test
void 중복되지않은_로또_티켓을_발행한다() {
	FixedPopNumberGenerator numberGenerator = new FixedPopNumberGenerator(Arrays.asList(1,1,2,3,3,4,5,6,7,8,9));
	LottoMachine lottoMachine = new LottoMachine(numberGenerator);

	List<Integer> actual = lottoMachine.generateTicket();

	assertThat(actual).containsExactly(1,2,3,4,5,6);
}

 

댓글