Статьи

Тестирование приложений AngularDart

Одна из замечательных особенностей Angular Framework — то, как он обеспечивает тестируемость ваших приложений. В этой статье я покажу различные подходы к тестированию сервисов, форматеров, декораторов и компонентов.

Заметки

AngularDart все еще в работе

AngularDart все еще находится в разработке и немного меняется. Я буду постоянно обновлять эту статью по мере выпуска новых версий фреймворка.

Знать лучший путь? Дай мне знать.

Если вы видите ошибку в примерах кода или у вас есть предложение по ее улучшению, пожалуйста, сообщите мне.

гиннес

Все примеры кода написаны с использованием библиотеки тестирования Guinness (вы можете найти вступление здесь ), но в примерах нет ничего, что было бы легко перевести unittest, или любой другой библиотеки тестирования.

DartMocks

Я использую библиотеку DartMocks для двойников теста (введение можно найти здесь ), но опять же, в примерах нет ничего специфичного для DartMocks.

Настройка Кармы

Я твердо верю в важность проведения ваших тестов непрерывно и безболезненно. Карма — это инструмент, который позволяет это. Я написал пошаговое руководство, объясняющее, как настроить его для проекта Dart, которое вы можете найти здесь .

Услуги тестирования

Предположим, у нас есть служба, которая отправляет запросы двум различным конечным точкам, а затем объединяет результаты.

@Injectable()
class SearchService {
  final Http http;
  SearchService(this.http);

  Future<List> search(String query) =>
      _merge([
        _request("api1/search?query=${query}"),
        _request("api2/search?query=${query}")
      ]);

  Future _request(url) => http.get(url).then((_) => _.data);

  Future _merge(futures) =>
      Future.wait(futures).then((res) => res.expand((_) => _));
}

Давайте посмотрим, как мы можем пойти о тестировании этого сервиса.

Без угловых

При создании приложений с Angular мы используем простые старые объекты Dart. И в результате нам не нужно использовать какие-либо вспомогательные средства для их тестирования.

Во-первых, нам нужно определить двойной тест для Httpсервиса.

@proxy class _Http extends TestDouble implements Http {}

Следующим шагом является определение поддельной реализации HttpResponse.

class _HttpResponse { var data; }

И заводская функция для ее создания.

fakeResponse(data) => new Future.value(new _HttpResponse()..data = data);

Определив все это, мы можем написать наш тест следующим образом:

describe("SearchService", () {
  it("should merge the results from two endpoints", () {
    final http = new _Http();

    http.stub("get").args("api1/search?query=abc")
        .andReturn(fakeResponse(["one", "two"]));

    http.stub("get").args("api2/search?query=abc")
        .andReturn(fakeResponse(["three"]));

    final searchService = new SearchService(http);

    return searchService.search("abc").then((res) {
      expect(res).toEqual(["one", "two", "three"]);
    });
  });
});

Это очень простой, простой тест. В этом нет ничего специфичного для Angular.

С угловой

Давайте перепишем тест, но на этот раз с некоторой помощью из фреймворка.

library my_lib_test;

import '../web/my_lib.dart';
import 'package:guinness/guinness.dart';
import 'package:unittest/unittest.dart' hide expect;
import 'package:angular/angular.dart';

import 'package:angular/mock/module.dart';    

describe("SearchService", () {
  beforeEach(setUpInjector);
  afterEach(tearDownInjector);

  beforeEach(module((Module m) => m..bind(SearchService)));

  it("should merge the results from two endpoints", 
    inject((MockHttpBackend http, SearchService searchService) {

    http.whenGET("api1/search?query=abc").respond('["one", "two"]');
    http.whenGET("api2/search?query=abc").respond('["three"]');

    final r = searchService.search("abc");

    scheduleMicrotask(() {
      http.flush();

      r.then(expectAsync((res) {
        expect(res).toEqual(["one", "two", "three"]);
      }));
    });
  }));
});

Давайте посмотрим поближе на то, что здесь происходит:

  • import 'package:angular/mock/module.dart;' импортирует все помощники угловых испытаний.
  • setUpInjectorсоздает экземпляр, Injectorкоторый используется для создания экземпляров объектов, необходимых в тесте.
  • beforeEach(module((Module m) => m..bind(SearchService))); регистрирует тестируемую службу с помощью инжектора.
  • inject используется для получения всех услуг, которые нам нужны в нашем тесте.
  • Обратите внимание, что нам не нужно было определять двойной тест для Http, а вместо этого использовать MockHttpBackendпредоставляемый Angular. Нам также не нужно было создавать экземпляр SearchService. Аналогично производственному коду, Angular заботится о создании всех сервисов и подключении всех зависимостей.

Рефакторинг

Тест выглядит немного громоздким из-за scheduleMicrotaskи http.flush. Если у вас есть куча тестов, опирающихся на Httpсервис, стоит создать помощника для улучшения их читабельности:

waitForHttp(future, callback) =>
    scheduleMicrotask(() {
      inject((MockHttpBackend http) => http.flush());
      future.then(callback);
    });

Учитывая этот помощник, мы можем переписать тест следующим образом:

it("should merge the results from two endpoints", 
  inject((MockHttpBackend http, SearchService searchService) {

  http.whenGET("api1/search?query=abc").respond('["one", "two"]');
  http.whenGET("api2/search?query=abc").respond('["three"]');

  waitForHttp(searchService.search("abc"), (res) {
    expect(res).toEqual(["one", "two", "three"]);
  });
}));

С или без угловых?

Поскольку существуют разные подходы к тестированию, здесь нет правильного или неправильного ответа. Лично я предпочитаю не использовать какие-либо фреймворки без необходимости, и Angular, вероятно, единственная клиентская среда, где это возможно.

Тестирование форматеров

Предположим, мы хотели бы протестировать следующий форматер:

@Formatter(name:'i18n')
class I18n {
  final Translations t;
  I18n(this.t);

  String call(String str, [Map opts]) =>
      t.hasTranslation(str) ? t.translate(str, opts) : "NO TRANSLATION";
}

Где Translationsопределяется следующим образом:

abstract class Translations {
  String translate(String str, Map opts);
  bool hasTranslation(String str);
}

Еще раз, давайте рассмотрим два способа его тестирования: с помощниками Angular и без них.

Без угловых

describe("i18n", () {
  it("should return the translated string if there is one", () {
    final t = new _Translations()
        ..stub("hasTranslation").andReturn(true)
        ..stub("translate").andReturn("after");

    final i18n = new I18n(t);

    expect(i18n("before")).toEqual("after");
  });

  it("should return a placeholder otherwise", () {
    final t = new _Translations()
        ..stub("hasTranslation").andReturn(false);

    final i18n = new I18n(t);

    expect(i18n("before")).toEqual("NO TRANSLATION");
  });
});

Там, где это похоже _Httpна _Translationsтест, двойной тест определяется следующим образом:

@proxy class _Translations extends TestDouble implements Translations {}

С угловой

describe("i18n", () {
  beforeEach(setUpInjector);
  afterEach(tearDownInjector);

  beforeEach(module((Module m) => m
      ..bind(Translations, toImplementation:_Translations)
      ..bind(I18n)));

  it("should return the translated string if there is one", 
    inject((Translations t, I18n i18n) {

    t.stub("hasTranslation").andReturn(true);
    t.stub("translate").andReturn("after");

    expect(i18n("before")).toEqual("after");
  }));

  it("should return a placeholder otherwise", 
    inject((Translations t, I18n i18n) {

    t.stub("hasTranslation").andReturn(false);

    expect(i18n("before")).toEqual("NO TRANSLATION");
  }));
});
  • Поскольку Translationsсервис не является стандартным Angular, фреймворк не предоставляет имитационную версию. Таким образом, мы все еще должны определить тест удвоить сами.
  • Мы не можем просто зарегистрировать _Translationsслужбу с помощью bind(_Translations), потому что в этом случае Angular не сможет создать экземпляр средства I18nформатирования.

Тестирование декораторов

Предположим, у нас есть декоратор, который отключает кнопку после первого нажатия.

@Decorator(selector: '[click-once]')
class ClickOnce {
  ClickOnce(Element el) {
    el.onClick.first.then((_) => el.disabled = true);
  }
}

Без угловых

В общем, я считаю, что декораторы, как правило, сильно зависят от фреймворка (например, от Scope), и, следовательно, я почти всегда тестирую их с помощью механизма тестирования Angular. Но так как ClickOnceэто так просто, это может быть сделано без каких-либо помощников.

describe("ClickOnce", () {
  it("should disable the element after the first click", () {
    final btn = new ButtonElement();
    new ClickOnce(btn);

    btn.click();

    expect(btn.disabled).toBeTrue();
  });
});

С угловой

describe("ClickOnce", () {
  beforeEach(setUpInjector);
  afterEach(tearDownInjector);

  beforeEach(module((Module m) => m..bind(ClickOnce)));

  it("should disable the element after the first click", 
    inject((TestBed tb) {

    final btn = tb.compile("<button click-once></button>");

    tb.triggerEvent(btn, "click");

    expect(btn.disabled).toBeTrue();
  }));
});

TestBedОбъект представлен угловой и используется для компиляции HTML и запуска событий.

Тестирование компонентов

Допустим, у нас есть компонент, форма, где мы можем редактировать адрес.

@Component(
    publishAs: 'form',
    templateUrl: "templates/address-form.html",
    selector: 'address-form',
    map: const {
      'address' : '=>address'
    })
class AddressForm {
  Address address;

  AddressForm(Scope scope) {
    scope.watch("form.address.country", (newValue, oldValue) {
      if (oldValue != null) address.city = "";
    });
  }
}

Где Addressопределяется следующим образом:

class Address {
  String country;
  String city;

  Address(this.country, this.city);
}

И address-form.htmlвыглядит так:

Address

<label>
  Country:
  <input type="text" ng-model="form.address.country" name="country">
</label>

<label>
  City:
  <input type="text" ng-model="form.address.city" name="city">
</label>

Мы используем, Scopeчтобы настроить часы, чтобы выключить cityкаждый раз, когда countryизменения.

Давайте посмотрим на первый тест.

describe("AddressForm", () {
  beforeEach(setUpInjector);
  afterEach(tearDownInjector);

  beforeEach(module((Module m) => m..bind(AddressForm)));
  beforeEach(loadTemplates(["address-form.html"]));

  it("should display the given address", 
    async(inject((TestBed tb, Scope scope) {

    scope.context["addr"] = new Address("Canada", "Toronto");
    final form = tb.compile("<address-form address='addr'/>", scope: scope);

    microLeap();
    tb.rootScope.apply();

    final root = form.shadowRoot;
    final country = root.querySelector("input[name=country]");
    final city = root.querySelector("input[name=city]");

    expect(country.value).toEqual("Canada");
    expect(city.value).toEqual("Toronto");
  })));
});

Позвольте мне провести вас через это:

  • Как и в предыдущих тестах , мы должны вызвать setUpInjector, tearDownInjectorи m..bind(AddressForm)создать тестовую среду.

  • При составлении формы адреса Angular попытается скачать соответствующий шаблон. Но поскольку Httpслужба отключена, она не сможет это сделать. Обычной практикой является предварительная загрузка всех необходимых шаблонов в кэш шаблонов перед выполнением тестов. Вот что loadTemplatesделает. Есть много других способов сделать то же самое в зависимости от ваших настроек. Так что эта конкретная реализация может не работать для вас.

loadTemplates(List<String> templates) {
  return () {
    updateCache(template, response) => inject((TemplateCache cache) => cache.put(template, response));

    final futures = templates.map((template) => HttpRequest.request('base/web/templates/$template', method: "GET").
      then((_) => updateCache("templates/$template", new HttpResponse(200, _.response))));

    return Future.wait(futures);
  };
}

  • asyncПомощник фиксирует все scheduleMicrotaskзвонки, так что они могут быть запущены по телефону microLeap. Эти помощники позволяют вам сделать ваш код последовательным, что уменьшает его вложенность.

  • Аналогично тесту декоратора tb.compileкомпилирует компонент и возвращает его корневой элемент.
  • tb.rootScope.apply(); запускает дайджест.
  • Наконец, поскольку компонент использует теневой DOM, мы должны получить доступ к его содержимому через его теневой корень.

Давайте напишем еще один тест, прежде чем исследовать некоторые рефакторинги, которые можно применить здесь.

it("should blank out city when country changes", 
  async(inject((TestBed tb, Scope scope) {

  scope.context["addr"] = new Address("Canada", "Toronto");
  final form = tb.compile("<address-form address='addr'/>", scope: scope);

  microLeap();
  tb.rootScope.apply();

  final root = form.shadowRoot;
  final country = root.querySelector("input[name=country]");
  final city = root.querySelector("input[name=city]");

  country.value = 'Australia';
  tb.triggerEvent(country, "change");

  expect(city.value).toEqual("");
})));

Рефакторинг

Это было довольно много кода для тестирования простого компонента. К счастью, большинство из них могут быть извлечены в общих помощников, которые могут быть использованы в вашем приложении.

Например, эти четыре строки:

beforeEach(setUpInjector);
afterEach(tearDownInjector);

beforeEach(module((Module m) => m..bind(AddressForm)));
beforeEach(loadTemplates(["address-form.html"]));

может быть извлечен в функцию:

setUpAngular({List templates, List injectables}) {
  beforeEach(setUpInjector);
  afterEach(tearDownInjector);

  beforeEach(module((Module m) => injectables.forEach(m.bind)));
  beforeEach(loadTemplates(templates));
}

И весь код, отвечающий за процесс компиляции, тоже можно извлечь:

compileComponent(String html, Map scopeData, callback){
  return async(inject((TestBed tb, Scope scope) {
    scopeData.forEach((k, v) => scope.context[k] = v);
    final el = tb.compile(html, scope: scope);

    microLeap();
    tb.rootScope.apply();

    callback(el.shadowRoot, tb);
  }));
}

Теперь, с этими двумя помощниками, тесты можно переписать следующим образом:

describe("AddressForm", () {
  setUpAngular(
      injectables: [AddressForm], 
      templates: ["address-form.html"]);

  it("should display the given address", compileComponent(
      "<address-form address='addr'/>", 
      {"addr" : new Address("Canada", "Toronto")}, (root, tb){

        final country = root.querySelector("input[name=country]");
        final city = root.querySelector("input[name=city]");

        expect(country.value).toEqual("Canada");
        expect(city.value).toEqual("Toronto");
      }
  ));

  it("should display the given address", compileComponent(
      "<address-form address='addr'/>", 
      {"addr" : new Address("Canada", "Toronto")}, (root, tb){

        final country = root.querySelector("input[name=country]");
        final city = root.querySelector("input[name=city]");

        country.value = 'Australia';
        tb.triggerEvent(country, "change");

        expect(city.value).toEqual("");
  }));
});

Подводя итоги

  • Одна из замечательных особенностей Angular заключается в том, что он не заставляет нас расширять что-либо, и мы можем просто использовать простые старые объекты Dart. Это позволяет тестировать их без помощи фреймворка.

  • Однако есть случаи, когда это становится проблематичным, особенно при тестировании декораторов и компонентов. Для того, чтобы помочь нам с этим Угловая обеспечивает такие функции , как setUpInjector, MockHttpBackend, TestBed, async, microLeap, injectи другие.

  • Эти встроенные помощники также можно использовать для определения высокоуровневых утилит, таких как setUpAngularили renderComponent.