通过框架极简主义和软件架构使测试变得更容易

已发表: 2022-03-10
快速总结 ↬与软件开发中的许多其他主题一样,测试和测试驱动开发通常在理论和实现上变得不必要地复杂,因为过于强调学习广泛的测试框架。 在本文中,我们将通过一个简单的类比重新审视测试的含义,探索软件架构中的概念,这些概念将直接导致对测试框架的需求减少,以及一些关于为什么您可能会从测试过程中的极简主义态度中受益的论点.

像许多其他 Android 开发人员一样,我最初尝试在该平台上进行测试使我立即面对令人沮丧的行话。 此外,我当时(大约 2015 年)遇到的几个例子并没有提供实际用例,这可能让我认为学习像Espresso这样的工具以验证TextView.setText( ...)工作正常,是一项合理的投资。

更糟糕的是,我在理论或实践上对软件架构没有实际的理解,这意味着即使我费心去学习这些框架,我也会为由几个god类组成的单体应用程序编写测试,编写在意大利面条代码中。 关键是,无论您的框架专业知识如何,构建、测试和维护此类应用程序都是一种自我破坏的练习; 然而,只有在构建了一个模块化松散耦合高度内聚的应用程序之后,这种认识才会变得清晰。

从这里我们到达本文讨论的主要观点之一,我将在这里用通俗易懂的语言总结一下:应用软件架构的黄金原则的主要好处之一(别担心,我将通过简单的示例和语言),是你的代码可以变得更容易测试。 应用这些原则还有其他好处,但软件架构和测试之间的关系是本文的重点。

然而,为了那些希望了解我们为什么以及如何测试我们的代码的人,我们将首先探索类比测试的概念; 无需您记住任何行话。 在深入探讨主要主题之前,我们还将研究为什么存在如此多的测试框架的问题,因为在研究这一点时,我们可能会开始看到它们的好处、局限性,甚至可能是替代解决方案。

跳跃后更多! 继续往下看↓

测试:为什么以及如何

对于任何经验丰富的测试人员来说,本节都不是新信息,但也许您仍然会喜欢这个类比。 当然,我是一名软件工程师,而不是火箭工程师,但我将借用一个类比,它与在物理空间和计算机内存空间中设计和构建对象有关。 事实证明,虽然介质发生了变化,但过程原则上是完全一样的。

假设我们是火箭工程师,我们的工作是建造航天飞机的第一级*火箭助推器。 假设我们已经为第一阶段提出了一个可使用的设计,以便在各种条件下开始构建和测试。

“第一级”是指火箭首次发射时发射的助推器

在我们开始这个过程之前,我想指出为什么我更喜欢这个类比:你不应该有任何困难来回答这个问题,即为什么我们在将设计置于危及人类生命的情况之前要费心测试我们的设计。 虽然我不会试图说服您在发布之前测试您的应用程序可以挽救生命(尽管这可能取决于应用程序的性质),但它可以节省评级、评论和您的工作。 在最广泛的意义上,测试是我们确保单个部件、几个组件和整个系统在我们将它们用于不发生故障至关重要的情况之前工作的方式。

回到这个类比的方式方面,我将介绍工程师测试特定设计的过程:冗余。 冗余原则上很简单:将要测试的组件的副本构建为与您希望在启动时使用的设计规范相同的设计规范。 在严格控制前提条件和变量的隔离环境中测试这些副本。 虽然这并不能保证火箭助推器在集成到整个航天飞机中时能够正常工作,但可以肯定的是,如果它不能在受控环境中工作,那么它根本不可能工作。

假设火箭设计的副本已针对数百个或数千个变量进行了测试,它归结为火箭助推器将被测试发射的环境温度。 在 35°C 下进行测试后,我们看到一切正常运行。 再次,火箭在大致室温下进行测试,没有失败。 最终测试将在发射场的最低记录温度下进行,即 -5° 摄氏度。 在最后一次测试中,火箭发射了,但在很短的一段时间后,火箭突然爆发,不久后猛烈爆炸; 但幸运的是在受控和安全的环境中。

在这一点上,我们知道温度的变化似乎至少与失败的测试有关,这让我们考虑火箭助推器的哪些部分可能会受到低温的不利影响。 随着时间的推移,人们发现一个关键部件,即用于阻止燃料从一个隔间流向另一个隔间的橡胶O 形环,在暴露于接近或低于冰点的温度时变得僵硬且无效。

您可能已经注意到,他的类比大致是基于挑战者号航天飞机灾难的悲惨事件。 对于那些不熟悉的人来说,可悲的事实(就调查得出的结论而言)是有很多失败的测试和工程师发出的警告,但行政和政治问题促使发射继续进行。 无论如何,不​​管你是否记住了冗余这个术语,我希望你已经掌握了测试任何系统部件的基本过程。

关于软件

虽然前面的类比解释了测试火箭的基本过程(同时对细节进行了大量的自由化),但我现在将以一种可能与您和我更相关的方式进行总结。虽然可以通过仅发射来测试软件一旦它处于任何类型的可部署状态,它就可以应用于设备,我想我们可以先将冗余原则应用于应用程序的各个部分。

这意味着我们创建整个应用程序的较小部分(通常称为软件单元)的副本,设置一个隔离的测试环境,并根据可能发生的任何变量、参数、事件和响应查看它们的行为在运行时。 测试确实和理论上一样简单,但要达到这个过程的关键在于构建可测试的应用程序。 这归结为两个问题,我们将在接下来的两节中讨论。 第一个问题与测试环境有关,第二个问题与我们构建应用程序的方式有关。

为什么我们需要框架?

为了测试一个软件(以下称为Unit ,尽管这个定义故意过度简化),有必要有某种测试环境,允许您在运行时与您的软件交互。 对于那些构建纯粹在JVMJava 虚拟机)环境中执行的应用程序而言,编写测试所需的只是JREJava 运行时环境)。 以这个非常简单的Calculator类为例:

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

在没有任何框架的情况下,只要我们有一个包含实际执行代码的main函数的测试类,我们就可以对其进行测试。 您可能还记得, main函数表示一个简单 Java 程序的执行起点。 至于我们要测试的内容,我们只是将一些测试数据输入到计算器的函数中,并验证它是否正确执行基本算术:

 public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }

测试 Android 应用程序当然是一个完全不同的过程。 尽管在ZygoteInit.java文件的源代码中隐藏了一个main函数(其细节在此不重要),它在 Android 应用程序在JVM上启动之前被调用,但即使是初级 Android 开发人员也应该知道系统本身负责调用这个函数; 不是开发商。 相反,Android 应用程序的入口点恰好是Application类,以及系统可以通过AndroidManifest.xml文件指向的任何Activity类。

所有这一切都只是一个事实,即在 Android 应用程序中测试单元呈现出更高级别的复杂性,严格来说是因为我们的测试环境现在必须考虑到 Android 平台。

解决紧耦合问题

紧耦合是描述依赖于特定平台、框架、语言和库的函数、类或应用程序模块的术语。 这是一个相对术语,意味着我们的Calculator.java示例与 Java 编程语言和标准库紧密耦合,但这就是它的耦合程度。 同样,测试与 Android 平台紧密耦合的类的问题是,您必须找到一种使用或围绕平台工作的方法。

对于与 Android 平台紧密耦合的类,您有两种选择。 第一个是简单地将您的类部署到 Android 设备(物理或虚拟)。 虽然我确实建议您在将应用程序代码交付到生产环境之前对其进行测试部署,但在开发过程的早期和中期阶段,这种方法效率极低。

Unit ,无论您喜欢哪种技术定义,通常都被认为是类中的单个函数(尽管有些扩展定义以包括由初始单个函数调用在内部调用的后续辅助函数)。 无论哪种方式,单位都应该很小。 构建、编译和部署整个应用程序来测试单个单元完全忽略了孤立测试的要点。

紧耦合问题的另一个解决方案是使用测试框架与平台依赖项进行交互,或模拟(模拟)平台依赖项。 与以前的方法相比, EspressoRobolectric等框架为开发人员提供了更有效的单元测试方法; 前者对于在设备上运行的测试很有用(称为“仪器测试”,因为显然称它们为设备测试并不够含糊),后者能够在 JVM 上本地模拟 Android 框架。

在我开始反对这些框架而不是我将很快讨论的替代方案之前,我想明确一点,我并不是要暗示你永远不应该使用这些选项。 开发人员用来构建和测试他们的应用程序的过程应该结合个人喜好和对效率的关注。

对于那些不喜欢构建模块化和松散耦合应用程序的人来说,如果您希望获得足够的测试覆盖率,您将别无选择,只能熟悉这些框架。 许多精彩的应用程序都是以这种方式构建的,而且我经常被指责使我的应用程序过于模块化和抽象。 无论您采用我的方法还是决定严重依赖框架,我都感谢您投入时间和精力来测试您的应用程序。

让你的框架保持一定距离

对于本文核心课程的最后序言,值得讨论一下为什么在使用框架时您可能希望保持极简主义的态度(这不仅适用于测试框架)。 上面的副标题是对软件最佳实践的宽宏大量的老师的释义:罗伯特“鲍勃叔叔”C.马丁。 自从我第一次研究他的作品以来,他给我的许多宝石中,这一个需要几年的直接经验才能掌握。

就我理解这句话的含义而言,使用框架的成本在于学习和维护它们所需的时间投资。 其中一些变化非常频繁,而一些变化不够频繁。 功能被弃用,框架停止维护,每 6 到 24 个月就会有一个新框架出现以取代最后一个。 因此,如果你能找到一个可以作为平台或语言特性实现的解决方案(这往往会持续更长时间),它往往更能抵抗上述各种类型的变化。

从技术角度讲, Espresso和较小程度的Robolectric等框架永远无法像简单的JUnit测试,甚至是早期的无框架测试那样高效地运行。 虽然JUnit确实是一个框架,但它与JVM紧密耦合,它的变化速度往往比 Android 平台本身要慢得多。 更少的框架几乎总是意味着代码在执行和编写一个或多个测试所需的时间方面更有效率。

由此,您可能会了解到,我们现在将讨论一种方法,该方法将利用一些技术,使我们能够与 Android 平台保持一定距离; 同时让我们有足够的代码覆盖率、测试效率,以及在需要时仍然在这里或那里使用框架的机会。

建筑艺术

用一个愚蠢的类比,人们可能会认为框架和平台就像霸道的同事,除非你与它们设置适当的界限,否则它们将接管你的开发过程。 软件架构的黄金原则可以为您提供创建和实施这些边界所必需的一般概念和特定技术。 正如我们稍后将看到的,如果您曾经想知道在代码中应用软件架构原则的真正好处是什么,一些直接和间接地使您的代码更容易测试。

关注点分离

在我看来,关注点分离是整个软件架构中最普遍适用和最有用的概念(并不是说其他​​应该被忽略)。 关注点分离 (SOC) 可以在我所知道的软件开发的每个角度应用或完全忽略。 为了简要总结这个概念,我们将研究 SOC 应用于类时,但请注意,SOC 可以通过广泛使用辅助函数来应用于函数,并且可以外推到应用程序的整个模块(“模块”在Android/Gradle 的上下文)。

如果您花了很多时间研究 GUI 应用程序的软件架构模式,您可能会遇到至少以下一种:模型-视图-控制器 (MVC)、模型-视图-演示者 (MVP) 或模型-视图-视图模型(MVVM)。 在构建了各种风格的应用程序之后,我会先说我不认为它们中的任何一个是所有项目(甚至单个项目中的功能)的最佳选择。 具有讽刺意味的是,Android 团队几年前提出的作为他们推荐方法的模式,MVVM,在没有 Android 特定测试框架的情况下似乎是最难测试的(假设您希望使用 Android 平台的 ViewModel 类,我承认这是一个粉丝的)。

无论如何,这些模式的细节不如它们的普遍性重要。 所有这些模式只是 SOC 的不同风格,它们强调三种代码的基本分离,我称之为:数据用户界面逻辑

那么,分离DataUser InterfaceLogic究竟如何帮助您测试应用程序? 答案是,通过将必须处理平台/框架依赖关系的类中的逻辑提取到具有很少或没有平台/框架依赖关系的类中,测试变得容易并且框架最小化。 为了清楚起见,我一般说的是必须呈现用户界面、将数据存储在 SQL 表中或连接到远程服务器的类。 为了演示它是如何工作的,让我们看一个假设的 Android 应用程序的简化的三层架构。

第一类将管理我们的用户界面。 为了简单起见,我为此使用了一个Activity ,但我通常选择Fragments作为用户界面类。 无论哪种情况,这两个类都与Android平台呈现出类似的紧密耦合:

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

如您所见, Activity有两个工作:首先,由于它是Android应用程序给定功能的入口点,因此它充当该功能其他组件的一种容器。 简单来说,可以将容器视为一种根类,其他组件最终通过引用(或本例中的私有成员字段)连接到该根类。 它还扩展、绑定引用并将侦听器添加到 XML 布局(用户界面)。

测试控制逻辑

我们不是让Activity在后端拥有对具体类的引用,而是让它与CalculatorContract.IControlLogic. 我们将在下一节讨论为什么这是一个接口。 现在,只需了解该界面另一侧的任何内容都应该是PresenterController之类的东西。 由于此类将控制前端 Activity后端 Calculator之间的交互,因此我选择将其称为CalculatorControlLogic

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

关于这个类的设计方式有很多微妙的地方使它更容易测试。 首先,它的所有引用要么来自 Java 标准库,要么来自应用程序中定义的接口。 这意味着在没有任何框架的情况下测试这个类绝对是一件轻而易举的事,并且可以在JVM上本地完成。 另一个小而有用的提示是,这个类的所有不同交互都可以通过一个通用的handleInput(...)函数来调用。 这提供了一个单一的入口点来测试这个类的每一个行为。

另请注意,在evaluateExpression()函数中,我从后端返回了一个Optional<String>类型的类。 通常我会使用函数式程序员所说的Either Monad ,或者我更喜欢称之为Result Wrapper 。 无论您使用什么愚蠢的名称,它都是一个能够通过单个函数调用表示多个不同状态的对象。 Optional是一个更简单的构造,它可以表示null或提供的泛型类型的某个值。 在任何情况下,由于后端可能会给出一个无效的表达式,我们希望给ControlLogic类一些方法来确定后端操作的结果; 对成功和失败都有解释。 在这种情况下, null将表示失败。

下面是一个使用JUnit编写的示例测试类,一个在测试术语中称为Fake的类:

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

如您所见,这个测试套件不仅可以非常快速地执行,而且编写起来根本不需要太多时间。 无论如何,我们现在将看一些更微妙的东西,它们使编写这个测试类变得非常容易。

抽象和依赖倒置的力量

还有两个重要的概念已应用于CalculatorControlLogic ,这使得测试变得非常容易。 首先,如果您想知道在 Java 中使用接口抽象类(统称为抽象)有什么好处,上面的代码就是一个直接的演示。 由于要测试的类引用抽象而不是具体类,我们能够从我们的测试类中为用户界面后端创建测试替身。 只要这些测试替身实现了适当的接口, CalculatorControlLogic就不会在意它们不是真实的东西。

其次, CalculatorControlLogic已经通过构造函数(是的,这是依赖注入的一种形式)获得了它的依赖关系,而不是创建自己的依赖关系。 因此,在生产或测试环境中使用时不需要重新编写,这对效率来说是一个加分项。

依赖注入控制反转的一种形式,这是一个用简单语言定义的棘手概念。 无论您使用依赖注入还是服务定位器模式,它们都实现了 Martin Fowler(我最喜欢的此类主题的老师)所描述的“配置与使用分离的原则”。 这导致类更容易测试,并且更容易彼此隔离构建。

测试计算逻辑

最后,我们来到ComputationLogic类,它应该近似于IO 设备,例如远程服务器或本地数据库的适配器。 由于我们不需要一个简单的计算器,它只负责封装验证和评估我们给它的表达式所需的逻辑:

 public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }

关于这个类没有太多要说的,因为通常会与特定的后端库有一些紧密耦合,这会出现与与 Android 紧密耦合的类类似的问题。 稍后我们将讨论如何处理这些类,但这个类很容易测试,我们不妨试一试:

 public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }

最容易测试的类是那些简单地被赋予一些值或对象,并且期望返回结果而不需要调用一些外部依赖项的类。 无论如何,无论你应用了多少软件架构魔法,你仍然需要担心无法与平台和框架分离的类。 幸运的是,我们仍然有一种方法可以使用软件架构:最坏的情况是使这些类更容易测试,最好的情况是简单到可以一目了然地完成测试。

卑微的对象和被动的观点

上述两个名称指的是一种模式,其中必须与低级依赖项对话的对象被简化到可以说不需要测试的程度。 我是通过 Martin Fowler 的关于 Model-View-Presenter 变体的博客首次向我介绍这种模式的。 后来,通过 Robert C. Martin 的作品,我了解到将某些类视为Humble Objects的想法,这意味着这种模式不需要仅限于用户界面类(尽管我并不是说 Fowler 曾经暗示了这样的限制)。

无论您选择如何称呼这种模式,它都非常易于理解,并且在某种意义上我相信它实际上只是将 SOC 严格应用到您的课程中的结果。 虽然这种模式也适用于后端类,但我们将使用我们的用户界面类来演示这一原则。 分离非常简单:与平台和框架依赖项交互的类不会自己思考(因此有HumblePassive的绰号)。 当事件发生时,他们唯一要做的就是将此事件的详细信息转发给恰好正在侦听的任何逻辑类:

 //from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });

逻辑类应该很容易测试,然后负责以非常细粒度的方式控制用户界面。 与其在user interface类上调用单个通用updateUserInterface(...)函数并让其完成批量更新的工作, user interface (或其他此类)将拥有应该易于使用的小而特定的函数名称和实现:

 //Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…

原则上,这两个示例应该足以让您了解如何实现此模式。 拥有逻辑的对象是松耦合的,而与讨厌的依赖关系紧密耦合的对象几乎没有逻辑。

Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...) calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. 哎哟。

It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.

Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic class (whether it be a Controller , Presenter , or even a ViewModel depending on how you use it), becomes a God class.

While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.

This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.

进一步的考虑

After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.

For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.

My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .

关于 SmashingMag 的进一步阅读

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSS 框架或 CSS 网格:我的项目应该使用什么?
  • 使用 Google 的 Flutter 进行真正的跨平台移动开发