Dev/TDD

Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - Junit5 확장 모델

린네의 2024. 10. 20. 16:52

 

Junit5 확장 모델 살펴보기

Junit5 확장 모델은 Extension API라는 단일 개념으로 설명할 수 있다. Extension자체는 내부에 필드나 메서드가 없는 인터페이스인

커인터페이스일 뿐으로, 해당 인터페이스를 구현하는 클래스에 특별한 의미나 기능을 부여하기 위해 사용한다.

 

  • 마커 인터페이스 (태그 인터페이스, 토큰 인터페이스)

마커 인터페이스는 구현 메서드가 따로 없는 인터페이스로, 해당 인터페이스를 구현하는 클래스에 특별한 의미나 기능을 부여하기 위해 사용한다. 대표적인 사례로는 Serailizable과 Cloneable인터페이스가 있다. 

 

public interface MyMarkerInterface { // No methods }

 

 

테스트가 생애 주기를 타는 중에 사전에 정의한 확장 지점에 걸리면 Junit엔진은 등록한 extension을 자동으로 호출한다. 확장지점(extension point)은 특정 이벤트가 발생하면 테스트가 발생하는 시점을 의미한다.

 

  • 확장 지점의 종류
  1. 조건부 테스트 실행 : 특정 조건을 충족했을 때 테스트를 실행하기 위해 사용한다
  2. 생애 주기 콜백 : 테스트가 생애주기에 반응하도록 만들어야할 때 사용한다
  3. 파라미터 리졸브 : 런타임에서 테스트에 주입할 파라미터를 리졸브하는 시점에 사용한다
  4. 예외 처리 : 특정 유형의 예외가 발생할 때 수행할 테스트 동작을 정의한다
  5. 테스트 인스턴스 후처리 : 테스트 인스턴스가 생성된 다음에 실행할 때 사용한다 

 

이러한 Junit5 Extension은 주로 프레임워크나 빌드 도구에서 사용한다. 

 

 

Junit5 extension 생성하기 

 

실제 코드를 작성해보자.  아래 코드는 context.properties 파일에 정의한 context 값에 따라 테스트의 실행 여부를 구현한 Junit5 Extension 의 일종이다.

 

junit.jupiter에 포함된 ExecutionCondition을 Implements하여 evaluateExecutionCondition를 @Override해서 사용할 수 있다.

 

 

  • 직접 생성한 Junit5 Extension
package com.test.junit.ch14;

import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;

import java.io.IOException;
import java.util.Properties;

/**
 *   - 승객수에 따라 low, regular, peak 로 구분된 세가지 Context를 정의
 *   - 혼잡 콘텍스에서는 테스트를 실행하지 않는 Extension 구현
 */
public class ExecutionContextExtension implements ExecutionCondition { // ExcutionConditin은 Junit.jupiter에 포함되어 있음

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext extensionContext) {
        Properties properties = new Properties();
        String excutionContext = "";

        try {

            // context 설정값은 임의로 정의되어 있다고 가정한다
            properties.load(ExecutionContextExtension.class.getClassLoader().getResourceAsStream("context.properties")); // context.properties 파일 load

        excutionContext = properties.getProperty("context"); // context 항목 읽어오기

        // context 가 peak 면 실행하지 않고, regular나 low일 때는 실행한다     
        if(!"regular".equalsIgnoreCase(excutionContext) && !"low".equalsIgnoreCase(excutionContext)) {
            return ConditionEvaluationResult.disabled("Test disabled outside regular and low contexts");
        }

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        // ConditionEvaluationResult 메서드는 테스트를 활성화할지 말지 결정한다
        return ConditionEvaluationResult.enabled("Test enabled on the " + excutionContext + " context");
    }
}

 

 

  • Extension을 사용한 Test Code
package com.test.junit.ch14;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.junit.jupiter.api.Assertions.assertEquals;

@ExtendWith({ExecutionContextExtension.class})
public class PassengerTest {

    @Test
    void testPassenger() {
        Passenger passenger = new Passenger("123-456-789", "John Smith");
        assertEquals("Passenger{identifier='123-456-789', name='John Smith'}", passenger.toString());
    }
}

 

  • context.properties
context=low

 

  • 정상 실행 결과

 

 

만약 context를 low나 regular가 아니라 peak로 할 경우 다음과 같은 결과가 반환된다.

 

 

  • extension 조건에 걸린 실행 결과

 

 

더보기

참고로 JVM에서 테스트 조건부 실행의 효과를 우회하게만들 수 있다. 

 

[Run]-[Edit Configuration]을 클릭하여 junit.jupiter.conditions.deactivate=*를 설정하면 테스트 실행과 관련한 모든 조건을 비활성화 할 수 있다 라는 설명이 책에 나와있는데, 나는 이렇게 하니까 잘 안됐다. 그래서 build.gradle설정을 변경해줬더니 정상적으로 동작했다.  (테스트 조건부 실행 효과를 우회)

 

build.gradle 파일에 다음과 같이 작성하면 된다.

 

tasks.named('test') {
    useJUnitPlatform()
    jvmArgs += ['-ea', '-Djunit.jupiter.conditions.deactivate=*']
}

 

 

[참고]

 

실행 환경의 gradle버전은 다음과 같으며 java17을 사용중에 있다.

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

 

 

확장 지점을 사용하여 Junit5 테스트 구현하기

간단한 Extension을 생성해보았으니 다음은 본격적인 테스트를 구현해볼 차례다. 

 

테스트 구현을 위해 인메모리 DB인 H2에 대한 의존성을 추가한다.

 

testImplementation 'com.h2database:h2:2.3.232'

 

 

  • DAO 구현
package com.test.junit.ch14.db;

import com.test.junit.ch14.PassengerExistException;
import com.test.junit.ch14.domain.Passenger;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class PassengerDaoImpl implements PassengerDao  {
    private Connection con;


    // 생성자에서 Connection 초기화
    public PassengerDaoImpl(Connection connection) {
        this.con = connection;
    }

    @Override
    public void insert(Passenger passenger) throws PassengerExistException {

        if(null != getById(passenger.getIdentifier())) {
            throw new PassengerExistException(passenger, passenger.toString());
        }

        String sql = "INSERT INTO PASSENGERS (ID, NAME) VALUES (?, ?)";

        try(PreparedStatement statement = con.prepareStatement(sql)) {
            statement.setString(1, passenger.getIdentifier());

            statement.setString(2, passenger.getName());
            statement.executeUpdate();

            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }

    @Override
    public void update(String id, String name) {

        String sql = "UPDATE PASSENGERS SET NAME = ? WHERE ID = ?";

        try(PreparedStatement statement = con.prepareStatement(sql)) {
            statement.setString(1, name);
            statement.setString(2, id);
            statement.executeUpdate();

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void delete(Passenger passenger) {
        String sql = "DELETE FROM PASSENGERS WHERE ID = ?";

        try(PreparedStatement statement = con.prepareStatement(sql)) {
            statement.setString(1, passenger.getIdentifier());
            statement.executeUpdate();

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Passenger getById(String id) {
        String sql = "SELECT * FROM PASSENGERS WHERE ID = ?";
        Passenger passenger = null;
        try(PreparedStatement statement = con.prepareStatement(sql)) {
            statement.setString(1, id);
            ResultSet resultSet = statement.executeQuery();

            if(resultSet.next()) {
                passenger = new Passenger(resultSet.getString(1), resultSet.getString(2));
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }

        return passenger;
    }

}
  • 생애주기 인터페이스 구현
package com.test.junit.ch14.extension;

import com.test.junit.ch14.db.TableManager;
import com.test.junit.ch14.db.ConnectionManager;
import org.junit.jupiter.api.extension.*;

import java.sql.Connection;
import java.sql.Savepoint;


/**
 *   1. 전체 테스트 묶음을 실행하기 전에 데이터베이스를 초기화하고 데이터베이스 커넥션을 얻는다
 *   2. 테스트 묶음이 종료되었을 때 데이터베이스 커넥션을 반납한다
 *   3. 테스트를 실행하기 전에 데이터베이스가 알려진 상태인지 확인해서 개발자가 테스트를 정확하게 실행할 수 있는지 확인 가능하게 한다
 *
 *
 *      생애 주기 인터페이스(BeforeAll, Callback, AfterAllCallback, AfterEachCallback 구현)
 */
public class DatabaseOperationsExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {

    private Connection connection;
    private Savepoint savepoint;


    /**
     *  전체 테스트 묶음이 실행된 다음 한 번 실행
     *  데이터베이스 커넥션을 반납
     * @param extensionContext
     * @throws Exception
     */
    @Override
    public void afterAll(ExtensionContext extensionContext) throws Exception {
        ConnectionManager.closeConnection();
    }


    /**
     *  각 테스트가 수행된 다음 실행. 테스트가 실행되기 전으로 데이터베이스의 상태를 롤백
     * @param extensionContext
     * @throws Exception
     */
    @Override
    public void afterEach(ExtensionContext extensionContext) throws Exception {
        connection.releaseSavepoint(savepoint);
    }


    /**
     *  전체 테스트 묶음이 실행되기 전에 한 번 실행
     *  데이터 베이스 커넥션을 얻고 기존 테이블을 드롭 후 새로 생성
     * @param extensionContext
     * @throws Exception
     */
    @Override
    public void beforeAll(ExtensionContext extensionContext) throws Exception {
        connection = ConnectionManager.openConnection();
        TableManager.dropTable(connection);
        TableManager.createTable(connection);
    }

    /**
     * 각 테스트가 실행되기 전에 실행
     * 자동 커밋 모드를 비활성하여 테스트 때문에 변경된 데이터가 커밋되는 것을 막고, 테스트 실행 전의 데이터베이스 상태를 저장함
     * 테스트 실행 전 데이터 베이스 상태를 저장하기 때문에 개발자는 테스트가 수행된 다음 데이터베이스의 상태를 롤백할 수 있음
     * @param extensionContext
     * @throws Exception
     */
    @Override
    public void beforeEach(ExtensionContext extensionContext) throws Exception {
        connection.setAutoCommit(false);
        savepoint = connection.setSavepoint("savePoint");
    }


}

 

 

  • 커스텀 예외 핸들러 구현
package com.test.junit.ch14.extension;

import com.test.junit.ch14.PassengerExistException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;

import java.util.logging.Logger;

public class LogPassengerExistsExceptionExtension implements TestExecutionExceptionHandler {
    private Logger logger = Logger.getLogger(this.getClass().getName());

    @Override
    public void handleTestExecutionException(ExtensionContext extensionContext, Throwable throwable) throws Throwable {
        if(throwable instanceof PassengerExistException) {
            logger.severe("Passenger exists:" + throwable.getMessage());
            return;
        }
        throw throwable;
    }
}

 

  • 리졸버 구현
package com.test.junit.ch14.extension;

import com.test.junit.ch14.db.ConnectionManager;
import com.test.junit.ch14.db.PassengerDao;
import com.test.junit.ch14.db.PassengerDaoImpl;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;


/**
 *  PassengerTest의 클래스의 생성자가 PassengerDao 타입의 파라미터를 받기 때문에, 해당 파라미터를 리졸브하는 ParameterResolve 생성
 *
 *  ParameterResolver 인터페이스를 구현
 *  - 해당 인터페이스는 테스트가 필요로하는 파라미터를 리졸브할 때 사용함
 *
 */
public class DataAccessObjectParameterResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return  parameterContext.getParameter().getType().equals(PassengerDao.class);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return new PassengerDaoImpl(ConnectionManager.getConnection());
    }
}

 

 

  • 실제 테스트 코드
package com.test.junit.ch14;

import com.test.junit.ch14.db.PassengerDao;
import com.test.junit.ch14.domain.Passenger;
import com.test.junit.ch14.extension.DataAccessObjectParameterResolver;
import com.test.junit.ch14.extension.DatabaseOperationsExtension;
import com.test.junit.ch14.extension.ExecutionContextExtension;
import com.test.junit.ch14.extension.LogPassengerExistsExceptionExtension;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

/**
 *  ExecutionContextExtension - 테스트 실행조건
 *  DatabaseOperationsExtension - 테스트의 생애주기
 *  DataAccessObjectParameterResolver - 테스트 클래스 생성시 필요한 파라미터를 리졸브
 */
@ExtendWith({ExecutionContextExtension.class,
            DatabaseOperationsExtension.class,
            DataAccessObjectParameterResolver.class,
            LogPassengerExistsExceptionExtension.class})
public class PassengerTest {

    private final PassengerDao passengerDao;

    public PassengerTest(PassengerDao passengerDao) {
        this.passengerDao = passengerDao;
    }

    @Test
    void testPassenger() {
        Passenger passenger = new Passenger("123-456-789", "John Smith");
        assertEquals("Passenger{identifier='123-456-789', name='John Smith'}", passenger.toString());
    }

    @Test
    void testInsertPassenger() throws PassengerExistException {
        Passenger passenger = new Passenger("123-456-789", "John smith");
        passengerDao.insert(passenger);
        assertEquals("John smith", passengerDao.getById("123-456-789").getName());
    }

    @Test
    void  testUpdatePassenger() throws PassengerExistException {
        Passenger passenger = new Passenger("123-456-789", "John Smith");
        passengerDao.insert(passenger);
        passengerDao.update("123-456-789", "Michael Smith");
        assertEquals("Michael Smith", passengerDao.getById("123-456-789").getName());
        //passengerDao.delete(passenger);
    }


    @Test
    void testDeletePassenger() throws PassengerExistException {
        Passenger passenger = new Passenger("123-456-789", "John Smith");
        passengerDao.insert(passenger);
        passengerDao.delete(passenger);
        assertNull(passengerDao.getById("123-456-789"));
    }


    @Test
    void testInsertExistingPassenger() throws PassengerExistException {
        Passenger passenger = new Passenger("123-456-789", "John Smith");
        passengerDao.insert(passenger);
        passengerDao.insert(passenger);
        assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
    }

}

 

 

이런식으로 테스트 구현시 원하는 확장을 추가할 수 있다.