Welcome to another step in our journey to mastering automated API testing with Kotlin. So far, you've learned how to organize tests using JUnit and OkHttp. Today, we will focus on automating tests for CRUD operations — Create, Read, Update, and Delete — which are integral actions for managing data in any application that uses RESTful APIs. Automated testing of these operations is essential to ensure that APIs function correctly and modify resources as expected. Thorough testing of CRUD operations will help you catch issues early and ensure API reliability.
In automated testing, setup and teardown are fundamental concepts that help ensure each test has a clean start and finish. Setup is about preparing what you need before a test runs, like creating test data or setting up configurations. Teardown involves cleaning up afterward, removing any leftovers from the test, such as deleting test data, so future tests aren't affected. This process ensures tests don't interfere with each other.
We'll use JUnit's @BeforeEach
and @AfterEach
annotations to make setup and teardown automatic. Imagine testing a todo
API with CRUD operations:
- Setup Phase: Runs before every test to create a fresh
todo
item, providing each test with the necessary starting data. - Teardown Phase: Runs after every test to delete the
todo
item, ensuring no leftover data impacts subsequent tests.
These annotations handle these phases automatically, before and after every test, keeping your tests independent and the environment clean.
To effectively manage setup and teardown for CRUD operations, we utilize JUnit's lifecycle annotations. These annotations automate the process, ensuring each test starts with the right conditions and ends without leaving any trace. By using @BeforeEach
and @AfterEach
, the setup and teardown run automatically before and after each test in the class, so you don't need to invoke them manually in every test. This means every test begins and ends with a clean state, which is crucial to avoid any interference between tests.
Here's an example of how the setup and teardown are structured:
Kotlin1import kotlinx.serialization.* 2import kotlinx.serialization.json.* 3import okhttp3.* 4import org.junit.jupiter.api.* 5import kotlinx.coroutines.runBlocking 6import okhttp3.RequestBody.Companion.toRequestBody 7import okhttp3.MediaType.Companion.toMediaType 8 9@TestInstance(TestInstance.Lifecycle.PER_CLASS) 10class TestCRUDOperations { 11 12 private val client = OkHttpClient() 13 private val baseUrl = "http://localhost:8000" 14 private lateinit var todoId: String 15 private val json = Json { ignoreUnknownKeys = true } 16 17 @Serializable 18 data class Todo( 19 val id: String? = null, 20 val title: String, 21 val description: String, 22 val done: Boolean 23 ) 24 25 @BeforeEach 26 fun setup() = runBlocking { 27 val todoData = Todo( 28 title = "Setup Todo", 29 description = "For testing CRUD operations", 30 done = false 31 ) 32 val jsonBody = json.encodeToString(todoData) 33 val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) 34 val request = Request.Builder() 35 .url("$baseUrl/todos") 36 .post(requestBody) 37 .build() 38 39 client.newCall(request).execute().use { response -> 40 if (!response.isSuccessful) { 41 throw Exception("Failed to create a todo for setup") 42 } 43 val responseBody = response.body?.string() 44 val responseData = json.decodeFromString<Todo>(responseBody!!) 45 todoId = responseData.id!! 46 } 47 } 48 49 @AfterEach 50 fun teardown() = runBlocking { 51 val request = Request.Builder() 52 .url("$baseUrl/todos/$todoId") 53 .delete() 54 .build() 55 56 client.newCall(request).execute() 57 } 58}
The @TestInstance(TestInstance.Lifecycle.PER_CLASS)
annotation is used to specify the lifecycle of the test instance in JUnit. By default, JUnit creates a new instance of the test class for each test method, which can be resource-intensive if the setup is complex or if there are many tests. Using @TestInstance(TestInstance.Lifecycle.PER_CLASS)
allows JUnit to create a single instance of the test class for all test methods, which can be more efficient. This is particularly useful when you have shared state or expensive setup operations that you want to perform only once per class. However, it also means that you need to be careful with shared state between tests, as they will all run on the same instance.
Within this setup, the actions occur before each test to create a todo
item, ensuring the test environment has the necessary data. The @AfterEach
annotation ensures that after each test, the teardown actions delete the created todo
, maintaining a clean slate for subsequent tests. This ensures consistent, independent test runs free from interference caused by leftover data.
With setup and teardown processes in place using JUnit, we establish a consistent environment for our tests. Now, we'll define the specific tests for each CRUD operation within the TestCRUDOperations
class. These tests will leverage the setup todo
item, ensuring that we assess the ability to create, read, update, and delete resources accurately. Let's explore each operation through dedicated test functions.
In our CRUD operations, the Read test checks if we can successfully retrieve a todo
item. Using the OkHttp client, we fetch the todo
item created during the setup. The test verifies the HTTP status code is 200
, indicating success, and it asserts that the data returned matches our expectations. This confirms that our API's read functionality works correctly.
Kotlin1@Test 2fun testReadTodo() = runBlocking { 3 // Act 4 val request = Request.Builder() 5 .url("$baseUrl/todos/$todoId") 6 .get() 7 .build() 8 9 client.newCall(request).execute().use { response -> 10 // Assert 11 assertEquals(200, response.code) 12 val responseBody = response.body?.string() 13 val fetchedTodo = json.decodeFromString<Todo>(responseBody!!) 14 assertEquals("Setup Todo", fetchedTodo.title) 15 assertEquals("For testing CRUD operations", fetchedTodo.description) 16 assertEquals(false, fetchedTodo.done) 17 } 18}
The Update operation using PATCH
focuses on modifying specific fields of a todo
item. Here, we send a PATCH
request to change the done
status to True
. The test checks if the status code is 200
, confirming the update was successful. We also verify the field was accurately updated in the API. This ensures that partial updates with PATCH
are functioning as intended.
Kotlin1@Test 2fun testUpdateTodoWithPatch() = runBlocking { 3 // Arrange 4 val updateData = mapOf("done" to true) 5 val jsonBody = json.encodeToString(updateData) 6 val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) 7 8 // Act 9 val request = Request.Builder() 10 .url("$baseUrl/todos/$todoId") 11 .patch(requestBody) 12 .build() 13 14 client.newCall(request).execute().use { response -> 15 // Assert 16 assertEquals(200, response.code) 17 val responseBody = response.body?.string() 18 val updatedTodo = json.decodeFromString<Map<String, Any>>(responseBody!!) 19 assertEquals(true, updatedTodo["done"]) 20 } 21}
For a complete replacement of fields, the Update operation using PUT
is employed. This test sends a PUT
request to replace all fields of the todo
item with new data. We assert the status code is 200
, indicating the operation succeeded, and confirm that all fields match the updated values. This test validates that full updates with PUT
are correctly processed by the API.
Kotlin1@Test 2fun testUpdateTodoWithPut() = runBlocking { 3 // Arrange 4 val putData = mapOf( 5 "title" to "Updated Title", 6 "description" to "Updated Description", 7 "done" to true 8 ) 9 val jsonBody = json.encodeToString(putData) 10 val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) 11 12 // Act 13 val request = Request.Builder() 14 .url("$baseUrl/todos/$todoId") 15 .put(requestBody) 16 .build() 17 18 client.newCall(request).execute().use { response -> 19 // Assert 20 assertEquals(200, response.code) 21 val responseBody = response.body?.string() 22 val updatedTodo = json.decodeFromString<Map<String, Any>>(responseBody!!) 23 assertEquals("Updated Title", updatedTodo["title"]) 24 assertEquals("Updated Description", updatedTodo["description"]) 25 assertEquals(true, updatedTodo["done"]) 26 } 27}
The Delete test checks if a todo
item can be removed successfully. A DELETE
request is sent to the API, and the test verifies the status code is 204
, signifying a successful deletion with no content returned. To confirm the deletion, we attempt to retrieve the same todo
item and expect a 404
status code, indicating it no longer exists. This ensures the API's delete functionality behaves as expected.
Kotlin1@Test 2fun testDeleteTodo() = runBlocking { 3 // Act 4 val deleteRequest = Request.Builder() 5 .url("$baseUrl/todos/$todoId") 6 .delete() 7 .build() 8 9 client.newCall(deleteRequest).execute().use { deleteResponse -> 10 // Assert 11 assertEquals(204, deleteResponse.code) 12 } 13 14 // Verify deletion 15 val getRequest = Request.Builder() 16 .url("$baseUrl/todos/$todoId") 17 .get() 18 .build() 19 20 client.newCall(getRequest).execute().use { getResponse -> 21 assertEquals(404, getResponse.code) 22 } 23}
These structured tests demonstrate the essential steps of the Arrange-Act-Assert pattern, ensuring each CRUD operation is verified thoroughly, with setup and teardown maintaining a consistent testing environment.
In today's lesson, we've delved into the essentials of testing CRUD operations with setup and teardown using JUnit and OkHttp. You've seen how automating these processes helps maintain a structured and reliable testing environment. Now, it's your turn to practice these concepts with hands-on exercises that will reinforce your understanding and confidence in applying these techniques. As you continue to build your skills, you'll be better equipped to ensure API robustness and reliability. Keep up the great work, and prepare to explore testing authenticated endpoints moving forward!
