ฉันจะจัดการทรัพยากรการทดสอบหน่วยใน Kotlin ได้อย่างไรเช่นเริ่ม / หยุดการเชื่อมต่อฐานข้อมูลหรือเซิร์ฟเวอร์ elasticsearch ในตัว


94

ในการทดสอบ Kotlin JUnit ของฉันฉันต้องการเริ่ม / หยุดเซิร์ฟเวอร์แบบฝังและใช้ในการทดสอบของฉัน

ฉันลองใช้@BeforeคำอธิบายประกอบJUnit กับเมธอดในคลาสทดสอบของฉันและมันก็ใช้ได้ดี แต่มันไม่ใช่พฤติกรรมที่ถูกต้องเนื่องจากมันรันทุกกรณีทดสอบแทนที่จะใช้เพียงครั้งเดียว

ดังนั้นฉันจึงต้องการใช้@BeforeClassคำอธิบายประกอบในวิธีการ แต่การเพิ่มลงในวิธีการทำให้เกิดข้อผิดพลาดว่าต้องเป็นวิธีการคงที่ Kotlin ดูเหมือนจะไม่มีวิธีการคงที่ จากนั้นก็ใช้กับตัวแปรคงที่เช่นเดียวกันเพราะฉันต้องเก็บข้อมูลอ้างอิงไปยังเซิร์ฟเวอร์ฝังตัวเพื่อใช้ในกรณีทดสอบ

ดังนั้นฉันจะสร้างฐานข้อมูลแบบฝังนี้เพียงครั้งเดียวสำหรับกรณีทดสอบทั้งหมดของฉันได้อย่างไร

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

    ...
}

หมายเหตุ: คำถามนี้เขียนและตอบโดยผู้เขียนโดยเจตนา ( คำถามที่ตอบด้วยตนเอง ) เพื่อให้คำตอบของหัวข้อ Kotlin ที่ถามบ่อยมีอยู่ใน SO


2
JUnit 5 อาจรองรับวิธีการแบบไม่คงที่สำหรับกรณีการใช้งานนั้นโปรดดูที่github.com/junit-team/junit5/issues/419#issuecomment-267815529และสามารถ +1 ความคิดเห็นของฉันเพื่อแสดงให้นักพัฒนา Kotlin สนใจในการปรับปรุงดังกล่าว
Sébastien Deleuze

คำตอบ:


156

ชั้นทดสอบหน่วยของคุณมักต้องการบางสิ่งเพื่อจัดการทรัพยากรที่ใช้ร่วมกันสำหรับกลุ่มวิธีการทดสอบ และใน Kotlin คุณสามารถใช้@BeforeClassและ@AfterClassไม่ได้อยู่ในระดับการทดสอบ แต่ภายในของวัตถุสหายพร้อมกับคำอธิบายประกอบ@JvmStatic

โครงสร้างของคลาสทดสอบจะมีลักษณะดังนี้:

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
}  

จากที่กล่าวมาคุณควรอ่านเกี่ยวกับ:

  • วัตถุร่วม - คล้ายกับคลาสอ็อบเจ็กต์ใน Java แต่เป็นซิงเกิลตันต่อคลาสที่ไม่คงที่
  • @JvmStatic - คำอธิบายประกอบที่เปลี่ยนเมธอดอ็อบเจ็กต์ที่แสดงร่วมเป็นวิธีการแบบคงที่บนคลาสภายนอกสำหรับการทำงานร่วมกันของ Java
  • lateinit- อนุญาตให้varเริ่มต้นคุณสมบัติในภายหลังเมื่อคุณมีวงจรชีวิตที่กำหนดไว้อย่างดี
  • Delegates.notNull()- สามารถใช้แทนlateinitคุณสมบัติที่ควรตั้งค่าอย่างน้อยหนึ่งครั้งก่อนที่จะอ่าน

ต่อไปนี้เป็นตัวอย่างคลาสทดสอบสำหรับ Kotlin ที่จัดการทรัพยากรแบบฝัง

ขั้นแรกถูกคัดลอกและแก้ไขจากการทดสอบ Solr-Undertowและก่อนที่จะรันกรณีทดสอบให้กำหนดค่าและเริ่มเซิร์ฟเวอร์ Solr-Undertow หลังจากการทดสอบดำเนินการจะล้างไฟล์ชั่วคราวที่สร้างโดยการทดสอบ นอกจากนี้ยังช่วยให้แน่ใจว่าตัวแปรสภาพแวดล้อมและคุณสมบัติของระบบถูกต้องก่อนที่จะดำเนินการทดสอบ ระหว่างกรณีทดสอบจะยกเลิกการโหลดคอร์ Solr ที่โหลดชั่วคราว การทดสอบ:

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
}

และอีกตัวเริ่มต้น AWS DynamoDB ภายในเป็นฐานข้อมูลแบบฝัง (คัดลอกและแก้ไขเล็กน้อยจากการเรียกใช้ AWS DynamoDB-local แบบฝัง ) การทดสอบนี้ต้องแฮ็คjava.library.pathก่อนที่จะเกิดอะไรขึ้นมิฉะนั้น DynamoDB ในเครื่อง (โดยใช้ sqlite กับไบนารีไลบรารี) จะไม่ทำงาน จากนั้นจะเริ่มเซิร์ฟเวอร์เพื่อแชร์สำหรับคลาสทดสอบทั้งหมดและล้างข้อมูลชั่วคราวระหว่างการทดสอบ การทดสอบ:

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

}

หมายเหตุ:บางส่วนของตัวอย่างย่อด้วย...


0

การจัดการทรัพยากรด้วยการโทรกลับก่อน / หลังในการทดสอบเห็นได้ชัดว่ามันมีข้อดี:

  • การทดสอบคือ "ปรมาณู" การทดสอบจะดำเนินการโดยรวมด้วยการเรียกกลับทั้งหมด One จะไม่ลืมที่จะเปิดใช้งานบริการอ้างอิงก่อนการทดสอบและปิดลงหลังจากเสร็จสิ้น หากทำอย่างถูกต้องการเรียกกลับการดำเนินการจะทำงานในทุกสภาพแวดล้อม
  • การทดสอบมีอยู่ในตัว ไม่มีข้อมูลภายนอกหรือขั้นตอนการตั้งค่าทุกอย่างอยู่ในคลาสทดสอบสองสามคลาส

มันมีข้อเสียบางอย่างเช่นกัน สิ่งสำคัญประการหนึ่งก็คือการทำให้รหัสก่อให้เกิดมลพิษและทำให้รหัสละเมิดหลักการความรับผิดชอบเดียว ตอนนี้การทดสอบไม่เพียง แต่ทดสอบบางอย่างเท่านั้น แต่ยังดำเนินการเริ่มต้นและการจัดการทรัพยากรที่มีน้ำหนักมาก ในบางกรณีอาจใช้ได้ (เช่นการกำหนดค่าObjectMapper ) แต่การแก้ไขjava.library.pathหรือวางไข่กระบวนการอื่น (หรือฐานข้อมูลแบบฝังในกระบวนการ) ไม่ใช่เรื่องไร้เดียงสา

ทำไมไม่รักษาบริการเหล่านั้นเป็นการอ้างอิงสำหรับการทดสอบของคุณมีสิทธิ์สำหรับ "ฉีด" เช่นการอธิบายโดย12factor.net

วิธีนี้จะช่วยให้คุณเริ่มต้นและเริ่มต้นบริการการอ้างอิงที่อื่นนอกรหัสทดสอบ

ปัจจุบันการจำลองเสมือนและคอนเทนเนอร์มีอยู่แทบทุกที่และเครื่องของนักพัฒนาส่วนใหญ่สามารถเรียกใช้ Docker ได้ และส่วนใหญ่ของแอพลิเคชันมีรุ่น dockerized: ElasticSearch , DynamoDB , PostgreSQLและอื่น ๆ Docker เป็นโซลูชั่นที่สมบูรณ์แบบสำหรับบริการภายนอกที่คุณต้องการในการทดสอบ

  • อาจเป็นสคริปต์ที่เรียกใช้โดยนักพัฒนาซอฟต์แวร์ทุกครั้งที่เธอต้องการดำเนินการทดสอบ
  • อาจเป็นงานที่รันโดยเครื่องมือสร้าง (เช่น Gradle มีความยอดเยี่ยมdependsOnและfinalizedByDSL สำหรับกำหนดการอ้างอิง) แน่นอนว่างานสามารถรันสคริปต์เดียวกันกับที่นักพัฒนาดำเนินการด้วยตนเองโดยใช้เชลล์เอ้าต์ / ผู้ดำเนินการกระบวนการ
  • มันอาจจะเป็นงานที่ดำเนินการโดย IDE ก่อนที่จะดำเนินการทดสอบ อีกครั้งสามารถใช้สคริปต์เดียวกัน
  • ผู้ให้บริการ CI ส่วนใหญ่ / ซีดีมีความคิดของ "บริการ" - การพึ่งพาภายนอก (กระบวนการ) ที่วิ่งควบคู่ไปกับการสร้างของคุณและสามารถเข้าถึงได้ผ่านทางมันเป็นปกติ SDK / เชื่อมต่อ / API: Gitlab , เทรวิส , Bitbucket , AppVeyor , สัญญาณ ...

แนวทางนี้:

  • ปลดปล่อยรหัสทดสอบของคุณจากตรรกะการเริ่มต้น การทดสอบของคุณจะทดสอบและไม่ต้องทำอะไรเพิ่มเติม
  • ถอดรหัสรหัสและข้อมูล การเพิ่มกรณีทดสอบใหม่สามารถทำได้โดยการเพิ่มข้อมูลใหม่ลงในบริการอ้างอิงด้วยชุดเครื่องมือดั้งเดิม เช่นสำหรับฐานข้อมูล SQL คุณจะใช้ SQL สำหรับ Amazon DynamoDB คุณจะใช้ CLI เพื่อสร้างตารางและใส่รายการ
  • ใกล้เคียงกับรหัสการผลิตซึ่งเห็นได้ชัดว่าคุณไม่ได้เริ่มบริการเหล่านั้นเมื่อแอปพลิเคชัน "หลัก" ของคุณเริ่มทำงาน

แน่นอนว่ามันมีข้อบกพร่อง (โดยพื้นฐานแล้วข้อความที่ฉันเริ่มต้น):

  • การทดสอบไม่ใช่ "ปรมาณู" มากกว่า บริการอ้างอิงต้องเริ่มต้นอย่างใดอย่างหนึ่งก่อนการดำเนินการทดสอบ วิธีเริ่มต้นอาจแตกต่างกันในสภาพแวดล้อมที่แตกต่างกัน: เครื่องของผู้พัฒนาหรือ CI, IDE หรือ CLI เครื่องมือสร้าง
  • การทดสอบไม่มีในตัว ตอนนี้ข้อมูลเมล็ดพันธุ์ของคุณอาจถูกบรรจุไว้ในรูปภาพด้วยซ้ำดังนั้นการเปลี่ยนแปลงอาจต้องสร้างโครงการใหม่ขึ้นมาใหม่
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.