| name | bcsc-core-native-testing |
| description | Create native unit tests for Swift (iOS) and Kotlin (Android) code in the bcsc-core package. USE WHEN: testing native code, adding unit tests for native modules, writing XCTest or JUnit tests for bcsc-core, mocking native protocols or interfaces. DO NOT USE FOR: TypeScript/React tests, UI integration tests, or E2E tests. |
| metadata | {"tags":"testing, swift, kotlin, xctest, junit, native-modules, bcsc-core"} |
bcsc-core Native Unit Testing
Overview
This skill guides creation of native unit tests for the packages/bcsc-core module, which contains Swift (iOS) and Kotlin (Android) implementations of authentication, PIN management, keychain storage, token services, and device authentication.
The codebase already uses protocol-based dependency injection on iOS and interface/class-based injection on Android, making it straightforward to create mock implementations for isolated unit testing.
Test Infrastructure
iOS — XCTest via SPM
Tests are built with Swift Package Manager using the package manifest at packages/bcsc-core/Package.swift. The key concepts:
BcscCoreTestable target: A subset of production iOS source files compiled for the simulator (without React Native / CocoaPods dependencies). Only files listed in the sources: array are included.
BcscCoreTests test target: XCTest files under ios/BcscCoreTests/, compiled with @testable import BcscCoreTestable.
SPM_BUILD flag: A Swift compiler define set in Package.swift via swiftSettings: [.define("SPM_BUILD")]. The file ios/SPMCompat.swift uses #if SPM_BUILD to provide lightweight stubs (e.g., SecureRandom, Data.arrayOfBytes()) for types from JWE files that aren't included in the SPM target.
- Scheme name: xcodebuild uses
BcscCoreTests-Package (SPM auto-appends -Package).
Adding a new iOS source file to the test target
If the code under test references types in a file not yet in Package.swift, add it to the sources: array in the BcscCoreTestable target. If that file depends on types from excluded files (e.g., JWE/crypto), add stubs to SPMCompat.swift behind #if SPM_BUILD.
Adding a new iOS test file
- Create a new
.swift file in ios/BcscCoreTests/.
- Import frameworks and the testable module:
import LocalAuthentication
import XCTest
@testable import BcscCoreTestable
- Write
XCTestCase subclasses. Follow the naming convention {ClassUnderTest}Tests.
- Run with
yarn test:ios or directly:
xcodebuild test -scheme BcscCoreTests-Package \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-skipPackagePluginValidation
Android — JUnit 4 + MockK
Tests live under android/src/test/java/com/bcsccore/ mirroring the main source package structure.
Dependencies (in android/build.gradle)
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.test:core:1.5.0'
Adding a new Android test file
- Create a Kotlin file in
android/src/test/java/com/bcsccore/{subpackage}/, matching the source package.
- Write standard JUnit 4 test classes with
@Test methods.
- Use
io.mockk.mockk<T>() and every { ... } returns ... for mocking.
- Run with
yarn test:android or directly:
cd android && ./gradlew test
Running all tests
yarn test
yarn test:ios
yarn test:android
Mocking Strategy
iOS — Protocol Conformance
The codebase defines protocols for all service boundaries. Create mock classes that conform to the protocol and expose controllable properties:
final class MockPINService: PINServiceProtocol {
var verifyResult: String?
var setPINResult: String = "mock-hash"
var deletePINCalled = false
func verifyPINAndGetHash(issuer _: String, accountID _: String, pin _: String) -> String? {
return verifyResult
}
func setPIN(issuer _: String, accountID _: String, pin _: String, isAutoGenerated _: Bool) throws -> String {
return setPINResult
}
func deletePIN(issuer _: String, accountID _: String) throws {
deletePINCalled = true
}
}
Pattern: Use var resultProperty for controlling return values and var methodCalled = false booleans for verifying side effects.
Available iOS protocols for mocking
| Protocol | File | Purpose |
|---|
PINServiceProtocol | PINServiceProtocol.swift | PIN set/verify/delete/hash |
PINKeychainServiceProtocol | PINKeychainService.swift | Keychain secret storage |
PINCryptoPolicyProtocol | PINCryptoPolicy.swift | Salt/key/iteration config |
CommonCryptoWrapperProtocol | CommonCryptoWrapper.swift | PBKDF2 key derivation |
LAContextProtocol | LAContext+Extensions.swift | Biometric evaluation |
DeviceAuthServiceProtocol | DeviceAuthService.swift | Device authentication |
TokenStorageServiceProtocol | TokenService.swift | Token keychain storage |
KeyPairManagerProtocol | KeyPairManager.swift | Cryptographic key pairs |
Android — MockK
Use MockK's mockk<T>() for mocking classes and interfaces:
val mockPinService = mockk<PinService>()
every { mockPinService.validatePIN(any(), "1234") } returns true
every { mockPinService.hasPIN(any()) } returns true
val account = Account(id = "test", issuer = "https://idsit.gov.bc.ca", clientID = "client")
val result = account.verifyPIN("1234", mockPinService)
assertTrue(result.success)
For data classes (e.g., NativeAccount), test serialization round-trips with Gson directly — no mocking needed.
Test Patterns
Time-dependent tests (eg. PIN penalty logic)
The penalty system locks users out after failed PIN attempts. Both platforms accept an injectable "current time" parameter to make tests deterministic.
iOS: Pass verifyDate: parameter:
let start = Date()
for i in 0..<5 {
_ = account.verifyPIN("wrong", pinService: mock, verifyDate: start.addingTimeInterval(Double(i)))
}
let afterPenalty = start.addingTimeInterval(65)
Android: Pass timestamp to verifyPIN and isServingPenalty:
val now = System.currentTimeMillis()
account.verifyPIN("wrong", mockPinService, now)
val penalty = account.isServingPenalty(now)
Important: When testing escalating penalties, you must advance time past each penalty tier before triggering the next. Calling verifyPIN while a penalty is active will return "locked" without incrementing the attempt counter.
Penalty tiers: 5 attempts → 1 min, 10 → 10 min, 15 → 1 hour, 20 → 1 day.
NSCoding / Serialization round-trips
iOS — Use NSKeyedArchiver / NSKeyedUnarchiver:
let data = try NSKeyedArchiver.archivedData(withRootObject: original, requiringSecureCoding: true)
let decoded = try NSKeyedUnarchiver.unarchivedObject(ofClass: Account.self, from: data)
XCTAssertEqual(decoded?.id, original.id)
Android — Use Gson:
val json = gson.toJson(original)
val restored = gson.fromJson(json, NativeAccount::class.java)
assertEquals(original.uuid, restored.uuid)
Test naming conventions
iOS: test{Behavior} in camelCase — e.g., testVerifyPINReturnsSuccessOnCorrectPIN
Android: Backtick-quoted descriptive names — e.g., `verifyPIN returns success when PIN is correct`
Helper factories
Android — Create a createAccount() helper with defaults to reduce boilerplate:
private fun createAccount(
id: String = "test-uuid",
issuer: String = "https://idsit.gov.bc.ca",
clientID: String = "test-client-id",
securityMethod: AccountSecurityMethod = AccountSecurityMethod.PIN_NO_DEVICE_AUTH,
): Account = Account(id = id, issuer = issuer, clientID = clientID, securityMethod = securityMethod)
Checklist for adding tests