ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JUnit] JUnit5 extension (확장 모델)
    DEV/JUnit 2024. 10. 22. 19:57

    JUnit4의 경우  runner와 rule을 사용하여 테스트를 확장할 수 있었다.

    JUnit5 확장 모델은 Extension API라는 단일 개념으로 설명할 수 있다.

    Extension 자체는 내부에 필드나 메서드가 없는 인터페이스인 마커 인터페이스일 뿐이다.

     

    마커 인터페이스?

    더보기

    태그 인터페이스 또는 토큰 인터페이스라고도 불리며 구현 메서드가 따로 없는 인터페이스로, 해당 인터페이스를 구현하는 클래스에 특별한 의미나 기능을 부여하기 위해 사용한다. 대표적인 사례로 Serializable, Cloneable 인터페이스가 있다. Serializable 인터페이스에는 구현 메서드가 없지만 해당 인터페이스를 구현하는 클래스는 직렬화 속성을 갖는다는 것을 나타낼 수 있다.

     

    * 사용 목적

    JUnit5 extension으로 테스트 클래스나 테스트 메서드의 동작을 확장할 수 있으며, 이러한 extension을 다른 테스트에서도 재사용할 수 있다. 특정 이벤트를 감시하다가 이벤트 발생하면 테스트를 작동하게 할 수도 있다. 이러한 이벤트를 확장 지점(extension point)이라고 하는데 테스트가 생애주기를 타는 중에 사전에 정의한 확정 지점에 걸리면 JUnit 엔진은 등록한 extension을 자동으로 호출한다.

     

    * 확장지점의 종류

    - 조건부 테스트 실행 : 특정 조건을 충족했을 때 테스트를 실행하기 위해 사용

    - 생애주기 콜백 : 테스트가 생애주기에 반응하도록 만들어야할 때 사용

    - 파라미터 리졸브 : 런타임에서 테스트에 주입할 파라미터를 리졸브하는 시점에 사용

    - 예외처리 : 특정 유형의 예외가 발생할 때 수행할 테스트 동작을 정의

    - 테스트 인스턴스 후처리 : 테스트 인스턴스가 생성된 다음에 실행할 때 사용

     

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

     

    * Junit5 extension 생성하기

    테스트할 클래스 Passenger

    public class Passenger {
        private String identifier;
        private String name;
        
        public Passenger(String identifier, String name) {
            this.identifier = identifier;
            this.name = name;
        }
    
        public String getIdentifier() {
            return identifier;
        }
    
        public String getName() {
            return name;
        }
    
        @Override
        public String toString() {
            return "Passenger " + getName() + "with identifier: " + getIdentifier();
        }
    }

     

     

    테스트 클래스 PassengerTest

    import org.junit.jupiter.api.Test;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    public class PassengerTest {
    
        @Test
        void testPassenger() {
            Passenger passenger = new Passenger("123-456-789", "John Smith");
            assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
        }
    }

     

    toString 메서드를 검증하는 간단한 테스트이다.

     

    여기서 조건부 테스트 확장을 적용하려한다.

    예를들면 context에 맞게 테스트를 실행할지 말지 결정하는 로직을 추가할 수 있다.

     

    승객 수에 따라 low=원활, regular=보통, peak=혼잡일 경우 원활과 보통 context에서만 테스트가 실행되는 extension을 생성하고 PassengerTest에 확장시켜보자.

     

    ExcutionCondition 인터페이스를 구현하여 조건부 테스트 실행 extension을 만든다.

    ExecutionContextExtension

    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;
    
    public class ExecutionContextExtension implements ExecutionCondition { // 조건부 테스트 실행 extension 생성
    
        // 메서드 재정의를 통해 테스트를 활성화할지 말지 결정하는 ConditionEvaluationResult 객체 반환
        @Override
        public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
    
            Properties properties = new Properties();
            String executionContext = "";
    
            try {
                properties.load(ExecutionContextExtension.class.getClassLoader()
                        .getResourceAsStream("context.properties"));
                executionContext = properties.getProperty("context");
    
                if(!"regular".equalsIgnoreCase(executionContext) &&
                   !"low".equalsIgnoreCase(executionContext)) {
                    return ConditionEvaluationResult.disabled(
                            "Test disabled outside regular and low contexts"
                    );
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            return ConditionEvaluationResult.enabled("Test enabled on the " +  executionContext + " context");
        }
    }

     

    이제 PassengerTest에  애노테이션을 추가하면 테스트를 확장할 수 있다.

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

     

    context.properties파일에 context값을 지정하여 테스트해보자.

    regular와 low는 정상적으로 테스트가 실행되는 반면, peak의 경우 테스트가 비활성화되고 실행되지 못한 이유를 확인할수있다.

     

    JVM이 테스트 조건부 실행의 효과를 우회하게하여 조건부 실행 자체를 비활성화할 수 있다.

    junit.jupiter.conditions.deactivate=*

     

    [Run] > [Edit Configuration] 에서 이러한 설정을 넣어주면 테스트 실행과 관련한 모든 조건을 비활성화 할 수 있다.

    즉, 어떤 조건에도 영향을 받지 않으므로 모든 테스트가 실행된다.

     

    생애주기 콜백 테스트 확장

    테스트가 실행되기 전에 데이터베이스 초기화, 그리고 커넥션이 필요하다.

    테스트가 실행된 후에는 커넥션을 반납해야한다.

     

    H2 데이터베이스, JDBC, JUnit5 extension을 사용하여 테스트해보자.

    testImplementation group: 'com.h2database', name: 'h2', version: '2.3.232'

    gradle H2 데이터베이스 의존성 추가

     

    데이터베이스 커넥션 관리를 위한 클래스 구현

    ConnectionManager

    import java.sql.Connection;
    import java.sql.DriverManager;
    import java.sql.SQLException;
    
    public class ConnectionManager {
    
        private static Connection connection;
    
        public static Connection getConnection() {
            return connection;
        }
    
        public static Connection openConnection() {
    
            try {
                Class.forName("org.h2.Driver"); // H2 드라이버
                connection = DriverManager.getConnection("jdbc:h2:~/passenger",
                        "sa", // 아이디
                        "" // 비밀번호
                );
                return connection;
            } catch (ClassNotFoundException | SQLException e) {
                throw new RuntimeException(e);
            }
        }
    
        public static void closeConnection() {
            if (null != connection) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    
    }

     

    테이블 관리를 위한 클래스 구현

    TablesManager

    import java.sql.Connection;
    import java.sql.PreparedStatement;
    import java.sql.SQLException;
    
    public class TablesManager {
    
        public static void createTable(Connection connection) {
            String sql = "CREATE TABLE IF NOT EXISTS PASSENGERS (ID VARCHAR(50), " +
                    "NAME VARCHAR(50));";
    
            executeStatement(connection, sql);
        }
    
        public static void dropTable(Connection connection) {
            String sql = "DROP TABLE IF EXISTS PASSENGERS;";
    
            executeStatement(connection, sql);
        }
    
        private static void executeStatement(Connection connection, String sql) {
            try (PreparedStatement statement = connection.prepareStatement(sql)) {
                statement.executeUpdate();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
    
    }

     

    쿼리 실행을 위한 Dao 인터페이스, DaoImpl 클래스 구현

    PassengerDao

    public interface PassengerDao {
        public void insert(Passenger passenger);
        public void update(String id, String name);
        public void delete(Passenger passenger);
        public Passenger getById(String id);
    }

     

    PassengerDaoImpl

    package com.study.junit.ch14.jdbc;
    
    import com.study.junit.ch14.Passenger;
    
    import java.sql.PreparedStatement;
    import java.sql.Connection;
    import java.sql.ResultSet;
    import java.sql.SQLException;
    
    public class PassengerDaoImpl implements PassengerDao {
    
        private Connection connection;
    
        public PassengerDaoImpl(Connection connection) {
            this.connection = connection;
        }
    
        @Override
        public void insert(Passenger passenger) {
            String sql = "INSERT INTO PASSENGERS (ID, NAME) VALUES (?, ?)";
    
            try (PreparedStatement statement = connection.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 = connection.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 = connection.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 = connection.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;
        }
    }

     

    책에 있는 예제가 조금 레거시하지만 지금까진 별로 중요한내용은아니고,

    여기서부터가 중요한내용이다.

     

    다음 내용을 실행하기 위한 JUnit5 extension을 구현할 것이다.

    1. 전체 테스트 묶음을 실행하기 전에 데이터베이스를 초기화하고 데이터베이스 커넥션을 얻는다.

    2. 테스트 묶음이 종료되었을 때 데이터베이스 커넥션을 반납한다.

    3. 테스트를 실행하기 전에 데이터베이스가 알려진 상태인지 확인해서 개발자가 테스트를 정확하게 실행할 수 있는지 확인 가능하게 한다.

     

    이와 같은 테스트 생애주기와 관련한 extension을 구현하기 위해 다음 인터페이스를 구현해보자.

    - BeforeEachCallback, AfterEachCallback : 각 테스트 메서드가 실행되기 전후 각각 실행됨

    - BeforeAllCallback, AfterAllCallback : 모든 테스트 메서드가 실행되기 전후 한번 실행됨

    package com.study.junit.ch14;
    
    import com.study.junit.ch14.jdbc.ConnectionManager;
    import com.study.junit.ch14.jdbc.TablesManager;
    import org.junit.jupiter.api.extension.*;
    
    import java.sql.Connection;
    import java.sql.Savepoint;
    
    // 네 가지 생애주기 인터페이스 구현
    public class DatabaseOperationsExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
    
        private Connection connection; // 커넥션 얻기 위한 필드
        private Savepoint savepoint; // 데이터베이스 상태기록용 필드
        
        @Override
        public void beforeAll(ExtensionContext context) throws Exception {
            connection = ConnectionManager.openConnection();
            TablesManager.dropTable(connection);
            TablesManager.createTable(connection);
        }
    
        @Override
        public void afterAll(ExtensionContext context) throws Exception {
            ConnectionManager.closeConnection();
        }
    
    
        @Override
        public void beforeEach(ExtensionContext context) throws Exception {
            // 자동커밋 비활성화로 테스트에서 변경된 데이터가 커밋되는것을 막음
            connection.setAutoCommit(false);
            savepoint = connection.setSavepoint("savepoint");
        }
    
        @Override
        public void afterEach(ExtensionContext context) throws Exception {
            connection.rollback(savepoint);
        }
    }

     

    기존에 작성한 테스트를 확장시킨 후 insert, update, delete 테스트를 해보자.

    package com.study.junit.ch14;
    
    import com.study.junit.ch14.jdbc.PassengerDao;
    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;
    
    @ExtendWith({ExecutionContextExtension.class,
                DatabaseOperationsExtension.class})
    public class PassengerTest {
    
        private PassengerDao passengerDao;
    
        public PassengerTest(PassengerDao passengerDao) {
            this.passengerDao = passengerDao;
        }
    
        @Test
        void testPassenger() {
            Passenger passenger = new Passenger("123-456-789", "John Smith");
            assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
        }
    
        @Test
        void testInsertPassenger() {
            Passenger passenger = new Passenger("123-456-789", "John Smith");
            passengerDao.insert(passenger);
            assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
        }
    
        @Test
        void testUpdatePassenger() {
            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());
        }
    
        @Test
        void testDeletePassenger() {
            Passenger passenger = new Passenger("123-456-789", "John Smith");
            passengerDao.insert(passenger);
            passengerDao.delete(passenger);
            assertNull(passengerDao.getById("123-456-789"));
        }
    }

     

    이 테스트는 실패하게되는데,

     

    org.junit.jupiter.api.extension.ParameterResolutionException: No ParameterResolver registered for parameter [cohttp://m.study.junit.ch14.jdbc.PassengerDao arg0] in constructor

     

    PassengerTest 클래스의 생성자가 PassengerDao 타입의 파라미터를 받지만, 해당 파라미터를 리졸브하는 ParameterResolver가 없기 때문에 발생한다. 이 문제는 ParameterResolver 인터페이스를 구현하면 해결된다.

     

    DataAccessObjectParameterResolver

    package com.study.junit.ch14;
    
    import com.study.junit.ch14.jdbc.ConnectionManager;
    import com.study.junit.ch14.jdbc.PassengerDao;
    import com.study.junit.ch14.jdbc.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;
    
    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());
        }
    }

     

    ParameterResolver 인터페이스는 테스트가 필요로하는 파라미터를 리졸브할때 사용한다.

     

    다시 리졸버를 추가해 테스트를 확장해서 테스트해보자.

    @ExtendWith({ExecutionContextExtension.class,
                DatabaseOperationsExtension.class,
                DataAccessObjectParameterResolver.class})
    public class PassengerTest {
    ...
    }

     

    이제 데이터 중복을 체크하여 테스트에 예외처리 또한 확장시켜보자.

    public class PassengerExistsException extends Exception{
        
        private Passenger passenger;
        
        public PassengerExistsException(Passenger passenger, String message) {
            super(message);
            this.passenger = passenger;
        }
    }

     

    이제 DaoImpl에서 insert 메서드에서 예외를 던질 수 있도록 수정한다.

    @Override
        public void insert(Passenger passenger) throws PassengerExistsException {
            String sql = "INSERT INTO PASSENGERS (ID, NAME) VALUES (?, ?)";
            
            if (null != getById(passenger.getIdentifier())) {
                throw new PassengerExistsException(passenger, passenger.toString());
            }
    
            try (PreparedStatement statement = connection.prepareStatement(sql)) {
                statement.setString(1, passenger.getIdentifier());
                statement.setString(2, passenger.getName());
                statement.executeUpdate();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }

     

    예외처리가 정상적으로 되는지 확인하기 위하여 예외 로그를 남기는 extension을 구현해보자.

    import com.study.junit.ch14.jdbc.PassengerExistsException;
    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 context, Throwable throwable) throws Throwable {
            if (throwable instanceof PassengerExistsException) {
                logger.severe("Passenger exists:" + throwable.getMessage());
                return;
            }
            throw throwable;
        }
    }

     

    이제 테스트를 추가 확장시켜주고, insert 메서드를 두번 실행시켜 예외처리가 정상적으로 작동하는지 확인한다.

    @ExtendWith({ExecutionContextExtension.class,
                DatabaseOperationsExtension.class,
                DataAccessObjectParameterResolver.class,
                LogPassengerExistsExceptionExtension.class})
    public class PassengerTest {
    //...
    	@Test
        void testInsertPassenger() throws PassengerExistsException {
            Passenger passenger = new Passenger("123-456-789", "John Smith");
            passengerDao.insert(passenger);
            passengerDao.insert(passenger);
            assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
        }
    //...
    }

     

    모든 테스트에 완료표시가 뜨고 PassengerExistsException 예외를 캐치하여 로그를 남긴것을 확인할 수 있다.

     

    * JUnit5에서 제공하는 확장 지점과 인터페이스

    확장지점 구현 인터페이스
    조건부 테스트 실행 ExecutionCondition
    생애주기 콜백 BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback
    파라미터리졸브 ParameterResolver
    예외처리 TestExecutionExceptionHandler
    테스트 인스턴스 후처리 TestInstancePostProcessor

     

    댓글

Designed by Tistory.