diff --git a/.github/workflows/validate-code-snippets.yml b/.github/workflows/validate-code-snippets.yml index 634df49b..3645e1f0 100644 --- a/.github/workflows/validate-code-snippets.yml +++ b/.github/workflows/validate-code-snippets.yml @@ -25,6 +25,8 @@ jobs: - name: Set up Kotlin uses: fwilhe2/setup-kotlin@v1 + with: + version: 2.4.0 - name: Set up Python uses: actions/setup-python@v5 diff --git a/docs/sdks/android/unit-testing.mdx b/docs/sdks/android/unit-testing.mdx new file mode 100644 index 00000000..0f5830a5 --- /dev/null +++ b/docs/sdks/android/unit-testing.mdx @@ -0,0 +1,660 @@ +--- +title: Unit Testing +sidebar_label: Unit Testing +description: "Unit-test your scanner listener and callback logic on Android without a camera — by isolating the Scandit Data Capture SDK behind your own abstraction, or mocking it with MockK or Mockito." +toc_max_heading_level: 3 +framework: android +keywords: + - android + - testing + - unit test + - mock + - mockk + - mockito + - listener +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Unit Testing + +The logic inside your Scandit Data Capture SDK listener callbacks — `onBarcodeScanned`, +`onSessionUpdated`, `onIdCaptured`, and so on — can be unit-tested without a camera, an emulator, +or a license key. The capture modes, sessions, and result objects the SDK passes to your listeners +are produced internally and can't be constructed in a test, so there are two approaches, which you +can use on their own or combine: + +- **Isolate the SDK behind your own abstraction** — keep your application logic independent of the + SDK and test that logic directly, with no SDK objects. +- **Mock the SDK's types** — stand in for the mode, session, and result objects, then drive your + listener with controlled values. + +Choose based on what you want to verify: your own logic in isolation, or your code's behaviour +against the SDK's own types. + +## Isolate the SDK Behind Your Own Abstraction + +The SDK is confined to a thin adapter, and your application logic depends only on an interface you +own — it never references an SDK type, so you can test it with plain values and no mocking. Define +the interface and your logic, keep the SDK inside the adapter, and test the logic directly: + +```kotlin +# import com.scandit.datacapture.barcode.capture.BarcodeCapture +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureListener +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSession +# import com.scandit.datacapture.barcode.data.Symbology +# import com.scandit.datacapture.core.data.FrameData +# import kotlin.test.assertEquals +# import org.junit.Test +// The app-facing abstraction — only what your logic needs. Symbology is a plain enum, so it's +// safe to use here and in tests. +interface BarcodeScanReceiver { + fun onScan(data: String, symbology: Symbology) +} + +// Your testable application logic. It never references an SDK type. +class CartModel : BarcodeScanReceiver { + val scannedItems = mutableListOf() + + override fun onScan(data: String, symbology: Symbology) { + scannedItems += data + } +} + +// The only type that touches the SDK: it unwraps the result and forwards plain values. +class BarcodeCaptureAdapter(private val receiver: BarcodeScanReceiver) : BarcodeCaptureListener { + override fun onBarcodeScanned( + barcodeCapture: BarcodeCapture, + session: BarcodeCaptureSession, + data: FrameData, + ) { + val barcode = session.newlyRecognizedBarcode ?: return + val value = barcode.data ?: return + receiver.onScan(value, barcode.symbology) + } +} + +// The test calls your logic directly — no SDK objects, no mocks. +class CartModelTest { + @Test + fun `adds a scanned item to the cart`() { + val cart = CartModel() + + cart.onScan("0123456789012", Symbology.EAN13_UPCA) + + assertEquals(listOf("0123456789012"), cart.scannedItems) + } +} +``` + +Wire the adapter up where you set up the scanner (context, settings, mode, camera, view), exactly +as in the Get Started guide — the only change is that the listener is the adapter. That setup and +the adapter depend on a live capture session, so exercise them with instrumented tests rather than +unit tests. + +## What You Can and Can’t Mock + +There are two reasons to **mock** the SDK types rather than construct them in a local unit test: + +1. **Real construction calls into native code.** Even types with a public constructor (such as + `BarcodeCaptureSettings`) delegate to the SDK’s native layer. Local JVM unit tests run on the + host JVM with no native library loaded, so real construction throws `UnsatisfiedLinkError`. +2. **They are `final` with non-public constructors.** Modes, sessions, result objects, settings, + and overlays ship as `final` classes you cannot subclass or instantiate from test code. + +The exceptions are the **listener interfaces** and **`FrameData`**, which are plain interfaces — +implement or mock them freely. + +| Type group | Examples | In a local unit test | +|---|---|---| +| Listeners | `BarcodeCaptureListener`, `IdCaptureListener`, `SparkScanListener`, … | Implement directly, or mock | +| `FrameData` | `FrameData` | Mock | +| Modes | `BarcodeCapture`, `BarcodeBatch`, `IdCapture`, `SparkScan`, `BarcodePick` | Mock | +| Sessions | `BarcodeCaptureSession`, `BarcodeBatchSession`, … | Mock; stub the accessors you read | +| Results | `Barcode`, `TrackedBarcode`, `CapturedId` | Mock; stub the properties you read | +| Settings | `BarcodeCaptureSettings`, `IdCaptureSettings`, … | Mock | +| Views / overlays | `SparkScanView`, `BarcodeCaptureOverlay`, … | Test on a device (see [Instrumented Tests](#instrumented-tests-on-a-device)) | + +Nested result objects — for example the documents and zones reached from a `CapturedId` — are also +produced by the SDK; mock them the same way. + +Because these classes are `final`, you need a mocking library that can mock final types: **MockK** +(idiomatic for Kotlin) or **Mockito** with the inline mock-maker (the default since Mockito 5). +The plain Mockito subclass mock-maker cannot mock final classes. + +## Add a Mocking Library + + + + +```kotlin title="build.gradle.kts" +dependencies { + testImplementation("io.mockk:mockk:1.13.13") + testImplementation("junit:junit:4.13.2") + testImplementation(kotlin("test")) // kotlin.test assertions +} +``` + + + + +```kotlin title="build.gradle.kts" +dependencies { + testImplementation("org.mockito:mockito-core:5.14.2") // inline mock-maker is the default + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("junit:junit:4.13.2") + testImplementation(kotlin("test")) // kotlin.test assertions +} +``` + + + + +Place these tests under `src/test/...` and run them with `./gradlew testDebugUnitTest` — they run +on the host JVM, no device required. + +## Example: Barcode Capture + +The `onBarcodeScanned` callback receives the mode, the session, and frame data. Your code reads +`session.newlyRecognizedBarcode`. + + + + +```kotlin +# import com.scandit.datacapture.barcode.capture.BarcodeCapture +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureListener +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSession +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.barcode.data.Symbology +# import com.scandit.datacapture.core.data.FrameData +# import kotlin.test.assertEquals +# import io.mockk.every +# import io.mockk.mockk +# import org.junit.Test +class BarcodeCaptureListenerTest { + @Test + fun `forwards the scanned barcode data`() { + val barcode = mockk { + every { data } returns "0123456789" + every { symbology } returns Symbology.CODE128 + } + val session = mockk { + every { newlyRecognizedBarcode } returns barcode + } + + val scanned = mutableListOf() + val listener = object : BarcodeCaptureListener { + override fun onBarcodeScanned( + barcodeCapture: BarcodeCapture, + session: BarcodeCaptureSession, + data: FrameData, + ) { + session.newlyRecognizedBarcode?.data?.let { scanned += it } + } + } + + listener.onBarcodeScanned(mockk(), session, mockk()) + + assertEquals(listOf("0123456789"), scanned) + } +} +``` + + + + +```kotlin +# import com.scandit.datacapture.barcode.capture.BarcodeCapture +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureListener +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSession +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.barcode.data.Symbology +# import com.scandit.datacapture.core.data.FrameData +# import kotlin.test.assertEquals +# import org.junit.Test +# import org.mockito.kotlin.doReturn +# import org.mockito.kotlin.mock +class BarcodeCaptureListenerTest { + @Test + fun `forwards the scanned barcode data`() { + val barcode = mock { + on { data } doReturn "0123456789" + on { symbology } doReturn Symbology.CODE128 + } + val session = mock { on { newlyRecognizedBarcode } doReturn barcode } + + val scanned = mutableListOf() + val listener = object : BarcodeCaptureListener { + override fun onBarcodeScanned( + barcodeCapture: BarcodeCapture, + session: BarcodeCaptureSession, + data: FrameData, + ) { + session.newlyRecognizedBarcode?.data?.let { scanned += it } + } + } + + listener.onBarcodeScanned(mock(), session, mock()) + + assertEquals(listOf("0123456789"), scanned) + } +} +``` + + + + +## The Other Scan Modes + +Every mode follows the same recipe: mock the session/result the callback receives, invoke the +listener, and assert. The callback signatures and the key objects to stub are: + +| Mode | Callback | Mock and stub | +|---|---|---| +| MatrixScan (`BarcodeBatch`) | `onSessionUpdated(mode, session, data)` | `BarcodeBatchSession.addedTrackedBarcodes` → `TrackedBarcode.barcode` | +| ID Capture | `onIdCaptured(mode, id)` / `onIdRejected(mode, id, reason)` | `CapturedId` properties; `RejectionReason` | +| SparkScan | `onBarcodeScanned(mode, session, data)` (`data` is nullable) | `SparkScanSession.newlyRecognizedBarcode` | +| BarcodePick | `onSessionUpdated`, `onPick(itemData, callback)` | `BarcodePickSession`; `BarcodePickActionCallback` | + +### MatrixScan (BarcodeBatch) + + + + +```kotlin +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatch +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatchListener +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatchSession +# import com.scandit.datacapture.barcode.batch.data.TrackedBarcode +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.core.data.FrameData +# import io.mockk.every +# import io.mockk.mockk +# import kotlin.test.assertEquals +val code = mockk { every { data } returns "SKU-42" } +val tracked = mockk { every { barcode } returns code } +val session = mockk { every { addedTrackedBarcodes } returns listOf(tracked) } + +val seen = mutableListOf() +val listener = object : BarcodeBatchListener { + override fun onSessionUpdated(mode: BarcodeBatch, session: BarcodeBatchSession, data: FrameData) { + session.addedTrackedBarcodes.forEach { tb -> tb.barcode.data?.let { seen += it } } + } +} + +listener.onSessionUpdated(mockk(), session, mockk()) + +assertEquals(listOf("SKU-42"), seen) +``` + + + + +```kotlin +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatch +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatchListener +# import com.scandit.datacapture.barcode.batch.capture.BarcodeBatchSession +# import com.scandit.datacapture.barcode.batch.data.TrackedBarcode +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.core.data.FrameData +# import kotlin.test.assertEquals +# import org.mockito.kotlin.doReturn +# import org.mockito.kotlin.mock +val code = mock { on { data } doReturn "SKU-42" } +val tracked = mock { on { barcode } doReturn code } +val session = mock { on { addedTrackedBarcodes } doReturn listOf(tracked) } + +val seen = mutableListOf() +val listener = object : BarcodeBatchListener { + override fun onSessionUpdated(mode: BarcodeBatch, session: BarcodeBatchSession, data: FrameData) { + session.addedTrackedBarcodes.forEach { tb -> tb.barcode.data?.let { seen += it } } + } +} + +listener.onSessionUpdated(mock(), session, mock()) + +assertEquals(listOf("SKU-42"), seen) +``` + + + + +### ID Capture + + + + +```kotlin +# import com.scandit.datacapture.id.capture.IdCapture +# import com.scandit.datacapture.id.capture.IdCaptureListener +# import com.scandit.datacapture.id.data.CapturedId +# import com.scandit.datacapture.id.data.RejectionReason +# import io.mockk.every +# import io.mockk.mockk +val accepted = mutableListOf() +val rejected = mutableListOf() +val listener = object : IdCaptureListener { + override fun onIdCaptured(mode: IdCapture, id: CapturedId) { + accepted += id.fullName.orEmpty() + } + override fun onIdRejected(mode: IdCapture, id: CapturedId?, reason: RejectionReason) { + rejected += reason + } +} + +val id = mockk { every { fullName } returns "Jane Doe" } +listener.onIdCaptured(mockk(), id) + +listener.onIdRejected(mockk(), id = null, reason = RejectionReason.DOCUMENT_EXPIRED) +``` + + + + +```kotlin +# import com.scandit.datacapture.id.capture.IdCapture +# import com.scandit.datacapture.id.capture.IdCaptureListener +# import com.scandit.datacapture.id.data.CapturedId +# import com.scandit.datacapture.id.data.RejectionReason +# import org.mockito.kotlin.doReturn +# import org.mockito.kotlin.mock +val accepted = mutableListOf() +val rejected = mutableListOf() +val listener = object : IdCaptureListener { + override fun onIdCaptured(mode: IdCapture, id: CapturedId) { + accepted += id.fullName.orEmpty() + } + override fun onIdRejected(mode: IdCapture, id: CapturedId?, reason: RejectionReason) { + rejected += reason + } +} + +val id = mock { on { fullName } doReturn "Jane Doe" } +listener.onIdCaptured(mock(), id) + +listener.onIdRejected(mock(), id = null, reason = RejectionReason.DOCUMENT_EXPIRED) +``` + + + + +### SparkScan + +`data` is nullable in the SparkScan callback, so the listener takes `FrameData?`. + + + + +```kotlin +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.barcode.spark.capture.SparkScan +# import com.scandit.datacapture.barcode.spark.capture.SparkScanListener +# import com.scandit.datacapture.barcode.spark.capture.SparkScanSession +# import com.scandit.datacapture.core.data.FrameData +# import io.mockk.every +# import io.mockk.mockk +# import kotlin.test.assertEquals +val code = mockk { every { data } returns "EAN-13-VALUE" } +val session = mockk { every { newlyRecognizedBarcode } returns code } + +val seen = mutableListOf() +val listener = object : SparkScanListener { + override fun onBarcodeScanned(sparkScan: SparkScan, session: SparkScanSession, data: FrameData?) { + session.newlyRecognizedBarcode?.data?.let { seen += it } + } +} + +listener.onBarcodeScanned(mockk(), session, data = null) + +assertEquals(listOf("EAN-13-VALUE"), seen) +``` + + + + +```kotlin +# import com.scandit.datacapture.barcode.data.Barcode +# import com.scandit.datacapture.barcode.spark.capture.SparkScan +# import com.scandit.datacapture.barcode.spark.capture.SparkScanListener +# import com.scandit.datacapture.barcode.spark.capture.SparkScanSession +# import com.scandit.datacapture.core.data.FrameData +# import kotlin.test.assertEquals +# import org.mockito.kotlin.doReturn +# import org.mockito.kotlin.mock +val code = mock { on { data } doReturn "EAN-13-VALUE" } +val session = mock { on { newlyRecognizedBarcode } doReturn code } + +val seen = mutableListOf() +val listener = object : SparkScanListener { + override fun onBarcodeScanned(sparkScan: SparkScan, session: SparkScanSession, data: FrameData?) { + session.newlyRecognizedBarcode?.data?.let { seen += it } + } +} + +listener.onBarcodeScanned(mock(), session, data = null) + +assertEquals(listOf("EAN-13-VALUE"), seen) +``` + + + + +### BarcodePick + +BarcodePick adds an asynchronous action callback: the SDK asks your listener to pick or unpick an +item and hands you a `BarcodePickActionCallback` to report the result. Mock the callback and +verify your listener completes it. + + + + +```kotlin +# import com.scandit.datacapture.barcode.pick.capture.BarcodePickActionCallback +# import com.scandit.datacapture.barcode.pick.capture.BarcodePickActionListener +import io.mockk.mockk +import io.mockk.verify + +val callback = mockk(relaxUnitFun = true) + +val listener = object : BarcodePickActionListener { + override fun onPick(itemData: String, callback: BarcodePickActionCallback) { + callback.onFinish(itemData.isNotBlank()) + } + override fun onUnpick(itemData: String, callback: BarcodePickActionCallback) { + callback.onFinish(true) + } +} + +listener.onPick("item-42", callback) + +verify { callback.onFinish(true) } +``` + + + + +```kotlin +# import com.scandit.datacapture.barcode.pick.capture.BarcodePickActionCallback +# import com.scandit.datacapture.barcode.pick.capture.BarcodePickActionListener +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +val callback = mock() + +val listener = object : BarcodePickActionListener { + override fun onPick(itemData: String, callback: BarcodePickActionCallback) { + callback.onFinish(itemData.isNotBlank()) + } + override fun onUnpick(itemData: String, callback: BarcodePickActionCallback) { + callback.onFinish(true) + } +} + +listener.onPick("item-42", callback) + +verify(callback).onFinish(true) +``` + + + + +## Instrumented Tests (On a Device) + +Some types can’t be exercised on the host JVM: native-backed objects (settings, sessions, results) +throw `UnsatisfiedLinkError` when constructed off-device, and SDK views are Android `View`s. Test +these in an [instrumented test](https://developer.android.com/training/testing/instrumented-tests) +(`src/androidTest`, run with `./gradlew connectedDebugAndroidTest`), where the device loads the +native library at startup and provides real Android components. + +To mock on a device, use `mockk-android` (for MockK) or `mockito-android` together with +`dexmaker-mockito-inline` (for Mockito, API 28+). + +:::caution +On a device, choose **one** mocking framework per instrumented test module. MockK and Mockito’s +inline mock-maker each install a JVMTI agent into the bootstrap class loader and only one can +initialize per process; having both on the same `androidTest` classpath makes the other fail. On +the host JVM they coexist without issue. +::: + +### Using Real SDK Objects + +On a device, native-backed types such as settings construct for real: + +```kotlin +# import androidx.test.ext.junit.runners.AndroidJUnit4 +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSettings +# import com.scandit.datacapture.barcode.data.Symbology +# import kotlin.test.assertTrue +# import org.junit.Test +# import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) +class SettingsInstrumentedTest { + @Test + fun realBarcodeCaptureSettings_togglesSymbologies() { + val settings = BarcodeCaptureSettings() // calls native — device only + settings.enableSymbology(Symbology.CODE128, true) + assertTrue(settings.getSymbologySettings(Symbology.CODE128).isEnabled) + } +} +``` + +### Creating and Configuring a Real View + +SDK views such as `BarcodeCountView` are Android `View`s and can't be instantiated on the host +JVM, but you can build and configure a **real** one in an instrumented test. The view needs an +Activity context (so create it on the UI thread, e.g. via `ActivityScenario`), a real +`DataCaptureContext`, and its mode: + +```kotlin +# import android.app.Activity +# import androidx.test.core.app.ActivityScenario +# import androidx.test.ext.junit.runners.AndroidJUnit4 +# import com.scandit.datacapture.barcode.count.capture.BarcodeCount +# import com.scandit.datacapture.barcode.count.capture.BarcodeCountSettings +# import com.scandit.datacapture.barcode.count.ui.view.BarcodeCountView +# import com.scandit.datacapture.barcode.count.ui.view.BarcodeCountViewStyle +# import com.scandit.datacapture.core.capture.DataCaptureContext +# import kotlin.test.assertEquals +# import org.junit.Test +# import org.junit.runner.RunWith +# class MainActivity : Activity() // your launcher activity +@RunWith(AndroidJUnit4::class) +class BarcodeCountViewInstrumentedTest { + @Test + fun togglesToolbarVisibility() { + val dataCaptureContext = DataCaptureContext.forLicenseKey("-- ENTER YOUR SCANDIT LICENSE KEY HERE --") + val barcodeCount = BarcodeCount.forDataCaptureContext(dataCaptureContext, BarcodeCountSettings()) + + var toolbarVisible: Boolean? = null + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val view = BarcodeCountView.newInstance( + activity, dataCaptureContext, barcodeCount, BarcodeCountViewStyle.ICON, + ) + view.shouldShowToolbar = false + toolbarVisible = view.shouldShowToolbar + } + } + + assertEquals(false, toolbarVisible) + } +} +``` + +This needs a valid license key, like the decode test below. + +### Verifying Real Decoding + +Mocking verifies your callback logic, but not that the SDK actually **decodes** a barcode or that +your listener is wired up correctly. To test the real pipeline without a camera, feed a known +image to the decoder with `BitmapFrameSource`, then assert on the result. + +```kotlin +# import android.graphics.BitmapFactory +# import androidx.test.platform.app.InstrumentationRegistry +# import com.scandit.datacapture.barcode.capture.BarcodeCapture +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureListener +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSession +# import com.scandit.datacapture.barcode.capture.BarcodeCaptureSettings +# import com.scandit.datacapture.barcode.data.Symbology +# import com.scandit.datacapture.core.capture.DataCaptureContext +# import com.scandit.datacapture.core.data.FrameData +# import com.scandit.datacapture.core.source.BitmapFrameSource +# import com.scandit.datacapture.core.source.FrameSourceState +# import java.util.concurrent.CountDownLatch +# import java.util.concurrent.TimeUnit +# import kotlin.test.assertEquals +# import kotlin.test.assertTrue +val dataCaptureContext = DataCaptureContext.forLicenseKey("-- ENTER YOUR SCANDIT LICENSE KEY HERE --") +val settings = BarcodeCaptureSettings().apply { enableSymbology(Symbology.QR, true) } +val barcodeCapture = BarcodeCapture.forDataCaptureContext(dataCaptureContext, settings) + +val latch = CountDownLatch(1) +var scannedData: String? = null +barcodeCapture.addListener(object : BarcodeCaptureListener { + override fun onBarcodeScanned( + barcodeCapture: BarcodeCapture, + session: BarcodeCaptureSession, + data: FrameData, + ) { + scannedData = session.newlyRecognizedBarcode?.data + latch.countDown() + } +}) + +// A bitmap containing a known barcode, loaded from your test assets +// (`src/androidTest/assets`), so use the *test* APK's context, not `targetContext`. +val testContext = InstrumentationRegistry.getInstrumentation().context +val bitmap = BitmapFactory.decodeStream(testContext.assets.open("test-qr.png")) +val frameSource = BitmapFrameSource.of(bitmap) +dataCaptureContext.setFrameSource(frameSource) +frameSource.switchToDesiredState(FrameSourceState.ON) + +assertTrue(latch.await(10, TimeUnit.SECONDS), "decoding timed out") +assertEquals("Scandit", scannedData) // "Scandit" is the text encoded in the test QR code +``` + +:::note +Decoding is **license-gated**, so this test needs a valid Scandit license key — a placeholder key +will not decode. Skip the test when no key is available so the suite still runs without one. +::: + +## Tips and Pitfalls + +- **MockK:** avoid `relaxed = true` on SDK classes whose members reach native code (for example a + mode object). A relaxed mock auto-answers every method and can trigger an `UnsatisfiedLinkError`. + Prefer a plain `mockk()` and stub only the members you read. When you only need to *call* a + `Unit`-returning method (such as a callback's `onFinish`), use the targeted `mockk(relaxUnitFun = true)` + instead — it never constructs return values, so it can't reach native code. +- **Mockito:** don’t create a mock *inside* an ongoing stubbing + (`on { x } doReturn mock { … }`) — it throws `UnfinishedStubbingException`. Build the inner mock + into a local variable first. Mockito also returns `null`/`0` for unstubbed methods, so an “empty + session” often needs no stubbing at all. +- Keep camera, `DataCaptureContext`, and `DataCaptureView` setup out of unit tests — mock the + pieces your logic actually reads. +- **Threading:** in production the SDK delivers listener callbacks on a background thread. Invoking + the listener directly in a test is synchronous, but if your listener hands work to another thread + (for example posting to the main thread), wait for it before asserting — e.g. with a + `CountDownLatch`. diff --git a/sidebars.ts b/sidebars.ts index e9383136..93c9fb30 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -412,6 +412,7 @@ const sidebars: SidebarsConfig = { label: "GitHub Samples", href: "https://github.com/Scandit/datacapture-android-samples", }, + 'sdks/android/unit-testing', ], }, { diff --git a/validation/android/test-bed/app/build.gradle.kts b/validation/android/test-bed/app/build.gradle.kts index 8ead09ad..164339a8 100644 --- a/validation/android/test-bed/app/build.gradle.kts +++ b/validation/android/test-bed/app/build.gradle.kts @@ -120,4 +120,14 @@ dependencies { compileOnly("androidx.annotation:annotation:1.9.1") compileOnly("androidx.appcompat:appcompat:1.7.1") compileOnly("androidx.lifecycle:lifecycle-livedata:2.10.0") + + // Test/mocking libraries referenced by the Unit Testing documentation snippets. + compileOnly("io.mockk:mockk:1.13.13") + compileOnly("org.mockito:mockito-core:5.14.2") + compileOnly("org.mockito.kotlin:mockito-kotlin:5.4.0") + // Match the kotlinc version pinned in CI (.github/workflows/validate-code-snippets.yml, + // setup-kotlin version: 2.4.0); a newer library than the compiler fails to resolve. + compileOnly("org.jetbrains.kotlin:kotlin-test:2.4.0") + compileOnly("junit:junit:4.13.2") + compileOnly("androidx.test.ext:junit:1.2.1") } diff --git a/validation/kotlin/plugin.py b/validation/kotlin/plugin.py index 5bc655ab..3f6e494d 100644 --- a/validation/kotlin/plugin.py +++ b/validation/kotlin/plugin.py @@ -31,6 +31,9 @@ _OBJECT_DECL = re.compile(r"^(?:(?:private|internal|public)\s+)?object\s+\w+") # Matches a companion object declaration (named or anonymous). _COMPANION_OBJECT_DECL = re.compile(r"^companion\s+object") +# Matches a top-level interface declaration (column 0). Interfaces can't be local, +# so they must be hoisted to package scope, like objects. +_INTERFACE_DECL = re.compile(r"^(?:(?:private|internal|public|fun)\s+)*interface\s+\w+") # ============================================================================= # Kotlin compiler utilities @@ -72,7 +75,7 @@ def _split_object_blocks(content: str) -> tuple[list[str], list[str], str]: if _COMPANION_OBJECT_DECL.match(line): block, i = _extract_block(lines, i) companion.append(block) - elif _OBJECT_DECL.match(line): + elif _OBJECT_DECL.match(line) or _INTERFACE_DECL.match(line): block, i = _extract_block(lines, i) top_level.append(block) else: @@ -156,7 +159,8 @@ def compile(self, snippets: list[Snippet], sdk_version: str) -> CompileResult: ] r = subprocess.run( - [kotlinc, "-cp", sdk_classpath, "-d", str(KOTLIN_CLASSES_DIR)] + kt_files, + [kotlinc, "-jvm-target", "11", "-cp", sdk_classpath, "-d", str(KOTLIN_CLASSES_DIR)] + + kt_files, capture_output=True, text=True, )