TutorialsSep 18, 202517 min read

Continuous integration of contract tests for Dagger in Kotlin applications

Terrence Aluda

Software Engineer

In Kotlin development, maintaining clean and scalable code hinges on effective dependency management. A common approach to managing dependencies is Dependency Injection (DI). DI is a design pattern that implements Inversion of Control (IoC), where objects do not create their dependencies but receive them from an external source. Think of it as a way to hand over the tools an object needs instead of having it build its own. This approach decouples the class behavior from dependency creation, making code easier to manage, test, and evolve. Dagger stands out as a powerful go-to DI framework that simplifies the management of object lifecycles and dependencies for Kotlin applications.

In a DI setup, services often depend on interfaces or abstract contracts rather than concrete implementations. While this promotes modularity, it also introduces the risk of integration issues when implementations evolve independently. Contract testing ensures that these implementations, where components interact via injected dependencies, meet agreed-upon expectations.

This guide uses a subscription-based SaaS product as an example to demonstrate how contract tests can be applied alongside dependency injection.

Prerequisites

To get the most from this tutorial, you will need:

  1. Basic knowledge of Kotlin
  2. Git CLI installed
  3. A CircleCI account
  4. A GitHub account
  5. openJDK
  6. Gradle

Setting up the project

In this section, you will set up the project structure and configure the essential dependencies for building and testing your application.

Start by cloning the project and checking into the starter-branch to switch to the most minimal version of the project. Use these commands:

git clone https://github.com/CIRCLECI-GWP/DaggerKotlinContractTestsCircleCIDemo.git && cd DaggerKotlinContractTestsCircleCIDemo
git checkout starter-branch

If you would like to review the entire codebase, you can use the master branch.

The starter-branch has this tree layout:

.
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   └── kotlin
    │       ├── Main.kt
    │       └── subscriptions
    │           ├── data
    │           │   └── DataModels.kt
    │           ├── di
    │           │   ├── AppComponent.kt
    │           │   └── AppModule.kt
    │           └── services
    │               ├── BillingServiceImpl.kt
    │               ├── BillingService.kt
    │               ├── SubscriptionServiceImpl.kt
    │               └── SubscriptionService.kt
    └── test
        └── kotlin
            └── subscriptions
                └── services
                    ├── SubscriptionServiceContractTest.kt
                    └── SubscriptionServiceImplContractTest.kt

Here’s an overview of the build.gradle.kts file to help you understand how dependencies and plugins are configured.

It begins by declaring the necessary plugins:

plugins {
    kotlin("jvm") version "2.1.0"
    kotlin("kapt") version "2.1.0"
    application
}
  • The Kotlin JVM plugin enables Kotlin support for Java Virtual Machine targets.
  • The kapt plugin (Kotlin Annotation Processing Tool) is essential for working with libraries like Dagger that rely on annotation processing.

  • This includes the application plugin, which allows you to specify an entry point for the application.

The application block defines this entry point:

application {
    mainClass.set("org.example.MainKt")
}
  • By pointing to MainKt (src/main/kotlin/Main.kt), you’re telling Gradle which class contains the main() function. This is the function that will be executed when the application starts.

Next, set the group and version of the project:

group = "org.example"
version = "1.0-SNAPSHOT"
  • These values help with dependency publishing and project metadata. For local or internal builds, they’re mostly informative.

In the repositories block, define where dependencies will be fetched from:

repositories {
    mavenCentral()
}
  • Maven Central is the default and most common repository for Kotlin and Java libraries.

The dependencies block is where you configure both runtime and test dependencies:

dependencies {
    implementation("com.google.dagger:dagger:2.56.2")
    kapt("com.google.dagger:dagger-compiler:2.56.2")
    testImplementation("io.mockk:mockk:1.13.7")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1")
    testImplementation(kotlin("test"))
}
  • Dagger is included as the dependency injection framework. The implementation line brings in Dagger’s runtime API, while kapt ensures that the Dagger compiler runs at build time to generate the necessary dependency graph.
  • Testing relies on JUnit 5 for the test framework, MockK for mocking, and kotlinx-coroutines-test to properly test coroutine-based code.

Finally, the tests task configuration tailors how your tests are executed:

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
        showStandardStreams = true
    }
    jvmArgs("-Dnet.bytebuddy.experimental=true")
}
  • This specifies the use of the JUnit Platform to run JUnit 5 tests. Logging is configured to show the results of passed, skipped, and failed tests, as well as the output streams (useful for debugging).
  • The custom JVM argument enables a feature in ByteBuddy (used internally by some test libraries, in this case, MockK). At the time of writing (May 2025), ByteBuddy doesn’t support versions of Java later than Java 20. Adding this argument helps get rid of an unnecessary build warning.

With the project environment ready, you can now shift your focus to designing and implementing the core subscription system.

Designing and implementing the subscription system

This section involves designing the core subscription logic. This involves defining the core data models and establishing their relationships. You will then build the business logic for handling subscription lifecycles, including creation, renewals, upgrades, and payment retries.

The data models will be hosted in the src/main/kotlin/subscriptions/data/DataModels.kt file. Open the file and add this content to it:

package org.example.subscriptions.data

data class User(
    val id: String,
    val name: String,
    val email: String
)

data class Plan(
    val name: String,
    val price: Double,
    val billingCycle: BillingCycle
)

enum class BillingCycle {
    MONTHLY, YEARLY
}

data class Subscription(
    val userId: String,
    val plan: Plan,
    val active: Boolean,
    val startDate: Long,
    val endDate: Long,
    val nextBillingDate: Long
)

data class Payment(
    val userId: String,
    val amount: Double,
    val successful: Boolean,
    val retryCount: Int = 0
)

  • The User data class represents a subscriber in the system. Each user has a unique id, a name, and an email.
  • The Plan model defines a subscription offering such as a “Basic” or “Pro” plan. Each plan has a name, a price, and a billingCycle.
  • The billingCycle field refers to an enum, defined to represent subscription cycles. Using an enum keeps the billing cycle options constrained and type-safe, which simplifies downstream logic for date calculations and renewals.
  • The Subscription model ties a user to a specific plan. It tracks the current state of the subscription via the active flag, along with timestamps (startDate, endDate, and nextBillingDate) to manage lifecycle events like activation, renewal, and expiry. This model is central to the system as it encapsulates all the logic related to a user’s access to the product and how it’s billed over time.
  • Payments are tied to users and store information about individual billing events. The amount represents the charged fee, successful flags whether the transaction went through, and retryCount helps manage automatic retry attempts in case of failure. In a real-world application, his model is particularly useful for implementing logic like grace periods, alerts, and lockouts after a number of failed payment attempts.

As you move forward in the tutorial, you’ll use these models to drive the business logic, implement features like subscription creation and renewal, and wire the system together using Dagger for dependency injection.

Setting up the billing logic

One of the most critical responsibilities in a subscription-based SaaS product is handling billing: processing payments, managing retries, and tracking outcomes. To isolate and organize this behavior, you will need to define a dedicated service interface (BillingService) and an implementation for the interface (BillingServiceImpl).

Start by adding the interface code to the src/main/kotlin/subscriptions/services/BillingService.kt file:

package org.example.subscriptions.services

import org.example.subscriptions.data.Payment
import org.example.subscriptions.data.User

interface BillingService {
    fun processPayment(user: User, amount: Double): Payment
    fun retryPayment(payment: Payment): Payment
}
  • processPayment(user: User, amount: Double): Payment

This method is responsible for charging a given user a specified amount. It returns a Payment object that captures whether the payment was successful and includes metadata such as the retry count.

  • retryPayment(payment: Payment): Payment

Failed payments aren’t uncommon. Credit cards can be declined, networks can time out, or accounts may have insufficient funds. This method handles the logic for retrying these failed transactions. By passing in the original Payment instance, the service can use the retryCount field to apply limits or exponential back-off logic.

By defining BillingService as an interface, you enable dependency inversion, one of the core principles of clean architecture. The high-level business logic (like SubscriptionService) doesn’t depend on a specific billing implementation but depends on this abstraction. Next, when you implement this interface, you can inject it into other components using Dagger.

Add the interface’s implementation to the src/main/kotlin/subscriptions/services/BillingServiceImpl.kt file:

package org.example.subscriptions.services

import javax.inject.Inject
import org.example.subscriptions.data.User
import org.example.subscriptions.data.Payment

class BillingServiceImpl @Inject constructor() : BillingService {

    override fun processPayment(user: User, amount: Double): Payment {
        // Simulate a random success/failure for payment processing
        val successful = Math.random() > 0.3
        return Payment(user.id, amount, successful)
    }

    override fun retryPayment(payment: Payment): Payment {
        // Simulate retrying payment, increasing retry count
        val successful = Math.random() > 0.5
        return payment.copy(successful = successful, retryCount = payment.retryCount + 1)
    }
}

This class implements the BillingService interface and is annotated with @Inject, enabling Dagger to construct and inject this class as a dependency wherever it is needed.

  • processPayment

This method simulates processing a payment for a given user and amount. In this case you are randomly determining success with a 70% chance of success (Math.random() > 0.3). This allows you to mimic real-world variability in payment gateway behavior during testing or prototyping.

The result is a new Payment instance showing whether the transaction was successful. This output can then be used by other services to decide the next steps (for example, to activate a subscription or trigger a retry).

  • retryPayment()

The retry logic simulates another payment attempt with a 50% of success. If the retry succeeds, the new Payment object shows this. Kotlin’s copy method is used to preserve the existing payment data while incrementing the retryCount and updating the successful status.

Setting up the subscription logic

Just as you did with billing, you’ll define the subscription-related functionality using an interface (SubscriptionService) and its corresponding implementation (SubscriptionServiceImpl).

Add the interface code to the src/main/kotlin/subscriptions/services/SubscriptionService.kt file.

package org.example.subscriptions.services

import org.example.subscriptions.data.Plan
import org.example.subscriptions.data.Subscription
import org.example.subscriptions.data.User

interface SubscriptionService {
    fun subscribe(user: User, plan: Plan): Subscription
    fun cancelSubscription(user: User): Subscription
    fun renewSubscription(user: User): Subscription
    fun upgradeSubscription(user: User, newPlan: Plan): Subscription
}

Here’s the breakdown:

  • subscribe(user: User, plan: Plan): Subscription initiates a new subscription for a user.

  • cancelSubscription(user: User): Subscription allows a user to cancel their active subscription. The implementation may involve deactivating the subscription immediately or scheduling it to end at the next billing date, depending on the business rules.

  • renewSubscription(user: User): Subscription handles the logic for renewing an existing subscription, usually triggered at the end of a billing cycle. It could include verifying payment success and extending the subscription period accordingly.

  • fun upgradeSubscription(user: User, newPlan: Plan): Subscription is used for switching from one plan to another, such as upgrading from “Basic” to “Pro”.

Next, start the implementation. Go to the src/main/kotlin/subscriptions/services/SubscriptionServiceImpl.kt file and add this code:

package org.example.subscriptions.services

import org.example.subscriptions.data.BillingCycle
import org.example.subscriptions.data.Plan
import org.example.subscriptions.data.Subscription
import org.example.subscriptions.data.User
import javax.inject.Inject

class SubscriptionServiceImpl @Inject constructor(
    private val billingService: BillingService
) : SubscriptionService {

    private val subscriptions = mutableListOf<Subscription>()

    override fun subscribe(user: User, plan: Plan): Subscription {
        // 1. First, we attempt to process payment
        var payment = billingService.processPayment(user, plan.price)

        // 2. If payment fails, retry up to 3 times
        var retries = payment.retryCount
        while (!payment.successful && retries < 3) {
            retries++
            println("Retrying payment... Attempt #$retries")
            payment = billingService.retryPayment(payment)
        }

        // 3. If payment is successful, create a subscription
        if (payment.successful) {
            val currentTime = System.currentTimeMillis()
            val subscription = Subscription(
                userId = user.id,
                plan = plan,
                active = true,
                startDate = currentTime,
                endDate = currentTime + plan.billingCycle.billingPeriodInMillis(),
                nextBillingDate = currentTime + plan.billingCycle.billingPeriodInMillis()
            )
            subscriptions.add(subscription)
            return subscription
        } else {
            throw IllegalStateException("Payment failed after multiple retries.")
        }
    }

    override fun cancelSubscription(user: User): Subscription {
        val subscription = subscriptions.find { it.userId == user.id }
            ?: throw IllegalStateException("Subscription not found.")
        return subscription.copy(active = false)
    }

    override fun renewSubscription(user: User): Subscription {
        val subscription = subscriptions.find { it.userId == user.id }
            ?: throw IllegalStateException("Subscription not found.")

        // Renew subscription by extending the end date
        val currentTime = System.currentTimeMillis()
        val renewedSubscription = subscription.copy(
            active = true,
            startDate = currentTime,
            endDate = currentTime + subscription.plan.billingCycle.billingPeriodInMillis(),
            nextBillingDate = currentTime + subscription.plan.billingCycle.billingPeriodInMillis()
        )
        subscriptions.remove(subscription)
        subscriptions.add(renewedSubscription)
        return renewedSubscription
    }

    override fun upgradeSubscription(user: User, newPlan: Plan): Subscription {
        val subscription = subscriptions.find { it.userId == user.id }
            ?: throw IllegalStateException("Subscription not found.")

        // Upgrade plan, extending the end date and updating the plan
        val currentTime = System.currentTimeMillis()
        val upgradedSubscription = subscription.copy(
            plan = newPlan,
            active = true,
            startDate = currentTime,
            endDate = currentTime + newPlan.billingCycle.billingPeriodInMillis(),
            nextBillingDate = currentTime + newPlan.billingCycle.billingPeriodInMillis()
        )
        subscriptions.remove(subscription)
        subscriptions.add(upgradedSubscription)
        return upgradedSubscription
    }

    // Helper method to simulate billing cycle periods
    private fun BillingCycle.billingPeriodInMillis(): Long {
        return when (this) {
            BillingCycle.MONTHLY -> 30L * 24L * 60L * 60L * 1000L
            BillingCycle.YEARLY -> 365L * 24L * 60L * 60L * 1000L
        }
    }
}
  • subscribe() initiates a subscription by:
    • Attempting to process the initial payment via billingService.
    • Retrying up to 3 times if the initial payment fails.
    • Upon success, it creates a new Subscription with calculated dates based on the plan’s billing cycle and adds it to the local list.
    • If the payment attempt continues failing, it throws an exception to halt the process.
  • cancelSubscription() finds the user’s current subscription and marks it inactive.

  • renewSubscription() renews an existing subscription by extending its duration based on the billing cycle and resetting the relevant dates.

  • upgradeSubscription() replaces the current plan with a new one, reinitializing the subscription’s duration and dates accordingly. This would be especially useful when a user chooses to switch tiers.

  • BillingCycle helper function calculates the duration of a billing cycle in milliseconds, which is used when setting subscription dates.

Setting up the DI backbone

In this part of the tutorial, you will define the core structure that enables Dagger to inject dependencies where they’re needed.

To enable Dagger to understand how to construct and provide the dependencies your app relies on, you’ll define a module. This module acts as a blueprint that instructs Dagger about how to instantiate services and inject them where needed. The AppModule is where you declare these provisioning rules. To the src/main/kotlin/subscriptions/di/AppModule.kt file, add this code:

package org.example.subscriptions.di

import org.example.subscriptions.services.BillingService
import org.example.subscriptions.services.BillingServiceImpl
import org.example.subscriptions.services.SubscriptionService
import org.example.subscriptions.services.SubscriptionServiceImpl
import dagger.Module
import dagger.Provides

@Module
class AppModule {

    @Provides
    fun provideBillingService(): BillingService = BillingServiceImpl()

    @Provides
    fun provideSubscriptionService(billingService: BillingService): SubscriptionService =
        SubscriptionServiceImpl(billingService)
}
  • @Module tells Dagger that this class provides dependencies. It serves as a container for @Provides methods that describe how to construct instances.
  • @Provides fun provideBillingService() defines how to create a BillingService. Here, it returns BillingServiceImpl, allowing Dagger to inject it wherever a BillingService is required.
  • @Provides fun provideSubscriptionService(...) depends on a BillingService. Dagger resolves this by calling the previous method and injecting the result into SubscriptionServiceImpl.

With the module in place, you can define the AppComponent component which is the bridge between Dagger’s dependency graph and the parts of the app that need those dependencies. The code will be hosted in the src/main/kotlin/subscriptions/di/AppComponent.kt file:

package org.example.subscriptions.di

import org.example.subscriptions.services.SubscriptionService
import org.example.subscriptions.services.BillingService
import dagger.Component

@Component(modules = [AppModule::class])
interface AppComponent {
    fun subscriptionService(): SubscriptionService
    fun billingService(): BillingService
}
  • @Component marks this interface as a Dagger component. Components are responsible for connecting the providers defined in @Module classes to the parts of the application that request dependencies.
  • modules = [AppModule::class] tells Dagger to use AppModule as the source of dependency definitions.
  • The functions subscriptionService() and billingService() are called provision methods. These let you request instances of SubscriptionService and BillingService, and Dagger will resolve and provide fully constructed objects based on your module’s logic.

This component acts as the entry point to your dependency graph, letting you inject and retrieve services with all their dependencies already wired up.

Running the application

To check that the application is working, you’ll need to test it. Before that, you will need to add code to the src/main/kotlin/Main.kt file. This file is the entry point of the application. Add this code:

package org.example

import org.example.subscriptions.data.BillingCycle
import org.example.subscriptions.data.Plan
import org.example.subscriptions.data.User
import org.example.subscriptions.di.DaggerAppComponent
import org.example.subscriptions.services.SubscriptionService

fun main() {
    val component = DaggerAppComponent.create()
    val subscriptionService: SubscriptionService = component.subscriptionService()

    val user = User(
        id = "user-001",
        name = "Jane Developer",
        email = "jane@demo.io"
    )

    val basicPlan = Plan(
        name = "Basic Plan",
        price = 19.99,
        billingCycle = BillingCycle.MONTHLY
    )

    val proPlan = Plan(
        name = "Pro Plan",
        price = 49.99,
        billingCycle = BillingCycle.MONTHLY
    )

    try {
        println("Subscribing user ${user.name} to ${basicPlan.name}")
        val subscription = subscriptionService.subscribe(user, basicPlan)
        println("Subscription successful: $subscription\n")

        println("Upgrading subscription to ${proPlan.name}...")
        val upgraded = subscriptionService.upgradeSubscription(user, proPlan)
        println("Subscription upgraded: $upgraded\n")

        println("Renewing subscription...")
        val renewed = subscriptionService.renewSubscription(user)
        println("Subscription renewed: $renewed\n")

        println("Cancelling subscription...")
        val cancelled = subscriptionService.cancelSubscription(user)
        println("Subscription cancelled: $cancelled")

    } catch (e: Exception) {
        println("Operation failed: ${e.message}")
    }
}
  • The main function serves as a complete demonstration of how the subscription system operates when wired together using dependency injection. It begins by invoking DaggerAppComponent.create(), which bootstraps the dependency graph through Dagger’s code-generated implementation. This component knows how to provide all required dependencies, including a fully initialized instance of SubscriptionService, with its BillingService dependency already injected behind the scenes.
  • A User object is then instantiated to represent the individual interacting with the system. Two different Plan instances are also defined, one for the basic tier and another for the pro tier. Both plans share a monthly billing cycle, but they differ in features and pricing.
  • The interaction starts by subscribing the user to the basic plan. This operation initiates payment processing via the injected BillingService, including retry logic if the initial payment fails. Assuming payment is successful, a new active subscription is created and logged. Next, the system upgrades the user to the Pro plan, which simulates a plan transition while preserving continuity. The new plan comes with a different price and may reflect additional features or access.
  • The subscription is then renewed, which effectively resets the start and end dates for another billing cycle. This mimics what would happen at the end of a billing period in a production system, extending the user’s access to the service.
  • Finally, the subscription is cancelled. This updates the subscription’s status to inactive, ensuring the user is no longer billed or granted active access moving forward. All these operations are wrapped in a try-catch block to handle potential exceptions and print a helpful error message if anything goes wrong.

Build the application and run it using:

./gradlew build && ./gradlew run

Contract testing the subscription system

To validate that each service honors its expected inputs and outputs, you will need to implement contract tests for the subscription system. To keep your tests organized and reusable, you’ll define an abstract base class (SubscriptionServiceContractTest) and provide an implementation (SubscriptionServiceImplContractTest) specific to the subscription service.

Go to the src/test/kotlin/subscriptions/services/SubscriptionServiceContractTest.kt file and add:

package subscriptions.services

import org.example.subscriptions.data.*
import org.example.subscriptions.services.BillingService
import org.example.subscriptions.services.SubscriptionService
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import org.junit.jupiter.api.BeforeEach

abstract class SubscriptionServiceContractTest {

    protected abstract val billingService: BillingService
    protected abstract val subscriptionService: SubscriptionService

    @BeforeEach
    open fun setUp() {
        clearMocks(billingService)
    }

    @Test
    fun `subscribe should retry payment and activate subscription`() {
        val user = User("1", "Alice", "alice@example.com")
        val plan = Plan("Premium", 10.0, BillingCycle.MONTHLY)

        val failedPayment = Payment(user.id, plan.price, false)
        val successfulRetry = failedPayment.copy(successful = true, retryCount = 1)

        every { billingService.processPayment(user, plan.price) } returns failedPayment
        every { billingService.retryPayment(failedPayment) } returns successfulRetry

        val subscription = subscriptionService.subscribe(user, plan)

        assertTrue(subscription.active)
        assertEquals(plan.name, subscription.plan.name)
        verify { billingService.retryPayment(failedPayment) }
    }

    @Test
    fun `upgradeSubscription should activate new plan`() {
        val user = User("2", "Bob", "bob@example.com")
        val oldPlan = Plan("Basic", 5.0, BillingCycle.MONTHLY)
        val newPlan = Plan("Premium", 15.0, BillingCycle.MONTHLY)

        every { billingService.processPayment(user, oldPlan.price) } returns Payment(user.id, oldPlan.price, true)
        subscriptionService.subscribe(user, oldPlan)

        every { billingService.processPayment(user, newPlan.price) } returns Payment(user.id, newPlan.price, true)

        val upgraded = subscriptionService.upgradeSubscription(user, newPlan)

        assertEquals(newPlan.name, upgraded.plan.name)
        assertTrue(upgraded.active)
    }

    @Test
    fun `cancelSubscription should deactivate existing subscription`() {
        val user = User("3", "Charlie", "charlie@example.com")
        val plan = Plan("Standard", 8.0, BillingCycle.MONTHLY)

        every { billingService.processPayment(user, plan.price) } returns Payment(user.id, plan.price, true)
        val activeSubscription = subscriptionService.subscribe(user, plan)

        val cancelled = subscriptionService.cancelSubscription(user)

        assertFalse(cancelled.active)
        assertEquals(activeSubscription.userId, cancelled.userId)
    }

    @Test
    fun `renewSubscription should update dates and keep subscription active`() {
        val user = User("4", "Dana", "dana@example.com")
        val plan = Plan("Monthly", 12.0, BillingCycle.MONTHLY)

        every { billingService.processPayment(user, plan.price) } returns Payment(user.id, plan.price, true)
        val originalSubscription = subscriptionService.subscribe(user, plan)

        Thread.sleep(1) // ensure timestamps will differ for realism

        val renewed = subscriptionService.renewSubscription(user)

        assertTrue(renewed.active)
        assertEquals(originalSubscription.userId, renewed.userId)
        assertTrue(renewed.startDate > originalSubscription.startDate)
        assertTrue(renewed.endDate > originalSubscription.endDate)
    }

}

The code starts by defining two properties, billingService and subscriptionService, which must be provided by subclasses.

@BeforeEach ensures that previous mock interactions with billingService are cleared before each test.

Understanding the test cases

There are four test cases for this tutorial:

  1. Subscribing with retry logic
  2. Upgrading a subscription
  3. Cancelling a subscription
  4. Renewing a subscription

1. Subscribing with retry logic

@Test
fun `subscribe should retry payment and activate subscription`() { ... }
  • It mocks a failed initial payment and a successful retry.
    • every { billingService.processPayment(...) } mocks the initial payment to fail.
    • every { billingService.retryPayment(...) } mocks a retry that succeeds.
    • verify { billingService.retryPayment(...) } verifies that the retry method was called.
  • assertTrue(subscription.active) confirms the subscription is marked as active.
  • assertEquals(plan.name, subscription.plan.name) verifies the correct plan is attached.
  • verify { billingService.retryPayment(failedPayment) } ensures the retry method was actually invoked.

2. Upgrading a subscription

@Test
fun `upgradeSubscription should activate new plan`() { ... }
  • Starts with a basic plan, then upgrades to a premium one.
    • every { billingService.processPayment(...) } mocks the payment for both the initial plan and the upgrade to succeed.
  • assertEquals(newPlan.name, upgraded.plan.name) checks that the new plan was successfully applied.
  • assertTrue(upgraded.active) ensures the upgraded subscription remains active.

3. Cancelling a subscription

@Test
fun `cancelSubscription should deactivate existing subscription`() { ... }
  • Ensures that calling cancelSubscription marks the subscription as inactive.
    • every { billingService.processPayment(...) } mocks a successful initial payment.
  • assertFalse(cancelled.active) verifies that the subscription is no longer active.
  • assertEquals(activeSubscription.userId, cancelled.userId) ensures user linkage is preserved.

4. Renewing a subscription

@Test
fun `renewSubscription should update dates and keep subscription active`() { ... }
  • Tests that renewal updates the subscription dates correctly.
    • every { billingService.processPayment(...) } mocks a successful initial payment.
  • assertTrue(renewed.active) confirms the subscription remains active.
  • assertEquals(originalSubscription.userId, renewed.userId) validates user ownership remains the same.
  • assertTrue(renewed.startDate > originalSubscription.startDate) ensures a new cycle has started.
  • assertTrue(renewed.endDate > originalSubscription.endDate) confirms the billing period was extended.

Below is the implementation of the abstract class. Add the code to the src/main/kotlin/subscriptions/services/SubscriptionServiceImpl.kt file.

package subscriptions.services

import io.mockk.mockk
import org.example.subscriptions.services.BillingService
import org.example.subscriptions.services.SubscriptionService
import org.example.subscriptions.services.SubscriptionServiceImpl

class SubscriptionServiceImplContractTest : SubscriptionServiceContractTest() {
    override val billingService = mockk<BillingService>(relaxed = true)
    override val subscriptionService: SubscriptionService = SubscriptionServiceImpl(billingService)
}

This class sets up the actual dependencies required to run the tests defined in SubscriptionServiceContractTest. It provides a mocked instance of BillingService using MockK. The relaxed = true flag instructs MockK to automatically return default values for any methods that haven’t been explicitly stubbed, which simplifies setup for tests that only care about certain behaviors.

With this mocked billing service in place, it then instantiates the real SubscriptionServiceImpl, injecting the mocked dependency. This allows all inherited contract tests to execute against a controlled, isolated environment.

To run the tests locally, use this command:

./gradlew test

Manual testing is useful, but automation ensures consistency. You can set up automation using CircleCI.

Automating the contract testing with CircleCI

The automation begins with a config.yml file, which tells CircleCI what to do when changes are pushed. Add these contents to .circleci/config.yml:

version: 2.1
jobs:
  setup-env-and-run-tests:
    docker:
      - image: cimg/openjdk:23.0.2
    steps:
      - checkout
      - run:
          name: Calculate cache key
          command: |-
            find . -name 'pom.xml' -o -name 'gradlew*' -o -name '*.gradle*' | \
                    sort | xargs cat > /tmp/CIRCLECI_CACHE_KEY
      - restore_cache:
          key: cache-{{ checksum "/tmp/CIRCLECI_CACHE_KEY" }}
      - run:
          command: ./gradlew test
      - store_test_results:
          path: build/test-results
      - save_cache:
          key: cache-{{ checksum "/tmp/CIRCLECI_CACHE_KEY" }}
          paths:
            - ~/.gradle/caches
      - store_artifacts:
          path: build/reports

workflows:
  build-and-run-contract-tests:
    jobs:
      - setup-env-and-run-tests
  • This config.yml file defines the CI pipeline that automates contract testing for the subscription system. It starts by specifying version 2.1 of the CircleCI configuration, which enables the use of advanced features like workflows and reusable job definitions.
  • The setup-env-and-run-tests job outlines the steps CircleCI will follow. It runs inside a Docker container using the cimg/openjdk:23.0.2 image, which comes pre-installed with the necessary Java environment to execute Gradle builds. The job begins by checking out the latest version of the repository, ensuring that all source code is available for the test run.
  • To optimize performance and prevent unnecessary re-downloads, the job then calculates a cache key by locating all Gradle-related configuration files, such as gradlew and *.gradle*, and concatenating their contents into a temporary file. This file acts as the fingerprint for the dependency cache. The restore_cache step uses a checksum of this file to attempt to reuse previously cached Gradle dependencies.
  • Once the environment is prepared and the cache is restored, the pipeline proceeds to execute the tests with the ./gradlew test command. After the tests are complete, results are stored using the store_test_results step, which captures output files so they can be displayed in CircleCI’s dashboard.
  • To preserve any build optimizations, the pipeline then saves the Gradle cache again using the same key, ensuring future builds can benefit from dependency reuse. Additionally, the build reports, such as HTML test outputs, are stored as artifacts. This makes them accessible for inspection after the job finishes.
  • Finally, the workflow named build-and-run-contract-tests triggers the single job defined earlier. Although only one job is included in this workflow, the structure allows for easy expansion, such as adding parallel test jobs, build stages, or deployment steps in the future.

Commiting the code and pushing to GitHub

To get the CircleCI pipeline running, you’ll first need to initialize your local repository and push the project to GitHub if you haven’t already. Here are the steps.

1. Initialize Git in your project’s root directory:

   git init

2. Stage all project files to Git:

   git add .

3. Commit your changes:

   git commit -m "first commit"

4. Push the changes to GitHub.

Use these instructions.

5. Create a new CircleCI project.

Note: If the pipeline doesn’t start, you can manually trigger the pipeline using the web UI.

The pipeline build should be successful and have green badges. Clicking the pipeline shows the steps.

Successful pipeline

Expanding the ./gradlew test step shows that all tests passed.

Detailed step

Conclusion

This tutorial covered the essentials of building a subscription system using interfaces, DI with Dagger, and contract testing automated via CircleCI. This approach keeps the codebase modular and testable. As a next step, try integrating services like Stripe or PayPal, and build on this foundation. Don’t hesitate to tweak the pipeline and make it your own.