TempuraTesting 9.0.3

TempuraTesting 9.0.3

Bending Spoons 维护。



  • 作者
  • Bending Spoons

Tempura by Bending Spoons

Build Status CocoaPods PRs Welcome Licence

Tempura是对iOS开发的全面方法,它通过Katana借用Redux的概念,并通过MVVM

🎯安装

Tempura可通过CocoaPods获得。

要求

  • iOS 11+
  • Xcode 11.0+
  • Swift 5.0+

CocoaPods

CocoaPods是一个Cocoa项目的依赖管理器。您可以使用以下命令安装它

$ sudo gem install cocoapods

要使用CocoaPods在您的Xcode项目中集成Tempura,您需要创建一个包含如下内容的Podfile

use_frameworks!
source 'https://cdn.cocoapods.org/'
platform :ios, '11.0'

target 'MyApp' do
  pod 'Tempura'
end

现在,只需运行

$ pod install

Swift包管理器

从版本 9.0.0 开始,Tempura 也支持 Swift Package Manager (SPM)

🤔我应该为什么使用这个?

Tempura 允许你

  1. 模拟你的应用状态
  2. 定义可以改变它的操作
  3. 创建 UI
  4. 享受状态和 UI 之间的自动同步
  5. 发布,迭代

我们开始在 Bending Spoons 内部的小团队中使用 Tempura。它对我们来说效果非常好,我们最终开发了和维护了二十多个高质量的 app,去年有超过 1000 万活跃用户使用这种方法。崩溃率和发展时间下降,用户参与度和质量上升。我们非常满意,因此想与 iOS 社区分享,希望你会像我们一样激动。❤️

Splice Thirty Day Fitness Pic Jointer Yoga Wave

👩‍💻给我看代码

Tempura 使用 Katana 来处理你应用的逻辑。你的应用状态在单个结构体中定义。

struct AppState: State {

  var items: [Todo] = [
    Todo(text: "Pet my unicorn"),
    Todo(text: "Become a doctor.\nChange last name to Acula"),
    Todo(text: "Hire two private investigators.\nGet them to follow each other"),
    Todo(text: "Visit mars")
  ]
}

你只能通过 State Updater 来操作状态。

struct CompleteItem: StateUpdater {
  var index: Int

  func updateState(_ state: inout AppState) {
    state.items[index].completed = true
  }
}

用于渲染屏幕 UI 的状态部分由 ViewModelWithState 选择。

struct ListViewModel: ViewModelWithState {
  var todos: [Todo]

  init(state: AppState) {
    self.todos = state.todos
  }
}

你应用每个屏幕的 UI 都由一个 ViewControllerModellableView 组成。它公开回调(我们称之为交互),以表示用户已发生操作。它基于 ViewModelWithState 进行渲染。

class ListView: UIView, ViewControllerModellableView {
  // subviews
  var todoButton: UIButton = UIButton(type: .custom)
  var list: CollectionView<TodoCell, SimpleSource<TodoCellViewModel>>

  // interactions
  var didTapAddItem: ((String) -> ())?
  var didCompleteItem: ((String) -> ())?

  // update based on ViewModel
  func update(oldModel: ListViewModel?) {
    guard let model = self.model else { return }
    let todos = model.todos
    self.list.source = SimpleSource<TodoCellViewModel>(todos)
  }
}

每个屏幕都由一个 ViewController 管理。默认情况下,它将自动监听状态更新,并保持 UI 同步。ViewController 的唯一其他责任是监听来自 UI 的交互并将操作分发给状态。

class ListViewController: ViewController<ListView> {
  // listen for interactions from the view
  override func setupInteraction() {
    self.rootView.didCompleteItem = { [unowned self] index in
      self.dispatch(CompleteItem(index: index))
    }
  }
}

注意,视图控制器的 dispatch 方法与 Katana store 暴露的方法略有不同:它接受一个简单的 Dispatchable,并且不返回任何内容。这是为了避免在视图控制器中实现逻辑。

如果你的交互处理程序需要做更多的事情,你应该将所有这些逻辑打包成一个副作用并分发它。

对于那些在视图控制器中需要使用一些逻辑的情况(例如,当你不想完全重构所有逻辑而更新旧应用时),你可以使用以下方法

  • open func __unsafeDispatch<T: StateUpdater>(_ dispatchable: T) -> Promise<Void>
  • open func __unsafeDispatch<T: ReturningSideEffect>(_ dispatchable: T) -> Promise<T.ReturningValue>

但是请注意,使用这些方法是被高度不推荐的,并且它们将在未来的版本中删除。

导航

真正的应用是由多个屏幕组成的。如果屏幕需要展示另一个屏幕,其 ViewController 必须符合 RoutableWithConfiguration 协议。

extension ListViewController: RoutableWithConfiguration {
  var routeIdentifier: RouteElementIdentifier { return "list screen"}

  var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
    return [
      .show("add item screen"): .presentModally({ [unowned self] _ in
        let aivc = AddItemViewController(store: self.store)
        return aivc
      })
    ]
  }
}

然后你可以通过 ViewController 中的导航操作之一来触发展示。

self.dispatch(Show("add item screen"))

更多关于导航的信息 这里

ViewController嵌套

您可以将ViewController嵌套在其他 ViewController 中。当您想重用部分 UI 和逻辑时,这非常有用。要做到这一点,在父 ViewController 中,您需要提供一个将接收子 ViewController 视图的 ContainerView

class ParentView: UIView, ViewControllerModellableView {
    var titleView = UILabel()
    var childView = ContainerView()
    
    func update(oldModel: ParentViewModel?) {
      // update only the titleView, the childView is managed by another VC
    }
}

然后,在父 ViewController 中,您只需添加子 ViewController

class ParentViewController: ViewController<ParentView> {
  let childVC: ChildViewController<ChildView>!
    
  override func setup() {
    self.childVC = ChildViewController(store: self.store)
    self.add(childVC, in: self.rootView.childView)  
  }
}

所有自动化都会自动完成。现在您将在 ParentViewController 中有一个 ChildViewController,ChildViewController 的视图将嵌套在 childView 中。

📸UI快照测试

Tempura 具有一个快照测试系统,可以用来获取您视图在所有可能状态下的截图,包括所有设备和所有支持的语言。

使用方法

您需要在应用的测试目标中包含 TempuraTesting pod。

target 'MyAppTests' do
  pod 'TempuraTesting'
end

在您的 plist 中指定截图的位置。

UI_TEST_DIR: $(SOURCE_ROOT)/Demo/UITests

在 Xcode 中创建一个新的 UI 测试用例类

文件 -> 新建 -> 文件... -> UI 测试用例类

在这里,您可以使用 test 函数来获得一个带有特定 ViewModelViewControllerModellableView 的快照。

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ])
  }
}

标识符将定义文件系统中快照图像的名称。

您还可以使用上下文参数自定义视图的渲染方式(例如,您可以将视图嵌入到 UITabBar 实例中)。以下是一个将视图嵌入到标签栏中的示例

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    var context = UITests.Context<AddItemView>()
    context.container = .tabBarController


    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ], context: context)
  }
}

如果 UIScrollView 中的重要内容没有完全显示,您可以使用 scrollViewsToTest(in view: V, identifier: String) 方法。这将产生一个额外的快照,渲染每个返回的 UIScrollView 实例的全部内容。

在这个示例中,我们使用 scrollViewsToTest(in view: V, identifier: String) 来拍摄屏幕底部 情感选择器 的扩展快照。

func scrollViewsToTest(in view: V, identifier: String) -> [String: UIScrollView] {
  return ["mood_collection_view": view.moodCollectionView]
}

如果您需要在渲染 UI 并截取快照之前等待异步操作的完成,您可以使用 isViewReady(view:identifier:) 方法。例如,在这里我们等待一个假设显示远程 URL 图片的视图准备好。当图片显示出来(即状态是 loaded),然后截取快照。

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ])
  }

  func isViewReady(_ view: AddItemView, identifier: String) -> Bool {
    return view.remoteImage.state == .loaded
  }
}

一旦截取了快照,测试就会通过。

上下文

您可以通过传递给 uiTest 方法的 context 对象启用一些高级功能。

  • container 允许您在 UITests 中将 VC 定义为一个视图的容器。基本的 navigationControllertabBarController 已经提供,或者您可以使用 custom 定义自己的。
  • hooks 允许在发生某些生命周期事件时执行操作。可用的钩子有 viewDidLoadviewWillAppearviewDidAppearviewDidLayoutSubviewsnavigationControllerHasBeenCreated
  • screenSizeorientation 属性允许您在测试期间定义自定义屏幕大小和方向。
  • renderSafeArea 允许您定义是否在测试期间将安全区域渲染为半透明的灰色覆盖层。
  • keyboardVisibility 允许您定义是否应将灰色覆盖层渲染为键盘的占位符。

多台设备

默认情况下,测试仅在您从 Xcode(或您的设备,或 CI 系统)选择的设备上进行。我们可以通过以下脚本等脚本来在所有设备上运行快照。

xcodebuild \
  -workspace <project>.xcworkspace \
  -scheme "<target name>" \
  -destination name="iPhone 5s" \
  -destination name="iPhone 6 Plus" \
  -destination name="iPhone 6" \
  -destination name="iPhone X" \
  -destination name="iPad Pro (12.9 inch)" \
  test

将在所有设备上并行运行测试。如果您想更改行为,请参考 xcodebuild 文档。

如果您想在 UI 测试中测试特定的语言,可以将 test 命令替换为 -testLanguage <iso code639-1>。应用将以该语言启动,并对该地区执行 UITesting。例如

xcodebuild \
  -workspace <project>.xcworkspace \
  -scheme "<target name>" \
  -destination name="iPhone 5s" \
  -destination name="iPhone 6 Plus" \
  -destination name="iPhone 6" \
  -destination name="iPhone X" \
  -destination name="iPad Pro (12.9 inch)" \
  -testLanguage it

远程资源

UI 需要显示远程内容(即远程图片、远程视频等)的情况很常见。在执行 UITests 时,这可能会成为问题,因为

  • 测试可能会因为网络或服务器问题而失败。
  • 系统应在远程资源加载时跟踪,然后将它们放入 UI 中,然后再截图。

为了解决这个问题,Tempura 提供了一个名为 LocalFileURLProtocolURLProtocol 子类,它尝试从您的本地存档加载远程文件。

想法是将所有需要渲染 UI 的资源放入您的(测试)存档中,LocalFileURLProtocol 将尝试加载它们,而不是执行网络请求。

对于给定的 URL,LocalFileURLProtocol 将使用以下规则匹配文件名:

  • 搜索具有 URL 作为名称的文件(例如,http://example.com/image.png
  • 搜索具有最后一个路径组件作为文件名的文件(例如,image.png)
  • 搜索具有没有扩展名的最后一个路径组件作为文件名的文件(例如,image)

如果无法检索到匹配的文件,则执行网络调用。

为了在您的应用中注册 LocalFileURLProtocol,您需要在测试的生命周期中尽可能早地调用以下API。

URLProtocol.registerClass(LocalFileURLProtocol.self)

注意,如果您正在使用 Alamofire,这可能不会按预期工作。您可以在这里找到一个相关问题和配置Alamofire以处理URLProtocol类的链接。

使用 ViewController 容纳进行 UI 测试

ViewTestCase 专注于测试 ViewControllerModellableView,它通过自动注入代表测试条件的 ViewModel 来实现。

如果您使用 ViewController 容纳(如上例中的 ParentView),那么在注入 ViewModel 时,有一部分视图将无法更新,因为还有另一个 ViewController 负责该部分。

在这种情况下,您需要使用 ViewControllerTestCase 协议在 ViewController 的级别上进行扩展和测试。

class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
  /// provide the instance of the ViewController to test
  var viewController: ParentViewController {
    let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
    let vc = ParentViewController(store: testStore)
    return vc
  }
  
  /// define the ViewModels
  let viewModel = ParentViewModel(title: "A test title")
  let childVM = ChildViewModel(value: 12)
  
  /// define the tests we want to perform
  let tests: [String: ParentViewModel] = [
    "first_test_vc": viewModel
  ]
    
  /// configure the ViewController with ViewModels, also for the children VCs
  func configure(vc: ParentViewController, for testCase: String, model: ParentViewModel) {
    vc.viewModel = model
    vc.childVC.viewModel = childVM
  }
    
  /// execute the UI tests
  func test() {
    let context = UITests.VCContext<ParentViewController>(container: .none)
    self.uiTest(testCases: self.tests, context: context)  
  }
}

如果没有任何子 ViewController 需要配置,那么这甚至更容易,您无需提供 configure(:) 方法。

class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
  /// provide the instance of the ViewController to test
  var viewController: ParentViewController {
    let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
    let vc = ParentViewController(store: testStore)
    return vc
  }
  
  /// define the ViewModel
  let viewModel = ParentViewModel(title: "A test title")
  
  /// define the tests we want to perform
  let tests: [String: ParentViewModel] = [
    "first_test_vc": viewModel
  ]
    
  /// execute the UI tests
  func test() {
    let context = UITests.VCContext<ParentViewController>(container: .tabbarController)
    self.uiTest(testCases: self.tests, context: context)  
  }
}

🧭下一步要做什么

示例应用

此存储库包含一个使用 Tempura 实现的待办事项列表应用的演示。要生成 Xcode 项目文件,您可以使用 Tuist。运行 tuist generate,打开项目并运行 Demo 目标。

查看文档

文档

📄Swift版本

Tempura 的某些版本只支持某些 Swift 版本的 Swift。根据您的项目使用哪个版本的 Swift,您应该使用特定版本的 Tempura。使用此表检查您需要哪个版本的 Tempura。

Swift版本 Tempura 版本
Swift 5.0 Tempura 4.0+
Swift 4.2 Tempura 3.0
Swift 4 Tempura 1.12

📬取得联系

如果您有任何 问题反馈,我们很乐意在 [email protected] 听到您的声音。

🙋‍♀️做出贡献

  • 如果您 发现了错误,请提交一个问题;
  • 如果您有 功能请求,请提交一个问题;
  • 如果您 想贡献代码,请提交一个 Pull Request;
  • 如果您 有一个想法 可以改进框架或推广,请 联系
  • 如果您想在项目中尝试这个框架或编写演示程序,请发送我们该仓库的链接。

👩‍⚖️许可证

Tempura 在MIT 许可证下可用。

关于

Tempura 由Bending Spoons维护。我们创建了自家的技术产品,这些产品被全世界数百万用户使用和喜爱。听起来很酷吗?看看我们