Немного о разрыве зависимостей и TDD
Антон МартыненкоОпубликовано 18.01.2010 в Разработка
TDD (Test-Driven Development) – это техника программирования, при которой разработка ведется через тестирование. Тесты пишутся до кода, либо до внесения изменений в существующий код. Эта техника предполагает написания множества юнит-тестов, которые тестируют код. Как правило, тесты выполняются во время интеграционного тестирования, что позволяет обнаружить ошибки.
По мере роста размера проекта тесты принимают все более важное значение, т.к. в большом проекте возникает проблемы с изменением кода. В большом проекте очень сложно менять код, не внося при этом ошибок. Чем он больше – тем сложнее понять, на что повлияет изменение, и тем выше шанс что-то сломать. При росте размера проекта важность тестов растет экспоненциально. Юнит-тесты – отличный помощник в такой ситуации.
Michael Feathers в его книге Working Effectively with Legacy Code вводит понятие «Унаследованный код» (Legacy code). Унаследованный код – это код без тестов, изменение которого может быть сложным из-за отсутствия автоматических регрессионных тестов. Объективно, это существенная часть кода в тех компаниях, где мы работаем (см. опрос – более 70% имеет ограниченное покрытие тестами, либо не пишут тесты совсем). Не секрет, что многие проекты в начале представляют собой простые и понятные системы, над которыми работают 1-2 программиста. В таких проектах написание тестов часто считается тратой времени. Но по мере роста проекта и возрастания сложности все более ощущается отсутствие автоматического регрессионного тестирования. Дизайн все более усложняется, и становится все труднее поддерживать и развивать проект. Так появляется унаследованный код…
Причины, по которым люди не пишут тесты достаточно разные. Вот немногие из них: отсутствие времени, лень, сложность написания тестов, непрофессионализм и т.д. Я бы хотел заострить внимание на сложности написания тестов, и показать пару техник, которые могут помочь. Основная причина сложности написания тестов – проблемы в архитектуре и проектировании классов. Очень часто классы имеют слишком много зависимостей, и из-за этого класс или метод невозможно изолировать для тестирования. Простой пример – форма, которая обращается к базе данных за какой-либо информацией. Обсуждение архитектуры и дизайна уходят за рамки этой статьи, но я бы хотел сказать, что такие изменения не делаются быстро, а уговорить заказчика на рефакторинг очень сложно – ведь заказчику сложно понять, почему он будет оплачивать месяцы работы, и после это не получит никакого нового функционала. Программист остается один на один с унаследованным кодом, и ему надо как-то разрывать зависимости и писать тесты.
Я бы хотел показать некоторые методики разрыва зависимостей, которые я использовал в работе.
Их автор – Michael Feathers. В книге Working Effectively with Legacy Code он описал больше методик, чем я здесь привожу. Я считаю, что описанные здесь методики наиболее востребованные и эффективные:
- Parameterize constructor
- Parameterize method
- Introduce static setter
- Extract and override call
- Extract and override factory method
Все методики я привожу на языке C#, так как я на нем пишу. Надеюсь, он будет всем понятен =)
1. Parameterize constructor
Эта методика предназначена для выноса зависимости при помощи конструктора. Основная ее идея – создание нового конструктора, который принимает в качестве параметра интерфейс класса, от которого зависит «унаследованный код».
В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:
namespace ParameterizeConstructor.LegacyCode
{
public class ClassWithDependency
{
public ClassWithDependency()
{
//some initialization
}
public bool DoSomething()
{
//...
int caseCount = WebServiceFacade.Instance.GetCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
using System.Diagnostics;
namespace ParameterizeConstructor.LegacyCode
{
public class WebServiceFacade
{
private static WebServiceFacade instance;
public static WebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
return 42;
}
//...
}
}
Нам необходимо внести изменения в код этого метода и мы должны сначала написать тест. Но мы не можем написать юнит-тест, потому что метод будет обращаться к веб-сервису во время запуска теста. Это неприемлемо. Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade:
namespace ParameterizeConstructor.BrokenDependency
{
public interface IWebServiceFacade
{
int GetCaseCount();
}
}
using System.Diagnostics;
namespace ParameterizeConstructor.BrokenDependency
{
public class WebServiceFacade : IWebServiceFacade
{
private static IWebServiceFacade instance;
public static IWebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
return 42;
}
}
}
Теперь мы добавляем новый конструктор, не забыв оставить старый:
namespace ParameterizeConstructor.BrokenDependency
{
public class ClassWithDependency
{
/// <summary>
/// We preserved original constructor
/// </summary>
public ClassWithDependency()
: this(WebServiceFacade.Instance)
{
}
/// <summary>
/// This constructor has been created for testing purposes. You can inject your dependency using this constructor
/// </summary>
public ClassWithDependency(IWebServiceFacade webService)
{
this.webService = webService;
//some initialization has gone here
}
private IWebServiceFacade webService;
public bool DoSomething()
{
//...
int caseCount = webService.GetCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
В старом конструкторе мы вызываем новый конструктор с параметром WebServiceFacade.Instance это сделано для того, чтобы гарантировать сохранение интерфейса класса для всех, кто это класс будет использовать.
Теперь мы можем написать тест, используя мок:
#if TEST
using NUnit.Framework;
using Rhino.Mocks;
namespace ParameterizeConstructor.BrokenDependency
{
[TestFixture]
public class TestClassWithDependency
{
[Test]
public void DoSomething()
{
var webService = MockRepository.GenerateMock<IWebServiceFacade>();
webService.Expect(x => x.GetCaseCount()).Return(20);
var classWithDependency = new ClassWithDependency(webService);
bool result = classWithDependency.DoSomething();
Assert.IsTrue(result);
//...
webService.VerifyAllExpectations();
}
}
}
#endif
2. Parameterize method
Эта методика предназначена для выноса зависимости при помощи изменения сигнатуры метода. В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace ParameterizeMethod.LegacyCode
{
public class ClassWithDependency
{
public bool DoSomething()
{
//...
int caseCount = WebServiceFacade.Instance.GetCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
using System.Diagnostics;
namespace ParameterizeMethod.LegacyCode
{
public class WebServiceFacade
{
private static WebServiceFacade instance;
public static WebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
return 42;
}
}
}
Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade:
namespace ParameterizeConstructor.BrokenDependency
{
public interface IWebServiceFacade
{
int GetCaseCount();
}
}
using System.Diagnostics;
namespace ParameterizeConstructor.BrokenDependency
{
public class WebServiceFacade : IWebServiceFacade
{
private static IWebServiceFacade instance;
public static IWebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
return 42;
}
}
}
Теперь мы добавляем новую сигнатуру метода, в старом методе делаем вызов нового с параметром WebServiceFacade.Instance это сделано для того, чтобы гарантировать сохранение интерфейса класса для всех, кто это класс будет использовать.
namespace ParameterizeMethod.BrokenDependency
{
public class ClassWithDependency
{
/// <summary>
/// We preserved original signature
/// </summary>
public bool DoSomething()
{
return DoSomething(WebServiceFacade.Instance);
}
/// <summary>
/// This method has been created for testing purposes
/// </summary>
public bool DoSomething(IWebServiceFacade webService)
{
//...
int caseCount = webService.GetCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
Теперь мы можем написать тест:
#if TEST
using NUnit.Framework;
using Rhino.Mocks;
namespace ParameterizeMethod.BrokenDependency
{
[TestFixture]
public class TestClassWithDependency
{
[Test]
public void DoSomething()
{
var webService = MockRepository.GenerateMock<IWebServiceFacade>();
webService.Expect(x => x.GetCaseCount()).Return(20);
var classWithDependency = new ClassWithDependency();
bool result = classWithDependency.DoSomething(webService);
Assert.IsTrue(result);
//...
webService.VerifyAllExpectations();
}
}
}
#endif
3. Introduce static setter
Эта методика позволяет внедрять мок-классы в синглтон и другие статические сущности. В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:
namespace IntroduceStaticSetter.LegacyCode
{
public class ClassWithDependency
{
public bool DoSomething()
{
//...
int caseCount = WebServiceFacade.Instance.GetCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
using System.Diagnostics;
namespace IntroduceStaticSetter.LegacyCode
{
public class WebServiceFacade
{
private static WebServiceFacade instance;
public static WebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
return 42;
}
}
}
Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade а также создать статический метод – setter:
namespace IntroduceStaticSetter.BrokenDependency
{
public interface IWebServiceFacade
{
int GetCaseCount();
}
}
using System.Diagnostics;
namespace IntroduceStaticSetter.BrokenDependency
{
public class WebServiceFacade : IWebServiceFacade
{
private static IWebServiceFacade instance;
public static IWebServiceFacade Instance
{
get
{
if (instance == null)
{
instance = new WebServiceFacade();
}
return instance;
}
}
/// <summary>
/// This method has been created for testing purposes
/// </summary>
public static void SetInstance(IWebServiceFacade webService)
{
instance = webService;
}
public int GetCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
return 42;
}
}
}
Класс с зависимостью остается без изменений, но теперь можно смело писать юнит-тест:
#if TEST
using NUnit.Framework;
using Rhino.Mocks;
namespace IntroduceStaticSetter.BrokenDependency
{
[TestFixture]
public class TestClassWithDependency
{
[Test]
public void DoSomething()
{
var webService = MockRepository.GenerateMock<IWebServiceFacade>();
webService.Expect(x => x.GetCaseCount()).Return(20);
WebServiceFacade.SetInstance(webService);
var classWithDependency = new ClassWithDependency();
bool result = classWithDependency.DoSomething();
Assert.IsTrue(result);
webService.VerifyAllExpectations();
}
}
}
#endif
4. Extract and override call
Эта методика позволяет избавиться от зависимостей, связанных с вызовом статических методов, либо вызовов какого-либо API. Предположим у нас есть следующий код:
namespace ExtractAndOverrideCall.LegacyCode
{
public class ClassWithDependency
{
public bool DoSomething()
{
string customerName = string.Empty;
//...
//here we somehow retrieve and assign result to customerName
customerName = "Anton Martynenko";
//...
int caseCount = CaseCountCalculator.CalculateCaseCount(customerName);
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
using System.Diagnostics;
namespace ExtractAndOverrideCall.LegacyCode
{
public class CaseCountCalculator
{
public static int CalculateCaseCount(string customerName)
{
//make call to real web service
Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
return 42;
}
}
}
Для разрыва зависимости мы добавим публичный виртуальный метод в класс с зависимостью:
namespace ExtractAndOverrideCall.BrokenDependency
{
public class ClassWithDependency
{
public bool DoSomething()
{
string customerName = string.Empty;
//...
//here we somehow retrieve and assign result to customerName
customerName = "Anton Martynenko";
//...
int caseCount = CalculateCaseCount(customerName);
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
public virtual int CalculateCaseCount(string customerName)
{
return CaseCountCalculator.CalculateCaseCount(customerName);
}
}
}
Теперь мы можем написать тест, используя мок класса с зависимостью и подменяя во времени выполнения результаты вызова метода public virtual int CalculateCaseCount(string customerName):
#if TEST
using NUnit.Framework;
using Rhino.Mocks;
namespace ExtractAndOverrideCall.BrokenDependency
{
[TestFixture]
public class TestClassWithDependency
{
[Test]
public void DoSomething()
{
var classWithDependency = MockRepository.GenerateMock<ClassWithDependency>();
classWithDependency.Expect(x => x.CalculateCaseCount(Arg<string>.Is.Equal("Anton Martynenko")))
.Return(31);
bool result = classWithDependency.DoSomething();
Assert.IsTrue(result);
classWithDependency.VerifyAllExpectations();
}
}
}
#endif
5. Extract and override factory method
Эта методика позволяет разорвать зависимости, связанные с создание экземпляров классов, основанных на промежуточных результатах выполнения метода. Посмотрим пример:
namespace ExtractAndOverrideFactoryMethod.LegacyCode
{
public class ClassWithDependency
{
public bool DoSomething()
{
string customerName = string.Empty;
//...
//here we somehow retrieve and assign result to customerName
customerName = "Anton Martynenko";
//...
var caseCountCalculator = new CaseCountCalculator(customerName);
int caseCount = caseCountCalculator.CalculateCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
}
}
using System.Diagnostics;
namespace ExtractAndOverrideFactoryMethod.LegacyCode
{
public class CaseCountCalculator
{
public CaseCountCalculator(string customerName)
{
//customerName is used for initialization
}
public int CalculateCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
return 42;
}
}
}
Экземпляр класса CaseCountCalculator создается на основе customerName, которое вычисляется в процессе выполнения метода. В этом случае мы извлечем интерфейс в классе CaseCountCalculator и используем паттерн «фабричный метод»:
namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
public interface ICaseCountCalculator
{
int CalculateCaseCount();
}
}
using System.Diagnostics;
namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
public class CaseCountCalculator : ICaseCountCalculator
{
public CaseCountCalculator(string customerName)
{
//customerName is used for initialization
}
public int CalculateCaseCount()
{
//make call to real web service
Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
return 42;
}
}
}
namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
public class ClassWithDependency
{
public bool DoSomething()
{
string customerName = string.Empty;
//...
//here we somehow retrieve and assign result to customerName
customerName = "Anton Martynenko";
//...
var caseCountCalculator = CreateCaseCountCalculator(customerName);
int caseCount = caseCountCalculator.CalculateCaseCount();
if (caseCount > 0)
{
//...
return true;
}
//...
return false;
}
public virtual ICaseCountCalculator CreateCaseCountCalculator(string customerName)
{
return new CaseCountCalculator(customerName);
}
}
}
Теперь мы можем написать тест, используя мок класса с зависимостью и подменяя во времени выполнения результаты вызова метода public virtual ICaseCountCalculator CreateCaseCountCalculator(string customerName):
#if TEST
using NUnit.Framework;
using Rhino.Mocks;
namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
[TestFixture]
public class TestClassWithDependency
{
[Test]
public void DoSomething()
{
var caseCountCalculator = MockRepository.GenerateMock<ICaseCountCalculator>();
caseCountCalculator.Expect(x => x.CalculateCaseCount())
.Return(31);
var classWithDependency = MockRepository.GenerateMock<ClassWithDependency>();
classWithDependency.Expect(x => x.CreateCaseCountCalculator(Arg<string>.Is.Equal("Anton Martynenko")))
.Return(caseCountCalculator);
bool result = classWithDependency.DoSomething();
Assert.IsTrue(result);
caseCountCalculator.VerifyAllExpectations();
classWithDependency.VerifyAllExpectations();
}
}
}
#endif
Конечно, стоит отметить, что в реальной жизни все намного сложнее, чем в примерах. Но комбинируя эти методики с остальными приемами Inversion of Control можно добиться значительного увеличения покрытия тестами и улучшения дизайна классов. Всем, кто заинтересован увеличением покрытия тестами унаследованного кода я рекомендую прочитать книгу Working Effectively with Legacy Code. Она содержит множество полезных советов для работы с тем, с чем множество из нас сталкивается каждый день =)
В примерах использовались:
- .NET 3.5
- Rhino Mocks 3.5
- NUnit 2.5.2.9222
Понравилась статья? Подпишись на обновления по RSS/E-mail


(15 голосов, средний: 3.8 из 5)
Еще советую почитать/посмотреть презентации Миско Хевери. http://misko.hevery.com/presentations/.
И одной фразой все эти случаи можно резюмировать: “Мы не можем научиться писать тесты. Все что мы можем – это писать тестируемый код”. И Dependecy Injection вам в этом в помощь.
Спасибо Вам за статью. Сразу хотелось бы попросить у автора прощения за следующую критику, я пишу это для того, чтобы помочь вам тоже стать лучше, как и Вы старались помочь нам и мне в частности при написании статьи. Всё полезно и интересно (даже непривычно читать на русском), но вот подход к написанию некоторых тестов расходится с best-practices индустрии, которые прововедуются в этой и этой книгах. Это не значит, что тесты плохие, просто хотелось бы обратить внимание.
Если вы пишете тест с использованием мока, то в конце теста должен быть один и только одни Verify, которые проверяет ранее построенные Expectations.Если у вас есть несколько вызовов Verify – вероятно Вы тетсируете слишком много. Также в некоторых Ваших тестах это явно не моки, а стабы, а стабы как известно не могут влиять на результат выполнения теста и следовательно для них не нужно вызывать Verify.
Я не хочу вызвать бурю негодования и мыслей в стиле “в интернете кто-то не прав”, попытаюсь этого избежать. Давайте рассмотрим всё на конкретном примере – Ваш первый тест и будем считать, что всё написанное относится больше к нему, а к остальному с уточнениями =)
Что здесь происходит? Для начала определимся, с тем что мы хотим протестировать – это метод DoSomething(). Мы фактически создаём stub, который настраиваем вот так – “Если вызван GetCaseCount, верни 20″ и дальше собственно делаем утверждение, которым уже тестируем наш метод DoSomething.
Вызывая в конце Verify мы ещё и проверяем был ли вызван GetCaseCount внутри тестируемого метода. Зачем? Таким образом мы смешиваем State и Interaction Testing, что нехорошо т.к. мы тестируем более одной вещи за тест. Я бы переписал тест вот так:
Таким образом мы тестируем только State, нет необходимости делать ещё и Interaction testing (хотя если возникает желание, можно сделать это в отдельном тесте, где уже будет полноценный Mock)
Помимо сказанного – в начале статьи Вы описываете TDD таким образом, чтобы понял даже неподготовленный читатель, затем ссылаетесь на опрос, что 70% разработчиков не пишут тестов вообще либо же имеют ограниченное покрытие, следовательно вы ориентировались на те ~ 30%, которые тесты всё таки пишут? Понять код ваших тестов не имея соответствующих знаний просто невозможно.
Согласен по поводу тестов. К сожалению, я не гуру ТДД (пока), пишу как умею =). ИМХО очень сложно писать в унаследованном коде тесты только для проверки состояния, либо для проверки взаимодействия, т.к. код изначально весьма сложный и запутанный.
Целью статьи не было описание ТДД, я просто хотел рассказать об этих техниках рефакторинга и мне надо было какое-то вступление. Статья расчитана на те 70%, у которых проблема с тестами. Многие хотят писать тесты, но не знают с чего начать, впрочем недавно у меня была такая же ситуация.
Главное понять, что такое мок (стаб) и понять, что надо сделать для того, чтобы появилась возможность их использования.
А кстати. Санёк Баглай какое-то время назад тоже писал про TDD, правда, в достаточно бессвязном стиле.
ЯТД, что вам было бы неплохо прикрепить к посту диаграмму по образцу фаулеровской схемы Active Tease Apart Inheritance.
Я знаю крутой метод рефакторинга исходного кода: сменить
работодателя (вместе с унаследованным кодом, написанным
неоцененным предыдущим разработчиком). Все им пользуются.
Если код написан другим – идеальное решение.
Если все работодатели в стране не ценят разработчиков – то проще
сменить страну. А что, программист один, а начальников много –
всех не перевоспитаешь.
Естественный отбор, конечно, присутствует, но только если человек,
знающий больше других, сам становится инструментом отбора – и
увольняет тупых манагеров. Больше никак.
2 изучающий английский
Не понял Вашего комментария, при чём тут работодатели, которые не ценят разработчиков?