
Написание программного обеспечения — это битва между сложностью и простотой. Найти баланс между ними сложно. Компромисс заключается между длинными неподдерживаемыми методами и чрезмерной абстракцией. Слишком сильный наклон в любую сторону ухудшает читаемость кода и увеличивает вероятность дефектов.
Можно ли избежать дефектов? NASA пытается, но они также проводят огромное количество тестирования. Их программное обеспечение буквально критически важно для миссии — это единственный шанс. Для большинства организаций это не так, и большие объемы тестирования дороги и неппрактичны. Хотя нет замены тестированию, возможно писать устойчивый к дефектам код без тестирования.
За 20 лет программирования и архитектуры приложений я выделил четыре практики для снижения дефектов. Первые две практики ограничивают внесение дефектов, а последние две практики выявляют дефекты. Каждая практика — это обширная тема сама по себе, по которой написано много книг. Я свел каждую практику к нескольким абзацам и предоставил ссылки на дополнительную информацию, где это возможно.
1. Пишите простой код
Простое должно быть легким, но это не так. Написание простого кода — это сложно.
Некоторые прочитают это и подумают, что это означает использование простых языковых конструкций, но это не так — простой код не является глупым кодом.
Чтобы сохранить объективность, я использую цикломатическую сложность как меру. Есть другие способы измерения сложности и другие типы сложности, я надеюсь исследовать эти темы в последующих статьях.
Microsoft определяет цикломатическую сложность как:
Цикломатическая сложность измеряет количество линейно-независимых
путей через метод, которое определяется количеством и
сложностью условных ветвлений. Низкая цикломатическая сложность
обычно указывает на метод, который легко понять, протестировать и
поддерживать.
Что такое низкая цикломатическая сложность? Microsoft рекомендует поддерживать цикломатическую сложность ниже 25.
Честно говоря, я обнаружил, что рекомендация Microsoft по цикломатической сложности в 25 слишком высока. Для поддерживаемости и сложности я обнаружил, что идеальный размер метода составляет от 1 до 10 строк с цикломатической сложностью от 1 до 5.
Билл Вагнер в Effective C#, Second Edition писал о размере методов:
Помните, что перевод вашего C# кода в машинно-исполняемый код — это двухэтапный процесс. Компилятор C# генерирует IL, который доставляется в сборках. JIT-компилятор генерирует машинный код для каждого метода (или группы методов, когда задействована встраивание), по мере необходимости. Небольшие функции значительно облегчают JIT-компилятору амортизацию этой стоимости. Небольшие функции также с большей вероятностью являются кандидатами для встраивания. Дело не только в малости: Более простой поток управления имеет такое же значение. Меньше ветвлений управления внутри функций облегчает JIT-компилятору регистрацию переменных. Это не просто хорошая практика писать более ясный код; это то, как вы создаете более эффективный код во время выполнения.
Чтобы поставить цикломатическую сложность в перспективу, следующий метод имеет цикломатическую сложность 12.
public string ComplexityOf12(int status)
{
var isTrue = true;
var myString = "Chuck";
if (isTrue)
{
if (isTrue)
{
myString = string.Empty;
isTrue = false;
for (var index = 0; index < 10; index++)
{
isTrue |= Convert.ToBoolean(new Random().Next());
}
if (status == 1 || status == 3)
{
switch (status)
{
case 3:
return "Bye";
case 1:
if (status % 1 == 0)
{
myString = "Super";
}
break;
}
return myString;
}
}
}
if (!isTrue)
{
myString = "New";
}
switch (status)
{
case 300:
myString = "3001";
break;
case 400:
myString = "4003";
break;
}
return myString;
}
Общепринятая гипотеза сложности постулирует положительную корреляцию между сложностью и дефектами.
Предыдущая строка немного запутанная. В простейших терминах — поддержание простоты кода снижает количество дефектов.
2. Пишите тестируемый код
Исследования показали, что написание тестируемого кода без написания фактических тестов снижает количество дефектов. Это настолько важно и глубоко, что нуждается в повторении: Написание тестируемого кода без написания фактических тестов снижает количество дефектов.
Это поднимает вопрос: что такое тестируемый код?
Я определяю тестируемый код как код, который можно тестировать изолированно. Это означает, что все зависимости могут быть замокированы из теста. Пример зависимости — это запрос к базе данных. В тесте данные мокируются (подделываются) и делается утверждение ожидаемого поведения. Если утверждение истинно, тест проходит, если нет — он не проходит.
Написание тестируемого кода может показаться сложным, но на самом деле это легко при следовании Инверсии управления (Внедрение зависимостей) и принципам S.O.L.I.D. Вы будете удивлены легкостью и будете задаваться вопросом, почему потребовалось так много времени, чтобы начать писать таким образом.
3. Ревью кода
Одна из самых влиятельных практик, которую может принять команда разработки, — это ревью кода.
Ревью кода способствует обмену знаниями между разработчиками. Говоря из опыта, открытое обсуждение кода с другими разработчиками оказало наибольшее влияние на мои навыки написания кода.
В книге Code Complete Стива МакКоннелла, Стив приводит многочисленные тематические исследования о преимуществах ревью кода:
- Исследование организации в AT&T с более чем 200 людьми сообщило о 14-процентном увеличении производительности и 90-процентном снижении дефектов после того, как организация внедрила ревью.
- Страховая компания Aetna обнаружила 82 процента ошибок в программе, используя инспекции, и смогла сократить свои ресурсы разработки на 20 процентов.
- В группе из 11 программ, разработанных одной и той же группой людей, первые 5 были разработаны без ревью. Остальные 6 были разработаны с ревью. После того как все программы были выпущены в производство, первые 5 имели в среднем 4,5 ошибки на 100 строк кода. 6, которые были проинспектированы, имели в среднем только 0,82 ошибки. Ревью сократили ошибки более чем на 80 процентов.
Если эти цифры не убеждают вас принять ревью кода, то вы обречены дрейфовать в черную дыру, напевая Johnny Paycheck’s Take This Job and Shove It.
4. Модульное тестирование
Признаю, когда я сталкиваюсь с дедлайном, тестирование — это первое, что исчезает. Но преимущества тестирования нельзя отрицать, как показывают следующие исследования.
Microsoft провела исследование Эффективности модульного тестирования. Они обнаружили, что кодирование версии 2 (версия 1 не имела тестирования) с автоматизированным тестированием немедленно сократило дефекты на 20%, но за счет дополнительных 30%.
Другое исследование рассматривало разработку через тестирование (TDD). Они наблюдали увеличение качества кода более чем в два раза по сравнению с аналогичными проектами, не использующими TDD. Проекты TDD в среднем занимали на 15% больше времени для разработки. Побочным эффектом TDD было то, что тесты служили документацией для библиотек и API.
Наконец, в исследовании Покрытие тестами и дефекты после верификации:
… Мы обнаружили, что в обоих проектах увеличение покрытия тестами
связано с уменьшением проблем, сообщаемых в полевых условиях, при корректировке на
количество изменений до релиза…
Пример
Следующий код имеет цикломатическую сложность 4.
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
AgencySettingsRepository agencySettingsRepository = new AgencySettingsRepository();
var agencySettings = agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
EmailService emailService = new EmailService();
emailService.SendTemplate(new[] { newAuthAdmin.Email }, GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value =
string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
Давайте рассмотрим тестируемость приведенного выше кода.
Это простой код?
Да, цикломатическая сложность ниже 5.
Есть ли зависимости?
Да. Есть 2 сервиса AgencySettingsRepository
и EmailService
.
Можно ли мокировать сервисы?
Нет, их создание скрыто внутри метода.
Тестируем ли код?
Нет, этот код не тестируем, потому что мы не можем мокировать AgencySettingsRepository
и EmailService
.
Пример рефакторинга кода
Как мы можем сделать этот код тестируемым?
Мы внедряем (используя внедрение через конструктор) AgencySettingsRepository
и EmailService
как зависимости. Это позволяет нам мокировать их из теста и тестировать изолированно.
Ниже приведена рефакторинговая версия.
Обратите внимание, как сервисы внедряются в конструктор. Это позволяет нам контролировать, какая реализация передается в конструктор SendMail
. Затем легко передать фиктивные данные и перехватить вызовы методов сервиса.
public class SendEmail
{
private IAgencySettingsRepository _agencySettingsRepository;
private IEmailService _emailService;
public SendEmail(IAgencySettingsRepository agencySettingsRepository, IEmailService emailService)
{
_agencySettingsRepository = agencySettingsRepository;
_emailService = emailService;
}
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
var agencySettings = _agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
_emailService.SendTemplate(new[] { newAuthAdmin.Email },
GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value = string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
}
Пример тестирования
Ниже приведен пример тестирования в изоляции. Мы используем фреймворк мокирования FakeItEasy.
[Test]
public void TestEmailService()
{
//Given
//Using FakeItEasy mocking framework
var repository = A<IAgencySettingsRepository>.Fake();
var service = A<IEmailService>.Fake();
var agency = new Agency { Name = "Acme Inc." };
var user = new User { FirstName = "Chuck", LastName = "Conway", Email = "chuck.conway@fakedomain.com" }
//When
var sendEmail = new SendEmail(repository, service);
sendEmail.SendUserHadJoinedEmailToAdministrator(agency, user);
//Then
//An exception is thrown when this is not called.
A.CallTo(() => service.SendTemplate(A<Agency>.Ignore, A<User>.Ignore)).MustHaveHappened();
}
Заключение
Написание устойчивого к дефектам кода удивительно просто. Не поймите меня неправильно, вы никогда не напишете код без дефектов (если выясните как, дайте мне знать!), но следуя 4 практикам, изложенным в этой статье, вы увидите снижение дефектов, обнаруженных в вашем коде.
Подводя итог, Написание простого кода — это поддержание цикломатической сложности около 5 и небольшого размера методов. Написание тестируемого кода легко достигается при следовании инверсии управления и принципам S.O.L.I.D. Ревью кода помогает вам и команде понять предметную область и код, который вы написали — просто необходимость объяснить ваш код выявит проблемы. И наконец, Модульное тестирование может кардинально улучшить качество вашего кода и предоставить документацию для будущих разработчиков.
Автор: Чак Конвей специализируется на разработке программного обеспечения и генеративном ИИ. Свяжитесь с ним в социальных сетях: X (@chuckconway) или посетите его на YouTube.