Junit5 확장 모델 살펴보기
Junit5 확장 모델은 Extension API라는 단일 개념으로 설명할 수 있다. Extension자체는 내부에 필드나 메서드가 없는 인터페이스인 마
커인터페이스일 뿐으로, 해당 인터페이스를 구현하는 클래스에 특별한 의미나 기능을 부여하기 위해 사용한다.
- 마커 인터페이스 (태그 인터페이스, 토큰 인터페이스)
마커 인터페이스는 구현 메서드가 따로 없는 인터페이스로, 해당 인터페이스를 구현하는 클래스에 특별한 의미나 기능을 부여하기 위해 사용한다. 대표적인 사례로는 Serailizable과 Cloneable인터페이스가 있다.
public interface MyMarkerInterface { // No methods }
테스트가 생애 주기를 타는 중에 사전에 정의한 확장 지점에 걸리면 Junit엔진은 등록한 extension을 자동으로 호출한다. 확장지점(extension point)은 특정 이벤트가 발생하면 테스트가 발생하는 시점을 의미한다.
- 확장 지점의 종류
- 조건부 테스트 실행 : 특정 조건을 충족했을 때 테스트를 실행하기 위해 사용한다
- 생애 주기 콜백 : 테스트가 생애주기에 반응하도록 만들어야할 때 사용한다
- 파라미터 리졸브 : 런타임에서 테스트에 주입할 파라미터를 리졸브하는 시점에 사용한다
- 예외 처리 : 특정 유형의 예외가 발생할 때 수행할 테스트 동작을 정의한다
- 테스트 인스턴스 후처리 : 테스트 인스턴스가 생성된 다음에 실행할 때 사용한다
이러한 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());
}
}
이런식으로 테스트 구현시 원하는 확장을 추가할 수 있다.