Если следовать определению унаследованного кода, данному Майклом Фезерсом (каждый код, не покрытый тестами), то первое направление работы с некоторым унаследованным кодом, требующим обновления, — поместить его в тестовую обвязку (написать для него тест). Но это часто легче сказать, чем сделать. Просто создать экземпляр (устаревшего) класса в тесте может быть на удивление сложно из-за того, как он обрабатывает свои зависимости.

Давайте рассмотрим две наиболее часто встречающиеся проблемы и способы их решения.

Случай с раздражающим параметром

Допустим, мы создаем приложение для интернет-магазина. Чтобы рассчитать цену товара, нам нужно применить некоторую бизнес-логику (скидки, ваучеры…) и учесть НДС (налог), который предоставляется каким-то внешним сервисом:

class CalculatePrice
{
	public function __construct(private VatDotComClient $vatProvider)
	{
	}

	public function calculate(Product $product)
	{
		$taxRate = $this->vatProvider->getRate($product->countryCode());
		// some business logic to calculate price
	}
}

Чтобы создать экземпляр нашего CalculatePriceservice в тесте, мы пишем:

public function itAppliesTheTaxRate()
{
	$calculateTax = new CalculatePrice(
		new VatDotComClient('api-key', 'secret')
	);
        // ...
}

Можете ли вы определить проблему здесь? Мы хотим протестировать бизнес-логику нашего сервиса — правильно ли он рассчитывает цену с учетом ставки НДС (какой бы она ни была). Но каждый раз, когда мы запускаем этот тест, VatDotComClientбудем делать HTTP-запросы к провайдеру. Это делает наш тест медленным (и зависимым от провайдера).

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

Что, если мы создадим какой-нибудь фиктивный НДС-клиент, возвращающий некоторые данные, которые мы контролируем (вместо того, чтобы общаться с каким-то реальным провайдером по сети). Затем мы могли бы использовать этого фиктивного провайдера в нашем тесте и настоящего в производственном коде. Звучит хорошо, но наш сервис зависит от (настоящего) VatDotComClient. Раздражает, да?

Чтобы обойти это, мы можем применить рефакторинг Extract Interface. Во-первых, мы создаем интерфейс (на основе VatDotComClient):

interface VatProvider
{
	public function getRate(string $countryCode): float;
}

Затем мы делаем наш CalculatePrice сервис зависимым от этого интерфейса (а не от конкретной реализации).

class CalculatePrice
{
	public function __construct(private VatProvider $vatProvider)
	{
	}

	public function calculate(Product $product)
	{
		$taxRate = $this->vatProvider->getRate($product->countryCode());
		// some business logic to calculate price
	}
}

Теперь наша существующая VatDotComClient является реализацией VatProvider, которую мы используем в продакшене, но мы также создадим нашу фиктивную реализацию:

class TestProvider implements VatProvider
{
	public function __construct(private float $rate)
	{
	}

	public function getRate(string $countryCode)
	{
		return $this->rate;
	}
}

Что мы можем использовать в нашем тесте:

public function itAppliesTheTaxRate()
{
	$calculateTax = new CalculatePrice(
		new TestProvider(0.25)
	);

	// ... Assert price what expected (with 0.25 tax rate)
}

Что хорошо в этом TestProvider, так это то, что он не будет обращаться к какой-либо реальной инфраструктуре (в данном случае вызовы HTTP), и мы также можем контролировать возвращаемые им фиктивные данные, поэтому мы можем принять это во внимание при настройке наших тестовых утверждений.

Случай скрытой зависимости

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

class ProcessPayment
{
	public function __construct()
	{
		// ...
	}

	public function process(Payment $payment)
	{
		// ...
	}
}

Судя по его конструктору, этот сервис не имеет зависимостей, поэтому его легко создать в тесте:

public function itRecordsThePayment()
{
	$payment = new Payment(340, 'EUR', 12);

	$processPayment = new ProcessPayment();
	$processpayments->process($payment);
	
	// ... Assert stuff
}

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

Что ж, при ближайшем рассмотрении нашего сервиса ProcessPayment мы видим, что он действительно имеет зависимость от класса Mailer, но скрыт в конструкторе (или в каком-то методе), и где-то между всей бизнес-логикой был вызов к нему для отправки электронного письма. .

class ProcessPayment
{
	public function __construct()
	{
		// some code
		$this->mailer = new Mailer('api-key', 'secret');
		// some code
	}

	public function process(Payment $payment)
	{
		// ...
	
		$this->mailer->send($payment->receiver(), new PaymentNotificationMessage($payment)):
		
	}
}

Первый шаг — сделать эту неявную (скрытую) зависимость явной. Для этого мы применяем конструктор параметроврефакторинг:

class ProcessPayment
{
	public function __construct(Mailer $mailer)
	{
		$this->mailer = $mailer;
	}

	public function process(Payment $payment)
	{
		// ...
	}
}

Теперь мы можем применить шаги Extract Interface и реализовать некую фиктивную почтовую программу, которую мы можем использовать в нашем тесте, чтобы мы фактически не отправляли электронную почту при тестировании бизнес-логики в службе ProcessPayment (и, конечно, мы все еще можем иметь какой-нибудь интеграционный тест, чтобы увидеть, действительно ли наша настоящая служба Mailer может отправлять электронные письма).

Синергия тестирования и хорошего дизайна

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

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

Вот почему вы должны стремиться использовать разработку через тестирование при разработке нового кода. Что касается устаревшего кода, если вам нужны дополнительные советы по работе с ним, прочтите книгу Майкла Физерса Эффективная работа с устаревшим кодом, чтобы получить дополнительные советы и рекомендации.