Ничто не позволяет лучше познакомиться с новой темой, как удачный пример. За день, я разрабатываю и модифицирую десятки тестов.. в не зависимости от тестируемого класса, а также от специфики теста, процесс разработки всегда один и тот же, тот о котором я рассказывал в первой части.
Я покажу пример, достаточный для демонстрирования методики TDD. Не смотря на его простоту, это пример взятый из жизни, из проекта, над которым я сейчас работаю. В этой статье я опишу предисторию, новое требование и реализацию требования следуя правилам TDD.
Итак, имеем: на основе xslt преобразования создается html код, представляющий собой веб-форму. Эта форма создается динамически и вставляется в aspx страницу. После заполнения формы пользователем, она отправляется на сервер, обрабатывается, валидируется, сохраняется и т.д. Поступает изменение, которое требует client-side валидации javascript'ом. Для удовлетворения этого требования создается дополнительный класс, который на основе xsd (который описывает типы input'ов) создает javascript, выполняющий валидацию и подсказки в случае неверного ввода. Необходимо создать класс, который вставляет валидационный javascript в html код.
В качестве фрейморка юнит-тестирования я предпочитаю интегрированные в Visual Studio средства TT (Team Test). После изучения нового требования приходим к тому, что необходим новый класс, ответственностью которого будет вставка валидационного скипта в html.
Каждому разрабатываемому классу в первую очередь ставиться в соответствие юнит-тест класс.. согласно принципу "сначала тест", создаем этот класс.
using System; using System.Text; using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Test.TddExample { [TestClass] public class TestValidatationScriptInserter { public TestValidatationScriptInserter() { } } }
Тест имеет только конструктор и пока что бесполезен. Как я говорил выше, мы предполагаем существования класса, который способен осуществлять вставку, пусть это будет класс ValidationScriptInserter. Теперь выразим наше предположение ввиде теста.
[TestMethod] public void Smoke() { ValidationScriptInserter inserter = new ValidationScriptInserter(); }
Пробуем запустить тест, но обнаруживаем, что тест даже не компилируется, так как класса ValidationScriptInserter пока что не существует в тестируемом проекте. Добавим его.
using System; using System.Collections.Generic; using System.Text; namespace TddExample { public class ValidationScriptInserter { } }
(Не забываем, что в тест-проекте необходимо добавить reference на тестируемый проект, а в коде юнит тестов добавить необходимы using декларации).
using System; using System.Text; using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using TddExample; namespace Test.TddExample { [TestClass] public class TestValidatationScriptInserter { public TestValidatationScriptInserter() { }
[TestMethod] public void Smoke() { ValidationScriptInserter inserter = new ValidationScriptInserter(); } }
Запускаем тест, он компилируется и выполняется с зеленым результатом. Далее выдвигаем предположение о функции Insert, которой должен обладать этот класс. Как всегда, пишем тест.
Следующий код (и как правило это первые 2-3 теста) я предпочитаю рассматривать даже не с точки зрения тестирования, а с точки зрения разработки сценария поведения разрабатываемого класса.
[TestMethod] public void InsertDumb() { string htmlCode = string.Empty; string javascriptCode = string.Empty; ValidationScriptInserter inserter = new ValidationScriptInserter(); htmlCode = inserter.Insert(htmlCode, javascriptCode); Assert.IsNotNull(htmlCode, "ValidationScriptInserter returned null object"); }
Запускаем тест, получем ошибку компиляции. Все верно, ведь метода Insert еще не существует. Пользуясь средствами VS2005, подводим курсор мыши к методу и щелкаем на "Generate method stub for.." в всплывающем меню. Студия добавит следующий метод:
public string Insert(string htmlCode, string javascriptCode) { throw new Exception("The method or operation is not implemented."); }
Запускаем тест, компиляция проходит успешно, но тест дает красный результат с сообщением - "Test method Test.TddExample.TestValidatationScriptInserter.InsertDumb threw exception: System.Exception: The method or operation is not implemented..", что является ожидаемым результатом. Внесем изменение в код, который позволит тесту пройти..
public string Insert(string htmlCode, string javascriptCode) { return string.Empty; }
Перезапускаем, и получаем зеленый результат. Если класс получает пустой или нулевой html код, то операция вставки не может быть осуществлена, поэтому класс должен выбросить исключение, о том, что входной параметр не верный. Пишем тест:
[TestMethod] public void InsertNoHtml() { string htmlCode = string.Empty; string javascriptCode = string.Empty; ValidationScriptInserter inserter = new ValidationScriptInserter(); try { htmlCode = inserter.Insert(htmlCode, javascriptCode); } catch (NullReferenceException ex) { if (!ex.Message.Equals("ValidationScriptInserter.Insert: html code is null or empty")) { Assert.Fail("ValidationScriptInserter.Insert - thrown unexpected exception"); } //OK return; } Assert.Fail("if html code null or empty - exception must be thrown.."); }
Запускаем, получаем красный результат - "Assert.Fail failed. if html code null or empty - exception must be thrown..".
Вносим изменения в класс Inserter.
public string Insert(string htmlCode, string javascriptCode) { if ((htmlCode == null) || (htmlCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: html code is null or empty"); } return string.Empty; }
Перезапускаем тест, получаем зеленый результат.
Отсутствие валидационного скрипта тоже свидетельствует о ошибке входных параметров, поэтому пишем тест, аналогичный предыдущему.
[TestMethod] public void InsertNoScript() { string htmlCode = "<html></html>"; string javascriptCode = string.Empty; ValidationScriptInserter inserter = new ValidationScriptInserter(); try { htmlCode = inserter.Insert(htmlCode, javascriptCode); } catch (NullReferenceException ex) { if (!ex.Message.Equals("ValidationScriptInserter.Insert: script code is null or empty")) { Assert.Fail("ValidationScriptInserter.Insert - thrown unexpected exception"); } //OK return; } Assert.Fail("if script code null or empty - exception must be thrown.."); }
Запускаем и убеждаемся, что он дает красный результат.
Вносим изменения, которые позволят тесту успешно пройти.
public string Insert(string htmlCode, string javascriptCode) { if ((htmlCode == null) || (htmlCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: html code is null or empty"); } if ((javascriptCode == null) || (javascriptCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: script code is null or empty"); } return string.Empty; }
Запускаем, и убеждаемся, что после внесения изменений тест стал успешно проходить.
После этих изменений можно перезапустить весь test-suite, и проверить состояние тестов разработанных ранее.
Видим, что перестал проходить тест InsertDumb, так-как он передает пустые значения html кода и вадидационного скрипта.. Как уже упоминалось, InsertDumb это что-то вроде сценария использования, главная его цель задать основное направление разработки метода/методов класса. Скорректируем для того, чтобы он мог успешно пройти.
[TestMethod] public void InsertDumb() { string htmlCode = "<html></html>"; string javascriptCode = "$(document).ready( function() { return false; } );"; ValidationScriptInserter inserter = new ValidationScriptInserter(); htmlCode = inserter.Insert(htmlCode, javascriptCode); Assert.IsNotNull(htmlCode, "ValidationScriptInserter returned null object"); }
Теперь все тесты проходят успешно, что дает нам основание для дальнейшего продвижения.
Откуда Inserter будет знать, в какое именно место необходимо вставлять скрипт? Сделаем предположение о том, что в html коде будет находиться метка, которая будет указывать на это место.. В случае, если метка отсутствует, предполагаем не верный hmtl код и выбрасываем исключение. Опять же, выразим предположение в виде теста.
[TestMethod] public void NoPlaceLabelForValidation() { string htmlCode = "<html></html>"; string javascriptCode = "codecodecode";
ValidationScriptInserter inserter = new ValidationScriptInserter(); try { htmlCode = inserter.Insert(htmlCode, javascriptCode); } catch (Exception ex) { //OK Assert.IsTrue(ex.Message.Equals("ValidationScriptInserter.Insert: no place label for validatation code")); return; } Assert.Fail("if no lable for validation code (<!-- Place for validation script -->) - exception must be thrown.."); }
Расширяем класс Inserter, так чтобы тест мог пройти.. Для этого, после проверок объектов hmtlCode и javascriptCode добавляем выброс исключения (тут, как и везде, я руководствуюсь основным правилом - вносить минимальное количество изменений, способных успешно завершить тест).
public string Insert(string htmlCode, string javascriptCode) { if ((htmlCode == null) || (htmlCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: html code is null or empty"); } if ((javascriptCode == null) || (javascriptCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: script code is null or empty"); } throw new Exception("ValidationScriptInserter.Insert: no place label for validatation code"); return string.Empty; }
Перезапускаем тест, и видим зеленый результат. Как это отмечалось прохождение теста не свидетельствует о том, что в коде нет ошибок.. он свидетельствует о том, что код работает так, как ожидает тест (т.е. что тест не выявляет ошибок). Пишем следующий тест, который как раз и выявляет неработоспособность кода.
[TestMethod] public void PlaceLabelForValidatation() { string htmlCode = "<html><!-- Place for validation script --></html>"; string javascriptCode = "codecodecode"; ValidationScriptInserter inserter = new ValidationScriptInserter(); try { htmlCode = inserter.Insert(htmlCode, javascriptCode); } catch (Exception ex) { if (ex.Message.Equals("ValidationScriptInserter.Insert: no place label for validatation code")) { Assert.Fail("inserted failed to find label"); } } }
Получаем красный результат, с ошибкой - "Assert.Fail failed. inserted failed to find label", т.е. что и требовалось доказать. Это подчекивает тот факт, что ценность теста заключается не в том, что он проходит, а в том, что он не проходит. Изменяем код таким образом, чтобы он действительно проверял наличие метки.
if (htmlCode.IndexOf("<!-- Place for validation script -->") == -1) { throw new Exception("ValidationScriptInserter.Insert: no place label for validatation code"); }
Компилируем и запускаем оба теста. Они дают зеленый результат.
Теперь, самое время возвратиться к главному тесту InsertDumb, прежде всего скорректируем его так, что он содержал правильные входные данные и проверки.
[TestMethod] public void InsertDumb() { string htmlCode = "<html><!-- Place for validation script --></html>"; string javascriptCode = "$(document).ready( function() { return false; } );"; ValidationScriptInserter inserter = new ValidationScriptInserter(); htmlCode = inserter.Insert(htmlCode, javascriptCode); Assert.IsNotNull(htmlCode, "ValidationScriptInserter returned null object"); Assert.IsTrue(htmlCode.Equals("<html>$(document).ready( function() { return false; } );</html>"), "inserter retured incorrect result"); }
И добавим код, для того чтобы тест смог пройти. Для успешного прохождения, после всех проверок нужно вставить следующий код:
htmlCode = htmlCode.Replace("<!-- Place for validation script -->", javascriptCode); return htmlCode;
Проверяем прохожнение как этого теста, так и всех остальных. Все тесты успешно проходят.
Мы прошли по пути Red-Green, т.е. из состояния красных тестов перешли в состояние, когда все тесты успешно проходят. Обратите внимание на следующие моменты:
- частый перезапуск тестов
- ни одной линии кода без теста
- минимальные изменения для успешного прохождения
Все эти моменты являются ключевыми и специфичными для разработки управляемой тестированием.
Остался последний этап рефакторинг. Из бросающегося в глаза: hardcoded стока метки + не нужное использование переменной htmlCode.
После небольших поправок финальный код, выглядит так:
using System; using System.Collections.Generic; using System.Text; namespace TddExample { public class ValidationScriptInserter { private const string PlaceLabel = "<!-- Place for validation script -->"; public string Insert(string htmlCode, string javascriptCode) { if ((htmlCode == null) || (htmlCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: html code is null or empty"); } if ((javascriptCode == null) || (javascriptCode == string.Empty)) { throw new NullReferenceException("ValidationScriptInserter.Insert: script code is null or empty"); } if (htmlCode.IndexOf(PlaceLabel) == -1) { throw new Exception("ValidationScriptInserter.Insert: no place label for validatation code"); } return htmlCode.Replace(PlaceLabel, javascriptCode);; } } }
Перезупускаем все тесты, и убеждаемся в том, что внесенные в процессе рефакторинга изменения не повлияли на функциональность, т.е. все тесты остались в зеленом состоянии.
Для проверки качества полноты тестирования запустим Code Coverage анализ. Его результаты говорят нам о том, что класс Inserter покрыт на 90.48%.
При анализе результатов видно, что не покрытыми остаются проверки, когда в качестве аргументов html кода и javascript'а передаются значения null.
Расширим тесты, для покрытия этих случаев (добавим два теста аналогичные InsertNoHtml, InsertNoScript).
Перезапускам тесты, и code coverage analisys. И еще раз просмотрим результаты:
Результаты полностью удовлетворительные (всегда надо стремиться к 100% покрытию кода тестами).
Это является последним шагом, который демонстрирует цикл TDD. После него, код может быть успешно сохранен в системе конроля версий.. а юнит-тесты будут перезапускаться с каждым билдом, проверяя все ли функционирует так, как это ожидалось на этапе разработки.
Я надеюсь, что это пример поможет вам понять основной путь, по которому надо двигаться, используя методику TDD, как основную методику разработки.
Если у вас есть вопросы, я буду рад на них ответить.
