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

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


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


    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 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)
        }
    }

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


    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

    Комментарии 2

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое