JUnit 5 mở rộng

Bài viết này mình đi tìm hiểu và trình bày về mô hình mở rộng của thư viện test JUnit 5. Mục đích của Junit 5 mở rộng là mở rộng hành vi của các lớp, method test, và có thể được tái sử dụng cho nhiều lớp test. Trước JUnit 5, phiên bản JUnit 4 đã sử dụng hai thành phần mở rộng test là: test runner và rules. So sánh để thấy sự khác biệt giữa hai phiên bản thì JUnit 5 đã đơn giản hóa cơ chế mở rộng test bằng cách giới thiệu một khái niệm duy nhất là API mở rộng.

Mô hình JUnit 5 mở rộng

Các phần JUnit 5 mở rộng liên quan đến một sự kiện nhất định trong việc thực thi test, nó được gọi là điểm mở rộng. Khi một pha nhất định đạt được một chu kỳ vòng đời thì, JUnit engin gọi các phần mở rộng đã đăng ký. Có 5 loại chính của các điểm mở rộng mà ta có thể sử dụng là:

  • Test sau - test instance post-processing
  • Thực hiện test có điều kiện - conditional test excution
  • Gọi lại vòng test - life-cycle callbacks
  • Mức độ phân giải tham số - parameter resolution
  • Xử lý ngoại lệ - exception handling

Sau đây chúng ta cùng đi vào chi tiết từng điểm mở rộng của JUnit 5.

Cấu hình Maven dependencies

Chúng ta sẽ cấu hình maven dependence cho project của chúng ta để có thể sử dụng thư viện JUnit unit-jupiter-engine như sau:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.0.2</version>
    <scope>test</scope>
</dependency>

Và chúng ta cũng cấu hình thư viện log và helper cho project của ta nữa.

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.8.2</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
</dependency>

Với cấu hình như trên thì project của ta có thể download bản mới nhất thư viện unit-jupiter-engine, log4j, và h2 từ Maven central.

Tạo JUnit 5 mở rộng

Để tạo một JUnit 5 mở rộng, chúng ta cần khai báo một lớp và lớp này sẽ implements một hoặc nhiều interface tương ứng với các điểm JUnit 5 mở rộng (5 điểm chính). Toàn bộ các interface này đều mở rộng từ Extension interface chính - chỉ là một giao diện đánh dấu.

TestInstancePostProcessor mở rộng

Loại của điểm mở rộng này là thực hiện sau một thực thể của test đã được tạo. Tên Interface để implement là TestInstancePostProcessor. Interface này có method postProcessTestInstance() để thực hiện oerride. Một trường hợp sử dụng điển hình cho mở rộng này là tiêm các phụ thuộc vào thực thể đã được tạo đó. Ví dụ, ta tạo một mở rộng để khởi tạo đối tượng Logger, sau đó thì gọi method setLogger() trên đối tượng test đã tạo.

public class LoggingExtension implements TestInstancePostProcessor {
 
    @Override
    public void postProcessTestInstance(Object testInstance, 
      ExtensionContext context) throws Exception {
        Logger logger = LogManager.getLogger(testInstance.getClass());
        testInstance.getClass()
          .getMethod("setLogger", Logger.class)
          .invoke(testInstance, logger);
    }
}

Như đã thấy ở trên, method postProcessTestInstance() cho phép truy cập vào đối tượng test đã tạo và gọi method setLogger() của lớp test sử dụng cơ chế phản chiếu.

Conditional Test mở rộng

JUnit 5 cung cấp một loại mở rộng mà có thể kiểm xoát việc test có được chạy hay không. Nó được khai báo bằng cách implement interface ExecutionCondition.

Ta tạo lớp EnvironmentExtension implement interface này (ExecutionCondition) và override method evaluateExecutionCondition().

Method sẽ xác thực nếu một thuộc tính của biến môi trường hiện tại có tên là "qa" thì tắt test:

public class EnvironmentExtension implements ExecutionCondition {
 
    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(
      ExtensionContext context) {
         
        Properties props = new Properties();
        props.load(EnvironmentExtension.class
          .getResourceAsStream("application.properties"));
        String env = props.getProperty("env");
        if ("qa".equalsIgnoreCase(env)) {
            return ConditionEvaluationResult
              .disabled("Test disabled on QA environment");
        }
         
        return ConditionEvaluationResult.enabled(
          "Test enabled on QA environment");
    }
}

Kết quả là các test đã đăn ký mở rộng này sẽ không chạy trên môi trường "qa".

Lifecycle Callbacks mở rộng

Bộ tiện ích của mở rộng này liên quan đến những sự kiện trong vòng đời của test, và có thể được định nghĩa bằng cách implement các interface sau:

  • BeforeAllCallbackAfterAllCallback- Thực thi trước và sau toàn bộ các method test.
  • BeforeEachCallBackAfterEachCallback - Thực thi trước và sau mỗi method test.
  • BeforeTestExecutionCallbackAfterTestExecutionCallback - Thực thi ngay trước và sau một method test.

Nếu lớp test định nghĩa các method kiểu lifecycle thì thứ tự thực hiện của chúng như sau:

  1. BeforeAllCallback
  2. BeforeAll
  3. BeforeEachCallback
  4. BeforeEach
  5. BeforeTestExecutionCallback
  6. Test
  7. AfterTestExecutionCallback
  8. AfterEach
  9. AfterEachCallback
  10. AfterAll
  11. AfterAllCallback

Trở lại ví dụ của chúng ta, ta tạo một lớp impliment một số interface lifecycle trên, và thực hiện test behevior xử lý database sử dụng JDBC. Trước tiên ta tạo thực thể Employee như sau

public class Employee {
 
    private long id;
    private String firstName;
    // constructors, getters, setters
}

Chúng ta cũng cần lớp tiện ích utiliti tạo kết nối đến database sử dụng thông tin kết nối từ file .properties.

public class JdbcConnectionUtil {
 
    private static Connection con;
 
    public static Connection getConnection() 
      throws IOException, ClassNotFoundException, SQLException{
        if (con == null) {
            // create connection con
        }
        return con;
    }
}

Sau cùng ta tạo lớp DAO đơn giản thao tác với thực thể Employee.

public class EmployeeJdbcDao {
    private Connection con;
 
    public EmployeeJdbcDao(Connection con) {
        this.con = con;
    }
 
    public void createTable() throws SQLException {
        // create employees table
    }
 
    public void add(Employee emp) throws SQLException {
       // add employee record
    }
 
    public List<Employee> findAll() throws SQLException {
       // query all employee records
    }
}

Và giờ là lúc ta tạo lớp mở rộng, lớp này sẽ implement một số interface lifecycle trên

public class EmployeeDatabaseSetupExtension implements
  BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
    //...
}

Mỗi một interface này chưa một abstract method mà lớp mở rộng của ta cần override. Với interface BeforeAllCallback ta override method beforeAll(), ở method này ta viết logic tạo bảng employee trước khi các method test chạy.

private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();
 
@Override
public void beforeAll(ExtensionContext context) throws SQLException {
    employeeDao.createTable();
}

Chúng ta tiếp tục override method beforeEach() của BeforeEachCallback, và method afterEach() của AfterEachCallback để bọc từng method test trong một transaction. Mục đích của việc này là để chúng ta rollback lại database sau mỗi mehod test chạy, nghĩa là method test tiếp theo sẽ làm việc với database lúc đầu. Ở method beforeEach() chúng ta tạo point để đánh dấu mốc rollback database.

private Connection con = JdbcConnectionUtil.getConnection();
private Savepoint savepoint;
 
@Override
public void beforeEach(ExtensionContext context) throws SQLException {
    con.setAutoCommit(false);
    savepoint = con.setSavepoint("before");
}

Rồi ta rollback database ở method afterEach() như sau.

@Override
public void afterEach(ExtensionContext context) throws SQLException {
    con.rollback(savepoint);
}

Chúng ta thực hiện đóng kết nối database ở method afterAll() của interface AfterAllCallback.

@Override
public void afterAll(ExtensionContext context) throws SQLException {
    if (con != null) {
        con.close();
    }
}

Parameter Resolution mở rộng

Nếu lớp test có constructor hay method nhận một tham số, vậy nó phải được giải quyết lúc chạy bởi một ParameterResover. Chúng ta hãy tạo lớp gọi là trình giải quyết, lớp này implement interface ParameterResolver để giải quyết các param của EmployeeJdbcDao.

public class EmployeeDaoParameterResolver implements ParameterResolver {
 
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, 
      ExtensionContext extensionContext) throws ParameterResolutionException {
        return parameterContext.getParameter().getType()
          .equals(EmployeeJdbcDao.class);
    }
 
    @Override
    public Object resolveParameter(ParameterContext parameterContext, 
      ExtensionContext extensionContext) throws ParameterResolutionException {
        return new EmployeeJdbcDao();
    }
}

Trình giải quyết của chúng ta sẽ thực hiện hai nhiệm vụ. Thứ nhất, method supportsParameter() sẽ nhận diện kiểu của tham số. Sau đó method resolveParameter() sẽ tạo ra đối tượng tham số đó (ở ví dụ của ta là EmployeeJdbcDao)

Exception Handling mở rộng

Là phần mở rộng cuối cùng nhưng không kém phần quan trọng là interface TestExecutionExceptionHandler. Ta có thể sử dụng nó để xác định hành vi của lớp test khi nó xảy ra ngoại lê như FileNotFoundException chẳng hạn. Ta tạo một lớp mở rộng implement interface TestExecutionExceptionHandler. Mục đích của lớp này là ghi ra file log nếu exception xảy ra là loại FileNotFoundException, và bắn ra exception nếu là loại khác.

public class IgnoreFileNotFoundExceptionExtension 
  implements TestExecutionExceptionHandler {
 
    Logger logger = LogManager
      .getLogger(IgnoreFileNotFoundExceptionExtension.class);
     
    @Override
    public void handleTestExecutionException(ExtensionContext context,
      Throwable throwable) throws Throwable {
 
        if (throwable instanceof FileNotFoundException) {
            logger.error("File not found:" + throwable.getMessage());
            return;
        }
        throw throwable;
    }
}

Bài viết này tôi đã tìm hiểu và giới thiệu cách tạo và sử dụng năm mở rộng của JUnit 5. Tài liệu tham khảo baeldung - junit5-extensions