Giải quyết Thử thách Kiểm thử Đơn vị Gradle với `gradle.properties
Đăng ngày 13 July 2025
Vấn đề: Cô lập các Unit Test của một Gradle Plugin
Khi viết các bài kiểm tra đơn vị (unit tests) cho một plugin Gradle, một thách thức phổ biến là quản lý các phụ thuộc vào cấu hình dự án, chẳng hạn như các thuộc tính được định nghĩa trong tệp`gradle.properties`. Trong trường hợp của chúng tôi, plugin`jbake.ghpages`phải đọc một thuộc tính`site_config_path`để hoạt động chính xác. Kiểm thử đơn vị cho tác vụ`initialize`cần kiểm tra hành vi của plugin khi có thuộc tính này.
Vấn đề cơ bản là các bài kiểm thử đơn vị, theo thiết kế, phải được cô lập. Việc sử dụng`ProjectBuilder`của Gradle tạo một thể hiện của`Project`trong bộ nhớ, hoàn toàn tách rời khỏi một dự án thực sự trên hệ thống tệp. Do đó, phiên bản thử nghiệm này không tự động đọc tệp`gradle.properties`và do đó không biết về quyền sở hữu`site_config_path`.
Kiến trúc của Vấn đề
Sơ đồ sau đây minh họa sự ngắt kết nối giữa môi trường kiểm thử và hệ thống tệp tin:
@startuml
!define RECTANGLE class
package "Môi trường Production" {
RECTANGLE ProjectReal {
+ build.gradle.kts
+ gradle.properties
+ src/
}
RECTANGLE GradleProperties {
site_config_path=src/jbake/settings/site.yml
}
RECTANGLE JbakePlugin {
- readProperty(name: String)
+ initialize()
}
ProjectReal --> GradleProperties : lit au démarrage
JbakePlugin --> GradleProperties : accède via project.properties
}
package "Môi trường Test" {
RECTANGLE ProjectTest {
+ Créé par ProjectBuilder
+ En mémoire uniquement
+ Pas de gradle.properties
}
RECTANGLE JbakePluginTest {
+ testInitialize()
}
ProjectTest -[#red]x GradleProperties : ❌ Pas d'accès
JbakePluginTest --> ProjectTest : utilise
}
note right of ProjectTest : Cách ly = Không có quyền truy cập\nvào hệ thống tệp
@enduml
Sự Cám Dỗ của một Ý Tưởng Tưởng Là Hay
Một cách tiếp cận đầu tiên có thể là tạo một tệp`gradle.properties`giả trong tài nguyên kiểm thử.
// src/test/resources/gradle.properties
site_config_path=src/jbake/settings/site.yml
Tuy nhiên, phương pháp này chắc chắn sẽ thất bại vì`ProjectBuilder`không được thiết kế để quét hệ thống tệp nhằm tìm kiếm các tệp cấu hình. Bài kiểm tra sẽ vẫn bị cô lập và bỏ qua tệp này.
Giải pháp: Mô phỏng Thuộc tính với `ExtraPropertiesExtension
Giải pháp tinh tế cho vấn đề này không phải là đọc tệp, mà là mô phỏng sự hiện diện của thuộc tính trực tiếp trong đối tượng`Project`kiểm thử. Gradle cung cấp một cơ chế mạnh mẽ cho việc này: các thuộc tính bổ sung (Extra Properties).
Bước 1: Tìm hiểu ExtraPropertiesExtension
`ExtraPropertiesExtension`là một vùng chứa khóa-giá trị được gắn với mỗi đối tượng của mô hình Gradle. Nó cho phép thêm các thuộc tính một cách linh hoạt vào một dự án, một tác vụ, hoặc bất kỳ extension Gradle nào khác.
@startuml
!define RECTANGLE class
RECTANGLE Project {
+ name: String
+ version: String
+ extensions: ExtensionContainer
+ properties: Map<String, ?>
}
RECTANGLE ExtensionContainer {
+ getByType(Class<T>): T
+ add(String, Object): void
}
RECTANGLE ExtraPropertiesExtension {
+ set(String, Object): void
+ get(String): Object
+ has(String): boolean
+ getProperties(): Map<String, Object>
}
Project --> ExtensionContainer : contient
ExtensionContainer --> ExtraPropertiesExtension : gère
ExtraPropertiesExtension --> Project : synchronise avec\nproject.properties
note right of ExtraPropertiesExtension : Điểm nhập để\ntiêm các thuộc tính
@enduml
Bước 2: Thiết lập Bài kiểm tra - Chuẩn bị
Hãy bắt đầu bằng cách tạo cấu trúc cơ bản cho bài kiểm tra đơn vị của chúng ta:
class JbakeGhPagesPluginTest {
@TempDir
lateinit var testProjectDir: File
@Test
fun `check initialize and config yaml file if not existing`() {
// Étape 1 : Créer un projet de test isolé
val project = ProjectBuilder.builder()
.withProjectDir(testProjectDir)
.build()
// Suite des étapes...
}
}
Bước 3: Tiêm Thuộc tính
Dưới đây là quy trình chi tiết để inject thuộc tính vào dự án kiểm thử:
@Test
fun `check initialize and config yaml file if not existing`() {
// Étape 1 : Créer un projet de test isolé en mémoire
val project = ProjectBuilder.builder()
.withProjectDir(testProjectDir)
.build()
// Étape 2 : Récupérer le gestionnaire de propriétés supplémentaires
val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)
// Étape 3 : Définir la propriété requise pour ce test
extra.set("site_config_path", "src/jbake/settings/site.yml")
// Étape 4 : Vérifier que la propriété est bien injectée
assertTrue(project.hasProperty("site_config_path"))
// Étape 5 : Appliquer le plugin qui pourra maintenant accéder à la propriété
project.plugins.apply("jbake.ghpages")
// Étape 6 : Exécuter la tâche et vérifier son comportement
val task: Task = project.tasks.findByName("initialize")
.apply(::assertNotNull)!!
// Étape 7 : Exécuter les actions de la tâche
task.actions.forEach { it.execute(task) }
// Étape 8 : Assertions finales
assertEquals(
"src/jbake/settings/site.yml",
project.properties["site_config_path"],
"La propriété devrait être accessible dans le projet"
)
}
Bước 4: Sơ đồ Luồng của Giải pháp
@startuml
start
:Créer ProjectBuilder;
:Obtenir ExtraPropertiesExtension;
:Injecter la propriété\nvia extra.set();
partition "Kiểm tra" {
:Vérifier project.hasProperty();
:Appliquer le plugin;
:Récupérer la tâche;
}
partition "Thực thi" {
:Exécuter les actions\nde la tâche;
:Le plugin lit\nproject.properties;
}
partition "Khẳng định" {
:Vérifier la propriété\nest accessible;
:Valider le comportement\nattendues;
}
stop
@enduml
Bước 5: So sánh Trước/Sau
@startuml
!define RECTANGLE class
package "TRƯỚC - Vấn đề" {
RECTANGLE TestProject1 {
+ ProjectBuilder.build()
+ Pas de propriétés
}
RECTANGLE Plugin1 {
+ initialize()
+ ❌ project.properties["site_config_path"] = null
}
TestProject1 --> Plugin1 : échec
note right of Plugin1 : Kiểm tra thất bại vì\nthuộc tính không tồn tại
}
package "SAU - Giải pháp" {
RECTANGLE TestProject2 {
+ ProjectBuilder.build()
+ ExtraPropertiesExtension
}
RECTANGLE ExtraProps {
+ set("site_config_path", "...")
}
RECTANGLE Plugin2 {
+ initialize()
+ ✅ project.properties["site_config_path"] = "src/jbake/settings/site.yml"
}
TestProject2 --> ExtraProps : configure
ExtraProps --> Plugin2 : propriété disponible
note right of Plugin2 : Kiểm tra thành công vì\nthuộc tính được mô phỏng
}
@enduml
Bước 6: Quản lý nhiều Test Case
Để kiểm tra các kịch bản khác nhau, hãy tạo nhiều bài kiểm tra với các cấu hình đa dạng:
class JbakeGhPagesPluginTest {
@TempDir
lateinit var testProjectDir: File
private fun createProjectWithProperty(propertyValue: String?): Project {
val project = ProjectBuilder.builder()
.withProjectDir(testProjectDir)
.build()
// Injection conditionnelle de la propriété
propertyValue?.let { value ->
val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)
extra.set("site_config_path", value)
}
return project
}
@Test
fun `should work with valid property`() {
val project = createProjectWithProperty("src/jbake/settings/site.yml")
project.plugins.apply("jbake.ghpages")
val task = project.tasks.findByName("initialize")!!
task.actions.forEach { it.execute(task) }
// Assertions pour le cas normal
assertEquals("src/jbake/settings/site.yml", project.properties["site_config_path"])
}
@Test
fun `should handle missing property gracefully`() {
val project = createProjectWithProperty(null) // Pas de propriété
project.plugins.apply("jbake.ghpages")
val task = project.tasks.findByName("initialize")!!
// Le plugin devrait gérer l'absence de propriété
assertDoesNotThrow {
task.actions.forEach { it.execute(task) }
}
}
@Test
fun `should handle invalid property path`() {
val project = createProjectWithProperty("invalid/path/to/config.yml")
project.plugins.apply("jbake.ghpages")
val task = project.tasks.findByName("initialize")!!
// Test du comportement avec un chemin invalide
assertThrows<FileNotFoundException> {
task.actions.forEach { it.execute(task) }
}
}
}
Kiến trúc Giải pháp Cuối cùng
@startuml
!define RECTANGLE class
package "Môi trường Kiểm thử" {
RECTANGLE TestClass {
+ createProjectWithProperty()
+ testValidProperty()
+ testMissingProperty()
+ testInvalidProperty()
}
RECTANGLE ProjectBuilder {
+ builder()
+ withProjectDir()
+ build()
}
RECTANGLE TestProject {
+ extensions
+ properties
+ plugins
+ tasks
}
RECTANGLE ExtraPropertiesExtension {
+ set(key, value)
+ get(key)
+ has(key)
}
RECTANGLE JbakePlugin {
+ apply(project)
+ createInitializeTask()
+ readSiteConfigPath()
}
}
TestClass --> ProjectBuilder : utilise
ProjectBuilder --> TestProject : crée
TestProject --> ExtraPropertiesExtension : contient
TestClass --> ExtraPropertiesExtension : configure
ExtraPropertiesExtension --> TestProject : synchronise properties
TestProject --> JbakePlugin : applique
JbakePlugin --> TestProject : lit properties
note right of ExtraPropertiesExtension : Điểm kiểm soát\ncho việc tiêm
note bottom of TestProject : Môi trường\nkiểm soát và cô lập
@enduml
Những Lợi ích của Cách tiếp cận này
-
Cách ly Hoàn toàn: Bài kiểm tra không phụ thuộc vào bất kỳ tệp bên ngoài nào. Nó độc lập và có thể chạy một cách đáng tin cậy trong bất kỳ môi trường nào (local, CI/CD, v.v.).
-
Sự Rõ ràng và Ý định: Bài kiểm tra tuyên bố rõ ràng các điều kiện tiên quyết để thực thi. Bất kỳ ai đọc bài kiểm tra đều thấy ngay rằng plugin yêu cầu quyền sở hữu`site_config_path`để hoạt động.
-
Khả năng bảo trì: Nếu tên thuộc tính thay đổi, bạn chỉ cần cập nhật nó tại một nơi duy nhất trong bài kiểm tra mà không cần phải thao tác với các tệp cấu hình kiểm tra.
-
Tính linh hoạtViệc kiểm thử các kịch bản khác nhau trở nên dễ dàng hơn bằng cách chỉ cần thay đổi giá trị của thuộc tính được tiêm vào trong mỗi bài kiểm thử (giá trị hợp lệ, không hợp lệ, bị thiếu, v.v.).
-
Hiệu năng: Không đọc tệp, mọi thứ đều diễn ra trong bộ nhớ, giúp cho các bài kiểm tra nhanh hơn.
-
Khả năng tái lập: Các bài kiểm tra có tính xác định vì chúng không phụ thuộc vào trạng thái của hệ thống tệp.
Hướng dẫn và Lời khuyên Tốt nhất
Tạo một Phương thức Tiện ích
class GradleTestUtils {
companion object {
fun createProjectWithProperties(
projectDir: File,
properties: Map<String, String>
): Project {
val project = ProjectBuilder.builder()
.withProjectDir(projectDir)
.build()
val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)
properties.forEach { (key, value) ->
extra.set(key, value)
}
return project
}
}
}
Xác thực Thuộc tính
@Test
fun `should validate property injection`() {
val project = createProjectWithProperty("test-value")
// Vérifications multiples
assertTrue(project.hasProperty("site_config_path"))
assertEquals("test-value", project.property("site_config_path"))
assertNotNull(project.properties["site_config_path"])
// Vérification que la propriété est accessible par le plugin
project.plugins.apply("jbake.ghpages")
// ... reste du test
}
Kết luận
Thay vì cố gắng làm cho môi trường kiểm thử đơn vị đọc các tệp cấu hình, cách thực hành tốt nhất là mô phỏng trạng thái cần thiết. Việc sử dụng`ExtraPropertiesExtension`để định nghĩa các thuộc tính Gradle bằng lập trình là phương pháp sạch sẽ và mạnh mẽ nhất để thực hiện các bài kiểm tra đơn vị cho plugin một cách hiệu quả và đáng tin cậy.
Kỹ thuật này chuyển đổi một vấn đề phụ thuộc bên ngoài thành một thao tác tiêm phụ thuộc (dependency injection) đơn giản, vốn là cốt lõi trong triết lý của Phát triển hướng kiểm thử (TDD). Nó mang lại khả năng kiểm soát toàn diện đối với môi trường kiểm thử trong khi vẫn duy trì sự cô lập cần thiết cho các bài kiểm thử đơn vị chất lượng.
Các sơ đồ và ví dụ trình bày trong bài viết này cho thấy cách tiếp cận này có thể được triển khai một cách tiệm tiến và có phương pháp, cho phép tạo ra một bộ kiểm thử mạnh mẽ và dễ bảo trì cho các plugin Gradle của bạn.