Что имеют в виду программисты, когда говорят: "Код против интерфейса, а не объекта"?

Я начал очень долгий и трудный квест, чтобы изучить и применить TDD к своему рабочему процессу. У меня сложилось впечатление, что TDD очень хорошо вписывается в принципы IoC.

После просмотра некоторых вопросов с тегами TDD здесь, в SO, я прочитал, что это хорошая идея для программирования с интерфейсами, а не с объектами.

Можете ли вы предоставить простые примеры кода того, что это такое, и как применять его в реальных случаях использования? Простые примеры являются ключевыми для меня (и других людей, желающих учиться), чтобы понять концепции.

Спасибо.

7 ответов

Решение

Рассматривать:

class MyClass
{
    //Implementation
    public void Foo() {}
}

class SomethingYouWantToTest
{
    public bool MyMethod(MyClass c)
    {
        //Code you want to test
        c.Foo();
    }
}

Так как MyMethod принимает только MyClass, если вы хотите заменить MyClass с фиктивным объектом для модульного тестирования вы не можете. Лучше использовать интерфейс:

interface IMyClass
{
    void Foo();
}

class MyClass : IMyClass
{
    //Implementation
    public void Foo() {}
}

class SomethingYouWantToTest
{
    public bool MyMethod(IMyClass c)
    {
        //Code you want to test
        c.Foo();
    }
}

Теперь вы можете проверить MyMethodпотому что он использует только интерфейс, а не конкретную конкретную реализацию. Затем вы можете реализовать этот интерфейс для создания любого макета или подделки, которые вы хотите для целей тестирования. Есть даже такие библиотеки, как Rhino Mocks Rhino.Mocks.MockRepository.StrictMock<T>(), которые берут любой интерфейс и строят вам на лету фиктивный объект.

Все дело в близости. Если вы кодируете реализацию (реализованный объект), вы находитесь в довольно тесных отношениях с этим "другим" кодом, как его потребителем. Это означает, что вы должны знать, как его конструировать (то есть, какие у него зависимости, возможно, в качестве параметров конструктора, возможно, в качестве установщиков), когда его утилизировать, и вы, вероятно, не сможете многое сделать без него.

Интерфейс перед реализованным объектом позволяет сделать несколько вещей:

  1. Например, вы можете / должны использовать фабрику для создания экземпляров объекта. Контейнеры IOC делают это очень хорошо для вас, или вы можете сделать свой собственный. Поскольку обязанности по построению находятся вне вашей ответственности, ваш код может просто предполагать, что он получает то, что ему нужно. На другой стороне фабричной стены вы можете создавать реальные экземпляры или макетировать экземпляры класса. В производственной среде вы, конечно, будете использовать реальный режим, но для тестирования вам может потребоваться создание заглушенных или динамически смоделированных экземпляров для тестирования различных состояний системы без необходимости запуска системы.
  2. Вам не нужно знать, где находится объект. Это полезно в распределенных системах, где объект, с которым вы хотите общаться, может быть или не быть локальным для вашего процесса или даже системы. Если вы когда-либо программировали Java RMI или старый skool EJB, вы знаете процедуру "общения с интерфейсом", которая скрывала прокси-сервер, выполнявший удаленные сетевые и распределяющие обязанности, о которых ваш клиент не заботился. WCF придерживается схожей философии "общение с интерфейсом" и позволяет системе определять способ связи с целевым объектом / службой.

** ОБНОВЛЕНИЕ ** Был запрос на пример контейнера МОК (Фабрика). Есть много для почти всех платформ, но по своей сути они работают так:

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

  2. Вы "регистрируете" реализации, которые вы хотите, чтобы контейнер создавал для вас как фабрику для интерфейсов, которые они реализуют (например: регистрируйте MyServiceImpl для интерфейса службы). Во время этого процесса регистрации обычно существует некоторая поведенческая политика, которую вы можете предоставить, например, если каждый раз создается новый экземпляр или используется один (тонна) экземпляр

  3. Когда контейнер создает объекты для вас, он внедряет любые зависимости в эти объекты как часть процесса создания (т. Е. Если ваш объект зависит от другого интерфейса, в свою очередь, обеспечивается реализация этого интерфейса и т. Д.).

Это может выглядеть так:

IocContainer container = new IocContainer();

//Register my impl for the Service Interface, with a Singleton policy
container.RegisterType(Service, ServiceImpl, LifecyclePolicy.SINGLETON);

//Use the container as a factory
Service myService = container.Resolve<Service>();

//Blissfully unaware of the implementation, call the service method.
myService.DoGoodWork();

При программировании с использованием интерфейса вы будете писать код, который использует экземпляр интерфейса, а не конкретный тип. Например, вы можете использовать следующий шаблон, который включает внедрение конструктора. Внедрение в конструктор и другие части инверсии управления не обязательны для программирования на интерфейсах, однако, поскольку вы исходите из перспективы TDD и IoC, я описал это таким образом, чтобы дать вам некоторый контекст, который, мы надеемся, знаком с.

public class PersonService
{
    private readonly IPersonRepository repository;

    public PersonService(IPersonRepository repository)
    {
        this.repository = repository;
    }

    public IList<Person> PeopleOverEighteen
    {
        get
        {
            return (from e in repository.Entities where e.Age > 18 select e).ToList();
        }
    }
}

Объект репозитория передается и является типом интерфейса. Преимущество передачи интерфейса заключается в возможности "поменять" конкретную реализацию без изменения использования.

Например, можно предположить, что во время выполнения контейнер IoC внедрит репозиторий, который подключен к базе данных. Во время тестирования вы можете передать в хранилище макет или заглушку, чтобы PeopleOverEighteen метод.

Это означает, что думать универсально. Не конкретно

Предположим, у вас есть приложение, которое уведомляет пользователя, отправив ему какое-то сообщение. Если вы работаете, используя интерфейс IMessage, например

interface IMessage
{
    public void Send();
}

для каждого пользователя вы можете настроить способ получения сообщения. Например, кто-то хочет получить уведомление по электронной почте, и ваш IoC создаст конкретный класс EmailMessage. Некоторые хотят SMS, и вы создаете экземпляр SMSMessage.

Во всех этих случаях код для уведомления пользователя никогда не будет изменен. Даже если вы добавите еще один конкретный класс.

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

В лучшем случае вы должны быть в состоянии использовать свои тесты в качестве примеров. Докутесты в Python являются хорошим примером для этого.

Если вы будете следовать этим рекомендациям, изменение реализации не должно быть проблемой.

Также по моему опыту рекомендуется проверять каждый "слой" вашего приложения. У вас будут атомарные юниты, которые сами по себе не имеют зависимостей, и у вас будут юниты, которые зависят от других юнитов, пока вы в конечном итоге не доберетесь до приложения, которое само по себе является юнитом.

Вы должны тестировать каждый слой, не полагаясь на тот факт, что, тестируя модуль A, вы также тестируете модуль B, от которого зависит модуль A (правило также применимо к наследованию.) Это также должно рассматриваться как деталь реализации, даже хотя вам может показаться, что вы повторяете себя.

Имейте в виду, что однажды написанные тесты вряд ли изменятся, а код, который они тестируют, изменится почти наверняка.

На практике также существует проблема ввода-вывода и внешнего мира, поэтому вы хотите использовать интерфейсы, чтобы при необходимости создавать макеты.

В более динамичных языках это не такая уж большая проблема, здесь вы можете использовать утиную типизацию, множественное наследование и миксины для составления тестовых случаев. Если вы вообще не любите наследование, вы, вероятно, делаете это правильно.

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

Пример, который я упоминал здесь ранее где-то, - это использование интерфейса для доступа к значениям конфигурации. Вместо того, чтобы смотреть прямо на ConfigurationManager, вы можете предоставить один или несколько интерфейсов, которые позволят вам получить доступ к значениям конфигурации. Обычно вы предоставляете реализацию, которая читает из файла конфигурации, но для тестирования вы можете использовать такую, которая просто возвращает тестовые значения или генерирует исключения или что-то еще.

Рассмотрим также ваш уровень доступа к данным. Тесная связь вашей бизнес-логики с конкретной реализацией доступа к данным затрудняет тестирование, не имея под рукой всей базы данных с нужными вам данными. Если ваш доступ к данным скрыт за интерфейсами, вы можете предоставить только те данные, которые необходимы для теста.

Использование интерфейсов увеличивает "площадь поверхности", доступную для тестирования, что позволяет проводить более детальные тесты, которые действительно проверяют отдельные блоки вашего кода.

Этот скринкаст объясняет гибкую разработку и TDD на практике для C#.

Кодирование по интерфейсу означает, что в вашем тесте вы можете использовать фиктивный объект вместо реального объекта. Используя хороший макет фреймворка, вы можете делать в своем макете объект все что угодно.

Другие вопросы по тегам