[Разработка под iOS] Composable Architecture — свежий взгляд на архитектуру приложения. Тесты
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам.
В прошлой серии
Часть 1 — основные компоненты архитектуры и как работает Composable Architecture
Тестируемый код
В предыдущем выпуске был разработан каркас приложения список покупок на Composable Architecture. Перед тем как продолжить наращивать функционал необходимо сохраниться — покрыть код тестами. В этой статье рассмотрим два вида тестов: unit тесты на систему и snapshot тесты на UI.
Что мы имеем?
Еще раз взглянем на текущее решение:
- состояние экрана описывается списком продуктов;
- два вида событий: изменить продукт по индексу и добавить новый;
- механизм, обрабатывающий действия и меняющий состояние системы — яркий претендент для написания тестов.
struct ShoppingListState: Equatable {
var products: [Product] = []
}
enum ShoppingListAction {
case productAction(Int, ProductAction)
case addProduct
}
let shoppingListReducer: Reducer<ShoppingListState, ShoppingListAction, ShoppingListEnviroment> = .combine(
productReducer.forEach(
state: \.products,
action: /ShoppingListAction.productAction,
environment: { _ in ProductEnviroment() }
),
Reducer { state, action, env in
switch action {
case .addProduct:
state.products.insert(
Product(id: UUID(), name: "", isInBox: false),
at: 0
)
return .none
case .productAction:
return .none
}
}
)
Типы тестов
Как понять что архитектура не очень? Легко, если вы не можете покрыть ее на 100% тестами (Vladislav Zhukov)
Не все архитектурные паттерны четко регламентируют подходы к тестированию. Рассмотрим как эту задачу решает Composable Arhitecutre.
Unit тесты
Одна из причин полюбить Composable Arhitecutre является подход к написанию unit тестов.
Тестирование основного механизма системы — recuder'а — происходит с помощью построения цепочки шагов: send(Action) и receive(Action). На каждом этапе проверяем, что состояние системы изменилось должным образом.
Send(Action) позволяет имитировать действий пользователя.
Receive(Action) говорит о том, что на предыдущем шаге выполнился эффект и вернул результат — action.
В конце теста или по ходу цепочки в блоке .do {} проверяем обращения к сервисам.
Первый наш тест посвящен операции добавления продукта.
func testAddProduct() {
// Создаем тестовый стор
let store = TestStore(
initialState: ShoppingListState(
products: []
),
reducer: shoppingListReducer,
environment: ShoppingListEnviroment()
)
// описываем ожидаемое поведение системы
store.assert(
// создаем событие добавление продукта
.send(.addProduct) { state in
// описываем ожидаемое состояние системы
state.products = [
Product(
id: UUID(),
name: "",
isInBox: false
)
]
}
)
}
Первое на что следует обратить внимание, что тестирование системы происходит независимо от слоя представления.
Запускаем тест и ловим фейл
Достаточно информативное сообщение об ошибке говорит нам о несовпадении присвоенного идентификатора продукта с ожидаемым:
Для того, чтобы разобраться в чем дело, введем понятие чистая функция.
Reducer — чистая функция
Что же такое чистая функция?
«Чистые» функции — это любые функции, исходные данные которых получены исключительно из их входных данных и не вызывают побочных эффектов в приложении.
В действительности, внутри нашей функции генерируется UUID в качестве идентификатора продукта. Такое поведение называется побочным эффектом, а наша функция становится "грязной".
Чтобы это исправить необходимо генерировать UUID через сервис. В Composable Architecture сервисы представлены объектом окружения (Environment).
Добавим в наш ShoppingListEnviroment сервис (функцию) генерации UUID.
struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID
}
И используем ее при создании продукта:
Reducer { state, action, env in
switch action {
case .addProduct:
state.products.insert(
Product(
id: env.uuidGenerator(),
name: "",
isInBox: false
),
at: 0
)
return .none
...
}
}
В результате получаем чистую функцию, которую можно тестировать. Возвращаясь к нашему тесту получаем следующее:
func testAddProduct() {
let store = TestStore(
initialState: ShoppingListState(),
reducer: shoppingListReducer,
// Создаем окружение
environment: ShoppingListEnviroment(
// инжектим сервис генерации мокового UUID
uuidGenerator: { UUID(uuidString: "00000000-0000-0000-0000-000000000000")! }
)
)
store.assert(
// Имитируем нажатие на кнопку "добавить продукт"
.send(.addProduct) { newState in
// Описываем ожидаемое изменение состояния системы
newState.products = [
Product(
// продукту установился определенный в сервисе UUID
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
name: "",
isInBox: false
)
]
}
)
}
Чтобы посмотреть на более интересный тест, добавим кэширование списка продуктов из следующего выпуска. Для этого добавим еще два сервиса: saveProducts и loadProducts:
struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID
var save: ([Product]) -> Effect<Never, Never>
var load: () -> Effect<[Product], Never>
}
Предполагая, что операции загрузки и сохранения могут быть асинхронные, они возвращают Effect. Effect — не что иное как Publisher. Более подробнее рассмотрим в следующей серии.
Пишем тест:
func testAddProduct() {
// проверим, что сохраняется то, что нужно
var savedProducts: [Product] = []
// убедимся, что количество сохранений совпадает с ожидаемым
var numberOfSaves = 0
// создаем тестовый стор
let store = TestStore(
initialState: ShoppingListState(products: []),
reducer: shoppingListReducer,
environment: ShoppingListEnviroment(
uuidGenerator: { .mock },
// функция сохранения принимает массив продуктов
// и возвращает эффект сохраняющий список
saveProducts: { products in Effect.fireAndForget { savedProducts = products; numberOfSaves += 1 } },
// функция загрузки списка
// возвращает эффект с закэшированным списком
loadProducts: { Effect(value: [Product(id: .mock, name: "Milk", isInBox: false)]) }
)
)
store.assert(
// иммитируем отправку события load при показе view
.send(.loadProducts),
// событие load запускает эффект загрузки данных
// который возвращает событие productsLoaded([Product])
.receive(.productsLoaded([Product(id: .mock, name: "Milk", isInBox: false)])) {
$0.products = [
Product(id: .mock, name: "Milk", isInBox: false)
]
},
// добавляем новый продукт в список
.send(.addProduct) {
$0.products = [
Product(id: .mock, name: "", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
]
},
// ожидаем, что предыдущее действие вызывало эффект сохранения
.receive(.saveProducts),
// после выполнения эффекта проверяем сохраненный результат
.do {
XCTAssertEqual(savedProducts, [
Product(id: .mock, name: "", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
])
},
// задаем имя добавленному продукту
.send(.productAction(0, .updateName("Banana"))) {
$0.products = [
Product(id: .mock, name: "Banana", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
]
},
// имитируем событие сохранения в endEditing textFiled'a
.send(.saveProducts),
// после выполнения эффекта проверяем сохраненный результат
.do {
XCTAssertEqual(savedProducts, [
Product(id: .mock, name: "Banana", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
])
}
)
// убеждаемся, что сохранение произошло только 2 раза
XCTAssertEqual(numberOfSaves, 2)
}
В этом блоке мы:
- рассмотрели написание unit тестов на систему;
- определили инструменты тестирования;
- написали тест, имитирующий действия пользователя при добавлении нового продукта в список.
Unit-Snapshot тесты на UI
Для snapshot тестов, авторы Composable Arhitecture разработали библиотеку SnapshotTesting (также можно использовать любое другое решение).
Для текущей итерации разработки, определяем минимальное количество различных состояний экрана равное четырем:
- пустой список;
- список с только что добавленным продуктом;
- список с одним не выбранным продуктом;
- список с одним выбранным продуктом.
Composable Architecture реализует подход data-driven development, что значительно облегчает написание snapshot-тестов — конфигурация UI определяется текущим состоянием системы.
Приступим:
import XCTest
import ComposableArchitecture
// Подключаем библиотеку для снепшот тестирования
import SnapshotTesting
@testable import Composable
class ShoppingListSnapshotTests: XCTestCase {
func testEmptyList() {
// Создаем view
let listView = ShoppingListView(
// создаем систему
store: ShoppingListStore(
// устанавливаем состояние
initialState: ShoppingListState(products: []),
reducer: Reducer { _, _, _ in .none },
environment: ShoppingListEnviroment.mock
)
)
assertSnapshot(matching: listView, as: .image)
}
func testNewItem() {
let listView = ShoppingListView(
// Чтобы не создавать store каждый раз
// можно завести экстеншен Store.mock(state:State)
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "", isInBox: false)]
))
)
assertSnapshot(matching: listView, as: .image)
}
func testSingleItem() {
let listView = ShoppingListView(
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "Milk", isInBox: false)]
))
)
assertSnapshot(matching: listView, as: .image)
}
func testCompleteItem() {
let listView = ShoppingListView(
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "Milk", isInBox: true)]
))
)
assertSnapshot(matching: listView, as: .image)
}
}
После выполнения всех тестов получаем набор эталонных значений:
В результате для каждого состояния системы было зафиксировано его визуальное представление.
Debug mode — вишенка на торте
Для отладки работы редьюсера есть полезный инструмент debug:
Reducer { state, action, env in
switch action { ... }
}.debug()
// или
Reducer { state, action, env in
switch action { ... }
}.debugActions()
Функция debug логирует в консоль каждый вызов функции редьюсера, указывая какое действие произошло и как изменилось состояние системы:
received action:
ShoppingListAction.load
(No state changes)
received action:
ShoppingListAction.setupProducts(
[
Product(
id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
name: "",
isInBox: false
),
Product(
id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
name: "Tesggggg",
isInBox: false
),
Product(
id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
name: "",
isInBox: false
),
]
)
ShoppingListState(
products: [
+ Product(
+ id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
+ name: "",
+ isInBox: false
+ ),
+ Product(
+ id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
+ name: "Tesggggg",
+ isInBox: false
+ ),
+ Product(
+ id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
+ name: "",
+ isInBox: false
+ ),
]
)
*плюсом отмечается изменения состояния системы.
Смотри в следующей серии
Часть 3 — расширяем функционал, добавляем удаление и сортировку продуктов (in progress)
Часть 4 — добавляем кэширование списка и идем в магазин (in progress)
Источники
Список продуктов Часть 2: github.com
Портал авторов подхода: pointfree.co
Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture
Исходники Snaphsot testing: github.com
===========
Источник:
habr.com
===========
Похожие новости:
- [C++, C] Использование Obj библиотек в KolibriOS в языках высокого уровня
- [*nix] ISH Linux или возможно ли установить и использовать Linux на iOS
- [Разработка под iOS, Разработка под MacOS, Софт, IT-компании] Вышла macOS Big Sur
- [Node.JS, Google API, Монетизация мобильных приложений] Разворачиваем сервер для проверки In-app purchase за 60 минут
- [Программирование, Разработка под iOS, Swift] Разница между @StateObject, @EnvironmentObject и @ObservedObject в SwiftUI (перевод)
- [Разработка под iOS, Системы сборки, Облачные сервисы] Интеграция CI/CD для нескольких сред с Jenkins и Fastlane. Часть 2 (перевод)
- [Программирование, Разработка под iOS, Разработка мобильных приложений] SPM: модуляризация проекта для увеличения скорости сборки
- [Open source, Разработка под iOS, Разработка под Linux, IT-компании] Apple запрещает приложения эмулятора терминала на iPhone: в текущих версиях через них можно скачивать код
- [Разработка под iOS, Разработка мобильных приложений, Разработка под Android] «Студийные» приложения Netflix на Android и iOS теперь с Kotlin Multiplatform (перевод)
- [C++] Пишем на языке С/C++ в Linux под KolibriOS
Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_ios, #_composable_architecture, #_architecture_design, #_functional_programming, #_razrabotka_pod_ios (
Разработка под iOS
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 15:50
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам. В прошлой серии Часть 1 — основные компоненты архитектуры и как работает Composable Architecture Тестируемый код В предыдущем выпуске был разработан каркас приложения список покупок на Composable Architecture. Перед тем как продолжить наращивать функционал необходимо сохраниться — покрыть код тестами. В этой статье рассмотрим два вида тестов: unit тесты на систему и snapshot тесты на UI. Что мы имеем? Еще раз взглянем на текущее решение:
struct ShoppingListState: Equatable {
var products: [Product] = [] } enum ShoppingListAction { case productAction(Int, ProductAction) case addProduct } let shoppingListReducer: Reducer<ShoppingListState, ShoppingListAction, ShoppingListEnviroment> = .combine( productReducer.forEach( state: \.products, action: /ShoppingListAction.productAction, environment: { _ in ProductEnviroment() } ), Reducer { state, action, env in switch action { case .addProduct: state.products.insert( Product(id: UUID(), name: "", isInBox: false), at: 0 ) return .none case .productAction: return .none } } ) Типы тестов Как понять что архитектура не очень? Легко, если вы не можете покрыть ее на 100% тестами (Vladislav Zhukov)
Unit тесты Одна из причин полюбить Composable Arhitecutre является подход к написанию unit тестов. Тестирование основного механизма системы — recuder'а — происходит с помощью построения цепочки шагов: send(Action) и receive(Action). На каждом этапе проверяем, что состояние системы изменилось должным образом. Send(Action) позволяет имитировать действий пользователя. Receive(Action) говорит о том, что на предыдущем шаге выполнился эффект и вернул результат — action. В конце теста или по ходу цепочки в блоке .do {} проверяем обращения к сервисам. Первый наш тест посвящен операции добавления продукта. func testAddProduct() {
// Создаем тестовый стор let store = TestStore( initialState: ShoppingListState( products: [] ), reducer: shoppingListReducer, environment: ShoppingListEnviroment() ) // описываем ожидаемое поведение системы store.assert( // создаем событие добавление продукта .send(.addProduct) { state in // описываем ожидаемое состояние системы state.products = [ Product( id: UUID(), name: "", isInBox: false ) ] } ) } Первое на что следует обратить внимание, что тестирование системы происходит независимо от слоя представления. Запускаем тест и ловим фейл Достаточно информативное сообщение об ошибке говорит нам о несовпадении присвоенного идентификатора продукта с ожидаемым: Для того, чтобы разобраться в чем дело, введем понятие чистая функция. Reducer — чистая функция Что же такое чистая функция? «Чистые» функции — это любые функции, исходные данные которых получены исключительно из их входных данных и не вызывают побочных эффектов в приложении. В действительности, внутри нашей функции генерируется UUID в качестве идентификатора продукта. Такое поведение называется побочным эффектом, а наша функция становится "грязной". Чтобы это исправить необходимо генерировать UUID через сервис. В Composable Architecture сервисы представлены объектом окружения (Environment). Добавим в наш ShoppingListEnviroment сервис (функцию) генерации UUID. struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID } И используем ее при создании продукта: Reducer { state, action, env in
switch action { case .addProduct: state.products.insert( Product( id: env.uuidGenerator(), name: "", isInBox: false ), at: 0 ) return .none ... } } В результате получаем чистую функцию, которую можно тестировать. Возвращаясь к нашему тесту получаем следующее: func testAddProduct() {
let store = TestStore( initialState: ShoppingListState(), reducer: shoppingListReducer, // Создаем окружение environment: ShoppingListEnviroment( // инжектим сервис генерации мокового UUID uuidGenerator: { UUID(uuidString: "00000000-0000-0000-0000-000000000000")! } ) ) store.assert( // Имитируем нажатие на кнопку "добавить продукт" .send(.addProduct) { newState in // Описываем ожидаемое изменение состояния системы newState.products = [ Product( // продукту установился определенный в сервисе UUID id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, name: "", isInBox: false ) ] } ) } Чтобы посмотреть на более интересный тест, добавим кэширование списка продуктов из следующего выпуска. Для этого добавим еще два сервиса: saveProducts и loadProducts: struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID var save: ([Product]) -> Effect<Never, Never> var load: () -> Effect<[Product], Never> } Предполагая, что операции загрузки и сохранения могут быть асинхронные, они возвращают Effect. Effect — не что иное как Publisher. Более подробнее рассмотрим в следующей серии. Пишем тест: func testAddProduct() {
// проверим, что сохраняется то, что нужно var savedProducts: [Product] = [] // убедимся, что количество сохранений совпадает с ожидаемым var numberOfSaves = 0 // создаем тестовый стор let store = TestStore( initialState: ShoppingListState(products: []), reducer: shoppingListReducer, environment: ShoppingListEnviroment( uuidGenerator: { .mock }, // функция сохранения принимает массив продуктов // и возвращает эффект сохраняющий список saveProducts: { products in Effect.fireAndForget { savedProducts = products; numberOfSaves += 1 } }, // функция загрузки списка // возвращает эффект с закэшированным списком loadProducts: { Effect(value: [Product(id: .mock, name: "Milk", isInBox: false)]) } ) ) store.assert( // иммитируем отправку события load при показе view .send(.loadProducts), // событие load запускает эффект загрузки данных // который возвращает событие productsLoaded([Product]) .receive(.productsLoaded([Product(id: .mock, name: "Milk", isInBox: false)])) { $0.products = [ Product(id: .mock, name: "Milk", isInBox: false) ] }, // добавляем новый продукт в список .send(.addProduct) { $0.products = [ Product(id: .mock, name: "", isInBox: false), Product(id: .mock, name: "Milk", isInBox: false) ] }, // ожидаем, что предыдущее действие вызывало эффект сохранения .receive(.saveProducts), // после выполнения эффекта проверяем сохраненный результат .do { XCTAssertEqual(savedProducts, [ Product(id: .mock, name: "", isInBox: false), Product(id: .mock, name: "Milk", isInBox: false) ]) }, // задаем имя добавленному продукту .send(.productAction(0, .updateName("Banana"))) { $0.products = [ Product(id: .mock, name: "Banana", isInBox: false), Product(id: .mock, name: "Milk", isInBox: false) ] }, // имитируем событие сохранения в endEditing textFiled'a .send(.saveProducts), // после выполнения эффекта проверяем сохраненный результат .do { XCTAssertEqual(savedProducts, [ Product(id: .mock, name: "Banana", isInBox: false), Product(id: .mock, name: "Milk", isInBox: false) ]) } ) // убеждаемся, что сохранение произошло только 2 раза XCTAssertEqual(numberOfSaves, 2) } В этом блоке мы:
Unit-Snapshot тесты на UI Для snapshot тестов, авторы Composable Arhitecture разработали библиотеку SnapshotTesting (также можно использовать любое другое решение). Для текущей итерации разработки, определяем минимальное количество различных состояний экрана равное четырем:
Composable Architecture реализует подход data-driven development, что значительно облегчает написание snapshot-тестов — конфигурация UI определяется текущим состоянием системы. Приступим: import XCTest
import ComposableArchitecture // Подключаем библиотеку для снепшот тестирования import SnapshotTesting @testable import Composable class ShoppingListSnapshotTests: XCTestCase { func testEmptyList() { // Создаем view let listView = ShoppingListView( // создаем систему store: ShoppingListStore( // устанавливаем состояние initialState: ShoppingListState(products: []), reducer: Reducer { _, _, _ in .none }, environment: ShoppingListEnviroment.mock ) ) assertSnapshot(matching: listView, as: .image) } func testNewItem() { let listView = ShoppingListView( // Чтобы не создавать store каждый раз // можно завести экстеншен Store.mock(state:State) store: .mock(state: ShoppingListState( products: [Product(id: .mock, name: "", isInBox: false)] )) ) assertSnapshot(matching: listView, as: .image) } func testSingleItem() { let listView = ShoppingListView( store: .mock(state: ShoppingListState( products: [Product(id: .mock, name: "Milk", isInBox: false)] )) ) assertSnapshot(matching: listView, as: .image) } func testCompleteItem() { let listView = ShoppingListView( store: .mock(state: ShoppingListState( products: [Product(id: .mock, name: "Milk", isInBox: true)] )) ) assertSnapshot(matching: listView, as: .image) } } После выполнения всех тестов получаем набор эталонных значений: В результате для каждого состояния системы было зафиксировано его визуальное представление. Debug mode — вишенка на торте Для отладки работы редьюсера есть полезный инструмент debug: Reducer { state, action, env in
switch action { ... } }.debug() // или Reducer { state, action, env in switch action { ... } }.debugActions() Функция debug логирует в консоль каждый вызов функции редьюсера, указывая какое действие произошло и как изменилось состояние системы: received action:
ShoppingListAction.load (No state changes) received action: ShoppingListAction.setupProducts( [ Product( id: 9F047826-B431-4D20-9B80-CC65D6A1101B, name: "", isInBox: false ), Product( id: D9834386-75BC-4B9C-B87B-121FFFDB2F93, name: "Tesggggg", isInBox: false ), Product( id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C, name: "", isInBox: false ), ] ) ShoppingListState( products: [ + Product( + id: 9F047826-B431-4D20-9B80-CC65D6A1101B, + name: "", + isInBox: false + ), + Product( + id: D9834386-75BC-4B9C-B87B-121FFFDB2F93, + name: "Tesggggg", + isInBox: false + ), + Product( + id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C, + name: "", + isInBox: false + ), ] ) *плюсом отмечается изменения состояния системы. Смотри в следующей серии Часть 3 — расширяем функционал, добавляем удаление и сортировку продуктов (in progress) Часть 4 — добавляем кэширование списка и идем в магазин (in progress) Источники Список продуктов Часть 2: github.com Портал авторов подхода: pointfree.co Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture Исходники Snaphsot testing: github.com =========== Источник: habr.com =========== Похожие новости:
Разработка под iOS ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 15:50
Часовой пояс: UTC + 5