Continuous integration of contract tests for Dagger in Kotlin applications
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:
- Basic knowledge of Kotlin
- Git CLI installed
- A CircleCI account
- A GitHub account
- openJDK
- 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 themain()
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, whilekapt
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, andkotlinx-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 uniqueid
, aname
, and anemail
. - The
Plan
model defines a subscription offering such as a “Basic” or “Pro” plan. Each plan has aname
, aprice
, and abillingCycle
. - The
billingCycle
field refers to anenum
, 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 theactive
flag, along with timestamps (startDate
,endDate
, andnextBillingDate
) 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, andretryCount
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.
- Attempting to process the initial payment via
-
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 aBillingService
. Here, it returnsBillingServiceImpl
, allowing Dagger to inject it wherever aBillingService
is required.@Provides fun provideSubscriptionService(...)
depends on aBillingService
. Dagger resolves this by calling the previous method and injecting the result intoSubscriptionServiceImpl
.
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 useAppModule
as the source of dependency definitions.- The functions
subscriptionService()
andbillingService()
are called provision methods. These let you request instances ofSubscriptionService
andBillingService
, 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 ofSubscriptionService
, with itsBillingService
dependency already injected behind the scenes. - A
User
object is then instantiated to represent the individual interacting with the system. Two differentPlan
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:
- Subscribing with retry logic
- Upgrading a subscription
- Cancelling a subscription
- 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 thecimg/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. Therestore_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 thestore_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.
Expanding the ./gradlew test
step shows that all tests passed.
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.