TDD на примере UrlBuilder
В этой статье я хотел бы на простом, но жизненном примере рассказать о применении техники разработки через тестирование. Главный плюс такого подхода, на который я хотел бы обратить внимание — в момент начала разработки мы уже знаем, что мы должны разработать, и имеем вполне конкретный критерий работоспособности кода.
Нами будет разработан строитель, создающий url по заданным параметрам — протокол, хост и т.п. Все совпадения с реальными веб-сервисами прошу считать плодом больной фантазии.
Проблема
Не так давно я закончил разработку достаточно крупного приложения под Android. В подробности вдаваться не буду, так как статья не об этом, скажу лишь, что приложение является толстым клиентом. Веб-сервис, к которому обращается приложение, имеет неприлочно много параметров, передающихся методом GET. Например, для поиска по всей базе:
http://server.com?action=search&query=поисковый%20запрос
Для поиска по все базе с фильтрацией по типу документа:
http://server.com?action=search&query=поисковый%20запрос&document_type_id=N
Для поиска по конкретному документу:
http://server.com?action=search&query=поисковый%20запрос&document_id=M
И так далее. Всего несколько десятков классов возможных запросов. Плюс каждый запрос может быть как к продакшену, так и к дев-серверу.
Встал вопрос, как формировать url для обращения к этому веб-сервису. Хранить маску для каждого конкретного запроса, как это сделал iPhone-программист, решая ту же задачу — явно не выход. При добавлении одного параметра пришлось бы править несколько десятков строк. И вообще, если сразу сам не сделаешь правильно по своей воле, потом тебя заставят сделать правильно недовольные пользователи.
Uri.Builder не подходит, хотя бы потому, что там нет возможности задать порт хоста.
Итак, я принял решение написать класс для построения url.
Анализ
Сначала определимся, что мы хотим от нашего класcа? Мы хотим, что бы он конструировал url, по которым мы смогли бы обращаться к нашим веб-сервисам и возвращал их как java.lang.String. Хотелось бы иметь возможность как строить url с нуля, последовательно указывая протокол, хост, путь и параметры, так и на основе некоторого пресета (например, url для поиска по всей базе, по конкретному документу и т.п.).
Разработка: интерфейсы классов и юнит-тесты
Из таких требований можно выделить по крайней мере 2 класса. Первый будет строить произвольный url, второй — url из некоторыйх заранее определённых классов. Этого вполне достаточно, что бы описать интерфейсы наших классов. Очевидно, что мы бдуем использовать паттерны «builder» и «fluent interface».
public class UrlBuilder {
public UrlBuilder protocol(String protocol){
return this;
}
public UrlBuilder host(String host){
return this;
}
public UrlBuilder port(String port){
return this;
}
public UrlBuilder path(String path){
return this;
}
public UrlBuilder param(String key, String value){
return this;
}
public String build() {
return null;
}
}
public class ServerUrlBuilder extends UrlBuilder{
public UrlBuilder fullSearch(String query){
return this;
}
public UrlBuilder fullSearch(String query, int documentTypeId){
return this;
}
public UrlBuilder documentSearch(String query, long documentId){
return this;
}
}
И сразу пишем юнит-тесты. Сначала для UrlBuilder...
public class UrlBuilderTest extends TestCase {
public void testRootUrl(){
UrlBuilder builder = new UrlBuilder();
String expected = "http://server.com/";
String actual = builder1.protocol(UrlBuilder.HTTP).host("server.com").build();
assertEquals(expected, actual);
}
public void testRootUrlWithOneParam(){
UrlBuilder builder = new UrlBuilder();
String expected = "http://server.com/?a=b";
String actual = builder.protocol(UrlBuilder.HTTP).host("server.com").param("a","b").build();
assertEquals(expected, actual);
}
public void testFullUrl(){
UrlBuilder builder = new UrlBuilder();
String expected = "http://server.com/folder/?a=b";
String actual = builder.protocol(UrlBuilder.HTTP).host("server.com").path("folder").param("a","b").build();
assertEquals(expected, actual);
}
}
… а потом и для ServerUrlBuilder.
public class SereverUrlBuilderTest extends TestCase {
public void testFullSearch(){
ServerUrlBuilder builder = new ServerUrlBuilder();
String expected = "http://server.com?action=search&query=поисковый запрос";
String actual = builder.fullSearch("поисковый запрос");
assertEquals(expected, actual);
}
public void testFullSearchWithDocumentTupeId(){
ServerUrlBuilder builder = new ServerUrlBuilder();
String expected = "http://server.com?action=search&query=поисковый запрос&document_type_id=123";
String actual = builder.fullSearch("поисковый запрос",123);
assertEquals(expected, actual);
}
public void testDocumentSearch(){
ServerUrlBuilder builder = new ServerUrlBuilder();
String expected = "http://server.com?action=search&query=поисковый запрос&document_id=123456";
String actual = builder.documentSearch("поисковый запрос",123456);
assertEquals(expected, actual);
}
}
Для полного покрытия было бы хорошо написать ещё с десяток тестов для разных комбинаций, а так же проверить негативные сценарии, когда не введёт один из параметров или введено некорректное значение. Но уважаемые читатели могут не простить таких простыней кода.
Запустим наши тесты. Конечно, ни один из них не будет пройден. Но зато теперь у нас чёткий критерий работоспособности кода. Осталось дописать всего пару строчек.
Реализация
Добавим в класс UrlBuilder константы — несколько протоколов и разделители, используемые в url. Запрограммируем логику каждого метода.
public class UrlBuilder {
public static final String HTTP = "http";
public static final String HTTPS = "https";
public static final String FTP = "ftp";
private static final String sProtocolHostSeparator = "://";
private static final String sPathSeparator = "/";
private static final String sParamSeparator = "?";
private static final String sParamEquals = "=";
private static final String sParamConcotinator = "&";
private String mProtocol;
private String mHost;
private final ArrayList<String> mPath = new ArrayList<String>();
private final ArrayList<String> mParamKeys = new ArrayList<String>();
private final ArrayList<String> mParamValues = new ArrayList<String>();
public UrlBuilder(){}
public UrlBuilder protocol(String protocol){
mProtocol = protocol;
return this;
}
public UrlBuilder host(String host){
mHost = host;
return this;
}
public UrlBuilder path(String path){
mPath.add(path);
return this;
}
public UrlBuilder param(String key, String value){
mParamKeys.add(key);
mParamValues.add(value);
return this;
}
public String build() {
final StringBuilder builder = new StringBuilder();
builder.append(mProtocol);
builder.append(sProtocolHostSeparator);
builder.append(mHost);
builder.append(sPathSeparator);
for(String path : mPath){
builder.append(path);
builder.append(sPathSeparator);
}
if(mParamKeys.size()>0){
builder.append(sParamSeparator);
for(int i=0; i<mParamKeys.size(); i++){
String key = mParamKeys.get(i);
if(i!=0){
builder.append(sParamConcotinator);
}
String value = mParamValues.get(i);
builder.append(key);
builder.append(sParamEquals);
builder.append(value);
}
}
String result = builder.toString();
return result;
}
}
Снова запусти юнет-тесты. Ура, часть из них проходит успешно. Осталось реализовать логику ServerUrlBuilder.
public class ServerUrlBuilder extends UrlBuilder{
public static final String SERVER_HOST = "server.com";
public static final String PARAM_ACTION = "action";
public static final String ACTION_SEARCH = "search";
public static final String PARAM_QUERY = "query";
public static final String PARAM_DOCUMENT_TYPE_ID = "document_type_id";
public static final String PARAM_DOCUMENT_ID = "document_id";
public UrlBuilder fullSearch(String query){
protocol(HTTP);
host(SERVER_HOST);
param(PARAM_ACTION, ACTION_SEARCH);
param(PARAM_QUERY, query);
return this;
}
public UrlBuilder fullSearch(String query, int documentTypeId){
protocol(HTTP);
host(SERVER_HOST);
param(PARAM_ACTION, ACTION_SEARCH);
param(PARAM_QUERY, query);
param(PARAM_DOCUMENT_TYPE_ID, documentTypeId);
return this;
}
public UrlBuilder documentSearch(String query, long documentId){
protocol(HTTP);
host(SERVER_HOST);
param(PARAM_ACTION, ACTION_SEARCH);
param(PARAM_QUERY, query);
param(PARAM_DOCUMENT_ID, documentId);
return this;
}
}
Снова запускаем тесты — все они проходят. Это говорит о том, что мы реализовали именно то, что требовалось. Разумеется, в той мере, в которой были полны и точны тесты.
Заключение
В этой статье мы реализовали класс UrlBuilder и производный от него ServerUrlBuilder, используя методологию TDD. Были показаны все стадии разработки: обнаружение проблемы, анализ, написание тестов и, наконец, реализация. Конечно, многое ещё нужно доработать. Например, можно добавить передачу массивов как параметров, UrlEncode для спецсимволов. Было бы здорово строить не только String, но и URI и Uri. Но это отдельный разговор, выходящий за рамки данной статьи.