Русский
Русский
English
Статистика
Реклама

Composable Architecture свежий взгляд на архитектуру приложения. Тесты

Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам.


В прошлой серии


Часть 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 тестов.


image


Тестирование основного механизма системы 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                )            ]        }    )}

Первое на что следует обратить внимание, что тестирование системы происходит независимо от слоя представления.


image


Запускаем тест и ловим фейл


Достаточно информативное сообщение об ошибке говорит нам о несовпадении присвоенного идентификатора продукта с ожидаемым:


image


Для того, чтобы разобраться в чем дело, введем понятие чистая функция.


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 XCTestimport ComposableArchitecture// Подключаем библиотеку для снепшот тестированияimport SnapshotTesting@testable import Composableclass 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)    }}

После выполнения всех тестов получаем набор эталонных значений:


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
К списку статей
Опубликовано: 16.11.2020 12:07:26
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Разработка под ios

Ios

Composable architecture

Architecture design

Functional programming

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru