Немного о разрыве зависимостей и TDD
TDD (Test-Driven Development) — это техника программирования, при которой разработка ведется через тестирование. Тесты пишутся до кода, либо до внесения изменений в существующий код. Эта техника предполагает написания множества юнит-тестов, которые тестируют код. Как правило, тесты выполняются во время интеграционного тестирования, что позволяет обнаружить ошибки.
По мере роста размера проекта тесты принимают все более важное значение, т.к. в большом проекте возникает проблемы с изменением кода. В большом проекте очень сложно менять код, не внося при этом ошибок. Чем он больше — тем сложнее понять, на что повлияет изменение, и тем выше шанс что-то сломать. При росте размера проекта важность тестов растет экспоненциально. Юнит-тесты — отличный помощник в такой ситуации.
Michael Feathers в его книге Working Effectively with Legacy Code вводит понятие «Унаследованный код» (Legacy code). Унаследованный код — это код без тестов, изменение которого может быть сложным из-за отсутствия автоматических регрессионных тестов. Объективно, это существенная часть кода в тех компаниях, где мы работаем (см. опрос — более 70% имеет ограниченное покрытие тестами, либо не пишут тесты совсем). Не секрет, что многие проекты в начале представляют собой простые и понятные системы, над которыми работают
Причины, по которым люди не пишут тесты достаточно разные. Вот немногие из них: отсутствие времени, лень, сложность написания тестов, непрофессионализм и т.д. Я бы хотел заострить внимание на сложности написания тестов, и показать пару техник, которые могут помочь. Основная причина сложности написания тестов — проблемы в архитектуре и проектировании классов. Очень часто классы имеют слишком много зависимостей, и из-за этого класс или метод невозможно изолировать для тестирования. Простой пример — форма, которая обращается к базе данных за какой-либо информацией. Обсуждение архитектуры и дизайна уходят за рамки этой статьи, но я бы хотел сказать, что такие изменения не делаются быстро, а уговорить заказчика на рефакторинг очень сложно — ведь заказчику сложно понять, почему он будет оплачивать месяцы работы, и после это не получит никакого нового функционала. Программист остается один на один с унаследованным кодом, и ему надо как-то разрывать зависимости и писать тесты.
Я бы хотел показать некоторые методики разрыва зависимостей, которые я использовал в работе.
Их автор — 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
5 коментарів
Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.