CodeFest 2019. Иван Букшев (ЦФТ) — Создание MockServer’a для сурового финансового продукта

16b6c87229eaf58768d25ed7b2bbbf52?s=47 CodeFest
April 06, 2019

CodeFest 2019. Иван Букшев (ЦФТ) — Создание MockServer’a для сурового финансового продукта

«Какая польза от автотестов?», «Какие средства для UI- тестирования есть в мире iOS-разработки?», «Кто должен писать UI-тесты?» — в докладе речь пойдет совсем не про это. Данная история будет освещать технические тонкости и подводные грабли в реализации MockServer’a — фреймворка, позволяющего подменять ответы на запросы от сервера.

Краткое содержание:
— Подходы к реализации MockServer’a.
— Настройка окружения для каждого UI-теста.
— Настройка уникальных конфигураций для UI-тестов.
— Процесс развития ответов от MockServer’а: от одиночных ответов до настраиваемой псевдодинамики.
— Решение проблем с версионированием API с последующей автоматизацией.

16b6c87229eaf58768d25ed7b2bbbf52?s=128

CodeFest

April 06, 2019
Tweet

Transcript

  1. Mock Server для сурового финансового продукта Букшев Иван Евгеньевич Старший

    инженер-программист мобильных приложений Центр Финансовых Технологий (ЦФТ), Денежные Переводы Новосибирск, 2019 г.
  2. !2 Предпосылки для внедрения UI-тестов

  3. Рост количества первоисточников проблем !3 iOS-команда (чел.) 0 13 25

    38 50 апр'17 июл'17 окт'17 янв'18 апр'18 июл'18 окт'18 янв'19 апр'19 июл'19 окт'19 янв'20
  4. Эффективность подхода !4 1CI-агент с 2-мя симуляторами или 5 QA-инженеров

    с 2-мя девайсами 0 100 200 300 400 500 600 700 800 0 3 6 9 12 тест-кейсы инженерные часы
  5. Параллелизация !5

  6. Статистика на CI !6

  7. !7 XCTestHTMLReport: детальная визуализация

  8. !8 XCTestHTMLReport: список устройств

  9. !9 XCTestHTMLReport: детали по каждому тесту

  10. !10 XCTestHTMLReport: скриншот экрана с ошибкой

  11. Summary Аргументы «А, давайте!» • Минимизация ошибок в production за

    счёт более быстрого реагирования на возникающие проблемы в период разработки. • Сокращение времени на регрессионное тестирование за счёт автоматического прогона основных (70-80%) тест-кейсов. • Визуализация результатов и наличие артефактов, которые можно показывать бизнесу. Аргумент «Есть один нюанс.» • Нужен «MockServer» для инфраструктуры UI-тестирования. !11
  12. !12 Варианты Для реализации MockServer’a Client-side Server-side

  13. Server-side !13 Как это выглядит? • Разворачивается локальный сервер на

    билд-машине и у каждого разработчика, если он ему необходим для работы. • В коде приложения указывается локальный адрес хоста для доступа.
  14. Swift Server-side !14 VAPOR, Perfect, Kitura, Zewo, etc.

  15. Server-side !15 Почему отказались? • Разворачивается локальный сервер на билд-машине

    и у каждого разработчика, если он ему необходим для работы. • Необходимо поддерживать изменения API на клиенте и на сервере. • Имеем «глупый» сервер: request -> response.
  16. Client-side !16 Как это выглядит? • Можно использовать 3rd party:

    OHTTPStubs, Swifter, Embassy. • Набор возможностей, принцип работы и способ интеграции в приложение у каждого решения индивидуальны.
  17. Client-side !17 • У каждого решения свои плюсы и минусы.

    • Где были возможности использования «в рамках разработки» и «в рамках тестирования», там были проблемы с тем, что на выходе получали всё тот же «глупый» сервер. • Где можно было создавать сложные правила, там были неприятные моменты с интеграцией в проект. • Необходимо было бы разбираться в кишках этих решений. Почему отказались?
  18. !18 Напишем-ка своё со своими «гибкими» решениями

  19. Собственный client-side в 5 этапов !19 1 2 3 4

    5
  20. Собственная реализация URLProtocol !20 1 2 3 4 5 An

    abstract class that handles the loading of protocol-specific URL data. open class URLProtocol: NSObject
  21. Реализация абстрактных методов !21 1 2 3 4 5 class

    func canInit(with request: URLRequest) -> Bool Return value true if the protocol subclass can handle request, otherwise false.
  22. Реализация абстрактных методов !22 1 2 3 4 5 class

    func canonicalRequest(for request: URLRequest) -> URLRequest Returns a canonical version of the specified request. It is up to each concrete protocol implementation to define what “canonical” means. A protocol should guarantee that the same input request always yields the same canonical form.
  23. Реализация абстрактных методов !23 1 2 3 4 5 func

    startLoading() When this method is called, the subclass implementation should start loading the request, providing feedback to the URL loading system via the URLProtocolClient protocol.
  24. Реализация абстрактных методов !24 1 2 3 4 5 func

    startLoading() // Работаем с self When this method is called, the subclass implementation should start loading the request, providing feedback to the URL loading system via the URLProtocolClient protocol.
  25. !25 1 2 3 4 5 var client: URLProtocolClient? {

    get } Работа с сущностью, в которую пихаем ответы
  26. !26 var client: URLProtocolClient? { get } The object the

    protocol uses to communicate with the URL loading system. Работа с сущностью, в которую пихаем ответы 1 2 3 4 5
  27. !27 configuration.protocolClasses = [MockServerURLProtocol.self] An URLSessionConfiguration object defines the behaviour

    and policies to use when uploading and downloading data using an URLSession object. Установка URLProtocol в URLSessionConfiguration 1 2 3 4 5
  28. Установка URLProtocol в URLSessionConfiguration !28 configuration.protocolClasses = [MockServerURLProtocol.self] URLSession objects

    support a number of common networking protocols by default. Use this array to extend the default set of common networking protocols available for use by a session with one or more custom protocols that you define. 1 2 3 4 5
  29. !29 1 2 3 4 5 Создание файлов-ответов на запросы

  30. Файл-ответ .json !30

  31. NetResponseRecorder !31 Создавать .json’ы ответов руками слишком долго — это

    можно оптимизировать, сохраняя локально весь трафик клиент-серверного общения. Помогает при поддержке новых версий API.
  32. NetResponseRecorder !32 Создавать .json’ы ответов руками слишком долго — это

    можно оптимизировать, сохраняя локально весь трафик клиент-серверного общения. Помогает при поддержке новых версий API. На самом деле, можно оптимизировать и эту автоматизацию.
  33. – Иженер-программист “MockServer готов, используйте.” !33

  34. – QA-инженер “То, что вы написали, нам не подходит.” !34

  35. Первый результат Плюсы • Контроль сетевого взаимодействия. • MockServer стало

    возможным использовать в рамках разработки. Минусы • Всё работало в рамках одного окружения: ответы на запросы всегда приходили одни и те же. • Использование MockServer’а для прогона тестов не представлялось возможным. !35
  36. Файл-конфигурации .json !36 [ { "http_method": "GET", "request_method": "promo-actions", "stub_file_path":

    "promo-actions/GET_promo-actions_Transfer_RURU", "response_status_code": 200 }, { "http_method": "GET", "request_method": "countries", "stub_file_path": "countries/GET_countries_Online_In", "response_status_code": 200 }, { "http_method": "GET", "request_method": "events", "stub_file_path": "events/GET_events_Transfer_RURU", "response_status_code": 200 }, … ]
  37. Правило из конфигурации !37 { "http_method": "GET", "request_method": "countries", "stub_file_path":

    "countries/GET_countries_Online_In", "response_status_code": 200 }
  38. !38 { "http_method": "GET", "request_method": "countries", "stub_file_path": "countries/GET_countries_Online_In", "response_status_code": 200

    } Правило из конфигурации
  39. !39 { "http_method": "GET", "request_method": "countries", "stub_file_path": "countries/GET_countries_Online_In", "response_status_code": 200

    } Правило из конфигурации
  40. !40 { "http_method": "GET", "request_method": "countries", "stub_file_path": "countries/GET_countries_Online_In", "response_status_code": 200

    } Правило из конфигурации
  41. !41 { "http_method": "GET", "request_method": "countries", "stub_file_path": "countries/GET_countries_Online_In", "response_status_code": 200

    } Правило из конфигурации
  42. Правило из конфигурации !42 { "http_method": "GET", "request_method": "countries", "stub_file_path":

    "countries/GET_countries_Online_In", "response_status_code": 200 } На запрос GET /countries необходимо прислать ответ, который находится по пути "countries/GET_countries_Online_In" с кодом 200.
  43. .bundle для MockServer’a !43 base-api configurations extra-api Дефолтные файлы-ответы на

    все запросы приложения. Конфигурации с наборами правил для MockServer’a. Файлы-ответы, которые указаны в конфигурациях, находятся в extra-api. Файлы-ответы, которые были указаны в конфигурациях — «перезаписывают» дефолтные ответы.
  44. !44 Со стороны UITests-таргета

  45. !45 func testTransferOverSumLimit() { super.launch(with: "ConfigurationFileName") ... } Уникальная конфигурация

    для теста
  46. !46 func testTransferOverSumLimit() { super.launch(with: "ConfigurationFileName") ... } Уникальная конфигурация

    для теста
  47. !47 private let app = XCUIApplication() func launch(with configurationFilePath: String?

    = nil) { self.app.launchEnvironment[“KEY_1”] = “EMULATE” if let filePath = configurationFilePath { self.app.launchEnvironment[“KEY_2”] = filePath } self.app.launch() } BaseTest .swift
  48. !48 private let app = XCUIApplication() func launch(with configurationFilePath: String?

    = nil) { self.app.launchEnvironment[“KEY_1”] = “EMULATE” if let filePath = configurationFilePath { self.app.launchEnvironment[“KEY_2”] = filePath } self.app.launch() } Запуск тестов на эмулированных данных
  49. !49 private let app = XCUIApplication() func launch(with configurationFilePath: String?

    = nil) { self.app.launchEnvironment[“KEY_1”] = “EMULATE” if let filePath = configurationFilePath { self.app.launchEnvironment[“KEY_2”] = filePath } self.app.launch() } Передача параметра-конфигурации
  50. !50 private let app = XCUIApplication() func launch(with configurationFilePath: String?

    = nil) { self.app.launchEnvironment[“KEY_1”] = “EMULATE” if let filePath = configurationFilePath { self.app.launchEnvironment[“KEY_2”] = filePath } self.app.launch() } Передача параметра-конфигурации
  51. !51 1. Унаследовать каждый UI-тест от BaseTest. 2. Сказать приложению,

    что нужна эмуляция. 3. Создать файлы-ответы и файл конфигурации (при необходимости). 4. Передать в приложение имя файла конфигурации (при наличии). Суммируя: со стороны UITests-таргета
  52. !52 Со стороны Application-таргета

  53. !53 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Нужно ли активировать MockServer?
  54. !54 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Нужно ли активировать MockServer?
  55. !55 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Запуск произошёл из UI-тестов ProcessInfo.processInfo.environment
  56. !56 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Активация для штатного запуска
  57. !57 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Активация для штатного запуска needEmulateServer: Bool needUseConfiguration: Bool configurationName: String recordResponses: Bool EmulationConfig
  58. !58 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Все остальные случаи
  59. !59 func needEmulate(emulationConfig: EmulationConfig?) -> Bool { #if DEBUG if

    let value = environment[“KEY_1”], value == “EMULATE” { return true } else if let config = emulationConfig { return config.needEmulateServer } #endif return false } Защита от попадания в production
  60. !60 Защита от попадания в production

  61. !61 Защита от попадания в production

  62. !62 let configurationFileName: String? = { if let fileName =

    environment["KEY_2"] { // Файл с конфигурацией, который мы указали в UI-тесте. return fileName } else if emulationConfig.needUseConfiguration { // Файл с конфигурацией для штатного запуска приложения. return emulationConfig.configurationName } else { return nil } }() Запуск приложения
  63. !63 let configurationFileName: String? = { if let fileName =

    environment["KEY_2"] { // Файл с конфигурацией, который мы указали в UI-тесте. return fileName } else if emulationConfig.needUseConfiguration { // Файл с конфигурацией для штатного запуска приложения. return emulationConfig.configurationName } else { return nil } }() Запуск приложения
  64. !64 let configurationFileName: String? = { if let fileName =

    environment["KEY_2"] { // Файл с конфигурацией, который мы указали в UI-тесте. return fileName } else if emulationConfig.needUseConfiguration { // Файл с конфигурацией для штатного запуска приложения. return emulationConfig.configurationName } else { return nil } }() Запуск приложения
  65. !65 let configurationFileName: String? = { if let fileName =

    environment["KEY_2"] { // Файл с конфигурацией, который мы указали в UI-тесте. return fileName } else if emulationConfig.needUseConfiguration { // Файл с конфигурацией для штатного запуска приложения. return emulationConfig.configurationName } else { return nil } }() Запуск приложения needEmulateServer: Bool needUseConfiguration: Bool configurationName: String recordResponses: Bool EmulationConfig
  66. !66 let configurationFileName: String? = { if let fileName =

    environment["KEY_2"] { // Файл с конфигурацией, который мы указали в UI-тесте. return fileName } else if emulationConfig.needUseConfiguration { // Файл с конфигурацией для штатного запуска приложения. return emulationConfig.configurationName } else { return nil } }() Запуск приложения
  67. !67 print("MockServer: Получен файл конфигурации: '\(filePath)'.") // Получаем сконфигурированные правила

    из файла конфигурации. let parser = JSONResponseStrategiesParser() let rules = parser.rules(from: filePath) // Добавляем все созданные правила на MockServer. rules.forEach { MockServerProcessingDirector.shared.add(rule: $0) } Конфигурация MockServer’a
  68. !68 print("MockServer: Получен файл конфигурации: '\(filePath)'.") // Получаем сконфигурированные правила

    из файла конфигурации. let parser = JSONResponseStrategiesParser() let rules = parser.rules(from: filePath) // Добавляем все созданные правила на MockServer. rules.forEach { MockServerProcessingDirector.shared.add(rule: $0) } Конфигурация MockServer’a
  69. !69 print("MockServer: Получен файл конфигурации: '\(filePath)'.") // Получаем сконфигурированные правила

    из файла конфигурации. let parser = JSONResponseStrategiesParser() let rules = parser.rules(from: filePath) // Добавляем все созданные правила на MockServer. rules.forEach { MockServerProcessingDirector.shared.add(rule: $0) } Конфигурация MockServer’a
  70. !70 print("MockServer: Получен файл конфигурации: '\(filePath)'.") // Получаем сконфигурированные правила

    из файла конфигурации. let parser = JSONResponseStrategiesParser() let rules = parser.rules(from: filePath) // Добавляем все созданные правила на MockServer. rules.forEach { MockServerProcessingDirector.shared.add(rule: $0) } Конфигурация MockServer’a
  71. !71 print("MockServer: Получен файл конфигурации: '\(filePath)'.") // Получаем сконфигурированные правила

    из файла конфигурации. let parser = JSONResponseStrategiesParser() let rules = parser.rules(from: filePath) // Добавляем все созданные правила на MockServer. rules.forEach { MockServerProcessingDirector.shared.add(rule: $0) } Конфигурация MockServer’a
  72. !72 1. Нужно ли активировать эмуляцию? Если да, то: 2.

    Нужно ли использовать кастомную конфигурацию? Если да, то: 3. Получить правила из файла конфигурации. 4. Добавить правила из пункта 3 на MockServer. Суммируя: со стороны Application-таргета
  73. Итоги первого рефакторинга Плюсы • MockServer стало возможным использовать как

    для разработки, так и для автоматического тестирования. • У каждого UI-теста появилась возможность указать индивидуальную конфигурацию (конфигурации можно переиспользовать и в рамках нескольких тестов). Минусы • #if-директива DEBUG !73
  74. – Иженер-программист “MockServer исправлен, используйте.” !74

  75. – QA-инженер “Нам мало того, что вы написали.” !75

  76. – QA-инженер “Нужна динамика.” !76

  77. Что значит «нужна динамика»? !77 Курс доллара: 1. Пользователь хочет

    отправить деньги. 2. Уточняется курс (GET /tariffs). 3. …
  78. Что значит «нужна динамика»? !78 Курс доллара: 1. Пользователь хочет

    отправить деньги. 2. Уточняется курс (GET /tariffs). 3. … 4. Опять уточняется курс (GET /tariffs).
  79. Файл-конфигурации .json !79 { "static": [ … ], "dynamic": [

    { "http_method": "GET", "request_method": "tariffs", "responses": [ { "stub_file_path": "response_1", "response_status_code": 200 }, { "stub_file_path": "response_2", "response_status_code": 200 }, ] }, ] }
  80. Правило динамической обработки !80 { "http_method": "GET", "request_method": "tariffs", "responses":

    [ { "stub_file_path": "response_1", "response_status_code": 200 }, { "stub_file_path": "response_2", "response_status_code": 200 }, ] }
  81. Правило динамической обработки !81 { "http_method": "GET", "request_method": "tariffs", "responses":

    [ { "stub_file_path": "response_1", "response_status_code": 200 }, { "stub_file_path": "response_2", "response_status_code": 200 }, ] }
  82. Правило динамической обработки !82 { "http_method": "GET", "request_method": "tariffs", "responses":

    [ { "stub_file_path": "response_1", "response_status_code": 200 }, { "stub_file_path": "response_2", "response_status_code": 200 }, ] }
  83. Инфраструктура !83 MockServer’a

  84. MockServerProcessingDirector !84 1. Контейнер для всех правил: После парсинга файла-

    конфигурации, все правила устанавливаются именно сюда. Хранятся в массиве, упорядоченном по убыванию приоритетов этих правил. 2. Первое звено в обработке какого-либо реквеста: именно сюда прилетает перехваченный URLProtocol для каждого запроса.
  85. !85 = Priority + Aspect MockServerRule

  86. !86 = Priority + Aspect MockServerRule Необходим для упорядочивания конфликтующих

    правил.
  87. !87 = Priority + Aspect MockServerRule Основная характеристика правила, по

    которой понимаем, что нужно делать: static, dynamic, serverUnavailable, etc.
  88. Стратегии для обработки !88 В зависимости от аспекта правила создаётся

    стратегия обработки. Тут могут находится сервисы по формированию имени файлов, по извлечению данных, по отправлению ответов на клиентскую часть — MockServerResponseTransmitter.
  89. MockServerResponseTransmitter !89 func sendSuccess(data: Data, statusCode: Int, urlProtocol: URLProtocol) {

    … let url = urlProtocol.request.url! let response = HTTPResponse(url, statusCode, httpVersion, headerFields)! let client = urlProtocol.client! client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) client.urlProtocol(urlProtocol, didLoad: data) client.urlProtocolDidFinishLoading(urlProtocol) }
  90. MockServerResponseTransmitter !90 func sendSuccess(data: Data, statusCode: Int, urlProtocol: URLProtocol) {

    … let url = urlProtocol.request.url! let response = HTTPResponse(url, statusCode, httpVersion, headerFields)! let client = urlProtocol.client! client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) client.urlProtocol(urlProtocol, didLoad: data) client.urlProtocolDidFinishLoading(urlProtocol) }
  91. MockServerResponseTransmitter !91 func sendSuccess(data: Data, statusCode: Int, urlProtocol: URLProtocol) {

    … let url = urlProtocol.request.url! let response = HTTPResponse(url, statusCode, httpVersion, headerFields)! let client = urlProtocol.client! client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) client.urlProtocol(urlProtocol, didLoad: data) client.urlProtocolDidFinishLoading(urlProtocol) }
  92. MockServerResponseTransmitter !92 func sendSuccess(data: Data, statusCode: Int, urlProtocol: URLProtocol) {

    … let url = urlProtocol.request.url! let response = HTTPResponse(url, statusCode, httpVersion, headerFields)! let client = urlProtocol.client! client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) client.urlProtocol(urlProtocol, didLoad: data) client.urlProtocolDidFinishLoading(urlProtocol) }
  93. MockServerResponseTransmitter !93 func sendSuccess(data: Data, statusCode: Int, urlProtocol: URLProtocol) {

    … let url = urlProtocol.request.url! let response = HTTPResponse(url, statusCode, httpVersion, headerFields)! let client = urlProtocol.client! client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) client.urlProtocol(urlProtocol, didLoad: data) client.urlProtocolDidFinishLoading(urlProtocol) }
  94. MockServerResponseTransmitter !94 func sendFailure(error: NSError, urlProtocol: URLProtocol) { let client

    = urlProtocol.client! client.urlProtocol(urlProtocol, didFailWithError: error) }
  95. MockServerResponseTransmitter !95 func sendFailure(error: NSError, urlProtocol: URLProtocol) { let client

    = urlProtocol.client! client.urlProtocol(urlProtocol, didFailWithError: error) }
  96. Итоги всех рефакторингов Плюс • Индивидуальные конфигурации, которые включают в

    себя правила со статическими ответами, с динамическими ответами, а также специфические правила: 503 ошибка для определённых запросов и т.п. !96
  97. Итоги всех рефакторингов Плюс • Низкий порог входа для QA-инженеров.

    !97
  98. Итоги всех рефакторингов Плюс • Низкий порог входа для QA-инженеров.

    !98
  99. Итоги всех рефакторингов Плюс • Возможность покрыть практически любой кейс.

    !99
  100. Итоги всех рефакторингов Небольшой тюнинг • DEBUG в #if-директиве заменился

    на свою собственную переменную. • Выглядит немного лаконичнее. !100
  101. Итоги всех рефакторингов Немного самокритики • Динамика прописывается в файле

    конфигурации. • Было бы здорово прописывать динамику в самом тесте, непосредственно перед тем, как нужно подменить ответ. !101
  102. Планы на будущее? • Новые правила. • Попытки сделать конфигурации

    ещё проще. • Вынесение динамики в тесты. • Возможно, что-то ещё — время покажет. !102
  103. Спасибо за внимание! !103 Готов ответить на ваши вопросы.