Skip to main content
Not sure which Scandit product fits your use case?

Install our data-capture-sdk skill so your coding agent can answer questions about Scandit products and recommend the right one for your use case, directly from your editor. More info →

Run this in a terminal in your project directory, then follow the instructions to select your coding agent.

npx skills add https://github.com/scandit/skills --skill data-capture-sdk

Your coding agent loads the skill automatically based on your prompt; to invoke it explicitly, call /data-capture-sdk followed by your task.

Already installed? Update steps differ by agent — see how to keep your skills up to date.

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:

// 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<String>()

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 groupExamplesIn a local unit test
ListenersBarcodeCaptureListener, IdCaptureListener, SparkScanListener, …Implement directly, or mock
FrameDataFrameDataMock
ModesBarcodeCapture, BarcodeBatch, IdCapture, SparkScan, BarcodePickMock
SessionsBarcodeCaptureSession, BarcodeBatchSession, …Mock; stub the accessors you read
ResultsBarcode, TrackedBarcode, CapturedIdMock; stub the properties you read
SettingsBarcodeCaptureSettings, IdCaptureSettings, …Mock
Views / overlaysSparkScanView, BarcodeCaptureOverlay, …Test on a device (see Instrumented Tests)

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

build.gradle.kts
dependencies {
testImplementation("io.mockk:mockk:1.13.13")
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.

class BarcodeCaptureListenerTest {
@Test
fun `forwards the scanned barcode data`() {
val barcode = mockk<Barcode> {
every { data } returns "0123456789"
every { symbology } returns Symbology.CODE128
}
val session = mockk<BarcodeCaptureSession> {
every { newlyRecognizedBarcode } returns barcode
}

val scanned = mutableListOf<String>()
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)
}
}

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:

ModeCallbackMock and stub
MatrixScan (BarcodeBatch)onSessionUpdated(mode, session, data)BarcodeBatchSession.addedTrackedBarcodesTrackedBarcode.barcode
ID CaptureonIdCaptured(mode, id) / onIdRejected(mode, id, reason)CapturedId properties; RejectionReason
SparkScanonBarcodeScanned(mode, session, data) (data is nullable)SparkScanSession.newlyRecognizedBarcode
BarcodePickonSessionUpdated, onPick(itemData, callback)BarcodePickSession; BarcodePickActionCallback

MatrixScan (BarcodeBatch)

val code = mockk<Barcode> { every { data } returns "SKU-42" }
val tracked = mockk<TrackedBarcode> { every { barcode } returns code }
val session = mockk<BarcodeBatchSession> { every { addedTrackedBarcodes } returns listOf(tracked) }

val seen = mutableListOf<String>()
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)

ID Capture

val accepted = mutableListOf<String>()
val rejected = mutableListOf<RejectionReason>()
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<CapturedId> { every { fullName } returns "Jane Doe" }
listener.onIdCaptured(mockk(), id)

listener.onIdRejected(mockk(), id = null, reason = RejectionReason.DOCUMENT_EXPIRED)

SparkScan

data is nullable in the SparkScan callback, so the listener takes FrameData?.

val code = mockk<Barcode> { every { data } returns "EAN-13-VALUE" }
val session = mockk<SparkScanSession> { every { newlyRecognizedBarcode } returns code }

val seen = mutableListOf<String>()
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)

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.

import io.mockk.mockk
import io.mockk.verify

val callback = mockk<BarcodePickActionCallback>(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) }

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 Views. Test these in an instrumented test (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:

@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 Views 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:

@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.

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.