Преамбула
Я работаю в компании, которая делает достаточно большое и, не побоюсь этого слово, громоздкое мобильное приложение с солидной для мобильного приложения историей в несколько лет и, соответственно с довольно солидным и монструозным кодом.
Поток пожеланий от заказчика разнообразен и обилен и в связи с этим время от времени приходится вносить изменения даже в те места, которые для этого, вроде как, не предназначены. Некоторые, возникающие при этом проблемы — регрессионные баги — доставляют время от времени немало сложных часов.
При этом, по тем или иным причинам на проекте существует лишь ручное тестирование и довольно внушительного количество тестировщиков, а довольно наивные попытки автоматизации оного остались лишь на уровне нескольких довольно тривиальных юнит-тестов на уровне «Hello world».
В частности — у отдела тестирования есть внушительный цикл тестов для поиска регрессии, который проводится достаточно регулярно и занимает приличное количество времени. Соответственно, однажды возникла задача как-то оптимизировать этот процесс. Об этом и пойдет речь.
Честно, я не помню, какие средства для автоматизированного приемочного тестирования я смотрел и почему они мне не подошли. (Буду очень благодарен, если кто-то в комментариях подскажет интересные варианты решения этого — наверняка я пропустил что-то очень стоящее) Одно могу сказать точно — так как наше приложение, фактически тонкий клиент — очень многие кейсы невозможно(ну или как минимум, я не знаю как) покрыть юнит-тестами и нужно что-то еще. Так или иначе было решено написать свою библиотеку для автоматизации приемочного тестирования.
О самой системе и ее использовании
Итак, эта система должна удовлетворять следующим требованиям:
- Система должна иметь возможность исполнение тестов в рантайме приложения.
- Система должна дать возможность тестировщикам перевести тесты с человеческого языка на что-то, что может выполняться автоматически.
- Система должна покрывать приемочные тесты в том смысле — что она должна обеспечивать доступ в том или ином смысле к тем данным, которые видит пользователь.
- Система должна быть переносима. (Желательно, чтобы можно было адаптировать ее к другим платформам, кроме iOS и, возможно, к другим проектам)
- Система должна давать возможность синхронизировать тесты с внешним источником.
- Система должна давать возможность отправлять результаты выполнения тестов во внешний сервис.
Крупноблочно, система тестирования должна состоять из следующих блоков:
А потенциальный тестировщик должен действовать по следующей схеме:
- Зайти на webGUI, залогиниться и написать/отредактировать какие-то тест-кейсы и тест-планы
- Запустить приложение на устройстве и открыть тестовый интерфейс (например по тапу тремя пальцами в любой момент приложения)
- Получить с сервера тест-планы и тест-кейсы интересующей тематики
- Запустить тесты. Какие-то из них завершатся корректно, какие-то упадут, какие-то(например, UI тесты) потребуют дополнительной валидации
- на webGUI открыть эту историю исполнения и найти тесты требующие дополнительной валидации и на основе дополнительных данных (например, скриншотов в те моменты) самостоятельно проставить — выполнился ли тест успешно, или же нет
В этой статье я бы хотел подробнее остановиться на модуле TestCore. Его можно описать примерно следующей схемой:
Последовательность действий:
- Считываются нативные макросы и размещаются в таблице макросов с ключами — названиями, с которыми они могут вызываться из тест-кейса
- Считываются файлы тест-кейсов — они прогоняются через лексер и парсер, на основе чего строится синтаксическое дерево исполнения.
- Тесткейсы размещаются в таблице тесткейсов с ключами — названиями.
- Извне приходит команда на исполнение некого тест-плана и пользователь видит результат тестирования.
И с этого момента подробнее:
Тест кейс представляет собой структуру следующего вида:
Заголовок теста и передаваемые параметры
Список действий теста
Терминальный символ.
Действием теста может быть вызов макроса, арифметические действия или присваивания. Вот, например, несколько простейших примеров, которые я использовал для проверки корректности системы:
#simpleTest
/*simple comment*/
send("someSignal")
waitFor("response")->timeOut(3.0)->failSignals("signalError")->onFail("log fail")
#end
#someTest paramA,paramB
log("we have #paramA and #paramB")
#end
#mathTest
foo = 1 + 2
bar = foo * 3
failOn(bar == 9, "calculation is failed")
failOn(((1 + 2) < 5),"1 + 2 < 5 : false")
failOn(NOT((1 + 2) > 5), "1 + 2 > 5 : true")
failOn(NOT("abc" == "def"), "true equality of abc and def")
failOn("abc" == "abc", "false equality of abc and abc")
failOn(1 == 2, "compeletly wrong equality")
#end
Операторы send, log, waitFor и прочие — это и есть макросы — о которых я писал выше — это, по сути, нативные методы, написание которых и лежит на плечах программиста, а не тестировщика.
Вот, для примера код макроса логирования:
@implementation LogMacros
-(id)executeWithParams:(NSArray *)params success:(BOOL *)success
{
NSLog(@"TESTLOG: %@",[params firstObject]);
return nil;
}
+(NSString *)nameString
{
return @"log";
}
@end
А вот, код макроса FailOn — по сути, являющегося ассертом.
@implementation FailOnMacros
-(id)executeWithParams:(NSArray *)params success:(BOOL *)success
{
id assertion = [params firstObject];
NSString *message = nil;
if (params.count > 1)
message = params[1];
if ([assertion isKindOfClass:[NSNumber class]])
{
if ([assertion intValue] == 0)
{
*success = NO;
TCLog(@"FAILED: %@",message);
}
}
return nil;
}
+(NSString*)nameString
{
return @"failOn";
}
@end
Таким образом, написав некоторое количество кастомных макросов (макросы из вышеприведенного примера и нескольких других), есть возможность обеспечить доступ к данным приложения для проверки их этими тестами, выполнению каких-то UI-действий, отправки скриншотов на сервер.
Одним из ключевых макросов является макрос waitFor — который ждет от приложения реакции на свои действия. Он же, на данный момент является одной из основных точек, где код приложение воздействует на исполнение тестовых примеров. То есть для комфортной работы библиотеки нужно не только написать какое-то количество специфических для проекта макросов, но и внедрить в код приложения различные статус-сигналы, о смене состояния, отправки запроса, получении ответа и так далее. То есть, иными словами, подготовить приложение к тому, что оно будет протестировано таким способом.
Под капотом
А под капотом начинается самое интересное. Основная часть (lexer, parser, executor, execution tree) написана на C, YACC и Lex — таким образом она может быть скомпилирована, запущена и вполне успешно интерпретировать тесты не только на iOS, но и на других системах, которые умеют C. Если будет интерес — я бы попробовал написать в отдельной статье о тонкостях сращивания неродных iOS языков с моей любимой IDE XCode — вся разработка велась именно в ней, но в этой статье я более менее вкратце расскажу только о коде.
Lex
Как вы уже поняли, для решения задачи писался свой маленький, но очень гордый интерпретируемый скриптовый язык, а значит в полный рост встает задача интерпретации. Для качественной интерпретации самописных велосипедов средств не так уж и много и я воспользовался связкой YACC и LEX
На хабре было несколько статей на их тему (если честно, их для старта не хватило. Очень не хватало какого-то не слишком сложного, но при этом не слишком очевидного, примера использования. И я хотел бы верить, что если у кого-то встанет такая задача — мой код поможет взять какой-то старт)
Цикл статей о написании компилятора с погружением в то, как оно работает
Небольшая статья об одном несложном примере
Вики о лексерах
Вики о YACC
Ну и много других полезных и не очень ссылок…
Если вкратце — задача Лексера — обеспечить, чтобы на вход парсеру подавались не символ-за-символом, а уже подготовленная последовательность токенов с уже определенными типами.
Чтобы не замусоривать статью длинными листингами — вот тут код одного из лексеров:
Лексер
Фактически, он различает арифметические знаки, числа и названия и их же передает на вход парсеру.
YACC
По сути — YACC это волшебная штука, которая переводит однажды написанное описание языка в Форме Бэкуса-Наура в интерпретатор языка.
Вот тут код основного парсера:
Парсер
Рассмотрим для понимания кусочек из него, для понимания:
program:
alias module END_TERMINAL {finalizeTestCase($2);}
;
module: /*this is not good, but it can be nil*/ {$$ = NULL;}
| expr new_line {$$ = listWithParam($1);}
| module expr new_line {$$ = addNodeToList($1,$2);}
;
YACC генерирует синтаксическое дерево, то есть фактически весь тест-кейс сворачивается в одну ноду program, которая в свою очередь состоит из объявления кейса, списка действия и финального терминала. Список действий в свою очередь может быть свернут из вызовов функций, арифметических выражений и так далее. Например:
func_call:
NAME_TOKEN '(' param_list ')' {$$ = functionCall($3,$1);}
| '(' func_call ')' {$$ = $2;}
| func_call '->' func_call {$$ = decorateCodeNodeWithCodeNode($1,$3);}
;
param_list: /*May be null param list */ {$$ = NULL;}
| math {$$ = listWithParam($1);}
| param_list ',' math {$$ = addNodeToList($1,$3);}
;
math:
param {$$ = $1;}
| '(' math ')' {$$ = $2;}
| math sign math {$$ = mathCall($2,$1,$3);}
| NOT '(' math ')' {$$ = mathCall(signNOT,$3, NULL);}
| MINUS math {$$ = mathCall(signMINUS,$2, NULL);}
;
В частности, например вызов функции — это ее название, ее параметры, ее модификаторы.
Вообще, сам по себе YACC — просто проходит по нодам-токенам и сворачивает их. Чтобы с этим можно было что-то сделать — на каждый проход по какой-то синтаксической конструкции навешивается логика, которая параллельно создает именно дерево в памяти, которым можно воспользоваться в дальнейшем. Для понимания — в нотации YACC
$$ — это результат, который проассоциирован с данным выражением
$1,$2,$3… — это результаты, проассоциированные с соответствующими фонемами данных выражений.
А вызову навроде listWithParam, mathCall и так далее — генерят и связывают между собой ноды в памяти.
Ноды
Исходники, как генерятся ноды можно прочитать тут:
Логика генерации нод
Заголовочник ноды
Фактически, от ноды требуется то, чтобы граф, который представляют они в совокупности, можно было обойти и по результатам обхода получить какое-то заключение о выполнении теста. Фактически, они должны представлять собой абстрактное синтаксическое дерево
После сворачивания выражения program в $$ мы имеем как раз это самое дерево и мы можем его вычислить.
Executor
Код экзекутора хранится собственно вот тут: Код
Фактически — это рекурсивный разбор дерева в глубину слева-направо. Нода в зависимости от ее типа разбирается либо как mathNode (арифметика), либо как operationalNode(вызов макроса, составления списка параметров).
Листьями этого графа служат либо константы (строковые, числовые), либо имена переменных, которые на этапе первичного разбора формируют lookup table и обзаводятся индексами быстрого доступа к ячейкам памяти в них, а на этапе вычисления просто обращаются к этим ячейкам, либо вызов макроса, который точно так же через модуль bridge запрашивает исполнение макроса с данным названием и списком параметров (вот тут следует не забыть, что параметры к этому моменту уже должны быть вычислены). И много других рутинных и не очень моментов связанных с управлением памятью, структурами данных и т.п.
Пример вызова
Ну и в качестве завершения приведу пример, как это чудо, собственно, вызывается из нативного кода:
-(void) doWork
{
TestReader *reader = [[TestReader alloc] init];
[reader processTestCaseFromFile:[[NSBundle mainBundle] pathForResource:@"testTestSuite" ofType:@"tc"]];
[reader processTestHierarchyFromFile:[[NSBundle mainBundle] pathForResource:@"example" ofType:@"th"]];
[self performSelectorInBackground:@selector(run) withObject:nil];
}
-(void) run
{
[[[TestManager sharedManager] hierarchyForName:@"TestFlow"] run];
}
Все исполнение тестового плана ведется в отдельном потоке — не в главном — и при написании макросов, требующих доступа к данным приложения, рекомендуется об этом не забывать. И на выходе метода Run будет объект класса TestHierarchy, который содержит в себе дерево объектов класса TestCase с названием и статусом исполнения, ну и конечно пачкой буков в логах.
В качестве P.S.
Как ни странно, тестировщики восприняли эту вещь с радостью и сейчас эта штука потихоньку готовится к внедрению на проект. Было бы здорово потом написать об этом чудесном процессе.
Исходные коды модуля TestCore для iOS вы можете найти по ссылке на гитхабе: github.com/trifonov-ivan/testSuite
Вообще, существенная часть работы делалась скорее для самообразования, но при этом была доведена до какого-то логического завершения — поэтому я буду очень благодарен если вы сможете подсказать мне какие-то слабые места в идее — быть может какие-то средства, более эффективно решающие эту задачу. Как вы думаете — стоит ли развивать идею до полноценного тестового сервиса?
Ну и да — если какая-то из частей будет нуждаться в более подробных пояснениях — я бы попробовал написать об этом, потому что нюансов в процессе возникала прорва. «Но поля этой статьи слишком узки для них» (с)
Автор: i_user