Skip to content

Testing

Ahmed Abbas edited this page Aug 7, 2026 · 4 revisions

Testing

This page covers testing your app's integration with the Convert iOS SDK — making experiment decisions deterministic in tests, keeping tracking quiet, and using the SDK's injection seams. For the SDK's own internal test suite, see the repository's Tests/ directory.

Test framework: swift-testing (+ XCTest for platform layer)

The SDK's own suites use swift-testing (import Testing, @Suite, @Test, #expect) throughout. Your integration tests can use either swift-testing or XCTest — the SDK's public surface is framework-agnostic.

Make decisions deterministic with direct-data mode

The biggest source of test flakiness is the asynchronous config fetch. Avoid it entirely by constructing the SDK with a pre-loaded config payload (init(configData:)) — decisions are available as soon as ready() resolves and never depend on the network:

import ConvertSwiftSDK
import Testing

@Test func bucketsVisitorDeterministically() async throws {
    let configData = try Data(contentsOf: Bundle.module.url(
        forResource: "test-config", withExtension: "json")!)
    let sdk = ConvertSwiftSDK(configData: configData)
    try await sdk.ready()

    let context = sdk.createContext(visitorId: "test-visitor")
    let variation = await context.runExperience("homepage-redesign")
    #expect(variation?.key == "control")   // stable because visitorId is fixed
}

With init(configData:), the SDK validates the payload directly without a network call. Use a stable explicit visitor id (e.g. "test-visitor") so bucketing is reproducible run to run — the same (experienceId, visitorId) pair always maps to the same variation (MurmurHash3 seed 9999, deterministic).

For XCTest:

import ConvertSwiftSDK
import XCTest

class ConvertIntegrationTests: XCTestCase {
    func testBucketsVisitorDeterministically() async throws {
        let configData = try Data(contentsOf: Bundle.module.url(
            forResource: "test-config", withExtension: "json")!)
        let sdk = ConvertSwiftSDK(configData: configData)
        try await sdk.ready()

        let context = sdk.createContext(visitorId: "test-visitor")
        let variation = await context.runExperience("homepage-redesign")
        XCTAssertEqual(variation?.key, "control")
    }
}

Keep tracking quiet in tests

Disable outbound tracking so tests do not emit network events:

let config = ConvertConfiguration(sdkKey: "test-key", networkTracking: false)
let sdk = ConvertSwiftSDK(configuration: config)

Bucketing, rule evaluation, and sticky persistence still work with tracking disabled — only the network side is silenced. See Tracking Control. For a single call, use the per-call override: await context.runExperience("key", enableTracking: false).

With init(configData:) the SDK never makes a config network call; pair it with networkTracking: false for full network isolation:

let sdk = ConvertSwiftSDK(configData: configData)
// For a sdk constructed with configData, tracking suppression at the EventQueue level
// still applies — either pass ConvertConfiguration(sdkKey:, networkTracking: false)
// via init(configuration:directData:) or use the runtime toggle:
await sdk.setTrackingEnabled(false)

No Robolectric needed — the SDK is pure Foundation

Unlike the Android SDK (which requires Robolectric to run on the JVM), the iOS SDK's core (ConvertSwiftSDKCore) is pure Swift with no UIKit/AppKit dependency. Tests against ConvertSwiftSDKCore types run as plain JVM-equivalent Swift unit tests — you do not need a simulator or device for core logic. Platform-layer tests (ConvertSwiftSDKTests) that exercise BackgroundSessionManager, LifecycleObserver, or Keychain adapters do require a simulator or macOS.

Capture SDK logs in tests

The SDK logs through the Logger port. Inject a custom logger to assert on log output:

import ConvertSwiftSDK

// Use the @testable import to reach the internal initializer
@testable import ConvertSwiftSDK

// A simple recording logger (not provided by the SDK — implement your own or use MockLogger
// from the SDK's own test support if your test target is @testable-importing ConvertSwiftSDK):
actor RecordingLogger: Logger {
    private(set) var lines: [String] = []
    func log(level: LogLevel, type: String, method: String, message: String) {
        lines.append("[\(level)] \(type).\(method): \(message)")
    }
}

Note: MockLogger in Tests/ConvertSwiftSDKTests/Support/MockPorts.swift is internal to the SDK's own test target. For your integration tests, implement your own recording logger conforming to the Logger port (public protocol Logger: Sendable in ConvertSwiftSDKCore), or avoid log assertions and instead assert on the observable effects (returned variations, event counts).

The bucketing parity suites

The SDK ships two cross-SDK parity suites, because there are two bucketing layouts. Both drive golden vectors captured from the JavaScript SDK, and both must pass 100% — they are the gate that proves a visitor lands in the same variation on every Convert SDK.

HashParityTests — the hash and the packed layout

74 golden vectors proving that MurmurHash3 (x86, 32-bit, seed 9999) and BucketingManager.selectBucket produce byte-for-byte identical decisions to the JS SDK:

# from ios-sdk/
swift test --filter HashParityTests

Uses swift-testing (@Suite("HashParity")). Every hash key is "<experienceId><visitorId>" (experience id first, no separator), seeded with the vector's own seed (most are 9999; a few probe edge-case seeds).

AnchoredBucketingParityTests — the anchored layout (contract v12)

59 golden vectors driven end-to-end through BucketingManager.bucketVersionGated, the version gate that routes an experience to the right layout: an experience whose version is greater than 11 selects under the anchored layout, anything at or below 11 delegates verbatim to the existing packed walk. The fixture carries full experience shapes (version, per-variation traffic_allocation and status), so the suite asserts the resolved variation id — not just a bucket value:

# from ios-sdk/
swift test --filter AnchoredBucketingParityTests

The companion AnchoredBucketingGateAndBoundaryTests covers what a shared fixture cannot: the version-gate sweep, anchor/width boundary conditions, bit-for-bit v11 delegation, sticky decisions winning over both layouts, and the unchanged bucketing-event shape.

Run all three before shipping any change to MurmurHash3, BucketingManager, or AnchoredBucketing:

swift test --filter HashParityTests
swift test --filter AnchoredBucketingParityTests
swift test --filter AnchoredBucketingGateAndBoundaryTests

Tips

  • Fresh SDK per test — construct a new ConvertSwiftSDK per test (or per class) so visitor and queue state do not leak. The default DecisionStore uses an on-disk ApplicationSupportFileStore at a process-shared path; for unit isolation, use the @testable import internal init with an in-memory MockFileStore-backed DecisionStore (as the SDK's own test suites do).
  • Use explicit visitor ids — never rely on the auto-UUID in tests; an explicit id makes bucketing assertions stable across runs.
  • Prefer fixtures over live keysinit(configData:) with a checked-in config fixture keeps tests hermetic and fast. Real config captures live in Tests/ConvertSwiftSDKCoreTests/Fixtures/.
  • Avoid wall-clock assertions — the SDK's own suites inject a MockClock via the internal clock: init parameter to make timer-driven behavior deterministic. If your test needs to assert timing (e.g. refresh intervals), use the internal init seam.

Running the full test suite

# from ios-sdk/
swift test                             # both targets: ConvertSwiftSDKCoreTests + ConvertSwiftSDKTests
swift test --filter HashParityTests    # hash + packed-layout parity gate
swift test --filter AnchoredBucketingParityTests   # anchored-layout parity gate
swift test --filter ConvertSwiftSDKCoreTests  # core only (no simulator required)
swift test --filter ConvertSwiftSDKTests    # platform layer (simulator / macOS)

Related pages

Clone this wiki locally