Немного о разрыве зависимостей и TDD

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

Исходники тут.

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному0
LinkedIn



5 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

2 изучающий английскийНе понял Вашего комментария, при чём тут работодатели, которые не ценят разработчиков?

Я знаю крутой метод рефакторинга исходного кода: сменить работодателя (вместе с унаследованным кодом, написанным неоцененным предыдущим разработчиком). Все им пользуются.Если код написан другим — идеальное решение.Если все работодатели в стране не ценят разработчиков — то проще сменить страну. А что, программист один, а начальников много — всех не перевоспитаешь.Естественный отбор, конечно, присутствует, но только если человек, знающий больше других, сам становится инструментом отбора — и увольняет тупых манагеров. Больше никак.

А кстати. Санёк Баглай какое-то время назад тоже писал про TDD, правда, в достаточно бессвязном стиле. ЯТД, что вам было бы неплохо прикрепить к посту диаграмму по образцу фаулеровской схемы Active Tease Apart Inheritance.

Согласен по поводу тестов. К сожалению, я не гуру ТДД (пока), пишу как умею =). ИМХО очень сложно писать в унаследованном коде тесты только для проверки состояния, либо для проверки взаимодействия, т.к. код изначально весьма сложный и запутанный.Целью статьи не было описание ТДД, я просто хотел рассказать об этих техниках рефакторинга и мне надо было какое-то вступление. Статья расчитана на те 70%, у которых проблема с тестами. Многие хотят писать тесты, но не знают с чего начать, впрочем недавно у меня была такая же ситуация.

Понять код ваших тестов не имея соответствующих знаний просто невозможно.

Главное понять, что такое мок (стаб) и понять, что надо сделать для того, чтобы появилась возможность их использования.

Спасибо Вам за статью. Сразу хотелось бы попросить у автора прощения за следующую критику, я пишу это для того, чтобы помочь вам тоже стать лучше, как и Вы старались помочь нам и мне в частности при написании статьи. Всё полезно и интересно (даже непривычно читать на русском), но вот подход к написанию некоторых тестов расходится с best-practices индустрии, которые прововедуются в этой и этой книгах. Это не значит, что тесты плохие, просто хотелось бы обратить внимание.Если вы пишете тест с использованием мока, то в конце теста должен быть один и только одни Verify, которые проверяет ранее построенные Expectations.Если у вас есть несколько вызовов Verify — вероятно Вы тетсируете слишком много. Также в некоторых Ваших тестах это явно не моки, а стабы, а стабы как известно не могут влиять на результат выполнения теста и следовательно для них не нужно вызывать Verify.Я не хочу вызвать бурю негодования и мыслей в стиле «в интернете кто-то не прав», попытаюсь этого избежать. Давайте рассмотрим всё на конкретном примере — Ваш первый тест и будем считать, что всё написанное относится больше к нему, а к остальному с уточнениями =)

        public void DoSomething()        {            var webService = MockRepository.GenerateMock();            webService.Expect(x => x.GetCaseCount()).Return(20);            var classWithDependency = new ClassWithDependency(webService);            bool result = classWithDependency.DoSomething();            Assert.IsTrue(result);            webService.VerifyAllExpectations();        }

Что здесь происходит? Для начала определимся, с тем что мы хотим протестировать - это метод DoSomething(). Мы фактически создаём stub, который настраиваем вот так - "Если вызван GetCaseCount, верни 20" и дальше собственно делаем утверждение, которым уже тестируем наш метод DoSomething.Вызывая в конце Verify мы ещё и проверяем был ли вызван GetCaseCount внутри тестируемого метода. Зачем? Таким образом мы смешиваем State и Interaction Testing, что нехорошо т.к. мы тестируем более одной вещи за тест. Я бы переписал тест вот так:

        public void DoSomething()        {            var webService = MockRepository.GenerateStub();            webService.Stub(x => x.GetCaseCount()).Return(20);            var classWithDependency = new ClassWithDependency(webService);            Assert.That(classWithDependency.DoSomething(), Is.True);        }

Таким образом мы тестируем только State, нет необходимости делать ещё и Interaction testing (хотя если возникает желание, можно сделать это в отдельном тесте, где уже будет полноценный Mock) Помимо сказанного — в начале статьи Вы описываете TDD таким образом, чтобы понял даже неподготовленный читатель, затем ссылаетесь на опрос, что 70% разработчиков не пишут тестов вообще либо же имеют ограниченное покрытие, следовательно вы ориентировались на те ~ 30%, которые тесты всё таки пишут? Понять код ваших тестов не имея соответствующих знаний просто невозможно.

Підписатись на коментарі