@capgo/capgo-skills
Version:
55 agent skills for Capacitor mobile development
128 lines (99 loc) • 6.43 kB
Markdown
# Native Unit Tests for Capacitor Plugins
## Layout
The plugin template keeps native tests next to the sources:
```
Package.swift # testTarget(name: "<Name>PluginTests", path: "ios/Tests/<Name>PluginTests")
ios/Sources/<Name>Plugin/<Name>.swift # implementation (testable, no bridge)
ios/Sources/<Name>Plugin/<Name>Plugin.swift # CAPPlugin glue
ios/Tests/<Name>PluginTests/*.swift
android/src/test/java/... # JVM unit tests (JUnit)
android/src/androidTest/java/... # instrumented tests (device/emulator)
```
Test the implementation class first. It has no bridge, so it needs no mocks. Test the `CAPPlugin` glue only for argument parsing, `resolve` / `reject` shape, and events.
## Running
```bash
# iOS: package scheme, iOS Simulator destination (swift test cannot build UIKit/Capacitor on macOS)
xcrun simctl list devices available | grep iPhone
xcodebuild test -scheme <PackageName> -destination 'platform=iOS Simulator,name=iPhone 16'
xcodebuild test -scheme <PackageName> -destination '...' -only-testing:<Name>PluginTests/<SuiteOrClass>
# Android
cd android && ./gradlew test
```
Test targets are not built by apps that consume the plugin, so test-only Swift features do not raise the plugin's minimum Xcode for users.
## Swift Testing or XCTest
| Test kind | Framework |
|---|---|
| New unit tests | Swift Testing (`import Testing`), Xcode 16+ toolchain |
| UI tests with `XCUIApplication` (example app) | XCTest only |
| `measure { }` performance tests | XCTest only (other methods in the same class can migrate) |
| AppIntentsTesting (iOS 27) | XCTest |
Migrate one class at a time. A file can import both `XCTest` and `Testing` during migration. When you remove `import XCTest`, add `import Foundation` if the file uses Foundation types (XCTest re-exported it).
### Migration map
| XCTest | Swift Testing |
|---|---|
| `final class FooTests: XCTestCase` | `struct FooTests` (`final class` or `actor` if you need `deinit`) |
| `override func setUp()` | `init() async throws`; stored properties initialized there, no IUOs |
| `override func tearDown()` | `deinit` (class / actor only) |
| `func testParsesUrl()` | `@Test func parsesUrl()`; sentence names with raw identifiers need Swift 6.2 (Xcode 26+) |
| `XCTAssertEqual(a, b)`, `XCTAssertTrue(x)`, `XCTAssertNil(x)` | `#expect(a == b)`, `#expect(x)`, `#expect(x == nil)` |
| `try XCTUnwrap(x)` | `try #require(x)` |
| `XCTAssertThrowsError(try f())` | `#expect(throws: (any Error).self) { try f() }`, or the specific error value when Equatable |
| `XCTAssertNoThrow(try f())` | `#expect(throws: Never.self) { try f() }` |
| `continueAfterFailure = false` | `try #require(...)` for every later assertion in the affected tests |
| `XCTestExpectation` + `fulfill` + `fulfillment(of:)` | `await confirmation { confirm in ... }` when the callback fires before the closure returns; otherwise `withCheckedContinuation` |
| `XCTSkipIf(c)` / `XCTSkipUnless(c)` | `@Test(.disabled(if: c))` / `.enabled(if: c)`; OS checks become `@available` on the test |
| `XCTExpectFailure` | `withKnownIssue { }` (`isIntermittent: true` for flaky) |
| `XCTAssertEqual(a, b, accuracy: e)` | No direct equivalent; `#expect(abs(a - b) <= e)` |
| `XCTAttachment` | `Attachment.record(value)` |
Swift Testing runs tests in parallel on arbitrary tasks. XCTest ran synchronous tests on the main actor, one at a time. Add `@MainActor` only to tests that relied on that, and `@Suite(.serialized)` to suites that share state (UserDefaults, files, singletons). Plugins often have such state.
Loops over inputs become parameterized tests: `@Test(arguments: ["a", "b"]) func parses(_ input: String)`.
Use only public API; no underscore-prefixed symbols.
### Testing a plugin method
`CAPPluginCall(callbackId:methodName:options:success:error:)` is the current initializer (the one without `methodName:` is deprecated, and Capacitor 9 removed many deprecated APIs). Create plugins with plain `init()`; the bridge is `nil` in tests.
```swift
import Testing
import Capacitor
@testable import EchoPlugin
@Suite struct EchoPluginTests {
@Test func echoReturnsValue() async throws {
let plugin = EchoPlugin()
let value: String? = await withCheckedContinuation { continuation in
let call = CAPPluginCall(
callbackId: "test-1",
methodName: "echo",
options: ["value": "hello"],
success: { result, _ in continuation.resume(returning: result?.data?["value"] as? String) },
error: { _ in continuation.resume(returning: nil) }
)
plugin.echo(call!)
}
#expect(value == "hello")
}
@Test func rejectsMissingValue() async {
let plugin = EchoPlugin()
let message: String? = await withCheckedContinuation { continuation in
let call = CAPPluginCall(callbackId: "test-2", methodName: "echo", options: [:],
success: { _, _ in continuation.resume(returning: nil) },
error: { error in continuation.resume(returning: error?.message) })
plugin.echo(call!)
}
#expect(message != nil)
}
}
```
Each continuation must resume exactly once. If the method can resolve and also notify, structure it so only one path resumes.
Events: subclass the plugin in the test target and override `notifyListeners(_:data:retainUntilConsumed:)` to record events instead of sending them through the bridge. Override methods that touch `UIApplication` or the bridge with test seams.
## Android (JUnit)
```kotlin
// android/src/test/java/com/example/echo/EchoTest.kt
import org.junit.Assert.assertEquals
import org.junit.Test
class EchoTest {
@Test
fun echoReturnsInput() {
assertEquals("hello", Echo().echo("hello"))
}
}
```
Test the implementation class without Android framework dependencies in `src/test`. For `PluginCall` glue, mock `PluginCall` with Mockito (`mockito-core` / `mockito-kotlin` as `testImplementation`) and verify `resolve(JSObject)`. Code that needs a `Context`, the WebView or real permissions goes in `src/androidTest` (instrumented, runs on an emulator: `./gradlew connectedAndroidTest`).
Capacitor 9 plugins build with AGP 9, which bundles Kotlin. Remove the standalone `kotlin-android` plugin from test setups copied from older templates.