Вы часто используете Mockito или другие похожие фреймворки для написания unit-тестов? Если это так, то я знаю, как сделать ваши тесты качественнее - никогда не используйте моки!

Wheel meme

Что такое Mock

Для начала нужно разобраться с терминами, что такое моки? Моки - это объекты, созданные с помощью Mockito.mock:

final Email email = Mockito.mock(Email.class);

Получившийся объект реализует Email интерфейс, но все его методы еще ничего не делают, однако у нас есть возможность определить поведение для его методов:

Mockito.when(
    email.html()
).thenReturn(
    "<p>Hello, Fakes!</p>"
);

Теперь можно использовать мок почты для печати на html странице, например:

Assert.assertThat(
    new EmailPage(email).html(), 
    IsEqual.equalTo(
        "<html><body><p>Hello, Fakes!</p></body><html>"
    )
);

Иными словами, моки - это объекты, которые генерируются внутри теста, конфигурируются под конкретное поведение и не более того, и вне теста не могут быть использованы. Если мок использовать как реальный объект взамен настоящему вне тестов, то программа не будет работать.

Это значит, что когда моки используются при написании тестов, то приходится думать о том коде, который находится внутри тестируемого объекта, потому что надо корректно сконфигурировать мок. Такой подход называется white-box testing. В примере выше при тестировании объекта EmailPage пришлось держать в голове реализацию метода EmailPage.html: Email.html вызывается внутри него, значит, нужно определить поведение для этого метода у мока Email.

И почему же это плохо?

Если изменить реализацию тестируемого метода EmailPage.html, то придется изменять и содержание теста, придется изменить настройку мока, а, может, даже создать еще пару моков, потому что текущее поведения мока Email было определено для старой реализации EmailPage.html. Старый тест был кем-то написан, на него было потрачено время и, деньги и вот теперь изменена реализация объекта, но не изменено его поведение, и тест перестал работать, и придется тратить еще время и деньги на переписывание старого теста.

white-box testing это плохо, потому что при написании тестов вектор мышления сдвигается в сторону реализации тестируемого объекта, а не в сторону его поведения.

Фейки

Тест выше можно переписать так, чтобы он тестировал поведение, а не реализацию:

Assert.assertThat(
    new EmailPage(new FakeEmail("<p>Hello, Fakes</p>")).html(), 
    IsEqual.equalTo(
        "<html><body><p>Hello, Fakes!</p></body><html>"
    )
);

Появился новый класс - FakeEmail - это легковесная простая реализация интерфейса Email, которая выглядит примерно так:

class FakeEmail implements Email {
    private String content;
    FakeEmail(String content) {
        this.content = content;
    }
    @Override
    String html() {
        return content;
    }
    ...
}

Этот объект можно использовать в тестах, он не использует сеть, не использует базу данных, не майнит крипту… Более того, этот объект можно использовать в коде, ведь он полноценный, и программа будет работать, если все реализации Email заменить на FakeEmail. Объекты таких классов я называю фейками, и они помогают тестировать другие объекты, проверяя поведение, а не реализацию.

Еще пример

Хорошо, но ведь когда явное поведение скрыто, то, кажется, без моков не обойтись. Например:

class FilterRepository {
    private final CloudFile file; // requires network
    ...
    public void updatePredicate(final String predicate) { ... }
}

В этом случае для проверки корректности метода FilterRepository.updatePredicate есть стандартный подход - Mockito.verify для того, чтобы узнать, что некоторый метод объекта CloudFile был вызван:

@Test
public void updateName() {
    final CloudFile file = Mockito.mock(CloudFile.class);
    final FilterRepository repo = new FilterRepository(file);
    repo.updatePredicate("Cloud");
    Mockito.verify(
        file, 
        Mockito.times(1)
    ).write(ArgumentMatchers.any(String.class));
}

Видите? Тут пришлось использовать any, потому что однозначно не ясно, как FilterRepository обновляет predicate в облачном хранилище, или же эта часть подвержена частым изменениям. И снова, тестируется то, как FilterRepository взаимодействует со своими зависимостями.

Есть антипаттерн - TTDD (Tautological Test Driven Development), он как раз описывает проблему повторения реализации в тестах. То есть, используя моки, чаще всего у вас будут Tautological тесты.

Однако, даже в этом случае возможно тестировать поведение объекта:

@Test
public void updateName() {
    final CloudFile file = new FakeCloudFile();
    final FilterRepository repo = new FilterRepository(file);
    repo.updatePredicate("Cloud");
    Assert.assertThat(
        file.content(),
        StringContains.containsString("Cloud")
    )
}

Класс FakeCloudFile - дешевый аналог CloudFile, реализованный, например, in-memory способом, и он позволяет тестировать поведение репозитория, а не его реализацию.


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

Фейки позволяют сосредоточиться во время написания тестов на поведении объекта, они не зависят от реализации, фейки быстрее, поскольку там нет рефлексии, тесты на фейках легче читать и понимать, что именно тестируется, какие именно входные данные и как узнать об изменениях в поведении.


Похожие мысли: