CI for Compose Multiplatform Apps: A Practical Guide

Developing cross-platform applications with Kotlin Multiplatform and Jetpack Compose is an exciting way to target multiple platforms from a single codebase. However, to ensure the reliability and maintainability of such applications, Continuous Integration (CI) is essential. This post will guide you through setting up a CI pipeline for a Compose Multiplatform project, providing a structured approach to automated building, testing, and deployment.

What is Continuous Integration (CI)?

Continuous Integration (CI) is a development practice where developers regularly merge their code changes into a central repository, after which automated builds and tests are run. CI aims to detect and address integration issues early in the development cycle, leading to more reliable software and faster release cycles.

Why Use CI for Compose Multiplatform?

  • Early Bug Detection: Identifies issues across all supported platforms quickly.
  • Automated Testing: Ensures that changes do not introduce regressions.
  • Consistency: Provides a consistent and automated build process.
  • Faster Feedback Loops: Gives developers rapid feedback on their code changes.

Choosing a CI Tool

Several CI tools are available, each with its strengths. Popular choices include:

  • GitHub Actions: Integrated directly into GitHub repositories.
  • GitLab CI: Part of the GitLab suite, suitable for projects using GitLab for version control.
  • Jenkins: A highly customizable, open-source automation server.
  • CircleCI: A cloud-based CI/CD platform known for its ease of use.

For this guide, we will use GitHub Actions due to its tight integration with GitHub and ease of configuration.

Setting Up CI with GitHub Actions

Step 1: Project Structure

Ensure your project is structured in a way that supports multiplatform development. A typical Compose Multiplatform project structure looks like this:


MyComposeMultiplatformApp/
├── androidApp/
├── iosApp/
├── commonMain/
├── desktopApp/
├── build.gradle.kts
├── settings.gradle.kts
└── gradle.properties

Step 2: Create a GitHub Actions Workflow File

Create a new file in the .github/workflows directory of your repository, named ci.yml. This file will define your CI workflow.

Step 3: Define the Workflow

Open .github/workflows/ci.yml and add the following content to define your workflow:


name: CI - Compose Multiplatform

on:
  push:
    branches: [ "main", "develop" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Validate Gradle Wrapper
        uses: gradle/wrapper-validation-action@v1

      - name: Build and Test
        run: ./gradlew check

Explanation of the YAML file:

  • name: Name of the workflow.
  • on: Defines when the workflow will be triggered. This workflow runs on pushes to the main and develop branches, and on pull requests to the main branch.
  • jobs: Defines the jobs to be executed.
  • build: The job that builds and tests the application.
  • runs-on: Specifies the runner environment (ubuntu-latest).
  • steps: List of steps to perform:
    • actions/checkout@v3: Checks out the repository.
    • actions/setup-java@v3: Sets up the Java Development Kit (JDK) version 17.
    • chmod +x gradlew: Grants execute permissions to the gradlew script.
    • gradle/wrapper-validation-action@v1: Validates the Gradle Wrapper to ensure it’s safe to use.
    • ./gradlew check: Runs all checks and tests defined in your Gradle configuration.

Step 4: Customize Build Tasks

The ./gradlew check command in the ci.yml file runs the default check task in your Gradle configuration. Customize your build.gradle.kts file to include relevant build and test tasks.


tasks.register("check") {
    dependsOn("test", "lintKotlin", "allTests")
}

tasks.register("allTests") {
    dependsOn(":commonMain:test", ":androidApp:testDebugUnitTest")
}

Here:

  • test runs unit tests for all platforms (e.g., Kotlin JVM, Android).
  • lintKotlin performs code linting.
  • allTests is a custom task aggregating tests from the commonMain and androidApp modules. You may need to adapt this task to the names and locations of your test tasks across other platforms (e.g., iOS).

Step 5: Testing UI Components

Compose UI testing is crucial to ensure the user interface behaves as expected. Set up UI tests for both Android and Desktop applications using frameworks like JUnit and Compose UI testing libraries.

Android UI Tests

dependencies {
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
}

android {
    testOptions {
        animationsDisabled = true // Optional, for smoother UI tests
        execution "ANDROIDX_TEST_ORCHESTRATOR" // For test isolation
    }
}

Step 6: Running Tests on Multiple Platforms

Ensure your tests cover all platforms by creating dedicated tasks in your build.gradle.kts file for each platform.


tasks.register("desktopTest") {
    dependsOn(":desktopApp:test")
}

tasks.register("androidTest") {
    dependsOn(":androidApp:testDebugUnitTest")
}

tasks.register("iosTest") {
    // Add logic for iOS tests if applicable
}

tasks.named("check") {
    dependsOn("desktopTest", "androidTest", "iosTest")
}

Step 7: Secret Management

When your CI process requires sensitive information (API keys, passwords), store them as GitHub Secrets. Access these secrets in your workflow file.


steps:
  - name: Send Slack Notification
    uses: actions/github-script@v6
    env:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Add secrets in your repository’s settings under the “Secrets” tab.

Step 8: Complete CI Workflow

The final CI workflow file will combine all steps. For example:


name: CI - Compose Multiplatform

on:
  push:
    branches: [ "main", "develop" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Validate Gradle Wrapper
        uses: gradle/wrapper-validation-action@v1

      - name: Build and Test
        run: ./gradlew check

      - name: Send Slack Notification (on failure)
        if: failure()
        uses: actions/github-script@v6
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          script: |
            const message = `:red_circle: CI Failed for ${{ github.repository }} - ${{ github.event.pull_request.title || github.sha }}nSee workflow logs at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
            await github.request('POST', process.env.SLACK_WEBHOOK_URL, {
              body: JSON.stringify({ text: message })
            });

Best Practices for Compose Multiplatform CI

  • Use Gradle Wrapper: Ensures consistent build environment.
  • Cache Gradle Dependencies: Speeds up build times.
  • Automated Code Analysis: Integrate linting and code quality checks.
  • Parallel Testing: Run tests in parallel to reduce feedback time.
  • Monitor CI Performance: Regularly review and optimize CI workflow for efficiency.

Conclusion

Implementing a Continuous Integration pipeline for Compose Multiplatform applications is essential for maintaining code quality, detecting issues early, and ensuring smooth releases. By following the steps outlined in this guide, you can set up a robust CI system using GitHub Actions that automates building, testing, and deployment processes, leading to more reliable and maintainable cross-platform applications.