Làm cách nào để quản lý tài nguyên kiểm tra đơn vị trong Kotlin, chẳng hạn như bắt đầu / dừng kết nối cơ sở dữ liệu hoặc máy chủ tìm kiếm đàn hồi được nhúng?


94

Trong các bài kiểm tra Kotlin JUnit của mình, tôi muốn khởi động / dừng các máy chủ nhúng và sử dụng chúng trong các bài kiểm tra của mình.

Tôi đã thử sử dụng @Beforechú thích JUnit trên một phương thức trong lớp thử nghiệm của mình và nó hoạt động tốt, nhưng nó không phải là hành vi đúng vì nó chạy mọi trường hợp thử nghiệm thay vì chỉ một lần.

Do đó, tôi muốn sử dụng @BeforeClasschú thích trên một phương thức, nhưng việc thêm nó vào một phương thức dẫn đến lỗi nói rằng nó phải nằm trên một phương thức tĩnh. Kotlin dường như không có các phương thức tĩnh. Và sau đó, điều tương tự cũng áp dụng cho các biến tĩnh, vì tôi cần giữ một tham chiếu đến máy chủ nhúng xung quanh để sử dụng trong các trường hợp thử nghiệm.

Vậy làm cách nào để tạo cơ sở dữ liệu nhúng này chỉ một lần cho tất cả các trường hợp thử nghiệm của tôi?

class MyTest {
    @Before fun setup() {
       // works in that it opens the database connection, but is wrong 
       // since this is per test case instead of being shared for all
    }

    @BeforeClass fun setupClass() {
       // what I want to do instead, but results in error because 
       // this isn't a static method, and static keyword doesn't exist
    }

    var referenceToServer: ServerType // wrong because is not static either

    ...
}

Lưu ý: câu hỏi này được tác giả viết và trả lời có chủ đích ( Câu hỏi tự trả lời ), vì vậy câu trả lời cho các chủ đề Kotlin thường gặp đều có trong SO.


2
JUnit 5 có thể hỗ trợ các phương thức không tĩnh cho trường hợp sử dụng đó, hãy xem github.com/junit-team/junit5/issues/419#issuecomment-267815529 và vui lòng +1 nhận xét của tôi để cho thấy các nhà phát triển Kotlin quan tâm đến những cải tiến đó.
Sébastien Deleuze

Câu trả lời:


156

Lớp kiểm thử đơn vị của bạn thường cần một số thứ để quản lý tài nguyên dùng chung cho một nhóm các phương pháp kiểm tra. Và trong Kotlin, bạn có thể sử dụng @BeforeClass@AfterClasskhông phải trong lớp thử nghiệm, mà là trong đối tượng đồng hành của nó cùng với @JvmStaticchú thích .

Cấu trúc của một lớp kiểm tra sẽ giống như sau:

class MyTestClass {
    companion object {
        init {
           // things that may need to be setup before companion class member variables are instantiated
        }

        // variables you initialize for the class just once:
        val someClassVar = initializer() 

        // variables you initialize for the class later in the @BeforeClass method:
        lateinit var someClassLateVar: SomeResource 

        @BeforeClass @JvmStatic fun setup() {
           // things to execute once and keep around for the class
        }

        @AfterClass @JvmStatic fun teardown() {
           // clean up after this class, leave nothing dirty behind
        }
    }

    // variables you initialize per instance of the test class:
    val someInstanceVar = initializer() 

    // variables you initialize per test case later in your @Before methods:
    var lateinit someInstanceLateZVar: MyType 

    @Before fun prepareTest() { 
        // things to do before each test
    }

    @After fun cleanupTest() {
        // things to do after each test
    }

    @Test fun testSomething() {
        // an actual test case
    }

    @Test fun testSomethingElse() {
        // another test case
    }

    // ...more test cases
}  

Với những điều trên, bạn nên đọc về:

  • các đối tượng đồng hành - tương tự như đối tượng Class trong Java, nhưng một singleton trên mỗi lớp không tĩnh
  • @JvmStatic - một chú thích biến một phương thức đối tượng đồng hành thành một phương thức tĩnh trên lớp bên ngoài cho Java interop
  • lateinit- cho phép một thuộc vartính được khởi tạo sau này khi bạn có một vòng đời được xác định rõ ràng
  • Delegates.notNull()- có thể được sử dụng thay vì lateinitcho một thuộc tính phải được đặt ít nhất một lần trước khi được đọc.

Dưới đây là các ví dụ đầy đủ hơn về các lớp thử nghiệm cho Kotlin quản lý tài nguyên nhúng.

Đầu tiên được sao chép và sửa đổi từ các bài kiểm tra Solr-Undertow và trước khi các trường hợp kiểm tra được chạy, hãy cấu hình và khởi động máy chủ Solr-Undertow. Sau khi các bài kiểm tra chạy, nó sẽ xóa mọi tệp tạm thời được tạo bởi các bài kiểm tra. Nó cũng đảm bảo các biến môi trường và thuộc tính hệ thống là chính xác trước khi chạy thử nghiệm. Giữa các trường hợp thử nghiệm, nó dỡ bỏ mọi lõi Solr được tải tạm thời. Các bài kiểm tra:

class TestServerWithPlugin {
    companion object {
        val workingDir = Paths.get("test-data/solr-standalone").toAbsolutePath()
        val coreWithPluginDir = workingDir.resolve("plugin-test/collection1")

        lateinit var server: Server

        @BeforeClass @JvmStatic fun setup() {
            assertTrue(coreWithPluginDir.exists(), "test core w/plugin does not exist $coreWithPluginDir")

            // make sure no system properties are set that could interfere with test
            resetEnvProxy()
            cleanSysProps()
            routeJbossLoggingToSlf4j()
            cleanFiles()

            val config = mapOf(...) 
            val configLoader = ServerConfigFromOverridesAndReference(workingDir, config) verifiedBy { loader ->
                ...
            }

            assertNotNull(System.getProperty("solr.solr.home"))

            server = Server(configLoader)
            val (serverStarted, message) = server.run()
            if (!serverStarted) {
                fail("Server not started: '$message'")
            }
        }

        @AfterClass @JvmStatic fun teardown() {
            server.shutdown()
            cleanFiles()
            resetEnvProxy()
            cleanSysProps()
        }

        private fun cleanSysProps() { ... }

        private fun cleanFiles() {
            // don't leave any test files behind
            coreWithPluginDir.resolve("data").deleteRecursively()
            Files.deleteIfExists(coreWithPluginDir.resolve("core.properties"))
            Files.deleteIfExists(coreWithPluginDir.resolve("core.properties.unloaded"))
        }
    }

    val adminClient: SolrClient = HttpSolrClient("http://localhost:8983/solr/")

    @Before fun prepareTest() {
        // anything before each test?
    }

    @After fun cleanupTest() {
        // make sure test cores do not bleed over between test cases
        unloadCoreIfExists("tempCollection1")
        unloadCoreIfExists("tempCollection2")
        unloadCoreIfExists("tempCollection3")
    }

    private fun unloadCoreIfExists(name: String) { ... }

    @Test
    fun testServerLoadsPlugin() {
        println("Loading core 'withplugin' from dir ${coreWithPluginDir.toString()}")
        val response = CoreAdminRequest.createCore("tempCollection1", coreWithPluginDir.toString(), adminClient)
        assertEquals(0, response.status)
    }

    // ... other test cases
}

Và một AWS DynamoDB bắt đầu cục bộ khác dưới dạng cơ sở dữ liệu nhúng (được sao chép và sửa đổi một chút từ Chạy AWS DynamoDB-cục bộ nhúng ). Thử nghiệm này phải hack java.library.pathtrước khi bất kỳ điều gì khác xảy ra nếu không DynamoDB cục bộ (sử dụng sqlite với thư viện nhị phân) sẽ không chạy. Sau đó, nó khởi động một máy chủ để chia sẻ cho tất cả các lớp kiểm tra và dọn dẹp dữ liệu tạm thời giữa các lần kiểm tra. Các bài kiểm tra:

class TestAccountManager {
    companion object {
        init {
            // we need to control the "java.library.path" or sqlite cannot find its libraries
            val dynLibPath = File("./src/test/dynlib/").absoluteFile
            System.setProperty("java.library.path", dynLibPath.toString());

            // TEST HACK: if we kill this value in the System classloader, it will be
            // recreated on next access allowing java.library.path to be reset
            val fieldSysPath = ClassLoader::class.java.getDeclaredField("sys_paths")
            fieldSysPath.setAccessible(true)
            fieldSysPath.set(null, null)

            // ensure logging always goes through Slf4j
            System.setProperty("org.eclipse.jetty.util.log.class", "org.eclipse.jetty.util.log.Slf4jLog")
        }

        private val localDbPort = 19444

        private lateinit var localDb: DynamoDBProxyServer
        private lateinit var dbClient: AmazonDynamoDBClient
        private lateinit var dynamo: DynamoDB

        @BeforeClass @JvmStatic fun setup() {
            // do not use ServerRunner, it is evil and doesn't set the port correctly, also
            // it resets logging to be off.
            localDb = DynamoDBProxyServer(localDbPort, LocalDynamoDBServerHandler(
                    LocalDynamoDBRequestHandler(0, true, null, true, true), null)
            )
            localDb.start()

            // fake credentials are required even though ignored
            val auth = BasicAWSCredentials("fakeKey", "fakeSecret")
            dbClient = AmazonDynamoDBClient(auth) initializedWith {
                signerRegionOverride = "us-east-1"
                setEndpoint("http://localhost:$localDbPort")
            }
            dynamo = DynamoDB(dbClient)

            // create the tables once
            AccountManagerSchema.createTables(dbClient)

            // for debugging reference
            dynamo.listTables().forEach { table ->
                println(table.tableName)
            }
        }

        @AfterClass @JvmStatic fun teardown() {
            dbClient.shutdown()
            localDb.stop()
        }
    }

    val jsonMapper = jacksonObjectMapper()
    val dynamoMapper: DynamoDBMapper = DynamoDBMapper(dbClient)

    @Before fun prepareTest() {
        // insert commonly used test data
        setupStaticBillingData(dbClient)
    }

    @After fun cleanupTest() {
        // delete anything that shouldn't survive any test case
        deleteAllInTable<Account>()
        deleteAllInTable<Organization>()
        deleteAllInTable<Billing>()
    }

    private inline fun <reified T: Any> deleteAllInTable() { ... }

    @Test fun testAccountJsonRoundTrip() {
        val acct = Account("123",  ...)
        dynamoMapper.save(acct)

        val item = dynamo.getTable("Accounts").getItem("id", "123")
        val acctReadJson = jsonMapper.readValue<Account>(item.toJSON())
        assertEquals(acct, acctReadJson)
    }

    // ...more test cases

}

LƯU Ý: một số phần của các ví dụ được viết tắt bằng...


0

Rõ ràng, quản lý tài nguyên với các lệnh gọi lại trước / sau trong các thử nghiệm có những ưu điểm:

  • Thử nghiệm là "nguyên tử". Một bài kiểm tra thực thi toàn bộ mọi thứ với tất cả các lệnh gọi lại Người ta sẽ không quên kích hoạt một dịch vụ phụ thuộc trước khi kiểm tra và tắt nó sau khi hoàn thành. Nếu được thực hiện đúng cách, các lệnh gọi lại thực thi sẽ hoạt động trên mọi môi trường.
  • Các bài kiểm tra là khép kín. Không có dữ liệu bên ngoài hoặc giai đoạn thiết lập, mọi thứ được chứa trong một vài lớp thử nghiệm.

Nó cũng có một số khuyết điểm. Một điều quan trọng trong số đó là nó gây ô nhiễm mã và làm cho mã vi phạm nguyên tắc trách nhiệm đơn lẻ. Các bài kiểm tra giờ đây không chỉ kiểm tra một thứ gì đó mà còn thực hiện quá trình khởi tạo và quản lý tài nguyên nặng nề. Nó có thể ổn trong một số trường hợp (như định cấu hình mộtObjectMapper ), nhưng việc sửa đổi java.library.pathhoặc tạo ra các quy trình khác (hoặc cơ sở dữ liệu nhúng trong quy trình) không phải là vô tội.

Tại sao không coi các dịch vụ đó là phần phụ thuộc để thử nghiệm của bạn đủ điều kiện cho "tiêm", như được 12factor.net mô tả .

Bằng cách này, bạn bắt đầu và khởi tạo các dịch vụ phụ thuộc ở đâu đó bên ngoài mã thử nghiệm.

Ngày nay ảo hóa và vùng chứa hầu như ở khắp mọi nơi và hầu hết các máy của nhà phát triển đều có thể chạy Docker. Và hầu hết các ứng dụng đều có phiên bản dày đặc: Elasticsearch , DynamoDB , PostgreSQL , v.v. Docker là một giải pháp hoàn hảo cho các dịch vụ bên ngoài mà các thử nghiệm của bạn cần.

  • Nó có thể là một tập lệnh chạy được nhà phát triển chạy theo cách thủ công mỗi khi cô ấy muốn thực hiện các bài kiểm tra.
  • Nó có thể là một tác vụ được chạy bởi công cụ xây dựng (ví dụ như Gradle có awesome dependsOnfinalizedByDSL để xác định các phụ thuộc). Tất nhiên, một tác vụ có thể thực thi cùng một tập lệnh mà nhà phát triển thực thi theo cách thủ công bằng cách sử dụng trình bao / thực thi quy trình.
  • Nó có thể là một tác vụ được chạy bởi IDE trước khi thực thi thử nghiệm . Một lần nữa, nó có thể sử dụng cùng một tập lệnh.
  • Hầu hết các nhà cung cấp CI / CD đều có khái niệm về "dịch vụ" - một phụ thuộc bên ngoài (quy trình) chạy song song với bản dựng của bạn và có thể được truy cập thông qua SDK / trình kết nối / API thông thường của nó: Gitlab , Travis , Bitbucket , AppVeyor , Semaphore ,…

Cách tiếp cận này:

  • Giải phóng mã thử nghiệm của bạn khỏi logic khởi tạo. Các bài kiểm tra của bạn sẽ chỉ kiểm tra và không làm gì hơn.
  • Tách mã và dữ liệu. Việc thêm một trường hợp thử nghiệm mới hiện có thể được thực hiện bằng cách thêm dữ liệu mới vào các dịch vụ phụ thuộc với bộ công cụ gốc của nó. Tức là đối với cơ sở dữ liệu SQL, bạn sẽ sử dụng SQL, đối với Amazon DynamoDB, bạn sẽ sử dụng CLI để tạo bảng và đặt các mục.
  • Gần với mã sản xuất hơn, nơi bạn rõ ràng không bắt đầu các dịch vụ đó khi ứng dụng "chính" của bạn khởi động.

Tất nhiên, nó có sai sót (về cơ bản, các câu lệnh mà tôi đã bắt đầu):

  • Các thử nghiệm không mang tính "nguyên tử" hơn. Dịch vụ phụ thuộc phải được bắt đầu bằng cách nào đó trước khi thực hiện thử nghiệm. Cách nó được bắt đầu có thể khác nhau trong các môi trường khác nhau: máy của nhà phát triển hoặc CI, IDE hoặc công cụ xây dựng CLI.
  • Các bài kiểm tra không tự có. Bây giờ dữ liệu hạt giống của bạn thậm chí có thể được đóng gói bên trong một hình ảnh, vì vậy việc thay đổi nó có thể yêu cầu xây dựng lại một dự án khác.
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.