Monday, March 9, 2015

API тесты, тестирование парсеров и toString

Мы живем в несовершенном мире. В совершенном мы имеем возможность получить полную спецификацию API, написать по ней тесты и потом написать парсеры для ответов сервера, опираясь на тесты.

Но мир несовершенен и поэтому можно столкнуться с ситуацией когда есть 100500 кода, парсящего ответы от сервера, написанного не очень хорошо, большая часть давно и многое людьми которые уже ушли.

Есть два варианта - вздохнуть и оставить все как есть или пытаться привести эту механику в порядок.

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

public void testParse() throws Exception {
    final String input = "some, for example, json";

    final ExpectedResult expected = new ExpectedResult(/* ... */);

    assertEquals(expected,
                 ParserUnderTest.parse(input));
}


Но вот беда - для тестов нужен образец, с которым сравнивать результат.

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

В java у объектов есть метод toString. Согласно документации, этот метод

Returns a string representation of the object. In general, the toString method returns a string that "textually represents" this object. The result should be a concise but informative representation that is easy for a person to read. It is recommended that all subclasses override this method.


Любопытно то, что результат должен быть "easy for person to read". Но мы можем дополнительно наложить ограничение чтобы он был "easy for machine to parse". Когда я столкнулся с такой задачей, то я принял следующий формат: объект в круглых скобках, список в квадратных, отображение (map) в фигурных. Получается что то вида:

(ClassName field=(OtherClass field="string" intField=1))

[(ClassName field=null), (ClassName field=null)]


Очевидно, что такой формат подпадает под оба критерия - он легко может быть разобран машиной и в то же время не теряет человекочитаемости.

Для формирования такого вывода удобно определить класс с интерфейсом вроде такого:
public final class ToStringBuilder {
    public ToStringBuilder(final String className);
    public ToStringBuilder(final Object o);

    public <T> ToStringBuilder field(final String name,
                                     final List<T> o);

    public <K, V> ToStringBuilder field(final String name,
                                        final Map<K, V> o);

    public ToStringBuilder field(final String name,
                                 final Object o);

    public String toString();
}


В таком случае реализация метода toString для каждого класса API представляется довольно тривиальной:

public final class SomeApiClass {
    /* ... */ 

    private final int mIntField;
    private final String mStringField;
    
    @Override public String toString() {
        return new ToStringBuilder(this)
            .field("mIntField", mIntField)
            .field("mStringField", mStringField)
            .toString();
    }
}


более того, в языках где существует reflection, можно использовать его для автоматического вывода объектов.

Таким образом, у нас есть возможность получить строковое представление, которое затем можно распарсить и из распаршеного представления сгенерировать объект-образец.

Тут также возможно несколько путей - строить объекты с помощью reflection'а же или просто сгенерировать код, создающий требуемые объекты. Я пошел по последнему пути, так как это делает код тестов легче поддерживаемым.

Аналогично можно по разному осуществлять сравнение объектов, однако, как выяснилось, самым простым способом было сравнивать строковые представления объектов. То есть:

assertEquals(expected.toString(), parsed.toString());


Собирая все воедино:

- Для каждого участвующего класса реализуется метод toString. - Код, обрабатывающий ответ сервера, дополняется кодом записывающим в файл ответ сервера и строковое представление распаршенного объекта ответа.
- Создается утилита-парсер (это долгосрочное вложение - такая утилита может легко использоваться повторно, к тому же такая утилита достаточно проста - реализация на языке Haskell заняла у меня около 80 строк), которая парсит из файла из п1 пару "тело ответа"-"объект образец" и генерирует код теста такого вида, как был представлен в начале статьи - все данные для этого уже есть на этом этапе.

В результате из дампа вида:

---------- request:
some magic json!
---------- response:
[(ApiObject id=4242, name="magicName"), (ApiObject id=2223, name="othername")]


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

final String input = "some magic json!";

assertEquals(Arrays
             .asList
             (new ApiObject(4242,
                            "magicName"),
              new ApiObject(2223,
                            "othername")).toString(), 
             PARSER.parse(input).toString());


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

Upd: Кстати, да, есть вариант реализовать toString таким образом, чтобы он сразу генерировал код создания, без промежуточного шага с парсером. Однако это ломает человекочитаемость вывода toString.

И, как показала практика, сравнивать строками годится только в случае простых объектов - этот способ может не работать при использовании HashMap и HashSet. Так что для более сложных объектов придется использовать reflection-based решение для сравнения в тестах или писать код equals вручную.

No comments:

Post a Comment