Skip to main content

Command Palette

Search for a command to run...

Stop External Services from Breaking Your Build with JUnit Tags

Keep everyday tests fast and dependable while running strict live integration tests only when you choose

Updated
11 min readView as Markdown
Stop External Services from Breaking Your Build with JUnit Tags
H
JVM enthusiast working for Grapevine Interactive in beautiful Cape Town.

Fast, dependable tests get run.

Slow or fragile tests gradually teach a team to avoid the test suite. A developer makes a small change, remembers that the build sometimes waits for an external API, and decides not to run it. A red build is retried because “that service is probably down again”. Eventually a genuine regression is dismissed as another unreliable test.

The problem is not that integration tests are bad. A test that proves your application can talk to a real service can reveal DNS, TLS, authentication, request-format and response-format problems that a unit test cannot.

The problem is allowing a resource outside your control to decide whether every local build succeeds.

JUnit 5 tags provide a small and useful boundary. We can mark tests that contact an external system, exclude them from Gradle's normal test task, and give them a separate task that must be selected deliberately.

In this post we will build a small Kotlin application that sends a JSON request to httpbingo, an HTTP request-and-response service compatible with the familiar httpbin endpoints. The application will have:

  1. A fast test backed by Ktor's in-memory MockEngine.
  2. A live integration test tagged with @Tag("integration").
  3. A normal Gradle build that never needs the network.
  4. A separate Gradle task for running the live test.

The complete example uses the standard Gradle source layout and can be copied into an empty directory.

What should run in the regular build?

The regular test suite should be deterministic. Given the same source and the same inputs, it should produce the same result.

A test that calls a public HTTP service also depends on conditions that are not part of the source tree:

  • The machine must have a working network connection.
  • DNS and TLS negotiation must succeed.
  • The remote service must be available and behave as expected.
  • A proxy, firewall or rate limit must not block the request.
  • If the API is authenticated, its credentials and test data must still be valid.

None of those conditions tells us whether a local calculation, validation rule or request mapper is correct.

When an ordinary build fails because a third-party service is unavailable, the failure is noisy but not useful. Worse, repeated false alarms make real failures easier to ignore. Keeping the regular suite fast and trustworthy makes developers more likely to run it and more likely to investigate a failure immediately.

This does not mean hiding the integration tests or ignoring their results. It means running them at the right time: explicitly during development, in a suitable scheduled environment, or in a dedicated CI job with controlled credentials and network access.

The example project

Create the following directory structure:

tagged-http-example/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
│   └── libs.versions.toml
└── src/
    ├── main/
    │   └── kotlin/
    │       └── dev/hbrown/tags/
    │           ├── HttpEchoClient.kt
    │           └── Main.kt
    └── test/
        └── kotlin/
            └── dev/hbrown/tags/
                ├── HttpEchoClientTest.kt
                └── HttpEchoIntegrationTest.kt

The live test stays under src/test/kotlin with the other tests. A tag describes how it should be selected; we do not need a custom source tree or a second copy of the test dependencies.

Step 1: Configure the Gradle project

Start with settings.gradle.kts:

rootProject.name = "tagged-http-example"

The project uses a Gradle version catalog. Add the following to gradle/libs.versions.toml:

[versions]
kotlin = "2.4.10"
ktor = "3.5.2"
coroutines = "1.11.0"
jackson = "2.15.4"
junit = "5.10.1"
junit-platform = "1.10.1"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-jackson = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }

junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" }

[bundles]
ktor-client = [
    "ktor-client-core",
    "ktor-client-cio",
    "ktor-client-content-negotiation",
    "ktor-serialization-jackson",
]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

There are two Ktor engines in the catalog for different reasons.

The CIO engine makes a real HTTP call when the application or integration test runs. The mock engine executes a Ktor request entirely in memory, allowing the fast test to verify the method, URL, headers, JSON body and response mapping without opening a socket.

Now add build.gradle.kts:

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlin.jvm)
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(libs.kotlinx.coroutines.core)
    implementation(libs.bundles.ktor.client)
    implementation(libs.jackson.module.kotlin)

    testImplementation(libs.junit.jupiter)
    testImplementation(libs.ktor.client.mock)
    testRuntimeOnly(libs.junit.platform.launcher)
}

kotlin {
    jvmToolchain(21)
    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_21)
    }
}

application {
    mainClass.set("dev.hbrown.tags.MainKt")
}

val integrationTestTag = "integration"

tasks.test {
    useJUnitPlatform {
        excludeTags(integrationTestTag)
    }
}

tasks.register<Test>("integrationTest") {
    group = "verification"
    description = "Runs tests that communicate with external services"

    testClassesDirs = sourceSets["test"].output.classesDirs
    classpath = sourceSets["test"].runtimeClasspath

    useJUnitPlatform {
        includeTags(integrationTestTag)
    }

    shouldRunAfter(tasks.test)
}

The last two task configurations are the important part.

The standard test task uses the JUnit Platform but excludes every test carrying the integration tag. Gradle's build and check lifecycle tasks depend on test, so the live HTTP call is also kept out of the regular build.

The new integrationTest task uses the same compiled test classes and runtime classpath. Its JUnit Platform configuration does the opposite: it includes only tests carrying the tag.

Notice that integrationTest is not added as a dependency of check or build. That omission is deliberate. If it were attached to the normal lifecycle, an httpbingo outage could break the build again and the separation would provide little value.

Step 2: Write the HTTP adapter

The HTTP code follows a useful boundary pattern: application-owned request and response models are exposed by a small client, while the Ktor details remain inside the adapter.

Create src/main/kotlin/dev/hbrown/tags/HttpEchoClient.kt:

package dev.hbrown.tags

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.call.body
import io.ktor.client.request.accept
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.jackson.jackson

data class EchoRequest(
    val message: String,
    val source: String,
)

data class EchoResponse(
    val json: EchoRequest,
    val url: String,
)

class HttpEchoClient(
    private val httpClient: HttpClient,
    private val baseUrl: String = "https://httpbingo.org",
) {
    suspend fun echo(request: EchoRequest): EchoResponse {
        val response = httpClient.post("$baseUrl/anything/junit-tags") {
            accept(ContentType.Application.Json)
            contentType(ContentType.Application.Json)
            setBody(request)
        }

        check(response.status.value in 200..299) {
            "HTTP echo request failed, status=[${response.status.value}]"
        }

        return response.body()
    }
}

fun createHttpClient(
    engine: HttpClientEngine = CIO.create(),
): HttpClient = HttpClient(engine) {
    install(ContentNegotiation) {
        jackson {
            registerKotlinModule()
            disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        }
    }
}

The adapter sends a POST request to httpbingo's anything endpoint. The service returns details of the request it received, including the JSON body and URL, which makes it convenient for a small integration example.

The HttpClientEngine is injected into the factory. Production code gets CIO by default, while a test can supply MockEngine. This is the same seam we would use around a paid messaging API, payment provider or another remote boundary.

Step 3: Make the application runnable

Create src/main/kotlin/dev/hbrown/tags/Main.kt:

package dev.hbrown.tags

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    createHttpClient().use { httpClient ->
        val response = HttpEchoClient(httpClient).echo(
            EchoRequest(
                message = "Hello from Kotlin",
                source = "tagged-http-example",
            ),
        )

        println("HTTP service echoed: ${response.json.message}")
        println("request URL: ${response.url}")
    }
}

Run it with:

./gradlew run

This command deliberately uses the real network because that is the application's job. The distinction we are making concerns what must happen during every test and build.

Step 4: Test the HTTP behaviour without the network

Create src/test/kotlin/dev/hbrown/tags/HttpEchoClientTest.kt:

package dev.hbrown.tags

import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.toByteArray
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class HttpEchoClientTest {
    @Test
    fun `echo sends the expected request and maps the response`() = runBlocking {
        val expectedRequest = EchoRequest(
            message = "Hello from a fast test",
            source = "mock-engine",
        )

        val engine = MockEngine { request ->
            assertEquals(HttpMethod.Post, request.method)
            assertEquals("/anything/junit-tags", request.url.encodedPath)
            assertEquals(ContentType.Application.Json, request.body.contentType)

            val requestBody = request.body.toByteArray().decodeToString()
            assertTrue(requestBody.contains("Hello from a fast test"))
            assertTrue(requestBody.contains("mock-engine"))

            respond(
                content = """
                    {
                      "json": {
                        "message": "Hello from a fast test",
                        "source": "mock-engine"
                      },
                      "url": "https://httpbingo.org/anything/junit-tags"
                    }
                """.trimIndent(),
                status = HttpStatusCode.OK,
                headers = headersOf(
                    HttpHeaders.ContentType,
                    ContentType.Application.Json.toString(),
                ),
            )
        }

        createHttpClient(engine).use { httpClient ->
            val response = HttpEchoClient(httpClient).echo(expectedRequest)

            assertEquals(expectedRequest, response.json)
            assertEquals(
                "https://httpbingo.org/anything/junit-tags",
                response.url,
            )
        }
    }
}

This is not a pretend test of the business logic. It exercises Ktor's request pipeline, JSON serialization, response deserialization and the adapter's mapping. The only substituted component is the network engine.

Because the test has no tag, it is included in the normal test task. It is fast, does not need credentials, and cannot fail because httpbingo is unavailable.

Step 5: Tag the live integration test

Create src/test/kotlin/dev/hbrown/tags/HttpEchoIntegrationTest.kt:

package dev.hbrown.tags

import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("integration")
class HttpEchoIntegrationTest {
    @Test
    fun `httpbingo accepts and echoes a real JSON request`() = runBlocking {
        val request = EchoRequest(
            message = "Hello from a live integration test",
            source = "junit-tag",
        )

        createHttpClient().use { httpClient ->
            val response = HttpEchoClient(httpClient).echo(request)

            assertEquals(request, response.json)
            assertTrue(response.url.endsWith("/anything/junit-tags"))
        }
    }
}

@Tag comes from JUnit Jupiter. Placing it on the class applies the tag to every test in that class. If a class contains a mixture of test types, the annotation can instead be placed on individual test methods.

A class-level tag is usually clearer for live integration tests. It is difficult to add a new test method and accidentally forget that the whole class crosses an external boundary.

The tag name is a label, not a Gradle task name. Gradle connects the two when includeTags("integration") and excludeTags("integration") are passed to the JUnit Platform.

Step 6: Run the two suites

Run the fast, deterministic tests with:

./gradlew test

Run the regular build with:

./gradlew build

Neither command executes HttpEchoIntegrationTest. They will still succeed if the network is disconnected or httpbingo is unavailable.

Run the live test explicitly with:

./gradlew integrationTest

Run both suites, while keeping their results and responsibilities separate, with:

./gradlew test integrationTest

If the project does not yet have a Gradle wrapper, generate one once with an installed Gradle distribution:

gradle wrapper

After that, commit the generated wrapper files and use ./gradlew so developers and CI run the same Gradle version.

What a failure now means

This configuration gives the two tasks different contracts.

When test fails, the cause should be in the source, test data or local test environment. That is a high-signal failure and should be investigated immediately.

When integrationTest fails, the application may have broken its external contract, but the first investigation should also consider availability, authentication, rate limits, test-account state and network policy. The failure still matters; it simply belongs to a different diagnostic context.

This distinction also leads to a better CI design. A pull-request job can run the fast suite on every change. A separate integration job can run in an environment that owns the necessary secrets and network access. Depending on the importance and stability of the external service, that job may run on demand, after deployment, or on a schedule.

Do not “solve” fragility by catching exceptions and allowing a live test to pass when the service is unreachable. A test that silently ignores the condition it exists to verify is worse than no test because it creates false confidence. Separation lets the live test remain strict without making every build dependent on it.

More than one kind of integration test

JUnit tags can describe several categories, and Gradle can select more than one tag. For example, a larger project might distinguish database, messaging and end-to-end tests:

useJUnitPlatform {
    includeTags("database", "messaging")
}

It can also combine tag expressions:

useJUnitPlatform {
    includeTags("integration & !expensive")
}

Start with the smallest taxonomy that solves the problem. One integration tag and one explicit task are often enough. A complicated collection of tags can become another system that developers need to remember and maintain.

Conclusion

Integration tests are valuable because they cross a boundary that unit tests deliberately avoid. That same boundary makes them slower and more vulnerable to conditions outside the build.

JUnit's @Tag annotation lets us preserve both kinds of feedback:

  • Fast, deterministic tests run whenever we test or build the project.
  • Strict live tests remain available through an explicit Gradle task.
  • A remote outage does not train the team to accept a red regular build.
  • A real regular-test failure keeps its urgency because the suite is trusted.

The implementation is small: annotate the external test, exclude its tag from test, and include it in a dedicated Test task. The more important result is behavioural. When the reliable path is also the quick path, people run it more often—and they pay attention when it fails.


The full source code for this example is available on Github: tagged-http-example.