Skip to content
All posts
March 11, 20264 min read

API Testing Guide: What to Test and How to Structure Your API Test Suite

API testing is high-ROI automation that most teams under-invest in. It's faster and more reliable than UI testing and covers the business logic where most bugs live. Here's how to build a thorough API test suite.

TestingAutomation
Share:

If you could only automate one layer of your application, automate the API layer. It's faster than UI automation, more stable, and tests exactly where your business logic lives.

Here's everything you need to build a complete API test suite.


What API Tests Verify

A thorough API test suite covers:

  1. Happy path — expected inputs produce expected outputs
  2. Validation — invalid inputs are rejected with appropriate error codes
  3. Authorization — unauthorized requests are rejected, authorized requests succeed
  4. Edge cases — boundary values, empty collections, maximum sizes
  5. Error handling — server errors return meaningful responses, not stack traces
  6. Contract — the response schema matches what clients expect

The Test Structure: Given-When-Then for APIs

Every API test follows a clear structure:

kotlin
@Test
fun `creating a task returns 201 with the created task`() {
    // Given
    val request = CreateTaskRequest(
        title = "Buy groceries",
        dueDate = "2026-04-01"
    )
    
    // When
    val response = api.post("/tasks", request)
    
    // Then
    assertEquals(201, response.statusCode)
    
    val task: Task = response.body()
    assertNotNull(task.id)
    assertEquals("Buy groceries", task.title)
    assertEquals("ACTIVE", task.status)
    assertEquals("2026-04-01", task.dueDate)
}

Testing HTTP Status Codes

Every endpoint should return the semantically correct status code. Test the code, not just that something came back:

ScenarioExpected Status
Successful creation201 Created
Successful retrieval200 OK
Resource not found404 Not Found
Invalid input400 Bad Request
Unauthorized401 Unauthorized
Forbidden (authenticated but not allowed)403 Forbidden
Server error500 (and verify it's handled, not exposed)
kotlin
@Test
fun `getting nonexistent task returns 404`() {
    val response = api.get("/tasks/nonexistent-id-12345")
    assertEquals(404, response.statusCode)
}

@Test
fun `creating task without title returns 400`() {
    val response = api.post("/tasks", mapOf("dueDate" to "2026-04-01"))
    assertEquals(400, response.statusCode)
    
    val error: ErrorResponse = response.body()
    assertEquals("title is required", error.message)
}

Testing Authorization

Authorization testing is often skipped. Don't skip it.

kotlin
@Test
fun `unauthenticated request returns 401`() {
    val response = api.get("/tasks") // No auth header
    assertEquals(401, response.statusCode)
}

@Test
fun `user cannot access another user's tasks`() {
    val userAToken = authenticate("user-a@example.com")
    val userBToken = authenticate("user-b@example.com")
    
    // User A creates a task
    val task = api.withToken(userAToken).post("/tasks", CreateTaskRequest("Private task"))
    assertEquals(201, task.statusCode)
    val taskId = task.body<Task>().id
    
    // User B tries to access User A's task
    val response = api.withToken(userBToken).get("/tasks/$taskId")
    assertEquals(403, response.statusCode) // or 404 if you don't want to leak existence
}

Contract Testing

Contract testing verifies that the API response shape matches what consumers expect. This is critical when you have mobile apps that parse the response.

If the backend changes a field name from

code
taskId
to
code
id
, every mobile client breaks. Contract tests catch this:

kotlin
@Test
fun `task response includes expected fields`() {
    val response = api.get("/tasks/${existingTask.id}")
    val body = response.body<JsonObject>()
    
    assertTrue(body.containsKey("id"))
    assertTrue(body.containsKey("title"))
    assertTrue(body.containsKey("status"))
    assertTrue(body.containsKey("createdAt"))
    assertFalse(body.containsKey("internalDatabaseId")) // Should not be exposed
}

For more sophisticated contract testing between services, look at Pact.


Testing Pagination

kotlin
@Test
fun `task list returns paginated results`() {
    // Setup: create 25 tasks
    repeat(25) { i -> api.post("/tasks", CreateTaskRequest("Task $i")) }
    
    // First page
    val page1 = api.get("/tasks?page=1&size=10")
    val body1 = page1.body<PaginatedResponse<Task>>()
    assertEquals(10, body1.items.size)
    assertEquals(25, body1.total)
    assertTrue(body1.hasNextPage)
    
    // Second page
    val page2 = api.get("/tasks?page=2&size=10")
    val body2 = page2.body<PaginatedResponse<Task>>()
    assertEquals(10, body2.items.size)
    
    // Third page (partial)
    val page3 = api.get("/tasks?page=3&size=10")
    val body3 = page3.body<PaginatedResponse<Task>>()
    assertEquals(5, body3.items.size)
    assertFalse(body3.hasNextPage)
}

Testing Search and Filtering

kotlin
@Test
fun `filtering by status returns only matching tasks`() {
    api.post("/tasks", CreateTaskRequest("Active task 1"))
    api.post("/tasks", CreateTaskRequest("Active task 2"))
    val completedTask = api.post("/tasks", CreateTaskRequest("Done task")).body<Task>()
    api.patch("/tasks/${completedTask.id}", mapOf("status" to "COMPLETED"))
    
    val response = api.get("/tasks?status=ACTIVE")
    val tasks = response.body<List<Task>>()
    
    assertEquals(2, tasks.size)
    assertTrue(tasks.all { it.status == "ACTIVE" })
}

Tooling

Kotlin/Android projects: Ktor Client or OkHttp for HTTP, JUnit 5 for test runner

Backend (JVM): REST-assured (Java/Kotlin), excellent DSL for API tests

Python: pytest + requests

JavaScript/TypeScript: Supertest (Node), Axios + Jest

Language-agnostic: Postman + Newman (run collections in CI), k6 (also does functional tests)


API Test Organization

Structure tests by resource and scenario:

code
tests/
  api/
    tasks/
      TaskCreationTest.kt
      TaskRetrievalTest.kt
      TaskUpdateTest.kt
      TaskDeletionTest.kt
    auth/
      AuthenticationTest.kt
      AuthorizationTest.kt
    users/
      UserProfileTest.kt

Takeaways

  • API tests are the highest-ROI automation: fast, stable, test where bugs actually live
  • Test status codes explicitly — not just "did something come back"
  • Authorization testing is skipped most often and exploited most often
  • Contract tests prevent breaking changes from reaching mobile clients
  • Organize by resource and scenario — makes it obvious when coverage is missing
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus