В конце 2014 года мы представили новый продукт в линейке офисных контролов – ASPxRichEdit. Нашей целью было создание мощного инструмента для работы с документами онлайн. Реализация требований, предъявляемых пользователями к текстовому редактору – поддержка стилей форматирования текста и параграфов, загрузка и сохранение документов популярных форматов без потери содержимого, настройка печати – все это подразумевает интенсивное взаимодействие между клиентом и сервером.
В этой статье я расскажу о подходах к тестированию этого взаимодействия, которые мы применяли в процессе разработки.
Используемый инструментарий
При разработке архитектуры любого серьезного проекта, независимо от выбранной платформы и используемых инструментов, есть очень важный момент – вся читаемость, портируемость и структурность кода не имеют никакого значения, если этот код нельзя покрыть тестами. Тесты должны писаться и запускаться легко и быстро, используя лишь минимум необходимого кода. В этом случае разработчики будут писать код и сразу же покрывать его тестами, или, руководствуясь мантрой «красный/зеленый/рефакторинг», писать тесты, а затем реализовывать новую функциональность. Но если для того, чтобы написать тесты, нужны сакральные знания, доступные только архитектору проекта – код тестами покрыт не будет.
Выбор инструментов для независимого тестирования серверного и клиентского кода не составил для нас сложности – мы остановились на использовании NUnit в качестве серверного тестового фреймворка и Jasmine для тестирование клиентского кода. В качестве раннера для клиентских тестов мы использовали ставший уже практически стандартом Chutzpah.
Модель клиент-серверного взаимодействия
Однако в случае ASPxRichEdit было важно покрыть тестами не только сам процесс отправки и обработки реквестов, но и синхронизацию клиентского и серверного состояния. Основная задача интеграционного тестирования в этом случае состояла в том, чтобы убедиться, что любое состояние серверной модели правильно интерпретируется на клиенте. В свою очередь, любое изменение документа на клиенте должно правильно отправиться на сервер и привести к соответсвующим изменениям серверной модели.
В нашем случае клиентская модель во многом повторяет серверную – десктопная версия рич-эдитора развивается в DevExpress уже больше восьми лет, поэтому для серверной части было принято решение обойтись без изобретения велосипеда, сопровождающегося увлекательными прогулками по граблям, а наличие «зеркальной» модели на клиенте упрощает синхронизацию. На мой взгляд, в этом подходе нет какой-то особой специфики, наверняка такую же ситуацию можно наблюдать во многих приложениях, основанных на «старом» серверном коде. В этом случае для обеспечения взаимодействия необходим код, который способен конвертировать JSON на основе серверной модели и изменять эту модель на основе JSON, пришедшего с клиента, и код, решающий такие же задачи на клиенте. Проще всего такой код сделать автогенерируемым, с чем отлично справляется студийный механизм темплейтов T4 Text Templates.
Использование PhantomJS для интеграционных тестов
Таким образом, нам необходимо протестировать, как клиентские реквесты интерпретируются сервером, и как клиент реагирует на пришедший с сервера ответ. Серверная часть теста пишется с исользованием уже упомянутого NUnit, а для запуска клиентской части мы решили использовать PhantomJS. Последний представляет собой основанный на WebKit полноценный браузер (JavaScript, CSS, DOM, SVG, Canvas), без элементов UI, но достаточно быстрый и легкий. Такая связка позволяет нам протестировать инициализацию клиента на основе серверной модели, применение изменеий клиента на сервере, а изменений серверной модели – на клиенте, а также возможные коллизии при синхронизации состояний.
В общем случае, тест представляет собой достаточно простой цикл. Сначала создается и настраивается серверная модель, затем рабочая сессия формирует стартовый JSON для инициализации клиента (в случае реальных документов модель разбивается на части и при первой загрузке передается только первый фрагмент, а остальная часть догружается асинхронно – пока сервер будет возвращать последующие части, клиент уже займется просчетом имеющейся части; в тестах же документы небольшие, поэтому инициализационный JSON содержит полную модель). Далее серверный код запускает PhantomJS с нашими библиотеками и стартовым скриптом. Скрипт создает экземпляр клиентского контрола и инициализирует его сфомированным на сервере JSON-объектом. Дальнейшая логика варьируется в зависимости от назначения теста.
Если мы тестируем инициализацию модели, то полученная модель сразу сериализуется обратно в JSON и выводится в консоль, а серверный код анализирует содержимое консоли и проверяет правильность создания клиентской модели. Если же мы тестируем создание JSON-объектов на клиенте и их интерпретацию на сервере, то в этом случае на клиенте выполняются необходимые операции, а все реквесты вместо отправки на сервер снова пишутся в консоль. Далее серверный код считывает содержимое буфера, изменяет модель и проверяет, насколько корректно обработались входящие команды.
Описанный алгоритм можно проиллюстрировать конкретным примером интеграционного теста:
[Test]
public void TestParagraphProperties() {
ChangeDocumentModel(); // предварительная настройка серверной модели
string[] clientResults = RunClientSide(
GetClientModelStateAction(), // записать Model в Output
ExecuteClientCommandAction() // выполнить клиентскую команду, записать реквест в Output
);
// clientResults[0] – JSON с клиентским состоянием модели (инициализация)
// clientResults[1] – JSON с реквестом на изменение серверной модели
AssertClientModelState(clientResults[0]);
ApplyClientRequestToServerModel(clientResults[1]);
AssertServerModelState(DocumentModel);
}
Как видите, код получившегося теста достаточно прост. После настройки серверной модели, мы запускаем PhantomJS. При этом функция RunClientSide() принимает массив действий, которые необходимо выполнить на клиенте (например, выполнение команд, изменяющих модель, получение сериализованного состояния клиентской модели). Результат выполнения каждого действия будет сохраняться в выходной массив, например:
function getClientModelState() {
var model = control.getModel();
buffer.push(JSON.stringify(model));
}
Далее получившийся массив сериализуется в JSON и записывается в console.log (т.е. output приложения):
function tearDownTest() {
console.log(JSON.stringify(buffer));
}
Код реализации раннера тестов:
string StartPhantomJSNoDebug(string phantomPath, string bootFile, out int exitCode) {
StringBuilder outputSb = new StringBuilder();
StringBuilder errorsSb = new StringBuilder();
exitCode = -1;
using (var p = new Process()) {
var arguments = Path.Combine(TestDirectory, bootFile);
//...
//set up process properties
p.OutputDataReceived += (s, e) => outputSb.AppendLine(e.Data);
p.ErrorDataReceived += (s, e) => errorsSb.AppendLine(e.Data);
p.Start();
p.BeginOutputReadLine();
p.BeginErrorReadLine();
if (!p.WaitForExit(15000)) {
p.Kill();
p.WaitForExit();
Assert.Fail("The PhantomJS process was killed after timeout. Output: rn" + outputSb.ToString());
}
else
p.WaitForExit();
exitCode = p.ExitCode;
}
if (!string.IsNullOrWhiteSpace(errorsSb.ToString()))
Assert.Fail("PhantomJS errors output: rn" + errorsSb.ToString());
return outputSb.ToString();
}
Если же потребуется посмотреть под отладчиком, что происходит в тестах, то раннер будет таким:
string StartPhantomJSWithDebug(string phantomPath, string bootFile, out int exitCode) {
StringBuilder outputSb = new StringBuilder();
StringBuilder errorsSb = new StringBuilder();
exitCode = -1;
using (var p = new Process()) {
var arguments = Path.Combine(TestDirectory, bootFile);
arguments = "--remote-debugger-port=9001 " + arguments;
//...
//set up process properties
p.Start();
Thread.Sleep(500);
try {
Process.Start(@"chrome.exe", @"http://localhost:9001/webkit/inspector/inspector.html?page=1");
}
catch { }
p.WaitForExit();
}
if (!string.IsNullOrWhiteSpace(errorsSb.ToString()))
Assert.Fail("PhantomJS errors output: rn" + errorsSb.ToString());
return outputSb.ToString();
}
Затем полученный JSON обрабатывается на сервере, после чего выполняется собственно тестирование – проверка состояния клиентской модели, применение JSON, полученного в результате выполнения клиентского кода и проверка состояния серверной модели.
Таким образом, с помощью PhantomJS нам удалось написать интеграционные тесты, позволяющие проверять стартовую инициализацию и последующую синхронизацию сложного клиент-серверного приложения.
Автор: VYudachev