Обзор компонентов
Помня про пластичность, тестируемость и наглядность, попытаемся воспроизвести процесс формирования архитектуры, не привязанной к каким-либо каноническим формам. Архитектура и ее составляющие должны сформироваться естественным путем, без требований следовать форме ради формы.
Сервис
Вначале была функция. Точнее, вначале были данные, т.к. без данных функция не имеет смысла. С другой стороны, если нет функции, откуда взялись данные? Чтобы не погружаться в проблему курицы и яйца, объединим функцию и данные и будем считать, что одно вытекает из другого, что они неразрывно связаны. Тогда получится, что вначале был объект. Будем также считать, что объект — это состояние (кучка данных) + набор методов (функций), которые с этим состоянием умеют работать. Чтобы не навлечь на себя гнев адептов ООП за столь вольную трактовку Парадигмы, сформулируем отдельный специальный термин: «Сервис».
Примерами сервисов являются локальное хранилище и сетевой слой. Отличительными особенностями сервиса являются:
- императивный интерфейс;
- внутреннее состояние.
Сервис может предоставлять доступ к своему состоянию синхронно, работать в режиме запрос-ответ, а также предоставлять подписки на обновления данных. В ходе выполнения задач сервис может обращаться к другим сервисам, которые доступны ему в виде зависимостей. Совокупное состояние всех сервисов формирует состояние приложения.
Сервис бесполезен, если им никто не пользуется. Значит, внешняя по отношению к сервису среда должна его инициализировать, а затем пинговать сообщениями. Сообщения также не берутся из ниоткуда: их порождают другие сервисы. В контексте iOS исключение — это сервис-патриарх UIApplicationMain
. Его создает и параметризирует с помощью AppDelegate
функция main
(видимо, все-таки вначале была функция!):
#import "AppDelegate.h"
int main(int argc, char *argv[])
{
@autoreleasepool {
return UIApplicationMain(
argc,
argv,
nil,
NSStringFromClass([AppDelegate class])
);
}
}
UIApplicationMain
получает сообщения не от сервисов, а напрямую от операционной системы. Однако обычно в этот код мы не заглядываем, поэтому сервисом-праотцом будем считать AppDelegate
. У него даже нет явного конструктора (инициализатора), поэтому AppDelegate
– идеальный кандидат на роль сущности, появившейся из небытия!
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var appController: IAppController?
func application(
_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.appController = AppController() // тривиальный
self.window = appController?.start() // нетестируемый код
return true
}
}
protocol IAppController {
func start() -> UIWindow
}
class AppController: IAppController {
func start() -> UIWindow {
let window = UIWindow()
window.rootViewController = UINavigationController(
rootViewController: StartViewController()
)
window.makeKeyAndVisible()
return window
}
}
Единственный недостаток AppDelegate
как сервиса в том, что его инициализатор недоступен. Все свои зависимости AppDelegate
инициализирует самостоятельно, что в общем случае приводит к невозможности «честно» покрыть его Unit-тестами. С другой стороны, этот класс настолько плотно увязан с окружением, т.е. операционной системой, что тестировать его нет смысла. Проще превратить его в тривиальный прокси-объект, вся ответственность которого будет заключаться в передаче системных событий другим специализированным сервисам.
Каждый сервис должен быть разделен на интерфейс и реализацию. В контексте iOS и языка Swift интерфейс обычно представлен протоколом, а реализация — классом.Связи между сервисами в терминах UML описываются диаграммой классов, соответственно, где описываемый сервис на диаграмме представлен классом, а все его зависимости — интерфейсами. Такую упрощенную диаграмму классов удобно называть диаграммой сервисов. Диаграмма сервисов позволяет легко оценить зависимости каждого сервиса и понять, насколько сложно будет покрыть сервис Unit-тестами.
На диаграмме сервисов используется три вида стрелок: наследование, использование и создание. Стрелка наследования связывает интерфейс и реализацию. Стрелка использования показывает, какие интерфейсы нужны сервису для работы. Стрелка создания показывает, кто отвечает за создание сервиса (места, в которых сервис используется).
Сервисы хранят состояние приложения, поэтому ветвления в бизнес-логике сервиса описываются диаграммой состояний.
Выше приведен пример диаграммы состояний для сервиса, отвечающего за вход в приложение. Сервис получает от пользователя номера телефона, затем ожидает SMS-код подтверждения. Если код верный, сервис получает от backend-а токен авторизации, в противном случае можно запросить код повторно, но не ранее чем через 60 секунд. Тестировать такой сервис непросто, потому что в нем смешивается несколько типов логики:
- бизнес-логика – ограничение на количество SMS-кодов (SMS стоят денег);
- UI-логика: индикация обратного отсчета и навигация;
- техническая логика – токен авторизации.
Предположим, нам удалось распределить ответственности входа так, чтобы все сервисы, отвечающие за вход, оставались тестируемыми. Например, UI-логику можно отдать вью-контроллерам, а таймер SMS-кодов держать внутри AuthAPI. Тогда AuthViewModel останется тестируемой:
import Foundation
final class AuthViewModel: IAuthViewModel {
private let authAPI: IAuthAPI
private let storage: IStorage
init(authAPI: IAuthAPI, storage: IStorage) {
self.authAPI = authAPI
self.storage = storage
}
var onFinish: VoidCompletion = nil
private var phone = ""
var isRequestAvailable: Bool { authAPI.isRequestAvailable }
func apply(phone: String, completion: @escaping VoidHandler) {
self.phone = phone
authAPI.requestSMS(phone: phone, completion: completion)
}
func check(code: String, onError: ErrorCompletion) {
authAPI.getAuthToken(
phone: self.phone, smsCode: code
) { [weak self] result in
switch result {
case .success(let token):
self?.storage.save(token: token)
self?.onFinish?()
case .failure(let error):
onError?(error)
}
}
}
}
Наглядность по-прежнему обеспечивается диаграммой состояний, различие лишь в том, что диаграмма описывает теперь совокупность сервисов, а не отдельный объект. А вот с пластичностью не все гладко: вспомогательные сервисы, обслуживающие AuthViewModel
, знают друг о друге. Это можно исправить, если жесткие зависимости заменить на контекстные через механизм подписок на события. Но эти подписки тоже нужно где-то сформировать, а затем это «где-то» протестировать!
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow()
let authAPI = AuthAPI()
let storage = Storage()
let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
authVM.onFinish = {
// в обработчике нетестируемая логика!
let booksVM = BooksViewModel()
let booksVC = BooksViewController(viewModel: booksVM)
let navigationVC = UINavigationController(rootViewController: booksVC)
window.rootViewController = navigationVC
}
let phoneVC = PhoneViewController(viewModel: authVM)
let navigationVC = UINavigationController(rootViewController: phoneVC)
window.rootViewController = navigationVC
window.makeKeyAndVisible()
return true
}
}
Таким образом, использование сервисов и подписок в AppDelegate
обеспечивает пластичность и наглядность, но логика формирования подписок остается нетестируемой. Значит, нужно перенести эту логику в тестируемую область. Для этого понадобится операция.
Операция
Как правило, операция представлена отдельным объектом, который получает сервисы в качестве зависимостей. Основное отличие операции от сервиса в том, что она не имеет императивного интерфейса, т.е. не предоставляет доступа к своему состоянию и не имеет каких-либо методов в интерфейсе, кроме метода launch
. Метод launch
принимает на вход параметры, описывающие контекст запуска (переменные и/или дополнительные контекстные зависимости), а также замыкание onFinish
, если операция асинхронная.
import UIKit
protocol IAuthOp {
func launch(onFinish: VoidCompletion)
}
final class AuthOp: IAuthOp {
private let window: UIWindow
private let authAPI: IAuthAPI
private let storage: IStorage
init(window: UIWindow, authAPI: IAuthAPI, storage: IStorage) {
self.window = window
self.authAPI = authAPI
self.storage = storage
}
func launch(onFinish: VoidCompletion) {
let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
authVM.onFinish = { [weak self] in
guard let self = self else { return }
let booksVM = BooksViewModel()
let booksVC = BooksViewController(viewModel: booksVM)
let navigationVC = UINavigationController(
rootViewController: booksVC
)
self.window.rootViewController = navigationVC
onFinish?()
}
let phoneVC = PhoneViewController(viewModel: authVM)
let navigationVC = UINavigationController(
rootViewController: phoneVC
)
window.rootViewController = navigationVC
window.makeKeyAndVisible()
}
}
Таким образом, операция похожа на обычную функцию: принимает параметры на вход и возвращает результат. Вместе с тем операция имеет свой жизненный цикл: ее нужно инициализировать, сохранить в памяти, запустить на выполнение, по завершению выполнения получить результат и затем удалить из памяти.
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private var authOp: IAuthOp?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow()
let authAPI = AuthAPI()
let storage = Storage()
self.authOp = AuthOp(
window: window,
authAPI: authAPI,
storage: storage
)
self.authOp?.launch(onFinish: { [weak self] in
self?.authOp = nil
})
return true
}
}
«Неделимая последовательность» в определении операции накладывает два ограничения на ее код:
- операция не может вызывать другие операции и не имеет к ним доступа;
- в последовательности сообщений не должно быть логических развилок и циклов (условия и циклы нуждаются в состоянии, поэтому содержатся в сервисах).
Такие ограничения позволяют легко описывать операцию с помощью диаграмм последовательности. Задача операции состоит в том, чтобы организовать обмен сообщениями между сервисами. В таком контексте удобно использовать термин «диаграмма сообщений». Каждое обращение к сервису в коде операции имеет взаимно однозначное отображение в стрелку-сообщение на диаграмме – так обеспечивается наглядность.
Диаграмма сообщений может состоять из нескольких частей. Одна часть – постоянная. Это сообщения, сформированные непосредственно в методе launch
и отвечающие за конфигурацию сервисов, за их взаимную настройку (подписки). Другая часть вариативная – это уже сообщения внутри этих предварительно настроенных сервисов. Такое разделение обусловлено тем, что сервисы содержат состояние и логические развилки, поэтому для описания этой вариативной части может потребоваться несколько диаграмм.
Для обеспечения пластичности можно использовать фабрику операций, в которую предварительно передается контейнер с ключевыми сервисами. Термин «ключевой» применительно к сервису означает, что время жизни сервиса сравнимо со временем жизни приложения. Доступ к контейнеру позволяет фабрике формировать произвольный набор зависимостей у любой операции, следовательно, мы легко можем менять логику операции.
При таком подходе возникает проблема циклической зависимости: сервис содержится в контейнере и использует фабрику операций, а фабрика должна иметь доступ к контейнеру с сервисами. Обойти проблему можно через двухэтапную инициализацию сервисов. Для начала задействуем особую операцию makeServicesOp
, которая будет отвечать исключительно за инстанцирование (создание объектов) сервисов. Метод launch
в makeServicesOp
возвращает в AppDelegate
контейнер с ключевыми сервисами, после чего AppDelegate
передает ссылку на этот контейнер в фабрику операций. При создании всех последующих операций фабрика сможет пользоваться любыми сервисами в контейнере уже без ограничений.
func application(
_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// создаем сервисы
let services = opFactory
.make_makeInitialServicesOp()
.launch()
opFactory.setInitialServices(services: services)
// инициализируем сервисы
scenarioFactory.make_setupInitialServicesOp().launch()
// запускаем приложение
scenarioFactory.make_startOp().launch()
return true
}
Код в объекте операции тестируется легко, если операция не создает сущности самостоятельно и все зависимости получает извне. Обычно такими зависимостями являются ключевые сервисы. В Unit-тесте их можно заменить mock-объектами, а через последовательность обращений к mock-объектам можно отследить и проверить на корректность цепочку сообщений в операции.
На практике нас ждет несколько проблем. Во-первых, системные сущности заменить на mock-объект не получится. Ярким примером являются сущности из UIKit
, например, UIWindow
. Работу с таким сущностями желательно инкапсулировать внутри ключевого сервиса.
import XCTest
final class AuthTests: XCTestCase {
final class AuthApiMock: IAuthAPI {
func requestSMS(phone: String,
completion: @escaping VoidHandler) {}
func getAuthToken(phone: String, smsCode: String,
completion: @escaping StringHandler) {}
var isRequestAvailable: Bool { return true }
}
final class StorageMock: IStorage {
func save(token: String) {}
}
func testWindowSetup() throws {
// MARK: Setup
let window = UIWindow()
let authApi = AuthApiMock()
let storage = StorageMock()
let authOp = AuthOp(
window: window,
authAPI: authApi,
storage: storage)
// MARK: Action
authOp.launch(onFinish: nil)
// MARK: Test
XCTAssertNotNil(window.rootViewController)
XCTAssertTrue(window.isKeyWindow)
}
}
Во-вторых, помимо ключевых сервисов в операции могут использоваться контекстные сервисы. Время жизни и параметры контекстного сервиса определяются контекстом его использования. Типичный представитель контекстных сервисов — это вью-модель. Отличительными характеристиками сервиса являются императивный интерфейс и наличие состояния. Интерфейс вью-модели обрабатывает команды, получаемых с UI-слоя и предоставляет доступ к данным, отображаемым на UI-слое, поэтому вью-модель – это сервис. Вью-модель формируется непосредственно перед показом соответствующего экрана с учетом контекста, поэтому она называется контекстным сервисом. Помимо времени жизни контекстный сервис отличается от ключевого сервиса отсутствием доступа к фабрике операций. Операция непосредственно (без использования фабрик) инициализирует вью-модель, передает ей на вход все необходимые ключевые сервисы и подписывает слушателей на события вью-модели. Иными словами, операция формирует контекстные связи вью-модели с окружающим миром.
import UIKit
protocol IAuthOp {
func launch(onFinish: VoidCompletion)
}
final class AuthOp: IAuthOp {
private let window: UIWindow
private let authAPI: IAuthAPI
private let storage: IStorage
init(window: UIWindow, authAPI: IAuthAPI, storage: IStorage) {
self.window = window
self.authAPI = authAPI
self.storage = storage
}
func launch(onFinish: VoidCompletion) {
let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
authVM.onFinish = { [weak self] in
guard let self = self else { return }
let booksVM = BooksViewModel()
let booksVC = BooksViewController(viewModel: booksVM)
let navigationVC = UINavigationController(
rootViewController: booksVC)
self.window.rootViewController = navigationVC
onFinish?()
}
let phoneVC = PhoneViewController(viewModel: authVM)
let navigationVC = UINavigationController(
rootViewController: phoneVC)
window.rootViewController = navigationVC
window.makeKeyAndVisible()
}
}
Протестировать саму вью-модель легко, но как протестировать настройку контекста, т.е. связку между вью-моделью и операцией? Заменить вью-модель mock-объектом не получится, т.к. вью-модель создается внутри и недоступна извне операции. Для этого понадобится роутер.
Роутер
Роутер отвечает за UI-логику и является сервисом, поскольку у него есть свой внутренний контекст (состояние). Этот контекст роутер обрабатывает вместе с внешним контекстом, полученным через вью-модель. Формально все сообщения для роутера выглядят так: «Покажи такой-то экран, вот тебе вью-модель, она расскажет подробности». Применительно к iOS роутер для каждой вью-модели инстанцирует соответствующий UIViewController
.
import UIKit
final class Router: IRouter {
var window: UIWindow?
func setup() {
let window = UIWindow()
window.makeKeyAndVisible()
self.window = window
}
func showOnboarding(viewModel: IOnboardingViewModel) {
// Вью-контроллеры инстанцирует исключительно роутер!
let onboardingVC = OnboardingViewController(viewModel: viewModel)
let navVC = UINavigationController(rootViewController: onboardingVC)
window?.rootViewController = navVC
}
func showPhone(viewModel: IAuthViewModel) {
let phoneVC = PhoneViewController(viewModel: viewModel)
// Роутер сам создал все вью-контроллеры, поэтому состояние
// и предысторию UI-слоя знает лучше всех. Если онбординг уже
// показывался, роутер откроет экран входа через уже готовый
// UINavigationController с анимацией
if let navVC = window?.rootViewController
as? UINavigationController {
navVC.pushViewController(phoneVC, animated: true)
} else {
let navVC = UINavigationController(rootViewController: phoneVC)
window?.rootViewController = navVC
}
}
func showBooks(viewModel: IBooksViewModel) {
...
Роутер обеспечивает тестируемость операции. Во-первых, благодаря инкапсуляции UI-слоя, все зависимости операции могут быть представлены ключевыми сервисами, один из которых – роутер. Во-вторых, операция отдает созданную ей вью-модель наружу в роутер. Это позволяет протестировать корректность настройки вью-модели: RouterMock
воздействует на вью-модель —> вью-модель эмитирует события в замыкание-подписку (сформировано операцией) —> отправляются сообщения к сервисам-зависимостям операции. Зависимости операции в Unit-тестах тоже представлены mock-объектами. Коорректность настройки контекстных связей между вью-моделью и ключевыми сервисами отслеживаем через обращения к этим mock-объектам. Таким образом, операция становится полностью тестируемой.
import Foundation
protocol IAuthOp {
func launch(onFinish: VoidCompletion)
}
final class AuthOp: IAuthOp {
private let authAPI: IAuthAPI
private let router: IRouter
private let storage: IStorage
init(authAPI: IAuthAPI, router: IRouter, storage: IStorage) {
self.authAPI = authAPI
self.router = router
self.storage = storage
}
func launch(onFinish: VoidCompletion) {
let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
authVM.onFinish = { [weak self] in
self?.router.showBooks(viewModel: BooksViewModel())
onFinish?()
}
router.setup()
router.showPhone(viewModel: authVM)
}
}
Следует различать и разделять бизнес-логику и UI-логику. Факт навигационного перехода на определенный экран по событию — это бизнес-логика. Обработка позиции скролла, индикаторы загрузки, анимации и вид навигационного перехода — это все UI-логика. Разберем пример.
Предположим, поступила задача от маркетинга: обрабатывать диплинк с акцией «Книга месяца со скидкой 90%». Обрабатывать — значит уметь открывать экран просмотра книги в произвольный момент времени. С точки зрения бизнес-логики все просто: получили диплинк с ID книги — открыли книгу. Если бы не требование UX-проектировщика: нельзя прерывать пользовательский контекст при открытии акции. Это означает, что UI-логика открытия диплинка зависит от контекста:
- если пользователь находится на экране с каталогом книг, то акционную книгу следует открыть через
push
вUINavigationController
каталога — так же, как открываются все остальные карточки товаров; - если пользователь уже находится на экране с книгой, то в случае совпадения открытой и акционной книг ничего не делаем, а в случае различия снова выполняем
push
книги в текущий в навигационный стек; - если пользователь находится на вкладке с настройками, где навигационного стека не предусмотрено, то экран с акционной книгой показываем модально.
Роутер всегда точно знает, какой экран он сейчас показывает (потому что сам отвечает за их показ) и какой экран от него требуют показать. Таким образом, роутер может самостоятельно принять решение, какой вид навигационного перехода следует применить: вызывающая сторона (бизнес-логика) может об этом не беспокоиться. Остальное приложение не знает о UI вообще ничего. Для остальных классов роутер — это просто еще один сервис, который выводит изображение на экран и принимает через этот экран команды пользователя (касания).
Таким образом, благодаря роутеру можно разделить UI-логику и бизнес-логику, а для последней обеспечить пластичность, наглядность и тестируемость. Проверяем:
- Тестируемость — обеспечена. Закрыли проблему связки между вью-моделью и операцией: RouterMock воздействует на ViewModel ➝ ViewModel эмитирует события в замыкание-подписку (сформирована внутри операции) ➝ замыкание отправляет сообщения к сервисам-зависимостям операции ➝ mock-зависимости уведомляют тест.
- Наглядность — обеспечена. Операция по-прежнему может быть описана диаграммой сообщений, просто на диаграмме появляется еще один сервис — роутер. Связка вью-модели и роутера позволяет полностью изолировать бизнес-логику от UI-логики,
- Пластичность — обеспечена. Мы можем из любой операции запросить у роутера показ любого экрана. Операция должна всего лишь предоставить контекст, т.е. вью-модель. Как обработать этот контекст, т.е. что делать с вью-моделью — это уже забота роутера.
А что насчет масштабирования? Действительно, приложение не может состоять из одной операции и набора сервисов. Во-первых, есть параметры запуска приложения, такие как диплинки или пуш-уведомления. Эти параметры определяют выбор операции – где будем хранить логику запуска? Во-вторых, операций всегда будет несколько, значит, появляется межоперационная логика. Например, мы запускаем OnboardingOp
только при первом запуске приложения, а BooksOp
запускается только в том случае, если пользователь авторизован. Если не авторизован, то следует сначала запустить AuthOp
, а после ее успешного завершения уже BooksOp
.
«Межоперационную» логику и цепочки запуска операций в терминах UML можно описать диаграммой деятельности, а в контексте ROSS ее удобно называть диаграммой операций.
Перед нами снова встает задача тестирования, только на этот раз нужно протестировать упомянутую «межоперационную» логику. Как и в случае с операцией, мы перенесем эту логику во внешнюю сущность. Эта сущность называется сценарий.
Сценарий
Сценарии формируют каркас приложения. Можно изучить только эти классы и в общих чертах понять, как приложение устроено и какую функциональность оно предоставляет. Сценарий полностью описывается диаграммой операций, что обеспечивает высокую наглядность. Технически сценарий представляет собой объект, который создается и запускается на выполнение точно так же, как и операция. У сценария также есть метод launch
, который может принимать замыкание onFinish
и другие параметры. Отличие сценария от операции в том, операция не может обращаться к другим операциям, а сценарий может. У сценария есть доступ к фабрике операций, в то время как операция ничего не знает о других операциях, соответственно, не имеет доступа к фабрике операций.
Сценарий отвечает за запуск операций, обработку результата их выполнения и передачу данных от одной операции к другой. Сценарий не создает операции сам — для этого есть фабрика операций. Тестирование сценария выполняется через отслеживание обращений к фабрике операций: обращения должны быть в правильной последовательности. Такие тесты уже достаточно высокоуровневые, чтобы конкурировать с UI-тестами. Вместе с тем тесты сценария остаются достаточно легкими и нехрупкими, сохраняя наглядность за счет использования диаграммы операций.
Сценарий формируется с помощью фабрики сценариев, а запустить сценарий на выполнение может только сервис, имеющий доступ к этой фабрике. Так мы обеспечиваем тестируемость как самого сценария, так и запускающего его сервиса. Теоретически доступ к фабрике сценариев может быть у любого ключевого сервиса, но на практике для запуска сценариев удобно использовать специальный сервис — AppService
. Его ответственность состоит исключительно в том, чтобы реагировать на входящие сообщения и запускать различные сценарии. Сам сценарий доступа к фабрике сценариев не имеет. Если какому-либо сценарию в ходе выполнения нужно запустить другой сценарий, он обращается к AppService
. Для этого интерфейс AppService
передается в сценарий через параметры его метода launch
.
Идеальный сценарий не имеет побочных эффектов, поэтому принимает на вход инициализатора исключительно фабрику операций. Отслеживая последовательность обращений к фабрике, мы проверяем корректность сценария.
Хороший сценарий принимает в качестве зависимостей фабрику операций и AppService. В этом случае в тестах сценария придется поддерживать только интерфейс для AppService.
Терпимый сценарий — это фабрика операций + AppService + прочие сервисы. Наличие отличных от AppService сервисов на входе — это признак того, что в сценарии есть бизнес-логика, которую было бы неплохо перенести в операции.
Плохой сценарий — это фабрика операций + контейнер сервисов. В этом случае контейнер с сервисами выступает как сервис-локатор, в результате чего сценарий становится нетестируемым.
Доступ к AppService
позволяет сценарию оставаться пластичным. AppDelegate
может делегировать в AppService
работу со сценариями, по-прежнему оставаясь нетестируемым, но зато тривиальным. В терминах UML AppService
легко описать диаграммой прецедентов. И это название хорошо отражает происходящее, поскольку при необходимости AppService
может запускать не только сценарии, но и одиночные операции, для которых создавать целый сценарий избыточно.
Вернемся к примеру со скидкой на онбординге. Очевидно, что нам придется серьезно перестроить наше модульное приложение, но на сей раз начнем мы с проектирования.
Диаграмма прецедентов будет включать 4 прецедента:
- вход;
- поиск в каталоге;
- оплата;
- просмотр книги.
Вход представлен сценарием, который включает в себя 3 операции: онбординг, оплата на онбордиге и авторизация. Поиск в каталоге — это одиночная операция. Оплата тоже выглядит одиночной операцией, но лишь на первый взгляд. Во-первых, теперь она может запускаться как на онбординге, так и после авторизации. Во-вторых, она должна учитывать контекст — платил ли пользователь уже? Из этого следует, что оплата должна быть представлена сценарием, включающим 3 отдельных операции: проверка статуса оплаты, оплата в авторизованном состоянии и оплата на онбординге. Следовательно, из сценария входа оплату убираем: для запуска оплаты сценарий входа теперь обращается к AppService
.
Просмотр книги — это сценарий, который включает 2 операции:
- проверка статуса оплаты (если не оплачено — запускается сценарий оплаты);
- непосредственно просмотр книги.
Предположим, что затея с платежкой увенчалась финансовым успехом и наш маркетолог продолжает генерировать идеи. Пусть на этот раз бизнес-гипотеза звучит так: «Если дать пользователю посмотреть каталог книг без регистрации, то конверсия в оплату увеличится; регистрацию запрашивать только перед просмотром книги, при этом оплатить без регистрации по-прежнему можно». Снова начнем с проектирования. Диаграмма прецедентов теперь включает только 3 прецедента:
- вход;
- оплата;
- просмотр книги.
Прецедент «вход» представлен сценарием из 2-х операций: онбординг и поиск в каталоге. Оплата по-прежнему вызывается через AppService
, просмотр тоже запрашивается через AppService
.
Прецедент «просмотр» представлен сценарием из 2-х операций: авторизация и просмотр. Оплата запрашивается через AppService
.
Прецедент «оплата» по-прежнему представлен сценарием из 3-х операций: проверка статуса оплаты, оплата в авторизованном состоянии и оплата на онбординге.
Легко заметить, что правки свелись исключительно к манипуляциям внутри сценариев. Мы больше не привязаны к экранам и можем легко перекраивать приложение.
РОСС
Итак, путем поэтапного развития нам удалось в итоге удовлетворить всем трем ключевым требованиям к архитектуре приложения:
- тестируемость обеспечена тем, что мы можем покрыть Unit-тестами все составляющие приложения: сервисы, операции, сценарии и даже UI-логику в роутере;
- пластичность обеспечена возможностью перестраивать операции и сценарии, не прибегая к масштабному рефакторингу — зависимости всегда под рукой;
- наглядность обеспечена диаграммами сообщений, операций, и прецедентов, которые однозначно отображаются в код. Диаграммы являются основной частью Технического задания, формируемого аналитиками и проектировщиками для программистов, а также являются «источником правды» для тестировщиков.
Полученная структура состоит из 4-х компонентов: Роутер, Операция, Сервис и Сценарий. Вместе они образуют аббревиатуру РОСС. Примечательно, что на английском языке аббревиатура звучит идентично: ROSS (Router, Operation, Service, Scenario).