Alexander Beletsky's Development Blog: 2008-04

Разработка ведомая тестированием. Часть 3. Пример

Ничто не позволяет лучше познакомиться с новой темой, как удачный пример. За день, я разрабатываю и модифицирую десятки тестов.. в не зависимости от тестируемого класса, а также от специфики теста, процесс разработки всегда один и тот же, тот о котором я рассказывал в первой части.

Я покажу пример, достаточный для демонстрирования методики 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, как основную методику разработки.

Если у вас есть вопросы, я буду рад на них ответить.