Skip to content

Testing

Joseph Samir edited this page Jun 21, 2026 · 2 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 suite (HashParityTests)

The SDK ships a cross-SDK parity suite at Tests/ConvertSwiftSDKCoreTests/Bucketing/HashParityTests.swift. It drives 74 golden vectors captured from the JavaScript SDK, 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

The parity suite uses swift-testing (@Suite("HashParity"), @Test("cross-SDK parity vector", arguments: vectors)). 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). All 74 vectors must pass 100%; this gate must stay green before any change to bucketing, hash, or rule-matching logic.

Run it before shipping any change to MurmurHash3.swift or BucketingManager.swift.

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    # parity gate only
swift test --filter ConvertSwiftSDKCoreTests  # core only (no simulator required)
swift test --filter ConvertSwiftSDKTests    # platform layer (simulator / macOS)

Related pages

Clone this wiki locally