Как писать тестируемый код

image

Если вы программист (или чего хуже архитектор), то можете ли вы ответить на такой простой вопрос: как писать НЕ тестируемый код? Призадумались? Если с трудом можете назвать хотя бы 3 способа добиться не тестируемого кода, то статья для вас.

Многие скажут: а зачем мне знать, как писать не тестируемый код, плохому хочешь меня научить? Отвечаю: если знать типичные паттерны не тестируемого кода, то, если они есть, можно легко увидеть их в своем проекте. А, как известно, признание проблемы — уже половина пути к лечению. Также в статье дается ответ, как собственно осуществляется такое лечение. Прошу под кат.
В статье не будет упора на конкретный язык программирования, ниже написанное актуально для всех процедурных языков. Но, как мне кажется, статья будет особенно полезна тем, кто программирует на динамических интерпретируемых языках, и не имел серьезного опыта разработки на типизированных компилируемых языках. Именно в коде данной категории разработчиков я чаще всего замечал описанные ниже паттерны. Примеры будут на псевдокоде, схожем с Java, C# и TypeScript.

Сразу оговорюсь, что в статье не будет рассмотрен вопрос о том, как нужно писать тесты. На эту тему и так существует немало статей. Будет рассмотрен вопрос, как нужно писать код, чтобы его можно было просто и красиво тестировать, а получившиеся тесты получались простыми и поддерживаемыми. Далее под понятием тест подразумевается красивый и чистый unit тест, который написан без различных хаков, без дополнительных «магических» библиотек для подмены зависимостей на лету, и прочего удовольствия, затрудняющего чтение и поддержку тестов. То есть тест в самом прекрасном значении этого слова.

Немного философии. Стоит ли вообще писать тесты? Мое мнение: если вы создаете проект, который будет развиваться, то вам просто необходимы тесты. Помимо того, что тесты выполняют свою прямую функцию (позволяют проверять соответствие кода требованиям), они как побочный эффект «выпрямляют» дизайн классов. Все потому, что на класс с «кривым» дизайном тесты не напишешь, следовательно, чтобы добиться тестируемость приходится рефакторить класс. Либо, если тесты пишутся до кода, дизайн класса сразу рождается правильным. Тестируемый код является переиспользуемым, так как мы можем повторно использовать его в тестах. А переиспользуемость — важный критерий правильного дизайна класса. Так же тесты, возможно, но совсем не обязательно, улучшат архитектуру приложения в целом. Как кто-то хорошо подметил: дизайн класса хорош ровно настолько, насколько данный класс можно протестировать с помощью unit тестов.

Список главных убийц тестируемого кода:


Добыча знаний


Добыча знаний происходит, когда метод требует один набор аргументов, но не использует их напрямую, а начинает «ковырять» эти аргументы в поисках других объектов. Типичные сценарии:

  • метод гуляет по объекту больше чем через одну точку (.)
  • метод не использует напрямую свой аргумент (использует его для получения другой информации)


Метод или конструктор должен требовать то, что ему реально нужно для работы. Он не должен сам заботиться о том, как все это получить/вычислить, выполняя какую либо работу. Нужно требовать минимально необходимый набор аргументов, для своей работы.

Пример из жизни: когда в магазине у вас просят заплатить за покупку, что вы делаете: даете кошелек, чтобы кассир сам взял оттуда деньги, или вы даете деньги? Думаю, аналогия понятна — класс кассир должен требовать на вход метода расчета класс деньги, а не класс кошелек.
Рассмотрим пару примеров.

до: не тестируемый код
class DiscountCard {
        DiscountCard(UserContext userContext) {
                this.user = userContext.getUser();
                this.level = userContext.getLevel();
                this.order = userContext.getOrder();            
        }
        
        // ...
}

// тесты

UserContext userContext = new UserContext();
userContext.setUser(new User("Ivan"));
PlanLevel level = new PlanLevel(143, "yearly");
userContext.setLevel(level);
Order order = new Order("SuperDeluxe", 100, true);
userContext.setOrder(order);
DiscountCard discountCard = new DiscountCard(userContext);

// можно тестировать



DiscountCard не использует напрямую userContext. Тому, кто будет писать тест, нужно будет изучить устройство класса DiscountCard, чтобы понять какие объекты там реально требуются, ведь userContext может содержать десятки объектов для инициализации. А это время и риск, что то сделать не так, а в результате тест может оказаться неправильным. Сделаем так, чтобы DiscountCard требовал то, что ему действительно нужно:

после: тестируемый код
class DiscountCard {
        DiscountCard(User user, PlanLevel level, Order order) {
                this.user = user;
                this.level = level;
                this.order = order;             
        }
        
        // ...
}

// тесты


User user = new User("Ivan");
PlanLevel level = new PlanLevel(143, "yearly");
Order order = new Order("SuperDeluxe", 100, true);

DiscountCard discountCard = new DiscountCard(user, level, order);

// можно тестировать



Также этот пример намеренно иллюстрирует знаменитый паттерн ServiceLocator, который хранит в себе все зависимости приложения. Именно поэтому такой паттерн считается анти паттерном. Старайтесь избегать его.

Рассмотрим еще один пример добычи знаний:

до: не тестируемый код
class SalesTaxCalculator {
        TaxTable taxTable;
        SalesTaxCalculator(TaxTable taxTable) {
                this.taxTable = taxTable;
        }
        float computeSalesTax(User user, Invoice invoice) {
                // аргумент "user" не используется напрямую
                Address address = user.getAddress();
                float amount = invoice.getSubTotal();
                return amount * taxTable.getTaxRate(address);
        }
}

// тесты

SalesTaxCalculator calc = new SalesTaxCalculator(new TaxTable());

Address address = new Address("Ленина 23 ...");
// много кода чтобы создать "user"
User user = new User(address, ...);
Invoice invoice = new Invoice(1, new ProductX(95.00));
assertEquals(calc.computeSalesTax(user, invoice), 100);



В тесте необходимо создать класс User, хотя от него требуется только адрес. То же самое можно сказать про класс Invoice. Опять же, от того, кто пишет тест, требуется «прошерстить» код SalesTaxCalculator, чтобы разобраться, что же там реально нужно. Багоёмкое место.

Также, SalesTaxCalculator невозможно переиспользовать в другом проекте, в котором нет классов User и Invoice.

после: тестируемый код
class SalesTaxCalculator {
        TaxTable taxTable;
        SalesTaxCalculator(TaxTable taxTable) {
                this.taxTable = taxTable;
        }
        
        float computeSalesTax(Address address, float amount) {
                return amount * taxTable.getTaxRate(address);
        }
}

// тесты 

SalesTaxCalculator calc = new SalesTaxCalculator(new TaxTable());
Address address = new Address("Ленина 23 ...");

assertEquals(calc.computeSalesTax(address, 95.00), 100);



Теперь класс требует только то, что нужно, и тесты стали прозрачными.

Оператор new в бизнес коде


Пожалуй, этому паттерну можно дать золотую медаль за создание не тестируемого кода.
Давайте начнем разбор с простого примера:

до: не тестируемый код
class House {
        Kitchen kitchen; 
        Bedroom bedroom = new Bedroom();
        
        House() {
                this.kitchen = new Kitchen(new Refrigerator());
        }
        
        // ...
}

// тесты

House house = new House();
// ээээм, непонятно как тестировать
// нет доступа к kitchen и bedroom




В этом коде плохо все. Его невозможно протестировать, потому что вызов любого метода House приведет к вызову kitchen и/или bedroom, а их мы не контролируем. Если они общаются с БД или шлют запросы в сеть, то тесты обречены. При помощи полиморфизма невозможно подменить кухню или спальню, наш дом жестко завязан на определенные классы. Как сделать код тестируемым?

после: тестируемый код
class House {
        Kitchen kitchen; 
        Bedroom bedroom;
        
        House(Kitchen kitchen, Bedroom bedroom) {
                this.kitchen = kitchen;
                this.bedroom = bedroom;
        }
        
        // ...
}

// тесты
Kitchen kitchen = new DummyKitchen(null); 
Bedroom bedroom = new DummyBedroom();
House house = new House (kitchen, bedroom);

// Замечательно, легковесные моки под моим контролем



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

Но некоторые читатели скажут: «Минуточку. Если теперь все зависимости надо требовать в конструктор, то для того чтобы создать „глубокий“ дочерний класс (класс, находящийся в глубине графа зависимостей приложения), мне придется прокидывать все зависимости такого класса через родительские классы. А это приведет к тому, что конструкторы „верхних“ классов (классы, находящиеся ближе к началу графа зависимостей) превратятся в скопище всего и вся. А относительно примера это значит, что тому классу, который делает new House теперь придется самому требовать в конструктор kitchen, и bedroom. Но почему он должен про них знать? Это ж бред.»

Но в этих рассуждениях есть одна ошибка. Следуя выше обозначенному принципу, класс, создающий House, вообще НЕ должен его создавать. Ведь, согласно принципу, он сам должен требовать House себе в зависимости. Но тогда откуда же, в конце концов, возьмется класс House? Он будет создан на старте приложения (если время жизни House совпадает с временем жизни приложения) либо его создаст фабрика (если время жизни House меньше времени жизни приложения):

создание класса House
class HouseFactory {
        House build() {
                Kitchen kitchen = new Kitchen(new Refrigerator()); 
                Bedroom bedroom = new Bedroom();
                return new House (kitchen, bedroom);
        }
}



Но некоторые опять возразят: «Так что теперь, создавать фабрику для каждого класса, увеличивая объем кода в 2 раза? Это ж бред». На самом деле одна фабрика создает и связывает классы с одинаковым временем жизни. А в реальных приложениях не такое большой количество кода с различным временем жизни. Для примера рассмотрим, какие типы объектов, разбитых по различному времени жизни, существуют в веб приложении:

  • долго живущие объекты (создаваемые на старте приложения)
  • объекты сессии
  • объекты запроса
  • редко существуют объекты с временем жизни меньше запроса


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

  • группа бизнес кода
  • группа связующего кода


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

Связующий код — код, который позволяет бизнес коду взаимодействовать, связывает его между собой. Связующий код не содержит в себе никакой бизнес логики. Сюда относятся фабрики, стартовая точка приложения и прочие связующие места. В связующем коде много оператора new.

Такой подход с разделением кода на 2 группы позволяет избежать смешивания логики приложения с созданием объектного графа приложения. Поэтому всегда при использовании оператора new задумывайтесь, в той ли группе кода вы его применяете.

Исключение:
оператор new можно использовать в бизнес коде для создания объектов-хранилищ, не содержащих поведения (например, HashMap, Array).

Рассмотрим еще один распространенный случай на следующем примере:

до: не тестируемый код
class DocumentActions {
        Network network;
        DocumentModel documentModel;
        
        DocumentActions(Network network, DocumentModel documentModel) {
                // не используется напрямую
                this.network = network;
                this.documentModel = documentModel;
        }
        changeTextStyle(int textOffset, TextStyle style) {
                Revision revision = new Revision(this.network, this.documentModel, textOffset);
                revision.updateTextStyle(style);
                revision.apply();
        }
        insertParagraph(int textOffset, ParagraphProps props, ParagraphStyle style) {
                Revision revision = new Revision(this.network, this.documentModel, textOffset);
                revision.addParagraph(props);
                revision.updateParagraphStyle(style);
                revision.apply();
        }
        
        // много таких же методов с new Revision
}

// тесты

// кто знает как трудно создать этот класс
Network network = new Network(...); 
DocumentModel documentModel = new DocumentModel(...);

DocumentActions docActions = new DocumentActions(network, documentModel);

// не понятно как проверить методы docActions



Класс DocumentActions в каждом своем методе создает класс Revision, лишая тесты возможности подменить реализацию этого класса на моки. А что еще хуже DocumentActions требует в конструктор классы, которые не использует. Но что делать, если для создания Revision каждый раз нужно отдавать третий аргумент textOffset, который заранее не известен? Выход: создать класс, который возьмет на себя знание, о том, как создавать Revision. А это не что иное, как фабрика:

после: тестируемый код
class DocumentActions {
        RevisionFactory revisionFactory;
        
        DocumentActions(RevisionFactory revisionFactory) {
                this.revisionFactory = revisionFactory;
        }
        changeTextStyle(int textOffset, TextStyle style) {
                Revision revision = this.revisionFactory.build(textOffset);
                revision.updateTextStyle(style);
                revision.apply();
        }
        insertParagraph(int textOffset, ParagraphProps props, ParagraphStyle style) {
                Revision revision = this.revisionFactory.build(textOffset);
                revision.addParagraph(props);
                revision.updateParagraphStyle(style);
                revision.apply();
        }
        
        // много таких же методов с this.revisionFactory.build
}

class RevisionFactory {
        Network network;
        DocumentModel documentModel;
        
        RevisionFactory(Network network, DocumentModel documentModel) {
                this.network = network;
                this.documentModel = documentModel;
        }
        Revision build(int textOffset) {
                return new Revision(this.network, this.documentModel, textOffset); 
        }
}

// тесты

class MyMockRevision extends Revision {
        // мокаем нужные методы
}

class MyMockRevisionFactory extends RevisionFactory {
        public Revision revision;
        Revision build(int textOffset) {
                this.revision = new MyMockRevision(this.network, this.documentModel, textOffset); 
                return this.revision;
        }
}

RevisionFactory revisionFactory = new MyMockRevisionFactory(null, null);
DocumentActions docActions = new DocumentActions(revisionFactory);

// можно тестировать 
// есть доступ к Revision через revisionFactory.revision



Фабрика знает, что для создания Revision точно нужны Network и DocumentModel. Поэтому она сама требует эти классы для себя. А все динамические параметры (textOffset), для создания Revision, будут требоваться в качестве аргументов метода build фабрики.

Теперь, если в будущем класс Revision потребует еще один постоянный аргумент в конструктор, то не составит труда немного поправить RevisionFactory, а класс DocumentActions останется вообще без изменений.

Глобальные переменные и синглтоны


Думаю, все согласятся с тем, что глобальные переменные это зло. Но многие не видят ничего плохого в синглтонах, хотя по своей сути это все те же глобальные переменные. Так чем плохи синглтоны? Вот 2 причины (хотя достаточно любой из них):

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

2) Наличие синглтонов заставляет ваши классы врать о своих зависимостях, так как вносит «невидимые» зависимости. Чтобы понять реальные зависимости класса, вам нужно полностью читать его код, в место того, чтобы просто взглянуть на список зависимостей конструктора/метода. И рано или поздно это приведет к тому, что тесты начнут влиять друг на друга, через скрытое глобальное состояние.

Пример из практики, иллюстрирующий сразу обе проблемы: в web приложении возникла необходимость логгировать действия пользователя. Для этого создали синглтон, который использовался в ряде классов. Упомяну, что синглтон использовал jQuery для получения дополнительной информации из DOM. Все шло хорошо до тех пор, пока не понадобилось переиспользовать часть классов в node версии приложения. Эта версия периодически начала падать в ходе тестирования. Оказалось, что в эту версию попали классы, которые использовали логгер синглтон, а в node нет DOM. Эти классы использовали «невидимую» зависимость (причина 2), в результате чего такая ситуация оказалась возможной. Не была заложена возможность подменить логгер на другой (причина 1), из-за чего пришлось переделывать некоторые классы.

Некоторые скажут: «я осознанно использую синглтон, чтобы иметь только единственный экземпляр класса на все приложение». Но создавая синглтон, вы делаете экземпляр класса единственным на все пространство исполнения кода (jvm в java, rhino или V8 в javascript), а не на все приложение. Просто в большинстве случаев внутри пространства исполнения кода находится только одно приложение, и получается, что эти пространства совпадают. Но это не так для тестов. Каждый тест — часть приложения, запущенная в одном пространстве исполнения с другими тестами.

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

К тому же, возможно, когда-нибудь вам потребуется иметь более одной копии приложения или его части в одном пространстве исполнения кода. Тогда пространство приложения не будет равно пространству исполнения кода. И не останется ничего другого как проделать работу по избавлению от синглтонов.

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

попытка 1
PhoneAccount phoneAccount = new PhoneAccount('79008001020');
phoneAccount.addMoney(100);
expect(phoneAccount.getBalance()).toBe(100);



Запускаете изолированно свой тест и в рантайме получаете ошибку доступа к null. Что пошло не так? Вы спрашиваете у коллеги, писавшего этот класс. Он, долго думая, вспоминает, что нужно инициализировать класс синглтон PhoneAccountTransactionProcessor. Вы думаете: «а как я должен был до этого догадаться?». Затем, добавляете:

попытка 2
PhoneAccountTransactionProcessor.init(...);
PhoneAccount phoneAccount = new PhoneAccount('79008001020');
phoneAccount.addMoney(100);
expect(phoneAccount.getBalance()).toBe(100);



Но опять при запуске получаете ошибку доступа к null. В недоумении вы снова спрашиваете коллегу: «что я делаю не так?». На что получаете ответ: «А ты инициализировал очередь транзакций — AccountTransactionQueue?». Немного раздраженный вы дописываете код, но что-то вам подсказывает, что этим дело не ограничится, и просите не уходить «опытного» коллегу.

попытка 3
AccountTransactionQueue.start(...);
PhoneAccountTransactionProcessor.init(...);
PhoneAccount phoneAccount = new PhoneAccount('79008001020');
phoneAccount.addMoney(100);
expect(phoneAccount.getBalance()).toBe(100);



И снова ошибка. Но не успеваете вы задать вопрос, как коллега выдает: «Ты же не подключил базу транзакций!». Дописываете, глубоко вздыхая:

попытка 4
TransactionsDataBase.connect(...)
AccountTransactionQueue.start(...);
PhoneAccountTransactionProcessor.init(...);
PhoneAccount phoneAccount = new PhoneAccount('79008001020');
phoneAccount.addMoney(100);
expect(phoneAccount.getBalance()).toBe(100);



Наконец, тест проходит.

Думаю, вы уже поняли, что не так с этим кодом — какая-то черная магия. Три класса врут о своих зависимостях, порядок инициализаций должен быть именно таким, хотя код не диктует вам именно такой порядок.

А что если не будет синглтонов, и каждый класс будет требовать все что ему нужно для работы в конструктор?

правильный код
TransactionsDataBase db = new TransactionsDataBase(...);

AccountTransactionQueue transactionQueue;
transactionQueue = new AccountTransactionQueue(db);

PhoneAccountTransactionProcessor transactionProcessor;
transactionProcessor = PhoneAccountTransactionProcessor(transactionQueue);

PhoneAccount phoneAccount = new PhoneAccount('79008001020', transactionProcessor);
phoneAccount.addMoney(100);

expect(phoneAccount.getBalance()).toBe(100);



Теперь нет никакой магии, никаких скрытых каналов общения между классами. Любой новый член команды сможет самостоятельно написать такой тест, потому что он сразу увидит все зависимости. Сам код диктует порядок инициализации, нельзя сделать по-другому. И это огромный плюс, ведь в реальных приложениях могут быть десятки строк с инициализацией классов. И еще одно важное заключение: в таком коде появляется возможность передать null вместо класса (там, где это возможно) либо заменить класс моком. В коде с синглтонами такое было невозможно.

Еще пример:

до: не тестируемый код
class LoginService {
        private static LoginService instance;
        private LoginService() {};
        static LoginService getInstance() {
                if (instance == null) {
                        instance = new RealLoginService();
                }
                return instance;
        }
        // вызвать перед началом тестов
        // не использовать за пределами тестов, строго на строго!
        static setForTest(LoginService testDouble) {
                instance = testDouble;
        }
        // вызвать после тестов
        // не использовать за пределами тестов, строго на строго!
        static resetForTest() {
                instance = null;
        }
        // ... 
}

// в другом месте
class AdminDashboard {
        boolean isAuthenticatedAdminUser(User user) {
                LoginService loginService = LoginService.getInstance();
                return loginService.isAuthenticatedAdmin(user);
        }
}

// тесты 

AdminDashboard adminDashboard = new AdminDashboard()
assertTrue(adminDashboard.isAuthenticatedAdminUser(user));
// нет способа подменить LoginService, будет использован настоящий
// жизнь боль



Устраняем синглтон:

после: тестируемый код
class LoginService {
        // ... 
}

// в другом месте
class AdminDashboard {
        AdminDashboard(LoginService loginService) {
                this.loginService = loginService;
        }
        boolean isAuthenticatedAdminUser(User user) {
                return this.loginService.isAuthenticatedAdmin(user);
        }
}

// тесты 

AdminDashboard adminDashboard = new AdminDashboard(new MockLoginService());
assertTrue(adminDashboard.isAuthenticatedAdminUser(user));



Теперь можно легко и просто подменять LoginService, а так же не нужны методы «только для тестов».Исключения, в каких случаях синглтоны все-таки можно использовать:

  • синглтон можно использовать для неизменяемых (immutable) объектов, то есть для тех которые не изменяются в течении жизни приложения. Например: константные настройки
  • синглтон можно использовать, когда объект с информацией не используется в приложении. Например, логгер или google аналитика. Приложение только записывает информацию, но не может прочитать ее. Такие синглтоны безопасны для приложения, поскольку у них как будто нет глобального состояния, это просто труба в некуда. Но если вам необходимо протестировать, что запись в логи действительно происходит, то вам придется поменять зависимости и спускать логгер в конструктор.


Также некоторые системные объекты имеют скрытые глобальные переменные, например math.random и new Date(). Если вы хотите тестировать классы, которые используют такие объекты, то вам необходимо создать свои обертки ними, чтобы иметь возможность подменять их.

Матерый конструктор


Матерый конструктор — конструктор, который делает что-то кроме инициализации полей своего класса. Строго говоря, конструктор должен отвечать только за инициализацию полей класса, а любая другая работа нарушает принцип единой ответственности (Single Responsibility Principle). Такая «лишняя» работа в конструкторе затрудняет его тестирование, так как тест не может передать свои заглушки зависимости в тестируемый класс. Вот типичные паттерны матерых конструкторов:

Последние 3 паттерна характерны не только для конструкторов, поэтому они были рассмотрены для общего случая выше.

Инициализация аргументов конструктора


до: не тестируемый код
class Metro {
        TicketPrices ticketPrices;
        Metro(TicketPrices ticketPrices) {
                this.ticketPrices = ticketPrices;
                ticketPrices.setCostCalculator(new MoscowCostCalculatorWithVerySlowConstructor());
        }
}

// очень медленный тест

TicketPrices ticketPrices = new TicketPrices();
Metro metro = new Metro(ticketPrices);
expect(metro.isWork()).toBe(true);



Конструктор класса Metro выполняет не свою обязанность — инициализирует аргумент. Такой код плох по многим причинам, но нас интересует тестируемость. Если инициализация будет медленной, то все тесты на Metro будут выполняться долго.

после: тестируемый код
class Metro {
        TicketPrices ticketPrices;
        Metro(TicketPrices ticketPrices) {
                this.ticketPrices = ticketPrices;
        }
}

class TicketPricesFactory {
        TicketPrices build() {
                TicketPrices ticketPrices = new TicketPrices();
                ticketPrices.setCostCalculator(new VerySlowMoscowCostCalculator());     
                return ticketPrices;
        }
}


// test

TicketPrices ticketPrices = new TicketPrices();
ticketPrices.setCostCalculator(null);   
Metro metro = new Metro(ticketPrices);
expect(metro.isWork()).toBe(true);




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

Условия и циклы


до: не тестируемый код
class Car {
        IEngine engine;
        Car() {
                if (FLAG_ENGINE.get()){
                        this.engine = new V8Engine();
                } else {
                        this.engine = new V12Engine();
                }
        }
}

// test

// эээм, надо установить FLAG_ENGINE в нужное положение
Car car = new Car();
// тестируем один из двух вариантов настоящего двигателя



Тест завязан на некий флаг. Априори можно протестировать только 2 варианта двигателей.

после: тестируемый код
class Car {
        IEngine engine;
        Car(IEngine engine) {
                this.engine = engine;
        }
}

class EngineFactory {
        IEngine build(boolean isV8) {
                if (isV8){
                        return new V8Engine();
                } else {
                        return new V12Engine();
                }
        }
}

// создание Сar выглядит так
Car car = new Car(new EngineFactory().build(FLAG_ENGINE.get()));

// test

IEngine simpleEngine = new SimpleEngine();
Car car = new Car(simpleEngine);

// тестируем как хотим, simpleEngine под нашим контролем 
// можно использовать любой двигатель



Перенос части конструктора в другие методы класса


до: не тестируемый код
class Voicemail {
        User user;
        private List<Call> calls;
        Voicemail(User user) {
                this.user = user;
        }
        
        init(Server server) {
                this.calls = server.getCallsFor(this.user);
        }
        
        // ТОЛЬКО ДЛЯ ТЕСТОВ, НЕ ИСПОЛЬЗОВАТЬ!!!
        setCalls(List<Call> calls) {
                this.calls = calls;
        }
        
        // ...
}

// тесты

User dummyUser = new DummyUser();
Voicemail voicemail = new Voicemail(dummyUser);
voicemail.setCalls(buildListOfTestCalls());



В этом примере при помощи метода init, забирающего на себя часть работы конструктора, пытаются облегчить жизнь в тестах. init будет использоваться в боевом коде, а в тестах будут подставляться нужные calls в обход тяжелого вызова init. Казалось бы, ничего плохого тут нет, и тесты можно нормально писать. Но это не выход. Нарушается принцип единой ответственности — за инициализацию класса отвечают несколько мест кода. Это вносит путаницу в код и затрудняет добавление нового функционала в такой класс. К тому же не тестируется метод init. Как правило, наличие таких методов как init, initialize, setup говорит о том, что класс берет на себя слишком много обязанностей. В данном примере класс Voicemail знает о способе получения звонков (server.getCallsFor). Но способ получения звонков не должен интересовать Voicemail:

после: тестируемый код
class Voicemail {
        List<Call> calls;
        Voicemail(List<Call> calls) {
                this.calls = calls;
        }
        
        // ...
}

class ProviderGetCalls {
        List<Call> getCalls(Server server, User user) {
                return server.getCallsFor(user);
        }
}

// тесты

Voicemail voicemail = new Voicemail(buildListOfTestCalls());

// теперь можно тестировать как угодно



Заключение


Многие принципы, которые были приведены в статье (не использовать оператор new в бизнес коде, требовать все зависимости явно в конструктор), известны давно, и все вместе они образуют принцип Dependency Injection.

Если для вашего языка существует IoC контейнер, то я вас поздравляю — вы избавлены от необходимости писать системный код (фабрики), о котором шла речь выше. За вас эту работу сделает IoC контейнер на основе заданной конфигурации. Именно для этого IoC контейнеры и были придуманы — избавить разработчиков от написания однообразных фабрик.

В языках, для которых нет IoC контейнеров (в основном динамические языки), тем не менее, ничего не мешает применять принцип Dependency Injection. Просто придется в ручную писать системный код, что абсолютно не является катастрофой.

В заключение хочу отметить, что всё выше написанное не претендует на полноту охвата данной темы. Все изложенное основано на личном опыте (два крупных проекта), статьях по данной теме (особенно помог «прозреть» блог одного из разработчиков angular Miško Hevery), а также на спорах и общении с коллегами.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

© Habrahabr.ru