예제로 테스트하기

게시 됨: 2022-02-15

우리는 테스트와 관련된 모든 것에 대한 블로그 시리즈를 계속하고 있습니다. 이 블로그에서는 실제 사례에 초점을 맞추고 있습니다.

이 게시물의 예제는 JUnit 5 및 AssertJ를 사용하여 작성되었지만 수업은 다른 모든 단위 테스트 프레임워크에 적용할 수 있습니다.

JUnit은 Java용으로 가장 널리 사용되는 테스트 프레임워크입니다. AssertJ는 개발자가 보다 표현적인 테스트를 작성하는 데 도움이 되는 Java 라이브러리입니다.

기본 테스트 구조

우리가 살펴볼 테스트의 첫 번째 예는 2개의 숫자를 더하는 간단한 계산기입니다.

 클래스 계산기는 {

     @테스트 // 1
     무효 합계() {
         계산기 계산기 = new Calculator(); // 2
         int 결과 = 계산기.sum(1, 2); // 삼
         assertThat(결과).isEqualTo(3); // 4
     }
}

모든 메서드 이름에서 shouldtest 를 반복하지 않도록 테스트를 작성할 때 ClassShould 명명 규칙을 사용하는 것을 선호합니다. 여기에서 자세한 내용을 읽을 수 있습니다.

위의 테스트는 무엇을 합니까?

테스트를 한 줄씩 나누어 보겠습니다.

  1. @Test 주석을 사용하면 JUnit 프레임워크에서 테스트로 실행할 메서드를 알 수 있습니다. 테스트가 아닌 테스트 클래스에 private 메서드가 있는 것은 지극히 정상입니다.
  2. 이것은 테스트 환경을 준비하는 테스트의 준비 단계입니다. 이 테스트에 필요한 것은 Calculator 인스턴스만 있으면 됩니다.
  3. 이것은 우리가 테스트하려는 동작을 트리거하는 동작 단계입니다.
  4. 이것은 발생한 일과 모든 것이 예상대로 해결되었는지 검사하는 어설션 단계입니다. assertThat(result) 메서드는 AssertJ 라이브러리의 일부이며 여러 오버로드가 있습니다.

각 오버로드는 특수화된 Assert 개체를 반환합니다. 반환된 객체에는 assertThat 메서드에 전달한 객체에 대해 의미가 있는 메서드가 있습니다. 우리의 경우 그 객체는 정수 테스트를 위한 메서드가 있는 AbstractIntegerAssert 입니다. isEqualTo(3)result == 3 인지 확인합니다. 그렇다면 테스트는 통과하고 그렇지 않으면 실패합니다.

이 블로그 게시물에서는 구현에 중점을 두지 않습니다.

Arrange , Act , Assert 에 대한 또 다른 사고 방식 은 Given , When , Then 입니다 .

sum 구현을 작성한 후 다음과 같은 몇 가지 질문을 할 수 있습니다.

  • 이 테스트에서 어떻게 개선할 수 있습니까?
  • 내가 다루어야 할 더 많은 테스트 케이스가 있습니까?
  • 양수와 음수를 더하면 어떻게 됩니까? 두 개의 음수? 하나는 긍정적이고 하나는 부정적인가?
  • 정수 값을 오버플로하면 어떻게 됩니까?

이러한 경우를 추가하고 기존 테스트 이름을 약간 개선해 보겠습니다.

구현 시 오버플로를 허용하지 않습니다. sum 가 오버플로되면 대신 ArithmeticException 이 발생합니다.

 클래스 계산기는 {

     개인 계산기 계산기 = new Calculator();

     @테스트
     무효 sumPositiveNumbers() {
         정수 합계 = 계산기.sum(1, 2);
         assertThat(sum).isEqualTo(3);
     }

     @테스트
     무효 sumNegativeNumbers() {
         정수 합계 = 계산기.sum(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @테스트
     무효 sumPositiveAndNegativeNumbers() {
         정수 합계 = 계산기.sum(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @테스트
     무효 failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() ->calculator.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(산술예외.클래스);
     } 

}

JUnit은 각 @Test 메소드를 실행하기 전에 CalculatorShould 의 새 인스턴스를 생성합니다. 즉, 각 CalculatorShould 에는 다른 calculator 가 있으므로 모든 테스트에서 이를 인스턴스화할 필요가 없습니다.

shouldFailWithArithmeticExceptionWhenOverflown 테스트는 다른 종류의 assert 를 사용합니다. 코드 조각이 실패했는지 확인합니다. assertThatThrownBy 메서드는 우리가 제공한 람다를 실행하고 실패했는지 확인합니다. 이미 알고 있듯이 모든 assertThat 메서드는 어떤 유형의 예외가 발생했는지 확인할 수 있도록 특수화된 Assert 를 반환합니다.

이것은 우리가 예상할 때 코드가 실패하는지 테스트하는 방법의 예입니다. 어느 시점에서든 Calculator 를 리팩터링하고 오버플로에 대해 ArithmeticException 을 throw하지 않으면 테스트가 실패합니다.

ObjectMother 디자인 패턴

다음 예제는 Person 인스턴스가 유효한지 확인하기 위한 유효성 검사기 클래스입니다.

 클래스 PersonValidatorShould {

    개인 PersonValidator 유효성 검사기 = new PersonValidator();

    @테스트
    무효 failWhenNameIsNull() {
        사람 사람 = new Person(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(사람))
            .isInstanceOf(InvalidPersonException.class);
    }

    @테스트
    무효 failWhenAgeIsNegative() {
        Person person = new Person("John", -5, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(사람))
            .isInstanceOf(InvalidPersonException.class);

    }
}

ObjectMother 디자인 패턴은 테스트에서 인스턴스화 세부 정보를 숨기기 위해 복잡한 개체를 만드는 테스트에서 자주 사용됩니다. 여러 테스트를 통해 동일한 개체를 만들 수도 있지만 다른 항목을 테스트할 수도 있습니다.

테스트 #1은 테스트 #2와 매우 유사합니다. 개인 메서드로 유효성 검사를 추출하여 PersonValidatorShould 를 리팩터링한 다음 모두 동일한 방식으로 실패할 것으로 예상하는 잘못된 Person 인스턴스를 전달할 수 있습니다.

 클래스 PersonValidatorShould {

     개인 PersonValidator 유효성 검사기 = new PersonValidator();

     @테스트
     무효 failWhenNameIsNull() {
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @테스트
     무효 failWhenAgeIsNegative() {
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     private void shouldFailValidation(Person invalidPerson) {
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

무작위성 테스트

코드에서 임의성을 테스트하려면 어떻게 해야 합니까?

임의의 Person 인스턴스를 generateRandom 하기 위해 generateRandom이 있는 PersonGenerator 가 있다고 가정해 보겠습니다.

다음을 작성하는 것으로 시작합니다.

 클래스 PersonGenerator는 {

     개인 PersonGenerator 생성기 = new PersonGenerator();

     @테스트
     무효 생성ValidPerson() {
         사람 사람 = generator.generateRandom();
         주장하다(사람).
    }
}

그리고 다음과 같이 자문해야 합니다.

  • 내가 여기서 무엇을 증명하려고 합니까? 이 기능은 무엇을 해야 합니까?
  • 생성된 사람이 null이 아닌 인스턴스인지 확인해야 합니까?
  • 무작위임을 증명해야 합니까?
  • 생성된 인스턴스가 일부 비즈니스 규칙을 따라야 합니까?

의존성 주입을 사용하여 테스트를 단순화할 수 있습니다.

 공개 인터페이스 RandomGenerator {
     문자열 생성RandomString();
     정수 생성RandomInteger();
}

이제 PersonGenerator 에는 해당 인터페이스의 인스턴스도 허용하는 또 다른 생성자가 있습니다. 기본적으로 java.Random 을 사용하여 임의의 값을 생성하는 JavaRandomGenerator 구현을 사용합니다.

그러나 테스트에서 더 예측 가능한 또 다른 구현을 작성할 수 있습니다.

 @테스트
 무효 생성ValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     PersonGenerator 생성기 = 새로운 PersonGenerator(randomGenerator);
     사람 사람 = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

이 테스트는 RandomGeneratorPersonGenerator 의 세부 사항에 들어가지 않고 RandomGenerator 에 지정된 대로 임의의 인스턴스를 생성한다는 것을 증명합니다.

java.Random 를 테스트하는 것은 JavaRandomGenerator 에 대한 간단한 래퍼이기 때문에 실제로 값을 추가하지 않습니다. 그것을 테스트함으로써 당신은 본질적으로 Java 표준 라이브러리에서 java.Random 을 테스트하게 될 것입니다. 명백한 테스트를 작성하는 것은 이점이 거의 없는 추가 유지 관리로만 이어질 것입니다.

PredictableGenerator 와 같은 테스트 목적으로 구현을 작성하지 않으려면 Mockito와 같은 모의 라이브러리를 사용해야 합니다.

PredictableGenerator 를 작성할 때 실제로 RandomGenerator 클래스를 수동으로 스텁했습니다. Mockito를 사용하여 스텁할 수도 있습니다.

 @테스트
 무효 생성ValidPerson() {
     RandomGenerator randomGenerator = 모의(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("John Doe");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator 생성기 = 새로운 PersonGenerator(randomGenerator);
     사람 사람 = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

테스트를 작성하는 이 방법은 표현이 더 풍부하고 특정 테스트에 대한 구현이 줄어듭니다.

Mockito는 목과 스텁을 작성하기 위한 Java 라이브러리입니다. 쉽게 인스턴스화할 수 없는 외부 라이브러리에 의존하는 코드를 테스트할 때 매우 유용합니다. 이를 통해 직접 구현하지 않고 이러한 클래스에 대한 동작을 작성할 수 있습니다.

Mockito는 또한 우리가 익숙한 것과 유사한 테스트가 하나 이상 있을 때 상용구를 줄이기 위해 mock을 생성하고 주입하는 또 다른 구문을 허용합니다.

 @ExtendWith(MockitoExtension.class) // 1
 클래스 PersonGenerator는 {

     @모의 // 2
     랜덤제너레이터 랜덤제너레이터;

     @InjectMocks // 3
     개인 PersonGenerator 생성기;

     @테스트
     무효 생성ValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         사람 사람 = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5는 "확장"을 사용하여 기능을 확장할 수 있습니다. 이 주석을 사용하면 주석을 통해 모의 객체를 인식하고 적절하게 주입할 수 있습니다.

2. @Mock 주석은 필드의 모의 인스턴스를 생성합니다. 이것은 테스트 메소드 본문에 mock(RandomGenerator.class) 를 작성하는 것과 동일합니다.

3. @InjectMocks 주석은 PersonGenerator 의 새 인스턴스를 생성하고 generator 인스턴스에 모의를 주입합니다.

JUnit 5 확장에 대한 자세한 내용은 여기를 참조하십시오.

Mockito 주입에 대한 자세한 내용은 여기를 참조하십시오.

@InjectMocks 사용에는 한 가지 함정이 있습니다. 개체의 인스턴스를 수동으로 선언할 필요가 없어질 수 있지만 생성자의 컴파일 시간 안전성을 잃게 됩니다. 어떤 시점에서 누군가가 생성자에 다른 종속성을 추가하면 여기에서 컴파일 시간 오류가 발생하지 않습니다. 이는 감지하기 쉽지 않은 테스트 실패로 이어질 수 있습니다. @BeforeEach 를 사용하여 인스턴스를 수동으로 설정하는 것을 선호합니다.

 @ExtendWith(MockitoExtension.class)
클래스 PersonGenerator는 {

     @모조품
     랜덤제너레이터 랜덤제너레이터;

     개인 PersonGenerator 생성기;

     @BeforeEach
     무효 설정() {
         생성기 = 새로운 PersonGenerator(randomGenerator);
     }

     @테스트
     무효 생성ValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         사람 사람 = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

시간에 민감한 프로세스 테스트

코드 조각은 종종 타임스탬프에 종속되며 System.currentTimeMillis() 와 같은 메서드를 사용하여 현재 에포크 타임스탬프를 얻는 경향이 있습니다.

이것은 괜찮아 보이지만 클래스가 내부적으로 결정을 내릴 때 코드가 올바르게 작동하는지 테스트하고 증명하기가 어렵습니다. 그러한 결정의 예는 현재 날짜를 결정하는 것입니다.

 클래스 인덱서는 {
     개인 인덱서 인덱서 = new Indexer();
     @테스트
     무효 생성IndexNameForTomorrow() {
         문자열 indexName = indexer.tomorrow("my-index");
         // 이 테스트는 오늘 작동하지만 내일은 어떻습니까?
        assertThat(인덱스 이름)
           .isEqualTo("my-index.2022-02-02");
     }
}

인덱스 이름을 생성할 때 요일을 '제어'하려면 Dependency Injection을 다시 사용해야 합니다.

Java에는 이와 같은 사용 사례를 처리하는 Clock 클래스가 있습니다. Clock 인스턴스를 Indexer 에 전달하여 시간을 제어할 수 있습니다. 기본 생성자는 이전 버전과의 호환성을 위해 Clock.systemUTC() 를 사용할 수 있습니다. 이제 System.currentTimeMillis() 호출을 clock.millis() 로 바꿀 수 있습니다.

Clock 을 주입하여 클래스에서 예측 가능한 시간을 적용하고 더 나은 테스트를 작성할 수 있습니다.

파일 생성 방법 테스트

  • 출력을 파일에 쓰는 클래스를 어떻게 테스트해야 할까요?
  • 모든 OS에서 작동하려면 이 파일을 어디에 저장해야 합니까?
  • 파일이 이미 존재하지 않는지 어떻게 확인할 수 있습니까?

파일을 다룰 때 다음 예제에서 볼 수 있듯이 이러한 문제를 스스로 해결하려고 하면 테스트를 작성하기 어려울 수 있습니다. 다음 테스트는 의심스러운 품질의 오래된 테스트입니다. DogToCsvWriter 가 개를 직렬화하고 CSV 파일에 쓰는지 테스트해야 합니다.

 클래스 DogToCsvWriterShould {

     개인 DogToCsvWriter 작성자 = new DogToCsvWriter("/tmp/dogs.csv");
     
     @테스트
     무효 convertToCsv() {
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         문자열 csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("몬티, 코기, 갈색\n조이, 말티즈, 흰색");
     }
}

직렬화 프로세스는 작성 프로세스와 분리되어야 하지만 테스트를 수정하는 데 집중합시다.

위 테스트의 첫 번째 문제는 Windows 사용자가 /tmp/dogs.csv 경로를 확인할 수 없기 때문에 Windows에서 작동하지 않는다는 것입니다. 또 다른 문제는 위의 테스트를 실행할 때 파일이 삭제되지 않기 때문에 파일이 이미 존재하는 경우 작동하지 않는다는 것입니다. CI/CD 파이프라인에서는 정상적으로 작동하지만 여러 번 실행하면 로컬에서 작동하지 않을 수 있습니다.

JUnit 5에는 프레임워크에 의해 생성 및 삭제되는 임시 디렉토리에 대한 참조를 얻는 데 사용할 수 있는 주석이 있습니다. 임시 파일을 만들고 삭제하는 메커니즘은 프레임워크마다 다르지만 아이디어는 동일합니다.

 클래스 DogToCsvWriterShould {

     @테스트
     무효 convertToCsv(@TempDir 경로 tempDir) {
         경로 dogCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter 작가 = 새로운 DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         문자열 csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("몬티, 코기, 갈색\n조이, 말티즈, 흰색");
     }
}

이 작은 변경으로 이제 위의 테스트가 절대 경로에 대해 걱정할 필요 없이 Windows, macOS 및 Linux에서 작동할 것이라고 확신합니다. 또한 테스트 후에 생성된 파일을 삭제하므로 이제 여러 번 실행하고 매번 예측 가능한 결과를 얻을 수 있습니다.

명령 대 쿼리 테스트

명령과 쿼리의 차이점은 무엇입니까?

  • 명령 : 값을 반환하지 않고 효과를 생성하는 작업을 수행하도록 개체에 지시합니다(void 메서드).
  • 쿼리 : 객체에 작업을 수행하고 결과 또는 예외를 반환하도록 요청합니다.

지금까지 우리는 행동 단계에서 값을 반환하거나 예외를 throw한 메서드를 호출한 쿼리를 주로 테스트했습니다. 어떻게 void 메서드를 테스트하고 다른 클래스와 올바르게 상호 작용하는지 확인할 수 있습니까? 프레임워크는 이러한 종류의 테스트를 작성하기 위한 다양한 방법 세트를 제공합니다.

지금까지 쿼리에 대해 작성한 주장은 assertThat 으로 시작했습니다. 명령 테스트를 작성할 때 쿼리에서와 같이 메서드의 직접적인 결과를 더 이상 검사하지 않기 때문에 다른 메서드 집합을 사용합니다. 우리는 우리 방법이 시스템의 다른 부분과 상호 작용을 '검증'하기를 원합니다.

 @ExtendWith(MockitoExtension.class)
 클래스 FeedMentionServiceShould {

     @모조품
     개인 FeedRepository 저장소;

     @모조품
     개인 FeedMentionEventEmitter 이미터;

     개인 FeedMentionService 서비스;

     @BeforeEach
     무효 설정() {
         서비스 = 새 FeedMentionService(저장소, 이미터);
     }

     @테스트
     무효 insertMentionToFeed() {
         긴 feedId = 1L;
         언급 언급 = ...;

         when(repository.upsertMention(feedId, 언급))
             .thenReturn(UpsertResult.success(feedId, 언급));

         FeedInsertionEvent 이벤트 = 새로운 FeedInsertionEvent(feedId, 언급);
         멘션서비스.삽입멘션투피드(이벤트);

         확인(이미터).mentionInsertedToFeed(feedId, 언급);
         verifyNoMoreInteractions(이미터);
     }
}

이 테스트에서 우리는 먼저 피드에서 멘션을 업서트하라는 요청을 받았을 때 UpsertResult.success 로 응답하도록 리포지토리를 조롱했습니다. 여기에서 저장소를 테스트하는 데는 관심이 없습니다. 리포지토리 메서드는 FeedRepositoryShould 에서 테스트해야 합니다. 이 동작을 조롱함으로써 우리는 실제로 저장소 메소드를 호출하지 않았습니다. 다음에 호출될 때 응답하는 방법을 간단히 설명했습니다.

그런 다음 우리는 우리의 피드에 이 mentionService 을 삽입하도록 우리의 언급 서비스에 지시했습니다. 피드에 멘션을 성공적으로 삽입한 경우에만 결과를 내보내야 한다는 것을 알고 있습니다. verify 방법을 사용하여 우리의 멘션 및 피드와 함께 mentionInsertedToFeed 메서드가 호출되었고 verifyNoMoreInteractions 를 사용하여 다시 호출되지 않았는지 확인할 수 있습니다.

마지막 생각들

품질 테스트를 작성하는 것은 경험에서 비롯되며 배우는 가장 좋은 방법은 직접 해보는 것입니다. 이 블로그에 작성된 팁은 연습에서 나온 것입니다. 위험에 직면한 적이 없다면 몇 가지 함정을 발견하기 어렵습니다. 이러한 제안을 통해 코드 디자인을 더욱 강력하게 만들 수 있기를 바랍니다. 신뢰할 수 있는 테스트를 사용하면 코드를 배포해야 할 때마다 땀을 흘리지 않고 변경할 수 있다는 자신감이 높아집니다.

Mediatoolkit 팀에 합류하고 싶으십니까?
시니어 프론트엔드 개발자 의 채용 공고를 확인하세요!