О TDD

2021-08-29

TDD — это как подростковый секс. Все о нём говорят, но мало кто представляет, что это такое на самом деле.

Ну и Driven Development, это, имхо, слишком сильное выражение. TDD — это отличная практика, которую лично я применяю и всем советую применять. Но не стоит думать, что вся разработка движется исключительно тестами.

К сожалению, чтобы начать применять TDD, нужно его прочувствовать на себе. Простые объяснения не работают. Но, чтобы прочувствовать TDD на себе, нужно начать его применять. Попробую разрешить эту проблему курицы-яйца демонстрацией на совершенно тупом искусственном примере.

TDD — это не про то, чтобы писать тесты до кода. Это про то, чтобы писать тесты одновременно с кодом.

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

public class CalculatorTest {

    Calculator calc;

    @Before
    public void setUp() {
        calc = new Calculator();
    }

}

Естественно, этот тест не будет компилироваться. Не компилирующийся тест — это «красный» тест. Значит, пора обернуться к коду и это исправить. Минимальными усилиями. В данном случае достаточно создать пустой класс.

public class Calculator {
}

Тест теперь компилируется, но пока ничего не тестирует. Оборачиваемся к тесту и добавляем тестирование сложения. Очевидно, будем тестировать 2 + 2.

    @Test
    public void testAdd() {
        assertEquals(4, calc.add(2, 2));    // 2 + 2 = 4
    }

Тест снова не компилируется, потому что у калькулятора нет метода add(). Добавим его. Можно очевидными подсказками IDE (по Alt+Enter).

public class Calculator {

    public int add(int i, int j) {
        return 0;
    }

}

IDE подставило нам в тело метода return 0;. Неплохая заглушка, ничуть не хуже любых других. Тест теперь компилируется, но падает. Потому что мы ожидаем, что дважды два — это четыре, а не нуль.

Каким минимальным изменением можно заставить тест стать «зелёным»? Очевидно же: return 4;.

    public int add(int i, int j) {
        return 4;
    }

Всё, тест — «зелёный». Ура, мы сделали сложение!

Да-да, не пугайтесь, TDD именно так и работает. Вы должны делать в коде минимальные изменения, которые делают тесты «зелёными».

Здесь проблема не в коде, а в недостаточном количестве тестов. Очевидно, что если мы добавим ещё один тест:

    @Test
    public void testAdd2_3() {
        assertEquals(5, calc.add(2, 3));    // 2 + 3 = 5
    }

— то код придётся поменять на уже очевидно правильный:

    public int add(int i, int j) {
        return i + j;
    }

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

    @Test
    public void testAddBig() {
        assertEquals(Integer.MIN_VALUE, calc.add(Integer.MAX_VALUE, 1));
    }

Точно таким же образом нужно поступить с методами mult(), sub(), div() нашего калькулятора.

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

Вот, например, для деления есть хитрый случай, когда делитель нуль:

    @Test(expected=ArithmeticException.class)
    public void testDiv_0() {
        calc.div(2, 0);
    }

Когда у нас есть тесты, мы можем спать спокойно. Спокойнее, чем после уплаты налогов. Если тесты запускаются перед каждым деплоем, то никто не сможет выкатить на продакшен кривой код.

Тестов должно быть достаточно. Как раз для того, чтобы спать спокойно. К сожалению, формальные метрики покрытия не коррелируют в точности с уровнем этого спокойствия. И в стопроцентно покрытом коде могут найтись баги. И наоборот, нет смысла добиваться стопроцентного покрытия всех блоков catch только ради стопроцентного покрытия, часто это только лишняя трата усилий.

Если обнаружился новый баг, обязательно нужно написать новый тест, который продемонстрирует этот новый баг. Это может быть тяжело. Но обязательно нужно. Как вы иначе узнаете, что починили баг? После релиза на стейджинг и ручного тестирования? Ну как минимум, это неспортивно. И очень долгий цикл проверки оказывается.

Про это ведь и есть TDD, чтобы сократить длительность цикла проверки. От демонстрации ошибки до её исправления тут проходят максимум минуты.

жизненный цикл

Тесты влияют на код. Возьмем, например, типичный Hello World.

public class HelloWorld {

    public static void main(String[] args) {
        System.out.println("Hello, TDD!");
    }

}

Как его протестировать? На самом деле можно, если разобраться, как устроен System, и что такое System.out. Его, оказывается, можно переопределить.

public class HelloWorldTest {

    ByteArrayOutputStream out;

    @Before
    public void setUp() {
        out = new ByteArrayOutputStream();
        System.setOut(new PrintStream(out));
    }

    @Test
    public void testMain() {
        HelloWorld.main(null);
        assertEquals("Hello, TDD!\n", getOut());
    }

    String getOut() {
        return new String(out.toByteArray());
    }

}

Тут очень помогают mock объекты. Раз уж Hello World зависит от System.out, давайте не просто переопределим, а замокаем его.

public class HelloWorldMockTest {

    PrintStream out;

    @Before
    public void setUp() {
        out = mock(PrintStream.class);
        System.setOut(out);
    }

    @Test
    public void testMain() {
        HelloWorld.main(null);
        verify(out).println(eq("Hello, TDD!"));
    }

}

Согласитесь, с Mockito суть теста стала гораздо более очевидной.

Hello World — это вам не чистая функция. Тут есть побочные эффекты. В виде печати сообщения. Вся программа — сплошной побочный эффект.

Тут есть неявная зависимость от System.out. Зависимости для тестов мы можем замокать. Но не всегда это возможно. Даже тут, мы переопределяем System.out, который один в системе. Неизвестно, как это выстрелит в других тестах, особенно, если они выполняются параллельно.

Поэтому лучше отделять то, что можно потестировать, от того, что можно не тестировать. Зачем тестировать работу System.out.println()? Нам важно содержимое сообщения. Вот его и выделим.

public class HelloWorld2 {

    public static void main(String[] args) {
        System.out.println(getHello());
    }

    static String getHello() {
        return "Hello, TDD!";
    }

}

Теперь тест становится простым до безобразия:

public class HelloWorld2Test {

    @Test
    public void testGetHello() {
        assertEquals("Hello, TDD!", HelloWorld2.getHello());
    }

}

Вот так вот тесты могут влиять на код. Упрощать. Или усложнять, смотря с какой стороны посмотреть :)

Про тесты ещё часто спрашивают: что нужно тестировать, контракт или реализацию?

Идея как бы в том, чтобы написать один тест на контракт, заданный, например, интерфейсом в Java. (Опустим, что в Java одним интерфейсом контракт достаточно строгий не опишешь.) А потом применять этот тест ко всем возможным реализациям этого интерфейса.

Ну, допустим, есть у меня вот такой интерфейс:

public interface MessageStorage {

    void save(Message message);

}

И три его реализации. Одна сохраняет сообщение в Redis, другая в Postgres, а третья и в Redis, и в Postgres.

И чего тут, у этого интерфейса, тестировать? Опять, как в примере с Hello World, одни лишь побочные эффекты.

Ну ладно, у реализаций явно есть зависимости от каких-то репозиториев, которые и пишут в Redis и Postgres. И эти зависимости, я надеюсь, внедряются через конструктор. Можно протестировать, что при вызове save() будут вызываться методы этих репозиториев. Но у нас у разных реализаций получаются разные конструкторы. А у репозиториев есть единый интерфейс, чтобы их можно было одинаково замокать? Ну и так далее...

Тем не менее из этих рассуждений можно выделить ценную мысль. Реализации для Redis и Postgres могут сами по себе реализовывать MessageStorage. А вместо реализации, которая должна сохранять и в Redis, и в Postgres, можно сделать универсальную реализацию, которая сохраняет сообщение в произвольный список MessageStorage.

public class RedisMessageStorage implements MessageStorage {
    //...
}

public class PostgresMessageStorage implements MessageStorage {
    //...
}

public class ComposedMessageStorage implements MessageStorage {

    public ComposedMessageStorage(List<MessageStorage> nested) {
        //...
    }

    //...
}

Для каждой из этих реализаций придётся написать свой тест. Ибо зависимости, как писать в Redis и Postgres, у них будут различны. Зато мы придумали ComposedMessageStorage, которое может где-то ещё пригодится.

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

Типичный пример: SQL запросы. Юнит-тестами вы можете (и должны) проверить, что у вас сформировался правильный (по вашему мнению) SQL. Но вы не сможете проверить, что этот SQL действительно будет выполняться в настоящей СУБД. Как минимум набор таблиц и данных может отличаться. Или, например, Amazon Redshift, хоть и притворяется PostgreSQL 8, на самом деле очень сильно от него отличается.

Для полной проверки выполняемости SQL запросов вам нужны интеграционные тесты. Можно, например, поднять тот же Redis и Postgres в Docker, накатить нужные миграции, добавить немного нужных данных, и подёргать ваши методы. А потом проверить, что нужные данные изменились нужным образом. Долго, тяжело, но работает. И очень полезно.

Считаются интеграционные тесты частью TDD? Ну а почему бы и нет?

Keep the bar green! (И это не про зелёный ликёр и водку)

this bar is not so green ;)

P.S. Примеры из этой статьи собраны в репозитории. Смотреть историю.