Skip to content

Commit

Permalink
feat: iOS unit tests (#366)
Browse files Browse the repository at this point in the history
## 📜 Description

Added native unit tests on iOS.

## 💡 Motivation and Context

### Where to write unit tests?

The problem with current integration of unit-tests on iOS is the fact
that we depends on `react` dependency heavily which is available in
`example` or `FabricExample` projects.

Of course I didn't want to duplicate tests (and there is no clear option
on how to add them for `KeyboardController.xcodeproj` i. e. root) so I
decided to create a new "HelloWorld" project and link files that I want
to test.

Fortunately current codebase is designed quite well (thanks for
supporting both architectures 😀), so I have a separated business-logic
(keyboard frame tracking etc.) and consumers (react views/bridge
bindings).

So I didn't make any modifications to the source code. The only one
"suspicious" thing is adding extension for `reactTag` property (I rely
on it in Extensions file and Extension file needs to be linked since
`ViewHierarchyNavigator` uses `UIResponder.current` extension).

### Unit tests approach

I decided to test everything as unit tests. Of course some of methods,
such as `becomeFirstResponder` doesn't work (actually they work, but
there is no way to check that method has been called as we can do in
jest - and by default iOS doesn't provide any mocks).

So I've created `TestableTextField`/`TestableTextView` and for hierarchy
creation used these classes.

Some of corresponding Android tests can not be ported to iOS:
- get all inputs -> relies on window usage, and actual window doesn't
contain any TextInputs -> most likely we need to pass params and pass a
mock from unit tests but I don't want to change source code right now
- testSetFocusToNextDoesNothingIfLastElement -> there is no way to check
that view keeps focus
- testSetFocusToPrevDoesNothingIfFirstElement -> there is no way to
check that view keeps focus

But overall I'm happy to have at least something to have a better
protection over accident changes that can break the library code.

### CI integration

Also I added a job on CI to run these tests continuously.

## 📢 Changelog

### CI
- added a job for running unit-tests;

### iOS

- added `KeyboardControllerNative` project;
- excluded `KeyboardControllerNative` from Pods (to prevent compilation
errors);

### JS

- excluded `KeyboardControllerNative` from being published to `npm`;

## 🤔 How Has This Been Tested?

Tested locally and on CI.

## 📝 Checklist

- [ ] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko committed Mar 29, 2024
1 parent 2e66fa0 commit 1c3ae50
Show file tree
Hide file tree
Showing 16 changed files with 1,000 additions and 2 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/verify-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,26 @@ jobs:

- name: Verify formatting
run: yarn lint-clang
unit-tests:
name: 📖 Unit tests
runs-on: macOS-14
defaults:
run:
working-directory: ./ios/KeyboardControllerNative
steps:
- uses: actions/checkout@v4

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "15.3"

- name: Install xcpretty
run: gem install xcpretty

- name: Run unit tests
run: "set -o pipefail && xcodebuild \
test \
-scheme KeyboardControllerNative \
-only-testing KeyboardControllerNativeTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
CODE_SIGNING_ALLOWED=NO | xcpretty"
2 changes: 1 addition & 1 deletion FabricExample/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,7 @@ SPEC CHECKSUMS:
React-jsitracing: 4fed160d939e93a39049481f47744af246a7ac2c
React-logger: 3eb80a977f0d9669468ef641a5e1fabbc50a09ec
React-Mapbuffer: 84ea43c6c6232049135b1550b8c60b2faac19fab
react-native-keyboard-controller: 7d37211044d15aa8178b04df9a85263f52a07203
react-native-keyboard-controller: 779693f3474f963ee1d9df1d965368ba82c88abf
react-native-safe-area-context: 1e374c51edf537be56313b893b6e96b0e254ddfe
React-nativeconfig: b4d4e9901d4cabb57be63053fd2aa6086eb3c85f
React-NativeModulesApple: cd26e56d56350e123da0c1e3e4c76cb58a05e1ee
Expand Down
2 changes: 1 addition & 1 deletion example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1405,7 +1405,7 @@ SPEC CHECKSUMS:
React-jsinspector: 85583ef014ce53d731a98c66a0e24496f7a83066
React-logger: 3eb80a977f0d9669468ef641a5e1fabbc50a09ec
React-Mapbuffer: 84ea43c6c6232049135b1550b8c60b2faac19fab
react-native-keyboard-controller: 27a2f1da4a1fedf9f01bf49e2dcabba700daa284
react-native-keyboard-controller: e0f1b0b71a76e05c99fc6bfdd8b8ec05bead4105
react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b
react-native-text-input-mask: 22ca8eeef84d42a896f79428f7d175a5eb8b1c4e
React-nativeconfig: b4d4e9901d4cabb57be63053fd2aa6086eb3c85f
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// ContentView.swift
// KeyboardControllerNative
//
// Created by Kiryl Ziusko on 29/03/2024.
//

import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}

#Preview {
ContentView()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Extension+UIView.swift
// Tests
//
// Created by Kiryl Ziusko on 21/02/2024.
//

import Foundation
import UIKit

public extension UIView {
var reactTag: NSNumber {
return tag as NSNumber
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// KeyboardControllerNativeApp.swift
// KeyboardControllerNative
//
// Created by Kiryl Ziusko on 29/03/2024.
//

import SwiftUI

@main
struct KeyboardControllerNativeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//
// KeyboardControllerNativeTests.swift
// KeyboardControllerNativeTests
//
// Created by Kiryl Ziusko on 29/03/2024.
//

@testable import KeyboardControllerNative
import XCTest

extension XCTestCase {
func waitForFocusChange(
to textField: TestableInput,
timeout: TimeInterval = 10.0,
file: StaticString = #file,
line: UInt = #line
) {
let expectation = XCTestExpectation(description: "Wait for focus change to \(textField.tag)")

XCTAssertFalse(
textField.becomeFirstResponderCalled,
"Expected focus shouldn't be initially set for tag \(textField.tag)"
)

DispatchQueue.main.async {
XCTAssertTrue(
textField.becomeFirstResponderCalled,
"Expected focus to be set to text field with tag \(textField.tag)",
file: file,
line: line
)
expectation.fulfill()
}

wait(for: [expectation], timeout: timeout)
}
}

protocol TestableInput: UIView, TextInput {
var becomeFirstResponderCalled: Bool { get set }
func becomeFirstResponder() -> Bool
}

class TestableTextField: UITextField, TestableInput {
var becomeFirstResponderCalled = false

override func becomeFirstResponder() -> Bool {
becomeFirstResponderCalled = true
return super.becomeFirstResponder()
}
}

class TestableTextView: UITextView, TestableInput {
var becomeFirstResponderCalled = false

override func becomeFirstResponder() -> Bool {
becomeFirstResponderCalled = true
return super.becomeFirstResponder()
}
}

final class KeyboardControllerNativeTests: XCTestCase {
var rootView: UIView!
var textFields: [TestableInput]!

override func setUpWithError() throws {
super.setUp()

rootView = UIView()
textFields = (1 ... 13).map { tag in
let textField = (tag % 2 == 0 ? TestableTextField() : TestableTextView()) as TestableInput
textField.tag = tag
let isEditable = tag != 3 && tag != 4 // Assuming ids 3 and 4 are not editable, similar to our Android test
(textField as? UITextField)?.isEnabled = isEditable
(textField as? UITextView)?.isEditable = isEditable

return textField
}

let subView = UIView()
for (index, textField) in textFields.enumerated() {
if index == 4 {
rootView.addSubview(subView)
}
if index >= 4, index <= 6 {
subView.addSubview(textField)
} else {
rootView.addSubview(textField)
}
}
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func testSetFocusToNextShouldSetFocusToNextField() throws {
let textInput1 = textFields[0]
FocusedInputHolder.shared.set(textInput1)

ViewHierarchyNavigator.setFocusTo(direction: "next")

waitForFocusChange(to: textFields[1])
}

func testSetFocusToPrevShouldSetFocusToPreviousField() throws {
let textInput2 = textFields[1]
FocusedInputHolder.shared.set(textInput2)

ViewHierarchyNavigator.setFocusTo(direction: "prev")

waitForFocusChange(to: textFields[0])
}

func testSetFocusToNextShouldSkipNonEditableFields() throws {
let textInput2 = textFields[1]
FocusedInputHolder.shared.set(textInput2)

ViewHierarchyNavigator.setFocusTo(direction: "next")

waitForFocusChange(to: textFields[4])
}

func testSetFocusToPrevShouldSkipNonEditableFields() throws {
let textInput5 = textFields[4]
FocusedInputHolder.shared.set(textInput5)

ViewHierarchyNavigator.setFocusTo(direction: "prev")

waitForFocusChange(to: textFields[1])
}

func testSetFocusToNextWithinGroup() throws {
let textInput5 = textFields[4]
FocusedInputHolder.shared.set(textInput5)

ViewHierarchyNavigator.setFocusTo(direction: "next")

waitForFocusChange(to: textFields[5])
}

func testSetFocusToPrevWithinGroup() throws {
let textInput6 = textFields[5]
FocusedInputHolder.shared.set(textInput6)

ViewHierarchyNavigator.setFocusTo(direction: "prev")

waitForFocusChange(to: textFields[4])
}

func testSetFocusToNextExitsGroup() throws {
let textInput7 = textFields[6]
FocusedInputHolder.shared.set(textInput7)

ViewHierarchyNavigator.setFocusTo(direction: "next")

waitForFocusChange(to: textFields[7])
}

func testSetFocusToPrevEntersGroupAtLastElement() throws {
let textInput8 = textFields[7]
FocusedInputHolder.shared.set(textInput8)

ViewHierarchyNavigator.setFocusTo(direction: "prev")

waitForFocusChange(to: textFields[6])
}

func testPerformanceExample() throws {
// This is an example of a performance test case.
measure {
// Put the code you want to measure the time of here.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// KeyboardControllerNativeUITests.swift
// KeyboardControllerNativeUITests
//
// Created by Kiryl Ziusko on 29/03/2024.
//

import XCTest

final class KeyboardControllerNativeUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.

// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false

// In UI tests it’s important to set the initial state - such as interface orientation,
// which required for your tests before they run. The setUp method is a good place to do this.
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()

// Use XCTAssert and related functions to verify your tests produce the correct results.
}

func testLaunchPerformance() throws {
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// KeyboardControllerNativeUITestsLaunchTests.swift
// KeyboardControllerNativeUITests
//
// Created by Kiryl Ziusko on 29/03/2024.
//

import XCTest

// swiftlint:disable:next type_name
final class KeyboardControllerNativeUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}

override func setUpWithError() throws {
continueAfterFailure = false
}

func testLaunch() throws {
let app = XCUIApplication()
app.launch()

// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app

let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}
Loading

0 comments on commit 1c3ae50

Please sign in to comment.