具有60个单元测试的示例iOS应用

无需功能性反应式编程的MVVM架构模式

在互联网上,缺少使用单元测试的应用程序的实际示例。 有一些关于抽象理论中的单元测试原理的文章,但是很少有明确的具体示例。 因此,我决定制作一个应用程序,以分享到目前为止我学到的一些知识。

这是运行该应用程序的视频:

代码覆盖率尚未完成,但没有必要达到100%:

Xcode单元测试会在每个单元测试之前自动运行setUp()方法,并在每个测试方法之后自动运行tearDown()方法。 这些方法通常用于清除内存中状态,以防止其从一个测试传播到另一个测试。

在setUp()中,我调用resetState方法来清除数据事件状态。

在ShowsRepository中,我仅在内存中使用Realm来在每次运行测试时都不会在磁盘上保持状态,这可以防止单元测试相互干扰。

 让配置= Realm.Configuration(inMemoryIdentifier:“ InMemoryRealm”) 

假物件

进行单元测试必不可少的是假对象。 您可以在此处阅读更多有关为什么虚假物品如此重要的信息(“一种科学实验”)。

在应用程序中,我使用了虚假的应用程序委托。 委派的虚假应用只是对不执行任何操作的真实应用代表的空替代。 当启动应用程序时,检测目标是否与单元测试的目标匹配,如果是,我将实际的应用程序代表替换为伪造的应用程序代表。

因此,每次运行测试时,该应用程序都不会启动实际的View Controllers / Views。 这种方法允许以更隔离的方式运行单元。 这样对单元测试的运行时性能有好处,并且不会导致意外的后果。

单元测试中使用存根和模拟。 查看项目代码,您可以找到一个带有存根的文件夹,另一个带有存根的文件夹。

依赖注入

在单元测试中,可以用实物代替存根和模拟物,以具有更可控的环境。

当您在ViewController中执行tableView.delegate = self时,您将使用依赖项注入,因为委托是MapKit实现中的依赖项,因此您正在注入它。

是的,即使您还没有注意到,您多年来一直在整个UIKit进行依赖项注入。 代理和数据源隐藏在协议后面。

对于存根,程序员可以使用接口/协议以及带有覆盖的依赖项或子类的注入。 当然,接口选项比子类更好,因为它导致较少的耦合。

假设您有一个从A类继承的B类,而您的目标是测试B类。可以创建一个从B类继承的C存根。这里的主要问题之一是B类已经带来了很多A类行为,很难摆脱它们(或者它的僵化或不可预测性)。 您想要做的只是保持相似或可代表但可控制且可预测的行为。

为了进行依赖注入,我使用了一个名为Swinjec t (https://github.com/Swinject/Swinject)的第三方库。
没有第三方库也可以建立依赖关系。 但是,Swinject将依赖项存储在Container中,因此更容易将依赖项维护在中央位置,从而提高了代码的整洁度和凝聚力。

要获得实例,您可以执行以下操作:
让person = container.resolve(Person.self)!

这样,您无需在实例化时传递参数。 注册依赖项时传递参数。

使用Swinject,甚至可以将类转换为Singleton,而无需触摸该类的文件(开放式封闭原理)。

Swininject resolve方法尝试获取与特定协议关联的实例。
在单元测试中,我们使存根实现特定的协议(在实际应用中由实对象实现),并通过使用register方法将存根与协议相关联。

注入依赖关系允许更大的责任分离,有助于遵守Liskov原则,Open Close原则和依赖关系反转原则。 这些是首字母缩写SOLID的特征,它是使应用程序更具测试性所遵循的最重要的抽象之一。

坚实的立场:

  • S-单一责任原则
  • O —开放封闭原则
  • L — Liskov替代原理
  • I —接口(协议)隔离原则
  • D —依赖倒置原则

我将只谈论SOLID的前三点:SOL,以及它们如何与制作更好的单元测试的“艺术”联系起来。

S-单一责任原则

班级应该只有一个改变理由,这意味着班级应该只有一份工作。

SRP与模块化有关 模块是可分离的组件,通常是一个可与其他组件互换的组件,用于组装成不同大小,复杂性或功能的单元。 每个部门都有自己的工作。

iOS应用程序的一个常见问题是Massive View Controller。 在这种情况下,视图控制器将成为负责所有其他事项的上帝班级。 如果大三学生在没有任何支持的情况下创建他的第一个应用程序,他可能会在同一View Controller中插入数据访问层,数据解析层,网络层和数据表示形式。

因此,此View Controller具有许多可能的更改点。

是否要使用CRUD操作更改图层?
更改相同的视图控制器。

您是否要更改数据解析层?
更改相同的视图控制器。

您要更改网络层吗?
更改相同的视图控制器。

您要更改网络层吗?
更改相同的视图控制器。

您要更改表示层吗?
更改相同的视图控制器

如果一个班级承担太多责任,它最终会成为出色的意大利面怪物。

如果在一起是在一起,那么也就在一起。 如果有一个直接使用SQLite实现的View Controller A,View Controller B和ViewController,则会出现组合爆炸
ViewControllerASQLite,ViewControllerBSQLite,ViewControllerCSQLite。
如果一天后出于性能原因需要从SQLite(或另一个数据库)更改为Realm(或另一个数据库),则需要在3个位置(ViewController A,B和C)而不是单个本地(存储库)进行更改负责与SQLite实现进行交互。

这将是违反SRP的最原始方法之一。 如果所有内容都在同一类中,则很难创建单元测试,因为甚至不可能进行依赖注入。

但是,解决方案不只是将任务委托给其他类。

在安装和拆卸方法中,可以在实际测试开始之前设置依赖关系,之后再进行清理。 如果您发现自己使用大量不相关的依赖项来填充这些方法,则可能是在违反SRP。 这样,某些测试方法仅需要一些依赖关系,但是仍然有必要模拟其他那些自重的方法。

知道一个班级是否有很多责任的另一种方法是查看顶部的导入。 尽管在Swift中这不再像在目标C中那样明确。

该问题不仅与任务划分有关,而且与类之间的层次结构和类之间的任务协调有关。

解决此问题的一种方法是使用分层体系结构。 与直接与类B和C进行通信的类A相比,A与B进行通信,而后者又与C进行通信。通过这种方式,任务的委派变得不那么集中并且分布更加分散。 这就是VIPER和其他建筑模式尝试做的。

在Clean Architecture中,View / UIViewController与Interactor进行通信,Interactor与Presenter进行通信,Presenter又与View / UIViewController进行通信。 就像一个难题适合另一个难题一样。

想法是使这些类智能地适合其他类,以使任何类都不具有过多的依赖关系。

在App中,ShowListViewModel具有ShowService类的依赖关系(一个依赖关系)。 ShowService类依次具有WebServiceManager和ShowsRepository的依赖关系(两个依赖关系)。
在没有中间层的情况下,ViewModel而不是一个依赖项(ShowService)将具有两个依赖项(ShowRepository和WebServiceManager)。 它不仅负责从外部来源获取数据,还负责了解是否必须从数据库或从Web服务获取此数据。 而不是只有1个变更点,而是有2个变更点。 如果除了这两个依赖项之外,他还有另一个依赖项,那么他将拥有三个。

O —开放封闭原则

开放封闭原则的思想是,一旦编写代码,以后就不应再修改。 即应将其关闭以进行修改。

如果需要实现新功能,则应通过扩展代码而不是修改现有代码(即开放扩展)来完成。

很容易理解,为什么“打开-关闭”原则对于单元测试也很重要,因为如果代码没有更改的弹性并且需要更改,则测试也可能需要随之更改。

制作遵循“开放关闭”原则的应用程序时要牢记的关键事项之一是不对特定的实现进行编程。

类可以隐藏在接口/协议的后面,因此可以在合同之后抽象。 如果程序员以后需要用另一个实现替换特定的实现,或者以一种更动态的方式更改类使用的对象(在运行时),那么它所要做的就是创建或查找符合相同接口/协议的另一个对象。

在UITableView中,将表示逻辑提取到实现数据源协议的外部实体(使用者)。 因此,由于应用到UITableViewCell的多态性,它可以接收具有千种不同格式的单元,因此它变得更加可重用和可扩展。 TableView就像一个空白框架。 它仅提供布局,委派一些UI事件(即,单元格单击)并显示动画。 在MVVM架构模式范例中,与UIViewController(位于View层)的任何相似之处都不只是巧合。

UITableView已经关闭以进行更改,但开发人员将继续以一千种不同的方式对其进行扩展。

苹果还遵循MapKit中的开放式封闭原则。
2012年9月19日(iOS 6),Apple在iOS中发布了其地图服务,取代了Google Maps作为Apple操作系统的默认地图服务。 但是在2012年之前(自iOS 3开始),苹果使用了Google Maps。

即使地图实现细节不同,Map Kit仍可以使用具有相同名称和相同参数的相同方法在iOS 3和iOS 6中使用。 为什么?
MapKit的实现细节隐藏在界面的后面。

基本上,允许类开放/可扩展更改的是抽象。 考虑抽象与具体。 抽象是指可以将不同的东西放在同一把伞上。 是一个适合多个人的时候。

您通常希望避免不必要地过多地对代码进行抽象化,因为这会使代码库变得难以理解或引起错误(例如,当参数不是强类型输入但应该这么做时)。 理想的情况是考虑在有意义的地方插入抽象的战略要点。

在ShowsRepository中,我有filter方法,该方法可以根据某些参数过滤系列。 如果必须选择一个代码单元进行测试,我会选择它,因为它是该应用程序中最复杂的部分之一。

该方法的当前实施不是很有效。 在这个应用程序中,我正在使用Realm,并且在此功能中,我正在使用Swift的本机过滤器功能对系列进行过滤。
因此,我使用的是Realm,但没有使用其最大的优势:零复制。 如果我使用的是Realm,而不是使用“零复制”来处理大量数据,那么我将获得严重的依赖性。

对于数据持久性,开发人员经常使用许多将数据序列化到内存的数据持久性解决方案。 例如,CoreData甚至不是数据库。 CoreData在数据下方使用数据库,例如SQLLite。 数据库以其自己的格式存储数据以优化写入和读取。 但是,然后CoreData必须将此数据反序列化到内存中,以便由Objective C或Swift进行处理,并且(反序列化)过程会影响内存和处理速度的计算能力。 Realm通过不将数据保留在内存中并使用虚拟内存来跳过序列化步骤,这使其速度更快。

领域对象类只是访问器(或代表真实值的代理)。 他们不会自己存储价值。 仅当访问特定字段时才从磁盘读取。 借助此过滤器功能,我将作为Swift中所有对象的属性进行访问。 因此,我正在为所有对象激活反序列化过程,并将它们传递给内存。

在期限紧迫的现实生活中,有时我们不得不选择最佳的实现选择,而这正是单元测试有用的地方。

如果我对过滤器功能进行了单元测试,则可以更改实现,并且仍然可以确信几秒钟后一切仍然可以继续进行。

目前,我不希望找到超过250个系列,因此没有什么区别,但是如果将来我想获得成千上万个系列,那就可以了。

  func filter(filterState:FilterState)-> [显示] { 
  matchObjects = realm.objects(Show.self) 
 结果= result.filter { 
 如果filterState.onlyRunning == true { 
 让结果= 
($ 0.status.value == Show.StatusEnum.Running.rawValue)
 如果(结果==假){ 
 返回假 
  } 
  } 
 如果让filterRating = filterState.rating { 
 保护让itemAverageRating = $ 0.averageRating.value else { 
 返回假 
  } 
 让结果= itemAverageRating> = filterRating.min 
&& itemAverageRating <= filterRating.max
 如果(结果==假){ 
 返回假 
  } 
  } 
  } 

如果将来我将实现更改为使用Realm的fetches请求的实现,那么我可以继续使用完全相同的测试,因为该方法被设计为可以灵活地更改,即使在实现中,我也使用另一个数据库,例如CoreData。

但是,假设该方法具有以下参数:

  filter(filterState:FilterState,realm:Realm) 

在这种情况下,如果我将实现细节更改为CoreData,则还必须更改测试方法。

在Show.swift中,我还避免在属性中公开Realm实现细节。

  var status:整数?  { 
 设置{ 
  statusInternal = RealmOptional (newValue) 
  } 
 得到{ 
 返回statusInternal.value 
  } 
  } 
 私有var statusInternal = RealmOptional () 

L — Liskov替代原理

Liskov替代原则规定如下:

程序中的对象应该可以用其子类型的实例替换,而不会改变该程序的正确性。

LSP适用于合同。 合同可以是类或接口/协议。

违反Liskov原则是对多态性的滥用,因为类型通过使用继承或接口/协议而采用相同的形式,但是它们的行为也不相同。

违反Liskov的经典示例iOS中的替代原理是,当从UIViewController派生的类无法在其viewWillAppear中调用super.viewWillAppear时。 它不能再替代超类行为。 但是,让我们专注于单元测试。

在单元测试中,如果您使用子类而不是接口(请参见上面的依赖项注入),则会增加一个困难,因为它们更容易代表实际对象。

有时,存根完全是愚蠢的或空的(记住假应用程序委托)。 但是在其他情况下,他们需要尽可能地模拟真实对象的行为。 换句话说,他们必须遵守Liskov原则。

在现实世界之外,现实世界中存在一些伪造的事物,它们试图替代或模仿真实事物的行为,并惨遭失败。 我相信您会想到一些很好的例子。 想法是使存根尽可能简单,而又不代表真实的系统。

例如,如果您要测试负责解析JSON或JSON映射的类,则不应依赖于来自Web服务的JSON。 您必须使用伪造的JSON创建自己的存根,伪造的JSON应该代表来自真实Web服务的JSON情况。

最后🙂。 我无法注释整个代码,否则文章将太长。

感谢您的阅读! 如果您喜欢这篇文章,请鼓掌
👏🏻这样其他人也可以阅读🙂