From 2cf8b23b6629749a0be6998b6f92566133172f4d Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Wed, 24 Jun 2026 17:28:38 +0200 Subject: [PATCH 1/6] validation: support test-framework snippets for Android docs Let the Kotlin snippet validator compile examples that use test/mocking libraries: - add MockK, Mockito (core + kotlin), kotlin-test, JUnit and androidx.test as compileOnly deps in the snippet test-bed - pass -jvm-target 11 to kotlinc (MockK's inline functions require it) - pin CI kotlinc to 2.4.0 so it matches the kotlin-test version Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/validate-code-snippets.yml | 2 ++ validation/android/test-bed/app/build.gradle.kts | 10 ++++++++++ validation/kotlin/plugin.py | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) 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/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..99940e8d 100644 --- a/validation/kotlin/plugin.py +++ b/validation/kotlin/plugin.py @@ -156,7 +156,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, ) From 5c8c069a99923835500ca32799e1c26b46323c6a Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Wed, 24 Jun 2026 17:28:39 +0200 Subject: [PATCH 2/6] docs(android): add Unit Testing guide Document how to unit-test scanner listener/callback logic without a camera by mocking the SDK's final, native-backed classes with MockK or Mockito (inline), covering all scan modes plus FrameData/Feedback. Includes instrumented real-object tests and a BitmapFrameSource real-decode pattern. Linked in the Android sidebar next to Agent Skills. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/sdks/android/unit-testing.mdx | 453 +++++++++++++++++++++++++++++ sidebars.ts | 1 + 2 files changed, 454 insertions(+) create mode 100644 docs/sdks/android/unit-testing.mdx diff --git a/docs/sdks/android/unit-testing.mdx b/docs/sdks/android/unit-testing.mdx new file mode 100644 index 00000000..d70da22c --- /dev/null +++ b/docs/sdks/android/unit-testing.mdx @@ -0,0 +1,453 @@ +--- +title: Unit Testing +sidebar_label: Unit Testing +description: "Unit-test your scanner listener and callback logic on Android without a camera, by mocking the Scandit Data Capture SDK 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 + +This page describes how to unit-test the logic inside your Scandit Data Capture SDK listener +callbacks, such as `onBarcodeScanned`, `onSessionUpdated`, and `onIdCaptured`, without a camera, +an emulator, or a license key. + +The SDK delivers results to your listener by calling its callback methods with mode, session, and +result objects. To test your callback logic, you do the same thing the SDK does: build those +callback arguments as mocks and call your listener directly. In production the camera drives the +SDK, which calls your listener; in a unit test you call the listener yourself with mocked +arguments. + +Your assertions target the logic in your own listener, for example that it reads the correct +barcode data, or that it routes an expired ID to the rejection path. All you need is a way to +produce realistic callback arguments. + +## 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. **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. +2. **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`. + +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 [Testing with Real Objects](#testing-with-real-objects)) | + +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. + +## Keep Your Listener Thin + +The most robust design keeps almost no logic in the listener itself: have it extract the plain +data it needs and hand it to a class you own, then unit-test *that* class with ordinary values +and no mocking at all. + +```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.core.data.FrameData +// Your own logic, free of SDK types — trivially testable. +class ScanProcessor(private val onValidScan: (String) -> Unit) { + fun handle(data: String?) { + if (!data.isNullOrBlank()) onValidScan(data) + } +} + +// A thin adapter that only unwraps the session. +class BarcodeCaptureAdapter(private val processor: ScanProcessor) : BarcodeCaptureListener { + override fun onBarcodeScanned( + barcodeCapture: BarcodeCapture, + session: BarcodeCaptureSession, + data: FrameData, + ) { + processor.handle(session.newlyRecognizedBarcode?.data) + } +} +``` + +The examples below test a listener directly by mocking the SDK callback arguments. + +## 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` | + +### 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) +``` + + + + +### 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) +``` + + + + +## Testing with Real Objects + +Some types can’t be used in a local JVM unit test and must be exercised in an +[instrumented test](https://developer.android.com/training/testing/instrumented-tests) +(`src/androidTest`, run with `./gradlew connectedDebugAndroidTest`). On a device the SDK loads its +native library at startup, so real construction works: + +```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) + } +} +``` + +Use instrumented tests for: + +- Views and overlays (`SparkScanView`, `BarcodePickView`, the `*Overlay` classes) — they are + Android `View`s or have native-backed initializers and cannot load on the host JVM. +- Any case where you want a real, fully-configured SDK object rather than a mock. + +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. +::: + +## Testing That Scanning Actually Works + +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` in an instrumented test, then assert on the result. + +```kotlin +# import android.graphics.BitmapFactory +# 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 +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, e.g. loaded from your test assets. +val bitmap = BitmapFactory.decodeStream(context.assets.open("test-qr.png")) +val frameSource = BitmapFrameSource.of(bitmap) +dataCaptureContext.setFrameSource(frameSource) +frameSource.switchToDesiredState(FrameSourceState.ON) + +latch.await(10, TimeUnit.SECONDS) +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. 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', ], }, { From bb7f0baa40b69cf74193307ba2c539ab73f7e065 Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Thu, 25 Jun 2026 10:40:26 +0200 Subject: [PATCH 3/6] docs(android): reorganize and refine the Unit Testing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - group the on-device sections under a single "Instrumented Tests" heading (real objects, real view, real decoding) with the setup and one-framework caution up front - add a real BarcodeCountView example (built via ActivityScenario) - lead with the capability instead of "This page describes…" - order the mock reasons native-construction first - trim the license note Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/sdks/android/unit-testing.mdx | 94 ++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/sdks/android/unit-testing.mdx b/docs/sdks/android/unit-testing.mdx index d70da22c..f62c6c83 100644 --- a/docs/sdks/android/unit-testing.mdx +++ b/docs/sdks/android/unit-testing.mdx @@ -19,9 +19,9 @@ import TabItem from '@theme/TabItem'; # Unit Testing -This page describes how to unit-test the logic inside your Scandit Data Capture SDK listener -callbacks, such as `onBarcodeScanned`, `onSessionUpdated`, and `onIdCaptured`, without a camera, -an emulator, or a license key. +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 SDK delivers results to your listener by calling its callback methods with mode, session, and result objects. To test your callback logic, you do the same thing the SDK does: build those @@ -37,11 +37,11 @@ produce realistic callback arguments. There are two reasons to **mock** the SDK types rather than construct them in a local unit test: -1. **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. -2. **Real construction calls into native code.** Even types with a public constructor (such as +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. @@ -345,12 +345,27 @@ verify(callback).onFinish(true) -## Testing with Real Objects +## 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 -Some types can’t be used in a local JVM unit test and must be exercised in an -[instrumented test](https://developer.android.com/training/testing/instrumented-tests) -(`src/androidTest`, run with `./gradlew connectedDebugAndroidTest`). On a device the SDK loads its -native library at startup, so real construction works: +On a device, native-backed types such as settings construct for real: ```kotlin # import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -370,27 +385,56 @@ class SettingsInstrumentedTest { } ``` -Use instrumented tests for: +### Creating and Configuring a Real View -- Views and overlays (`SparkScanView`, `BarcodePickView`, the `*Overlay` classes) — they are - Android `View`s or have native-backed initializers and cannot load on the host JVM. -- Any case where you want a real, fully-configured SDK object rather than a mock. +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: -To mock on a device, use `mockk-android` (for MockK) or `mockito-android` together with -`dexmaker-mockito-inline` (for Mockito, API 28+). +```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 + } + } -:::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. -::: + assertEquals(false, toolbarVisible) + } +} +``` + +This needs a valid license key, like the decode test below. -## Testing That Scanning Actually Works +### 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` in an instrumented test, then assert on the result. +image to the decoder with `BitmapFrameSource`, then assert on the result. ```kotlin # import android.graphics.BitmapFactory From deffd426b3636f4a0c299b72c5f72543d624e1b8 Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Thu, 25 Jun 2026 15:17:43 +0200 Subject: [PATCH 4/6] validation: hoist top-level interface declarations Interfaces can't be declared locally in Kotlin, so a snippet that defines its own interface (e.g. an app-facing abstraction) failed to compile when wrapped in the generated validate() method. Hoist top-level `interface` declarations to package scope, the same way standalone `object`s are handled. Co-Authored-By: Claude Opus 4.8 (1M context) --- validation/kotlin/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/validation/kotlin/plugin.py b/validation/kotlin/plugin.py index 99940e8d..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: From cdb05e007027b23e93c0d9dcf5395611e3d9cc38 Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Thu, 25 Jun 2026 15:17:44 +0200 Subject: [PATCH 5/6] docs(android): add the abstraction approach and remaining mode examples - present two approaches up front (isolate behind your own abstraction, or mock the SDK's types), matching the iOS guide - add an "Isolate the SDK Behind Your Own Abstraction" section: a thin adapter over your own interface, tested with no SDK objects - add MockK + Mockito examples for MatrixScan (BarcodeBatch) and SparkScan so every mode the ticket covers has a snippet - note that nested SDK-produced results (e.g. a CapturedId's documents) must be mocked too, and add a threading tip for background-thread callbacks - reorganize the on-device sections under one "Instrumented Tests" heading Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/sdks/android/unit-testing.mdx | 245 ++++++++++++++++++++++++----- 1 file changed, 202 insertions(+), 43 deletions(-) diff --git a/docs/sdks/android/unit-testing.mdx b/docs/sdks/android/unit-testing.mdx index f62c6c83..2233fef8 100644 --- a/docs/sdks/android/unit-testing.mdx +++ b/docs/sdks/android/unit-testing.mdx @@ -1,7 +1,7 @@ --- title: Unit Testing sidebar_label: Unit Testing -description: "Unit-test your scanner listener and callback logic on Android without a camera, by mocking the Scandit Data Capture SDK with MockK or Mockito." +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: @@ -21,17 +21,77 @@ import TabItem from '@theme/TabItem'; 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. +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: -The SDK delivers results to your listener by calling its callback methods with mode, session, and -result objects. To test your callback logic, you do the same thing the SDK does: build those -callback arguments as mocks and call your listener directly. In production the camera drives the -SDK, which calls your listener; in a unit test you call the listener yourself with mocked -arguments. +- **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. -Your assertions target the logic in your own listener, for example that it reads the correct -barcode data, or that it routes an expired ID to the rejection path. All you need is a way to -produce realistic callback arguments. +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 @@ -54,7 +114,10 @@ implement or mock them freely. | 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 [Testing with Real Objects](#testing-with-real-objects)) | +| 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). @@ -91,38 +154,6 @@ dependencies { Place these tests under `src/test/...` and run them with `./gradlew testDebugUnitTest` — they run on the host JVM, no device required. -## Keep Your Listener Thin - -The most robust design keeps almost no logic in the listener itself: have it extract the plain -data it needs and hand it to a class you own, then unit-test *that* class with ordinary values -and no mocking at all. - -```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.core.data.FrameData -// Your own logic, free of SDK types — trivially testable. -class ScanProcessor(private val onValidScan: (String) -> Unit) { - fun handle(data: String?) { - if (!data.isNullOrBlank()) onValidScan(data) - } -} - -// A thin adapter that only unwraps the session. -class BarcodeCaptureAdapter(private val processor: ScanProcessor) : BarcodeCaptureListener { - override fun onBarcodeScanned( - barcodeCapture: BarcodeCapture, - session: BarcodeCaptureSession, - data: FrameData, - ) { - processor.handle(session.newlyRecognizedBarcode?.data) - } -} -``` - -The examples below test a listener directly by mocking the SDK callback arguments. - ## Example: Barcode Capture The `onBarcodeScanned` callback receives the mode, the session, and frame data. Your code reads @@ -227,6 +258,69 @@ listener, and assert. The callback signatures and the key objects to stub are: | 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 @@ -286,6 +380,67 @@ listener.onIdRejected(mock(), id = null, reason = RejectionReason.DOCUMENT_EXPIR +### 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 @@ -495,3 +650,7 @@ will not decode. Skip the test when no key is available so the suite still runs 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`. From 540e4ce7c7075c76a23657f85a3c0eae304112b1 Mon Sep 17 00:00:00 2001 From: Benoit Vermont Date: Thu, 30 Jul 2026 09:48:03 +0200 Subject: [PATCH 6/6] docs(android): fix reader-facing issues in the decode test snippet The "Verifying Real Decoding" snippet relied on an ambient `context` supplied by the snippet validator, which a reader pasting into a real androidTest would not have. Grab the test APK's context explicitly, since the fixture image lives in src/androidTest/assets. Also assert on the `latch.await` result, so a decode that never fires (e.g. a missing license key) fails on the timeout rather than on a confusing null comparison. Co-Authored-By: Claude Opus 5 (1M context) --- docs/sdks/android/unit-testing.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/sdks/android/unit-testing.mdx b/docs/sdks/android/unit-testing.mdx index 2233fef8..0f5830a5 100644 --- a/docs/sdks/android/unit-testing.mdx +++ b/docs/sdks/android/unit-testing.mdx @@ -593,6 +593,7 @@ 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 @@ -605,6 +606,7 @@ image to the decoder with `BitmapFrameSource`, then assert on the result. # 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) @@ -622,13 +624,15 @@ barcodeCapture.addListener(object : BarcodeCaptureListener { } }) -// A bitmap containing a known barcode, e.g. loaded from your test assets. -val bitmap = BitmapFactory.decodeStream(context.assets.open("test-qr.png")) +// 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) -latch.await(10, TimeUnit.SECONDS) +assertTrue(latch.await(10, TimeUnit.SECONDS), "decoding timed out") assertEquals("Scandit", scannedData) // "Scandit" is the text encoded in the test QR code ```