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 alt

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

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


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



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


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.com


Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture


Исходники Snaphsot testing: github.com

Источник: https://habr.com/ru/post/528178/


Интересные статьи

Интересные статьи

Год назад я пришел в vivid.money третьим разработчиком. Несмотря на это, в проекте практически не было кода, а первые фичи только начинали разрабатываться. Уже тогда было понятно, что ком...
Это четвертая и финальная часть из серии моих публикаций, посвященных вопросу ввоза обедненного гексафторида урана (ОГФУ) из Европы в Россию. Первая посвящена технологиям обогащения у...
Среди советов по улучшению юзабилити интернет-магазина, которые можно встретить в инете, один из явных лидеров — совет «сообщайте посетителю стоимость доставки как можно раньше».
В прошлой статье мы познакомились с RFID-метками, которые незримо окружают нас в быту. Сегодня продолжим разбираться с бытовым применением меток и взглянем на метки китайского производства. ...
Однажды компания Parallels решила пойти навстречу тем своим сотрудникам, которые уже давно работали в компании и не хотели ее менять, но при этом хотели поменять свое местожительство, чтобы быть ...