What is unit testing?

Learn what unit testing is, how unit tests work in XCTest, Swift Testing and JUnit, and how to run them fast in mobile CI.

Unit testing is the practice of verifying the smallest testable pieces of an app, usually a single function, method or class, in isolation from the rest of the code. Each unit test feeds known inputs into that piece of code and checks the state is correct, so a failure points straight at the code that broke.

What is unit testing?

Unit testing is the fastest, cheapest layer of automated testing. A unit test checks one piece of logic, such as validating an email address or a view model's state change, with every outside dependency replaced by something predictable. Because nothing else is involved, a mobile team can run thousands of unit tests in seconds on every commit, without a real device. On Android, local unit tests don’t need an emulator either.

A unit test answers one narrow question: given this input, does this specific small part of the code do what we expect it to do? It doesn't launch the app, tap through screens, call a real server or write to a real database. Those jobs belong to integration tests and UI tests, which sit higher up in a mobile app testing strategy.

On iOS, unit tests are written with XCTest or Apple's newer Swift Testing framework. On Android, they're usually JUnit tests written in Kotlin, living in the module's src/test folder.

Software Engineering at Google defines unit tests as "tests of relatively narrow scope, such as of a single class or method."

Because the scope is narrow, a failing unit test is easy to act on. When one fails, the assertion tells you where and why: the line that failed, the value it expected and the value it got. If the test is well scoped, that’s usually enough to fix the bug without opening a debugger or reproducing it on a device.

How does unit testing work?

A unit test isolates one piece of code, gives it controlled inputs, runs it, and checks the result. Each test usually has three parts: arrange, act, assert. A test runner then compiles and executes the tests and reports which ones passed or failed. On mobile, where that runner executes matters as much as how the test is written.

The arrange, act, assert pattern

Most unit tests follow the same shape:

  1. Arrange: create the object under test and any fakes it depends on.
  2. Act: call the method you're testing.
  3. Assert: check the result matches what you expect.

Here's a unit test written with Swift Testing, which ships with Xcode 16 and later:

import Testing
@testable import SignUp

struct EmailValidatorTests {

    // Arrange, act, assert in one small test
    @Test func acceptsValidAddress() {
        let validator = EmailValidator()                   // Arrange
        let result = validator.isValid("[email protected]") // Act
        #expect(result)                                    // Assert
    }

    // One test function, run once per argument
    @Test(arguments: ["", "name@", "@example.com", "name example.com"])
    func rejectsInvalidAddress(_ email: String) {
        let validator = EmailValidator()
        #expect(!validator.isValid(email))
    }
}

And the same check on Android, as a local JUnit 4 test in app/src/test/java:

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class EmailValidatorTest {

    // Arrange: JUnit creates a new instance of this class for every test,
    // so each test gets a fresh validator
    private val validator = EmailValidator()

    @Test
    fun acceptsValidAddress() {
        // Act
        val result = validator.isValid("[email protected]")

        // Assert
        assertTrue(result)
    }

    @Test
    fun rejectsAddressWithoutDomain() {
        assertFalse(validator.isValid("name@"))
    }
}

You'd run it from the command line with ./gradlew testDebugUnitTest.

Where mobile unit tests run

Android's testing fundamentals split tests by where they run. Local tests run on your machine's JVM and are "usually small and fast, isolating the subject under test from the rest of the app." Instrumented tests run on a device or emulator and are slower.

iOS is different. XCTest and Swift Testing unit tests for an app target usually run inside the iOS Simulator, often hosted by your app. That makes the simulator boot part of your test time, which is why test configuration matters more on iOS than on the web.

Flow chart showing how a unit test runs in mobile CI, from code push to Android or iOS test runner to pass or fail
Flow chart showing how a unit test runs in mobile CI, from code push to Android or iOS test runner to pass or fail

Why unit testing matters for mobile development

Unit tests are the only tests fast enough to run on every commit in a mobile workflow. UI and device tests need a virtual or physical device to run on, and they're slower and harder to keep stable. A solid base of unit tests catches logic bugs in seconds, so the expensive tests can focus on what only they can check.

The Google Testing Blog post Just Say No to More End-to-End Tests suggested a starting split of 70% unit tests, 20% integration tests and 10% end-to-end tests. That’s the shape of the mobile testing pyramid: a wide base of unit tests, with fewer and slower tests above it. Unit tests are also the base that continuous testing is built on. Its case for that split is that unit tests are fast and reliable, and that a failed test points straight at the bug.

On mobile, the cost of a missed bug is higher than on the web. A release that ships a broken feature can't be rolled back in minutes. You can pause a phased or staged rollout, but the fix still has to go through App Store or Google Play review, and some users won't update for weeks.

Ship React Native fixes without waiting for store review

If your app uses React Native, Bitrise CodePush sends JavaScript and asset fixes straight to your users' devices as an over-the-air (OTA) update. You can roll the fix out in stages and roll it back if something goes wrong. Changes to native code still go through the App Store and Google Play.

Unit tests also protect your feedback loop. In continuous integration, every pull request runs the test suite before it merges. If that suite is mostly UI tests, each run takes long enough that developers stop waiting for it.

Flaky tests are a growing problem as well. Bitrise's Mobile Insights report, based on more than 10 million builds, found the share of teams experiencing any test flakiness grew from 10% to 26% between 2022 and mid-2025. Well-isolated unit tests are the least likely tests in your suite to be flaky, because they don't depend on network timing or device state.

Unit testing best practices

Good unit tests are fast and deterministic, and they check behaviour rather than implementation. They don’t touch the network or the file system. They don’t read the clock either, and each one checks a single outcome. The practices below keep a mobile unit test suite quick enough to run on every pull request and trustworthy enough that a red build means a real bug.

1. Keep UI and platform code out of the logic you test

You can’t unit test what’s tangled up with the UI or the device. Move business logic into plain Swift types or Kotlin classes, such as view models and use cases, and let your UIViewController, SwiftUI View or Android Activity wire things together. Keep anything that only works on a device out of that logic too: on Android, that means a Context, SharedPreferences or other android.* APIs, and on iOS, anything tied to the app’s lifecycle. Wrap these behind an interface or protocol.

Better still, move that logic into its own module. On Android, a pure Kotlin (JVM) module has no Android dependencies at all, so its tests run on the JVM with nothing to stub. If a class does need Android APIs, your options are Robolectric, which simulates them on the JVM, or an instrumented test on an emulator, and both are slower.

On iOS, tests for an app target are hosted by your app, so every run launches it in the simulator first. Move pure logic into a framework or a local Swift package instead. Its test target doesn’t need a host app: in Xcode, select the test target, open the General tab, and check that Host Application is set to None. Swift package tests can also run on the Mac with swift test, with no simulator at all.

2. Use dependency injection to swap in fakes

Pass dependencies in through initialisers or constructors, instead of creating them inside the class. This is dependency injection, and it’s the main way to control the inputs and state your logic runs with. Type each dependency as a protocol (Swift) or interface (Kotlin), so a test can hand in a fake that returns fixed data:

interface OrderRepository {
    fun latestOrderTotalCents(): Int
}

// A fake for tests: no network, no database, always the same answer
class FakeOrderRepository(private val totalCents: Int) : OrderRepository {
    override fun latestOrderTotalCents(): Int = totalCents
}

We prefer simple fakes over heavy mocking frameworks for most tests. Fakes break less when you refactor, because they don’t encode which methods were called in which order.

3. Don’t depend on time, randomness or network

A test that reads the clock (Date() or System.currentTimeMillis()) or calls a real API will eventually fail for reasons that have nothing to do with your code. Inject a clock and a random source, and keep network calls out of unit tests entirely. These hidden dependencies are a common cause of flaky tests.

4. Test behaviour, not private methods

Assert on what a unit returns or exposes, and leave its internals out of the test. If you find yourself making methods public just to test them, that’s a sign the class is doing too much and should be split.

5. Keep the unit suite separate, and run it in parallel

Aim for each unit test to finish in milliseconds. If a “unit” test needs a real database, an emulator or a sleep, it’s an integration or instrumented test and belongs in a separate, slower workflow.

On iOS, a test plan keeps the two apart. In Xcode, choose Product > Test Plan > New Test Plan to create an .xctestplan file, and put unit tests in one plan and UI tests in another. CI can then run the fast plan on every push and the slow plan on merge.

Then turn on parallel execution. In the unit test plan’s Tests tab, open the Options for the unit test target and enable Execute in parallel. On Android, Gradle can fork several test JVMs at once:

// build.gradle.kts (module level)
tasks.withType<Test>().configureEach {
    // Run local unit tests in several JVM forks at once
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}

Parallel runs only work if tests don’t share state, so fix any test that depends on another test running first.

Unit testing vs integration testing

Unit testing checks a single piece of code in isolation, with its dependencies faked. Integration testing checks that two or more real pieces work together, such as a repository talking to a real local database or an API client parsing real server responses. You need both: unit tests find logic bugs fast, and integration tests find the bugs that only show up at the seams.

Unit testing Integration testing
Scope
One function, method or class
Two or more components together
Dependencies
Replaced with fakes or mocks
Real, or close to real
Speed
Milliseconds per test
Seconds per test, sometimes more
Where it runs on Android
Local JVM (src/test)
Often on a device or emulator (src/androidTest)
Where it runs on iOS
Simulator, or host machine for Swift packages
Simulator or device
When it fails
Points at one unit of code
Points at a boundary between units
Flakiness risk
Low
Medium
Typical share of suite
The large majority
A smaller middle layer

Above both sit UI and end-to-end tests, which drive the real app the way a user would. On iOS, that usually means XCUITest. On Android, it's Espresso or Compose UI tests. They're the slowest layer, so keep them for critical user journeys.

How Bitrise handles unit testing

Bitrise runs your unit tests with dedicated steps for each platform: Xcode Test for iOS and Android Unit Test. Both collect results so they show up in the build's Tests tab, and both flag tests that pass on a retry as flaky. For large suites, test sharding splits the work across parallel machines.

iOS: the Xcode Test for iOS step

The Xcode Test for iOS step (xcode-test) runs xcodebuild test against a shared Xcode scheme. You set the project path, the scheme, the simulator destination and, if you use them, a test plan. Tests run headless by default. The step exports the .xcresult bundle and test attachments. It also exports the xcodebuild log.

The step works with both XCTest and Swift Testing, since both run through xcodebuild. You won't need any code signing files to run tests on the simulator.

Android: the Android Unit Test step

The Android Unit Test step (android-unit-test) finds and runs the local unit tests for the module and variant you choose. Leave Module and Variant empty and it tests all of them. It zips the HTML and XML reports into $BITRISE_DEPLOY_DIR.

A minimal unit test workflow

Here's a bitrise.yml with one workflow per platform. Add the Deploy to Bitrise.io step at the end of each, or the results won't appear in the Tests tab.

format_version: "13"
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git

workflows:
  unit_tests_ios:
    steps:
    - git-clone@8: {}
    - xcode-test@6:
        inputs:
        - project_path: $BITRISE_PROJECT_PATH
        - scheme: $BITRISE_SCHEME
        - destination: platform=iOS Simulator,name=Bitrise iOS default,OS=latest
        # Run a failing test up to 3 times in total; tests that pass on retry are marked flaky
        - test_repetition_mode: retry_on_failure
        - maximum_test_repetitions: 3
    - deploy-to-bitrise-io@2: {}

  unit_tests_android:
    steps:
    - git-clone@8: {}
    - android-unit-test@1:
        inputs:
        - project_location: $BITRISE_SOURCE_DIR
        - module: app
        - variant: debug
    - deploy-to-bitrise-io@2: {}

Flaky test detection and quarantine

When retries are on, any test that fails and then passes is marked as flaky in the Tests tab, and its name is added to the BITRISE_FLAKY_TEST_CASES environment variable. A later step can use that list to post a Slack message or open a ticket. On iOS, retries map to xcodebuild's -retry-tests-on-failure option, which has been reported to rerun every Swift Testing test, not only the failed ones. On Android, the step has no retry input: retries come from the Gradle test-retry plugin in your build.gradle.

On the Enterprise plan, you can quarantine flaky tests from the Tests tab or Bitrise Insights. Bitrise passes the list to your build in BITRISE_QUARANTINED_TESTS_JSON, and both test steps skip those tests automatically, so one unreliable test doesn't block every merge.

Scaling with test sharding

Once a suite grows into the thousands, test sharding splits it across parallel machines. For iOS, the Xcode Test Shard Calculation step works out the split from a build-for-testing bundle, and the Xcode Test without building step runs each shard. Each parallel copy then gets $BITRISE_IO_PARALLEL_INDEX and $BITRISE_IO_PARALLEL_TOTAL to know which shard to run. The Bitrise blog post on test sharding covers when it pays off. It's one of the ways teams keep mobile CI/CD feedback fast as an app grows.

‍

See what Bitrise can do for you

Confidently build, test, and ship high-quality mobile apps with Bitrise.

Author

Gergely Sallai

Engineering Manager at Bitrise

Frequently Asked Questions

What is an example of a unit test?

A test that checks an email validator accepts “[email protected]” and rejects “name@” is a classic unit test. It calls one function with fixed inputs and asserts on the result. Nothing touches the network or a database, and no UI is involved.

Who writes unit tests, developers or QA?

Developers write unit tests, usually alongside the code they’re testing, and in most mobile teams they write the integration tests too. Where a team has QA engineers, they tend to focus on end-to-end and exploratory testing, where knowledge of user behaviour matters more than knowledge of the code’s internals.

How many unit tests is enough?

There's no magic number. Cover the logic that would hurt most if it broke, such as payments and authentication. Data transformations belong there too, along with every bug you've fixed. Code coverage helps you spot untested areas, but a high percentage doesn't prove your assertions check the right things.

What's the difference between a unit test and a UI test?

A unit test checks one piece of code directly and runs in milliseconds. A UI test interacts with the interface like a user, tapping buttons and reading the screen. Most UI tests launch the whole app, which makes them slower and more likely to be flaky. On Android, Jetpack Compose tests can also render a single composable without the rest of the app, which covers individual components but not full user journeys. The usual balance is many unit tests and a few UI tests for critical flows.

Should I use XCTest or Swift Testing?

Use both. Swift Testing is Apple’s newer framework, and its @Test and #expect make tests shorter and clearer. The two run side by side in the same target, so you can write new tests in Swift Testing and leave existing XCTest tests alone. UI tests still need XCTest, because XCUITest is built on it. For a walkthrough of writing both, see the Bitrise blog’s Swift unit and UI testing guide.