Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ buildscript {
'dokka' : '1.9.20',
'junit' : '4.13.2',
'gson' : '2.13.2',
'okhttp' : '4.12.0',
'coroutines' : '1.10.2',
'webkit' : '1.15.0'
]
repositories {
Expand Down
115 changes: 48 additions & 67 deletions integration-tests/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Ketch SDK Integration Tests

This module contains integration tests for the Ketch Android SDK. The tests are designed to validate the SDK's functionality in a real Android environment.
This module contains integration tests for the Ketch Android SDK. The tests validate SDK functionality in a real Android environment.

**ATT is N/A on Android** — App Tracking Transparency is iOS-only. For ATT testing see [mobile-att-testing.md](../../ketch-tag/docs/design/mobile-att-testing.md#ketch-android). For headless CDN tests see [mobile-headless-api-testing.md](../../ketch-tag/docs/design/mobile-headless-api-testing.md#ketch-android).

## Overview

Expand Down Expand Up @@ -32,14 +34,31 @@ integration-tests/
- An Android device or emulator running API 28 or higher
- The Ketch SDK module (`ketchsdk`) must be built successfully

### Running Tests Locally
### Headless integration tests

Live CDN round-trip (no WebView): `KetchHeadlessIntegrationTest` uses org `ketch_samples` / property `android`.

```bash
./gradlew :integration-tests:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.ketch.android.integration.tests.KetchHeadlessIntegrationTest
```

### WebView integration tests

```bash
# From the ketch-android directory
./gradlew :integration-tests:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.ketch.android.integration.tests.KetchSdkIntegrationTest
```

## Running Tests Locally

#### From Android Studio

1. Open the `ketch-android` project in Android Studio
2. Connect an Android device or start an emulator
3. Navigate to `integration-tests/src/androidTest/java/com/ketch/android/integration/tests/`
4. Right-click on `KetchSdkIntegrationTest` and select "Run 'KetchSdkIntegrationTest'"
4. Right-click on the desired test class and select Run

#### From Command Line

Expand All @@ -57,10 +76,22 @@ make test-integration-class CLASS=com.ketch.android.integration.tests.ZAutoDismi
make help
```

Or with gradlew directly:

```bash
# All integration tests
./gradlew :integration-tests:connectedAndroidTest
```

Reports: `integration-tests/build/reports/androidTests/connected/`.

## Test Coverage

| Suite | Class | What it validates |
| ----- | ----- | ----------------- |
| **Headless CDN** | `KetchHeadlessIntegrationTest` | `getLocation`, bootstrap, full config, consent get/set |
| **WebView** | `KetchSdkIntegrationTest` | SDK init, UI buttons, WebView experience load |

The current test suite covers:

- **SDK Initialization**: Verifies the SDK initializes correctly
Expand All @@ -77,9 +108,8 @@ The sample app includes:

- Buttons to trigger all major SDK methods (`load`, `showConsent`, `showPreferences`, etc.)
- **Open Second Activity** button to demonstrate cross-activity display with a shared Ketch instance
- Status display to show current SDK state
- Status display for SDK state and privacy framework values (TCF, US Privacy, GPP)
- Real-time updates from SDK callbacks
- Display of privacy framework values (TCF, US Privacy, GPP)

## Configuration

Expand All @@ -89,76 +119,27 @@ The sample app uses test configuration values:
- **Property**: `test_property`
- **Environment**: `test`

To test with real values, update the constants in `MainActivity.kt`:

```kotlin
private const val ORG_CODE = "your_real_org_code"
private const val PROPERTY = "your_real_property"
private const val ENVIRONMENT = "production"
```
To test with real values, update the constants in `MainActivity.kt`.

## Adding New Tests

To add new integration tests:

1. Create test methods in `KetchSdkIntegrationTest.kt`
1. Create test methods in the appropriate `*IntegrationTest.kt` class
2. Use Espresso matchers and assertions
3. Follow the existing test patterns
4. Add any necessary helper methods

Example test structure:

```kotlin
@Test
fun testNewFeature() {
// Arrange
// Set up test conditions

// Act
// Perform actions (button clicks, etc.)

// Assert
// Verify expected outcomes
}
```

## Future Enhancements

Planned improvements:

- [ ] Tests for dialog display and interaction
- [ ] Network request mocking for controlled testing
- [ ] Performance testing
- [ ] Accessibility testing
- [ ] Tests for different Android versions and devices
- [ ] Integration with CI/CD pipelines
3. Follow existing test patterns
4. Keep headless tests in `KetchHeadlessIntegrationTest`; WebView tests in `KetchSdkIntegrationTest`

## Troubleshooting

Common issues and solutions:

**Tests fail with "No activities found"**

- Ensure the sample app builds successfully
- Check that the device/emulator is running

**SDK initialization errors**

- Verify the SDK module is included in dependencies
- Check that test configuration values are valid

**UI tests fail intermittently**

- Add appropriate wait conditions for async operations
- Ensure tests are running on a clean app state
| Symptom | Likely cause |
| ------- | ------------ |
| Tests fail with "No activities found" | Sample app did not build; emulator not running |
| SDK initialization errors | Invalid test configuration; missing SDK dependency |
| UI tests fail intermittently | Missing wait for async WebView load |

## Dependencies

The integration tests use:

- **Espresso**: For UI testing
- **AndroidX Test**: For test infrastructure
- **JUnit 4**: For test framework
- **Awaitility**: For async test conditions (if needed)
- **Espresso** — UI testing
- **AndroidX Test** — test infrastructure
- **JUnit 4** — test framework

All dependencies are automatically managed through the `build.gradle` file.
All dependencies are managed through `build.gradle`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.ketch.android.integration.tests

import com.ketch.android.api.KetchDataCenter
import com.ketch.android.data.ConsentConfig
import com.ketch.android.data.HeadlessConfiguration
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertTrue

object HeadlessIntegrationSupport {
const val ORG_CODE = "ketch_samples"
const val PROPERTY = "android"
const val ENVIRONMENT = "production"
const val TIMEOUT_SECONDS = 45L

fun uniqueEmailIdentity(): Map<String, String> =
mapOf("email" to "headless-${UUID.randomUUID()}@integration.ketch.test")

fun <T> awaitHeadless(block: (callback: (Result<T>) -> Unit) -> Unit): T {
val latch = CountDownLatch(1)
var result: Result<T>? = null
block { value ->
result = value
latch.countDown()
}
assertTrue(
"Headless call timed out after ${TIMEOUT_SECONDS}s",
latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS),
)
return result!!.getOrElse { throw AssertionError("Headless call failed: ${it.message}", it) }
}

fun consentConfigFrom(
config: HeadlessConfiguration,
identities: Map<String, String>,
organizationCode: String = ORG_CODE,
propertyCode: String = PROPERTY,
environmentCode: String = ENVIRONMENT,
): ConsentConfig {
val jurisdiction = config.jurisdiction?.code
?: config.jurisdiction?.defaultJurisdictionCode
?: "us"
val purposes = config.purposes
?.mapNotNull { purpose ->
val code = purpose.code
val legalBasis = purpose.legalBasisCode
if (code != null && legalBasis != null) {
code to ConsentConfig.PurposeLegalBasis(legalBasis)
} else {
null
}
}
?.toMap()
?: emptyMap()
require(purposes.isNotEmpty()) { "Configuration returned no purposes" }
return ConsentConfig(
organizationCode = organizationCode,
propertyCode = propertyCode,
environmentCode = environmentCode,
jurisdictionCode = jurisdiction,
identities = identities,
purposes = purposes,
)
}

val dataCenter: KetchDataCenter = KetchDataCenter.US
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.ketch.android.integration.tests

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ketch.android.KetchSdk
import com.ketch.android.data.ConsentUpdate
import com.ketch.android.data.FullConfigurationRequest
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.ORG_CODE
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.PROPERTY
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.ENVIRONMENT
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.awaitHeadless
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.consentConfigFrom
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.dataCenter
import com.ketch.android.integration.tests.HeadlessIntegrationSupport.uniqueEmailIdentity
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Live CDN headless round-trip tests (web/v3, sandbox org).
* Requires network on emulator/device. Does not use WebView.
*/
@RunWith(AndroidJUnit4::class)
class KetchHeadlessIntegrationTest {

@Test
fun testGetLocationReturnsGeoIP() {
val location = awaitHeadless { callback ->
KetchSdk.getLocation(dataCenter, callback)
}
assertNotNull("Expected location payload", location.location)
assertFalse(
"Expected countryCode from CDN",
location.location?.countryCode.isNullOrBlank(),
)
}

@Test
fun testGetBootstrapConfiguration() {
val boot = awaitHeadless { callback ->
KetchSdk.getBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback)
}
assertTrue(
"Bootstrap should include experiences metadata",
boot.experiences != null || boot.jurisdiction != null || !boot.purposes.isNullOrEmpty(),
)
}

@Test
fun testHeadlessColdStartConsentRoundTrip() {
val identities = uniqueEmailIdentity()

awaitHeadless { callback ->
KetchSdk.getLocation(dataCenter, callback)
}

val boot = awaitHeadless { callback ->
KetchSdk.getBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback)
}

val fullConfig = awaitHeadless { callback ->
KetchSdk.getFullConfiguration(
FullConfigurationRequest(
organizationCode = ORG_CODE,
propertyCode = PROPERTY,
),
dataCenter,
callback,
)
}

val consentConfig = consentConfigFrom(fullConfig, identities)

val consent = awaitHeadless { callback ->
KetchSdk.getConsent(consentConfig, dataCenter, callback)
}
assertTrue(
"CDN consent get should return protocols and/or purposes",
!consent.protocols.isNullOrEmpty() || !consent.purposes.isNullOrEmpty(),
)

val purposeCode = consentConfig.purposes.keys.first()
val legalBasis = consentConfig.purposes[purposeCode]!!

val update = ConsentUpdate(
organizationCode = ORG_CODE,
propertyCode = PROPERTY,
environmentCode = ENVIRONMENT,
identities = identities,
jurisdictionCode = consentConfig.jurisdictionCode,
migrationOption = ConsentUpdate.MigrationOption.MIGRATE_DEFAULT,
purposes = mapOf(
purposeCode to ConsentUpdate.PurposeAllowedLegalBasis(
allowed = true,
legalBasisCode = legalBasis.legalBasisCode,
),
),
)

val updated = awaitHeadless { callback ->
KetchSdk.setConsent(update, dataCenter, callback)
}
assertNotNull("setConsent should return purposes", updated.purposes)
assertTrue(
"setConsent should include updated purpose",
updated.purposes!!.containsKey(purposeCode),
)
}
}
Loading
Loading