Тесты, деньги и техдолг (сказ из жизни одного Java-проекта)
Дорогой друг, эта статья отчасти о тестировании, а отчасти о деньгах и жизни. Я написал её после того, как в очередной раз пришлось рассказывать новому сотруднику команды о том, как мы пишем тесты, когда и почему мы это делаем. Теперь я просто буду давать на эту статью ссылку, чтобы не повторяться. Я выражаю свой личный взгляд и мнение, надеясь, что и тебе он тоже будет полезен.
Сказ мой о разработке на Java, при этом всё нижеизложенное справедливо и для других языков программирования. От смены языков люди и проблемы в тестировании не меняются.
Отказ от ответственности (aka disclaimer): все персонажи являются вымышленными, и любое совпадение с реально живущими или когда-либо жившими людьми случайно.
Что вы узнаете из этой статьи:
- зачем программисты пишут тесты;
- почему программисты НЕ пишут тесты;
- кто такие нигилисты и причём здесь деньги.
Кому может быть интересна эта статья:
- нигилистам и не знающим, кто это такие;
- программистам-одиночкам, не привыкшим работать в команде;
- студентам, только начинающим работать в коллективе.
Очень важно, чтобы в вашей команде разработка ПО велась с использованием Continuous Integration, она же CI, она же «Непрерывная Интеграция».
Continuous integration — это практика разработки, которая требует работать с единым репозиторием кода, часто его актуализировать, объединять с основной веткой кода, автоматически тестировать для проверки работоспособности.
Это позволяет командам находить ошибки на ранней стадии, решать интеграционные проблемы.
Конечная цель — получить работоспособный артефакт проекта.
CI Pipeline
Процесс выглядит так:
- Есть репозиторий, в котором мы храним текстовые файлы с исходным кодом программы.
- Необходимо их достать, скомпилировать и прогнать тесты.
- Получившуюся программу записать в бинарный файл, например, в Docker-образ, с помощью команды
docker build
. - Конечный артефакт сохранить в репозиторий бинарных файлов.
В ходе сборки крайне желательно протестировать программу:
- выполнить заранее написанные модульные (unit) тесты;
- выполнить заранее написанные функциональные (functional) тесты;
- провести дымовое тестирование (smoke testing);
- при желании, можно провести и интеграционное тестирование.
Дорогой друг, ты получаешь артефакт, чтобы позднее развернуть программу в каком-то окружении. Чтобы не было двойного толкования по разным видам тестов, я объясню своими словами, что это такое.
Модульное тестирование (unit testing)
Применяется для проверки малого независимого блока кода: утилитных методов, методов с бизнес-логикой.
Пример: конвертирование входных данных из объекта A
в результирующий объект Б
.
@Test
void defineBirthdayShouldReturnDateSuccessfully() {
LocalDate expectedDate = LocalDate.of(1991, 5, 29);
String sourceData = "1991 05 29";
LocalDate resultDate = projectService.defineBirthday(sourceData);
assertEquals(expectedDate, resultDate);
}
Функциональное тестирование (functional testing)
C его помощью проверяется функциональная связность нескольких блоков для решения одной общей задачи. Пример: создание пользователя:
- проверить входящие данные;
- создать записи в основной таблице;
- создать записи в таблице аудита;
- проверить исполнение.
@Test
void nextUserShouldPersistUserObjectSuccessfully() {
User user = buildTestUser();
UserAudit userAudit = buildTestUserAudit(user);
doReturn(user).when(userDAO).saveUser(eq(user));
doReturn(userAudit).when(userAuditDAO).saveUserAudit(eq(userAudit));
projectService.nextUser(user);
verify(userDAO).saveUser(eq(user));
verify(userAuditDAO).saveUserAudit(eq(userAudit));
}
Дымовое тестирование (smoke testing)
В названии есть игра слов, которая пришла из электротехники: после сборки схемы цепи её включали в сеть и смотрели, не пошёл ли где дым. По сути, это простой интеграционный тест, с помощью которого мы проверяем, что служба способна собраться, запуститься и ответить на простой запрос. Например, web-запрос по URI /ping
или /version
с предсказуемым ответом. Такие тесты легче писать, чем интеграционные, а ведь наша главная задача — быстрее узнать о проблеме.
Пример: backend-слой не женится c web-слоем, о чём может сообщить ошибка, к примеру, с Dependency injection.
@Test
public void getPingShouldReturnSuccessResponse() {
ServerResponse response = executeGet("/ping");
StatusDTO responseDTO = MAPPER.readValue(response.body, StatusDTO.class);
assertEquals(OK_200, response.code);
assertEquals(new StatusDTO("up"), responseDTO);
verify(rabbitService).hasRabbitConnection();
}
Интеграционное тестирование (integration testing)
Главный вид тестов. Мы проверяем не какие-то маленькие элементы программы, а задачи, функционально затрагивающие несколько служб, поэтому кода может быть на порядок больше.
Пример: этапы смены пароля пользователя:
- проверка пароля и пароля для будущей замены;
- создание нового тестового пользователя;
- проверка аутентификации нового пользователя;
- отправка запроса для службы SMS-уведомлений о смене пароля;
- смена пароля с кодом из SMS;
- проверка аутентификации пользователя с новым паролем;
- удаление тестового пользователя.
@Test
public void stage001_validateUserPasswords() {
String password = genPassword();
String nextPassword = genPassword();
setPassword(password);
setNextPassword(nextPassword);
List.of(password, nextPassword).forEach(pwd -> {
var dto = PasswordValidateRequest.builder().password(pwd).build();
ResponseEntity response = restTemplate
.postForEntity(baseUrl + API_USERS_URI + "/PasswordValidateRequests", dto, Void.class);
assertEquals(HttpStatus.OK, response.getStatusCode());
});
}
@Test
public void stage002_createNewUser() {
String login = buildPhoneNumber();
String password = getPassword();
setLogin(login);
UserDTO dto = buildUser(login, password, INTERNET);
HttpHeaders headers = new HttpHeaders();
headers.add(ACCESS_HEADER, ACCESS_TOKEN);
HttpEntity httpEntity = new HttpEntity<>(dto, headers);
ResponseEntity response = restTemplate
.exchange(baseUrl + SRV_USERS_URI, HttpMethod.POST, httpEntity, UserDTO.class);
UserDTO data = response.getBody();
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull("response data is null", data);
setUserId(data.getId());
assertNotNull("user id undefined", data.getId());
assertEquals("user login undefine", login, data.getUserName());
assertTrue("user is not active", data.getActive());
}
@Test
public void stage003_readUserByAuthSuccessfully() {
readUserByAuth();
}
@Test
public void stage010_sendSmsNotificationToRestorePassword() {
String phone = getLogin();
UserPhone dto = new UserPhone(phone);
ResponseEntity response = restTemplate
.postForEntity(baseUrl + API_USERS_URI + "/restore_password", dto, Void.class);
assertEquals("SMS hasn't sent", OK, response.getStatusCode());
var notification = notificationRepository.findLastByPhone(phone);
assertEquals("SMS_RESTORE_PASSWORD_CODE", notification.getSmsTemplate());
assertNotNull("no confirm SMS data", notification.getContentData());
var smsData = smsDataReader.readValue(notification.getContentData());
assertNotNull("no sms code", smsData.getCode());
setSmsConfirmCode(smsData.getCode());
}
@Test
public void stage011_patchShouldChangeUserPasswordBySmsCode() {
String smsCode = getSmsConfirmCode();
String login = getLogin();
String nextPassword = getNextPassword();
setPassword(nextPassword);
UserPhonePassword dto = UserPhonePassword.builder()
.password(nextPassword).phone(login).code(smsCode)
.build();
HttpEntity httpEntity = new HttpEntity<>(dto, new HttpHeaders());
ResponseEntity response = restTemplate.exchange(baseUrl + API_USERS_URI + "/password", PATCH, httpEntity, Void.class);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
public void stage012_readUserByAuthSuccessfully() throws IOException {
readUserByAuth();
}
@Test
public void stage099_deleteUser() {
Object userId = getUserId();
HttpHeaders headers = new HttpHeaders();
headers.add(ACCESS_HEADER, ACCESS_TOKEN);
HttpEntity> httpEntity = new HttpEntity<>(HttpEntity.EMPTY, headers);
ResponseEntity before = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/{id}", HttpMethod.GET, httpEntity, String.class, userId);
assertEquals(HttpStatus.OK, before.getStatusCode());
ResponseEntity response = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/{id}", DELETE, httpEntity, Void.class, userId);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
ResponseEntity after = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/{id}", HttpMethod.GET, httpEntity, Void.class, userId);
assertEquals(HttpStatus.NOT_FOUND, after.getStatusCode());
}
private void readUserByAuth() throws IOException {
String login = getLogin();
String password = getPassword();
HttpHeaders headers = authHeaders(login, password);
headers.add(ACCESS_HEADER, ACCESS_TOKEN);
HttpEntity> httpEntity = new HttpEntity<>(HttpEntity.EMPTY, headers);
ResponseEntity response = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/me",GET, httpEntity, String.class);
assertEquals(OK, response.getStatusCode());
UserDTO data = userReader.readValue(response.getBody());
assertNotNull("user id undefined", data.getId());
assertTrue("user is not active", data.getActive());
}
Дорогой друг, последовательность вызова методов в интеграционном тесте проверяет тот же сценарий, которым будет пользоваться клиент на боевом сервере. Наша задача — точно узнать, что ни на одном из этапов мы не зафакапили
, и на выходных можем нормально почилить
, а не сидеть за компутером
вместо тусовочки с бестфрендами
.
В теории всё гладко: тесты пишутся, сервисы деплоятся, лавэха му довольные клиенты платят деньги = профит. На практике возникают проблемы… проблемы в применении того, что было озвучено выше.
Здесь-то, мой дорогой друг, и начинается история нашего героя. Позволь представить тебе молодого программиста… Большой Крис.
Фото старое, из прошлой жизни Криса. Не переживай, Большой Крис нормальный спокойный парень, недавно отсидевший за угон автомобиля с отягчающими обстоятельствами. Крис решил, что стоит изменить свою жизнь и вступить на путь истинный, став программистом. Ну, не таксовать же всю жизнь, в самом деле? Реклама в Ютубчике
так и трубит, что программисты очень нужны, и деньжата у них, вроде бы, водятся. Вот и решил Крис, что лучше делать хорошие правильные вещи, работать с умными людьми и зарабатывать деньги. Иначе зачем ты вообще читаешь эту статью?
Изучил он парочку онлайн-курсов, прочитал «Чистый код» Роберта Мартина, прошёл несколько собеседований на позицию Java Junior Developer, и был принят на работу. В назначенный день Крис приходит в команду молодым разработчиком. Ему говорят: «Вот тебе наш проект. Можешь с ним ознакомиться».
Крис видит, что проект хранится в репозитории Git, поэтому первым делом скачивает его на рабочий компьютер командой:
git pull https://internet.com/any-repo/money-transfer.git
Затем запускает команду сборщика проекта. Обычно это либо Gradle, либо Maven:
$ ./gradlew clean build
$ ./mvnw clean package
Но ничего не получается, во время прохождения тестов возникают какие-то ошибки:
Tests in error:
restorePasswordByCall_Phone(uk.bank.services.UserServiceTest): PreparedStatementCallback; SQL [INSERT INTO authorities(user_id, authority) VALUES (?, ?)]; ERROR: deadlock detected(..)
restorePasswordByCall_Phone(uk.bank.services.UserServiceTest): StatementCallback; uncategorized SQLException for SQL [truncate users cascade]; SQL state [25P02]; error code [0]; ERROR: current transaction is aborted, commands ignored until end of transaction block; nested exception is org.postgresql.util.PSQLException: ERROR: current transaction is aborted, commands ignored until end of transaction block
getUsersAuthoritiesShouldGetMapRoles(uk.bank.services.UserRoleServiceTest): StatementCallback; SQL [truncate users cascade]; ERROR: deadlock detected(..)
getUsersAuthoritiesShouldGetMapRoles(uk.bank.services.UserRoleServiceTest): StatementCallback; uncategorized SQLException for SQL [truncate authorities cascade]; SQL state [25P02]; error code [0]; ERROR: current transaction is aborted, commands ignored until end of transaction block; nested exception is org.postgresql.util.PSQLException: ERROR: current transaction is aborted, commands ignored until end of transaction block
Если присмотреться, то можно заметить:
ERROR: current transaction is aborted, commands ignored until end of transaction block
Поясню: приложение проигнорировало ошибку в транзакции и продолжило формировать запросы дальше.
Крис хмурит брови, подходит к кому-то из команды и спрашивает: «Что это такое?». Ему отвечают: «Какая-то странная ошибка. Попробуй ещё раз».
Попытка №2. Возникает другая ошибка:
Caused by: org.postgresql.util.PSQLException: Connection to 172.18.18.18:5463 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:247)
Ключевое здесь:
PSQLException: Connection to 172.18.18.18:5463
Поясню: на этот раз пропало соединение с VPN. Как правило, в компаниях есть своя собственная внутренняя сеть, и мы видим, что к ней идёт подключение. Так бывает, что внутренние сервисы могут быть доступны только внутри локальной сети компании. И оказывается, что для простой сборки проекта и прохождения тестов необходимо быть подключенным к VPN. Крис с прежним вопросом обращается к команде. Ему предлагают попробовать в третий раз.
Попытка №3. Проект собрался, даже подключился через VPN к какому-то сервису. И тут вылетает новая ошибка:
14:34:51.127 [XNIO-2 task-4] ERROR uk.bank.controllers.ControllerV1Test name=createUser - exception, /v1/users
java.lang.IllegalStateException: error parse person for user: 32000140
at uk.bank.services.PersonClient.parsePersonData(PersonClient.java:173)
Обращаем внимание:
java.lang.IllegalStateException: error parse person for user
Поясню: на этот раз тесты обращаются к некоторому внешнему сервису Person
, а тот, скорее всего, был недоступен, и поэтому вернулась ошибка 500 Internal Server Error
.
В голове Криса стал крутиться один и тот же вопрос: «Какого х@#?!». Хоть и немного раздражённый, он не стал озвучивать его, и по привычке просто перезапустил сборку проекта заново. Аллилуйя, на этот раз всё получилось!
Results :
Tests run: 278, Failures: 0, Errors: 0, Skipped: 5
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 01:12 min
[INFO] Finished at: 2020-10-18T14:10:03+03:00
[INFO] ------------------------------------------------------------------------
Итог: рабочий день только начался, а новый программист в команде уже столкнулся с большим количеством проблем на ровном месте. «Старички» никак не объясняли происходящее, а просто заставляли перезапускать сборку проекта до тех пор, пока она не пройдёт успешно. «У меня всё работает, ты что-то делаешь не так», — было их обычным ответом. Вы думаете, это какой-то прикол и такого не бывает в реальной жизни?
Вот и Большой Крис подумал, что стал участником какой-то шутки, забавы или «посвящения», как когда-то в армии бывало. Это было первой ошибкой команды.
Дорогой друг, а что бы ты сделал на месте Большого Криса? Правильный ответ: стоит посмотреть на тесты в проекте. Это первое, с чем должен ознакомиться любой программист, начиная работу над новым проектом. Так как именно с ними у Криса возникли первые проблемы, то это вполне логичное решение. Итак, взглянем…
@Test
public void find_user_by_phone_error() {
when(userServiceV2.findUserByPhoneAndMail(PHONE, EMAIL))
.thenReturn(null);
when(userServiceV2.findUserByPhoneAndMailIsNull(PHONE))
.thenReturn(null);
mockMvc.perform(get(END_POINT)
.param("phone", PHONE)
.param("email", EMAIL)
.accept(APPLICATION_JSON)
.contentType(APPLICATION_JSON))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(content().contentType("application/json;charset=UTF-8"))
.andExpect(jsonPath("$.success", is(false)))
.andExpect(jsonPath("$.errors[0].code", is(ErrorDTO.PHONE_ALREADY_IN_USE.getCode())))
.andExpect(jsonPath("$.errors[0].userMessage", containsString("Восстановите пароль")));
}
@Test
public void testCarlineService() {
RestTemplate restTemplate = new RestTemplate();
UserWrap userWrap = new UserWrap();
userWrap.setTransactId("tran123");
userWrap.setId(123L);
HttpEntity entity = new HttpEntity<>(userWrap);
ResponseEntity response = restTemplate.exchange(portalSbernedHost + unitUrl, HttpMethod.POST, entity, UserWrap.class);
System.out.println("response: " + response.getStatusCode());
}
Двух первых тестов достаточно, чтобы возникли вопросы к проекту:
- Какие правила именования тестовых методов используются в проекте?
В названии первого теста слова разделяютсяподчёркиванием
, в названии второго теста каждое новое слово начинается спрописной буквы
. И похоже (по другим тестам), что всех всё устраивает. - Что необходимо использовать в тестах: MockMvc, или RestTemplate, или TestRestTemplate?
Конкретный проект написан на Java с использованием Spring Framework.
ЕстьRestTemplate
, используемый в боевом коде для взаимодействия по HTTP.TestRestTemplate
— это обёртка надRestTemplate
для работы в тестах, не бросающая исключения при получении кода ошибки5xx
.MockMVC
создан для тестирования только web-слоя сервиса. Оба теста были в одном тестовом классе. Нехорошо как-то. - Почему в тестах мы видим разные способы тестирования?
Всё выглядит так, словно каждый программист пишет тесты как ему нравится, или как у него получается их написать, без выделения общих подходов и code review. - Есть ли правила работы с
mock
/spy
-объектами?
В первом методе есть ключевые словаwhen
иthenReturn
. То есть имеется объект, у которого вызывается некий метод. Мы подставляем обманку, говоря, что если вызывается методM
, то нужно вернуть значениеN
. Однако в конце теста не проверяется выполнение этих методов, действительно ли они вызывались, действительно ли мы вернули то, что ожидали. - В возвращаемых значениях используется
null
В этом нет ничего хорошего. При работе с ним возможны многие проблемы, и если можно, то лучше уйти от такого применения, используя правильные возвращаемые объекты. Наличиеnull
— явный признак того, что и в бизнес-логике проекта он часто используется. Пахнет плохим кодом. - Есть ли правила вывода информации на печать?
В тексте второго теста с помощьюSystem.out.println
выводится информация о возвращаемом HTTP-коде. Это не слишком хорошее решение, лучше использовать библиотеку для работы с логами. Тогда можно переключать и сегментировать информацию: выводить лишь предупреждения и ошибки (warn
), что-то в режиме отладки (debug
). Здесь же ничего подобного нет, просто текст в консоли.
Вы понимаете, что посмотреть глазами вывод на печать — это единственная проверка в данном тесте? Нет ниassertEquals
, ниassertTrue
. Очень странный тестовый метод, зачем вообще он нужен, если ничего не проверяет и не даёт автоматизированной обратной связи?
Выводы
Каждый новый сотрудник компании может:
- увидеть плохой код в тестовых методах;
- увидеть тесты, которые ничего не проверяют;
- понять, что если тесты написаны плохо — значит, то же самое будет и в бизнес-логике проекта.
Вопросы
- Кто всё это писал?
- Кто позволил всё это написать?
Дорогой друг, это именно те вопросы ради которых я начал писать эту статью. Дело в том, что Большой Крис пришёл в команду профессионалов, на проект, которым пользуются клиенты компании. Он мотивирован начать новую жизнь, обрести новые знакомства, перенять опыт умных людей. Что Крис встретил в итоге?
Проблема №1
Проект «ИКС» компании «Рога и Копыта» очень важен. Он приносит деньги основателям бизнеса, кормит саму команду разработки и позволяет внедрять новые проекты, ещё не достигшие безубыточности. Как я упомянул ранее, в коллективе уже работают профессионалы, каждый из них эффективно справляется со своей работой и за короткие сроки внедряет новые фичи, исправляет возникшие баги. Такой режим работы не позволяет не только тесты написать, а даже проект изучить. Как итог, общее дело превращается в лоскутное одеяло.
Проблема №2
У Большого Криса ещё мало опыта, и он не знает, что культура в команде не сформирована, а отношения извращены: его мнение, как молодого программиста, никому не интересно.
— Я вижу, что тесты не помогут мне понять, как работает проект, они сами периодически не работают, а некоторые просто ничего не делают. Может, проведём общую встречу для всей команды? Я читал в одной книге… — начал говорить Большой Крис
— Слушай, я сейчас занят. Давай позже, или обратись к тимлиду, — ответил product owner проекта.
— Не думаю, что у нас есть какие-то проблемы, — ответил тимлид.
— Но тесты не работают и написаны очень странно. Может мне кто-нибудь помочь? — выпалил Крис, не сдержавшись.
— Ты здесь первый день и уже всем мешаешь. Можешь не отвлекать от работы? — ответил ему Senior Certified Java Programmer.
Краткий и ёмкий диалог. Большой Крис всё запомнил. Это стало второй ошибкой команды.
Желания сделать лучше, упорядочить архитектуру и написать хорошие тесты, как правило, разбиваются:
- о монолитность кода;
- о большое количество изменений, которые придётся сделать;
- о невозможность быть уверенным в сохранении работоспособности и правильного взаимодействия с другими сервисами, потому что нет тестов.
Дорогой читатель может возразить, что отсутствие тестов может быть оправдано:
- Ведь программистам не за тесты платят деньги, а за фичи…
- На тесты необходимо дополнительное время, а его нет и нужно фичи пилить…
Выводы
- в команде нет культуры, а значит нет и правил;
- если в команде нет правил, то никто не подконтролен;
- если в команде никто не подконтролен, то образуется технический долг.
Если программистам платят деньги за фичи, и в проекте нет тестов, то возрастает время на реализацию новой функциональности и, как следствие, на исправление возникающих ошибок. В результате фичи превращаются в постоянный рефакторинг кода. Как итог, программисты тратят всё больше времени… на рефакторинг, который не улучшит ситуацию, потому что… в команде нет культуры написания кода. Образуется новый техдолг. Цикл замкнут.
Технический долг — это проблема, которую не решили вовремя и отложили на будущее. Сейчас он не может препятствовать введению новой функциональности, но в будущем может проявиться в какой-то задержке, в проблеме, которую нельзя решить вовремя и быстро. Придётся потратить время на решение (исправление) этого долга.
Дорогой читатель, если ты ещё не понял, объясню: в проекте не может быть второстепенных частей. Команде, занимающейся разработкой проекта, важно:
- и писать новую функциональность;
- и покрывать свой код тестами;
- и повышать культуру разработки и общения внутри коллектива.
Получается, компания тратит деньги не на фичи, а на нигилистов
, которые не желают быть командными игроками. Всё, что их окружает, это технический долг, который позволяет им оставаться незаменимыми. Они могут быстро править баги, потому что сами их и создали.
Нигилизм (от лат. nihil — ничто) — философия, ставящая под сомнение общепринятые ценности, идеалы, нормы нравственности, культуру. Нигилизм подразумевает отрицание, негативное отношение к определённым, или даже ко всем сторонам общественной жизни.
«Не будь нигилистом Senior Certified Java-программистом», — сказал бы И.В. Тургенев сейчас. Чтобы не спойлерить сюжет, читайте его книгу «Отцы и дети» о Евгении Базарове.
Задайтесь вопросом: «Если всё получается сейчас, будет ли так и дальше, когда из команды уйдут те самые Senior Certified Java-программисты?». Увы, если знания не формализованы в коде через тесты и не создан самоподдерживающийся каркас проекта из тестов, то вас ждут проблемы с введением в проект новых людей.
Дорогой друг, позволь свои слова подкрепить цитатой из ещё одной книги,»Шаблоны реализации корпоративных приложений» за авторством Кент Бека:
ПО должно быть спроектировано так, чтобы уменьшалась его общая стоимость. Она делится на начальную стоимость разработки и стоимость сопровождения:
Когда в индустрии был накоплен достаточный опыт разработки ПО, для многих стал сюрпризом тот факт, что начальная стоимость гораздо ниже стоимости сопровождения.Сопровождение обходится дорого, поскольку понимание кода — это процесс, занимающий много времени и подверженный ошибкам. Внесение изменений существенно облегчается, если известно, что именно требуется изменить. Изучение существующего кода является самой трудоёмкой частью. Все вносимые изменения должны быть протестированы и внедрены.
… Сегодняшний доллар не должен стоить дороже завтрашнего.
Давайте закругляться, а то я что-то много уже написал. Перейдём сразу к рекомендациям, которые позволят уменьшить технический долг проекта. Сразу оговорюсь, что нам не нужен идеальный код в тестах, здесь не будет стратегии TDD, BDD и деклараций Given-When-Then. Нам нужен код, который можно быстро читать, быстро править и дополнять. Всё это моё ИМХО из работы над проектами и общения с живыми людьми.
Ведите файл Readme.md
Создайте в корне проекта этот файл и опишите кратко:
- что делает сервис и для чего он был создан;
- правила установки, сборки и запуска проекта на локальном компьютере;
- примеры взаимодействия с программой, если есть API.
Если много написать не получится, то можно сделать краткие заметки со ссылкой на Wiki. Пожалуйста, не думайте, что всё очевидно и для вашего проекта такой файл не нужен, это самообман. Для сравнения: попробуйте найти свой самый большой старый проект на GitHub и подсчитайте время, которое вам понадобится, чтобы в нём разобраться.
Не будьте толерантны к нигилистам
С такими людьми не стоит работать, потому что они — источник технического долга, который придется разбирать вам или вашим коллегам. Нигилистам абсолютно неинтересно, с чем столкнутся их коллеги. Им важно выполнять свои личные задачи здесь и сейчас, быть эффективным сию минуту: «А после нас хоть потоп». Если вы такой человек, пожалуйста, не работайте в IT.
Инициируйте архитектурный комитет
Два человека смогут друг с другом договориться. Если в команде больше двух сотрудников и вы планируете расширяться, то формализация договорённости через обсуждение — это хороший способ согласовать основные инженерные решения в компании. Так вы сможете не возвращаться постоянно к одним и тем же вопросам. Итоги архкомов стоит зафиксировать в документации компании.
На каждый when
выполняйте verify
Обязательно проверяйте метод-обманку, который должен быть вызван в тесте, иначе зачем вообще вы его написали?
verify(rabbitService).hasRabbitConnection();
Не используйте null
Избитая тема, но… старайтесь отказываться, где это возможно, от использования null
, чтобы он не пронизывал, как игла, весь ваш код. «I call it my billion-dollar mistake…», — сказал Antony Hoare, автор языка ALGOL и алгоритма Quicksort.
public List listFiles(String path) {
String[] files = getFiles(path);
if (files != null) return List.of(files);
else return null;
}
Прогоняйте тесты перед тем, как получить артефакт
Следите за тем, чтобы:
- успешно были пройдены модульные и функциональные тесты;
- успешно были пройдены интеграционные тесты, если они являются частью сборки проекта и не обращаются ко внешним сервисам;
- доступ ко внешней сети не должен быть обязательным (доступ к зависимым библиотекам не учитывается).
Если интеграционные тесты не являются частью вашего проекта, запускайте их уже после поставки проекта в QA или Stage-окружения.
Не используйте общие сторонние сервисы
Используйте testcontainers
или описание всех нужных сервисов сразу в файле docker-compose.yml
, если необходимо взаимодействовать со сторонними службами (PostgreSQL, RabbitMQ и др.). И пожалуйста, не используйте общую для многих, стороннюю БД:
- Запуск тестов может приводить к странным «мигающим» ошибкам.
- Формируется техдолг, который приходится держать в голове для выявления допустимых и недопустимых ошибок.
- Тесты вашего проекта зависят от правильности работы других сервисов.
Разделяйте проект и тесты на слои
Старайтесь разделять тесты на слои, чтобы проводить проверки независимо и не отвлекаться на исправление кода, не относящегося к конкретному тесту. Как правило, выделяют три слоя:
- работа с БД: CRUD-операции с Entity-объектами;
- работа с внешним API сервиса (mocks при обращении к бизнес-логике и работе с БД);
- работа с бизнес-логикой (mocks при обращении к БД).
Давайте понятные имена тестовым методам
- Это всегда поможет понять, что проверяет тест.
- Такие имена помогут быстрее вводить новых людей в проект.
- Включите фантазию и избегайте названий, схожих с
testRoleSuccess
,testRoleFail
,testRoleUnknown
. - Цените и своё время, и время других программистов.
- Не стесняйтесь длинных имён, например:
Тесты — это живая документация проекта
Если вас попросят вести документацию отдельно от проекта, то она всё равно когда-нибудь устареет. Правильно написанные тесты не могут устареть, иначе они перестанут работать. Именно поэтому важно обращать внимание на именование тестовых классов и методов.
Удаляйте неиспользуемый код
Когда мы работаем с репозиторием, то, как правило, у нас есть несколько веток. Они могут называться master
, develop
и stage
:
- Не оставляйте в тестах
master
-ветки части закомментированного кода или пометки@Ignore
. - Контекст работы меняется, и вскоре все могут забыть про наличие неработающего кода.
- Неработающий код формирует техдолг.
Не жалейте время на тесты
Тесты очень важны, выделите время на их написание. Жалея время на тесты, вы крадете своё будущее, желая сэкономить сейчас. Расставьте приоритеты и выделите, что для вас является ценным:
- Время — это ваш актив, который выплатит свои дивиденды в будущем.
- Техдолг — это ваш пассив, и тесты не позволят ему вырасти.
Тесты сформируют каркас и помогут удерживать ваш проект в рабочем состоянии при реализации новой функциональности.
Высказывание американского инвестора Уоррена Баффета ярко выделяет проблему потраченных средств и полученных взамен благ. В нашем случае это затраты времени на написание тестов. Магия «сложного процента» освободит вас и ваших коллег от большой части ошибок в будущем.
Не стремитесь покрыть тестами 100% кода
Бывает обратная ситуация, когда перфекционисты стремятся полностью покрыть код тестами.
- Покройте только основные вызовы вашего API с проверкой разного исхода событий.
- Лишнее тестирование будет сдерживать скорость разработки новой или изменения старой функциональности.
Время — это ваш актив. Техдолг — это ваш пассив.
Актив — это то, что приносит доходы. Пассив — это то, что приносит расходы.
Тесты — это материальная форма выражения актива, а отсутствие тестов — чёрная дыра пассива.
Тесты будут возвращать дивиденды, экономя ваше время на всём протяжении жизни проекта. От техдолга не уйти, он всегда будет, пока жив проект и ведётся работа. Ваша задача — стараться держать техдолг в рамках разумного.
Постскриптум (aka P.S.):
Дорогой читатель, у Большого Криса всё хорошо. После неудачного первого рабочего дня он решил познакомиться со своими коллегами чуть поближе и пригласил их на пикник, любезно согласившись подвезти на своём автомобиле. Это была первая встреча Архитектурного Комитета, после которой никаких проблем в общении больше не было, но это уже совсем другая история.