Angular-测试驱动开发-全-

Angular 测试驱动开发(全)

原文:zh.annas-archive.org/md5/60F96C36D64CD0F22F8885CC69A834D2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将为读者提供一个关于 JavaScript 测试驱动开发(TDD)的完整指南,然后深入探讨 Angular 的方法。它将提供清晰的、逐步的示例,不断强调 TDD 的最佳实践。本书将同时关注使用 Karma 进行单元测试和使用 Protractor 进行端到端测试,不仅关注如何使用工具,还要理解它们的构建原因以及为什么应该使用它们。在整个过程中,将重点关注何时、何地以及如何使用这些工具,不断强调测试驱动开发生命周期(测试、执行和重构)的原则。

本书中的所有示例都基于 Angular v2,并与 Angular v4 兼容。

本书涵盖的内容

第一章,测试驱动开发简介,通过解释 TDD 如何在开发过程中发挥作用,向我们介绍了测试驱动开发的基本原理。

第二章,JavaScript 测试的细节,涵盖了 JavaScript 环境下的 TDD。本章探讨了 JavaScript 应用程序中需要的测试类型,如单元测试、行为测试、集成测试和端到端测试。还解释了不同类型的 JavaScript 测试工具、框架及其在 Angular 应用程序中的用途。

第三章,Karma 之道,探讨了 Karma 的起源以及为什么在任何 Angular 项目中都必须使用它。通过本章的学习,读者不仅将了解 Karma 解决的问题,还将通过一个完整的示例来使用 Karma。

第四章,使用 Protractor 进行端到端测试,深入研究了端到端测试应用程序,涵盖了应用程序的所有层。本章向读者介绍了 Protractor,Angular 团队的端到端测试工具。然后解释了 Protractor 的创建原因以及它如何解决问题。最后,它逐步指导读者如何在现有的 Angular 项目中安装、配置和使用 Protractor 进行 TDD。

第五章,Protractor,领先一步,深入探讨了 Protractor 并探索了一些高级配置。然后,它通过示例解释了测试套件的调试过程。这还探讨了一些常用的 Protractor API,并附有相关示例。

第六章,第一步,介绍了如何使用 TDD 来构建具有类、组件和服务的 Angular 应用程序的入门步骤。本章帮助读者开始 TDD 之旅,并看到基本原理的实际应用。到目前为止,本书专注于 TDD 和工具的基础。然后,通过向前迈进一步,它深入探讨了与 Angular 一起使用 TDD。

第七章,翻转,继续扩展我们对使用 TDD 与 Angular 功能的知识,例如路由和导航,以通过我们的示例 Angular 应用程序获得结果集。除了 Angular 功能,本书还指导读者如何使用 Protractor 的帮助对这些特定功能进行端到端测试。

第八章,告诉世界,涵盖了示例 Angular 应用程序的更多单元测试,包括路由和导航。除此之外,本章还重构了现有代码,使其更具可测试性。然后,在重构代码的同时介绍了 Angular 服务和事件广播,并引入了 MockBackend 来测试服务中的 HTTP 请求。

你需要为这本书做什么

在本书中,我们使用Node Package Managernpm)作为运行应用程序和各种测试工具的命令工具。因此,全局安装 npm 是先决条件。要安装它,必须在操作系统上安装 Node.js。

我们不会讨论如何安装 Node.js 和 npm。已经有很多资源可用于在任何操作系统上安装它们。

这本书适合谁

这本书适合有基本的 Angular 经验但想要了解何时、为什么以及如何应用测试技术和最佳实践来创建高质量清晰代码的开发人员。要充分利用本书,您应该对 HTML、CSS 和 JavaScript 有很好的理解,并对带有 TypeScript 的 Angular 有基本的了解。

规范

在本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:"下面的代码行读取链接并将其分配给Calculator函数。"

代码块设置如下:

var calculator = {
   multiply : function(amount1, amount2) {
       return amount1 * amount2;
   }
};

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

<!DOCTYPE html>
<html>
  <head>
    <title>Test Runner</title>
  </head>
  <body>
    // ...
 **<script src="calculator.js"></script>**
  </body>
</html>

任何命令行输入或输出都以以下方式编写:

**$ npm install protractor
$ npm protractor --version**

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器"。

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会以这种方式出现。

第一章:介绍测试驱动开发

Angular 处于客户端 JavaScript 测试的前沿。每个 Angular 教程都包括相应的测试,甚至测试模块都是核心 Angular 包的一部分。Angular 团队致力于使测试成为 Web 开发的基础。

本章向您介绍了使用 Angular 进行测试驱动开发TDD)的基础知识,包括以下主题:

  • TDD 概述

  • TDD 生命周期:先测试,使其运行,然后改进

  • 常见的测试技术

TDD 概述

TDD 是一种演进式的开发方法,您在编写足够的生产代码来满足测试及其重构之前编写测试。

本节将探讨 TDD 的基础知识。让我们以裁缝为例,看看他如何将 TDD 应用到自己的流程中。

TDD 的基础知识

在开始编写代码之前就了解要写什么。这可能听起来陈词滥调,但这基本上就是 TDD 给您的。TDD 从定义期望开始,然后让您满足期望,最后在满足期望后强迫您对更改进行精炼。

练习 TDD 的一些明显好处如下:

  • 没有小改变:小改变可能会在整个项目中引起许多破坏性问题。实践 TDD 是唯一可以帮助的方法,因为测试套件将捕捉破坏点并在任何更改后保存项目,从而拯救开发人员的生命。

  • 明确定义任务:测试套件明确提供了任务的清晰视野和逐步工作流程,以便取得成功。首先设置测试允许您只关注在测试中定义的组件。

  • 重构的信心:重构涉及移动、修复和更改项目。测试通过确保逻辑独立于代码结构的行为,保护核心逻辑免受重构的影响。

  • 前期投资,未来收益:最初,测试似乎需要额外的时间,但当项目变得更大时,实际上会在以后得到回报,它让我们有信心扩展功能,只需运行测试就能识别出任何破坏性问题。

  • QA 资源可能有限:在大多数情况下,QA 资源是有限的,因为让 QA 团队手动检查一切都需要额外的时间,但编写一些测试用例并成功运行它们肯定会节省一些 QA 时间。

  • 文档化:测试定义了特定对象或函数必须满足的期望。期望充当合同,可以用来查看方法应该或可以如何使用。这使得代码更易读和理解。

用不同的眼光来衡量成功

TDD 不仅是一种软件开发实践--它的基本原则也被其他工匠所共享。其中之一就是裁缝,他的成功取决于精确的测量和周密的计划。

分解步骤

以下是裁缝制作西装的高级步骤:

  1. 先测试
  • 确定西装的尺寸

  • 让客户确定他们想要西装的风格和材料

  • 测量客户的手臂、肩膀、躯干、腰部和腿

  1. 进行裁剪
  • 根据所需的风格选择面料

  • 根据客户的身形测量面料

  • 根据测量裁剪面料

  1. 重构
  • 将裁剪和外观与客户所需的风格进行比较

  • 进行调整以满足所需的风格

  1. 重复
  • 先测试:确定西装的尺寸

  • 进行裁剪:测量面料并进行裁剪

  • 重构:根据审查进行更改

上述步骤是 TDD 方法的一个例子。裁缝必须在开始裁剪原材料之前进行测量。想象一下,如果裁缝没有使用测试驱动的方法,也没有使用测量工具(尺寸),那将是荒谬的。如果裁缝在测量之前就开始裁剪会发生什么?如果面料被剪得太短会发生什么?裁缝需要多少额外时间来裁剪?因此,要多测量,少裁剪。

作为开发者,你是否会“在测量之前就剪裁”?你会相信一个没有测量工具的裁缝吗?你会如何看待一个不进行测试的开发者?

多次测量,一次裁剪

裁缝总是从测量开始。如果裁缝在测量之前就开始裁剪会发生什么?如果面料被剪得太短会发生什么?裁缝需要多少额外时间来裁剪?因此,要多测量,少裁剪。

软件开发人员可以在开始开发之前选择无数种方法。一个常见的方法是根据规范进行工作。文档化的方法可能有助于定义需要构建的内容;然而,如果没有明确的标准来满足规范,实际开发的应用可能与规范完全不同。采用 TDD 方法,过程的每个阶段都验证结果是否符合规范。想象一下裁缝在整个过程中继续使用卷尺来验证西装。

TDD 体现了测试优先的方法论。TDD 使开发人员能够以明确的目标开始,并编写直接满足规范的代码,因此您可以像专业人士一样开发,并遵循有助于编写高质量软件的实践。

JavaScript 实用 TDD

让我们深入了解 JavaScript 环境中的实际 TDD。这个演练将带领我们完成向计算器添加乘法功能的过程。

只需记住以下 TDD 生命周期:

  • 先测试

  • 让它运行

  • 让它变得更好

指出开发待办事项清单

开发待办事项清单有助于组织和专注于单独的任务。它还可以在开发过程中提供一个列出想法的平台,这些想法以后可能成为单一功能。

让我们在开发待办事项清单中添加第一个功能--添加乘法功能:

3 * 3 = 9

上述清单描述了需要做的事情。它还清楚地说明了如何验证乘法3 * 3 = 9

设置测试套件

为了设置测试,让我们在一个名为calculator.js的文件中创建初始计算器。它初始化为一个对象,如下所示:

var calculator = {}; 

测试将通过网页浏览器运行,作为一个简单的 HTML 页面。因此,让我们创建一个 HTML 页面,并导入calculator.js进行测试,并将页面保存为testRunner.html

要运行测试,让我们在网页浏览器中打开testRunner.html文件。

testRunner.html文件将如下所示:

<!DOCTYPE html> 
<html> 
<head> 
  <title>Test Runner</title> 
</head> 
<body> 

<script src="calculator.js"></script> 
</body> 
</html> 

项目的测试套件已经准备就绪,功能的开发待办事项清单也已准备就绪。下一步是根据功能列表逐个深入 TDD 生命周期。

先测试

虽然编写一个乘法函数很容易,并且它将像一个非常简单的功能一样工作,但作为练习 TDD 的一部分,现在是时候遵循 TDD 生命周期了。生命周期的第一阶段是根据开发待办事项编写测试。

以下是第一次测试的步骤:

  1. 打开calculator.js

  2. 创建一个新的函数multipleTest1来测试乘法*3 * 3,之后calculator.js文件将如下所示:

        function multipleTest1() { 
            // Test 
            var result = calculator.multiply(3, 3); 

            // Assert Result is expected 
            if (result === 9) { 
                console.log('Test Passed'); 
            } else { 
                console.log('Test Failed'); 
            } 
        };  

        multipleTest1();

测试调用一个尚未定义的multiply函数。然后通过显示通过或失败消息来断言结果是否符合预期。

注意

请记住,在 TDD 中,您正在考虑方法的使用,并明确编写它应该如何使用。这使您能够根据用例定义接口,而不仅仅是查看正在开发的功能的有限范围。

TDD 生命周期中的下一步是使测试运行。

使测试运行

在这一步中,我们将运行测试,就像裁缝对套装进行了测量一样。测试步骤中进行了测量,现在可以调整应用程序以适应这些测量。

以下是运行测试的步骤:

  1. 在 Web 浏览器上打开testRunner.html

  2. 在浏览器中打开 JavaScript 开发者控制台窗口。

测试将抛出错误,这将在浏览器的开发者控制台中可见,如下截图所示:

使测试运行

抛出的错误是预期的,因为计算器应用程序调用了尚未创建的函数--calculator.multiply

在 TDD 中,重点是添加最简单的更改以使测试通过。实际上不需要实现乘法逻辑。这可能看起来不直观。关键是一旦存在通过的测试,它应该始终通过。当一个方法包含相当复杂的逻辑时,更容易运行通过的测试来确保它符合预期。

可以做的最简单的更改是什么,以使测试通过?通过返回预期值9,测试应该通过。虽然这不会添加乘法功能,但它将确认应用程序的连接。此外,在我们通过了测试之后,未来的更改将变得容易,因为我们只需保持测试通过即可!

现在,添加multiply函数,并使其返回所需的值9,如下所示:

var calculator = { 
    multiply : function() { 
        return 9; 
    } 
}; 

现在,让我们刷新页面重新运行测试,并查看 JavaScript 控制台。结果应该如下截图所示:

使测试运行

是的!没有错误了。有一条消息显示测试已经通过。

现在有了通过的测试,下一步将是从multiply函数中删除硬编码的值。

让项目变得更好

重构步骤需要从multiply函数中删除硬编码的return值,这是我们为了通过测试而添加的最简单的解决方案,并添加所需的逻辑以获得预期的结果。

所需的逻辑如下:

var calculator = { 
    multiply : function(amount1, amount2) { 
        return amount1 * amount2; 
    } 
}; 

现在,让我们刷新浏览器重新运行测试;它将像之前一样通过测试。太棒了!现在multiply函数已经完成。

calculator.js文件的完整代码,用于calculator对象及其测试,如下所示:

var calculator = { 
    multiply : function(amount1, amount2) { 
        return amount1 * amount2; 
    } 
}; 

function multipleTest1() { 
    // Test 
    var result = calculator.multiply(3, 3); 

    // Assert Result is expected 
    if (result === 9) { 
        console.log('Test Passed'); 
    } else { 
        console.log('Test Failed'); 
    } 
}

multipleTest1(); 

测试机制

要成为一个合格的 TDD 开发者,重要的是要了解一些测试技术的基本机制和测试方法。在本节中,我们将通过几个测试技术和机制的示例来介绍这本书中将要使用的。

这将主要包括以下几点:

  • 使用Jasmine间谍进行测试替身

  • 重构现有测试

  • 构建模式

以下是将要使用的其他术语:

  • 被测试的函数:这是正在测试的函数。它也被称为被测试系统、被测试对象等。

  • 3A(安排、行动和断言):这是一种用于设置测试的技术,最初由 Bill Wake 描述(xp123.com/articles/3a-arrange-act-assert/)。3A 将在第二章中进一步讨论,JavaScript 测试的详细信息

使用框架进行测试

我们已经看到了在计算器应用程序上执行测试的快速简单方法,我们已经为multiply方法设置了测试。但在现实生活中,这将会更加复杂,应用程序也会更大,早期的技术将会过于复杂,难以管理和执行。在这种情况下,使用测试框架会更方便、更容易。测试框架提供了测试的方法和结构。这包括创建和运行测试的标准结构,创建断言/期望的能力,使用测试替身的能力,以及更多。本书使用 Jasmine 作为测试框架。Jasmine 是一个行为驱动的测试框架。它与测试 Angular 应用程序非常兼容。在第二章中,JavaScript 测试的详细信息,我们将深入了解 Jasmine。

以下示例代码并不完全是在 Jasmine 测试/规范运行时的运行方式,它只是关于测试替身如何工作或这些测试替身如何返回预期结果的想法。在第二章中,JavaScript 测试的详细信息,我们将准确展示这个测试替身应该如何与 Jasmine 规范运行器一起使用。

使用 Jasmine 间谍进行测试替身

测试替身是一个充当并用于代替另一个对象的对象。Jasmine 有一个测试替身函数,称为spies。Jasmine 间谍与spyOn()方法一起使用。

让我们看一下需要进行测试的testableObject对象。使用测试替身,我们可以确定testableFunction被调用的次数。

以下是测试替身的示例:

var testableObject = { 
    testableFunction : function() { } 
}; 
jasmine.spyOn(testableObject, 'testableFunction'); 

testableObject.testableFunction(); 
testableObject.testableFunction(); 
testableObject.testableFunction(); 

console.log(testableObject.testableFunction.count); 

上述代码使用 Jasmine 间谍(jasmine.spyOn)创建了一个测试替身。以下是 Jasmine 测试替身提供的一些功能:

  • 函数调用次数

  • 指定返回值的能力(存根返回值)

  • 传递给底层函数的调用能力(传递)

在本书中,我们将进一步学习测试替身的使用经验。

存根返回值

使用测试替身的好处是方法的底层代码不必被调用。通过测试替身,我们可以准确指定方法在给定测试中应该返回什么。

考虑以下对象和函数的示例,其中函数返回一个字符串:

var testableObject = { 
    testableFunction : function() { return 'stub me'; } 
}; 

前述对象testableObject有一个需要存根化的函数testableFunction

因此,要存根化单个返回值,需要链式调用and.returnValue方法,并将预期值作为param传递。

以下是如何将单个返回值进行间谍链以进行存根化:

jasmine.spyOn(testableObject, 'testableFunction') 
.and 
.returnValue('stubbed value'); 

现在,当调用testableObject.testableFunction时,将返回stubbed value

考虑前述单个stubbed value的示例:

var testableObject = { 
    testableFunction : function() { return 'stub me'; } 
}; 
//before the return value is stubbed 
Console.log(testableObject.testableFunction()); 
//displays 'stub me' 

jasmine.spyOn(testableObject,'testableFunction') 
.and 
.returnValue('stubbed value'); 

//After the return value is stubbed 
Console.log(testableObject.testableFunction()); 
//displays 'stubbed value' 

类似地,我们可以像前面的示例一样传递多个返回值。

以下是如何将多个返回值进行间谍链以逐个进行存根化:

jasmine.spyOn(testableObject, 'testableFunction') 
.and 
.returnValues('first stubbed value', 'second stubbed value', 'third stubbed value'); 

因此,对于每次调用testableObject.testableFunction,它将按顺序返回存根化的值,直到达到返回值列表的末尾。

考虑前面多个存根化值的示例:

jasmine.spyOn(testableObject, 'testableFunction') 
.and 
.returnValue('first stubbed value', 'second stubbed value', 'third stubbed value'); 

//After the is stubbed return values 
Console.log(testableObject.testableFunction()); 
//displays 'first stubbed value' 
Console.log(testableObject.testableFunction()); 
//displays 'second stubbed value' 
Console.log(testableObject.testableFunction()); 
//displays 'third stubbed value' 

测试参数

测试替身提供了关于应用程序中方法使用方式的见解。例如,测试可能希望断言方法被调用时使用的参数,或者方法被调用的次数。以下是一个示例函数:

var testableObject = { 
    testableFunction : function(arg1, arg2) {} 
}; 

以下是测试调用前述函数时使用的参数的步骤:

  1. 创建一个间谍,以便捕获调用的参数:
        jasmine.spyOn(testableObject, 'testableFunction'); 

  1. 然后,要访问参数,请运行以下命令:
        //Get the arguments for the first call of the function 
        var callArgs = testableObject.testableFunction
        .call.argsFor(0); 

        console.log(callArgs); 
        //displays ['param1', 'param2'] 

以下是如何使用console.log显示参数:

var testableObject = { 
    testableFunction : function(arg1, arg2) {} 
}; 
//create the spy 
jasmine.spyOn(testableObject, 'testableFunction'); 

//Call the method with specific arguments 
  testableObject.testableFunction('param1', 'param2'); 

//Get the arguments for the first call of the function 
var callArgs = testableObject.testableFunction.call.argsFor(0); 

console.log(callArgs); 
//displays ['param1', 'param2'] 

重构

重构是重构、重写、重命名和删除代码的行为,以改善代码的设计、可读性、可维护性和整体美感。TDD 生命周期步骤使项目变得更好主要涉及重构。本节将通过一个重构示例引导我们。

看一下需要重构的函数的以下示例:

var abc = function(z) { 
    var x = false; 
    if(z > 10) 
        return true; 
    return x; 
} 

这个函数运行良好,没有包含任何语法或逻辑问题。问题在于这个函数很难阅读和理解。重构这个函数将改善其命名、结构和定义。这个练习将消除伪装的复杂性,揭示函数的真正含义和意图。

以下是步骤:

  1. 重命名函数和变量名称以使其更有意义,即重命名xz,使其有意义:
        var isTenOrGreater = function(value) { 
            var falseValue = false; 
            if(value > 10) 
                return true; 
            return falseValue; 
        } 

现在,函数可以轻松阅读,命名也有意义。

  1. 删除任何不必要的复杂性。在这种情况下,if条件语句可以完全删除,如下所示:
        var isTenOrGreater = function(value) { 
            return value > 10; 
        }; 

  1. 反思结果。

在这一点上,重构已经完成,函数的目的应该立即显现出来。接下来应该问的问题是:“为什么这个方法一开始就存在呢?”。

这个例子只是简要地介绍了如何识别代码中的问题以及如何改进它们的步骤。本书将在整个书中提供其他示例。

使用建造者构建

这些天,设计模式是一种常见的实践,我们遵循设计模式来使生活更轻松。出于同样的原因,这里将遵循建造者模式。

建造者模式使用builder对象来创建另一个对象。想象一个具有 10 个属性的对象。如何为每个属性创建测试数据?对象是否必须在每个测试中重新创建?

builder对象定义了一个可以在多个测试中重复使用的对象。以下代码片段提供了使用这种模式的示例。这个例子将在validate方法中使用builder对象:

var book = { 
    id : null, 
    author : null, 
    dateTime : null 
}; 

book对象有三个属性:idauthordateTime。从测试的角度来看,我们希望能够创建一个有效的对象,即所有字段都已定义的对象。我们可能还希望创建一个缺少属性的无效对象,或者我们可能希望设置对象中的某些值来测试验证逻辑。就像这里dateTime是一个实际的日期时间,应该由建造者对象分配。

以下是为bookBuilder对象创建建造者的步骤:

  1. 创建一个建造者函数,如下所示:
        var bookBuilder = function() {}; 

  1. 在建造者中创建一个有效的对象,如下所示:
        var bookBuilder = function() { 
            var _resultBook = { 
                id: 1, 
                author: 'Any Author', 
                dateTime: new Date() 
            }; 
        } 

  1. 创建一个函数来返回构建的对象:
        var bookBuilder = function() { 
            var _resultBook = { 
                id: 1, 
                author: "Any Author", 
                dateTime: new Date() 
            }; 
            this.build = function() { 
                return _resultBook; 
            } 
        } 

  1. 如图所示,创建另一个函数来设置_resultBook的作者字段:
        var bookBuilder = function() { 
            var _resultBook = { 
                id: 1, 
                author: 'Any Author', 
                dateTime: new Date() 
            }; 
            this.build = function() { 
                return _resultBook; 
            }; 
            this.setAuthor = function(author){ 
                _resultBook.author = author; 
            }; 
        }; 

  1. 更改函数定义,以便可以链接调用:
        this.setAuthor = function(author) { 
            _resultBook.author = author; 
            return this; 
        }; 

  1. 一个设置器函数也将被创建用于dateTime,如下所示:
        this.setDateTime = function(dateTime) { 
            _resultBook.dateTime = dateTime; 
            return this; 
        }; 

现在,bookBuilder可以用来创建一个新的书,如下所示:

var bookBuilder = new bookBuilder(); 

var builtBook = bookBuilder.setAuthor('Ziaul Haq') 
.setDateTime(new Date()) 
.build(); 
console.log(builtBook.author); // Ziaul Haq 

前面的建造者现在可以在我们的测试中被用来创建一个一致的对象。

这是完整的建造者供参考:

var bookBuilder = function() { 
    var _resultBook = { 
        id: 1, 
        author: 'Any Author', 
        dateTime: new Date() 
    }; 

    this.build = function() { 
        return _resultBook; 
    }; 

    this.setAuthor = function(author) { 
        _resultBook.author = author; 
        return this; 
    }; 

    this.setDateTime = function(dateTime) { 
        _resultBook.dateTime = dateTime; 
        return this; 
    }; 
}; 

让我们创建validate方法来验证从建造者创建的书对象:

var validate = function(builtBookToValidate){ 
    if(!builtBookToValidate.author) { 
        return false; 
    } 
    if(!builtBookToValidate.dateTime) { 
        return false; 
    } 
    return true; 
}; 

让我们首先通过传递所有必需的信息,使用建造者创建一个有效的书对象,如果这是通过validate对象传递的,这应该显示一个有效的消息:

var validBuilder = new bookBuilder().setAuthor('Ziaul Haq') 
.setDateTime(new Date()) 
.build(); 

// Validate the object with validate() method 
if (validate(validBuilder)) { 
    console.log('Valid Book created'); 
} 

同样,让我们通过构建器创建一个无效的书籍对象,通过在必要信息中传递一些空值。通过将对象传递给validate方法,它应该显示解释为什么无效的消息:

var invalidBuilder = new bookBuilder().setAuthor(null).build(); 

if (!validate(invalidBuilder)) { 
    console.log('Invalid Book created as author is null'); 
} 

var invalidBuilder = new bookBuilder().setDateTime(null).build(); 

if (!validate(invalidBuilder)) { 
    console.log('Invalid Book created as dateTime is null'); 
} 

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您从其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

自测问题

Q1. 测试替身是重复测试的另一个名称。

  1. 正确

  2. 错误

Q2. TDD 代表测试驱动开发。

  1. 正确

  2. 错误

Q3. 重构的目的是提高代码质量。

  1. 正确

  2. 错误

Q4. 测试对象构建器 consolida 了用于测试的对象的创建。

  1. 正确

  2. 错误

Q5. 三个 A 是一个体育队。

  1. 正确

  2. 错误

摘要

本章介绍了 TDD。它讨论了 TDD 生命周期(先测试,使其运行,然后改进)以及这些步骤可以被任何人用于 TDD 方法,类似于我们看到裁缝使用的方式。最后,它回顾了本书中将讨论的一些测试技术,包括测试替身,重构和构建模式。

尽管 TDD 是一个庞大的主题,但本书仅专注于与 Angular 一起使用的 TDD 原则和实践。

在下一章中,我们将了解有关 JavaScript 测试的详细信息。

第二章:JavaScript 测试的细节

TDD 的实践是获得高质量软件和令人满意的准确性的好方法,即使人手较少。对于 Web 应用程序,JavaScript 已经成为最流行的脚本语言,测试 JavaScript 代码已经成为一个挑战。基于浏览器的测试实际上是一种浪费时间的做法,对于 TDD 来说很难跟进,但是解决这个问题的方法是使用一些支持 JavaScript 自动化测试的很酷的工具。大多数 Web 应用项目仅限于单元测试,没有自动化测试工具,端到端测试或功能测试几乎是不可能的。

许多专注于 JavaScript 测试的工具和框架正在涌现,它们提供不同的解决方案,使开发人员的生活变得更加轻松。除了发明新的 JavaScript 框架,开发人员社区还发明了一些工具集,以使测试变得更加容易。就像 Angular 团队一样,他们提供了像Karma这样的很酷的工具。我们还有测试框架或工具的重复,它们都以不同的方式解决了类似的问题。选择哪种工具或框架取决于开发人员;他们必须选择最适合他们要求的工具。

在本章中,我们将涵盖以下内容:

  • 自动化测试的简要介绍

  • 专注于 JavaScript 的不同类型的测试

  • 一些测试工具和框架的简要概念

JavaScript 测试的技艺

我们都知道 JavaScript 是一种动态类型的解释语言。因此,与 Java 等编译语言不同,没有编译步骤可以帮助您找出错误。因此,JavaScript 开发人员应该花更多的时间来测试代码。然而,现在生活变得更加容易,开发人员可以使用最新的工具技术在最少的步骤和时间内进行测试。这是自动化测试的一部分,代码在更改时将自动进行测试。在这个过程中,测试可能是在后台运行的任务,可以集成到 IDE 或 CLI 中,并且在开发过程中提供测试结果。

在接下来的章节中,我们将讨论如何使用测试运行器和无头浏览器在多个浏览器中自动化测试过程。

自动化测试

测试很有趣,编写测试会使代码更好;这是一个很好的实践,但是过程化的手动测试有点耗时、容易出错,并且不可重复。在这个过程中,需要编写测试规范,更改代码以通过测试,刷新浏览器以获取结果,并重复这个过程多次。作为程序员,重复相同的事情有点无聊。

除了单调乏味之外,它也大大减慢了开发过程,这让开发人员对 TDD 的实践失去了动力。因此,当手动过程减慢进度时,我们必须寻找一些自动化的过程来完成工作,并为其他可能增加更多业务价值的任务节省时间。

因此,拥有一些工具或技术可以帮助程序员摆脱这些重复乏味的手动步骤,这些步骤减慢了过程,并自动完成任务,更快地完成任务,并节省时间,使它们对业务更有价值,这将是很棒的。幸运的是,有一些工具可以自动化这些测试。我们将在其他章节中更多地介绍这些工具和技术。

除了减慢开发过程的问题之外,当我们谈论测试功能时,另一个重要的问题出现了,那就是跨浏览器兼容性问题。由于 Web 应用程序应该在现代平台和浏览器上完美运行,而逐个手动测试几乎是不可能的,自动化测试可能是一个解决方案,使用 Web 驱动程序和无头浏览器。

让我们回顾一下我们在上一章中解释的基本测试流程--测试它,使其运行,并使其更好。为了使这个过程自动化,开发人员可以在 CLI 甚至开发 IDE 中实现工具集,并且这些测试将在一个单独的进程中持续运行,而不需要开发人员的任何输入。

让我们想象一下任何应用程序的注册或注册功能,我们必须手动填写表单并每次点击提交按钮以测试该功能,并通过更改数据重复该过程。这实际上被称为功能测试(我们将在本章末讨论)。为了自动执行这些过程,我们将在 CLI 中使用工具集(测试运行器、Web 驱动程序和无头浏览器),并使用一条命令和一些参数完成整个过程。

在自动化测试中测试 JavaScript 并不是一个新概念,实际上,它是最常用的自动化浏览器。Selenium 是在 2004 年为此而发明的,之后出现了许多工具,包括 PhantomJS、Karma、Protractor 和 CasperJS。在本章中,我们将讨论其中一些。

测试的类型

在 TDD 中,开发人员必须遵循一种流程来实现测试的目标。在这个流程中,每一步都有一个独立的测试目标。例如,有些测试仅用于测试每个函数的行为,而有些用于测试模块/功能的流程。基于此,我们将在这里讨论两种主要类型的测试。它们如下:

  • 单元测试:这主要用于行为测试。

  • 端到端测试:这主要被称为 e2e 测试,用于功能测试。

单元测试

单元测试是一种软件开发过程,其中应用程序的最小可测试部分被单独称为一个单元,并且该小部分的行为应该能够在隔离的情况下进行测试,而不依赖于其他部分。如果我们将 JavaScript 应用程序视为软件,那么该应用程序的每个单独的方法/函数都将是代码的一个单元,这些方法或代码单元的行为应该能够以隔离的方式进行测试。

关于单元测试的一个重要观点是,任何代码单元都应该能够在隔离的情况下运行/进行测试,并且可以以任何顺序运行,这意味着如果单元测试在任何应用程序中成功运行,它代表了该应用程序的组件或模块的隔离。

例如,我们在上一章中已经展示了一个小的测试示例,演示了如何进行方法测试;尽管我们没有使用任何测试框架,但这个想法是一样的。我们通过传递一些参数来调用方法,得到了该方法的结果,然后将结果与预期值进行比较。

通常,我们将使用我们选择的单元测试框架编写这些测试。现在有许多测试框架和工具,我们必须根据我们的需求决定并选择最好的一个。最常用的框架是 Jasmine、Mocha 和 QUnit。我们将在本章深入讨论这些工具,并在随后的章节中涵盖真实的例子。

测试应该快速运行并且自动化,并且具有清晰的输出。例如,您可以验证如果使用特定参数调用函数,它应该返回预期的结果。

单元测试可以随时运行测试,例如在以下情况下:

  • 从开发过程的最开始,即使测试失败

  • 完成任何功能的开发后,验证行为是否正确

  • 修改任何现有功能后,以验证行为是否发生了变化

  • 在现有应用程序中添加新功能后,我们需要验证新功能是否被隔离,并且没有破坏任何其他功能

端到端测试

端到端测试是一种用于测试应用程序流程是否按照设计进行的方法。例如,如果用户从产品列表中点击一个产品,它应该提示模态框显示所选产品的详细信息。在这种情况下,产品/项目所有者将根据规范逐步定义项目要求。在开发过程之后,将根据规范的工作流程对项目进行测试。这被称为功能/流程测试,也是端到端测试的另一个名称。

除了单元测试之外,端到端测试对于确认各个组件作为一个应用程序一起工作,传递信息并相互通信非常重要。与单元测试的主要区别在于它不会单独测试任何组件;相反,它是对所有相关组件一起进行流程的综合测试。

考虑一个注册模块,用户应该提供一些有效信息来完成注册,该模块/应用程序的功能/流程测试应该遵循一些步骤来完成测试。

步骤如下:

  1. 加载/编译表单

  2. 获取表单元素的 DOM

  3. 触发提交按钮的点击事件

  4. 从输入字段中收集值以进行验证

  5. 验证输入字段

  6. 调用虚拟 API 来存储数据

在每一步中,都会有一些结果与预期结果集进行比较。

这些类型的功能/流程测试可以通过人工填写表单,点击下一步按钮,完成应用程序流程,并将结果与在实施过程中早期定义的规范进行比较来进行手动测试。

然而,有一些技术可用于以自动化方式进行功能/流测试,而无需从任何人那里获取输入,这被称为端到端测试。为了使这个测试过程更容易,有一些工具可用;最常用的是 Selenium、PhantomJS 和 Protractor。这些工具可以轻松集成到任何应用程序测试系统中。在本章中,我们将稍微详细地讨论这些测试工具,并在随后的章节中将它们集成到应用程序的测试套件中。

测试工具和框架

了解不同的测试工具是一大挑战。对于 Angular 测试来说,其中一些非常重要,我们将在本书中详细学习它们。然而,在本节中,我们将学习一些在不同 Web 应用程序中用于各种测试和方法的知名工具和框架。它们如下:

  • Karma:这是 JavaScript 的测试运行器

  • Protractor:这是端到端测试框架

  • Jasmine:这是行为驱动的 JavaScript 测试框架

  • Mocha:这是 JavaScript 测试框架

  • QUnit:这代表单元测试框架

  • Selenium:这是自动化 Web 浏览器的工具

  • PhantomJS:这是无头 Webkit 浏览器

Karma

在讨论 Karma 是什么之前,最好先讨论它不是什么。它不是一个编写测试的框架;它是一个测试运行器。这意味着 Karma 赋予我们能力以自动化方式在多个不同的浏览器中运行测试。过去,开发人员必须手动执行以下步骤:

  • 打开浏览器

  • 将浏览器指向项目 URL

  • 运行测试

  • 确认所有测试都已通过

  • 进行更改

  • 刷新页面

使用 Karma,自动化使开发人员能够运行单个命令并确定整个测试套件是否通过或失败。从 TDD 的角度来看,这使我们能够快速找到并修复失败的测试。

与手动流程相比,使用 Karma 的一些优点如下:

  • 在多个浏览器和设备中自动化测试的能力

  • 监视文件的能力

  • 在线文档和支持

  • 只做一件事——运行 JavaScript 测试——并且做得很好

  • 使其易于与持续集成服务器集成

使用 Karma 的缺点:

  • 需要学习、配置和维护额外的工具

自动化测试和使用 Karma 的过程非常有利。在本书的 TDD 旅程中,Karma 将是我们的主要工具之一。我们将在第三章 Karma 方式中详细了解 Karma。

Protractor

Protractor 是一种端到端测试工具,允许开发人员模拟用户交互。它通过与 Web 浏览器的交互自动化功能和特性的测试。Protractor 具有特定的方法来帮助测试 Angular,但它们并不专属于 Angular。

使用 Protractor 的一些优点如下:

  • 可配置以测试多个环境

  • 与 Angular 轻松集成

  • 语法和测试可以与选择的单元测试框架类似

使用 Protractor 的缺点:

  • 它的文档和示例有限

对于本书中的示例的端到端测试,Protractor 将是我们的主要框架。Protractor 将在第四章 使用 Protractor 进行端到端测试中进一步详细介绍。

茉莉花

Jasmine 是一个用于测试 JavaScript 代码的行为驱动开发框架。它可以轻松集成和运行网站,并且与 Angular 无关。它提供间谍和其他功能。它也可以在没有 Karma 的情况下运行。在本章中,我们将学习 Jasmine 常用的内置全局函数的详细信息,并了解 Jasmine 测试套件如何满足 Web 应用程序的测试要求。此外,在整本书中,我们将使用 Jasmine 作为我们的测试框架。

使用 Jasmine 的一些优点如下:

  • 与 Karma 的默认集成

  • 提供额外的功能来辅助测试,如测试间谍、伪造和传递功能

  • 清晰易读的语法,允许测试以与被测试行为相关的方式格式化

  • 与多个输出报告器集成

以下是使用 Jasmine 的一些缺点:

  • 运行测试时没有文件监视功能。这意味着测试必须在用户更改时重新运行。

  • 所有 Protractor 方法和功能的学习曲线可能会很陡峭。

摩卡

Mocha 是最初为 Node.js 应用程序编写的测试框架,但它也支持浏览器测试。它与 Jasmine 非常相似,并且大部分语法都是相似的。Mocha 的主要区别在于它不能作为一个独立的测试框架运行--它需要一些插件和库来作为一个测试框架运行,而 Jasmine 是独立的。它更具可配置性和灵活性。

让我们讨论一些 Mocha 的优点:

  • 易安装

  • 有良好的文档可用

  • 有几个报告者

  • 与几个 node 项目插件相匹配

以下是一些缺点:

  • 需要单独的插件/模块来进行断言、间谍等

  • 需要额外的配置才能与 Karma 一起使用

QUnit

QUnit 是一个强大、易于使用的 JavaScript 单元测试套件。它被 jQuery、jQuery UI 和 jQuery Mobile 项目使用,并且能够测试任何通用的 JavaScript 代码。QUnit 专注于在浏览器中测试 JavaScript,同时尽可能为开发人员提供便利。

QUnit 的一些优点:

  • 易安装

  • 有良好的文档可用

使用 QUnit 的一个缺点是:

  • 主要为 jQuery 开发,不适合与其他框架一起使用

Selenium

Selenium(www.seleniumhq.org/)自我定义如下:

"Selenium 自动化浏览器。就是这样!"

浏览器的自动化意味着开发人员可以轻松地与浏览器交互。他们可以点击按钮或链接,输入数据等。Selenium 是一个强大的工具集,当正确使用和设置时,有很多好处;然而,设置它可能会令人困惑和繁琐。

Selenium 的一些优点如下:

  • 大量功能集

  • 分布式测试

  • 通过服务如Sauce Labssaucelabs.com/)支持 SaaS

  • 有文档和资源可用

以下是 Selenium 的一些缺点:

  • 必须作为一个单独的进程运行

  • 需要几个步骤来配置

由于 Protractor 是 Selenium 的一个包装器,因此不会详细讨论。

PhantomJS

PhantomJS 是一个可编写 JavaScript API 的无头 WebKit 脚本。它对各种 Web 标准有快速本地支持;DOM 处理、CSS 选择器、JSON、Canvas 和 SVG。PhantomJS 用于测试工作流程。

简而言之,PhantomJS 是一个无头运行的浏览器(即不会显示屏幕)。它带来的好处是速度--如果你在计算机上控制一个实际的程序,你会有一定的开销来启动浏览器,配置配置文件等。

PhantomJS 并不意味着取代测试框架;它将与测试框架一起使用。

选择权在我们手中

正如我们所见,有许多用于测试 JavaScript 项目的工具集和框架:

  • 对于断言框架,我们将选择 Jasmine,因为 Angular 本身使用 Jasmine 作为断言;但在某些情况下,主要是对于 Node.js 项目,Mocha 也很有趣

  • 只要我们专注于自动化测试套件,测试运行器对我们来说至关重要,当涉及到 Angular 项目时,没有什么可以与 Karma 相提并论

  • 对于端到端测试,Protractor 是最好的框架,我们将在本章中使用它。

  • 只要是端到端测试,它必须是自动化的,而 Selenium 就在这里为我们自动化浏览器。

  • 重要的是要进行跨浏览器支持的测试,并且 PhantomJS 在这里为我们提供无头浏览器。

向 Jasmine 测试套件打招呼

只要我们必须使用测试框架来构建测试套件,所有框架上都有一些基本和常见的断言。重要的是要理解这些断言和间谍以及何时使用它们。

在本节中,我们将解释 Jasmine 的断言和间谍,因为 Jasmine 将是本书中的测试框架。

套件

任何测试套件都以全局的 Jasmine describe函数开始,该函数接收两个参数。第一个是字符串,第二个是函数。字符串是套件名称/标题,函数是将在套件中实现的代码块。

考虑以下例子:

describe("A sample test suite to test jasmine assertion", function() {  
   // .. implemented code block 
}); 

规范

任何使用 Jasmine 的全局it函数定义的规范,类似于接收两个参数的套件,第一个是字符串,第二个是函数。字符串是规范名称/标题,函数是规范中将要实现的代码块。看看以下例子:

describe("A sample test suite to test jasmine assertion", function() { 
    var a; 
    it("Title for a spec", function() { 
        // .. implemented code block 
    }); 
}); 

期望

任何使用expect函数定义的期望,该函数接收一个称为实际的参数值。该函数是一个与匹配器函数链接的链,该匹配器函数以预期值作为参数与实际值进行匹配。

有一些常用的匹配器;它们都在实际值和预期值之间实现布尔比较。通过将expect方法与not关键字链接,任何匹配器都可以评估负值。

一些常见的匹配器包括toBetoEqualtoMatchtoBeNulltoBeDefinedtoBeUndefinedtoContain

考虑给定的例子:

describe("A sample test suite to test jasmine assertion", function() {  
    var a, b; 
    it("Title for a spec", function() { 
        var a = true; 
        expect(a).toBe(true); 
        expect(b).not.toBe(true); 
    }); 
}); 

设置和拆卸

为了通过 DRY(不要重复自己)来改进测试套件,消除重复的设置和拆卸代码,Jasmine 提供了一些全局函数用于设置和拆卸。这些全局函数(beforeEachafterEach等)如下所示,并且它们按照名称的意思运行。

每个函数都针对一个测试规范运行。Jasmine 的全局设置和拆卸函数是beforeEachafterEachbeforeAllafterAll

考虑以下示例:

describe("A sample test suite to test jasmine assertion", function() { 
    var a=0;    
    beforeEach(function() { 
        a +=1; 
    }); 
    afterEach(function() { 
        a =0; 
    }); 
    it("Title for a spec 1", function() { 
        expect(a).toEqual(1); 
    }); 
    it("Title for a spec 2", function() { 
        expect(a).toEqual(1); 
        expect(a).not.toEqual(0); 
    }); 
}); 

间谍

间谍是 Jasmine 中的测试双函数;它们可以替换任何函数并跟踪对它及其所有参数的调用。有一些匹配器可以跟踪间谍是否被调用。这些是toHaveBeenCalledtoHaveBeenCalledTimes等。

有一些与间谍一起使用的有用的链式方法,比如returnValue/returnValues,它们将在调用时返回一个或多个值。还有一些类似的有用方法,比如callThroughcallstubcall.allArgscall.firstcall.reset

考虑以下示例:

describe("A sample test suite to test jasmine assertion", function() { 
    var myObj, a, fetchA; 
    beforeEach(function() { 
        myObj = { 
            setA: function(value) { 
                a = value; 
            }, 
            getA: function(value) { 
                return a; 
            }, 
        }; 
        spyOn(myObj, "getA").and.returnValue(789); 
        myObj.setA(123); 
        fetchA = myObj.getA(); 
    }); 

    it("tracks that the spy was called", function() { 
         expect(myObj.getA).toHaveBeenCalled(); 
    }); 
    it("should not affect other functions", function() { 
        expect(a).toEqual(123); 
    }); 
    it("when called returns the requested value", function() { 
        expect(fetchA).toEqual(789); 
    }); 
}); 

Jasmine 的测试套件

在前面的部分中,我们查看了一些常用的断言,所有测试框架,包括 Jasmine,在任何类型的测试套件中都会使用。

尽管在本书中,我们将为 Angular 测试构建一个自动化测试套件,让我们在 Jasmine 测试套件中尝试一些断言,并看看它是如何工作的。这个示例测试套件将让我们亲身体验断言在测试套件中的工作方式。

对于这个测试套件,我们将使用 Jasmine 的示例规范运行器项目(该项目在 Jasmine 下载包中可用),项目的文件夹结构将如下所示:

Jasmine 的测试套件

让我们快速看一下我们需要在 Jasmine 的示例SpecRunner项目中更新的文件:

SpecRunner.html: 
<!DOCTYPE HTML> 
<html> 
<head> 
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> 
    <title>Jasmine Spec Runner v2.4.1</title> 
    <link rel="shortcut icon" type="image/png" href="lib/jasmine-2.4.1/jasmine_favicon.jpg"> 
    <link rel="stylesheet" type="text/css" href="lib/jasmine-2.4.1/jasmine.css"> 

    <script type="text/javascript" src="lib/jasmine-2.4.1/jasmine.js"></script> 
    <script type="text/javascript" src="lib/jasmine-2.4.1/jasmine-html.js"></script> 
    <script type="text/javascript" src="lib/jasmine-2.4.1/boot.js"></script> 
    <!-- include source files here... --> 
    <script type="text/javascript" src="src/mySource.js"></script> 
    <!-- include spec files here... --> 
    <script type="text/javascript" src="spec/mySpec.js"></script> 
</head> 
<body> 
</body> 
</html> 

src/mySource.js: 
var a, 
myObj = { 
    setA: function(value) { 
        a = value; 
    }, 
    getA: function(value) { 
        return a; 
    }, 
}; 

Spec/mySpec.js: 
describe("A sample test suite to test jasmine assertion", function() { 
    var fetchA; 
    beforeEach(function() { 
        spyOn(myObj, "getA").and.returnValue(789); 
        myObj.setA(123); 
        fetchA = myObj.getA(); 
    }); 

    it("tracks that the spy was called", function() { 
         expect(myObj.getA).toHaveBeenCalled(); 
    }); 
    it("should not affect other functions", function() { 
        expect(a).toEqual(123); 
    }); 
    it("when called returns the requested value", function() { 
        expect(fetchA).toEqual(789); 
    }); 
}); 

只要它是基于浏览器的测试套件,我们就必须将SpecRunner.html指向一个 web 浏览器以获取测试结果。我们将通过所有测试,并且我们的测试结果将如下截图所示:

Jasmine 的测试套件

Angular 的 Jasmine 测试套件

在前面的例子中,我们看到了用于 JavaScript 测试的 Jasmine 测试套件,但是对于 Angular,应该如何呢?实际上,对于 Angular 项目测试套件,没有直接的答案;我们将不使用基于浏览器的测试套件,而是使用 Karma 作为测试套件的测试运行器。但是,由于我们在前面的例子中熟悉了基于浏览器的 Jasmine 测试套件,让我们看看如果我们为 Angular 项目制作一个类似的测试套件会是什么样子。

我们将不得不在 Angular 项目中添加一个名为src的子文件夹用于测试规范,然后项目的文件夹结构将如下所示:

Angular 的 Jasmine 测试套件

注意

在 Angular 项目中,我们将使用 TypeScript 而不是纯 JavaScript,因为 Angular 官方建议使用 TypeScript。因此,我们希望大家都了解 TypeScript 的语法并知道如何编译成 JS。

在这本书中,对于 Angular 测试套件,我们将使用 SystemJS 作为模块加载器,因为 Angular 官方建议使用它;我们将看一下 SystemJS。

这个示例 Angular 测试套件只是为了展示我们如何轻松地为 Angular 项目制作一个测试套件,尽管它并没有遵循最佳实践和最佳的模块加载方式。

在第三章中,Karma 方式,我们将使用真实的例子更新这个测试套件,并使用 SystemJS 作为模块加载器。

在 GitHub 上,Angular 团队有一个名为Angular2-seed的种子项目,可以为任何 Angular 项目提供测试套件;我们将为我们真正的 Angular 项目遵循这个项目。

如果我们看一下文件夹结构,它几乎与之前的相同,规范文件中也有最少的更改;规范中唯一的变化是使用 TypeScript:

src/unit-tests.html: 

<!DOCTYPE html> 
<html> 
<head> 
    <meta http-equiv="content-type" content="text/html;charset=utf-8"> 
    <title>NG2 App Unit Tests</title> 
    <link rel="stylesheet" href="node_modules/jasmine-core/lib/jasmine-core/jasmine.css"> 
    <script src="node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script> 
    <script src="node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script> 
    <script src="node_modules/jasmine-core/lib/jasmine-core/boot.js"></script> 
    <script src="../app/mysource.js"></script> 
    <script src="my.spec.js"></script> 

</head> 
<body> 
</body> 
</html> 

app/mysource.ts: 
export class Source { 
    // ...  
} 

src/my.spec.ts: 
describe('1st tests', () => { 
    it('true is true', () => expect(true).toEqual(true)); 

    it('null is not the same thing as undefined', 
        () => expect(null).not.toEqual(undefined) 
    ); 
}); 

由于这也是一个基于浏览器的测试套件,我们必须在 Web 浏览器中指向unit-tests.html以获取测试结果。我们将通过所有测试,并且我们的测试结果将如下所示:

Angular 的 Jasmine 测试套件

自测问题

自测问题将帮助您进一步测试您在 JavaScript 应用程序测试中使用 TDD 的知识。

Q1. 端到端测试意味着什么样的测试?

  • 功能测试

  • 行为测试

Q2. Protractor 是一个单元测试框架。

  • 正确

  • 错误

Q3. PhantomJS 是一种类型的浏览器。

  • 正确

  • 错误

Q4. QUnit 是用于什么样的测试框架?

  • jQuery

  • Angular

  • NodeJS

Q5. 设置和拆卸是 Jasmine 的一个特性。

  • 正确

  • 错误

总结

在本章中,我们回顾了 TDD 的不同测试机制,并介绍了自动化测试。我们回顾了不同类型的 JavaScript 测试框架和工具,并审查了这些框架的优缺点。我们还回顾了 Jasmine 的一些常见断言,并尝试亲自动手看它们是如何工作的。

在下一章中,我们将学习关于 Karma,并了解它如何与 Angular 测试套件配合使用。

第三章:Karma 的方式

由于 Karma,JavaScript 测试已经成为主流。Karma 使 JavaScript 测试变得无缝。Angular 是围绕测试创建的。

在本章中,我们将学习关于 Karma 的一些东西,包括以下内容:

  • Karma 的起源

  • 为什么以及如何 Karma 将与 Angular 项目一起工作

  • 在 Angular 项目中的 Karma 配置和实现

  • Travis CI 概述

Karma 的诞生

在使用新工具时,了解其来源和构建原因非常重要。本节将为我们提供 Karma 起源的背景。

Karma 的区别

Karma 是由 Vojta Jína 创建的。该项目最初被称为 Testacular。在 Vojtech Jína 的论文中,他讨论了 Karma 的设计、目的和实现。

在他的论文(JavasScript Test Runner,github.com/karma-runner/karma/raw/master/thesis.pdf)中,他描述了 Karma 如下:

“……一个测试运行器,可以帮助 Web 应用程序开发人员通过使自动化测试更简单和更快速来提高生产力和效率。事实上,我有更高的抱负,这篇论文只是其中的一部分 - 我想推广测试驱动开发(TDD)作为开发 Web 应用程序的“方式”,因为我相信这是开发高质量软件的最有效方式。”

Karma 具有在真实浏览器上轻松自动运行 JavaScript 单元测试的能力。传统上,测试是通过手动启动浏览器并不断点击刷新按钮来进行的。这种方法很笨拙,通常会导致开发人员限制编写的测试数量。

使用 Karma,开发人员几乎可以在任何标准测试框架中编写测试,选择要运行的浏览器,设置要监视更改的文件,然后就可以进行持续的自动化测试了。我们只需简单地检查输出窗口以查看测试是失败还是通过。

结合 Karma 和 Angular 的重要性

Karma 是为 AngularJS 构建的。在 Karma 之前,缺乏面向 Web 的 JavaScript 开发人员的自动化测试工具。

记住,Karma 是一个测试运行器,而不是一个测试框架。它的工作是运行测试并报告哪些测试将通过或失败。为什么这有帮助呢?测试框架是你将编写测试的地方。除了这样做,你还需要专注于轻松运行测试并查看结果。Karma 可以轻松地在多个不同的浏览器上运行测试。它还具有一些其他功能,比如文件监视,这将在本书的后面详细讨论。

安装 Karma

是时候开始使用 Karma 了。安装和应用程序不断变化。以下指南旨在简要介绍;你可以去 Karma 网站karma-runner.github.io/查找最新的说明。

本节的主要重点将是本书中使用的特定配置,而不是深入的安装指南。

安装前提条件

要安装 Karma,我们需要在计算机上安装 Node.js。Node.js 在 Google 的 V8 引擎上运行,并允许 JavaScript 在多个操作系统上运行。

开发人员可以使用NPMNode Package Manager)发布节点应用程序和模块。NPM 允许开发人员快速将应用程序和模块集成到他们的应用程序中。

Karma 通过npm包运行和安装;因此,在使用或安装 Karma 之前,我们需要 Node.js。要安装 Node.js,请转到nodejs.org/并按照安装说明进行操作。

一旦我们安装了 Node.js,让我们在命令提示符中输入以下命令来安装 Karma:

**$ npm install karma -g**

上述命令使用npm全局安装 Karma 使用-g。这意味着我们可以在命令提示符中简单地输入以下内容来使用 Karma:

**$ karma --version**

默认情况下,安装 Karma 将安装karma-chrome-launcherkarma-jasmine作为依赖项。确保这些模块也全局安装。

配置 Karma

Karma 配备了一个自动创建配置文件的方法。要使用自动方式,请输入以下命令:

**$ karma init**

以下是所选选项的示例:

配置 Karma

自定义 Karma 的配置

以下说明描述了为项目运行 Karma 所需的特定配置。自定义包括测试框架(Jasmine)、要测试的浏览器(Chrome)和要测试的文件。要自定义配置,请打开karma.conf.js并执行以下步骤:

  1. 确保启用的框架使用以下代码说jasmine
        frameworks: ['jasmine'], 

  1. 配置test目录。请注意,以下定义需要包括需要运行的测试以及可能的任何依赖项。将保存我们的测试的目录是/test/unit/
        files: [ 
            'test/unit/**/*.js' 
        ], 

  1. 将测试浏览器设置为 Chrome,如下所示。然后它将被初始化,并且在每个测试后都会弹出一个弹窗:
        browsers: ['Chrome'], 

确认 Karma 的安装和配置

要确认 Karma 的安装和配置,请执行以下步骤:

  1. 运行以下命令确认 Karma 启动时没有错误:
        **$ karma start**

  1. 输出应该是这样的:
        **$ INFO [karma]: Karma v0.12.16 server started at 
        http://localhost:9876/**

  1. 除此之外,输出应该指出没有找到测试文件:
        **$ WARN [watcher]: Pattern "test/unit/**/*.js" does not
        match any file.**

输出应该这样做,还有一个失败的测试消息:

        **$ Chrome 35.0.1916 (Windows 7): Executed 0 of 0 ERROR
        (0.016 secs / 0 secs)**

注意

一个重要的要点是,我们需要在系统上全局安装jasmine-core,否则 Karma 将无法成功运行。

这是预期的,因为还没有创建测试。如果 Karma 启动,请继续下一步,我们将看到我们的 Chrome 浏览器显示以下输出:

确认 Karma 的安装和配置

常见的安装/配置问题

如果缺少 Jasmine 或 Chrome Launcher,请执行以下步骤:

  1. 运行测试时,可能会出现错误,提示缺少 Jasmine 或 Chrome Launcher。如果出现此错误,请输入以下命令安装缺少的依赖项:
        **$ npm install karma-jasmine -g**
        **$ npm install karma-chrome-launcher -g**

  1. 重试测试并确认错误已经解决。

在某些情况下,您可能无法使用-g命令全局安装npm_modules。这通常是由于计算机上的权限问题。以下是您需要做的以提供权限(sudo/administrator):

  1. 解决方法是直接在项目文件夹中安装 Karma。使用相同的命令而不带-g来执行此操作:
                **$ npm install karma**

  1. 使用相对路径运行 Karma:
                **$ ./node_modules/karma/bin/karma --version**

现在 Karma 已安装并运行,是时候投入使用了。

使用 Karma 进行测试

在本节中,我们将创建一个测试来确认 Karma 是否按预期工作。为此,请执行以下步骤:

  1. 创建测试目录。在 Karma 配置中,测试被定义在以下目录中:
        files: [ 
               'test/unit/**/*.js' 
           ], 

  1. 继续创建test/unit目录。

  2. test/unit目录中创建一个新的firstTest.js文件。

  3. 编写第一个测试如下:

        describe('when testing karma', function (){ 
          it('should report a successful test', function (){ 
            expect(true).toBeTruthy(); 
              }); 
        }); 

前面的测试使用了 Jasmine 函数,并具有以下属性:

  • describe:这提供了测试套件的简短字符串描述,即将被测试的东西。

  • it:这提供了一个特定断言的简短字符串,称为测试规范

  • expect:这提供了一种断言值的方式

  • toBeTruthy:这是期望的几个属性之一,可用于进行断言

这个测试除了确认通过测试的输出之外没有任何实际价值。

砰!让我们检查一下控制台窗口,看看 Karma 是否执行了我们的测试。我们的命令行应该显示类似于这样的内容:

**$ INFO [watcher]: Added file "./test/unit/firstTest.js"**

这个输出意味着 Karma 自动识别到有一个新文件被添加了。接下来的输出应该是这样的:

**$ Chrome 35.0.1916 (Windows 7): Executed 1 of 1 SUCCESS (0.02 secs 
    / 0.015 secs)**

这意味着我们的测试已经通过了!

确认 Karma 的安装

现在,Karma 的初始设置和配置已经完成。以下是步骤的回顾:

  1. 我们通过npm命令安装了 Karma。

  2. 我们通过karma init命令初始化了一个默认配置。

  3. 接下来,我们用 Jasmine 和一个test/unit测试目录配置了 Karma。

  4. 我们启动了 Karma,并确认它可以在 Chrome 中打开。

  5. 然后,我们在test/unit测试目录中添加了一个 Jasmine 测试firstTest.js

  6. Karma 认识到firstTest.js已经被添加到了测试目录中。

  7. 最后,Karma 执行了我们的firstTest.js并报告了我们的输出。

通过几个步骤,我们能够看到 Karma 自动运行和执行测试。从 TDD 的角度来看,我们可以专注于将测试从失败转移到通过,而不需要太多的努力。无需刷新浏览器;只需检查命令输出窗口。保持 Karma 运行,所有的测试和文件都将自动添加和运行。

在接下来的章节中,我们将看到如何将 Karma 与 TDD 方法相结合。如果你对 Karma 目前还可以,并且想继续使用 Protractor,请跳到下一章。

使用 Karma 与 Angular

在这里,我们将演示如何对 Angular 组件进行 TDD 方法的实践。在本章结束时,我们应该能够做到以下几点:

  • 对使用 Karma 及其配置感到自信

  • 了解 Jasmine 测试的基本组件

  • 开始理解如何在 Angular 应用程序中集成 TDD 方法

获取 Angular

通过 Bower 无法安装 Angular;与 Angular1 不同,它必须通过 npm 安装。引导 Angular 应用程序不像 Angular1 那样简单,因为 Angular 不使用普通的 JavaScript。它使用 TypeScript 或 ES6(ES2015),这两者在运行之前都需要编译为普通的 JavaScript。

我们相信大多数开发人员已经了解了 Angular 的变化以及它的编译工作原理。简单回顾一下--在这里,我们将在我们的 Angular 示例项目中使用 TypeScript,因为 Angular 建议使用它,尽管也有使用 ES6 的选项。我们将使用 node/npm tsc 模块将 TypeScript 编译为普通的 JavaScript;node/npm 也将是我们的 CLI 工具,用于构建/启动项目和运行测试。

这里需要对 node/npm 模块有基本的了解,特别是 npm 命令的工作原理。

Angular 项目

我们不会演示如何安装 Angular 以及如何从头开始构建项目,因为 Angular 文档网站已经很好地展示了这一点。因此,我们将从 Angular 团队的示例中获取一个简单的 Angular 项目,并为我们的实现更新它。

我们将从 Angular GitHub 仓库克隆quickstart项目,并从那个项目开始。希望除了 node/npm 之外,我们都已经全局安装了git

**$ git clone https://github.com/angular/quickstart.git angular-
    karma**

这将把项目本地复制为angular-karma,文件夹结构将如图所示:

Angular 项目

让我们继续进行并准备运行:

**$ cd angular-karma**
**$ npm install**

以下是准备示例项目的几个步骤。npm install命令将安装在项目根目录的package.json文件中定义的项目依赖的所需模块。

然后,我们将使用npm start来运行项目;在package.json中定义的这个脚本用于在本地服务器上运行项目。

让我们编译并运行项目:

**$ npm start**

如果所有必需的依赖都已安装,此命令将把 TypeScript 编译为普通的 JavaScript,并在本地服务器上运行项目。

项目将在浏览器中启动,并将如下所示:

Angular 项目

如果这个示例项目成功运行,那么我们就可以进行下一步了,在下一步中,我们将添加一个测试规范,其中将包括 Karma,并使用 Karma 运行这些测试。

准备工作

当我们克隆了示例quickstart项目时,它已经在项目中集成和配置了 Karma。为了学习的目的,我们想要在现有项目中集成 Karma。

为此,我们将不得不从项目根目录中删除现有的karma.conf.js文件。此外,我们还将从node_modules中删除 Karma、Jasmine 和相关模块。

有趣的是,我们可以通过一个简单的命令轻松创建基本的 Karma 配置文件,而不是手动操作。而且通过这个命令,它会询问一些基本问题,就像我们在本章的前一部分看到的那样。

设置 Karma

在 Angular 项目中设置 Karma,第一步是在项目根目录创建一个karma.conf.js文件。这个文件基本上包含一些键值对的配置。

有趣的是,我们可以通过一个简单的命令轻松创建基本的 Karma 配置文件。通过这个命令,它会询问一些基本问题,就像我们在本章的前一部分看到的那样:

**$ karma init**

使用默认答案。在当前目录创建了karma.conf.js之后,打开配置。可能需要更改一些配置,主要是 Karma 要使用的文件的定义。

files部分使用以下定义,定义运行测试所需的文件:

files: [ 
        // System.js for module loading 
      'node_modules/systemjs/dist/system.src.js', 

      // Polyfills 
      'node_modules/core-js/client/shim.js', 
      'node_modules/reflect-metadata/Reflect.js', 

      // zone.js 
      'node_modules/zone.js/dist/zone.js', 
      'node_modules/zone.js/dist/long-stack-trace-zone.js', 
      'node_modules/zone.js/dist/proxy.js', 
      'node_modules/zone.js/dist/sync-test.js', 
      'node_modules/zone.js/dist/jasmine-patch.js', 
      'node_modules/zone.js/dist/async-test.js', 
      'node_modules/zone.js/dist/fake-async-test.js', 

// RxJs 
      { pattern: 'node_modules/rxjs/**/*.js', included: false,
      watched: false },      { pattern: 'node_modules/rxjs
      /**/*.js.map', included: false,        watched: false }, 

// Angular itself 
      { pattern: 'node_modules/@angular/**/*.js', included: 
      false,        watched: false }, 

{ pattern: 'systemjs.config.js', included: false, watched: false }, 
      { pattern: 'systemjs.config.extras.js', included: false,   
      watched: false }, 
      'karma-test-shim.js', 

          {pattern: 'app/**/*.js', included: false, watched: true} 
    ] 

注意

在这里,通过模式,我们传递了两个选项,includedwatchedincluded指的是我们是否想要使用<script>标签包含文件;在这里,我们将通过 SystemJS 添加它,所以传递为falsewatched指的是文件在更改时是否会被监视。我们设置为true,因为我们想要监视这些更改。

似乎添加了很多文件,但这些是运行测试的基本必需品。

让我们仔细看看这些文件是什么。在第一部分,这些主要是库文件,包括 SystemJS 作为模块加载器,zonejs 作为同步处理程序,RxJS 作为响应式库,以及 Angular 库本身。

重要的是,第二部分中的一个新文件是karma-test-shim.js,我们需要在测试套件中与 Karma 一起使用作为模块加载器,也就是说,使用 SystemJS 在 Karma 测试运行器中加载模块。我们将在本节后面看一下那个文件。

然后,这是我们所有的应用程序源文件;我们也会把测试/规范文件放在同一个目录下,这样它们就会加载所有的模块文件,包括它们的测试/规范文件。

除了文件之外,根据需求,我们可能需要更改一些配置点,如下所示:

  • plugins:这是必需的,因为 Karma 将使用这些npm模块来执行。如果我们计划使用更多的npm模块,我们需要在这里添加它们;例如,如果我们计划将 PhantomJS 作为我们的浏览器使用,我们需要在列表中添加'karma-phantomjs-launcher'
        plugins: [ 
'karma-jasmine', 
'karma-chrome-launcher' 
   ] 

  • frameworks:目前不需要更改这个,因为默认情况下它选择 Jasmine;但是,如果我们计划使用其他框架,比如 Mocha,那么应该更新以下选项:
        frameworks: ['jasmine'], 

  • browsers:当我们需要在多个浏览器中运行测试时,这是有用的,大多数情况下,我们可能需要在 PhantomJS 中运行测试,因此我们可以添加多个浏览器,如下所示:
        browsers: ['Chrome', 'PhantomJS'] 

到目前为止,这些是我们在karma.con.js文件中需要的基本更改。

让我们来看看我们的karma.conf.js文件,看看它是什么样子的:

module.exports = function(config) { 

  config.set({ 
    basePath: '', 
    frameworks: ['jasmine'], 
    plugins: [ 
      require('karma-jasmine'), 
      require('karma-chrome-launcher') 
    ], 

    files: [ 
      // System.js for module loading 
      'node_modules/systemjs/dist/system.src.js', 

      // Polyfills 
      'node_modules/core-js/client/shim.js', 
      'node_modules/reflect-metadata/Reflect.js', 

      // zone.js 
      'node_modules/zone.js/dist/zone.js', 
      'node_modules/zone.js/dist/long-stack-trace-zone.js', 
      'node_modules/zone.js/dist/proxy.js', 
      'node_modules/zone.js/dist/sync-test.js', 
      'node_modules/zone.js/dist/jasmine-patch.js', 
      'node_modules/zone.js/dist/async-test.js', 
      'node_modules/zone.js/dist/fake-async-test.js', 

      // RxJs 
      { pattern: 'node_modules/rxjs/**/*.js', included: false,
      watched: false },     
      { pattern: 'node_modules/rxjs/**/*.js.map', included: 
      false,        watched: false }, 

      // Paths loaded via module imports: 
      // Angular itself 
      { pattern: 'node_modules/@angular/**/*.js', included: 
      false,        watched: false },
      { pattern: 'node_modules/@angular/**/*.js.map', included: 
      false, watched: false },
      { pattern: 'systemjs.config.js', included: false, 
      watched:        false },
      { pattern: 'systemjs.config.extras.js', included: false,
      watched: false },

      'karma-test-shim.js', 

      { pattern: 'app/**/*.js', included: false, watched: true } 
    ], 

    port: 9876, 
    colors: true, 
    autoWatch: true, 
    browsers: ['Chrome'], 
    singleRun: false 
  }) 
}

我们在文件列表中添加的另一个重要文件是karma-test-shim.js;正如我们之前提到的,这对我们使用 SystemJS(模块加载器)与 Karma 是必需的。我们从 Angular 快速启动项目中复制了该文件,根据项目结构可能需要进行更改。

让我们来看看我们的karma.conf.js文件:

Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing. 

jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; 

var builtPath = '/base/app/'; 

__karma__.loaded = function () { }; 

function isJsFile(path) { 
  return path.slice(-3) == '.js'; 
} 

function isSpecFile(path) { 
  return /\.spec\.(.*\.)?js$/.test(path); 
} 

function isBuiltFile(path) { 
  return isJsFile(path) && (path.substr(0, builtPath.length) == 
  builtPath); 
} 

var allSpecFiles = Object.keys(window.__karma__.files) 
  .filter(isSpecFile) 
  .filter(isBuiltFile); 

System.config({ 
  baseURL: 'base', 
  // Extend usual application package list with test folder 
  packages: { 'testing': { main: 'index.js', defaultExtension: 'js' 
  } }, 

  // Assume npm: is set in `paths` in systemjs.config 
  // Map the angular testing umd bundles 
  map: { 
    '@angular/core/testing':      
'npm:@angular/core/bundles/core-testing.umd.js',    
'@angular/common/testing':      
'npm:@angular/common/bundles/common-testing.umd.js',    
'@angular/compiler/testing':      
'npm:@angular/compiler/bundles/compiler-testing.umd.js',    
'@angular/platform-browser/testing':      
'npm:@angular/platform-browser/bundles/     
platform-browser-testing.umd.js',    
'@angular/platform-browser-dynamic/testing':      'npm:@angular/platform-browser-dynamic/bundles    
 /platform-browser-dynamic-testing.umd.js',    
'@angular/http/testing':      
'npm:@angular/http/bundles/http-testing.umd.js',    
'@angular/router/testing':      
'npm:@angular/router/bundles/router-testing.umd.js',    
'@angular/forms/testing':      
'npm:@angular/forms/bundles/forms-testing.umd.js', 
  }, 
}); 

System.import('systemjs.config.js') 
  .then(importSystemJsExtras) 
  .then(initTestBed) 
  .then(initTesting); 

/** Optional SystemJS configuration extras. Keep going w/o it */ 
function importSystemJsExtras(){ 
  return System.import('systemjs.config.extras.js') 
  .catch(function(reason) { 
    console.log( 
      'Warning: System.import could not load the optional        "systemjs.config.extras.js". Did you omit it by accident?        Continuing without it.' 
    ); 
    console.log(reason); 
  }); 
} 

function initTestBed(){ 
  return Promise.all([ 
    System.import('@angular/core/testing'), 
    System.import('@angular/platform-browser-dynamic/testing') 
  ]) 

  .then(function (providers) { 
    var coreTesting    = providers[0]; 
    var browserTesting = providers[1]; 

    coreTesting.TestBed.initTestEnvironment( 
      browserTesting.BrowserDynamicTestingModule, 
      browserTesting.platformBrowserDynamicTesting()); 
  }) 
} 

// Import all spec files and start karma 
function initTesting () { 
  return Promise.all( 
    allSpecFiles.map(function (moduleName) { 
      return System.import(moduleName); 
    }) 
  ) 
  .then(__karma__.start, __karma__.error); 
} 

测试 Karma 运行器

Karma 的初始设置几乎完成了;我们将不得不运行我们的测试并查看它的进展。在我们运行之前还有一步--我们必须在npm脚本中添加karma任务以通过npm命令运行。为此,我们将不得不在package.json文件的脚本部分中添加一个名为test的任务。

"scripts": { 
     "test": "karma start karma.conf.js" 
  } 

在添加了这个片段之后,我们可以通过npm运行测试,使用npm test,这与karma start的方式相同:

**$ npm test**

因此,最终,我们准备通过 Karma 运行我们的测试。然而,糟糕,我们遇到了一些错误!它缺少运行测试所需的jasmine-core模块;实际上,可能还缺少更多的模块。

带有错误的输出如下:

测试 Karma 运行器

是的,我们确实缺少模块,这些实际上是我们测试套件中的开发依赖项。我们将在下一节中更多地了解它们。

缺少的依赖项

尽管我们正在为 Angular 构建一个基本的测试套件,但我们仍然缺少一些必需的 npm 模块,这些是我们测试套件的开发依赖项,如下所示:

  • jasmine-core:这表明 Jasmine 是我们的测试框架

  • karma:这是我们测试套件的 Karma 测试运行程序

  • karma-chrome-launcher:这是从 Karma 启动 Chrome 所需的,因为我们在karma.config中定义了 Chrome 作为我们的浏览器

  • karma-jasmine:这是 Jasmine 的 Karma 适配器

只要这些是依赖项,我们应该安装这些模块并在package.json中包含它们。

我们可以一起安装所有这些,如下所示:

**$ npm install jasmine-core karma karma-chrome-launcher karma-
    jasmine --save-dev**

成功安装所有必需的依赖项后,我们似乎已经完成了配置过程,准备再次运行test

**$ npm test**

命令输出应该像这样:

**$ Chrome 39.0.2623 (Mac OS X 10.10.5): Executed 0 of 0 ERROR 
    (0.003 secs / 0 secs)** 

就是这样。Karma 现在正在为第一个 Angular 应用程序运行。

使用 Angular 和 Karma 进行测试

使用 Karma 进行的第一个测试的目的是创建一个动态的待办事项清单。这个演练将遵循我们在第一章中讨论的 TDD 步骤,测试驱动开发简介:先测试,使其运行,然后改进。这将使我们能够在 Angular 应用程序中使用 TDD 获得更多经验。

一个开发待办事项清单

在开始测试之前,让我们把注意力集中在使用开发待办事项清单需要开发的内容上。这将使我们能够组织我们的想法。

这是待办事项清单:

  • 维护项目列表:示例列表包括 test,execute 和 refactor

  • 向列表中添加项目:添加项目后的示例列表是 test,execute,refactor 和 repeat

  • 从列表中删除项目:添加和删除项目后的示例列表是 test,execute 和 refactor

测试项目列表

第一个开发项目是为我们提供在组件上有一个项目列表的能力。接下来的几个步骤将引导我们通过使用 TDD 生命周期添加第一个功能的 TDD 过程。

先测试

确定从哪里开始通常是最困难的部分。最好的方法是记住三个A组装行动断言)并从基本的 Jasmine 模板格式开始。要做到这一点的代码如下:

describe('Title of the test suite', () => { 
    beforeEach(() => { 
        // .... 
    }); 

    it('Title of the test spec', () => { 
        // .... 
    }); 
}); 

让我们来看一下解释:

  • describe:这定义了我们正在测试的主要功能。字符串将以可读的方式解释该功能,然后函数将遵循测试。

  • beforeEach:这是组装步骤。在这一步中定义的函数将在每次断言之前执行。最好在这个函数中放置每个测试之前所需的测试设置。

  • it:这是行动和断言步骤。在it部分,我们将执行正在测试的操作,然后进行一些断言。行动步骤不必进入it函数。根据测试的需要,它可能更适合在beforeEach函数中。

三个 A - 组装,行动和断言

现在模板准备好了,我们可以开始拼凑这些部分。我们将再次遵循三个 A 的原则。

以下是组装部分的两个部分。

在第一部分中,我们初始化组件并使用以下代码执行类的构造函数:

import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 

import { AppComponent } from './app.component'; 

beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent ] 
    }) 
    .compileComponents(); 
  })); 

beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
}); 
... 

在这里,我们导入了一些 Angular 测试 API,例如asyncTestbed。在这里,async用于加载所需的模块以为测试套件引导应用程序,TestBed是编写 Angular API 单元测试的主要 API。它具有一些服务,用于在测试套件中创建,编译和初始化组件。

我们还没有定义AppComponent组件,但我们会在获得失败的测试之后这样做。

第二部分讨论了AppComponent对象。AppComponent对象将在其this变量上包含项目列表。添加以下代码到beforeEach以获取组件对象:

// comp will hold the component object  
let comp: AppComponent; 
let fixture: ComponentFixture<AppComponent>; 
beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
}); 

在断言中,再次有两个部分:

第一个断言是确保AppComponent对象具有定义为三个项目的items变量。items变量将用于保存所有项目的列表:

it('Should define a list object', () => { 
        expect(com.items).toBeDefined(); 
    }); 

第二和第三个断言将用于确认列表中的数据是否正确:

//Second test 
it('Should have 3 items in list', () => { 
        expect(com.items.length).toBe(3); 
    }); 

//Third test 
it('List items should be as expected', () => { 
        expect(com.items).toEqual(['test','execute','refactor']); 
    }); 

就是这样;第一个是测试,第二个是执行,第三个是重构。

使其运行

TDD 生命周期中的下一步是使应用程序运行并修复代码,以使测试通过。记住,考虑可以添加的最小组件,以便通过以下步骤进行测试:

  1. 通过输入以下命令来运行 Karma 测试:
**$ npm start**
**$ npm test**

  1. 如果我们遇到TypeError: app_component_1.AppComponent is not a constructor错误,那么可能是由于以下原因:
  • 前面的错误消息表明AppComponent对象尚未定义。由于错误消息告诉我们需要什么,这是开始的完美地方。
  1. AppComponent类添加到app.component.ts文件中,如下所示:
        export class AppComponent { };
  1. 再次从npm控制台运行starttest命令。现在我们应该看到一个新的错误。错误:预期的未定义为以下定义
  • 新的错误消息再次很清晰。我们还可以看到,代码现在已经通过了我们在以下位置的断言点:
               expect(com.items).toBeDefined();
  • 由于对象中没有项目,我们需要添加一个。更新app/app.component.ts文件如下:
              export class AppComponent { 
                  items:Array<string>; 
              }; 

  1. 让我们再次从npm控制台运行starttest命令。现在我们应该看到三个测试中的一个通过了!这意味着我们成功地使用了 TDD 和 Karma 来使我们的第一个测试通过了。现在,我们需要修复其他三个。
  • 下一个错误是:预期的 0 等于 3

  • 错误输出再次准确描述了需要发生的事情。我们只需要用元素测试、执行和运行初始化数组。让我们去app/app.component.ts并将数据添加到数组初始化:

            export class AppComponent { 
                items:Array<string>; 
                constructor() { 
                    this.items = ['test','execute','refactor']; 
                } 
            }; 

  1. 再次从 npm 控制台运行starttest命令。太棒了!输出是绿色的,并且声明所有测试都通过了。此步骤的结果组件和类代码如下:
        import {Component} from '@angular/core'; 

        @Component({ 
            // ...  
        }) 

        export class AppComponent { 
            items:Array<string>; 
            constructor() { 
                this.items = ['test','execute','refactor']; 
            } 
        }; 

现在使其运行步骤完成了,我们可以继续下一步,使其更好。

使其更好

到目前为止,没有直接重构或在开发待办事项列表中标识的内容。对开发待办事项列表的审查显示可以划掉一个项目:

  • 查看待办事项列表:示例列表包括测试、执行和重构

  • 向待办事项列表添加项目:在添加项目后的示例列表将包括测试、执行、重构和新项目

接下来的要求是向列表中添加一个新项目。将再次遵循 TDD 节奏:先测试,使其运行,然后使其更好。

向组件类添加一个函数

下一个任务是赋予类添加项目到对象的能力。这将需要向对象添加一个方法。这个演练将遵循我们之前遵循的相同的 TDD 步骤。

首先测试

不要创建新文件并复制一些组装步骤,而是将以下测试插入到最后一个it方法下。原因是将使用相同的模块和控制器:

describe('Title of the test suite', () => { 
    let app:AppComponent; 

    beforeEach(() => { 
        // .... 
    }); 

    it('Title of the test spec', () => { 
        // .... 
    }); 

    describe('Testing add method', () => { 

    beforeEach(() => { 
        // .... 
    }); 

    it('Title of the test spec', () => { 
        // .... 
    }); 
   }); 
}); 

三个 A - 组装、行动和断言

现在模板准备好了,我们可以开始使用 3A 法则填补空白:

  • 组装:组件和对象将被继承,无需初始化或设置。

  • 行动:在这里,我们需要对add方法进行操作,添加一个新项目。我们将act函数放入beforEach函数中。这样可以在添加更多测试时重复相同的步骤:

        beforeEach(() => { 
             com.add('new-item') 
        }); 

  • 断言:在这里,应该向列表中添加一个项目,然后确认数组中的最后一个项目是否符合预期:
        it('Should have 4 items in list', () => { 
             expect(com.items.length).toBe(4); 
        }); 
        it('Should add a new item at the end of list', () => { 
            var lastIndexOfList = com.items.length - 1; 
            expect(com.items[lastIndexOfList]).toEqual('new-item'); 
        }); 

使其运行

TDD 生命周期中的下一步是使其运行。记住,考虑可以添加以使测试通过的最小组件,如下所示:

  • 确保 Karma 在我们的控制台中运行,方法是输入以下命令:
**$ npm start**
**$ npm test**

  • 第一个错误将声明TypeError: undefined is not a function

此错误是指以下代码行:

        app.add('new-item'); 

错误告诉我们add方法尚未定义。add函数将需要添加到app/app.component.ts代码中。类已经定义,因此需要将add函数放入类中:

        add() { 
                this.items.push('new-item'); 
        }; 

请注意,add函数不包含任何逻辑。已添加了最小的组件以使测试满足错误消息。

  • 在控制台窗口查看下一个错误。

成功!现在所有五个测试都已通过。

为了使测试通过,添加的代码如下所示:

import {Component} from '@angular/core'; 

@Component({ 
    selector: 'my-app', 
    template: `<h3>MY Items</h3><ul><li *ngFor="let item of items">{{ 
    item }}</li></ul>` 
}) 

export class AppComponent { 
    items:Array<string>; 
    constructor() { 
        this.items = ['test','execute','refactor']; 
    } 
    add() { 
        this.items.push('new-item'); 
    } 
}; 

使其更好

我们需要重构的主要问题是add函数仍未完全实现。它包含一个硬编码的值,一旦我们将不同的项目发送到add函数中,测试就会失败。

保持 Karma 运行,以便在进行更改时继续通过测试。当前add方法的主要问题如下:

  • 它不接受任何参数

  • 它不会将参数推送到列表中,而是使用硬编码的值

现在,生成的add函数应如下所示:

        add(item) { 
            this.items.push(item); 
        }; 

再次从npm控制台运行starttest命令。确认 Karma 输出仍然显示SUCCESS

**$ Chrome 49.0.2623 (Mac OS X 10.10.5): Executed 5 of 5 SUCCESS
    (0.016 secs / 0.002 secs)**

配置 Karma 与 Travis CI

持续集成CI)是开发实践,开发人员需要将代码集成到共享存储库中。它在代码库发生任何更改时在自动化构建过程中运行测试。这可以在推送到生产环境之前及早检测到错误。有许多 CI 服务,包括 Travis CI、Jenkin CI、Circle CI 等。

在本节中,我们将看到如何将 Karma 与 Travis 集成。

Travis CI

Travis CI 是一个流行的托管式持续集成平台,它与 GitHub 项目/仓库集成,可以在代码库的任何分支中的每次更改或者拉取请求时自动运行测试。只需在项目根目录中放置一个.travis.yml文件,其中包含有关项目的一些配置信息,就可以轻松获得集成系统。

那么,我们可能会问,为什么选择 Travis?还有其他几个 CI 服务。如果我们将 Travis 与其他 CI 服务进行比较,它比其他服务有一些优势:

  • 这是一个托管服务;无需主机、安装和配置

  • 它是免费和开源的

  • 它为每个分支都有单独的测试代码,因此很容易为单个分支运行测试

配置 Travis

正如我们所说,我们将在项目目录中有一个.travis.yml文件,其中包含有关我们的项目的一些配置和信息。

以下是 YAML 文件中的基本配置:

  • 指定语言:我们在这里使用了 Node.js:
        language: node_js 
        node_js: 
            -  "4" 

  • 命令或脚本:这是在每次构建之前或之后运行的必需内容;如下所示,此脚本将在每次运行构建之前设置git用户名:
        before_script: 
            -  git config - -global user.name jquerygeek  

在前面的示例中,我们已经传递了配置,以在真实浏览器(Firefox)中使用 karma 在虚拟屏幕上运行构建过程,默认情况下使用 PhantomJS 无头浏览器运行该过程。只要 Travis 支持 PhantomJS 之外的真实浏览器,这可能会很方便:

        before_script: 
            -  export DISPLAY=:99.0 
            - sh -e /etc/init.d/xvfb start  

  • 通知:这是设置电子邮件和聊天通知所必需的。在这里,我们将email设置为false,因为我们不希望收到有关构建的加班电子邮件通知:
        notifications: 
            email: false  

使用 Karma 设置测试

正如之前所见,我们猜测在 npm 包中的项目根目录中有package.json文件;如果没有,让我们在那里创建一个package.json文件并添加这些配置片段。这里,第一个是 Karma 的依赖项,第二个是为npm test设置所需的参数,因为 Travis 将运行此命令来触发我们的测试。这些将告诉 Travis 如何运行我们的测试:

  'devDependencies': { 
      'karma': '~0.12' 
  }, 

  'scripts': { 
      'test': 'karma start  - -single-run - -browsers PhantomJS ' 
  } 

我们的初始设置和配置已经准备好进行测试。我们定义了 Karma 依赖项,因为 Travis 将为每个套件运行nmp install,并将采取必要的步骤来添加 Karma。并且为了运行测试,它将调用npm test,我们定义了测试任务将如何运行测试。在这里,我们将默认浏览器设置为 PhantomJS,以便测试将使用它运行。但是,如果我们需要使用不同的浏览器运行测试,我们应该在.travis.yml文件中使用before_script命令进行定义,就像我们之前为 Firefox 所示的那样。

在这种情况下,npm test将不会使用自定义浏览器运行测试;为此,我们必须使用浏览器名称进行自定义调用,如下所示:

**karma start - -browsers Firefox - -single-run** 

自测问题

以下自测问题将帮助您进一步测试使用 AngularJS 和 Karma 进行 TDD 的知识:

Q1.  如何使用 Karma 创建配置文件?

  1. karma config

  2. karma init

  3. karma -config karma.conf.js

Q2.  Jasmine 测试方法名为before,在每次测试之前执行。

  1. 正确

  2. 错误

Q3.  Bower 用于安装 Karma。

  1. 正确

  2. 错误

Q4.  这三个 A 代表哪一个?

  1. 一组超级英雄

  2. 集合,行动和断言

  3. 接受,批准和行动

摘要

在本章中,我们讨论了 Karma 如何变得重要。我们看到了如何安装,配置和运行 Karma。最后,我们通过一个使用 Karma 进行 TDD 的 Angular 示例项目进行了演示。

在下一章中,我们将学习如何使用 Protractor 进行端到端测试。

第四章:使用 Protractor 进行端到端测试

单元测试只是测试每段代码的责任的测试的一个方面。然而,当涉及测试任何组件、模块或完整应用程序的流程和功能时,端到端测试是唯一的解决方案。

在本章中,我们将逐步查看应用程序所有层的端到端测试流程。我们将介绍 Protractor,这是 Angular 团队的端到端测试工具。我们已经知道了它的原因,它是为什么创建的,以及它解决了什么样的问题。

在本章中,我们将学习:

  • 安装和配置 Protractor 的过程

  • 在我们现有的 Angular 项目中实施 Protractor 端到端测试

  • e2e 测试运行

  • 返回测试结果

Protractor 概述

Protractor 是一个端到端测试工具,使用 Node.js 运行,并作为 npm 包提供。在具体讨论 Protractor 之前,我们需要了解什么是端到端测试。

我们已经在第二章中简要了解了端到端测试,但让我们快速回顾一下:

端到端测试是针对应用程序所有相互连接的移动部分和层的测试。这与单元测试不同,单元测试侧重于单个组件,如类、服务和指令。通过端到端测试,重点是应用程序或模块作为一个整体的工作方式,例如确认按钮点击触发 x、y 和 z 动作。

Protractor 允许通过与应用程序的 DOM 元素交互来对任何模块甚至任何大小的 Web 应用程序进行端到端测试。它提供了选择特定 DOM 元素、与该元素共享数据、模拟按钮点击以及与用户相同的方式与应用程序交互的能力。然后,它允许根据用户的期望设置期望。

Protractor 的核心

在快速概述中,我们对 Protractor 有了一个基本的了解——它如何选择 DOM 元素并与它们进行交互,就像真正的用户一样,以便在任何应用程序上运行 e2e 测试。为了执行这些活动,Protractor 提供了一些全局函数;其中一些来自其核心 API,一些来自 WebDriver。我们将在第五章 Protractor,更进一步中详细讨论它们。

然而,让我们现在快速概述一下:

  • 浏览器:Protractor 提供了全局函数 browser,它是来自 WebDriver 的全局对象,主要用于在 e2e 测试过程中与应用程序浏览器进行交互。它提供了一些有用的方法来进行交互,如下所示:
        browser.get('http://localhost:3000'); // to navigate the    
        browser to a specific url address  
        browser.getTitle(); // this will return the page title that 
        defined in the projects landing page  

还有许多其他内容,我们将在下一章中讨论。

  • 元素:这是 Protractor 提供的一个全局函数;它基本上用于根据定位器查找单个元素,但它也支持多个元素选择,通过链接另一个方法 .all 作为 element.all,它还接受 Locator 并返回 ElementFinderArray。让我们看一个 element 的例子:
        element(Locator); // return the ElementFinder 
        element.all(Locator); // return the ElementFinderArray 
        element.all(Locator).get(position);  // will return the
        defined  position 
        element from the ElementFinderArray  
        element.all(Locator).count(); // will return the 
        total number in the select element's array   

还有许多其他内容,我们将在下一章中讨论。

  • 操作:正如我们所见,element 方法将返回一个选定的 DOM element 对象,但我们需要与 DOM 进行交互,为此工作的操作方法带有一些内置方法。DOM 不会通过任何操作方法调用与浏览器单元联系。让我们看一些操作的例子:
        element(Locator).getText(); // return the ElementFinder 
        based on locator  
        element.(Locator).click(); // Will trigger the click 
        handler for that specific element  
        element.(Locator).clear(); // Clear the field's value 
        (suppose the element is input field)

还有许多其他内容,我们将在下一章中讨论。

  • 定位器:这实际上告诉 Protractor 如何在 DOM 元素中找到某个元素。Protractor 将 Locator 导出为全局工厂函数,将与全局 by 对象一起使用。让我们看一些 Locator 的例子:
        element(by.css(cssSelector)); // select element by css 
        selector  
        element(by.id(id)); //  select element by element ID 
        element.(by.model); // select element by ng-model 

还有许多其他内容,我们将在下一章中讨论。

一个快速的例子

现在我们可以通过一个快速示例来考虑以下用户规范。

假设我在搜索框中输入 abc,应该发生以下情况:

  • 搜索按钮应该被点击

  • 至少应该收到一个结果。

上述规范描述了一个基本的搜索功能。上述规范中没有描述控制器、指令或服务;它只描述了预期的应用程序行为。如果用户要测试规范,他们可能执行以下步骤:

  1. 将浏览器指向该网站。

  2. 选择输入字段。

  3. 在输入字段中键入abc

  4. 点击搜索按钮。

  5. 确认搜索输出显示至少一个结果。

Protractor 的结构和语法与 Jasmine 以及我们在第三章中编写的测试相似,卡尔玛方式。我们可以将 Protractor 视为 Jasmine 的包装器,具有支持端到端测试的附加功能。要使用 Protractor 编写端到端测试,我们可以按照刚才看到的相同步骤进行,但使用代码。

以下是带有代码的步骤:

  1. 将浏览器指向该网站:
        browser.get('/'); 

  1. 选择输入字段:
        var inputField = element.all(by.css('input')); 

  1. 在输入字段中键入abc
        inputField.setText('abc'); 

  1. 点击搜索按钮:
        var searchButton = element.all(by.css('#searchButton'); 
        searchButton.click(); 

  1. 在页面上找到搜索结果的详细信息:
        var searchResults = element.all(by.css('#searchResult'); 

  1. 最后,需要断言至少有一个或多个搜索结果在屏幕上可用:
        expect(searchResults).count() >= 1); 

作为完整的测试,代码将如下所示:

    describe('Given I input 'abc' into the search box',function(){ 
        //1 - Point browser to website 
        browser.get('/'); 
        //2 - Select input field 
        var inputField = element.all(by.css('input')); 
        //3 - Type abc into input field 
        inputField.setText('abc'); 
        //4 - Push search button 
        var searchButton = element.all(by.css('#searchButton'); 
        searchButton.click(); 
        it('should display search results',function(){ 
        // 5 - Find the search result details 
        var searchResults = element.all(by.css('#searchResult'); 
        //6 - Assert 
        expect(searchResults).count() >= 1); 
        }); 
    }); 

就是这样!当 Protractor 运行时,它将打开浏览器,转到网站,按照说明进行,最后检查期望结果。端到端测试的诀窍在于清晰地了解用户规范是什么,然后将该规范转化为代码。

前面的示例是本章将描述的内容的高层视图。现在我们已经介绍了 Protractor,本章的其余部分将展示 Protractor 在幕后的工作原理,如何安装它,并最终通过 TDD 的完整示例来引导我们。

Protractor 的起源

Protractor 并不是 Angular 团队构建的第一个端到端测试工具。第一个工具被称为场景运行器。为了理解为什么要构建 Protractor,我们首先需要看看它的前身--场景运行器。

场景运行器处于维护模式,并已到达其生命周期的尽头。它已被淘汰,取而代之的是 Protractor。在本节中,我们将看看场景运行器是什么,以及这个工具存在哪些缺陷。

Protractor 的诞生

朱莉·拉尔夫是 Protractor 的主要贡献者。根据朱莉·拉尔夫的说法,Protractor 的动机是基于她在 Google 内另一个项目中使用 Angular 场景运行器的经验(javascriptjabber.com/106-jsj-protractor-with-julie-ralph/)。

“我们尝试使用场景运行器。我们发现它真的无法做我们需要测试的事情。我们需要测试诸如登录之类的东西。您的登录页面不是一个 Angular 页面,场景运行器无法处理。它也无法处理弹出窗口和多个窗口,浏览器历史记录导航等等。”

基于她对场景运行器的经验,朱莉·拉尔夫决定创建 Protractor 来填补空白。

Protractor 利用了 Selenium 项目的成熟性,并包装其方法,以便它可以轻松用于 Angular 项目。记住,Protractor 是通过用户的眼睛进行测试的。它旨在测试应用程序的所有层:Web UI,后端服务,持久层等等。

没有 Protractor 的生活

单元测试并不是唯一需要编写和维护的测试。单元测试侧重于应用程序的小个体组件。通过测试小组件,代码和逻辑的信心增强。单元测试不关注连接时完整系统的工作方式。

使用 Protractor 进行端到端测试允许开发人员专注于功能或模块的完整行为。回到搜索示例,只有当整个用户规范通过时,测试才应该通过;在搜索框中输入数据,单击“搜索”按钮,然后查看结果。Protractor 并不是唯一的端到端测试框架,但它是 Angular 应用程序的最佳选择。以下是选择 Protractor 的几个原因:

  • 它在整个 Angular 教程和示例中都有文档记录

  • 它可以使用多个 JavaScript 测试框架编写,包括 Jasmine 和 Mocha

  • 它为 Angular 组件提供了便利的方法,包括等待页面加载,对承诺的期望等等

  • 它包装了 Selenium 方法,自动等待承诺实现

  • 它得到了 SaaS(软件即服务)提供商的支持,例如 Sauce Labs,可在saucelabs.com/上使用

  • 它得到了与维护 Angular 和 Google 相同的公司的支持和维护

使用 Protractor 做好准备

现在是时候开始动手安装和配置 Protractor 了。安装和应用程序不断变化。主要关注点将放在本书中使用的特定配置上,而不是深入的安装指南。有几种不同的配置,因此请查看 Protractor 网站以获取更多详细信息。要查找最新的安装和配置指南,请访问 angular.github.io/protractor/

安装先决条件

Protractor 有以下先决条件:

  • Node.js:Protractor 是一个使用 npm 可用的 Node.js 模块。安装 Node.js 的最佳方法是按照官方网站上的说明进行操作 nodejs.org/download/

  • Chrome:这是由 Google 构建的 Web 浏览器。它将用于在 Protractor 中运行端到端测试,而无需 Selenium 服务器。请按照官方网站上的安装说明进行安装 www.google.com/chrome/browser/

  • Chrome 的 Selenium WebDriver:这是一个允许您与 Web 应用程序进行交互的工具。Selenium WebDriver 随 Protractor npm 模块一起提供。我们将在安装 Protractor 时按照说明进行操作。

安装 Protractor

以下是安装 Protractor 的步骤:

  1. 一旦 Node.js 安装并在命令提示符中可用,输入以下命令在当前目录中安装 Protractor:
**$ npm install protractor**

  1. 上述命令使用 Node 的 npm 命令在当前本地目录中安装 Protractor。

  2. 在命令提示符中使用 Protractor,使用相对路径到 Protractor bin 目录。

  3. 测试 Protractor 版本是否可以确定如下:

**$ ./node_modules/protractor/bin/protractor --version**

安装 Chrome 的 WebDriver

以下是安装 Chrome 的 WebDriver 的步骤:

  1. 要安装 Chrome 的 Selenium WebDriver,请转到 Protractor bin 目录中的 webdriver-manager 可执行文件,该文件位于 ./node_modules/protractor/bin/,然后输入以下内容:
**$ ./node_modules/protractor/bin/webdriver-manager update**

  1. 确认目录结构。

  2. 上述命令将创建一个包含项目中所需的 Chrome 驱动程序的 Selenium 目录。

安装现在已经完成。Protractor 和 Chrome 的 Selenium WebDriver 都已安装。现在我们可以继续进行配置。

自定义配置

在本节中,我们将使用以下步骤配置 Protractor:

  1. 从标准模板配置开始。

  2. 幸运的是,Protractor 安装时在其安装目录中带有一些基本配置。

  3. 我们将使用的是位于 protractor/example 部分的conf.js

  4. 查看示例配置文件:

capabilities参数应该只指定浏览器的名称:

          exports.config = {  
          //...  
          capabilities: { 
            'browserName': 'chrome' 
          },   
          //... 
          }; 

framework 参数应该指定测试框架的名称,我们将在这里使用 Jasmine:

          exports.config = {  
          //...  
          framework: 'jasmine'   
          //... 
          };

最后一个重要的配置是源文件声明:

          exports.config = { 
            //... 
            specs: ['example_spec.js'], 
            //... 
          }; 

太棒了!现在我们已经安装和配置了 Protractor。

确认安装和配置

要确认安装,Protractor 需要在specs配置部分中至少定义一个文件。在添加真正的测试并复杂化之前,在根目录中创建一个名为confirmConfigTest.js的空文件。然后,编辑位于项目根目录中的conf.js文件,并将测试文件添加到specs部分,使其看起来如下:

**specs: ['confirmConfigTest.js'],**

要确认 Protractor 已安装,可以转到项目目录的根目录并输入以下内容来运行 Protractor:

 **$ ./node_modules/protractor/bin/protractor conf.js**

如果一切设置正确并安装完成,我们将在命令提示符中看到类似于这样的内容:

**Finished in 0.0002 seconds**
**0 tests, 0 assertions, 0 failures**

常见的安装和配置问题

在安装 Chrome 的 WebDriver 时,您可能会遇到一些常见问题:

问题 解决方案
Selenium 未正确安装 如果测试与 Selenium WebDriver 位置相关的错误,您需要确保按照更新 WebDriver 的步骤进行操作。更新步骤会将 WebDriver 组件下载到本地 Protractor 安装文件夹中。在 WebDriver 更新之前,您将无法在 Protractor 配置中引用它。确认更新的简单方法是查看 Protractor 目录,并确保存在一个 Selenium 文件夹。
无法找到测试 当 Protractor 未执行任何测试时,这可能会令人沮丧。开始的最佳地方是在配置文件中。确保相对路径和任何文件名或扩展名都是正确的。

有关完整列表,请参阅官方 Protractor 网站angular.github.io/protractor/

将 Protractor 与 Angular 集成

到目前为止,我们已经看到了如何安装和配置 Protractor,也对 Protractor 的工作原理有了基本概述。在本节中,我们将通过将 Protractor 集成到现有的 Angular 项目中的过程,来了解 Protractor 在实际的 e2e 测试中是如何使用的。

获取现有项目

此测试中的代码将利用第三章中经过单元测试的代码,Karma 方式。我们将把代码复制到一个名为 angular-protractor 的新目录中。

作为提醒,该应用是一个待办事项应用程序,其中有一些项目在待办事项列表中;让我们向列表中添加一些更多项目。它有一个单一的组件类 AppComponent,其中有一个项目列表和一个 add 方法。当前的代码目录应该结构如下:

获取现有项目

获得这个结构后,第一件事是通过运行以下命令在本地获得所需的依赖项 node_modules

**$ npm install**

这将安装所有必需的模块;接下来,让我们使用 npm 命令构建和运行项目:

**$ npm start**

一切应该都很好;项目应该在 http://localhost:3000 上运行,输出应该如下:

获取现有项目

是的,我们已经准备好进入下一步,在我们的 Angular 项目中实现 Protractor。

Protractor 设置流程

设置将反映我们在本章前面看到的安装和配置步骤:

  1. 安装 Protractor。

  2. 更新 Selenium WebDriver。

  3. 根据示例配置配置 Protractor。

我们将在一个新的项目目录中按照前一节中涵盖的 Protractor 安装和配置步骤进行操作。唯一的区别是,Protractor 测试可以以 e2e 前缀命名,例如 **.e2e.js。这将使我们能够轻松地在项目结构中识别 Protractor 测试。

提示

这绝对取决于开发者的选择;有些人只是将 Protractor 测试放在一个新目录中,带有子文件夹 spec/e2e。这只是项目结构的一部分。

安装 Protractor

我们可能已经全局设置了 Protractor,也可能没有,所以最好在项目中安装 Protractor。因此,我们将在本地安装 Protractor,并在 package.json 中添加为 devDependency

要在我们的项目中安装 Protractor,请从项目目录运行以下命令:

**$ npm install protractor -save-dev**

我们可以按以下方式检查 Protractor:

**$ ./node_modules/protractor/bin/protractor --version**

这应该提供最新版本 4.0.10,如下所示:

**Version 4.0.10**

提示

我们将遵循的一个好的做法

我们展示了如何在目录中设置 Protractor,但最好使用以下命令全局安装 Protractor:

**$ npm install -g protractor**

这样我们就可以轻松地从命令行调用 Protractor,就像使用protractor一样;要知道 Protractor 的版本,我们可以按照以下方式调用它:

**$ protractor -version**

更新 WebDriver

要更新 Selenium WebDriver,转到 Protractor bin目录中的webdriver-manager可执行文件,该目录可以在./node_modules/protractor/bin/中找到,并键入以下内容:

**$ ./node_modules/protractor/bin/webdriver-manager update**

根据通知,一个好的做法是全局安装 Protractor,如果这样,我们也将全局拥有webdriver-manager命令,这样,我们可以轻松地运行update,如下所示:

**$ webdriver-manager update**

这将更新 WebDriver 并支持最新的浏览器。

准备工作

由于我们克隆了示例快速启动项目,它已经在项目中集成和配置了 Protractor。为了学习的目的,我们想在现有项目中集成 Protractor。

为此,我们将不得不从项目根目录中删除现有的protractor.config.js文件。

设置核心配置

正如我们之前所看到的,Protractor 配置将存储在一个 JS 文件中。我们需要在项目根目录中创建一个配置文件;让我们将其命名为protractor.config.js

目前,保持可变字段为空,因为这些字段取决于项目结构和配置。因此,初始外观可能是这样的,我们已知这些配置选项:

exports.config = { 

    baseUrl: ' ', 

    framework: 'jasmine', 

    specs: [], 

    capabilities: { 
        'browserName': 'chrome' 
    } 

}; 

只要我们的项目在本地端口3000上运行,我们的baseUrl变量将如下所示:

exports.config = { 
    // ....  
    baseUrl: ' http://localhost:3000', 
    // ....  
}; 

我们计划将 e2e 测试规范放在与单元测试文件相同的文件夹中,app/app.component.spec.ts。这次它将有一个新的 e2e 前缀,看起来像app/app.component.e2e.ts。基于此,我们的规范和配置将被更新:

exports.config = { 
    // ....  
    specs: [ 
        'app/**/*.e2e.js' 
    ], 
    // ..... 
}; 

只要它是一个 Angular 项目,我们需要传递额外的配置,useAllAngular2AppRoots: true,因为这将告诉 Protractor 等待页面上所有 Angular 应用程序的根元素,而不仅仅是匹配的一个根元素:

exports.config = { 
    // ....  
    useAllAngular2AppRoots: true, 
    // ..... 
}; 

我们通过节点服务器运行我们的项目;因此,我们需要传递一个配置选项,以便 Jasmine 本身支持节点。这个配置在 Jasmine 2.x 版本中是必须的,但如果我们使用 Jasmine 1.x,则可能不需要。在这里,我们已经在jasmineNodeOpts中添加了两个最常见的选项;根据需求,还有一些选项被使用:

exports.config = { 
    // ....  
    jasmineNodeOpts: { 
        showColors: true, 
        defaultTimeoutInterval: 30000 
    }, 
    // ..... 
}; 

深入测试细节

要运行 Protractor 测试,我们需要两个文件:一个是配置文件,我们已经在项目根目录中创建了protractor.conf.js,另一个是规范,我们将在 app 文件夹中定义 e2e 测试规范,该文件将位于app/app.component.e2e.ts

所以,让我们看看我们应该在那里定义的文件:

describe('Title for test suite', () => { 

    beforeEach(() => { 
        // ...  
    }); 

    it('Title for test spec', () => { 
        // ... 
    }); 

});; 

我们应该知道这些语法,因为我们已经在单元测试套件中使用了 Jasmine 语法。

让我们快速回顾一下

  • describe:这包含要运行测试套件的代码块

  • beforeEach:这用于包含设置代码,该代码在每个测试规范中使用

  • it:这用于定义测试规范并包含该测试规范的特定代码以运行

对于任何网站运行 e2e 测试的主要部分是获取该网站的 DOM 元素,然后通过测试过程与这些元素进行交互。因此,我们需要获取我们运行项目的 DOM 元素。

只要当前项目在 web 浏览器中运行,我们首先必须获取浏览器本身的实例;有趣的是,Protractor 通过全局的 browser 对象提供了这一点。通过这个 browser 对象,我们可以获取所有浏览器级别的命令,比如browser.get,我们可以通过我们的项目 URL 进行导航:

beforeEach(() => { 
    browser.get(''); 
});; 

通过browser.get('')方法,我们将导航到我们项目的根目录。

我们有全局的 browser 对象,通过它我们可以获取正在运行页面的标题,基本上就是我们在项目index.html文件中定义的标题。browser.getTitle将提供标题,然后我们可以将其与预期进行匹配。因此,我们的测试规范将如下所示:

it('Browser should have a defined title', () => { 
       expect(browser.getTitle()).toEqual('Angular Protractor'); 
}); 

如果我们快速看一下,我们的短 e2e 测试规范将如下所示:

describe('AppComponent Tests', () => { 
    beforeEach(() => { 
        browser.get(''); 
    }); 

    it('Browser should have a defined title', () => { 
        expect(browser.getTitle()).toEqual('Angular Protractor'); 
    }); 
}); 

是时候用 Protractor 运行 e2e 测试了。命令将如下所示:

**$ protractor protractor.conf.js**

结果如预期--0 失败,因为我们已将index.html页面标题设置为Angular Protractor。**** 结果将如下:

1 spec, 0 failures 
Finished in 1.95 seconds 

是时候继续并为页面的其他 DOM 元素添加一个新的测试规格了,我们在页面上列出了列表项目;因此,我们将通过 Protractor 自动测试它们。

首先,我们将检查我们是否列出了所有三个项目。在本章的早些部分,我们已经学习了一些 Protractor 常用的 API,但是为了快速回顾,我们将使用element.all方法,通过传递一些定位器(by.cssby.idby.model)来获取元素数组对象。然后,我们可以使用 Jasmine 匹配器来与预期值进行匹配,如下所示:

it('Should get the number of items as defined in item object', () => { 
        var todoListItems = element.all(by.css('li')); 
        expect(todoListItems.count()).toBe(3); 
    }); 

我们应该得到通过的结果,因为我们在 UI 中列出了三个项目。

我们可以添加一些用于测试 UI 元素的测试规格。例如,为了检查列出的项目是否按正确的顺序列出,我们可以检查它们的标签,如下所示:

it('Should get the first item text as defined', () => { 
        expect(todoListItems.first().getText()).toEqual('test'); 
    }); 

    it('Should get the last item text as defined', () => { 
        expect(todoListItems.last().getText()).toEqual('refactor'); 
    }); 

我们已经将第一个和最后一个项目的标签/文本与预期值进行了匹配,它也应该通过。

让我们将所有的测试规格组合在 e2e 文件中。它将如下所示:

describe('AppComponent Tests', () => { 
    var todoListItems = element.all(by.css('li')); 

    beforeEach(() => { 
        browser.get('/'); 
    }); 

    it('Browser should have a defined title', () => { 
        expect(browser.getTitle()).toEqual('Angular Protractor'); 
    }); 

    it('Should get the number of items as defined in item object', () 
    => { 
        expect(todoListItems.count()).toBe(3); 
    }); 

    it('Should get the first item text as defined', () => { 
        expect(todoListItems.first().getText()).toEqual('test'); 
    }); 

    it('Should get the last item text as defined', () => { 
        expect(todoListItems.last().getText()).toEqual('refactor'); 
    }); 
}); 

让我们一起运行所有的规格:

**$ protractor protractor.conf.js**

正如预期的那样,所有的测试都应该通过,结果将如下所示:

**4 specs, 0 failures**
**Finished in 2.991 seconds**

提示

只要我们将 Protractor 配置文件命名为protractor.conf.js,在运行protractor命令时就不需要提及配置文件名;Protractor 会自行获取其配置文件。如果使用其他名称,我们就需要在 Protractor 中提及配置文件名。

因此,在这种情况下,我们可以按照以下方式运行测试:

**$ protractor**

结果将与之前一样。

通过 NPM 运行测试

在这个项目中,我们将通过 npm 构建和运行项目。在第三章Karma 方式中,我们通过npm运行了karma测试;同样,我们也将通过npm运行protractor测试。为了做到这一点,我们必须在项目的package.jsonscripts部分中添加protractor

"scripts": { 
    // ...  
    "e2e": "protractor" 
    // .... 
  }; 

要在我们的项目中安装protractor,请从项目目录中运行:

**$ npm e2e**

在一些操作系统中,这个命令可能会产生一些npm错误。这实际上是因为webdriver-manager可能没有更新。为了解决这个问题,我们必须将webdriver-manager更新脚本添加到npm中,并且只在第一次运行时运行一次,就像这样:

"scripts": { 
    // ...  
    "webdriver-update": "webdriver-manager update" 
    // .... 
  }; 

我们还必须这样运行它:

**$ npm webdriver-update**

就是这样,我们已经准备好再次运行 e2e 测试,这应该与protractor命令完全相同。

让我们确认一下:

**$ npm run e2e**

预期结果将如下所示:

**4 specs, 0 failures**
**Finished in 2.991 seconds**

让测试变得更好。

本章讨论了一些需要进一步澄清的事情。这些包括以下内容:

  • 异步逻辑在哪里?

  • 我们如何真正实现端到端测试的 TDD?

异步魔术

在前面的测试中,我们看到了一些你可能会质疑的魔术。以下是我们忽略的一些魔术组件:

  • 在测试执行之前加载页面

  • 对在承诺中加载的元素进行断言

在测试执行之前加载页面

在上一个测试中,我们使用以下代码指定浏览器应该指向主页:

browser.get(''); 

前面的命令将启动浏览器并导航到baseUrl位置。一旦浏览器到达页面,它将加载 Angular,然后实现特定于 Angular 的函数。我们的测试没有任何等待逻辑,这是 Protractor 与 Angular 的美妙之处。页面加载的等待已经内置到框架中。我们的测试可以写得非常干净。

对在承诺中加载的元素进行断言

断言和期望已经在其中写入了承诺的实现。在我们的测试中,我们编写了断言,以便它期望计数为3

expect(todoListItems.count()).toBe(3); 

然而,实际上,我们可能认为我们需要在断言中添加异步测试,以等待承诺被实现,涉及更复杂的东西,比如以下内容:

it('Should get the number of items as defined in item object', (done) => { 
    var todoListItems = element.all(by.css('li')); 
    todoListItems.count().then(function(count){ 
        expect(count).toBe(3); 
        done(); 
    }); 
}); 

前面的代码更长,更细粒度,更难阅读。Protractor 具有使测试更简洁的能力,对于某些内置到期望中的元素。

使用 Protractor 进行 TDD

通过我们的第一个测试,清楚地区分了端到端测试和单元测试。在单元测试中,我们专注于将测试与代码强耦合。例如,我们的单元测试对特定组件类AppComponent的作用域进行了监听。我们必须初始化组件以获得组件的实例,如下所示:

import {AppComponent} from "./app.component"; 

beforeEach(() => { 
    app = new AppComponent(); 
}); 

在 Protractor 测试中,我们不关心我们正在测试哪个组件类,我们的重点是测试的用户视角。我们从 DOM 中选择特定元素开始;在我们的情况下,该元素与 Angular 相关联。断言是特定重复器的元素数量等于预期计数。

通过端到端测试的松散耦合,我们可以编写一个专注于用户规范的测试,最初显示三个元素,然后可以自由地以我们想要的方式在页面、类、组件等中编写它。

自测问题

使用 Protractor 进行 TDD 来开发第三个开发待办事项。

Q1. Protractor 使用以下哪些框架?

  • Selenium

  • Unobtanium

  • Karma

Q2. 您可以在任何现有的 Angular 项目中安装 Protractor。

Q3. Karma 和 Protractor 可以在单个项目中一起运行。

Q4. 哪个团队开发了 Protractor?

  • ReactJS 团队

  • Angular 团队

  • NodeJS 团队

摘要

本章概述了使用 Protractor 进行端到端测试,并提供了安装、配置和应用现有 Angular 项目的端到端测试的必要思路。Protractor 是测试任何 Angular 应用程序的重要组成部分。它弥合了差距,以确保用户的规范按预期工作。当端到端测试根据用户规范编写时,应用程序的信心和重构能力会增长。在接下来的章节中,我们将看到如何以简单直接的例子更深入地应用 Karma 和 Protractor。

下一章将详细介绍 Protractor 的一些高级配置,一些 API 的细节,并对测试进行调试。

第五章:Protractor,更进一步

端到端测试真的很有趣,只要直接与浏览器交互,但是一个好的开发者应该了解 Protractor 的高级功能,以进行大规模的应用程序测试。此外,在端到端测试中调试是一种挑战,因为它取决于浏览器的 DOM 元素。

Protractor 有一些用于调试的 API。本章将主要涵盖这些 API 和功能,包括以下内容:

  • 设置和配置 Protractor

  • 一些高级的 Protractor API,比如 browser,locator 和 action

  • 使用browser.pause()browser.debug()API 来调试 Protractor

高级设置和配置

在上一章中,我们看到了 Protractor 的基本和常用的设置和配置。在这里,我们将看一些高级配置,使安装更简单和更强大。

全局安装 Protractor

以下是全局安装 Protractor 的步骤:

  1. 一旦 Node.js 被安装并在命令提示符中可用,输入以下命令在系统上全局安装 Protractor:
**$ npm install -g protractor**

上一条命令使用了 Node 的npm命令全局安装 Protractor,这样我们就可以只用protractor命令来使用 Protractor 了。

  1. 测试 Protractor 版本是否可以如下确定:
**$ protractor --version**

高级配置

在本节中,我们将使用以下步骤对 Protractor 进行更详细的配置:

  1. 更新 protractor 的config文件以支持单个测试套件中的多个浏览器。multiCapabilities参数是一个数组,可以为任何测试套件传递多个browserName对象,如下所示:
        exports.config = {  
          //...  
        multiCapabilities: [{
         'browserName': 'firefox' 
        }, { 
         'browserName': 'chrome' 
        }]
        //... };
  1. 我们可以在capabilities参数中为浏览器设置高级设置;例如,对于chrome,我们可以传递额外的参数作为chromeOptions,如下所示:
        exports.config = {  
          //...  
          capabilities: { 
            'browserName': 'chrome'
            'chromeOptions': {
              'args': ['show-fps-counter=true']
            }}]
        //... };
  1. 有时,我们可能需要直接运行 Protractor 而不使用 Selenium 或 WebDriver。这可以通过在config.js文件中传递一个参数来实现。该参数是配置对象中的directConnect: true,如下所示:
        exports.config = { 
          //... 
          directConnect: true, 
          //... 
        }; 

太棒了!我们已经配置了 Protractor 更进一步。

Protractor API

端到端测试任何网页的主要活动是获取该页面的 DOM 元素,与它们交互,为它们分配一个动作,并与它们共享信息;然后,用户可以获取网站的当前状态。为了使我们能够执行所有这些操作,Protractor 提供了各种各样的 API(其中一些来自 web driver)。在本章中,我们将看一些常用的 API。

在上一章中,我们看到了 Protractor 如何与 Angular 项目一起工作,我们需要与 UI 元素进行交互。为此,我们使用了一些 Protractor API,比如element.allby.cssfirstlastgetText。然而,我们没有深入了解这些 API 的工作原理。要理解 Protractor 中 API 的工作原理非常简单,但在现实生活中,我们大多数时候将不得不处理更大、更复杂的项目。因此,重要的是我们了解并更多地了解这些 API,以便与 UI 进行交互并玩耍。

浏览器

Protractor 与 Selenium WebDriver 一起工作,后者是一个浏览器自动化框架。我们可以使用 Selenium WebDriver API 中的方法来与测试规范中的浏览器进行交互。我们将在接下来的章节中看一些这些方法。

要将浏览器导航到特定的网址并在 Angular 加载之前加载该页面的模拟模块,我们将使用.get()方法,通过传递特定的地址或相对路径:

browser.get(url); 
browser.get('http://localhost:3000'); // This will navigate to
the localhost:3000 and will load mock module if needed 

要获取当前页面的网址,使用CurrentUrl()方法,如下所示:

browser.getCurrentUrl(); // will return http://localhost:3000 

要导航到另一个页面并使用页面内导航进行浏览,使用setLocation,如下所示:

browser.setLocation('new-page'); // will change the url and navigate to the new url, as our current url was http://localhost:3000, now it will change and navigate to http://locahost:3000/#/new-page 

要获取当前页面的标题(基本上是在 HTML 页面中设置的标题),使用getTitle方法,如下所示:

browser.getTitle(); // will return the page title of our page, for us it will return us "Angular Protractor Debug" 

要在 Angular 加载之前使用模拟模块重新加载当前页面,使用refresh()方法,如下所示:

browser.refresh(); // this will reload the full page and definitely will load the mocks module as well. 

要暂停测试过程,使用pause()方法。这对于调试测试过程非常有用,我们将使用这个测试调试部分:

browser.pause(); 

为了调试测试过程,使用debugger()方法。这个方法是不同的,可以被认为是pause()方法的高级版本。这对于测试过程的高级调试以及将自定义辅助函数注入到浏览器中非常有用。我们也将使用这个测试调试部分:

browser.debugger(); 

要关闭当前浏览器,使用close()。这对于复杂的多模块测试非常有用,有时我们需要在打开新浏览器之前关闭当前浏览器:

browser.close(); 

为了在 Protractor 中支持 Angular,我们必须将useAllAngularAppRoots参数设置为true。这样做的逻辑是,当我们将此参数设置为true时,它将在元素查找器遍历页面时搜索所有 Angular 应用程序:

browser.useAllAngular2AppRoots; 

Elements

提示

Protractor 本身暴露了一些全局函数,element就是其中之一。这个函数接受一个定位器(一种选择器--我们将在下一步中讨论),并返回一个ElementFinder。这个函数基本上是根据定位器找到单个元素,但它支持多个元素的选择,以及链式调用另一个方法element.all,它也接受一个定位器并返回一个ElementFinderArray。它们都支持链式方法进行下一步操作。

element.all

正如我们已经知道的那样,element.all返回一个ElementArrayFinder,支持链式方法进行下一步操作。我们将看一下其中一些方法以及它们的实际工作方式:

要选择多个具有特定定位器的元素数组,我们应该使用element.all,如下所示:

element.all(Locator); 
var elementArr = element.all(by.css('.selector'));  // return the ElementFinderArray 

在将一堆元素作为数组获取之后,我们可能需要选择特定的元素。在这种情况下,我们应该通过传递特定的数组索引作为位置号来链接get(position)

element.all(Locator).get(position); 
elementArr.get(0); // will return first element from the ElementFinderArray  

在将一堆元素作为数组获取之后,我们可能需要再次选择子元素并使用首选的定位器,为此我们可以再次使用现有元素链接.all(locator)方法,如下所示:

element.all(Locator).all(Locator); 
elementArr.all(by.css('.childSelector')); // will return another ElementFinderArray as child elements based on child locator  

获取所需的元素之后,我们可能想要检查所选择的元素数量是否符合预期。有一个名为count()的方法,用于链到获取所选元素的总数:

element.all(Locator).count(); 
elementArr.count(); // will return the total number in the select element's array   

get(position)方法类似,我们可以通过链接first()方法从数组中获取第一个元素:

element.all(Locator).first(); 
elementArr.first(); // will return the first element from the element's array   

first()方法类似,我们可以通过链接last()方法从数组中获取最后一个元素:

element.all(Locator).last(); 
elementArr.last(); // will return the last element from the element array   

只要我们有一堆元素作为数组,我们可能需要遍历这些元素以执行任何操作。在这种情况下,我们可能需要通过链接each()方法来进行循环:

element.all(Locator).each(Function) { }; 
elementArr.each( function (element, index) { 
    // ......  
}); // ... will loop through out the array elements 

就像each()方法一样,还有另一个方法filter(),可以与元素数组链接以遍历项目并为它们分配一个过滤器:

element.all(Locator).filter(Function) { }; 
elementArr.filter( function (element, index) { 
    // ......  
}); //... will apply filter function's action to all elements  

element

element类返回ElementFinder,这意味着元素数组中的单个元素,它也支持链接方法以进行下一个操作。在前面的示例中,我们看到了如何从元素数组中获取单个选择的元素,以便所有链接方法也适用于该单个元素。有许多用于操作单个元素的链接方法,我们将看一些最常用的方法。

通过将特定的定位器作为参数传递给element方法,我们可以选择单个 DOM 元素,如下所示:

element(Locator); 
var elementObj = element(by.css('.selector'));  // return the ElementFinder based on locator  

获取特定的单个元素后,我们可能需要找到该元素的子元素,然后使用element.all方法与重新运行的elementFinder对象链接。为此,将特定的定位器传递给elementFinderArray以查找子元素,如下所示:

element(Locator).element.all(Locator); 
elementObj.element.all(by.css('.childSelector')); // will return another ElementFinderArray as child elements based on child locator  

在选择特定元素后,我们可能需要检查该元素是否存在,同时链接isPresent()方法,如下所示:

element(Locator).isPresent(); 
elementObj.isPresent(); // will return boolean if the selected element is exist or not.   

操作

操作主要是改变影响或触发所选 DOM 元素的方法。选择 DOM 元素的目的是通过触发一些操作与其交互,以便它可以像真正的用户一样行动。有一些常用的用于特定交互的操作。我们将在这里看一些。

要获取任何元素的内部文本或包含的文本,我们必须在选择特定元素后,将getText()方法与elementFinder对象链接,如下所示:

element(Locator).getText(); 
var elementObj = element(by.css('.selector'));  // return the ElementFinder based on locator  
elementObj.getText(); // will return the contained text of that specific selected element  

要获取任何元素的内部 HTML,我们必须在选择特定元素后,将getInnerHtml()方法与elementFinder对象链接,如下所示:

element.(Locator).getInnerHtml(); 
elementObj.getInnerHtml(); // will return the inner html of the selected element.  

通过将属性键传递给getAttribute()方法,我们可以找到任何元素的特定属性值,并将其与所选的elementFinder对象链接,如下所示:

element(Locator).getAttribute('attribute'); 
elementObj.getAttribute('data'); // will return the value of data attribute of that selected element if that have that attribute 

在大多数情况下,我们需要清除输入字段的值。为此,我们可以将clear()方法与所选的elementFinder对象链接,如下所示:

element.(Locator).clear(); 
elementObj.clear(); // Guessing the elementFinder is input/textarea, and after calling this clear() it will clear the value and reset it.    

提示

请记住,只有输入或文本可能具有一些值,并且需要您清除/重置该值。

当我们需要在选择特定的elementFinder对象后触发按钮、链接或图像的点击事件时,我们需要链接click()方法,它将像真正的点击那个元素一样:

element.(Locator).click(); 
elementObj.click(); // will trigger the click event as the selected element chaining it.    

有时,我们可能需要触发submit()方法进行表单提交。在这种情况下,我们必须将submit()方法与所选元素链接起来。所选元素应该是一个form元素:

element.(Locator).submit(); 
elementObj.submit(); // Will trigger the submit for the form 
element as submit() work only for form element.   

定位器

定位器告诉 Protractor 如何在 DOM 元素中找到某个元素。Protractor 将locator作为全局工厂函数导出,将与全局by对象一起使用。根据我们的 DOM,我们可以以许多方式使用它们,但让我们看看一些最常用的方式。

我们可以通过将任何 CSS 选择器之一传递给by.css方法来选择任何元素,如下所示:

element(by.css(cssSelector));  
element.all(by.css(cssSelector)); 
<span class="selector"></span> 
element.all(by.css('.selector'));  // return the specific DOM element/elements that will have selector class on it 

我们可以通过将其元素 ID 传递给by.id方法来选择任何元素,如下所示:

element(by.id(id)); 
<span id="selectorID"></span>   
element(by.id('selectorID')); // return the specific DOM element that will have selectorID as element id on it  

我们还可以通过将其传递给by.tagName来选择特定的元素或元素标签名,如下所示:

element(by.tagName(htmlTagName)); 
element.all(by.tagName(htmlTagName)); 
<span data="myData">Content</span> 
element.all(by.tagName('span')); // will return the DOM element/elements of all span tag.  

要选择任何特定输入字段的 DOM 元素,我们可以在by.name方法中传递名称,如下所示:

element(by.name(elementName)); 
<input type="text" name="myInput"> 
element(by.name('myInput')); // will return the specific input field's DOM element that have name attr as myInput 

除了 CSS 选择器或 ID 之外,我们还可以通过将其文本标签传递给by.buttonText来选择特定的按钮:

<button name="myButton">Click Me</button> 
element(by.buttonText('Click Me')); // will return the specific button that will have Click Me as label text  
element(by.buttonText(textLabel)); 

我们可以通过将模型名称定义为ng-model传递给by.model来查找元素,如下所示:

element.(by.model); 
<span ng-model="userName"></span> 
element(by.model('userName')); // will return that specific element which have defined userName as model name    

同样,我们可以通过在by.bindings中定义的绑定ng-bind来查找特定的 DOM 元素,如下所示:

element.(by.binding); 
<span ng-bind="email"></span> 
element(by.binding('email')); // will return the element that have email as bindings with ng-bind  

除了之前解释的所有定位器,还有另一种找到特定 DOM 元素的方法:自定义定位器。在这里,我们必须使用by.addLocator创建一个自定义定位器,通过传递定位器名称和回调。然后,我们必须通过by.customLocatorName(args)传递该自定义定位器,如下所示:

element.(by.locatorName(args)); 
<button ng-click="someAction()">Click Me</button> 
by.addLocator('customLocator', function(args) { 
    // .....  
}) 
element(by. customLocator(args)); // will return the element that will match with the defined logic in the custom locator. This useful mostly when user need to select dynamic generated element.  

Protractor 测试-事后分析

调试 e2e 测试有点困难,因为它们依赖于应用程序的整个生态系统。有时它们依赖于先前的操作,比如登录,有时它们依赖于权限。调试 e2e 的另一个主要障碍是它依赖于 WebDriver。由于它在不同的操作系统和浏览器上的行为不同,这使得调试 e2e 变得困难。除此之外,它生成了很长的错误消息,这使得很难区分与浏览器相关的问题和测试过程中的错误。

尽管如此,我们将尝试调试所有的 e2e 测试,看看对我们的情况有何作用。

失败类型

测试套件失败可能有各种原因,因为它依赖于 WebDriver 和系统中的各个部分。

让我们看看一些已知的失败类型:

  • WebDrive 失败:当命令无法完成时,WebDriver 会抛出错误。例如,浏览器无法获取定义的地址来帮助它导航,或者可能找不到预期的元素。

  • WebDriver 意外失败:有时,WebDriver 会因无法更新 Web 驱动程序管理器而失败并报错。这是一个与浏览器和操作系统相关的问题,尽管不常见。

  • Angular 的 Protractor 失败:当 Protractor 在库中找不到预期的 Angular 时,Protractor 会失败,因为 Protractor 测试依赖于 Angular 本身。

  • Protractor Angular2 失败:当配置中缺少useAllAngular2AppRoots参数时,Protractor 将在 Angular 项目的测试规范中失败,因为没有这个参数,测试过程将只查看一个单一的根元素,而期望在过程中有更多的元素。

  • Protractor 超时失败:有时,当测试规范陷入循环或长时间等待并且无法及时返回数据时,Protractor 会因超时而失败。但是,超时是可配置的,因此可以根据需要增加。

  • 期望失败:这是测试规范中常见的失败。

加载现有项目

本测试中使用的代码来自第四章使用 Protractor 进行端到端测试。我们将代码复制到一个新目录:angular-protractor-debug

作为提醒,该应用程序是一个待办事项应用程序,其中有一些待办事项列表,并且我们向其中添加了一些项目。它有一个单一的组件类AppComponent,其中有一个项目列表和一个add方法。

当前目录应该按以下结构组织:

加载现有项目

在验证文件夹结构与前面截图中显示的相同之后,第一步是通过运行以下命令在本地获取所需的依赖项node_modules

**$ npm install**

这将安装所有所需的模块。现在,让我们使用npm命令构建和运行项目:

**$ npm start**

现在一切应该都没问题了:项目应该在http://localhost:3000上运行,并且输出应该如下所示:

加载现有项目

有了这个,我们就可以继续实现在我们的 Angular 项目中加入调试器的下一步了。

在项目中包含调试器

在将调试器添加到我们的项目之前,让我们在现有项目中运行 e2e 测试。我们希望在 e2e 测试规范中没有任何失败的情况下通过。

让我们使用以下命令运行它:

**$ npm run e2e**

如预期,我们的测试通过了。结果如下:

将调试器包含在项目中

我们可以在已通过的测试规范所在的位置添加我们的调试代码,但让我们将已通过的测试用例与调试器分开,并在不同的目录中进行调试。让我们创建一个新目录,debug/。我们需要该目录中的两个文件:一个用于配置,另一个用于规范。

对于 Protractor 配置文件,我们可以复制protractor.conf.js文件并将其重命名为debugConf.js

配置中的一切都与先前的配置相同。但是,我们需要增加 Jasmine 测试的默认超时时间,否则在调试过程中测试将超时。

让我们将超时时间增加到3000000毫秒:

exports.config = { 
    // ....  
    jasmineNodeOpts: { 
      showColors: true, 
      defaultTimeoutInterval: 3000000 
    }, 
    // ..... 
}; 

接下来,我们将需要一个规范文件来编写测试规范和调试测试。将新的规范文件保存为app.debug.e2e.ts。哦是,我们需要再次更改配置文件以定义调试的规范文件。

exports.config = { 
    // ....  
    specs: [ 
      'app.debug.e2e.js' 
    ], 
    // ..... 
}; 

我们可以为app.debug.e2e.ts创建一个简单的测试规范文件。然后,我们可以添加调试代码并进行调试。

简单的测试规范如下所示:

describe('AppComponent Tests', () => { 
    beforeEach(() => { 
        browser.get('/'); 
    }); 

    it('Test spec for debug and play', () => { 

    }); 
}); 

暂停和调试

要调试任何测试规范,我们必须暂停测试过程并逐步查看发生了什么。Protractor 也有内置方法来暂停测试过程。以下是两种暂停和调试测试过程的方法:

  • browser.pause()

  • browser.debugger()

使用暂停

使用browser.pause()命令,调试 Protractor 测试变得简单而容易。使用pause()方法,我们可以进入 Protractor 调试器控制流,并执行一些命令来检查测试控制流中发生了什么。大多数情况下,开发人员在测试失败并出现未知错误以及出现长错误消息时使用调试器。

使用browser.pause()命令后,我们可以根据需要使用更多命令。

让我们简要看一下:

  • c:如果我们将c作为一个命令输入,它将在测试中向前移动一步,我们将深入了解测试命令的工作原理。如果我们计划继续进行测试,最好快点进行,因为会有超时问题(Jasmine 默认超时),我们已经了解过了。稍后我们会看到一个例子。

  • repl:通过输入repl作为命令,我们可以进入调试的交互模式。它被称为交互模式,因为我们可以直接从终端与浏览器交互,通过输入 WebDriver 命令。浏览器的响应、结果或错误也将显示在终端上。稍后我们将看到更多实际的例子。

  • Ctrl + C:按下Ctrl + C 退出暂停模式并继续测试。当我们使用这个时,测试将从暂停的地方继续。

一个快速的例子

要在测试规范中使用browser.pause(),我们将在测试规范中的希望暂停测试并观察控制流的地方添加该方法。在这里,我们只有一个测试规范,有一个错误/失败的测试用例,我们知道它会失败,我们将找出失败的原因。

我们将如所示将pause()方法添加到测试spec it() {}函数中:

it('Test spec for debug and play', () => { 
  browser.pause(); 
  // There is not element with the id="my_id", so this will fail
  the test 
  expect(element(by.id('my_id')).getText()).toEqual('my text') 
});  

现在是时候运行测试了。由于我们已经将调试器的测试规范分开,我们将通过 Protractor(而不是npm)运行测试。

让我们用以下命令运行测试:

**$ protractor debug/debugConf.js**

由于我们在expect()方法之前放置了browser.pause()方法,它将在那里暂停。我们可以看到在控制流中,这使得它等待 Angular:

一个快速的例子

我们将继续前进;为此,让我们输入C。它将运行executeAsyncScript并等待 Angular 加载:

一个快速的例子

我们将再向前迈出一步,输入C。它将尝试根据我们提供的定位器选择元素,即element(by.id('my_id')

一个快速的例子

现在我们接近获得测试结果了。为此,我们将再向前迈出一步,输入C。现在,它将尝试根据定位器选择元素,并且将无法选择。这将产生一个带有错误消息的结果,正如预期的那样:

一个快速的例子

使用交互模式进行调试

要进入交互模式,我们必须输入repl,之后可以运行测试规范中使用的任何命令。

让我们找到元素及其文本:

> element(by.id('my_id')).getText() 

结果与之前一样,通过逐步前进,输入C

结果NoSuchElementError: 使用定位器未找到元素:By (css 选择器, *[id="my_id"])

现在,让我们看看当element将被找到时,交互模式如何为有效的定位器工作:

> element.all(by.css('li')).first().getText() 

结果测试

使用调试器

使用 browser.debugger() 命令进行调试比使用 browser.pause() 更复杂和更高级。使用 browser.pause() 命令,我们可以暂停测试的控制流,并将自定义辅助函数注入到浏览器中,以便调试的方式与我们在浏览器控制台中调试的方式相同。

这种调试应该在节点调试模式下进行,就像在 Protractor 调试中一样。这种调试对于不擅长节点调试的人来说并不有用。

这是一个例子:

要在测试规范中使用 browser.debugger() 方法,我们将不得不在测试规范中添加该方法,以设置断点并观察控制流。

对于我们来说,我们必须添加 debugger() 方法,如下所示,到 test spec it() {} 函数中,这将是我们的断点:

it('Test spec for debug and play', () => { 
  browser.debugger(); 
  // There is not element with the id="my_id", so this will fail 
the test 
  expect(element(by.id('my_id')).getText()).toEqual('my text') 
  });   

现在让我们运行它:

**$ protractor debug debug/debugConf.js**

注意

要运行调试器,我们必须在 protractor 后面添加 debug

运行命令后,我们必须输入 C 继续,但这里我们只需要这样做一次。输出如下:

使用调试器

自测问题

Q1. Selenium WebDriver 是一个浏览器自动化框架。

Q2. 使用 browser.debugger() 是调试 Protractor 的一种简单方法。

Q3. by.css()by.id()by.buttonText() 被称为什么?

  • 元素

  • 定位器

  • 操作

  • 浏览器

摘要

Protractor 有各种类型的 API。在本章中,我们试图了解一些最常用的 API,并提供了一些示例。我们还详细介绍了 API 类型(如浏览器、元素、定位器和操作),以及它们如何相互链接。

在本章中介绍了调试,并尝试学习了一种简单的调试方法,使用 browser.pause(),然后我们转向了一种复杂的方法(browser.debugger()),并了解到复杂的开发人员需要节点调试器经验。

在下一章中,我们将深入研究更多的现实项目;此外,我们将学习自上而下和自下而上的方法,并学会它们。

第六章:第一步

第一步总是最困难的。本章提供了如何使用 TDD 构建具有组件、类和模型的 Angular 应用程序的初始介绍性漫游。我们将能够开始 TDD 之旅,并看到基本原理的实际应用。到目前为止,本书侧重于 TDD 的基础和所需的工具。现在,我们将转变思路,深入研究 Angular 中的 TDD。

本章将是 TDD 的第一步。我们已经看到如何安装 Karma 和 Protractor,以及如何应用它们的小例子和漫游。在本章中,我们将重点关注:

  • 创建一个简单的评论应用程序

  • 将 Karma 和 Protractor 与应用程序集成

  • 涵盖测试的组件及其相关类

准备应用程序的规格

创建一个输入评论的应用程序。应用程序的规格如下:

  • 如果我发布了一个新评论,当我点击提交按钮时,评论应该被添加到评论列表中

  • 当我点击喜欢按钮时,评论的喜欢数量应该增加

现在我们有了应用程序的规格,我们可以创建我们的开发待办事项列表。创建整个应用程序的待办事项列表并不容易。根据用户的规格,我们知道需要开发什么。以下是 UI 的草图:

准备应用程序的规格

不要急于进行实现,考虑我们将如何使用组件类、*ngFor等。抵制,抵制,抵制!虽然我们可以考虑未来的开发方式,但直到我们深入代码,这一切才会变得清晰,这也是我们开始遇到麻烦的地方。TDD 及其原则在这里帮助我们将思绪和注意力放在正确的地方。

设置 Angular 项目

在之前的章节中,我们详细讨论了如何设置项目,查看了涉及的不同组件,并走过了整个测试过程。我们将跳过这些细节,并在下一节中提供一个列表,用于初始化操作,以便设置项目并准备好进行单元测试和端到端测试的测试配置。

加载现有项目

我们将从 Angular 团队的示例中获取一个简单的 Angular 项目,并对其进行修改以适应我们的实现。

我们将从 Angular GitHub 仓库克隆quickstart项目,并从那个开始。除了node/npm之外,我们应该在全局安装 Git。

**$ git clone https://github.com/angular/quickstart.git 
    angular-project**

这将把项目本地复制为angular-project;这个项目可能包含一些额外的文件(它们可能会不断更新),但我们将尽量保持我们的项目文件夹结构看起来像这样:

加载现有项目

最初我们将保持简单,然后逐步添加所需的文件。这将使我们更加自信。

让我们继续进行并运行以下命令:

**$ cd angular-project**
**$ npm install**

npm install命令将安装项目依赖项所需的模块,这些模块在项目根目录中的package.json文件中定义。

设置目录

在先前的示例中,我们将组件、单元测试规范和端到端测试规范放在同一个文件夹中,以保持简单。对于一个更大的项目,在同一个文件夹中管理所有这些是困难的。

为了使其更易管理,我们将把测试规范放在一个单独的文件夹中。在这里,我们的示例quickstart项目已经将测试规范放在默认文件夹中,但我们将有一个新的结构,并将我们的测试文件放在新的结构中。

让我们开始设置项目目录:

  1. 导航到项目的根文件夹:
        **cd angular-project**

  1. 初始化测试(spec)目录:
        **mkdir spec**

  1. 初始化unit测试目录:
        **mkdir spec/unit**

  1. 初始化端到端(e2e)测试目录:
        **mkdir spec/e2e**

初始化完成后,我们的文件夹结构应如下所示:

设置目录

设置 Karma

Karma 的详细信息可以在第三章中找到,Karma 之道。在这里,我们将主要看一下 Karma 配置文件。

在这个quickstart项目中,我们已经安装并配置了 Karma,并且在项目目录中有karma.conf.js文件。

为了确认系统中有 Karma,让我们使用以下命令在全局安装它:

**npm install -g karma**

如前所述,我们已经在这个项目中配置了 Karma 作为quickstart项目的一部分,并且我们在项目目录中有karma.conf.js文件。

现在我们将看一些每个人都应该知道的基本配置选项。在这个配置文件中,还有一些高级选项,比如测试报告和错误报告。我们将跳过这些,因为在这个初始阶段并不是非常重要。

让我们了解一下我们将需要进一步进行的一些配置。

当我们在服务器上有自定义路径的项目时,basePath应该进行更新。目前是'',因为该项目在根路径上运行。下一个选项是frameworks;默认情况下,我们在这里使用jasmine,但是如果我们想使用其他框架,比如mocha,我们可以更改框架名称。需要记住的一点是,如果我们计划使用不同的框架,我们将不得不添加相关的插件。

    basePath: '', 
        frameworks: ['jasmine'], 

需要插件,因为 Karma 将使用这些npm模块来执行操作;例如,如果我们计划使用 PhantomJS 作为浏览器,我们需要将'karma-phantomjs-launcher'添加到列表中:

    plugins: [ 
            'karma-jasmine', 
            'karma-chrome-launcher' 
    ] 

下一个最重要的选项是files[];通过这个,Karma 将包含所有测试所需的文件。它根据依赖加载文件。我们将在files[]数组中包含所有所需的文件。

首先,我们将添加System.js,因为我们在应用程序中使用systemjs作为模块加载器。然后,添加polyfills以在所有浏览器上支持 shim,zone.js以支持应用程序中的异步操作,RxJS 作为响应式库,Angular 库文件,Karma 测试的 shim,组件文件,最后是测试规范。列表中可能还有一些其他文件用于调试和报告;我们将跳过它们的解释。

我们的files[]数组将如下所示:

    files: [
          // System.js for module loading
          'node_modules/systemjs/dist/system.src.js',

          // Polyfills
          'node_modules/core-js/client/shim.js',
          'node_modules/reflect-metadata/Reflect.js',

          // zone.js
          'node_modules/zone.js/dist/zone.js',
          'node_modules/zone.js/dist/long-stack-trace-zone.js',
          'node_modules/zone.js/dist/proxy.js',
          'node_modules/zone.js/dist/sync-test.js',
          'node_modules/zone.js/dist/jasmine-patch.js',
          'node_modules/zone.js/dist/async-test.js',
          'node_modules/zone.js/dist/fake-async-test.js',

          // RxJs
          { pattern: 'node_modules/rxjs/**/*.js', included: false, 
          watched: false },
          { pattern: 'node_modules/rxjs/**/*.js.map', included: 
          false, watched: false },

          // Paths loaded via module imports:
          // Angular itself
          { pattern: 'node_modules/@angular/**/*.js', included: 
          false, watched: false },
          { pattern: 'node_modules/@angular/**/*.js.map', included: 
          false, watched: false },

          { pattern: 'systemjs.config.js', included: false, watched: 
          false },
          { pattern: 'systemjs.config.extras.js', included: false, 
          watched: false },
          'karma-test-shim.js',

          // transpiled application & spec code paths loaded via 
          module imports
          { pattern: appBase + '**/*.js', included: false, watched: 
          true },
          { pattern: testBase + '**/*.spec.js', included: false, 
          watched: true },
],

这就是我们现在在karma.conf文件中需要知道的全部。如果需要,我们将通过更新这些设置来进行。

让我们来看看完整的karma.conf.js文件:

module.exports = function(config) {

  var appBase    = 'app/';       // transpiled app JS and map files
  var appSrcBase = 'app/';       // app source TS files
  var appAssets  = 'base/app/'; // component assets fetched by 
  Angular's compiler

  var testBase    = 'spec/unit/';       // transpiled test JS and map 
  files
  var testSrcBase = 'spec/unit/';       // test source TS files

  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'), // click "Debug" in 
      browser to see it
      require('karma-htmlfile-reporter') // crashing w/ strange 
      socket error
    ],

    customLaunchers: {
      // From the CLI. Not used here but interesting
      // chrome setup for travis CI using chromium
      Chrome_travis_ci: {
        base: 'Chrome',
        flags: ['--no-sandbox']
      }
    },
    files: [
      // System.js for module loading
      'node_modules/systemjs/dist/system.src.js',

      // Polyfills
      'node_modules/core-js/client/shim.js',
      'node_modules/reflect-metadata/Reflect.js',

      // zone.js
      'node_modules/zone.js/dist/zone.js',
      'node_modules/zone.js/dist/long-stack-trace-zone.js',
      'node_modules/zone.js/dist/proxy.js',
      'node_modules/zone.js/dist/sync-test.js',
      'node_modules/zone.js/dist/jasmine-patch.js',
      'node_modules/zone.js/dist/async-test.js',
      'node_modules/zone.js/dist/fake-async-test.js',

      // RxJs
      { pattern: 'node_modules/rxjs/**/*.js', included: false, 
      watched: false },
      { pattern: 'node_modules/rxjs/**/*.js.map', included: false, 
      watched: false },

      // Paths loaded via module imports:
      // Angular itself
      { pattern: 'node_modules/@angular/**/*.js', included: false, 
      watched: false },
      { pattern: 'node_modules/@angular/**/*.js.map', included: 
      false, watched: false },

      { pattern: 'systemjs.config.js', included: false, watched: 
      false },
      { pattern: 'systemjs.config.extras.js', included: false, 
      watched: false },
      'karma-test-shim.js',

      // transpiled application & spec code paths loaded via module 
      imports
      { pattern: appBase + '**/*.js', included: false, watched: true 
      },
      { pattern: testBase + '**/*.spec.js', included: false, watched: 
      true },

      // Asset (HTML & CSS) paths loaded via Angular's component 
      compiler
      // (these paths need to be rewritten, see proxies section)
      { pattern: appBase + '**/*.html', included: false, watched: true 
      },
      { pattern: appBase + '**/*.css', included: false, watched: true 
      },

      // Paths for debugging with source maps in dev tools
      { pattern: appSrcBase + '**/*.ts', included: false, watched: 
      false },
      { pattern: appBase + '**/*.js.map', included: false, watched: 
      false },
      { pattern: testSrcBase + '**/*.ts', included: false, watched: 
      false },
      { pattern: testBase + '**/*.js.map', included: false, watched: 
      false }
    ],

    // Proxied base paths for loading assets
     proxies: {
       // required for component assets fetched by Angular's compiler
       "/app/": appAssets
   },

    exclude: [],
    preprocessors: {},
    // disabled HtmlReporter; suddenly crashing w/ strange socket 
    error
    reporters: ['progress', 'kjhtml'],//'html'],

    // HtmlReporter configuration
    htmlReporter: {
      // Open this file to see results in browser
      outputFile: '_test-output/tests.html',

      // Optional
      pageTitle: 'Unit Tests',
      subPageTitle: __dirname
    },

    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: true
  })
};

测试目录已更新

我们在第三章中看到了karma-test-shim.js的详细信息,Karma 方式。这是通过 Karma 运行单元测试所需的。

我们已经更改了测试规范目录/位置,并且karma-test-shim.js是根据项目的默认结构进行配置的。因为我们已经将测试移动到不同的位置并且不在app/文件夹中,我们需要相应地更新karma-test-shim.js

这是需要进行的更改:

    var builtPath = '/base/'; 

设置 Protractor

在第四章中,使用 Protractor 进行端到端测试,我们讨论了 Protractor 的完整安装和设置。这个示例应用程序已经安装和配置了 Protractor。因此,我们只需要查看protractor.conf.js文件。

配置的 Protractor 实例已实现了测试报告。我们将跳过配置文件中的这些部分,只看一下常见的设置选项。

在我们进入配置文件概述之前,为了确保,我们将在系统上全局安装 Protractor:

**$ npm install -g protractor**

更新 Selenium WebDriver:

**$ webdriver-manager update**

我们必须确保 Selenium 已安装。

如预期的那样,protractor.conf.js位于应用程序的根目录。这是protractor.conf.js文件的完整配置:

var fs = require('fs'); 
var path = require('canonical-path'); 
var _ = require('lodash'); 

exports.config = { 
  directConnect: true, 

  // Capabilities to be passed to the webdriver instance. 
  capabilities: { 
    'browserName': 'chrome' 
  }, 

  // Framework to use. Jasmine is recommended. 
  framework: 'jasmine', 

  // Spec patterns are relative to this config file 
  specs: ['**/*e2e-spec.js' ], 

  // For angular tests 
  useAllAngular2AppRoots: true, 

  // Base URL for application server 
  baseUrl: 'http://localhost:8080', 

  // doesn't seem to work. 
  // resultJsonOutputFile: "foo.json", 

  onPrepare: function() { 
    //// SpecReporter 
    //var SpecReporter = require('jasmine-spec-reporter'); 
    //jasmine.getEnv().addReporter(new 
    SpecReporter({displayStacktrace: 'none'}));  
    //// jasmine.getEnv().addReporter(new SpecReporter({
    displayStacktrace: 'all'})); 

    // debugging 
    // console.log('browser.params:' +    
    JSON.stringify(browser.params)); 
    jasmine.getEnv().addReporter(new Reporter( browser.params )) ; 

    // Allow changing bootstrap mode to NG1 for upgrade tests 
    global.setProtractorToNg1Mode = function() { 
      browser.useAllAngular2AppRoots = false; 
      browser.rootEl = 'body'; 
    }; 
  }, 

  jasmineNodeOpts: { 
    // defaultTimeoutInterval: 60000, 
    defaultTimeoutInterval: 10000, 
    showTiming: true, 
    print: function() {} 
  } 
};  

自上而下与自下而上的方法-我们使用哪种?

从开发的角度来看,我们必须确定从哪里开始。本书将讨论的方法如下:

  • 自下而上的方法:采用这种方法,我们考虑我们将需要的不同组件(类、服务、模块等),然后选择最合乎逻辑的组件并开始编码。

  • 自上而下的方法:采用这种方法,我们从用户场景和 UI 开始工作。然后我们围绕应用程序中的组件创建应用程序。

这两种方法都有其优点,选择可以基于您的团队、现有组件、需求等。在大多数情况下,最好根据最小阻力来做出选择。

在本章中,规范的方法是自上而下的;一切都为您准备好,从用户场景开始,将允许您有机地围绕 UI 构建应用程序。

测试一个组件

在进入交付功能的规范和思维方式之前,重要的是要了解测试组件类的基本知识。在大多数应用程序中,Angular 中的组件是一个关键特性。

准备好开始

我们的示例应用程序(quickstart)有一些非常基本的单元测试和端到端测试规范。我们将从一开始采用 TDD 方法,因此在实现过程中不会使用任何测试规范和现有组件的代码。

为此,我们可以做的就是清理这个示例应用程序,只保留文件夹结构和应用程序引导文件。

因此,首先,我们必须删除单元测试文件(app.component.spec.ts)和端到端测试文件(app.e2e-spec.ts)。这两个测试规范存在于应用程序结构中。

设置一个简单的组件测试

在测试组件时,将组件注入测试套件中,然后将组件类初始化为第二个任务非常重要。测试确认组件范围内的对象或方法是否按预期可用。

为了在测试套件中拥有组件实例,我们将在测试套件中使用简单的import语句,并在beforeEach方法中初始化组件对象,以便在测试套件中的每个测试规范中都有组件对象的新实例。以下是一个示例:

import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 

import {AppComponent} from "../../app.component"; 

describe('AppComponent Tests Suite', () => { 

  let comp: AppComponent; 
  let fixture: ComponentFixture<AppComponent>; 

  beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent ] 
    }) 
    .compileComponents(); 
  })); 

  beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 

  }); 
}); 

因此,只要为每个测试规范初始化组件类,它就会为每个规范创建一个新实例,并且内部范围将根据此进行操作。

初始化组件

为了测试组件,重要的是初始化组件类,以便在测试套件的范围内拥有组件对象,并且对象的所有成员都在特定的测试套件周围可用。

只要组件包含渲染 UI 的模板,就需要在开始端到端测试之前初始化组件,并且这取决于 DOM 元素。

因此,当我们计划对任何组件进行端到端测试时,我们应该在 DOM 中初始化它,如下所示:

<body> 
  <my-app></my-app> 
</body> 

端到端与组件的单元测试

在前面的示例中,我们看了组件测试套件,这是用于单元测试的,我们需要导入并创建组件类的实例作为单元测试。我们将测试组件中定义的每个方法的功能或特性。

另一方面,对于端到端测试,我们不需要导入或创建组件类的实例,因为我们不需要使用组件对象或其所有成员进行交互。相反,它需要与正在运行的应用程序的登陆页面的 DOM 元素进行交互。

因此,为此,我们需要运行应用程序并将测试套件导航到应用程序的登陆页面,我们可以使用 Protractor 本身提供的全局browser对象来实现这一点。

这是一个示例,它应该是这样的:

import { browser, element, by } from 'protractor'; 

describe('Test suite for e2e test', () => { 
    beforeEach(() => { 
        browser.get(''); 
    }); 
}); 

我们可以根据需要使用browser.get('path')导航到应用程序的所有 URL。

深入我们的评论应用程序

现在设置和方法已经确定,我们可以开始我们的第一个测试。从测试的角度来看,由于我们将使用自顶向下的方法,我们将首先编写我们的 Protractor 测试,然后构建应用程序。我们将遵循我们已经审查过的相同的 TDD 生命周期:首先测试,使其运行,然后使其更好。

首先测试

给定的场景已经以规范的格式给出,并符合我们的 Protractor 测试模板:

describe('', () => { 
    describe('', () => { 
     beforeEach(() => { 
     }); 

      it('', () => { 
      }); 
    }); 
}); 

将场景放入模板中,我们得到以下代码:

describe('Given I am posting a new comment', () => { 
    describe('When I push the submit button', () => { 
        beforeEach(() => { 
            // ...  
        }); 

        it('Should then add the comment', () => { 
            // ... 
        }); 
    }); 
}); 

遵循 3A 原则(组装、行动、断言),我们将把用户场景放入模板中。

组装

浏览器将需要指向应用程序的第一个页面。由于基本 URL 已经定义,我们可以将以下内容添加到测试中:

beforeEach(() => { 
    browser.get(''); 
}); 

现在测试已经准备好,我们可以继续下一步:行动。

行动

根据用户的规范,我们需要做的下一件事是添加一个实际的评论。最简单的方法就是将一些文本放入输入框中。对于这个测试,再次不知道元素将被称为什么或者它将做什么,我们将根据它应该是什么来编写它。

以下是为应用程序添加评论部分的代码:

beforeEach(() => { 
    ... 
    var commentInput = element(by.css('input')); 
    commentInput.sendKeys('a sample comment'); 
}); 

作为测试的一部分,最后一个组件是点击“提交”按钮。在 Protractor 中可以很容易地通过 click 函数实现这一点。即使我们还没有页面,或者任何属性,我们仍然可以命名将要创建的按钮:

beforeEach(() => { 
    ... 
    var submitButton = element(by.buttonText('Submit')).click(); 
}); 

最后,我们将击中测试的关键点,并断言用户的期望。

断言

用户期望是一旦点击“提交”按钮,评论就会被添加。这有点模糊,但我们可以确定用户需要以某种方式收到评论已添加的通知。

最简单的方法是在页面上显示所有评论。在 Angular 中,这样做的最简单方法是添加一个 *ngFor 对象来显示所有评论。为了测试这一点,我们将添加以下内容:

it('Should then add the comment', () => { 
    var comment = element.all(by.css('li')).first(); 
    expect(comment.getText()).toBe('a sample comment'); 
}); 

现在测试已经构建并满足用户的规范。它既小又简洁。以下是完成的测试:

describe('Given I am posting a new comment', () => { 
    describe('When I push the submit button', () => { 
      beforeEach(() => { 
            //Assemble 
            browser.get(''); 
            var commentInput = element(by.css('input')); 
            commentInput.sendKeys('a sample comment'); 

            //Act 
            var submitButton =  element(by.buttonText
            ('Submit')).click(); 
      }); 

       //Assert 
  it('Should then add the comment', () => { 
            var comment = element.all(by.css('li')).first(); 
            expect(comment.getText()).toBe('a sample comment'); 
  }); 
    }); 
}); 

使其运行

基于测试的错误和输出,我们将在构建应用程序的过程中进行。

使用以下命令启动 Web 服务器:

**$ npm start**

运行 Protractor 测试以查看第一个错误:

**$ protractor**

或者,我们可以运行这个:

**$ npm run e2e // run via npm** 

我们的第一个错误可能是没有得到定位器期望的元素:

**$ Error: Failed: No element found using locator: 
    By(css selector, input)**

错误的原因很简单:它没有按照定位器中定义的元素获取。我们可以看到当前的应用程序以及为什么它没有获取到元素。

总结当前应用程序

只要我们将示例 Angularquickstart项目克隆为我们要测试的应用程序,它就具有一个准备好的 Angular 环境。它使用一个简单的应用程序组件定义了“我的第一个 Angular 2 应用程序”作为输出来引导 Angular 项目。

因此,在我们的 TDD 方法中,我们不应该有任何与环境/Angular 引导相关的错误,看起来我们走在了正确的道路上。

让我们看看我们的示例应用程序现在有什么。在我们的首页index.html中,我们已经包含了所有必需的库文件,并实现了system.js来加载应用程序文件。

index.html文件中的<body>标签中,我们已经启动了应用程序,如下所示:

<body> 
    <my-app>Loading...</my-app> 
</body> 

HTML 标签期望一个带有my-app作为该组件选择器的组件,是的,我们有app.component.ts如下:

import {Component} from '@angular/core'; 
@Component({ 
    selector: 'my-app', 
    template: '<h1>My First Angular 2 App</h1>' 
}) 
export class AppComponent { } 

Angular 引入了ngModule作为appModule,以模块化和管理每个组件的依赖关系。通过这个appModule,应用程序可以一目了然地定义所有所需的依赖关系。除此之外,它还帮助延迟加载模块。我们将在 Angular 文档中详细了解ngModule的细节。

它在应用程序中导入了所有必需的模块,从单一入口点声明了所有模块,并且还定义了引导组件。

应用程序总是基于该文件的配置进行引导。

该文件位于应用程序根目录下,名为app.module.ts,其内容如下:

import { NgModule }      from '@angular/core'; 
import { BrowserModule } from '@angular/platform-browser'; 

import { AppComponent }  from './app.component'; 

@NgModule({ 
  imports:      [ BrowserModule], 
  declarations: [ AppComponent ], 
  bootstrap:    [ AppComponent ] 
}) 
export class AppModule { } 

应用程序的入口点是main.ts文件,它将导入appModule文件,并指示根据该文件引导应用程序:

import { platformBrowserDynamic } from '@angular/platform
-browser-dynamic'; 

import { AppModule } from './app.module'; 

platformBrowserDynamic().bootstrapModule(AppModule); 

测试找不到我们的输入定位器。我们需要将输入添加到页面,并且我们需要通过组件的模板来做到这一点。

添加输入

以下是我们需要遵循的步骤来将输入添加到页面:

  1. 我们将不得不在应用程序组件的模板中添加一个简单的input标签,如下所示:
        template: ` 
        <input type='text' />` 

  1. 再次运行测试后,似乎与输入定位器相关的错误已经没有了,但是出现了一个新的错误,即button标签丢失:
        **$ Error: Failed: No element found using locator: 
        by.buttonText('Submit')**

  1. 就像之前的错误一样,我们需要在模板中添加一个button,并附上适当的文本:
        template: ` ...........  
        <button type='button'>Submit</button>` 

  1. 再次运行测试后,似乎没有与button定位器相关的错误,但是又出现了新的错误,如下所示,重复器定位器丢失:
        **$ Error: Failed: No element found using locator: By
        (css selector, li)**

这似乎是我们假设提交的评论将通过*ngFor在页面上可用的结果。为了将其添加到页面上,我们将在组件类中使用一个方法来为重复器提供数据。

组件

如前所述,错误是因为没有comments对象。为了添加comments对象,我们将使用具有comments数组的组件类。

执行以下步骤将comments对象添加到作用域中:

  1. 由于我们已经在组件中有AppComponent作为一个类,我们需要定义评论数组,我们可以在重复器中使用:
        export class AppComponent { 
            comments:Array<string>; 
        } 

  1. 然后,我们将在模板中为评论添加一个重复器,如下所示:
        template: `..........  
            <ul> 
              <li *ngFor="let comment of comments">{{comment}}</li> 
            </ul>` 

  1. 让我们运行 Protractor 测试,看看我们的进展:
        **$   Error: Failed: No element found using locator: By(css
        selector, li)**

糟糕!我们仍然得到相同的错误。不过别担心,可能还有其他问题。

让我们看看实际呈现的页面,看看发生了什么。在 Chrome 中,导航到http://localhost:3000并打开控制台以查看页面源代码(Ctrl + Shift + J)。请注意,重复器和组件都在那里;但是,重复器被注释掉了。由于 Protractor 只查看可见元素,它不会找到列表。

太棒了!现在我们知道为什么重复列表不可见,但是我们必须修复它。为了使评论显示出来,它必须存在于组件的comments作用域中。

最小的更改是向数组中添加一些内容以初始化它,如下面的代码片段所示:

export class AppComponent { 
    comments:Array<string>; 
    constructor() { 
        this.comments = ['First comment', 'Second comment',
        'Third comment']; 
    } 
}; 

现在,如果我们运行测试,我们会得到以下输出:

**$ Expected 'First comment' to be 'a sample comment'.**

很好,看起来我们离成功更近了!我们已经解决了几乎所有意外错误并达到了我们的期望。

让我们来看看我们迄今为止所做的更改以及我们的代码是什么样子的。

这是index.html文件的body标签:

<body> 
    <my-app>Loading...</my-app> 
</body> 

应用组件文件如下:

import {Component} from '@angular/core'; 

@Component({ 
    selector: 'my-app', 
    template: `<h1>My First Angular 2 App</h1> 
    <input type='text' /> 
    <button type='button'>Submit</button> 
    <ul> 
      <li *ngFor="let comment of comments">{{comment}}</li> 
    </ul>` 
}) 
export class AppComponent { 
    comments:Array<string>; 

    constructor() { 
        this.comments = ['First comment', 'Second comment', 
        'Third comment']; 
    } 
} 

使其通过

使用 TDD,我们希望添加最小可能的组件来使测试通过。

由于我们目前已经将评论数组硬编码为初始化为三个项目,并且第一个项目为First comment,将First comment更改为a sample comment,这应该使测试通过。

以下是使测试通过的代码:

export class AppComponent { 
    comments:Array<string>; 
    constructor() { 
        this.comments = ['a sample comment', 'Second comment', 
        'Third comment']; 
    } 
}; 

运行测试,哇!我们得到了一个通过的测试:

**$ 1 test, 1 assertion, 0 failures**

等一下!我们还有一些工作要做。虽然我们让测试通过了,但还没有完成。我们添加了一些黑客技巧,只是为了让它通过。有两件事引人注目:

  • 我们单击了实际上没有任何功能的“提交”按钮

  • 我们对评论的预期值进行了硬编码初始化

在我们继续之前,上述更改是我们需要执行的关键步骤。它们将在 TDD 生命周期的下一个阶段中解决,即使其更好(重构)。

使其更好

需要重新设计的两个组件如下:

  • 为“提交”按钮添加行为

  • 删除评论的硬编码值

实现“提交”按钮

“提交”按钮需要实际做一些事情。我们可以通过硬编码值来绕过实现。使用我们经过验证的 TDD 技术,转而采用专注于单元测试的方法。到目前为止,重点一直放在 UI 上并将更改推送到代码上;我们还没有编写单个单元测试。

在接下来的工作中,我们将转变思路,专注于通过测试驱动“提交”按钮的开发。我们将遵循 TDD 生命周期(先测试,使其运行,然后使其更好)。

配置卡尔玛

我们在第三章中为待办事项列表应用程序做了非常类似的事情,“卡尔玛方式”。我们不会花太多时间深入到代码中,所以请查看以前的章节,以深入讨论一些属性。

以下是我们需要遵循的配置卡尔玛的步骤:

  1. 使用添加的文件更新files部分:
        files: [ 
            ... 
            // Application files 
            {pattern: 'app/**/*.js', included: false, watched: 
            true} 

            // Unit Test spec files 
            {pattern: 'spec/unit/**/*.spec.js', included: false,
            watched: true} 
            ... 
        ], 

  1. 启动卡尔玛:
        **$ karma start**

  1. 确认卡尔玛正在运行:
        **$ Chrome 50.0.2661 (Mac OS X 10.10.5): Executed 0 of 0 
        SUCCESS (0.003 secs / 0 secs)**

先测试

让我们从spec/unit文件夹中的新文件开始,名为app.component.spec.ts。这将包含单元测试的测试规范。我们将使用基本模板,包括所有必要的导入,如TestBed

    describe('', () => { 
     beforeEach(() => { 
     }); 

      it('', () => { 
      }); 
    }); 

根据规范,当单击“提交”按钮时,需要添加评论。我们需要填写测试的三个组成部分(组装、行动和断言)的空白。

组装

行为需要成为前端组件的一部分来使用。在这种情况下,测试的对象是组件的范围。我们需要将这一点添加到这个测试的组装中。就像我们在第三章中所做的那样,“卡尔玛方式”,我们将在以下代码中做同样的事情:

import {AppComponent} from "../../app/app.component"; 

describe('AppComponent Unit Test', () => { 
    let comp: AppComponent; 
    let fixture: ComponentFixture<AppComponent>; 

    beforeEach(() => { fixture = TestBed.create
    Component(AppComponent); 
      comp = fixture.componentInstance; 

    }); 
}); 

现在,component对象及其成员在测试套件中可用,并将如预期般进行测试。

行动

规范确定我们需要在组件对象中调用add方法。将以下代码添加到测试的beforeEach部分:

beforeEach(() => { comp.add('a sample comment'); 
}); 

现在,断言应该获取第一个评论进行测试。

断言

断言component对象中的评论项现在包含任何评论作为第一个元素。将以下代码添加到测试中:

it('',function(){ 
  expect(com.comments[0]).toBe('a sample comment'); 
}); 

保存文件,让我们继续进行生命周期的下一步并运行它(执行)。

让它运行

现在我们已经准备好测试,我们需要让测试通过。查看 Karma 运行时的控制台输出,我们看到以下内容:

**$ TypeError: com.add is not a function**

查看我们的单元测试,我们看到这是add函数。让我们继续按照以下步骤将add函数放入控制器的scope对象中:

  1. 打开控制器范围并创建一个名为add的函数:
        export class AppComponent { 
            ............. 
            add() { 
            // .... 
            } 
        } 

  1. 检查 Karma 的输出,让我们看看我们的进展:
        **$ Expected 'First comment' to be 'a sample comment'.**

  1. 现在,我们已经达到了期望。记住要考虑最小的改变来使其工作。修改add函数,将$scope.comments数组设置为任何评论:
        export class AppComponent { 
            ............. 
            add() { 
                this.comments.unshift('a sample comment'); 
            } 
        }; 

注意

unshift函数是一个标准的 JavaScript 函数,它将一个项目添加到数组的开头。

当我们检查 Karma 的输出时,我们会看到以下内容:

**$ Chrome 50.0.2661 (Mac OS X 10.10.5): Executed 1 of 1 
    SUCCESS (0.008 secs / 0.002 secs)**

成功!测试通过了,但还需要一些工作。让我们继续进行下一阶段并改进它(重构)。

让它变得更好

需要重构的主要点是add函数。它不接受任何参数!这应该很容易添加,并且只是确认测试仍然运行。更新app.component.tsadd函数,以接受一个参数并使用该参数添加到comments数组中:

export class AppComponent { 
    ............. 
    add(comment) { 
        this.comments.unshift(comment); 
    } 
}; 

检查 Karma 的输出窗口,并确保测试仍然通过。完整的单元测试如下所示:

import {AppComponent} from "../../app/app.component"; 

describe('AppComponent Tests', () => { 
    let comp: AppComponent; 
    let fixture: ComponentFixture<AppComponent>; 

    beforeEach(() => { 
        fixture = TestBed.createComponent(AppComponent); 
        comp = fixture.componentInstance;         
        comp.add('a sample comment'); 
    }); 

    it('First item inthe item should match', () => { 
        expect(com.comments[0]).toBe('a sample comment'); 
    }); 
}); 

AppComponent类文件现在是这样的:

import {Component} from '@angular/core'; 

@Component({ 
    selector: 'my-app', 
    template: `<h1>My First Angular 2 App</h1> 
    <input type='text' /> 
    <button type='button'>Submit</button> 
    <ul> 
      <li *ngFor="let comment of comments">{{comment}}</li> 
    </ul>` 
}) 
export class AppComponent { 
    comments:Array<string>; 

    constructor() { 
        this.comments = ['First comment', 'Second comment', 
        'Third comment']; 
    } 
    add(comment) { 
        this.comments.unshift(comment); 
    } 
} 

备份测试链

我们完成了单元测试并添加了add函数。现在我们可以添加函数来指定提交按钮的行为。将add方法链接到按钮的方法是使用(click)事件。添加行为到提交按钮的步骤如下:

  1. 打开app.component.ts文件并进行以下更新:
        @Component({ 
           template: `....... 
            <button type="button" (click)="add('a sample      
            comment')">Submit</button> 
            ...........` 
        }) 

等等!这个值是硬编码的吗?好吧,我们再次希望做出最小的更改,并确保测试仍然通过。我们将不断进行重构,直到代码达到我们想要的状态,但我们不想采取大爆炸的方式,而是希望进行小的、增量的改变。

  1. 现在,让我们重新运行 Protractor 测试,并确保它仍然通过。输出显示它通过了,我们没问题。硬编码的值没有从注释中删除。让我们继续并立即删除它。

  2. AppComponent 类文件现在应该如下所示:

        constructor() { 
            this.comments = []; 
        } 

  1. 运行测试,看到我们仍然得到一个通过的测试。

我们需要清理的最后一件事是 (click) 中的硬编码值。添加的评论应该由评论输入文本中的输入确定。

绑定输入

以下是我们需要遵循的绑定输入的步骤:

  1. 为了能够将输入绑定到有意义的东西,将 ngModel 属性添加到 input 标签中:
        @Component({ 
            template: `............. 
            <input type="text" [(ngModel)]="newComment"> 
            ...........` 
        }) 

  1. 然后,在 (click) 属性中,简单地使用 newComment 模型作为输入:
        @Component({ 
           template: `....... 
            <button type="button" (click)="add(newComment)">
            Submit</button> 
            ...........` 
        }) 

  1. 我们将不得不在应用程序模块(app.module.ts)中导入表单模块,因为它是 ngModel 的依赖项:
        import { FormsModule }   from '@angular/forms'; 
        @NgModule({ 
        imports: [ BrowserModule, FormsModule ], 
        }) 

  1. 运行 Protractor 测试,并确认一切都通过了,可以进行。

向前迈进

现在我们已经让第一个规范工作了,并且它是端到端和单元测试的,我们可以开始下一个规范。下一个规范说明用户希望能够喜欢一条评论。

我们将采用自上而下的方法,从 Protractor 开始我们的测试。我们将继续遵循 TDD 生命周期:先测试,使其运行,然后使其更好。

先测试

按照模式,我们将从一个基本的 Protractor 测试模板开始:

describe('', () => { 
     beforeEach(() => { 
     }); 

      it('', () => { 
      }); 
    }); 

当我们填写规范时,我们得到以下结果:

describe('When I like a comment', () => { 
    beforeEach(() => { 
    }); 

    it('should then be liked', () => { 
      }); 
}); 

有了模板,我们准备构建测试。

组装

这个测试的组装将需要存在一个评论。将评论放在现有的发布评论测试中。它应该看起来类似于这样:

describe(''Given I am posting a new comment', () => { 
    describe('When I like a comment', () => { 
    ... 
    }); 
}); 

行动

我们测试的用户规范是Like按钮对特定评论执行操作。以下是需要的步骤和执行它们所需的代码(请注意,以下步骤将添加到 beforeEach 文本中):

  1. 存储第一条评论,以便在测试中使用:
        var firstComment = null; 
        beforeEach(() => { 
            ... 
        } 

  1. 找到第一条评论的 likeButton
        var firstComment = element.all(by.css('li').first(); 
        var likeButton = firstComment.element(by.buttonText('like')); 

  1. 当点击Like按钮时,代码如下:
        likeButton.click(); 

断言

规范的期望是一旦评论被点赞,它就会被点赞。最好的方法是通过放置点赞数量的指示器,并确保计数为1。然后代码将如下所示:

it('Should increase the number of likes to one', () => { 
var commentLikes = firstComment.element(by.binding('likes')); 
  expect(commentLikes.getText()).toBe(1); 
}); 

现在创建的测试看起来是这样的:

describe('When I like a comment', () => { 
    var firstComment = null; 
    beforeEach(() => { 

      //Assemble 
      firstComment = element.all(by.css('li').first(); 
      var likeButton = firstComment.element(by.buttonText('like')); 

      //Act 
      likeButton.click(); 
  }); 

  //Assert 
  it('Should increase the number of likes to one', () => { 
      var commentLikes = firstComment.element(by.css('#likes')); 
      expect(commentLikes.getText()).toBe(1); 
  }); 
}); 

让它运行

测试已经准备就绪,迫不及待地要运行。我们现在将运行它并修复代码,直到测试通过。以下步骤将详细说明需要进行的错误和修复循环,以使测试路径:

  1. 运行 Protractor。

  2. 在命令行中查看错误消息:

**$ Error: No element found using locator: by.buttonText("like")**

  1. 正如错误所述,没有like按钮。继续添加按钮:
        @Component({ 
              template: `........ 
              <ul> 
              <li *ngFor="let comment of comments"> 
              {{comment}} 
            <button type="button">like</button> 
              </li> 
              </ul>` 
          }); 

  1. 运行 Protractor。

  2. 查看下一个错误消息:

**$ Expected 'a sample comment like' to be 'a sample comment'.**

  1. 通过添加like按钮,我们导致其他测试失败。原因是我们使用了getText()方法。Protractor 的getText()方法获取内部文本,包括内部元素。

  2. 为了解决这个问题,我们需要更新先前的测试,将like作为测试的一部分包括进去:

        it('Should then add the comment', () => { 
          var comments = element.all(by.css('li')).first(); 
          expect(comments.getText()).toBe('a sample comment like'); 
        }); 

  1. 运行 Protractor。

  2. 查看下一个错误消息:

**$ Error: No element found using locator: by.css("#likes")**

  1. 现在是添加likes绑定的时候了。这个稍微复杂一些。likes需要绑定到一个评论。我们需要改变组件中保存评论的方式。评论需要保存comment标题和点赞数。评论应该是这样的一个对象:
        {title:'',likes:0} 

  1. 再次强调,这一步的重点只是让测试通过。下一步是更新组件的add函数,以根据我们在前面步骤中描述的对象创建评论。

  2. 打开app.component.ts并编辑add函数,如下所示:

        export class AppComponent { 
            ...... 
              add(comment) { 
                  var commentObj = {title: comment, likes: 0}; 
                  this.comments.unshift(commentObj); 
              } 
        } 

  1. 更新页面以使用评论的值:
        @Component({ 
            template: `........... 
            <ul> 
              <li *ngFor="let comment of comments"> 
          {{comment.title}} 
            </li> 
            </ul>` 
        }) 

  1. 在重新运行 Protractor 测试之前,我们需要将新的comment.likes绑定添加到 HTML 页面中:
        @Component({ 
            template: `........... 
            <ul> 
              <li *ngFor="let comment of comments"> 
          {{comment.title}} 
          ............. 
          <span id="likes">{{comment.likes}}</span> 
              </li> 
          </ul>` 
        }) 

  1. 现在重新运行 Protractor 测试,让我们看看错误在哪里:
**$ Expected 'a sample comment like 0' to be 'a sample
        comment like'**

  1. 由于评论的内部文本已更改,我们需要更改测试的期望:
        it('Should then add the comment',() => { 
        ... 
          expect(comments.getText()).toBe('a sample comment like 0'); 
        }); 

  1. 运行 Protractor:
**$ Expected '0' to be '1'.**

  1. 最后,我们来到了测试的期望。为了使这个测试通过,最小的更改将是使like按钮更新comment数组上的点赞数。第一步是在控制器中添加一个like方法,它将更新点赞数:
        export class AppComponent { 
            ...... 
              like(comment) { 
                  comment.like++; 
              } 
        } 

  1. like方法与 HTML 页面链接,使用按钮上的(click)属性,如下所示:
        @Component({ 
              template: `........ 
              <ul> 
              <li *ngFor="let comment of comments"> 
              {{comment}} 
            <button type="button" (click)="like(comment)">
            like</button> 
        <span id="likes">{{comment.likes}}</span> 
              </li> 
              </ul>` 
          }); 

  1. 运行 Protractor 并确认测试通过!

页面现在看起来如下截图:

让它运行

与本章开头的图表相比,所有功能都已创建。现在我们已经让 Protractor 中的测试通过了,我们需要检查单元测试以确保我们的更改没有破坏它们。

修复单元测试

所需的主要更改之一是将评论作为一个包含值和点赞数量的对象。在过多考虑单元测试可能受到影响之前,让我们开始吧。执行以下命令:

**$ karma start**

如预期的那样,错误与新的comment对象有关:

**$ Expected { value : 'a sample comment', likes : 0 } to be 
    'a sample comment'.**

审查期望,似乎唯一需要的是在期望中使用comment.value,而不是comment对象本身。将期望更改如下:

it('',() => { 
    var firstComment = app.comments[0]; 
    expect(firstComment.title).toBe('a sample comment'); 
}) 

保存文件并检查 Karma 输出。确认测试通过。Karma 和 Protractor 测试都通过了,我们已经完成了添加评论和点赞的主要用户行为。现在我们可以继续下一步,让事情变得更好。

让它变得更好

总的来说,这种方法最终得到了我们想要的结果。用户现在可以在 UI 中点赞评论并看到点赞数量。从重构的角度来看,我们没有对like方法进行单元测试。

审查我们的开发待办清单,我们看到列表是我们写下的一个动作。在完全结束该功能之前,让我们讨论一下是否要为like功能添加单元测试的选项。

耦合测试

正如已经讨论过的,测试与实现紧密耦合。当涉及复杂逻辑或需要确保应用程序的某些方面以特定方式行为时,这是一件好事。重要的是要意识到耦合,并知道何时将其引入应用程序以及何时不需要。我们创建的like函数只是简单地增加了对象上的计数器。这可以很容易地进行测试;然而,单元测试将引入的耦合不会给我们带来额外的价值。

在这种情况下,我们不会为like方法添加另一个单元测试。随着应用程序的进展,我们可能会发现需要添加单元测试以开发和扩展功能。

在添加测试时,以下是我考虑的一些事项:

  • 添加测试的价值是否超过了维护成本?

  • 测试是否为代码增加了价值?

  • 它是否帮助其他开发人员更好地理解代码?

  • 功能是否以某种方式进行了测试?

根据我们的决定,不需要进行更多的重构或测试。在下一节中,我们将退一步,回顾本章的要点。

自测问题

Q1. 卡尔玛需要 Selenium WebDriver 来运行测试。

  • 正确

  • 错误

Q2. 鉴于以下代码片段,您将如何选择以下按钮:

    <button type="button">Click Me</button>? 

  • element.all(by.button('button'))

  • element.all(by.css('type=button'))

  • element(by.buttonText('Click Me')

总结

在本章中,我们介绍了使用 Protractor 和 Karma 的 TDD 技术。随着应用程序的开发,我们能够看到何时、为什么以及如何应用 TDD 测试工具和技术。

这种自上而下的方法与第三章中讨论的自下而上的方法不同,卡尔玛方式,以及第四章中讨论的自下而上的方法,使用 Protractor 进行端到端测试。在自下而上的方法中,规范用于构建单元测试,然后在其上构建 UI 层。在本章中,展示了一种自上而下的方法,重点放在用户行为上。

自上而下的方法测试 UI,然后通过其他层过滤开发。这两种方法都有其优点。在应用 TDD 时,了解如何同时使用两者是至关重要的。除了介绍不同的 TDD 方法之外,我们还看到了 Angular 的一些核心测试组件,例如以下内容:

  • 从端到端和单元角度测试一个组件

  • 将组件类导入测试套件并为单元测试启动它

  • Protractor 绑定到ngModel,向输入列发送按键,并通过其内部 HTML 代码和所有子元素获取元素的文本的能力

下一章将基于此处使用的技术,并研究无头浏览器测试、Protractor 的高级技术以及如何测试 Angular 路由。

第七章:翻转

在这一点上,我们应该对使用 TDD 进行 Angular 应用程序的初始实现感到自信。此外,我们应该熟悉使用先测试的方法。先测试的方法在学习阶段非常好,但有时当我们遇到很多错误时会耗费时间。对于简单和已知的行为,可能不适合采用先测试的方法。

我们已经看到了先测试的方法是如何工作的,所以我们可以通过检查任何功能来跳过这些步骤,而不创建这些组件。除此之外,我们可以更进一步,让我们更有信心更快地编写我们的组件。我们可以准备好我们的组件,然后编写端到端的测试规范来测试预期的行为。如果端到端测试失败,我们可以在 Protractor 调试器中触发错误。

在本章中,我们将继续扩展我们对 Angular 应用 TDD(但不是先测试的方法)的知识。我们不会在这里讨论基本的 Angular 组件生态系统的细节;相反,我们将更进一步,包括更多的 Angular 特性。我们将通过以下主题进一步扩展我们的知识:

  • Angular 路由

  • 导航到路由

  • 与路由参数数据通信

  • 使用 CSS 和 HTML 元素的 Protractor 定位器的位置引用

TDD 的基础知识

在本章中,我们将演示如何将 TDD 应用于搜索应用程序的路由和导航。在进行实例演练之前,我们需要了解本章中将使用的一些技术、配置和函数,包括以下内容:

  • Protractor 定位器

  • 无头浏览器测试

在回顾了这些概念之后,我们可以继续进行实例演练。

Protractor 定位器

Protractor 定位器是每个人都应该花一些时间学习的关键组件。在之前的 Protractor 章节中,我们了解了一些常用的定位器,并提供了工作示例。我们将在这里提供一些 Protractor Locator的示例。

Protractor 定位器允许我们在 HTML 页面中查找元素。在本章中,我们将看到 CSS、HTML 和 Angular 特定的定位器的实际应用。定位器被传递给element函数。element函数将在页面上查找并返回元素。通用的定位器语法如下:

element(by.<LOCATOR>); 

在上述代码中,<LOCATOR>是一个占位符。以下部分描述了其中的一些定位器。

CSS 定位器

CSS 用于向 HTML 页面添加布局、颜色、格式和样式。从端到端测试的角度来看,元素的外观和样式可能是规范的一部分。例如,考虑以下 HTML 片段:

<div class="anyClass" id="anyId"></div> 
// ... 
var e1 = element(by.css('.anyClass')); 
var e2 = element(by.css('#anyId')); 
var e3 = element(by.css('div')); 
var e4 = $('div'); 

所有这四个选择都将选择 div 元素。

按钮和链接定位器

除了能够选择和解释某物的外观方式之外,能够在页面内找到按钮和链接也很重要。这将使测试能够轻松地与网站进行交互。以下是一些示例:

  • buttonText 定位器:
        <button>anyButton</button> 
        // ... 
        var b1 = element(by.buttonText('anyButton')); 

  • linkText 定位器:
        <a href="#">anyLink</a> 
        // ... 
        var a1 = element(by.linkText('anyLink')); 

URL 位置引用

在测试 Angular 路由时,我们需要能够测试我们测试的 URL。通过在 URL 和位置周围添加测试,我们必须确保应用程序能够使用特定路由。这很重要,因为路由为我们的应用程序提供了一个接口。以下是如何在 Protractor 测试中获取 URL 引用的方法:

var location = browser.getLocationAbsUrl(); 

现在我们已经看到了如何使用不同的定位器,是时候将知识付诸实践了。

准备一个 Angular 项目

重要的是要有一个快速设置项目的过程和方法。您花在思考目录结构和所需工具的时间越少,您就可以花更多时间开发!

因此,在之前的章节中,我们看了如何获取 Angular 的简单现有项目,开发为 quickstart 项目 (github.com/angular/quickstart)。

然而,有些人使用 angular2-seed (github.com/mgechev/angular2-seed) 项目,Yeoman,或者创建一个自定义模板。虽然这些技术很有用并且有其优点,但在开始学习 Angular 时,了解如何从零开始构建应用是至关重要的。通过自己构建目录结构和安装工具,我们将更好地理解 Angular。

您将能够根据您特定的应用程序和需求做出布局决策,而不是将它们适应其他模块。随着您的成长和成为更好的 Angular 开发人员,这一步可能不再需要,并且会成为您的第二天性。

加载现有项目

首先,我们将从 Angular 的 quickstart 项目 github.com/angular/quickstart 克隆项目,将其重命名为 angular-flip-flop,我们的项目文件夹结构如下:

加载现有项目

在前几章中,我们讨论了如何设置项目,理解了涉及的不同组件,并走过了整个过程。我们将跳过这些细节,并假设您可以回忆起如何执行必要的安装。

准备项目

这个quickstart项目在项目的首页(index.html)中没有包含基本的href。我们需要这样做才能完美地进行路由,因此让我们在index.html<head>部分添加一行(base href):

<base href="/"> 

在这里,我们的引导组件在应用程序组件中,HTML 模板在组件本身中。在继续之前,我们应该将模板分离到一个新文件中。

为此,我们将更新我们的应用程序组件(app/app.component.ts),如下所示:

import { Component } from '@angular/core'; 

@Component({ 
  moduleId: module.id, 
  selector: 'my-app', 
  templateUrl: 'app.component.html' 
}) 
export class AppComponent { 

}; 

让我们在app/app.component.html中创建我们单独的模板文件。代码将如下所示:

<h1>My First Angular 2 App</h1> 

运行项目

让我们继续进行,并准备使用以下命令运行:

**$ cd angular-flip-flop**
**$ npm install // To install the required node modules.** 
**$ npm run // To build and run the project in http server.** 

要确认安装并运行项目,应用程序将自动在 Web 浏览器中运行。

在运行项目后,预期的输出如下:

运行项目

重构项目

让我们稍微改变项目结构,不过不多。默认情况下,它在相同位置包括了单元测试和组件文件,并将 e2e 测试文件分离到app/文件夹之外的e2e/文件夹中。

然而,我们将保持所有测试在相同的位置,也就是在app之外;我们将把所有测试保存在spec/e2espec/unit中。

目标是将测试规范与组件分开。这样,我们可以将我们的单元测试文件保存在spec/unit文件夹之外。

因此,我们当前的文件夹结构将如下所示:

重构项目

注意

只要我们已经改变了单元测试和 e2e 测试的路径,我们就必须在 Karma 配置和 Protractor 配置文件中更改路径。

为 Karma 设置无头浏览器测试

在之前的章节中,我们使用默认配置运行 Karma。默认的 Chrome 配置在每次测试时都会启动 Chrome。针对应用程序将在其中运行的实际代码和浏览器进行测试是一个强大的工具。然而,在启动时,浏览器可能并不总是知道你希望它如何行为。从单元测试的角度来看,你可能不希望浏览器在窗口中启动。原因可能是测试可能需要很长时间运行,或者你并不总是安装了浏览器。

幸运的是,Karma 配备了轻松配置 PhantomJS 的能力,一个无界面浏览器。无界面浏览器在后台运行,不会在 UI 中显示网页。PhantomJS 无界面浏览器是一个非常好用的测试工具。它甚至可以设置为对你的测试进行截图!在 PhantomJS 网站上阅读更多关于如何做到这一点以及使用的 WebKit 的信息,网址是phantomjs.org/。以下设置配置将展示如何在 Karma 中设置 PhantomJS 进行无界面浏览器测试。

预配置

当 Karma 被安装时,它会自动包含 PhantomJS 浏览器插件。有关更多信息,请参考插件位于github.com/karma-runner/karma-phantomjs-launcher。不应该需要任何更多的安装或配置。

然而,如果你的设置显示缺少karma-phantomjs-launcher,你可以很容易地使用npm进行安装,就像这样:

**$ npm install karma-phantomjs-launcher --save -dev**

配置

PhantomJS 被配置在 Karma 配置的browsers部分。打开karma.conf.js文件,并使用以下细节进行更新:

browsers: ['PhantomJS'], 

同样在plugins选项中进行:

plugins: [ 
        ......... 
        require('karma-phantomjs-launcher'), 
    ], 

现在项目已经初始化并配置了无界面浏览器测试,你可以通过以下教程看到它的运行情况。

Angular 路由和导航的教程

这个教程将利用 Angular 路由。路由是 Angular 的一个非常有用的特性,在 Angular 1.x 之前也是如此,但更加强大。它们允许我们使用不同的组件来控制应用程序的某些方面。

这个教程将在不同的组件之间切换,以展示如何使用 TDD 来构建路由。以下是规格说明。将有一个导航菜单,其中有两个菜单项,View1View2

  • 在导航菜单中,点击标签View1

  • 内容区域(路由器出口)将加载/翻转View1内容

以下是第二部分:

  • 在导航菜单中,单击标签View2

  • 内容区域(路由器出口)将加载/翻转View2内容

基本上,这将是一个在两个视图之间进行翻转的应用程序。

设置 Angular 路由

路由器是 Angular 中的可选服务,因此它不包含在 Angular 核心中。如果我们需要使用路由器,我们将需要在应用程序中安装 Angular router服务。

只要我们从quickstart克隆了我们的项目,我们应该没问题,因为它最近已将 Angular 路由器添加到其依赖项中,但我们应该检查并确认。如果在package.json中的依赖项中没有@angular/router,我们可以使用npm安装 Angular 路由器,如下所示:

**$ npm install @angular/router --save**

定义方向

路由指定了位置并期望结果。从 Angular 的角度来看,路由必须首先指定,然后与某些组件关联。

要在我们的应用程序中实现路由器,我们需要在应用程序模块中导入路由器模块,其中它将在应用程序中注册路由器。之后,我们将需要配置所有路由并将该配置传递给应用程序模块。

路由器模块

要在应用程序中实现路由器,我们需要在应用程序模块中导入RouterModule,位于app/app.module.ts,如下所示:

import {RouterModule} from "@angular/router"; 

这将只是在应用程序系统中使router模块可用,但我们必须有一个路由器配置来定义整个应用程序中所有可能的路由器,然后通过应用程序模块将该配置导入应用程序生态系统。

配置路由

路由器在配置之前是无用的,为了配置它,我们首先需要导入router组件。配置主要包含一个数组列表,其中路由路径和相关组件作为键值对存在。我们可以将配置数组添加到应用程序模块中,或者我们可以创建一个单独的配置文件并将应用模块包含在其中。我们将选择第二个选项,以便路由配置与应用模块分离。

让我们在应用程序根目录中创建路由器配置文件app/app.routes.ts。在那里,首先,我们需要从 Angular 服务中导入 Angular Routes,如下所示:

import {Routes} from '@angular/router';

从路由器配置文件中,我们需要导出配置数组,如下所示:

export const rootRouterConfig: Routes = [ 
 // List of routes will come here 
]; 

应用程序中的路由

我们已经将router模块导入到了位于app/app.module.ts的应用程序模块中。

然后,我们需要将路由配置文件(rootRouterConfig)导入到这个应用程序模块文件中,如下所示:

import {rootRouterConfig} from "./app.routes";

在应用程序模块中,我们知道NgModule导入了可选模块到应用程序生态系统中,类似地,为了在应用程序中包含路由,RouterModule有一个名为RouterModule.forRoot(RouterConfig)的函数,接受routerConfiguration来实现整个应用程序中的路由。

应用程序模块(app/app.module.ts)将导入RouterModule如下:

@NgModule({ 
  declarations: [AppComponent, ........], 
  imports     : [........., RouterModule.forRoot(rootRouterConfig)], 
  bootstrap   : [AppComponent] 
}) 
export class AppModule { 
} 

配置中的路由

现在,让我们向位于app/app.routes.tsRoutes配置数组中添加一些路由。路由配置数组包含一些对象作为键值对,每个对象中大多有两到三个元素。

数组对象中的第一个元素包含“路径”,第二个元素包含与该“路径”对应的要加载的“组件”。

让我们向配置数组中添加两个路由,如下所示:

export const rootRouterConfig: Routes = [ 
  { 
    path: 'view1',  
    component: View1Component 
  }, 
  { 
    path: 'view2',  
    component: View2Component 
  } 
]; 

在这里,定义了两个路由,view1view2,并分配了两个组件以加载该路由。

在某些情况下,我们可能需要从一个路由重定向到另一个路由。例如,对于应用程序的根路径(''),我们可能计划重定向到view1路由。为此,我们必须在对象中设置redirectTo元素,并将一些路由名称分配为其值。我们还需要添加一个额外的元素作为pathMatch,并将其值设置为full,以便在重定向到其他路由之前匹配完整路径。

代码如下所示:

export const rootRouterConfig: Routes = [ 
  { 
    path: '',  
    redirectTo: 'view1',  
    pathMatch: 'full' 
  }, 
  .............. 
]; 

因此,是的,我们的初始路由配置已经准备就绪。现在,完整的配置将如下所示:

import {Routes} from '@angular/router'; 
import {View1Component} from './view/view1.component'; 
import {View2Component} from './view/view2.component'; 

export const rootRouterConfig: Routes = [ 
  { 
    path: '',  
    redirectTo: 'view1',  
    pathMatch: 'full' 
  }, 
  { 
    path: 'view1',  
    component: View1Component 
  }, 
  { 
    path: 'view2',  
    component: View2Component 
  } 
]; 

我在这里应该提到,我们必须导入view1view2组件,因为我们在路由配置中使用了它们。

要详细了解 Angular 路由,请参考angular.io/docs/ts/latest/guide/router.html

实践路由

到目前为止,我们已经安装和导入了路由模块,配置了路由,并在应用程序生态系统中包含了一些内容。我们仍然需要做一些相关的任务,比如创建路由出口,创建导航,以及创建路由中定义的组件,以便亲身体验路由。

定义路由出口

只要路由在appComponent中配置,我们就需要一个占位符来加载路由导航的组件,Angular 将其定义为路由出口。

RouterOutlet是一个占位符,Angular 根据应用程序的路由动态填充它。

对于我们的应用程序,我们将在appComponent模板中放置router-outlet,位于(/app/app.component.html),就像这样:

<router-outlet></router-outlet> 

准备导航

在路由配置中,我们为我们的应用程序设置了两个路径,/view1/view2。现在,让我们创建具有两个路由路径的导航菜单,以便进行简单的导航。为此,我们可以创建一个单独的简单组件,以便为整个应用程序组件隔离导航。

/app/nav/navbar.component.ts中为NavbarComponent创建一个新的组件文件,如下所示:

import {Component} from '@angular/core'; 

@Component({ 
  selector: 'app-navbar', 
  templateUrl: 'navbar.component.html', 
  styleUrls: ['navbar.component.css'] 
}) 
export class NavbarComponent {} 

此外,在/app/nav/navbar.component.html中为导航组件创建一个模板,如下所示:

<main> 
  <nav> 
    <a [routerLink]="['/view1']">View1</a> 
    <a [routerLink]="['/view2']">View2</a> 
    <a [routerLink]="['/members']">Members</a>      
  </nav> 
</main> 

注意

现在不要担心导航中的members链接;我会在后面的部分告诉你它是什么。

让我们为导航组件创建基本的 CSS 样式,以便更好地查看/app/nav/navbar.component.css,如下所示:

:host { 
  border-color: #e1e1e1; 
  border-style: solid; 
  border-width: 0 0 1px; 
  display: block; 
  height: 48px; 
  padding: 0 16px; 
} 

nav a { 
  color: #8f8f8f; 
  font-size: 14px; 
  font-weight: 500; 
  margin-right: 20px; 
  text-decoration: none; 
  vertical-align: middle; 
} 

nav a.router-link-active { 
  color: #106cc8; 
} 

我们有一个导航组件。现在我们需要将其绑定到我们的应用组件,也就是我们的应用程序登陆页面。

为了这样做,我们必须将以下内容附加到位于/app/app.component.htmlappComponent模板中:

<h1>My First Angular 2 App</h1> 
<app-navbar></app-navbar> 
<router-outlet></router-outlet> 

准备组件

对于每个定义的路由,我们需要创建一个单独的组件,因为每个路由都将与一个组件相关联。

在这里,我们有两个定义的路由,我们需要创建两个单独的组件来处理路由导航。根据我们的需求,我们将创建View1ComponentView2Component

/app/view/view1.component.ts中为View 1组件创建一个新的组件文件,如下所示:

import {Component} from '@angular/core'; 

@Component({ 
  selector: 'app-view1', 
  template: '<div id="view1">I am view one component</div>' 
}) 
export class View1Component { } 

View 2组件创建另一个组件文件(/app/view/view2.component.ts):

import {Component} from '@angular/core'; 

@Component({ 
  selector: 'app-view2', 
  template: '<div id="view2">I am view two component</div>' 
}) 
export class View2Component { } 

我们已经准备好了我们的路由和相关组件(导航View1View2)。希望一切都按预期工作,我们可以在浏览器中看到应用程序的输出。

在查看浏览器中的预期输出之前,让我们通过端到端测试来测试预期结果。现在我们知道了预期的行为,我们将根据我们的期望编写端到端测试规范。一旦我们准备好了端到端测试规范,我们将看到它如何满足我们的期望。

组装翻转/翻转测试

在 3A 中的第一个Aassemble之后,这些步骤将向我们展示如何组装测试:

  1. 从 Protractor 基本模板开始,如下所示:
        describe('Given views should flip through navigation         
        interaction', () => { 
          beforeEach( () => { 
            // ..... 
        }); 

        it('Should fliped to the next view', () => { 
           // ....  
        }); 
        }); 

  1. 使用以下代码导航到应用程序的根目录:
        browser.get('view1'); 

  1. beforeEach方法需要确认正确的组件视图正在显示。这可以通过使用 CSS 定位器来查找view1div标签来完成。期望将如下所示:
        var view1 = element(by.css('#view1')); 
        expect(view1.isPresent()).toBeTruthy(); 

  1. 然后,添加一个期望,即view2不可见:
        var view2 = element(by.css('#view2')); 
        expect(view2.isPresent()).toBeFalsy(); 

  1. 然后通过获取view1组件的整个文本来进一步确认:
        var view1 = element(by.css('#view1')); 
        expect(view1.getText()).toEqual('I am view one component'); 

翻转到下一个视图

前面的测试需要确认,当在导航中点击view2链接时,view2组件的内容将会加载。为了测试这一点,我们可以使用by.linkText定位器。它将如下所示:

var view2Link = element(by.linkText('View2')); 
view2Link.click(); 

beforeEach函数现在已经完成,看起来像这样:

var view1 = element(by.css('#view1')); 
var view2 = element(by.css('#view2')); 
beforeEach(() => { 
    browser.get('view1'); 
    expect(view1.isPresent()).toBeTruthy(); 
    var view2Link = element(by.linkText('View2')); 
    view2Link.click(); 
}) 

接下来,我们将添加断言。

断言翻转

断言将再次使用 Protractor 的 CSS 定位器,如下所示,来查找view2是否可用:

it('Should fliped to View2 and view2 should visible', () => { 
  expect(view2.isPresent()).toBeTruthy(); 
}); 

我们还需要确认view1不再可用。添加view1不应存在的期望,如下所示:

it('Should fliped to View2 and view1 should not visible', () => { 
  expect(view1.isPresent()).toBeFalsy(); 
}); 

另外,为了确保,我们可以检查view2的内容是否已加载,如下所示:

it('Should fliped to View2 and should have body content as expected',  () => { 
    expect(view2.getText()).toEqual('I am view two component'); 
}); 

由于我们即将进行的测试将从导航中点击view2链接切换到view1组件,让我们通过点击导航中的view1链接返回到view1组件,希望事情能如预期般工作:

it('Should flipped to View1 again and should visible', () => { 
    var view1Link = element(by.linkText('View1')); 
    view1Link.click(); 
    expect(view1.isPresent()).toBeTruthy(); 
    expect(view2.isPresent()).toBeFalsy(); 
  }); 

测试现在已经组装完成。

运行翻转/反转测试

我们的测试规范已经准备好,现在是运行测试并查看结果的时候了。

首先,我们将通过以下命令保持项目在 HTTP 服务器上运行:

**$ npm start**

然后,我们必须运行 Protractor。确保运行应用程序和 Protractor 配置文件的端口号;为了确保,更新配置中运行服务器端口。要运行 Protractor,请使用以下命令:

**$ npm run e2e**

结果应该如下所示:

Suite: Given views should flip through navigation in 
    passed - View1 should have body content as expected 
    passed - Should flipped to View2 and view2 should visible 
    passed - Should flipped to View2 and should have body content
    as expected 
    passed - Should flipped to View1 again and should visible 
        Suite passed: Given views should flip through navigation in 

根据我们的期望,Protractor 测试已经通过。现在我们可以查看浏览器,检查事情是否与端到端测试结果一样。

在浏览器中打开应用程序

只要我们已经运行了用于端到端测试的npm start命令,我们的应用程序就可以在本地主机的特定端口3000上运行。默认情况下,它将在浏览器中打开。

预期输出如下截图所示:

在浏览器中打开应用程序

以 TDD 方式进行搜索

这个演练将向我们展示如何构建一个简单的搜索应用程序。它有两个组件:第一个讨论了搜索查询组件,第二个使用路由来显示搜索结果的详细信息。

搜索查询的演练

正在构建的应用程序是一个搜索应用程序。第一步是设置带有搜索结果的搜索区域。想象一下,我正在进行搜索。在这种情况下,将发生以下操作:

  • 输入搜索查询

  • 结果将显示在搜索框底部

这部分应用程序与我们在第六章中看到的测试、布局和方法非常相似,第一步。应用程序将需要使用输入,响应点击,并确认结果数据。由于测试和代码使用与之前示例相同的功能,因此不值得提供完整的搜索功能演练。相反,以下小节将展示所需的代码并附带一些解释。

搜索查询测试

以下代码代表了搜索查询功能的测试:

describe('Given should test the search feature', () => { 
    let searchBox, searchButton, searchResult; 

    beforeEach(() => { 

    //ASSEMBLE  
    browser.get(''); 
    element(by.linkText('Search')).click(); 
    searchResult = element.all(by.css('#searchList tbody tr')); 
    expect(searchResult.count()).toBe(3); 

    //ACT 
    searchButton = element(by.css('form button')); 
    searchBox = element(by.css('form input')); 
    searchBox.sendKeys('Thomas'); 
    searchButton.click(); 
    }); 

    //Assert 
    it('There should be one item in search result', () => { 
    searchResult = element.all(by.css('#searchList tbody tr')); 
    expect(searchResult.count()).toBe(1); 
    }); 
}); 

我们应该注意到与之前的测试有相似之处。功能被编写成模仿用户在搜索框中输入的行为。测试找到输入字段,输入一个值,然后选择写着搜索的按钮。断言确认结果包含一个单一值。

搜索应用程序

为了执行搜索操作,我们需要创建一个搜索组件,其中包含一个输入字段来接受用户输入(搜索查询)和一个按钮来执行用户操作并触发点击事件。此外,它可能有一个占位符来包含搜索结果。

只要我们的应用程序已经包含了路由器,我们就可以为特定路由放置搜索组件。

请注意,我们将我们的搜索组件称为MembersComponent,因为我们在搜索组件中使用了一些成员数据。路由也将根据这个进行配置。

因此,在我们现有的app.routes.ts文件中,我们将添加以下搜索路由:

export const rootRouterConfig: Routes = [ 
  { 
    path: '/members', 
    component: MembersComponent 
  } 
................... 
]; 

搜索组件

搜索组件(MembersComponent)将是这里搜索功能的主要类。它将执行搜索并返回搜索结果。

在搜索组件的初始加载期间,它将没有任何搜索查询,因此我们已经设置了行为以返回所有数据。然后,在搜索触发后,它将根据搜索查询返回数据。

搜索组件将放置在app/members/members.compoennt.ts中。在代码中,首先,我们将不得不导入所需的 Angular 服务,如下所示:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router } from '@angular/router'; 

我们将使用Http服务进行 AJAX 调用,默认情况下,在 Angular 中,Http服务返回一个可观察对象。但是,处理承诺比处理可观察对象更容易。因此,我们将把这个可观察对象转换为一个承诺。Angular 建议使用rxjs模块,该模块具有toPromise方法,用于将可观察对象转换为承诺。因此,我们将导入rxjs模块,如下所示:

import 'rxjs/add/operator/toPromise'; 

Angular 引入了ngOnInit()方法,在初始化组件时执行,类似于任何类中的构造方法,但对于运行测试规范很有帮助。为此,我们从 Angular 核心中导入了OnInit接口,Component类将实现OnInit接口以获取ngOnInit方法。

除此之外,Component类应注入所需的模块,例如HttpRouter,如下所示:

export class MembersComponent implements OnInit { 
    constructor(private http:Http, private router:Router) { 
  } 
} 

如前所述,我们将使用ngOnInit()方法,并从中初始化搜索机制,如下所示:

export class MembersComponent implements OnInit { 
 ngOnInit() { 
    this.search(); 
  } 

在这里,我们将在成员列表上应用“搜索”功能,为此,我们在app/data/people.json中有一些虚拟数据。我们将从这里检索数据并对数据执行搜索操作。让我们看看如何:

  • getData()方法将从 API 检索数据并返回一个承诺。
        getData() { 
            return this.http.get('app/data/people.json') 
            .toPromise() 
            .then(response => response.json()); 
        } 

  • searchQuery()方法将解析返回的承诺,并根据搜索查询创建一个数据数组。如果没有提供搜索查询,它将返回完整的数据集作为数组:
        searchQuery(q:string) { 
            if (!q || q === '*') { 
              q = ''; 
            } else { 
              q = q.toLowerCase(); 
            } 
            return this.getData() 
              .then(data => { 
              let results:Array<Person> = []; 
              data.map(item => { 
                if (JSON.stringify(item).toLowerCase().includes(q)) { 
                  results.push(item); 
                } 
              }); 
              return results; 
            }); 
        } 

  • search()方法将为模板准备数据集,以便在前端绑定:
        search(): void { 
          this.searchQuery(this.query) 
          .then(results => this.memberList = results); 
        } 

我们还有一个可选的方法,用于导航到成员详细信息组件。我们称之为person组件。在这里,viewDetails()方法将传递成员 ID,router.navigate()方法将应用程序导航到带有 ID 参数的person组件,如下所示:

viewDetails(id:number) { 
    this.router.navigate(['/person', id]); 
  } 

MembersComponent的完整代码如下:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router } from '@angular/router'; 
import 'rxjs/add/operator/toPromise'; 
import { Person } from './person/person.component'; 

@Component({ 
  selector: 'app-member', 
  moduleId: module.id, 
  templateUrl: 'members.component.html', 
  styleUrls: ['members.component.css'] 
}) 
export class MembersComponent implements OnInit { 
  memberList: Array<Person> = []; 
  query: string; 

  constructor(private http:Http, private router:Router) { 
  } 

  ngOnInit() { 
    this.search(); 
  } 

  viewDetails(id:number) { 
    this.router.navigate(['/person', id]); 
  } 

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

  search(): void { 
    this.searchQuery(this.query) 
    .then(results => this.memberList = results); 
  } 

  searchQuery(q:string) { 
    if (!q || q === '*') { 
      q = ''; 
    } else { 
      q = q.toLowerCase(); 
    } 
    return this.getData() 
      .then(data => { 
      let results:Array<Person> = []; 
      data.map(item => { 
        if (JSON.stringify(item).toLowerCase().includes(q)) { 
          results.push(item); 
        } 
      }); 
      return results; 
    }); 
  } 
} 

search组件模板包含搜索表单和搜索结果列表(当有结果要显示时)。

模板如下所示:

<h2>Members</h2> 

<form> 
  <input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()"> 
  <button type="button" (click)="search()">Search</button> 
</form> 

<table *ngIf="memberList" id="searchList"> 
  <thead> 
  <tr> 
    <th>Name</th> 
    <th>Phone</th> 
  </tr> 
  </thead> 
  <tbody> 
  <tr *ngFor="let member of memberList; let i=index"> 
    <td><a href="javascript:void(0)" (click)="viewDetails(member.id)">{{member.name}}</a></td> 
    <td>{{member.phone}}</td> 
  </tr> 
  </tbody> 
</table> 

前面的 Angular 组件与前几章中已经展示的内容类似。

我们正在使用people.json文件中的虚拟数据集,其中包含有关带地址的人的信息。我们希望将信息分为两部分,一部分是摘要信息,另一部分是地址详细信息。由于我们将使用这个数据集,因此很容易为这个数据集创建一个对象模型。

摘要数据集将被定义为Person对象,地址详细信息将被定义为Address。让我们在app/members/person/person.component.ts中创建一个人员对象,并将两个对象模型放在同一个文件中。

PersonAddress两个对象模型类如下:

export class Person { 
  id:number; 
  name:string; 
  phone:string; 
  address:Address; 

  constructor(obj?:any) { 
    this.id = obj && Number(obj.id) || null; 
    this.name = obj && obj.name || null; 
    this.phone = obj && obj.phone || null; 
    this.address = obj && obj.address || null; 
  } 
} 

export class Address { 
  street:string; 
  city:string; 
  state:string; 
  zip:string; 

  constructor(obj?:any) { 
    this.street = obj && obj.street || null; 
    this.city = obj && obj.city || null; 
    this.state = obj && obj.state || null; 
    this.zip = obj && obj.zip || null; 
  } 
} 

显示搜索结果!

现在,搜索按钮已经设置了所需的功能,结果应该只包含基于搜索查询的数据,而不是所有数据。让我们看一下用户规范。

给定一组搜索结果:

  • 我们将根据搜索查询得到成员列表

  • 我们将点击任何成员的名字并导航到详细组件以获取详细信息

按照自上而下的方法,第一步将是 Protractor 测试,然后是使应用程序完全功能的必要步骤。

测试搜索结果

根据规范,我们需要利用现有的搜索结果。我们可以在现有的搜索查询测试中添加内容,而不是从头开始创建一个测试。从搜索查询测试中嵌入一个基本测试,如下所示:

describe('Given should test the search result in details view', () => { 
  beforeEach(() => { 
  }); 

  it('should be load the person details page', () => { 
  }); 
}); 

下一步是构建测试。

组装搜索结果测试

在这种情况下,搜索结果已经可以从搜索查询测试中获得。我们不必为测试添加任何其他设置步骤。

选择搜索结果

正在测试的对象是结果。测试是结果被选择后,应用程序必须执行某些操作。编写这个测试的步骤如下:

  1. 选择resultItem。由于我们将使用路由来表示详细信息,我们将创建一个指向详细页面的链接并点击该链接。以下是创建链接的方法:

选择resultItem内的链接。这使用当前选择的元素,然后找到符合条件的任何子元素。代码如下:

        let resultItem = element(by.linkText('Demaryius Thomas')); 

  1. 现在,要选择链接,请添加以下代码:
        resultItem.click(); 

确认搜索结果

现在搜索项已被选中,我们需要验证结果详情页面是否可见。在这一点上,最简单的解决方案是确保详情视图是可见的。这可以通过使用 Protractor 的 CSS 定位器来查找搜索详情视图来实现。以下是用于确认搜索结果的代码:

it('Should be load the person details page', () => { 
    var resultDetail = element(by.css('#personDetails')) 
    expect(resultDetail.isDisplayed()).toBeTruthy(); 
}) 

以下是完整的测试:

describe('Given should test the search result in details view', () => { 

  beforeEach(() => { 
    browser.get('members'); 
    let searchButton = element(by.css('form button')); 
    let searchBox = element(by.css('form input')); 
    searchBox.sendKeys('Thomas'); 
    searchButton.click(); 
    let resultItem = element(by.linkText('Demaryius Thomas')); 
    resultItem.click(); 
  }); 

  it('should be load the person details page', () => { 
    var resultDetail = element(by.css('#personDetails')) 
    expect(resultDetail.isDisplayed()).toBeTruthy(); 
  }); 

}); 

现在测试已经设置好,我们可以继续到生命周期的下一个阶段并运行它。

搜索结果组件

搜索结果组件(我们命名为 Person)将路由以接受来自 params 路由的 person ID,并将根据该 ID 搜索数据。

搜索结果组件将放置在 app/members/person/person.component.ts 中。在代码中,首先我们将需要导入所需的 Angular 服务,如下所示:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router, ActivatedRoute, Params } from '@angular/router'; 

我们已经在 members 组件中看到了一些这些 Angular 服务。在这里,我们将主要讨论 ActivatedRoute,因为它是新的。这是一个用于与当前/激活路由交互的 Angular 路由模块:当我们需要访问当前路由中的 params 时,我们将通过它来访问它们。

正如我们讨论过的,我们在初始化组件时将需要 ActivatedRoute;因此,我们在 ngOnInit() 方法中调用了 ActivatedRoute。它将为我们提供当前路由参数,并且我们将使用它来从演示成员数据集中检索特定的 Person,如下所示:

export class PersonComponent implements OnInit { 
  person: Person; 
  constructor(private http:Http, private route: ActivatedRoute, 
  private router: Router) { 
  } 

  ngOnInit() { 
    this.route.params.forEach((params: Params) => { 
       let id = +params['id']; 
       this.getPerson(id).then(person => { 
         this.person = person; 
       }); 
     }); 
  } 

我们在 app/data/people.json 中有一些虚拟数据。这是 members 组件中使用的相同数据。我们将根据所选的 ID 检索数据,就像这样:

getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

getData() 方法将从 API 中检索数据并返回一个 promise:

getPerson(id:number) { 
    return this.getData().then(data => data.find(member => 
    member.id === id)); 
  } 

getPerson() 方法将解析返回的 promise,并根据所选的 ID 返回 Person 对象。

关于 PersonComponent 的完整代码如下:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router, ActivatedRoute, Params } from '@angular/router'; 
import 'rxjs/add/operator/toPromise'; 

@Component({ 
  selector: 'app-person', 
  moduleId: module.id, 
  templateUrl: 'person.component.html', 
  styleUrls: ['../members.component.css'] 
}) 
export class PersonComponent implements OnInit { 
  person: Person; 
  constructor(private http:Http, private route: ActivatedRoute, private router: Router) { 
  } 

  ngOnInit() { 
    this.route.params.forEach((params: Params) => { 
       let id = +params['id']; 
       this.getPerson(id).then(person => { 
         this.person = person; 
       }); 
     }); 
  } 

  getPerson(id:number) { 
    return this.getData().then(data => data.find(member => member.id === id)); 
  } 

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 
} 

search 组件模板包含搜索表单和搜索结果列表(当有结果要显示时)。

模板如下所示:

<h2>Member Details</h2> 

<table *ngIf="person" id="personDetails"> 
  <tbody> 
  <tr> 
    <td>Name :</td> 
    <td>{{person.name}}</td> 
  </tr> 
    <tr> 
      <td>Phone: </td> 
      <td>{{person.phone}}</td> 
    </tr> 
    <tr> 
      <td>Street: </td> 
      <td>{{person.address.street}}</td> 
    </tr> 
    <tr> 
      <td>City: </td> 
      <td>{{person.address.city}}</td> 
    </tr> 
    <tr> 
      <td>State: </td> 
      <td>{{person.address.state}}</td> 
    </tr> 
    <tr> 
      <td>Zip: </td> 
      <td>{{person.address.zip}}</td> 
  </tr> 
  </tbody> 
</table> 

路由中的搜索结果

我们有搜索结果/Person 组件,但我们忘记在路由配置中包含它。没有它,我们将会有一个异常,因为在没有在路由中包含它的情况下,无法从 members 列表导航到 Person 组件。

因此,在我们现有的 app.routes.ts 文件中,我们将添加以下搜索路由:

export const rootRouterConfig: Routes = [ 
  { 
    path: '/person/:id', 
    component: PersonComponent 
  } 
................... 
]; 

运行搜索轮

我们的应用程序已经准备好进行重构、路由配置、e2e 测试以及它们的子组件。我们将查看当前文件结构和项目的输出。

应用结构

我们的应用程序有两个主要的文件夹,一个是app目录,另一个是spec/test目录。

让我们看看我们app目录的当前结构:

应用结构

这是test目录:

应用结构

让我们运行

我们的搜索功能已经准备就绪。如果我们运行npm start,我们的应用程序将在默认端口3000上在浏览器中运行。让我们导航到成员以获取搜索功能的输出。搜索功能的 URL 是http://localhost:3000/members

当我们登陆成员页面时,实际上会加载所有数据,因为搜索输入为空,这意味着没有搜索查询。输出应该如下所示:

让我们运行

现在让我们用一个搜索查询来检查成员页面。如果我们将查询输入为Thomas并进行搜索,它将给我们只有一行数据,如下所示:

让我们运行

我们在数据列表中有一行。现在是时候看数据的详细信息了。点击Thomas后,我们将看到关于 Thomas 的详细信息,包括地址,如下所示:

让我们运行

万岁!完整的应用程序如预期般在浏览器中运行。

现在 e2e 怎么样!

项目在浏览器中运行,我们已经为每个组件进行了 e2e 测试。让我们看看当我们一起运行整个应用程序的 e2e 测试时,e2e 测试的反应如何。

让我们运行npm run e2e;输出如下所示:

现在 e2e 怎么样!

自测问题

Q1. 在导航后使用哪个自定义占位符来加载组件?

<router-output> </router-output> 

<router-outlet> </router-outlet> 

<router-link> </router-link> 

Q2. 给定以下的 Angular 组件,你将如何选择element并模拟点击?

<a href="#">Some Link</a> 
$('a').click();. 
element(by.css('li)).click();. 
element(by.linkText('Some Link')).click();. 

Q3. 在 Angular 中使用路由时,你需要安装@angular/router

  • 正确

  • 错误

总结

本章向我们展示了如何使用 TDD 来构建一个 Angular 应用程序。到目前为止,这种方法侧重于从用户角度进行规范,并使用自顶向下的 TDD 方法。这种技术帮助我们测试并完成了可用的小组件。

随着应用程序的增长,它们的复杂性也在增加。在下一章中,我们将探讨自下而上的方法,并看看何时使用这种技术而不是自上而下的方法。

本章向我们展示了如何使用 TDD 来开发具有路由导航的基于组件的应用程序。路由允许我们很好地分离我们的组件和视图。我们研究了几种 Protractor 定位器的用法,从 CSS 到重复器,链接文本和内部定位器。除了使用 Protractor,我们还学习了如何配置 Karma 与无头浏览器,并看到它的实际应用。

第八章:告诉世界

TDD 的构建侧重于基本组件,即生命周期和过程,使用逐步的演练。我们从头开始研究了几个应用程序,了解如何构建 Angular 应用程序并使用工具对其进行测试。

是时候进一步深入 Angular 的深处并集成服务、EventEmitters 和路由了。

这一章将在几个方面略有不同:

  • 我们将使用第七章 翻转中的搜索应用程序,而不是构建全新的应用程序

  • 我们将为之前章节中跳过的 Angular 路由和导航添加单元测试

  • 我们将通过将常用操作分离到服务中,使现有的搜索应用程序更加现代化

  • 我们将利用 Angular 的EventEmitter类在不同的组件之间进行通信

准备好沟通

在本章中,我们将采用不同的方法,因为我们已经学会了 TDD 方法。我们在上一章中开发了一个小项目,我们的计划是与该项目一起工作,并使其更好,以便向世界展示。

因此,在演练之前,我们必须回顾并确定项目的任何问题和改进的范围。为此,我们必须对搜索应用程序的代码库有信心。

加载现有项目

首先,我们将从第七章 翻转中复制项目,该项目最初来自github.com/angular/quickstart,并将其重命名为angular-member-search

让我们继续准备运行它:

**$ cd angular-member-search**
**$ npm install** 
**$ npm start**

为了确认安装并运行项目,应用程序将自动在 Web 浏览器中运行它。

当我们运行项目时,我们应该得到以下输出:

加载现有项目

哦!我们的端到端测试已经准备就绪。在进行更新之前,我们必须确保现有的端到端测试成功。

让我们在单独的控制台中运行e2e测试:

**$ npm run e2e**

是的,一切都成功通过了:

加载现有项目

单元测试

在上一章中,我们从顶部开始。目标是根据我们所学到的知识详细说明端到端测试。我们清楚了用户场景,通过了测试,我们的场景通过了我们的实现。

在上一章中,我们只涵盖了端到端测试。因此,在本章中,我们将尽可能多地涵盖单元测试。

此外,在上一章中,我们主要关注了 Angular 路由和导航。因此,作为一个逻辑延伸,我们将看看如何测试 Angular 路由和导航。

组件测试

在进行组件测试之前,我们应该讨论一些关于测试 Angular 组件的要点。我们已经有了一个基本的想法:在 Angular 中,一切都是一些组件的组合。因此,我们应该更详细地学习关于 Angular 组件测试的内容。

我们可以根据组件的行为和用例以各种方式进行组件测试。甚至当它们作为一个应用程序一起工作时,我们甚至可以为多个组件编写测试规范。

让我们来看看一些测试组件的方法。

孤立测试

孤立测试,也称为独立测试,之所以被命名为这样,是因为这种类型的测试可以在不需要根据测试规范编译组件的情况下运行。如果不编译,测试规范中将不会有编译后的模板;只有组件类及其方法。这意味着,如果一个组件的特性不太依赖于 DOM,它可以以孤立的方式进行测试。

孤立测试主要用于复杂功能或计算测试,它只是初始化组件类并调用所有方法。

例如,看一下第六章的单元测试,第一步,其中AppComponent负责添加评论并增加它们的喜欢:

beforeEach(() => { 
    comp = new AppComponent(); 
    comp.add('a sample comment'); 
    comp.like(comp.comments[0]); 
}); 

    it('First item in the item should match', () => { 
        expect(comp.comments[0].title).toBe('a sample 
        comment'); 
    }); 

    it('Number of likes should increase on like', () => { 
        expect(comp.comments[0].likes).toEqual(1); 
    }); 

浅层测试

孤立测试有时可以满足测试规范的要求,但并非总是如此。大多数情况下,组件具有依赖于 DOM 的特性。在这种情况下,重要的是在测试规范中渲染组件的模板,以便我们在作用域中有编译后的模板,并且测试规范能够与 DOM 进行交互。

例如,如果我们想为我们的AppComponent编写一个基本的单元测试,它大部分依赖于 DOM,因为组件类中没有方法,那么我们只需要编译组件并检查它是否被定义。此外,我们可以在测试规范中检查组件的模板是否在<h1>元素内有正确的文本。

代码将如下所示:

beforeEach(async(() => { 
    TestBed.configureTestingModule({ 
        declarations: [ AppComponent ]
    }) 
    .compileComponents(); 
})); 

beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
    de = fixture.debugElement.query(By.css('h1')); 
}); 

it('should create and initiate the App component', () => { 
    expect(comp).toBeDefined(); 
}); 

it('should have expected test in <h1> element', () => { 
    fixture.detectChanges(); 
    const h1 = de.nativeElement; 
    expect(h1.innerText).toMatch(/My First Angular 2 App/i, 
    '<h1> should say something about "Angular App"'); 
}); 

集成测试

以下是一些关于集成测试的关键点:

  • 名称集成测试应该让我们对这是什么样的测试有一些了解。它类似于浅层测试,因为它也需要使用模板编译组件并与 DOM 交互。

  • 接下来,我们将查看我们的路由和导航测试套件,其中我们将集成AppComponent、路由器和导航测试套件。

  • 我们已经为AppComponent准备好了一个测试套件,因为它包括navbar组件和router-outlet组件。所有这些组件一起工作,以满足路由规范。

  • 因此,要为路由器获得一个自信的测试规范,我们应该选择集成测试。

我们将在接下来的章节中详细解释路由器测试的示例。

注意

集成测试和浅层测试之间的主要区别在于,集成测试适用于完整应用程序的测试套件,或应用程序的一小部分,其中多个组件共同解决某个目的。它与端到端测试有一些相似之处,但采用了不同的方法。

Karma 配置

在之前的章节中,使用了默认的 Karma 配置,但尚未解释过这个默认配置。文件监视是一个有用的默认行为,现在将进行审查。

文件监视

当使用 Karma 的init命令时,默认情况下启用文件监视。Karma 中的文件监视是通过karma.conf.js文件中的以下定义进行配置:

autoWatch: true, 

文件监视功能按预期工作,并监视配置中files数组中定义的文件。当文件被更新、更改或删除时,Karma 会重新运行测试。从 TDD 的角度来看,这是一个很好的功能,因为测试将继续在没有任何手动干预的情况下运行。

要注意的主要问题是添加文件。如果要添加的文件不符合files数组中的条件,autoWatch参数将无法响应更改。例如,让我们考虑以下文件的定义:

files : [ 'dir1/**/*.js'] 

如果是这种情况,监视程序将找到所有以.js结尾的文件和子目录文件。如果新文件位于不同的目录中,而不在dir1中,则监视程序将无法响应新文件,因为它位于与配置不同的目录中。

测试路由器和导航

我们在第七章Flip Flop中介绍了 Angular 路由器和导航以及一般组件。

正如我们已经讨论过 Angular 组件、路由器和导航的不同类型的测试,我们将看一下集成测试。为此,我们将使用我们的应用组件测试,也就是我们的基础组件,然后将路由和router-outlet组件测试与应用组件集成,以测试路由器。

测试应用组件

在进行路由器测试之前,我们将准备好我们的应用组件测试。在应用组件测试中,我们将测试组件是否被正确定义和初始化,然后通过选择 DOM 元素来测试页面标题。

我们在之前的章节中学习了浅层测试;当我们与 DOM 元素交互时,我们需要进行浅层测试。在这里也是一样的:因为我们将要处理 DOM 元素,所以我们将使用浅层测试作为我们的应用组件测试。

对于浅层测试,我们将需要依赖于 Angular 核心测试中的TestBed Angular 测试 API,它将用于编译和初始化测试套件中的组件。除此之外,我们还将需要依赖于核心测试中的ComponentFixture模块。我们还需要两个模块,名为ByDebugElement,来自 Angular 核心和平台 API,用于与 DOM 元素交互。

我们的应用组件测试将位于spec/unit/app.component.ts,并且将如下所示:

import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 
import { By }           from '@angular/platform-browser'; 
import { DebugElement } from '@angular/core'; 

import { AppComponent } from '../../app/app.component'; 
import { NavbarComponent } from '../../app/nav/navbar.component';
import { RouterOutlet } from '@angular/router';

describe('AppComponent test suite', function () { 
  let comp: AppComponent; 
  let fixture: ComponentFixture<AppComponent>; 
  let de: DebugElement; 

  beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent ] 
    }) 
    .compileComponents(); 
  })); 

  beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
    de = fixture.debugElement.query(By.css('h1')); 
  }); 

  it('should create and initiate the App component', () => { 
    expect(comp).toBeDefined(); 
  }); 

  it('should have expected test in <h1> element', () => { 
    fixture.detectChanges(); 
    const h1 = de.nativeElement; 
    expect(h1.innerText).toMatch(/My First Angular 2 App/i, 
      '<h1> should say something about "Angular App"'); 
  }); 
}); 

如果我们运行这个测试,我们将看到以下结果:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 2 of 2 SUCCESS
   (0 secs / 0.522 secs)

我们的应用组件测试现在已经准备好了;接下来,我们将进行路由器测试,包括router-outlet和导航。

测试路由器

Angular 路由器不是 Angular 核心的一部分;它是一个单独的模块,必须在使用之前导入。它有一些指令,比如RouterOutletRouterLink,在完成路由器活动时起着积极的作用。为了测试路由器,首先我们将测试这些指令,以准备好测试完整的路由器的平台。

提示

我们可以使用实际的路由器模块来测试路由器,但有时会给整个路由系统带来一些复杂性。因此,测试规范可能会失败,而没有提供准确的错误。为了避免这种情况,建议创建路由器存根并将其用于路由器测试。

路由器存根

我从 Angular 官方的测试文档中得到了路由存根的想法。我喜欢关于路由存根的想法,并从 Angular 的 GitHub 存储库中的angular.io/public/docs/_examples/testing/ts/testing/router-stubs.ts文件中复制了router-stubs文件。第一个路由存根指令是RouterStubLinksDirective,它负责托管元素或锚链接(<a>)来执行指令的onClick()方法。与[routerLink]属性绑定的 URL 流向指令的linkParams属性。当点击锚链接(<a>)时,它应该触发onClick()方法,并且将设置为临时的navigateTo属性。

这个router-stubs文件依赖于 Angular 路由和相关指令,包括RouterLinkRouterOutlet,因此我们需要导入它们。

因此,路由存根将位于spec/unit/stub/router-stub.ts,代码如下:

export  {Router, NavigationExtras, RouterLink, RouterOutlet} from '@angular/router'; 

import { Component, Directive, Injectable, Input } from '@angular/core'; 

@Directive({ 
  selector: '[routerLink]', 
  host: { 
    '(click)': 'onClick()' 
  } 
}) 
export class RouterLinkStubDirective { 
  @Input('routerLink') linkParams: any; 
  navigatedTo: any = null; 

  onClick() { 
    this.navigatedTo = this.linkParams; 
  } 
} 

除了RouterLinkStubDirective之外,这个存根应该包含RouterOutletStubComponent来支持router-outlet指令,以及RouterStub来支持主路由模块:

@Component({selector: 'router-outlet', template: ''}) 
export class RouterOutletStubComponent { } 

@Injectable() 
export class RouterStub { 
  navigate(commands: any[], extras?: NavigationExtras) { } 
} 

路由出口和导航测试

正如我们所知,router-outlet 和导航 (RouterLink) 菜单与应用程序的登陆页面一起工作,也就是说,与我们的应用程序组件一起。测试机制将采用相同的形式。这意味着我们将测试这两个模块与应用程序组件。

正如稍早提到的,我们将在这里使用集成测试来进行router-outlet测试。我们已经准备好了应用程序组件的测试套件;现在是时候集成router-outlet和导航 (RouterLink),并且我们将有我们的应用程序组件的集成测试套件,以及router-outletRouterLink

我们有一个navbar组件,基本上是一个导航组件,包含RouterLink来通过路由进行导航。我们将不得不将该组件导入到我们的测试套件中,以便它能够正确执行。除了实际的路由模块,我们还需要导入我们创建的RouterStub。再次强调,router-stubs包含RouterOutletStubComponentRouterLinkStubDirective组件。

在导入所有必需的组件之后,我们将不得不在TestBed配置中声明它们。作为设置的一部分,我们将在测试套件的范围内从RouterLinkStubDirective中获取所有的navLinks来测试和绑定click事件到linkParams

测试套件的设置将如下所示:

import { NavbarComponent } from '../../app/nav/navbar.component'; 
import { AppComponent } from '../../app/app.component';
import { RouterOutletStubComponent, RouterLinkStubDirective } from './stub/router-stubs.js'; 

describe('AppComponent test suite', function () { 
  let navDestination:any; 
  let navLinks:any; 
  let fixture: ComponentFixture<AppComponent>; 
  let de: DebugElement;  

  beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent, NavbarComponent, 
                     RouterLinkStubDirective, 
                     RouterOutletStubComponent ] 
    }) 
    .compileComponents(); 
  })); 

  beforeEach(() => { 
    fixture.detectChanges(); 

    navDestination = fixture.debugElement 
      .queryAll(By.directive(RouterLinkStubDirective)); 

    navLinks = navDestination 
      .map(de => de.injector.get(RouterLinkStubDirective) as 
      RouterLinkStubDirective); 
  });

对于测试规范,首先我们将测试导航菜单中的链接参数。我们有navLinks,我们将把它们与navLinkslinkParams进行匹配。

然后,我们将测试在点击导航菜单项时预期的导航。我们将通过navigatedTo方法来测试。

我们的测试规范将如下所示:

  it('can get RouterLinks from template', () => { 
    expect(navLinks.length).toBe(3, 'should have 3 links'); 
    expect(navLinks[0].linkParams).toBe('/view1', '1st link should
    go to View1'); 
    expect(navLinks[1].linkParams).toBe('/view2', '1st link should
    go to View2'); 
    expect(navLinks[2].linkParams).toBe('/members', '1st link should
    go to members search page'); 
  }); 

  it('can click nav items link in template and navigate 
  accordingly', () => { 
    navDestination[0].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[0].navigatedTo).toBe('/view1'); 

    navDestination[1].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[1].navigatedTo).toBe('/view2'); 

    navDestination[2].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[2].navigatedTo).toBe('/members'); 
  }); 

因此,我们可以说这将覆盖router-outletrouterLink的测试,这将确认路由链接按预期工作,并且我们能够在点击导航菜单后导航到预期的路由。

实施集成测试

我们的测试规范已经准备好了。我们一直在计划进行集成测试,现在我们可以执行了。在这里,我们将组合应用组件和navbar组件,以及router-outletrouterLink,来测试路由和导航。我们将使用浏览器平台 API 的debugElement模块与 DOM 元素进行交互。

测试套件已经准备好了--是时候运行测试了。

让我们用以下命令来运行它:

**npm test** 

所有测试规范都按预期通过了。结果将如下:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 4 of 4 SUCCESS
   (0 secs / 1.022 secs) 

更多的测试...

我们刚刚添加了一些测试,将覆盖到目前为止我们开发的一些功能,主要集中在路由器(router-outletrouterLink)上。

我们将为成员和搜索功能添加更多的测试,但是我们将更新搜索和成员列表的现有功能的行为。除此之外,我们当前的代码库没有适当地解耦组件功能,这将使得单独测试功能变得复杂。

我们已经有了端到端测试,它将验证我们从组件中期望的输出,但是对于单元测试,我们需要重构代码并解耦它们。在更新行为并重构正确的代码库之后,我们将覆盖其余功能的测试。

应用行为总结

让我们快速查看一下搜索应用的概述:

  • 我们的搜索应用在 DOM 中调用 Members 组件。它包含两个主要部分:搜索区域和结果区域。

  • 从搜索区域,我们输入搜索查询并提交以在结果区域获得预期结果。

  • 根据搜索查询结果区域列出了成员列表。我们可能已经发现,在Members组件初始化时我们获取了所有数据;这是因为我们在ngOnInit()中调用了 Members 组件的search()方法,并且当没有设置搜索查询时,它会返回所有数据。

  • 通过点击成员的名字,我们可以在详细页面上看到关于该成员的详细信息。

更新应用程序行为

根据先前的规范,似乎我们在搜索功能中有一些不正确的行为。现在,我们在初始化搜索组件的成员时调用search()。这似乎有点不对;我们应该在输入搜索查询和/或点击搜索按钮后开始搜索。

预期行为是首先加载所有成员数据,然后在开始搜索后,数据列表将根据搜索查询进行更新。

为了做到这一点,让我们更新members.component.ts中的ngOnInit()方法,并添加一个新方法getMember(),在组件初始化期间获取整个数据列表。

预期的更改如下:

ngOnInit() { 
    this.getMembers(); 
  } 

  getMembers() { 
    this.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

search() { 
    // Do Search 
  } 

识别问题

根据现有的代码,似乎我们在members.component.tsperson.component.ts中都定义了getData()方法,因为在这两个组件中,我们都需要调用 JSON 数据源来获取成员数据集。

那么,问题是什么?这是一个不好的做法,因为它重复了代码,当应用程序变得庞大和复杂时,重复的代码很难管理。

例如,现在我们有以下方法两次:

getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

如果我们必须更改数据源 URL 或 API,我们将不得不在两个地方更改此方法。两次更改并不那么困难,但是对于更大的应用程序来说,可能会更多次。

是的,这是一个问题,需要解决。

寻找解决方案

我们已经确定了问题,即代码重复。我们知道解决方案:我们必须在一个共同的地方编写该方法,并在两个组件中使用它。简而言之,我们必须使这个方法可重用,以便每个组件都可以共享它。

看起来很简单,但我们必须按照 Angular 的方式来做。我们不能只是将方法移到一个单独的文件中并导入它。

Angular 为这种情况引入了服务。现在让我们通过示例来看一些这样的服务。

Angular 服务

Angular 服务被引入以编写可在组件之间共享的代码。因此,如果我们需要为多个组件编写一段代码,建议创建一个可重用的单一服务,然后在需要该代码段的任何地方,只需将该服务注入到组件中,并根据需要使用其方法。

服务用于抽象应用程序逻辑。它们用于为特定操作提供单一职责。单一职责允许组件易于测试和更改。这是因为焦点在一个组件上,而不是所有内部依赖关系。

大多数情况下,服务充当任何应用程序的数据源。每当我们需要一段代码与服务器通信以获取数据(通常是 JSON)时,我们使用服务。

这是因为大多数组件需要访问数据,每个人都可以根据需要注入通用服务。因此,我们有一个常用的代码段,实际上是我们应用程序的数据层。我们应该将这些部分移至服务以使我们的应用程序更智能,以便我们可以告诉世界我们在任何方面都没有重复代码。

我们现在有服务了?

按计划,我们已将members.component.tsperson.component.ts组件中的getData()方法移至一个新文件,以便我们可以消除代码重复。

让我们在app/services/members.service.ts中创建一个新文件,创建一个新的类来导出,名为MembersService,并将getData()方法移至其中。除了移动方法之外,我们还需要从 Angular HTTP 模块中导入{ Http, Response },因为getData依赖于 HTTP。

观察以下代码示例:

import { Http, Response } from '@angular/http'; 

export class MembersService { 
  constructor(private http:Http) { 

  } 

  getAPIData() { 
    return this.http.get('app/data/people.json'); 
  } 

  getData() { 
    return this.getAPIData() 
      .toPromise() 
      .then(response => response.json()); 
  } 

} 

我们现在有一个服务,可以开始使用它了。让我们在 Members 组件中导入并使用它。

等等,在此之前,我们需要将服务导入到应用程序模块中以进行标识。只要它是一个服务,我们就必须将其标识为提供者;该服务将充当服务提供者。

我们的app.module.ts文件将如下所示:

import {MembersService} from './services/members.service'; 

@NgModule({ 
  declarations: [AppComponent, NavbarComponent, ....], 
  imports     : [BrowserModule, FormsModule, ......], 
  providers   : [MembersService], 
  bootstrap   : [AppComponent] 
}) 

现在,要在我们的组件中使用服务,我们必须将其导入并注入到我们的 MembersComponents 中,服务名称为MembersService。只要我们将服务作为组件的构造函数注入,我们就可以在整个组件中使用该服务。要访问该方法,我们需要调用this.membersService.getData()

因此,我们的成员组件将如下所示:

import { MembersService } from '../services/members.service'; 
@Component({ 
   ............ 
}) 
export class MembersComponent implements OnInit { 
 constructor(public membersService: MembersService, private router:Router) { 

  } 

  getMembers() { 
    this.membersService.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

是时候运行并查看输出,看看服务如何与成员组件一起工作。

让我们将浏览器指向http://localhost:3000/members

哎呀!发生了什么?我们在浏览器控制台中收到了一个错误:

Error: (SystemJS) Can't resolve all parameters for MembersService: (?) 

根据错误,我们犯了一个错误:SystemJS(用作模块加载器)无法注入MembersService,因为我们忘记了添加一些内容使服务完美。在 Angular 中,我们必须在每个服务中说明它是否可注入;如果不这样做,我们将无法将该服务注入到任何组件中。

为此,我们将不得不使用 Angular Injectable装饰器。我们将简要看一下它。

可注入服务

Injectable 装饰器是 Angular 核心库的一部分,在创建可注入服务时使用。如果不将其定义为可注入的,就无法识别服务的依赖关系。要将其定义为可注入,我们必须在类定义的顶部使用@Injectable()

代码将如下所示:

import { Injectable } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 

@Injectable() 
export class MembersService { 
  constructor(private http:Http) { 

  } 

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 
} 

我们已经使服务可注入。现在,我们应该可以将其注入到成员组件中,并将浏览器指向http://localhost:3000/members

万岁!不再有错误,我们得到了预期的数据列表:

可注入服务

看起来我们的服务是可注入的,并且工作正常。现在是时候在PersonComponent中实现它,因为我们也需要在该组件上使用数据服务。与成员组件一样,让我们使用服务名称membersService将其导入并注入到PersonComponent中。同样,我们将不得不使用this.membersService.getData()来访问数据服务方法。

我们的PersonComponent将如下所示:

import { MembersService } from '../../services/members.service'; 

@Component({ 
  ........... 
}) 
export class PersonComponent implements OnInit { 
  constructor(public membersService: MembersService, private route: ActivatedRoute, private router: Router) { 

  } 

.................... 

  getPerson(id:number) { 
    return this.membersService.getData() 
          .then(data => data.find(member => member.id === id)); 
  } 
} 

是时候运行并查看服务与成员组件的输出了。

我们有我们的端到端测试,它将确认新更改一切正常:

**$ npm run e2e**

是的,一切都顺利通过了:

可注入服务

耶!我们的代码重构没有影响我们的预期行为。

服务将为您提供更多

为了充分利用服务的全部优势,我们将从成员和人员组件中移动两个以上的方法。在此之前,这些方法是特定于组件的;现在,通过将它们添加到服务中,这些方法可以通过注入服务从任何组件中使用。

也许我们以后会从这个改变中受益,但是希望将这些方法与组件解耦。

新添加的代码将如下所示:

@Injectable() 
export class MembersService { 
  constructor(private http:Http) { 

  } 

  ............ 

  searchQuery(q:string) { 
    if (!q || q === '*') { 
      q = ''; 
    } else { 
      q = q.toLowerCase(); 
    } 
    return this.getData() 
      .then(data => { 
      let results:any = []; 
      data.map(item => { 
        if (JSON.stringify(item).toLowerCase().includes(q)) { 
          results.push(item); 
        } 
      }); 
      return results; 
    }); 
  } 

  getPerson(id:number) { 
    return this.getData() 
   .then(data => data.find(member => member.id === id)); 
  } 
} 

测试服务

代码解耦和分离背后的目标是使代码可测试。我们这样做了,我们已经将数据检索部分从成员组件中分离出来,并创建了一个服务,以便易于测试。该服务是可注入的;除此之外,它与 Angular 组件类似。因此,为了进行单元测试,我们将测试服务包含的方法。

测试服务注入

像其他 Angular 组件一样,我们可以测试服务是否定义良好。但主要区别在于,只要服务是可注入的,我们就需要在测试规范中注入它以获取要测试的实例。

对于一个样本测试规范,我们可以设置它导入 TestBedinject,然后使用 MembersService 作为提供者配置 TestingModule。然后,在测试规范中,我们将注入服务并检查服务是否符合预期。

我们的样本测试套件将如下所示:

import { inject, TestBed } from '@angular/core/testing'; 
import { MembersService } from '../../app/services/members.service'; 

describe('Given service should be defined', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
      ],  
    }); 
  }); 

 it('should initiate the member service', inject([MembersService], (membersService) => { 
    expect(membersService).toBeDefined(); 
  })); 

}); 

对于这个测试,预期结果将为 true。

测试 HTTP 请求

要对 HTTP 请求进行单元测试,我们将使用异步技术来保持 HTTP 调用异步,并在 Angular 测试中,我们将使用 fakeAsync 模块,这是一个用于模拟 HTTP 请求的异步模块。

等等,“模拟”?

是的;要在 Angular 测试套件中测试 HTTP 请求,我们不需要进行实际的 HTTP 请求。为了实现 HTTP 请求的效果,我们可以模拟我们的 HTTP 服务;Angular 提供了一个名为 MockBackend 的模拟服务。

MockBackend 是一个可以配置为为 HTTP 模拟请求提供模拟响应的类,它将与 HTTP 服务完全相同,但不会进行实际的网络请求。

在我们配置了 MockBackend 之后,它可以被注入到 HTTP 中。因此,在我们使用 http.get 的服务中,我们将得到预期的数据返回。

我们的带有 HTTP 请求的测试套件将如下所示:

import { fakeAsync, inject, TestBed } from '@angular/core/testing'; 

import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; 
import { MockBackend, MockConnection } from '@angular/http/testing'; 

import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service should be defined and response HTTP request', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
        BaseRequestOptions, 
        MockBackend, 
        { 
          provide: Http, 
          useFactory: (backend, defaultOptions) => { 
            return new Http(backend, defaultOptions); 
          }, 
          deps: [MockBackend, BaseRequestOptions], 
        }, 
      ], 
    }); 
  });
}); 

在这里,首先除了导入 MockBackend 外,我们还导入了 MockConnection,它用于订阅后端连接并将连接的数据提供给下一步。然后,我们配置了 MockBackend,它将返回 HTTP 对象。

接下来,我们将通过注入 MockBackendMembersService 准备好我们的测试规范:

  it('should return response when subscribed to getUsers', fakeAsync( 
    inject([MockBackend, MembersService], (backend, membersService) => { 
      backend.connections.subscribe( 
        (c: MockConnection) => { 
          c.mockRespond( 
            new Response( 
              new ResponseOptions({ body: mockData }) 
            )); 
          }); 

        membersService.getAPIData().subscribe(res => { 
          expect(res.json()).toEqual(mockData); 
        }); 
  }))); 

}); 

在测试规范中,我们除了MembersService之外还注入了MockBackendMockBackend将使用MockConnection对象订阅backend服务。MockConnection将创建一个新的ResponseOptions对象,在这个对象中,我们可以配置我们的响应属性。

在这里,我们只设置了响应对象的body属性,并将body值设置为预定义的mockData对象。

服务存根

我们也可以使用存根数据测试服务。例如,我们可以创建一个名为MembersServiceSpyMembersService的存根版本,它将模拟该服务的所有必要功能。

这个虚假服务将返回一个带有模拟数据的 resolved Promise,因此我们可以只使用这个存根方法进行测试。它将为我们在服务中拥有的所有方法创建一个 spy,并为每个单独的方法返回一个单独的Promise

存根服务将位于spec/unit/stub/members.service.stub.ts,代码如下:

import { Component, Directive, Injectable, Input } from '@angular/core'; 

export class MembersServiceSpy { 
  members = { 
    "id": 2, 
    "name": "Demaryius Thomas", 
    "phone": "(720) 213-9876", 
    "address": { 
      "street": "5555 Marion Street", 
      "city": "Denver", 
      "state": "CO", 
      "zip": "80202" 
    } 
  }; 

  getData = jasmine.createSpy('getData').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

  getPerson = jasmine.createSpy('getPerson').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

  searchQuery = jasmine.createSpy('searchQuery').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

} 

使用存根数据测试服务

在这里,我们将使用存根数据测试MembersService。为此,我们需要导入存根服务。并且在TestBed配置中,我们将提供MemberServiceSpy作为服务,而不是实际的成员服务。

MembersService测试套件的代码如下所示:

import { MembersServiceSpy } from './stub/members.service.stub.js'; 
import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service will response for every method', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [{ provide: MembersService, useClass: MembersServiceSpy }] 
    }); 
  }); 

  it('should return data', fakeAsync(inject( 
    [MembersService], (service) => { 
      service.getData(); 
      expect(service.members).toEqual(mockData); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.searchQuery('Thomas'); 
        expect(service.members.name).toBe('Demaryius Thomas'); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.getPerson(2); 
        expect(service.members.id).toBe(2); 
    }))); 

}); 

组合并运行服务的测试

我们在这里为 Members 服务有两个测试套件。我们可以将它们合并并运行测试。

完整的测试套件代码将如下代码片段所示:

import { fakeAsync, inject, TestBed } from '@angular/core/testing'; 

import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; 
import { MockBackend, MockConnection } from '@angular/http/testing'; 

import { MembersServiceSpy } from './stub/members.service.stub.js'; 
import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service should be defined and response HTTP request', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
        BaseRequestOptions, 
        MockBackend, 
        { 
          provide: Http, 
          useFactory: (backend, defaultOptions) => { 
            return new Http(backend, defaultOptions); 
          }, 
          deps: [MockBackend, BaseRequestOptions], 
        }, 
      ], 
    }); 
  }); 

  it('should initiate the member service', inject([MembersService], (membersService) => { 
    expect(membersService).toBeDefined(); 
  })); 

  it('should return response when send HTTP request', fakeAsync( 
    inject([MockBackend, MembersService], (backend, membersService) => { 
      backend.connections.subscribe( 
        (c: MockConnection) => { 
          c.mockRespond( 
            new Response( 
              new ResponseOptions({ body: mockData }) 
            )); 
          }); 

        membersService.getAPIData().subscribe(res => { 
          expect(res.json()).toEqual(mockData); 
        }); 
  }))); 

}); 

describe('Given service will response for every method', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [{ provide: MembersService, useClass: MembersServiceSpy }] 
    }); 
  }); 

  it('should return data', fakeAsync(inject( 
    [MembersService], (service) => { 
      service.getData(); 
      expect(service.members).toEqual(mockData); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.searchQuery('Thomas'); 
        expect(service.members.name).toBe('Demaryius Thomas'); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.getPerson(2); 
        expect(service.members.id).toBe(2); 
    }))); 

}); 

Members 服务的测试套件已经准备好运行。让我们使用以下命令运行它:

**npm test** 

所有的测试规范都如预期般通过。结果将如下所示:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 9 of 9 SUCCESS 
   (0 secs / 4.542 secs) 

通过事件的力量进行通信

与 Angular 1.x 相比,Angular 具有更强大的事件处理能力。Angular 1.x 具有双向数据绑定,而 Angular 不推荐使用。Angular 通过事件的力量处理数据和模板之间的通信。

Angular 项目依赖于一些组件的组合。为了正常运行,这些组件需要相互通信以共享数据和事件。大多数情况下,组件需要在它们具有父子关系时进行通信。Angular 可以通过几种方式在父子组件之间进行通信。最好的方式是通过处理自定义事件。我们将详细了解自定义事件,并看看它们如何与我们的搜索应用程序一起工作。

Angular 事件

正如我们所知,Angular 推荐单向数据绑定,这意味着只能从组件到 DOM 元素。这是单向数据流,这就是 Angular 的工作原理。那么当我们需要在另一个方向上进行数据流时--从 DOM 元素到组件呢?这取决于不同的事件,例如点击,按键,鼠标悬停和触摸。这些事件将绑定到 DOM 元素上,以侦听用户操作并将该操作传递给组件。

事件绑定语法由带有括号的目标事件组成,位于等号的左侧。组件包含目标事件作为方法,因此每当事件触发时,它将从组件调用该方法。让我们看看搜索表单中的事件:

<button type="button" (click)="search()">Search</button>

任何元素的事件都是常见的目标,但在 Angular 中有点不同,因为 Angular 首先会检查目标名称是否与任何已知指令或组件的事件属性匹配。

Angular 中的自定义事件

自定义事件是由 Angular 的指令或组件引发的,使用EventEmitter。指令创建一个EventEmitter对象,并将自己公开为其属性,以通过@Output装饰器传递。我们将在接下来查看@Output装饰器的细节。在将EventEmitter对象公开为属性之后,指令将调用EventEmitter.emit(value)来触发事件并将值传递给父指令。

自定义指令/组件类将定义自定义事件如下:

  @Output() someCustomEvent: EventEmitter<any> = new EventEmitter(); 

    this.someCustomEvent.emit(value);

父指令将通过绑定到此属性来监听事件,并通过$event对象接收值。

父指令/组件将包含自定义指令,其中将包含自定义事件someCustomEvent,它将触发父指令的doSomething()方法。

<custom-component (someCustomEvent)="doSomething($event)"></custom-component> 

父指令/组件将包含doSomething()方法,如下所示:

doSomething(someValue) { 
    this.value = someValue; 
} 

输出和 EventEmitter API

Output 是来自 Angular 核心的装饰器类,用于从子组件传递自定义事件到父组件。要使用它,我们需要从@angular/core中导入它。

当我们将自定义事件设置为@Output时,该事件将在父组件中可供监听。该装饰器将放置在类内部,如下所示:

export class SearchComponent { 
  @Output() someCustomEvent: EventEmitter<any> = new EventEmitter(); 
}

EventEmitter也是 Angular 的核心类。当我们需要使用它时,我们必须从@angular/core导入它。EventEmitter API 用于在子组件中的值发生变化时通过调用EventEmitter.emit(value)来通知父组件。正如我们所知,父组件总是监听自定义事件。

进一步改进规划

到目前为止,我们拥有的搜索应用程序是一个简单的搜索应用程序。但是我们可以通过保持它的简单性来使其变得更好。我的意思是,我们可以以最佳方式做到这一点,就像我们试图通过将可重用代码分离到新的服务中来解耦数据逻辑一样。

我们仍然有一些需要改进的地方。看起来我们的应用程序还没有完全解耦。我们的组件没有像我们期望的那样解耦。我们正在谈论包含搜索功能和成员列表功能的MembersComponent

我们将遵循单一责任原则,这意味着每个组件应该有单一责任。在这里,MembersComponent有两个责任。因此,我们应该将这个组件拆分为两个单独的组件。

让我们将其拆分为两个单独的组件,称为MembersComponentSearchComponent。实际上,我们刚刚为一个名为SearchComponent的新组件制定了计划,并从成员组件中将搜索功能移到了那里。

现在让我们为两个组件期望的行为制定计划:

  • 搜索组件将负责将用户输入作为搜索查询,并使用我们的服务获取预期的搜索结果

  • 然后我们将搜索结果传递给成员组件

  • 成员组件将从搜索组件获取搜索结果,并将数据列表绑定到 DOM

  • 两个组件将使用事件进行通信和数据交换

计划是通过遵循最佳实践和使用 Angular 的内置功能使这个简单的应用程序变得完美。

搜索组件

按计划,我们需要将搜索功能与成员组件分离。为此,让我们在app/search/search.component.ts创建一个名为SearchComponent的新组件,并创建搜索组件的模板文件。模板文件将简单包含搜索表单。

搜索组件文件将需要导入和注入MembersService,因为它将用于根据搜索查询执行搜索。该组件将具有搜索查询,并将请求服务进行搜索并获取搜索结果。

搜索组件的代码将如下所示:

import { Component } from '@angular/core'; 

import { MembersService, Person } from '../services/members.service'; 

@Component({ 
  selector: 'app-search', 
  moduleId: module.id, 
  templateUrl: 'search.component.html' 
}) 
export class SearchComponent { 
  query: string; 
  memberList: Array<Person> = []; 

  constructor(public membersService: MembersService) { 

  } 

  search() { 
    this.doSearch(); 
  } 

  doSearch(): void { 
    this.membersService.searchQuery(this.query) 
    .then(results => { 
      this.memberList = results; 
    }); 
  } 

} 

搜索组件的模板将如下所示:

<form> 
  <input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()"> 
  <button type="button" (click)="search()">Search</button> 
</form> 

只要我们的应用程序输出不会出错,我们将不得不将搜索组件绑定到成员列表页面,就像以前一样。因此,我们将不得不将搜索组件附加到成员组件的模板中。在这种情况下,它将成为成员组件的子组件。

成员组件的模板将如下所示:

<h2>Members</h2> 
<app-search></app-search> 
<table *ngIf="memberList" id="searchList"> 
  ...... 
</table> 

启用组件之间的共享

现在我们有两个独立的组件,搜索和成员组件。搜索组件已附加到成员组件,但搜索结果在成员组件中不可用。

搜索和成员是独立的组件,它们之间没有桥梁。两者都有隔离的范围来包含它们的元素和变量。

为了在组件之间共享数据,我们需要启用它们之间的通信。如前所述,Angular 事件将会拯救我们,使我们能够在搜索和成员组件之间启用通信。从搜索组件,我们需要使用 Angular 自定义事件与其父组件MembersComponent进行通信。

与父组件通信

搜索组件是成员组件的子组件。它们需要相互通信以共享数据。我们将需要使用自定义事件,借助 Angular 的EventEmiiter API 来自搜索组件发出搜索结果。此外,我们还需要使用@OutPut装饰器将搜索结果设置为输出,以供父组件使用。

为了使用两者,我们需要从 Angular 核心中导入两者。然后,我们需要将@Output搜索结果设置为 EventEmitter 的新实例。这个@Output装饰器使searchResult属性可用作事件绑定。

当搜索组件更新搜索结果时,我们希望告诉父组件searchResult事件已发生。为此,我们需要使用 Output 装饰器声明的 Emitter 对象调用emit(data)与我们声明的searchResult一起。emit()方法用于通过自定义事件通知每次结果已传递。

现在,Members 组件可以获取$event对象,因为我们已经将其传递到模板中,使用(searchRessult)="anyMethod($event);

更新后,带有EventEmitter的搜索组件将如下所示:

import { Component, Output, EventEmitter } from '@angular/core'; 

@Component({ 
  ................... 
}) 
export class SearchComponent { 
  ............. 
  @Output() searchResult: EventEmitter<any> = new EventEmitter(); 

  doSearch(): void { 
    this.membersService.searchQuery(this.query) 
    .then(results => { 
      this.memberList = results; 
      this.searchResult.emit(this.memberList)); 
    }); 
  } 

} 

现在是时候与MembersComponent通信了。让我们在成员组件中声明onSearch()方法,它将接受事件作为参数。

Members 组件将更改为以下内容:

export class MembersComponent implements OnInit { 

  ngOnInit() { 
    this.getMembers(); 
  } 

  onSearch(searchResult) { 
    this.memberList = searchResult; 
  } 

  getMembers() { 
    this.membersService.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

} 

由于我们正在从成员模板中附加搜索组件,让我们将onSearch函数挂钩到搜索组件标记上。我们将称之为(searchResult)--在其周围加上括号--告诉 Angular 这是一个事件绑定。

搜索组件的模板将如下所示:

<h2>Members</h2> 
<app-search (searchResult)="onSearch($event)" ></app-search> 
<table *ngIf="memberList" id="searchList"> 
  ...... 
</table> 

重构后检查输出

搜索应用将被重新打造成一个商店应用,而不是重写已经编写的搜索功能。为了利用现有的搜索项目,它将被复制到一个新的项目文件中。然后,新项目将使用测试来驱动开发变化和重构。重构步骤已被省略,但代码审查将展示代码和测试如何被修改以创建产品应用。

是时候运行它,看看服务如何与 Members 组件一起工作。让我们将浏览器指向http://localhost:3000/members

重构后检查输出

我们有端到端测试,将确认新更改一切顺利:

 **$ npm run e2e**

是的,我们可以看到一切都顺利通过了:

重构后检查输出

是的!我们的代码重构没有影响我们的预期行为。

当前项目目录

我们已经更新和重构了代码,为此我们有一些新的组件、服务等。现在,我们将有一个新的项目结构,将逻辑和组件解耦。

我们当前的目录结构如下:

重构后检查输出

继续前进

在这本书中,我尽量涵盖了一定程度的主题,以便任何人都可以基于 Angular 开始测试驱动开发。但我们跳过了很多重要的内容,尤其是 rxJS。

rxJS 是基于响应式编程的一个单独模块。因此,我们需要熟悉响应式编程才能理解它。

可观察对象

在 Angular 中,默认情况下,HTTP 请求返回的是可观察对象而不是已解决的承诺。由于我们在这里没有涉及 rxJS,我们跳过了可观察对象,并将响应转换为承诺。但我们应该学习可观察对象如何在 Angular 中工作。

发布和订阅

发布和订阅消息是一个强大的工具,但和任何东西一样,如果使用不当,可能会导致混乱。

有两种消息发布的方式:发射或广播。了解区别很重要,因为它们的工作方式略有不同,可能会影响我们应用的性能。

自测问题

Q1. 回调函数指的是在异步函数完成后被调用的函数。

  • 正确

  • 错误

Q2. 异步函数总是按调用顺序完成。

  • 正确

  • 错误

Q3. Angular 中有一个名为MockBackend的模块,用于在单元测试中伪造 HTTP 调用。

  • 正确

  • 错误

Q4. 在 Angular 中,EventEmitter API 用于组件通信。

  • 正确

  • 错误

总结

在本章中,我们探讨了 Angular 中的服务和事件的强大功能。我们还看到了一些通过服务和事件分离代码的示例。

此外,我们还研究了 Angular 组件的不同类型测试,并为 Angular 路由编写了单元测试,并将其与应用程序组件和导航集成。我们还进一步探讨了 Karma 的配置,以便使用其功能。

现在我们已经到达书的结尾,是时候将我们的知识应用到现实世界中了。在离开之前,让我们快速回顾一下我们学到了什么。我们学习了 TDD,TDD 如何在 JavaScript 环境中运行,以及可用的测试工具、技术和框架。我们了解了 Karma 和 Protractor 在真实的 Angular 项目中的使用。现在我们知道如何为 Angular 项目编写单元测试和端到端测试。

这本书向你展示了实践 TDD 的路径;现在轮到你继续学习,提高这方面的知识,并在复杂项目中进行更多实践,以便更加自信地应用 TDD。

posted @ 2024-05-18 12:02  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报