Ktor is an asynchronous framework for developing applications in the Kotlin language that has recently gained popularity. It allows you to create both server and client applications, providing tools for working with HTTP, WebSockets, and other network protocols.
In this article:
➤ Advantages of Ktor
➤ Key differences between Ktor and Spring
➤ Sample Ktor application
➤ Service for working with users
➤ Configuring the Ktor module
➤ Setting up a database using Exposed
➤ Setting up a database using JDBI
➤ Configuring Koin for Dependency Injection
➤ Examples of tests using Mockk
➤ Examples of tests using Testcontainers
➤ Example of migrations using Flyway
➤ Using environment variables
Advantages of Ktor
Asynchrony and high performance:
Ktor uses Kotlin coroutines, which ensures efficient asynchronous request processing and high application performance.
Lightweight and modular:
The framework provides a minimal set of basic functions, allowing you to connect only the necessary modules and plugins, which reduces the size and complexity of the application.
Simplicity and expressiveness of code:
By using DSL (Domain-Specific Language), the code becomes more readable and understandable, simplifying the development process.
Cross-platform:
Ktor supports JVM, JavaScript and Native, allowing you to create applications for multiple platforms, including servers, desktops and mobile devices.
Integration with the Kotlin ecosystem:
Full compatibility with other Kotlin libraries and tools, such as kotlinx.serialization for data serialization and kotlinx.coroutines for thread management.
Extensibility:
The ability to create your own plugins and extensions, which allows you to adapt the framework to the specific needs of the project.
Support for modern protocols:
Built-in support for HTTP/2, WebSockets and other modern network technologies.
Active community and support:
Regular updates from JetBrains and an active community of developers ensure stability and continuous development of the framework.
Together with Ktor you can use:
Koin:
dependency injection library.
Exposed:
ORM for working with a database.
JDBI (Java Database Interface):
Java library for simplified work with relational databases.
and much more
Main differences between Ktor and Spring
Language and Integration:
Ktor:
Designed specifically for Kotlin and leverages its features such as coroutines and DSL (Domain-Specific Language) for configuration.
Spring:
Based on Java, but supports Kotlin via special modules. However, some features may be less optimized for Kotlin.
Architectural approach:
Ktor:
Provides a lightweight and modular approach, allowing developers to select only the components they need. This gives more control over configuration and dependencies.
Spring:
Offers a comprehensive set of features out of the box, which can speed up development, but sometimes leads to redundancy.
Performance:
Ktor:
Due to its asynchronous nature and the use of coroutines, Ktor can provide high performance when handling large numbers of concurrent requests.
Spring:
Traditionally uses a synchronous model, although it supports asynchrony via Spring WebFlux. However, this adds complexity to setup and use.
Configuration and setup:
Ktor:
Uses code-based configuration via DSL, making configuration more intuitive and type-safe.
Spring:
Relies on annotations and external configuration files (XML or YAML), which can be less transparent and require more code.
Ecosystem and support:
Ktor:
Newer and has a smaller ecosystem than Spring, but is actively developed and supported by JetBrains.
Spring:
It has a long history, extensive documentation and a large community, making it easy to find solutions and support.
Usage:
Ktor:
Ideal for microservices and applications where performance and flexibility are important.
Spring:
Suitable for large enterprise applications with complex requirements and where the rich functionality of the framework is valued.
Example Ktor application
build.gradle.kts configuration:
plugins { application kotlin("jvm") version "1.9.10" } application { mainClass.set("com.example.ApplicationKt") } repositories { mavenCentral() } dependencies { // Who is server and client? implementation("io.ktor:ktor-server-core:2.3.2") implementation("io.ktor:ktor-server-netty:2.3.2") implementation("io.ktor:ktor-client-core:2.3.2") implementation("io.ktor:ktor-client-cio:2.3.2") // Logging implementation("ch.qos.logback:logback-classic:1.4.8") // Koin implementation("io.insert-koin:koin-ktor:3.4.0") // Exposed implementation("org.jetbrains.exposed:exposed-core:0.43.0") implementation("org.jetbrains.exposed:exposed-dao:0.43.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.43.0") // Database (H2 for example) implementation("com.h2database:h2:2.1.214") }
Service for working with users
Create a directory src/main/kotlin/com/example/services and add the UserService.kt file – a class that manages database operations related to the User entity. It uses the Exposed library to interact with the database and performs two main functions: adding a user and getting a list of all users.
import com.example.models.Users import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction class UserService { fun addUser(name: String, email: String) { transaction { Users.insert { it[Users.name] = name it[Users.email] = email } } } fun getUsers(): List<Map<String, Any>> { return transaction { Users.selectAll().map { mapOf("id" to it[Users.id], "name" to it[Users.name], "email" to it[Users.email]) } } } }
Method addUser(name: String, email: String):
This method adds a new user to the database. Let's see how it works:
transaction:
The transaction block is used to ensure that all operations within the block are performed in a single transaction. This is important for working with a database to maintain data integrity. If an error occurs within a transaction, the changes will be rolled back.
Users.insert:
The insert function adds a new record to the Users table.
it[Users.name] = name and it[Users.email] = email:
In the insert block, values are set for the fields of the Users table. Here, name and email are taken from the parameters of the addUser function.
getUsers() method:
This method returns a list of all users from the database as a list of cards (key-value). Let's look at how it works.
transaction:
Like addUser, a transaction block is used to perform database operations. All operations in this block are performed as a single unit.
Users.selectAll():
The selectAll() method selects all records from the Users table.
.map { … }:
The map method is used to transform each row in the result set into a Map. Each map creates a map where the keys are the names of the fields (id, name, email), and the values are taken from the corresponding columns in the Users table:
"id" to it[Users.id]: adds the user id to the map.
"name" to it[Users.name]: Adds username.
"email" to it[Users.email]: adds user email.
Setting up the Ktor module
Now let's define the main module of the Ktor application with a route for working with users.
Create src/main/kotlin/com/example/Application.kt:
import com.example.di.appModule import com.example.models.initDatabase import com.example.services.UserService import io.ktor.application.* import io.ktor.features.ContentNegotiation import io.ktor.features.DefaultHeaders import io.ktor.features.StatusPages import io.ktor.gson.gson import io.ktor.http.HttpStatusCode import io.ktor.response.* import io.ktor.request.* import io.ktor.routing.* import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import org.koin.ktor.ext.inject import org.koin.ktor.ext.Koin fun main() { embeddedServer(Netty, port = 8080) { initDatabase() installModules() }.start(wait = true) } fun Application.installModules() { // Koin install(Koin) { modules(appModule) } // Who features install(DefaultHeaders) install(ContentNegotiation) { gson { setPrettyPrinting() } } install(StatusPages) { exception<Throwable> { cause -> call.respond(HttpStatusCode.InternalServerError, cause.localizedMessage) } } val userService by inject<UserService>() routing { route("/users") { post { val request = call.receive<Map<String, String>>() val name = request["name"] ?: error("Name is required") val email = request["email"] ?: error("Email is required") userService.addUser(name, email) call.respond(HttpStatusCode.Created) } get { call.respond(userService.getUsers()) } } } }
Function main:
fun main() { embeddedServer(Netty, port = 8080) { initDatabase() installModules() }.start(wait = true) }
embeddedServer:
is a function to create and run a server. It uses Netty as an engine and runs the server on port 8080.
initDatabase():
database initialization. Calls a function that sets up a connection to the database and creates tables.
installModules():
The main configuration function of the application, setting up Koin and Ktor features.
installModules function:
fun Application.installModules() { // Koin install(Koin) { modules(appModule) }
This function configures Ktor and Koin and defines routes for handling requests.
install(Koin) { modules(appModule) }:
Connecting Koin for dependency injection initializes the appModule module, which contains dependencies including UserService.
Installing Ktor functions:
install(DefaultHeaders) install(ContentNegotiation) { gson { setPrettyPrinting() } } install(StatusPages) { exception<Throwable> { cause -> call.respond(HttpStatusCode.InternalServerError, cause.localizedMessage) } }
DefaultHeaders:
adds standard HTTP headers to each response (e.g. Date).
ContentNegotiation:
Setting up serialization and deserialization of data in JSON using the Gson library.
StatusPages:
Error handling: Here, if an exception occurs, the server returns a 500 Internal Server Error status and an error message.
UserService injection and Ktor routes:
val userService by inject<UserService>() routing { route("/users") { post { val request = call.receive<Map<String, String>>() val name = request["name"] ?: error("Name is required") val email = request["email"] ?: error("Email is required") userService.addUser(name, email) call.respond(HttpStatusCode.Created) } get { call.respond(userService.getUsers()) } } }
val userService by inject():
Here, Koin's inject injects an instance of UserService into the application. This service is used to interact with the database.
routing:
Ktor routing block, where paths and handlers for requests are defined.
Route /users:
POST /users:
Gets the data from the request body (in JSON format), then calls userService.addUser(name, email) to add the user to the database. If the user is added successfully, a response with the code 201 Created is sent.
GET /users:
Returns a list of all users from the database using userService.getUsers() and sends the result to the client.
HTTP client for requests to external APIs:
If you need to add an HTTP client for requests to external APIs, you can create an ExternalApiService.kt file in src/main/kotlin/com/example/services:
import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.request.* class ExternalApiService { private val client = HttpClient(CIO) suspend fun getExternalData(): String { return client.get("https://jsonplaceholder.typicode.com/todos/1") } }
Detailed explanation:
private val client = HttpClient(CIO):
Here a client object of type HttpClient is created, which is used to send HTTP requests.
The HttpClient constructor is passed CIO, which is an asynchronous HTTP engine that Ktor provides for performing network requests. This engine supports asynchronous I/O operations and allows for optimal resource utilization.
The created HttpClient instance will be used for all requests made using that client until it is closed.
suspend fun getExternalData(): String:
suspend is a keyword that indicates that the function is asynchronous and its execution can be suspended.
Asynchronous functions in Kotlin allow you to run time-consuming operations (such as network requests) without blocking the main thread.
This function returns a String result that contains the response body from the external API.
return client.get("https://jsonplaceholder.typicode.com/todos/1"):
client.get(…):
This is a method that makes an HTTP GET request to the specified URL.
URL https://jsonplaceholder.typicode.com/todos/1:
This is a test API endpoint that provides data about the task with ID 1.
get method:
This is a generic function, so Kotlin automatically infers the return type based on the expected result type, in this case String.
When the getExternalData() function is called, the client makes a request and returns the result as a string that contains the JSON response from the server.
Features of work:
Asynchrony:
Since the function is suspend, the request is executed asynchronously. This allows the program not to block the thread waiting for a response from the server, which is especially useful when there are a large number of network requests.
CIO Client:
Using an asynchronous engine allows for optimal thread management, reducing system load and increasing performance.
API usage:
POST /users with request body { "name": "John", "email": "[email protected]" } to add a user.
GET /users to get a list of users.
Setting up a database using Exposed
Let's create a table and a DAO class to work with the database. Let's create a directory src/main/kotlin/com/example/models and add there the User.kt file for the user model.
import org.jetbrains.exposed.dao.IntIdTable import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.transactions.transaction object Users : IntIdTable() { val name = varchar("name", 50) val email = varchar("email", 100).uniqueIndex() } fun initDatabase() { Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver") transaction { SchemaUtils.create(Users) } }
Basic example of Users table:
The Users class inherits from IntIdTable(), which means the table will have an auto-incrementing integer id field as the primary key. The name and email fields are defined as strings, where email is marked as unique:
initDatabase function:
Connects to the database (here using H2 in memory for testing) and creates the schema (tables) using SchemaUtils.create.
Example of creating One-to-Many relationships:
Let's add a Posts table that will have a foreign key linked to the Users table, creating a user-posts relationship (one user can have many posts).
object Posts : IntIdTable() { val title = varchar("title", 255) val content = text("content") // foreign key referencing user val user = reference("user_id", Users) }
Now the database initialization function will create both tables:
fun initDatabase() { Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver") transaction { SchemaUtils.create(Users, Posts) } }
Inserting data into related tables:
transaction { // Insert user val userId = Users.insertAndGetId { it[name] = "Alice" it[email] = "[email protected]" } // Insert post for user Posts.insert { it[title] = "First post" it[content] = "Content of the first post" it[user] = userId } }
Query using relationship:
To get all posts of a specific user, you can run the query:
transaction { val userPosts = Posts.select { Posts.user eq userId }.map { it[Posts.title] to it[Posts.content] } userPosts.forEach { (title, content) -> println("Post title: $title, content: $content") } }
Example of creating a Many-to-Many relationship:
Let's add a Tags table and a many-to-many relationship between Posts and Tags.
object Tags : IntIdTable() { val name = varchar("name", 50) } // Intermediate table for many-to-many relationship object PostTags : Table() { val post = reference("post_id", Posts) val tag = reference("tag_id", Tags) override val primaryKey = PrimaryKey(post, tag) }
Now the database initialization function should create three tables:
fun initDatabase() { Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver") transaction { SchemaUtils.create(Users, Posts, Tags, PostTags) } }
Inserting data into related tables:
Let's create tags and link them to the post
transaction { val postId = Posts.insertAndGetId { it[title] = "Second post" it[content] = "Content of the second post" it[user] = userId } val tagId1 = Tags.insertAndGetId { it[name] = "Kotlin" } val tagId2 = Tags.insertAndGetId { it[name] = "Programming" } PostTags.insert { it[post] = postId it[tag] = tagId1 } PostTags.insert { it[post] = postId it[tag] = tagId2 } }
Query to get all tags of a post:
To get tags associated with a post
transaction { val tags = (PostTags innerJoin Tags).slice(Tags.name) .select { PostTags.post eq postId } .map { it[Tags.name] } println("Tags for post: $tags") }
Example of updating and deleting data:
Data update:
Let's say we need to update a user's email. We use the update request.
transaction { Users.update({ Users.id eq userId }) { it[email] = "[email protected]" } }
Deleting data:
To delete a user (and possibly related records), use delete
transaction { Users.deleteWhere { Users.id eq userId } }
An example of a complex query with sorting and limit:
Let's request the last 5 posts of a user, sorted by creation date (assuming that the created field in the Posts table exists and is automatically filled in)
object Posts : IntIdTable() { val title = varchar("title", 255) val content = text("content") val created = datetime("created").defaultExpression(CurrentDateTime()) val user = reference("user_id", Users) }
Now to get the last 5 posts of a user:
transaction { val latestPosts = Posts.select { Posts.user eq userId } .orderBy(Posts.created, SortOrder.DESC) .limit(5) .map { it[Posts.title] to it[Posts.created] } latestPosts.forEach { (title, created) -> println("Title: $title, Created: $created") } }
Setting up a database using JDBI
JDBI (Java Database Interface):
is a Java library for simplified work with relational databases. It provides a convenient API over JDBC (Java Database Connectivity), making interaction with the database less complex and more understandable. JDBI allows you to write SQL queries in pure form, but at the same time automates the conversion of data between SQL and Java objects, which simplifies the process of working with databases and makes the code more readable and maintainable.
Key features of JDBI:
Lightweight:
JDBI is focused on using simple SQL queries without imposing an ORM (Object-Relational Mapping) like Hibernate or Exposed.
SQL Object API support:
In JDBI, you can describe SQL queries using annotations in interfaces and automatically associate them with methods, which eliminates the need to write a lot of code.
Object mapping:
JDBI automatically converts SQL query result strings into Java objects.
Flexibility:
Unlike ORM solutions, JDBI allows you to develop applications in which SQL queries are written directly, while maintaining full control over query execution and performance.
The main components of JDBI are:
Handle:
The primary JDBI object for executing queries. It opens and manages a connection to the database.
SQL Object API:
allows you to write SQL queries as methods of annotated interfaces, making the code concise.
Fluent API:
provides a "fluid" API for working with SQL queries, where you can flexibly configure parameters and query execution.
Example of working with JDBI:
Let's assume we have a database with a users table. Let's look at a basic example of using JDBI to interact with this table.
Setting up JDBI:
We include the JDBI dependency in build.gradle.kts:
dependencies { implementation("org.jdbi:jdbi3-core:3.29.0") // for SQL Object API implementation("org.jdbi:jdbi3-sqlobject:3.29.0") // to support Kotlin implementation("org.jdbi:jdbi3-kotlin:3.29.0") }
Create a model class:
data class User( val id: Int, val name: String, val email: String )
Let's create an interface for SQL queries using JDBI annotations:
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper import org.jdbi.v3.sqlobject.statement.SqlQuery import org.jdbi.v3.sqlobject.statement.SqlUpdate // Tells JDBI to automatically map query results to User @RegisterBeanMapper(User::class) interface UserDao { @SqlUpdate("INSERT INTO users (name, email) VALUES (:name, :email)") fun insertUser(name: String, email: String) @SqlQuery("SELECT * FROM users WHERE id = :id") fun findById(id: Int): User? @SqlQuery("SELECT * FROM users") fun listUsers(): List<User> }
Using UserDao to perform queries:
import org.jdbi.v3.core.Jdbi fun main() { // Create a JDBI connection val jdbi = Jdbi.create("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "username", "password") // Working with a database via DAO jdbi.useHandle<Exception> { handle -> val userDao = handle.attach(UserDao::class.java) // Create a table handle.execute("CREATE TABLE users (id IDENTITY PRIMARY KEY, name VARCHAR(50), email VARCHAR(100))") // Adding users userDao.insertUser("Alice", "[email protected]") userDao.insertUser("Bob", "[email protected]") // Getting user by ID val user = userDao.findById(1) println("User: $user") // We get a list of all users val users = userDao.listUsers() println("All users: $users") } }
Example explanation:
Connection setup:
JDBI is created using Jdbi.create(…), where the connection string, username and password are specified.
Using the UserDao interface:
In JDBI, the UserDao interface is linked to the database connection via handle.attach(UserDao::class.java), which allows SQL queries to be used as methods.
SQL Annotations:
Methods in UserDao are annotated with @SqlUpdate (for update queries) and @SqlQuery (for select queries). They define which SQL queries will be executed when the corresponding methods are called.
Transformation of results:
@RegisterBeanMapper(User::class) tells JDBI to automatically map the results to User objects, making it easier to work with query results.
Setting up Koin for Dependency Injection
Koin is a dependency injection (DI) library for Kotlin. It simplifies and automates dependency management, making code cleaner and easier to test. Koin differs from other DI frameworks in that it does not require annotations or code generation, but uses the Kotlin DSL to declare dependencies.
Key Features of Koin:
Lightness and simplicity:
Koin is designed specifically for Kotlin, so it uses the Kotlin DSL and allows you to write dependency declarations with minimal setup.
Without annotations and proxy classes:
Unlike Dagger or Hilt, Koin does not use annotations and does not require pre-compilation to work. This makes it fast and flexible to customize.
Support for modular structure:
Koin allows you to organize dependencies into modules, which is convenient for scaling and maintaining a large application.
Testability:
With Koin, you can easily swap out dependencies for tests, making unit testing easier.
The main components of Koin are:
Modules:
a dependency container where all required classes and their dependencies are described.
single and factory:
Koin supports two types of dependency declarations:
single:
creates a singleton, the same instance of the class throughout the entire duration of the application.
factory:
creates a new instance of the class on each call.
inject and get:
inject is used to inject dependencies via delegates, and get is used to obtain a dependency anywhere in the code where the Koin context is available.
Example of Koin usage:
Let's look at how to inject dependencies using Koin using a simple application with UserRepository and UserService classes.
Adding Koin dependency to your project:
In build.gradle.kts add a dependency for Koin
dependencies { implementation("io.insert-koin:koin-core:3.4.0") implementation("io.insert-koin:koin-ktor:3.4.0") // for integration with Ktor }
Creating classes and dependencies:
// UserRepository.kt class UserRepository { fun getUser() = "User data from repository" } // UserService.kt class UserService(private val userRepository: UserRepository) { fun getUserData() = userRepository.getUser() }
Creating a Koin module:
Let's create a module that declares dependencies for UserRepository and UserService:
import org.koin.dsl.module val appModule = module { // single instance of UserRepository single { UserRepository() } // a new instance of UserService on each request factory { UserService(get()) } }
single { UserRepository() }:
defines UserRepository as a singleton.
factory { UserService(get()) }:
defines UserService as a factory that creates a new instance each time it is requested. get() tells Koin to use the UserRepository instance to create the UserService.
Launching Koin in the app:
Koin starts by adding modules to its context. In Ktor, this is done via install(Koin):
import io.ktor.application.* import org.koin.core.context.startKoin import org.koin.ktor.ext.Koin fun main() { embeddedServer(Netty, port = 8080) { install(Koin) { modules(appModule) } // rest of Ktor configuration }.start(wait = true) }
Using dependencies with inject:
Now we can use UserService using inject
import org.koin.ktor.ext.inject class UserController { private val userService by inject<UserService>() fun printUserData() { println(userService.getUserData()) } }
Example of using Koin for testing:
Koin makes it easy to replace dependencies in tests. For example
import org.koin.core.context.stopKoin import org.koin.dsl.module import org.koin.test.KoinTest import org.koin.test.inject import kotlin.test.BeforeTest import kotlin.test.AfterTest import kotlin.test.Test class UserServiceTest : KoinTest { private val userService by inject<UserService>() @BeforeTest fun setup() { val testModule = module { single { UserRepository() } factory { UserService(get()) } } startKoin { modules(testModule) } } @AfterTest fun tearDown() { // Koin Stopped After Tests stopKoin() } @Test fun testUserData() { assertEquals("User data from repository", userService.getUserData()) } }
Examples of tests using Mockk
Here is an example of how you can test UserService using the Koin library for dependency injection and MockK to create mocks.
Let's assume we are testing a UserService that uses a UserRepository to retrieve user data. In the test, we will replace the real UserRepository with a mock object to isolate the test from the database or other external
Setting up dependencies:
In build.gradle.kts we add dependencies for Koin, MockK and Koin-test:dependencies.
dependencies { // Main dependencies implementation("io.insert-koin:koin-core:3.4.0") // Dependencies for tests testImplementation("io.insert-koin:koin-test:3.4.0") testImplementation("io.mockk:mockk:1.12.0") testImplementation("org.jetbrains.kotlin:kotlin-test") }
UserRepository and UserService classes:
The classes we will be testing look like this
// UserRepository.kt class UserRepository { fun getUser(): String { // In a real situation, this method might access a database return "Real User Data" } } // UserService.kt class UserService(private val userRepository: UserRepository) { fun getUserData(): String { return userRepository.getUser() } }
Setting up the Koin module for testing:
Let's create a test where we will replace UserRepository with a mock object using MockK.
import io.insert-koin.core.context.startKoin import io.insert-koin.core.context.stopKoin import io.insert-koin.dsl.module import io.insert-koin.test.KoinTest import io.insert-koin.test.inject import io.mockk.every import io.mockk.mockk import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals class UserServiceTest : KoinTest { // The UserService dependency will be automatically injected by Koin private val userService by inject<UserService>() private val mockRepository = mockk<UserRepository>() @BeforeTest fun setup() { // Definition of a Koin test module where we replace UserRepository with a mock val testModule = module { // Use mockRepository instead of real UserRepository single { mockRepository } single { UserService(get()) } } // Launching Koin with a test module startKoin { modules(testModule) } } @AfterTest fun tearDown() { // Stopping Koin after running the test stopKoin() } @Test fun `should return mock user data`() { // Setting up the behavior of a mock object every { mockRepository.getUser() } returns "Mocked User Data" // We execute the method under test val result = userService.getUserData() // We check that the result meets expectations assertEquals("Mocked User Data", result) } }
Explanation of the test:
Creating a mock object:
private val mockRepository = mockk<UserRepository>()
We create a mock object for UserRepository using MockK to avoid using a real database or other external dependencies.
Definition of a test module:
val testModule = module { single { mockRepository } single { UserService(get()) } }
In the test module, UserRepository is replaced with a mockRepository, and UserService is instantiated using this substituted dependency.
Setting up mock behavior:
every { mockRepository.getUser() } returns "Mocked User Data"
Here we specify that when calling getUser() on mockRepository , the method should return "Mocked User Data". This allows us to test UserService in isolation from the actual behavior of UserRepository .
Checking the result:
val result = userService.getUserData() assertEquals("Mocked User Data", result)
By calling getUserData on UserService, we expect it to return "Mocked User Data" since that is the behavior specified for the mock UserRepository.
Koin Stop:
stopKoin()
After a test is completed, Koin stops to prevent the test context from affecting other tests.
This example demonstrates how to use Koin for dependency management and MockK to mock dependency behavior in tests. With this approach, we can isolate UserService and test its logic without relying on the UserRepository implementation.
Examples of tests using Testcontainers
Testcontainers is a Java library for running Docker containers in a test environment. It allows you to run real services, such as databases, message queues, caches, and other infrastructure components, as Docker containers right while running tests. This makes Testcontainers convenient for writing integration tests that require working with real services, instead of using fakes or embedded databases.
Key features of Testcontainers:
Isolated test environment:
Each test is run in a separate container, which allows you to avoid tests interfering with each other and get the same results regardless of local settings.
Support for various databases:
Testcontainers supports many popular databases such as PostgreSQL, MySQL, Redis, MongoDB and others. This allows you to test your application with the same database that is used in production.
Support for other services:
In addition to databases, Testcontainers supports services such as Kafka, RabbitMQ, Selenium (for UI testing), and others.
Integration with JUnit 4 and 5:
Testcontainers integrates easily with popular testing frameworks such as JUnit 4 and JUnit 5.
When to use Testcontainers:
Testcontainers are useful in the following situations:
When you need to test interaction with a database (for example, PostgreSQL or MySQL) in conditions close to the real environment.
When testing interactions between different components of a system, such as between an application and a message broker.
For writing integration tests where it is important to have access to the real service, not a fake one.
Example of using Testcontainers with PostgreSQL and JUnit 5
Let's assume we have a Kotlin application that uses a PostgreSQL database, and we want to write an integration test to test interaction with the database.
Connecting dependencies:
Let's add dependencies for Testcontainers and PostgreSQL to build.gradle.kts:
dependencies { testImplementation("org.testcontainers:testcontainers:1.19.0") testImplementation("org.testcontainers:postgresql:1.19.0") testImplementation("org.junit.jupiter:junit-jupiter:5.9.2") }
Example of using Testcontainers for a database test:
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.AfterAll import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers import java.sql.Connection import java.sql.DriverManager @Testcontainers @TestInstance(TestInstance.Lifecycle.PER_CLASS) class PostgresTest { @Container private val postgresContainer = PostgreSQLContainer<Nothing>("postgres:15-alpine").apply { withDatabaseName("testdb") withUsername("testuser") withPassword("testpassword") // Launching the container start() } private lateinit var connection: Connection @BeforeAll fun setup() { // Connecting to a database inside a container connection = DriverManager.getConnection( postgresContainer.jdbcUrl, postgresContainer.username, postgresContainer.password ) // Creating a table for the test connection.createStatement().executeUpdate( """ CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL ); """ ) } @Test fun `test insert and query`() { // Inserting data connection.createStatement().executeUpdate( "INSERT INTO users (name, email) VALUES ('Alice', '[email protected]');" ) // Executing a request val resultSet = connection.createStatement().executeQuery("SELECT * FROM users") resultSet.next() val name = resultSet.getString("name") val email = resultSet.getString("email") assertEquals("Alice", name) assertEquals("[email protected]", email) } @AfterAll fun teardown() { connection.close() // Stopping the container after tests are completed postgresContainer.stop() } }
Code explanation:
Create a PostgreSQL container:
Using PostgreSQLContainer we create a container with PostgreSQL. We set the database name, username and password.
The container is launched with the postgres:15-alpine image.
Setting up a database connection:
@BeforeAll establishes a connection to the database in the container.
A users table is also created to store test data.
Testing insertion and query of data:
@Test inserts data into and fetches it from the users table, and then checks the data for correctness using assertEquals.
Stopping the container after tests are completed:
The PostgreSQL container is automatically stopped after all tests have been run thanks to @AfterAll.
Benefits of using Testcontainers:
Real testing conditions:
Allows you to work with real services and infrastructure, which helps identify errors that may not appear when using built-in or simulated services.
Test isolation:
Each test runs in a new container, ensuring that the data is clean and that it is not dependent on the state of other tests.
CI/CD Compatibility:
Testcontainers integrates easily into CI/CD pipelines, allowing you to run tests in isolated environments on different platforms.
Example of migrations using Flyway
Flyway is a database migration management tool that automates the process of upgrading and rolling back the database schema. It is commonly used to version the database structure and maintain schema consistency across all environments.
Steps to set up and use Flyway:
Let's say we have a database project where we need to add, modify, or delete tables and manage structure changes using Flyway migrations.
Adding Flyway to a project:
Let's add Flyway dependencies to build.gradle.kts:
dependencies { implementation("org.flywaydb:flyway-core:9.8.1") // Driver for PostgreSQL implementation("org.postgresql:postgresql:42.3.1") }
Setting up Flyway:
Let's add Flyway configuration to build.gradle.kts
import org.flywaydb.gradle.task.FlywayMigrateTask plugins { id("org.flywaydb.flyway") version "9.8.1" } flyway { url = "jdbc:postgresql://localhost:5432/mydatabase" user = "username" password = "password" schemas = arrayOf("public") // Migration Directory locations = arrayOf("filesystem:src/main/resources/db/migration") }
Creating a migration:
Flyway looks for migration SQL files in the folder specified in locations. For each change, a migration file is created that will be automatically applied when Flyway is started. Migrations must follow the filename format: V<number>__<description>.sql.
Example:
V1__create_users_table.sql
V2__add_email_to_users.sql
Each migration file contains SQL commands to perform changes to the database.
Example migration file V1__create_users_table.sql:
Let's create a migration file to create the users table
-- V1__create_users_table.sql CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE );
Example migration file V2__add_email_to_users.sql:
Let's add a new column created_at to the users table
-- V2__add_created_at_column.sql ALTER TABLE users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
Launching Flyway migrations:
Now we can run migrations using the Gradle command
./gradlew flywayMigrate
This command will execute all migrations that have not yet been applied and update the database structure. Flyway stores information about executed migrations in a special table (flyway_schema_history) to ensure that each migration is executed only once.
Roll back migration (if required):
If we need to roll back the migration, we can use the command:
./gradlew flywayUndo
However, it should be noted that Flyway supports migration rollback (undo) only in the commercial version. In the free version, migrations can be manually undone by adding separate SQL files to modify or delete the created data.
Example code for running migrations programmatically:
In some cases, you may need to run migrations programmatically, for example, when the application starts. Flyway allows you to do this:
import org.flywaydb.core.Flyway fun main() { val flyway = Flyway.configure() .dataSource("jdbc:postgresql://localhost:5432/mydatabase", "username", "password") .load() flyway.migrate() }
Using environment variables
Global environment variables are often used to store sensitive information or settings that depend on the application's runtime (such as databases, API keys, and other settings) without having to store them directly in the code.
In Kotlin (and Java), environment variables can be accessed through system properties or special libraries such as dotenv. Let's look at a few ways to use environment variables.
Example of accessing environment variables directly:
Environment variables can be retrieved in Kotlin using System.getenv().
Let's assume that we need to get the database connection details such as URL, username and password which are set in the environment variables DB_URL, DB_USER, DB_PASSWORD.
fun main() { val dbUrl = System.getenv("DB_URL") ?: "jdbc:postgresql://localhost:5432/defaultdb" val dbUser = System.getenv("DB_USER") ?: "default_user" val dbPassword = System.getenv("DB_PASSWORD") ?: "default_password" }
This approach is convenient for simple applications and is suitable for environment variables that are defined on the system (for example, in the .bashrc or .zshrc file on the local machine).
Using .env file with dotenv library:
For more flexible management of environment variables, especially in different environments (development, testing, production), you can use the dotenv library. This library allows you to store environment variables in a .env file and load them when the application starts.
Adding dependency:
Let's add the dotenv library to build.gradle.kts:
dependencies { implementation("io.github.cdimascio:java-dotenv:5.2.2") }
Creating a .env file:
Create a .env file in the root of the project and add environment variables to it:
DB_URL=jdbc:postgresql://localhost:5432/mydatabase DB_USER=myuser DB_PASSWORD=supersecretpassword
Using environment variables from .env file in code:
You can then use the dotenv library to load variables from the .env file:
import io.github.cdimascio.dotenv.dotenv fun main() { val dotenv = dotenv() val dbUrl = dotenv["DB_URL"] ?: "jdbc:postgresql://localhost:5432/defaultdb" val dbUser = dotenv["DB_USER"] ?: "default_user" val dbPassword = dotenv["DB_PASSWORD"] ?: "default_password" }
Using environment variables with Ktor:
Ktor also supports the use of environment variables for application configuration. Environment variables can be loaded via application.conf or retrieved using System.getenv().
Example of database configuration in application.conf:
In application.conf you can specify the path to environment variables via ${}:
ktor { deployment { port = 8080 } database { url = ${DB_URL} user = ${DB_USER} password = ${DB_PASSWORD} } }
Example of usage in Ktor code:
In Ktor code, you can get values from the configuration and use them to connect to the database:
import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main() { embeddedServer(Netty, port = 8080) { environment.config.config("database").apply { val dbUrl = property("url").getString() val dbUser = property("user").getString() val dbPassword = property("password").getString() } }.start(wait = true) }
Example of launching with global environment variables:
To work with environment variables in different environments (for example, in Docker or on CI/CD systems), you can specify environment variables when starting the application.
DB_URL="jdbc:postgresql://production-db:5432/proddb" \ DB_USER="produser" \ DB_PASSWORD="prodpassword" \ java -jar myapp.jar
In IntelliJ IDEA, you can easily configure loading environment variables from a .env file for a Kotlin/Java project:
To add environment variables from a .env file to an Application run configuration in IntelliJ IDEA, follow these steps:
Opening the launch configuration:
From the menu, select Run > Edit Configurations….
Find your application's launch configuration in the list (for example, Application).
Adding environment variables:
In the configuration settings, find the Environment variables field.
Click the … icon next to this field to open the environment variables editor.
Add variables manually by copying values from the .env file, in the KEY=VALUE format. For example:
DB_URL=jdbc:postgresql://localhost:5432/mydatabase DB_USER=myuser DB_PASSWORD=supersecretpassword
Variables can be added one at a time by pressing +, or all at once by separating them with the ; symbol on Windows or : on macOS/Linux.
Click OK to save the variables.
Saving and running:
Click Apply and OK to save the startup configuration.
Now the environment variables will be available to your application when it starts. You can get them using System.getenv("KEY") in your code.
Alternative method:
Using the EnvFile Plugin
To automatically load variables from a .env file:
Install the EnvFile plugin in IntelliJ IDEA:
Open File > Settings > Plugins.
Find EnvFile and install it.
Restart the IDE after installation.
In Run > Edit Configurations…, open your application's launch configuration.
In the EnvFile tab, check Enable EnvFile and select the .env file.
Save the settings and run the project.