七、测试所有的东西

自动化测试已经成为大多数现代软件开发过程的重要组成部分。敏捷方法和方法(如软件工艺)强调自动化测试的重要性,并经常提倡全面测试驱动开发(TDD)的实践。

一套好的自动化测试为项目增加了巨大的价值,因为它确保团队不会忽视任何破坏现有特性的代码更改。因此,测试可以建立信心。多亏了他们,开发人员才不怕改变东西,不怕玩弄创意,不怕重构,不怕改进代码。他们控制着自己的代码库。

无论您是否使用 TDD,您都可能希望在某种程度上自动测试您的 Aurelia 应用。这就是本章的内容。

为了使测试 Aurelia 项目更容易,Aurelia 团队选择了一组常用于测试 JavaScript 项目的库,JasmineKarma量角器,并将它们包含在项目框架和 CLI 项目生成器中,以及各自的配置和在项目中运行测试的任务。

单元测试

在下一节中,我们将探讨如何对 Aurelia 应用进行单元测试,主要是通过在联系人管理应用中添加单元测试。

如果您不熟悉 Jasmine,您应该将其文档放在手边,因为您可能希望在阅读本章时查找它:http://jasmine.github.io/2.0/introduction.html

运行单元测试

使用 CLI 创建的项目包括运行单元测试的任务。此任务在aurelia_project/tasks/test.js文件中定义,并使用位于项目根目录下的配置文件karma.conf.js启动 Karma。

可以通过在项目目录中打开控制台并运行以下命令来执行此任务:

> au test

此命令将启动单个测试运行,并在控制台中输出结果。

run任务类似,test任务可以通过watch开关进行修改,使其能够监视测试文件,并在每次检测到更改时重新运行:

> au test --watch

此命令将启动测试运行并监视测试文件,以便在每次更改后重新运行测试。

配置验证

如果你看了一眼aurelia-validation的代码,你可能已经注意到在ValidationRules类可以使用之前需要加载这个插件。这是因为ValidationRules公开的方法期望使用ValidationParser实例静态初始化该类,以便解析错误消息中的字符串插值。

由于我们的模型类ContactPhoneNumberAddress等依赖于其构造函数中的ValidationRules类,因此如果我们不首先初始化这些模型类,我们将无法在任何测试中使用它们。此外,我们的自定义验证规则在使用之前也必须加载。

因此,让我们添加一个安装文件,它将在每次测试运行开始时初始化验证:

test/unit/setup-validation.js

import {Container} from 'aurelia-dependency-injection'; 
import {BindingLanguage} from 'aurelia-templating'; 
import {TemplatingBindingLanguage}  
  from 'aurelia-templating-binding'; 
import {ValidationParser, ValidationRules}  
  from 'aurelia-validation'; 
import '../../src/validation/rules'; 

const container = new Container(); 
container.registerSingleton( 
  BindingLanguage, TemplatingBindingLanguage); 
const parser = container.invoke(ValidationParser); 
ValidationRules.initialize(parser); 

在这里,我们首先导入rules文件,以便正确注册自定义验证规则。

接下来,我们创建一个 DI 容器并初始化解析器所需的绑定语言实现,然后使用它创建一个ValidationParser实例,我们使用它初始化ValidationRules类。

最后,让我们将此文件添加到单元测试设置中:

test/aurelia-karma.js

//Omitted snippet... 
function requireTests() { 
  var TEST_REGEXP = /(spec)\.js$/i; 
  var allTestFiles = [ 
    '/base/test/unit/setup.js', 
    '/base/test/unit/setup-validation.js' 
  ]; 

  Object.keys(window.__karma__.files).forEach(function(file) { 
    if (TEST_REGEXP.test(file)) { 
      allTestFiles.push(file); 
    } 
  }); 

  require(allTestFiles, window.__karma__.start); 
} 
//Omitted snippet... 

在这里,我们只需在启动测试运行时使用requiresetup-validation.js文件添加到 Karma 加载的文件列表中。

配置蓝鸟警告

我们还要配置 Bluebird Promise 库的警告,这样我们的控制台就不会充斥着警告:

test/unit/setup.js

import 'aurelia-polyfills'; 
import {initialize} from 'aurelia-pal-browser'; 
initialize(); 

Promise.config({ 
  warnings: { 
    wForgottenReturn: false 
  } 
}); 

在这里,我们只需复制并粘贴位于src/main.js顶部的Promise配置。

此时,我们可以轻松地开始编写单元测试了。

test/unit/app.spec.js文件包含启动项目时 CLI 创建的app组件的样本测试。由于此组件自我们启动以来已完全更改,因此这些测试不再相关,将失败,因此您应该删除此文件。

按照惯例,包含单元测试的文件具有.spec.js扩展名。Aurelia 项目中的默认 Karma 配置期望测试按照此命名约定放置在文件中,因此我们将在联系人管理应用中遵循它。

单元测试模型

我们将从测试模型类开始。它们包含几个关键特性,我们希望确保这些特性正常工作。

但是,我们首先要通过打开控制台并运行构建来确保捆绑包是最新的:

> au build

然后,为了使编写测试的过程更加容易,让我们首先启动一个控制台并启动一个连续的测试过程:

> au test -watch

任务应开始运行,并应显示如下内容:

Chrome 53.0.2785 (Windows 10 0.0.0): Executed 0 of 0 ERROR (0.015 secs / 0 secs)

测试运行返回错误只是因为它找不到任何要运行的测试。让我们改变这一点。

工厂静态测试方法

我们将编写的第一个测试将确保使用空对象调用fromObject方法创建空PhoneNumber对象:

test/unit/contacts/models/phone-number.spec.js

import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 

describe('the PhoneNumber class', () => { 
  it('should create empty PhoneNumber when creating from empty object',  
  () => { 
    const result = PhoneNumber.fromObject({}); 
    expect(result).toEqual(new PhoneNumber()); 
  }); 
}); 

在这里,我们定义了一个使用空对象调用fromObject静态方法的测试用例,然后确保结果等于一个空PhoneNumber对象。

如果保存文件并查看控制台,您应该会看到一条类似以下内容的消息:

Chrome 53.0.2785 (Windows 10 0.0.0): Executed 1 of 1 SUCCESS (0.016 secs / 0.008 secs)

让我们编写另一个测试,它将测试fromObject的另一个角度。它将确保标量属性正确复制到新的PhoneNumber对象:

test/unit/contacts/models/phone-number.spec.js

import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 

describe('the PhoneNumber class', () => { 
  //Omitted snippet... 

  it('should map all properties when creating from object', () => { 
    const src = { 
      type: 'Mobile', 
      number: '1234567890' 
    }; 
    const result = PhoneNumber.fromObject(src);
for (let property in src) { 
      expect(result[property]).toEqual(src[property]); 
    } 
  }); 
}); 

在这里,我们的新测试使用具有预期标量属性的对象调用fromObject静态方法:typenumber。然后,我们确保每个属性都已正确复制到生成的PhoneNumber对象。

还应为EmailAddressAddressSocialProfile类添加此类测试,每个类都在自己的文件中:email-address.spec.jsaddress.spec.jssocial-profile.spec.js,遵循相同的模式。我将把这个作为练习留给读者。本章的示例应用可作为参考。

既然已经测试了列表项类,那么让我们为Contact类编写测试。我们将从之前编写的相同类型的测试开始:

test/unit/contacts/models/contact.spec.js

import {Contact} from '../../../../src/contacts/models/contact'; 

describe('the Contact class', () => { 

  it('should create empty Contact when creating from empty object', () => { 
    const result = Contact.fromObject({}); 
    expect(result).toEqual(new Contact()); 
  }); 

  it('should map all properties when creating from object', () => { 
    const src = { 
      firstName: 'Never gonna give you up', 
      lastName: 'Never gonna let you down', 
      company: 'Never gonna run around and desert you', 
      birthDay: '1987-11-16', 
      note: 'Looks like you've been rickrolled' 
    }; 
    const result = Contact.fromObject(src); 

    for (let property in src) { 
      expect(result[property]).toEqual(src[property]); 
    } 
  }); 
}); 

然而,Contact类的fromObject方法不仅仅是复制属性,它还将列表项映射到它们各自的模型类。让我们添加测试以确保其正常工作:

test/unit/contacts/models/contact.spec.js

import {Contact} from '../../../../src/contacts/models/contact'; 
import {Address} from '../../../../src/contacts/models/address'; 
import {EmailAddress} from '../../../../src/contacts/models/email-address'; 
import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 
import {SocialProfile} from '../../../../src/contacts/models/social-profile'; 

describe('the Contact class', () => { 
  //Omitted snippet... 

  it ('should map phone numbers when creating from object', () => { 
    const result = Contact.fromObject({ phoneNumbers: [{}, {}] }); 
    const expected = [new PhoneNumber(), new PhoneNumber()]; 

    expect(result.phoneNumbers).toEqual(expected); 
  }); 

  it ('should map email addresses when creating from object', () => { 
    const result = Contact.fromObject({ emailAddresses: [{}, {}] }); 
    const expected = [new EmailAddress(), new EmailAddress()]; 

    expect(result.emailAddresses).toEqual(expected); 
  }); 

  it ('should map addresses when creating from object', () => { 
    const result = Contact.fromObject({ addresses: [{}, {}] }); 
    const expected = [new Address(), new Address()]; 

    expect(result.addresses).toEqual(expected); 
  });
it ('should map social profiles when creating from object', () => { 
    const result = Contact.fromObject({ socialProfiles: [{}, {}] }); 
    const expected = [new SocialProfile(), new SocialProfile()];
expect(result.socialProfiles).toEqual(expected); 
  }); 
}); 

这里,我们为列表项类添加import语句。然后我们添加四个测试用例,每个列表项类一个,确保在每个用例中一个对象数组正确映射到相应的类。

测试计算属性

当涉及到单元测试时,计算属性与函数没有什么不同。让我们编写一些测试来覆盖Contact类的isPerson属性:

test/unit/contacts/models/contact.spec.js

//Omitted snippet... 
it('should be a person if it has a firstName and no lastName', () => { 
  const sut = Contact.fromObject({ firstName: 'A first name' }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should be a person if it has a lastName and no firstName', () => { 
  const sut = Contact.fromObject({ lastName: 'A last name' }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should be a person if it has a firstName and a lastName', () => { 
  const sut = Contact.fromObject({  
    firstName: 'A first name', 
    lastName: 'A last name' 
  }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should not be a person if it has no firstName and no lastName', () => { 
  const sut = Contact.fromObject({ company: 'A company' }); 
  expect(sut.isPerson).toBeFalsy(); 
}); 
//Omitted snippet... 

在这里,我们添加了四个测试用例,以确保isPerson属性的行为正确。

存储将应用测试的实例的变量名为sut,代表被测系统。它被许多作者认为是自动化测试中的标准术语。我喜欢使用这个首字母缩略词,因为它清楚地标识了测试对象。

我将把它作为一个练习留给读者来编写fullNamefirstLetter属性的测试用例。本章的示例应用可作为参考。

单元测试服务

测试服务也非常简单。在我们的联系人管理应用中,我们有一个单一的服务:ContactGateway。然而,目前它的测试不是很友好,主要是因为它的构造函数配置了HttpClient实例。

正在从网关构造函数中删除配置

让我们重构网关,使其更易于测试。我们将HttpClient配置移动到功能的configure功能,因此ContactGateway的构造函数不包含任何配置逻辑:

src/contacts/index.js

import {Router} from 'aurelia-router'; 
import {HttpClient} from 'aurelia-fetch-client'; 
import {ContactGateway} from './services/gateway'; 
import environment from 'environment'; 

export function configure(config) { 
  const router = config.container.get(Router); 
  router.addRoute({ route: 'contacts', name: 'contacts',  
    moduleId: 'contacts/main', nav: true, title: 'Contacts' }); 

  const httpClient = config.container.invoke(HttpClient) 
    .configure(config => { config 
      .useStandardConfiguration() 
      .withBaseUrl(environment.contactsUrl); 
    }); 
  config.container.registerInstance(ContactGateway,  
    new ContactGateway(httpClient)); 
} 

在这里,我们使用 DI 容器创建一个HttpClient实例并对其进行配置,然后创建一个ContactGateway实例,我们在 DI 容器中注册它。您可能会注意到,我们没有在容器中注册HttpClient本身。在大多数应用中,这样做完全可以。然而,由于我们希望我们的功能尽可能独立,而其他功能可能使用不同的HttpClient实例来调用不同的后端,因此我们不注册此功能,因为它可能会干扰其他功能。

接下来,我们可以从ContactGateway的构造函数中删除配置代码:

src/contacts/services/gateway.js

import {inject} from 'aurelia-framework'; 
import {HttpClient, json} from 'aurelia-fetch-client'; 
import {Contact} from '../models/contact'; 

@inject(HttpClient) 
export class ContactGateway { 

  constructor(httpClient) { 
    this.httpClient = httpClient; 
  } 

  //Omitted snippet... 
} 

ContactGateway的构造函数现在没有任何配置逻辑。

由于我们更改了应用中的代码,因此需要在添加测试之前重新生成代码:

> au build

测试读取方法

让我们首先为ContactGateway的两种读取方法编写两个测试:

test/unit/contacts/services/gateway.spec.js

import {ContactGateway}  
  from '../../../../src/contacts/services/gateway';  
import {Contact} from '../../../../src/contacts/models/contact'; 

describe('the ContactGateway class', () => { 

  let httpClient, sut; 

  beforeEach(() => { 
    httpClient = jasmine.createSpyObj('HttpClient', ['fetch']); 
    sut = new ContactGateway(httpClient); 
  }); 

  function createContact() { 
    return Contact.fromObject({ id: 1, company: 'Blue Spire' }); 
  } 

  function createJsonResponseMock(content) { 
    return { json: () => Promise.resolve(content) }; 
  } 

  it('should fetch all contacts', done => { 
    const contacts = [createContact()]; 
    httpClient.fetch.and.returnValue(Promise.resolve( 
      createJsonResponseMock(contacts))); 

    sut.getAll() 
      .then(result => expect(result).toEqual(contacts)) 
      .then(() => expect(httpClient.fetch) 
        .toHaveBeenCalledWith('contacts')) 
      .then(done); 
  }); 

  it('should fetch a contact by its id', done => { 
    const contact = createContact(); 
    httpClient.fetch.and.returnValue(Promise.resolve( 
      createJsonResponseMock(contact))); 

    sut.getById(contact.id) 
      .then(result => expect(result).toEqual(contact)) 
      .then(() => expect(httpClient.fetch) 
        .toHaveBeenCalledWith(`contacts/${contact.id}`)) 
      .then(done); 
  }); 
}); 

在这里,我们首先使用 Jasmine 的beforeEach函数定义一个测试设置。此测试设置将在每个测试用例之前执行。在这个设置中,我们首先为HttpClient创建一个模拟,然后创建将应用测试的ContactGateway实例。

接下来,我们定义两个 helper 函数:第一个用于创建Contact对象,第二个用于为带有 JSON 主体的响应对象创建模拟。我们的测试用例将使用这两个函数。

最后,我们编写测试用例来验证getAllgetById方法是否正常工作。这两个测试用例是异步测试,因此它们要求将一个done函数作为参数传递,当测试完成时,它们将调用该函数。它们都遵循相同的模式:

  1. 创建被测试方法应该返回的Contact对象。
  2. 配置HttpClient的模拟fetch方法,因此它返回一个Promise解析到模拟响应对象,该解析将数据公开为 JSON 主体返回。
  3. 调用被测试的方法,并在其解析时执行以下操作:

  4. 检查返回的Promise是否解析为预期数据

  5. 检查是否使用正确的参数调用了HttpClientfetch方法

测试写入方法

测试写方法非常相似。然而,它需要更多的工作,因为 HTML5 文件 API 目前没有提供比较Blob对象的简单方法。因此,为了测试网关发送的请求主体,我们需要编写一些助手函数:

test/unit/contacts/services/gateway.spec.js

//Omitted snippet... 

function readBlob(blob) { 
  return new Promise(resolve => { 
    let reader = new FileReader(); 
    reader.addEventListener("loadend", () => {  
      resolve(reader.result); 
    }); 
    reader.readAsText(blob); 
  }); 
} 

function expectBlobsToBeEqual(result, expected) { 
  expect(result.type).toEqual(expected.type); 
  expect(result.size).toEqual(expected.size); 

  return Promise 
    .all([ readBlob(result), readBlob(expected) ]) 
    .then(([c1, c2]) => expect(c1).toEqual(c2)); 
} 

function expectFetchToHaveBeenCalled(expectedPath,  
                                     expectedProperties) { 
  let expectedBody; 
  if (expectedProperties.body) { 
    expectedBody = expectedProperties.body; 
    delete expectedProperties.body; 
  } 

  expect(httpClient.fetch).toHaveBeenCalledWith(expectedPath,    
    jasmine.objectContaining(expectedProperties)); 
  if (expectedBody) { 
    return expectBlobsToBeEqual( 
      httpClient.fetch.calls.mostRecent().args[1].body,  
      expectedBody); 
  } 
} 
//Omitted snippet... 

第一个 helper 函数名为readBlob,它简单地将Blob对象作为其参数,并返回一个Promise,该函数将Blob的内容解析为字符串。因为读取Blob内容的过程是异步的,所以它只是将此过程封装在Promise中。

第二个助手函数名为expectBlobsToBeEqual,它需要两个Blob对象作为参数。它首先比较它们的typesize属性以确保它们相等,然后使用readBlob检索两个Blob对象的内容并比较结果以确保它们也相等,返回结果Promise

最后一个名为expectFetchToHaveBeenCalled的助手函数接收预期路径和预期请求属性。它首先从预期的请求属性(如果有)中提取预期的主体,然后将其从对象中删除。然后,它确保使用预期的路径和预期的请求属性减去主体调用了HttpClient的模拟fetch方法,因为比较Blob对象是一个异步过程,必须单独执行。最后,如果提供了预期的主体,它将使用传递到最后一次调用fetch的主体和预期主体调用expectBlobsToBeEqual函数,并返回结果Promise

最后一个 helper 函数将帮助我们编写关于网关如何调用其HttpClientfetch方法的断言。让我们从测试create方法开始:

test/unit/contacts/services/gateway.spec.js

import {json} from 'aurelia-fetch-client'; 
//Omitted snippet... 

it('should create a contact', done => { 
  const contact = createContact(); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  sut.create(contact) 
    .then(() => expectFetchToHaveBeenCalled( 
      'contacts',  
      { method: 'POST', body: json(contact) })) 
    .then(done); 
}); 
//Omitted snippet... 

在这里,我们首先从 Fetch 客户机导入json函数。我们将使用它来转换 JSON 编码的Blob对象中的预期请求负载。

测试本身非常简单,并为接下来的测试设置路径,这些测试将遵循相同的模式:

  1. 创建一个Contact对象,该对象将被传递给测试方法。
  2. 配置HttpClient的模拟fetch方法,使其返回已解析的Promise
  3. 调用被测试的方法,当它解析时,检查是否使用正确的参数调用了HttpClientfetch方法。

updateupdatePhoto方法的测试非常相似:

test/unit/contacts/services/gateway.spec.js

//Omitted snippet... 
it('should update a contact', done => { 
  const contact = createContact(); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  sut.update(contact.id, contact) 
    .then(() => expectFetchToHaveBeenCalled( 
      `contacts/${contact.id}`,  
      { method: 'PUT', body: json(contact) })) 
    .then(done); 
}); 

it("should update a contact's photo", done => { 
  const id = 9; 
  const contentType = 'image/png'; 
  const file = new File(['some binary content'], 'img.png', { 
    type: contentType 
  }); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  const expectedRequestProperties = { 
    method: 'PUT', 
    headers: { 'Content-Type': contentType }, 
    body: file 
  }; 
  sut.updatePhoto(id, file) 
    .then(() => expectFetchToHaveBeenCalled( 
      `contacts/${id}/photo`,  
      expectedRequestProperties)) 
    .then(done); 
}); 
//Omitted snippet... 

这两个测试遵循与前一个测试相同的模式。

单元测试值转换器

测试值转换器与测试服务没有太大区别。当然,这取决于需要测试的转换器的复杂性。在我们的联系人管理应用中,值转换器非常简单。

让我们为我们的orderBy值转换器编写一两个测试来了解它:

test/unit/resources/value-converters/order-by.spec.js

import {OrderByValueConverter}  
  from '../../../../src/resources/value-converters/order-by'; 

describe('the orderBy value converter', () => { 
  let sut; 

  beforeEach(() => { 
    sut = new OrderByValueConverter(); 
  }); 

  it('should sort values using property', () => { 
    const array = [ { v: 3 }, { v: 2 }, { v: 4 }, { v: 1 }, ]; 
    const expectedResult = [ { v: 1 }, { v: 2 },  
      { v: 3 }, { v: 4 }, ]; 

    const result = sut.toView(array, 'v'); 

    expect(result).toEqual(expectedResult); 
  }); 

  it('should sort values in reverse order when direction is "desc"', () => { 
    const array = [ { v: 3 }, { v: 2 }, { v: 4 }, { v: 1 }, ]; 
    const expectedResult = [ { v: 4 }, { v: 3 },  
      { v: 2 }, { v: 1 }, ]; 

    const result = sut.toView(array, 'v', 'desc'); 

    expect(result).toEqual(expectedResult); 
  }); 
}); 

在这里,我们首先定义一个创建测试主题的简单测试设置,然后添加两个测试用例。第一个方法验证传递给toView方法的数组是否使用指定的属性正确排序。第二个验证传递给toView方法的数组在"desc"作为第三个参数传递时是否按相反顺序排序。

当然,如果测试的值转换器支持双向绑定,并且有一个fromView方法,那么应该添加额外的测试用例来覆盖第二种方法。

我将把它作为练习留给读者,让他们为groupByfilterBy值转换器编写测试。本章的示例应用可作为参考。

单元测试自定义元素和属性

到目前为止,我们编写的所有测试都与 Aurelia 无关。我们测试的代码可以在一个完全不同的 UI 框架中使用,可能不需要任何更改。这是因为我们还没有测试任何视觉效果。

在测试自定义元素和属性时,我们可以满足于之前编写的测试类型,只测试它们的视图模型。测试将仅涵盖组件的行为方面。然而,拥有能够覆盖整个组件(包括它们的视图对应项)的测试将更加强大。

组件测试仪

幸运的是,Aurelia 提供了aurelia-testing库,可用于全面测试组件。因此,它出口两个重要类别:StageComponentComponentTester

StageComponent类有一个静态方法:

withResources(resources: string | string[]): ComponentTester 

这个方法只是在幕后创建一个ComponentTester类的实例,调用它自己的withResources方法,然后返回它。StageComponent基本上只是成分测试仪上的 API 糖。以下两条线路可以在没有任何影响的情况下切换:

var tester = StageComponent.withResources('some/resources') 
var tester = new ComponentTester().withResources('some/resources') 

ComponentTester类提供了一个 API,用于配置一个短期的沙盒 Aurelia 应用,在该应用中,测试组件将在测试期间存活:

  • withResources(resources: string | string[]): ComponentTester:将提供的资源作为全局资源加载到沙盒应用中。
  • inView(html: string): ComponentTester:使用提供的 HTML 作为沙盒应用的根视图。
  • boundTo(bindingContext: any): ComponentTester:使用提供的值作为沙盒应用根视图的绑定上下文。
  • manuallyHandleLifecycle(): ComponentTester:告知组件测试人员应用的生命周期应由测试用例手动处理。
  • bootstrap(configure: (aurelia: Aurelia) => void): void:使用提供的功能配置沙盒 Aurelia 应用。默认情况下,应用使用aurelia.use.standardConfiguration()进行配置。此方法可用于加载组件所需的其他插件或功能。
  • create(bootstrap: (aurelia: Aurelia) => Promise<void>): Promise<void>:使用提供的引导功能创建沙盒应用。这里最常用的是aurelia-bootstrapper库的bootstrap函数。当应用加载并启动时,返回的Promise被解析。
  • bind(): Promise<void>:绑定沙盒应用。它只能在手动处理应用生命周期时使用。
  • attached(): Promise<void>:将沙盒应用附加到 DOM。它只能在手动处理应用生命周期时使用。
  • detached(): Promise<void>:将沙盒应用与 DOM 分离。它只能在手动处理应用生命周期时使用。
  • unbind(): Promise<void>:解除沙盒应用的绑定。它只能在手动处理应用生命周期时使用。
  • dispose():处理沙盒应用的所有资源,并将其从 DOM 中完全移除。

在撰写本文时,aurelia-testing库仍处于测试阶段,因此在发布之前可能会添加一些新功能。

测试文件放置目标属性

让我们看看如何使用 component tester,为我们在第 5 章制作可重用组件中编写的file-drop-target自定义属性编写一个测试套件:

test/unit/resources/attributes/file-drop-target.spec.js

import {StageComponent} from 'aurelia-testing'; 
import {bootstrap} from 'aurelia-bootstrapper'; 

describe('the file-drop-target custom attribute', () => { 

  let viewModel, component, element; 

  beforeEach(() => { 
    viewModel = { files: null }; 
    component = StageComponent 
      .withResources('resources/attributes/file-drop-target') 
      .inView('<div file-drop-target.bind="files"></div>') 
      .boundTo(viewModel); 
  }); 

  function create() { 
    return component.create(bootstrap).then(() => { 
      element = document 
        .querySelector('[file-drop-target\\.bind]'); 
    }); 
  } 

  afterEach(() => { 
    component.dispose(); 
  }); 
}); 

在这里,我们首先创建一个空的测试套件,其中包含使用beforeEach函数的测试设置和使用afterEach函数的测试拆卸。在测试设置中,我们首先创建一个具有files属性的viewModel对象,它将绑定到我们的file-drop-target属性。其次,我们使用StageComponent类创建一个沙盒 Aurelia 应用,在每个测试期间,我们的自定义属性都将存在其中。

这个沙盒应用将把file-drop-target属性作为全局资源加载。它的根视图将是一个带有file-drop-target属性的div元素,绑定到根绑定上下文的files属性,即viewModel对象。

我们还定义了一个createhelper 函数,它将创建并启动沙盒应用,并在呈现应用后检索托管file-drop-target属性的element

最后,在测试拆卸中,我们只需dispose沙箱。

为了测试file-drop-target自定义属性,我们需要在托管测试属性的element上触发拖放事件。因此,我们先编写一个工厂函数来创建这样的事件:

test/unit/resources/attributes/file-drop-target.spec.js

import {DOM} from 'aurelia-pal'; 
//Omitted snippet...  
function createDragEvent(type, dataTransfer) { 
  const e = DOM.createCustomEvent(type, { bubbles: true }); 
  e.dataTransfer = dataTransfer; 
  return e; 
} 
//Omitted snippet... 

这个函数非常简单。它只是使用作为参数传递的事件的type创建一个Event对象。它还告诉事件它应该在触发时使 DOM 冒泡。最后,在返回之前,它为事件分配提供的dataTransfer对象。

我们将在一系列其他函数中使用此函数,这些函数将用于触发拖放过程的各个步骤:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
function dragOver() { 
  element.dispatchEvent(createDragEvent('dragover')); 
  return new Promise(setTimeout); 
} 

function drop(dataTransfer) { 
  element.dispatchEvent(createDragEvent('drop', dataTransfer)); 
  return new Promise(setTimeout); 
} 

function dragEnd(dataTransfer) { 
  element.dispatchEvent(createDragEvent('dragend', dataTransfer)); 
  return new Promise(setTimeout); 
} 
//Omitted snippet... 

这三个函数分别创建和分派特定的拖放事件。它们还返回一个Promise,一旦浏览器的事件队列被清空,就会出现解析。

更新绑定通常是一个异步过程,具体取决于绑定的类型。Aurelia 的绑定引擎严重依赖于浏览器的事件循环,以使更新绑定的过程尽可能平滑。

因此,返回一个Promise,其resolve函数使用setTimeout被推送到浏览器事件队列的末尾,这是测试中使用的一种技术,用于确保可能需要对更新的属性或调度的事件做出反应的绑定有时间更新自身。

最后,我们需要创建用于测试的File对象:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
function createFile() { 
  return new File( 
    ['some binary content'],  
    'test.txt',  
    { type: 'text/plain' }); 
} 
//Omitted snippet... 

现在我们有了编写第一个测试用例所需的所有工具:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
it('should assign dropped files to bounded instruction', done => { 
  const files = [createFile()]; 

  create() 
    .then(() => dragOver()) 
    .then(() => drop({ files })) 
    .then(() => expect(viewModel.files).toEqual(files)) 
    .then(done); 
}); 
//Omitted snippet... 

此测试确保,在拖放托管自定义属性的元素上的文件列表时,drop 事件中的文件被指定给绑定到该属性的属性。

该测试首先创建一个files列表并发送一个dragover事件,该事件本身没有用处,但只是为了遵循拖放操作的标准流程。接下来,它使用前面创建的files发送drop事件。最后,它确保将files正确分配给viewModelfiles属性。

最后,让我们添加另一个测试用例,以确保事件的数据被正确清除:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
it('should clear data when drag ends', done => { 
  const files = [createFile()]; 
  const clearData = jasmine.createSpy('clearData'); 

  create() 
    .then(() => dragOver()) 
    .then(() => drop({ files })) 
    .then(() => dragEnd({ clearData })) 
    .then(() => expect(clearData).toHaveBeenCalled()) 
    .then(done); 
  }); 
//Omitted snippet... 

如果您现在运行测试,它们应该都是绿色的。

测试列表编辑器元素

对自定义元素进行单元测试非常类似。让我们通过测试前面编写的list-editor自定义元素来了解它是如何工作的:

test/unit/resources/elements/list-editor.spec.js

import {StageComponent} from 'aurelia-testing'; 
import {bootstrap} from 'aurelia-bootstrapper'; 

describe('the list-editor custom element', () => { 

  let items, createItem, component, element; 

  beforeEach(() => { 
    items = []; 
    createItem = jasmine.createSpy('createItem'); 
    component = StageComponent 
      .withResources('resources/elements/list-editor') 
      .inView(`<list-editor items.bind="items"  
          add-item.call="createItem()"></list-editor>`) 
      .boundTo({ items, createItem }); 
  }); 

  function create() { 
    return component.create(bootstrap).then(() => { 
      element = document.querySelector('list-editor'); 
    }); 
  } 

  afterEach(() => { 
    component.dispose(); 
  }); 
}); 

在这里,我们首先创建一个带有测试设置的测试套件,该测试设置创建一个空的items数组,并模拟一个函数来创建新项。它还创建了一个组件测试器,该测试器将加载list-editor作为全局资源,在其根视图中使用list-editor元素,并将包含items数组和模拟createItem函数的对象定义为根绑定上下文,该对象将绑定到list-editor实例。

我们还定义了一个create函数,该函数将创建并引导沙盒应用,在每次测试期间,被测试元素将在其中生存。启动应用后,它还将检索list-editorDOM 元素。

最后,我们定义了一个测试拆卸,它将简单地dispose组件测试仪。

当然,我们需要对象作为项目使用。让我们创建一个可以在测试用例中使用的简单类:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
class Item { 
  constructor(text) { 
    this.text = text; 
  } 

  toString() { 
    return this.text; 
  } 
} 

这个简单的Item类在其构造函数中需要一个text值,并在转换为字符串时返回该text

在我们的测试中,我们需要检索由list-editor呈现的各种元素,以检查某些内容是否正确呈现或触发操作。因此,让我们在list-editor视图中添加一些 CSS 类。这些类将帮助我们选择特定的元素,而不依赖 HTML 结构本身,这将使测试变得脆弱,因为对 HTML 结构的任何更改都可能破坏它们。

src/resources/elements/list-editor.html

<template> 
  <div class="form-group le-item" repeat.for="item of items"> 
    <template with.bind="item"> 
      <template replaceable part="item"> 
        <div class="col-sm-2 col-sm-offset-1"> 
          <template replaceable part="label"></template> 
        </div> 
        <div class="col-sm-8"> 
          <template replaceable part="value">${$this}</template> 
        </div> 
        <div class="col-sm-1"> 
          <template replaceable part="remove-btn"> 
            <button type="button"  
                    class="btn btn-danger le-remove-btn"  
                    click.delegate="items.splice($index, 1)"> 
              <i class="fa fa-times"></i> 
            </button> 
          </template> 
        </div> 
      </template> 
    </template> 
  </div> 
  <div class="form-group" show.bind="addItem"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary le-add-btn"  
              click.delegate="addItem()"> 
        <slot name="add-button-content"> 
          <i class="fa fa-plus-square-o"></i> 
          <slot name="add-button-label">Add</slot> 
        </slot> 
      </button> 
    </div> 
  </div> 
</template> 

在这里,我们只需在每个元素上添加一个le-itemCSS 类,作为每个项的根。我们还为每个按钮添加了一个le-remove-btnCSS 类,允许我们从列表中删除项目。最后,我们向按钮添加一个le-add-btnCSS 类,允许我们向列表中添加一项。

le前缀表示列表编辑器。这不是试图写法国漫画。

正如我们之前所做的,我们必须重新构建应用,因此捆绑包是最新的,并在list-editor模板中包含新的 CSS 类:

> au build

让我们添加两个 helper 函数来检索元素、执行操作或断言测试元素中呈现的 DOM 的结果:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
describe('the list-editor custom element', () => { 
  //Omitted snippet... 

  function getItemsViews() { 
    return Array.from(element.querySelectorAll('.le-item'));   
  }
function clickRemoveButtonAt(index) { 
    const removeBtn = element 
      .querySelectorAll('.le-remove-btn')[index]; 
    removeBtn.click(); 
    return new Promise(setTimeout); 
  }
function clickAddButton() { 
    const addBtn = element.querySelector('.le-add-btn'); 
    addBtn.click(); 
    return new Promise(setTimeout); 
  }
function isItemRendered(item, itemsViews) { 
    return (itemsViews || getItemsViews()) 
      .some(iv => iv.textContent.includes(item.text)); 
  }
function areAllItemsRendered() { 
    const itemsViews = getItemsViews(); 
    return items.every(i => isItemRendered(i, itemsViews)); 
  } 
}); 

在这里,我们定义以下函数:

  • getItemsViews:检索元素(每个items的根)。
  • clickRemoveButtonAt:检索给定索引下项目的移除按钮,并在其上触发click事件。它返回一个Promise,当浏览器的事件队列被清空时,它将进行解析,以确保所有绑定都是最新的。
  • clickAddButton:检索添加按钮,并在其上触发click事件。它返回一个Promise,当浏览器的事件队列被清空时,它将进行解析,以确保所有绑定都是最新的。
  • isItemRendered:如果提供的项目已经在list-editor的 DOM 中呈现,则返回true,否则返回false
  • areAllItemsRendered:如果项目已经全部在list-editor的 DOM 中呈现,则返回true,否则返回false

此时,我们已经具备了编写测试所需的一切。

首先,让我们验证是否正确渲染了所有项目:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should render one form-group per item', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 

  create() 
    .then(() => expect(areAllItemsRendered()).toBe(true)) 
    .then(done); 
}); 
//Omitted snippet... 

接下来,让我们添加测试,以确保在单击项目的删除按钮时,该项目被删除:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should remove the item when the remove button is clicked', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 
  items.push(new Item('test item 3')); 

  const indexToRemove = 1; 
  const itemToRemove = items[indexToRemove]; 

  create() 
    .then(() => clickRemoveButtonAt(indexToRemove))  
    .then(() => expect(items.indexOf(itemToRemove)).toBe(-1)) 
    .then(() => expect(isItemRendered(itemToRemove)).toBe(false)) 
    .then(done); 
}); 
//Omitted snippet... 

最后,让我们添加一个测试用例,以确保点击添加按钮将创建一个新项目,并将其添加到列表中:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should add new item when the add item button is clicked', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 

  const indexOfItemToAdd = items.length; 
  const itemToAdd = new Item('test item 3'); 
  createItem.and.callFake(() => { items.push(itemToAdd); }); 

  create() 
    .then(() => clickAddButton()) 
    .then(() => expect(items.indexOf(itemToAdd)) 
      .toBe(indexOfItemToAdd)) 
    .then(() => expect(isItemRendered(itemToAdd)).toBe(true)) 
    .then(done); 
}); 
//Omitted snippet... 

此时,所有测试都应通过。

单元测试路由组件

在撰写本文时,无法使用ComponentTester测试路由组件。我们只能在单元测试中测试视图模型的行为,并依靠端到端测试来验证视图。然而,Aurelia 团队计划添加此功能;你应该去看看,以防你读这本书的时候它已经发行了。

单元测试此类组件的视图模型与我们已经编写的大多数测试没有太大区别,但让我们通过为 contact creation 组件编写测试套件来查看一个快速示例:

test/unit/contacts/components/creation.spec.js

 import {ValidationError}
  from 'aurelia-validation';
import {ContactCreation}
  from '../../../../src/contacts/components/creation';
import {Contact} from '../../../../src/contacts/models/contact';

describe('the contact creation component', () => {
  let gateway, validationController, router, sut;
  beforeEach(() => {
    gateway = jasmine.createSpyObj('ContactGateway', ['create']);
    validationController = jasmine.createSpyObj(
       'ValidationController', ['validate']);
    router = jasmine.createSpyObj('Router', ['navigateToRoute']);
    sut = new ContactCreation(gateway, validationController,
    router);
   });
});

在这里,我们首先创建一个带有测试设置的测试套件,该测试设置创建一组模拟,然后使用这些模拟创建 SUT。

我们还将添加一个帮助函数来创建验证错误:

test/unit/contacts/components/creation.spec.js

//Omitted snippet... 
function createValidationError() { 
  return new ValidationError({}, 'Invalid', sut.contact,  
    'firstName'); 
} 
//Omitted snippet... 

最后,让我们添加第一个测试用例,以确保在尝试保存无效联系人时不会发生任何事情,并添加第二个测试用例,以确保保存有效联系人的操作正确:

test/unit/contacts/components/creation.spec.js

//Omitted snippet... 
it('should do nothing when contact is invalid', done => { 
  const errors = [createValidationError()]; 
  validationController.validate.and 
    .returnValue(Promise.resolve(errors)); 

  sut.save() 
    .then(() => expect(gateway.create).not.toHaveBeenCalled()) 
    .then(() => expect(router.navigateToRoute) 
      .not.toHaveBeenCalled()) 
    .then(done); 
}); 

it('should create and navigate when contact is valid', done => { 
  validationController.validate.and 
    .returnValue(Promise.resolve([])); 
  gateway.create.and.returnValue(Promise.resolve()); 

  sut.save() 
    .then(() => expect(gateway.create) 
      .toHaveBeenCalledWith(sut.contact)) 
    .then(() => expect(router.navigateToRoute) 
      .toHaveBeenCalledWith('contacts')) 
    .then(done); 
}); 
//Omitted snippet... 

这为如何测试管线组件的视图模型提供了一个很好的思路。我将把它作为练习留给读者,让他们在contacts特性中为其他路由组件添加测试。本章的示例应用可作为参考。

端到端测试

单元测试的目的是单独验证代码单元,而端到端(E2E测试旨在验证整个应用。这些测试可以深入到不同的深度。它们的范围可能仅限于客户端应用本身。在这种情况下,应用使用的任何远程服务都需要以某种方式进行模拟。

它们还可以覆盖整个系统。大多数情况下,这意味着支持应用的服务必须部署到测试位置,并使用控制良好的测试数据进行初始化。

无论您的端到端测试策略是什么,技术都几乎保持不变。在本节中,我们将了解如何利用量角器为联系人管理应用编写功能测试场景。

摆设

在编写本文时,CLI 不包括量角器设置。既然我们是使用 CLI 启动项目的,那么让我们看看如何向应用添加对端到端测试的支持。

我们首先需要为 Gulp 安装protractor插件以及del库。在项目目录中打开控制台并运行以下命令:

> npm install gulp-protractor del --save-dev

接下来,我们需要存储一些关于端到端测试过程的配置值。让我们将这些添加到aurelia.json文件中:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  "unitTestRunner": { 
    "id": "karma", 
    "displayName": "Karma", 
    "source": "test\\unit\\**\\*.js" 
  }, 
 "e2eTestRunner": { 
    "id": "protractor", 
    "displayName": "Protractor", 
    "source": "test/e2e/src/**/*.js", 
    "output": "test/e2e/dist/", 
    "transpiler": { 
      "id": "babel", 
      "displayName": "Babel", 
      "options": { 
        "plugins": [ 
          "transform-es2015-modules-commonjs" 
        ] 
      } 
    } 
  }, 
  //Omitted snippet... 
} 

这个新的部分包含 E2E 任务将使用的路径和 transpiler 选项。

这个任务非常简单:它使用 Babel 传输测试套件,这样就可以在节点上运行,然后启动量角器。让我们首先编写任务描述符:

aurelia_project/tasks/e2e.json

{ 
  "name": "e2e", 
  "description":  
    "Runs all end-to-end tests and reports the results.", 
  "flags": [] 
} 

接下来,让我们编写任务本身:

aurelia_project/tasks/e2e.js

import gulp from 'gulp'; 
import del from 'del'; 
import {webdriver_update, protractor} from 'gulp-protractor'; 
import plumber from 'gulp-plumber'; 
import notify from 'gulp-notify'; 
import changedInPlace from 'gulp-changed-in-place'; 
import sourcemaps from 'gulp-sourcemaps'; 
import babel from 'gulp-babel'; 
import project from '../aurelia.json'; 
import {CLIOptions} from 'aurelia-cli'; 

function clean() { 
  return del(project.e2eTestRunner.output + '*'); 
} 

function build() { 
  return gulp.src(project.e2eTestRunner.source) 
    .pipe(plumber({ 
      errorHandler: notify.onError('Error: <%= error.message %>') 
    })) 
    .pipe(changedInPlace({firstPass:true})) 
    .pipe(sourcemaps.init()) 
    .pipe(babel(project.e2eTestRunner.transpiler.options)) 
    .pipe(gulp.dest(project.e2eTestRunner.output)); 
} 

function run() { 
  return gulp.src(project.e2eTestRunner.output + '**/*.js') 
    .pipe(protractor({ 
      configFile: 'protractor.conf.js', 
      args: ['--baseUrl', 'http://127.0.0.1:9000'] 
    })) 
    .on('end', () => { process.exit(); }) 
    .on('error', e => { throw e; }); 
} 

export default gulp.series( 
  webdriver_update, 
  clean, 
  build, 
  run 
); 

如果您不熟悉 Gulp,请让我快速解释此任务的作用:

  • 如果需要,它会更新 WebDriver
  • 它清理输出目录,即传输的测试套件所在的目录
  • 它在输出目录中传输测试套件
  • It launches Protractor.

    量角器主要是位于 Selenium 之上的 API,Selenium 是允许我们在浏览器中播放场景的实际引擎。WebDriver 是允许我们与 Selenium 通信的节点绑定。

您可能已经注意到,配置文件路径被传递给了量角器。让我们编写以下配置:

protractor.conf.js

exports.config = { 
  directConnect: true, 

  capabilities: { 
    'browserName': 'chrome' 
  }, 

  specs: ['test/e2e/dist/**/*.js'], 

  plugins: [{ 
    package: 'aurelia-tools/plugins/protractor' 
  }], 

  jasmineNodeOpts: { 
    showColors: true, 
    defaultTimeoutInterval: 30000 
  } 
}; 

在任何深度探索量角器都超出了本书的范围。然而,您可能可以从这个配置中了解到,它将使用 Google Chrome 来运行测试,它希望测试文件位于test/e2e/dist目录中,这是我们配置任务以传输测试套件的地方,并且插件是从aurelia-tools包加载的。aurelia-tools库已经包含在基于 CLI 的项目中,因此无需安装它。

最后一部分非常重要,因为该插件向量角器 API 添加了一些特定于 Aurelia 的方法。我们将在下一节中看到这些。

模仿后端

我们的联系人管理应用本身并不存在。它位于基于 HTTP 的 API 之上,该 API 允许应用访问数据并执行操作。因此,我们需要 API 的受控版本,实际上是一种模拟,它将包含一组预定义的数据,我们可以在每次测试之前将其重置为原始状态。

您可以从本书的工件中获取这个模拟 API。只需从示例中复制chapter-7\app\test\e2e\api-mock目录,并将其粘贴到您自己项目的test\e2e目录中即可。您可能需要首先创建e2e目录。

完成后,通过打开api-mock目录中的控制台并运行以下命令,确保恢复 API 模拟所需的所有依赖项:

> npm install

API 模拟现在可以运行了。

为了在每次测试之前重置数据集,我们需要一个助手函数:

test/e2e/src/contacts/api-mock.js

import http from 'http'; 

export function resetApi() { 
  const deferred = protractor.promise.defer(); 

  const request = http.request({ 
    protocol: 'http:', 
    host: '127.0.0.1', 
    port: 8000, 
    path: '/reset', 
    method: 'POST' 
  }, response => { 
    if (response.statusCode < 200 || response.statusCode >= 300) { 
      deferred.reject(response); 
    } else { 
      deferred.fulfill(); 
    } 
  }); 
  request.end(); 

  return deferred.promise; 
} 

若您不知道,量角器在节点上运行,而不是在浏览器中运行。因此,我们首先导入http节点模块。接下来,我们定义并导出一个resetApi函数,它只向 HTTP API 的/reset端点发送一个POST请求。它还返回一个Promise,在 HTTP 请求完成时解析。

此函数告诉后端将其数据集重置为原始状态。我们将在每次测试之前调用它,因此每次测试都可以确保使用相同的数据集,即使以前的测试创建了新联系人或更新了现有联系人。

页面对象模式

典型的 E2E 测试将加载给定的 URL,从文档中检索一个或多个 DOM 元素,对这些元素执行操作或调度事件,然后验证是否达到了预期的结果。

因此,选择元素并对其执行操作可能会快速膨胀测试代码。此外,在多个测试用例中选择一组给定的元素是相当常见的。必须在许多地方复制选择代码,这使得代码僵硬且难以更改。测试变得更加严格而不是自由。

为了使我们的测试更具自描述性,更易于更改,我们将使用页面对象模式。该模式描述了我们如何创建一个类来表示给定页面或组件的 UI,以便封装选择特定元素并对其执行操作的逻辑。

让我们通过为联系人列表组件创建这样一个类来说明这一点:

test/e2e/src/contacts/list.po.js

export class ContactsListPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 

  getAllContacts() { 
    return element.all(by.css('.cl-details-link')) 
      .map(link => link.getText()); 
  } 

  clickContactLink(index) { 
    const result = {}; 
    const link = element.all( 
      by.css(`.cl-details-link`)).get(index); 
    link.getText().then(fullName => { 
      result.fullName = fullName; 
    }); 
    link.click(); 
    return browser.waitForRouterComplete().then(() => result); 
  } 

  clickNewButton() { 
    element(by.css('.cl-create-btn')).click(); 
    return browser.waitForRouterComplete(); 
  } 

  setFilter(value) { 
    element(by.valueBind('filter & debounce')) 
      .clear().sendKeys(value); 
    return browser.sleep(200); 
  } 

  clickClearFilter() { 
    element(by.css('.cl-clear-filter-btn')).click(); 
    return browser.sleep(200); 
  } 
} 

这个类以一个getAllContacts方法开始。此方法使用量角器 API 选择具有cl-details-linkCSS 类的所有元素,然后将它们映射到它们的文本内容。此方法允许我们获取包含所有显示联系人全名的数组。

接下来,它公开了一个clickContactLink方法,该方法在cl-details-linkCSS 类提供的index处检索元素,然后获取其文本内容,将其指定为result对象上的fullName属性,然后单击该元素。然后,它使用 Aurelia 的量角器插件提供的一种扩展方法来等待路由器完成其导航循环,这将通过点击链接触发,并返回结果Promise,其结果会因result对象而改变。

如前所述,深入探索量角器超出了本书的范围。但是,如果您不熟悉它,那么理解量角器 API 中的所有方法都返回Promise是很重要的,但是通常不需要使用then来链接它们,因为量角器在内部对所有异步操作进行排队。

我强烈建议您在尝试编写广泛的 E2E 测试套件之前熟悉量角器的这一方面。

clickNewButton方法非常简单;它选择带有cl-create-btnCSS 类的元素并单击它,然后等待路由器完成其导航循环。

setFilter方法使用量角器的另一种 Aurelia 插件扩展方法来选择绑定到filter属性并用debounce绑定行为修饰的元素数据。然后,它清除其值并向其发送给定的按键序列,然后让浏览器休眠 200 毫秒。

最后,clickClearFilter方法选择具有cl-clear-filter-btnCSS 类的元素并单击它。然后,它使浏览器睡眠 200 毫秒。

在撰写本文时,需要在操作后发出一条sleep指令,以确保可能需要对该操作作出反应的所有绑定都已更新。

页面对象的目的是封装和抽象与视图的交互。由于处理组件 HTML 的所有代码都集中在一个类中,因此修改组件视图的影响将仅限于此类。此外,正如我们将在下一节中看到的,测试用例本身只需在视图上处理这个高级 API,而不必处理 HTML 结构本身的复杂性。大多数对量角器 API 的调用将隐藏在页面对象中。

您可能已经注意到,在前面的代码片段中,大多数选择器都使用新的 CSS 类来选择元素。让我们将其添加到联系人列表模板中:

src/contacts/components/list.html

<template> 
  <section class="container"> 
    <h1>Contacts</h1> 

    <div class="row"> 
      <div class="col-sm-1"> 
        <a route-href="route: contact-creation"  
           class="btn btn-primary cl-create-btn"> 
          <i class="fa fa-plus-square-o"></i> New 
        </a> 
      </div> 
      <div class="col-sm-2"> 
        <div class="input-group"> 
          <input type="text" class="form-control"  
                 placeholder="Filter"  
                 value.bind="filter & debounce"> 
          <span class="input-group-btn" if.bind="filter"> 
            <button class="btn btn-default cl-clear-filter-btn"  
                    type="button"  
                    click.delegate="filter = ''"> 
              <i class="fa fa-times"></i> 
              <span class="sr-only">Clear</span> 
            </button> 
          </span> 
        </div> 
      </div> 
    </div> 

    <group-list items.bind="contacts  
                  | filterBy:filter:'firstName':'lastName': 
                    'company'" 
                group-by="firstLetter" order-by="fullName"> 
      <template replace-part="item"> 
        <a route-href="route: contact-details;  
                       params.bind: { id: id }"  
           class="cl-details-link"> 
          <span if.bind="isPerson"> 
            ${firstName} <strong>${lastName}</strong> 
          </span> 
          <span if.bind="!isPerson"> 
            <strong>${company}</strong> 
          </span> 
        </a> 
      </template> 
    </group-list> 
  </section> 
</template> 

最后,在开始第一个测试用例之前,让我们快速添加测试中需要的另外两个页面对象:

test/e2e/src/contacts/creation.po.js

export class ContactCreationPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 
} 

test/e2e/src/contacts/details.po.js

export class ContactDetailsPO { 

  getFullName() { 
    return element(by.tagName('h1')).getText(); 
  } 
} 

第一个页面对象封装了联系人创建组件。它只是公开了一个getTitle方法,该方法选择h1元素并返回其文本内容。

第二个是联系人详细信息组件。它有一个getFullName方法,允许我们通过选择h1元素并返回其文本内容来检索显示的联系人全名。

编写第一个测试用例

现在我们需要的所有工具都准备好了,让我们为联系人列表组件编写第一个测试用例:

test/e2e/src/contacts/list.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 

describe('the contacts list page', () => { 

  let listPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 

    resetApi().then(() => { 
      browser 
        .loadAndWaitForAureliaPage('http://127.0.0.1:9000/') 
        .then(done); 
    }); 
  }); 

  it('should display the list of contacts', () => { 
    expect(listPo.getTitle()).toEqual('Contacts'); 
    listPo.getAllContacts().then(names => { 
      expect(names.length).toBeGreaterThan(0); 
    }); 
  }); 
}); 

这里,我们从一个测试设置开始,它创建联系人列表页面对象的一个实例,重置 API,然后使用 Aurelia 的量角器插件提供的另一个扩展方法加载给定的 URL,然后等待 Aurelia 应用完成引导。

接下来,我们定义第一个测试用例,它使用页面对象的方法来确保显示一些联系人。

尽管使用量角器运行的测试是异步的,但大多数情况下,没有必要使用 Jasmine 的done函数让框架知道测试用例何时完成,因为量角器修改了 Jasmine 的函数,以便使用其自己的内部任务队列来处理异步性。

此规则的例外情况是,当执行不由量角器处理的异步操作时,例如在beforeEach函数中,我们使用异步 HTTP 请求重置 API。

运行测试

在这一点上,我们已经准备好一切,并运行我们的 E2E 测试。为此,我们首先需要运行 API 模拟,方法是打开项目内test/e2e/api-mock目录中的控制台并执行以下命令:

> npm start

API 运行后,我们还必须启动应用本身,方法是打开项目目录中的控制台并运行以下命令:

> au run

这两个命令是必需的,因为 E2E 测试需要在浏览器中加载应用才能执行,并且需要在每次测试之前调用 API 重置其数据。当然,应用本身也需要 API 来请求数据和执行操作。

一旦 API 模拟和应用都在运行,我们可以通过打开项目目录中的第三个控制台并运行以下命令来启动 E2E 测试:

> au e2e

您将看到任务开始,在此过程中,将显示一个 Chrome 实例。在 Chrome 关闭和任务完成之前,您将看到应用负载和测试用例场景在眼前实时播放。完整输出应类似于此:

Running tests

当 WebDriver 需要首先更新自身时,e2e任务有时可能需要一些时间来启动。

测试联系人列表

现在我们知道一切正常,让我们为联系人列表组件添加一些测试:

test/e2e/src/contacts/list.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 
import {ContactDetailsPO} from './details.po.js'; 
import {ContactCreationPO} from './creation.po.js'; 

describe('the contacts list page', () => { 

  let listPo, detailsPo, creationPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 
    detailsPo = new ContactDetailsPO(); 
    creationPo = new ContactCreationPO(); 

    resetApi().then(() => { 
      browser 
        .loadAndWaitForAureliaPage('http://127.0.0.1:9000/') 
        .then(done); 
    }); 
  }); 

  it('should load the list of contacts', () => { 
    expect(listPo.getTitle()).toEqual('Contacts'); 
    listPo.getAllContacts().then(names => { 
      expect(names.length).toBeGreaterThan(0); 
    }); 
  }); 

  it('should display details when clicking a contact link', () => { 
    listPo.clickContactLink(0).then(clickedContact => { 
      expect(detailsPo.getFullName()) 
        .toEqual(clickedContact.fullName); 
    }); 
  }); 

  it('should display the creation form when clicking New', () => { 
    listPo.clickNewButton(); 

    expect(creationPo.getTitle()).toEqual('New contact'); 
  }); 

  it('should filter the list', () => { 
    const searched = 'Google'; 

    listPo.setFilter(searched); 

    listPo.getAllContacts().then(names => { 
      expect(names.every(n => n.includes(searched))).toBe(true); 
    }); 
  }); 

  it('should reset unfiltered list when clicking clear filter', () =>  
  { 
    let unfilteredNames; 
    listPo.getAllContacts().then(names => { 
      unfilteredNames = names; 
    }); 
    listPo.setFilter('Google'); 

    listPo.clickClearFilter(); 

    listPo.getAllContacts().then(names => { 
      expect(names).toEqual(unfilteredNames); 
    }); 
  }); 
}); 
  • 第一个新测试用例确保,当单击列表中的联系人条目时,应用导航到联系人的详细信息组件
  • 第二个确保,当点击新建按钮时,应用导航到联系人创建组件
  • 第三种方法确保在 filter 文本框中键入搜索词时,使用此搜索词过滤列表
  • 最后,第四个选项确保在搜索后清除过滤器文本框时,列表将恢复为未过滤状态

此测试套件现在涵盖了联系人列表组件的所有功能。如果此时运行 E2E 测试,您应该看到五个测试用例通过。

测试触点创建

让我们通过为 contact creation 组件添加一个测试套件来尝试将事情复杂化,该组件包括一个带有验证规则的复杂表单。

首先,我们将按照页面对象模式编写一个可重用类,它将封装联系人表单视图。这样,我们就可以使用这个类来测试联系人的创建,并最终测试联系人版本。

我们将从列表编辑器的基本页面对象开始。此类将封装有关如何访问 contact form 组件上的list-editor元素的一部分并对其执行操作的详细信息。

test/e2e/src/contacts/form.po.js

class ListEditorPO { 

  constructor(property) { 
    this.property = property; 
  }  

  _getContainer() { 
    return element(by.css( 
      `list-editor[items\\.bind=contact\\.${this.property}]`)); 
  } 

  _getItem(index) { 
    return this._getContainer() 
      .all(by.css(`.le-item`)) 
      .get(index); 
  }  

  _selectOption(index, name, value) { 
    this._getItem(index) 
      .element(by.valueBind(`${name} & validate`)) 
      .element(by.css(`option[value=${value}]`)) 
      .click(); 
    return browser.sleep(200); 
  } 

  _setText(index, name, value) { 
    this._getItem(index) 
      .element(by.valueBind(`${name} & validate`)) 
      .clear() 
      .sendKeys(value); 
    return browser.sleep(200); 
  } 

  clickRemove(index) { 
    this._getItem(index) 
      .element(by.css(`.le-remove-btn`)) 
      .click(); 
    return browser.sleep(200); 
  } 

  clickAdd() { 
    this._getContainer() 
      .element(by.css(`.le-add-btn`)) 
      .click(); 
    return browser.sleep(200); 
  } 
} 

在这里,我们首先定义一个名为ListEditorPO的基类。此类封装了与 contact 表单中单个list-editor元素的交互,并知道如何:

  1. 在绑定到列表中给定索引处给定属性的select中选择给定的option
  2. 将给定的键序列发送到列表中给定索引处绑定到给定属性的字段中。
  3. 点击列表中给定索引处的删除按钮。
  4. 点击添加按钮。

接下来,我们将通过编写四个专门的页面对象来扩展此类,联系人可以拥有的每种类型的项目都有一个页面对象:

test/e2e/src/contacts/form.po.js

//Omitted snippet... 

class PhoneNumberListEditorPO extends ListEditorPO { 

  constructor() { 
    super('phoneNumbers'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setNumber(index, value) { 
    return this._setText(index, 'number', value); 
  } 
} 

class EmailAddressListEditorPO extends ListEditorPO { 

  constructor() { 
    super('emailAddresses'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setAddress(index, value) { 
    return this._setText(index, 'address', value); 
  } 
} 

class AddressListEditorPO extends ListEditorPO { 

  constructor() { 
    super('addresses'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setNumber(index, value) { 
    return this._setText(index, 'number', value); 
  } 

  setStreet(index, value) { 
    return this._setText(index, 'street', value); 
  } 

  setPostalCode(index, value) { 
    return this._setText(index, 'postalCode', value); 
  } 

  setState(index, value) { 
    return this._setText(index, 'state', value); 
  } 

  setCountry(index, value) { 
    return this._setText(index, 'country', value); 
  } 
} 

class SocialProfileListEditorPO extends ListEditorPO { 

  constructor() { 
    super('socialProfiles'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setUsername(index, value) { 
    return this._setText(index, 'username', value); 
  } 
} 

在这里,我们定义了一组扩展基础ListEditorPO类的类:PhoneNumberListEditorPOEmailAddressListEditorPOAddressListEditorPOSocialProfileListEditorPO。他们都:

  • 指定基础list-editor元素绑定到的属性
  • 添加专门的方法来设置基础list-editor中每个项目的字段值,如电话号码为setTypesetNumber,地址为setStreetsetCity

最后,我们将为联系人表单本身编写一个页面对象:

test/e2e/src/contacts/form.po.js

//Omitted snippet... 

export class ContactFormPO { 

  constructor() { 
    this.phoneNumbers = new PhoneNumberListEditorPO(); 
    this.emailAddresses = new EmailAddressListEditorPO(); 
    this.addresses = new AddressListEditorPO(); 
    this.socialProfiles = new SocialProfileListEditorPO(); 
  } 

  _setText(name, value) { 
    element(by.valueBind(`contact.${name} & validate`)) 
      .clear() 
      .sendKeys(value); 
    return browser.sleep(200); 
  } 

  setFirstName(value) { 
    return this._setText('firstName', value); 
  } 

  setLastName(value) { 
    return this._setText('lastName', value); 
  } 

  setCompany(value) { 
    return this._setText('company', value); 
  } 

  setBirthday(value) { 
    return this._setText('birthday', value); 
  } 

  setNote(value) { 
    return this._setText('note', value); 
  } 

  getValidationErrors() { 
    return element.all(by.css('.validation-message')) 
      .map(x => x.getText()); 
  } 
} 

这里,我们导出一个名为ContactFormPO的类,它封装了与联系人表单视图的交互。它有一个扩展ListEditorPO的类的实例,因此测试可以与电话号码、电子邮件地址、地址和社交档案的各种list-editor元素交互。它还有一些方法允许我们设置名字、姓氏、公司、生日和便笺的值。最后,它有一个方法,允许我们检索表单上的所有验证错误消息。

在能够编写新的测试之前,我们需要将这个表单页面对象与联系人创建组件的页面对象连接起来。我们还将添加几个方法:

test/e2e/src/contacts/creation.po.js

import {ContactFormPO} from './form.po.js'; 

export class ContactCreationPO extends ContactFormPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 

  clickSave() { 
    element(by.buttonText('Save')).click(); 
    return browser.sleep(200); 
  } 

  clickCancel() { 
    element(by.linkText('Cancel')).click(); 
    return browser.sleep(200);
 } 
} 

在这里,我们首先让ContactCreationPO类继承ContactFormPO类,然后添加第一个方法点击保存按钮,另一个方法点击取消链接。

准备就绪后,为 contact creation 组件编写测试套件非常简单:

test/e2e/src/contacts/creation.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 
import {ContactCreationPO} from './creation.po.js'; 

describe('the contact creation page', () => { 

  let listPo, creationPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 
    creationPo = new ContactCreationPO(); 

    resetApi().then(() => { 
      browser.loadAndWaitForAureliaPage('http://127.0.0.1:9000/'); 
      listPo.clickNewButton().then(done); 
    }); 
     });   
}); 

在这个测试套件的设置中,我们首先为列表和创建组件创建页面对象。我们重置 API 的数据,然后加载应用,点击新建按钮导航到联系人创建组件。

现在,我们可以使用一些案例来验证 contact creation 组件的行为,从而丰富此测试套件:

it('should display errors when clicking save and form is invalid', () => { 
  creationPo.setBirthDay('this is absolutely not a date'); 
  creationPo.phoneNumbers.clickAdd(); 
  creationPo.emailAddresses.clickAdd(); 
  creationPo.addresses.clickAdd(); 
  creationPo.socialProfiles.clickAdd(); 

  creationPo.clickSave(); 

  expect(creationPo.getTitle()).toEqual('New contact'); 
  expect(creationPo.getValidationErrors()).toEqual([ 
    'Birthday must be a valid date.',  
    'Address is required.',      
    'Number is required.',  
    'Street is required.',  
    'Postal Code is required.',  
    'City is required.',  
    'Country is required.',  
    'Username is required.' 
  ]); 
}); 

it('should create contact when clicking save and form is valid', () => { 
  creationPo.setFirstName('Chuck'); 
  creationPo.setLastName('Norris'); 
  creationPo.setBirthDay('1940-03-10'); 

  creationPo.emailAddresses.clickAdd(); 
  creationPo.emailAddresses.setType(0, 'Office'); 
  creationPo.emailAddresses.setAddress(0,  
    'himself@chucknorris.com'); 

  creationPo.clickSave(); 

  expect(listPo.getTitle()).toEqual('Contacts'); 
  expect(listPo.getAllContacts()).toContain('Chuck Norris'); 
}); 

it('should not create contact when clicking cancel', () => { 
  creationPo.setFirstName('Steven'); 
  creationPo.setLastName('Seagal'); 

  creationPo.clickCancel(); 

  expect(listPo.getTitle()).toEqual('Contacts'); 
  expect(listPo.getAllContacts()).not.toContain('Steven Seagal'); 
}); 

这里,我们定义了三个测试用例。第一种方法确保,当表单处于无效状态且点击保存按钮时,不会出现导航并显示正确的验证消息。第二种方法确保,当表单处于有效状态且点击保存按钮时,应用导航回联系人列表组件。它还确保新联系人显示在列表中。第三个测试用例确保点击取消使应用导航回联系人列表组件。它还确保列表中不显示新联系人。

进一步测试

通过在我们的应用中添加其他特性的测试,本章可以持续更长的时间,但是编写额外的测试对 Aurelia 本身的学习体验没有什么价值。使用量角器对 Aurelia 应用进行端到端测试是一个值得一读的主题。然而,本节的目标是让您体验一下它并开始学习。希望是这样。

总结

能够使用单元测试在微观层面进行测试,并且能够使用端到端测试在宏观层面进行测试,这对于框架来说是非常有价值的。由于其模块化体系结构和面向组件的特性,Aurelia 使编写此类测试变得相当容易。

事实上,自动化测试是一个巨大的课题。这些书都是关于这个主题的,所以试图在一章中深入讨论它是徒劳的。但是,此时您应该具备为自己的 Aurelia 应用编写自动测试的最低知识。

在本书的这一点上,使用 Aurelia 构建单页应用所需的大多数主要工具都应该是现成的。你可能还没有完全掌握它们,但你知道它们是什么,它们的目的是什么。

然而,仍然有几个主题缺失,其中之一就是国际化。这就是我们将在下一章讨论的内容。