.NET
July 2011 19

Написание автоматических тестов для тестирования пользовательского интерфейса десктопных приложений

В последние годы тема юнит-тестов, регрессивного тестирования, continuous integration, TDD, BDD, etc становится все популярней и все больше разработчиков начинают активно применять данные техники в своих проектах. При этом отдельным вопросом встает проблема автоматического тестирования пользовательского интерфейса в десктопных приложениях. В этой статье я постараюсь рассмотреть уже существующие решения, а так же привести вариант своего велосипеда написания тестов для UI на .net.

Постановка задачи


В начале нужно оговориться, что приведенные ниже методики рассчитаны на применение программистами, желающими автоматизировать процесс проверки созданного ими же UI, так что для команды QA, не владеющей хотя бы базовыми навыками программирования, боюсь, статья окажется мало полезной. Так же замечу, что тесты для UI ни в коем случае не могут считать юнит-тестами, не должны быть включены в цикл написания кода через TDD и желательно должны выполняться на отдельном сервере во время сборки билда(в идеале, конечно, после каждого коммита). Почему не локально? Потому что это будет очень медленно, начнет раздражать и через какое-то время разработчик просто забьет на их запуск.

Задача для примера у нас будет простая – есть приложение с двумя кнопками. По нажатию первой в текстом поле будет появляться определенный текст(пусть будет “Habrahabr”). По нажатию на вторую туда же будет выводиться текущее время и дата.

Соответственно, нужно минимум три теста для следующих кейсов:
  1. Изначальный текст в текстовом поле при старте приложения.
  2. Текст после нажатия на первую кнопку.
  3. Текст после нажатия на вторую кнопку.

Обзор существующих решений


1. .Net UI Automation


Сам фреймворк появился довольно давно, вместе с выходом WPF, однако должного освещения в блоггах не нашел. UI Automation представляет из себя библиотеку виртуализации дерева контролов произвольного Win32, Windows Forms или WPF приложения, с возможностью последующего доступа к свойствам этих контролов на чтение и запись. Так же есть поддержка эмуляции эвентов ввода. Если кто-то из читателей в свое время работал с библиотекой Microsoft Active Accessibility, то замечу, что UI Automation является практически прямой ее наследницей.
В этой библиотеке каждый контрол представляется в виде объекта типа AutomationElement, который предоставляет нам методы по генерации эвентов, получения свойств и поиска дочерних элементов. Самый первый объект AutomationElement для окна нашего приложения приложения можно получить, используя методы AutomationElement.FromHandle(process.MainWindowHandle), где process — ссылка на процесс тестируемого приложения, либо через десктоп:
AutomationElement.RootElement.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.NameProperty, "заголовок окна нашего приложения"));
* This source code was highlighted with Source Code Highlighter.

Для специфичных контролов, UI Automation предоставляет набор дополнительных оберток, называемых AutomationPatterns, например ExpandCollapsePattern, SelectionItemPattern и т.д., позволяющий, соответственно, использовать специфичный для этих контролов функционал, например возможность развернуть/свернуть экспандер.

Достоинства
  • Бесплатность.
  • Поддержка от майкрософт.
  • Является частью .net фреймворка.
  • Возможность тестирования Win32, Windows Forms и WPF приложений.
Недостатки
  • Необходимость задания пути к исполняемому файла либо завязка на заголовок окна — плохо, т.к. путь может поменяться, а заголовок быть не уникальным.
  • Ограниченный набор доступных свойств — по сути мы можем работать только с теми типами контролов и свойствами, которые майкрософт виртуализировал. Любая кастомизация(например использование сложных составных контролов) приведет к усложнению процесса написания тестов.
  • Так же возможным недостатком может оказаться то, что приложение запускается как сторонний процесс, поэтому, если вдруг возникнет необходимость изменить или подменить поведение какого-то объекта в вашей системе, то начнутся соответствующие танцы на ушах. «А и не надо заменять, тесты должны проверять систему в том же виде, в котором она будет работать у пользователя.» — скажите вы и отчасти будете правы. Вот только когда необходимо проверить поведение системы в критических условиях(отказ на доступ к файлу в определенный момент), убедиться в отсутствие ликов памяти или хотя бы протестировать вывод текущей даты, то откажется, что сделать это, не имея доступа к «внутренностям» системы очень не удобно, долго, а порой и вовсе не возможно.

Примеры тестов для поставленной задачи

Изначальный текст в текстовом поле при старте приложения
  1. [TestMethod]
  2. public void TestStartup()
  3. {
  4.   var appPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\TestUI\bin\Debug\TestUI.exe");
  5.   var process = Process.Start(appPath);
  6.   try
  7.   {
  8.     Thread.Sleep(5000);
  9.     var mainWindow = AutomationElement.FromHandle(process.MainWindowHandle);
  10.     var buttonControl = mainWindow.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
  11.  
  12.     var textBoxControl = mainWindow.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit));
  13.     var textBox = (ValuePattern)textBoxControl.GetCurrentPattern(ValuePattern.Pattern);
  14.  
  15.     Assert.AreEqual("123123123", textBox.Current.Value);
  16.   }
  17.   finally
  18.   {
  19.     process.Kill();
  20.   }
  21. }
* This source code was highlighted with Source Code Highlighter.

Текст после нажатия на первую кнопку.
  1. [TestMethod]
  2. public void TestMethodUIAutomation()
  3. {
  4.   var  appPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),@"..\..\..\TestUI\bin\Debug\TestUI.exe");
  5.   var process = Process.Start(appPath);
  6.   try
  7.   {
  8.     Thread.Sleep(5000);
  9.     var mainWindow = AutomationElement.FromHandle(process.MainWindowHandle);
  10.     var buttonControl = mainWindow.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
  11.  
  12.     var textBoxControl = mainWindow.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit));
  13.     var textBox = (ValuePattern) textBoxControl.GetCurrentPattern(ValuePattern.Pattern);
  14.  
  15.     Assert.AreEqual("123123123", textBox.Current.Value);
  16.  
  17.     var button = (InvokePattern) buttonControl.GetCurrentPattern(InvokePattern.Pattern);
  18.     button.Invoke();
  19.  
  20.     Assert.AreEqual("Habrahabr", textBox.Current.Value);
  21.   }
  22.   finally
  23.   {
  24.     process.Kill();  
  25.   }         
  26. }
* This source code was highlighted with Source Code Highlighter.

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

2. White project


Бесплатный фреймворк с кодеплекса, основанный на UI Automation. Достоинства и недостатки те же, отличается только более удобным и расширенным api для работы с деревом контролов.

Примеры тестов для поставленной задачи

Изначальный текст в текстовом поле при старте приложения
  1. [TestMethod]
  2. public void TestStartup()
  3. {
  4.   var appPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\TestUI\bin\Debug\TestUI.exe");
  5.   var application = White.Core.Application.Launch(appPath);
  6.  
  7.   Assert.IsNotNull(application);
  8.  
  9.   var window = application.GetWindow("MainWindow");
  10.   var textBox = window.Get<White.Core.UIItems.TextBox>();
  11.  
  12.   Assert.IsNotNull(textBox);
  13.   Assert.AreEqual("123123123", textBox.Text);
  14. }
* This source code was highlighted with Source Code Highlighter.

Текст после нажатия на первую кнопку
  1. [TestMethod]
  2. public void TestWithWhite()
  3. {
  4.   var appPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\TestUI\bin\Debug\TestUI.exe");
  5.   var application = White.Core.Application.Launch(appPath);
  6.  
  7.   Assert.IsNotNull(application);
  8.  
  9.   var window = application.GetWindow("MainWindow");
  10.   var textBox = window.Get<White.Core.UIItems.TextBox>();  
  11.  
  12.   var button = window.Get<White.Core.UIItems.Button>(SearchCriteria.ByText("Click for test"));
  13.   button.Click();
  14.  
  15.   Assert.AreEqual("Habrahabr", textBox.Text);
  16. }
* This source code was highlighted with Source Code Highlighter.

3. Visual Studio 2010 Coded UI Test


Coded UI — решение от майкрософт, появившееся в 2010 студии и неоднократно описанное, в том числе и на хабре, например здесь и здесь.

Достоинства
  • Наличие рекордера, записывающего действия пользователя для автогенерации тестов.
  • Поставка «out of the box» для Visual Studio.
  • Поддержка от майкрософт, интеграция в TFS.
  • Возможность работы на уровне дерева контролов, без привязки к координатам экрана.
Недостатки

В целом, набор недостатков тот же, что и у UI Automation. Отдельно только надо выделить то, что возможность работы есть только в определенных версиях 2010 студии(Ultimate, Premium, Professional). Причем если запуск тестов возможен во всех трех, то создание соответствующего типа item'а в проекте и запуск рекордера возможен только в версиях Ultimate и Premium. И если для своих домашних проектов и можно скачать с торрентов купить Ultimate версию, то для коммерческого проекта, где речь идет о лицензии для десятков разработчиков такой шаг может натолкнуться на непонимание со стороны вышестоящего начальства и бухгалтерии.

Код тестов я приводить не буду, ввиду того, что он является автогенереным и по-этому особого интереса не представляет.

4. Test Complete и ему подобные


Системы, подобные Test Complete можно обобщить в одну группы. Я не стану описывать их подробно, т.к. это тема отдельное статьи, выделю лишь некоторые моменты. Основное их достоинство — широкий спектр применений, отсутствие необходимости в навыках программирования и наличие отдельной, не требующих Visual Studio, системы по создание, хранению и поддержки тестов. Недостатки же повторяют предыдущие решения — платность, отношение к тестируемой системе, как к «черному ящику», без возможности мокирования плюс Test Complete имеет собственную среду для запуска тестов, так что использоваться обычный mstest не получится.

Сделаем что-нибудь свое


Если вы пишите UI своего приложения на WPF, то для его тестирования можно воспользоваться классом VisualTreeHelper. Алгоритм довольно простой — запускаем в тест-методе наше приложение в отдельном потоке, получаем через VisualTreeHelper нужный контрол, эмулируем эвенты и считываем значения для ассертов.

Для более удобного создания тестов, для себя я сделал небольшой утилитный класс, упрощающий выполнение рутинных действий:

Запуск приложения
var application = UI.Run(() => new App { MainWindow = new MainWindow() });

* This source code was highlighted with Source Code Highlighter.

Получения свойства контрола. Поясню, т.к. работать с контролами может только тот тред, в котором их создали, приходиться делать такой финт ушами.
var window = application.Get(x => x.MainWindow);

* This source code was highlighted with Source Code Highlighter.

Ну и поиск в дереве
var textBox = _mainWindow.FindChild((TextBox el) => el.Name == "SomeText");

* This source code was highlighted with Source Code Highlighter.

Еще бывает необходимо проверить лайут, цвета и прочие композиционные вещи. Тогда можно по старинке получить изображение контрола для сравнения через RenderTargetBitmap.
  1. private void AssertRender(string expectImageName, FrameworkElement elementForTest)
  2. {
  3.   var image = elementForTest.Render();
  4.   var expectPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, expectImageName);
  5.   if (!(File.Exists(expectPath) && File.ReadAllBytes(expectPath).SequenceEqual(image)))
  6.   {
  7.     File.WriteAllBytes(Path.Combine(AppDomain.CurrentDomain.BaseDirectory,"fail_" + expectImageName), image);
  8.     throw new AssertFailedException(string.Format("Element {0} not equal to image '{1}'", elementForTest.Get(x => x.Name), expectImageName));
  9.   }
  10. }
  11.  
  12. AssertRender("button.png", button);
* This source code was highlighted with Source Code Highlighter.

Достоинства
  • Бесплатность.
  • Не требует установки дополнительных библиотек.
  • Позволяет мокировать части системы.
Недостатки
  • Нет рекордера для тестов. Все пишем руками.
  • Поддержка тестов целиком ложится на ваши плечи.
Примеры тестов для поставленной задачи

Изначальный текст в текстовом поле при старте приложения
  1. [TestMethod]
  2. public void TestStartup()
  3. {
  4.   var application = UI.Run(() => new App { MainWindow = new MainWindow() });
  5.   var mainWindow = application.Get(x => x.MainWindow);
  6.  
  7.   var textBox = mainWindow.FindChild((TextBox el) => el.Name == "SomeTexBox");
  8.  
  9.   Assert.IsNotNull(textBox);
  10.  
  11.   Assert.AreEqual("123123123", textBox.Get(x=> x.Text));
  12.  
  13.   application.Invoke(x => x.Shutdown());
  14. }
* This source code was highlighted with Source Code Highlighter.

Текст после нажатия на первую кнопку
  1. [TestMethod]
  2. public void TestFirstButtonClick()
  3. {
  4.   var application = UI.Run(() => new App { MainWindow = new MainWindow() });
  5.   var mainWindow = application.Get(x => x.MainWindow);
  6.  
  7.   var textBox = mainWindow.FindChild((TextBox el) => el.Name == "SomeTexBox");
  8.   var button = mainWindow.FindChild((Button el) => el.Content.Equals("Click for test"));
  9.   button.Raise(ButtonBase.ClickEvent);
  10.  
  11.   Assert.AreEqual("Habrahabr", textBox.Get(x => x.Text));
  12.  
  13.   application.Invoke(x => x.Shutdown());
  14. }
* This source code was highlighted with Source Code Highlighter.

Текст после нажатия на вторую кнопку.
  1. [TestMethod]
  2. [HostType("Moles")]
  3. public void TestSecondButton()
  4. {
  5.   var application = UI.Run(() => new App { MainWindow = new MainWindow() });
  6.   var mainWindow = application.Get(x => x.MainWindow);
  7.  
  8.   var dateTimeExpect = new DateTime(2011, 12, 08, 12, 30, 25);
  9.   MDateTime.NowGet = () => dateTimeExpect;
  10.  
  11.   var button = mainWindow.FindChild((Button el) => el.Content.Equals("Click for test 2"));
  12.   button.Raise(ButtonBase.ClickEvent);
  13.  
  14.   var textBox = mainWindow.FindChilds<TextBox>().First();
  15.   Assert.AreEqual(dateTimeExpect.ToString(), textBox.Get(x => x.Text));
  16.  
  17.   application.Invoke(x => x.Shutdown());
  18. }
* This source code was highlighted with Source Code Highlighter.

Тут я просто замокировал вызов DateTime.Now с помощью фреймворка Moles, обзор которого можно посмотреть, например, здесь.

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

Заключение


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

Ссылки


.Net UI Automation
White project
Visual Studio 2010 Coded UI Test
Test Complete
Moles
Исходники тестируемого приложения + примеры тестов с использованием VisualTreeHelper (для запуска теста с датой придется установить Moles)
+30
34.5k 82
Comments 7