Юнит-тесты. Очень глубокое погружение

Основное

Современный ИТ-продукт состоит из таких элементов:

1. Бизнес-код.

2. Документация.

3. CI/CD-пайплайн. 

4. Правила коммуникации.

5. Автоматизированные тесты.

Хорошая бизнес-логика и чистый код? Недостаточно. Только если все вышеперечисленное интегрировано в единое целое — тогда можем надеяться, что получится хороший ИТ-продукт.

Тестирование — неотъемлемая часть современной разработки; сейчас уже не существует четкой границы между приложением и его тестами; разработка практически слилась с тестированием в единый процесс. Если тестирование недостаточное, небрежное, продукт получится нефункциональным, неподдерживаемым.

Тестирование, с точки зрения TDD, может и должно влиять на архитектуру ПО.

Видов тестирования существует множество, но именно юнит-тесты составляют фундамент пирамиды тестирования и бОльшую часть тестов, если брать по количеству. Поэтому философия юнит-тестирование — важная составляющая философии тестирования.

(О TDD, очень коротко — здесь. Далее будут примеры кода; все примеры на Java, но в принципе релевантны и для других ЯП.)

Что такое юнит-тестирование

Каждый разработчик имеет хоть какой-то опыт написания юнит-тестов. По крайней мере ему хорошо известно их предназначение и как они работают.

Дать четкое «официальное» определение юнит-тесту тяжело, из-за того что нужно сначала определить, что такое юнит. Попробуем так:

Юнит — это изолированная часть функциональности. 

Звучит правильно. Согласно этому определению, один юнит-тест должен покрывать один модуль кода.

Посмотрим на схему. Например имеем простейшее приложение, оно состоит из 3 модулей, каждый модуль состоит из 2 вложенных модулей (юнитов).

Юнит-тестирование - модули
Юнит-тестирование — модули

Здесь шесть модулей: 

  1. UserService
  2. RoleService
  3. PostService
  4. CommentService
  5. UserRepo
  6. RoleRepo

Согласно этой схеме, юнит можно описать следующим образом:

Это класс, который может тестироваться изолированно от всей системы.

Можно ли написать тесты для каждого отдельного юнита? И да и нет. Потому что юниты не функционируют на 100% независимо один от другого. Они должны как-то взаимодействовать — или приложение не будет работать.

Как писать правильные изолированные юнит-тесты для сущностей, которые не могут быть реально изолированы?

Чтобы понять, как это, сначала нужно усвоить такую концепцию: разработка через тестирование (TDD).

Разработка через тестирование (TDD)

Суть — в написании тестов до того как появится бизнес-код.

Когда джун впервые это слышит, он как правило удивляется. Как писать тесты для кода, которого еще нет?

TDD — это три этапа:

  1. Пишут тест новой функциональности. Тест разумеется упадет — ведь еще не написан бизнес-код.
  2. Пишется минимум кода, имплементирующего функцию.
  3. Если тест прошел, результат рефакторят, и возвращаются на первый этап.

Поэтому TDD еще называют “Red-Green-Refactor”. В некоторых вариантах TDD существует не три, а четыре или пять этапов.

Уточняем понятие «юнит»

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

  1. Пользователь, вводит свой комент, то есть «предоставляет тело» комента и ID поста. 
  2. Если пост отсутствует, выдается ошибка.
  3. Если комент больше 300 символов, выдается ошибка.
  4. Если эти валидации проходят, комент успешно сохраняется.

Имплементация на Java:

public class CommentService {

  private final PostRepository postRepository;
  private final CommentRepository commentRepository;

  // constructor is omitted for brevity

  public void addComment(long postId, String content) {
    if (!postRepository.existsByid(postId)) {
      throw new CommentAddingException("No post with id = " + postId);
    }
    if (content.length() > 300) {
      throw new CommentAddingException("Too long comment: " + content.length());
    }
    commentRepository.save(new Comment(postId, content));
  }
}

Количество символов протестировать легко. Проблема в том, что CommentService полагается на зависимости, переданные через конструктор. Как протестировать класс в таком случае? Есть разные подходы. Так называемая Детройтская школа (классическая), и Лондонская (продвигающая моки), с разным подходом к понятию «юнит». 

Детройтская школа TDD

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

class CommentServiceTest {

  @Test
  void testWithStubs() {
    CommentService service = new CommentService(
        new StubPostRepository(),
        new StubCommentRepository()
    );
  }
}

Здесь StubPostRepository и StubCommentRepository являются имплементациями соответствующих интерфейсов, используемых в тест-кейсах. 

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

Детройтская школа юнит-тестирования
Детройтская школа юнит-тестирования

Может быть много тестовых наборов, связанных с имплементациями StubPostRepository и StubCommentRepository.

Детройтская школа понимает под понятием «юнит» следующее:

Класс, который может быть протестирован изолированно от своей системы. Все внешние зависимости должны быть или заменены стабами (заглушками), или реальными бизнес-объектами.

Лондонская школа TDD

Эти приверженцы моков тестируют метод addComment по другому:

class CommentServiceTest {

  @Test
  void testWithStubs() {
    PostRepository postRepository = mock(PostRepository.class);
    CommentRepository commentRepository = mock(CommentRepository.class);
    CommentService service = new CommentService(
        postRepository,
        commentRepository
    );
  }
}

Лондонская школа рассматривает юнит как сильно изолированная часть кода; каждый мок является имплементацией зависимости класса; для каждого тест-кейса моки должны быть уникальными.

Юнит — это класс, который может тестироваться изолированно от всей системы. Любые внешние зависимости должны утилизироваться моками. Стабы не должны использоваться повторно. Запрещено применение реальных бизнес-объектов.

Этот подход схематически изображен далее.

Лондонская школа юнит-тестирования
Лондонская школа юнит-тестирования

Итоговое определение юнита

Теперь попробуем дать финальное определение юнита.

Юнит — это класс, который может тестироваться изолированно от всей системы. Все внешние зависимости должны быть «заглушены» или моками, или стабами. Бизнес-объекты не должны включаться в процессы юнит-тестирования. 

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

Однако в этом правиле есть исключение: объекты Value. Это структуры данных, инкапсулирующие изолированные части кода. Например, value-объект Money состоит из суммы и обозначения валюты; объект FullName может иметь имя, фамилию, и отчество. Такие классы — простые контейнеры данных без специфического поведения, и это нормально использовать их в тестах.

Теперь же, имея финальное определение юнита, поговорим о том, как должен выглядеть правильный юнит-тест. Кратко:

  1. Классы не должны нарушать принцип инверсии зависимостей (DI, dependency inversion).
  2. Юнит-тесты не должны влиять друг на друга.
  3. Должны быть детерминистичными.
  4. Не должны зависеть от любого внешнего состояния.
  5. Должны быть быстрыми.
  6. Должны запускаться только в CI-окружении.

Требования к юнит-тестам

Классы не нарушают DI-принцип

Самое очевидное правило, но стоит его упомянуть первым, поскольку нарушение этого правила делает юнит-тесты бесполезными. Взглянем на такой код:

public class CommentService {

  private final PostRepository postRepository = new PostRepositoryImpl();
  private final CommentRepository commentRepository = new CommentRepositoryImpl();
  
  ...
}

Хотя CommentService декларирует внешние зависимости, они привязаны к PostRepositoryImpl и CommentRepositoryImpl. Невозможно передать стабы/моки/дубли, чтобы изолированно верифицировать поведение класса. Поэтому нужно передавать все зависимости через конструктор.

Тесты не должны влиять друг на друга

Следующий пункт «заповедей юнит-тестирования» сформулируем так:

Пользователь запускает все юнит-тесты последовательно или параллельно, и это не должно влиять на результат выполнения. 

Почему это важно: представим, что тесты А и Б выполнились, все отлично. Но CI-нод запустил сначала тест Б и затем тест А. Если результат теста Б влияет на тест А, это приведет к ложно-негативному результату. Такие кейсы сложно отслеживать и исправлять.

Например StubCommentRepository:

public class StubCommentRepository implements CommentRepository {

  private final List<Comment> comments = new ArrayList<>();

  @Override
  public void save(Comment comment) {
    comments.add(comment);
  }

  public List<Comment> getSaved() {
    return comments;
  }

  public void deleteSaved() {
    comments.clear();
  }
}

Если передать тот же экземпляр StubCommentRepository, это разве гарантирует, что юнит-тесты не повлияют друг на друга? Нет. Как видим, StubCommentRepository не имеет того что называется потоковой безопасностью. Вполне возможно, что при параллельном запуске тесты не покажут те же результаты, что при последовательном.

Существует два способа решить эту проблему:

  1. Обеспечить, чтобы каждый стаб был thread-safe.
  2. Создавать новый стаб/мок для каждого тест-кейса.

Юнит-тесты должны быть детерминистичными

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

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

class DateUtilTest {

  @Test
  void shouldBeMorning() {
    OffsetDateTime now = OffsetDateTime.now();
    assertTrue(DateUtil.isMorning(now));
  }
}

Этот тест не детерминистичный по своему дизайну. Он пройдет, только если текущее системное время классифицируется как утро.

Лучшая практика — избегать тестовых данных, опирающихся на «нечеткие» функции, такие как:

  • Текущие дата и время.
  • ВременнАя зона.
  • Параметры аппаратного обеспечения.
  • Рендомные числа.

Нужно отметить, что так называемое property-based-тестирование опирается на подобную генерацию тестовых данных, но там это работает по другому. Обсудим это в конце статьи.

Тесты не должны зависеть от любого внешнего состояния

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

Например, создается приложение — прогноз погоды. Оно принимает URL с данными через HTTP API. Посмотрим на код далее — это простой тест, проверяющий доступность статуса:

class WeatherTest {

  @Test
  void shouldGetCurrentWeatherStatus() {
    String apiRoot = "https://api.openweathermap.org";
    Weather weather = new Weather(apiRoot);

    WeatherStatus weatherStatus = weather.getCurrentStatus();

    assertNotNull(weatherStatus);
  }
}

Проблема в том, что внешний API может быть нестабилен — нельзя быть уверенным, что какой-то внешний сервер будет 100% всегда онлайн. Даже если это гарантируем, всегда есть вероятность других проблем, например что CI-сервер закроет http-запросы, или какие-то блоки фаервола.

Важно, чтобы юнит-тест представлял собой монолитный кусок кода, не требующий каких-то внешних сервисов.

Все тесты должны выполняться в CI-окружении

Тесты должны работать превентивно. Они должны отклонять любой код, который не соответствует спецификациям. То есть, код, который не проходит юнит-тест, не может быть слит в main-ветку.

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

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

Итоги по требованиям

Как видим выше, юнит-тесты не так просты как кажутся. Потому что юнит-тесты — это не только assertions и дебаг и моки с параметрами. Юнит-тестирование это валидация поведения.

Сколько времени нужно выделять на юнит-тесты?

  1. Юнит-тест — это своеобразная документация к коду. Если другой разработчик не вполне понимает деталей поведения системы, юнит-тест поможет ему понять функции класса или API.
  2. Есть большая вероятность, что придется возвращаться к этому тесту. Если он сделан в спешке, написан плохо, уйдет много времени чтобы понять, что делает этот класс.

Майндсет юнит-тестирования

На какую философию опирается юнит-тестирование? Выше упоминалось слово поведение, несколько раз. В этом ответ. Юнит-тесты проверяют поведение, а не просто «вызывают функции».

Стабильность при рефакторинге

Например имеем какой-то небольшой кусок кода после рефакторинга, и несколько тестов начали падать. Это неприятно: если нет значимых изменений бизнес-логики, мы не хотим лишний раз трогать эти тесты. 

Далее пример из начала статьи, представим, что пользователь может удалять все свои архивные посты. Взглянем на имплементацию на java:

public class PostDeleteService {

  private final UserService userService;
  private final PostRepository postRepository;

  public void deleteAllArchivedPosts() {
    User currentUser = userService.getCurrentUser();
    List<Post> posts = postRepository.findByPredicate(
        PostPredicate.create()
            .archived(true).and()
            .createdBy(oneOf(currentUser))
    );
    postRepository.deleteAll(posts);
  }
}

PostRepository это интерфейс к внешнему хранилищу, например PostgreSQL или MySQL. PostPredicate кастомный билдер предиката.

Как будем тестировать корректность метода? Можем сделать моки для UserService и PostRepository и проверить параметры ввода:

public class PostDeleteServiceTest {
  // initialization

  @Test
  void shouldDeletePostsSuccessfully() {
    User mockUser = mock(User.class);
    List<Post> mockPosts = mock(List.class);
    when(userService.getCurrentUser()).thenReturn(mockUser);
    when(postRepository.findByPredicate(
        eq(PostPredicate.create()
            .archived(true).and()
            .createdBy(oneOf(mockUser)))
    )).thenReturn(mockPosts);

    postDeleteService.deleteAllArchivedPosts();

    verify(postRepository, times(1)).deleteAll(mockPosts);
  }
}

Здесь when, thenReturn, и eq-методы — из библиотеки Mockito (ссылка).

Тестируем здесь поведение? В принципе, нет. Здесь нет тестирования, здесь верифицируется порядок вызова методов. Проблема в том, что этот тест «не переживет» рефакторинг кода, который он тестирует.

Заменим oneOf(user) на предикат is(user). Тогда код такой:

public class PostDeleteService {

  private final UserService userService;
  private final PostRepository postRepository;

  public void deleteAllArchivedPosts() {
    User currentUser = userService.getCurrentUser();
    List<Post> posts = postRepository.findByPredicate(
        PostPredicate.create()
            .archived(true).and()
            // replaced 'oneOf' with 'is'
            .createdBy(is(currentUser))
    );
    postRepository.deleteAll(posts);
  }
}

Рефакторинг вообще не изменил бизнес-логику. Но тест упадет, из-за мока.

public class PostDeleteServiceTest {
  // initialization

  @Test
  void shouldDeletePostsSuccessfully() {
    // setup

    when(postRepository.findByPredicate(
        eq(PostPredicate.create()
            .archived(true).and()
            // 'oneOf' but not 'is'
            .createdBy(oneOf(mockUser)))
    )).thenReturn(mockPosts);

    // action
  }
}

Всякий раз когда код рефакторится, тест падает. Это усложняет поддержку тестов. А если будет крупный рефакторинг? Например, если добавить метод postRepository.deleteAllByPredicate, он поломает вообще все.

Так происходит потому, что мы пошли по неверному пути. А ведь мы хотели тестировать поведение. Попробуем написать новый тест, который действительно тестирует поведение. Во первых, нужно декларировать кастомную имплементацию PostRepository для теста. Будем хранить данные в RAM-памяти, это нужно для PostPredicate. Таким образом, вызывающий метод зависит от того, корректно ли работают с предикатами.

Версия теста после рефакторинга:

public class PostDeleteServiceTest {
  // initialization

  @Test
  void shouldDeletePostsSuccessfully() {
    User currentUser = aUser().name("n1");
    User anotherUser = aUser().name("n2");
    when(userService.getCurrentUser()).thenReturn(currentUser);
    testPostRepository.store(
        aPost().withUser(currentUser).archived(true),
        aPost().withUser(currentUser).archived(true),
        aPost().withUser(anotherUser).archived(true)
    );

    postDeleteService.deleteAllArchivedPosts();

    assertEquals(1, testPostRepository.count());
  }
}

Что изменилось:

  1. Нет мока PostRepository. Есть кастомная имплементация — TestPostRepository, инкапсулирующая сохраненные посты и гарантирующая правильную обработку PostPredicate.
  2. Вместо возвращения при помощи PostRepository списка постов, передаем реальные объекты при помощи TestPostRepository.
  3. Не беспокоимся о вызываемых функциях. Нам нужно просто валидировать операцию удаления. Мы знаем, что в хранилище два архивированных поста текущего пользователя и один пост другого пользователя. Успешная операция должна оставить там один пост. Поэтому применяем assertEquals к количеству постов.

Теперь тест изолирован от проверки вызова метода. Думаем только о корректности имплементации TestPostRepository. Без разницы, как PostDeleteService имплементирует бизнес-кейс. Это не о том «как», это о том «что» юнит делает. Такой тест не пострадает от рефакторинга.

Можно заметить, что UserService все еще обычный мок. Это нормально, потому что возможность замены метода getCurrentUser() несущественна. Кроме того, это метод без параметров, то есть не нужно отработать возможное несоответствие входных параметров. Моки — это не хорошо и не плохо, для разных задач есть разные средства.

Об MVC-фреймворках

Многие приложения и сервисы разрабатываются в MVC-фреймворках. В Java популярен Spring Boot. Некоторые авторитеты считают, что архитектура не должна зависеть от фреймворка, мы же считаем, что все сложнее. В реале многие проекты очень даже ориентированы на фреймворк. Бывает сложно, даже нереально заменить фреймворк в проекте. Разумеется, это влияет на тестирование.

Вообще, не обязательно, чтобы «код был полностью изолирован от фреймворков». Если фреймворк имеет полезные функции, снижающие избыточность кода и ориентирующие код на бизнес-логику, это нормальный фреймворк.

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

Посмотрим на примере. Например есть генератор XML. Каждый элемент имеет уникальный целочисленный ID, и есть сервис, генерирующий эти ID. Что если количество сгенерированных ID будет огромным? Если каждый элемент в каждом XML-документе имеет уникальный целочисленный ID, возможно переполнение. Например в проекте Spring, чтобы обойти эту проблему, объявим IDService с prototype scope. XMLService будет получать новый экземпляр IDService всякий раз когда активирован генератор. Код:

@Service
public class XMLGenerator {

  @Autowired
  private IDService idService;

  public XML generateXML(String rawData) {
    // split raw data and traverse each element
    for (Element element : splittedElements) {
      element.setId(idService.generateId());
    }
    // processing
    return xml;
  }
}

Проблема в том, что XMLGenerator является синглтоном (это дефолтный Spring bean scope). Поэтому IDService не будет обновлен.

Это можно решить путем инъекции ApplicationContext и запрашивая bean прямо:

@Service
public class XMLGenerator {

  @Autowired
  private ApplicationContext context;

  public XML generateXML(String rawData) {
    // Creates new IDService instance
    IDService idService = context.getBean(IDService.class);
    // split raw data and traverse each element
    for (Element element : splittedElements) {
      element.setId(idService.generateId());
    }
    // processing
    return xml;
  }
}

Но теперь другая проблема; теперь класс привязан к экосистеме Spring. XMLGenerator понимает, что есть DI-контейнер, и можно получить экземпляр класса из него. Подобные действия усложняют юнит-тестирование. Потому что нельзя протестировать XMLGenerator вне Spring-контекста.

Лучше объявить IDServiceFactory так:

@Service
public class XMLGenerator {

  @Autowired
  private IDServiceFactory factory;

  public XML generateXML(String rawData) {
    IDService idService = factory.getInstance();
    // split raw data and traverse each element
    for (Element element : splittedElements) {
      element.setId(idService.generateId());
    }
    // processing
    return xml;
  }
}

Выглядит лучше. IDServiceFactory инкапсулирует логику передачи экземпляра IDService. IDServiceFactory инъецирован в поле класса прямо. Spring это умеет. А что если нет Spring? Это можно сделать обычным юнит-тестом? Технически — да, это возможно. Java Reflection API позволяет изменять значения в полях private-классов; но не рекомендуем пользоваться Reflection API для юнит-тестирования, это «абсолютный антипаттерн». Хотя есть одно исключение: если бизнес-код работает с Reflection API.

Но вернемся к инъекции зависимостей. Есть три подхода:

  1. Через поле
  2. Через сеттер
  3. Через конструктор

Второй и третий подходы лучше — не имеют проблем первого, можно применять, оба работают. Например:

@Service
public class XMLGenerator {

  private final IDServiceFactory factory;

  public XMLGenerator(IDServiceFactory factory) {
    this.factory = factory;
  }

  public XML generateXML(String rawData) {
    IDService idService = factory.getInstance();
    // split raw data and traverse each element
    for (Element element : splittedElements) {
      element.setId(idService.generateId());
    }
    // processing
    return xml;
  }
}

Видим, что XMLGenerator полностью изолирован от фреймворка.

Итоги по майндсету

  1. Тестируй то, что код делает, а не то как делает.
  2. Рефакторинг не должен ломать тесты.
  3. Изолируй код от фреймворка.

Лучшие практики

Посмотрим, как улучшить свои юнит-тесты.

Имена

В основном IDE присваивают тестовым наборам имена, добавляя Test в конце имени класса. Как выше — PostServiceTest, WeatherTest, CommentControllerTest и так далее. Это нормально выглядит, но есть проблемы:

  1. Тип теста не всегда можно понять из названия (он юнит-, интеграционный, сквозной).
  2. Имя не всегда говорит, какие методы тестируются.

Для дополнительной ясности желательно «расширять» простой суффикс Test:

  1. Указывая тип теста, чтобы указать область тестирования. Например для одного класса могут быть быть разные наборы: PostServiceUnitTest, PostServiceIntegrationTest, PostServiceE2eTest).
  2. Добавляя имя метода: WeatherUnitTest_getCurrentStatus, CommentControllerE2ETest_createComment.

Второй пункт возможно спорный. Некоторые разработчики считают, что класс должен быть монолитным по возможности, и различение классов по их методам может привести к случайным ошибкам.

Это дельное замечание, однако прикрепление имени метода выгодно тем, что:

  1. Не все классы так уж монолитны по своей природе. Даже у самых упорных приверженцев Domain-Driven Testing не получается так делать везде.
  2. Есть сложные методы. Может быть 10 тестовых методов проверки одного метода в классе; если положить их все в один тестовый набор, он получится огромным, сложным.

Также нужно давать имена по контексту. Нет единого правила в этом, но вообще правильное именование сильно упрощает работу с тестами. Особенно если вся команда соблюдает это.

Assertions

Существует мнение, что всякий тест должен иметь только один assertion. Если больше, то «разносить» их по нескольким тестовым наборам.

Не факт, что это хорошо. Лучше так:

Каждый тест-кейс должен иметь по одному assertion’у на один бизнес-кейс.

Это нормально иметь несколько assertion’ов, но нужно стараться чтобы они были связаны. Пример:

public class PersonServiceTest {
  // initialization

  @Test
  void shouldCreatePersonSuccessfully() {
    Person person = personService.createNew("firstName", "lastName");

    assertEquals("firstName", person.getFirstName());
    assertEquals("lastName", person.getLastName());
  }
}

Хотя здесь два assertion’а, они привязаны к одному бизнес-контексту (созданию новой Person).

Теперь посмотрим на код:

class WeatherTest {
  // initialization

  @Test
  void shouldGetCurrentWeatherStatus() {
    LocalDate date = LocalDate.of(2012, 5, 25);
    WeatherStatus testWeatherStatus = generateStatus();
    tuneWeather(date, testWeatherStatus);

    WeatherStatus result = weather.getStatusForDate(date);

    assertEquals(
        testWeatherStatus,
        result,
        "Unexpected weather status for date " + date
    );
    assertEquals(
        result,
        weather.getStatusForDate(date),
        "Weather service is not idempotent for date " + date
    );
  }
}

Эти два assertion’а не создают монолитный кусок кода. Мы здесь тестируем результат getStatusForDate, и то что вызов функции — идемпотентный. Лучше разделить это на два теста, потому что две проверки не связаны.

Тексты ошибок

Тесты падают, это их свойство. Когда тестовый набор падает, что люди делают? Фиксят код, ищут проблемы. Если assertion падает, в лог идет ошибка с объяснением в чем дело. Иногда эти объяснения не очень понятны, из-за того что тесты изначально не писались в расчете на понятность логов. Посмотрим:

class WeatherTest {
  // initialization

  @ParameterizedTest
  @MethodSource("weatherDates")
  void shouldGetCurrentWeatherStatus(LocalDate date) {
    WeatherStatus testWeatherStatus = generateStatus();
    tuneWeather(date, testWeatherStatus);

    WeatherStatus result = weather.getStatusForDate(date);

    assertEquals(testWeatherStatus, result);
  }
}

Допустим в weatherDates может быть 20 разных значений даты. По идее должно быть 20 тестов, они есть. Один тест упал, видим сообщение:

expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual   :CLOUDY

Не очень понятное. 19 из 20 тестов прошли. Какая-то проблема с датой, а лог не очень понятный. Лучше переписать такой тест, улучшая фидбек:

class WeatherTest {
  // initialization

  @ParameterizedTest
  @MethodSource("weatherDates")
  void shouldGetCurrentWeatherStatus(LocalDate date) {
    WeatherStatus testWeatherStatus = generateStatus();
    tuneWeather(date, testWeatherStatus);

    WeatherStatus result = weather.getStatusForDate(date);

    assertEquals(
        testWeatherStatus,
        result,
        "Unexpected weather status for date " + date
    );
  }
}

Теперь-то лучше:

Unexpected weather status for date 2022-03-12 ==> expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual   :CLOUDY

Теперь яснее, где была проблема с датой: 2022-03-12. Также посмотрим на имплементацию toString; когда объект передается в assertEquals, библиотека этим методом превращает его в строку.

Инициализация тестовых данных

Конечно в модульном тестировании будут нужны тестовые данные в виде строк в БД, объектов, переменных. Есть 3 способа их инициализировать:

  1. Прямое объявление
  2. Паттерн Object Mother
  3. Паттерн Test Data Builder

Прямое объявление

Есть класс Post. Посмотрим на код:

public class Post {

  private Long id;
  private String name;
  private User userWhoCreated;
  private List<Comment> comments;

  // constructor, getters, setters
}

Можем создать новые экземпляры конструкторами:

public class PostTest {

  @Test
  void someTest() {
    Post post = new Post(
        1,
        "Java for beginners",
        new User("Jack", "Brown"),
        List.of(new Comment(1, "Some comment"))
    );

    // action...
  }
}

В таком подходе есть проблемы:

  1. Имена параметров не «объясняющие». Нужно проверять объявления конструкторов, чтобы понять каждое значение.
  2. Атрибуты класса не статические. Что если добавят другое поле? Нужно будет править каждый вызов конструктора в каждом тесте.

А что сеттеры? 

public class PostTest {

  @Test
  void someTest() {
    Post post = new Post();
    post.setId(1);
    post.setName("Java for beginners");
    User user = new User();
    user.setFirstName("Jack");
    user.setLastName("Brown");
    post.setUser(user);
    Comment comment = new Comment();
    comment.setId(1);
    comment.setTitle("Some comment");
    post.setComments(List.of(comment));

    // action...
  }
}

Теперь имена параметров правильные, но появились другие проблемы.

  1. Объявление слишком подробное. Тяжело понять сходу.
  2. Некоторые из параметров должны быть обязательными. Если добавим другое поле в класс Post, могут быть рантайм-эксепшены из-за несогласованности в объекте.

Значит ищем другой подход.

Паттерн Object Mother

Это простая статическая фабрика, то есть создание экземпляра:

public class PostFactory {

  public static Post createSimplePost() {
    // simple post logic
  }

  public static Post createPostWithUser(User user) {
    // simple post logic
  }
}

В простых ситуациях это работает. Но класс Post имеет много инвариантов (пост с комментом, пост с комментом и юзером, пост с юзером и несколькими комментами). Если объявим отдельный метод на каждую возможную ситуацию, получим хаос. Вобщем, нужен паттерн Test Data Builder.

Паттерн Test Data Builder

Имя говорящее. Это билдер специально для объявлений тестовых данных. Как он работает:

public class PostTest {

  @Test
  void someTest() {
    Post post = aPost()
        .id(1)
        .name("Java for beginners")
        .user(aUser().firstName("Jack").lastName("Brown"))
        .comments(List.of(
            aComment().id(1).title("Some comment")
        ))
        .build();

    // action...
  }
}

aPost(), aUser(), и aComment() — статические методы, создающие билдеры для соответствующих классов. Они инкапсулируют дефолтные значения всех атрибутов. Вызов Id, имени и других методов оверрайдит значения. Также можно усилить этот подход, сделав значения неизменяемыми, чтобы каждое изменение атрибута возвращало новый экземпляр билдера. Также можно объявлять шаблоны, уменьшая количество boilerplate-кода.

public class PostTest {

  private PostBuilder defaultPost =
      aPost().name("post1").comments(List.of(aComment()));

  @Test
  void someTest() {
    Post postWithNoComments = defaultPost.comments(emptyList()).build();
    Post postWithDifferentName = defaultPost.name("another name").build();

    // action...
  }
}

Лучшие практики коротко:

  1. Четкие имена. Они должны быть «говорящими», объяснять предназначение кода.
  2. Не группировать assertion’ы, если они выполняют разные функции.
  3. Четкие объясняющие сообщения об ошибках.
  4. Инициализация тестовых данных должна быть продуманной.

Тесты должны помогать писать код, а не создавать лишние заботы.

Инструменты юнит-тестирования

Для Java:

JUnit

Основной инструмент для большинства проектов. Есть тест-раннер и assertion-библиотека.

Ссылка

Mockito

Стандарт что касается моков в Java. API для их тестирования. 

public class SomeSuite {

  @Test
  void someTest() {
    // creates a mock for CommentService
    CommentService mockService = mock(CommentService.class);

    // when mockService.getCommentById(1) is called, new Comment instance is returned
    when(mockService.getCommentById(eq(1)))
        .thenReturn(new Comment());

    // when mockService.getCommentById(2) is called, NoSuchElementException is thrown
    when(mockService.getCommentById(eq(2)))
        .thenThrow(new NoSuchElementException());
  }
}

Сайт

Spock

Инструмент, рассчитанный на корпоративный сектор, как говорится в документации. Есть тест-раннер, моки и assertions. Тесты можно писать на Java и на Groovy вместо Java:

def "two plus two should equal four"() {
    given:
    int left = 2
    int right = 2

    when:
    int result = left + right

    then:
    result == 4
}

Сайт

Vavr Test

Особый инструмент — библиотека тестирования свойств, о которой упоминалось в статье; отличается от стандартных инструментов с assertions. Есть генератор входных значений. Для каждого генерированного значения проверяется инвариантный результат; если результат false, тестовые данные сокращаются до тех пор пока не останутся только failures. В примере — проверка корректности функции isEven:

public class SomeSuite {

  @Test
  void someTest() {
    Arbitrary<Integer> evenNumbers = Arbitrary.integer()
        .filter(i -> i > 0)
        .filter(i -> i % 2 == 0);

    CheckedFunction1<Integer, Boolean> alwaysEven =
        i -> isEven(i);

    CheckResult result = Property
        .def("All numbers must be treated as even ones")
        .forAll(evenNumbers)
        .suchThat(alwaysEven)
        .check();

    result.assertIsSatisfied();
  }
}

Сайт

***

Источник

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас