테스트 작성 지침

테스트 작성 지침 (Test writing guidelines)

Trino 프로젝트는 모든 사용자에게 고품질의 산출물과 기능을 제공하는 것을 목표로 해요. 개발·CI 빌드·릴리스에 사용되는 테스트 모음은 이 목표를 달성하는 데 중요해요.

출처: 문서

본문

Trino는 다양한 커넥터 플러그인의 도움으로 많은 데이터 소스의 통합 레이어 역할을 하고, 다른 플러그인 지원도 포함해요. 그 결과 많은 외부 시스템과 함께 다양한 사용 사례를 테스트해야 해요. Trino 테스트 모음은 풀 리퀘스트의 변경에 대해 실행되는 테스트를 가능한 한 제한하고, master 브랜치로의 병합은 모든 테스트를 처리해요.

다음 요구 사항과 지침은 기여자가 다음 목표로 테스트를 만들도록 도와줘요.

  • 빠른 실행.
  • 하드웨어·소프트웨어·계산 능력 요구가 낮고, 따라서 비용이 낮음.
  • 여러 번 실행해도 신뢰할 수 있는 실행과 테스트 결과.
  • 실행 경로를 충분히 커버할 만큼 복잡하면서도 불필요한 복잡성은 피함.
  • 개발자 워크스테이션에서 빠르고 반복적인 개발을 가능하게 함.

규약과 권장 사항 (Conventions and recommendations)

다음 섹션은 새 테스트를 만들거나 기존 테스트 코드를 리팩터링할 때 따라야 할 규약과 권장 사항을 자세히 다뤄요. 기존 코드베이스는 이 지침을 따르는 최신 테스트 코드와 오래된 레거시 코드가 혼합되어 있어요. 레거시 테스트 코드는 새 테스트의 예시로 쓰면 안 되고, 이 문서의 지침을 따르세요.

또한 지침은 실무 경험에서의 추가 개선·정련 과정에서 바뀔 수 있다는 점에 유의하세요.

모든 새 테스트와 기존 테스트 리팩터링 작업에 적용되는 요구 사항이 몇 가지 있어요.

  • 모든 테스트는 JUnit 5를 사용해야 해요.
  • 모든 테스트는 정적으로 가져온 AssertJ 단언을 사용해야 해요. 보통 org.assertj.core.api.Assertions에서.
  • 테스트 클래스 이름은 Test로 시작해야 해요. 예: TestExample
  • 테스트 클래스는 패키지 프라이빗(package-private)이고 final로 정의해야 해요.
  • 테스트 메서드는 test로 시작해야 해요. 예: testExplain()
  • 테스트 메서드는 패키지 프라이빗으로 정의해야 해요.
  • 테스트는 가능할 때 프로덕션 인프라를 TestContainers로 추상화하는 테스트를 포함해 단위 테스트로 작성해야 해요. 제품(Product) 또는 기타 통합 테스트는 피해야 해요. 이런 테스트는 보통 외부 인프라에 의존하고 전체 Trino 런타임을 사용하므로 종종 더 느리고 신뢰성 문제가 있어요.
  • 테스트는 단위 테스트와 제품 테스트, 또는 다른 플러그인·통합 사이에 중복하면 안 돼요.

지침 (Guidelines)

요구 사항에 더해, 특정 지침 몇 가지가 새 테스트 작성과 기존 테스트 리팩터링에 도움이 돼요.

높은 가치의 테스트에 집중 (Focus on high value tests)

Trino에서 테스트는 매우 비싸고 제한된 환경에서 수 시간의 계산 시간이 걸리므로 모든 개발을 느리게 해요. 크고 비싼 테스트의 경우, 테스트가 Trino에 가져오는 가치를 고려하고 가치가 비용으로 정당화되는지 확인하세요. 테스트 예산이 사실상 제한되어 있고, CI 테스트는 대부분의 날에 종종 여러 시간 큐에 대기하므로 전체 프로젝트 속도를 떨어뜨려요.

조합형 테스트 피하기 (Avoid combinatorial tests)

항목을 격리해서 테스트하고, 몇 가지 흔한 조합만 테스트해 통합이 동작하는지 확인하세요. 가능한 모든 조합에 대한 테스트를 구현하지 마세요.

제품 테스트 피하기 (Avoid product tests)

기능에 대한 단위 테스트를 만들 수 있다면 단위 테스트를 쓰고 제품 테스트 작성을 피하세요. 시간이 지나면서 대부분의 제품 테스트를 제거하는 것이 목표이며, 새 제품 테스트를 피하면 마이그레이션 비용이 커지는 것을 막을 수 있어요.

다음 경우에만 제품 테스트를 사용하세요.

  • 전체 서버를 사용하는 최소한의 하이레벨 통합 테스트. 예를 들어 플러그인이 플러그인 클래스로더·클래스패스에서 올바르게 동작하는지 검증할 수 있어요.
  • Kerberos가 설정된 컨테이너 같은 특수 환경에서 테스트 코드를 실행해야 하는 경우. 이 통합을 검증하는 데 필요한 최소한의 테스트만 실행하세요.

테스트 추상화 만들기 피하기 (Avoid creating testing abstractions)

기존 빌드 도구와 프레임워크가 충분한 기능을 제공하므로 다음 접근은 피해야 해요.

  • 테스트 실행 병렬화용 커스텀 디스패치 프레임워크 만들기
  • 테스트 전용 단언 프레임워크 만들기
  • 커스텀 파라메트릭 테스트 프레임워크 만들기

데이터 프로바이더와 파라메트릭 테스트 피하기 (Avoid data providers and parametric tests)

데이터 프로바이더와 파라메트릭 테스트는 불필요한 복잡성을 더해요. 높은 가치의 테스트에 집중하고 조합형 테스트·데이터셋을 피하는 것을 고려하세요. 그리고 다음 세부 사항을 참고하세요.

  • 대부분의 데이터 프로바이더는 사소하게 작거나, 무차별적인 대규모 조합 데이터셋을 생성해요.
  • boolean 파라미터 같은 사소한 경우에는 명시적 테스트 케이스를 작성하는 것을 선호하세요.
  • 작은 데이터셋에는 "인라인 목록의 for-each 항목"을 사용하세요.
  • 더 큰 데이터셋에는 타입 안전 enum 클래스 사용을 고려하세요.
  • 큰 테스트 데이터셋은 Trino 유지 관리자와 사용 사례를 논의해 해결책이나 다른 지침을 마련하세요.
  • 테스트의 여러 독립 데이터 프로바이더(중첩 for 루프나 여러 데이터 프로바이더 파라미터 포함)는 피하세요.

상태 있는 테스트 클래스 피하기 (Avoid writing stateful test classes)

상태 있는 테스트는 특히 테스트 실행이 병렬화될 때 한 테스트의 문제가 다른 테스트로 새어 나가는 원인이 될 수 있어요. 그 결과 테스트 실패 디버깅·문제 해결과 테스트 유지 관리가 더 어려워져요. 가능하면 이런 상태 있는 테스트 클래스는 피해야 해요.

메모리 관리 시도하지 않기 (Do not try to manage memory)

JUnit과 JVM이 테스트 수명 주기와 메모리 관리를 처리해요. @After 메서드에서 필드를 null로 만드는 등 "메모리 해제"를 위한 수동 단계는 피하세요. 메모리 집약적 객체를 final 필드에 할당하는 것은 안전해요. 클래스는 테스트 실행 후 자동으로 역참조되기 때문이에요.

단순한 리소스 초기화 사용 (Use simple resource initialization)

생성자에서 리소스를 초기화하고 필요하면 @After 메서드에서 정리하는 것을 선호하세요. 이 접근은 필드를 null로 만들지 않는 것과 결합해, 필드를 final로 만들고 정상 Java 코드의 어떤 Closeable 클래스처럼 동작하게 해줘요. 정리를 단순화하려면 Guava Closer 클래스 사용을 고려하세요.

테스트 설정·정리 단순하게 유지 (Keep test setup and teardown simple)

@Before/@After 각 테스트 메서드 스타일의 설정·정리는 피하세요.

  • 자연스러우면 try-with-resources를 선호하세요.
  • 필요하면 명시적으로 호출되는 공유 초기화·정리 메서드를 사용하세요.
  • @Before/@After 메서드가 유익한 테스트가 있다면 유지 관리자와 접근을 논의해 해결책을 만들고 지침을 개선하세요.

새 플러그인·커넥터 기능의 테스트 가능성 보장 (Ensure testability of new plugin and connector features)

새 플러그인·커넥터 기능은 테스트 플러그인 중 하나(예: memory 또는 null)를 사용해 테스트할 수 있어야 해요. Hive 플러그인에서만 테스트되는 기존 기능이 있으며, 시간이 지나면서 테스트 플러그인을 사용한 커버리지를 기대해요.

플러그인·커넥터 테스트에 초점 유지 (Keep focus on plugin and connector tests)

플러그인, 특히 커넥터 플러그인의 경우 플러그인 고유 코드에 집중하세요. 코어 엔진 기능에 대한 테스트는 추가하지 마세요. 플러그인은 SPI 구현의 정확성과 외부 시스템과의 호환성에 초점을 맞춰야 해요.

플레이키 테스트 피하기 (Avoid flaky tests)

플레이키(flaky) 테스트는 신뢰할 수 없는 테스트예요. 같은 테스트를 여러 번 실행하면 결과가 일관되지 않아요. 보통 테스트는 성공하다가 드물게 실패해요. 플레이키의 원인은 외부의 불안정한 시스템 의존, 연결, 디버깅하기 어려운 다른 설정을 포함해요.

기존 플레이키 테스트는 수정이 구현될 때까지 CI 신뢰성을 개선하기 위해 일시적으로 @Flaky 애노테이션으로 표시할 수 있어요.

  • 이상적으로는 수정은 테스트를 신뢰할 수 있게 만드는 거예요.
  • HDFS를 피하는 관행을 포함해 플레이키 인프라에 의존하지 않도록 테스트를 재작성하세요.
  • 필요하면 명시적 재시도를 추가하되, 리소스 사용에 주의하세요.
  • 일정 기간 후에도 테스트가 고쳐지지 않으면 제거해야 해요.

@Flaky 애노테이션이 있는 새 테스트는 도입할 수 없어요. 테스트를 안정적이게 재작성하거나 아예 피하세요.

테스트 비활성화 피하기 (Avoid disabling tests)

테스트를 비활성화하는 대신 제거하는 것을 선호하세요. 코드베이스가 변함에 따라 테스트 코드는 유지·업데이트되고, 비활성 테스트는 시간과 노력을 낭비할 뿐이에요. 비활성화된 테스트는 언제든 제거할 수 있어요.

Assumptions.abort() 사용 피하기 (Avoid using Assumptions.abort())

특히 호출 스택 깊은 곳에서 테스트를 건너뛰기 위해 Assumptions.abort()를 사용하는 접근은 테스트 실패 디버깅을 어렵게 해요. abort()는 예외를 던져 동작하는데, 이 예외가 중간 코드에 우연히 포착되어 오해를 불러일으키는 스택 트레이스와 테스트 실패를 일으킬 수 있어요.

테스트 상속 피하기 (Avoid test inheritance)

테스트 상속은 불필요한 복잡성을 만들어요. 테스트를 단순하게 유지하고 필요하면 컴포지션을 사용하세요.

헬퍼 단언 피하기 (Avoid helper assertions)

필수인 AssertJ 사용은 풍부한 단언 집합을 제공하므로 보통 커스텀 헬퍼 단언이 불필요해요. 커스텀 단언은 종종 테스트를 따라가고 디버깅하기 어렵게 만들어요.

헬퍼 단언이 필요하다고 결정했다면 다음 세부 사항을 고려하세요.

  • 이름을 assert로 시작하세요. 예: assertSomeLogicWorks
  • 프라이빗·정적을 선호하세요.

예시 (Examples)

다음 예시는 권장·비권장 관행을 보여줘요.

테스트용 동시성 (Concurrency for tests)

인스턴스에 PER_CLASS를 사용해요. QueryAssertions는 메서드별로 만들기에는 너무 비싸기 때문이에요. 그리고 CONCURRENT로 테스트의 병렬 실행을 허용해요.

@TestInstance(PER_CLASS)
@Execution(CONCURRENT)
final class TestJoin
{
    private final QueryAssertions assertions = new QueryAssertions();

    @AfterAll
    void teardown()
    {
        assertions.close();
    }

    @Test
    void testXXX()
    {
        assertThat(assertions.query(
            """
            ...
            """))
            .matches("...");
    }
}

수동 수명 주기 관리 피하기 (Avoid manual lifecycle management)

연결처럼 Closeable의 수명 주기를 @BeforeEach/@AfterEach로 관리하는 것은 피해요. 오버헤드를 줄이기 위해서요.

@TestInstance(PER_METHOD)
final class Test
{
    private Connection connection;

    @BeforeEach
    void setup()
    {
        // WRONG: create this in the test method using try-with-resources
        connection = newConnection();
    }

    @AfterEach
    void teardown()
    {
        connection.close();
    }

    @Test
    void test()
    {
        ...
    }
}

try-with-resources 접근을 쓰면 테스트를 깔끔하게 병렬화하고 자동 메모리 관리를 포함할 수 있어요.

final class Test
{

    @Test
    void testSomething()
    {
        try (Connection connection = newConnection();) {
            ...
        }
    }

    @Test
    void testSomethingElse()
    {
        try (Connection connection = newConnection();) {
            ...
        }
    }
}

가짜 추상화 피하기 (Avoid fake abstractions)

테스트에 가짜 추상화를 사용하지 마세요.

@DataProvider(name = "data")
void test(boolean flag)
{
    // WRONG: use separate test methods
    assertEqual(
        flag ? ... : ...,
        flag ? ... : ...);
}

단순화된 별도 단언으로 교체하세요.

void test()
{
    assertThat(...).isEqualTo(...); // case corresponding to flag == true
    assertThat(...).isEqualTo(...); // case corresponding to flag == false
}

커스텀 병렬화 피하기 (Avoid custom parallelization)

커스텀 병렬 테스트 실행 프레임워크를 개발하지 마세요.

@Test(dataProvider = "parallelTests")
void testParallel(Runnable runnable)
{
   try {
       parallelTestsSemaphore.acquire();
   }
   catch (InterruptedException e) {
       Thread.currentThread().interrupt();
       throw new RuntimeException(e);
   }
   try {
       runnable.run();
   }
   finally {
       parallelTestsSemaphore.release();
   }
}

@DataProvider(name = "parallelTests", parallel = true)
Object[][] parallelTests()
{
   return new Object[][] {
        parallelTest("testCreateTable", this::testCreateTable),
        parallelTest("testInsert", this::testInsert),
        parallelTest("testDelete", this::testDelete),
        parallelTest("testDeleteWithSubquery", this::testDeleteWithSubquery),
        parallelTest("testUpdate", this::testUpdate),
        parallelTest("testUpdateWithSubquery", this::testUpdateWithSubquery),
        parallelTest("testMerge", this::testMerge),
        parallelTest("testAnalyzeTable", this::testAnalyzeTable),
        parallelTest("testExplainAnalyze", this::testExplainAnalyze),
        parallelTest("testRequestTimeouts", this::testRequestTimeouts)
   };
}

대신 병렬화는 JUnit에 맡기고 별도 테스트 메서드를 구현하세요.

파라메트릭 테스트 피하기 (Avoid parameterized tests)

커스텀 파라메트릭 테스트 프레임워크를 만들지 마세요.

@Test
void testTinyint()
{
    SqlDataTypeTest.create()
        .addRoundTrip(...)
        .addRoundTrip(...)
        .addRoundTrip(...)
        .execute(getQueryRunner(), trinoCreateAsSelect("test_tinyint"))
        .execute(getQueryRunner(), trinoCreateAndInsert("test_tinyint"))
        .addRoundTrip(...)
        .execute(getQueryRunner(), clickhouseQuery("tpch.test_tinyint"));
}

더 알아보기 (Learn more)

커넥터 테스트에 유용한 테스트 플러그인으로는 memory 커넥터가 있어요. 커넥터 개발 전반은 커넥터 개발 문서를 참고해 보세요.