NnTestKit is a Swift package that provides a collection of helper methods to simplify unit and UI testing in your iOS projects. It extends XCTest and Swift Testing frameworks to offer more convenient and powerful assertion methods, memory leak tracking with modern Swift macros, and UI testing utilities.
NOTE: Test helper methods are split across libraries for better organization:
- NnTestHelpers: Core XCTest extensions (memory leak tracking, property assertions, error handling)
- NnUITestHelpers: UI testing utilities (BaseUITestCase and related helpers)
- NnTestVariables: Lightweight library with test-related properties (can be included in production)
- NnSwiftTestingHelpers: Swift Testing framework support with modern macro-based memory leak detection
- NnTestKitMacros: Swift macros for enhanced testing functionality
All test helper libraries should only be included as dependencies in test targets.
- Memory leak tracking with modern
@LeakTrackedmacro (Swift 5.10+) - Property and array assertions
- Error handling assertions (sync and async)
- Swift Testing framework support
- UI test case setup and helper methods
- Seeded UI test helpers for driving app state from UI tests via typed,
Codableseed configs - Configurable default timeout for UI helpers via
UITestSeedDefaults.timeout - Swift 6 concurrency compatibility
To add NnTestKit to your Xcode project, add the following dependency to your Package.swift file:
.package(url: "https://github.com/nikolainobadi/NnTestKit", from: "2.0.0")Then, add the appropriate libraries to your test target dependencies:
// For unit tests with XCTest
.testTarget(
name: "MyAppTests",
dependencies: [
.product(name: "NnTestHelpers", package: "NnTestKit"),
.product(name: "NnTestVariables", package: "NnTestKit")
]
)
// For UI tests
.testTarget(
name: "MyAppUITests",
dependencies: [
.product(name: "NnUITestHelpers", package: "NnTestKit"),
.product(name: "NnTestVariables", package: "NnTestKit")
]
)
// For Swift Testing framework tests
.testTarget(
name: "MyAppSwiftTests",
dependencies: [
.product(name: "NnSwiftTestingHelpers", package: "NnTestKit")
]
)For Swift 5.10+, use the @LeakTracked macro for memory leak detection without inheritance:
Important Requirements:
- The test suite must be a class (not a struct) as the macro injects a
deinitmethod - Import both
TestingandNnSwiftTestingHelpersin your test file - Most useful when testing class instances that need deallocation tracking
- Reference types (classes) are tracked; value types (structs) don't need leak tracking
Basic Usage:
import Testing
import NnSwiftTestingHelpers
@testable import MyModule
@LeakTracked
final class MyTestSuite { // Must be a class, not a struct
@Test("MyClass deallocates properly")
func test_memoryLeakDetected() {
let sut = makeSUT()
// Test operations...
}
private func makeSUT(fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, column: Int = #column) -> MyClass {
let service = MyService() // Assuming MyService is a class
let sut = MyClass(service: service) // Assuming MyClass is a class
trackForMemoryLeaks(service, fileID: fileID, filePath: filePath, line: line, column: column)
trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column)
return sut
}
}Behavior Modes:
The macro supports three leak detection behaviors that can be specified when calling trackForMemoryLeaks:
.failIfLeaked(Default) - Fails the test if a tracked object is not deallocated.warnIfLeaked- Logs a warning but doesn't fail the test.expectLeak- Fails if the object IS deallocated (useful for testing retain cycles)
private func makeSUT(fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, column: Int = #column) -> MyClass {
let service = MyService()
let sut = MyClass(service: service)
// Default: fails on leak
trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column)
// Warn only (doesn't fail test)
trackForMemoryLeaks(service, behavior: .warnIfLeaked, fileID: fileID, filePath: filePath, line: line, column: column)
// Expect the object to leak (fails if it deallocates)
// trackForMemoryLeaks(sut, behavior: .expectLeak, fileID: fileID, filePath: filePath, line: line, column: column)
return sut
}Tracking Multiple Objects:
@LeakTracked
final class ViewModelTests {
@Test("ViewModel and dependencies deallocate properly")
func test_viewModelLifecycle() {
let sut = makeSUT()
// Test operations...
}
private func makeSUT(fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, column: Int = #column) -> ViewModel {
let service = MockService()
let repository = MockRepository()
let sut = ViewModel(service: service, repository: repository)
// Track all objects that should be deallocated
trackForMemoryLeaks(service, fileID: fileID, filePath: filePath, line: line, column: column)
trackForMemoryLeaks(repository, fileID: fileID, filePath: filePath, line: line, column: column)
trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column)
return sut
}
}NnTestKit extends XCTestCase with several useful methods:
Prevent memory leaks before they infect your code by passing the object you want to track into this method before running your unit tests. This method ensures the object in question is dellocated by the end of the test eles the test will fail.
import XCTest
import NnTestHelpers
final class MyTests: XCTestCase {
func testMemoryLeak() {
let instance = MyClass()
trackForMemoryLeaks(instance)
// Your test code here
}
}Use this method when you want to ensure a propery is not nil, then make any extra assertions you may want to check.
import XCTest
import NnTestHelpers
final class MyTests: XCTestCase {
func testProperty() {
let sut = makeSUT()
let value = sut.methodToCreateIntValue()
assertProperty(value, name: "value") { XCTAssert($0 > 0) }
}
}If you just want to compare the optional value to an expected property, assertPropertyEquality does the trick.
import XCTest
import NnTestHelpers
final class MyTests: XCTestCase {
func testPropertyEquality() {
let sut = makeSUT()
let value = sut.methodToCreateIntValue()
assertPropertyEquality(value, expectedProperty: 5)
}
}Easily check for the existence of any type or Equatable data.
import XCTest
import NnTestHelpers
final class MyTests: XCTestCase {
func testArrayContainsItems() {
let array = [1, 2, 3, 4, 5]
assertArray(array, contains: [1, 2])
}
}Yes, XCTAssertNoThrow alreAdy exists, but it doesn't allow you to pass in file: StaticString = #filePath, line: UInt = #line as arguments, which is kind of a dealbreaker for me. This method solves that problem, so using it nested in another helper method will still allow you to track the exact file/line where the error occurs. And there's an async-friendly version of this method as well.
import XCTest
import NnTestHelpers
final class MyTests: XCTestCase {
func testNoErrorThrown() {
assertNoErrorThrown {
// Your test code here
}
}
func testAsyncNoErrorThrown() async {
await asyncAssertNoErrorThrown {
// Your asynchronous test code here
}
}
}Same as the previous method, this just accepts file: StaticString = #filePath, line: UInt = #line as optional arguments to better track the failures. And there's an async-friendly version of this method as well.
import XCTest
import NnTestHelpers
enum MyCustomError: Error {
case invalidInput
}
final class MyTests: XCTestCase {
func testErrorIsThrown() {
assertThrownError(expectedError: MyCustomError.invalidInput) {
// Your throwing test code here
}
}
func testAsyncErrorIsThrown() async {
await asyncAssertThrownError(expectedError: MyCustomError.invalidInput) {
// Your asynchronous throwing test code here
}
}
}Note: As of v2.0.0,
BaseUITestCaseand all UI testing helpers have been moved to theNnUITestHelperslibrary. Update your imports fromimport NnTestHelperstoimport NnUITestHelpers.
UI Tests are extremely powerful, but the recording process is often disappointing. NnTestKit provides BaseUITestCase to help with common actions that can be performed. All UI test helper methods support a customizable timeout parameter and fall back to UITestSeedDefaults.timeout (default: 10 seconds) when no timeout is provided. You can override the global default from setUpWithError to adjust for slower environments:
override func setUpWithError() throws {
try super.setUpWithError()
UITestSeedDefaults.timeout = 15
}Easily pass in any environment variables to be used in the app during UI tests. IS_TRUE is the default value, which simple sets the value of the passed in key to "true". ProcessInfo is extended to include a helper method to easily check for the existence of an IS_TRUE value within the environment.
When UI testing, the environment variable IS_UI_TESTING is automatically passed into the environment. Like ProcessInfo.isTesting, ProcessInfo.isUITesting can be access by importing the smaller libarary NnTestVariables so it won't cause too much bloat if used in production.
// App target
import SwiftUI
import NnTestVariables
@main
struct AppLauncher {
static func main() throws {
if ProcessInfo.isTesting {
if ProcessInfo.isUITesting {
TestApp.main()
} else {
Text("Running unit tests")
}
} else {
MyApp.main()
}
}
}// UI Test target
import XCTest
import NnUITestHelpers
import NnTestVariables
final class MyUITests: BaseUITestCase {
func testEnvironmentSetup() {
addKeyToENV("MY_KEY", value: "MY_VALUE")
// Your test code here
}
}UI tests often need to drive the app into a known state before exercising a flow (existing users, sample data, feature flags, etc.). NnTestKit provides a typed seeding pipeline that pushes a Codable config from the test into the app via environment variables, so the app can decode it at launch and run its own seeder.
The pieces live in two libraries:
NnTestVariablesexposesUITestSeedContext<Config>,UITestSeedKey, andUITestSeedDefaults— these are safe to include in the app target so the app can read the seed at launch.NnUITestHelpersexposes thelaunchSeeded/setSeedConfighelpers onBaseUITestCase.
Define a seed config shared between app and test targets:
import NnTestVariables
struct MySeedConfig: Codable, Sendable {
let users: [String]
let startOnboarded: Bool
}Read the seed context in the app at launch:
// App target
import SwiftUI
import NnTestVariables
@main
struct AppLauncher {
static func main() throws {
if ProcessInfo.isUITesting,
let context = UITestSeedContext<MySeedConfig>.fromEnvironment(MySeedConfig.self) {
// Run your app-side seeder with context.config, context.userEmail,
// context.userPassword, and context.runId before rendering UI.
MySeeder.run(context)
}
MyApp.main()
}
}UITestSeedContext.fromEnvironment(_:) reads UITEST_RUN_ID, UITEST_USER_EMAIL, and the JSON-encoded UITEST_SEED_CONFIG from the environment. The canonical keys are exposed via the UITestSeedKey enum so test and app code never have to hard-code strings.
Launch the app with a seed config from your UI test:
import XCTest
import NnUITestHelpers
import NnTestVariables
final class MyUITests: BaseUITestCase {
func testSeededLaunch() {
let config = MySeedConfig(users: ["alice", "bob"], startOnboarded: true)
launchSeeded(config: config)
// The app has now been seeded. `mainUserEmail` returns the generated
// email for this run so you can type it into login fields, etc.
typeInField(fieldId: "email", text: mainUserEmail)
}
func testSeededSignUpFlow() {
// Use setSeedConfig when another flow owns the app.launch() call
// (e.g. a reusable sign-up helper).
setSeedConfig(MySeedConfig(users: [], startOnboarded: false))
signUpWithEmail(mainUserEmail)
}
}launchSeeded generates a unique per-run runId and a derived test user email (tester+<runId>@uitest.local), JSON-encodes the config into UITEST_SEED_CONFIG, and launches the app. setSeedConfig does the same without launching, for cases where another helper owns the launch step. Both helpers also accept an envKeys: array for additional environment flags you want forwarded with their default IS_TRUE value.
Reference additional seeded users by name:
func testMultipleUsers() {
launchSeeded(config: MySeedConfig(users: ["alice", "bob"], startOnboarded: true))
// App-side seeder creates users using the same email format:
// "\(userName.lowercased())+\(runId)@uitest.local"
let aliceEmail = seedEmail(for: "alice")
let bobEmail = seedEmail(for: "bob")
}Generate collision-free names across runs:
let houseName = makeUniqueName("BetaHouse") // e.g. "BetaHouse47"makeUniqueName(_:) appends two random digits so names (houses, rooms, usernames, etc.) don't collide across repeated UI test runs.
Dismiss the iOS "Save Password?" prompt after login:
func testLoginFlow() {
launchSeeded(config: MySeedConfig(users: ["alice"], startOnboarded: true))
typeInField(fieldId: "email", text: mainUserEmail)
typeInField(fieldId: "password", isSecure: true, text: UITestSeedDefaults.password)
tapButton("Log In")
dismissPasswordPromptIfNeeded()
}dismissPasswordPromptIfNeeded(timeout:) waits for the "Not Now" system button and taps it if it appears. It falls back to UITestSeedDefaults.timeout when no timeout is provided. UITestSeedDefaults.password is a shared constant the app-side seeder should use when creating the test user.
BaseUITestCase already contains an instance of XCUIApplication for you to access, stored in the app property. Use it to easily launch the app or to compose the XCUIElementQuery needed to find a UI element.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testWaitForElement() {
app.launch()
// Use default 3-second timeout
let text = waitForElement(app.staticTexts, id: "myTextLabel").label
// Or customize timeout for slow-loading elements
let slowElement = waitForElement(app.buttons, id: "loadButton", timeout: 10)
// remaining test code
}
}On some iOS versions (notably iOS 26+), the accessibility tree can contain duplicate elements for the same button — for example nested buttons inside system alerts. tapFirstButton(_:query:timeout:) resolves the ambiguity by tapping the first match instead of failing on the duplicates.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testTapFirstButton() {
app.launch()
// Tap "Delete" even if iOS presents duplicate matches inside the alert.
tapFirstButton("Delete", query: app.alerts.buttons)
}
}Easily change the date on a date picker. Currently, this method supports only changing selected day, or changing both the selected month and the selected day.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testSelectDate_onlyChangeDay() {
app.launch()
let datePicker = waitForElement(app.datePickers, id: "myDatePicker")
selectDate(picker: datePicker, dayNumberToSelect: 15)
}
func testSelectDate_changeMonthAndDay() {
app.launch()
// With optional timeout parameter
selectDate(pickerId: "myDatePicker", currentMonth: "June", newMonth: "January", newDay: 15, timeout: 5)
}
}Select a tableview/collectonView row (cell) based on the text it should contain.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testRowSelection() {
app.launch()
let row = getRowContainingText(parentViewId: "myCollectionView", text: "Row Text", timeout: 5)
XCTAssertTrue(row.exists)
}
}Delete a tableview/collectionView row (cell) based on the text it should contain. If a confirmationDialgue is expected to display after attempting to delete the row, set withConfirmationAlert to true to tap the corresponding alertSheetButton.
NOTE: By default, "Delete" will be used as the alertSheetButtonId when the value is nil. If you need to tap a different button, simply set alertSheetButtonId to the id of the button you want to press in the alert sheet.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testDeleteRow_noConfirmationAlert() {
app.launch()
let row = getRowContainingText(parentViewId: "myCollectionView", text: "Row Text")
deleteRow(row: row)
}
func testDeleteRow_withConfirmationAlert() {
app.launch()
let row = getRowContainingText(parentViewId: "myCollectionView", text: "Row Text")
deleteRow(row: row, swipeButtonId: "Delete", withConfirmationAlert: true, alertSheetButtonId: "ConfirmDelete", timeout: 5)
}
}I'm going to be honest, this method can be a bit flaky. Unfortunatley dealing with third party alerts isn't as reliable as dealing with native iOS alerts. Still, if you need to tap a button on an alert presented by a third party, this is the method to use.
NOTE: Sometimes the app will need to be tapped in order to proceed. If you experience issues, toggle withAppTap and try again.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testWaitForThirdPartyAlert() {
app.launch()
waitForThirdPartyAlert(decription: """MyApp" Wants to Use "google.com" to Sign In"", button: "Cancel", withAppTap: true)
// Perform actions that trigger the third-party alert
}
}
You can enter text in either a regular textfield or a secureField. You can clear the field text before typing, as well as tap the keyboard "Done" button when finished.
NOTE: By default, this method will tap the textfield before taking any action. If you expect the field to already be in focus, it may be best to set shouldTapFieldBeforeTyping to false to avoid problems.
import XCTest
import NnUITestHelpers
final class MyUITests: BaseUITestCase {
func testTypeInField() {
app.launch()
typeInField(fieldId: "username", text: "testUser")
// Type text into a secure text field and clear it first
typeInField(fieldId: "password", isSecure: true, text: "password123", clearField: true)
// Type text and tap the Done button after typing with custom timeout
typeInField(fieldId: "search", text: "query", tapSubmitButton: true, timeout: 5)
}
}NnTestKit is fully compatible with Swift 6's strict concurrency checking:
@MainActorannotations on UI testing components@preconcurrencyimports for Combine framework compatibility- Thread-safe memory leak tracking implementation
I am open to contributions! If you have ideas, enhancements, or bug fixes, feel free to open an issue. Please ensure that your code adheres to the existing coding standards and includes appropriate documentation and tests.
This project is licensed under the MIT License. See the LICENSE file for details.