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

Сказ мой о разработке на Java, при этом всё нижеизложенное справедливо и для других языков программирования. От смены языков люди и проблемы в тестировании не меняются.
Отказ от ответственности (aka disclaimer): все персонажи являются вымышленными, и любое совпадение с реально живущими или когда-либо жившими людьми случайно.

Что вы узнаете из этой статьи:
  • зачем программисты пишут тесты;
  • почему программисты НЕ пишут тесты;
  • как быстрее «вводить» новых людей в проект;
  • кто такие нигилисты и причём здесь деньги.

Кому может быть интересна эта статья:
  • программистам-одиночкам, не привыкшим работать в команде;
  • студентам, только начинающим работать в коллективе.
  • всем у кого тесты в проекте как дитя-сирота: не накормлена и не обута.

Прежде чем начать, придётся дать немного теории. Знаю, это занудно, но что поделать...


Очень важно, чтобы в вашей команде разработка ПО велась с использованием Continuous Integration, она же CI, она же «Непрерывная Интеграция».

Continuous integration — это практика разработки, которая требует работать с единым репозиторием кода, часто его актуализировать, объединять с основной веткой кода, автоматически тестировать для проверки работоспособности.

Это позволяет командам находить ошибки на ранней стадии, решать интеграционные проблемы.

Конечная цель — получить работоспособный артефакт проекта.

CI Pipeline

Процесс выглядит так:


  1. Есть репозиторий, в котором мы храним текстовые файлы с исходным кодом программы.
  2. Необходимо их достать, скомпилировать и прогнать тесты.
  3. Получившуюся программу записать в бинарный файл, например, в Docker-образ, с помощью команды docker build.
  4. Конечный артефакт сохранить в репозиторий бинарных файлов.

В ходе сборки крайне желательно протестировать программу:

  • выполнить заранее написанные модульные (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)

Главный вид тестов. Мы проверяем не какие-то маленькие элементы программы, а задачи, функционально затрагивающие несколько служб, поэтому кода может быть на порядок больше. Интеграционные тесты могут быть или частью вашего проекта, или вынесены в отдельный проект, или оба варианта вместе. В первом варианте вы работаете с локально поднятыми службами (message queue, database) + используете HTTP Mocks, эмитируя ответы сторонних сервисов. Во втором варианте вы проверяете работу вашего сервиса и его взаимодействие с другими сервисами в окружениях QA/Stage.

Пример (2ой вариант): этапы смены пароля пользователя:

  • проверка пароля и пароля для будущей замены;
  • создание нового тестового пользователя;
  • проверка аутентификации нового пользователя;
  • отправка запроса для службы 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<Void> 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<UserDTO> httpEntity = new HttpEntity<>(dto, headers);
   ResponseEntity<UserDTO> 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<Void> 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<UserPhonePassword> httpEntity = new HttpEntity<>(dto, new HttpHeaders());
   ResponseEntity<Void> 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<String> before = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/{id}", HttpMethod.GET, httpEntity, String.class, userId);
   assertEquals(HttpStatus.OK, before.getStatusCode());

   ResponseEntity<Void> response = restTemplate.exchange(baseUrl + SRV_USERS_URI + "/{id}", DELETE, httpEntity, Void.class, userId);
   assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());

   ResponseEntity<Void> 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<String> 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 clone 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<UserWrap> entity = new HttpEntity<>(userWrap);
   ResponseEntity<UserWrap> 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<String> 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.)



Дорогой читатель, у Большого Криса всё хорошо. После неудачного первого рабочего дня он решил познакомиться со своими коллегами чуть поближе и пригласил их на пикник, любезно согласившись подвезти на своём автомобиле. Это была первая встреча Архитектурного Комитета, после которой никаких проблем в общении больше не было, но это уже совсем другая история.