2012-08-11

Больше тестирования

Суть статей про Юнит-тестирование:

В правильных тестах не должно быть [1]
  • ввода-вывода
  • обращения к ресурсам
  • доступа к базе 
  • доступа к другим приложениям
Печально, что все этим же и заканчивается: если вы не можете протестировать ваш класс, то, скорее всего, он неправильно спроектирован. Для сложных случаев придется использовать магию моков, стабов и фикстур.


Классная работа

Рассмотрим пример:

package com.blazer.testing;

public class Divider {
    private float dividend;

    public Divider(float value) {
        this.dividend = value;
    }

    public float divide(float divisor) throws DivisionByZeroException {
        if (divisor == 0) {
            throw new DivisionByZeroException();
        }

        return dividend / divisor;
    }

    public static class DivisionByZeroException extends Exception {
    }
}

Покрываем его тестами:

package com.blazer.testing;

import org.junit.Test;

import static junit.framework.Assert.assertEquals;

public class DividerTest {
    @Test
    public void testDivision() throws Divider.DivisionByZeroException {
        Divider divider = new Divider(5f);
        assertEquals(2.5f, divider.divide(2f));
    }

    @Test
    public void testZeroDividend() throws Divider.DivisionByZeroException {
        Divider divider = new Divider(0f);
        assertEquals(0f, divider.divide(5f));
    }

    @Test(expected = Divider.DivisionByZeroException.class)
    public void testDivisionByZero() throws Divider.DivisionByZeroException {
        Divider divider = new Divider(5f);
        divider.divide(0f);
    }
}

Не правда ли тестирование это просто?

Домашнее задание 

Покройте тестами ваше реальное приложение. В приложении имеется пол-дюжины взаимосвязанных компонентов, доступ к СУБД, файловой системе и написано оно в процедурном стиле.

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

Тестируемость

Первое что приходится делать для тестирования - жестоко дробить код: первое что приходит на ум - вынести парсер и протестировать. Тесты пишутся за пять минут.И тут ступор: что делать дальше, как тестировать остальные 90% кода непонятно [2].

Ответ прост не боятся дробить и нарушать инкапсуляцию (в разумных пределах): как сказал автор статьи "доллар за нарушение красоты ОО подхода окупится двадцаткой в тестируемости".

Стабы и моки

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

@Test

public void correctWithPadding() throws FileNotFoundException {
    File file = mock(File.class);
    when(file.isDirectory()).thenReturn(true);
    when(file.list()).thenReturn(new String[]{"abc", "0321", "0123", "asd"});

    assertEquals("0321", new FileSelector(file).getMaxFile());
}

Иногда хочется протестировать большой внутренний метод. Для этого видится два решения: либо делать метод публичным (нарушение инкапсуляции), либо передавать в класс стратегию (класс с единственным методом).

Фикстуры

Главный затык в этом месте: как создать тестовую среду, на которой можно поработать с базой. Как оказалось, все упиралось в инструменты: с maven и hibernate/migrations можно не напрягаясь подменить рабочую базу маленькой СУБД создаваемой в памяти, как правило, для одной транзакции (в особых случаях коммитить изменения таки приходится). Как только это препятствие было убрано, тестировать работу с базой стало легко и просто.

@Before
public void initDB() throws SQLException, IOException {
    connection = DriverManager.getConnection(CONNECTION_STRING, "SA", "");
    connection.prepareStatement(INIT_TABLE).execute();
    loadFixtures(connection, "com/blazer/homework/fixtures.sql");
}

@After
public void dropDB() throws SQLException, IOException {
    connection.rollback();
    Closer.close(connection);
}

@Test
public void testGetData() throws SQLException {
    Dao dao = new Dao(connection);
    Assert.assertEquals(new HashSet() {{
        add(new Domain("qwer", 123L));
        add(new Domain("asdf", 456L));
        add(new Domain("zxcv", 789L));
    }}, dao.getData());
}

Продолжение следует

Дальше предстоит разобраться как тестировать пользовательский интерфейс. Здесь все опять упирается в инструментарий: VAADIN генерирует одноразовую и абсолютно нетестируемую разметку. Они даже написали платную тулзу поверх Selenium. При тестировании отображения - смена дизайна поломает все тесты, а тестируя только презентеры в MVP - не протестируем то, что рендерится браузером.

Еще одним переходным этапом может стать использование DI в коде, что резко понизит связанность между модулями и повысит тестируемость кода.

Превосходный результат



[1] Отличная статья, кстати. Жаль, что я её прочитал после написания кода
[2] Застрял на этом этапе на два года